基於Golang的CLI 命令列程式開發

Henry_HZY發表於2020-10-11




一. 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.單元測試

2.功能測試

此處按照IBM的c語言程式的使用例項來進行功能測試。
首先在selpg目錄下建立三個txt檔案,分別為:
①in.txt, 用於輸入的文字,內容如下(為方便演示,只有20行):
在這裡插入圖片描述

②out.txt, 儲存輸出的文字,內容初始為空
③error.txt,儲存錯誤資訊,內容初始為空

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

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

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

(4)selpg -s1 -e1 in.txt >out.txt

selpg 將第 1 頁寫至標準輸出;標準輸出被 shell/核心重定向至out.txt“”。
在這裡插入圖片描述

(5)selpg -s20 -e20 in.txt 2>error.txt

selpg 將第 20 頁至標準輸出(螢幕);所有的錯誤訊息被 shell/核心重定向至“error.txt”。請注意:在“2”和“>”之間不能有空格;這是 shell 語法的一部分(請參閱“man bash”或“man sh”)。
在這裡插入圖片描述

(6)selpg -s1 -e1 in.txt >out.txt 2>error.txt

selpg 將第 1頁寫至標準輸出,標準輸出被重定向至“output_file”;selpg 寫至標準錯誤的所有內容都被重定向至“error_file”。當“input_file”很大時可使用這種呼叫;您不會想坐在那裡等著 selpg 完成工作,並且您希望對輸出和錯誤都進行儲存。
在這裡插入圖片描述

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

selpg 將第 20 頁寫至標準輸出,標準輸出被重定向至“output_file”;selpg 寫至標準錯誤的所有內容都被重定向至 /dev/null(空裝置),這意味著錯誤訊息被丟棄了。裝置檔案 /dev/null 廢棄所有寫至它的輸出,當從該裝置檔案讀取時,會立即返回 EOF。
此處本應有的的error資訊被丟棄了。
在這裡插入圖片描述

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

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

(10)selpg -s10 -e20 input_file 2>error_file | other_command

與上面的示例 9 相似,只有一點不同:錯誤訊息被寫至“error_file”。
在這裡插入圖片描述

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

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

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

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

該命令利用了 Linux 的一個強大特性,即:在“後臺”執行程式的能力。在這個例子中發生的情況是:“程式標識”(pid)如 1234 將被顯示,然後 shell 提示符幾乎立刻會出現,使得您能向 shell 輸入更多命令。同時,selpg 程式在後臺執行,並且標準輸出和標準錯誤都被重定向至檔案。這樣做的好處是您可以在 selpg 執行時繼續做其它工作。
在這裡插入圖片描述



五.總結

具體程式碼可見gitee



六. References

  1. Golang之使用Flag和Pflag
  2. 開發 Linux 命令列實用程式
  3. Package os

相關文章