使用Golang开发一个简易版shell

 更新时间:2024年02月28日 09:33:45   作者:波罗学  
这篇文章主要为大家详细介绍了如何使用Golang开发一个简易版shell,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下

之前看到 Github 有个 build-your-own-x 的仓库,觉得挺有意思的,有不少有趣的实现。我就想着多尝试实现些这样的小项目,看看不同的领域。一方面提升我的编程能力,另外,也希望能发现一些不错的项目。

今天的项目在 build-your-own-x 中也能找到,即 build your own shell。这个项目能帮助学习 Go 如何进行如 IO 输入输出、如何发起进程调用等操作。

核心流程

首先,我声明这是个简陋的 shell,但能帮助我们更好理解 Shell。它支持如提示符打印、读取用户输入、解析输入内容、执行命令,另外还支持开发内建命令。

如下是演示效果:

接下来,我将从零开始一步步复现我的整个开发过程。

框架搭建

我从创建一个 Shell 结构体开始,这是整个 shell 程序的核心,它其中包含一个 bufio.Reader 从标准输入读取用户输入。

type Shell struct {
  reader      *bufio.Reader
}

func NewShell() *Shell {
  return &Shell{
    reader: bufio.NewReader(os.Stdin),
  }
}

如上,通过 NewShell 构造函数创建 Shell 实例。这个函数返回一个新的 Shell 实例,其中包含了初始化的 bufio.Reader。

为了方便扩展,接下来添加了几个方法,分别是:

• PrintPrompt用于打印提示符;

• ReadInput用于读取用户输入;

• ParseInput用于解析输入并分割成命令名和参数;

• ExecuteCmd用于执行命令。

定义如下:

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 方法中被调用,如下所示:

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
    }
  }
}

主函数 main 的代码不复杂,如下所示:

func main() {
    s := NewShell()
    _ = s.RunAndListen()
}

通过 NewShell 创建 Shell 示例,调用 RunAndListen 监听用户输入即可。

接下来,我开始介绍其中每一步的实现过程。

打印提示符

首先,打印提示符的代码,非常简单,如下所示:

func (s *Shell) PrintPrompt() {
  fmt.Print("$ ")
}

单纯的打印 $ 作为提示符,更复杂的场景可以加上路径提示,如:

[~/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 的提示符是可以自定义,有兴趣可以在这里扩展个接口类型,用于不同提示符的格式化实现。

读取用户输入

最简单的读取用户输入的代码,代码如下:

func (s *Shell) ReadInput() (string, error) {
  input, err := s.reader.ReadString('\n')
  if err != nil {
    return "", err
  }

  return input, nil
}

按 \n 分割命令,分割出来的文本可以理解为一次执行请求。

但实际情况是在使用 Shell 时,我们会发现一些特殊符号是要处理,如引号。

例如:

[~/demo/shell]$ echo '
Hello World!
Nice to See you!
'

下面是一个简化的实现:

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
}

如上的代码中,逐一读取输入内容。程序中,通过判断当前是处于引号中,保证正确识别用户输入。

如果你读过我之前一篇文章,熟练使用 bufio.Scanner 类型,也可以用它提供的自定义分割规则的方式,在这个场景下也可以使用。我的完整源码 goshell 就是基于 Scanner 实现的。

另外,这个输入不支持删除,如果我输出错了,只能退出重来,也是挺头疼的。如果要实现,要依赖于其他库实现。

解析输入

读取完成,通过 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 执行请求可能包含多个命令,甚至是 shell 脚本。

太复杂的能力实现起来太麻烦,我们可以支持一个最简单的能力,分号分割运行多个命令。

$ 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,它用于保存从用户输入解析而来的命令名和命令参数。

由于修改了 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
    }
  }
}

到此,通过分号分割多命令也是支持的了。

命令执行

最后一步就是执行命令了。代码如下所示:

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 类型创建一个命令用于执行外部命令。

这个命令的标准输出和标准错误都被设置为当前进程的对应输出,这样命令的输出就可以直接显示给用户。

最后,通过调用 cmd.Run() 执行该命令即可。

退出功能

在初步测试中,我发现 shell 还不支持退出。为了解决这个问题,我在 RunAndListen 循环中加入了对 exit 命令的检查。

for {
  cmdName := cmdRequest.Name
  cmdArgs := cmdRequest.Args

  if cmdName == "exit" {
    return nil
  }

  if err := s.ExecuteCmd(cmdName, cmdArgs); err != nil {
  }
}

如果用户输入的是exit,循环将终止,直接退出 shell。

内建命令

现在,如果测试这个代码,看起来运转一切正常。但如果仔细测试,会发现它还不支持 cd 的能力。

为什么 cd 不能用?

因为改变当前目录要修改进程的工作目录,这种操作不能像其他外部命令那样通过创建新进程实现。因此,我引入了内建命令的实现,并实现第一个内建命令了ChangeDirCommand

首先是搭建一个简单框架,定义一个接口:

type BuiltinCmder interface {
    Execute(args ...string) error
}

任何实现了这个接口的类型都可以作为内建的命令。

在 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 中新增了内建命令的执行:

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...)
  // ...
}

现在,只要实现 ChangeDirCommand,并在 main 入口函数注册这个内建就行了。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

总结

通过开发这个简单的 shell,了解 Go 如何读取如用户输入,解析与执行用户命令。对 shell 的流程也有了一个大概了解。

未来,如果有想法,或许会继续扩展这个 shell,添加更多内建命令,可以将不同部分模块化,如 Prompt, Reader, Parser 和 Command 都是可以继续抽象以支持更多能力。

如果继续它的开发,期待学习到更多关于系统编程和 Go 语言的高级特性。而且shell 可不止这点能力,如果你了解 shell,使用过 bash 或是 zsh 等 shell 就知道它们是如何提高我们工作效率的了。

到此这篇关于使用Golang开发一个简易版shell的文章就介绍到这了,更多相关Go开发shell内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • 详解Golang time包中的结构体time.Time

    详解Golang time包中的结构体time.Time

    在日常开发过程中,会频繁遇到对时间进行操作的场景,使用 Golang 中的 time 包可以很方便地实现对时间的相关操作,本文先讲解一下 time 包中的结构体 time.Time,需要的朋友可以参考下
    2023-07-07
  • golang qq邮件发送验证码功能

    golang qq邮件发送验证码功能

    验证码在多个场景下发挥着重要作用,如注册/登录、短信接口保护、提交/投票、密码找回和支付验证等,以保障账号安全和防止恶意操作,此外,文章还介绍了使用golang通过qq邮件发送验证码的实现过程,包括获取授权码、下载依赖包和编写代码,感兴趣的朋友跟随小编一起看看吧
    2024-09-09
  • 并发安全本地化存储go-cache读写锁实现多协程并发访问

    并发安全本地化存储go-cache读写锁实现多协程并发访问

    这篇文章主要介绍了并发安全本地化存储go-cache读写锁实现多协程并发访问,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-10-10
  • go语言中布隆过滤器低空间成本判断元素是否存在方式

    go语言中布隆过滤器低空间成本判断元素是否存在方式

    这篇文章主要为大家介绍了go语言中布隆过滤器低空间成本判断元素是否存在方式详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-09-09
  • Go语言切片常考的面试真题解析

    Go语言切片常考的面试真题解析

    了解最新的Go语言面试题型,让面试不再是难事,下面这篇文章主要给大家介绍了关于Go语言切片面试常考的一些问题,文中通过实例代码介绍的非常详细,需要的朋友可以参考下
    2022-02-02
  • Go语言常用字符串处理方法实例汇总

    Go语言常用字符串处理方法实例汇总

    这篇文章主要介绍了Go语言常用字符串处理方法,实例汇总了Go语言中常见的各种字符串处理技巧,具有一定参考借鉴价值,需要的朋友可以参考下
    2015-03-03
  • go语言限制协程并发数的方案详情

    go语言限制协程并发数的方案详情

    一个线程中可以有任意多个协程,但某一时刻只能有一个协程在运行,多个协程分享该线程分配到的计算机资源,接下来通过本文给大家介绍go语言限制协程的并发数的方案详情,感兴趣的朋友一起看看吧
    2022-01-01
  • Golang环境变量设置和查看工具go env详解

    Golang环境变量设置和查看工具go env详解

    go env 是 Go 工具链中的一个命令,用于设置和查看当前 Golang 环境的相关信息,对于理解、编译和运行 Golang 程序非常有用,本文就给大家简单的介绍一下Golang环境变量设置和查看工具go env,需要的朋友可以参考下
    2023-07-07
  • 详解go 动态数组 二维动态数组

    详解go 动态数组 二维动态数组

    这篇文章主要介绍了go 动态数组 二维动态数组,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2020-07-07
  • golang操作elasticsearch的实现

    golang操作elasticsearch的实现

    这篇文章主要介绍了golang操作elasticsearch,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2020-06-06

最新评论