如何在go二进制程序中打包静态资源文件

Posted July 25, 2020 by  ‐ 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)
    ...
}

现在,是不是感觉超级简单呢?:)

Edit this page on GitHub