使用Golang開發(fā)一個簡易版shell
之前看到 Github 有個 build-your-own-x 的倉庫,覺得挺有意思的,有不少有趣的實現(xiàn)。我就想著多嘗試實現(xiàn)些這樣的小項目,看看不同的領(lǐng)域。一方面提升我的編程能力,另外,也希望能發(fā)現(xiàn)一些不錯的項目。
今天的項目在 build-your-own-x 中也能找到,即 build your own shell。這個項目能幫助學(xué)習(xí) Go 如何進行如 IO 輸入輸出、如何發(fā)起進程調(diào)用等操作。
核心流程
首先,我聲明這是個簡陋的 shell,但能幫助我們更好理解 Shell。它支持如提示符打印、讀取用戶輸入、解析輸入內(nèi)容、執(zhí)行命令,另外還支持開發(fā)內(nèi)建命令。
如下是演示效果:

接下來,我將從零開始一步步復(fù)現(xiàn)我的整個開發(fā)過程。
框架搭建
我從創(chuàng)建一個 Shell 結(jié)構(gòu)體開始,這是整個 shell 程序的核心,它其中包含一個 bufio.Reader 從標準輸入讀取用戶輸入。
type Shell struct {
reader *bufio.Reader
}
func NewShell() *Shell {
return &Shell{
reader: bufio.NewReader(os.Stdin),
}
}如上,通過 NewShell 構(gòu)造函數(shù)創(chuàng)建 Shell 實例。這個函數(shù)返回一個新的 Shell 實例,其中包含了初始化的 bufio.Reader。
為了方便擴展,接下來添加了幾個方法,分別是:
• PrintPrompt用于打印提示符;
• ReadInput用于讀取用戶輸入;
• ParseInput用于解析輸入并分割成命令名和參數(shù);
• ExecuteCmd用于執(zhí)行命令。
定義如下:
func (s *Shell) PrintPrompt() func (s *Shell) ReadInput() (string, error) func (s *Shell) ParseInput(input string) (string, []string) func (s *Shell) ExecuteCmd(cmdName string, cmdArgs []string) error
它們就是核心流程中最重要的四個方法,都是在 RunAndListen 方法中被調(diào)用,如下所示:
func (s *Shell) RunAndListen() error {
for {
s.PrintPrompt()
input, err := s.ReadInput()
if err != nil {
fmt.Fprintln(os.Stderr, err)
continue
}
cmdName, cmdArgs := s.ParseInput(input)
if err := s.ExecuteCmd(cmdName, cmdArgs); err != nil {
fmt.Fprintln(os.Stderr, err)
continue
}
}
}主函數(shù) main 的代碼不復(fù)雜,如下所示:
func main() {
s := NewShell()
_ = s.RunAndListen()
}通過 NewShell 創(chuàng)建 Shell 示例,調(diào)用 RunAndListen 監(jiān)聽用戶輸入即可。
接下來,我開始介紹其中每一步的實現(xiàn)過程。
打印提示符
首先,打印提示符的代碼,非常簡單,如下所示:
func (s *Shell) PrintPrompt() {
fmt.Print("$ ")
}單純的打印 $ 作為提示符,更復(fù)雜的場景可以加上路徑提示,如:
[~/demo/shell]$
修改后的代碼如下所示:
func (s *Shell) PrintPrompt() {
// 獲取當前工作目錄
cwd, err := os.Getwd()
if err != nil {
// 如果無法獲取工作目錄,打印錯誤并使用默認提示符
fmt.Println("Error getting current directory:", err)
fmt.Print("$ ")
return
}
// 獲取當前用戶的HOME目錄
homeDir, err := os.UserHomeDir()
if err != nil {
fmt.Println("Error getting home directory:", err)
fmt.Print("$ ")
return
}
// 如果當前工作目錄以HOME目錄開頭,則用'~'替換掉HOME目錄部分
if strings.HasPrefix(cwd, homeDir) {
cwd = strings.Replace(cwd, homeDir, "~", 1)
}
// 打印包含當前工作目錄的提示符
fmt.Printf("[%s]$ ", cwd)
}這是非常粗糙的拿到目錄并打印出來。
通常 Shell 的提示符是可以自定義,有興趣可以在這里擴展個接口類型,用于不同提示符的格式化實現(xiàn)。
讀取用戶輸入
最簡單的讀取用戶輸入的代碼,代碼如下:
func (s *Shell) ReadInput() (string, error) {
input, err := s.reader.ReadString('\n')
if err != nil {
return "", err
}
return input, nil
}按 \n 分割命令,分割出來的文本可以理解為一次執(zhí)行請求。
但實際情況是在使用 Shell 時,我們會發(fā)現(xiàn)一些特殊符號是要處理,如引號。
例如:
[~/demo/shell]$ echo ' Hello World! Nice to See you! '
下面是一個簡化的實現(xiàn):
func (s *Shell) ReadInput() (string, error) {
var input []rune
var inSingleQuote, inDoubleQuote bool
for {
r, _, err := s.reader.ReadRune()
if err != nil {
return "", err
}
// Check for quote toggle
switch r {
case '\'':
inSingleQuote = !inSingleQuote
case '"':
inDoubleQuote = !inDoubleQuote
}
// Break on newline if not in quotes
if r == '\n' && !inSingleQuote && !inDoubleQuote {
break
}
input = append(input, r)
}
return string(input), nil
}如上的代碼中,逐一讀取輸入內(nèi)容。程序中,通過判斷當前是處于引號中,保證正確識別用戶輸入。
如果你讀過我之前一篇文章,熟練使用 bufio.Scanner 類型,也可以用它提供的自定義分割規(guī)則的方式,在這個場景下也可以使用。我的完整源碼 goshell 就是基于 Scanner 實現(xiàn)的。
另外,這個輸入不支持刪除,如果我輸出錯了,只能退出重來,也是挺頭疼的。如果要實現(xiàn),要依賴于其他庫實現(xiàn)。
解析輸入
讀取完成,通過 ParseInput 方法解析成 cmdName 和 cmdArgs,代碼如下:
func (s *Shell) ParseInput(input string) (string, []string) {
input = strings.TrimSuffix(input, "\n")
input = strings.TrimSuffix(input, "\r")
args := strings.Split(input, " ")
return args[0], args[1:]
}真正的 Shell 肯定比這個強大的多了。最容易想到的,一次 shell 執(zhí)行請求可能包含多個命令,甚至是 shell 腳本。
太復(fù)雜的能力實現(xiàn)起來太麻煩,我們可以支持一個最簡單的能力,分號分割運行多個命令。
$ cd /; ls
我們修改代碼,支持這個能力。
type CmdRequest struct {
Name string
Args []string
}
func (s *Shell) ParseInput(input string) []*CmdRequest {
subInputs := strings.Split(input, ";")
cmdRequests := make([]*CmdRequest, 0, len(subInputs))
for _, subInput := range subInputs {
subInput = strings.Trim(subInput, " ")
subInput = strings.TrimSuffix(subInput, "\n")
subInput = strings.TrimSuffix(subInput, "\r")
args := strings.Split(subInput, " ")
cmdRequests = append(cmdRequests, &CmdRequest{Name: args[0], Args: args[1:]})
}
return cmdRequests
}上面代碼里,定義了一個新類型 CmdRequest,它用于保存從用戶輸入解析而來的命令名和命令參數(shù)。
由于修改了 ParseInput 的返回類型,RunAndListen 中的邏輯就要改動了。
如下所示:
for {
// ...
cmdRequests := s.ParseInput(input)
for _, cmdRequest := range cmdRequests {
cmdName := cmdRequest.Name
cmdArgs := cmdRequest.Args
if err := s.ExecuteCmd(cmdName, cmdArgs); err != nil {
fmt.Fprintln(os.Stderr, err)
continue
}
}
}到此,通過分號分割多命令也是支持的了。
命令執(zhí)行
最后一步就是執(zhí)行命令了。代碼如下所示:
func (s *Shell) ExecuteCmd(cmdName string, cmdArgs []string) error {
cmd := exec.Command(cmdName, cmdArgs...)
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
return cmd.Run()
}我使用的是標準庫 exec 包中的 Command 類型創(chuàng)建一個命令用于執(zhí)行外部命令。
這個命令的標準輸出和標準錯誤都被設(shè)置為當前進程的對應(yīng)輸出,這樣命令的輸出就可以直接顯示給用戶。
最后,通過調(diào)用 cmd.Run() 執(zhí)行該命令即可。
退出功能
在初步測試中,我發(fā)現(xiàn) shell 還不支持退出。為了解決這個問題,我在 RunAndListen 循環(huán)中加入了對 exit 命令的檢查。
for {
cmdName := cmdRequest.Name
cmdArgs := cmdRequest.Args
if cmdName == "exit" {
return nil
}
if err := s.ExecuteCmd(cmdName, cmdArgs); err != nil {
}
}如果用戶輸入的是exit,循環(huán)將終止,直接退出 shell。
內(nèi)建命令
現(xiàn)在,如果測試這個代碼,看起來運轉(zhuǎn)一切正常。但如果仔細測試,會發(fā)現(xiàn)它還不支持 cd 的能力。
為什么 cd 不能用?
因為改變當前目錄要修改進程的工作目錄,這種操作不能像其他外部命令那樣通過創(chuàng)建新進程實現(xiàn)。因此,我引入了內(nèi)建命令的實現(xiàn),并實現(xiàn)第一個內(nèi)建命令了ChangeDirCommand。
首先是搭建一個簡單框架,定義一個接口:
type BuiltinCmder interface {
Execute(args ...string) error
}任何實現(xiàn)了這個接口的類型都可以作為內(nèi)建的命令。
在 Shell 類型新建了一個字段,名為 builtinCmds ,修改定義如下:
type Shell struct {
reader *bufio.Reader
builtinCmds map[string]BuiltinCmder
}并添加了一個方法,名為 RegisterBuiltinCmd:
func (s *Shell) RegisterBuiltinCmd(cmdName string, cmd BuiltinCmder) {
s.builtinCmds[cmdName] = cmd
}在 Shell 的 ExecuteCmd 中新增了內(nèi)建命令的執(zhí)行:
func (s *Shell) ExecuteCmd(cmdName string, cmdArgs []string) error {
if cmd, ok := s.builtinCmds[cmdName]; ok {
return cmd.Execute(cmdArgs...)
}
cmd := exec.Command(cmdName, cmdArgs...)
// ...
}現(xiàn)在,只要實現(xiàn) ChangeDirCommand,并在 main 入口函數(shù)注冊這個內(nèi)建就行了。ChangeDirCommand 代碼和入口注冊代碼,如下所示:
type ChangeDirCommand struct{}
func (c *ChangeDirCommand) Execute(args ...string) error {
if len(args) < 2 {
return errors.New("Expected path argument")
}
return os.Chdir(args[1])
}
func main() {
s := NewShell()
s.RegisterBuiltinCmd("cd", &ChangeDirCommand{})
_ = s.RunAndListen()
}到此大功搞成,源碼地址:goshell
總結(jié)
通過開發(fā)這個簡單的 shell,了解 Go 如何讀取如用戶輸入,解析與執(zhí)行用戶命令。對 shell 的流程也有了一個大概了解。
未來,如果有想法,或許會繼續(xù)擴展這個 shell,添加更多內(nèi)建命令,可以將不同部分模塊化,如 Prompt, Reader, Parser 和 Command 都是可以繼續(xù)抽象以支持更多能力。
如果繼續(xù)它的開發(fā),期待學(xué)習(xí)到更多關(guān)于系統(tǒng)編程和 Go 語言的高級特性。而且shell 可不止這點能力,如果你了解 shell,使用過 bash 或是 zsh 等 shell 就知道它們是如何提高我們工作效率的了。
到此這篇關(guān)于使用Golang開發(fā)一個簡易版shell的文章就介紹到這了,更多相關(guān)Go開發(fā)shell內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
在ubuntu下安裝go開發(fā)環(huán)境的全過程
Go語言是谷歌公司開發(fā)的編程語言,雖然安裝和配置go很簡單,但是很多初學(xué)者在第一次安裝go環(huán)境時會遇到各種坑,下面這篇文章主要給大家介紹了關(guān)于在ubuntu下安裝go開發(fā)環(huán)境的相關(guān)資料,文中通過圖文介紹的非常詳細,需要的朋友可以參考下2022-08-08
go gin+token(JWT)驗證實現(xiàn)登陸驗證
本文主要介紹了go gin+token(JWT)驗證實現(xiàn)登陸驗證,文中通過示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下2021-12-12
Go語言Elasticsearch數(shù)據(jù)清理工具思路詳解
這篇文章主要介紹了Go語言Elasticsearch數(shù)據(jù)清理工具思路詳解,本文給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2021-10-10
關(guān)于golang高并發(fā)的實現(xiàn)與注意事項說明
這篇文章主要介紹了關(guān)于golang高并發(fā)的實現(xiàn)與注意事項說明,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2021-05-05
gin框架Context如何獲取Get?Query?Param函數(shù)數(shù)據(jù)
這篇文章主要為大家介紹了gin框架Context?Get?Query?Param函數(shù)獲取數(shù)據(jù),有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-03-03
Go Slice進行參數(shù)傳遞如何實現(xiàn)詳解
這篇文章主要為大家介紹了Go Slice進行參數(shù)傳遞如何實現(xiàn)的過程詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-12-12

