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
以下是增强版模板引擎的核心结构:
/**
* @author dingyonghao
*/
@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,用于处理FreeMarker中的null值和集合
*/
private static class SafeObjectWrapper extends DefaultObjectWrapper {
public SafeObjectWrapper(Version incompatibleImprovements) {
super(incompatibleImprovements);
setExposeFields(true);
setForceLegacyNonListCollections(false);
}
@Override
public TemplateModel wrap(Object obj) throws TemplateModelException {
// 对于null值,我们不立即转换为空字符串,而是返回空模型
// 这样FreeMarker可以应用默认值
if (obj == null) {
return null; // 返回null而不是EMPTY_STRING,让FreeMarker处理默认值
}
// 对JSONObject类型进行特殊处理
if (obj instanceof JSONObject) {
return new JSONObjectTemplateModel((JSONObject) obj, this);
}
// 对集合类型进行特殊处理
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) {
// 对于未知类型,尝试返回其字符串表示
if (obj != null) {
return new SimpleScalar(obj.toString());
}
return null; // 返回null而不是EMPTY_STRING
}
/**
* 特殊的模板模型,用于处理JSONObject类型,同时支持作为哈希表和字符串访问
*/
private static class JSONObjectTemplateModel implements TemplateHashModel, TemplateScalarModel {
private final JSONObject jsonObject;
private final ObjectWrapper wrapper;
public JSONObjectTemplateModel(JSONObject jsonObject, ObjectWrapper wrapper) {
this.jsonObject = jsonObject;
this.wrapper = wrapper;
}
@Override
public TemplateModel get(String key) throws TemplateModelException {
Object value = jsonObject.get(key);
return wrapper.wrap(value);
}
@Override
public boolean isEmpty() {
return jsonObject.isEmpty();
}
@Override
public String getAsString() {
return jsonObject.toJSONString();
}
}
/**
* 特殊的模板模型,同时支持作为集合和单个元素访问
* 这样在模板中既可以使用${items}访问整个集合,也可以使用${item.property}访问第一个元素的属性
*/
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) {
// 尝试通过getter方法获取属性
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) {
// 如果无法获取属性,返回null
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<?> iterator = collection.iterator();
for (int i = 0; i < index; i++) {
iterator.next();
}
return wrapper.wrap(iterator.next());
}
}
@Override
public int size() {
return collection.size();
}
@Override
public String getAsString() {
// 实现TemplateScalarModel接口,当需要字符串值时调用
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 source;
}
}
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}";
{
System.out.println("case1 ==================================");
System.out.println(engine.process(templateStr, null));
}
{
Map<String, Object> data = new HashMap<>();
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"));
data.put("user", ListUtil.toList(user));
System.out.println("case2 ==================================");
System.out.println(engine.process(templateStr, data));
}
{
Map<String, Object> data = new HashMap<>();
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"));
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)
评论