Skip to content
Go
text/template 与 html/template — 文本模板与 HTML 安全模板

text/template 与 html/template — 文本模板与 HTML 安全模板

Go 的模板包做的是同一件事:把数据"填"进一段带 {{...}} 占位的文本里,产出最终文本。差别在产出的是什么:渲染给浏览器看的 HTML 页面,用 html/template(自动做上下文转义,防注入);邮件正文、YAML/nginx 配置、SQL 脚手架、代码生成 CLI,用 text/template(忠实输出,不加戏)。两个包 API 几乎完全一致,选错方向两头翻车——前者会把邮件富文本转义成实体乱码,后者会留下 XSS 后门。


1. 两个包的关系

text/template 包文档开头:

Package template implements data-driven templates for generating textual output. … To generate HTML output, see html/template, which has the same interface as this package but automatically secures HTML output against certain attacks.

html/template 进一步说明它的实现方式:

Package template (html/template) implements data-driven templates for generating HTML output safe against code injection. It provides the same interface as text/template and should be used instead of text/template whenever the output is HTML.

This package wraps text/template so you can share its template API to parse and execute HTML templates safely.

也就是说 html/template 不是一套独立引擎,它包了一层 text/template,在解析时把每个 {{.X}} 改写成带内部转义函数的流水线。它的安全模型也是文档明说的:

The security model used by this package assumes that template authors are trusted, while Execute’s data parameter is not.

模板作者可信、传入的数据不可信。反过来 text/template 的文档还有一句更硬的警告:模板本身不自动转义,“injecting code into a template can lead to arbitrary code execution”——让用户提交模板文本并用任何模板引擎执行,都不是安全设计。

官方文档自带的对照例子:同一段模板 Hello, {{.}}!,数据是 <script>alert('you have been pwned')</script>——

texttext/template 输出: Hello, <script>alert('you have been pwned')</script>! (直接执行脚本)html/template 输出: Hello, &lt;script&gt;alert(&#39;you have been pwned&#39;)&lt;/script&gt;!

为什么邮件/配置反而不该用 html/template

邮件正文本身就是一段 HTML 片段(营销邮件、验证码模板都是手写的 <b> <a>),它的"不安全"是预期内的:模板是你写的,收件人邮箱客户端会正确解析标签。用 html/template 渲染它,等于把可信内容当数据转义掉。实测同一模板 {{.}}、同一数据 <b>50% off</b> click:

texttext/template: <b>50% off</b> clickhtml/template: &lt;b&gt;50% off&lt;/b&gt; click 后者发到邮箱里,用户看到的是尖括号和实体码原文,格式全毁。配置生成(YAML、Dockerfile、Nginx conf)同理:配置不是 HTML,html/template 的转义没有任何意义,还可能破坏结构。规则是看输出目的地:浏览器页面 → html/template;其余一切 → text/template。(本文实测均基于 go1.27.0 windows/amd64,引用措辞取自 go doc 原文。)


2. 上下文感知转义:同一个值的六种形态

手写转义难,是因为 HTML 里"正确"的转义规则取决于值落在哪个上下文。html/template 在解析时就判断每个动作的上下文并挂上对应的转义器,官方文档举的例子:给定

html<a href="/search?q={{.}}">{{.}}</a> 解析后实际变成:

html<a href="/search?q={{. | urlescaper | attrescaper}}">{{. | htmlescaper}}</a> 实测最能说明问题。一个最小模板把六种上下文放一起,数据只有一个字符串 a'"<b>&Z:

goconst ctx = `TEXT {{.V}}ATTR <a title="{{.V}}">x</a>JS_IN_SQ <script>var s = '{{.V}}';</script>JS_RAW <script>var n = {{.V}};</script>URL <a href="/s?q={{.V}}">l</a>CSS <div style="font-family:'{{.V}}'">x</div>`// html/template 执行,数据 struct{V string}{"a'\"<b>&Z"} 实测输出:

textTEXT a&#39;&#34;&lt;b&gt;&amp;ZATTR <a title="a&#39;&#34;&lt;b&gt;&amp;Z">x</a>JS_IN_SQ <script>var s = 'a\u0027\u0022\u003cb\u003e\u0026Z';</script>JS_RAW <script>var n = "a'\"\u003cb\u003e\u0026Z";</script>URL <a href="/s?q=a%27%22%3cb%3e%26Z">l</a>CSS <div style="font-family:'a\27\22\3c b\3e\26Z'">x</div> 同一个输入值,产出六种完全不同的形态:HTML 上下文用实体(&#39;)、JS 字符串用 Unicode 转义(\u0027 等)、URL 查询用百分号编码、CSS 用反斜杠十六进制;JS_RAW 行里裸值的引号甚至是模板自动补上的。指望业务代码手写对每种上下文都正确的转义,等于在 Go 里重写六个转义器还漏不得——这正是 html/template 存在的理由。

URI 过滤与 #ZgotmplZ

html/template 对 href/src 里的 URL 还有一层协议白名单。文档原话:<a href="{{.}}"> 在值是 O'Reilly: 这种非允许协议时输出 #ZgotmplZ。实测裸字符串放进 href:

textJS_SCHEME <a href="#ZgotmplZ">x</a> // 数据 "javascript:alert(1)"DATA_URI <a href="#ZgotmplZ">y</a> // 数据 "data:text/html,<script>alert(1)</script>" #ZgotmplZ 是一个故意不可能存在的 URI 值,让注入失效。但注意边界:数据是 "<b>x</b>" 这种不含协议的内容不会被过滤,只会被百分号编码成相对路径 %3cb%3ex%3c/b%3e。

urlquery:只在 text/template 用

内置函数 urlquery 的文档:

Returns the escaped value of the textual representation of its arguments in a form suitable for embedding in a URL query. This function is unavailable in html/template, with a few exceptions.

实测 {{urlquery .}}、数据 a b&c=d:

texttext urlquery: a+b%26c%3Ddhtml urlquery out="a&#43;b%26c%3Dd" // 即便"可用"也被再加工,不要依赖html href auto: <a href="/s?q=a%20b%26c%3dd">l</a> // href 里裸放 {{.}} 自动处理 结论:在 html/template 里,URL 参数直接 {{.}} 放进 href 即可,手动套 urlquery 反而叠出二次转义。


3. 标记"已是安全内容":safe 类型

有些内容确实是可信 HTML(富文本编辑器过了白名单的片段、html/template 自身产出的内容),不想被再转义,html/template 提供一组字符串类型标记。go doc html/template 实测存在的类型:HTML、HTMLAttr、JS、JSStr、Srcset、CSS、URL(定义在 html/template,text/template 包里没有这些类型)。它们的文档都有同一句警告:

Use of this type presents a security risk: the encapsulated content should come from a trusted source, as it will be included verbatim in the template output.

个别类型还限定了合法形状:HTML “should not be used for HTML from a third-party, or HTML with unclosed tags or comments”;CSS 只接受符合 CSS3 文法/声明/值产生式的字符串;URL 的文档直接说明 javascript:checkThatFormNotEditedBeforeLeavingPage() 这种来自可信源的脚本 URL 应该放行,而默认按数据进来的 javascript: URL 一律被过滤。

实测输出(数据字段分别用 template.HTML("<b>bold</b>")、template.JS、template.URL):

textHTML <b>bold</b> // template.HTML 原文通过STR &lt;b&gt;x&lt;/b&gt; // 同内容裸字符串被转义ATTR <input readonly> // template.HTMLAttr 作为属性名通过URL_JS <a href="javascript:alert%281%29">js</a> // template.URL("javascript:alert(1)") 放行!JS <script>var d={"k":"\u003cb\u003e"};var e="\u003cb\u003ex\u003c\/b\u003e";</script> // template.JS 原文;裸字符串走 JS 转义

陷阱:把用户输入转成这些类型,等于亲手关掉全部防护。上表 URL_JS 一行实测:显式 template.URL 包过的 javascript: 协议不被过滤。“用户提交的链接"和"后台配置的可信链接"混在同一个字段里随手转成 template.URL,就是一个现成的 XSS。判据回到那句话:只有可信来源才配这些类型;拿不准就传裸 string,让自动转义兜底。

注意:一些网络文章和旧笔记里提到的 template.JSScript、template.URLS 在 Go 1.27 标准库中不存在(go doc html/template.JSScript 返回 “no symbol”,GOROOT 源码 src/html/template 里也没有)。写 safe 类型时用上面实测存在的七个即可。


4. 语法速查(点到为止)

动作定界符默认 {{ }},定界符之外的文本原样复制。数据访问:字段 {{.Name}}、map 键 {{.City}}、管道 {{.Name | printf "%-10s"}}、变量 {{$x := .Foo}}。

if / else。真值规则文档原文:

The empty values are false, 0, any nil pointer or interface value, and any array, slice, map, or string of length zero.

实测 {{if .}}T{{else}}F{{end}}:0、0.0、""、false、nil 数据、空 slice/array/map、nil 指针、nil channel 全部为 F;非 nil 但已 close 的 channel 为 T——常见"channel 为空则假"的说法不在文档清单里,实测也不成立。{{else if}} 链文档明确支持;{{else with}} 是 Go 1.23 新增,官方 release notes 原文 “Templates now support the new {{else with}} action, which reduces template complexity in some use cases."。求值用的 text/template 导出函数 IsTrue(val any) (truth, ok bool):“This is the definition of truth used by if and other such actions”,实测 IsTrue("x") 返回 true true。

range。文档:被 range 的值 “must be an array, slice, map, iter.Seq, iter.Seq2, integer or channel”(当前 go doc 措辞)。要点实测:

  • map 遍历按 key 排序:“If the value is a map and the keys are of basic type with a defined order, the elements will be visited in sorted key order.” —— map[string]int{"c":3,"a":1,"b":2} 输出 1 2 3,模板层已抹平 Go for-range 的随机序,做配置/清单生成可复现。
  • {{break}}/{{continue}} 存在。官方 release notes(Go 1.18)原文:“Within a range pipeline the new {{break}} command will end the loop early and the new {{continue}} command will immediately start the next loop iteration.” 实测对 []int{1,2,3,4,5,6} 跳过 3、遇 5 break,输出 1 2 4。
  • {{range $i, $e := .}} 双变量;空集合走 {{else}} 分支;{{range 3}} 输出 3 次;range-over-func(maps.Keys 的 iter.Seq)实测可用,输出 a b 。

with。把 dot 重新绑定到非空值:{{with .P}}...{{end}};空值(含 nil 指针)走 else,实测 {{with .P}}{{.X}}{{else}}empty-p{{end}} 在 .P 为 nil 时输出 empty-p。

define / template / block。文档:Parse 时 “Named template definitions ({{define …}} or {{block …}} statements) in text define additional templates associated with t and are removed from the definition of t itself”。block 是"定义+就地执行"的 shorthand:

A block is shorthand for defining a template {{define “name”}} T1 {{end}} and then executing it in place {{template “name” pipeline}}.

典型用法是根模板提供可覆盖的骨架,子模板只重写 block——配合 Clone(见第 5.2 节)。实测:

textoriginal out="HEADER T\nbody\ndefault foot"clone out="HEADER T\nbody\nCLONE FOOT" // Clone 后 Parse 覆盖 foot,原件不动 index / slice / call。{{index .M "k"}} 多层下钻 x[1][2][3];{{slice .List 1 2}} 等价 list[1:2];{{call .F 1 2}} 调用函数值,文档要求函数返回"one or two result values, the second of which is of type error”,返回非 nil error 则执行停止(见第 6 节)。

内置函数:printf/print/println 是 fmt.Sprintf/Sprint/Sprintln 的别名;len、and/or/not、eq/ne/lt/le/gt/ge(布尔函数把任意零值当假)。and/or 自 Go 1.18 起短路(官方 notes:“The and function no longer always evaluates all arguments”)。比较函数跨整数宽度可比,但 int 与 float32 不可混比。

注释与空白裁剪。{{/* 注释 */}} 不进输出,实测 A{{/* a comment */}}B 输出 "AB"。裁剪标记文档原文:

if an action’s left delimiter (by default “{{”) is followed immediately by a minus sign and white space, all trailing white space is trimmed from the immediately preceding text. … “{{- 3}}” is like “{{3}}” but trims the immediately preceding text, while “{{-3}}” parses as an action containing the number -3.

实测:

textbasic-trim : "A23<45B" // {{23 -}} < {{- 45}}no-trim : "line1\n 5 x\nline2"left-trim : "line15 x\nline2" // {{- 吃掉前面缩进和换行right-trim : "line1\n 5x\nline2" // -}} 吃掉后面空白 缩进敏感的配置生成、想每行一条的文本输出,基本全靠 {{- / -}} 收拾。


5. 执行语义的三个坑

5.1 Execute 出错不回滚已写出的内容

Execute 文档原文:

If an error occurs executing the template or writing its output, execution stops, but partial results may already have been written to the output writer.

实测:BEGIN {{.Boom}} END,其中 Boom() 是返回 error 的方法:

textbuffer="BEGIN "err=template: p:1:8: executing "p" at <.Boom>: error calling Boom: method boom failed BEGIN 已经写进 writer,END 没来。HTTP handler 里若直接 tmpl.Execute(w, data) 往 ResponseWriter 写,出错时用户拿到半截页面,而且 header 已发出、无法改状态码。生产写法:先 Execute 进 bytes.Buffer,err 返回后再统一写头写体——成功才是完整页面,失败才来得及返回 500。

5.2 并发执行安全;模板"构造"与执行不能交叉

  • 包文档的并发保证:“Once parsed, a template may be executed safely in parallel, although if parallel executions share a Writer the output may be interleaved.”
  • New 方法文档(构造侧):“Because associated templates share underlying data, template construction cannot be done safely in parallel. Once the templates are constructed, they can be executed in parallel.”
  • Funcs 文档(时序约束):“Any function used in the template must be added before the template is parsed. Funcs may be called more than once, including after parsing (for example, after Template.Clone), to replace a function of the same name; the replacement is used when the template is executed.”

注意 Funcs 返回的是同一个 *Template(文档:“The return value is the template, so calls can be chained”),实测 Parse 后再次 Funcs 替换同名函数,原模板后续执行全部用新函数:

textreplace after parse: before="v1" replaced="v2" original-again="v2"

注意:常见说法"模板执行过之后就不能再改"并不是当前 go doc 的原句;文档实际说的是"构造(关联命名空间共享底层数据)不可并发,构造完才能并行执行”。但工程结论一致:已交给并发执行的模板再调 Funcs/Parse/Option 就是数据竞争。要变体用 Clone,返回类型 (*Template, error),文档:“Clone returns a duplicate of the template, including all associated templates. The actual representation is not copied, but the name space of associated templates is, so further calls to Template.Parse in the copy will add templates to the copy but not to the original.” 实测 Clone 改 foot 后原件输出不变,互不影响。

5.3 missingkey 与 <no value>

map 缺 key 的默认行为是渲染出 "<no value>" 字符串——文档在 Option 下逐项写明:

missingkey: Control the behavior during execution if a map is indexed with a key that is not present in the map. “missingkey=default” or “missingkey=invalid”: Do nothing and continue execution. If printed, the result of the index operation is the string “”. “missingkey=zero”: The operation returns the zero value for the map type’s element. “missingkey=error”: Execution stops immediately with an error.

实测(map[string]string{“k”:“v”},模板 [{{.nope}}]):

texttext missingkey=default out="[<no value>]" err=<nil>html missingkey=default out="[]" err=<nil> // 注意: html/template 这里不打 <no value>text missingkey=zero out="[]" err=<nil>text missingkey=error out="[" err=template: m:1:3: executing "m" at <.nope>: map has no entry for key "nope"html missingkey=error out="[" err=template: m:1:3: executing "m" at <.nope>: map has no entry for key "nope" 三种取值对执行期最终输出生效;error 分支同样不回滚(out="[",呼应 5.1)。两个容易踩的点:其一,"<no value>" 是 text/template 的默认输出,html/template 实测渲染成空串,所以"页面里莫名少了内容"先查 key 拼写;其二,missingkey=zero 对 map[string]int 输出 [0]、对结构体值输出 [{0}],零值也会进产物。另外 Option 文档写明参数不认识会直接 panic(“If the option string is unrecognized or otherwise invalid, Option panics”),别把用户输入拼进选项串。生产建议统一 missingkey=error,让模板里的拼写错误在启动/测试期就炸,而不是静默渲染成空。


6. 方法调用与 nil:都是执行错误,不是 panic

模板里调返回 (T, error) 的方法,error 变成执行错误(5.1 的 Boom 即为此);内置 call 的文档同理:函数 “must return either one or two result values, the second of which is of type error”,非 nil 即停。实测几类错误原文:

text{{.P.X}} nil 指针解引用: template: n:1:4: executing "n" at <.P.X>: nil pointer evaluating *main.Inner.X{{.secret}} 非导出字段(Parse 能过、Execute 报错): template: u:1:2: executing "u" at <.secret>: secret is an unexported field of struct type main.Model{{template "nope"}} 子模板未定义: template: p2:1:33: executing "p2" at <{{template "nope"}}>: template "nope" not defined{{.Nope}} 结构体无此字段: template: ex:2:2: executing "ex" at <.Nope>: can't evaluate field Nope in type struct {} nil 指针字段访问不 panic(返回错误),但非导出字段在模板里完全不可见——Go 层导出什么,模板就只能看到什么,想暴露请加方法。errors.As 接 *template.ExecError(文档:“ExecError is the custom error type returned when Execute has an error evaluating its template. (If a write error occurs, the actual error is returned; it will not be of type ExecError.)")可以把"模板求值错"和"写输出错”(如 ResponseWriter 断连)分开处理。


7. 模板的组织与嵌入

完整可运行的小例子,目录:

textwebapp/├── main.go├── templates/header.tmpl {{define "header"}}== {{.Title}} =={{end}}└── templates/mail.tmpl {{define "mail.tmpl"}}{{template "header" .}} Hi {{.Name}}, {{.Body}} {{end}} gopackage mainimport ( "embed" "fmt" "html/template" "os")//go:embed templatesvar tmplFS embed.FSfunc main() { t, err := template.ParseFS(tmplFS, "templates/*.tmpl") if err != nil { panic(err) } data := struct{ Title, Name, Body string }{"Welcome", "Bob", "glad to have you"} err = t.ExecuteTemplate(os.Stdout, "mail.tmpl", data) fmt.Println("\nerr:", err)} 实测输出:

text== Welcome ==Hi Bob, glad to have youerr: <nil> 要点:

  • 命名规则:ParseFiles 文档——“the templates created by ParseFiles are named by the base (see filepath.Base) names of the argument files”,所以入口模板名就是文件基名(实测 t.Lookup("mail.tmpl") 命中)。ParseFS 是它的 fs.FS 版;ParseGlob(pattern) 等价于对 Glob 命中的文件列表调 ParseFiles,且 “the pattern must match at least one file”。根模板名建议与某文件基名一致,否则按文档所说 t.Execute 可能失败,得改用 ExecuteTemplate。
  • go:embed 细节://go:embed templates 默认不会打包以 _、. 开头的文件/目录;模板里有这类文件(如 _partials/)时需要 //go:embed all:templates。展开规则见 fs-embed.md,不在此重复。
  • template.Must 只在 init 用:Must 文档明说它是给变量初始化用的糖(“intended for use in variable initializations such as var t = template.Must(...)”),错误即 panic。启动阶段 panic 是正确行为——模板坏了服务根本不该起来;运行期对请求数据调 Must 则等于"模板解析失败就崩掉整个进程",而且运行期反复 Parse 还有 5.2 的竞争问题。实测 template.Must(template.New("bad2").Parse("{{if}}")) 确实 panic。

8. FuncMap 实战

text/template.FuncMap 就是 map[string]any;html/template.FuncMap 是它的别名(type FuncMap = template.FuncMap),两个包注册函数写同一份。Funcs 返回值可链式调用(文档:“The return value is the template, so calls can be chained”),New(...).Funcs(...).Parse(...) 是最常见构造顺序:

gofuncMap := template.FuncMap{ "lower": strings.ToLower, "day": func(t time.Time) string { return t.Format("2006-01-02") }, "json": func(v any) (template.JS, error) { b, err := json.Marshal(v) return template.JS(b), err },}t := template.Must(template.New("f").Funcs(funcMap).Parse(`{{.Name | lower}} {{printf "%.2f" .Score}} {{day .When}}`))// 数据: {"ALICE", 3.14159, 2026-10-14T09:30Z} 实测输出 alice 3.14 2026-10-14。在 <script> 里传结构化数据,标准姿势就是上面的 json → template.JS:实测 {{.M | json}} 输出 var d = {"k":"\u003cb\u003e"};——json.Marshal 默认把 < 转义成 \u003c 这样的 JSON unicode 转义,天然避免 </script> 提前闭合,模板层不再二次转义。两个函数设计要点:优先返回 (T, error) 而不是在函数里 panic;函数返回的 template.JS/template.HTML 同样受"可信来源"约束(第 3 节)。

Funcs 必须在 Parse 前给(用于该模板的函数):实测未注册就引用 {{nosuchfn .}} 直接解析失败,原文:

texttemplate: x:1: function "nosuchfn" not defined

9. 性能与生产排障

解析一次、执行多次。Parse 是建树+上下文分析,Execute 只是求值填坑;5.2 的时序约束也决定了 Parse 必须留在启动期。同一台机器粗测(go1.27,windows/amd64,单线程,小模板 3 万次):

textparse once, 30000 exec: 17.0474msparse+exec every time, 30000: 127.4108ms 量级上每次请求重解析比复用慢了近一个数量级。不同机器倍数会差,结论不变:请求路径里出现 template.Parse/ParseGlob/ParseFS 是性能问题,更是并发安全问题。复用模板本身可安全并发执行(文档保证),只有共享输出 Writer 会让字节交错。

输出方式:Execute(wr io.Writer, data) 收任何 io.Writer。直出 stdout/文件无脑;HTTP 响应按 5.1 先进 bytes.Buffer 再落盘。

错误定位。错误串格式是 template: 名字:行[:列]: ...。实测原文(解析错带行号、执行错带行列):

textparse err: template: loc:3: unclosed action started at loc:2exec err: template: ex:2:2: executing "ex" at <.Nope>: can't evaluate field Nope in type struct {} 报错里的行号是模板源里的行号,不是 Go 代码行号——排障先对照模板。多文件解析后"到底有哪些模板可用"难记,打印 t.DefinedTemplates()(实测输出形如 ; defined templates are: "hdr", "foot", "page"),或在出错分支列出 t.Templates() 的 Name。


10. 什么时候不要用模板

  • 前端 SPA/组件框架(React/Vue)渲染页面:数据走 JSON API,模板引擎退出请求路径,别在 Go 里拼 HTML 再等前端"水合"反受转义牵制。
  • SQL、Shell、YAML 的值拼接:模板解决不了注入,反而制造注入面。SQL 用参数绑定(database/sql 的 ?),Shell 用 exec.Command 的 argv,生成的 YAML 值要保证不含换行或按 YAML 规则引起来——模板只是排版工具,不校验语义。
  • 模板当程序用:模板里没有循环优化、没有类型检查,出现多层嵌套 if/range 拼复杂结构时,把它挪回 Go 代码,模板只留展示。
  • 接受用户写的模板文本:两个包的文档都假设模板作者可信。真要做"用户模板"功能,用 CEL/expression 之类受限求值,或只暴露纯数据白名单字段。

实战建议

  • 按输出去处选包:浏览器 HTML → html/template;邮件正文、配置、代码生成 → text/template。
  • html/template 里永远不要手写转义,也不要在 href 上套 urlquery;裸 {{.}} 放进去即可。
  • safe 类型只包可信内容;用户输入转成 template.HTML/template.URL 就是关防护(实测 template.URL 放行 javascript:)。
  • 服务启动前完成全部 Parse/Funcs/Option,之后把模板视为只读;要变体用 Clone。
  • 函数在 Parse 前注册;返回 (T, error),让错误进执行错误链。
  • 统一 Option("missingkey=error");知道 text/template 默认会漏 <no value>、html/template 是静默空串。
  • HTTP 渲染先写 bytes.Buffer,成功后再提交 ResponseWriter,规避半截页面。
  • template.Must 只用于变量初始化/启动期;运行期 Parse 既慢又不安全。
  • 嵌入模板用 //go:embed + ParseFS,注意 all: 才打包 _/. 开头文件(见 fs-embed)。
  • 排障记住错误串 template: name:line:col,配合 DefinedTemplates() 定位"no such template"。