上篇文章中,我們講到了 Golang 原生的資源嵌入方案,本篇我們先來聊聊開源實現中排行中靠前的方案:go-bindata
。
之所以先聊這個方案,是因為雖然它目前的熱度和受歡迎程度並不是最高的,但是它的影響範圍和時間綜合來看,是比較大的,而且在實現和使用上,因為歷史原因,它的硬分叉版本也是最多的,情況最為複雜。
各個開源專案之間的淵源
先來聊聊這類開源專案之間的淵源吧。目前專案中會用到的 go-bindata
的專案主要有四個,分別是:
- (1500+ stars)https://github.com/go-bindata/go-bindata
- (840+ stars)https://github.com/elazarl/go-bindata-assetfs
- (630+ stars)https://github.com/jteeuwen/go-bindata
- (280+ stars)https://github.com/kevinburke/go-bindata
這些專案的共同起源是 jteeuwen/go-bindata 這個專案,它的第一行程式碼提交於 十年前的2011年6月。
但是在 2018 年2月7日,作者因為一些原因刪除了他建立的所有倉庫,隨後這個賬號也被棄用。這個時候,有一位好心的國外使用者在 Twitter 上對其他使用者進行了提醒。
隨後自然是引發了類似最近 fake.js
作者刪庫、早些時候的 npm left-pad
倉庫軟體刪除相同的,極其糟糕的連鎖反應,大量軟體無法正常構建。
在一些遺留的專案中,我們可以清楚的看到這個事情的發生時間點,比如 twitter 對 go-bindata 的 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
之後,可以看到一個非常壯觀的呼叫。(在我們程式碼足夠簡單的前提下,這個呼叫複雜度可以說比較離譜)
官方實現中排行比較靠前的呼叫中,並未出現 embed 相關的函式呼叫。go-bindata 則出現了大量時間消耗在 0.88~0.95s 的資料讀取、記憶體拷貝操作,另外針對資源的 GZip 解壓縮也佔用了累計 0.85s 的時間。
不過請注意,這個測試建立在上千次的小檔案獲取上的,所以平均每次的時間消耗,其實也是能夠接受的。當然,如果有同類需求,使用原生的實現方案更加高效。
接著來看看記憶體資源的使用。相比較官方實現,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 的使用就暫且不提啦,感興趣的同學可以參考本文做一個測試。
除了上面提到的實現之外,其實還有一些有趣的實現,雖然它們並不出名:
https://github.com/kataras/bindata
- 基於 iris 的web 定製優化,儲存資料和輸出都使用 GZip 處理,相比較原版有數倍效能提升。
https://github.com/conku/bindatafs
- 基於 go-bindata 的專注處理內嵌頁面模版的開源倉庫。
https://github.com/wrfly/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...