Prig:像 AWK,但用 Go 來寫「腳本」
摘要:本文介紹我打造的類 AWK 工具 Prig,它以 Go 作為腳本語言。文中會將 Prig 與 AWK 進行比較,深入探討 Prig 的運作原理,最後簡要介紹 Prig 內建的
Sort與SortMap(若使用 Go 1.18 以上版本,則會運用 Go 新的泛型功能)。
在最近的一則 Hacker News 留言中,我得知了 Charles Blake 打造的小型文字處理工具 rp,它有點像 AWK,只是改用 Nim 作為腳本語言。之所以可行,是因為 Nim 編譯器速度很快,而且 Nim 語言本身非常精簡,因此很適合用來寫一次性的腳本。
Go 具備上述兩個優點中的其中一項:建置速度很快。它不算是一種精簡的語言,因此不太適合寫一行指令就能搞定的腳本。但話說回來,也沒有糟到哪裡去:Prig 腳本的字元數大約是對應 AWK 版本的兩倍。
Charles 提到,像 Nim 和 Go 這樣編譯速度快的語言,非常適合用來打造這類工具。要實現它,程式碼幾乎簡單到不行:Prig 大約只有 200 行直白的 Go 程式碼,作用就是把使用者在命令列中輸入的「腳本」插入到 Go 原始碼範本中,編譯後再執行產生的執行檔。
在我的 Linux 電腦上,go build 編譯一個只使用標準函式庫的程式大約只需 200 毫秒,因此啟動時間非常合理——幾乎在你放開 Enter 鍵之前,Go 就已經編譯並執行完程式了。相較之下,Nim 讓 rp 在我系統上的啟動時間約為 1.4 秒(若使用 tcc 後端則為 0.8 秒)。
於是我決定用 Go 打造一個與 rp 對等的工具,最後完成了 Prig,顧名思義就是 Processing Records In Go(用 Go 處理記錄)。可以說 Prig 就像 AWK,只是比較挑剔——它對動態型別嗤之以鼻。
Prig 與 AWK 的比較
首先,如果你以前沒用過 AWK,這裡用一段話來介紹。AWK 是一個逐行處理輸入的語言直譯器。首先它會執行可選的 BEGIN 區塊。接著,它會對每一行輸入執行 pattern { action } 區塊:如果該行符合 pattern,就執行對應的 action。如果你沒有指定 pattern,則每一行都會符合;如果你沒有指定 action,預設動作就是印出該行。處理完所有輸入後,它會再執行可選的 END 區塊。我們很快就會看到一些範例。
那麼 Prig 看起來是什麼樣子,與 AWK 相比又如何呢?讓我們來看幾個範例腳本。假設你有一個包含 HTTP 請求行的記錄檔,內容如下:
$ cat logs.txt
GET /robots.txt HTTP/1.1
HEAD /README.md HTTP/1.1
GET /wp-admin/ HTTP/1.0你想要取出第二個欄位(相對 URL),並為每個請求印出你網站的完整 URL。以下是用 Prig 的做法:
$ prig 'Println("https://example.com" + S(2))' <logs.txt
https://example.com/robots.txt
https://example.com/README.md
https://example.com/wp-admin/Println 函式其實就是 Go 的 fmt.Println,只是為了效率改用帶緩衝的寫入器。它相當於 AWK 的 print 陳述式。S(i) 函式會以字串形式回傳第 i 個欄位,因此 S(2) 會回傳第二個欄位,就像 AWK 中的 $2 一樣。其餘的語意就只是一般的 Go 語意。
同樣的腳本若用 AWK 寫,則會像這樣:
$ awk '{ print "https://example.com" $2 }' <logs.txt
https://example.com/robots.txt
...只少了 3 個字元——目前表現還不錯。
接下來情況就開始對 Go 比較不利了。下面是一個同時以 Prig 和 AWK 版本呈現的腳本,它會將最後一個欄位加總,再除以記錄總數,以印出平均值:
$ cat average.txt
a b 400
c d 200
e f 200
g h 200
$ prig -b 's := 0.0' 's += F(NF())' -e 'Println(s / float64(NR()))' \
<average.txt
250
$ awk '{ s += $NF } END { print s / NR }' <average.txt
250這個腳本在 Prig 中是 60 個字元,在 AWK 中是 35 個字元——幾乎是兩倍長。Go(以及許多靜態型別語言)在這方面就處於劣勢。首先,我們必須把加總變數初始化為 0;在 AWK 中這是隱含完成的。
接著,相比於 AWK 較簡潔的 $NF,F(NF()) 多了一層括號。我很早就做了一個設計決定,讓所有 Prig 內建功能都成為函式——一開始我曾把 NF 和 NR 設計成變數,但全部改為函式後,程式碼就能依需求延遲分割欄位(有些簡單的腳本根本不需要)。
還有 float64() 的轉型,加上 NR() 和 Println() 的括號,使得 Prig 在某些情況下看起來有點像 Lisp。AWK 的 print s / NR 看起來肯定順眼多了!
第三個範例會在輸入行包含字串 GET 或 HEAD 時,將該行的第三個欄位乘以 1000(也就是以毫秒為單位)後印出。以下是 Prig 腳本與其對等的 AWK 版本:
$ cat millis.txt
1 GET 3.14159
2 HEAD 4.0
3 GET 1.0
$ prig 'if Match(`GET|HEAD`, S(0)) { Printf("%.0fms\n", F(3)*1000) }' \
<millis.txt
3142ms
4000ms
1000ms
$ awk '/GET|HEAD/ { printf "%.0fms\n", $3*1000 }' <millis.txt
3142ms
4000ms
1000msPrig 版本是 62 個字元,AWK 是 43 個字元——還算不差。這裡主要的差異在於 AWK 的 /regex/ 快捷寫法。我曾考慮在 Prig 中為此加入特例,但最終還是決定採用簡單、一致的 Go 風格,而不使用捷徑——因此在 Prig 中你必須明確地寫出 if 和 Match。
接下來是一個較長的範例。這個腳本會統計輸入中每個不重複單字的出現次數,然後依出現頻率由高到低印出單字及其次數。
$ cat words.txt
The foo barfs
foo the the the
$ prig -b 'freqs := map[string]int{}' \
'for i := 1; i <= NF(); i++ { freqs[strings.ToLower(S(i))]++ }' \
-e 'for _, f := range SortMap(freqs, ByValue, Reverse) { ' \
-e 'Println(f.K, f.V) }' \
<words.txt
the 4
foo 2
barfs 1
$ awk '{ for (i = 1; i <= NF; i++) freqs[tolower($i)]++ }
END { for (k in freqs) print k, freqs[k] | "sort -nr -k2,1" }' \
<words.txt這算是相當冗長,特別是在 Prig 中。首先我們初始化一個以單字為鍵的次數對應表(在 AWK 中這同樣是隱含的)。逐行處理的程式碼非常相似,只是在 Go 中因為要加上 strings 套件前綴而稍微囉嗦一點。
兩者的排序方式則大不相同:在 Prig 中,我定義了兩個排序函式,Sort 會接收一個包含整數、浮點數或字串的切片並回傳一個新的已排序切片,而 SortMap 則會回傳一個已排序的鍵值對切片(可選擇依值排序,也可選擇反向排序)。
POSIX AWK 並沒有內建排序功能(只有 Gawk 有),因此我們使用 AWK 的管線重新導向語法,將結果送給 sort 工具處理。我們其實也可以在 Prig 中用 shell 管線達成同樣效果,但這裡是為了展示 SortMap 函式的用法。
就大多數範例而言,AWK 無疑更清晰、更精簡——這也正是 Aho、Weinberger 和 Kernighan 當初為 AWK 設計新語言,而不是直接以 C(或類似語言)為基礎的原因。
另一方面,如果你很熟悉 Go 卻不熟悉 AWK,Prig 或許會對你有幫助。它的速度也明顯更快,因為 Go 會編譯成最佳化的機器碼,而 AWK 則是直譯執行。
以下是一些簡要的效能數據:以上述「統計單字頻率」的範例來說,Prig 的速度大約是 AWK(使用 Gawk)的三倍:Prig 處理一個 43MB 的檔案花了 1.1 秒,Gawk 則花了 3.1 秒。當然,此時我們其實是在比較 Go 與 Gawk(詳見這份效能比較)。
對於像大量數字相加這種受 CPU 限制的工作,Go 當然快得多,在這個範例中大約快了 20 倍(別忘了,在這 274 毫秒中有 200 毫秒是花在編譯上):
$ time gawk 'BEGIN { for (i=0; i<100000000; i++) s+=i; print s }'
4999999950000000
real 0m5.698s
...
$ time ./prig -b 's:=0; for i:=0; i<100000000; i++ { s+=i }; Println(s)'
4999999950000000
real 0m0.274s
...產生的 Go 程式
prig.go 本身的程式碼非常簡單:大約 200 行 Go 程式碼,其中約三分之一用來解析命令列參數。其餘部分只是把你的腳本放入 Go 原始碼範本中,執行 go build 進行編譯,然後執行產生的結果。
產生的 Go 程式的基本結構就如你所預期的:一些初始化程式碼、「begin」程式碼、一個使用 bufio.Scanner 逐行處理並執行「逐筆記錄」程式碼的迴圈,然後是「end」程式碼。另外還有 Prig 的內建函式。
你可以使用 prig -s 來檢視產生的 Go 原始碼。以下是上述「最後一個欄位的平均值」範例。並非完全逐字呈現;為了簡潔,我省略了未使用的部分:
$ prig -s -b 's := 0.0' 's += F(NF())' -e 'Println(s / float64(NR()))'
// ... package and import ...
var (
_output *bufio.Writer
_record string
_nr int
_fields []string
)
func main() {
_output = bufio.NewWriter(os.Stdout)
defer _output.Flush()
// begin
s := 0.0
_scanner := bufio.NewScanner(os.Stdin)
for _scanner.Scan() {
_record = _scanner.Text()
_nr++
_fields = nil
// per-record
s += F(NF())
}
if _scanner.Err() != nil {
_errorf("error reading stdin: %v", _scanner.Err())
}
// end
Println(s / float64(NR()))
}
func Println(args ...interface{}) {
_, err := fmt.Fprintln(_output, args...)
if err != nil {
_errorf("error writing output: %v", err)
}
}
func NR() int {
return _nr
}
func S(i int) string {
if i == 0 {
return _record
}
_ensureFields()
if i < 1 || i > len(_fields) {
return ""
}
return _fields[i-1]
}
func F(i int) float64 {
s := S(i)
f, _ := strconv.ParseFloat(s, 64)
return f
}
func _ensureFields() {
if _fields != nil {
return
}
_fields = strings.Fields(_record)
}
func NF() int {
_ensureFields()
return len(_fields)
}
// ... other Prig builtin functions ...請注意我如何將 Prig 內部名稱加上底線前綴,以避免與使用者定義的變數發生名稱衝突。雖然稱不上萬無一失,但對這個使用情境來說已經夠用了。
主迴圈基本上就是你手動用 Go 會寫出的樣子(雖然你可能會用區域變數而非全域變數)。不過,在典型的 Go 中,你可能會在主迴圈內將 F(NF()) 的邏輯直接展開並加上邊界檢查,像這樣:
if len(fields) > 0 {
last := fields[len(fields)-1]
f, err := strconv.ParseFloat(last, 64)
if err == nil {
s += f
}
}在這種情境下,讓 Prig 的 F() 幫你處理邊界檢查就很方便:s += F(NF()) 比那段長達 7 行的冗長程式碼簡單多了。Go 本身比較囉嗦,但加上幾個擺放得宜的輔助函式,就能變得非常精簡!
測試的樂趣
Prig 的測試(位於 prig_test.go)有點非典型,因為它們直接執行 prig 執行檔。有些開發者可能會對此不以為然,但這樣做讓 Prig 保持得更簡單。主要測試採用了「表格驅動測試」,這是 Go 測試的常用手法,你可以在其他地方讀到相關介紹。
由於每次測試都要經過 go build 的週期,每個測試都相對較慢(約 200 毫秒),但整個測試套件在我的系統上仍可在 7 到 8 秒內跑完。在 Windows 上則會慢很多,因為啟動新處理程序的成本要高得多。
不過,我做的一件還不錯的事是測試 prig --help 中顯示的範例。在撰寫 Prig 的使用說明訊息時,我在範例中不斷出現小小的打字錯誤,得一直複製貼上到終端機手動測試。
到某個時候我想,何不直接用 go test 自動測試這些範例呢?於是我把命令列範例抽取成獨立的字串,並在 TestExamples 中進行測試。我用一個臨時寫的小型解析器將每個範例命令列轉換為參數列表,然後對其呼叫 prig。
這有點類似 Go 優秀的可測試範例,只是對象從 Go 程式碼範例換成了命令列範例。
泛型的實驗
Prig 設計上較困難的部分之一是排序輔助函式,而且我到現在還不太確定自己是否設計對了。API 設計是一個讓程式設計看起來更像藝術而非科學的領域。
無論如何,我最後完成了兩個對於你可能會在 Prig 中使用的資料型別很有用的函式。以下是相當精簡的使用說明所寫的內容:
Sort[T int|float64|string](s []T) []T
// return new sorted slice; also Sort(s, Reverse) to sort descending
SortMap[T int|float64|string](m map[string]T) []KV[T]
// return sorted slice of key-value pairs
// also Sort(s[, Reverse][, ByValue]) to sort descending or by value在 Go 1.18(應該很快就會發布)中,這些函式運用了新的泛型功能,因此會進行型別檢查並回傳具體的切片型別。由於有可選參數,實際的 Go 函式簽章(以及 KV 型別)定義如下:
type _sortOption int
const (
Reverse _sortOption = iota
ByValue
)
func Sort[T int|float64|string](s []T, options ..._sortOption) []T {
// ... implementation ...
}
type KV[T int|float64|string] struct {
K string
V T
}
func SortMap[T int|float64|string](m map[string]T,
options ..._sortOption) []KV[T] {
// ... implementation ...
}Sort 相當單純:它接收一個切片並回傳一個新的已排序切片。預設是由小到大排序,若傳入 Reverse 選項則由大到小排序。我原本可以使用比 int、float64 和 string 更廣泛的型別集合,但這樣能讓 Prig 保持簡單(對於下面會看到的非泛型版本也是如此)。
SortMap 的 API 設計就稍微棘手一些。你無法直接對 Go 的 map 進行排序,因此需要將它轉換為鍵值對的切片:這就是 KV 型別。你可以依鍵排序(預設),若傳入 ByValue 選項則依值排序。
這一切運作得還不錯,而我那非常有限的 Go 1.18 泛型使用經驗也算是成功了。
但對於我們大多數仍在使用 1.18 之前、不支援泛型的 Go 版本的人來說呢?嗯,我讓同樣的 API 在沒有泛型的情況下也能運作……算是吧。非泛型版本使用 interface{},當然就沒有型別安全可言。而它之所以能在不需要型別轉換的情況下運作,僅僅是因為你通常只是把結果印出來;Print 系列函式本來就透過 interface{} 接受任何型別的參數。
因此,單字統計的範例程式碼在 Go 1.18(有泛型)和 Go 1.17(沒有泛型)上都能同樣正常運作:
for _, f := range SortMap(freqs, ByValue, Reverse) {
Println(f.K, f.V)
}Prig 會透過執行 go version 來偵測你安裝的 Go 版本,若是 1.17 或更早版本,就會使用非泛型版本。以下是非泛型版本的 Sort 和 SortMap 的定義方式:
func Sort(s interface{}, options ..._sortOption) []interface{} {
// ... implementation ...
}
type KV struct {
K string
V interface{}
}
func SortMap(m interface{}, options ..._sortOption) []KV {
// ... implementation ...
}很瘋狂嗎?大概是吧。大多數函式庫絕不可能用這種偷天換日的手法矇混過關,因為這些 API 對許多任務來說根本不相容。但對於 Prig 中的一項實驗而言,這樣做似乎效果還不錯。
結論:值得嗎?
我骨子裡就是個不折不扣的宅客,所以答案是肯定的,我很享受打造 Prig 的過程(大部分是在從基督城飛往法蘭克福的班機上完成的)。我喜歡它的程式碼如此簡單:大約 200 行 Go 程式碼、300 行範本程式碼……以及 400 行測試。所有繁重的工作都交給 Go 及其標準函式庫了!
我真的會用 Prig 嗎?有可能,如果我在處理大型檔案且需要比 AWK 更好的效能時。我也可能會用它來測試 Go 的小片段程式碼——例如,「Printf 的寬度又是怎麼運作的?啊,對了,用 prig 來試試看」:
$ prig -b 'Printf("%3.5s\n", "hi")'
hi
$ prig -b 'Printf("%3.5s\n", "hello world")'
hello你該用 Prig 嗎?我不會阻止你!但老實說,你大概還是學無所不在(而且精簡得多)的 AWK 語言會更好。這是一個出色、已有 45 年歷史的工具,到 2022 年仍被廣泛用於文字與資料處理。由 A、W、K 三位原作者合著的 The AWK Programming Language 這本書非常值得一讀。
如果你需要一個用於資料處理的執行檔,例如在沒有安裝 awk 的輕量容器中,你也可能會用到它。對於這類情況,你可以使用 prig -s 印出原始碼,再用 go build 編譯結果,並將執行檔複製到目標環境——不需要其他相依套件。
如果你想將 AWK 整合到你的 Go 程式中,或只是想了解 AWK 直譯器是如何運作的,可以看看我的 GoAWK 專案。
我很樂意聽到你對 Prig 的回饋:如果你有任何改進的想法,或是用其他語言打造了 rp 或 Prig 的變體,歡迎來打聲招呼!
隨機一篇部落格
留言
登入後參與討論