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