
先说一个背景。我们这边维护着一个跨境结算平台客户分布在全球十几个国家和地区平台里的大部分数据录入、审核、对账都依赖导出的 Excel 单据。别看 EasyExcel 本身写 Excel 很快可一旦要求“同一份表格中文客户看到中文表头英国客户看到英文表头德国客户看到德语国家名称、状态描述、日期格式也要跟着变”框架原生功能就完全不够用了。我那段时间一直在做这件事基于 EasyExcel 原有框架做一层国际化扩展不侵入框架底层不改源码只利用它暴露的注解、Converter 和 WriteHandler 等扩展点把“多语言导出”这件事做成了平台里一个公用组件。这篇文章就是当时整个方案的拆解包括需求分析、设计思路、核心代码实现以及最后上线时踩过的几个比较隐蔽的坑。如果你也在处理 Excel 导出的多语言需求这文章可以直接当参考。1. 多语言导出需求拆解表头、枚举、下拉、格式一个都不能少1.1 跨境结算场景下的国际化难点清单我一开始也想简单了以为国际化就是把表头那几个中文字换成英文。真正把需求收集完才发现一个 Excel 文件里可以出现几十处跟语言相关的信息远不止表头表头文本列名、分组标题、合并单元格标题这些属于静态内容但每个语言版本都得有一套。固定内容单元格比如“合计”“小计”“备注说明”“操作提示”这些写在单元格里的辅助文案。枚举值映射订单状态“PENDING / PROCESSING / COMPLETED”虽然内部存的是英文状态码但展示给客户时必须翻译成目标语言。业务主数据国家名称、城市名、产品名称、结算币种等字段不能简单用语言包翻译得按主数据维护的多语言名称取值。日期、时间、数字格式中文习惯yyyy-MM-dd HH:mm:ss美国习惯MM/dd/yyyy欧元区数字千分位又不一样这些格式不属于字符翻译但属于展示国际化。下拉选项内容如果表格里带数据校验下拉框下拉的选项文本同样要随语言切换。图片和超链接部分产品报表需要嵌入产品图片不同语言区域可能要展示不同语言的图片版本。这类需求如果不在框架层面解决而是每次导出的业务代码里手写if (en.equals(locale))判断代码会迅速失控。更重要的是后续每接入一个新的业务线就得把这些逻辑重写一遍。所以早期就要确认国际化是作为一个横向能力下沉到导出组件里还是让每个业务导出方法自己处理我们最终选择了前者。1.2 为什么选择扩展原框架而不是另起炉灶做技术方案的时候其实也评估过其他路线直接用 POI 自己写 Excel 导出。好处是每个单元格都由自己控制国际化想怎么做都行。但 POI 的代码量太大了合并单元格、样式、图片、数据校验这些功能都要自己维护项目里十几条业务线的导出需求不可能为每个都实现一遍。用 Freemarker / Velocity 渲染 Excel 模板xlsx 本质是 zipXML。这条路也能做国际化但模板文件本身是静态的遇到动态列、动态增减行、复杂合并单元要写一堆 XML 片段维护成本特别高。用 EasyExcel 原生能力 少量扩展。这个方案风险最低EasyExcel 本身已经解决了 Excel 读写性能和内存占用问题我们只需要在它的扩展点上做翻译动作。回头来看选择扩展 EasyExcel 是最划算的。EasyExcel 的设计里本来就有Converter读写转换器和CellWriteHandler单元格写入处理器这俩是面向扩展开放的。翻译动作本质上就是“在数据从 Java 对象落到单元格之间拦截一次并替换成目标语言文本”这个操作用和 EasyExcel 的机制天然匹配不需要在框架层面做任何改动。2. 整体方案设计一条主链路加三处扩展点2.1 核心思路分“静态内容”和“动态内容”两层处理一个 Excel 文件里的国际化内容按来源可以分成两类静态文案表头、固定说明、页眉页脚。这类内容跟数据无关适合用“语言包 占位符”方案统一管理。动态数据状态枚举、国家名称、产品名称。这类内容跟着行数据走每条记录的值都不一样语言包只管到映射关系具体替换得在写单元格时执行。所以我的设计分成三层第一层I18nField注解标记在导出 DTO 的字段上用于声明这个字段的翻译类型。第二层语言提供者LanguageProvider统一从 yaml / 数据库 / 外部服务获取翻译文本。第三层EasyExcel 扩展点包括Converter处理动态数据单元格和CellWriteHandler处理表头等静态文本。这样拆开之后业务代码里基本不需要关注国际化细节导出的时候传入一个语言标识告诉组件“这次导出的目标语言是什么”剩下的事情全部由底层组件完成。2.2 扩展点一表头与固定文本的 Excel 模板预处理表头处理有一个方案分岔点。一种做法是“模板预生成”即提前为每种语言生成一份不同的 Excel 模板文件导出时根据参数里带上对应的模板。这个方案的好处是表头的排版、列宽、合并关系都能在模板里精确控制缺点是维护成本高。每次增加一列就要同步更新 N 份模板文件漏一个语言版本就出事故。另一种做法是“运行时替换”也是我最终采用的方案。表头文本依然写在模板里但不是直接写翻译后的文本而是写成占位符 key例如head.applyNo。框架在写入表头单元格时根据当前语言环境把 key 替换成真实文本。业务方只需要维护一份“key - 多语言文本”的映射关系列结构变动时只改模板和语言包不需要维护多份文件。这里要说明一下为什么 EasyExcel 原生ExcelProperty的 value 写不进多语言文本。因为注解的值在 Java 里是编译期常量同一个 DTO 不可能根据不同 locale 显示不同的注解 value。所以必须拿到运行时钩子。2.3 扩展点二数据单元格的动态字段国际化表头只是第一层真正麻烦的是数据单元格。一开始想过用CellWriteHandler统一处理所有数据行的单元格在每个单元格写完后去判断列索引对应的字段类型再做翻译。但这样做有一个问题同一列既可能是普通字符串、也可能是枚举状态码光靠列索引判断很容易混淆而且代码里到处是if (columnIndex 2)这种硬编码后续 DTO 调整字段顺序就完蛋。更好的做法是用Converter这是 EasyExcel 为“字段级读写转换”设计的原生扩展点。在导出 DTO 的字段上给需要翻译的字段加一个自定义转换器转换器在拿到原始值后根据字段上的I18nField注解去语言包查询翻译结果。这样业务代码无感、字段顺序无关、翻译规则和字段绑定是最稳定的一种方式。2.4 扩展点三缓存与性能层面的优化策略多语言导出最容易被忽略的是性能问题。我的第一版实现里每写一个单元格都去查询一次翻译文本。导出一万行数据、每行十个字段就是十万次查询速度非常慢。后来做了三个层面的优化语言包本身用内存缓存。翻译文本是相对静态的启动时加载进ConcurrentHashMap查询直接走内存不走数据库。同一导出发任务内的翻译结果做行级缓存。如果一列的数据大部分是重复状态码可以记录最近翻译过的原始值避免重复查表。按 locale 维度缓存模板解析结果。EasyExcel 在写入前会解析 ExcelProperty 结构这个过程也会耗时。虽然我们的扩展不生成独立模板但 Handler 在每一行都会执行所以 Handler 内部不能有耗时操作。性能上的一个原则尽量把“翻译”这件事提前到数据构建阶段而不是放在“每次单元格写入”阶段。能用批量查询解决的绝不用单条查。3. 核心代码实现从注解到输出的完整链路3.1 第一步定义国际化注解与语言上下文先定义I18nField注解作用于字段上Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface I18nField { /** 语言包中的 key 前缀例如 status最终通过 prefix value 拼接完整 key */ String prefix() default ; }然后定义语言上下文保存当前这一次导出请求对应的 Localepublic final class LocaleHolder { private static final ThreadLocalLocale CURRENT new ThreadLocal(); public static void set(Locale locale) { CURRENT.set(locale); } public static Locale get() { Locale locale CURRENT.get(); return locale null ? Locale.getDefault() : locale; } public static void clear() { CURRENT.remove(); } }注意ThreadLocal在线程池场景下一定要在 finally 块里 clear否则下一个任务会读到上一个任务的语言环境这个问题我在后面排障部分会细说。3.2 第二步实现表头渲染器表头翻译的核心是自定义CellWriteHandler。public class I18nHeadWriteHandler implements CellWriteHandler { Override public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, ListWriteCellData? cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { if (!Boolean.TRUE.equals(isHead)) { return; } String rawValue; try { rawValue cell.getStringCellValue(); } catch (Exception e) { // 非字符串表头如数字列不参与翻译 return; } if (rawValue null || rawValue.isEmpty()) { return; } String translated LanguageService.translate(rawValue, LocaleHolder.get()); if (translated ! null !rawValue.equals(translated)) { cell.setCellValue(translated); } } }这里有个设计取舍。表头占位符写法是ExcelProperty(value head.applyNo)但实际写进单元格的是head.applyNo这个字符串。翻译时就是拿这个字符串当 key 去查语言包。查不到就保留原值这样即使语言包漏配也不会导出一个空表头只是没翻译而已方便排查。语言包示例yamlzh-CN: head.applyNo: 申请单号 head.customerName: 客户名称 head.status: 状态 en-US: head.applyNo: Application No. head.customerName: Customer Name head.status: Status3.3 第三步数据单元格的值替换与 Converter 实现表头处理完了接着做数据行的动态翻译。先定义一个用语言包做翻译的 Servicepublic class LanguageService { private static final MapString, String DICT new ConcurrentHashMap(); public static String translate(String key, Locale locale) { if (key null || locale null) { return null; } return DICT.get(locale : key); } public static String translateI18nField(String prefix, String value, Locale locale) { if (value null || prefix null) { return value; } String translated DICT.get(locale : prefix . value); return translated null ? value : translated; } }再实现自定义 Converterpublic class I18nDataConverter implements ConverterString { Override public Class? supportJavaTypeKey() { return String.class; } Override public CellDataTypeEnum supportExcelTypeKey() { return CellDataTypeEnum.STRING; } Override public WriteCellData? convertToExcelData(String value, ExcelContentProperty contentProperty, GlobalWriteCellWriteHandler globalWriteCellWriteHandler) { I18nField i18nField contentProperty null ? null : contentProperty.getField().getAnnotation(I18nField.class); if (i18nField null || value null || value.isEmpty()) { return new WriteCellData(value null ? : value); } String translated LanguageService.translateI18nField(i18nField.prefix(), value, LocaleHolder.get()); return new WriteCellData(translated); } }在 DTO 里这样用public class OrderExportDTO { ExcelProperty(value head.applyNo) private String applyNo; ExcelProperty(value head.status, converter I18nDataConverter.class) I18nField(prefix status) private String status; ExcelProperty(value head.country, converter I18nDataConverter.class) I18nField(prefix country) private String country; }这样status字段本身存的是PENDING、PROCESSING这种状态码导出时 Converter 会去查status.PENDING在当前 locale 下的翻译文本。country存的是国家编码CN/US/DE翻译同理。如果你只需要做几种固定状态的翻译也可以直接给status字段写一个专用 Converter。但我在实际开发中推荐通用方案因为订单状态、退货原因、结算类型、客户等级都有枚举翻译需求统一用I18nField(prefix)一个注解就能覆盖所有场景。3.4 第四步大文件分页导出的内存优化跨境结算的单子数量很大动辄一次导出几万行。EasyExcel 虽然比 POI 省内存但一次性把全量数据查出来再doWrite还是会出问题。后来我们用分批写的方式public void exportOrders(OutputStream outputStream, Locale locale, ExportQuery query) { LocaleHolder.set(locale); try { ExcelWriter writer EasyExcel.write(outputStream, OrderExportDTO.class) .registerWriteHandler(new I18nHeadWriteHandler()) .build(); WriteSheet writeSheet EasyExcel.writerSheet(orders).build(); int pageNum 1; int pageSize 2000; while (true) { ListOrderExportDTO pageData orderService.pageQuery(query, pageNum, pageSize); if (pageData.isEmpty()) { break; } writer.write(pageData, writeSheet); pageNum; } writer.finish(); } finally { LocaleHolder.clear(); } }这里LocaleHolder.set(locale)和LocaleHolder.clear()成对出现保证线程池复用线程时不会把语言环境带到别的导出任务里去。如果数据行超过几十万还要配合EasyExcel.write(outputStream).excelType(ExcelTypeEnum.XLSX)尽量让框架使用XLSX的写模式因为XLS格式写大文件内存预留比较大容易 OOM。我们导出的单表最多出现过 60 万行使用 XLSX 分批写完全没问题。4. 踩坑实录与问题排查4.1 坑一模板缓存导致多语言数据串了上线初期发现过一个很诡异的问题A 用户在下午导出了中文文件过了半小时 B 用户导出的英文文件里表头中文和英文混在一起再过一会儿中文文件里出现了英文表头。排查下来问题出在I18nHeadWriteHandler本身。当时的 Handler 内部存了一个MapLocale, MapString, String翻译缓存字段而且是单例 Bean。多线程并发写同一个 Handler 实例时Map 里的数据互相覆盖表头翻译就乱了。解决方案Handler 变成无状态类所有翻译结果都不存在 Handler 实例字段里只依赖LanguageService的全局只读缓存。这也解释了为什么LanguageService里的字典缓存用ConcurrentHashMap因为它是被大量并发写线程共享的必须线程安全。4.2 坑二合并单元格的表头翻译位置错乱平台里很多报表是两级表头比如“交易信息”大标题下分“申请单号、客户名称、下单时间”三个小列。这种表头通常用ExcelProperty里的数组来构造两层结构。问题现象是翻译后的文本出现了位移。比如 “Transaction Info” 这个大标题翻译完显示到了左边第一列或者大标题翻译后合并区域没有同步扩展英文字长被截断。这个坑的原因在于afterCellDispose执行时机和合并单元格逻辑的先后顺序有关。表头内容先写合并区域后处理翻译后的长文本会把原本预留的列宽撑爆但合并区边界不会跟着变。解决办法分两步合并区域不依赖自动计算而是强合并头部区域宽度预先留足。表头文本写入时根据翻译后的文本长度动态调整该列列宽或者给表头设置自动换行 固定高度。如果项目里合并规则很复杂我建议把表头的合并区域信息放到一个配置类里翻译后重新计算合并单元格位置不要依赖 EasyExcel 的默认推演。4.3 坑三日期、数字格式在不同语言下的表现差异这个坑属于概念上的“国际化”范畴但和文本翻译不是一回事。我们导出 DTO 里的字段如果直接用Date类型EasyExcel 会按照全局默认格式去写不会因为你把 locale 设置成en-US就自动输出MM/dd/yyyy。客户那边拿到的可能是2024/01/31这种中间态和一个en-US环境的系统期望不一致。我的做法是在导出查询阶段就把日期格式化成字符串用ThreadLocal或者SimpleDateFormat按 locale 生成对应格式统一输出字符串列。虽然牺牲了一点单元格原生日期类型但好处是格式完全可控不会出现客户看到一串数字序列号的投诉。数字格式同理千分位、小数点符号在不同语言下也可能不同有的地域用逗号表示小数点我建议所有金额字段也在数据转换时统一格式化成字符串避免在不同系统的 Excel 客户端里显示异常。4.4 快查表常见问题与对应方案现象根因解决方案表头翻译偶尔错乱且带语言混用Handler 实例存了可变翻译缓存并发串数据Handler 改为无状态翻译结果走线程安全全局缓存导出大文件时 OOM一次性doWrite全量数据内存被撑爆采用分页分批写ExcelWriter.write(...)控制每个分片大小合并单元格表头翻译后错位/截断翻译文本变长但合并区域和列宽未同步更新预留列宽设置自动换行合并区域由配置类动态计算英文导出的日期格式不符合当地习惯Date 字段按框架默认格式输出未感知 Locale数据查询阶段按 locale 格式化日期、金额、数字导出字符串列ThreadLocal 语言环境串到下一个任务导出方法结束后没有 clear ThreadLocalfinally 块中执行LocaleHolder.clear()或用线程池包装器统一清理翻译漏配导致导出空表头语言包缺少对应 key翻译返回 null翻译不到保留原值并主动打印告警日志方便统计缺失 key5. 这套方案的未来扩展空间5.1 语言包管理与热更新机制目前我们的语言包是 yaml 文件 启动时加载进内存。平台规模变大以后语言包会变成运营配置的一部分频繁更新不可能每次重启服务。下一步可以改成对接配置中心语言包以配置项下发配置中心版本号变更时本地缓存自动失效。LanguageService里的ConcurrentHashMap变成“读多写少”的刷新式缓存更新时 copy-on-write 替换整个 Map 引用导出线程不需要加锁。这个变化对上层完全透明业务代码不用动只要把LanguageService的实现换成动态版本即可。5.2 对接外部翻译服务做业务字段自动翻译有些业务字段属于“非枚举”类型例如客户填写的自定义备注、产品描述。这类文本无法用prefix value的方式枚举翻译只能接入翻译服务。设计上可以预留一个接口public interface DynamicTranslator { String translate(String text, Locale source, Locale target); }对接翻译服务时要注意缓存和费用控制。同一段文本如果短时间内多次导出应该缓存翻译结果还要把文本里的专有名词、品牌名、订单号等不可翻译的内容做保护处理。5.3 多语言导出的回归测试与快照对比多语言导出功能最怕的是“某一次改列结构只更新了中文语言包漏了英文语言包”。人工检查所有语言版本的 Excel 是非常痛苦的过程。我现在用的思路是“多语言快照对比”准备同一份样例数据分别用中文、英文、德文导出 Excel再用程序读取表格里的单元格文本和预期语言包逐项对比。专门写一个测试用例在 CI 里跑读取所有head.*和枚举翻译 key 是否缺失。一旦语言包 key 数量低于预期或者出现同一行同一列在不同语言下内容为空测试直接失败。这套方案最值钱的部分其实不是代码实现而是把“多语言”这件事从前置数据模型就要想清楚数据入库时就要区分“代码值”和“展示值”代码值稳定存储展示值按语言现算。否则就算导出的框架层做得再好底层数据只存了中文到了导出环节也是无米之炊。最后分享一点实战体会做 EasyExcel 国际化扩展这件事最大的坑往往不在框架而在对“国际化”这个概念的定义。文本翻译、格式化差异、业务主数据的多语言这三件事看起来都叫国际化解决方式完全不同混在一起处理一定会出问题。我建议所有看到这里的朋友先别急着写代码拿一张纸把你业务里的多语言内容分个类哪些是静态文案、哪些是枚举值、哪些是主数据字段、哪些是格式差异。分类清楚了你就能理解为什么我只依赖注解和 Converter 就做完了 80% 的工作。如果让我给一个十分钟就能落地的方案那就是定义I18nField注解用 LanguageService 加载语言包注册一个I18nHeadWriteHandler处理表头再注册一个通用I18nDataConverter处理数据字段。整套代码量不大但能解决 90% 以上的多语言导出场景。剩下 10% 的动态业务文本翻译和复杂报表格式等到真正遇到的时候再扩展也不迟。