Go 語言實戰:命令行程序(1)

2022-01-05 Go語言中文網

看到別人的好作品,像畫作、模型還是代碼,我們第一反應可能是感嘆結構複雜、技巧精湛,然後緊接著冒出一個想法:太難了,我做不到。

這往往是因為我們對相應的領域了解不夠,只看到複雜的結果,對如何通向目的地毫無概念。如果了解如何分解任務,到最簡單的步驟為止,還有從最簡單能看到反饋的雛形開始,逐步改善,普通人也能做出複雜的作品,最多時間比有天賦的人多花一些。

這一期開始,我們會花幾期的時間,逐步地嘗試改善一個命令行程序。

本文目錄


準備

我們從一個命令行程序開始。

命令行界面

命令行界面(CLI,Command Line Interface),又叫字符用戶界面 (CUI,Character User Interface),區別於圖形用戶界面(GUI,Graphic User Interface)。GUI 就像在國外不用學當地語言,有一份我們能看懂的、甚至有圖片的菜單供選擇,指一下就有結果,無需語言交流。而在 CLI 裡,人和機器通過標準輸入輸出(可以簡單理解為打字)進行交互:你必須通過命令準確地告訴系統你想幹嘛,然後系統執行並把結果打在屏幕上。你必須得先知道系統接受什麼命令。如果輸入命令以外的東西,系統只能告訴你『我聽不懂』。

GUI 當然要比傻傻等著你打字的黑窗友好,也是日常使用的主流。但在方便之餘,你無法提出菜單以外的細緻要求,執行菜單上沒有顯示的操作。同一個動作(如點一下菜單第二項),結果高度依賴當前的菜單顯示,你必須等菜單顯示完成才能接著『交互』,而不能一口氣直接下達想要的一系列動作指令。這就好像你明明想好了要幹什麼,卻不能說話,非要等下屬慢慢翻到那頁菜單。相比之下,CLI 可以一口氣接受一系列精確的指令。所以即使在圖形界面的系統中,命令行也沒有被遺棄,甚至還在不斷地加強。

從開發的角度說,圖形界面開發的門檻反而比較高,命令行程序因為沒有圖形界面,減少了很多工作量,可以把精力集中在核心的功能上,適合練手。

函數籤名和函數類型

別誤會,我沒有打算詳細介紹函數。

在實際的開發中,自然會接觸函數的用法。在寫出優雅強大的函數之前,我們可以先調用標準庫或第三方包裡別人寫好的函數,並從中學習。

要正確使用函數,我們需要查看文檔,看懂函數籤名和注釋,有些還會有例子,就像看說明書。如果想學習實現,則要進一步看源碼。

以經常用的 fmt.Println 為例。

可以在 https://pkg.go.dev 上搜索 fmt 包,找到 Println 這個函數,內容是這樣的:

func Println(a ...interface{}) (n int, err error)

Println formats using the default formats for its operands and writes to standard output. Spaces are always added between operands and a newline is appended. It returns the number of bytes written and any write error encountered.

Example Code:

package main

import (
 "fmt"
)

func main() {
 const name, age = "Kim", 22
 fmt.Println(name, "is", age, "years old.")

 // It is conventional not to worry about any
 // error returned by Println.

}

Kim is 22 years old.

註:pkg.go.dev 從 19 年起取代了 godoc.org 成為了 Go 語言的文檔網站,上面不僅可以搜索到標準庫,所有被緩存了的第三方 module 也都能搜到。(go module 默認會先向 proxy 請求第三方包,proxy 發現尚未緩存就會先獲取緩存再返回。換言之,幾乎所有公開的有人請求的 module 都可以搜到。)

函數籤名、注釋、例子還有例子的輸出,是標準的文檔構成。

註:文檔裡的實際上是函數原型(prototype),但要確認的主要是籤名信息。

Println 不是討論重點,注釋和例子就不展開了。主要介紹一下函數籤名。

函數籤名(function signature)定義了函數的輸入(參數列表)和輸出(返回值列表)。它本質上是函數開發者和調用者之間的契約,包含函數的關鍵信息:參數的類型、個數和順序,返回值的類型、個數和順序。調用者通過它了解調用時要提供什麼,以及在調用完成後會得到什麼。(當然,按籤名調用還是有可能出現邏輯上的錯誤,開發者需要在注釋中進一步說明注意事項。)函數名、參數名、返回值名可以出現在籤名裡也可以省略,命名信息對籤名來說並不重要

最簡單的函數籤名是這樣的:(參數列表) (返回值列表)。籤名信息前面加上 func 關鍵字就成了函數類型(type)字面量,再加上函數名就成了函數原型(prototype),再加上函數體 {/*代碼實現*/} 就變成完整的函數。實際使用中,雖然函數籤名是關鍵,但命名能幫助我們區分函數、參數和返回值,還能從命名中推測用途,所以很多函數籤名其實是帶著命名的類型字面量或函數原型的形式。

// 單純的籤名信息
(int, int) (int, error)
// 函數類型字面量,但不細究的話,也可以叫函數籤名
func (int, int) (int, error)
// 函數原型,有時這個也叫函數籤名
func Count(int, int) (int, error)
// 完整的函數
func Count(start int, end int) (int, error) {
    // 有引用到的參數需要命名。一般函數沒有多餘的參數,所以參數都是命名的。
    count := 0
    for i := start; i < end; i++ {
        // ...
        count++
    }
    return count, nil
}

Go 裡面函數也是一種類型,籤名相同的函數就被認為是同一個類型。下面的代碼是合法的:

var f func(a int, b int) (c int) = func(x int, y int) (z int) { return x + y }
var f2 func(int, int) int = f

實際上,真正的籤名信息是 (int, int) int ,func 關鍵字和各種命名 a, b, c, x, y, z 都可以省略,有沒有命名、命名是否相同,不影響它們是同一個類型。(函數的參數名 x 和 y 在函數體沒有引用時也可以省略,例如 func(int, int) int {return 0} 。)

無論是哪一種形式,關注的要點都是參數列表和返回值列表。知道以下幾點規則,你就可以讀懂函數籤名:

跟其它 C 家族語言返回值類型在前、沒有關鍵字不同(C 語言:int myFunc(int a)),Go 以關鍵字開頭,函數名和參數列表在返回值列表前面。

(順序:關鍵字 - 函數名 - 參數列表 - 返回值列表。)

因為允許多返回值,參數和返回值都是列表。其中參數列表外面的括號不能省略,即使參數列表為空;而返回值列表如果為空或者只有一個匿名返回值,可以省略括號。

(區分參數還是返回值:第一個括號裡的是參數,右邊剩下的是返回值。Go 沒有類似 void 的關鍵字,沒有返回值時,返回值部分直接為空。)

連續多個相同類型的命名參數或返回值,可以一起聲明,(a, b, c int, s string) 等價於 (a int, b int, c int, s string) 。(要看懂這種寫法,但不推薦這樣寫。這樣寫在增減參數和調整參數順序時,容易出錯,會把類型張冠李戴。)

可變參數

Go 支持可變參數(variadic arguments)。具體聲明形式是,在類型前面加上三個句點 ... ,表示可以接受 0 到多個該類型的參數。例如 Println 的 (a ...interface{}) 表示可以接受任意個空接口類型的值作為參數。

註:空接口方法列表為空,意味著任意類型都滿足空接口,任意類型都可以作為實參傳遞給函數。相當於 Java 裡用 Object 作為參數類型。

調用時:

// 可以沒有參數,只輸出一個換行符
fmt.Println()
// 可以 3 個 int 型字面量
fmt.Println(1, 2, 3)
// 不同類型混合著來
// 允許不同類型是因為用了空接口類型,數量可以為任意個才是可變參數的關鍵
fmt.Println("院子裡有", 1, "棵棗樹,另", 1, "棵也是棗樹?", true)

函數最多只能聲明一個 可變參數 ,而且只能是最後一個參數(可變參數放中間,後面的參數就很難對得上號了)。

可變參數實際上是一個語法糖,傳給可變參數的一系列值被打包成了一個對應類型的切片,供函數內部引用。Println 的參數在函數內部相當於 (a []interface{}) 。不過今天不討論函數的實現,只討論調用。

既然可變參數實際上變成了一個切片,如果調用方剛好有一個同類型切片 s,可以直接拿來當實參嗎?

不能。可變參數調用時要求傳入的是一個一個對應類型的值,傳相應的切片類型不符。難道只能 (s[0], s[1], s[2]) 這樣一個個地傳參嗎?如果切片有一百個元素呢.

這時有另外一個語法糖,在實參後面同樣加上 ... ,就會產生類似 Python 解包(unpack)的效果。當然,只是像,實際上是告訴函數這是一個切片,可以直接複製給可變參數,並沒有解包再打包的操作。

... 的位置很容易搞混:可變參數(形參)聲明放在前面,給實參『解包』放在後面。

開發

鋪墊了一些背景知識,下面開始動手。

需求背景

準備這期內容時,我在讀者中間徵集過日常找不到軟體工具的小需求,作為實戰項目的選題。最後也沒找到合適選題,這期先用我曾經遇到的需求做例子。後續大家想到什麼需求,還是可以留言,也許就用在下一個項目上。

這個需求很簡單:排序。源自我第一份工作時,開發之餘偶爾幫項目做版本管理。VCS 用的 P4,所有手機型號的項目,在同一個代碼庫的同一棵源碼樹上,通過分支和特性開關區分型號。優點是,跨型號共性問題,只要在源頭上修改一次,隨著代碼定期集成到各分支,都會修復,避免重複勞動和遺漏型號。缺點是,針對某些型號的修改,如果隔離沒做好,會影響無關的型號。

送測和正式發布的編譯,為避免引入不確定的提交,採用基線(base)+ 追加提交的方式。會選擇一個經過驗證的提交作為 base,到 base 為止的所有修改都參與編譯;base 之後的提交,往往都不太確定,遇到必須包含的提交,就要添加到追加提交裡,編譯時會將這些提交當作補丁按順序應用到代碼上(相當於臨時 cherrypick)。但這個順序,不是提交順序,而是填寫順序。假如提交 A 修復問題 1 同時引起問題 2,之後提交 B 對同一個地方做修改修復問題 2。那麼填寫時必須按照 A 到 B 的順序,否則 B 的修改會被 A 覆蓋,問題 2 將仍然存在。

每次編譯之前,在內網公布 base,模塊負責人根據 base 回復需要追加的提交,然後管理員就得到了一堆提交號。P4 的提交號是自增序列號,所以只要將它們升序排列,就能保證先後順序。

交流大概是這樣的:

管理員:本次編譯,base 為 123456
驅動組:133297 修復兼容性問題
電源管理組:167834 修正功耗計算
圖形組:123467 調整刷新緩存
系統組:145683 修改進程管理策略
.

管理員經過整理,得到了 123467,133297,145683,167834 作為編譯的參數。提交少的時候,人工處理一下就完了。但如果因為某些原因無法提高 base,後續的補丁卻源源不斷,提交可能會積累到過百,這時人工確認就又累又容易出錯了。於是我當時就寫了一個命令行工具來處理這麼一個簡單的需求。

為什麼不直接用 Excel 呢?首先是 Office 啟動慢,特別在已經打開一系列開發工具的前提下;其次需要將提交錄入,排序之後還得想辦法導出,又增加了工作量。Linux 底下倒是有一個 sort 命令,但是當時我在用 Windows。對於這種簡單的需求,自己開發不僅工作量不大,遇到需求有變化時還很容易按需調整。

當時還沒接觸 Go,用的 C 開發。現在當然要用 Go 來練習。

註:考慮到篇幅有限,下面只展示代碼的關鍵部分,需要補足剩餘的代碼成分才能編譯運行。

關於如何初始化一個項目,以及項目的基本結構,請參考第一期的內容。如果還有問題,歡迎在留言區或者加入交流群提問。

小目標

一開始不要設太高的期待,先讓程序可以跑起來,這樣才能基於運行的反饋,一步步改善程序。為此先把需求簡化到最簡:從標準輸入獲取提交號,排好序之後,輸出到標準輸出,用英文逗號隔開(格式要方便後續使用,P4 要求的格式就是用逗號隔開的提交號,你也可以根據自己的需要調整)。

假定把這個程序叫 gosort ,那麼用起來大概是這樣的:

> gosort 133297 167834 123467 145683
123467,133297,145683,167834

這個程度很簡單,調用標準庫就可以做到。

命令行參數

gosort 133297 167834 123467 145683 這一串,對命令行環境來說,是(帶參數的)命令,會根據開頭的命令,傳遞給名為 gosort 的程序;而對 gosort 程序來說,這一串則是命令行參數。注意,命令(程序名)也是參數的一部分。有些程序實現了多種功能,對外連結到不同文件名,會根據傳進來的程序名稱不同,執行不同的動作。最典型的例子是 busybox ,它以單一可執行文件,提供了一個包含超過兩百個命令的 unix 工具集合,被稱為嵌入式 Linux 的瑞士軍刀。

不像其它 C 家族語言,Go 的命令行參數不是作為 main 函數的參數傳遞的,而是通過 os 包的 Args 變量獲取。os 包初始化時會獲取參數並儲存在 Args 中,它是一個字符串切片 []string。前面介紹過查詢文檔的方法,想了解更多可以自行到 pkg.go.dev 查詢;標準庫源碼則在 Go 的安裝目錄的 src 目錄下,按包名儲存,另外大多數 IDE 都支持源碼的跟蹤跳轉(一般的操作,是對著 os.Args 按 Ctrl + 滑鼠左鍵)。

先讀取命令行參數然後直接輸出看看效果:

// 包聲明、import 語句已省略,請自行補充
func main() {
    fmt.Println(os.Args)
}

# 先編譯
> go build
# 後執行。程序名請替換成你自己的 module 名。Linux 下本地執行需要加 ./
> gosort 133297 167834 123467 145683
# 以下是輸出,我們先不要糾結方括號
[gosort 133297 167834 123467 145683]

# 當然我們也可以直接 go run
> go run main.go 133297 167834 123467 145683
# go run 本質上是在臨時目錄編譯後執行,所以輸出的程序名裡帶有臨時目錄信息
[C:\Users\Jayce\AppData\Local\Temp\go-build065892054\b001\exe\main.exe 133297 167834 123467 145683]

改善

這裡我們需要改善幾個問題:

在這個程序裡,程序名用不上,留在切片裡還會參與後續的排序。os.Args 是第三方包的包級變量,儘量不要直接在上面排序。雖然命令行參數在這個程序裡暫時沒有別的用處,但直接修改公共變量仍是一個壞習慣。方括號其實是輸出切片內容時的格式,最終結果不需要方括號,要想辦法去掉。

main 函數裡的代碼改進如下(這裡就不再執行,請自己執行,查看改動後的輸出):

func main() {
    // n 是除了程序名以外的參數數量
    // len() 是內置函數,獲取集合(這裡是切片)的大小
    n := len(os.Args) - 1

    // 創建一個大小為 n 的切片
    nums := make([]string, n)

    // copy() 也是內置函數,把除程序名以外的參數拷貝到新切片
    // [1:] 是從下標 1 開始重新切片,跳過下標 0(即程序名)
    // 重新切片返回的新切片,跟原切片指向同一個底層數組,修改會互相影響,重新切片後還是要拷貝
    copy(nums, os.Args[1:])

    // 把參數逐個輸出,其中前面的參數後面跟逗號,最後一個參數後面跟換行
    for i := 0; i < n-1; i++ {
        fmt.Print(nums[i], ",")
    }
    fmt.Println(nums[n-1])
}

排序

多快好省地實現排序算法,本身也是學問。但這次我們不研究這個,直接使用 sort 包。

自定義類型想要排序,需要實現 sort.Interface 接口的一系列方法;基本類型則預先實現了對應的函數。對於 string 類型的升序排序,sort 包給我們提供了 sort.Strings() 。

另外,前面最後的輸出代碼,實現起來還是比較麻煩,而且存在一個 bug。藉助字符串工具包裡的 strings.Join()  函數,可以先拼接成目標字符串,再一口氣輸出,既簡單又繞開了 bug:

// 這次不再詳細注釋,有疑問請習慣查文檔,或者參與討論
func main() {
    n := len(os.Args) - 1
    nums := make([]string, n)
    copy(nums, os.Args[1:])
    sort.Strings(nums)
    fmt.Println(strings.Join(nums, ","))
}

這時編譯之後再執行程序,效果如下:

> gosort 133297 167834 123467 145683
123467,133297,145683,167834

通過調用標準庫,5 行代碼實現了我們階段性的小目標。

下一期我們還是討論這個程序,面對需求的變化,如何改善程序去支持更複雜的功能。

思考題 sort.Strings(nums) 為什麼沒有返回值?字符串切片 nums 只是作為實參傳給了排序函數,按理說切片本身發生了拷貝,為什麼排序最後對 nums 生效了?

相關焦點

  • Go語言 | go version命令的高級用法
    go version 用法go這個命令工具,可以使用help子命令查看任意命令的幫助。$ go help versionusage: go version [-m] [-v] [file ...]Version prints the build information for Go executables.
  • Go 語言基礎入門教程 —— 第一個 Go 程序
    第一個 Go 程序選擇好了開發工具,接下來,就可以直接開始編寫第一個 Go 語言程序了,還是遵循程式語言的一貫傳統,我們從 Hello World 開始 Go 語言學習之旅。包是 Go 語言裡最基本的分發單位,也是工程管理中依賴關係的體現。要生成 Go 可執行程序,必須建立一個名字為 main 的包,並且在該包中包含一個叫 main() 的主函數,該函數是 Go 可執行程序的執行起點,這一點和 C 語言和 Java 語言很像,後續編譯 Go 項目程序的時候也要從包含 main 包的文件開始。Go 語言的 main() 函數不能帶參數,也不能定義返回值。
  • Golang命令行參數詳解
    Linux內核啟動可執行程序時,會為其分配單獨的虛擬內存空間,加載程序的代碼、數據都內存中,分配堆、棧,並將環境變量、命令行參數以及其他數據保存在棧內存。首先,看一看 Linux 系統如何將命令行參數傳遞給可執行程序。將「代碼清單-C命令行參數」中的代碼編譯之後,在可執行程序的入口(_start)處設置斷點,查看參數。
  • Go語言迎來全迸發時代,未來全面覆蓋程序開發領域丨解讀2015
    這些工具已經涵蓋了一個軟體的生命周期的方方面面,極大的方便了Go程序的開發者們。在1.4版本中,Go語言的標準工具集中加入了go generate。顧名思義,這是一個用於生成Go語言代碼的命令。有意思的是,這源於一個幾乎所有的電腦程式研發者們都有過的夢想——讓電腦程式自己編寫程序。
  • 部署Go語言程序的N種方式
    部署Go語言項目本文以部署 Go Web 程序為例,介紹了在 CentOS7 伺服器上部署 Go 語言程序的若干方法。編譯編譯可以通過以下命令或編寫 makefile 來操作。CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o .
  • Go每日一庫之 Cobra:著名的命令行庫
    本文是 Go語言中文網組織的 GCTT 翻譯,發布在 Go語言中文網公眾號,轉載請聯繫我們授權。在 2014 年 6 月的 Go 1.4 版本中,Russ Cox 提出了 限制使用內部包和命令的建議[1]。
  • Goland中使用Golang命令行工具
    File watcher是一個內置的GoLand工具,可讓您在更改或保存文件時自動運行命令行工具。GoLand自動保存您在文件中所做的更改。goimports使用goimports,您可以自動更新Go導入行(添加缺少的內容並刪除未引用的導入內容)。如果你沒有goimports包,那麼你可以打開命令行終端使用該命令下載: go get golang.org/x/tools/cmd/goimports.
  • Go命令行庫Cobra的使用
    安裝成功之後會生成一個名為cobra的可執行文件:hidden@hidden:~$ ls -al $GOPATH/bin | grep cobra-rwxr-xr-x 1 zhuzhonghua zhuzhonghua 11411056 Aug 12 15:48 cobra使用Cobra生成應用程式假設現在我們要開發一個基於CLI的命令程序
  • go語言學習項目篇-Mysql_Markdown(1)
    2.3按md格式生成分析完需求,開始尋找第一步如何使用go語言連接資料庫?首先我們學習如何使用go操控Mysql資料庫1.安裝測試1.1安裝驅動(go語言不內置連接資料庫的驅動,所以這裡我們得使用第三方驅動)go get github.com/go-sql-driver/mysql
  • C語言-淺談include命令行
    C語言學習者,時常會遇到一個問題:閃屏!但是如何調用system函數呢?~這就說明了,要調用每一類函數,必須在源程序命令行#include後加上對應函數庫的頭文件。~③淺談windows.h很多人會問除了stdlib.h為什麼windows.h
  • 五分鐘在Mac OS上安裝Go語言並破解Goland IDE跑出「Hello World」
    如果你還想抓住2019年的尾巴,學習一門新語言,現在還不算晚哦,Go語言是可以快速上手值得推薦的語言呢導讀,通過本文,您將可以了解到:Go創始人背景在Mac OS上安裝Go在MacOS上執行第一個HelloWorld程序Go語言開發工具Goland及破解用Goland運行HelloWorld如何讓
  • Go語言:1分鐘寫下第一個Go程序,並在終端裡以指令方式運行
    GOPATH變量不設置,不影響運行,它在Go語言安裝包默認安裝後有一個~/go的默認地址,但$GOPATH/bin必須添加到$PATH的路徑中。這是所有開發者自安裝的Go語言第三方類庫所生成的工具指令,在系統上能夠被查到的基礎,如果不設置,系統不知道去哪裡查找我們在終端裡隨意寫出的指令名稱。舉個例子,gin是一個Go語言編寫的為Go程序提供熱編譯功能的工具。
  • 一天增長几千星星的 Go 項目:GitHub 的(CLI)命令行工具
    Go 語言的應該了解,GitHub 的這個項目在 2019 年 10 月份就創建了。簡介 GitHub 官方發布命令行工具(Beta)測試版,官方表示,GitHub CLI 提供了一種簡單無縫的 GitHub 使用方法。用戶可以在 macOS、Windows 和 Linux 上安裝 GitHub CLI,官方會依據使用者的意見反饋,在之後版本添加更多功能。
  • Easyocr - 3行代碼識別圖片中的任意語言文字
    這個模塊支持70多種語言的即用型OCR,包括中文,日文,韓文和泰文等。下面是使用這個模塊的實戰教程。開始之前,你要確保Python和pip已經成功安裝在電腦上,如果沒有,可以訪問這篇文章:超詳細Python安裝指南 進行安裝。
  • Go 語言應用之 template
    去看看《Go 語言原本》[1]。如果大家對歐神感興趣,想看他拍的 vlog,請在文章末尾留言,如果留言的人數很多,我就可以「挾留言以令歐神」^_^。大家給力點,可以先去留個言再看後面的內容。在講技術內容之前呢,再說一下加我好友的方式,很簡單,後臺回覆:加好友。
  • Go標準庫flag包的「小陷阱」
    flag包估計是很多gopher入門go語言的必經之路。flag使用起來十分簡單,功能也不差,常規的命令行程序的flag形式它都支持,比如下面這個示例程序:// flag_demo1.gopackage mainimport ( "flag" "fmt")var ( n = flag.Int("n", 1234, "help
  • 用 Swift 來寫命令行程序
    在上一個例子中,我們通過組合使用 popen 和 wget 命令來調用自然語言翻譯服務,來實現像 Google 翻譯那樣的翻譯功能。本文的程序會基於之前我們已經完成的工作來進行。但與之前每次執行都只能翻譯一句話所不同的是,這次我們要實現一個具備交互功能的 shell 程序,來翻譯在控制臺輸入的每一句話。
  • Go命令的PATH安全性
    今日Golang發布一個安全版本(Go 1.15.7 和Go 1.14.14),解決了涉及在不受信任目錄中進行PATH查找的問題CVE-2021-3115,相關的問題會導致執行go get命令時的遠程執行。關於該問題Golang官方博客進行了介紹,我們來學習下。
  • 小試牛刀,手把手帶你實現第一個Go程序
    上篇文章,我們已經搭建了Go語言環境,可以戳這裡:手把手帶你進行Golang環境配置,本次我們來安排一下如何在win平臺上輸出都一個Go程序。小試牛刀,第一個Go程序每學一門新語言,都是從Hello World開始的。
  • Go語言的學習筆記(第一章)
    計算機是不能理解高級語言的,更不能直接執行高級語言,它只能直接理解機器語言,所以使用任何高級語言編寫的程序若想被計算機運行,都必須將其轉換成計算機語言,也就是機器碼。而這種轉換的方式有兩種:1.編譯2.解釋由此高級語言也分為編譯型語言和解釋型語言。