FreeMarker 本身不提供传统意义上的“插件系统”(如 WordPress 或 IDE 插件),但其强大的 可扩展性 允许开发者通过多种方式实现“插件化”功能,以增强模板能力、集成外部服务或封装业务逻辑。

一、核心概念

在 FreeMarker 语境中,“插件”通常指以下几种可扩展机制:

概念 说明
1. 自定义指令(Custom Directive) 通过实现 TemplateDirectiveModel 接口,创建可在模板中使用的 #@myDirective param="value"/ 语法的指令。这是最强大、最常用的“插件”形式。
2. 自定义方法(Custom Method) 实现 TemplateMethodModelEx 接口,创建可在模板中调用的函数,如 ${myMethod(arg1, arg2)}
3. 自定义内建函数(Custom Built-ins) 扩展变量的 ? 操作符,如 ${user.name?myFilter}。需通过 Configuration.setCustomAttribute() 或反射注入(较复杂,不推荐新手)。
4. 共享变量(Shared Variables) 通过 Configuration.setSharedVariable("serviceName", serviceObject) 将 Java 对象暴露给所有模板使用,是最简单的“插件”注册方式。
5. 模板加载器(TemplateLoader) 自定义模板来源(如数据库、网络、加密文件),实现 TemplateLoader 接口。
6. 宏(Macro)库 虽然不是 Java 插件,但可通过 .ftl 文件定义宏集合,实现模板层面的“插件”复用。

本指南重点:自定义指令(Custom Directive)和自定义方法(Custom Method),它们是实现业务插件的核心。


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

以下以 Maven + FreeMarker 2.3.32 + JUnit 5 为例,演示开发一个名为 highlight 的自定义指令插件。

步骤 1:创建 Maven 项目并添加依赖

pom.xml

<dependencies>
    <!-- FreeMarker 核心 -->
    <dependency>
        <groupId>org.freemarker</groupId>
        <artifactId>freemarker</artifactId>
        <version>2.3.32</version>
    </dependency>

    <!-- 单元测试 -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.0</version>
        <scope>test</scope>
    </dependency>
</dependencies>

步骤 2:开发自定义指令插件

创建插件类 HighlightDirective.java

package com.example.plugin;

import freemarker.core.Environment;
import freemarker.template.*;
import java.io.IOException;
import java.util.Map;

/**
 * 自定义指令:#@highlight color="yellow" text="Hello"/
 * 或 <#@highlight color="yellow">Hello</@highlight>
 */
public class HighlightDirective implements TemplateDirectiveModel {

    // 定义参数名常量
    private static final String PARAM_COLOR = "color";
    private static final String PARAM_TEXT = "text";
    private static final String DEFAULT_COLOR = "yellow";

    @Override
    public void execute(Environment env,
                        Map params,
                        TemplateModel[] loopVars,
                        TemplateDirectiveBody body) throws TemplateException, IOException {

        // 1. 解析参数
        String color = DEFAULT_COLOR;
        String text = null;

        // 获取 color 参数
        TemplateModel colorModel = (TemplateModel) params.get(PARAM_COLOR);
        if (colorModel != null) {
            if (colorModel instanceof TemplateScalarModel) {
                color = ((TemplateScalarModel) colorModel).getAsString();
            } else {
                throw new TemplateModelException("The '" + PARAM_COLOR + "' parameter must be a string.");
            }
        }

        // 获取 text 参数(可选)
        TemplateModel textModel = (TemplateModel) params.get(PARAM_TEXT);
        if (textModel != null) {
            if (textModel instanceof TemplateScalarModel) {
                text = ((TemplateScalarModel) textModel).getAsString();
            } else {
                throw new TemplateModelException("The '" + PARAM_TEXT + "' parameter must be a string.");
            }
        }

        // 2. 处理嵌套内容(body)
        if (text == null && body != null) {
            // 如果未提供 text 参数,则使用指令体内容
            StringWriter writer = new StringWriter();
            body.render(writer);
            text = writer.toString();
        }

        if (text == null) {
            throw new TemplateModelException("Either 'text' parameter or directive body must be provided.");
        }

        // 3. 生成 HTML 高亮输出
        String highlighted = String.format("<mark style='background-color:%s;'>%s</mark>", color, text);

        // 4. 写入输出流
        env.getOut().write(highlighted);
    }
}

步骤 3:开发自定义方法插件(可选)

创建 StringLengthMethod.java

package com.example.plugin;

import freemarker.template.TemplateMethodModelEx;
import freemarker.template.TemplateModelException;
import java.util.List;

/**
 * 自定义方法:${stringLength("hello")} -> 5
 */
public class StringLengthMethod implements TemplateMethodModelEx {
    @Override
    public Object exec(List arguments) throws TemplateModelException {
        if (arguments == null || arguments.isEmpty()) {
            throw new TemplateModelException("Missing argument for stringLength method.");
        }
        Object arg = arguments.get(0);
        if (arg instanceof TemplateScalarModel) {
            String str = ((TemplateScalarModel) arg).getAsString();
            return str.length();
        }
        throw new TemplateModelException("Argument must be a string.");
    }
}

步骤 4:注册插件到 FreeMarker 配置

创建测试类 PluginTest.java

package com.example;

import com.example.plugin.HighlightDirective;
import com.example.plugin.StringLengthMethod;
import freemarker.template.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.StringWriter;
import java.io.Writer;
import java.util.HashMap;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

public class PluginTest {

    private Configuration configuration;

    @BeforeEach
    void setUp() {
        configuration = new Configuration(new Version(2, 3, 32));
        configuration.setDefaultEncoding("UTF-8");

        // === 注册插件(关键步骤)===

        // 1. 注册自定义指令(使用 shared variable 方式)
        configuration.setSharedVariable("highlight", new HighlightDirective());

        // 2. 注册自定义方法
        configuration.setSharedVariable("stringLength", new StringLengthMethod());

        // 3. 也可以注册其他共享服务
        // configuration.setSharedVariable("userService", new UserService());
    }

    @Test
    void shouldUseHighlightDirective() throws Exception {
        String templateContent = """
            <p>Normal text.</p>
            <p>Using param: <#@highlight color="pink" text="Important!"/></p>
            <p>Using body: <#@highlight color="lightgreen">Very Important!</@highlight></p>
            """;

        // 使用 StringTemplateLoader 加载字符串模板(测试用)
        StringTemplateLoader loader = new StringTemplateLoader();
        loader.putTemplate("test.ftl", templateContent);
        configuration.setTemplateLoader(loader);

        Template template = configuration.getTemplate("test.ftl");
        Writer out = new StringWriter();
        template.process(new HashMap<>(), out);

        String result = out.toString();

        assertThat(result).contains("background-color:pink");
        assertThat(result).contains("Important!");
        assertThat(result).contains("background-color:lightgreen");
        assertThat(result).contains("Very Important!");
    }

    @Test
    void shouldUseStringLengthMethod() throws Exception {
        String templateContent = "Length: ${stringLength('FreeMarker')}";

        StringTemplateLoader loader = new StringTemplateLoader();
        loader.putTemplate("method.ftl", templateContent);
        configuration.setTemplateLoader(loader);

        Template template = configuration.getTemplate("method.ftl");
        Writer out = new StringWriter();
        template.process(new HashMap<>(), out);

        assertThat(out.toString()).isEqualTo("Length: 10");
    }
}

步骤 5:在真实模板中使用插件

创建 src/main/resources/templates/page.ftl

<!DOCTYPE html>
<html>
<head><title>Plugin Demo</title></head>
<body>
    <h1>Welcome</h1>

    <p>Here is a <#@highlight color="gold">highlighted phrase</@highlight>.</p>

    <p>The word "FreeMarker" has ${stringLength("FreeMarker")} letters.</p>
</body>
</html>

Java 代码加载并渲染:

// 生产环境:使用 FileTemplateLoader 或 ClassTemplateLoader
configuration.setDirectoryForTemplateLoading(new java.io.File("src/main/resources/templates"));

Template template = configuration.getTemplate("page.ftl");
Writer out = new StringWriter();
template.process(dataModel, out);
String html = out.toString();

三、常见错误

错误 原因 解决方案
Unknown directive: highlight 插件未注册或名称拼写错误 检查 setSharedVariable("name", plugin) 是否执行
The parameter must be a string 传入了非字符串类型(如数字、对象) 在插件中加强类型检查,或提供类型转换
No value for directive body 忘记闭合指令 <#@highlight>...</@highlight> 使用成对标签或自闭合 <#@highlight .../>
TemplateModelException 插件代码抛出异常 捕获并包装为 TemplateModelException,提供清晰错误信息
ClassCastException 未检查 TemplateModel 类型 务必使用 instanceof 判断类型
IOException 写入失败 env.getOut() 写入异常 通常由上层处理,插件中可捕获并包装

四、注意事项

  1. 线程安全TemplateDirectiveModelTemplateMethodModel 实例应在 Configuration 中共享,必须是线程安全的。避免在插件类中使用实例变量存储状态。
  2. 异常处理:插件中抛出的异常应为 TemplateModelExceptionTemplateException,以便 FreeMarker 正确处理并定位错误模板行号。
  3. 参数验证:对输入参数进行严格校验(非空、类型、范围)。
  4. 命名规范:插件名避免与 FreeMarker 关键字冲突(如 if, list)。
  5. 性能敏感:避免在插件中进行耗时操作(如远程调用、大文件读取)。如需,应缓存结果或异步处理。
  6. HTML 转义:如果输出 HTML,注意是否需转义输入内容(除非明确信任)。可使用 ?htmlfmpp 工具辅助。

五、使用技巧

  1. 封装复杂逻辑:将数据库查询、API 调用、复杂计算封装在插件中,模板只需调用。
  2. 使用 @env 获取上下文Environment 对象可访问当前模板、命名空间、局部变量等。
  3. 支持多种参数模式:如 highlight 指令同时支持 text 参数和指令体。
  4. 提供默认值:为可选参数设置合理默认值。
  5. 日志输出:在插件中使用 SLF4J 记录调试日志(需引入日志依赖)。
  6. 组合使用:一个指令可调用其他方法或指令。

六、最佳实践与性能优化

实践 说明
单一职责 每个插件只做一件事(如 highlight 只负责高亮)。
易于测试 插件逻辑独立,可通过单元测试验证。
文档化 为插件编写文档,说明参数、用法、示例。
版本兼容 注意 FreeMarker 版本 API 变化,保持向后兼容。
错误信息友好 抛出异常时包含参数名、期望类型等信息。
性能优化
- 缓存计算结果:对耗时操作(如远程 API)结果缓存(注意过期策略)。 @Cacheable 注解或手动缓存
- 避免重复解析:如解析 CSS 颜色名,可缓存映射表。 static final Map
- 轻量级实现:插件代码应简洁高效。
- 异步加载(高级):对非关键资源,可返回占位符,由前端异步加载。 结合 JavaScript
🔐 安全考虑
- 输入验证与清理:防止 XSS、命令注入。 使用 ?html, ?js_string 等内建函数
- 权限控制(可选):在插件中检查用户权限。 结合 Spring Security 等

总结

FreeMarker “插件开发”本质是通过 自定义指令自定义方法 扩展模板能力。核心步骤为:

  1. 实现 TemplateDirectiveModelTemplateMethodModelEx 接口
  2. Configuration 中通过 setSharedVariable 注册
  3. 在模板中使用 #@directive${method()} 调用

通过插件机制,您可以将复杂的业务逻辑、工具函数、外部服务集成到模板中,提升开发效率和模板表达力。遵循最佳实践,确保插件线程安全、健壮、高效、安全,是构建高质量 FreeMarker 应用的关键。