# selpg **Repository Path**: warpmatrix/selpg ## Basic Information - **Project Name**: selpg - **Description**: 使用 go 语言重构 c 程序 selpg - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2020-10-11 - **Last Updated**: 2020-12-19 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # CLI 命令行实用程序开发——selpg 在 linux 下编写应用程序,使用 cli 与操作系统进行交互,能极大程度地提高应用的灵活性和使用效率。在写命令行程序(工具、server)时,对命令参数进行解析是常见的需求。一个优良的命令行程序能让应用与操作系统融为一体,极大程度地提升程序员编程、调试、运维、管理的效率。 各种语言一般都会提供解析命令行参数的方法或库,以方便程序员使用。如果命令行参数纯粹自己写代码解析,还是比较复杂的。秉着不要重复发明轮子的思想,我们应该了解相应的一些解析命令行参数的库。go 语言作为一种从编码、编译、库管理、产品发布全过程支持的语言,也有相应的支持如标准库中的 flag 包等,方便进行命令行解析。下面以应用程序 selpg 开发为例简单介绍 go 语言下命令应用开发的基础。 ## Table of Contents - [1. 概述](#1-概述) - [2. 应用参数处理](#2-应用参数处理) - [3. 开发流程](#3-开发流程) - [3.1. 需要额外使用的库文件](#31-需要额外使用的库文件) - [3.2. 数据结构的定义](#32-数据结构的定义) - [3.3. 测试驱动开发(单元测试)](#33-测试驱动开发单元测试) - [3.4. 参数的绑定及初始化](#34-参数的绑定及初始化) - [3.5. 检验参数的合法性](#35-检验参数的合法性) - [3.6. 程序逻辑的实现](#36-程序逻辑的实现) - [4. 使用示例(功能测试)](#4-使用示例功能测试) ## 1. 概述 selpg 的全称为 SELect PaGs。程序的功能和程序的名称一致:从文本输入选择页范围的实用程序。该输入可以来自作为最后一个命令行参数指定的文件,在没有给出文件名参数时也可以来自标准输入。 ## 2. 应用参数处理 selpg 应用程序在扫描了所有的选项参数(也就是那些以连字符为前缀的参数)后,如果 selpg 发现还有一个参数,则它会接受该参数为输入文件的名称并尝试打开它以进行读取。如果没有其它参数,则 selpg 假定输入来自标准输入。 该应用程序有如下几种参数: - "-sNumber" 和 "-eNumber" 强制选项 这两个参数指定要抽取的页面范围的起始页和结束页。selpg 对所给的页号进行合理性检查;换句话说,它会检查两个数字是否为有效的正整数以及结束页是否不小于起始页。这两个选项,"-sNumber" 和 "-eNumber" 是强制性的,而且必须是命令行上在命令名 selpg 之后的头两个参数。 - "-lNumber" 和 "-f" 可选选项 这个参数指定了应用程序统计文本页数的方式:这个参数可以是缺省类型,因此可以不给出选项进行说明。也就是说,如果既没有给出"-lNumber"也没有给出"-f"选项,则 selpg 会理解为页有固定的长度(每页 72 行)。 1. "-lNumber" 可以指定文本的每页的行数为 Number。 2. "-f" 指定文本每页行数不定,需要在输入中寻找换页符,并将其作为页定界符处理。 > 上述的 "-lNumber" 和 "-f" 两个参数是互斥的。 - "-dDestination" 可选选项 selpg 允许用户使用 "-dDestination" 选项将选定的页直接发送至打印机。这里的 "Destination" 就是指定的打印机目的地。具体的目的地状态可以通过,`lp` 和 `lpstat` 命令进行查看。 ## 3. 开发流程 ### 3.1. 需要额外使用的库文件 开发中需要我们使用到 pflag 库。首先执行 `go get github.com/spf13/pflag` 获取 pflag 库。随后使用 `import` 语句导入库即可。 ```go import ( "bufio" "fmt" "io" "os" "os/exec" flag "github.com/spf13/pflag" ) ``` ### 3.2. 数据结构的定义 程序使用 `pflag` 库进行命令行参数的解析。相应的参数被存储在名为 `spArgs` 的数据结构当中。 ```go type spArgs struct { startPage int endPage int inFilename string pageLen int pageType string printDest string } ``` ### 3.3. 测试驱动开发(单元测试) 先写程序框架和测试函数,以明确程序开发的目标。程序主要分为三个模块:参数的解析与处理、参数的合法性分析、程序主体部分的执行。程序的主函数如下: ```go func main() { progname = os.Args[0] var sa spArgs sa.init() if err := fmtArgs(&sa); err != nil { flag.Usage() fmt.Fprintf(os.Stderr, "%s", err) return } if err := procIO(sa); err != nil { fmt.Fprintf(os.Stderr, "%s", err) return } } ``` 首先是参数解析、初始化的过程。在没有相应的命令行参数的情况下 `apArgs` 数据结构中的字段被初始化为对应的默认值。 ```go func TestInit(t *testing.T) { want := spArgs{-1, -1, "", 72, "l", ""} var sa spArgs sa.init() if sa != want { t.Errorf("sa.init() got %v, want %v", sa, want) } } ``` 第二个模块用于判断命令行参数的合法性,对应的参数已经被存储在 `spArgs` 数据结构中。主要涉及的错误有: - 起始位置无效 - 终止位置无效 - 每页的行数设置无效 - 输入文件不存在 ```go func TestFmtArgs(t *testing.T) { cases := []struct { sa spArgs want error }{ {spArgs{1, 1, "demo.txt", 72, "l", ""}, nil}, {spArgs{0, 1, "demo.txt", 72, "l", ""}, &MyError{"the startPage is invalid"}}, {spArgs{2, 1, "demo.txt", 72, "l", ""}, &MyError{"the endPage is invalid"}}, {spArgs{1, 1, "demo.txt", 0, "l", ""}, &MyError{"the pageLen is invalid"}}, {spArgs{1, 1, "nonexist.txt", 72, "l", ""}, &MyError{"file \"nonexist.txt\" doesn't exist"}}, } for _, c := range cases { err := fmtArgs(&c.sa) if err != c.want && err.Error() != c.want.Error() { t.Errorf("fmtArgs(%v) == %v, want %v", c.sa, err, c.want) } } } ``` 第三个模块是相应功能的实现,并把要求的内容发送到相应的 io 流中。由于直接将对应的内容发送到 io 流中,因此无法使用平时的测试框架进行测试,可以使用功能测试验证代码的正确性。 以下是单元测试的结果: ![unit-test](images/unit-test.png) ### 3.4. 参数的绑定及初始化 具体的代码实现如下,相关函数完成了参数的解析、绑定,对参数的用法进行了描述。函数的实现主要使用了以下的 API: - `flag.xxxVarP`:对参数的绑定,并设置初始值 - `flag.Lookup`:寻找特定的参数 - `flag.Usage`:设置描述参数的使用方法,调用函数打印出对应的 usage。 - `flag.Parse`:对命令行参数进行解析 ```go func (sa *spArgs) init() { flag.IntVarP(&sa.startPage, "start", "s", -1, "start page(>1)") flag.IntVarP(&sa.endPage, "end", "e", -1, "end page(>=start_page)") flag.IntVarP(&sa.pageLen, "len", "l", 72, "page len") flag.StringVarP(&sa.printDest, "dest", "d", "", "print destination") flag.StringVarP(&sa.pageType, "type", "f", "l", "'l' for lines-delimited, 'f' for form-feed-delimited. default is 'l'") flag.Lookup("type").NoOptDefVal = "f" flag.Usage = func() { fmt.Fprintf(os.Stderr, "USAGE: %s -sstartPage -eendPage [ -f | -llines_per_page ] [ -ddest ] [ in_filename ]\n", progname) flag.PrintDefaults() } flag.Parse() if len(flag.Args()) > 0 { sa.inFilename = flag.Args()[0] } } ``` ### 3.5. 检验参数的合法性 第二个模块的函数完成了对应参数错误使用的判断,并进行了相应错误的返回。主要需要使用 `error` 接口,对函数的处理结果进行相应的返回。 ```go func fmtArgs(sa *spArgs) error { const MAXINT = int(^uint(0) >> 1) if sa.startPage < 1 || sa.startPage > MAXINT { err := MyError{} err.desc = fmt.Sprintf("the startPage is invalid") return &err } if sa.endPage < 1 || sa.endPage > MAXINT || sa.endPage < sa.startPage { err := MyError{} err.desc = fmt.Sprintf("the endPage is invalid") return &err } if sa.pageLen < 1 || sa.pageLen > MAXINT { err := MyError{} err.desc = fmt.Sprintf("the pageLen is invalid") return &err } if sa.inFilename != "" { _, err := os.Stat(sa.inFilename) if os.IsNotExist(err) { err := MyError{} err.desc = fmt.Sprintf("file \"%s\" doesn't exist", sa.inFilename) return &err } } return nil } ``` ### 3.6. 程序逻辑的实现 第三个模块实现程序的主要逻辑部分,程序主要涉及对 io 流的读写,具体的代码实现如下。 第一部分是输入缓冲区的建立,需要使用到的 API 有: - `os.Open` 通过操作系统打开读入的文件 - `bufio.NewReader` 创建相应输入流的缓冲区读入接口 ```go func procIO(sa spArgs) error { fin := os.Stdin if sa.inFilename != "" { var err error fin, err = os.Open(sa.inFilename) if err != nil { err := MyError{} err.desc = fmt.Sprintf("%s: could not open input file \"%s\"", progname, sa.inFilename) return &err } } defer fin.Close() finBuf := bufio.NewReader(fin) ``` 第二部分是输出缓冲区的建立,需要使用到主要 API 有: - `exec.Command` 创建对应的指令进程,接收将 `selpg` 程序读到的内容。由于没有连接打印机,此处使用了 `cat` 指令,而不是 `lp` 指令。 - `os.Stat` 检查文件系统是否存在对应的输出文件,不存在则进行相应的创建操作 - `cmd.StdinPipe` 设置指令进程的输入管道。`selpg` 程序通过该管道,可以传输对应的数据内容。 - `cmd.start` 执行指令进程 ```go foutBuf := io.WriteCloser(os.Stdout) if sa.printDest != "" { // exec.Command("lp", "-d", sa.printDest) // 无法使用 lp 指令,因此改用 cat 指令进行测试 cmd := exec.Command("cat") _, err := os.Stat(sa.printDest) if os.IsNotExist(err) { cmd.Stdout, err = os.OpenFile(sa.printDest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0) os.Chmod(sa.printDest, 0664) } else { cmd.Stdout, err = os.OpenFile(sa.printDest, os.O_APPEND|os.O_WRONLY|os.O_TRUNC, 0) } if err != nil { err := MyError{} err.desc = fmt.Sprintf("%s: can't open file %s", progname, sa.printDest) return &err } foutBuf, err = cmd.StdinPipe() if err != nil { err := MyError{} err.desc = fmt.Sprintf("%s: can't open pipe to \"lp -d%s\"", progname, sa.printDest) return &err } defer foutBuf.Close() cmd.Start() } ``` 第三部分对文件进行相应的读入分析。根据不同的模式进行分页,得到程序所需的数据内容。该部分主要使用到的 API 为: - `ReadString` 读入数据直到遇到第一个终止符号,并将读到的数据赋值到对应的 string 变量中 ```go pageCtr := 1 if sa.pageType == "l" { lineCtr := 0 for { line, err := finBuf.ReadString('\n') if err != nil { break } lineCtr++ if lineCtr > sa.pageLen { pageCtr++ lineCtr = 1 } if pageCtr >= sa.startPage && pageCtr <= sa.endPage { fmt.Fprintf(foutBuf, "%s", line) } } } else { for { page, err := finBuf.ReadString('\f') if err != nil { break } if pageCtr >= sa.startPage && pageCtr <= sa.endPage { fmt.Fprintf(foutBuf, "%s", page) } pageCtr++ } } return nil } ``` ## 4. 使用示例(功能测试) 1. `$ selpg -s1 -e1 input_file` 该命令将把"input_file"的第 1 页写至标准输出(也就是屏幕),因为这里没有重定向或管道。 ![1-1](images/1-1.png) ![1-2](images/1-2.png) 2. `$ selpg -s1 -e1 < input_file` 该命令与示例 1 所做的工作相同,但在本例中,selpg 读取标准输入,而标准输入已被 shell/内核重定向为来自"input_file"而不是显式命名的文件名参数。输入的第 1 页被写至屏幕。 ![2-1](images/2-1.png) ![2-2](images/2-2.png) 3. `$ other_command | selpg -s10 -e20` "other_command" 的标准输出被 shell/内核重定向至 selpg 的标准输入。将第 10 页到第 20 页写至 selpg 的标准输出(屏幕)。 ![3-1](images/3-1.png) ![3-2](images/3-2.png) 4. `$ selpg -s10 -e20 input_file >output_file` selpg 将第 10 页到第 20 页写至标准输出;标准输出被 shell/内核重定向至"output_file"。 ![4-1](images/4-1.png) ![4-2](images/4-2.png) 5. `$ selpg -s10 -e20 input_file 2>error_file` selpg 将第 10 页到第 20 页写至标准输出(屏幕);所有的错误消息被 shell/内核重定向至 "error_file"。请注意:在"2"和">"之间不能有空格;这是 shell 语法的一部分(请参阅"man bash"或"man sh")。 ![5](images/5.png) 6. `$ selpg -s10 -e20 input_file >output_file 2>error_file` selpg 将第 10 页到第 20 页写至标准输出,标准输出被重定向至"output_file";selpg 写至标准错误的所有内容都被重定向至"error_file"。当"input_file"很大时可使用这种调用;您不会想坐在那里等着 selpg 完成工作,并且您希望对输出和错误都进行保存。 ![6-1](images/6-1.png) ![6-2](images/6-2.png) 7. `$ selpg -s10 -e20 input_file >output_file 2>/dev/null` selpg 将第 10 页到第 20 页写至标准输出,标准输出被重定向至"output_file";selpg 写至标准错误的所有内容都被重定向至 /dev/null(空设备),这意味着错误消息被丢弃了。设备文件 /dev/null 废弃所有写至它的输出,当从该设备文件读取时,会立即返回 EOF。 ![7](images/7.png) 8. `$ selpg -s10 -e20 input_file >/dev/null` selpg 将第 10 页到第 20 页写至标准输出,标准输出被丢弃;错误消息在屏幕出现。这可作为测试 selpg 的用途,此时您也许只想(对一些测试情况)检查错误消息,而不想看到正常输出。 ![8](images/8.png) 9. `$ selpg -s10 -e20 input_file | other_command` selpg 的标准输出透明地被 shell/内核重定向,成为"other_command"的标准输入,第 10 页到第 20 页被写至该标准输入。"other_command"的示例可以是 lp,它使输出在系统缺省打印机上打印。"other_command"的示例也可以 wc,它会显示选定范围的页中包含的行数、字数和字符数。"other_command"可以是任何其它能从其标准输入读取的命令。错误消息仍在屏幕显示。 ![9](images/9.png) 10. `$ selpg -s10 -e20 input_file 2>error_file | other_command` 与上面的示例 9 相似,只有一点不同:错误消息被写至"error_file"。 ![10](images/10.png) 在以上涉及标准输出或标准错误重定向的任一示例中,用">>"替代">"将把输出或错误数据附加在目标文件后面,而不是覆盖目标文件(当目标文件存在时)或创建目标文件(当目标文件不存在时)。 以下所有的示例也都可以(有一个例外)结合上面显示的重定向或管道命令。我没有将这些特性添加到下面的示例,因为我认为它们在上面示例中的出现次数已经足够多了。例外情况是您不能在任何包含"-dDestination"选项的 selpg 调用中使用输出重定向或管道命令。实际上,您仍然可以对标准错误使用重定向或管道命令,但不能对标准输出使用,因为没有任何标准输出 — 正在内部使用 popen() 函数由管道将它输送至 lp 进程。 1. `$ selpg -s10 -e20 -l66 input_file` 该命令将页长设置为 66 行,这样 selpg 就可以把输入当作被定界为该长度的页那样处理。第 10 页到第 20 页被写至 selpg 的标准输出(屏幕)。 ![11](images/11.png) 2. `$ selpg -s10 -e20 -f input_file` 假定页由换页符定界。第 10 页到第 20 页被写至 selpg 的标准输出(屏幕)。 ![12](images/12.png) 3. `$ selpg -s10 -e20 -dlp1 input_file` 第 10 页到第 20 页由管道输送至命令"lp -dlp1",该命令将使输出在打印机 lp1 上打印。 ![13-1](images/13-1.png) ![13-2](images/13-2.png) 4. `$ selpg -s10 -e20 input_file > output_file 2>error_file &` 最后一个示例将演示 Linux shell 的另一特性: 该命令利用了 Linux 的一个强大特性,即:在"后台"运行进程的能力。在这个例子中发生的情况是:"进程标识"(pid)如 1234 将被显示,然后 shell 提示符几乎立刻会出现,使得您能向 shell 输入更多命令。同时,selpg 进程在后台运行,并且标准输出和标准错误都被重定向至文件。这样做的好处是您可以在 selpg 运行时继续做其它工作。 ![14](images/14.png)