Go 模板入门:用 html/template 生成安全的 HTML 页面

本文讲解 Go 标准库 html/template 的解析、渲染、数据传递、循环、条件、模板函数和安全转义,适合刚开始写服务端页面的学习者。

不是所有 Web 页面都需要前端框架

Go 经常被用来写 JSON API,但它也很适合生成简单服务端页面。后台管理、内部工具、报表页、文档页、小型内容站,有时用 html/template 直接渲染 HTML 会比引入前端构建链更省事。页面请求进来,服务端查询数据,套进模板,返回 HTML,流程非常清楚。

Go 标准库有两个模板包:text/template 和 html/template。前者适合生成普通文本,后者适合生成 HTML。写网页时应该使用 html/template,因为它会根据上下文自动转义,能减少 XSS 风险。

这篇文章会用一个文章列表页做例子,讲解模板解析、变量访问、条件、循环、模板函数和 handler 中的渲染方式。

第一个模板

定义数据:

type PageData struct {
	Title string
	Name  string
}

模板字符串:

const page = `
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>{{ .Title }}</title>
</head>
<body>
  <h1>{{ .Title }}</h1>
  <p>你好,{{ .Name }}</p>
</body>
</html>
`

解析并渲染:

tmpl, err := template.New("page").Parse(page)
if err != nil {
	return err
}

data := PageData{Title: "Go 模板入门", Name: "小林"}
if err := tmpl.Execute(os.Stdout, data); err != nil {
	return err
}

模板里的 {{ .Title }} 表示访问当前数据对象的 Title 字段。点号 . 是当前上下文。初学时可以把模板理解成“HTML 里嵌入少量数据表达式”。

在 HTTP handler 中渲染

真实服务里通常从文件解析模板:

templates/
└── articles.html

articles.html:

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>{{ .Title }}</title>
</head>
<body>
  <h1>{{ .Title }}</h1>
  <ul>
    {{ range .Articles }}
      <li>{{ .Title }} - {{ .Author }}</li>
    {{ else }}
      <li>暂无文章</li>
    {{ end }}
  </ul>
</body>
</html>

Go 代码:

type Article struct {
	Title  string
	Author string
}

type ArticlesPage struct {
	Title    string
	Articles []Article
}

func articlesHandler(tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		data := ArticlesPage{
			Title: "文章列表",
			Articles: []Article{
				{Title: "Go 入门", Author: "小林"},
				{Title: "模板基础", Author: "阿周"},
			},
		}

		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		if err := tmpl.ExecuteTemplate(w, "articles.html", data); err != nil {
			log.Printf("render articles: %v", err)
			http.Error(w, "render error", http.StatusInternalServerError)
		}
	}
}

启动时解析模板:

tmpl, err := template.ParseFiles("templates/articles.html")
if err != nil {
	log.Fatal(err)
}

mux := http.NewServeMux()
mux.HandleFunc("/articles", articlesHandler(tmpl))

模板解析应该尽量放在启动阶段,而不是每个请求都解析。这样模板语法错误能尽早暴露,请求时也更快。

条件和循环

条件:

{{ if .LoggedIn }}
  <p>欢迎回来,{{ .UserName }}</p>
{{ else }}
  <p>请先登录</p>
{{ end }}

循环:

{{ range .Tags }}
  <span>{{ . }}</span>
{{ end }}

range 内部的点号会变成当前元素。如果你需要访问外层数据,可以先保存变量:

{{ $siteTitle := .SiteTitle }}
{{ range .Articles }}
  <h2>{{ .Title }}</h2>
  <small>{{ $siteTitle }}</small>
{{ end }}

模板语言故意不复杂。不要把大量业务逻辑写进模板。模板负责展示,复杂判断应该在 Go 代码里提前整理成适合渲染的数据。

比如不要在模板里到处判断文章状态,不如在数据结构里准备好展示文本:

type ArticleView struct {
	Title      string
	StatusText string
}

这样模板只负责输出。

模板函数

可以注册函数:

func formatDate(t time.Time) string {
	return t.Format("2006-01-02")
}

tmpl, err := template.New("articles.html").Funcs(template.FuncMap{
	"formatDate": formatDate,
}).ParseFiles("templates/articles.html")

模板中使用:

<time>{{ formatDate .PublishedAt }}</time>

函数适合格式化日期、截断文本、简单数字格式化。不要在模板函数里访问数据库或调用外部服务。模板渲染应该是纯粹、快速、可预测的过程。

如果函数返回 (value, error),模板执行时会处理错误:

func mustFormat(t time.Time) (string, error) {
	if t.IsZero() {
		return "", fmt.Errorf("zero time")
	}
	return t.Format("2006-01-02"), nil
}

渲染失败会从 Execute 返回错误。

html/template 的安全转义

html/template 会自动转义 HTML:

data := PageData{
	Name: `<script>alert("xss")</script>`,
}

模板:

<p>{{ .Name }}</p>

输出不会执行脚本,而是把特殊字符转义。这是 html/template 和 text/template 的重要区别。写 HTML 页面时不要用 text/template。

有时你确实有可信 HTML,比如 Markdown 转换后的内容。可以使用 template.HTML,但要非常谨慎:

type ArticlePage struct {
	Title string
	Body  template.HTML
}

只有当内容已经经过可信清洗,或者来源完全受控时,才这样做。用户输入不能直接转成 template.HTML,否则等于绕过安全保护。

小结

Go 的 html/template 很适合生成简单、可靠的服务端页面。它支持字段访问、条件、循环、模板函数和自动 HTML 转义。入门阶段只要掌握解析模板、准备数据、在 handler 中执行模板,就能做出很多内部工具和后台页面。

模板应该保持简单。复杂业务规则放在 Go 代码里,模板只接收整理好的展示数据;模板解析放在启动阶段,渲染错误要记录;用户输入默认让 html/template 转义,不要轻易使用 template.HTML。

服务端渲染不是过时技术。对很多小系统来说,它反而是最稳、最少依赖的方案。Go 标准库让这件事足够直接。

模板继承与布局

大型项目通常有统一布局,Go 模板通过定义和填充 block 实现:

// templates/layout.html
{{ define "layout" }}
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>{{ template "title" . }}</title>
</head>
<body>
  <nav>导航栏</nav>
  <main>
    {{ template "content" . }}
  </main>
  <footer>版权信息</footer>
</body>
</html>
{{ end }}

具体页面:

<!-- templates/page.html -->
{{ define "title" }}文章列表{{ end }}
{{ define "content" }}
  <h1>{{ .Title }}</h1>
  {{ range .Articles }}
    <article>{{ .Title }}</article>
  {{ end }}
{{ end }}

渲染时先解析所有模板:

tmpl, err := template.ParseGlob("templates/*.html")
if err != nil {
	log.Fatal(err)
}

注意 html/template 的模板名由 {{ define }} 决定,不是文件名。

模板错误处理与调试

模板错误通常在运行时暴露。推荐在启动时验证所有模板能执行(用零值数据):

func validateTemplates(tmpl *template.Template) error {
	for _, t := range tmpl.Templates() {
		dummy := map[string]interface{}{}
		if err := t.Execute(io.Discard, dummy); err != nil {
			// 有些模板需要特定字段,零值会失败,这可以预期
			log.Printf("template %s may require specific fields", t.Name())
		}
	}
	return nil
}

更实用的做法是准备一套测试数据:

var testData = ArticlesPage{
	Title: "测试",
	Articles: []Article{
		{Title: "A", Author: "B"},
	},
}

func TestTemplatesRender(t *testing.T) {
	tmpl, _ := template.ParseGlob("templates/*.html")
	for _, name := range tmpl.Templates() {
		if err := tmpl.ExecuteTemplate(io.Discard, name.Name(), testData); err != nil {
			// 跳过不需要此数据的模板
			continue
		}
	}
}

模板性能优化

template.ParseFiles 每次调用都会读取文件和编译模板。请求路径上不应该解析模板:

// 错误:每个请求都解析文件
func badHandler(w http.ResponseWriter, r *http.Request) {
	tmpl, _ := template.ParseFiles("templates/page.html")
	tmpl.Execute(w, data)
}

正确做法是在 main() 或初始化函数中解析一次,全局复用:

var templates *template.Template

func init() {
	templates = template.Must(template.ParseGlob("templates/*.html"))
}

如果需要热重载,可以用文件监听(开发环境)或滚动发布(生产环境)。核心原则:渲染本身应该是纯内存操作。

生成非 HTML 内容

html/template 自动转义 HTML,但生成 JSON、XML、JavaScript 时要格外小心。Go 1.22 之前常用 text/template 生成 JSON,但这不是最佳实践。生成 JSON 请直接用 encoding/json:

// 不要这样做
var tmpl = `{"name":"{{ .Name }}"}` // 不安全

// 正确做法
json.NewEncoder(w).Encode(data)

如果确实需要生成 JavaScript 内联(不推荐),使用 template.JS 要经过严格清洗:

type Page struct {
	Config template.JS // 仅在数据已完全受控时使用
}

性能对比与选型参考

在不同 Go 版本和不同场景下,该技术栈的性能表现有所不同。下表总结了各版本的典型基准数据(以 1000 次迭代为基准):

场景Go 1.20Go 1.21Go 1.22+说明
基础内存分配基线+5%+12%GC 改进带来的收益
编译速度基线+3%+8%增量编译和缓存优化
标准库执行基线+2%+5%持续微优化

大多数情况下,升级到最新的稳定版 Go 都能获得性能和安全性收益,且向后兼容。Go 语言团队有严格的兼容性承诺,升级成本很低。

并发场景下的使用注意事项

当在并发环境中使用本文介绍的技术时,有以下几点必须牢记:

  1. 共享状态必须加锁:如果多个 goroutine 读写同一份数据,必须使用 sync.Mutex 或 sync.RWMutex 保护
  2. 避免死锁:加锁后要及时释放,defer 是个好帮手但要确保它不会只执行到一半就 panic
  3. 不要跨 goroutine 传递互斥锁:将包含 mutex 的结构体值拷贝给另一个 goroutine 是错误的,因为 mutex 内部的信号状态不会被正确拷贝
  4. 使用 channel 通信:Go 的哲学是"通过通信共享内存,而不是通过共享内存通信"
type SafeCounter struct {
    mu    sync.RWMutex
    value int
}

func (c *SafeCounter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

func (c *SafeCounter) Value() int {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.value
}

错误处理深度解析

Go 的错误处理看似笨拙,实际上有其工程价值:

显式 vs 隐式错误处理

Go 的错误处理是显式的,每个可能导致错误的步骤都要检查:

func process() error {
    data, err := readDB()
    if err != nil {
        return fmt.Errorf("read db: %w", err)
    }
    result, err := transform(data)
    if err != nil {
        return fmt.Errorf("transform: %w", err)
    }
    if err := writeCache(result); err != nil {
        return fmt.Errorf("write cache: %w", err)
    }
    return nil
}

虽然代码行数增加了,但每个失败点都清晰可见,调试时不需要层层跳出异常处理堆栈。

错误包装的最佳实践

Go 1.13 引入的 %w 允许保留原始错误信息:

var ErrNotFound = errors.New("not found")

func Fetch(ctx context.Context, id string) (*Item, error) {
    item, err := db.Get(ctx, id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, fmt.Errorf("%w: id=%s", ErrNotFound, id)
        }
        return nil, fmt.Errorf("db get: %w", err)
    }
    return item, nil
}

调用方可以用 errors.Is(err, ErrNotFound) 来判断。

常见坑与避坑指南

  1. 不要信任用户输入:无论表单、JSON、Cookie 还是 HTTP Header,都当作不可信数据处理
  2. 资源要释放:文件、数据库连接、HTTP 响应体都要及时关闭。defer 是好习惯
  3. 不要忽略错误:即使 defer file.Close() 可能返回错误,至少记录日志
  4. 不要滥用 goroutine:每个 goroutine 都要有明确的退出路径
  5. 不要硬编码配置:端口、路径、超时时间、密钥都应该从配置读取
  6. 不要过早优化:先让代码正确和可读,再用 benchmark 和 profile 找到热点

测试策略

全面的测试覆盖是高质量代码的基础:

单元测试

func TestProcessData(t *testing.T) {
    tests := []struct {
        name    string
        input   string
        want    string
        wantErr bool
    }{
        {"正常输入", "hello", "HELLO", false},
        {"空输入", "", "", false},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := ProcessData(tt.input)
            if (err != nil) != tt.wantErr {
                t.Errorf("ProcessData() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if got != tt.want {
                t.Errorf("ProcessData() = %v, want %v", got, tt.want)
            }
        })
    }
}

基准测试

func BenchmarkProcessData(b *testing.B) {
    input := strings.Repeat("a", 1000)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        ProcessData(input)
    }
}

运行 go test -bench=. -benchmem 查看内存分配。

表驱动测试 vs 单独函数

表驱动测试适合输入输出明确的纯函数。当测试涉及复杂的依赖注入或状态管理时,单独的测试函数更清晰。

Context 使用最佳实践

Context 是 Go 中控制请求生命周期和传递元数据的标准方式:

func handler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()

    result, err := service.Process(ctx, req)
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            http.Error(w, "timeout", http.StatusGatewayTimeout)
            return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(result)
}

注意事项:

  • 不要存储 nil context,用 context.TODO() 作为占位符
  • Context 应该作为函数第一个参数
  • 不要往 context 里放过大的数据(会复制)
  • 超时时间按层级递减,外层 30s,内层 10s,数据库查询 3s

面试高频考点

如果你正在准备 Go 相关面试,以下概念是高频考点:

  1. goroutine 和线程的区别
  2. channel 的缓冲和非缓冲用法
  3. defer 的执行顺序和与返回值的关系
  4. map 的并发不安全性和解决方案
  5. interface 的隐式实现和类型断言
  6. slice 的底层数组和 append 机制
  7. GC 的基本原理和调优参数
  8. context 的使用场景和超时控制
  9. error 的包装和 errors.Is/errors.As
  10. sync.Mutex vs sync.RWMutex vs atomic

掌握这些意味着具备了独立开发 Go 服务的基础能力。

FAQ

Q: 这个技术在实际项目中真的有用吗?
A: 是的。本文技术来源于真实后端开发场景,在日常服务开发中都会反复用到。

Q: Go 版本会影响示例代码吗?
A: 本文主要针对 Go 1.20+ 编写。较新版本语法微调,但核心概念保持不变。

Q: 学习 Go 应该先学标准库还是直接上框架?
A: 先学标准库。框架是标准库的封装和扩展。理解了标准库才能正确选择和使用框架。

Q: 代码里的错误处理为什么都是显式的?
A: 这是 Go 的设计哲学。显式错误处理让失败路径清晰可见,排查错误更容易。

Q: 并发相关代码怎么测试?
A: 用 -race 标志检测数据竞争。结合 sync.WaitGroup 和 context.WithTimeout 编写测试。

延伸阅读与参考资源

  • Go 官方网站:https://go.dev/
  • Go 标准库文档:https://pkg.go.dev/std
  • Go by Example:https://gobyexample.com/
  • Effective Go:https://go.dev/doc/effective_go
  • Go 常见问题:https://go.dev/doc/faq
  • Go 发布说明:https://go.dev/doc/devel/release

本文力求在讲解技术细节的同时兼顾工程实用性。Go 语言的设计简洁但不简单,掌握它需要持续的实践和反思。希望这篇文章能成为你学习道路上的一个可靠参考。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练