前言

在写项目的时候,有时候不可避免地要处理静态文件,如果将源码直接作为软件提供问题不大,使用相对路径读取这些静态文件就可以了。但是如果项目作为库向外公布显然不可行,使用相对路径是读取不到文件的,而使用绝对路径却会带来更大的问题:因为不同的人使用,路径绝对不可能完全一致的。如果要求用户在指定路径下放置这些依赖的静态文件,虽然可行但是会给用户带来很大的困扰,而且这样的实现方式显然不够优雅。这时候,将这些静态文件一起打包进可执行文件似乎是一个完美的解决方案,那么如何实现呢?最简单的方法是硬编码,将静态文件以文本或字节数组的形式直接编入源代码,go 也有一些库帮你自动生成代码,比如 go-bindata。很明显,这个库已经终止维护了,这是因为在 go 1.16 版本,官方发布了 embed 完美地解决了这个问题。本文简要介绍 embed 的一些基础用法。

embed

假设我们有一个文件 hello.txt

Hello World!
Hello go embed!

我们要写一个程序读取其中的内容并输出到终端:

// file: main.go
package main

import (
	"fmt"
	"os"
)

func main() {
	s, err := os.ReadFile("./hello.txt")
	if err != nil {
		panic(err)
	}
	fmt.Println(string(s))
}

很简单,不是吗?

➜ tree .
.
├── go.mod
├── hello.txt
└── main.go

0 directories, 3 files

➜ go build main.go

➜ ./main                             
Hello World!
Hello go embed!

但是如果 hello.txt 这个文件不存在的话,我们再次运行程序:

➜ rm hello.txt

➜ tree .
.
├── go.mod
├── main
└── main.go

0 directories, 3 files

➜ ./main  
panic: open ./hello.txt: no such file or directory

goroutine 1 [running]:
main.main()
	/home/aimerneige/Code/go/go-embed-tutorial/main.go:11 +0x96
exit status 2

显然,因为读取不到文件,程序在第 11 行 panic 了。那么如何将 hello.txt 也一并打包入可执行文件呢?

我们先不使用 embed,而是直接将文件内容硬编码,我们就会得到这样的代码:

// file: main.go
package main

import (
	"fmt"
)

var f string = `Hello World!
Hello go embed!`

func main() {
	fmt.Println(f)
}

显然我们可以得到相同的结果:

➜ go build main.go

➜ tree .
.
├── go.mod
├── main
└── main.go

0 directories, 3 files

➜ ./main 
Hello World!
Hello go embed!

这里,我们使用了一个字符串 f 来存储文件中的内容。但是直接将文本内容放入源代码十分不优雅,尤其是当文本文件内容较多的时候,源代码就会太混乱,改动也比较麻烦。

接下来,我们恢复文件 hello.txt 后使用 embed 来优化它:

// file: main.go
package main

import (
	_ "embed"
	"fmt"
)

//go:embed hello.txt
var f string

func main() {
	fmt.Println(f)
}

首先,我们下划线引入了一个包 _ "embed",这样做是为了引入 embed 包来执行它的 init 函数而不使用它。有了这个包以后,我们就可以以注释的方式来将一个文件的内容绑定到一个变量上:

//go:embed hello.txt
var f string

这个变量就会以字符串的形式存储文件 hello.txt 中的数据。

这时候,我们在 hello.txt 文件存在的情况下编译程序:

➜ go build main.go

➜ ./main 
Hello World!
Hello go embed!

简单执行文件我们当然可以得到相同的输出,但是如果这个时候我们删除 hello.txt 会发生什么呢?

➜ rm hello.txt

➜ tree .
.
├── go.mod
├── main
└── main.go

0 directories, 3 files

➜ ./main 
Hello World!
Hello go embed!

正如预期的那样,我们依然得到了相同的输出,这是因为 hello.txt 中的内容已经编码到了可执行文件中了。

类似地,我们还可以将图片编码为字节数组:

// file: main.go
package main

import (
	"bytes"
	_ "embed"
	"image/jpeg"
	"image/png"
	"os"
)

//go:embed avatar.jpg
var imgData []byte

func main() {
	img, err := jpeg.Decode(bytes.NewReader(imgData))
	if err != nil {
		panic(err)
	}
	f, err := os.Create("./avatar.png")
	if err != nil {
		panic(err)
	}
    defer f.Close()
	png.Encode(f, img)
}
➜ tree .
.
├── avatar.jpg
├── go.mod
└── main.go

0 directories, 3 files

此时,我们编译 main.go,得到的可执行文件可以在任意目录下执行,并且都会在同级目录下输出一个 avatar.png

➜ go build main.go

➜ rm avatar.jpg

➜ ./main

➜ tree .
.
├── avatar.png
├── go.mod
├── main
└── main.go

0 directories, 4 files

注意 embed 的路径是相对于源代码文件而言的。如下例:

➜ tree . 
.
├── go.mod
├── hello.txt
├── internal
│   ├── asserts
│   │   └── example.png
│   ├── internal.go
│   └── internal.txt
└── main.go

2 directories, 6 files
// file: internal/internal.go
package internal

import (
	_ "embed"
)

//go:embed internal.txt
var txt string

//go:embed asserts/example.png
var png []byte

internal 包中,我们引用的俩个文件 internal/internal.txtinternal/asserts/example.png 的路径不是项目的相对路径,而是源文件 internal/internal.go 的相对路径。

上面的写法已经能够覆盖大部分的场景了,但是,如果我们的文件很多要怎么办呢?这时就需要使用 embed.FSembed.FS 可以将文件或文件夹打包入可执行文件,并抽象为 FS 以供调用:

// file: main.go
package main

import (
	"bytes"
	"embed"
	"fmt"
	"image/png"
	"io/fs"
)

//go:embed resources/images
var images embed.FS

//go:embed resources/text
var texts embed.FS

//go:embed resources
var resources embed.FS

func main() {
	// read images
	for i := 1; i <= 5; i++ {
		filePath := fmt.Sprintf("resources/images/Screenshot-%d.png", i)
		imgFile, err := fs.ReadFile(images, filePath)
		if err != nil {
			panic(err)
		}
		image, err := png.Decode(bytes.NewReader(imgFile))
		if err != nil {
			panic(err)
		}
		fmt.Printf("size of %s: %dx%d\n", filePath, image.Bounds().Dx(), image.Bounds().Dy())
	}
	// read text
	for i := 1; i <= 2; i++ {
		filePath := fmt.Sprintf("resources/text/example%d.txt", i)
		example, err := texts.ReadFile(filePath)
		if err != nil {
			panic(err)
		}
		fmt.Printf("text of %s: %s\n", filePath, string(example))
	}
	// read sub dir file
	license, err := resources.ReadFile("resources/license/LICENSE")
	if err != nil {
		panic(err)
	}
	fmt.Println(string(license))
}
➜ tree .            
.
├── go.mod
├── main.go
└── resources
    ├── images
    │   ├── Screenshot-1.png
    │   ├── Screenshot-2.png
    │   ├── Screenshot-3.png
    │   ├── Screenshot-4.png
    │   └── Screenshot-5.png
    ├── license
    │   └── LICENSE
    └── text
        ├── example1.txt
        └── example2.txt

4 directories, 10 files

➜ cat resources/license/LICENSE  
EXAMPLE LICENSE FILE

➜ cat resources/text/example1.txt 
example 1

➜ cat resources/text/example2.txt
example 2

➜ go build main.go 

➜ ./main 
size of resources/images/Screenshot-1.png: 1920x1080
size of resources/images/Screenshot-2.png: 1920x1080
size of resources/images/Screenshot-3.png: 1920x1080
size of resources/images/Screenshot-4.png: 1920x1080
size of resources/images/Screenshot-5.png: 1920x1080
text of resources/text/example1.txt: example 1
text of resources/text/example2.txt: example 2
EXAMPLE LICENSE FILE

➜ rm resources -rf

➜ tree .
.
├── go.mod
├── main
└── main.go

0 directories, 3 files

➜ ./main 
size of resources/images/Screenshot-1.png: 1920x1080
size of resources/images/Screenshot-2.png: 1920x1080
size of resources/images/Screenshot-3.png: 1920x1080
size of resources/images/Screenshot-4.png: 1920x1080
size of resources/images/Screenshot-5.png: 1920x1080
text of resources/text/example1.txt: example 1
text of resources/text/example2.txt: example 2
EXAMPLE LICENSE FILE

要注意的是,我们读取文件时提供的路径一定是完整的相对路径。

embed.FS 在 http 静态服务中非常有用:

main.go

// file: main.go
package main

import (
	"embed"
	"fmt"
	"io/fs"
	"net/http"
)

//go:embed public
var public embed.FS

func main() {
	mux := http.NewServeMux()
	mux.Handle("/", handler())
	http.ListenAndServe(":8080", mux)
}

func handler() http.Handler {
	f := fs.FS(public)
	html, err := fs.Sub(f, "public")
	if err != nil {
		panic(err)
	}
	fmt.Println("Server start at http://127.0.0.1:8080/")
	return http.FileServer(http.FS(html))
}

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Hello World</title>
</head>
<body>
    <h1>Hello World</h1>
    <p>This is a simple example index.html</p>
    <p>Click About bellow to see about page.</p>
    <a href="./about.html">About</a>
</body>
</html>

about.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>About</title>
</head>
<body>
    <h1>About</h1>
    <p>This is a simple about.html page.</p>
    <p>Click bellow Index to back to index page.</p>
    <a href="./index.html">Index</a>
</body>
</html>
➜ tree .
.
├── go.mod
├── main.go
└── public
    ├── about.html
    └── index.html

1 directory, 4 files

当然还有很多其他神奇玩法,这里只介绍最基本的用法,其他大家发挥想象力。

参考链接