5

深入浅出 Golang 资源嵌入方案:go-bindata篇

 2 years ago
source link: https://soulteary.com/2022/01/16/explain-the-golang-resource-embedding-solution-go-bindata.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

上篇文章中,我们讲到了 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 修改上游仓库地址也记录了这个事情的发生

从 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调用状况

读取嵌入资源(小文件)CPU调用状况

官方实现中排行比较靠前的调用中,并未出现 embed 相关的函数调用。go-bindata 则出现了大量时间消耗在 0.88~0.95s 的数据读取、内存拷贝操作,另外针对资源的 GZip 解压缩也占用了累计 0.85s 的时间。

读取嵌入资源(小文件)CPU调用详情

读取嵌入资源(小文件)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 实现的性能,以及更多的定制化能力。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK