FreeMarker 的强大之处不仅在于其内置指令(如 <#if>
, <#list>
),更在于其可扩展性——允许开发者创建自定义指令。自定义指令让你能将复杂的 Java 逻辑封装成简洁的模板标签,极大地提升模板的可读性、复用性和功能性。
一、核心概念
自定义指令 (Custom Directive):
- 由 Java 代码实现的、可在 FreeMarker 模板中使用的指令。
- 语法类似于
<#if>
或<#list>
,但使用自定义名称,如<@myDirective param1=value1 />
或<@myDirective>...</@myDirective>
。 - 允许在模板中执行复杂的业务逻辑、数据处理、生成动态内容或与外部系统交互。
TemplateDirectiveModel
接口:- 核心接口。所有自定义指令的 Java 实现类都必须实现此接口。
- 定义了一个关键方法:
void execute(Environment env, Map params, TemplateModel[] loopVars, TemplateDirectiveBody body) throws TemplateException, IOException;
- 当模板引擎遇到
<@myDirective ...>
时,就会调用该接口的execute
方法。
Environment
:- 表示当前模板执行的环境。通过它,指令可以:
- 访问当前模板的配置 (
getConfiguration()
)。 - 获取/设置变量 (
getVariable()
,setVariable()
)。 - 输出内容到模板 (
getOut().write(...)
). - 抛出模板异常 (
throw new TemplateException(...)
).
- 访问当前模板的配置 (
- 表示当前模板执行的环境。通过它,指令可以:
params
(参数 Map):- 一个
Map<String, TemplateModel>
,包含了在指令调用时传入的所有命名参数。 - 例如:
<@myDirective name="John" age=30 />
,则params.get("name")
是字符串"John"
的TemplateModel
表示,params.get("age")
是数字30
的TemplateModel
表示。 - 需要将
TemplateModel
转换为具体的 Java 类型(如String
,Integer
,List
,Map
)。
- 一个
loopVars
(循环变量数组):- 一个
TemplateModel[]
,用于支持指令作为<#list>
的“接受者”(类似<#list items as item><@myDirective /></#list>
)。 loopVars
数组的元素对应<#list>
语句中的循环变量(item
)。- 在
execute
方法中,可以通过loopVars[0] = ...
来修改循环变量的值(如果需要)。 - 通常,自定义指令不直接使用
loopVars
,除非设计为与<#list>
配合。
- 一个
TemplateDirectiveBody
(指令体):- 表示指令的“嵌套内容”或“主体”。
- 对于 结束标签指令 (
<@myDirective>...</@myDirective>
),body
不为null
。 - 对于 自结束指令 (
<@myDirective />
),body
为null
。 - 通过调用
body.render(env.getOut())
可以执行并输出指令体内的内容。
TemplateModel
及其子类:- FreeMarker 用来在 Java 层和模板层之间传递数据的接口。
- 常见子类:
TemplateScalarModel
->String
TemplateNumberModel
->Number
(Integer
,Double
,Long
)TemplateBooleanModel
->Boolean
TemplateSequenceModel
->List
,Array
TemplateHashModel
->Map
,POJO
TemplateMethodModel
->Method
TemplateDirectiveModel
-> 自定义指令
- 类型转换工具:
freemarker.template.utility.DeepUnwrap
和freemarker.ext.beans.BeanModel
常用于将TemplateModel
转换为原生 Java 对象。
注册 (Registration):
- 自定义指令实现类需要在 FreeMarker
Configuration
对象中注册,才能在模板中使用。 - 通过
configuration.setSharedVariable("directiveName", new MyDirective());
完成。
- 自定义指令实现类需要在 FreeMarker
二、操作步骤(非常详细)
目标:创建一个自定义指令 <@highlight text=... color="yellow" />
,用于高亮显示文本。
步骤 1:创建自定义指令 Java 类
- 创建类:创建一个 Java 类,例如
HighlightDirective.java
。 - 实现接口:让该类实现
TemplateDirectiveModel
接口。 - 实现
execute
方法:- 参数处理:从
params
中获取参数,进行类型检查和转换。 - 逻辑执行:实现指令的核心功能(这里是生成高亮 HTML)。
- 输出:使用
env.getOut().write()
将结果写入模板输出流。 - 处理指令体:如果指令有结束标签,考虑是否需要执行
body
。
- 参数处理:从
// src/main/java/com/example/freemarker/directive/HighlightDirective.java
package com.example.freemarker.directive;
import freemarker.core.Environment;
import freemarker.template.*;
import java.io.IOException;
import java.io.Writer;
import java.util.Map;
/**
* 自定义指令:高亮显示文本。
* 用法1 (自结束): <@highlight text="要高亮的文本" color="yellow" />
* 用法2 (有体): <@highlight color="yellow">要高亮的文本</@highlight>
*/
public class HighlightDirective implements TemplateDirectiveModel {
// 定义参数名常量
private static final String PARAM_TEXT = "text";
private static final String PARAM_COLOR = "color";
private static final String DEFAULT_COLOR = "yellow";
@Override
public void execute(Environment env,
Map params,
TemplateModel[] loopVars,
TemplateDirectiveBody body) throws TemplateException, IOException {
// 1. 获取并处理参数
String text = null;
String color = DEFAULT_COLOR; // 默认颜色
// 检查 'text' 参数 (优先级高于指令体)
if (params.containsKey(PARAM_TEXT)) {
TemplateModel textModel = (TemplateModel) params.get(PARAM_TEXT);
if (textModel instanceof TemplateScalarModel) {
text = ((TemplateScalarModel) textModel).getAsString();
} else if (textModel != null) {
// 如果不是字符串,尝试转换(谨慎使用)
text = textModel.toString(); // 注意:这可能不是用户期望的
} else {
throw new TemplateModelException("Parameter '" + PARAM_TEXT + "' cannot be null.");
}
}
// 检查 'color' 参数
if (params.containsKey(PARAM_COLOR)) {
TemplateModel colorModel = (TemplateModel) params.get(PARAM_COLOR);
if (colorModel instanceof TemplateScalarModel) {
color = ((TemplateScalarModel) colorModel).getAsString();
} else {
throw new TemplateModelException("Parameter '" + PARAM_COLOR + "' must be a string.");
}
}
// 2. 处理指令体 (如果提供了 text 参数,则忽略指令体)
if (text == null && body != null) {
// text 参数未提供,尝试从指令体获取内容
// 创建一个 StringWriter 来捕获 body 的输出
StringWriter bodyWriter = new StringWriter();
body.render(bodyWriter); // 执行指令体,将其内容写入 bodyWriter
text = bodyWriter.toString().trim(); // 获取内容并去除首尾空格
if (text.isEmpty()) {
throw new TemplateModelException("No text provided for highlighting (neither 'text' parameter nor body content).");
}
} else if (text == null) {
// text 为 null 且 body 为 null
throw new TemplateModelException("No text provided for highlighting. Use 'text' parameter or provide content within the directive body.");
}
// 3. 执行核心逻辑:生成高亮 HTML
String highlightedHtml = generateHighlightHtml(text, color);
// 4. 输出结果到模板
Writer out = env.getOut();
out.write(highlightedHtml);
}
/**
* 生成高亮显示的 HTML 片段
*/
private String generateHighlightHtml(String text, String color) {
// 简单的内联样式,实际项目中可能使用 CSS 类
return String.format("<mark style=\"background-color: %s; padding: 2px 4px; border-radius: 3px;\">%s</mark>",
escapeHtml(color), escapeHtml(text));
}
/**
* 简单的 HTML 实体转义 (防止 XSS)
* 实际项目应使用更完善的库如 Apache Commons Lang 的 StringEscapeUtils
*/
private String escapeHtml(String input) {
if (input == null) return null;
return input.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'");
}
}
步骤 2:在 FreeMarker Configuration 中注册指令
- 创建 Configuration:初始化 FreeMarker 的
Configuration
对象。 - 注册指令:使用
setSharedVariable
方法将指令实例注册到全局共享变量中。注册的名称就是模板中使用的指令名。
// src/main/java/com/example/freemarker/FreeMarkerConfig.java
package com.example.freemarker;
import freemarker.template.Configuration;
import freemarker.template.TemplateExceptionHandler;
import com.example.freemarker.directive.HighlightDirective;
import java.io.File;
public class FreeMarkerConfig {
public static Configuration createConfiguration() throws Exception {
Configuration cfg = new Configuration(Configuration.VERSION_2_3_31);
// 设置模板加载目录
cfg.setDirectoryForTemplateLoading(new File("/path/to/templates"));
// 设置默认编码
cfg.setDefaultEncoding("UTF-8");
// 设置异常处理器
cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
// (重要) 注册自定义指令
cfg.setSharedVariable("highlight", new HighlightDirective());
// 可以注册更多指令...
// cfg.setSharedVariable("myOtherDirective", new MyOtherDirective());
return cfg;
}
}
步骤 3:在模板中使用自定义指令
- 创建模板:创建一个
.ftl
文件,例如demo.ftl
。 - 使用指令:使用注册时指定的名称(
highlight
)来调用自定义指令。
<#-- /templates/demo.ftl -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>FreeMarker 自定义指令演示</title>
</head>
<body>
<h1>FreeMarker 自定义指令演示</h1>
<h2>示例 1: 使用 'text' 参数</h2>
<p>这是一个 <@highlight text="重要的" color="lightgreen" /> 单词。</p>
<h2>示例 2: 使用指令体 (默认颜色)</h2>
<p>这是一个 <@highlight>突出的</@highlight> 单词。</p>
<h2>示例 3: 使用指令体和自定义颜色</h2>
<p>这是一个 <@highlight color="pink">醒目的</@highlight> 单词。</p>
<h2>示例 4: 复杂内容 (结合其他指令)</h2>
<#assign items = ["苹果", "香蕉", "橙子"]>
<ul>
<#list items as item>
<li><@highlight color="lightblue">${item}</@highlight></li>
</#list>
</ul>
<h2>示例 5: 错误处理演示 (会抛出异常)</h2>
<#-- <@highlight /> 会触发异常,因为没有提供文本 -->
</body>
</html>
步骤 4:Java 代码渲染模板
// src/main/java/com/example/freemarker/Main.java
package com.example.freemarker;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import java.io.*;
import java.util.HashMap;
import java.util.Map;
public class Main {
public static void main(String[] args) {
try {
// 1. 获取配置 (包含已注册的指令)
Configuration cfg = FreeMarkerConfig.createConfiguration();
// 2. 获取模板
Template template = cfg.getTemplate("demo.ftl"); // 路径相对于 templateLoader 目录
// 3. 准备数据模型 (本例中 demo.ftl 主要使用指令,数据模型可为空)
Map<String, Object> dataModel = new HashMap<>();
// 4. 合并模板和数据,输出到控制台或响应
Writer out = new OutputStreamWriter(System.out);
template.process(dataModel, out);
out.flush();
out.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
三、常见错误
忘记注册指令:
- 错误:实现了
TemplateDirectiveModel
,但在Configuration
中没有调用setSharedVariable
。 - 后果:模板中使用
<@highlight ...>
时,抛出TemplateException
,提示“Unknown directive: highlight”。 - 解决:确保在
Configuration
初始化时正确注册。
- 错误:实现了
参数类型转换错误:
- 错误:直接将
params.get("paramName")
强制转换为String
或Integer
,而不检查TemplateModel
的具体类型。 - 后果:
ClassCastException
。 - 解决:始终先检查
instanceof
,然后安全转换,或使用DeepUnwrap
等工具。
- 错误:直接将
未处理
null
参数:- 错误:假设某个参数一定存在或不为
null
。 - 后果:
NullPointerException
或逻辑错误。 - 解决:检查
params.containsKey()
,并为必需参数抛出TemplateModelException
。
- 错误:假设某个参数一定存在或不为
TemplateDirectiveBody
处理不当:- 错误:对于有结束标签的指令,忘记调用
body.render(out)
,导致指令体内容不显示。 - 错误:对于自结束指令,错误地尝试使用
body
(此时为null
)。 - 解决:使用
if (body != null)
进行判空检查。
- 错误:对于有结束标签的指令,忘记调用
未正确使用
Environment.getOut()
:- 错误:使用
System.out.println()
或其他Writer
输出内容。 - 后果:输出不会出现在最终的模板结果中,而是直接打印到控制台。
- 解决:必须使用
env.getOut().write()
将内容写入模板输出流。
- 错误:使用
命名冲突:
- 错误:自定义指令名与 FreeMarker 内置指令名(如
if
,list
)或已注册的共享变量名冲突。 - 后果:行为异常或覆盖内置功能。
- 解决:选择唯一且描述性的名称,避免使用保留字。
- 错误:自定义指令名与 FreeMarker 内置指令名(如
四、注意事项
- 线程安全:
TemplateDirectiveModel
实现类的实例通常会被多个线程共享(因为注册在Configuration
中)。确保你的实现是线程安全的。避免在类中使用实例变量存储状态。如果需要状态,应在execute
方法内使用局部变量。 - 异常处理:在
execute
方法中抛出TemplateException
或IOException
会被 FreeMarker 捕获并处理(取决于TemplateExceptionHandler
)。使用TemplateModelException
通常更合适,它是TemplateException
的子类,用于表示模板模型相关的错误。 - 资源管理:如果指令需要打开文件、数据库连接等资源,务必在
try-finally
块或try-with-resources
语句中妥善关闭。 - 性能考量:避免在指令中执行耗时的操作(如远程调用、复杂计算)。尽量将数据准备放在 Java 业务逻辑层,指令只负责展示。
- 安全性:如果指令生成 HTML,务必对用户输入进行 HTML 转义(如
escapeHtml
示例),防止跨站脚本(XSS)攻击。 loopVars
的使用:除非你的指令设计为与<#list>
等配合(如<#compress>
),否则通常不需要处理loopVars
。
五、使用技巧
- 提供默认值:为可选参数设置合理的默认值(如
color="yellow"
)。 - 参数验证:对参数进行验证(如检查颜色值是否为有效 CSS 颜色),并提供清晰的错误信息。
- 支持多种用法:像
highlight
指令一样,同时支持参数传入和指令体传入,增加灵活性。 - 封装复杂逻辑:将分页、权限检查、缓存读取、API 调用等复杂逻辑封装在自定义指令中。
- 利用
TemplateModel
类型:可以接受TemplateSequenceModel
(List) 或TemplateHashModel
(Map/POJO) 作为参数,实现更复杂的数据处理。 - 创建指令库:将一组相关的指令打包成一个库,方便在不同项目中复用。
- 使用
DeepUnwrap
:freemarker.template.utility.DeepUnwrap.permissiveUnwrap(TemplateModel)
可以将TemplateModel
尽可能转换为其最接近的原生 Java 对象(String
,Number
,Boolean
,List
,Map
,Date
等),简化处理。但需注意潜在的类型不匹配。
六、最佳实践与性能优化
- 单一职责:每个自定义指令应专注于完成一个明确的任务。
- 清晰的命名:使用描述性强的名称(如
formatDate
,renderImage
,checkPermission
)。 - 文档化:为自定义指令编写文档,说明其用途、参数、用法示例和可能的错误。
- 复用而非复制:将通用功能(如 HTML 转义、日志记录)提取到工具类中。
- 性能优化:
- 避免阻塞 I/O:指令执行应在合理时间内完成。避免同步的远程 HTTP 调用或慢速数据库查询。考虑使用缓存或异步模式(需谨慎设计)。
- 利用缓存:如果指令的输出基于某些输入且不常变化,可以在 Java 层实现缓存机制(如
Caffeine
,Ehcache
),避免重复计算。 - 高效的数据结构:在指令内部处理数据时,使用合适的集合和算法。
- 测试:
- 单元测试:为
execute
方法编写单元测试,覆盖各种参数组合和边界情况。 - 集成测试:编写包含该指令的模板,并使用
Template
的process
方法进行测试,验证最终输出是否符合预期。
- 单元测试:为
- 错误处理友好:抛出的
TemplateModelException
消息应清晰、具体,帮助模板开发者快速定位问题。 - 版本兼容性:注意 FreeMarker 版本升级可能带来的 API 变化。
通过掌握这些核心概念、详细步骤、避免常见错误、注意关键事项、运用技巧并遵循最佳实践,你就能创建出强大、高效、安全且易于维护的 FreeMarker 自定义指令,显著提升模板开发的效率和质量。