SC-hw2-CLI实用程序开发之selpg

[Toc]

CLI实用程序开发之selpg


1. 准备知识

1.1 CLI实用程序

Command-Line Interface,简称CLI,是在图形用户界面得到普及之前使用得最为广泛的用户界面。随着互联网的发展,由于偏好图形界面的非专业人员参与,虽然让CLI使用比例有所下降,但CLI的简洁、灵活、高效的特性始终无法被替代,仍然一直是服务器开发(编程、调试、运维、管理等)的首选方式。因此,CLI实用程序是Linux下应用开发的基础,学好如何编写CLI实用工具便是Linux开发的基本功了。

1.2 标准IO重定向

本篇博客涉及较多关于IO重定向的内容,主要有以下几个准则。

  • 输入
    • command:运行某命令,如需标准输入,默认为终端输入(ctrl+d结束输入)
    • command inputfile:运行某命令,并指定inputfile为需要读取的文件
    • command < inputfile:运行某命令,将inputfile作为标准输入
  • 输出
    • command:运行某命令,如有标准输出,默认为终端输出
    • command > outputfile:运行某命令,并指定outputfile作为标准输出文件,如有输出结果就输出到outputfile中
    • command 2> errorfile:运行某命令,并指定errorfile作为错误输出文件,如有运行错误就输出到errorfile中
  • 以上准则可以适当组合使用

1.3 管道pipe

管道主要是一种进程间通信的机制,在Linux下用符号 | 创建管道,主要用于完成进程间数据传递。因为每个进程各自有不同的用户地址空间,于是A进程的数据独立于B进程互相无法访问,所以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程A把数据从用户空间拷到内核缓冲,进程B再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信。

通过上面对管道的简单了解,可以发现管道在进程间也能起着输入输出重定向的作用。

  • other_command | command:将other_command的标准输出作为command的标准输入
  • command | other_command:将command的标准输出作为other_command的标注输入

1.4 相关包

  • bufio:实现了带缓冲的IO,可以理解为实现了一些io包下的低水平接口,进而提供了较高水平的IO能力。
  • flag:帮助实现命令行参数解析
  • fmt:提供各种print方法
  • io:顾名思义,提供一些有关IO的方法和可以被进一步实现的较低水平接口。
  • math:数学包无需多说。
  • os:提供了不依赖平台的操作系统函数及其接口。
  • os/exec:提供了一些执行外部命令的方法。它包装了os.StartProcess函数以便更容易的修正输入和输出,使用管道连接I/O,以及作其它的一些调整。

2. selpg简介

2.1 源代码地址

selpg

2.2 程序说明

selpg是一个命令行工具,允许用户指定该程序从标准输入或从作为命令行参数给出的文件名读取文本输入,并允许用户指定标准输出位置和输入输出的页范围。可以有选择性的查看或打印某个文档的部分内容,简单、高效、节约资源。

2.3 文件说明

文件名 用途
selpg.go 核心功能文件,selpg功能的所有实现,包括错误处理
selpg_input_generator.sh 批处理文件,用于生成selpg_input.txt
selpg_test.sh 批处理文件,用于批量测试selpg
selpg_input.txt 可选输入文件,内容为1~10000的递增数列,用于对selpg进行一系列测试
selpg_output.txt 可选输出文件,用于测试selpg的输出重定向
selpg_error.txt 可选错误输出文件,用于测试selpg的错误输出重定向

2.4 参数格式

选项 值类型 用途
-s Num int 指定起始页码为Num,必选项,默认为-1(不合法,强制要求重新指定)
-e Num int 指定结束页码为Num,必选项,默认为-1(不合法,强制要求重新指定)
filename filename string 指定输入文件,可选项,默认为空
-l Num int 指定每页的行数为Num,可选项,默认为每页72行,并且为默认解读模式
-f 无附加参数 bool 指定解读模式为,’\f’作为页分隔符,不可与-l同时使用
-d destination string 指定打印机设备地址

2.5 使用实例

以下为selpg_test.sh的内容,根据开发Linux命令行实用程序的测试要求编写,它也将作为后面的测试用例的批处理文件

echo "test 1 start"
selpg -s=1 -e=1 selpg_input.txt
echo "test 1 complete"
# 1. 该命令将把“selpg_input.txt”的第 1 页写至标准输出(也就是屏幕),因为这里没有重定向或管道。

echo "test 2 start"
selpg -s=1 -e=1 < selpg_input.txt
echo "test 2 complete"
# 2. 该命令与示例 1 所做的工作相同,但在本例中,selpg 读取标准输入,而标准输入已被 shell/内核重定向为来自“selpg_input.txt”而不是显式命名的文件名参数。输入的第 1 页被写至屏幕。

echo "test 3 start"
selpg -s=2 -e=5 selpg_input.txt | selpg -s=1 -e=2
echo "test 3 complete"
# 3. “selpg -s=2 -e=5”的标准输出被 shell/内核重定向至 selpg 的标准输入。将第 1 页到第 2 页写至 selpg 的标准输出(屏幕)。

echo "test 4 start"
selpg -s=10 -e=20 selpg_input.txt > selpg_output.txt
echo "test 4 complete"
# 4. selpg 将第 10 页到第 20 页写至标准输出;标准输出被 shell/内核重定向至“selpg_output.txt”。

echo "test 5 start"
selpg -s=10 -e=20 selpg_input.txt 2> selpg_error.txt
echo "test 5 complete"
# 5. selpg 将第 10 页到第 20 页写至标准输出(屏幕);所有的错误消息被 shell/内核重定向至“selpg_error.txt”。请注意:在“2”和“>”之间不能有空格;这是 shell 语法的一部分(请参阅“man bash”或“man sh”)。

echo "test 6 start"
selpg -s=10 -e=20 selpg_input.txt > selpg_output.txt 2> selpg_error.txt
echo "test 6 complete"
# 6. selpg 将第 10 页到第 20 页写至标准输出,标准输出被重定向至“selpg_output.txt”;selpg 写至标准错误的所有内容都被重定向至“selpg_error.txt”。当“selpg_input.txt”很大时可使用这种调用;您不会想坐在那里等着 selpg 完成工作,并且您希望对输出和错误都进行保存。

echo "test 7 start"
selpg -s=10 -e=20 selpg_input.txt > selpg_output.txt 2> /dev/null
echo "test 7 complete"
# 7. selpg 将第 10 页到第 20 页写至标准输出,标准输出被重定向至“selpg_output.txt”;selpg 写至标准错误的所有内容都被重定向至 /dev/null(空设备),这意味着错误消息被丢弃了。设备文件 /dev/null 废弃所有写至它的输出,当从该设备文件读取时,会立即返回 EOF。

echo "test 8 start"
selpg -s=10 -e=20 selpg_input.txt > /dev/null
echo "test 8 complete"
# 8. selpg 将第 10 页到第 20 页写至标准输出,标准输出被丢弃;错误消息在屏幕出现。这可作为测试 selpg 的用途,此时您也许只想(对一些测试情况)检查错误消息,而不想看到正常输出。

echo "test 9 start"
selpg -s=10 -e=20 selpg_input.txt | wc
echo "test 9 complete"
# 9. selpg 的标准输出透明地被 shell/内核重定向,成为“other_command”的标准输入,第 10 页到第 20 页被写至该标准输入。“other_command”的示例可以是 lp,它使输出在系统缺省打印机上打印。“other_command”的示例也可以 wc,它会显示选定范围的页中包含的行数、字数和字数。“other_command”可以是任何其它能从其标准输入读取的命令。错误消息仍在屏幕显示。

echo "test 10 start"
selpg -s=10 -e=20 selpg_input.txt 2> selpg_error.txt | lp
echo "test 10 complete"
# 10. 与上面的示例 9 相似,只有一点不同:错误消息被写至“selpg_error.txt”。

echo "test 11 start"
selpg -s=10 -e=20 -l=66 selpg_input.txt
echo "test 11 complete"
# 11. 该命令将页长设置为 66 行,这样 selpg 就可以把输入当作被定界为该长度的页那样处理。第 10 页到第 20 页被写至 selpg 的标准输出(屏幕)。

echo "test 12 start"
selpg -s=10 -e=20 -f selpg_input.txt 2> selpg_error.txt
echo "test 12 complete"
# 12. 假定页由换页符定界。第 10 页到第 20 页被写至 selpg 的标准输出(屏幕)。selpg 写至标准错误的所有内容都被重定向至“selpg_error.txt”。

3. selpg实现逻辑

3.1 定义参数结构体

为了提高程序的可读性和方便对多个参数的管理,当然也方便扩展,我们定义一个用于接受合法参数的结构体,结构体具有起始页码、结束页码、文件名、页长度、页分隔类型、打印地址等属性,其中起始页码和结束页码为必选项,其他为可选项。

type selpgArgs struct {
    start    int    // index of start page
    end      int    // index of end page
    filename string // name of input file
    length   int    // page length --- number of lines, default 72
    pageType bool   // -f: true, form-feed-delimited;    -l: false, lines-delimited --- default l
    des      string // device destination of printer
}

3.2 参数输入

这里主要需要用到flag包来做参数解析。flag.XxxVar() 可以根据提供的flag,绑定该flag所对应某个参数选项中的“值”到具体的参数结构体的某个成员变量,此外还可以设置默认值和提示。与flag.XxxVar() 类似但略有不同的 flag.Xxx() 则不直接绑定参数值和变量值,而是返回一个指针,这样便提供了一个更灵活的操作。

根据前面所说的flag包相关知识,这里我将-s -l -f -d的值直接绑定到变量,并赋予对应的默认值和提示。这里需要解释一下-l和-f互斥条件是否满足的判断逻辑。因为-f是不带附加参数的,所以-f绑定的成员变量为bool类型,有-f则为true,为true时就说明是-f有效。乍一看好像没问题,但是仅仅这样是无法处理-l和-f同时出现的情况。然后我们想到了同时判断-l的值还是不是默认值72以及-f是否出现,这样确实可以解决绝大部分情况,但唯独一种情况无法解决,即-l 72和-f同时出现的时候

因此,这里稍微做了一点改进就是,先将默认值设成非法的-1,然后调用flag.parse()后判断length是否有被设置过(非-1即为设置过),如果有被设置则认为用户需要-l模式,而此时如果又有-f,就判断为冲突。反之,如果length还是-1,则认为用户没有启用-l模式(即使用户手动将length设成了-1,但这是非法的),这种情况下如果有-f就不会冲突,如果没有-f,则认为用户还是需要-l模式,这时再将length长度设置回默认值72。

最后是调用flag.Args()获取未处理的参数,即没有flag标识的参数如文件名,在本例中只允许至多有一个文件名,故剩余未处理参数大于1时就是命令错误。

func inputArgs(args *selpgArgs) {
    flag.IntVar(&(args.start), "s", -1, "start page")
    flag.IntVar(&(args.end), "e", -1, "end page")
    flag.IntVar(&(args.length), "l", -1, "page length")
    flag.BoolVar(&(args.pageType), "f", false, "page type")
    flag.StringVar(&(args.des), "d", "", "print destination")s

    flag.Parse()

    // deal with -l and -f, especially -l 72 and -f
    if args.length != -1 && args.pageType == true {
        fmt.Fprintf(os.Stderr, "-l Num and -f cannot be used together\n")
        os.Exit(1)
    }
    if args.length == -1 {
        args.length = 72
    }

    others := flag.Args()
    if len(others) == 1 {
        args.filename = others[0]
    } else if len(others) == 0 {
        args.filename = ""
    } else {
        fmt.Fprintf(os.Stderr, "too many arguments\n")
        os.Exit(2)
    }
}

3.3 参数检查

这里检查了四种可以在运行前判断的错误情况:

  • 起止页码过小,必须从1开始
  • 起止页码过大,不能溢出
  • 起始页码大于结束页码
  • 页长度过小或溢出
    func checkArgs(args *selpgArgs) {
      if args.start < 1 || args.end < 1 {
          fmt.Fprintf(os.Stderr, "the number of start page and end page should start from 1\n")
          os.Exit(3)
      }
      if args.start > math.MaxInt32-1 || args.end > math.MaxInt32-1 {
          fmt.Fprintf(os.Stderr, "the number of start page and end page should not larger than MAX INT\n")
          os.Exit(4)
      }
      if args.start > args.end {
          fmt.Fprintf(os.Stderr, "the number of start page should not larger than the number of end page\n")
          os.Exit(5)
      }
      if args.length < 1 || args.length > math.MaxInt32-1 {
          fmt.Fprintf(os.Stderr, "invalid page length\n")
          os.Exit(6)
      }
    }
    

3.4 数据输入和处理

思路是先根据不同输入方式(标准输入或文件输入)获取reader,用于处理后面的读操作。然后再根据不同输出方式(标准输出或打印机)获取writer,用于处理后面的写操作。两个对象都获得的之后,再根据页分隔类型解析输入,将reader和writer传入对应的处理函数。
最后还需要留意地方是文件指针需要延迟关闭,以及一个错误判断——起止页码都不能大于输入所拥有的最大页码,这个最大页码是作为选项参数的处理函数返回获得的。

func processData(args *selpgArgs) {

    var reader *bufio.Reader
    if args.filename == "" {
        reader = bufio.NewReader(os.Stdin)
    } else {
        filein, err := os.Open(args.filename)
        defer filein.Close()
        if err != nil {
            fmt.Fprintf(os.Stderr, "failed to open file %s\n", args.filename)
            os.Exit(7)
        }
        reader = bufio.NewReader(filein)
    }

    pageCount := 1

    if args.des == "" {
        writer := bufio.NewWriter(os.Stdout)
        if args.pageType {
            pageCount = optionF(args, reader, writer)
        } else {
            pageCount = optionL(args, reader, writer)
        }
    } else {
        cmd := exec.Command("lp", "-d", args.des)
        writer, err := cmd.StdinPipe()
        defer writer.Close()
        if err != nil {
            fmt.Fprintf(os.Stderr, "stdin pipe error\n")
            os.Exit(8)
        }

        err = cmd.Start()
        if err != nil {
            fmt.Fprintf(os.Stderr, "command start error\n")
            os.Exit(9)
        }

        if args.pageType {
            pageCount = optionFD(args, reader, writer)
        } else {
            pageCount = optionLD(args, reader, writer)
        }

        err = cmd.Wait()
        if err != nil {
            fmt.Fprintf(os.Stderr, "command wait error\n")
            os.Exit(10)
        }
    }

    if pageCount < args.start {
        fmt.Fprintf(os.Stderr, "the number of start page does not exist\n")
        os.Exit(11)
    }
    if pageCount < args.end {
        fmt.Fprintf(os.Stderr, "the number of end page does not exist\n")
        os.Exit(12)
    }
}

3.5 选项操作

两者实现思路差不多,都是到达页分隔阈值的时候页计数器增加1(如果有行计数器则需要重置),边读变写,只不过固定行数的页分隔模式多了一个行数的计数器lineCount,以及’\f’的解析模式只能逐个字节读写。

  • 固定行数页分隔模式
func optionL(args *selpgArgs, reader *bufio.Reader, writer *bufio.Writer) (pageCount int) {
    pageCount = 1
    lineCount := 1
    for {
        line, err := reader.ReadBytes('\n')
        if err != nil {
            if err == io.EOF {
                break
            }
            fmt.Fprintf(os.Stderr, "failed to read bytes, %s\n", err.Error())
            os.Exit(15)
        }

        if pageCount >= args.start && pageCount <= args.end {
            _, writeErr := writer.Write(line)
            if writeErr != nil {
                fmt.Fprintf(os.Stderr, "failed to write bytes\n")
                os.Exit(16)
            }
            writer.Flush()
        }
        if lineCount >= args.length {
            pageCount++
            lineCount = 1
        } else {
            lineCount++
        }
    }
    return pageCount
}

func optionFD(args *selpgArgs, reader *bufio.Reader, writer io.WriteCloser) (pageCount int) {
    pageCount = 1
    for {
        ch, err := reader.ReadByte()
        if err != nil {
            if err == io.EOF {
                break
            }
            fmt.Fprintf(os.Stderr, "failed to read byte\n")
            os.Exit(17)
        }
        if pageCount >= args.start && pageCount <= args.end {
            _, writeErr := writer.Write([]byte{ch})
            if writeErr != nil {
                fmt.Fprintf(os.Stderr, "failed to write bytes\n")
                os.Exit(18)
            }
        }
        if ch == '\f' {
            pageCount++
        }
    }
    return pageCount
}
  • ‘\f’页分隔模式
func optionF(args *selpgArgs, reader *bufio.Reader, writer *bufio.Writer) (pageCount int) {
    pageCount = 1
    for {
        ch, err := reader.ReadByte()
        if err != nil {
            if err == io.EOF {
                break
            }
            fmt.Fprintf(os.Stderr, "failed to read byte\n")
            os.Exit(13)
        }
        if pageCount >= args.start && pageCount <= args.end {
            writeErr := writer.WriteByte(ch)
            if writeErr != nil {
                fmt.Fprintf(os.Stderr, "failed to write byte\n")
                os.Exit(14)
            }
            writer.Flush()
        }
        if ch == '\f' {
            pageCount++
        }
    }
    return pageCount
}

3.6 错误处理总览

错误码 原因
1 -l和-f冲突
2 参数过多
3 起止页码过小
4 起止页码溢出
5 起始页码大于结束页码
6 页长度过小或溢出
7 文件打开错误
8 管道创建错误
9 子进程启动错误
10 子进程等待错误
11 起始页码大于最大页码
12 结束页码大于最大页码
13 字节读错误
14 字节写错误
15 字节流读错误
16 字节流写错误

4. 测试

这里使用前面提到的测试用例,先对selpg进行go install以便全局使用,再将终端定位到批处理文件所在目录,键入sh selpg_test.sh以运行测试

4.1 test1: selpg -s=1 -e=1 selpg_input.txt

  • 含义:该命令将把“selpg_input.txt”的第 1 页写至标准输出(也就是屏幕),因为这里没有重定向或管道。
  • 结果分析:输出了1~72,符合预期结果

4.2 test2: selpg -s=1 -e=1 < selpg_input.txt

  • 含义:该命令与示例 1 所做的工作相同,但在本例中,selpg 读取标准输入,而标准输入已被 shell/内核重定向为来自“selpg_input.txt”而不是显式命名的文件名参数。输入的第 1 页被写至屏幕。
  • 结果分析:同样的,输出了1~72,符合预期结果

4.3 test3: selpg -s=2 -e=5 selpg_input.txt | selpg -s=1 -e=2

  • 含义:“selpg -s=2 -e=5”的标准输出被 shell/内核重定向至 selpg 的标准输入。将第 1 页到第 2 页写至 selpg 的标准输出(屏幕)。
  • 结果分析:这里将第一个selpg的标准输出,即selpg_input.txt的第 2 页到第 5 页作为下个selpg程序的标准输入,并输出该输入下的第 1 页到第 2 页,因此屏幕输出应为原selpg_input.txt的第 2 页到第 3 页,即73~216,符合预期结果。

4.4 test4~test8

  • selpg -s=10 -e=20 selpg_input.txt > selpg_output.txt
    selpg 将第 10 页到第 20 页写至标准输出;标准输出被 shell/内核重定向至“selpg_output.txt”。
  • selpg -s=10 -e=20 selpg_input.txt 2> selpg_error.txt
    selpg 将第 10 页到第 20 页写至标准输出(屏幕);所有的错误消息被 shell/内核重定向至“selpg_error.txt”。请注意:在“2”和“>”之间不能有空格;这是 shell 语法的一部分(请参阅“man bash”或“man sh”)。
  • selpg -s=10 -e=20 selpg_input.txt > selpg_output.txt 2> selpg_error.txt
    selpg 将第 10 页到第 20 页写至标准输出,标准输出被重定向至“selpg_output.txt”;selpg 写至标准错误的所有内容都被重定向至“selpg_error.txt”。当“selpg_input.txt”很大时可使用这种调用;您不会想坐在那里等着 selpg 完成工作,并且您希望对输出和错误都进行保存。
  • selpg -s=10 -e=20 selpg_input.txt > selpg_output.txt 2> /dev/null
    selpg 将第 10 页到第 20 页写至标准输出,标准输出被重定向至“selpg_output.txt”;selpg 写至标准错误的所有内容都被重定向至 /dev/null(空设备),这意味着错误消息被丢弃了。设备文件 /dev/null 废弃所有写至它的输出,当从该设备文件读取时,会立即返回 EOF。
  • selpg -s=10 -e=20 selpg_input.txt > /dev/null
    selpg 将第 10 页到第 20 页写至标准输出,标准输出被丢弃;错误消息在屏幕出现。这可作为测试 selpg 的用途,此时您也许只想(对一些测试情况)检查错误消息,而不想看到正常输出。
  • 结果分析
    • test4:在文件中输出649~1440,符合

    • test5:输出649~1440,符合

    • test6:可以看到 test 6 start 后就complete了,结果输出在文件中——同test4,错误输出被废弃,符合。
    • test7:可以看到 test 7 start 后就complete了,结果输出在文件中——同test4,错误输出被废弃,符合。
    • test8:可以看到 test 8 start 后就complete了,结果输出被废弃,符合。

4.5 test9~test10

command | other_command
selpg 的标准输出透明地被 shell/内核重定向,成为“other_command”的标准输入,第 10 页到第 20 页被写至该标准输入。“other_command”可以是任何其它能从其标准输入读取的命令。错误消息仍在屏幕显示。

  • selpg -s=10 -e=20 selpg_input.txt | wc
    “other_command”的示例也可以 wc,它会显示选定范围的页中包含的行数、字数和字数。
  • selpg -s=10 -e=20 selpg_input.txt 2> selpg_error.txt | lp
    “other_command”的示例可以是 lp,它使输出在系统缺省打印机上打印。与上面的 test 9 相似,只有一点不同:错误消息被写至“selpg_error.txt”。
  • 结果分析
    • test9:可以看到行数、单词数、字节数分别为793(11*72) 793(11*72) 3647
    • test10:因为没有连接到打印机,所以直接lp会报错

4.6 test11: selpg -s=10 -e=20 -l=66 selpg_input.txt

  • 含义:该命令将页长设置为 66 行,这样 selpg 就可以把输入当作被定界为该长度的页那样处理。第 10 页到第 20 页被写至 selpg 的标准输出(屏幕)。
  • 结果分析:每页66行的话,第 10 页到第 11 页就是 595 ~ 1320,符合

4.7 test12: selpg -s=10 -e=20 -f selpg_input.txt 2> selpg_error.txt

  • 含义:假定页由换页符定界。第 10 页到第 20 页被写至 selpg 的标准输出(屏幕)。selpg 写至标准错误的所有内容都被重定向至“selpg_error.txt”。
  • 结果分析:因为输入文件没有设置分页符,所以相当于只有 1 页,因此会发生起止页码大于最大页码的错误,并输出到文件中,符合