星期一早上刚到工位,同事就甩过来一张报错截图:poi-tl 渲染 Word 模板时抛了ExpressionEvalException: Error eval,caused by 是SpelEvaluationException: EL1008E。他嘀咕了一句"模板在本地跑得好好的,换个环境就挂",我让他把完整堆栈发过来,扫了一眼就明白了个大概——这是模板里的表达式在数据模型上找不到对应属性,属于 poi-tl 项目里最高频的报错之一,新手老手都躲不开。
今天就把 EL1008E 的完整排查思路和修复方案写透。围绕这个报错,我会拆解 poi-tl 底层用 Spring 表达式(SpEL)解析模板标签的机制,覆盖普通字段、嵌套对象、List 循环三个最典型的翻车现场,末尾再给一套能直接落地的防御性做法。无论你是第一次用 poi-tl 渲染{{name}}这种简单标签,还是已经在跟{{?items}}这类循环标签搏斗,这篇应该都能帮你省下半天排查时间。
1. 报错本质:SpEL 表达式引擎在 poi-tl 里干了什么
1.1 先把异常信息看明白
完整的报错一般长这样:
com.deepoove.poi.exception.ExpressionEvalException: Error eval at com.deepoove.poi.el.SpelELProcessor.eval(SpelELProcessor.java:103) at com.deepoove.poi.resolver.DefaultELResolver.evaluate(DefaultELResolver.java:79) ... Caused by: org.springframework.expression.spel.SpelEvaluationException: EL1008E: Property or field 'userName' cannot be found on object of type 'java.util.HashMap' - maybe not public or not valid? at org.springframework.expression.spel.support.ReflectivePropertyAccessor$OptimalPropertyAccessor.canRead(ReflectivePropertyAccessor.java:182) ...关键就在Caused by那一行。EL1008E是 Spring 表达式语言(SpEL)内置的异常码,翻译成大白话就是:
表达式里写的属性名,在目标对象上找不到。
比如我上面的示例,poi-tl 想从java.util.HashMap这个数据对象上取userName,但 HashMap 里并没有userName这个 key,于是 SpEL 直接拒绝执行并抛出EL1008E。
poi-tl 自己包了一层ExpressionEvalException,把底层 SpEL 的异常掩藏到Caused by里。这就有个问题:很多人只看最上面一行Error eval,一头雾水,不知道怎么排查。所以第一件事就是养成习惯——往下翻,看Caused by,报错的真实原因全在那里。
1.2 模板标签到 Java 属性的解析链路
要理解为什么会找不到,得先知道 poi-tl 从模板标签到最终值经历了什么。
poi-tl 的模板语法是{{表达式}},比如{{userName}}、{{user.name}}、{{?items}}、{{/items}}。渲染时,poi-tl 会把标签里的字符串提取出来,交给内部的 SpEL 解析器去执行:
模板标签 {{user.name}} ↓ 提取表达式 user.name ↓ SpEL 解析器在数据模型(Map 或 POJO)上读取属性 ↓ 找不到属性 -> EL1008E这里有一点特别容易忽略:poi-tl 默认的数据模型可以是Map<String, Object>,也可以是一个 POJO 对象。SpEL 对这两种对象的"找属性"策略不一样:
- 对Map:SpEL 会先看这个 key 是否存在。key 不存在,直接 EL1008E。
- 对POJO:SpEL 按 JavaBean 规范找 getter 方法,比如表达式
user.name就调getName()。如果类里没有这个 getter,也是 EL1008E。
实际项目中,90% 的 EL1008E 都出在"模板标签和数据模型对不上"这一件事上。要么 key 写错了,要么 key 的大小写不对,要么对象嵌套层级对不上,要么 getter 根本不存在。理解这条链路之后,后面的排查就都是顺着它走的。
2. 一次典型 EL1008E 的完整排查链路
2.1 从堆栈信息里圈定嫌疑对象
收到报错之后,我的习惯是先定位是"哪个标签"炸的。完整堆栈里通常能看出一些线索,但说实话,poi-tl 的堆栈不会直接告诉你"第几段第几个标签出问题",它只会告诉你"哪个表达式、在哪个对象上取值失败"。
比如这条:
Caused by: ... EL1008E: Property or field 'username' cannot be found on object of type 'com.example.dto.UserDTO' - maybe not public or not valid?这已经很有价值了:表达式是username,目标对象是UserDTO。剩下的问题就是:模板里哪个地方写了{{username}}?UserDTO 里的字段到底叫什么?
但真实世界没那么温柔。曾经有个项目报表模板里有四十多个标签,异常里只报了一个字段,我肉眼扫模板扫了三遍才找到。后来我学乖了,不再人眼找,直接用脚本把模板里的所有标签提取出来,逐个跟数据模型核对。
2.2 快速提取模板标签:别用眼睛找,用命令找
docx 本质上是一个 zip 包,正文存在word/document.xml里。用命令行可以直接把所有标签捞出来:
unzip -p template.docx word/document.xml | grep -o '{{[^}]*}}'如果你用的是 Windows,解压命令可以用系统自带的 tar(Win10 以上版本):
tar -xOf template.docx word/document.xml | findstr /o "{{"拿到所有标签后,复制到文本编辑器里,跟数据模型的字段清单做对比,问题往往一眼就暴露。这比在 Word 里翻来翻去高效得多。
2.3 验证数据模型:把 data 序列化打出来
确认模板标签之后,下一步是确认 data 里到底有什么。如果 data 是 Map,直接 JSON 序列化打印一行就能看清 key 结构:
Map<String, Object> data = new HashMap<>(); data.put("user", userDTO); data.put("items", itemList); System.out.println(JSON.toJSONString(data));如果 data 是 POJO,也建议序列化输出——一个是确认字段名,另一个是确认嵌套对象是否为 null。很多 EL1008E 不是第一层 key 不存在,而是第二层对象为 null,取值时一路往下点,点到 null 上就炸了。
2.4 独立验证表达式:写个 SpEL 小工具试错
数据模型和标签都拿到手之后,最稳妥的一步是脱离 poi-tl,直接用 Spring 的SpelExpressionParser验证表达式本身。poi-tl 渲染时做的事,本质上就是这么几行:
ExpressionParser parser = new SpelExpressionParser(); StandardEvaluationContext context = new StandardEvaluationContext(); context.setRootObject(data); Expression exp = parser.parseExpression("user.name"); Object value = exp.getValue(context); System.out.println(value);把报错的表达式替换进去,如果这段代码也抛 EL1008E,那问题就锁定在"表达式与数据模型不匹配",跟 poi-tl 本身没关系。如果这段代码能正常取值,那问题才可能出在 poi-tl 的配置或版本上。这个小工具是我排查所有 poi-tl 渲染异常的第一步,省了我大量时间。
3. 五个常见根因与对应修复方案
3.1 字段名拼写与大小写不一致
这是 EL1008E 的第一大来源。Java 命名习惯是驼峰,但模板可能是产品经理手工填的,或者从 Excel 字段说明里复制出来的,两个地方经常对不上。
举几个我真实见过的例子:
| 模板里写的 | 数据模型里实际是 | 结果 |
|---|---|---|
{{userName}} | username | 找不到 |
{{created_at}} | createdAt | 找不到 |
{{item.name}} | name(但循环变量是 item,见第4章) | 找不到 |
{{User.name}} | user.name | 找不到 |
这种问题没什么技巧,改模板或者改数据模型都行,核心是统一。我的建议是优先改模板——模板是给人看的,保持可读性。比如 Java 字段叫createTime,模板里就别写成create_time,也别反过来为了迁就模板把 Java 字段改成下划线。
3.2 嵌套对象为 null,属性一路点到底
{{user.address.city}}这种多级表达式,如果user存在、address为 null,SpEL 在解析到user.address时就会中断,抛出的异常同样是 EL1008E 或相近的 SpEL 异常家族。很多人把精力放在最末端的city字段上,其实问题出在中间层。
处理方案通常有三种:
- 在业务代码里保证
address不为 null,初始化为空对象。 - 模板里改平铺结构,由 Java 侧提前拼好一个
fullAddress字段。 - 接受 null 可能性,用 poi-tl 的默认值或空串策略兜底。
我个人的偏好是方案 2。模板里写平铺字段的维护成本最低,渲染结果也最可控。多级表达式看起来高端,但每一级都可能是定时炸弹。
3.3 getter 方法缺失或命名不符合 JavaBean 规范
这种情况在 POJO 数据模型下容易出现。SpEL 从 POJO 取属性依赖 getter,不是直接读字段。如果一个类里只有 public 字段却没有 getter,或者 getter 命名不规范,SpEL 一样会觉得"属性不存在"。
常见翻车场景是 Lombok。@Data注解没生效、依赖缺失、或者 IDE 没开启注解处理,类里实际没有生成getName(),渲染时就报 EL1008E。排查方法是反编译 class 文件看 getter 是否存在,或者干脆在 IDE 里点开结构面板扫一眼。还有一个隐蔽点:手动写的 getter 返回类型和字段类型不一致,比如字段是List<User>,getter 却返回List,虽然不报 EL1008E,但后续循环渲染时也会有怪问题,这里一并提一下。
3.4 poi-tl 表达式模式被改动
poi-tl 的TemplateConfig支持配置表达式引擎模式,默认是ELMode.SPEL_MODE,也就是我们前面说的 SpEL 解析。如果你或者前任同事把它改成了ELMode.POI_TL_MODE,解析行为会不一样,一些 SpEL 特性表达式就会找不到属性。
TemplateConfig config = TemplateConfig.builder() .setELMode(TemplateConfig.ELMode.SPEL_MODE) .build(); XWPFTemplate template = XWPFTemplate.compile("template.docx", config).render(data);排查时如果确定字段名和数据模型都没问题,就检查一下工程里有没有自定义TemplateConfig。这种问题最坑人,因为模板和数据模型都对,就是运行环境配置不同。
3.5 内置函数或标签前缀写错
poi-tl 有一些特殊前缀的标签,比如循环的?,结束的/,还有内置函数类标签。如果这些前缀和表达式之间格式写错,比如少了冒号、多了空格,底层解析时也会以 EL1008E 或类似异常暴露出来。遇到这种,去官网的"语法"目录下对照一下写法,比自己瞎试快。
4. List 循环渲染:EL1008E 的高发地带
4.1 poi-tl 循环标签的标准写法
很多人搜索 "poi-tl 模板 怎么渲染 list",核心就是循环标签。一个标准段落循环是这样:
模板里写:
{{?items}} {{item.name}}:{{item.price}} 元 {{/items}}Java 侧:
Map<String, Object> data = new HashMap<>(); List<Product> products = Arrays.asList( new Product("苹果", 5.5), new Product("香蕉", 3.2) ); data.put("items", products);渲染时,poi-tl 会遍历items这个集合,循环体内的代码执行多次,每个 item 对应集合中的一个元素。这里有个关键点:poi-tl 循环体内默认的循环变量名是item,不是items,也不是你可以随便自定义的变量名。也就是说,{{?items}}里的items是集合名,而循环体内的{{item.name}}里的item是固定的迭代变量名。
4.2 循环变量写错:排行榜第一的错误
我见过最多的 EL1008E 是这样写的:
{{?items}} {{product.name}} {{product.price}} 元 {{/items}}看起来逻辑没毛病:遍历items,每个元素叫product。但 poi-tl 根本不认product这个循环变量名,它只会把循环体内的对象绑定到默认变量item上。SpEL 去items集合的第一个元素上找product属性,找不到,报 EL1008E。
正确写法是:
{{?items}} {{item.name}} {{item.price}} 元 {{/items}}如果你确实觉得item不够语义化,可以在 Java 侧把集合元素处理好,比如把name、price这样的字段复制到 item 的对应属性上,但别跟 poi-tl 的循环变量命名较劲。
循环场景下还有两个容易踩的点:
- 集合字段名写错。Java 侧是
data.put("productList", ...),模板里写的却是{{?items}},这属于数据模型 key 对不上,排查方式跟普通字段一样。 - 循环体内的表达式用了全路径,比如
{{item.user.address.city}}。如果某个 item 的user为 null,也会触发 EL1008E 系列异常。
4.3 List
用List<Map<String, Object>>作为循环数据源也是常见做法,但 Map 的 key 匹配是大小写敏感的。
比如某个接口返回的是:
List<Map<String, Object>> rows = new ArrayList<>(); Map<String, Object> row = new HashMap<>(); row.put("userName", "张三"); rows.add(row); data.put("items", rows);模板里写:
{{?items}} {{item.username}} {{/items}}userName和username差一个字母大小写,但 SpEL 不会帮你智能匹配,直接 EL1008E。遇到 Map 数据源,我一般建议在 Java 侧统一 key 命名,并且输出一行 JSON 日志做对照,别凭记忆写模板。
还有一些特殊 key,比如包含数字、中划线、空格的 key,表达式{{item.user-name}}会被 SpEL 解析成减法运算,压根不是取属性。这种场景下,最好在 Java 侧做一层转换,把 key 改成合法的 Java 标识符,比如userName、user_name。
4.4 嵌套循环的两个变量陷阱
列表套列表的场景我也遇到过。外层循环用{{?categories}},内层用{{?products}},两者循环体内都用item:
{{?categories}} {{item.name}} {{?products}} {{item.name}} {{/products}} {{/categories}}问题来了:内层的{{item.name}}到底取的是内层 product 的 name,还是外层 category 的 name?poi-tl 对嵌套循环的处理在不同版本上有过行为差异,我在 1.10.x 上遇到过内层 item 把外层 item 覆盖掉的情况,渲染结果错乱但不报错。
针对嵌套循环,我的建议是尽量避免。如果业务确实需要,就把内层需要展示的数据提前拍平,或者用不同的 key 包装成平级结构。模板越简单,出问题的概率越低,这是 poi-tl 项目里的铁律。
5. 绕开 EL1008E:防御性写法和排查习惯
5.1 建立模板字段清单,渲染前做字典比对
模板和数据模型对不上,大部分原因是"没有一份权威的字段清单"。Word 模板是业务人员维护的,Java 字段是开发维护的,两边各写各的,不出问题才怪。
我现在做 poi-tl 项目,第一件事就是让模板里每一个标签都登记到一张字段清单表里:
| 模板标签 | 数据模型字段 | 类型 | 是否循环 | 备注 |
|---|---|---|---|---|
{{userName}} | userName | String | 否 | |
{{item.name}} | name | String | 是 | 循环变量 item |
{{item.price}} | price | Double | 是 | 金额格式化 |
这张表既是开发文档,也是验收清单。模板改一个标签,表格同步改一次。虽然看着繁琐,但能挡住九成以上的低级错误。
5.2 写一个模板标签校验小工具
更进一步,我写过一个校验小工具:提取模板里的全部标签,和 data 的 key 做差集,渲染前就能发现潜在的 EL1008E。
核心逻辑就两步。第一步,解压 docx 提取document.xml里所有{{...}}:
Pattern pattern = Pattern.compile("\\{\\{([^{}]+)\\}\\}"); Matcher matcher = pattern.matcher(documentXml); while (matcher.find()) { String expr = matcher.group(1).trim(); // 过滤循环开始、结束和内置函数标签 if (expr.startsWith("?") || expr.startsWith("/") || expr.startsWith("$")) { continue; } tags.add(expr); }第二步,用 SpEL 在 data 上实际解析一次,能通过的放行,不能通过的输出到日志:
ExpressionParser parser = new SpelExpressionParser(); StandardEvaluationContext context = new StandardEvaluationContext(); context.setRootObject(data); for (String tag : tags) { try { parser.parseExpression(tag).getValue(context); } catch (SpelEvaluationException e) { System.out.println("疑似错误标签:" + tag + ",原因:" + e.getMessage()); } }这个工具不追求 100% 准确——循环体的item.name本来就要等遍历时才能验证——但能很快筛出普通字段的问题。放在单元测试里,每次改模板或改数据模型后跑一遍,心里踏实很多。
5.3 数据模型兜底:别让 null 变成地雷
除了字段名匹配,null 值也是 EL1008E 的帮凶。我处理数据模型时习惯做一层兜底:
- 集合字段:初始化为空集合,而不是 null。
- 嵌套对象:可能为 null 的,在模板里不要继续点下级属性。
- 字符串字段:根据业务决定是否需要默认空串。
poi-tl 本身对 null 值的处理是"尽力而为",但不同版本的默认行为不完全一致。与其依赖框架,不如在 Java 侧就把数据洗干净。数据模型的稳定性,直接决定模板渲染的稳定性。
5.4 个人排错顺序建议
最后总结一下我实际排查 EL1008E 时遵循的顺序,也是一个经验性的 checklist:
- 看
Caused by,确认是哪个表达式、哪个对象类型。 - 提取模板全部标签,定位可疑表达式。
- JSON 序列化打印 data,核对字段名、大小写、嵌套层级。
- 用
SpelExpressionParser独立验证表达式,排除 poi-tl 干扰。 - 确认是不是循环场景,循环变量名是否真的是
item。 - 检查
TemplateConfig有没有改过表达式模式。 - 还不行就翻 poi-tl 的版本 changelog,换版本试。
这套流程走下来,EL1008E 基本都能定位到根因。用到后面你会发现,这个报错反而成了 poi-tl 数据模型是否规范的一个信号灯——它炸一次,就说明模板和数据之间有一处账没对上,趁早补上比上线后炸要好得多。