概述

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

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

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

基础知识

几乎所有语言都提供了完善的 CLI 实用程序支持工具。以下是一些入门文档(c 语言):

python开发CLI应用程序的相应入门文档:
Using Python to create UNIX command line tools

操作系统

按照实验要求,结合自己的电脑设备与系统等条件,使用VirtualBox下Ubuntu 20.04系统完成实验。虚拟机相关设置与上一次实验相同。

环境准备

虚拟机下的实验环境与上一次实验的相同,不需要额外的配置。

开发实践

使用Go开发CLI的优势

CLI或“命令行界面”是用户在命令行上与之交互的程序。Go已经成为CLI开发的一个非常流行的选择,因为它没有编译为静态二进制文件的部署依赖性。如果您编写了一个具有安装依赖关系的CLI,您就知道这有多重要。

os包的作用

使用os包可以通过os.Args数组获得传入开发的CLI程序的参数列表,如下面的代码所示:

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包的作用

一般而言,开发的CLI程序可能会接收许多符合POSIX标准或GNU标准的选项或参数,使得同一个程序可以通过设定命令行选项和参数方便快捷地提供调用者指定的功能。为此,不少CLI程序在自己程序主体中实现了命令解析器的功能。然而,对于一个功能比较丰富,带有多个选项和参数的CLI程序。解析这些选项和参数的工作可能就相当复杂,需要一定的编译原理中词法分析与语法分析等技术。如果这些工作都需要CLI程序来做,那么不仅容易出错,而且还会添加开发者很多负担。软件开发一般最好遵循“不要重复发明轮子”的原则,可以在尽量利用前人做出的功能强大的,健壮的软件包的基础上进一步开发自己新希望开发的功能。

flag包则实现了命令行参数的解析。使用flag包,则可以方便地解析出传入CLI程序的选项和参数。在此基础上,则可以方便地实现解析CLI选项和参数的功能。如下面的代码所示:

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())
}

其中,flag包支持的命令行参数类型有bool、int、int64、uint、uint64、floatfloat64、string、duration等。

flag.TypeVar的函数原型为 flag.TypeVar(Type指针, flag名, 默认值, 帮助信息)。

Type可以为上面flag包支持的命令行参数类型之一。

例如,上面代码flag.IntVar(&port, “p”, 8000, “specify port to use. defaults to 8000.”)实现了,读入-p指定的int参数到port变量中,默认值为8000,帮助信息为"specify port to use. defaults to 8000."。

在使用flag.TypeVar等函数指定了接收的参数类型后,可以使用flag.Parse()函数进行命令行传入选项和参数的具体解析。flag包支持的命令行选项格式有以下几种:

  1. -flag=xxx (使用等号与一个-符号)
  2. –flag=xxx (使用等号与两个-符号)
  3. -flag xxx (使用空格与一个-符号)
  4. –flag xxx (使用空格与两个-符号)

特别地,bool类型的参数必须使用等号的方式指定。

上述flag解析在第一个非flag参数(单个”-“不是flag参数)之前停止,或者在终止符之后停止。

除此之外,还可以使用flag.Args()返回命令行参数后的其他参数,返回[]string类型。使用flag.NArg()可以获得命令行选项后的其他参数个数。使用flag.NFlag()可以获得使用的命令行选项个数。

根据实验要求,需要使用 pflag 替代 goflag 以满足 Unix 命令行规范。下面的代码中都将使用pflag包。

创建项目

根据go的工作空间目录结构,由于现在考虑开发一个自己的selpg包,因此下文的工作目录默认在"$GOPATH/src/gitee.com/alphabstc/selpg"下(alphabstc为我的gitee和github的用户id)。

首先在Bash下执行命令:

go mod init gitee.com/alphabstc/selpg

创建项目
之后可以看到selpg目录下新出现了一个名为go.mod的文件,查看其内容如下:
go.mod
如之前所述,由于需要引用用户的pflag包,根据课程文档中的教程,需要声明对下面pflag包的依赖。如下图所示:
在这里插入图片描述

同时,需要在Bash中输入以下的命令以在本地获取pflag包:

go get github.com/spf13/pflag

这样就完成了selpg项目的初步初始化工作。

注意到,由于proxy.golang.org可能无法访问,因此需要在Bash下设置如下的环境变量:

export GOPROXY=https://goproxy.io
export GO111MODULE=on

这样可能才可以正常运行和测试编写的代码。

数据结构

本实验没有用到特殊的复杂的数据结构,使用的主要数据结构为存储命令行选项和参数的变量,如下所示:

var (
	start         int
	end           int
	pageType      bool
	lengthPerPage int
	inputFile     string
	outputFile    string
)

其中,start表示开始页的编号,end表示结束页的编号,pageType表示分页的类型,为真则表示使用分页符’\f’来表示一页的结束,否则使用一页的最大行数来判断一页的结束。inputFile为输入文件的文件名,outputFile为输出文件的文件名。

注意,上面这些变量都只需要在selpg包的内部用到,不需要被其他的包引用,因此这些变量的首字母都声明为小写。

展示用法

当用户输入的选项或参数不合法时,需要展示selpg这一CLI程序的正确用法,类似教程中selpg.c的代码,编写展示用法的函数如下:

func showUsage() {//show the usage of selpg
	fmt.Fprintln(os.Stderr, "Usage: selpg -sNumber -eNumber [ options ] [ inputFile ]")
	fmt.Fprintln(os.Stderr, "Example: selpg -s10 -e20 ...")
}

参数解析

如之前所述,可以使用pflag方便快捷地进行命令行参数解析和处理,可以根据实验要求编写参数解析的代码如下(pflag包中的函数参数类似于flag包中的,可以参考之前对flag包中主要函数的原型说明和用法):

	pflag.IntVarP(&start, "start", "s", -1, "Start page ID")
	pflag.IntVarP(&end, "end", "e", -1, "End page ID")
	pflag.IntVarP(&lengthPerPage, "lengthPerPage", "l", 72, "the count of lines per page")
	pageTypePtr := pflag.BoolP("pageType", "f", false, "use '\f' to divide page")
	pflag.StringVarP(&outputFile, "destination", "d", "", "Output destination file")
	pflag.Parse()
	pageType = *pageTypePtr
	if pflag.NArg() >= 1 {
		inputFile = pflag.Arg(0)
	} else {
		inputFile = ""
	}

以上完成了从命令行接收选项和参数。

效验参数合法性

读取上面的参数之后,类似selpg.c中的代码,还需要效验参数是否合法。及时发现用户输入的参数不合法并给出提示,可以增加自己编写代码的健壮性,而且对于用户及时纠正错误和继续正常使用软件更加友好。编写效验代码如下:

	if start == -1 {
		return "The start page number is required."
	}
	if end == -1 {
		return "The end page number is required."
	}
	if start <= 0 {
		return "The start page number should be greater than 0."
	}
	if end <= 0 {
		return "The end page number should be greater than 0."
	}
	if start > end {
		return "The end page number should be greater than the start page number."
	}
	return ""

上面的代码在发现用户输入参数不合法时,会及时返回出错信息。如果用户输入的参数均符合要求,那么就会返回"",说明选项和参数都符合要求。

选择指定页内容功能

现在实现选择出指定范围页内容的核心功能,类似于selpg.c中的process_input函数,编写go语言中的process_input函数如下。总的来说,根据用户传入的选项为-f还是-l,选择使用分页符作为一页结束的标志,或者使用行数超过指定书目作为一页结束的标志。对代码的具体分析详见代码注释:

func process_input(inputReader *bufio.Reader, outputWriter *bufio.Writer) {
	if pageType {//-f
		page := 1
		for {
			b, err := inputReader.ReadByte()// read a char
			if err == io.EOF {//the end of file/ error
				break
			}
			if b == '\f' {//the end of a page
				page += 1
			}
			if page >= start && page <= end {//should in the range
				outputWriter.WriteByte(b)
			}
		}
	} else {//-l
		lines := 0
		page := 1
		scanner := bufio.NewScanner(inputReader)//get all lines
		for scanner.Scan() {
			lines += 1
			if lines > lengthPerPage {//the count of lines above limit number
				page += 1
				lines = 1
			}
			if page >= start && page <= end {//should in the range
				outputWriter.Write(scanner.Bytes())//output a line
				outputWriter.WriteString("\n")
			}
		}
	}
}

主函数

现在实现selpg的主函数,类似于selpg.c中的主函数,编写go语言中的main函数如下。总的来说,下面的函数首先调用之前定义的parseArgs函数,解析出具体的选项和参数,如果发生错误则输出错误信息并以错误状态返回。如果用户输入的选项和参数均合法,那么就根据用户选择是从stdin中读入还是从文件中读入进行打开文件和设置inputReader等操作;根据用户选择写入stdout还是写入文件进行打开输出文件和设置outputWriter等操作。对代码的具体分析详见代码注释:

func main() {
	var err = parseArgs()
	if err != "" {
		fmt.Fprintln(os.Stderr, err)//output the error info
		showUsage()//show the usage
		os.Exit(1)
	}

	var inputReader *bufio.Reader
	var outputWriter *bufio.Writer

	if inputFile == "" {//from the stdin
		inputReader = bufio.NewReader(os.Stdin)
	} else {
		iFile, err := os.Open(inputFile)//from the file
		if err != nil {
			fmt.Fprintln(os.Stderr, err.Error())//output the error info
		}
		inputReader = bufio.NewReader(iFile)//set the inputReader
		defer iFile.Close()
	}

	if outputFile == "" {//to the stdout
		outputWriter = bufio.NewWriter(os.Stdout)
	} else {
		cmd := exec.Command("lp", fmt.Sprintf("-d%s", outputFile))//to file
		oFile, err := cmd.StdinPipe()
		if err != nil {
			fmt.Fprintln(os.Stderr, err.Error())//output the error info
		}
		outputWriter = bufio.NewWriter(oFile)//set the outputWriter
		defer oFile.Close()
	}
	defer outputWriter.Flush()

	process_input(inputReader, outputWriter)//process from input to output
}

编写单元测试

类似于之前的开发,编写单元测试以测试函数的功能确实正常。该测试没有太多复杂的算法,仅仅将输出文件和标准输出进行逐字符比对,发现读入错误,行长度不一致,或者对应字符不同,则报告错误信息。由于文件夹中没有包含其他文件,因此选择使用main.go源代码自身作为输入文件进行测试。编写单元测试的代码如下,对代码的具体分析详见代码注释:

func TestMain(t *testing.T) {
	start = 1//set the arguments
	end = 1
	lengthPerPage = 60
	pageType = false

	file1, _ := os.Open("main.go")//use the main.go as the input file
	file2, _ := os.Create("selpg_test.out")//use this as the output file
	reader := bufio.NewReader(file1)//set the reader
	writer := bufio.NewWriter(file2)//set the writer
	process_input(reader, writer)//run the process_input
	writer.Flush()//flush the output
	file1.Close()//close files
	file2.Close()

	//check answer
	file1, _ = os.Open("main.go")//open the main.go
	file2, _ = os.Open("selpg_test.out")//open the selpg_test.out

	reader1 := bufio.NewReader(file1)//set the readers
	reader2 := bufio.NewReader(file2)
	scanner1 := bufio.NewScanner(reader1)//get the scanners
	scanner2 := bufio.NewScanner(reader2)

	for line := 1; line <= lengthPerPage; line++ {//compare teh answers
		if !scanner1.Scan() || !scanner2.Scan() {//error
			t.Errorf("scanner scan failed")
			return
		}
		buf1 := scanner1.Bytes()//read a line
		buf2 := scanner2.Bytes()//read a line
		if len(buf1) != len(buf2) {//the length of line do not match
			t.Errorf("Length of line %d is not match", line)
		}
		for i := 0; i < len(buf1); i++ {
			if buf1[i] != buf2[i] {
				t.Errorf("Char in line %d mismatch at position %d", line, i + 1)
			}
		}
	}

	file1.Close()//close files
	file2.Close()
	os.Remove("selpg_test.out")// delete the test file
}

单元测试结果

单元测试
可以看到通过了单元测试。

功能测试结果

功能测试1
可以看到确实打印出了main.go文件的前15行,符合要求。

功能测试2
可以看到确实打印出了main.go文件的第 ( 2 − 1 ) × 3 + 1 (2-1) \times 3+1 (21)×3+1 4 × 3 4 \times 3 4×3行,符合要求

功能测试3
输入参数不合法时,确实会报错,符合要求。
功能测试4
开始页超过结束页时也确实会报错,符合要求。
功能测试5
如果不使用文件输入,而是使用stdin输入,也符合要求。上图在使用stdin输入四行后,按下Ctrl+D发出文件结束信号。确实输出了第2~3页(因为限制一页只有一行,选出的也就是第2到3行),符合要求。

功能测试6如果使用-f选项,也符合要求。

在这里插入图片描述

换一个输入文件,也符合要求。

以上的功能测试验证了实现的selpg命令行程序功能基本正确。

项目链接

https://gitee.com/alphabstc/service-computing-selpg

Logo

K8S/Kubernetes社区为您提供最前沿的新闻资讯和知识内容

更多推荐