道理我都懂,但 go embed 究竟该怎么用?

道理我都懂,但 go embed 究竟该怎么用?

编程那点事 随便写写 Go 1948 字 / 4 分钟

Go 1.16 发布!

就在前几天,Go 1.16 赶在二月的末尾发布了。

对于这个版本我期待了很久,因为官方终于从语言层面解决了静态文件嵌入的问题—— 加入了 go embed。从此,像 go-bindata、statik、togo 等库都将退出历史的舞台。 同时 Go 1.16 配套的加入了 io/fs 标准库,提供了实现文件系统的接口。同时对 httpembedos 标准库都加入了对 fs 库的支持。 我记得之前用 togo 做静态资源嵌入时,togo 生成的 .go 文件中是它自己实现了 http/fs 中的 FileSystem 接口,以此实现了一个内部的文件系统。现在可以通过的 io/fs 实现一个基本的文件系统,再通过 http.FS 转换给 http 库使用。可以说 io/fs 库打通了其它标准库中对文件系统转换的需求。

我们常用读写文件的 io/ioutil 库也在 1.16 中做了改动,因为社区反映 ioutil 这个名字模棱两可,遂将 io/ioutil 中的包给废弃了。 具体变动如下:

BeforeAfter
Discardio.Discard
NopCloserio.NopCloser
ReadAllio.ReadAll
ReadDiros.ReadDir
ReadFileos.ReadFile
TempDiros.MkdirTemp
TempFileos.CreateTemp
WriteFileos.WriteFile

需要指出的是,上文中我提到的“废弃”,且版本的英文说明用词是Deprecated但并不意味着 io/ioutil 在未来的 Go 版本中将被移除。 我们仍然可以使用,但是 IDE 会加上横线并提示不推荐使用。Russ Cox 也发推明确说明 io/ioutil 库并不会被“移除”。想想也是,Go 是保证向后兼容的嘛。

以上就是对 Go 1.16 更新的大致介绍,可以看到大多改动都围绕着文件处理。今天想来重点聊聊其中的 go embed,网上关于 go embed 的文章有很多,但是鲜有文章提到 go embed 在我们的实际项目中究竟应该如何使用。

一看就会,一用就废

我摸索了挺久才发现一个比较优雅的写法,并成功将 go embed 用到了我前阵子写的 Elaina 中。

在开始介绍之前,我们先来复习一下 go embed 的使用方法、三种数据格式以及对应的注意事项。 go embed 通过注释的形式进行使用。例如:

import (
	_ "embed"
)

//go:embed readme.md
var intro string

这样就将 readme.md 文件的内容嵌入到了 intro 变量中。Go 能够允许嵌入的变量类型有如下三种:

变量类型说明
[]byte用于存储二进制形式的数据,比如图片、富媒体等。
string用于存储 UTF-8 编码的字符串。
embed.FS用于嵌入多个文件和目录的结构。

如果变量类型有误,程序将在编译期间报错。

需要特别注意的是:

`go embed` 仅能嵌入当前目录及其子目录,无法嵌入上层目录。同时也不支持软链接。

更绝的是,go emebd 禁止嵌入如 .git .svn 这些目录,官方认为这些目录不属于 package 的一部分,如果嵌入则会在编译时报错。可参见 Go 源码src/cmd/go/internal/load/pkg.go#L2091-2107

// isBadEmbedName reports whether name is the base name of a file that
// can't or won't be included in modules and therefore shouldn't be treated
// as existing for embedding.
func isBadEmbedName(name string) bool {
	if err := module.CheckFilePath(name); err != nil {
		return true
	}
	switch name {
	// Empty string should be impossible but make it bad.
	case "":
		return true
	// Version control directories won't be present in module.
	case ".bzr", ".hg", ".git", ".svn":
		return true
	}
	return false
}

我原本还想着通过 go embed 在程序编译时读取 .git/config 配置敏感信息的…… 💔

三种嵌入文件的情况

在 Elaina 项目中使用 go emebd 时,我遇到了三种不同的目录结构,这三种目录结构也大致囊括了我们在实际项目会遇到的场景。这里分享一下我的做法。

嵌入多个文件

在一个 Web 应用项目中常会有 templates 目录,存放了 HTML 的模板文件,它们常以 .tmpl 或者 .html 作为后缀名。

.
├── sandbox.tmpl
└── sandbox_404.tmpl

要嵌入这些模板文件,我们可以在 templates 目录下创建一个 fs.go 文件:

package templates

import (
	"embed"
)

//go:embed *.tmpl
var FS embed.FS

这样就将所有的 .tmpl 后缀的文件嵌入进了 FS 变量中。 后面在路由中使用 html/template 库来从文件系统中加载并解析模板。以下是在 Gin 框架中的示例:

tpl := template.Must(template.New("").ParseFS(templates.FS, "*"))
r.SetHTMLTemplate(tpl)

嵌入多个目录

一个 Web 应用项目下往往还会有个 public 目录,其用于存储所有的静态资源。目录下会有诸如 css js assets 这样的子目录。

.
├── css
│   └── sandbox.css
└── js
    └── sandbox.js

这一次是嵌入多个目录,我们可以效仿上面的做法,在 public 目录下创建一个 fs.go 文件:

package public

import (
	"embed"
)

//go:embed css js
var FS embed.FS

在注册路由时,Gin 的 StaticFS 需要一个实现了 http.fsFileSystem 接口的变量。这里我们使用 http.FS 方法,将 fs.FS 转换成 FileSystem。二者其实都是只需实现 Open(name string) (File, error) 这个方法即可。

r.StaticFS("/static", http.FS(public.FS))

嵌入子目录

有时我们的项目是前后端分离的,需要将打包编译好的前端嵌入进来。编译好的前端往往会在 dist 目录下。

.
├── css
│   ├── app.3ca5488f.css
│   └── chunk-vendors.08a0794a.css
├── index.html
├── js
│   ├── app.1bdd8cf2.js
│   ├── app.1bdd8cf2.js.map
│   ├── chunk-2d0ac239.c72b0c7d.js
│   ├── chunk-2d0ac239.c72b0c7d.js.map
├── manifest.json
├── precache-manifest.a2e4eb7c729e7ecf28ada54a6ea672b4.js
└── service-worker.js

而我们并不能效仿前两种情况,创建一个 fs.go 文件在 dist 目录下。原因有两点:

  1. dist 目录往往是写在 .gitignore 中被忽略的。
  2. dist 中既有文件又有目录,若指定其嵌入 * 的话,fs.go 文件也会被嵌入进来。

因此这里我们将 fs.go 放置于 dist 的父目录中。文件内容还是类似的:

package frontend

import (
	"embed"
)

//go:embed dist
var FS embed.FS

需要注意的是,若直接使用 `frontend.FS` 注册路由,所有的文件路径都会有 `dist/` 前缀。我们需要通过形如 `http://localhost:8080/dist/index.html` 的地址进行访问,这显然不是我们想要的。
因此,这里需要使用 fs.Sub() 方法,来进入 frontend.FS 的下层文件夹,并返回一个新的 FS

fe, err := fs.Sub(frontend.FS, "dist")
if err != nil {
	log.Fatal("Failed to sub path `dist`: %v", err)
}
r.StaticFS("/m", http.FS(fe))

其实前两种情形都可以用这第三种 fs.Sub() 进入目录来解决(即分别进入 templates public 目录)。 但这将失去变量 templates.FS public.FS 这些清晰易懂的包名命名。

总结

以上就是我摸索出的 go embed 在实际项目中的使用方式。可能不大准确,欢迎大家纠正以及提出你所认为的最佳实践。