如何在go二进制程序中打包静态资源文件
Posted 2020-07-25 17:26 +0800 by ZhangJie ‐ 2 min read
Why?
有时我们希望在go二进制程序中打包一些静态资源文件,目的可能有多种,比较常见的是为了简化安装。通常我们安装一个go编写的工具,更倾向于使用 go get $repo
的方式来完成,这似乎已经成为了一种共识。当然,也有些项目还依赖一些静态资源文件,这些静态资源文件是不会被自动安装的,就需要借助其他方式来完成静态资源的安装,比如通过install.sh脚本,后者Makefile构建脚本等等。
今天,我想讨论下,如何简单快速地支持静态资源打包到二进制程序中,以及在二进制程序中对这些静态资源加以引用。
How?
github上已经有不少开发者在探索,方法其实都比较雷同,大致思路就是:
- 读取静态资源文件,转换成bytes数据;
- 内部提供一些类似文件系统的接口,提供文件名,返回文件数据;
- blabla…
开发者的需求,可能不完全一致,比如:
- 我想像遍历本地文件系统一样遍历文件目录,不只是提供一个文件名返回一个文件;
- 我的代码已经写完了,我只想做最小修改,将静态资源文件打包到二进制程序中,而后还原回文件系统;
- 我的代码不需要支持类似文件服务器的功能,不需要那么多华丽呼哨的功能;
开发者提供了很多类似的实现,这里有篇文章可供参考:https://tech.townsourced.com/post/embedding-static-files-in-go/。能工模形,巧匠窃意。其实在大致了解了实现的方式之后,就懒得再去学如何使用这些五花八门的第三方工具了。说真的,真的没几个好用的,至少从我的角度来说。可能它设计的比较通用,但是与我来说没有用处,我追求极简。
而且,go官方是有意来支持打包静态资源的,关于这一点,已经有issue在跟进讨论:https://github.com/golang/go/issues/35950。
尽管现在的状态还是Proposal-Hold状态,但是我觉得这个feature的到来也不会等很久了,anyway,我不想在这些即将被淘汰的三方工具上浪费学习的时间、改写代码的时间。
所以呢,为什么不简单一点,自己写一个当下比较适用项目本身的?写这个东西花不了二十分钟时间!
Let’s Do it!
功能分析
我理解实现打包静态资源文件,有这么几个点需要考虑:
- 提供一个小工具,通过它可以反复执行类似的静态资源打包的操作;
- 可以指定一个文件或者目录,将其转换成一个go文件放入项目中,允许编译时连接;
- go文件可以通过导出变量的形式,导出文件数据,允许在其他go代码中引用文件的内容;
- 静态资源文件可能有很多,希望能对文件内容进行压缩,以便减小go binary文件尺寸;
- 通常是本地组织好静态资源文件,写代码、测试ok、最后发布前希望将其打包到go binary,打包、解包、使用静态资源要最小化项目代码修改;
功能实现
我们先实现这个打包静态资源的工具,需要这几个参数:input、output,分别代表输入文件(or 目录)、输出文件名(go文件),gopkg代表输出go文件的包名(默认gobin)。
package main
var (
input = flag.String("input", "", "read data from input, which could be a regular file or directory")
output = flag.String("output", "", "write transformed data to named *.go, which could be linked with binary")
gopkg = flag.String("gopkg", "gobin", "write transformed data to *.go, whose package is $package")
)
我们的工具将从input对应的文件中读取文件内容,并转换成一个output对应的go文件中的导出变量。如果input是一个目录呢,我们则需要对目录下文件进行遍历处理。由于静态资源文件数据可能较大,这里需要进行gzip压缩(对于文本压缩率可高达80%左右)有助于减少go binary文件尺寸。
那读取到文件内容之后,如何将其转换成go文件中的导出变量呢?很简单,我们定义一个go模板,将读取到的文件内容gzip压缩后转换成bytes数组传递给模板引擎就可以了。模板中的{{.GoPackage}}将引用命令选项$gopkg的值,{{.Variable}}即为导出变量的值,这里我们会使用选项$input对应的CamelCase转换之后的文件名(或目录名),{{.Data}}即为gzip压缩后的文件数据。
var tpl = `package {{.GoPackage}}
var {{.Variable}} = []uint8{
{{ range $idx, $val := .Data }}{{$val}},{{ end }}
}`
接下来,我们看下怎么读取文件的内容,再强调下,要读取的内容可能是单个文件,也可能是一个目录。
// ReadFromInputSource 从输入读取内容,可以是一个文件,也可以是一个目录(会先gzip压缩然后再返回内容)
func ReadFromInputSource(inputSource string) (data []byte, err error) {
_, err := os.Lstat(inputSource)
if err != nil {
return nil, err
}
buf := bytes.Buffer{}
err = compress.Tar(inputSource, &buf)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
gzip对文件数据进行压缩,篇幅原因,这里只贴个链接地址,感兴趣的可以自行查看:https://github.com/hitzhangjie/codemaster/blob/master/compress/compress.go。
好,现在我们将这个打包工具的完整逻辑再完整梳理一下。
func main() {
// 输入输出参数校验
if len(*input) == 0 || len(*gopkg) == 0 {
fmt.Println("invalid argument: invalid input")
os.Exit(1)
}
// 读取输入内容
buf, err := ReadFromInputSource(*input)
if err != nil {
fmt.Errorf("read data error: %v\n", err)
os.Exit(1)
}
// 将内容转换成go文件写出
inputBaseName := filepath.Base(*input)
if len(*output) == 0 {
*output = fmt.Sprintf("%s_bindata.go", inputBaseName)
}
outputDir, outputBaseName := filepath.Split(*output)
tplInstance, err := template.New(outputBaseName).Parse(tpl)
if err != nil {
fmt.Printf("parse template error: %v\n", err)
os.Exit(1)
}
_ = os.MkdirAll(outputDir, 0777)
fout, err := os.OpenFile(*output, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0666)
if err != nil {
fmt.Printf("open input error: %v", err)
os.Exit(1)
}
err = tplInstance.Execute(fout, &struct {
GoPackage string
Variable string
Data []uint8
}{
GoPackage: *gopkg,
Variable: strcase.ToCamel(outputBaseName),
Data: buf,
})
if err != nil {
panic(fmt.Errorf("template execute error: %v", err))
}
fmt.Printf("ok, filedata stored to %s\n", *output)
}
下面我们演示下如何使用这个工具来对静态资源打包。
假定存在如下静态资源目录static,其下包含了多个文件,现在我想将其全部打包到一个go文件中。
$ tree .
.
|- static
|- file1.txt
|- file2.txt
|- file3.txt
运行 go build -v bindata
编译我们之前写的工具,然后运行 bindata -input=path/to/static -output=goin/static.go -gopkg=gobin
。
$ tree .
.
|- static
|- file1.txt
|- file2.txt
|- file3.txt
|- gobin
|- static.go
我们看到当前目录下多生成了一个gobin目录,其下多了个go文件static.go,查看下文件内容:
$ cat gobin/static.go
package gobin
var StaticGo = []uint8{
31,139,8,0,0,0,0,0,0,255,236,213,193,10,194,48,12,128,225,158,125,138,62,129,36,77,219,60,79,15,171,171,136,7,91,65,124,122,105,39,131,29,244,182,58,89,190,75,24,140,209,145,253,44,166,203,128,199,242,40,106,61,0,0,222,218,54,217,187,54,193,76,215,13,178,66,98,240,236,25,136,21,32,121,100,165,97,197,51,205,238,185,132,155,2,120,142,225,122,58,167,225,211,125,185,132,24,191,60,231,253,42,243,252,19,101,76,89,167,172,235,119,160,241,240,235,227,136,206,234,222,205,150,250,183,78,250,239,104,209,191,145,254,247,166,238,157,182,212,191,155,254,255,134,164,255,30,22,253,147,244,47,132,16,123,241,10,0,0,255,255,106,242,211,179,0,16,0,0,
}
哈哈,现在看到static目录及其下的文件已经被完整打包到一个go文件中了,且通过导出变量进行了导出,后续使用的时候,可以先将其还原到本地文件系统,以前已经写好的代码不用做任何修改,怎么还原到本地文件系统呢,并使用呢?
// 在你需要引用这些静态资源的package中释放这些静态资源文件到本地文件系统
func init() {
compress.UnTar(path/to/static, bytes.NewBuffer(gobin.StaticGo))
val := config.Read(path/to/static/file1.go, "section", "property", defaultValue)
...
}
现在,是不是感觉超级简单呢?:)