本文参考 FreeMarker 官方文档(2.3.x)整理。
FreeMarker 是 Java 服务端模板引擎,与 Thymeleaf 是“同赛道双雄”:Thymeleaf 偏 HTML 自然模板,FreeMarker 用 ${...} 和 <#...>指令语法,生成能力更通用(HTML、XML、SQL、代码、邮件都行)。
核心概念是 数据模型(Data Model)+ 模板(Template)+ 指令(Directive)+ 内置函数(Built-ins)。
这篇笔记覆盖日常开发 90% 的 FreeMarker 用法,包含数据模型、取值语法、指令(if/list/assign/macro)、内置函数、格式化、Spring Boot 集成等知识点。
模板(Template) + 数据模型(Data Model) → 输出(HTML/XML/邮件/代码)模板:静态文本 + FreeMarker 指令(${}插值、<#>标签)
数据模型:一棵树(root 对象 + 子变量),由 Java 通过 model.addAttribute() 传入
输出:纯文本,不只是 HTML —— 生成 SQL、Java 代码、配置文件都行
maven 依赖如下:
<!-- Spring Boot 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>注意,Freemarker 默认模板位置为 src/main/resources/templates/*.ftl,模板的后缀可以为 .ftl 或 .ftlh。
编写控制器,渲染 templates/user.ftl 模板:
@Controller
public class UserController {
@GetMapping("/user")
public String user(Model model) {
// 添加数据,HTML页面可以使用 ${} 语法进行读取
model.addAttribute("name", "张三");
model.addAttribute("user", new User("李四", 25, true));
model.addAttribute("users", List.of(...));
return "user"; // 渲染 templates/user.ftl
}
}HTML模板代码:
<!-- templates/user.ftl -->
<html>
<body>
<h1>你好,${name}!</h1>
</body>
</html>FreeMarker vs Thymeleaf 一句话:
Thymeleaf 模板是“合法 HTML + 属性”。
FreeMarker 模板是“文本 + ${}/<#>指令”,FreeMarker 更灵活,但不直接打开看原型效果。
${name} <!-- 标量 -->
${user.name} <!-- 对象属性(自动调用 getter) -->
${user.address.city} <!-- 嵌套 -->
${users[0]} <!-- 列表按下标 -->
${users[0].name} <!-- 组合 -->
<!-- 列表/集合遍历的两种取法 -->
${users[1]}
${users?first} ${users?last} ${users?size}
<!-- 字典/Map -->
${map["key"]} <!-- 或 ${map.key}(key 为合法标识符时) -->
<!-- 算术/逻辑 -->
${age + 1} ${age * 2}
${(a + b) * 3}<!-- 默认值:! 后给兜底 -->
${user.name!} <!-- null 或不存在 → 空字符串 -->
${user.name!"游客"} <!-- null → "游客" -->
<!-- 链式空安全 -->
${user.address.city!} <!-- user 或 address 为 null 都不报错 -->
<!-- 判断是否存在 -->
<#if user??>有用户</#if>
<#if user.name??>有姓名</#if>FreeMarker 最大坑:变量不存在或为 null 时直接 ${x} 会抛异常,任何可能为空的取值都加 ! 是铁律。
${"你好," + name + "!"}
${"你好,${name}!"} <!-- 插值(有引号场景) -->
${'你好,${name}'} <!-- 单引号同样支持 --><#if user.admin>
管理员
<#elseif user.vip>
VIP 用户
<#else>
普通用户
</#if>
<!-- 逻辑:== != < > <= >= && || ! -->
<#if age >= 18 && user.active>成年且启用</#if>
<#if status == "done">完成</#if> <!-- 字符串比较 -->
<!-- 判断空集合 -->
<#if users?has_content>有数据</#if><!-- 基本循环 -->
<#list users as u>
<p>${u.name} - ${u.age}</p>
</#list>
<!-- 循环状态:u_index(0开始)、u_has_next、item_cycle -->
<#list users as u>
<p>${u_index + 1}. ${u.name}</p> <!-- 序号从1 -->
</#list>
<!-- 隔行变色(item_cycle 轮换值) -->
<#list users as u>
<tr class="${u?item_cycle('odd','even')}">...</tr>
</#list>
<!-- 空列表提示 -->
<#list users as u>
...
<#else>
<p>暂无数据</p>
</#list>
<!-- break 跳出 / 指定范围循环 -->
<#list 1..5 as i>${i}</#list> <!-- 1 2 3 4 5 -->⚠️注意:循环状态 ?index(0 基)、?counter(1 基)、?item_cycle(v1,v2) 轮换是三个高频内置函数。
<#assign total = 0>
<#assign name = "张三">
<#assign greeting = "你好,${name}">
<#assign x = 10 y = 20> <!-- 一次定义多个 -->
<#assign total = total + 1> <!-- 更新 --><!-- 定义宏 -->
<#macro greeting name>
<p>你好,${name}!</p>
</#macro>
<!-- 调用 -->
<@greeting name="张三"/>
<@greeting "李四"/> <!-- 简写(按位置传参) -->
<!-- 宏默认值 -->
<#macro badge status="normal">
<span class="badge-${status}">${status}</span>
</#macro>
<@badge/> <!-- 用默认 normal -->
<@badge status="vip"/>
<!-- 宏体内内容(nested) -->
<#macro card title>
<div class="card">
<h3>${title}</h3>
<#nested/> <!-- 放调用者提供的内部内容 -->
</div>
</#macro>
<@card title="用户卡片">这里是内容</@card><!-- include:把别的模板插进来(直接包含,多次包含会重复) -->
<#include "/common/header.ftl">
<!-- import:导入宏库(可避免宏名冲突,用命名空间调用) -->
<#import "/common/macros.ftl" as m>
<@m.greeting name="张三"/>内置函数语法:${变量?函数},是 FreeMarker 的“方法库”。
${s?upper_case} <!-- 大写 -->
${s?lower_case} <!-- 小写 -->
${s?length} <!-- 长度 -->
${s?trim} <!-- 去空格 -->
${s?substring(0, 3)} <!-- 截取 -->
${s?replace("a","b")} <!-- 替换 -->
${s?contains("x")?string("含","不含")} <!-- 判断 -->
${s?starts_with("ab")} <!-- 前缀判断 -->${n?c} <!-- 原样输出数字(去掉逗号分组,重要!) -->
${n?string("0.00")} <!-- 2位小数:3.14 -->
${n?string("#.##")} <!-- 最多2位 -->
${n?string("000")} <!-- 补零:007 -->
${n?round} ${n?floor} ${n?ceiling}
${n?string.percent} <!-- 百分比 -->
<!-- 金额千分位 -->
${price?string("0,000.00")} <!-- 12,345.67 -->⚠️注意:?c 是高频必用:FreeMarker 默认按 Locale 对数字加千分位(1234567 → 1,234,567),在 HTML value 或 JS 里会出错,用 ${n?c} 原样输出。
<!-- 日期转字符串 -->
${date?string("yyyy-MM-dd")}
${time?string("HH:mm:ss")}
${datetime?string("yyyy-MM-dd HH:mm:ss")}
<!-- 常用 -->
${date?date} <!-- 只显示日期 -->
${date?datetime} <!-- 显示日期时间 -->
${.now} <!-- 当前时间 -->
${.now?string("yyyy-MM-dd")}${list?size} <!-- 长度 -->
${list?first} ${list?last}
${list?seq_contains(x)} <!-- 是否包含 -->
${list?seq_index_of(x)} <!-- 下标 -->
${list?reverse} <!-- 反转 -->
${list?sort} <!-- 排序 -->
${list?sort_by("age")} <!-- 按属性排序 -->
${list?join(", ")} <!-- 用分隔符连接成字符串 -->
${x?has_content} <!-- 变量存在且非空(用于 if) -->
${x?is_string} ${x?is_number} ${x?is_boolean} ${x?is_sequence}${html?html} <!-- HTML 转义(防 XSS) -->
${json?js_string} <!-- JS 字符串转义 -->
${url?url} <!-- URL 转义 -->⚠️注意:?html防 XSS:输出用户提交的内容务必转义,${content?html}。
<!-- 范围序列 -->
<#list 1..10 as i>${i}</#list>
<#list 1..10 step 2 as i>${i}</#list> <!-- 1 3 5 7 9 -->
<!-- 内联序列 -->
<#list ["a","b","c"] as s>${s}</#list>
<!-- 内联哈希(Map 字面量) -->
<#assign m = {"name": "张三", "age": 25}>
${m["name"]}
${m.name}
<!-- 遍历 Map -->
<#list m?keys as k>${k}=${m[k]}</#list>${.now} <!-- 当前时间 -->
${.lang} <!-- 语言 -->
${.version} <!-- FreeMarker 版本 -->FreeMarker 模板能执行任意 Java 方法调用(${user.getClass()...}),模板即代码。要点:
模板文件只放受信来源,不要用用户输入作为模板名或内容
不把 Configuration 或任意 Bean 暴露给模板(api_builtin_enabled=false)
用 spring.freemarker.settings.api_builtin_enabled=false 关闭 ?api 内部访问
输出用户内容一律 ?html 转义
配置信息如下:
# application.properties
spring.freemarker.cache=false # 开发关缓存
spring.freemarker.suffix=.ftl # 后缀
spring.freemarker.content-type=text/html;charset=UTF-8 # 编码
spring.freemarker.settings.number_format=0.## # 全局数字格式(防千分位坑)
spring.freemarker.settings.date_format=yyyy-MM-dd # 日期格式
spring.freemarker.settings.time_format=HH:mm:ss # 时间格式
spring.freemarker.settings.datetime_format=yyyy-MM-dd HH:mm:ss # 日期时间格式// Controller 与 Thymeleaf 一样返回视图名
model.addAttribute("list", ...);
return "user";
// 全局变量:@ControllerAdvice + @ModelAttribute全局配 number_format=0.## 能避免大量 ${n?c} 补丁,是团队级最佳实践。
变量不存在/null 直接取:${user.name} 但 user 为 null → 异常。任何可能为空加 !(${user.name!})。
数字千分位坑:${1234567} 默认渲染成 1,234,567,用在 value 属性/JS 里出错 —— 用 ${n?c} 或全局 number_format。
<#if>里字符串比较:<#if status == done>少了引号会把 done当变量(不存在报错),必须 == "done"。
布尔判断:<#if user.admin> 是对的;<#if user.admin == true> 也行但多余;<#if user.admin == "true"> 类型错(布尔 vs 字符串)。
<#list>空列表:没数据直接渲染空白,用 <#else>分支给提示。
<#include>vs <#import>:include 重复包含同一文件会重复输出(变量/宏重定义报错);复用宏用 import。
宏参数没传:宏内部用了未传参数 → 未定义错误;给宏参数加默认值(<#macro m x="1">)兜底。
日期类型不匹配:java.sql.Date/LocalDate/Timestamp 混用,?string 格式化报错 —— 统一类型,LocalDateTime 需 ?string("yyyy-MM-dd HH:mm:ss")。
?c 用在字符串上报错:?c 只能用于数字,字符串用 ! 或 ?string。
模板路径大小写/斜杠:<#include "/common/Header.ftl"> 路径对大小写敏感(Linux),注意全小写约定。
循环里修改集合:<#list> 遍历时不能改集合(无 remove),用 assign 重建或过滤。
${} 里用了 <#> 指令语法:插值里不能写指令,${<#if>...} 非法 —— 逻辑放外面。
数字直接拼接字符串出错:${"编号" + 123}可以(自动转字符串),但 ${123 + "abc"} 报错(数字+非数字)。
HTML 注入:用户输入不转义输出即 XSS,${content?html}。
缓存忘了关:开发期 spring.freemarker.cache=false,否则改模板要重启。
# 取值
${var} ${obj.prop} ${list[0]} ${map.key}
${x!} ${x!"默认"} ${x??} # 空值处理
# 指令
<#if 条件>...<#elseif>...<#else>...</#if>
<#list list as item>${item_index}.${item}<#else>空</#list>
<#assign x = 值> <#include "file.ftl"> <#import "m.ftl" as m>
# 宏
<#macro name param="默认"><#nested/></#macro>
<@name param="值">内容</@name>
# 内置函数
?upper_case ?lower_case ?length ?trim ?substring(0,3)
?c(数字原样) ?string("0.00") ?string("yyyy-MM-dd")
?size ?first ?last ?join(", ") ?seq_contains(x) ?item_cycle(a,b)
?has_content ?html(XSS转义) ?js_string
# Spring Boot 配置
spring.freemarker.cache=false
spring.freemarker.settings.number_format=0.##FreeMarker 上手路径:先搞懂数据模型 + ${}取值 + !空值兜底,再练三大指令(if/list/assign),然后学内置函数(重点 ?c、?string、?html),最后是宏和 import 复用。
官方文档 https://freemarker.apache.org/docs/ 的“Template Author's Guide”最权威,如果对上面存在疑虑,可以参考官方文档。
记住:FreeMarker 是“文本 + 指令”的通用模板引擎 —— 空值必加 !、数字记得 ?c、输出用户内容要 ?html,这三条能避开 80% 的坑。