Go 命令行工具初體驗:使用標準包開發

最初是在社群活動時接觸到 Golang,前陣子換工作後,新的產品團隊也是使用 Golang 來開發產品,在接觸新語言時覺得有些地方很有意思,好像能看到某種程式語言的變遷,或者說開發方向之類?跟常見的物件導向語言不同,Golang 不強調物件,而且帶有一些函式編程的特色,如果說 C++ 是替 C 補上物件導向的環節,那 Golang 更像是 C 語言的現代版。

本篇會簡單介紹如何使用 Golang 來開發一個簡單的命令行工具。我們可以假設一個微服務開發的情境,開發者需要頻繁在開發環境中啟動或關閉微服務,這時它會需要一個工具,能依照需求啟動各個微服務,通常在 Linux,我們會使用 shell script 來做這件事;如果是 Windows,則會使用 power shell 或 batch file;更正式的生產環境,可能會採用容器調度工具。Golang 由於具有跨平台的特性,也可以用在這個情境中。

Install Golang

開發前,當然要先安裝囉,對於 Windows 的使用者,會建議使用 chocolatey 來安裝

choco install golang -y

安裝好後確認版本

go version

得到

go version go1.14 windows/amd64

由於 Golang 是發展快速的語言,對版本要特別注意,可能前個版本的功能或環境,到下個版本就不同了。

然後,建議開發者的專案目錄可以長這樣

project
├── cmd  # main applications for this project.
|   └── main.go
├── pkg  # code that's ok to use by external applications
|   ├── module 1
|   |   └── module.go
|   └── module 2
└── README.md

cmd 用來放主要的應用程式,pkg 用來放相關的 lib,更細部的 layout 可以參考相關連結。因為我們的程式不會很大,這邊先用到 cmd 就可以了。

Hello World

所有語言的入門款就是 Hello, World,在 cmd/main.go 下加入程式碼,印出第一行文字

package main

import "fmt"

func main() {
    fmt.Println("Hello, world")
}

package main 是這個 go file 所屬的 module,對於所有的 go file 而言,都會有專屬的模組,方便進行引用;import 則是引用其他的模組,fmt 是 format 的縮寫,是 Go 的標準庫,用來做一些格式化的輸入輸出,可以在相關的網站看到說明

Package fmt implements formatted I/O with functions analogous to C’s printf and scanf. The format ‘verbs’ are derived from C’s but are simpler.

main 是 Go 的保留關鍵字,只要是 func main,就會是程式的入口,概念跟 C 語言的 int main() 相同。

當敲好程式後,可以直接用 go run 來編譯並執行

go run cmd/main.go

將 command-line tool 整合到 language 中,這點就很有現代語言特色,讓開發者更專注心力於開發上。當然,如果只是想要 build 應用程式,也可以使用

go build -o micro-cli.exe ./cmd

執行完後可以看到專案目錄多了一個 micro-cli.exe,執行可以得到

D:\git\golang-introduction>micro-cli.exe
Hello, world

Exec System Command

接著,我們需要使用 Golang 來執行外部的程式,引入 os/exec 這個模組

func main() {
    fmt.Println("Hello, world")
    noteCmd := exec.Command("notepad")
    noteCmd.Start()
}

這樣就能用 Golang 來打開記事本了。

如果除了啟動程式,還需要重新導向該程式的標準輸出到現在的視窗,可以怎麼做?這邊先準備一個文字檔,裡面放要輸出的內容

# README

this is readme file

在 Windows 下,可以使用

type README.md

來輸出檔案內容。我們將 go file 改成

func main() {
    fmt.Println("Hello, world")
    noteCmd := exec.Command("cmd", "/c", "type README.md")
    buf := make([]byte, 1024)
    stdout, _ := noteCmd.StdoutPipe()
    noteCmd.Start()
    stdout.Read(buf)
    os.Stdout.Write(buf)
}

先建立一個 byte 的動態儲存陣列(Go 的專門用語叫 slice),大小是 1024,再將 noteCmd 的標準輸出建立 pipeline,連接到建好的陣列中。如此 noteCmd 的輸出就會像水管,源源不絕進到 buf,我們再由 buf 中取值,輸出到 micro-cli.exe 的標準輸出,完成串接。

Parse Arguments

因為命令行工具需要對應到不同的情境,比如說,有些時候希望輸出 A 檔案的內容,有些時候希望輸出 B 檔案的內容,因此最好有個 option 可以讓人做切換。Golang 的標準庫自帶 argument parser,名稱是 flag,說明可以看,用法是

var (
    help     bool
    filename string
)

func init() {
    flag.BoolVar(&help, "h", false, "this is help")
    flag.StringVar(&filename, "r", "", "select your file")
    flag.Usage = usage
}

func main() {
    flag.Parse()
    if help {
    flag.Usage()
    return
    }
    fmt.Println("Hello, world")
    noteCmd := exec.Command("cmd", "/c", "type README.md")
    buf := make([]byte, 1024)
    stdout, _ := noteCmd.StdoutPipe()
    noteCmd.Start()
    stdout.Read(buf)
    os.Stdout.Write(buf)
}

func usage() {
    fmt.Println("Usage: micro-cli [-h] [-r filename]")
    flag.PrintDefaults()
}

先看第一部分

var (
    help     bool
    filename string
)

func init() {
    flag.BoolVar(&help, "h", false, "this is help")
    flag.StringVar(&filename, "r", "", "select your file")
    flag.Usage = usage
}

用 var 建立兩個全域變數 help 跟 filename,用來儲存 flag 的值,接著在 init 設定 flag。func init 跟 func main 同樣是保留字,當程式進入時,會先執行 init 的內容,之後才進行 main,這用在一些初始化設定很方便。

這邊做了三個初始化設定:(1) 看 h 這個選項是否存在,如果存在,賦值給 help,預設是 false,最後的文字是說明;(2) 看 r 這個選項是否存在,如果存在,賦值給 filename;(3) 將 Usage 這個函數指給 flag.Usage。

再來看第二部分

func main() {
    flag.Parse()
    if help {
    flag.Usage()
    return
    }
    ...
}

func usage() {
    fmt.Println("Usage: micro-cli [-h] [-r filename]")
    flag.PrintDefaults()
}

使用 flag.Parse 來解析選項,並實際賦值給前面設定好的變數;使用後,就能運用 help 跟 filename 了,這邊定義,當使用者用了 h 參數,就印出使用說明。使用說明看函式 usage,會先印出使用方法,再用 PrintDefaults 顯示細部設定。

來看看實際執行結果,先看 help

go run cmd/main.go -h

Usage: micro-cli [-h] [-r filename]
    -h this is help
    -r string
       select your file

簡單將使用說明與程式結合起來。

接著,修改程式,來讀讀看不同的檔案

func main() {
    flag.Parse()
    if help {
    flag.Usage()
    return
    }
    fmt.Println("Hello, world")
    noteCmd := exec.Command("cmd", "/c", "type "+filename)
    buf := make([]byte, 1024)
    stdout, _ := noteCmd.StdoutPipe()
    noteCmd.Start()
    n, _ := stdout.Read(buf)
    os.Stdout.Write(buf[:n])
}

執行

go run cmd/main.go -r file1
Hello, world
This is file 1

go run cmd/main.go -r file2
Hello, world
This is file 2

只要後面帶不同的 filename,就能讀到不同的檔案了

Read Config

因為每次要讀檔案,都要重新再輸入一次 option,對某些情境實在有點麻煩,想想,如果只有一兩個 option 就算了,假設現在 option 有 10 個,每次啟動程式都要輸入,很容易出現 typo,最好的辦法是將不常更改的 option 放在 config file,使用 config 來設定。

先在專案結構中建立一個 configs 資料夾,用來放設定檔,設定檔格式可以使用 json,但不限制,這邊用 json 格式相對單純而且我比較熟

project
├── cmd  # main applications for this project.
|   └── main.go
├── configs
|   └── config.json
├── pkg  # code that's ok to use by external applications
|   ├── module 1
|   |   └── module.go
|   └── module 2
└── README.md

內容是

{
    "filename": "file1"
}

為讀取 config,要先建立一個對應 config 結構的 struct,好讓程式知道該如何將 config 翻譯成物件

type config struct {
    Filename string `json:"filename"`
}

包在反引號 ` 中的文字是 Go 的 tag,它的功用是讓編譯器知道這個 struct 可以對應到 json,在這個例子中,struct config 的 field Filename 對應到 config file 的 filename 欄位。

修改主程式來讀取設定檔

func main() {
    data, _ := ioutil.ReadFile("configs/config.json")
    var fileConfig config
    json.Unmarshal(data, &fileConfig)
    fmt.Println("Hello, world")
    noteCmd := exec.Command("cmd", "/c", "type "+fileConfig.Filename)
    buf := make([]byte, 1024)
    stdout, _ := noteCmd.StdoutPipe()
    noteCmd.Start()
    n, _ := stdout.Read(buf)
    os.Stdout.Write(buf[:n])
}

使用 ioutil 來讀取檔案,將讀取到的 byte 資訊用 json.Unmarshal 反序列化,轉成人眼能看懂的結構,或者講更明白,賦值給 fileConfig 這個變數。接著,就能使用 fileConfig 內的 Filename 來讀取檔案了。

觀察執行結果

go run cmd/main.go
Hello, world
This is file 1

好的,不用每次都帶 option 了。

小結

用簡單的命令行工具,來當 Golang 的入門熱身,可以看到 Golang 跟 C 語言些相同的地方,例如它們都是靜態語言,可讀性跟可維護性較腳本語言更好,適合開發大型程式。但是 Golang 相對於 C,有幾項優點

  • 支援多重回傳值,有效解決 C 語言函式輸入輸出語意模糊的問題
  • 標準庫更強大,例如 flags 或是 ioutil,讓開發者能更專注於開發
  • 支援垃圾回收,同樣也讓開發者更專注於應用
  • 跨平台,這對當前的應用環境很重要,你絕對不希望換個作業系統要重寫一次程式碼
  • 工具齊全,有時候有點太齊全了,例如強制性的語法靜態檢查
  • 編譯速度快如閃電

作為 Google 力推的程式語言,Golang 可以挖掘的地方還有很多,像是它最重要的賣點 Goroutine,很適合開發高併發程式;它的精簡語法也適合開發微服務。我們可以想像它是因應雲端世代而產生的新工具。

Reference

Read more

Weekly Issue 第 21 期:JetBrains 發表 2025 Go 生態系調查

最近在讀 Tony Fadell 的 "Build",作者曾經參與過 iPhone 的開發,各種經驗談讓人嘆為觀止,例如這段:「如果故事有某個部分銜接不上,那麼產品本身也會有某個地方行不通…這便是為什麼最後 iPhone 的表面是玻璃,而不是塑膠,以及為什麼 iPhone 沒有硬體鍵盤。」 好在哪呢?好在如果能掌握這個觀念,就能知道如何「閱讀」產品,看見一個產品,就像閱讀一則故事一樣,知道它的抑揚頓挫,知道它想表現的東西。我相信每個經歷過產品開發的人,看這本書都會很有感覺。   🗞️ 熱門新聞 The Go Ecosystem in 2025: Key Trends in Frameworks, Tools, and Developer Practices JetBrains 前陣子公布 Go 生態系的調查結果。

By Ken Chen

Weekly Issue 第 20 期:AI 泡沫的遺產

2000 年的 .com 泡沫雖然造成嚴重的經濟問題,但也給後續的網路世代留下豐富的遺產。我們現在使用的網路基礎建設,很多是因為泡沫的原因,才能一次性投資到位。而當下經歷的 AI 浪潮,在時間過去後,又會給我們留下什麼遺產呢? 🗞️ 熱門新聞 The Benefits of Bubbles 我看 Ben Thompson 的文章通常會有兩種感受,負面是他太囉唆了,把簡單的觀念講得太長(儘管容易懂),而正面是他的觀點一向很有創造性。 這篇也是,前陣子看到有篇談 AI 泡沫後,什麼都不會留下,因為 GPU 很快會隨著時間折舊掉。我持保留態度,我認為重點不僅是 GPU(正如我認為 .com 泡沫的重點不是 CPU),還有其他的東西,至於是什麼,我沒想到。 BT 認為是晶圓製造與電力,It's amazing,

By Ken Chen

Weekly Issue 第 19 期:Coursera 的預覽模式宣告 MOOC 終結

我有時會上課程網站買課,特別是國外的網站,有些課程內容品質高,而且還能無價體驗,我常常在想這在商業上怎麼行得通。Coursera 最近推出預覽功能,某方面來說,也是在宣告長期要往付費走。 網路最大的特點是開放,因為開放,我們看到不可思議的成長,也因為開放,我們有時會很惋惜理想的落幕。 🗞️ 熱門新聞 The Day MOOCs Truly Died: Coursera's Preview Mode Kills Free Learning 很有趣的一篇新聞:Coursera 的預覽模式給了 MOOC 最後一擊。 我對 Coursera 的商業模式不熟,看起來它之前是靠證書與服務營利。很難想像線上課程能用免費支撐這麼久,這幾乎是公益了,將內容鎖在付費牆後比較像可理解的商業行為。 讓我困惑的是,這些年 Coursera 是如何獲利?以及,當時投資人對它的想像是什麼? The PSF has withdrawn

By Ken Chen

Weekly Issue 第 18 期:OpenAI 發布 AI 瀏覽器 Atlas

OpenAI 最近發布 AI 瀏覽器,加上稍早的 Sora 2,在技術圈中引起一些討論。 我認為 OpenAI 嘗試將模型領域的優勢帶到應用面,但這也讓它顯得更像是一家營利公司,而非研究單位(雖然現在沒人會把 OpenAI 當成研究單位了)。 🗞️ 熱門新聞 Dane Stuckey (OpenAI CISO) on prompt injection risks for ChatGPT Atlas Simon Willison 聊了他對 OpenAI Altas 的看法,主要是資安方面。 幾個點:1) 提示詞注入問題依然存在,而且還沒有好解法;2) OpenAI 設計了登出模式與監視模式,讓使用者更容易意識到安全性。 在我看來第二點很重要,好設計應該要避免使用者犯錯,如果 AI 瀏覽器可以在登出狀態下執行,能避免掉很多麻煩的狀況,當然這意味著沒辦法自動購物。

By Ken Chen