FreeMarker 提供了丰富的内建函数(Built-in functions),如 ?upper_case, ?length, ?date 等,用于处理模板中的数据。然而,有时标准的内建函数无法满足特定需求。FreeMarker 允许开发者创建自定义内建函数,以扩展其功能。这通过实现特定的 Java 接口并将其注册到 FreeMarker 的配置中来完成。


一、核心概念

  1. 内建函数 (Built-in Function)

    • 在 FreeMarker 模板中,通过 ?functionName 语法调用的函数。
    • 作用于模板表达式的结果(左侧的值)。
    • 例如:${name?upper_case} 中的 upper_case 就是一个内建函数。
  2. TemplateModel

    • FreeMarker 中所有数据类型(字符串、数字、哈希表、序列、方法等)在 Java 层的抽象接口。
    • 自定义内建函数需要操作和返回 TemplateModel 实例。
  3. TemplateTransformModel (已过时/不推荐):

    • 旧版本 FreeMarker 用于创建自定义内建函数的接口。在现代 FreeMarker (2.3+) 中,推荐使用 TemplateTransformModel 的替代方案或直接通过 Configuration 注册。
  4. TemplateTransformModel 的替代方案

    • TemplateMethodModelEx:虽然主要用于 method_name() 形式的调用,但有时可变通使用。
    • TemplateTransformModel:主要用于创建可应用于文本块的转换器(类似 <#transform>),行为上可以模拟某些内建函数。
    • ConfigurationsetSharedVariable最常用和推荐的方法。将一个实现了特定逻辑的 Java 对象(通常包装了处理逻辑)作为共享变量放入 Configuration,然后在模板中通过 ? 语法访问其方法。但这本质上是调用对象的方法,而不是原生的内建函数。
  5. 真正的自定义内建函数

    • FreeMarker 的核心机制允许通过 ConfigurationsetBuiltIn 方法(或相关 API)来注册自定义的内建函数实现。这需要实现 FreeMarker 内部的、与解析和执行相关的类。
    • 关键接口/类
      • freemarker.core.BuiltIn: 这是 FreeMarker 内部用于表示内建函数的抽象类。开发者通常需要继承它或其子类。
      • freemarker.template.utility.BuiltInsForStrings 等包:包含标准内建函数的实现,可作为参考。
    • 重要提示:直接实现 BuiltIn 或其子类涉及到 FreeMarker 的内部 API,可能在不同版本间有兼容性问题,且相对复杂。因此,社区和实践中更常见的“自定义内建函数”是通过共享变量(Shared Variables)来模拟的
  6. 共享变量 (Shared Variable)

    • 通过 Configuration.setSharedVariable(String name, Object value) 方法注册的对象。
    • 在所有模板中全局可用,无需放入每个数据模型。
    • 常用于注册工具类、常量或提供自定义方法调用

二、操作步骤(非常详细) - 推荐方法:使用共享变量模拟

由于直接实现 FreeMarker 内部 BuiltIn 类较为复杂且依赖内部 API,以下介绍最常用、最稳定且易于维护的“模拟”自定义内建函数的方法:通过共享变量注册一个包含自定义方法的 Java 对象。

步骤 1:创建自定义工具类

创建一个 Java 类,包含你希望在模板中作为“内建函数”使用的方法。这些方法需要处理 TemplateModel 并返回 TemplateModel

// CustomBuiltIns.java
import freemarker.template.*;
import java.util.List;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
 * 模拟自定义内建函数的工具类。
 * 注意:这些方法的签名需要符合 TemplateMethodModelEx 的要求。
 */
public class CustomBuiltIns implements TemplateMethodModelEx {

    // ==================== 自定义内建函数 1: ?formatDate(pattern) ====================
    /**
     * 格式化日期字符串或时间戳。
     * 用法: ${dateOrTimestamp?formatDate("yyyy-MM-dd HH:mm")}
     * @param arguments 第一个参数是日期/时间戳,第二个参数是格式模式
     * @return 格式化后的字符串的 TemplateModel
     * @throws TemplateModelException
     */
    public Object exec(List arguments) throws TemplateModelException {
        if (arguments.size() < 1 || arguments.size() > 2) {
            throw new TemplateModelException("Expected 1 or 2 arguments (date, [pattern])");
        }

        // 获取第一个参数 (要格式化的日期/时间)
        TemplateModel dateArg = (TemplateModel) arguments.get(0);
        long timestamp;

        // 处理不同的日期类型
        if (dateArg instanceof TemplateDateModel) {
            timestamp = ((TemplateDateModel) dateArg).getAsDate().getTime();
        } else if (dateArg instanceof TemplateNumberModel) {
            // 假设是时间戳 (毫秒)
            timestamp = ((TemplateNumberModel) dateArg).getAsNumber().longValue();
        } else {
            throw new TemplateModelException("First argument must be a date or number (timestamp)");
        }

        Date date = new Date(timestamp);

        // 获取格式模式,如果未提供则使用默认模式
        String pattern = (arguments.size() == 2) ? arguments.get(1).toString() : "yyyy-MM-dd";

        try {
            SimpleDateFormat sdf = new SimpleDateFormat(pattern);
            return new SimpleScalar(sdf.format(date)); // 返回字符串
        } catch (IllegalArgumentException e) {
            throw new TemplateModelException("Invalid date format pattern: " + pattern, e);
        }
    }

    // ==================== 自定义内建函数 2: ?truncate(length, [suffix]) ====================
    /**
     * 截断字符串。
     * 用法: ${text?truncate(50)} 或 ${text?truncate(50, "...")}
     * @param arguments 第一个参数是字符串,第二个是长度,第三个是后缀 (可选)
     * @return 截断后的字符串的 TemplateModel
     * @throws TemplateModelException
     */
    public Object exec(List arguments) throws TemplateModelException {
        if (arguments.size() < 1 || arguments.size() > 3) {
            throw new TemplateModelException("Expected 1 to 3 arguments (string, length, [suffix])");
        }

        // 获取字符串
        TemplateModel stringArg = (TemplateModel) arguments.get(0);
        if (!(stringArg instanceof TemplateScalarModel)) {
            throw new TemplateModelException("First argument must be a string");
        }
        String text = ((TemplateScalarModel) stringArg).getAsString();

        // 获取长度
        int length;
        try {
            length = ((TemplateNumberModel) arguments.get(1)).getAsNumber().intValue();
        } catch (ClassCastException | TemplateModelException e) {
            throw new TemplateModelException("Second argument (length) must be a number");
        }

        // 获取后缀
        String suffix = (arguments.size() == 3) ? arguments.get(2).toString() : "";

        // 执行截断
        if (text.length() <= length) {
            return stringArg; // 不需要截断
        }
        return new SimpleScalar(text.substring(0, length) + suffix);
    }

    // ==================== 自定义内建函数 3: ?containsIgnoreCase(substring) ====================
    /**
     * 检查字符串是否包含子字符串(忽略大小写)。
     * 用法: <#if name?containsIgnoreCase("john")>Found!</#if>
     * 注意:这个方法返回布尔值,通常用于条件判断。
     * @param arguments 第一个参数是字符串,第二个是子字符串
     * @return 布尔值的 TemplateModel
     * @throws TemplateModelException
     */
    public Object exec(List arguments) throws TemplateModelException {
        if (arguments.size() != 2) {
            throw new TemplateModelException("Expected exactly 2 arguments (string, substring)");
        }

        TemplateModel stringArg = (TemplateModel) arguments.get(0);
        if (!(stringArg instanceof TemplateScalarModel)) {
            throw new TemplateModelException("First argument must be a string");
        }
        String text = ((TemplateScalarModel) stringArg).getAsString();

        String substring = arguments.get(1).toString();

        return new TemplateBooleanModel() {
            @Override
            public boolean getAsBoolean() throws TemplateModelException {
                return text.toLowerCase().contains(substring.toLowerCase());
            }
        };
    }
}

步骤 2:在 Java 代码中注册共享变量

在初始化 FreeMarker Configuration 时,将自定义工具类的实例注册为共享变量。

// FreeMarkerConfig.java
import freemarker.template.*;
import java.io.*;
import java.util.*;

public class FreeMarkerConfig {
    private Configuration configuration;

    public FreeMarkerConfig() throws IOException {
        configuration = new Configuration(Configuration.VERSION_2_3_32);

        // 设置模板加载路径
        configuration.setDirectoryForTemplateLoading(new File("path/to/templates"));
        // 设置默认编码
        configuration.setDefaultEncoding("UTF-8");
        // 设置异常处理器
        configuration.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
        configuration.setLogTemplateExceptions(false);
        configuration.setWrapUncheckedExceptions(true);

        // ==================== 注册自定义“内建函数” ====================
        // 创建自定义工具类实例
        CustomBuiltIns customBuiltIns = new CustomBuiltIns();

        // 将其注册为共享变量
        // 注意:共享变量名就是你在模板中使用的“内建函数”名,但需要加上前缀或特殊命名以避免冲突。
        // 这里我们使用 'cb_' 前缀来区分,例如 ?cb_formatDate
        try {
            configuration.setSharedVariable("cb_formatDate", customBuiltIns);
            configuration.setSharedVariable("cb_truncate", customBuiltIns);
            configuration.setSharedVariable("cb_containsIgnoreCase", customBuiltIns);
            // ... 注册其他方法
        } catch (TemplateModelException e) {
            throw new RuntimeException("Failed to register custom built-ins", e);
        }

        // 可以注册其他共享变量,如常量
        // configuration.setSharedVariable("APP_VERSION", "1.0.0");
    }

    public Configuration getConfiguration() {
        return configuration;
    }
}

步骤 3:在 FreeMarker 模板中使用“自定义内建函数”

现在可以在模板中像使用标准内建函数一样使用这些注册的共享变量(方法)。

<#-- template.ftl -->
<#assign now = .now>
<#assign longText = "This is a very long text that needs to be truncated for display purposes.">
<#assign userName = "John Doe">

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Custom Built-ins Demo</title>
</head>
<body>
    <h1>Custom Built-ins Demo</h1>

    <h2>1. Format Date</h2>
    <p>Current Time (Default): ${now?cb_formatDate}</p>
    <p>Current Time (Custom): ${now?cb_formatDate("yyyy-MM-dd HH:mm:ss")}</p>
    <p>Timestamp (1690000000000): ${1690000000000?cb_formatDate("MMM dd, yyyy")}</p>

    <h2>2. Truncate Text</h2>
    <p>Original: ${longText}</p>
    <p>Truncated (20 chars): ${longText?cb_truncate(20)}</p>
    <p>Truncated (30 chars with ...): ${longText?cb_truncate(30, "...")}</p>

    <h2>3. Case-Insensitive Contains</h2>
    <#if userName?cb_containsIgnoreCase("john")>
        <p>User name contains 'john' (case-insensitive).</p>
    <#else>
        <p>User name does not contain 'john'.</p>
    </#if>

    <#if userName?cb_containsIgnoreCase("alice")>
        <p>User name contains 'alice'.</p>
    <#else>
        <p>User name does not contain 'alice'.</p>
    </#if>
</body>
</html>

步骤 4:渲染模板

// Main.java
public class Main {
    public static void main(String[] args) {
        try {
            // 初始化配置
            FreeMarkerConfig config = new FreeMarkerConfig();
            Configuration cfg = config.getConfiguration();

            // 获取模板
            Template template = cfg.getTemplate("template.ftl");

            // 准备数据模型 (如果模板需要其他动态数据)
            Map<String, Object> dataModel = new HashMap<>();
            // dataModel.put("someVar", someValue);

            // 渲染输出
            Writer out = new FileWriter("output.html");
            template.process(dataModel, out);
            out.close();

            System.out.println("Template processed successfully! Output: output.html");

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

三、常见错误

  1. 共享变量名冲突

    • 错误:注册的共享变量名与 FreeMarker 关键字、标准内建函数名或数据模型中的变量名冲突。
    • 解决:使用清晰的命名约定,如添加前缀 (cb_, util_) 或后缀。避免使用 ?if, ?list, ?size 等保留字。
  2. TemplateModelException

    • 错误:在 exec 方法中抛出 TemplateModelException,通常是因为参数类型不匹配、数量错误或处理逻辑出错。
    • 解决:在 exec 方法中进行严格的参数检查(数量、类型),并提供清晰的错误信息。
  3. 类型转换错误

    • 错误:尝试将 TemplateModel 强制转换为错误的子类型(如将 TemplateNumberModel 当作 TemplateScalarModel)。
    • 解决:使用 instanceof 进行类型检查,或使用 TemplateModelException 处理转换失败。
  4. 未注册共享变量

    • 错误:在模板中使用 ?cb_function,但忘记在 Configuration 中调用 setSharedVariable
    • 解决:确保在创建 Configuration 实例后、获取模板前完成所有共享变量的注册。
  5. 方法签名错误

    • 错误exec(List arguments) 方法签名不正确或抛出未声明的异常。
    • 解决:确保方法签名完全匹配 TemplateMethodModelEx.exec,并只抛出 TemplateModelException
  6. 返回 null

    • 错误exec 方法返回 null
    • 解决exec 方法必须返回一个有效的 TemplateModel 实例(如 SimpleScalar, SimpleNumber, TemplateBooleanModel 等),不能返回 null。如果需要表示“无”,可以返回空字符串 new SimpleScalar("")TemplateModelTemplateModel(表示缺失)。

四、注意事项

  1. 命名约定:为自定义“内建函数”选择清晰、描述性的名称,并使用前缀/后缀避免冲突。
  2. 参数验证:在 exec 方法中对参数的数量和类型进行严格验证。
  3. 错误处理:使用 TemplateModelException 抛出有意义的错误信息,便于模板开发者调试。
  4. 性能:避免在 exec 方法中进行耗时的操作(如数据库查询、网络调用)。内建函数应尽可能轻量。
  5. 线程安全TemplateMethodModelEx 实例通常会被多个线程共享(因为注册在 Configuration 上),确保其实现是线程安全的。避免在实例中使用可变的实例变量。如果必须使用状态,考虑使用 ThreadLocal 或确保方法本身是无状态的。
  6. TemplateModel 包装:熟悉 freemarker.template 包中的 SimpleXxx 类(如 SimpleScalar, SimpleNumber, SimpleDate, SimpleCollection)来包装 Java 对象。
  7. ? 语法限制:通过共享变量模拟的“内建函数”使用 ?functionName 语法,但其背后是方法调用。确保理解其工作原理。

五、使用技巧

  1. 创建工具类库:将常用的自定义方法组织在一个或多个工具类中,便于复用。
  2. 利用标准内建函数:在自定义方法内部,可以调用 FreeMarker 的标准内建函数(如果能访问到 Environment),但这比较复杂。通常直接在 Java 中处理。
  3. 支持链式调用:设计方法使其返回的 TemplateModel 仍然可以应用其他内建函数(例如,?cb_truncate 返回 SimpleScalar,后续可以接 ?upper_case)。
  4. 提供默认参数:像示例中的 ?cb_formatDate?cb_truncate 一样,为可选参数提供合理的默认值。
  5. 文档化:为自定义“内建函数”编写文档,说明其用途、参数和返回值。

六、最佳实践与性能优化

  1. 优先使用共享变量模式:这是最稳定、最易于维护和理解的方法,避免直接操作 FreeMarker 内部 API。
  2. 单例 ConfigurationConfiguration 对象是线程安全的,应在应用生命周期内创建单例,并在其中注册所有共享变量。
  3. 缓存昂贵操作:如果自定义函数涉及计算量大的操作(如复杂字符串处理、查找表),考虑在 Java 层进行缓存。
  4. 避免副作用:自定义内建函数应该是纯函数,即相同的输入总是产生相同的输出,且不修改外部状态。
  5. 类型安全:尽可能进行严格的类型检查,提供良好的错误反馈。
  6. 模块化:如果自定义函数很多,可以按功能分组到不同的共享变量对象中。
  7. 性能监控:如果怀疑某个自定义函数成为性能瓶颈,使用性能分析工具进行 profiling。
  8. 考虑替代方案
    • 宏 (<#macro>):对于复杂的、可重用的 UI 片段,宏可能比自定义内建函数更合适。
    • 自定义方法 (method_name()):如果功能更适合以函数形式调用(不依赖左侧值),可以注册 TemplateMethodModelEx 作为普通方法(不带 ?)。

通过遵循以上详细的步骤和最佳实践,您可以成功地在 FreeMarker 中创建和使用自定义的“内建函数”,极大地扩展模板引擎的功能,满足特定的业务需求。记住,共享变量 + TemplateMethodModelEx 是实现这一目标的推荐和主流方式。