Let's Go
CLI 命令行
概述
CLI(Command Line Interface)实用程序是Linux下应用开发的基础。正确的编写命令行程序让应用与操作系统融为一体,通过shell或script使得应用获得最大的灵活性与开发效率。例如:
- Linux提供了cat、ls、copy等命令与操作系统交互; go语言提供一组实用程序完成从编码、编译、库管理、产品发布全过程支持;
- 容器服务如docker、k8s提供了大量实用程序支撑云服务的开发、部署、监控、访问等管理任务;
- git、npm等也是大家比较熟悉的工具。
尽管操作系统与应用系统服务可视化、图形化,但在开发领域,CLI在编程、调试、运维、管理中提供了图形化程序不可替代的灵活性与效率。
基础知识
POSIX/GNU 命令行接口的一些概念与规范。命令行程序主要涉及内容:
- 命令
- 命令行参数
- 选项:长格式、短格式
- IO:stdin、stdout、stderr、管道、重定向
- 环境变量
Golang支持
使用os,flag包,最简单处理参数的代码:
package main
import (
"fmt"
"os"
)
func main() {
for i, a := range os.Args[1:] {
fmt.Printf("Argument %d is %s\n", i+1, a)
}
}
使用flag包的代码:
package main
import (
"flag"
"fmt"
)
func main() {
var port int
flag.IntVar(&port, "p", 8000, "specify port to use. defaults to 8000.")
flag.Parse()
fmt.Printf("port = %d\n", port)
fmt.Printf("other args: %+v\n", flag.Args())
}
开发实践
Golang之使用flag和pflag
为了满足 Unix 命令行规范,我们使用 pflag 替代 goflag。我们接下来了解flag和pflag的使用
flag基本操作
- 导入flag
import "flag"
- 定义flag
var ip = flag.Int("flagname", 1234, "help message for flagname")
- 将flag绑定到一个变量
var flagvar int
func init() {
flag.IntVar(&flagvar, "flagname", 1234, "help message for flagname")
}
- 绑定自定义的类型
flag.Var(&flagVal, "name", "help message for flagname")
flag解析
flag.Parse()
命令行参数的格式
–flag xxx (使用空格,一个 - 符号)
–flag xxx (使用空格,两个 - 符号)
–flag=xxx (使用等号,一个 - 符号)
–flag=xxx (使用等号,两个 - 符号)
flag官方实例
// 这个实例展示了关于flag包的更复杂的使用
package main
import (
"errors"
"flag"
"fmt"
"strings"
"time"
)
// 实例1:一个单独的字符串flag,叫“species”,其默认值为“goher”
var species = flag.String("species", "gopher", "the species we are studying")
// 实例2: 两个flag分享一个变量,所以我们可以一起写
// 初始化顺序没有定义,所以可以同时使用两个默认值。这必须在初始化函数中定义。
var gopherType string
func init() {
const (
defaultGopher = "pocket"
usage = "the variety of gopher"
)
flag.StringVar(&gopherType, "gopher_type", defaultGopher, usage)
flag.StringVar(&gopherType, "g", defaultGopher, usage+" (shorthand)")
}
// 实例3:用户定义flag类型,一个时间段的切片
type interval []time.Duration
// String是一个用来格式化flag值(flag.Value接口的一部分)的方法
// String方法的输出将被用于调试
func (i *interval) String() string {
return fmt.Sprint(*i)
}
// Set是一个用来设置flag值(flag.Value接口的一部分)的方法
// Set的参数是String类型,用于设置为flag
// 这是一个以逗号为分隔符的数组,我们需要分离它
func (i *interval) Set(value string) error {
// 如果flag能被设置为多时间,加速度值,如果有如此声明,我们将会删除这些
// 这些将会允许很多组合,例如"-deltaT 10s -deltaT 15s"
if len(*i) > 0 {
return errors.New("interval flag already set")
}
for _, dt := range strings.Split(value, ",") {
duration, err := time.ParseDuration(dt)
if err != nil {
return err
}
*i = append(*i, duration)
}
return nil
}
// 将一个flag定义为堆积期间。因为它还有个特殊类型,我们需要使用Var函数,从而在初始化中创建flag
var intervalFlag interval
func init() {
// 将命令行flag与intervalFlag绑定,并设置使用信息
flag.Var(&intervalFlag, "deltaT", "comma-separated list of intervals to use between events")
}
func main() {
// 所有有趣的信息都在上面了,但是如果想要使用flag包
// 最好的方法就是去执行,特别是在main函数(而不是init函数)前执行 flag.Parse()
// 我们这里并不运行,因为它不是个main函数,而且测试单元会详细设计flag内容
}
pflag安装
github地址:spf13/pflag
go get github.com/spf13/pflag
pflag基本操作
基本的使用和“flag包”基本相同,下面列举不同的操作
- 添加shorthand参数
// func IntP(name, shorthand string, value int, usage string) *int
// IntP is like Int, but accepts a shorthand letter that can be used after a single dash.
var ip= flag.IntP("flagname", "f", 1234, "help message")
- 设置非必须选项的默认值
var ip = flag.IntP("flagname", "f", 1234, "help message")
flag.Lookup("flagname").NoOptDefVal = "4321"
结果如下图所示:
Parsed Arguments | Resulting Value |
---|---|
–flagname=1357 | ip=1357 |
–flagname | ip=4321 |
[nothing] | ip=1234 |
- 命令行语法
--flag // 布尔flags, 或者非必须选项默认值
--flag x // 只对于没有默认值的flags
--flag=x
- flag定制化
例如希望使用“-”,“_”或者“.“:
func wordSepNormalizeFunc(f *pflag.FlagSet, name string) pflag.NormalizedName {
from := []string{"-", "_"}
to := "."
for _, sep := range from {
name = strings.Replace(name, sep, to, -1)
}
return pflag.NormalizedName(name)
}
myFlagSet.SetNormalizeFunc(wordSepNormalizeFunc)
例如希望联合两个参数:
func aliasNormalizeFunc(f *pflag.FlagSet, name string) pflag.NormalizedName {
switch name {
case "old-flag-name":
name = "new-flag-name"
break
}
return pflag.NormalizedName(name)
}
myFlagSet.SetNormalizeFunc(aliasNormalizeFunc)
- 弃用flag或者它的shothand
例如希望弃用名叫badflag参数,并告知开发者使用代替参数:
flags.MarkDeprecated("badflag", "please use --good-flag instead")
例如希望保持使用noshorthandflag,但想弃用简称n:
flags.MarkShorthandDeprecated("noshorthandflag", "please use --noshorthandflag only")
从而当使用n时,会提示Flag shorthand -n has been deprecated, please use --noshorthandflag only
- 隐藏flag
例如希望保持使用secretFlag参数,但在help文档中隐藏这个参数的说明:
flags.MarkHidden("secretFlag")
- 关闭flags的排序
例如希望关闭对help文档或使用说明的flag排序:
flags.BoolP("verbose", "v", false, "verbose output")
flags.String("coolflag", "yeaah", "it's really cool flag")
flags.Int("usefulflag", 777, "sometimes it's very useful")
flags.SortFlags = false
flags.PrintDefaults()
输出:
-v, --verbose verbose output
--coolflag string it's really cool flag (default "yeaah")
--usefulflag int sometimes it's very useful (default 777)
Linux 命令行实用程序selpg
本文演示如何使用go语言编写与 cat、ls、pr 和 mv 等标准命令类似的 Linux 命令行实用程序。我选择了一个名为 selpg 的实用程序,这个名称代表 SELect PaGes。selpg 允许用户指定从输入文本抽取的页的范围,这些输入文本可以来自文件或另一个进程。selpg 是以在 Linux 中创建命令的事实上的约定为模型创建的,这些约定包括:
- 独立工作
- 在命令管道中作为组件工作(通过读取标准输入或文件名参数,以及写至标准输出和标准错误)
- 接受修改其行为的命令行选项
功能
该实用程序从标准输入或从作为命令行参数给出的文件名读取文本输入。它允许用户指定来自该输入并随后将被输出的页面范围。例如,如果输入含有 100 页,则用户可指定只打印第 35 至 65 页。这种特性有实际价值,因为在打印机上打印选定的页面避免了浪费纸张。另一个示例是,原始文件很大而且以前已打印过,但某些页面由于打印机卡住或其它原因而没有被正确打印。在这样的情况下,则可用该工具来只打印需要打印的页面。
该命令本质上就是将一个文件,通过自己设定的分页方式,输出到屏幕或者重定向到其他文件上,或者利用打印机打印出来。使用格式如下。
-s start_page -e end_page [ -f | -l lines_per_page ][ -d dest ] [ in_filename ]
必要参数
- -s: 后面接开始读取的页号 int
- -e: 后面接结束读取的页号 int s和e都要大于1,并且s <= e,否则提示错误
s和e都要大于1,并且s <= e,否则提示错误
可选参数:
- -l: 后面跟行数 int,代表多少行分为一页,不指定 -l 又缺少 -f, 则默认按照72行分一页
- -f: 该标志无参数,代表按照分页符’\f’分页
- -d: 后面接打印机名称,用于打印filename,唯一一个无标识参数,代表选择读取的文件名
引入所需要的包
import (
"fmt"
"os"
"syscall"
"os/exec"
"io"
"bufio"
"strings"
flag "github.com/spf13/pflag"
)
-
io,实现了一系列非平台相关的 IO 相关接口和实现,比如提供了对 os 中系统相关的 IO 功能的封装。我们在进行流式读写(比如读写文件)时,通常会用到该包。
-
os/exec,执行外部命令,它包装了 os.StartProcess 函数以便更容易映射到 stdin 和 stdout,并且利用 pipe 连接 I/O。
-
bufio,在 io 的基础上提供了缓存功能。在具备了缓存功能后, bufio 可以比较方便地提供 ReadLine 之类的操作。
-
os,提供了对操作系统功能的非平台相关访问接口。接口为Unix风格。提供的功能包括文件操作、进程管理、信号和用户账号等。
-
fmt,实现格式化的输入输出操作,其中的 fmt.Printf() 和 fmt.Println() 是开发者使用最为频繁的函数。
-
pflag,提供命令行参数的规则定义和传入参数解析的功能。绝大部分的 CLI 程序都需要用到这个包。
设计selpg结构体
type selpgArgs struct {
startPage int
endPage int
inputFile string
pageLen int
pageType bool
printDest string
}
参数处理
func processArgs() {
if flag.NFlag() < 1 {
/* handle mandatory args first */
} else if sa.startPage < 1 {
fmt.Fprintf(os.Stderr, "invalid start page %v\n", sa.startPage)
} else if sa.endPage < 1 || sa.endPage < sa.startPage {
fmt.Fprintf(os.Stderr, "invalid end page %v\n", sa.endPage)
} else if !sa.pageType && sa.pageLen < 1 {
fmt.Fprintf(os.Stderr, "invalid page length %v\n", sa.pageLen)
} else {
if flag.NArg() > 0 {
sa.inputFile = flag.Arg(0)
if syscall.Access(sa.inputFile, syscall.O_RDONLY) != nil {
fmt.Fprintf(os.Stderr, "input file \"%s\" does not exist or cannot be read\n", sa.inputFile)
} else { return }
} else { return }
}
flag.Usage()
os.Exit(1)
}
- -s 和 -e 强制选项:
selpg 要求用户用两个命令行参数"-s"("-s10" 表示从第 10 页开始)和 “-e”("-e20" 表示在第 20 页结束)指定要抽取的页面范围的起始页和结束页。selpg 对所给的页号进行合理性检查;换句话说,它会检查两个数字是否为有效的正整数以及结束页是否不小于起始页。"-s"和"-e"是强制性的,而且必须是命令行上在命令名 selpg 之后的头两个参数:
$ selpg -s10 -e20 ...
-
-l 和 -f 可选选项:
selpg 可以处理两种输入文本:-
类型 1: 该类文本的页行数固定。这是缺省类型,因此不必给出选项进行说明。也就是说,如果既没有给出"-l"也没有给出"-f"选项,则 selpg 会理解为页有固定的长度(每页 72 行)。
选择 72 作为缺省值是因为在行打印机上这是很常见的页长度。这样做的意图是将最常见的命令用法作为缺省值,这样用户就不必输入多余的选项。该缺省值可以用 “-l” 选项覆盖,如下所示:
$ selpg -s10 -e20 -l66 ...
这表明页有固定长度,每页为 66 行。
-
类型 2: 该类型文本的页由 ASCII 换页字符(十进制数值为 12)定界。该格式与“每页行数固定”格式相比的好处在于,当每页的行数有很大不同而且文件有很多页时,该格式可以节省磁盘空间。在含有文本的行后面,类型 2 的页只需要一个字符 ― 换页 ― 就可以表示该页的结束。打印机会识别换页符并自动根据在新的页开始新行所需的行数移动打印头。
将这一点与类型 1 比较:在类型 1 中,文件必须包含 PAGELEN - CURRENTPAGELEN 个新的行以将文本移至下一页,在这里 PAGELEN 是固定的页大小而 CURRENTPAGELEN 是当前页上实际文本行的数目。在此情况下,为了使打印头移至下一页的页首,打印机实际上必须打印许多新行。这在磁盘空间利用和打印机速度方面效率都很低(尽管实际的区别可能不太大)。
类型 2 格式由"-f"选项表示,如下所示:
$ selpg -s10 -e20 -f ...
该命令告诉 selpg 在输入中寻找换页符,并将其作为页定界符处理。
-
-
-d 可选选项:
selpg 还允许用户使用 “-d” 选项将选定的页直接发送至打印机。lp 命令"-d"选项后面参数为可接受的打印目的地名称。该目的地应该存在 ― selpg 不检查这一点。在运行了带"-d"选项的 selpg 命令后,若要验证该选项是否已生效,请运行命令 “lpstat -t”。该命令应该显示添加到打印队列的一项打印作业。如果当前有打印机连接至该目的地并且是启用的,则打印机应打印该输出。这一特性是用 popen() 系统调用实现的,该系统调用允许一个进程打开到另一个进程的管道,将管道用于输出或输入。在下面的示例中,我们打开到命令的管道以便输出,并写至该管道而不是标准输出:
$ selpg -s10 -e20 -dlp1
该命令将选定的页作为打印作业发送至 lp1 打印目的地。您应该可以看到类似"request id is lp1-6"的消息。该消息来自 lp 命令;它显示打印作业标识。如果在运行 selpg 命令之后立即运行命令 lpstat -t | grep lp1 ,您应该看见 lp1 队列中的作业。如果在运行 lpstat 命令前耽搁了一些时间,那么您可能看不到该作业,因为它一旦被打印就从队列中消失了。
输入处理
func processInput() {
fin := os.Stdin
fout := os.Stdout
lineCount := 0
pageCount := 1
var inpipe io.WriteCloser
var err error
if sa.inputFile != "" {
fin, err = os.Open(sa.inputFile)
}
if sa.printDest != "" {
cmd := exec.Command("lp", "-d", sa.printDest)
inpipe, err = cmd.StdinPipe()
if err != nil {
fmt.Fprintf(os.Stderr, "could not open pipe to \"%s\"\n", sa.printDest)
flag.Usage()
os.Exit(1)
}
cmd.Stdout = fout
cmd.Start()
}
if sa.pageType {
reader := bufio.NewReader(fin)
for {
page, rerr := reader.ReadString('\f')
if pageCount >= sa.startPage {
page = strings.Replace(page, "\f", "", -1)
if sa.printDest != "" {
fmt.Fprintf(inpipe, "%s", page)
} else {
fmt.Fprintf(fout, "%s", page)
}
}
pageCount++
if rerr == io.EOF || pageCount > sa.endPage {
break
}
}
} else {
line := bufio.NewScanner(fin)
for line.Scan() {
if pageCount >= sa.startPage {
if sa.printDest != "" {
fmt.Fprintf(inpipe, "%s\n", line.Text())
} else {
fmt.Fprintf(fout, "%s\n", line.Text())
}
}
lineCount++
if lineCount == sa.pageLen {
lineCount = 0
pageCount++
if pageCount > sa.endPage {
break
}
}
}
}
if pageCount < sa.startPage {
fmt.Fprintf(os.Stderr, "start_page (%d) larger than total pages (%d), no output written\n", sa.startPage, pageCount)
} else if pageCount < sa.endPage {
fmt.Fprintf(os.Stderr, "end_page (%d) larger than total pages (%d), less output than expected\n", sa.endPage, pageCount)
} else {
fin.Close()
if sa.printDest != "" {
inpipe.Close()
}
fmt.Fprintf(os.Stderr, "done\n");
}
}
一旦处理了所有的命令行参数,就使用这些指定的选项以及输入、输出源和目标来开始输入的实际处理。
selpg 通过以下方法记住当前页号:如果输入是每页行数固定的,则 selpg 统计新行数,直到达到页长度后增加页计数器。如果输入是换页定界的,则 selpg 改为统计换页符。这两种情况下,只要页计数器的值在起始页和结束页之间这一条件保持为真,selpg 就会输出文本(逐行或逐字)。当那个条件为假(也就是说,页计数器的值小于起始页或大于结束页)时,则 selpg 不再写任何输出。
单元测试
编写单元测试代码
func TestprocessInput(t *testing.T) {
flag.Parse()
processArgs()
a := processInput()
if a != 1 {
t.Errorf("error")
}
}
运行单元测试
使用
为了演示最终用户可以如何应用我们所介绍的一些原则,下面给出了可使用的 selpg 命令字符串示例:
-
该命令将把"input.txt"的第 1 页写至标准输出(也就是屏幕),因为这里没有重定向或管道。
$ selpg -s1 -e1 input.txt
-
该命令与示例 1 所做的工作相同,但在本例中,selpg 读取标准输入,而标准输入已被 shell/内核重定向为来自"input.txt"而不是显式命名的文件名参数。输入的第 1 页被写至屏幕。
$ selpg -s1 -e1 < input.txt
-
"other_command"的标准输出被 shell/内核重定向至 selpg 的标准输入。将第 1 页到第 2 页写至 selpg 的标准输出(屏幕)。
$ other_command | selpg -s1 -e2
-
selpg 将第 1 页到第 2 页写至标准输出;标准输出被 shell/内核重定向至"output.txt"。
$ selpg -s1 -e2 input.txt >output.txt
-
selpg 将第 1 页到第 2 页写至标准输出(屏幕);所有的错误消息被 shell/内核重定向至"error.txt"。
$ selpg -s1 -e2 input.txt 2>error.txt
-
selpg 将第 1 页到第 2 页写至标准输出,标准输出被重定向至"output.txt";selpg 写至标准错误的所有内容都被重定向至"error.txt"。当"input.txt"很大时可使用这种调用;您不会想坐在那里等着 selpg 完成工作,并且您希望对输出和错误都进行保存。
$ selpg -s1 -e2 input.txt >output.txt 2>error.txt
-
selpg 将第 1 页到第 2 页写至标准输出,标准输出被重定向至"output_file"。selpg 写至标准错误的所有内容都被重定向至 /dev/null(空设备),这意味着错误消息被丢弃了。设备文件 /dev/null 废弃所有写至它的输出,当从该设备文件读取时,会立即返回 EOF。
$ selpg -s1 -e2 input.txt >output.txt 2>/dev/null
-
selpg 将第 1 页到第 2 页写至标准输出,标准输出被丢弃;错误消息在屏幕出现。这可作为测试 selpg 的用途,此时您也许只想(对一些测试情况)检查错误消息,而不想看到正常输出。
$ selpg -s1 -e2 input.txt >/dev/null
-
selpg 的标准输出透明地被 shell/内核重定向,成为"other_command"的标准输入,第 1 页到第 2 页被写至该标准输入。"other_command"的示例可以是 lp,它使输出在系统缺省打印机上打印。"other_command"的示例也可以 wc,它会显示选定范围的页中包含的行数、字数和字符数。"other_command"可以是任何其它能从其标准输入读取的命令。错误消息仍在屏幕显示。
$ selpg -s1 -e2 input.txt | other_command
-
与上面的示例 9 相似,只有一点不同:错误消息被写至"error.txt"。
$ selpg -s1 -e2 input.txt 2>error.txt | other_command
在以上涉及标准输出或标准错误重定向的任一示例中,用“>>”替代“>”将把输出或错误数据附加在目标文件后面,而不是覆盖目标文件(当目标文件存在时)或创建目标文件(当目标文件不存在时)。
以下所有的示例也都可以(有一个例外)结合上面显示的重定向或管道命令。我没有将这些特性添加到下面的示例,因为我认为它们在上面示例中的出现次数已经足够多了。例外情况是您不能在任何包含"-d"选项的 selpg 调用中使用输出重定向或管道命令。实际上,您仍然可以对标准错误使用重定向或管道命令,但不能对标准输出使用,因为没有任何标准输出 — 正在内部使用 popen() 函数由管道将它输送至 lp 进程。
$ selpg -s1 -e2 -l10 input.txt
该命令将页长设置为 10 行,这样 selpg 就可以把输入当作被定界为该长度的页那样处理。第 1 页到第 2 页被写至 selpg 的标准输出(屏幕)。
$ selpg -s1 -e2 -f input.txt
假定页由换页符定界。第 1 页到第 2 页被写至 selpg 的标准输出(屏幕)。
$ selpg -s1 -e2 -dlp1 input.txt
第 10 页到第 20 页由管道输送至命令“lp -dlp1”,该命令将使输出在打印机 lp1 上打印。
最后一个示例将演示 Linux shell 的另一特性:
$ selpg -s1 -e2 input.txt > output.txt 2>error.txt &
该命令利用了 Linux 的一个强大特性,即:在“后台”运行进程的能力。在这个例子中发生的情况是:“进程标识”(pid)如 1234 将被显示,然后 shell 提示符几乎立刻会出现,使得您能向 shell 输入更多命令。同时,selpg 进程在后台运行,并且标准输出和标准错误都被重定向至文件。这样做的好处是您可以在 selpg 运行时继续做其它工作。
您可以通过运行命令 ps(代表“进程状态”)检查它是否仍在运行或已经完成。该命令会显示数行信息,每行代表一个从该 shell 会话启动的进程(包括 shell 本身)。