一. CLI 命令行程序概述

CLI(Command Line Interface)实用程序是Linux下应用开发的基础。正确的编写命令行程序让应用与操作系统融为一体,通过shell或script使得应用获得最大的灵活性与开发效率。例如:

Linux提供了cat、ls、copy等命令与操作系统交互;
go语言提供一组实用程序完成从编码、编译、库管理、产品发布全过程支持;
容器服务如docker、k8s提供了大量实用程序支撑云服务的开发、部署、监控、访问等管理任务;
git、npm等也是大家比较熟悉的工具。

尽管操作系统与应用系统服务可视化、图形化,但在开发领域,CLI在编程、调试、运维、管理中提供了图形化程序不可替代的灵活性与效率。


二. 系统环境&项目介绍&开发准备

1.系统环境

操作系统:CentOS7
硬件信息:使用virtual box配置虚拟机(内存3G、磁盘30G)
编程语言:GO 1.15.2

2.项目介绍

本项目的开发主要基于IBM Developer社区的C语言程序(https://www.ibm.com/developerworks/cn/linux/shell/clutil/index.html),出于熟悉golang语言的目的,笔者主要的工作只是将其翻译为golang格式,其中还使用了部分库,如os和pflag,再次感谢原作者及开源代码工作者。

项目完成后的运行效果与CLI 命令行程序一致,一个简单的输出文本第一页20行的内容的例子如下:
在这里插入图片描述

3.开发准备

①首先下载上文的C语言源码(点击下载)
②安装并使用 pflag 替代 goflag 以满足 Unix 命令行规范,此处出于篇幅考虑,只在后面的函数介绍时给出部分使用教程,详细的pflag 使用教程可见【六. References. 1. Golang之使用Flag和Pflag】
③将C语言源码翻译为golang语言


三.具体程序设计及Golang代码实现 1.selpg的程序结构

selpg的程序结构非常简单,主要有以下组成:
①sp_args结构
②main函数
③process_args函数
④process_input函数
⑤usage函数

2.导入的库

主要要导入的库有:
①bufio:用于文件的读写
②io:用于文件读写、读环境变量
③pflag:用于解释命令行参数,替代 goflag 以满足 Unix 命令行规范

/*================================= includes ======================*/

package main

import (
	"bufio"
	"fmt"
	"io"
	"os"
	"os/exec"

	"github.com/spf13/pflag"
)
3.sp_args结构体

sp_args结构体是用于记录数据的结构体,分别记录着开始页码,结束页码,文件名,每页大小,页的类型和打印输出位置等信息。

/*================================= types =========================*/

type sp_args struct {
	start_page  int
	end_page    int
	in_filename string
	page_len    int  /* default value, can be overriden by "-l number" on command line */
	page_type   bool /* 'l' for lines-delimited, 'f' for form-feed-delimited */
	/* default is 'l' */
	print_dest string
}
4.全局变量

全局变量共有两个:
①progname是程序名,在输出错误信息时有用;
②用 INT_MAX 检查一个数是否为有效整数,由于golang没有预定义的INT_MAX,此处用别的方式来手动实现

/*================================= globals =======================*/

var progname string                /* program name, for error messages */
const INT_MAX = int(^uint(0) >> 1) //golang需要手动声明INT_MAX

4.main函数

main函数作为程序的入口,给出了整个程序的大概运行过程。
①首先进行sp_args变量和progname的初始化,其中主要的默认属性为开始页码和结束页码均为1,每页长度为20行,不可用用换页符换页
②然后调用process_args函数来处理输入时的各种参数错误
③最后才调用process_input函数来执行输入的参数。

/*================================= main()=== =====================*/

func main() {

	var sa sp_args
	sa.start_page = 1
	sa.end_page = 1
	sa.in_filename = ""
	sa.page_len = 20 //默认20行一页
	sa.page_type = false
	sa.print_dest = ""

	/* save name by which program is invoked, for error messages */
	progname = os.Args[0]

	process_args(len(os.Args), &sa)
	process_input(sa)
}
5.process_args函数

process_args函数用于处理输入时的各种参数错误。
①首先通过pflag绑定各参数和usage函数
②然后判断各种参数的错误即可,比如起始页码是负数,终止页码小于起始页码等情况,具体的错误情况在代码中已给出注释
③当发生错误,首先通过pflag.usage函数输出正确的指令参数格式来提醒用户,并通过os.Exit函数退出程序

/*================================= process_args() ================*/

func process_args(ac int, psa *sp_args) {
	//指令格式:selpg -sstart_page -eend_page [-lline | -f ] [-d dstFile] filename
	//使用pflag绑定各参数, psa初始化
	pflag.Usage = usage
	pflag.IntVarP(&psa.start_page, "start_page", "s", 1, "Start page")
	pflag.IntVarP(&psa.end_page, "end_page", "e", 1, "End page")
	pflag.IntVarP(&psa.page_len, "page_len", "l", 20, "Lines per page")
	pflag.BoolVarP(&psa.page_type, "page_type", "f", false, "Page type")
	pflag.StringVarP(&psa.print_dest, "dest", "d", "", "Destination")
	pflag.Parse()

	/* check the command-line arguments for validity */
	if ac < 3 { /* Not enough args, minimum command is "selpg -sstartpage -eend_page"  */
		fmt.Fprintf(os.Stderr, "%s: not enough arguments\n", progname)
		pflag.Usage()
		os.Exit(1)
	}

	/* handle 1st arg - start page */
	temp := os.Args[1]
	if temp[0:2] != "-s" { 
		fmt.Fprintf(os.Stderr, "%s: 1st arg should be -sstart_page\n", progname)
		pflag.Usage()
		os.Exit(2)
	}

	if psa.start_page < 1 || psa.start_page > (INT_MAX-1) {
		fmt.Fprintf(os.Stderr, "%s: invalid start page %d\n", progname, psa.start_page)
		pflag.Usage()
		os.Exit(3)
	}

	/* handle 2nd arg - end page */
	temp = os.Args[2]
	if temp[0:2] != "-e" {
		fmt.Fprintf(os.Stderr, "%s: 2nd arg should be -eend_page\n", progname)
		pflag.Usage()
		os.Exit(4)
	}

	if psa.end_page < 1 || psa.end_page > (INT_MAX-1) || psa.end_page < psa.start_page {
		fmt.Fprintf(os.Stderr, "%s: invalid end page %d\n", progname, psa.end_page)
		pflag.Usage()
		os.Exit(5)
	}

	/* now handle optional args */
	//使用pflag,selpg.c的while+switch可去掉
	if psa.page_len != 5 {
		if psa.page_len < 1 {
			fmt.Fprintf(os.Stderr, "%s: invalid page length %d\n", progname, psa.page_len)
			pflag.Usage()
			os.Exit(6)
		}
	}

	if pflag.NArg() > 0 { /* there is one more arg */
		psa.in_filename = pflag.Arg(0)
		/* check if file exists */
		file, err := os.Open(psa.in_filename)
		if err != nil {
			fmt.Fprintf(os.Stderr, "%s: input file \"%s\" does not exist\n", progname, psa.in_filename)
			os.Exit(7)
		}
		/* check if file is readable */
		file, err = os.OpenFile(psa.in_filename, os.O_RDONLY, 0666)
		if err != nil {
			if os.IsPermission(err) {
				fmt.Fprintf(os.Stderr, "%s: input file \"%s\" exists but cannot be read\n", progname, psa.in_filename)
				os.Exit(8)
			}
		}
		file.Close()
	}

}
6.process_args函数

process_input函数用于执行输入的参数,执行文件读写和输出到屏幕等操作。其中由于没有打印机,转而使用cat命令测试。

/*================================= process_input() ===============*/

func process_input(sa sp_args) {
	var fin *os.File        /* input stream */
	var fout io.WriteCloser /* output stream */
	var c byte              /* to read 1 char */
	var line string
	var line_ctr int /* line counter */
	var page_ctr int /* page counter */
	var err error
	cmd := &exec.Cmd{}

	/* set the input source */
	if sa.in_filename == "" {
		fin = os.Stdin
	} else {
		fin, err = os.Open(sa.in_filename)
		if err != nil {
			fmt.Fprintf(os.Stderr, "%s: could not open input file \"%s\"\n", progname, sa.in_filename)
			os.Exit(9)
		}
	}

	/* set the output destination */
	if sa.print_dest == "" {
		fout = os.Stdout
	} else {
		cmd = exec.Command("cat") //由于没有打印机,使用cat命令测试
		cmd.Stdout, err = os.OpenFile(sa.print_dest, os.O_WRONLY|os.O_TRUNC, 0600)
		if err != nil {
			fmt.Fprintf(os.Stderr, "%s: could not open output file \"%s\"\n", progname, sa.print_dest)
			os.Exit(10)
		}

		fout, err = cmd.StdinPipe()
		if err != nil {
			fmt.Fprintf(os.Stderr, "%s: could not open pipe to \"%s\"\n", progname, sa.print_dest)
			os.Exit(11)
		}
		cmd.Start()
	}

	/* begin one of two main loops based on page type */
	rd := bufio.NewReader(fin)
	if sa.page_type == false {
		line_ctr = 0
		page_ctr = 1
		for true {
			line, err = rd.ReadString('\n')
			if err != nil { /* error or EOF */
				break
			}
			line_ctr++
			if line_ctr > sa.page_len {
				page_ctr++
				line_ctr = 1
			}
			if page_ctr >= sa.start_page && page_ctr <= sa.end_page {
				fmt.Fprintf(fout, "%s", line)
			}
		}
	} else {
		page_ctr = 1
		for true {
			c, err = rd.ReadByte()
			if err != nil { /* error or EOF */
				break
			}
			if c == '\f' {
				page_ctr++
			}
			if page_ctr >= sa.start_page && page_ctr <= sa.end_page {
				fmt.Fprintf(fout, "%c", c)
			}
		}
		fmt.Print("\n") 
	}

	/* end main loop */
	if page_ctr < sa.start_page {
		fmt.Fprintf(os.Stderr, "%s: start_page (%d) greater than total pages (%d), no output written\n", progname, sa.start_page, page_ctr)
	} else if page_ctr < sa.end_page {
		fmt.Fprintf(os.Stderr, "%s: end_page (%d) greater than total pages (%d), less output than expected\n", progname, sa.end_page, page_ctr)
	}

	fin.Close()
	fout.Close()
	fmt.Fprintf(os.Stderr, "%s: done\n", progname)
}
7.usage函数

usage函数用于输出正确的指令参数格式。

/*================================= usage() =======================*/

func usage() {
	fmt.Fprintf(os.Stderr, "\nUSAGE: %s -sstart_page -eend_page [ -f | -llines_per_page ] [ -ddest ] [ in_filename ]\n", progname)
}



四.程序测试

1.功能测试

此处按照IBM的c语言程序的使用实例来进行功能测试。
首先在selpg目录下建立三个txt文件,分别为:
①in.txt, 用于输入的文本,内容如下(为方便演示,只有20行):
在这里插入图片描述

②out.txt, 保存输出的文本,内容初始为空
③error.txt,保存错误信息,内容初始为空

selpg -s1 -e1 in.txt

该命令将把“in.txt”的第 1 页写至标准输出(也就是屏幕),因为这里没有重定向或管道。

[henryhzy@localhost selpg]$ selpg -s1 -e1 in.txt
Hello world!
I am HenryHZY.
line 1
iine 2
line 3
line 4
line 5
line 6
iine 7
line 8
line 9
line 10
line 11
iine 12
line 13
line 14
line 15
line 16
iine 17
line 18
selpg: done
selpg -s1 -e1 < in.txt

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

[henryhzy@localhost selpg]$ selpg -s1 -e1 < in.txt
Hello world!
I am HenryHZY.
line 1
iine 2
line 3
line 4
line 5
line 6
iine 7
line 8
line 9
line 10
line 11
iine 12
line 13
line 14
line 15
line 16
iine 17
line 18
selpg: done
other_command | selpg -s1 -e1

“other_command”的标准输出被 shell/内核重定向至 selpg 的标准输入。将第 1页写至 selpg 的标准输出(屏幕)。

[henryhzy@localhost selpg]$ ls | selpg -s1 -e1
error.txt
in.txt
out.txt
selpg.go
selpg: done
selpg -s1 -e1 in.txt >out.txt

selpg 将第 1 页写至标准输出;标准输出被 shell/内核重定向至out.txt“”。
在这里插入图片描述

selpg -s20 -e20 in.txt 2>error.txt

selpg 将第 20 页至标准输出(屏幕);所有的错误消息被 shell/内核重定向至“error.txt”。请注意:在“2”和“>”之间不能有空格;这是 shell 语法的一部分(请参阅“man bash”或“man sh”)。
在这里插入图片描述

selpg -s1 -e1 in.txt >out.txt 2>error.txt

selpg 将第 1页写至标准输出,标准输出被重定向至“output_file”;selpg 写至标准错误的所有内容都被重定向至“error_file”。当“input_file”很大时可使用这种调用;您不会想坐在那里等着 selpg 完成工作,并且您希望对输出和错误都进行保存。
在这里插入图片描述

selpg -s20 -e20 in.txt >out.txt 2>/dev/null

selpg 将第 20 页写至标准输出,标准输出被重定向至“output_file”;selpg 写至标准错误的所有内容都被重定向至 /dev/null(空设备),这意味着错误消息被丢弃了。设备文件 /dev/null 废弃所有写至它的输出,当从该设备文件读取时,会立即返回 EOF。
此处本应有的的error信息被丢弃了。
在这里插入图片描述

selpg -s10 -e20 in.txt >/dev/null

selpg 将第 10 页到第 20 页写至标准输出,标准输出被丢弃;错误消息在屏幕出现。这可作为测试 selpg 的用途,此时您也许只想(对一些测试情况)检查错误消息,而不想看到正常输出。

[henryhzy@localhost selpg]$ selpg -s10 -e20 in.txt >/dev/null
selpg: start_page (10) greater than total pages (1), no output written
selpg: done
selpg -s10 -e20 input_file 2>error_file | other_command

selpg 的标准输出透明地被 shell/内核重定向,成为“other_command”的标准输入,第 1页被写至该标准输入。“other_command”的示例可以是 lp,它使输出在系统缺省打印机上打印。“other_command”的示例也可以 wc,它会显示选定范围的页中包含的行数、字数和字符数。“other_command”可以是任何其它能从其标准输入读取的命令。错误消息仍在屏幕显示。

[henryhzy@localhost selpg]$ selpg -s1 -e1 in.txt | ps
selpg: done
  PID TTY          TIME CMD
10209 pts/0    00:00:00 bash
10528 pts/0    00:00:00 ps
selpg -s10 -e20 input_file 2>error_file | other_command

与上面的示例 9 相似,只有一点不同:错误消息被写至“error_file”。
在这里插入图片描述

selpg -s1 -e1 -l10 in.txt

该命令将页长设置为 10 行,这样 selpg 就可以把输入当作被定界为该长度的页那样处理。文本的前10行被写至 selpg 的标准输出(屏幕)。

[henryhzy@localhost selpg]$ selpg -s1 -e1 -l10 in.txt
Hello world!
I am HenryHZY.
line 1
iine 2
line 3
line 4
line 5
line 6
iine 7
line 8
selpg: done
selpg -s1 -e1 -f in.txt

假定页由换页符定界。第 10页被写至 selpg 的标准输出(屏幕)。

[henryhzy@localhost selpg]$ selpg -s1 -e1 -f in.txt
Hello world!
I am HenryHZY.
line 1
iine 2
line 3
line 4
line 5
line 6
iine 7
line 8
line 9
line 10
line 11
iine 12
line 13
line 14
line 15
line 16
iine 17
line 18

selpg: done

selpg -s1 -e1 in.txt | cat -n

由于没有打印机,原测试的打印机输出改为cat输出。

[henryhzy@localhost selpg]$ selpg -s1 -e1 in.txt | cat -n
selpg: done
     1	Hello world!
     2	I am HenryHZY.
     3	line 1
     4	iine 2
     5	line 3
     6	line 4
     7	line 5
     8	line 6
     9	iine 7
    10	line 8
    11	line 9
    12	line 10
    13	line 11
    14	iine 12
    15	line 13
    16	line 14
    17	line 15
    18	line 16
    19	iine 17
    20	line 18

selpg -s10 -e20 in.txt > out.txt 2>error.txt &

该命令利用了 Linux 的一个强大特性,即:在“后台”运行进程的能力。在这个例子中发生的情况是:“进程标识”(pid)如 1234 将被显示,然后 shell 提示符几乎立刻会出现,使得您能向 shell 输入更多命令。同时,selpg 进程在后台运行,并且标准输出和标准错误都被重定向至文件。这样做的好处是您可以在 selpg 运行时继续做其它工作。
在这里插入图片描述

2.单元测试

根据查找的单元测试与功能测试的区别:

功能测试是站在用户的角度从外部测试应用查看效果是否达到
单元测试是站在程序员的角度从内部测试应用

既然已经测试过功能测试,那么在进行时单元测试,简单地以函数为单元测试调用情况即可。若是在结合输入输出正确与否,感觉有些多余了。:)

测试代码:

package main

import "testing"

func Test_usage(t *testing.T) {
	tes := []struct {
		name string
	}{
		{name: "Test_usage1"},
		{name: "Test_usage2"},
	}
	for _, tt := range tes {
		t.Run(tt.name, func(t *testing.T) {
			usage()
		})
	}
}

func Test_process_args(t *testing.T) {

	tests := []struct {
		name string
		len  int
		sa   sp_args
	}{}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			process_args(tt.len, &tt.sa)
		})
	}
}

func Test_process_input(t *testing.T) {
	tests := []struct {
		name string
		sa   sp_args
	}{}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			process_input(tt.sa)
		})
	}
}

func Test_main(t *testing.T) {
	tests := []struct {
		name string
	}{}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			main()
		})
	}
}

为了方便进行单元测试,此处使用vscode进行测试,直接点击相应函数上面的run test即可,非常方便~~
各个函数的测试截图如下:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述



五.完整代码

具体代码可见gitee仓库:gitee仓库



六. References