go-bindata:go 语言的静态资源嵌入

单文件无依赖发布,是 go 语言一项杀手级特性。看着不怎么起眼,但被应用发布和运维折磨过的朋友,会明白这意味着什么。

可没高兴多久,发现应用还是要引入各种静态资源。这时就要拿出 go-bindata 了。

本文没有一开始给出最佳实践,而是从最简单的做法开始,展示一点一点改进的过程。长度尽量精简,希望你看到最后。

壹、是什么

项目主页:https://github.com/go-bindata/go-bindata

官方自述:

This package converts any file into managable Go source code. Useful for embedding binary data into a go program. The file data is optionally gzip compressed before being converted to a raw byte slice.

简单说,将 任意 文件转成 go 源码。它还可以帮你把数据 压缩一下 。常用于将数据嵌入程序。

这些资源文件变成源码之后,数据储存在字节切片中 (raw byte slice),只需要导入生成的源码,调用几个简单的函数就可访问,反正比 文件IO 来得 简单和快 。因为是源码,也会加入编译,最后 包含在可执行文件中 ,发布时也就不再需要带着资源文件。

原文写的是二进制数据,大概作者认为纯文本和字面量本身就可以在程序中声明。但下面你会看到,即使是这些数据,也有使用 go-bindata 的必要。

贰、安装

1
go get -u github.com/go-bindata/go-bindata/...

一行命令,没什么好说的。三个点是指检查并安装所有子目录(如果有可以编译的 main 函数)。实际上提供的 CLI 工具在 go-bindata 子目录里,也就是 github.com/go-bindata/go-bindata/go-bindata/ ,三个 go-bindata 从前到后分别是 organ 名、项目名、目录名。

你可能会发现,这里提供的地址,跟其他一些文章给的不一样。为了不把前面拖长,背后的故事放到了 最后

叁、使用

先看帮助信息(当前版本 v3.1.2 。篇幅关系,省略了详细内容,你还是安装之后自己执行一遍吧。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
go-bindata --help # 虽然参数列表里没有 --help, 但是确实起效了,-h 也有效
Usage: go-bindata [options] <input directories>
-debug
-dev
-fs
-ignore value
-mode uint
-modtime int
-nocompress
-nomemcopy
-nometadata
-o string (default "./bindata.go")
-pkg string (default "main")
-prefix string
-tags string
-version

基本的用法 是直接命令 + 目录,参数全部走默认(生成 ./bindata.go ,包名 main),只包含目标目录,不包括子目录。

1
go-bindata data/

但这样用过于粗糙,特别是生成的源码直接放在根目录的 main 包下,不方便管理。

网上比较 常见的用法 是这样:

1
go-bindata -o=asset/asset.go -ignore="\\.DS_Store|desktop.ini|README.md" -pkg=asset template/... theme/... doc/...

做出的改进有:

  • 为了更好地管理生成的源码,(-o)指定输出的目录和文件名,(-pkg)给一个独立的包名(为了减少 import 时的认知负担,建议三者直接保持一致)。
  • 目标目录可以有多个,三句点省略号表示 递归包含子目录
  • 为了避免一些常见的文件被当作资源文件编译进去,(-ignore)添加一个 ignore pattern,注意用的是 正则表达式

而我更 推荐的用法 是尽量把资源文件集中放在一个目录下面,避免这里放一点那里放一点,最后生成时遗漏。例如统一放 assets 目录:

1
go-bindata -o=bindata/bindata.go -ignore="\\.DS_Store|desktop.ini|README.md" -pkg=bindata -prefix=assets assets/...
  • assets 目录放了资源文件之后,为了避免混淆,也为了一眼能看出来是 go-bindata 生成的代码,源码路径、文件名 和 包名,都统一改为了 bindata
  • 资源既然统一放在 assets 目录下,增加 -prefix 参数,在生成的代码中去掉公共前缀。这样就可以直接用 abc.png 来引用 assets/abc.png

执行这行命令得到的 ./bindata/bindata.go 大概是这样子的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// Code generated by go-bindata. DO NOT EDIT. @generated
// sources:
// assets/web.toml
// ...
// 被转换的文件清单...
package bindata
import (/*...*/)
// 私有结构体、函数定义
// ...
// 数据以私有 []byte 的方式保存
// ...
// 篇幅关系,只展示公共 API
// Asset 根据文件名读取文件内容的 []byte,出错返回 error
func Asset(name string) ([]byte, error) {/*...*/}
// MustAsset 跟 Asset 的区别只在于出错不返回 error ,直接 panic
func MustAsset(name string) []byte {/*...*/}
// AssetInfo 根据文件名返回文件信息
func AssetInfo(name string) (os.FileInfo, error) {/*...*/}
// AssetNames 返回所有文件名
func AssetNames() []string {/*...*/}
// AssetDir 返回指定目录下的所有文件,可以近似看作 ls / dir 命令
// 参数从根目录算起,空串 "" 当作根目录
func AssetDir(name string) ([]string, error) {/*...*/}
// RestoreAsset 将 name 指定的文件,恢复到 dir 指定的位置上
func RestoreAsset(dir, name string) error {/*...*/}
// RestoreAssets 是递归版的 RestoreAsset,如果 name 是目录,会递归执行下去
func RestoreAssets(dir, name string) error {/*...*/}

除此以外,再了解一下 -debug-dev 参数,就基本够用了,更多参数完全可以看着帮助信息自己试。

加了这两个参数(的其中一个),转换时不会真的把资源放进生成的源码,还是去原来的文件读,只是做了一层 API 封装。这样有利于开发和资源设计并行。

  • 代码已经生成,开发可以基于生成的 API 进行,背后究竟读硬盘上的文件还是内存里的切片,不影响调用。
  • 各种资源文件还可以继续修改,只要在原有的文件上修改,没有新增文件或者重命名,就不需要重新执行 go-bindata 重新生成。频繁修改资源文件,调试时很容易忘掉是否有重新执行 go-bindata 。这个特性就特别有用。

-debug-dev 之间的差别,仅仅是背后加载文件时,使用 绝对路径 还是 相对路径。这个看调试方便,差别不是特别大,正式的构建时时一定要关掉的。

肆、自动生成

已经有固定的命令 + 参数搭配了,但是每次执行,不要说手敲麻烦又易错,就连复制粘贴都是体力活。

更不要说修改完资源容易忘掉重新执行转换。这时候就需要 go generate 和 make 出场了。

关于 go generate 的详细介绍和用法,请自行搜索,或者等我后续介绍。

在 Windows 下配置 make 的方法,已经在前面几篇介绍 go 开发环境配置中写了。后续也考虑介绍 Makefile 的写法。

anyway,两个都只讲用到的,这里不详细展开。

go generate

go generate 是 go 工具链自带的命令,自 go 1.4 之后提供。(写文章时最新是 1.14,我还在用 1.13)

只要在某个 go 源文件开头写(注意 //go:generate 前面和中间没有任何空格,冒号是半角冒号)

1
2
3
4
5
6
7
8
9
//go:generate go-bindata -o=bindata/bindata.go -ignore="\\.DS_Store|desktop.ini|README.md" -pkg=bindata assets/...
package xyz
import (
"abc"
)
//...

这之后只要执行

1
go generate

工具链就会自行扫描项目所有源码里的 //go:generate <cmd> [args]... ,执行里面的 cmd args ,包括但不限于 go-bindata,任何在当前工作目录可以执行的命令,都行。

建议哪里的代码引用了资源文件,这行指令就放那个源码的开头。如果多处引用,则建议统一放程序入口。

go generate + make

但 generate 只是解放了一长串命令 + 参数 的记忆负担,对强迫症来说,每次修改资源都要记得 go generate 仍然很难受。这时可以用 make 减轻负担。因为重点不是介绍 make,直接上结论:

1
2
3
4
5
6
7
8
9
10
11
12
13
.PHONY: bindata build
all: build
# build 依赖 bindata.go,这样构建时就不会忘掉生成
build: bindata/bindata.go
go build # 真实项目中 go build 应该加上更多编译参数,这里不是重点,省略
bindata:
go generate
bindata/bindata.go: assets/*
go generate

稍微解释一下 bindatabindata/bindata.go 两个 target :它们都是要执行 go generate 命令,生成转换后的源码,区别在于,前者是 伪目标 , 后者是真实文件。

  • bindata :不存在这么一个文件,而且前面显式声明了它是一个伪目标 (phony target) ,意味着构建这个目标时,不需要判断文件是否存在和新旧,必定执行。可以用来在特殊情况下强制执行(如资源文件通过 cp -p 从别的地方拷贝过来覆盖过)。
  • bindata/bindata.go :是真实的文件。make 会比较 target 和 prerequisites 是否存在和修改时间先后判断是否执行。bindata/bindata.go 存在且最新时,是不会执行命令的。

这个实际试一下就明白了:

1
2
3
4
5
6
7
8
9
10
11
12
# 前面已经生成了最新的 bindata/bindata.go
make bindata/bindata.go
make: 'bindata/bindata.go' is up to date.
# make bindata 仍然有效
make bindata
go generate # 这行是 make 的 echo,说明 go generate 执行了
# 这时更新一下其中一个资源的时间
touch assets/web.toml
make bindata/bindata.go
go generate # 同上,这行是 make 的 echo

make 就够了

不过这样也还是有问题。

随着加入更多的代码生成工具,像 stringer (自动生成 String 方法),wire(自动生成依赖注入),protobufs(从 .proto 生成 .pb.go)等等,都要靠 go generate 触发。这时 粒度 就有点粗了,明明只是其中一种修改了要重新生成,偏偏一个 go generate 全部都触发。文件少的时候还好,多的时候就会平白增加生成和磁盘读写的时间。

而且分散在各个 go 文件注释中的 go generate 指令也增加了管理难度。

其实就大多数生成命令而言,make 就够用了。go generate 能做到的事情,make 基本都可以完成,还能定义宏和依赖关系,更加灵活。全局的生成指令建议 都放到 Makefile。只有个别生成指令跟某个 go 源文件高度相关、参数各处不一样,可以继续放在注释里,靠 go generate 调用。

修改之后的 Makefile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 这里只是为了演示,实际中不改动的部分没有必要定义宏
# 或者定义一个 BINDATA_NAME 统一用它就好
BINDATA_PATH = bindata
BINDATA_NAME = bindata
BINDATA_PACKAGE = bindata
BINDATA_DIR = assets
# ignore list 还是建议定义一个宏,方便随时添加
BINDATA_IGNORE = "\\.DS_Store|desktop.ini|README.md"
.PHONY: bindata build
all: build
build: bindata/bindata.go
go build
# 这两个 target 执行的命令一样,合并成一条规则
bindata bindata/bindata.go: $(BINDATA_DIR)/*
go-bindata -o=$(BINDATA_PATH)/$(BINDATA_NAME).go -ignore=$(BINDATA_IGNORE) -pkg=$(BINDATA_PACKAGE) -prefix=$(BINDATA_DIR) $(BINDATA_DIR)/...

伍、配置文件嵌入

回到文章开头提出的场景。

嵌入资源文件,是为了保持单文件发布的优势。各种资源嵌入源码后,不仅应用变成了单个可执行文件,数据还做了(Gzip)压缩。直接从代码区读取数据,也比磁盘 IO 要来的快捷可控。基本上只要文件不是非常巨大,资源嵌入都是利大于弊的。

而对于配置文件而言,要考虑得多一些。要允许用户修改配置,代码中的配置是无法修改的。这面临几个选择:

  1. 应用单文件发布,配置文件让用户自行创建。

    开发角度看很方便。但对用户不友好,特别是开源项目。用户面对的只有可执行文件,只能尝试执行、启动,或者看一下 help 信息。对于如何、在哪创建配置文件,该怎么写,新用户 毫无头绪 。这样做需要项目文档相对完善,文档中有配置的章节,并且文档入口放在项目主页显眼的地方,最好在 help 信息里也有。

  2. 可执行文件带着默认的配置文件,打包发布。

    这种做法对用户友好一些。但首先享受不到单文件发布的便利。而且用户一旦不小心错误覆盖、或者删除了配置文件,仍然陷入了第一种情况,需要从头手敲配置。

    一种改进是将默认配置加上 .sample 后缀,用户启用了配置文件需要拷贝一份后去掉多余的后缀。这看起来是个好办法,把上述问题除了单文件发布都解决了。我用过这个方案。实际中发现哪怕仅仅拷贝重命名,对于小白用户而言还是 过于复杂,能产生各种开发者想象不到的问题。(Windows 上隐藏了后缀名,怎么改都不对;直接把 sample 文件覆盖了,出错了不知道拿什么做参考…)

经过不同的尝试,我认为比较好的办法是:

  • 把默认配置嵌入代码,单文件发布;
  • 在某个时机,将默认配置重新写回磁盘,用户在这个文件基础上修改配置;(这个时机可以是一个显式的 install 操作,也可以是启动时发现还没有配置文件,等等,根据需要实现)
  • 如果因为某些原因丢失了配置,重新生成默认配置即可。

代码实现(假定默认配置为 assets/web.toml,已经按上面最后的配置转换成 bindata/bindata.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package config
import (
"path/filepath"
"playground/bindata"
)
const (
customDir = "custom"
configFile = "web.toml"
)
type Config struct {
// ...
}
func Load() *Config {
maybeRestoreConfigFile()
return loadConfigFromFile()
}
func maybeRestoreConfigFile() {
if !isFile(filepath.Join(customDir, configFile)) {
// 如果配置文件不存在,先将默认配置恢复到目标位置
bindata.RestoreAsset(customDir, configFile)
}
}
func loadConfigFromFile() *Config {/*...*/}
func isFile(path string) bool {/* 判断一个路径是不是一个文件 */}

篇幅关系,这是一个非常精简的例子,省略了很多错误判断,不重要的函数也把实现删掉了。最后外部直接调用 config.Load() ,无论原本是否有配置文件,都能加载到配置。

如果配置比较复杂,不想静默地生成一个默认配置,可以显式地加入一个 install 之类的命令,引导用户填写一些配置,再结合默认配置生成。但总体上是这么个思路。

最后

提一下 go-bindata 项目之前的一些周折。

如果你搜索 go-bindata 的文章,会发现早期的文章指向的项目地址往往是:https://github.com/jteeuwen/go-bindata 。那是最早的项目地址,jteeuwen 是原作者 Jim Teeuwen 的账号。

但不知道什么时候,因为什么原因,原作者把项目关闭了,连 jteeuwen 这个账号都删除了。(从现存线索推断,大约是 2018 年的事)

现在原地址也有一个项目,但已经 不是原项目 ,也 不再维护 了。那是有人发现 go-bindata 删除后,为了让依赖它的项目不会报错,重新注册了 jteeuwen 这个账号,重新 fork 了这个项目 (真正原项目已删,是从一个 fork 那里 fork 的) 。因为初衷是让某个项目能够继续工作(据说是已经没法修改的私人项目,所以也不能指向新的地址),并没有打算继续维护,也不想冒充原项目,所以这个项目设为了 archived (read only)。详情可以参考以下讨论:

现在给出的项目地址,不确定跟原作者有没有关系——估计是没有的。那它不过是众多 fork 的其中一个。选它仅仅因为它最活跃、关注人数最多。这可能跟它挂在了同名 organization 下有一定关系,也可能里面有某个大牛。

理由并不重要,只需要知道它最活跃是一个共识,就够了。


知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。