Freemarker 使用进阶:自定义对象访问
引言:
在日常使用 Freemarker 作为模板引擎时,我们经常会遇到 null
值、缺失字段、多层嵌套结构、集合判空等问题。尤其是在业务数据不稳定、模板由非技术用户配置的场景中,模板容错能力和渲染健壮性显得尤为重要。
本文是安全地使用 FreeMarker 渲染 JSON 数据续集,将介绍一种增强型 Freemarker
使用方式:通过自定义 ObjectWrapper
,实现安全、灵活的对象访问能力,支持:
null 值默认处理
集合自动降级为第一个元素属性访问
反射字段 + getter 双重 fallback
安全的嵌套属性访问
一、问题背景
通常 Freemarker 模板中的字段访问如:
Hello, ${user.name}!
Your city: ${user.address.city}
如果 user
或 user.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
五、应用场景推荐
该模式适用于:
六、结语
通过自定义 ObjectWrapper
,我们不仅解决了 Freemarker 原生在 null、集合处理上的不友好问题,还显著提升了模板的容错性与灵活性。
这种方式对于构建一个可配置、非代码编写者友好的 SaaS 模板系统非常有帮助。
如需完整代码样例,请参考上方 FreeMarkerTemplateEngine
实现。欢迎你在此基础上继续扩展,如支持 Map 降级、List 扩展属性或自定义格式化函数等。
如果你在使用过程中遇到问题,欢迎评论交流 😊
#Freemark(1)#Java(6)
评论