使用Golang開(kāi)發(fā)一個(gè)簡(jiǎn)易版shell
之前看到 Github 有個(gè) build-your-own-x 的倉(cāng)庫(kù),覺(jué)得挺有意思的,有不少有趣的實(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í)行命令,另外還支持開(kāi)發(fā)內(nèi)建命令。
如下是演示效果:

接下來(lái),我將從零開(kāi)始一步步復(fù)現(xiàn)我的整個(gè)開(kāi)發(fā)過(guò)程。
框架搭建
我從創(chuàng)建一個(gè) Shell 結(jié)構(gòu)體開(kāi)始,這是整個(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),
}
}如上,通過(guò) NewShell 構(gòu)造函數(shù)創(chuàng)建 Shell 實(shí)例。這個(gè)函數(shù)返回一個(gè)新的 Shell 實(shí)例,其中包含了初始化的 bufio.Reader。
為了方便擴(kuò)展,接下來(lái)添加了幾個(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()
}通過(guò) NewShell 創(chuàng)建 Shell 示例,調(diào)用 RunAndListen 監(jiān)聽(tīng)用戶輸入即可。
接下來(lái),我開(kāi)始介紹其中每一步的實(shí)現(xiàn)過(guò)程。
打印提示符
首先,打印提示符的代碼,非常簡(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 {
// 如果無(wú)法獲取工作目錄,打印錯(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目錄開(kāi)頭,則用'~'替換掉HOME目錄部分
if strings.HasPrefix(cwd, homeDir) {
cwd = strings.Replace(cwd, homeDir, "~", 1)
}
// 打印包含當(dāng)前工作目錄的提示符
fmt.Printf("[%s]$ ", cwd)
}這是非常粗糙的拿到目錄并打印出來(lái)。
通常 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 分割命令,分割出來(lái)的文本可以理解為一次執(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)容。程序中,通過(guò)判斷當(dāng)前是處于引號(hào)中,保證正確識(shí)別用戶輸入。
如果你讀過(guò)我之前一篇文章,熟練使用 bufio.Scanner 類型,也可以用它提供的自定義分割規(guī)則的方式,在這個(gè)場(chǎng)景下也可以使用。我的完整源碼 goshell 就是基于 Scanner 實(shí)現(xiàn)的。
另外,這個(gè)輸入不支持刪除,如果我輸出錯(cuò)了,只能退出重來(lái),也是挺頭疼的。如果要實(shí)現(xiàn),要依賴于其他庫(kù)實(shí)現(xiàn)。
解析輸入
讀取完成,通過(guò) 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)起來(lái)太麻煩,我們可以支持一個(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,它用于保存從用戶輸入解析而來(lái)的命令名和命令參數(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
}
}
}到此,通過(guò)分號(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)輸出,這樣命令的輸出就可以直接顯示給用戶。
最后,通過(guò)調(diào)用 cmd.Run() 執(zhí)行該命令即可。
退出功能
在初步測(cè)試中,我發(fā)現(xiàn) shell 還不支持退出。為了解決這個(gè)問(wèn)題,我在 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è)代碼,看起來(lái)運(yùn)轉(zhuǎn)一切正常。但如果仔細(xì)測(cè)試,會(huì)發(fā)現(xiàn)它還不支持 cd 的能力。
為什么 cd 不能用?
因?yàn)楦淖儺?dāng)前目錄要修改進(jìn)程的工作目錄,這種操作不能像其他外部命令那樣通過(guò)創(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é)
通過(guò)開(kāi)發(fā)這個(gè)簡(jiǎn)單的 shell,了解 Go 如何讀取如用戶輸入,解析與執(zhí)行用戶命令。對(duì) shell 的流程也有了一個(gè)大概了解。
未來(lái),如果有想法,或許會(huì)繼續(xù)擴(kuò)展這個(gè) shell,添加更多內(nèi)建命令,可以將不同部分模塊化,如 Prompt, Reader, Parser 和 Command 都是可以繼續(xù)抽象以支持更多能力。
如果繼續(xù)它的開(kāi)發(fā),期待學(xué)習(xí)到更多關(guān)于系統(tǒng)編程和 Go 語(yǔ)言的高級(jí)特性。而且shell 可不止這點(diǎn)能力,如果你了解 shell,使用過(guò) bash 或是 zsh 等 shell 就知道它們是如何提高我們工作效率的了。
到此這篇關(guān)于使用Golang開(kāi)發(fā)一個(gè)簡(jiǎn)易版shell的文章就介紹到這了,更多相關(guān)Go開(kāi)發(fā)shell內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
詳解如何用Golang處理每分鐘100萬(wàn)個(gè)請(qǐng)求
在項(xiàng)目開(kāi)發(fā)中,我們常常會(huì)遇到處理來(lái)自數(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開(kāi)發(fā)環(huán)境的全過(guò)程
Go語(yǔ)言是谷歌公司開(kāi)發(fā)的編程語(yǔ)言,雖然安裝和配置go很簡(jiǎn)單,但是很多初學(xué)者在第一次安裝go環(huán)境時(shí)會(huì)遇到各種坑,下面這篇文章主要給大家介紹了關(guān)于在ubuntu下安裝go開(kāi)發(fā)環(huán)境的相關(guān)資料,文中通過(guò)圖文介紹的非常詳細(xì),需要的朋友可以參考下2022-08-08
go gin+token(JWT)驗(yàn)證實(shí)現(xiàn)登陸驗(yàn)證
本文主要介紹了go gin+token(JWT)驗(yàn)證實(shí)現(xiàn)登陸驗(yàn)證,文中通過(guò)示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-12-12
Go語(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)說(shuō)明
這篇文章主要介紹了關(guān)于golang高并發(fā)的實(shí)現(xiàn)與注意事項(xiàng)說(shuō)明,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2021-05-05
gin框架Context如何獲取Get?Query?Param函數(shù)數(shù)據(jù)
這篇文章主要為大家介紹了gin框架Context?Get?Query?Param函數(shù)獲取數(shù)據(jù),有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-03-03
Go Slice進(jìn)行參數(shù)傳遞如何實(shí)現(xiàn)詳解
這篇文章主要為大家介紹了Go Slice進(jìn)行參數(shù)傳遞如何實(shí)現(xiàn)的過(guò)程詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-12-12

