FreeMarker 的强大之处不仅在于其内置指令(如 <#if>, <#list>),更在于其可扩展性——允许开发者创建自定义指令。自定义指令让你能将复杂的 Java 逻辑封装成简洁的模板标签,极大地提升模板的可读性、复用性和功能性。


一、核心概念

  1. 自定义指令 (Custom Directive)

    • 由 Java 代码实现的、可在 FreeMarker 模板中使用的指令。
    • 语法类似于 <#if><#list>,但使用自定义名称,如 <@myDirective param1=value1 /><@myDirective>...</@myDirective>
    • 允许在模板中执行复杂的业务逻辑、数据处理、生成动态内容或与外部系统交互。
  2. TemplateDirectiveModel 接口

    • 核心接口。所有自定义指令的 Java 实现类都必须实现此接口。
    • 定义了一个关键方法:void execute(Environment env, Map params, TemplateModel[] loopVars, TemplateDirectiveBody body) throws TemplateException, IOException;
    • 当模板引擎遇到 <@myDirective ...> 时,就会调用该接口的 execute 方法。
  3. Environment

    • 表示当前模板执行的环境。通过它,指令可以:
      • 访问当前模板的配置 (getConfiguration())。
      • 获取/设置变量 (getVariable(), setVariable())。
      • 输出内容到模板 (getOut().write(...)).
      • 抛出模板异常 (throw new TemplateException(...)).
  4. params (参数 Map)

    • 一个 Map<String, TemplateModel>,包含了在指令调用时传入的所有命名参数
    • 例如:<@myDirective name="John" age=30 />,则 params.get("name") 是字符串 "John"TemplateModel 表示,params.get("age") 是数字 30TemplateModel 表示。
    • 需要将 TemplateModel 转换为具体的 Java 类型(如 String, Integer, List, Map)。
  5. loopVars (循环变量数组)

    • 一个 TemplateModel[],用于支持指令作为 <#list> 的“接受者”(类似 <#list items as item><@myDirective /></#list>)。
    • loopVars 数组的元素对应 <#list> 语句中的循环变量(item)。
    • execute 方法中,可以通过 loopVars[0] = ... 来修改循环变量的值(如果需要)。
    • 通常,自定义指令不直接使用 loopVars,除非设计为与 <#list> 配合。
  6. TemplateDirectiveBody (指令体)

    • 表示指令的“嵌套内容”或“主体”。
    • 对于 结束标签指令 (<@myDirective>...</@myDirective>),body 不为 null
    • 对于 自结束指令 (<@myDirective />),bodynull
    • 通过调用 body.render(env.getOut()) 可以执行并输出指令体内的内容。
  7. TemplateModel 及其子类

    • FreeMarker 用来在 Java 层和模板层之间传递数据的接口。
    • 常见子类:
      • TemplateScalarModel -> String
      • TemplateNumberModel -> Number (Integer, Double, Long)
      • TemplateBooleanModel -> Boolean
      • TemplateSequenceModel -> List, Array
      • TemplateHashModel -> Map, POJO
      • TemplateMethodModel -> Method
      • TemplateDirectiveModel -> 自定义指令
    • 类型转换工具freemarker.template.utility.DeepUnwrapfreemarker.ext.beans.BeanModel 常用于将 TemplateModel 转换为原生 Java 对象。
  8. 注册 (Registration)

    • 自定义指令实现类需要在 FreeMarker Configuration 对象中注册,才能在模板中使用。
    • 通过 configuration.setSharedVariable("directiveName", new MyDirective()); 完成。

二、操作步骤(非常详细)

目标:创建一个自定义指令 <@highlight text=... color="yellow" />,用于高亮显示文本。

步骤 1:创建自定义指令 Java 类

  1. 创建类:创建一个 Java 类,例如 HighlightDirective.java
  2. 实现接口:让该类实现 TemplateDirectiveModel 接口。
  3. 实现 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("&", "&amp;")
                   .replace("<", "&lt;")
                   .replace(">", "&gt;")
                   .replace("\"", "&quot;")
                   .replace("'", "&#x27;");
    }
}

步骤 2:在 FreeMarker Configuration 中注册指令

  1. 创建 Configuration:初始化 FreeMarker 的 Configuration 对象。
  2. 注册指令:使用 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:在模板中使用自定义指令

  1. 创建模板:创建一个 .ftl 文件,例如 demo.ftl
  2. 使用指令:使用注册时指定的名称(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();
        }
    }
}

三、常见错误

  1. 忘记注册指令

    • 错误:实现了 TemplateDirectiveModel,但在 Configuration 中没有调用 setSharedVariable
    • 后果:模板中使用 <@highlight ...> 时,抛出 TemplateException,提示“Unknown directive: highlight”。
    • 解决:确保在 Configuration 初始化时正确注册。
  2. 参数类型转换错误

    • 错误:直接将 params.get("paramName") 强制转换为 StringInteger,而不检查 TemplateModel 的具体类型。
    • 后果ClassCastException
    • 解决:始终先检查 instanceof,然后安全转换,或使用 DeepUnwrap 等工具。
  3. 未处理 null 参数

    • 错误:假设某个参数一定存在或不为 null
    • 后果NullPointerException 或逻辑错误。
    • 解决:检查 params.containsKey(),并为必需参数抛出 TemplateModelException
  4. TemplateDirectiveBody 处理不当

    • 错误:对于有结束标签的指令,忘记调用 body.render(out),导致指令体内容不显示。
    • 错误:对于自结束指令,错误地尝试使用 body(此时为 null)。
    • 解决:使用 if (body != null) 进行判空检查。
  5. 未正确使用 Environment.getOut()

    • 错误:使用 System.out.println() 或其他 Writer 输出内容。
    • 后果:输出不会出现在最终的模板结果中,而是直接打印到控制台。
    • 解决必须使用 env.getOut().write() 将内容写入模板输出流。
  6. 命名冲突

    • 错误:自定义指令名与 FreeMarker 内置指令名(如 if, list)或已注册的共享变量名冲突。
    • 后果:行为异常或覆盖内置功能。
    • 解决:选择唯一且描述性的名称,避免使用保留字。

四、注意事项

  1. 线程安全TemplateDirectiveModel 实现类的实例通常会被多个线程共享(因为注册在 Configuration 中)。确保你的实现是线程安全的。避免在类中使用实例变量存储状态。如果需要状态,应在 execute 方法内使用局部变量。
  2. 异常处理:在 execute 方法中抛出 TemplateExceptionIOException 会被 FreeMarker 捕获并处理(取决于 TemplateExceptionHandler)。使用 TemplateModelException 通常更合适,它是 TemplateException 的子类,用于表示模板模型相关的错误。
  3. 资源管理:如果指令需要打开文件、数据库连接等资源,务必在 try-finally 块或 try-with-resources 语句中妥善关闭。
  4. 性能考量:避免在指令中执行耗时的操作(如远程调用、复杂计算)。尽量将数据准备放在 Java 业务逻辑层,指令只负责展示。
  5. 安全性:如果指令生成 HTML,务必对用户输入进行 HTML 转义(如 escapeHtml 示例),防止跨站脚本(XSS)攻击。
  6. loopVars 的使用:除非你的指令设计为与 <#list> 等配合(如 <#compress>),否则通常不需要处理 loopVars

五、使用技巧

  1. 提供默认值:为可选参数设置合理的默认值(如 color="yellow")。
  2. 参数验证:对参数进行验证(如检查颜色值是否为有效 CSS 颜色),并提供清晰的错误信息。
  3. 支持多种用法:像 highlight 指令一样,同时支持参数传入和指令体传入,增加灵活性。
  4. 封装复杂逻辑:将分页、权限检查、缓存读取、API 调用等复杂逻辑封装在自定义指令中。
  5. 利用 TemplateModel 类型:可以接受 TemplateSequenceModel (List) 或 TemplateHashModel (Map/POJO) 作为参数,实现更复杂的数据处理。
  6. 创建指令库:将一组相关的指令打包成一个库,方便在不同项目中复用。
  7. 使用 DeepUnwrapfreemarker.template.utility.DeepUnwrap.permissiveUnwrap(TemplateModel) 可以将 TemplateModel 尽可能转换为其最接近的原生 Java 对象(String, Number, Boolean, List, Map, Date 等),简化处理。但需注意潜在的类型不匹配。

六、最佳实践与性能优化

  1. 单一职责:每个自定义指令应专注于完成一个明确的任务。
  2. 清晰的命名:使用描述性强的名称(如 formatDate, renderImage, checkPermission)。
  3. 文档化:为自定义指令编写文档,说明其用途、参数、用法示例和可能的错误。
  4. 复用而非复制:将通用功能(如 HTML 转义、日志记录)提取到工具类中。
  5. 性能优化
    • 避免阻塞 I/O:指令执行应在合理时间内完成。避免同步的远程 HTTP 调用或慢速数据库查询。考虑使用缓存或异步模式(需谨慎设计)。
    • 利用缓存:如果指令的输出基于某些输入且不常变化,可以在 Java 层实现缓存机制(如 Caffeine, Ehcache),避免重复计算。
    • 高效的数据结构:在指令内部处理数据时,使用合适的集合和算法。
  6. 测试
    • 单元测试:为 execute 方法编写单元测试,覆盖各种参数组合和边界情况。
    • 集成测试:编写包含该指令的模板,并使用 Templateprocess 方法进行测试,验证最终输出是否符合预期。
  7. 错误处理友好:抛出的 TemplateModelException 消息应清晰、具体,帮助模板开发者快速定位问题。
  8. 版本兼容性:注意 FreeMarker 版本升级可能带来的 API 变化。

通过掌握这些核心概念、详细步骤、避免常见错误、注意关键事项、运用技巧并遵循最佳实践,你就能创建出强大、高效、安全且易于维护的 FreeMarker 自定义指令,显著提升模板开发的效率和质量。