工程

Go 模板缓存与热加载:开发效率和生产性能怎么兼得

Go 模板不必在开发热加载和生产缓存之间二选一。本文用 html/template、embed.FS 与 os.DirFS 拆出统一 Renderer,并按页面隔离模板树,让开发刷新即见、生产稳定缓存。

很多 Go 内容站最早都会把模板写成“请求来了再解析”:开发时改完 HTML 刷新就能看到结果,但生产环境会把文件读取、模板解析和错误暴露都放进请求路径。反过来,启动时一次性解析并缓存,线上稳定了,开发时每改一个模板又要重启进程。

更稳的做法是把两件事拆开:模板从哪里读,以及什么时候缓存。生产环境从 embed.FS 读取,解析后缓存;开发环境从 os.DirFS 读取,每次请求重新解析。业务 handler 只调用同一个 Render,不关心当前是哪种模式。

模板先按页面隔离

建议固定一个简单目录:

templates/
  layout/base.html
  partials/nav.html
  partials/footer.html
  pages/post.html
  pages/home.html

布局模板负责完整骨架,页面模板只定义内容块:

{{/* layout/base.html */}}
{{define "base"}}
<!doctype html>
<html lang="zh">
<body>
  {{template "nav" .}}
  <main>{{template "content" .}}</main>
  {{template "footer" .}}
</body>
</html>
{{end}}
{{/* pages/post.html */}}
{{define "content"}}
<article>
  <h1>{{.Title}}</h1>
  <div>{{.HTML}}</div>
</article>
{{end}}

这里不要把所有 pages/*.html 一次性解析进同一棵模板树。多个页面通常都会定义 "content",如果放在一起会互相覆盖,最后生效的是解析顺序里的最后一个页面。更安全的边界是:一个页面对应一棵模板树,共享 layout 和 partials。

一个渲染器收口

下面这段代码保留了最小结构。生产模式懒加载并缓存;开发模式每次请求重新解析。html/template 解析完成后可以并发执行,所以锁只保护缓存 map,不包住 ExecuteTemplate

package view

import (
	"html/template"
	"io"
	"io/fs"
	"sync"
)

type Renderer struct {
	fsys  fs.FS
	dev   bool
	funcs template.FuncMap

	mu    sync.RWMutex
	cache map[string]*template.Template
}

func New(fsys fs.FS, dev bool, funcs template.FuncMap) *Renderer {
	return &Renderer{fsys: fsys, dev: dev, funcs: funcs, cache: map[string]*template.Template{}}
}

func (r *Renderer) Render(w io.Writer, page string, data any) error {
	t, err := r.get(page)
	if err != nil {
		return err
	}
	return t.ExecuteTemplate(w, "base", data)
}

func (r *Renderer) get(page string) (*template.Template, error) {
	if r.dev {
		return r.parse(page)
	}

	r.mu.RLock()
	t := r.cache[page]
	r.mu.RUnlock()
	if t != nil {
		return t, nil
	}

	r.mu.Lock()
	defer r.mu.Unlock()
	if t = r.cache[page]; t != nil {
		return t, nil
	}
	t, err := r.parse(page)
	if err != nil {
		return nil, err
	}
	r.cache[page] = t
	return t, nil
}

func (r *Renderer) parse(page string) (*template.Template, error) {
	files := []string{
		"layout/base.html",
		"partials/nav.html",
		"partials/footer.html",
		"pages/" + page + ".html",
	}
	return template.New("").
		Funcs(r.funcs).
		Option("missingkey=error").
		ParseFS(r.fsys, files...)
}

missingkey=error 值得打开。它主要约束 map 数据:模板访问不存在的 map key 时,默认结果容易被误判为数据为空;开启后执行阶段直接报错。结构体字段写错本来就会报错,但保留这个选项可以让 map 和 struct 的失败方式更接近。内容站的模板字段是代码契约,应该尽早失败。

生产 embed,开发读目录

入口处只负责选择文件系统:

package main

import (
	"embed"
	"io/fs"
	"os"

	"example.com/site/view"
)

//go:embed templates
var embedded embed.FS

func newRenderer() (*view.Renderer, error) {
	funcs := map[string]any{"date": formatDate}

	if os.Getenv("APP_ENV") == "development" {
		return view.New(os.DirFS("templates"), true, funcs), nil
	}

	sub, err := fs.Sub(embedded, "templates")
	if err != nil {
		return nil, err
	}
	return view.New(sub, false, funcs), nil
}

生产环境把模板打进二进制,部署时不会漏传 HTML 文件;开发环境直接读工作目录,保存后刷新即可。为了模板热加载引入文件监听通常不划算:监听器要处理编辑器临时文件、批量保存、目录递归和跨平台差异,而每次请求重新解析对小型内容站几乎没有可感知成本。

如果希望生产环境在启动时就发现模板错误,可以给渲染器加一个预热方法:

func (r *Renderer) Warm(pages ...string) error {
	r.mu.Lock()
	defer r.mu.Unlock()

	for _, page := range pages {
		t, err := r.parse(page)
		if err != nil {
			return err
		}
		r.cache[page] = t
	}
	return nil
}

预热不是为了省第一次请求的几毫秒,而是把错误提前到启动阶段。部署系统更容易处理“进程启动失败”,不容易处理“某个页面第一次被访问才 500”。

边界和取舍

第一,FuncMap 必须在解析前注册。模板解析时就会校验函数名,先解析再补函数没有意义。

第二,page 应由 handler 传入固定模板名,或从路由参数映射到白名单。不要把 URL 片段直接拼成 pages/ 文件名;os.DirFS 不是安全沙箱,开发模式下也不该让外部输入决定模板路径。

第三,模板缓存不要和页面数据缓存混在一起。模板的生命周期跟进程一致;文章、列表和导航数据的生命周期跟发布和编辑有关。两者放在一层缓存里,失效策略会变得不可验证。

第四,HTML 片段要显式处理。html/template 默认会转义字符串,这是安全默认值。只有 Markdown 已经经过可信渲染和 HTML 清洗,并且清洗策略符合站点允许的标签范围时,才把正文转成 template.HTML

type PostPage struct {
	Title string
	HTML  template.HTML
}

不要把用户提交的原文或未清洗 HTML 直接转成 template.HTML。这个类型是在告诉模板引擎“这里已经安全”,不是一个清洗函数。

这套方案的核心取舍很简单:生产环境把模板当不可变代码,启动后只执行;开发环境把模板当正在编辑的文件,每次请求重新读取。接口统一后,handler 里只剩业务逻辑:

func (h *Handler) Post(w http.ResponseWriter, r *http.Request) {
	post := h.loadPost(r)
	if err := h.views.Render(w, "post", post); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

对 Go 自建内容站来说,这已经足够克制:不牺牲开发反馈,不把生产请求放回模板解析路径,也不为热加载引入额外运行时依赖。