Golang模拟Linux管道操作符

621 阅读7分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第2天,点击查看活动详情

Linux管道操作符|

正如我另一篇文章说的那样,这是经常使用的一个命令:

cat system.log | grep hello

上面的命令,可以让我们在system.log里找到包含hello字符串的行,并输出到屏幕上。

什么叫模拟Linux管道操作符

管道操作符将一个命令分成前后两个部分,前面一个命令的标准输出会对接到后一个命令的标准输入。

以上说法就是最正确的描述。我们实际上就是来模拟这个过程。

这里需要注意的是,我们无法模拟形式上的东西,也就是说无法自定义一个符号来代替|,我们最终的效果如下。

当然,如果我们使用Golang来完整的开发一个类似`bash`的程序,比如说称之为`gosh`,我们当然随便使用任何符号来替换`|`

这个文章里,我们会使用Golang开发一个简单的程序,称之为go-pipe,用如下的方法使用:


go-pipe "cat system.log" "grep hello"

也就是说go-pipe这个程序,接收两个命令行参数,然后这个程序来模拟管道,进而,最后的结果就和下面的指令一样:

cat system.log | grep hello

一切从新建Golang文件开始

一些基础的概念就不提了,比如说搭建Golang开发环境等等。

我们从main.go开始:

package main

func main() {

}

我们要接收两个命令行参数,那么这样即可:

	arg1:=os.Args[1] // 这个可能是"cat system.log"
	arg2:=os.Args[2] // 这个可能是"grep hello"

上面的代码没有正确处理错误,比如说,用户没有输入两个参数。这里不管,因为主题是模拟Linux管道操作符,错误处理啥的先忽略不提。

catgrep 的启动顺序

由于要模拟两个程序的执行,我们必须先理清,这两个程序启动的时机。

  • 是先启动 cat,再启动 grep ?
  • 还是 先启动 grep 再启动 cat ?
  • 亦或 两者同时启动?

不妨假设system.log1个G,那么试想,难道cat是把这个 1个G的内容全部读到内存里,然后再慢慢的输出给grep

很显然,这是不好的。

所以说,这两个程序应该是同时执行,cat根据自身的buffer大小,将system.log的数据输出给grep

所以,我们在go-pipe程序中,要并发的启动由两个参数所指定的程序cat system.loggrep hello

在Golang中启动另一个程序

这个其实很好做到,官方库里自带了这个功能,这个功能基本上所有的编程语言都会自带,因为是一个基本的需求。

在Golang中,这样既可:

	catcmd := exec.Command("cat", "system.log")
	catcmd.Run()

如果没有更多的需求,用上面这样的代码就行了。

但是我们的需求是将 cat system.log这个命令行参数,作为一个指令进行启动。自然的,我们要分割一下字符串:

	arg1 := os.Args[1]
	arg1seg := strings.Split(arg1, " ")
	arg1cmd := exec.Command(arg1seg[0], arg1seg[1:]...)
	arg1cmd.Run()

上面的代码很清楚,不过有一点要提到:

exec.Command函数,第二个参数是一个可变长度的参数,这很好理解,因为一个指令的参数往往不止一个。例如:

cat system.log.1 system.log.2 |grep hello

上面的指令,cat就有两个参数。

采用 cmd.Start 来启动程序

一旦使用cmd.Run这个函数来启动程序的话,这个线程就会被卡住,直到这个程序结束。

所以我们换一个函数: cmd.Start

	arg1 := os.Args[1]
	arg1seg := strings.Split(arg1, " ")
	arg1cmd := exec.Command(arg1seg[0], arg1seg[1:]...)
	arg1cmd.Start()

这样做的好处就是,Start 函数会立即返回,而不会卡住这个线程。

我们如法炮制,去接收第二个命令行参数:

	arg1 := os.Args[1]
	arg1seg := strings.Split(arg1, " ")
	arg1cmd := exec.Command(arg1seg[0], arg1seg[1:]...)
	
        arg2 := os.Args[2]
	arg2seg := strings.Split(arg2, " ")
	arg2cmd := exec.Command(arg2seg[0], arg2seg[1:]...)
	
        arg1cmd.Start()
        arg2cmd.Start()

此时此刻,我们就将两个命令行参数所制定的程序,启动了起来。 为什么将两个Start写在最后呢,这是因为,我们还要对接一下输入和输出。

对接输入和输出

回顾一下我们的目标:
就是让

cat system.log|grep hello
go-pipe "cat system.log" "grep hello"

上面两者的效果要是一样的。

所有光启动不行,还要将第一个程序的标准输出对接到第二个程序的标准输入。

看起来很简单,伪代码可能如下:

	arg1 := os.Args[1]
	arg1seg := strings.Split(arg1, " ")
	arg1cmd := exec.Command(arg1seg[0], arg1seg[1:]...)

	arg2 := os.Args[1]
	arg2seg := strings.Split(arg2, " ")
	arg2cmd := exec.Command(arg2seg[0], arg2seg[1:]...)

	arg1cmd.Stdout = arg2cmd.Stdin // 对接

	arg1cmd.Start()
	arg2cmd.Start()

如上代码所示,第一个cmd的Stdout = 第二个cmd的Stdin,这样写,似乎很合理。

但是不好意思,代码提示错误:

image.png

错误很简单,就是因为两个变量类型不一致,不能直接赋值,那怎么办呢?

我们不能用上面那两个成员变量,应该用另外两个:

	cmd1out, _ := arg1cmd.StdoutPipe() // 第一个cmd的标准输出
	cmd2in, _ := arg2cmd.StdinPipe()   // 第二个cmd的标准输入

接下来只要想办法,让这两个对接就行了。

用一个很简单的思维来思考,就是我们正在写的这个程序go-pipe其实就是catgrep的中转站,我们在go-pipe中读取cat的输出,然后把这个内容写入grep程序,这样就行了。

	buffer := make([]byte, 1024)
	//
	for {
		n, err := cmd1out.Read(buffer)
		if err != nil {
			break
		}
		cmd2in.Write(buffer[:n])
	}

上面的代码就是一个简单的中转站操作,就是读取 cmd1out 然后 写入 cmd2in。 不过上面的代码,有一个隐藏的bug,实际情况下会小概率发生,这里不提,文章最后提一下。

好了,在程序最后,我们需要Wait这两个cmd的操作,结束,否则,main函数就直接执行完了。

	arg1cmd.Wait()
	arg2cmd.Wait()

整体代码:

package main

import (
	"os"
	"os/exec"
	"strings"
)

func main() {
	arg1 := os.Args[1]
	arg1seg := strings.Split(arg1, " ")
	arg1cmd := exec.Command(arg1seg[0], arg1seg[1:]...)

	arg2 := os.Args[2]
	arg2seg := strings.Split(arg2, " ")
	arg2cmd := exec.Command(arg2seg[0], arg2seg[1:]...)
	arg2cmd.Stdout = os.Stdout

	cmd1out, _ := arg1cmd.StdoutPipe()
	cmd2in, _ := arg2cmd.StdinPipe()

	arg1cmd.Start()
	arg2cmd.Start()

	//

	buffer := make([]byte, 1024)
	//
	for {
		n, err := cmd1out.Read(buffer)
		if err != nil {
			break
		}
		cmd2in.Write(buffer[:n])
	}
	arg1cmd.Wait()
	arg2cmd.Wait()
}

此时此刻,我们准备好system.log文件:

image.png

然后:

go run main.go "cat system.log" "grep hello"

发现,确实能从system.log找到hello所在的行。

但是有一个问题, 程序卡住了。

这是为什么能?仔细思考以下整个过程。

发现,第二个程序, 也就是grep程序,应该就是卡住的原因,grep此时还在继续等待输入。

如果不确定的话,请使用ps aux|grep grep,来观察一下 grep hello 这个指令是否依然在运行。

其实很好办, 我们在for循环退出之后,马上关闭cmd2in就行了:

	for {
		n, err := cmd1out.Read(buffer)
		if err != nil {
			break
		}
		cmd2in.Write(buffer[:n])
	}

	cmd2in.Close()

现给出整体代码:

package main

import (
	"os"
	"os/exec"
	"strings"
)

func main() {
	arg1 := os.Args[1]
	arg1seg := strings.Split(arg1, " ")
	arg1cmd := exec.Command(arg1seg[0], arg1seg[1:]...)

	arg2 := os.Args[2]
	arg2seg := strings.Split(arg2, " ")
	arg2cmd := exec.Command(arg2seg[0], arg2seg[1:]...)
	arg2cmd.Stdout = os.Stdout

	cmd1out, _ := arg1cmd.StdoutPipe()
	cmd2in, _ := arg2cmd.StdinPipe()

	arg1cmd.Start()
	arg2cmd.Start()

	//

	buffer := make([]byte, 1024)
	//
	for {
		n, err := cmd1out.Read(buffer)
		if err != nil {
			break
		}
		cmd2in.Write(buffer[:n])
	}

	cmd2in.Close()

	arg1cmd.Wait()
	arg2cmd.Wait()
}

如此一来,就能正常运行了。

中转站的bug

细心的人肯定已经发现了。

		cmd2in.Write(buffer[:n])

就是这一句有问题,因为Write函数,并不能确保将n个字节写入cmd2in里面去。

例如,如果n很大的时候,就不行。比如说n取个100M。我估计就写不进去。

Write函数,会返回实际写入成功的字节数,此时,我们需要继续将未写入的字节,继续写进去,也就是说,我们要搞一个循环,一直写,直到总共写入的字节==n。才行,大概的代码如下:

for {
        n, err := cmd1out.Read(buffer)
        if err != nil {
                break
        }
        var totalWrite int
        for {
            if totalWrite == n {
                    break
            }
            tempn, _ := cmd2in.Write(buffer[totalWrite:n]) // 注意,这里开始下标变成了 totalWriten
            totalWrite += tempn
        }
}

好了,这里我们没有考虑Write函数的第二个返回值,也就是错误,实际项目代码里,这个还要考虑。