开始之前
"UNIX非常简单,只有天才才明白它的简单"
——Dennis Ritchie (https://en.wikipedia.org/wiki/Dennis_Ritchie)
当然,我不是一个天才,并且我甚至都不确定Dennis Ritchie的话是否包括用户空间工具。此外,对于一个全功能的操作系统而言,shell只是其中一块很小的部分(与Kernel对比来说,shell真的是一个简单的部分),但是我希望在文章的最后,你可以惊讶的发现,一旦你理解了shell背后的概念,实现一个shell是多么的简单。
什么是Shell
Shell通常很难定义,我将shell定义为操作系统的一个基本的用户接口,你可以在shell中输入命令并接收对应的输出。当需要更多的信息或更好的定义时,你可以查看维基百科的文章(https://en.wikipedia.org/wiki/Shell_(computing))
关于shell的一些例子有:
Bash(https://en.wikipedia.org/wiki/Bash_(Unix_shell))
Zsh(https://en.wikipedia.org/wiki/Z_shell)
Gnome Shell(https://en.wikipedia.org/wiki/GNOME_Shell)
Windows Shell(https://en.wikipedia.org/wiki/Windows_shell)
Gnome和Windows的图形用户接口是shell类型的接口,但大多数IT相关人士(最起码是我)当谈及shell时宁愿是基于文本的。例如,列表中的前两个,将描述一个简单切无图形化的shell。
事实上,这个功能是解释给定一个输入命令,以及接收对应的输出。例如,运行程序ls将会展示当前目录下的内容。
输入:
ls
输出:
Applications etc
Library home
...
这就是shell,超级简单,让我们开始吧!
输入循环
执行一个命令,我们将接收输入,我们使用键盘完成这些输入。
键盘是我们的标准输入装置(os.Stdin),我们可以创建一个阅读器去访问键盘。每次我们按下回车键,就会创建一个新行。新行通过\n进行标记。当按下回车键是,任何的写入都会被存储到输入变量中。
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString(‘\n')
让我们在main.go 文件中放入一个主函数,并围绕ReadString 功能添加一个for循环,我们可以连续的输入命令。在读取输入过程中发生错误时,我们会通过标准错误设备打印该错误(os.Stderr)。如果我们使用fmt.Println而不使用特殊的输出设备,错误信息将会被指向标准输出设备(os.Stdout)。shell本身不能改变这个功能性,但单独的设备允许对输出进行简单的过滤,以便做进一步处理。
func main() {
reader := bufio.NewReader(os.Stdin)
for {
// Read the keyboad input.
input, err := reader.ReadString('\n')
if err != nil {
fmt.Fprintln(os.Stderr, err)
}
}
}
执行命令
现在,我们想执行输入的命令。让我们为其创建一个新的函数execInput,execInput取一个字符串作为变量。首先,我们在输入的最后删除新行控制字符\n。其次,我们为exec.Command(input)准备一个命令,并为这个命令设置对应的输出和错误装置。最后,我们准备cmd.Run( )过程命令。
func execInput(input string) error {
// Remove the newline character.
input = strings.TrimSuffix(input, "\n")
// Prepare the command to execute.
cmd := exec.Command(input)
// Set the correct output device.
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
// Execute the command and save it's output.
err := cmd.Run()
if err != nil {
return err
}
return nil
}
第一个原型
通过在循环的顶端添加一个输入指示器(>),以及在循环的底端添加一个新的execInput函数完成了主函数。
func main() {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Print("> ")
// Read the keyboad input.
input, err := reader.ReadString('\n')
if err != nil {
fmt.Println(err)
}
// Handle the execution of the input.
err = execInput(input)
if err != nil {
fmt.Println(err)
}
}
}
该做第一个测试了。Go语言通过运行main.go来构建和运行shell。当你看到输入指示>时,就可以写一些命令了。例如,我们可以运行ls命令。
> ls
LICENSE
main.go
main_test.go
Wow,成功了!ls被执行了,且给我们展示了当前目录下的内容。退出shell同其他程序一样,通过CTRL-C组合键即可。
变量
通过ls -l命令得到更长形式的列表。
> ls -l
exec: "ls -l": executable file not found in $PATH
这种格式不再起作用了,这是因为我们的shell试着运行无法找到的ls -l的程序,ls和-l既是一段程序,-l 也同样被称作变量,被程序自身解析。目前,我们不能区分命令和变量。为了修复这个缺点,我们必须修改execLine函数,将输入使用空格进行分离。
func execInput(input string) error {
// Remove the newline character.
input = strings.TrimSuffix(input, “\n")
// Split the input to separate the command and the arguments.
args := strings.Split(input, " “)
// Pass the program and the arguments separately.
cmd := exec.Command(args[0], args[1:]...)
...
}
这段程序的名称现在被存到args[0]中,变量在随后的索引中。现在运行ls -l就会得到我们想要的结果。
> ls -l
total 24
-rw-r--r-- 1 simon staff 1076 30 Jun 09:49 LICENSE
-rw-r--r-- 1 simon staff 1058 30 Jun 10:10 main.go
-rw-r--r-- 1 simon staff 897 30 Jun 09:49 main_test.go
改变目录(cd)
现在我们可以通过一系列独立的变量运行命令。对最小可用集合设置一些功能点是很有必要的,现在只剩下一件事了(最起码根据我的看法)。当你演示shell时你可能已经偶遇了这些:你使用cd命令并不能改变目录。
> cd /
> ls
LICENSE
main.go
main_test.go
很明显这不是我根目录下的内容。为什么cd命令没有起作用?如果你知道真实路径,那就非常简单了(https:/ /stackoveryow.com/a/38776411) cd程序是shell一个内建的命令。
再一次,我们需要修改execInput函数。在Split函数之后,我们需要把存储在args[0]的第一个变量(执行的命令)进行一个状态转换。当命令为cd时,我们检查后面是否有变量,如果没有,我们不能改变目录到指定目录(在大多数其他shell中,需要改变目录到根目录下)。如果后面有变量arg[1](存储到路径中),我们可以使用os.Chdir(args[1])改变目录。在这个程序块的最后,我们返回execInput 函数以停止进一步的内键命令执行。
由于这很简单,我们只需要在cd块的右面增加一个 built-in exit 命令,就可以停止我们的shell(另一个选择是使用CTRL-C)
// Split the input to separate the command and the arguments.
args := strings.Split(input, " ")
// Check for built-in commands.
switch args[0] {
case "cd":
// 'cd' to home dir with empty path not yet supported.
if len(args) < 2 {
return errors.New("path required")
}
err := os.Chdir(args[1])
if err != nil {
return err
}
// Stop further processing.
return nil
case "exit":
os.Exit(0)
}
…
当然,随后的输出看起来像我的跟目录了。
> cd /
> ls
Applications
Library
Network
System
…
综上,我们写了一个简单的shell。
可以考虑的优化
当你不满足于这些时,你可以试着提升你的shell。这有些灵感:
修改输入指标:
增加工作目录
增加机器主机名
增加当前用户
通过上/下键,浏览你的输入历史
结论
已经到了文章的结尾,希望你们享受这个过程。我认为,当你理解了shell背后的概念,shell真的相当简单。
Go语言也是更简单的编程语言之一,他帮助我们更快的得到结果。Go语言可以通过自身管理内存,无需我们做任何低级别的操作。Rob Pike和Ken Thompson与创建Unix的Robert Griesemer共同创建了Go语言,所以我想用Go语言写一个shell是个很好的组合。
因为我也是在学习中,如果你发现了一些可以提升的东西请联系我。
后续更新
根据新闻网站Reddit的评论(https:/ /www.reddit.com/r/golang/comments/8vj47z/writing_a_simple_shell_in_go/),我现在已经使用了正确的输出设备。
完整的源代码
下面是完整的源代码,你可以查看这个仓库(https:/ /gitlab.com/sj14/gosh/),但源代码有可能已经与本文中展示的代码有所不同。
package main
import (
"bufio"
"errors"
"fmt"
"os"
"os/exec"
"strings"
)
func main() {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Print("> ")
// Read the keyboad input.
input, err := reader.ReadString('\n')
if err != nil {
fmt.Fprintln(os.Stderr, err)
}
// Handle the execution of the input.
err = execInput(input)
if err != nil {
fmt.Fprintln(os.Stderr, err)
}
}
}
// ErrNoPath is returned when 'cd' was called without a second argument.
var ErrNoPath = errors.New("path required")
func execInput(input string) error {
// Remove the newline character.
input = strings.TrimSuffix(input, "\n")
// Split the input separate the command and the arguments.
args := strings.Split(input, " ")
// Check for built-in commands.
switch args[0] {
case "cd":
// 'cd' to home with empty path not yet supported.
if len(args) < 2 {
return ErrNoPath
}
err := os.Chdir(args[1])
if err != nil {
return err
}
// Stop further processing.
return nil
case "exit":
os.Exit(0)
}
// Prepare the command to execute.
cmd := exec.Command(args[0], args[1:]...)
// Set the correct output device.
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
// Execute the command and save it's output.
err := cmd.Run()
if err != nil {
return err
}
return nil
}
参考资料
原文链接:https://sj14.gitlab.io/post/2018-07-01-go-unix-shell/
原文作者:Simon Jürgensmeyer
有疑问加站长微信联系(非本文作者)