前言
ctemplate 是 Google 开源的一套 C++ 页面模板库,用来把「界面长什么样」和「数据怎么算」分开。它的工作方式是:模板写在独立文件里,里面用双花括号占位;C++ 代码构造一个字典(dictionary),把变量和分节填进去,再调用展开函数生成最终文本。典型用途是生成 HTML、邮件正文、代码片段和报表。
先澄清一个容易混淆的地方:ctemplate 里的「模板」指的是文本模板,不是 C++ 语言的模板(template)机制。它跟std::vector<int>那种泛型编程没有关系,头文件里也没有任何模板元编程技巧。搜索资料时把两者混在一起,会看到大量无关内容。
另一个常见误解是「ctemplate 会自动做 HTML 转义」。它不会。默认情况下变量按原样输出,要转义必须显式使用修饰符,例如{{NAME:html_escape}}。把用户输入直接拼进页面而不转义,是这类模板最常见的 security 问题。
本文讲 ctemplate 的模板语法、字典 API、展开流程和构建方式,示例以 C++17 为基准。ctemplate 是第三方库,不同发行版打包的版本和接口可能略有差异,具体以该库官方文档与系统头文件为准;本文代码未经编译或运行验证。
一、模板语法:变量、分节与包含
ctemplate 的模板是普通文本文件,只有双花括号里的内容有特殊含义。核心语法有四类:
| 语法 | 含义 | 说明 |
|---|
{{NAME}} | 变量替换 | 用字典中同名键的值替换 |
{{#SECTION}}...{{/SECTION}} | 分节 | 可重复、可隐藏的一段内容 |
{{>INCLUDE}} | 包含 | 插入另一个模板文件 |
{{! 注释 }} | 注释 | 展开时被去掉,不进入结果 |
变量名区分大小写,通常用大写字母加下划线。修饰符写在变量名之后,用冒号分隔,例如{{USER_NAME:html_escape}}。修饰符决定输出前做什么处理,常用的有 HTML 转义、URL 转义、JavaScript 字符串转义等;具体可用的修饰符列表以官方文档为准。
分节是循环与条件共用的机制。模板里写一次结构,C++ 侧通过添加多个分节字典来重复它;一个分节字典都不添加时,这段内容不输出,于是同一个语法既能表达循环也能表达「有数据才显示」。分节可以嵌套,内层分节属于外层字典。
包含用于拆分公共片段,比如页头、页脚。被包含的模板通过AddIncludeDictionary关联一个字典,包含文件里的变量从该字典取值。包含路径相对于模板根目录解析,因此部署时要把整套模板目录一起带上。
一个简单的模板文件report.tpl可以写成这样:
<!DOCTYPE html>
<html>
<head><title>{{TITLE:html_escape}}</title></head>
<body>
<h1>{{TITLE:html_escape}}</h1>
{{#ROWS}}
<tr><td>{{NAME:html_escape}}</td><td>{{SCORE}}</td></tr>
{{/ROWS}}
{{^ROWS}}
<p>no data</p>
{{/ROWS}}
</body>
</html>{{^ROWS}}是反向分节,只在分节为空时输出,适合放「暂无数据」提示。这个写法在 ctemplate 中称为 inverted section,和正向分节成对使用。
二、字典:变量、整数与分节
ctemplate::TemplateDictionary是数据容器。构造时给一个名字,仅用于调试和错误信息,不影响展开结果。
// 适用于 C++17
#include <ctemplate/template.h>
ctemplate::TemplateDictionary dict("report");
dict.SetValue("TITLE", "Quarterly Report");
dict.SetIntValue("TOTAL", 42);
dict.SetBoolValue("DEBUG", false);SetValue接收以空字符结尾的字符串,库内部会复制一份,调用后原始缓冲区可以释放。SetIntValue做整数到文本的转换,避免调用方自己拼std::to_string。SetBoolValue主要配合分节或条件使用。
循环数据用AddSectionDictionary,每次调用返回一个子字典指针,往子字典里填本轮的值:
// 适用于 C++17
for (const auto& row : rows) {
ctemplate::TemplateDictionary* item = dict.AddSectionDictionary("ROWS");
item->SetValue("NAME", row.name);
item->SetIntValue("SCORE", row.score);
}子字典的生命周期由父字典管理,调用方不需要也不应该delete。指针在父字典销毁前有效,但不要长期缓存:后续添加分节可能让内部存储重新分配。展开完成前保持父字典存活即可。
如果只是想让某个分节显示一次而不循环,可以添加一个空的分节字典,或者调用ShowSection。反过来,HideSection可以强制隐藏。不同版本对「未添加字典的分节默认是否显示」的处理可能不同,跨版本使用时以实际头文件声明为准。
字典还支持包含字典:
// 适用于 C++17
ctemplate::TemplateDictionary* header = dict.AddIncludeDictionary("HEADER");
header->SetFilename("partials/header.tpl");
header->SetValue("USER", "alice");SetFilename指定被包含的模板文件,变量填在返回的字典上。注意AddIncludeDictionary的参数是模板里{{>HEADER}}的名字,不是文件名。
三、展开:ExpandTemplate 与模板目录
展开有一次性函数和对象式两种用法。最简单的是ctemplate::ExpandTemplate:
// 适用于 C++17
std::string output;
bool ok = ctemplate::ExpandTemplate("report.tpl",
ctemplate::DO_NOT_STRIP,
&dict,
&output);
if (!ok) {
// 模板不存在、语法错误或包含文件缺失
}第二个参数是空白处理策略。DO_NOT_STRIP原样保留;STRIP_BLANK_LINES去掉只含空白的行;STRIP_WHITESPACE还会压缩行首行尾空白。生成 HTML 时通常用DO_NOT_STRIP,避免破坏<pre>之类对空白敏感的结构。
模板文件默认在当前目录查找。生产环境应显式设置根目录,否则程序从不同工作目录启动就会找不到文件:
// 适用于 C++17
ctemplate::Template::SetTemplateRootDirectory("/opt/myapp/templates");这个设置是进程级的,应在任何展开之前完成。多线程程序要在启动阶段设置好,不要在运行期反复修改。
ctemplate 会缓存已加载的模板。开发阶段修改模板文件后,如果进程不重启,可能仍在使用旧内容;可以调用ctemplate::Template::ReloadAllIfChanged()触发重新加载。该函数有不同 reload 模式,具体枚举与行为以头文件为准。生产环境通常不需要热加载,重启服务更简单。
展开失败时不要忽略返回值。常见原因是路径不对、模板语法错误(比如分节没有闭合)、包含文件缺失。ExpandTemplate的具体错误信息可以通过库的日志接口获取,不同版本的接口不同。
四、构建、线程与工程建议
ctemplate 通常以系统包或源码方式提供。Debian/Ubuntu 上的开发包一般叫libctemplate-dev,安装后用 pkg-config 查询编译参数;包名在不同发行版可能不同,先执行pkg-config --list-all找带 ctemplate 的条目,不要凭记忆写死。
# 安装开发包(包名以发行版为准)
sudo apt install libctemplate-dev
# 查询编译与链接参数
pkg-config --cflags --libs libctemplate# CMake 中使用 pkg-config 导入
find_package(PkgConfig REQUIRED)
pkg_check_modules(CTEMPLATE REQUIRED libctemplate)
target_include_directories(app PRIVATE ${CTEMPLATE_INCLUDE_DIRS})
target_link_libraries(app PRIVATE ${CTEMPLATE_LIBRARIES})链接时通常会看到两个库:libctemplate和libctemplate_nothreads。前者内部带锁,适合多线程同时展开;后者不做同步,单线程使用或调用方自己保证串行时更轻。二选一,不要同时链接,否则符号和运行时行为都可能不符合预期。
线程安全要分两层看。模板对象加载后的只读展开在多线程下是支持的(使用带锁版本);但TemplateDictionary不是线程安全的,多个线程不能同时往同一个字典里写。正确做法是每个线程用自己的字典,或者先串行构造好字典再并发展开只读副本。SetTemplateRootDirectory这类全局设置也应在启动阶段完成。
工程上还有几点值得注意:
- 模板目录与可执行文件分开部署,通过配置项指定根目录,不要依赖当前工作目录。
- 模板里不要放业务计算。ctemplate 的语法刻意做得很弱,就是为了防止视图层长出逻辑;需要计算的值在 C++ 侧算好再塞进字典。
- 所有来自外部的字符串都要加转义修饰符。默认输出是原样拼接,这不是「方便」,而是「默认不安全」。
- 为模板缺失准备降级路径:记录错误并返回通用页面,而不是让异常或空串直接进入响应。
常见坑点
- ❌ 把 ctemplate 当成 C++ 语言模板,去找
template<typename T>的用法。
✅ 它是文本模板库,处理的是字符串替换与分节,与泛型编程无关。
- ❌ 变量直接写
{{USER_INPUT}},指望库自动转义。
✅ 明确写{{USER_INPUT:html_escape}};输出到 URL 或脚本时换对应修饰符。
- ❌
AddSectionDictionary返回的指针保存到全局或长期容器里。
✅ 子字典归父字典所有,只在本次展开期间使用,用完即弃。
- ❌ 忽略
ExpandTemplate的返回值,模板路径错了却以为数据没填进去。
✅ 检查返回值,并在启动时验证模板根目录和关键模板存在。
- ❌ 多线程共享同一个
TemplateDictionary并发SetValue。
✅ 每线程独立字典;共享只读字典也要确认库版本的线程安全约定。
- ❌ 同时链接
-lctemplate与-lctemplate_nothreads。
✅ 根据是否需要内部加锁选择其中一个。
- ❌ 模板里写循环条件和数据计算,把视图层做成第二套业务逻辑。
✅ 计算在 C++ 侧完成,模板只负责占位与重复结构。
- ❌ 用相对路径加载模板,服务以 systemd 启动后找不到文件。
✅ 用绝对路径设置模板根目录,或由配置注入部署路径。
总结
| 主题 | 关键点 |
|---|
| 定位 | 文本模板库,不是 C++ 语言模板 |
| 变量 | {{NAME}},默认不转义,需显式修饰符 |
| 循环 | AddSectionDictionary多次调用同一分节名 |
| 条件 | 正向分节与{{^SECTION}}反向分节配合 |
| 包含 | AddIncludeDictionary加SetFilename |
| 展开 | ExpandTemplate,检查返回值与空白策略 |
| 构建 | 链接libctemplate或libctemplate_nothreads之一 |
ctemplate 的设计哲学是「模板能力越弱越安全」:它不提供表达式、不提供函数调用,只保留变量、分节和包含。接受这个约束,把逻辑留在 C++ 侧,把转义写清楚,它就是一个稳定、容易审计的视图层工具。