Go go:embed 实战:把前端静态资源打进单个二进制文件
部署一个带前端页面的 Go 服务,你是不是这么干的:编译出server二进制,再单独拷一个static/目录到服务器,nginx或程序里http.Dir("./static")指过去。结果换了台机器忘了拷目录,服务起来了页面却 404。
Go 1.16 引入的//go:embed能把这些静态文件直接编进二进制。最后交付的就是一个文件,拷过去就能跑,不再有「文件找不到」的部署事故。这篇从最简单的嵌入讲到给前端 SPA 做路由回退的完整实战。
最朴素的做法:运行时读文件的坑
先看不用 embed 的老写法哪里痛:
// 运行时才去磁盘找文件,路径依赖当前工作目录http.Handle("/",http.FileServer(http.Dir("./static")))问题有三个:一是二进制和static/目录必须一起部署,少一个就废;二是路径./static依赖启动时的工作目录,cd到别处再启动就找不到;三是没法保证运行环境的文件和你编译时用的是同一份。embed 把「读文件」从运行时提前到了编译时,三个问题一起消失。
嵌入单个文件
//go:embed是一个编译指令(注释形式),写在变量声明的正上方,不能有空行隔开。
packagemainimport(_"embed"// 用 embed 指令但不直接调用它的 API 时,必须空导入"fmt")//go:embed version.txtvarversionstring// 文件内容直接读进字符串funcmain(){fmt.Println("当前版本:",version)}注意两个细节:嵌入成string或[]byte时,只需要空导入_ "embed"(不调用其 API 但要触发编译器识别指令);//go:embed和变量声明之间不能有空行,否则指令失效但不报错,变量会是空的——这是新手最常踩的坑。
嵌入整个目录:embed.FS
真正实用的是嵌入整个目录。这时变量类型要用embed.FS,它实现了fs.FS接口:
packagemainimport("embed""net/http")//go:embed staticvarstaticFiles embed.FS// 把整个 static 目录嵌进来funcmain(){// embed.FS 直接就是一个文件系统,交给 http.FileServerhttp.Handle("/",http.FileServerFS(staticFiles))http.ListenAndServe(":8080",nil)}http.FileServerFS是 Go 1.22 加的,直接吃fs.FS。老版本用http.FileServer(http.FS(staticFiles)),效果一样。
嵌入的路径规则要记牢:
- 路径相对于含该指令的
.go文件所在目录,不是模块根目录。 - 可以用多个模式:
//go:embed static templates config.json。 - 默认不包含以
.或_开头的文件(如.gitignore)。要包含得用all:前缀://go:embed all:static。
坑:嵌入后的路径带着目录前缀
上面那样嵌入static目录,访问时路径会变成/static/index.html,因为embed.FS保留了static/这层前缀。用户显然希望访问/index.html。用fs.Sub剥掉这层前缀:
import("embed""io/fs""net/http")//go:embed staticvarstaticFiles embed.FSfuncmain(){// 剥掉 static/ 前缀,让 index.html 直接位于根sub,err:=fs.Sub(staticFiles,"static")iferr!=nil{panic(err)// 编译期就嵌好了,这里出错说明目录名写错}http.Handle("/",http.FileServerFS(sub))http.ListenAndServe(":8080",nil)}fs.Sub返回一个以static为根的子文件系统,这样/就直接对应static/index.html。
实战:给前端 SPA 做路由回退
React、Vue 这类单页应用,前端路由(如/users/123)在服务端并没有对应文件。直接用FileServer,刷新这类页面会 404。正确做法是:静态资源存在就返回文件,不存在就回退到index.html,交给前端路由处理。
packagemainimport("embed""io/fs""net/http")//go:embed all:distvardist embed.FSfuncmain(){sub,_:=fs.Sub(dist,"dist")fileServer:=http.FileServerFS(sub)http.HandleFunc("/",func(w http.ResponseWriter,r*http.Request){// 去掉开头的 /,fs 的路径不能以 / 开头path:=r.URL.Pathifpath!="/"{path=path[1:]}else{path="index.html"}// 文件存在就正常伺服,不存在就回退到 index.htmlif_,err:=fs.Stat(sub,path);err!=nil{r.URL.Path="/"// 重写到根,让下面返回 index.htmlhttp.ServeFileFS(w,r,sub,"index.html")return}fileServer.ServeHTTP(w,r)})http.ListenAndServe(":8080",nil)}核心是用fs.Stat判断请求的路径在嵌入的文件系统里是否真实存在:存在(如/assets/app.js)就正常返回,不存在(如前端路由/users/123)就返回index.html。这样刷新任何前端路由都不会 404。
配合构建:先打前端再编 Go
实际项目里,dist目录是前端构建产物。嵌入要求编译时该目录已存在,所以顺序是:先跑前端构建,再编 Go。用 Makefile 固化:
build: cd web && npm run build # 产出 web/dist cp -r web/dist ./dist # 拷到 embed 指令能找到的位置 go build -o server . # 此时 dist 已就位,嵌入成功 .PHONY: build如果dist目录不存在,go build会直接报pattern dist: no matching files found——这是好事,编译期就拦住了缺文件的问题,而不是等到线上 404。
什么时候别用 embed
embed 不是银弹。资源会算进二进制体积,几十 MB 的前端产物会让二进制显著变大,进程常驻内存也更高。以下场景更适合外置文件:
- 静态资源特别大(视频、大图库),不想让二进制膨胀。
- 资源需要独立于程序更新(改个页面不想重新编译发版)。
- 需要运维直接改配置文件的场景。
小到中等体积的前端产物、模板、默认配置,embed 是最省心的;大资源或需热更新的,还是外置。
小结
//go:embed把静态文件编进二进制,交付单文件、消灭「文件找不到」的部署事故。- 嵌
string/[]byte空导入_ "embed";嵌目录用embed.FS;指令与变量间不能有空行。 - 路径相对当前
.go文件目录;./_开头文件要all:前缀才包含。 - 目录前缀用
fs.Sub剥掉;SPA 用fs.Stat判断存在与否做index.html回退。 - 构建顺序是先打前端再编 Go;大资源或需热更新的别嵌。
一句话记忆:embed 把「运行时读文件」变成「编译时进二进制」,部署从拷目录变成拷一个文件。