深入淺出 Golang 資源嵌入方案:go-bindata篇

soulteary發表於2022-01-22

上篇文章中,我們講到了 Golang 原生的資源嵌入方案,本篇我們先來聊聊開源實現中排行中靠前的方案:go-bindata

之所以先聊這個方案,是因為雖然它目前的熱度和受歡迎程度並不是最高的,但是它的影響範圍和時間綜合來看,是比較大的,而且在實現和使用上,因為歷史原因,它的硬分叉版本也是最多的,情況最為複雜。

各個開源專案之間的淵源

先來聊聊這類開源專案之間的淵源吧。目前專案中會用到的 go-bindata 的專案主要有四個,分別是:

這些專案的共同起源是 jteeuwen/go-bindata 這個專案,它的第一行程式碼提交於 十年前的2011年6月

但是在 2018 年2月7日,作者因為一些原因刪除了他建立的所有倉庫,隨後這個賬號也被棄用。這個時候,有一位好心的國外使用者在 Twitter 上對其他使用者進行了提醒

來自好心人的提醒

隨後自然是引發了類似最近 fake.js 作者刪庫、早些時候的 npm left-pad 倉庫軟體刪除相同的,極其糟糕的連鎖反應,大量軟體無法正常構建。

在一些遺留的專案中,我們可以清楚的看到這個事情的發生時間點,比如 twitter 對 go-bindata 的 fork 存檔

從 Twitter fork 修改上游倉庫地址也記錄了這個事情的發生

在2月8日,開源社群的其他同學想辦法申訴得到了這個賬號,將“刪庫”之前的程式碼恢復到了這個賬號中,為了表明這個倉庫是僅做恢復之用途,好心人將軟體倉庫設定為只讀(歸檔)後,做了一個雷鋒式的宣告

來自社群其他好心人的補救

在此後的歲月裡,雖然這個倉庫失去了原作者的維護。但是 Golang 和 Golang 社群生態依舊在蓬勃發展,靜態資源嵌入的需求還是比較旺盛的,於是便有了上文中的其他三個開源軟體倉庫,以及一些我尚未提到的知名度更低的一些倉庫。

各個版本的軟體的差異

上面將各個開源專案之間的淵源講完了,我們來看看這幾個倉庫之間都有哪些不同。

在這幾個倉庫中,go-bindata/go-bindata 是知名度最高的版本,elazarl/go-bindata-assetfs 提供了原版軟體不支援 net/http 使用的 FS 封裝。還記得上一篇文章中提到的 FS 介面實現嗎,沒錯,這個專案主要就是做了這個功能。除此之外,在過去幾年裡,前端領域技術的蓬勃發展,尤其是 SPA 型別的前端應用的蓬勃發展,也讓 elazarl/go-bindata-assetfs 這個專注於服務 SPA 應用單檔案分發的解決方案有了實戰的地方。所以如果你有類似的需求,依舊可以使用這個倉庫,將你的前端 SPA 專案打包成一個可執行檔案進行快速分發

當然,開源社群中的軟體發展經常是交錯的,在 elazarl/go-bindata-assetfs 提供了 FS 封裝不久,go-bindata/go-bindata 也提供了 -fs 引數,支援了將嵌入資源和 net/http 一起使用的功能。所以如果你追求程式的依賴最小化,並希望嵌入的資源和 net/http 一起使用,可以考慮只使用這個倉庫

此外,還有一些有程式碼潔癖的程式設計師,則建立了一個新的 fork 版本,kevinburke/go-bindata。相比較原版以及go-bindata/go-bindata 程式碼,它的程式碼健壯程度更好,並且修正了社群使用者對 go-bindata/go-bindata 反饋的一些問題,新增了一些社群使用者期望的新功能。不過這個倉庫中的程式和原版一樣,並未包含配合 net/http 一起使用所需要的 fs 封裝。所以如果想使用這個程式處理的靜態資源和 net/http 一同使用,需要搭配 elazarl/go-bindata-assetfs ,或者自己封裝一個簡單的 fs

這些軟體與官方實現的差異

go-bindata 相比較官方實現,其實會多一些額外的功能:

  • 允許使用者使用兩種不同的模式來讀取靜態資源(比如使用反射和 unsafe.Pointer 的方式直接讀取資料,或者使用 Golang 程式變數的方式來進行資料互動)
  • 在某些場景下,相對更低的資源儲存空間佔用(基於構建時進行的 GZip壓縮)
  • 對靜態資源的引用路徑,進行動態調整或預處理的能力
  • 更開放的資源引入模式,支援從上級目錄引入資源(官方實現僅支援當前目錄)

當然,相比較上一篇文章中官方實現而言,go-bindata 的實現相對“髒一些”,會將靜態資源打包為一個 go 程式檔案。並且在程式執行之前,我們需要先執行資源構建操作,才能讓程式跑起來。而不是像官方實現一樣,“零新增無汙染”,go run 或者 go build 一條命令就能解決“一切”問題。

接下來,我們就先聊聊 go-bindata 的基礎使用和效能表現吧。

基礎使用:go-bindata 預設配置

和上一篇文章一樣,在瞭解效能差異之前,我們先來完成基礎功能的編寫。

mkdir basic-go-bindata && cd basic-go-bindata
go mod init solution-embed

這裡有一個小細節,因為 go-bindata/go-bindata 最新的 3.1.3 版本並沒有正式釋出,所以如果我們想安裝包含最新功能修復的內容,需要使用下面的方式來進行安裝:

# go get -u -v github.com/go-bindata/go-bindata@latest

go get: added github.com/go-bindata/go-bindata v3.1.2+incompatible

在上篇文章中,想要使用官方 go-embed 功能進行資源嵌入,我們的程式實現會類似下面這樣:

package main

import (
    "embed"
    "log"
    "net/http"
)

//go:embed assets
var assets embed.FS

func main() {
    mutex := http.NewServeMux()
    mutex.Handle("/", http.FileServer(http.FS(assets)))
    err := http.ListenAndServe(":8080", mutex)
    if err != nil {
        log.Fatal(err)
    }
}

而使用 go-bindata 的話,因為我們需要使用一個額外生成的程式檔案,程式需要改為類似下面這樣,並且需要新增一段 go:generate 指令:

package main

import (
    "log"
    "net/http"

    "solution-embed/pkg/assets"
)

//go:generate go-bindata -fs -o=pkg/assets/assets.go -pkg=assets ./assets

func main() {
    mutex := http.NewServeMux()
    mutex.Handle("/", http.FileServer(assets.AssetFile()))
    err := http.ListenAndServe(":8080", mutex)
    if err != nil {
        log.Fatal(err)
    }
}

這裡我們使用 go generate 指令,宣告瞭程式執行前所需要執行的相關命令,它除了支援執行環境中的全域性程式之外,還可以執行通過 go get 安裝的可執行的命令。如果你使用過 Node.js 生態中的 npx (npm) 命令,你會覺得很親切,不過和 npx 不同的是,這個指令和程式的上下文更密切,支援分散寫在不同的程式中,和程式上下文更密切一些。

先執行 go generate,專案當前目錄的 pkg/assets/assets.go 位置會出現一個的程式檔案,它包含了我們所需要的資源,因為 bindata 實現使用了 \x00 之類的字元進行編碼,所以生成的程式碼相比較原始的靜態資源會膨脹4~5倍,但是並不影響我們編譯後得到的二進位制檔案大小(和官方實現表現一致)

du -hs *
 17M    assets
4.0K    go.mod
4.0K    go.sum
4.0K    main.go
 83M    pkg

不論我們選擇使用 go run main.go 還是 go build main.go ,當程式執行起來之後,訪問 http://localhost:8080/assets/example.txt 就能驗證程式是否正常啦。

相關程式碼實現在 https://github.com/soulteary/awesome-golang-embed/tree/main/go-bindata-related/basic-go-bindata,感興趣可以自取。

此外,相比較官方程式不支援使用當前程式目錄之外的資源(需要使用 go generate cp -r ../originPath ./destPath 的方式來曲線救國),go-bindata 可以直接在生成資源的使用引用外部資源。並在對外提供服務之前,使用-prefix 引數調整生成的資原始檔中的引用路徑。

測試準備:go-bindata 預設配置

測試程式碼和“前文”中的差別不大,稍作調整即可使用:

package main

import (
    "log"
    "net/http"
    "net/http/pprof"
    "runtime"

    "solution-embed/pkg/assets"
)

//go:generate go-bindata -fs -o=pkg/assets/assets.go -pkg=assets ./assets

func registerRoute() *http.ServeMux {

    mutex := http.NewServeMux()
    mutex.Handle("/", http.FileServer(assets.AssetFile()))
    return mutex
}

func enableProf(mutex *http.ServeMux) {
    runtime.GOMAXPROCS(2)
    runtime.SetMutexProfileFraction(1)
    runtime.SetBlockProfileRate(1)

    mutex.HandleFunc("/debug/pprof/", pprof.Index)
    mutex.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
    mutex.HandleFunc("/debug/pprof/profile", pprof.Profile)
    mutex.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
    mutex.HandleFunc("/debug/pprof/trace", pprof.Trace)
}

func main() {
    mutex := registerRoute()
    enableProf(mutex)

    err := http.ListenAndServe(":8080", mutex)
    if err != nil {
        log.Fatal(err)
    }
}

效能測試:go-bindata 預設配置

除了主程式和測試程式需要調整,其餘專案內容可以直接使用前文中的程式碼。在執行完 benchmark.sh 指令碼後,可以得到和上篇文章一樣的效能取樣資料。

回顧上篇文章中,我們的測試取樣的執行結果耗時都不長:

=== RUN   TestSmallFileRepeatRequest
--- PASS: TestSmallFileRepeatRequest (0.04s)
PASS
ok      solution-embed    0.813s
=== RUN   TestLargeFileRepeatRequest
--- PASS: TestLargeFileRepeatRequest (1.14s)
PASS
ok      solution-embed    1.331s
=== RUN   TestStaticRoute
--- PASS: TestStaticRoute (0.00s)
=== RUN   TestSmallFileRepeatRequest
--- PASS: TestSmallFileRepeatRequest (0.04s)
=== RUN   TestLargeFileRepeatRequest
--- PASS: TestLargeFileRepeatRequest (1.12s)
PASS
ok      solution-embed    1.509s

而執行本文中 go-bindata 的取樣指令碼後,能看到測試時間整體變長了非常多:

=== RUN   TestSmallFileRepeatRequest
--- PASS: TestSmallFileRepeatRequest (1.47s)
PASS
ok      solution-embed    2.260s
=== RUN   TestLargeFileRepeatRequest
--- PASS: TestLargeFileRepeatRequest (29.43s)
PASS
ok      solution-embed    29.808s

這部分使用的相關程式碼,我上傳到了 https://github.com/soulteary/awesome-golang-embed/tree/main/go-bindata-related/benchmark,有需要可以自取。

嵌入大檔案的效能狀況

這裡我們依舊是使用 go tool pprof -http=:8090 cpu-large.out 來展示程式計算呼叫過程的資源消耗狀況(因為呼叫非常多,這裡我們只看直接關係比較大的部分)。在瀏覽器中開啟 http://localhost:8090/ui/ ,可以看到類似下面的呼叫圖:

讀取嵌入資源以及相對耗時的呼叫狀況

相比較官方 go:embed 實現中 embed 函式只消耗了 0.07s,io.copy 只消耗 0.88s。go-bindata 在 embed 處理和 io.copy 上則分別花費了 12.99~13.08s 和 26.06~27.03s。前者效能消耗增加了 180 多倍,後者則接近 30 倍。

繼續使用 go tool pprof -http=:8090 mem-large.out,來檢視記憶體的使用狀況:

讀取嵌入資源記憶體消耗狀況

可以看到不論是程式的呼叫鏈的複雜度,還是資源的使用量,go-bindata 的消耗看起來都十分誇張。在同樣一百次快速呼叫之後,記憶體中總計使用過 19180 MB,是官方實現的 3 倍,相當於原始資源的 1000 多倍的消耗,平均到每次請求,我們大概需要付出原檔案 10 倍的資源來提供服務,非常不划算

所以,這裡不難得出一個簡單的結論:請勿在 go-bindata 中嵌入過分大的資源,會造成嚴重的資源浪費,如果有此類需求,可以使用上篇文章中提到的官方方案來解決問題。

嵌入小檔案的資源使用

看完大檔案,我們同樣再來看看小檔案的資源使用狀況。執行 go tool pprof -http=:8090 cpu-small.out 之後,可以看到一個非常壯觀的呼叫。(在我們程式碼足夠簡單的前提下,這個呼叫複雜度可以說比較離譜)

讀取嵌入資源(小檔案)CPU呼叫狀況

官方實現中排行比較靠前的呼叫中,並未出現 embed 相關的函式呼叫。go-bindata 則出現了大量時間消耗在 0.88~0.95s 的資料讀取、記憶體拷貝操作,另外針對資源的 GZip 解壓縮也佔用了累計 0.85s 的時間。

讀取嵌入資源(小檔案)CPU呼叫詳情

不過請注意,這個測試建立在上千次的小檔案獲取上的,所以平均每次的時間消耗,其實也是能夠接受的。當然,如果有同類需求,使用原生的實現方案更加高效。

讀取嵌入資源(小檔案)記憶體呼叫詳情

接著來看看記憶體資源的使用。相比較官方實現,go-bindata大概資源消耗是其的4倍,對比原始檔案,我們則需要額外使用6倍的資源。如果小檔案特別多或者請求量特別大,使用go-bindata應該不是一個最優解。但如果是臨時或者少量檔案的需求,偶爾使用也問題不大

使用 Wrk 進行吞吐測試

和之前的文章一樣,我們先執行 go build main.go,獲取構建後的程式,然後執行 ./main 啟動服務,來測試小檔案的吞吐能力:

# wrk -t16 -c 100 -d 30s http://localhost:8080/assets/vue.min.js
Running 30s test @ http://localhost:8080/assets/vue.min.js
  16 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    89.61ms   73.12ms 701.06ms   74.80%
    Req/Sec    74.17     25.40   210.00     68.65%
  35550 requests in 30.05s, 3.12GB read
Requests/sec:   1182.98
Transfer/sec:    106.43MB

可以看到相比較前篇文章中官方實現,吞吐能力縮水接近 20 倍。不過依舊能保持每秒 1000 多次的吞吐,對於一般的小專案來說,問題不大。

再來看看針對大檔案的吞吐:

# wrk -t16 -c 100 -d 30s http://localhost:8080/assets/chip.jpg 

Running 30s test @ http://localhost:8080/assets/chip.jpg
  16 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     0.00us    0.00us   0.00us     nan%
    Req/Sec     1.66      2.68    10.00     91.26%
  106 requests in 30.10s, 1.81GB read
  Socket errors: connect 0, read 0, write 0, timeout 106
Requests/sec:      3.52
Transfer/sec:     61.46MB

相比較官方實現能夠每秒吞吐接近 300 次,使用 go-bindata 後,每秒只能處理 3.5 次的請求,進一步驗證了前文中不建議使用 go-bindata 處理大檔案的判斷。

效能測試:go-bindata 關閉 GZip壓縮、開啟減少記憶體佔用功能

預設的 go-bindata 會開啟 GZip 壓縮(採用 Go 預設壓縮比率),如果我們不開啟 GZip 測試效能會有改善嗎?此外,如果我們開啟基於反射和 unsafe.Pointer的減少記憶體佔用的功能,程式的效能是否會有改善?

想要關閉 GZip,開啟減少記憶體佔用的功能,只需要在 go:generate 指令中新增下面的引數開關即可。

-nocompress -nomemcopy

重新執行 go generate 之後,我們檢視生成檔案的尺寸,會發現居然比沒開啟 GZip 還更小一些(有一些資源確實不適合 GZip):

du -hs *   
 17M    assets
4.0K    benchmark.sh
4.0K    go.mod
4.0K    go.sum
 24M    main
4.0K    main.go
 68M    pkg

在針對上面測試程式進行調整之後,我們再次對程式進行測試,同樣是執行 benchmark.sh,可以看到執行時間發生了質的變化,甚至逼近了官方實現(僅相差 0.01s 和 0.07s)。

bash benchmark.sh 
=== RUN   TestSmallFileRepeatRequest
--- PASS: TestSmallFileRepeatRequest (0.05s)
PASS
ok      solution-embed    1.246s
=== RUN   TestLargeFileRepeatRequest
--- PASS: TestLargeFileRepeatRequest (1.19s)
PASS
ok      solution-embed    1.336s

接下來,我們來看看程式呼叫又發生了哪些驚人的變化呢?

關於這部分的相關程式碼,我上傳到了 https://github.com/soulteary/awesome-golang-embed/tree/main/go-bindata-related/benchmark-no-compress,感興趣可以自取,並進行實驗。

嵌入大檔案的效能狀況

還是先使用 go tool pprof -http=:8090 cpu-large.out 來展示程式計算呼叫過程的資源消耗狀況。可以看到這裡關於資源處理的呼叫複雜度和官方比較差不多了,相比較官方實現的呼叫鏈,開啟了減少記憶體佔用和關閉了 GZip 壓縮後的程式,在程式平行計算上來看,甚至是優於前文中官方呼叫的

讀取嵌入資源以及相對耗時的呼叫狀況

也這是即使資源處理呼叫有著差不多的呼叫複雜度,即使執行時間 0.91s 是官方 0.42s 一倍有餘,整體服務響應時間基本沒有差別的原因。

接著使用 go tool pprof -http=:8090 mem-large.out,我們來檢視記憶體的使用狀況:

讀取嵌入資源記憶體消耗狀況

如果你對照前文來看,你會發現在開啟“減少記憶體消耗”功能之後,go-bindata 的記憶體佔用甚至比官方實現還要小3MB。當然,即使是和官方實現一樣的資源消耗,平均到每次請求,我們還是需要大概付出原檔案 3.6 倍的資源。

嵌入小檔案的資源使用

小檔案的測試結果粗看起來和官方實現差別不大,這裡就不浪費篇幅過多贅述了。我們直接進行壓力測試,來看看程式的吞吐能力吧。

使用 Wrk 進行吞吐測試

和之前的文章一樣,我們先執行 go build main.go,獲取構建後的程式,然後執行 ./main 啟動服務,先進行小檔案的吞吐能力測試:

# wrk -t16 -c 100 -d 30s http://localhost:8080/assets/vue.min.js

Running 30s test @ http://localhost:8080/assets/vue.min.js
  16 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     4.22ms    2.55ms  47.38ms   70.90%
    Req/Sec     1.46k   128.35     1.84k    77.00%
  699226 requests in 30.02s, 61.43GB read
Requests/sec:  23292.03
Transfer/sec:      2.05GB

測試結果非常令人驚訝,每秒的響應能力甚至比官方實現還多幾百。接著來看看針對大檔案的吞吐能力:

# wrk -t16 -c 100 -d 30s http://localhost:8080/assets/chip.jpg 

Running 30s test @ http://localhost:8080/assets/chip.jpg
  16 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   340.98ms  138.47ms   1.60s    81.04%
    Req/Sec    18.24      9.33    60.00     73.75%
  8478 requests in 30.10s, 141.00GB read
Requests/sec:    281.63
Transfer/sec:      4.68GB

大檔案的測試結果和官方實現幾乎沒有差別,數值差異在每秒幾個。

其他

受限於篇幅,關於 “homebrew” 版的 go-bindata 的使用就暫且不提啦,感興趣的同學可以參考本文做一個測試。

除了上面提到的實現之外,其實還有一些有趣的實現,雖然它們並不出名:

最後

在測試到這裡,我們就可以針對 go-bindata 做出一個簡單的判斷了,如果你追求不使用或者少使用反射和unsafe.Pointer,那麼在少量檔案、不包含大體積檔案的前提下,使用 go-bindata 是可行的。

一旦資料量大起來,建議還是使用官方實現。當然,如果你能夠接受使用反射和unsafe.Pointer,go-bindata 可以提供給你不遜於官方 go-embed 實現的效能,以及更多的定製化能力。

--EOF


我們有一個小小的折騰群,裡面聚集了幾百位喜歡折騰的小夥伴。

在不發廣告的情況下,我們在裡面會一起聊聊軟硬體、HomeLab、程式設計上的一些問題,也會在群裡不定期的分享一些技術沙龍的資料。

喜歡折騰的小夥伴歡迎掃碼新增好友。(新增好友,請備註實名,註明來源和目的,否則不會通過稽核)

關於折騰群入群的那些事


如果你覺得內容還算實用,歡迎點贊分享給你的朋友,在此謝過。

如果你想更快的看到後續內容的更新,請不吝“點贊”或“轉發分享”,這些免費的鼓勵將會影響後續有關內容的更新速度。


本文使用「署名 4.0 國際 (CC BY 4.0)」許可協議,歡迎轉載、或重新修改使用,但需要註明來源。 署名 4.0 國際 (CC BY 4.0)

本文作者: 蘇洋

建立時間: 2022年01月16日
統計字數: 12144字
閱讀時間: 25分鐘閱讀
本文連結: https://soulteary.com/2022/01...

相關文章