道理我都懂,但 go embed 究竟该怎么用?
Go 1.16 发布!
就在前几天,Go 1.16 赶在二月的末尾发布了。
对于这个版本我期待了很久,因为官方终于从语言层面解决了静态文件嵌入的问题—— 加入了 go embed
。从此,像 go-bindata、statik、togo 等库都将退出历史的舞台。
同时 Go 1.16 配套的加入了 io/fs
标准库,提供了实现文件系统的接口。同时对 http
、embed
、os
标准库都加入了对 fs
库的支持。
我记得之前用 togo 做静态资源嵌入时,togo 生成的 .go
文件中是它自己实现了 http/fs
中的 FileSystem
接口,以此实现了一个内部的文件系统。现在可以通过的 io/fs
实现一个基本的文件系统,再通过 http.FS
转换给 http
库使用。可以说 io/fs
库打通了其它标准库中对文件系统转换的需求。
我们常用读写文件的 io/ioutil
库也在 1.16 中做了改动,因为社区反映 ioutil
这个名字模棱两可,遂将 io/ioutil
中的包给废弃了。
具体变动如下:
Before | After |
---|---|
Discard | io.Discard |
NopCloser | io.NopCloser |
ReadAll | io.ReadAll |
ReadDir | os.ReadDir |
ReadFile | os.ReadFile |
TempDir | os.MkdirTemp |
TempFile | os.CreateTemp |
WriteFile | os.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 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.fs
中 FileSystem
接口的变量。这里我们使用 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
目录下。原因有两点:
dist
目录往往是写在.gitignore
中被忽略的。dist
中既有文件又有目录,若指定其嵌入*
的话,fs.go
文件也会被嵌入进来。
因此这里我们将 fs.go
放置于 dist
的父目录中。文件内容还是类似的:
package frontend
import (
"embed"
)
//go:embed dist
var FS embed.FS
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
在实际项目中的使用方式。可能不大准确,欢迎大家纠正以及提出你所认为的最佳实践。
喜欢这篇文章?为什么不打赏一下呢?