用 Go 寫腳本:一個 400 行的 Git 客戶端,能建立儲存庫並將自己推送到 GitHub
幾年前,我寫了一個叫 pygit 的小型 Python 程式,功能剛好足以作為一個 Git 客戶端來建立儲存庫、新增幾個提交,並把自己推送到 GitHub。
我想比較一下用 Go 來寫會是什麼樣子,看看用 Go 寫小型腳本是否可行——那種講求快速、能動就好的程式碼,效能不是重點,錯誤處理只要有堆疊追蹤就夠了。
成果就是 gogit,一個約 400 行的 Go 程式,可以初始化儲存庫、提交並推送到 GitHub。它是用一般的 Go 寫成的……除了錯誤處理之外,因為 Go 慣用的錯誤處理方式實在太冗長,不太適合用來寫腳本(這點下面會再談)。
技術摘要
我在這裡就不詳細說明 Git 的運作原理了(我的 pygit 文章中有更多說明),簡單來說,Git 的資料模型相當精巧。它使用一個簡單的、以檔案為基礎的物件儲存區,位於 .git/objects,其中每個物件都有一個 40 字元的雜湊值,可以是 commit、tree(目錄清單)或 blob(已提交的檔案)。就這樣而已——用來寫入 commit、tree 和 blob 的 gogit 程式碼大約只有 50 行。
我實作的功能甚至比 pygit 還少:只有 init、commit 和 push。Gogit 甚至不支援索引(staging area,暫存區),所以你不需要 gogit add,而是每次直接用 gogit commit 並附上你想提交的路徑清單。如同 pygit 的程式碼所示,處理索引相當麻煩。而且也沒有必要,我希望 gogit 能作為一個極簡主義的練習。
Gogit 也拿掉了 cat-file、hash-object 和 diff 這些指令——提交並推送到 GitHub 並不需要它們。不過我在除錯時還是有用到 Git 的 cat-file。
以下是我用來建立儲存庫、提交並推送到 GitHub 的指令(請注意這裡使用了 go run 來編譯並執行這個「腳本」):
# Initialise the repo
$ go run . init
# Make the first commit (other commits are similar)
$ export GIT_AUTHOR_NAME='Ben Hoyt'
$ export [email protected]
$ go run . commit -m 'Initial commit' gogit.go go.mod LICENSE.txt
commited 0580a17 to master
# Push updates to GitHub
$ export GIT_USERNAME=benhoyt
$ export GIT_PASSWORD=...
$ go run . push https://github.com/benhoyt/gogit
updating remote master from 0000000 to 0580a17 (5 objects)錯誤處理
Go 錯誤處理的冗長一直備受詬病。它簡單又明確,但每個可能失敗的函式呼叫,都要額外用三行程式碼來處理錯誤:
mode, err := strconv.ParseInt(modeStr, 8, 64)
if err != nil {
return err
}在撰寫正式環境的程式碼時,這倒不是什麼大問題,因為反正你會想要對錯誤處理有更多控制——例如包裝得更漂亮的錯誤,或是人類可讀的訊息:
mode, err := strconv.ParseInt(modeStr, 8, 64)
if err != nil {
return fmt.Errorf("mode must be an octal number, not %q", modeStr)
}然而在簡單的腳本中,你需要的所有錯誤處理就只是顯示訊息、印出堆疊追蹤,然後結束程式。這就是在 Python 中不捕捉例外時會發生的事,而在 Go 中用幾個輔助函式就能輕鬆模擬:
func check0(err error) {
if err != nil {
panic(err)
}
}
func check[T any](value T, err error) T {
if err != nil {
panic(err)
}
return value
}
func assert(cond bool, format string, args ...any) {
if !cond {
panic(fmt.Sprintf(format, args...))
}
}現在 Go 有了泛型,你可以輕鬆定義一個會回傳結果的 check 函式。不過,你還是需要根據回傳值的數量來提供不同的變體。通常是零個或一個,其中一個最常見,所以我把那個變體直接命名為 check,而零回傳值的則命名為 check0。我也定義了 assert,它接收一個布林值和一個格式化的訊息,而不是錯誤。
這些輔助函式能讓你把這樣的程式碼:
func writeTree(paths []string) ([]byte, error) {
sort.Strings(paths) // tree object needs paths sorted
var buf bytes.Buffer
for _, path := range paths {
st, err := os.Stat(path)
if err != nil {
return nil, err
}
if st.IsDir() {
panic("sub-trees not supported")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
hash, err := hashObject("blob", data)
if err != nil {
return nil, err
}
fmt.Fprintf(&buf, "%o %s\x00%s", st.Mode().Perm()|0o100000, path, hash)
}
return hashObject("tree", buf.Bytes())
}變成下面這樣,將函式本體從 21 行縮減到 10 行,簡潔程度可與 Python 相提並論:
func writeTree(paths []string) []byte {
sort.Strings(paths) // tree object needs paths sorted
var buf bytes.Buffer
for _, path := range paths {
st := check(os.Stat(path))
assert(!st.IsDir(), "sub-trees not supported")
data := check(os.ReadFile(path))
hash := hashObject("blob", data)
fmt.Fprintf(&buf, "%o %s\x00%s", st.Mode().Perm()|0o100000, path, hash)
}
return hashObject("tree", buf.Bytes())
}這不是完美的做法,因為 check 這個字會稍微掩蓋你實際呼叫的函式,但它確實讓撰寫那種快速、能動就好的腳本變得輕鬆許多。
你甚至能得到比單純 return err「更好」的錯誤,因為堆疊追蹤會精確顯示當時正在執行哪個函式和哪一行程式碼:
$ go run . push https://github.com/benhoyt/gogit
panic: Get "https://github.com/benhoyt/gogit/info/refs?service=git-receive-pack":
context deadline exceeded (Client.Timeout exceeded while awaiting headers)
goroutine 1 [running]:
main.check[...](...)
/home/ben/h/gogit/gogit.go:94
main.getRemoteHash(0x416ad0?, {0x7ffe1f0152d9?, 0x4b87d4?}, {0xc00001c00d, 0x7}, {0xc00001a00d, 0x28})
/home/ben/h/gogit/gogit.go:245 +0x6da
main.push({0x7ffe1f0152d9, 0x20}, {0xc00001c00d, 0x7}, {0xc00001a00d, 0x28})
/home/ben/h/gogit/gogit.go:217 +0xd9
main.main()
/home/ben/h/gogit/gogit.go:73 +0x21e
exit status 2從 return err 改為 check讓程式碼行數從 607 行減少到 415 行,減少了 32%。
如果你想進一步採用這種做法,甚至有一個由 Joe Tsai 和 Josh Bleecher Snyder 撰寫的函式庫叫 try,它使用 recover 來「正確地」做到這件事。很有意思!我還是希望 Go 團隊能想出辦法讓錯誤處理不那麼冗長。
效能
這一節會很短,因為我不在乎這個程式的速度,而且 Go 版本的速度很可能跟 Python 版本一樣快,甚至更快。Go 可以快上許多,但我們處理的都是很小的檔案,而且在 Python 中,像雜湊運算和寫入磁碟這類關鍵程式碼反正都是用 C 寫的。
記憶體使用量是效能的另一個面向。同樣地,我們在這裡處理的都是小檔案,所以把所有東西讀進記憶體並不是問題。在 Python 中,你也可以做串流處理,但在 Go 中由於有非常出色的 io.Reader 和 io.Writer 介面,做起來並沒有那麼一致地容易。
話雖如此,在 Go 中把所有東西讀進 []byte 或 string 再進行操作還是稍微容易一些,所以我在 gogit 中就是這麼做的。我們談的是幾 KB 的記憶體,而我的機器有好幾 GB。
與 Python 版本的比較
以目前來看,Pygit 大約有 600 行程式碼,而 gogit 則有 400 行左右。不過,這有點誤導,因為我在寫 Go 版本時移除了好幾項功能:不支援 Git 索引,也沒有 cat-file、hash-object 或 diff。
我做了一個快速測試,把 Python 版本中的那些函式移除後,剩下 360 行程式碼。我覺得 Go 的 400 行對比 Python 的 360 行並不差——只多了 10%。而且 Go 版本還包含了 20 行的 import 和 20 行的 check/assert 函式。所以實際上兩者的大小幾乎一模一樣!
來看看幾個具體的函式。首先是 find_object,它會在 Git 物件儲存區中尋找具有給定前綴的物件。以下是 Python 版本:
def find_object(sha1_prefix):
obj_dir = os.path.join('.git', 'objects', sha1_prefix[:2])
rest = sha1_prefix[2:]
objects = [name for name in os.listdir(obj_dir) if name.startswith(rest)]
if not objects:
raise ValueError('object {!r} not found'.format(sha1_prefix))
if len(objects) >= 2:
raise ValueError('multiple objects ({}) with prefix {!r}'.format(
len(objects), sha1_prefix))
return os.path.join(obj_dir, objects[0])以下是 Go 版本:
func findObject(hashPrefix string) string {
objDir := filepath.Join(".git/objects", hashPrefix[:2])
rest := hashPrefix[2:]
entries, _ := os.ReadDir(objDir)
var matches []string
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), rest) {
matches = append(matches, entry.Name())
}
}
assert(len(matches) > 0, "object %q not found", hashPrefix)
assert(len(matches) == 1, "multiple objects with prefix %q", hashPrefix)
return filepath.Join(objDir, matches[0])
}有很多地方都很相似,例如 os.path.join 對比 filepath.Join、os.listdir 對比 os.ReadDir 等等。但請注意 Python 中的列表推導式——一行就能搞定——在 Go 中卻變成了五行 for 迴圈。在 Go 中寫腳本時,我真的很想念列表推導式……
再來看另一個,commit 函式,先看 Python 版本:
def commit(message, author):
tree = write_tree()
parent = get_local_master_hash()
timestamp = int(time.mktime(time.localtime()))
utc_offset = -time.timezone
author_time = '{} {}{:02}{:02}'.format(
timestamp,
'+' if utc_offset > 0 else '-',
abs(utc_offset) // 3600,
(abs(utc_offset) // 60) % 60)
lines = ['tree ' + tree]
if parent:
lines.append('parent ' + parent)
lines.append('author {} {}'.format(author, author_time))
lines.append('committer {} {}'.format(author, author_time))
lines.append('')
lines.append(message)
lines.append('')
data = '\n'.join(lines).encode()
sha1 = hash_object(data, 'commit')
master_path = os.path.join('.git', 'refs', 'heads', 'master')
write_file(master_path, (sha1 + '\n').encode())
return sha1接著是 Go 版本:
func commit(message, author string, paths []string) string {
tree := writeTree(paths)
var buf bytes.Buffer
fmt.Fprintln(&buf, "tree", hex.EncodeToString(tree))
parent := getLocalHash()
if parent != "" {
fmt.Fprintln(&buf, "parent", parent)
}
now := time.Now()
offset := now.Format("-0700")
fmt.Fprintln(&buf, "author", author, now.Unix(), offset)
fmt.Fprintln(&buf, "committer", author, now.Unix(), offset)
fmt.Fprintln(&buf)
fmt.Fprintln(&buf, message)
data := buf.Bytes()
hash := hashObject("commit", data)
check0(os.WriteFile(".git/refs/heads/master", []byte(hex.EncodeToString(hash)+"\n"), 0o664))
return hex.EncodeToString(hash)
}有趣的是,這次 Python 版本反而比較長:23 行對比 Go 的 19 行。這主要歸功於 Go 對時間戳記更佳的處理方式。Go 的標準函式庫並非完美,但它的 time 套件比 Python 的 time 和 datetime 套件加起來還要好。
整體而言,Go 的標準函式庫看起來比 Python 的更加一致、設計得更好,Python 的標準函式庫感覺像是經過數十年由許多不同的人設計出來的(事實上也的確如此)。
結論
搭配以 panic 為基礎的錯誤處理,Go 很適合用來撰寫那種快速、能動就好的命令列腳本。
老實說,對於用完即丟的腳本,我可能還是會優先選擇 Python,因為它的語法更精簡,有列表(以及其他)推導式,而且預設就有例外處理。
然而,對於任何比用完即丟的腳本再稍微複雜一點的東西,我很快就會轉向 Go。它的標準函式庫設計得更好,io.Reader 和 io.Writer 介面非常出色,而其輕量級的靜態型別則能在不礙事的情況下幫忙捕捉錯誤。
隨機一篇部落格
留言
登入後參與討論