
星期一早上刚到工位同事就甩过来一张报错截图poi-tl 渲染 Word 模板时抛了ExpressionEvalException: Error evalcaused 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 默认的数据模型可以是MapString, Object也可以是一个 POJO 对象。SpEL 对这两种对象的找属性策略不一样对MapSpEL 会先看这个 key 是否存在。key 不存在直接 EL1008E。对POJOSpEL 按 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解压命令可以用系统自带的 tarWin10 以上版本tar -xOf template.docx word/document.xml | findstr /o {{拿到所有标签后复制到文本编辑器里跟数据模型的字段清单做对比问题往往一眼就暴露。这比在 Word 里翻来翻去高效得多。2.3 验证数据模型把 data 序列化打出来确认模板标签之后下一步是确认 data 里到底有什么。如果 data 是 Map直接 JSON 序列化打印一行就能看清 key 结构MapString, 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为 nullSpEL 在解析到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 返回类型和字段类型不一致比如字段是ListUsergetter 却返回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 侧MapString, Object data new HashMap(); ListProduct 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的隐藏坑key 大小写敏感用ListMapString, Object作为循环数据源也是常见做法但 Map 的 key 匹配是大小写敏感的。比如某个接口返回的是ListMapString, Object rows new ArrayList(); MapString, 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 的 namepoi-tl 对嵌套循环的处理在不同版本上有过行为差异我在 1.10.x 上遇到过内层 item 把外层 item 覆盖掉的情况渲染结果错乱但不报错。针对嵌套循环我的建议是尽量避免。如果业务确实需要就把内层需要展示的数据提前拍平或者用不同的 key 包装成平级结构。模板越简单出问题的概率越低这是 poi-tl 项目里的铁律。5. 绕开 EL1008E防御性写法和排查习惯5.1 建立模板字段清单渲染前做字典比对模板和数据模型对不上大部分原因是没有一份权威的字段清单。Word 模板是业务人员维护的Java 字段是开发维护的两边各写各的不出问题才怪。我现在做 poi-tl 项目第一件事就是让模板里每一个标签都登记到一张字段清单表里模板标签数据模型字段类型是否循环备注{{userName}}userNameString否{{item.name}}nameString是循环变量 item{{item.price}}priceDouble是金额格式化这张表既是开发文档也是验收清单。模板改一个标签表格同步改一次。虽然看着繁琐但能挡住九成以上的低级错误。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 数据模型是否规范的一个信号灯——它炸一次就说明模板和数据之间有一处账没对上趁早补上比上线后炸要好得多。