//go:embed 入门

2021-05-24 ⏳4.0分钟(1.6千字)

Go1.16 引入了//go:embed功能,可以将资源文件内容直接打包到二进制文件,方便部署。最近读到 Carl M. Johnson 写的 How to Use //go:embed 一文,写得相当不错。于是联系作者拿到了授权,现译成中文,分享给大家。

我之前写过 how to use //go:generate 一文介绍。通过//go:generate,我们可以在编译之前通过自动运行一些工具来生成代码。现在 Go 有了新功能,好多之前生成代码的地方现在已经用不到了。除了这个逆天的 flag.Func feature新特性(因为这是作者提出并实现的。译者注),Go 1.16还引入了新的 //go:embed 编译指令。 它可以将任何文件或者文件夹的内容打包到编译出的可执行文件中。为了给大家演示 //go:embed 的功能,我创建了一个示例仓库。下面我来详细介绍各个示例。

简单来说,我们可以给代码添加一行特殊的注释来,Go 编译器知道是要嵌入文件还是嵌入文件夹。注释长得像 //go:embed 文件名。注释占一行,紧接着一行是一个变量。如果要嵌入文件,变量的类型得是 string 或者 []byte,如果要嵌入一组文件,变量的类型得是embed.FS

go:embed 指令可以识别 Go 的文件通配符,所以你可以写 files/*.html 来嵌入 files 下的所有 html 文件(但是不支持递归通配符 **/*.html)。

更多技术细节请参考官方文档。接下来我们就通过几个示例来讲解 embed 可能的玩法。

版本信息

我在之前讲 //go:generate的文章中跟大家介绍了如何通过 Go 链接器来给代码嵌入版本自信。//go:embed 则为我们提供了一种更简单的方法来从 version.txt 文本文件中提取版本信息:

package main

import (
    _ "embed"
    "fmt"
    "strings"
)

var (
    Version string = strings.TrimSpace(version)
    //go:embed version.txt
    version string
)

func main() {
    fmt.Printf("Version %q\n", Version)
}

下面是一个更加复杂的例子,我们甚至可以根据编译 tag 的不同动态地嵌入不同的版本信息。

// version_dev.go
// +build !prod

package main

var version string = "dev"
// version_prod.go
// +build prod

package main

import (
    _ "embed"
)

//go:embed version.txt
var version string
$ go run .
Version "dev"

$ go run -tags prod .
Version "0.0.1"

Quine

Quine 是一种可以输出自身源码的程序。利用 go:embed 我们可以轻松实现 quine 程序:

package main

import (
    _ "embed"
    "fmt"
)

//go:embed quine.go
var src string

func main() {
    fmt.Print(src)
}

运行程序,会输出自身源码。

嵌入复杂结构

如果我们有一些非常复杂但又要提前算出来的信息,我们可以把它们写到一个文件,放入项目,然后通过 //go:generate 和模板自动生成对应的 .go 文件(可以参考我之前写的 //go:generate 文章)。我们也可以将数据按照 Go 语言能识别的方式序列化并保存,然后在程序启动的时候加载并反解:

package main

import (
    "bytes"
    _ "embed"
    "encoding/gob"
    "fmt"
)

var (
    // 文件 value.gob 保存了一些复杂的数据,是我们事先算好并保存的。
    //go:embed value.gob
    b []byte
    s = func() (s struct {
        Number   float64
        Weather  string
        Alphabet []string
    }) {
        dec := gob.NewDecoder(bytes.NewReader(b))
        if err := dec.Decode(&s); err != nil {
            panic(err)
        }
        return
    }()
)

func main() {
    fmt.Printf("s: %#v\n", s)
}

网站文件

这应该是 //go:embed 最主要的应用场景之一了。我们现在可以把网站所有的静态文件或者模板文件嵌入到一个可执行文件中。我们甚至还可以能过命令行参数在读磁盘文件和读内嵌文件之间进行转换:

package main

import (
    "embed"
    "io/fs"
    "log"
    "net/http"
    "os"
)

func main() {
    useOS := len(os.Args) > 1 && os.Args[1] == "live"
    http.Handle("/", http.FileServer(getFileSystem(useOS)))
    http.ListenAndServe(":8888", nil)
}

//go:embed static
var embededFiles embed.FS

func getFileSystem(useOS bool) http.FileSystem {
    if useOS {
        log.Print("using live mode")
        return http.FS(os.DirFS("static"))
    }

    log.Print("using embed mode")
    fsys, err := fs.Sub(embededFiles, "static")
    if err != nil {
        panic(err)
    }

    return http.FS(fsys)
}

注间我们需要使用中 fs.Sub 来去除 embed.FS 的目录前缀,这样其行为才能跟 os.DirFS 保持一致。

Ben Johnson 也已弘发布了一个工具包,可以让 embed.FS 按文件内容的哈希值生成文件名。

下面是一个嵌入模板的示例:

package main

import (
    "embed"
    "os"
    "text/template"
)

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

func main() {
    name := "en.tmpl"
    if len(os.Args) > 1 {
        name = os.Args[1] + ".tmpl"
    }
    arg := "World"
    if len(os.Args) > 2 {
        arg = os.Args[2]
    }

    t, err := template.ParseFS(tpls, "*")
    if err != nil {
        panic(err)
    }
    if err = t.ExecuteTemplate(os.Stdout, name, arg); err != nil {
        panic(err)
    }
}

假设 en.tmpl 的内容是 Hello {{ . }}, how are you today? ,再假设 jp.tmpl 的内容是 こんにちは{{ . }}。お元気ですか。,我们会看到如下输出内容:

$ go run ./main.go
Hello World, how are you today?

$ go run ./main.go jp ワールド
こんにちはワールド。お元気ですか。

Gotchas

常见问题

在使用 embed 的时候需要注意一些常见问题。首先,在使用 embed 指令的时候需要导入 embed 包。所以像下面这样的写法是不会生效的:

package main

import (
    "fmt"
)

//go:embed file.txt
var s string

func main() {
    fmt.Print(s)
}
$ go run missing-embed.go
# command-line-arguments
./missing-embed.go:8:3: //go:embed only allowed in Go files that import "embed"

另一方面,Go 又不允许导入没有引用的包。如果相导入一个包但又不引用它,你需要写成 import _ "embed",告诉 go 没有用到也要导入。

其次,你只能给包一级的变量添加 //go:embed 注释。给局部变量添加这种注释会报编译错误:

package main

import (
    _ "embed"
    "fmt"
)

func main() {
    //go:embed file.txt
    var s string
    fmt.Print(s)
}
$ go run bad-level.go
# command-line-arguments
./bad-level.go:9:4: go:embed cannot apply to var inside func

最后,当你要嵌入文件假时,系统会自动掉队以 . 或者 _ 开头的文件。但如果你使用了像 dir/* 这类的通配符,系统会嵌入所有匹配到的文件。即使是以 . 或者 _ 开头的也不例外。注意了,如果你想嵌入一些文件但又不想公开所有文件列表的时候,在 macos 上不滤嵌入 .DS_Store 可能会有安全问题。因此,使用 //go:embed dir/\* 基本都有问题的,应该根据需要使用 //go:embed dir 或者 //go:embed dir/*.ext。出于安全考虑,Go 不支持嵌入文件链接内容和父级目录内容。


文件嵌入应该还有各种千奇百怪的用法。比如说,你是不是可以把文档和许可信息嵌入命令行程序,这样就不需要阅读源码仓库的 README 文件了。可不可以将数据库查询写入一个 .sql 文件再嵌入程序。或者你可以实现一个分层文件系统,将通过 embed.FS 嵌入的内容和用户有提供的内容组合起来……这只是几个抛砖引玉的点子。我相信以后还会有更多巧妙而且想不到的用法。

Go 1.16 于2021年2月16日正式发布。如果你还没升级,今天赶紧试试吧。