本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2022年01月16日 统计字数: 12144字 阅读时间: 25分钟阅读 本文链接: https://soulteary.com/2022/01/16/explain-the-golang-resource-embedding-solution-go-bindata.html ----- # 深入浅出 Golang 资源嵌入方案:go-bindata篇 上篇文章中,我们讲到了 Golang 原生的资源嵌入方案,本篇我们先来聊聊开源实现中排行中靠前的方案:`go-bindata`。 之所以先聊这个方案,是因为虽然它目前的热度和受欢迎程度并不是最高的,但是它的影响范围和时间综合来看,是比较大的,而且在实现和使用上,因为历史原因,它的硬分叉版本也是最多的,情况最为复杂。 ## 各个开源项目之间的渊源 先来聊聊这类开源项目之间的渊源吧。目前项目中会用到的 `go-bindata` 的项目主要有四个,分别是: - (1500+ stars)[https://github.com/go-bindata/go-bindata](https://github.com/go-bindata/go-bindata) - (840+ stars)[https://github.com/elazarl/go-bindata-assetfs](https://github.com/elazarl/go-bindata-assetfs) - (630+ stars)[https://github.com/jteeuwen/go-bindata](github.com/jteeuwen/go-bindata) - (280+ stars)[https://github.com/kevinburke/go-bindata](https://github.com/kevinburke/go-bindata) 这些项目的共同起源是 [jteeuwen/go-bindata](github.com/jteeuwen/go-bindata) 这个项目,它的第一行代码提交于 十年前的[2011年6月](https://github.com/jteeuwen/go-bindata/commit/2a84f0bab0a269d51340cbfdb8c117847681ace1)。 但是在 2018 年2月7日,作者因为一些原因删除了他创建的所有仓库,随后这个账号也被弃用。这个时候,有一位好心的国外用户在 Twitter 上对其他用户进行了[提醒](https://twitter.com/francesc/status/961249107020001280)。 ![来自好心人的提醒](https://attachment.soulteary.com/2022/01/16/twitter-report.jpg) 随后自然是引发了类似最近 `fake.js` 作者删库、早些时候的 `npm left-pad` 仓库软件删除相同的,极其糟糕的连锁反应,大量软件无法正常构建。 在一些遗留的项目中,我们可以清楚的看到这个事情的发生时间点,比如 twitter 对 go-bindata 的 [fork 存档](https://github.com/twitter/go-bindata/commits/master)。 ![从 Twitter fork 修改上游仓库地址也记录了这个事情的发生](https://attachment.soulteary.com/2022/01/16/github-fork-history.jpg) 在2月8日,开源社区的其他同学想办法申诉得到了这个账号,将“删库”之前的代码恢复到了这个账号中,为了表明这个仓库是仅做恢复之用途,好心人将软件仓库设置为只读(归档)后,做了[一个雷锋式的声明](https://github.com/jteeuwen/go-bindata/issues/5)。 ![来自社区其他好心人的补救](https://attachment.soulteary.com/2022/01/16/announcement-and-disclosure.jpg) 在此后的岁月里,虽然这个仓库失去了原作者的维护。但是 Golang 和 Golang 社区生态依旧在蓬勃发展,静态资源嵌入的需求还是比较旺盛的,于是便有了上文中的其他三个开源软件仓库,以及一些我尚未提到的知名度更低的一些仓库。 ## 各个版本的软件的差异 上面将各个开源项目之间的渊源讲完了,我们来看看这几个仓库之间都有哪些不同。 在这几个仓库中,`go-bindata/go-bindata` 是知名度最高的版本,`elazarl/go-bindata-assetfs` 提供了原版软件不支持 `net/http` 使用的 FS 封装。还记得[上一篇文章](https://soulteary.com/2022/01/15/explain-the-golang-resource-embedding-solution-part-1.html)中提到的 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 默认配置 和上一篇文章一样,在了解性能差异之前,我们先来完成基础功能的编写。 ```bash mkdir basic-go-bindata && cd basic-go-bindata go mod init solution-embed ``` 这里有一个小细节,因为 [go-bindata/go-bindata](https://github.com/go-bindata/go-bindata) 最新的 3.1.3 版本并没有正式发布,所以如果我们想安装包含最新功能修复的内容,需要使用下面的方式来进行安装: ```bash # 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 功能进行资源嵌入,我们的程序实现会类似下面这样: ```go 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` 指令: ```go 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倍,但是并不影响我们编译后得到的二进制文件大小(和官方实现表现一致)**。 ```go 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](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 默认配置 测试代码和“[前文](https://soulteary.com/2022/01/15/explain-the-golang-resource-embedding-solution-part-1.html)”中的差别不大,稍作调整即可使用: ```go 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` 脚本后,可以得到和上篇文章一样的性能采样数据。 回顾上篇文章中,我们的测试采样的执行结果耗时都不长: ```bash === 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 的采样脚本后,能看到测试时间整体变长了非常多: ```bash === 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](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/` ,可以看到类似下面的调用图: ![读取嵌入资源以及相对耗时的调用状况](https://attachment.soulteary.com/2022/01/16/cpu-profiler-large-key.jpg) 相比较官方 `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`,来查看内存的使用状况: ![读取嵌入资源内存消耗状况](https://attachment.soulteary.com/2022/01/16/mem-profiler-large-key.jpg) 可以看到不论是程序的调用链的复杂度,还是资源的使用量,go-bindata 的消耗看起来都十分夸张。在同样一百次快速调用之后,内存中总计使用过 19180 MB,是官方实现的 3 倍,相当于原始资源的 1000 多倍的消耗,**平均到每次请求,我们大概需要付出原文件 10 倍的资源来提供服务,非常不划算**。 所以,这里不难得出一个简单的结论:**请勿在 go-bindata 中嵌入过分大的资源,会造成严重的资源浪费**,如果有此类需求,可以使用上篇文章中提到的官方方案来解决问题。 ### 嵌入小文件的资源使用 看完大文件,我们同样再来看看小文件的资源使用状况。执行 `go tool pprof -http=:8090 cpu-small.out` 之后,可以看到一个非常壮观的调用。(在我们代码足够简单的前提下,这个调用复杂度可以说比较离谱) ![读取嵌入资源(小文件)CPU调用状况](https://attachment.soulteary.com/2022/01/16/cpu-profiler-small.jpg) 官方实现中排行比较靠前的调用中,并未出现 embed 相关的函数调用。go-bindata 则出现了大量时间消耗在 0.88~0.95s 的数据读取、内存拷贝操作,另外针对资源的 GZip 解压缩也占用了累计 0.85s 的时间。 ![读取嵌入资源(小文件)CPU调用详情](https://attachment.soulteary.com/2022/01/16/cpu-profiler-small-key.jpg) 不过请注意,**这个测试建立在上千次的小文件获取上的,所以平均每次的时间消耗,其实也是能够接受的**。当然,如果有同类需求,使用原生的实现方案更加高效。 ![读取嵌入资源(小文件)内存调用详情](https://attachment.soulteary.com/2022/01/16/mem-profiler-small-key.jpg) 接着来看看内存资源的使用。相比较官方实现,go-bindata大概资源消耗是其的4倍,对比原始文件,我们则需要额外使用6倍的资源。**如果小文件特别多或者请求量特别大,使用go-bindata应该不是一个最优解。但如果是临时或者少量文件的需求,偶尔使用也问题不大**。 ### 使用 Wrk 进行吞吐测试 和之前的文章一样,我们先执行 `go build main.go`,获取构建后的程序,然后执行 `./main` 启动服务,来测试小文件的吞吐能力: ```bash # 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 多次的吞吐,对于一般的小项目来说,问题不大。 再来看看针对大文件的吞吐: ```bash # 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` 指令中添加下面的参数开关即可。 ```go -nocompress -nomemcopy ``` 重新执行 `go generate` 之后,我们查看生成文件的尺寸,会发现居然比没开启 GZip 还更小一些(有一些资源确实不适合 GZip): ```go 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)。 ```go 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](https://github.com/soulteary/awesome-golang-embed/tree/main/go-bindata-related/benchmark-no-compress),感兴趣可以自取,并进行实验。 ### 嵌入大文件的性能状况 还是先使用 `go tool pprof -http=:8090 cpu-large.out` 来展示程序计算调用过程的资源消耗状况。可以看到这里关于资源处理的调用复杂度和官方比较差不多了,相比较官方实现的调用链,开启了减少内存占用和关闭了 GZip 压缩后的程序,**在程序并行计算上来看,甚至是优于前文中官方调用的**。 ![读取嵌入资源以及相对耗时的调用状况](https://attachment.soulteary.com/2022/01/16/no-copy-cpu-profiler-large.jpg) 也这是即使资源处理调用有着差不多的调用复杂度,即使执行时间 0.91s 是官方 0.42s 一倍有余,整体服务响应时间基本没有差别的原因。 接着使用 `go tool pprof -http=:8090 mem-large.out`,我们来查看内存的使用状况: ![读取嵌入资源内存消耗状况](https://attachment.soulteary.com/2022/01/16/no-copy-mem-profiler-large-key.jpg) 如果你对照前文来看,你会发现**在开启“减少内存消耗”功能之后,go-bindata 的内存占用甚至比官方实现还要小3MB**。当然,即使是和官方实现一样的资源消耗,平均到每次请求,我们还是需要大概付出原文件 3.6 倍的资源。 ### 嵌入小文件的资源使用 小文件的测试结果粗看起来和官方实现差别不大,这里就不浪费篇幅过多赘述了。我们直接进行压力测试,来看看程序的吞吐能力吧。 ### 使用 Wrk 进行吞吐测试 和之前的文章一样,我们先执行 `go build main.go`,获取构建后的程序,然后执行 `./main` 启动服务,先进行小文件的吞吐能力测试: ```bash # 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 ``` 测试结果非常令人惊讶,**每秒的响应能力甚至比官方实现还多几百**。接着来看看针对大文件的吞吐能力: ```bash # 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](https://github.com/kataras/bindata) - 基于 iris 的web 定制优化,存储数据和输出都使用 GZip 处理,相比较原版有数倍性能提升。 - [https://github.com/conku/bindatafs](https://github.com/conku/bindatafs) - 基于 go-bindata 的专注处理内嵌页面模版的开源仓库。 - [https://github.com/wrfly/bindata](https://github.com/wrfly/bindata) - 一个在实现上更加简洁的优化版本。 ## 最后 在测试到这里,我们就可以针对 go-bindata 做出一个简单的判断了,如果你追求不使用或者少使用反射和`unsafe.Pointer`,那么在少量文件、不包含大体积文件的前提下,使用 go-bindata 是可行的。 一旦数据量大起来,建议还是使用官方实现。当然,如果你能够接受使用反射和`unsafe.Pointer`,go-bindata 可以提供给你不逊于官方 go-embed 实现的性能,以及更多的定制化能力。 --EOF