Go embed 交付一个完整小站:模板、静态路径与脱离工作目录运行
服务二进制复制到新机器,程序启动成功,CSS却全是404。常见原因是代码还在按当前工作目录找assets;本地从项目根目录启动,恰好掩盖了这份运行条件。
Go的embed可以把选定资源编进二进制,部署时不再依赖旁边必须有同一份目录。它解决交付边界,不解决任意热更新;资源修改后需要重新构建,二进制仍然代表当时那份文件。
下面使用Go 1.24.1搭建一个完整小站:模板渲染首页,静态CSS从嵌入文件系统提供,测试路径和HTML转义,再把二进制放到空工作目录启动。路由使用Go 1.22之后的ServeMux模式,旧版本需要另外调整,不能直接照抄。
五个文件形成可交付程序
新建embed-site目录,包含以下结构:
embed-site/
go.mod
main.go
main_test.go
assets/
style.css
templates/
page.html
go.mod:
module example.com/embed-site
go 1.24
main.go:
package main
import (
"bytes"
"embed"
"flag"
"fmt"
"html/template"
"io/fs"
"log"
"net"
"net/http"
"time"
)
//go:embed assets/* templates/*.html
var packaged embed.FS
func newHandler() (http.Handler, error) {
static, err := fs.Sub(packaged, "assets")
if err != nil { return nil, err }
page, err := template.ParseFS(packaged, "templates/page.html")
if err != nil { return nil, err }
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if len(name) > 200 { http.Error(w, "name too long", http.StatusBadRequest); return }
if name == "" { name = "访客" }
var rendered bytes.Buffer
if err := page.ExecuteTemplate(&rendered, "page.html", struct { Name string }{name}); err != nil {
log.Printf("template execution failed: %v", err)
http.Error(w, "render failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if _, err := w.Write(rendered.Bytes()); err != nil { log.Printf("response write failed: %v", err) }
})
return mux, nil
}
func main() {
address := flag.String("listen", "127.0.0.1:8094", "listen address")
flag.Parse()
handler, err := newHandler()
if err != nil { log.Fatal(err) }
listener, err := net.Listen("tcp", *address)
if err != nil { log.Fatal(err) }
server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
fmt.Println("http://" + listener.Addr().String())
log.Fatal(server.Serve(listener))
}
templates/page.html:
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<title>嵌入资源小站</title>
<link rel="stylesheet" href="/static/style.css">
<main>
<h1>你好,{{.Name}}</h1>
<p>模板和样式来自这一份二进制。</p>
<form method="get"><label>名称 <input name="name" maxlength="80"></label><button>更新</button></form>
</main>
</html>
assets/style.css:
body { margin: 0; background: #edf3f9; color: #17334e; font: 18px sans-serif; }
main { max-width: 40rem; margin: 3rem auto; padding: 2rem; background: white; }
input, button { font: inherit; }
导入embed是为了提供FS类型并启用指令。指令匹配的是构建时的包内资源路径,不是服务启动时的工作目录。程序启动后读取的是嵌入数据,不再需要从assets目录打开同名文件。
首页使用html/template,名称按HTML上下文处理,不把用户输入转换成可信HTML类型。模板文件可信、输入字段不可信,是这份渲染模型的边界;若允许上传任意模板,又属于另一种执行权限设计。
文件系统里的 assets,与 URL 中的 static 不是同一层
packaged的根包含assets和templates。直接把它挂到/static/,请求style.css时可能仍然多隔着一层assets。fs.Sub把文件系统视图切到assets,再由StripPrefix去掉URL前缀,两者分别处理资源命名空间和HTTP路径。
URL请求/static/style.css进入处理器后,文件服务器看到的是/style.css,在assets子视图里能找到对应文件。templates没有进入这个静态子视图,不会因为同在二进制里就顺手对外提供。
所有被嵌入且位于静态子视图中的资源,都应按可公开文件审查。不要把密钥、内部配置或私有源文件放进assets,再期待没有页面链接就不会被访问。embed保证交付,不提供访问授权。
fs.Sub也是视图操作,不应被描述成任意操作系统目录的安全沙箱。本文底层是受控embed.FS;换成其他文件系统后,应重新核对符号链接和路径访问规则,不能从这份演示推导全部实现都有相同边界。
把路径和转义写成真实测试
main_test.go:
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestPackagedSite(t *testing.T) {
handler, err := newHandler()
if err != nil { t.Fatal(err) }
malicious := "<script>alert(1)</script>"
for _, sample := range []struct { path string; status int; contains string }{
{"/", 200, "访客"},
{"/?name=" + url.QueryEscape(malicious), 200, "<script>"},
{"/static/style.css", 200, "background"},
{"/style.css", 404, ""},
{"/templates/page.html", 404, ""},
{"/not-found", 404, ""},
{"/?name=" + strings.Repeat("a", 201), 400, "name too long"},
} {
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, sample.path, nil))
if recorder.Code != sample.status { t.Fatalf("%s: status %d", sample.path, recorder.Code) }
if !strings.Contains(recorder.Body.String(), sample.contains) { t.Fatalf("%s: missing expected body", sample.path) }
if strings.Contains(recorder.Body.String(), malicious) { t.Fatal("unescaped input") }
}
}
运行:
go test ./...
go build -o embedded-site .
./embedded-site
访问打印的地址,应看见带样式的首页。修改名称后仍由同一模板渲染;测试里的script内容只作为文字显示,不能成为实际脚本标签。
GET根路径使用/{$}明确只匹配根,未知路径不会落进一个什么都返回首页的处理器。静态文件则保留自己的前缀分支。单页应用可能有不同回退需求,但应在路由合同里明确,不要在排错时无条件把所有404改成200。
字段上限这里按UTF-8字节检查,HTML maxlength又采用自己的原生长度单位,二者不是同一合同。演示控件给出保守交互限制,服务端仍独立检查200字节;正式产品如果要显示精确剩余字数,应统一单位并明确提示。
脱离源码目录,才算验证了交付方式
构建成功以后,在另一个终端进入空目录,用二进制的绝对路径启动:
mkdir run-empty
cd run-empty
/absolute/path/embed-site/embedded-site -listen 127.0.0.1:8095
绝对路径替换为刚才产物的位置。此时工作目录没有assets和templates,首页与/static/style.css仍应正常。这项证据比从项目根启动更有价值,因为它主动去掉了曾经掩盖缺文件的条件。
再改源码style.css但不重新构建,运行中的产物不会采用新样式。需要更新时执行构建并发布新二进制,浏览器缓存又是另一层,需要按资源版本策略处理。不能把源码文件已改当成产物已更新。
嵌入模式在编译时必须匹配合法资源。可以暂时移走唯一的CSS文件再构建,应看到模式匹配失败;随后恢复文件并重新构建。不要为了让构建通过把模式扩大到整个仓库,把原本不该交付的文件一起打进去。
以点或下划线开头的文件、目录在相应嵌入遍历规则里有特殊处理;需要时应按官方规则明确选择,而不是从本地目录列表推断最终嵌入成员。本文使用普通文件名和收窄模式,让构建内容容易核对。
例如assets/*可以直接匹配该目录里某些以点开头的文件,而单独写assets并递归遍历时会按另一套排除规则处理。这也是不能靠“隐藏文件通常不打包”保护配置的原因。应该核对确切模式与产物成员,静态目录保持只包含可交付资源。
二进制还属于构建时选定的操作系统与架构。资源已经嵌入,不代表一个macOS产物可以直接复制到Linux运行;跨平台构建应同时选择目标环境,并在目标环境验证启动与文件服务。资源路径独立于工作目录,只解决其中一项部署条件。
最后保留两份验收:测试证明路由和渲染合同,空工作目录启动证明资源交付合同。它们都通过以后,再决定是否需要外部可变配置、可更新模板或CDN静态资源;这些需求会改变部署方式,不能靠在二进制旁边放一个同名文件期待embed自动覆盖。