副本SaveAndLoad
长年主义者,副本累积。日拱一卒,功不唐捐。
单文件无倚赖正式发布,是 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 的必要。
贰、安装
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 。篇幅关系,省略了详细内容,你却是安装之后自己执行一遍吧。)
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),只包含目标目录,不包括子目录。
go-bindata data/但这样用过于粗糙,特别是生成的源码直接放在根目录的 main 包下,不方便管理。
网上比较 常见的用法是这样:
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 目录:
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 大概是这样子的:
// Code generated by go-bindata. DO NOT EDIT. @generated// sources:// assets/web.toml// …// 被转换的文档清单…package bindataimport (/*…*/)// 私有结构体、函数定义// …// 数据以私有 []byte 的方式保存// …// 篇幅关系,只展现公共 API// Asset 根据文档名读取文档内容的 []byte,出错返回 errorfunc Asset(name string) ([]byte, error) {/*…*/}// MustAsset 跟 Asset 的区别只在于出错不返回 error ,直接 panicfunc 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 前面和中间没任何空格,冒号是半角冒号)
//go:generate go-bindata -o=bindata/bindata.go -ignore=”\\.DS_Store|desktop.ini|README.md” -pkg=bindata assets/…package xyzimport ( “abc”)//…这之后只要执行
go generate工具链就会自行扫描项目所有源码里的 //go:generate <cmd> [args]…,执行里面的 cmd args ,包括但不限于 go-bindata,任何在当前工作目录可以执行的命令,都行。
建议哪里的代码引用了天然资源文档,这行指令就放那个源码的开头。如果多处引用,则建议统一放程序入口。
go generate + make
但 generate 只是解放了一长串命令 + 参数 的记忆负担,对强迫症来说,每次修改天然资源都要记得 go generate 仍然很难受。此时可以用 make 减轻负担。因为重点不是介绍 make,直接上结论:
.PHONY: bindata buildall: build# build 倚赖 bindata.go,这样构建时就不会忘掉生成build: bindata/bindata.go go build # 真实项目中 go build 应该加上更多编译参数,这里不是重点,省略bindata: go generatebindata/bindata.go: assets/* go generate稍微解释一下 bindata 和 bindata/bindata.go 两个 target :它们都是要执行 go generate 命令,生成转换后的源码,区别在于,前者是伪目标, 后者是真实文档。
bindata :不存在这么一个文档,而且前面显式声明了它是一个伪目标 (phony target) ,意味著构建这个目标时,不需要判断文档是否存在和新旧,必定执行。可以用来在特殊情况下强制执行(如天然资源文档通过cp -p 从别的地方拷贝过来覆盖过)。bindata/bindata.go :是真实的文档。make 会比较 target 和 prerequisites 是否存在和修改时间先后判断是否执行。bindata/bindata.go 存在且最新时,是不会执行命令的。这个实际试一下就知道了:
# 前面已经生成了最新的 bindata/bindata.gomake bindata/bindata.gomake: bindata/bindata.go is up to date.# make bindata 仍然有效make bindatago generate # 这行是 make 的 echo,说明 go generate 执行了# 此时更新一下其中一个天然资源的时间touch assets/web.tomlmake bindata/bindata.gogo generate # 同上,这行是 make 的 echomake 就够了
不过这样也却是有问题。
随着加入更多的代码生成工具,像 stringer (自动生成 String 方法),wire(自动生成倚赖注入),protobufs(从 .proto 生成 .pb.go)等等,都要靠 go generate 触发。此时粒度就有点粗了,明明只是其中一种修改了要重新生成,偏偏一个 go generate 全部都触发。文档少的时候还好,多的时候就会平白增加生成和磁盘读写的时间。
而且分散在各个 go 文档注释中的 go generate 指令也增加了管理难度。
其实就大多数生成命令而言,make 就够用了。go generate 能做到的事情,make 基本都可以完成,还能定义宏和倚赖关系,更加灵活。全局的生成指令建议 都放到 Makefile。只有个别生成指令跟某个 go 源文档高度相关、参数各处不一样,可以继续放在注释里,靠 go generate 调用。
修改之后的 Makefile:
# 这里只是为了演示,实际中不改动的部分没必要定义宏# 或者定义一个 BINDATA_NAME 统一用它就好BINDATA_PATH = bindataBINDATA_NAME = bindataBINDATA_PACKAGE = bindataBINDATA_DIR = assets# ignore list 却是建议定义一个宏,方便随时添加BINDATA_IGNORE = “\\.DS_Store|desktop.ini|README.md”.PHONY: bindata buildall: buildbuild: 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 要来的快捷可控。基本上只要文档不是非常巨大,天然资源内嵌都是利大于弊的。
而对于配置文档而言,要考虑得多一些。要允许用户修改配置,代码中的配置是无法修改的。这面临几个选择:
应用领域单文档正式发布,配置文档让用户自行创建。开发角度看很方便。但对用户不友好,特别是开源项目。用户面对的只有可执行文档,只能尝试执行、启动,或者看一下 help 信息。对于如何、在哪创建配置文档,该怎么写,新用户毫无头绪。这样做需要项目文档相对完善,文档中有配置的章节,并且文档入口放在项目主页显眼的地方,最好在 help 信息里也有。可执行文档带着默认的配置文档,打包正式发布。这种作法对用户友好一些。但首先享受不到单文档正式发布的便利。而且用户一旦不小心错误覆盖、或者删除了配置文档,仍然陷入了第一种情况,需要从头手敲配置。一种改良是将默认配置加上 .sample 后缀,用户启用了配置文档需要拷贝一份后去掉多余的后缀。这看起来是个好办法,把上述问题除了单文档正式发布都解决了。我用过这个方案。实际中辨认出哪怕仅仅拷贝重命名,对于小白用户而言却是过于复杂,能产生各式各样开发者想象不到的问题。(Windows 上隐藏了后缀名,怎么改都不对;直接把 sample 文档覆盖了,出错了不知道拿甚么做参考…)经过不同的尝试,我认为比较好的办法是:
把默认配置内嵌代码,单文档正式发布;在某个时机,将默认配置重新写回磁盘,用户在这个文档基础上修改配置;(这个时机可以是一个显式的 install 操作,也可以是启动时辨认出还没配置文档,等等,根据需要实现)如果因为某些原因丢失了配置,重新生成默认配置即可。代码实现(假定默认配置为 assets/web.toml,已经按上面最终的配置转换成 bindata/bindata.go)
package configimport ( “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)。详情可以参考以下讨论:
https://github.com/jteeuwen/go-bindata/issues/5https://github.com/jteeuwen/discussions/issues现在得出的项目地址,不确定跟原
理由并不重要,只需要知道它最活跃是一个共识,就够了。