Freemarker 使用进阶:自定义对象访问

July 30, 2025 作者: yonghao 分类: Java 浏览: 0 评论: 0

引言:

在日常使用 Freemarker 作为模板引擎时,我们经常会遇到 null 值、缺失字段、多层嵌套结构、集合判空等问题。尤其是在业务数据不稳定、模板由非技术用户配置的场景中,模板容错能力和渲染健壮性显得尤为重要。

本文是安全地使用 FreeMarker 渲染 JSON 数据续集,将介绍一种增强型 Freemarker 使用方式:通过自定义 ObjectWrapper,实现安全、灵活的对象访问能力,支持:

  • null 值默认处理

  • 集合自动降级为第一个元素属性访问

  • 反射字段 + getter 双重 fallback

  • 安全的嵌套属性访问


一、问题背景

通常 Freemarker 模板中的字段访问如:

Hello, ${user.name}!
Your city: ${user.address.city}

如果 useruser.address 为 null,会直接抛出 InvalidReferenceException 异常,除非我们手动加 !default 来提供默认值。

此外,对于集合类型,我们有时希望:

${user.name}         <-- 获取第一个用户的名字
${userList[0].name}  <-- 繁琐且对非技术用户不友好

而 Freemarker 默认不支持“集合转单个对象访问”的语法糖。


二、解决方案:自定义 ObjectWrapper

通过继承 DefaultObjectWrapper,我们可以重写 wrap 方法,对模板数据做统一处理:

cfg.setObjectWrapper(new SafeObjectWrapper(Configuration.VERSION_2_3_32));

✅ 核心特性:

1. null 值容忍

if (obj == null) {
    return null; // 返回 null 让模板内自由使用默认值如 ${xx!'默认'}
}

2. 集合自动降级访问第一个元素属性

if (obj instanceof Collection) {
    if (collection.isEmpty()) return TemplateScalarModel.EMPTY_STRING;
    return new CollectionAndItemTemplateModel(collection, this);
}

3. CollectionAndItemTemplateModel:支持多重访问模式

Skills: ${user.skills}        <-- 输出字符串 "Java, Python"
Skill 1: ${user.skills[0]}    <-- 支持序列访问
Lang: ${user.skills.name}     <-- 如果 skills 是对象集合,可访问第一个元素字段

通过实现 TemplateScalarModel + TemplateHashModel + TemplateSequenceModel 多接口,这一类可以灵活处理各种访问方式。

4. 字段优先,getter 兜底

try {
    Field field = firstItem.getClass().getDeclaredField(key);
    ...
} catch {
    Method getter = clazz.getMethod("getXxx");
    ...
}

三、完整示例:FreeMarkerTemplateEngine

以下是增强版模板引擎的核心结构:

package com.shulex.gpt.prompts.template.engine;

import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.exceptions.ExceptionUtil;
import com.shulex.gpt.prompts.config.FreeMarkerConfig;
import com.shulex.gpt.prompts.enums.TemplateEngineTypeEnum;
import freemarker.template.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;

import java.io.StringWriter;
import java.util.*;

@Primary
@Component
@EnableConfigurationProperties(FreeMarkerConfig.class)
@Slf4j
public class FreeMarkerTemplateEngine implements ITemplateEngine {

    private final Configuration cfg;

    public FreeMarkerTemplateEngine(FreeMarkerConfig config) {
        cfg = new Configuration(Configuration.VERSION_2_3_32);
        cfg.setDefaultEncoding(config.getDefaultEncoding());
        cfg.setLogTemplateExceptions(config.getLogTemplateExceptions());
        cfg.setWrapUncheckedExceptions(config.getWrapUncheckedExceptions());
        cfg.setFallbackOnNullLoopVariable(config.getFallbackOnNullLoopVariable());
        cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
        cfg.setClassicCompatible(true);
        cfg.setObjectWrapper(new SafeObjectWrapper(Configuration.VERSION_2_3_32));
    }

    /**
     * 安全的 ObjectWrapper,用于处理 null 值和集合降级访问
     */
    private static class SafeObjectWrapper extends DefaultObjectWrapper {
        public SafeObjectWrapper(Version version) {
            super(version);
            setExposeFields(true);
        }

        @Override
        public TemplateModel wrap(Object obj) throws TemplateModelException {
            if (obj == null) {
                return null;
            }

            if (obj instanceof Collection) {
                Collection<?> collection = (Collection<?>) obj;
                if (collection.isEmpty()) {
                    return TemplateScalarModel.EMPTY_STRING;
                }
                return new CollectionAndItemTemplateModel(collection, this);
            }

            return super.wrap(obj);
        }

        @Override
        protected TemplateModel handleUnknownType(Object obj) {
            return obj != null ? new SimpleScalar(obj.toString()) : null;
        }

        private static class CollectionAndItemTemplateModel implements TemplateModel, TemplateHashModel, TemplateSequenceModel, TemplateScalarModel {
            private final Collection<?> collection;
            private final ObjectWrapper wrapper;
            private final Object firstItem;

            public CollectionAndItemTemplateModel(Collection<?> collection, ObjectWrapper wrapper) {
                this.collection = collection;
                this.wrapper = wrapper;
                this.firstItem = collection.iterator().next();
            }

            @Override
            public TemplateModel get(String key) throws TemplateModelException {
                if (firstItem instanceof Map) {
                    Object value = ((Map<?, ?>) firstItem).get(key);
                    return wrapper.wrap(value);
                } else {
                    try {
                        java.lang.reflect.Field field = firstItem.getClass().getDeclaredField(key);
                        field.setAccessible(true);
                        Object value = field.get(firstItem);
                        return wrapper.wrap(value);
                    } catch (Exception e) {
                        try {
                            String getterName = "get" + Character.toUpperCase(key.charAt(0)) + key.substring(1);
                            java.lang.reflect.Method method = firstItem.getClass().getMethod(getterName);
                            Object value = method.invoke(firstItem);
                            return wrapper.wrap(value);
                        } catch (Exception ex) {
                            return null;
                        }
                    }
                }
            }

            @Override
            public boolean isEmpty() {
                return collection.isEmpty();
            }

            @Override
            public TemplateModel get(int index) throws TemplateModelException {
                if (index < 0 || index >= collection.size()) {
                    return null;
                }

                if (collection instanceof List) {
                    return wrapper.wrap(((List<?>) collection).get(index));
                } else {
                    Iterator<?> it = collection.iterator();
                    for (int i = 0; i < index; i++) {
                        it.next();
                    }
                    return wrapper.wrap(it.next());
                }
            }

            @Override
            public int size() {
                return collection.size();
            }

            @Override
            public String getAsString() {
                StringBuilder sb = new StringBuilder();
                boolean first = true;
                for (Object item : collection) {
                    if (!first) sb.append(", ");
                    sb.append(item != null ? item.toString() : "");
                    first = false;
                }
                return sb.toString();
            }
        }
    }

    @Override
    public TemplateEngineTypeEnum getType() {
        return TemplateEngineTypeEnum.FREE_MARKER;
    }

    @Override
    public String process(String source, Map<String, Object> data) {
        if (source == null) return "";

        try {
            Map<String, Object> safeDataModel = data != null ? data : new HashMap<>();
            Template temp = new Template(null, source, cfg);
            StringWriter writer = new StringWriter();
            temp.process(safeDataModel, writer);
            return writer.toString();
        } catch (Exception e) {
            log.error("模板渲染失败: {}", ExceptionUtil.stacktraceToString(e));
            return "模板渲染错误: " + e.getMessage();
        }
    }

    // 测试用例
    public static void main(String[] args) {
        FreeMarkerTemplateEngine engine = new FreeMarkerTemplateEngine(new FreeMarkerConfig());

        String templateStr =
                "Hello, ${user.name!'Unknown'}!\n" +
                "Your age is: ${user.age!'Not provided'}\n" +
                "Your address: ${user.address.city!'No city'}, ${user.address.country!'No country'}\n" +
                "Skills: ${(user.skills)!'[java,python,sql]'}\n" +
                "Nested data: ${nestedData.key1!'Default value'}\n" +
                "Nested data: ${nestedData.key2}";

        // Case 1: null 数据
        System.out.println("case1 ==================================");
        System.out.println(engine.process(templateStr, null));

        // Case 2: user 为 List 类型(测试集合降级)
        {
            Map<String, Object> user = new HashMap<>();
            user.put("name", "Alice");
            user.put("age", null);
            Map<String, String> address = new HashMap<>();
            address.put("city", "Beijing");
            user.put("address", address);
            user.put("skills", Arrays.asList("Java", "Python", "Ruby"));

            Map<String, Object> data = new HashMap<>();
            data.put("user", ListUtil.toList(user));

            System.out.println("case2 ==================================");
            System.out.println(engine.process(templateStr, data));
        }

        // Case 3: user 为普通对象,部分字段缺失
        {
            Map<String, Object> user = new HashMap<>();
            user.put("name", "Alice");
            user.put("age", null);
            Map<String, String> address = new HashMap<>();
            address.put("country", "CHINA");
            user.put("address", address);
            user.put("skills", Arrays.asList("Java", "Python", "JavaScript"));

            Map<String, Object> data = new HashMap<>();
            data.put("user", user);

            System.out.println("case3 ==================================");
            System.out.println(engine.process(templateStr, data));
        }
    }
}

四、渲染测试用例

模板内容:

Hello, ${user.name!'Unknown'}!
Your age is: ${user.age!'Not provided'}
Your address: ${user.address.city!'No city'}, ${user.address.country!'No country'}
Skills: ${(user.skills)!'[java,python,sql]'}
Nested data: ${nestedData.key1!'Default value'}

case1:无数据传入

输出如下:

Hello, Unknown!
Your age is: Not provided
Your address: No city, No country
Skills: [java,python,sql]
Nested data: Default value

case2:传入 user 为 List(集合自动降级)

data.put("user", ListUtil.toList(userMap));

输出如下:

Hello, Alice!
Your age is: Not provided
Your address: Beijing, No country
Skills: Java, Python, Ruby
Nested data: Default value

case3:user 为普通对象,address 部分字段缺失

输出:

Hello, Alice!
Your age is: Not provided
Your address: No city, CHINA
Skills: Java, Python, JavaScript
Nested data: Default value

五、应用场景推荐

该模式适用于:

场景

适配性

JSON 数据结构复杂

✅ 强容错

多语言动态模板

✅ 可安全降级

非技术用户编辑模板

✅ 默认值支持友好

多层嵌套数据结构

✅ 自动支持属性访问

集合 + 属性混合访问

✅ 自动转换第一个元素


六、结语

通过自定义 ObjectWrapper,我们不仅解决了 Freemarker 原生在 null、集合处理上的不友好问题,还显著提升了模板的容错性与灵活性。

这种方式对于构建一个可配置、非代码编写者友好的 SaaS 模板系统非常有帮助。


如需完整代码样例,请参考上方 FreeMarkerTemplateEngine 实现。欢迎你在此基础上继续扩展,如支持 Map 降级、List 扩展属性或自定义格式化函数等。

如果你在使用过程中遇到问题,欢迎评论交流 😊


#Freemark(1)#Java(6)

评论