老版本 EasyExcel 一个神出鬼没的异常

1 背景

数据中台上线以来,总有一个 同步作业 偶尔会抛出异常:com.alibaba.excel.exception.ExcelAnalysisException: Converter not found, convert STRING to java.lang.String

在这里插入图片描述

作业通过 datax 同步用户在 ftp 上传的文件数据到数据中台;datax 会调用使用 EasyExcel 库加载 Excel 文件的数据,并在加载过程中偶尔抛出异常。

异常是由于无法找到,把 Excel 中的 字符串 Cell 转换为 java 字符串 的 转换器,这就很奇怪,这应该是一个最基本的转换器了。

这个 神出鬼没 的异常,有以下几个特点:

  • 只有固定的 同步作业 会抛出异常,其余的同步作业都运行正常;
  • 这个异常是偶发的,每个月会发生 4-5 次;
  • 作业由于异常运行失败后 5 分钟,再重试,就成功了,非常诡异。

虽然,重试机制可以保证作业最终是运行成功的,但,只要抛出这个异常,整条数据链路的完成时间就会延迟。因此,必须找出导致异常的 root cause。

2 根本原因分析

2.1 EasyExcel 的源码分析

首先,数据中台当前使用的 EasyExcel 版本是 2.1.4,已经是 2019 年的老版本了:
在这里插入图片描述

(1) 分析异常栈

在这里插入图片描述
异常由 ConverterUtils 的 130 行抛出,具体代码如下:
在这里插入图片描述
由于无法从 converterMap 中获取转换器,从而抛出 ExcelDataConvertException 异常。

converterMap 里面保存了 Excel Cell 数据类型 - java 类型转换器 的对应关系。

因此需要分析 converterMap 是如何被初始化的,有可能在运行到 ConverterUtils 的 130 行 时,converterMap 仍未完成初始化,导致无法获取 转换器

(2) 分析 converterMap 如何初始化

  • converterMap 来自 AbstractHolder 类:
    在这里插入图片描述
  • converterMap 的初始化时机

在读取 Excel 数据时,会实例化类 ReadSheetHolder,它是 AbstractHolder 的子类(ReadSheetHolder -继承-> AbstractReadHolder -继承-> AbstractHolder

在实例化 ReadSheetHolder 时,会调用 AbstractReadHolder 的构造器:
在这里插入图片描述
AbstractReadHolder 的构造器会调用 DefaultConverterLoader.loadDefaultReadConverter()converterMap 进行赋值。

(3) DefaultConverterLoader 初始化 转换器Map

在这里插入图片描述

  • 首先,loadDefaultReadConverter 是类级别的静态方法;
  • loadDefaultReadConverter 最终会调用 loadAllConverter 方法;
  • loadAllConverter 会判断静态属性 allConverter 是否为 null,如果非 null 则直接返回 allConverter,否则初始化 allConverter 中的转换器。

(4) 异常根本原因分析

  • 首先,作业是要同步文件夹中的 4 个文件到数据中台,并发度为 3,即同时会有 3 个线程同步文件,因此,loadAllConverter 运行在一个多线程的场景下;
  • loadAllConverter 方法没有做线程安全处理;
  • 会出现一种情况,线程 A 执行到
allConverter = new HashMap<String, Converter>(64);

B线程刚好执行到

if (allConverter != null) {
    return allConverter;
}

就返回了空的 allConverter 了,由此,导致无法找到 转换器,而抛出 ExcelDataConvertException 异常。

这种由于没有做线程安全处理而导致的异常,也符合它神出鬼没的特性。

2.2 本地复现异常

2.2.1 先在 maven 引入数据中台版本的 EasyExcel 依赖

<dependency>
          <groupId>com.alibaba</groupId>
          <artifactId>easyexcel</artifactId>
          <version>2.1.4</version>
</dependency>

2.2.2 编写测试代码

package com.nutanix.test;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import java.util.List;
import java.util.ArrayList;
import java.util.Map;

/**
 * 使用EasyExcel读取Excel文件并转换为Map集合的工具类
 */
public class ExcelToMapReader {

    /**
     * 读取Excel文件并转换为List<Map<String, Object>>
     * @param filePath Excel文件路径
     * @return 包含Excel数据的Map集合列表,每个Map对应一行数据
     */
    public static List<Map<String, Object>> readExcelToMap(String filePath) {
        // 创建一个监听器实例
        ExcelMapListener listener = new ExcelMapListener();

        // 读取Excel文件
        EasyExcel.read(filePath, listener)
                .sheet() // 读取第一个sheet
                .doRead(); // 执行读取操作

        // 返回读取到的数据
        return listener.getDataList();
    }

    /**
     * 自定义监听器,用于处理Excel读取事件
     */
    private static class ExcelMapListener extends AnalysisEventListener<Map<String, Object>> {

        // 存储读取到的数据
        private List<Map<String, Object>> dataList = new ArrayList<>();

        /**
         * 每读取一行数据都会调用此方法
         * @param data 一行数据,键是表头,值是单元格内容
         * @param context 分析上下文
         */
        @Override
        public void invoke(Map<String, Object> data, AnalysisContext context) {
            dataList.add(data);
        }

        /**
         * 读取完成后调用此方法
         * @param context 分析上下文
         */
        @Override
        public void doAfterAllAnalysed(AnalysisContext context) {
            // 可以在这里添加读取完成后的处理逻辑
            System.out.println("Excel文件读取完成,共读取 " + dataList.size() + " 行数据");
        }

        /**
         * 获取读取到的数据列表
         * @return 数据列表
         */
        public List<Map<String, Object>> getDataList() {
            return dataList;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            readExcelToMap("/tmp/customer-file1.xlsx");
        });

        Thread thread2 = new Thread(() -> {
            readExcelToMap("/tmp/customer-file2.xlsx");
        });

        Thread thread3 = new Thread(() -> {
            readExcelToMap("/tmp/customer-file3.xlsx");
        });

        Thread thread4 = new Thread(() -> {
            readExcelToMap("/tmp/customer-file4.xlsx");
        });

        thread.start();
        thread2.start();
        thread3.start();
        thread4.start();
    }
}

程序模拟数据中台的作业,启动 4 个线程,分别读取对应的 excel 文件。

2.2.3 打断点

DefaultConverterLoaderloadAllConverter() 打断点:
在这里插入图片描述

2.2.4 调试

  • 运行程序,4 个线程都会在断点处停下;
  • 选择其中 1 个线程,并 Step Over 到 allConverter = new HashMap<String, Converter>(64); 后,例如下图;
    在这里插入图片描述
  • 再选择另一个线程,并让它恢复运行;
    在这里插入图片描述
  • 异常重现
    在这里插入图片描述

3 新版本的 EasyExcel 处理

在新版本的 EasyExcel,阿里团队已经通过采用静态代码块的方式,在 DefaultConverterLoader 首次被访问时,对 allConverter 进行了初始化,从而解决了在并发环境的线程安全问题:
在这里插入图片描述

4 总结

至此,根因已经找到了,就是因为,老版本的 EasyExcel,在初始化 类型与转换器的 Map 对象时,没有做线程安全处理,导致,在并发环境下,ExcelDataConvertException 异常神出鬼没地出现。

希望本文,对其他遇到这个问题的同学有帮助。

<think>我们正在使用EasyExcel库来在一个Excel文件中创建多个Sheet。根据引用[1]的示例代码,我们可以通过以下步骤实现: 1. 创建一个ExcelWriter对象,并指定输出的文件。 2. 循环创建多个Sheet,每个Sheet通过`EasyExcel.writerSheet`方法指定Sheet名称和表头。 3. 使用ExcelWriter对象的write方法将数据写入对应的Sheet。 4. 最后关闭ExcelWriter(包括finish方法)以完成写入。 具体步骤: 假设我们有一个SheetInfoBean的列表,每个SheetInfoBean包含: - sheetName: Sheet的名称 - headClass: 表头对应的Class(使用注解定义表头) - dataList: 该Sheet的数据列表 代码结构如下: ```java try (ExcelWriter excelWriter = EasyExcel.write(fileName).build()) { for (SheetInfoBean bean : sheetInfoList) { // 构建每个Sheet的配置 WriteSheet writeSheet = EasyExcel.writerSheet(bean.getSheetName()) .head(bean.getHeadClass()) // 设置表头 .build(); // 写入数据,dataList是bean中该Sheet的数据列表 excelWriter.write(bean.getDataList(), writeSheet); } } catch (Exception e) { // 异常处理 } // 注意:使用try-with-resources会自动关闭资源(包括finish),但如果没有使用try-with-resources,则需要在finally块中确保excelWriter.finish()被调用。 // 如果不使用try-with-resources,可以这样: ExcelWriter excelWriter = null; try { excelWriter = EasyExcel.write(fileName).build(); for (SheetInfoBean bean : sheetInfoList) { WriteSheet writeSheet = EasyExcel.writerSheet(bean.getSheetName()) .head(bean.getHeadClass()) .build(); excelWriter.write(bean.getDataList(), writeSheet); } } finally { if (excelWriter != null) { excelWriter.finish(); // 确保finish被调用以写入文件 } } ``` 注意:在写入过程中,每个Sheet的数据和表头都是独立的。 引用[1]中的代码示例也展示了同样的方法。 另外,如果每个Sheet的数据结构不同,那么我们需要为每个Sheet指定不同的headClass(即不同的模型类)。如果多个Sheet使用相同的表头结构,则可以复用同一个headClass。 因此,使用EasyExcel一个Excel文件中创建多个Sheet的关键是使用ExcelWriter循环写入多个WriteSheet。 如果数据量很大,还可以考虑分批次写入(如引用[2]所示),但这里我们只是创建多个Sheet,每个Sheet的数据量可能很大,那么可以在每个Sheet内部分批次写入,即在一个Sheet中多次调用write方法(但每次write都是同一个WriteSheet对象)。例如: ```java WriteSheet writeSheet = EasyExcel.writerSheet("Sheet1").head(HeadClass.class).build(); excelWriter.write(dataList1, writeSheet); // 写入第一批数据 excelWriter.write(dataList2, writeSheet); // 写入第二批数据,同一个Sheet ``` 但是,注意:同一个WriteSheet对象多次写入,数据会连续追加在该Sheet中。 因此,对于多个Sheet,我们每个Sheet使用一个WriteSheet对象,并且每个Sheet只写入一次(除非需要追加)。 现在,我们可以根据上述示例来编写代码。 如果用户需要具体的例子,我们可以提供一个简单的示例: 假设我们要导出两个Sheet: Sheet1: 用户信息(User类),包含姓名和年龄 Sheet2: 产品信息(Product类),包含产品名称和价格 步骤: 1. 定义两个模型类(使用@ExcelProperty注解) 2. 准备数据 3. 使用ExcelWriter分别写入两个Sheet 示例代码: ```java // 定义用户模型 public class User { @ExcelProperty("姓名") private String name; @ExcelProperty("年龄") private Integer age; // 省略getter/setter } // 定义产品模型 public class Product { @ExcelProperty("产品名称") private String productName; @ExcelProperty("价格") private Double price; // 省略getter/setter } // 导出方法 public void exportMultipleSheets(String fileName) { // 准备数据 List<User> userList = ...; List<Product> productList = ...; try (ExcelWriter excelWriter = EasyExcel.write(fileName).build()) { // 第一个Sheet:用户 WriteSheet userSheet = EasyExcel.writerSheet(0, "用户信息") .head(User.class) .build(); excelWriter.write(userList, userSheet); // 第二个Sheet:产品 WriteSheet productSheet = EasyExcel.writerSheet(1, "产品信息") .head(Product.class) .build(); excelWriter.write(productList, productSheet); } } ``` 注意:writerSheet方法的第一个参数是Sheet的索引(从0开始),也可以不指定索引只指定名称,这样会按顺序创建。 以上就是在同一个Excel文件中创建多个Sheet的方法。 如果用户需要更复杂的操作,比如每个Sheet内部分批次写入,可以在每个Sheet上多次调用write(使用同一个WriteSheet对象),如: ```java WriteSheet userSheet = EasyExcel.writerSheet("用户信息").head(User.class).build(); excelWriter.write(userListPart1, userSheet); excelWriter.write(userListPart2, userSheet); ``` 这样用户信息这个Sheet中就会先写入userListPart1,紧接着写入userListPart2。 引用[2]展示了分批次导出(每个Sheet一个批次),但这里我们也可以在一个Sheet内部分多个批次写入。 因此,总结来说,使用EasyExcel创建多个Sheet的步骤是: 1. 创建ExcelWriter 2. 循环或逐个创建WriteSheet,并写入数据(可以多次写入同一个WriteSheet) 3. 关闭ExcelWriter(调用finish) 请根据实际需求调整。</think>使用 EasyExcel一个 Excel 文件中创建多个 Sheet 的步骤如下(基于引用[1]的官方推荐方法): ### 核心代码实现 ```java try (ExcelWriter excelWriter = EasyExcel.write(filePath).build()) { // 创建第一个Sheet WriteSheet sheet1 = EasyExcel.writerSheet(0, "员工信息") // 索引+名称 .head(Employee.class) // 设置表头模型 .build(); excelWriter.write(employeeList, sheet1); // 写入数据 // 创建第二个Sheet WriteSheet sheet2 = EasyExcel.writerSheet(1, "部门统计") .head(Department.class) .build(); excelWriter.write(departmentList, sheet2); // 可继续添加更多Sheet... } // try-with-resources自动关闭 ``` ### 关键点说明 1. **ExcelWriter 初始化** ```java ExcelWriter excelWriter = EasyExcel.write(filePath).build(); ``` - 通过 `build()` 创建核心写入对象 - 使用 try-with-resources 确保自动关闭流(JDK7+) 2. **Sheet 配置** ```java WriteSheet sheet = EasyExcel.writerSheet(sheetIndex, sheetName) .head(YourModel.class) // 绑定表头模型 .registerConverter(new CustomConverter()) // 可选:注册自定义转换器 .build(); ``` - `sheetIndex`:Sheet 序号(从0开始) - `sheetName`:Sheet 显示名称 - `head()`:绑定带 `@ExcelProperty` 注解的模型类 3. **写入数据** ```java excelWriter.write(dataList, sheet); // dataList 需匹配模型类 ``` 4. **资源释放** ```java excelWriter.finish(); // 实际在 try-with-resources 中自动调用 ``` > 引用说明:此实现参考 EasyExcel 官方多 Sheet 写入方案[^1],通过循环构建 WriteSheet 对象实现多 Sheet 导出。 ### 完整示例场景 ```java // 模型类 public class Product { @ExcelProperty("产品ID") private Long id; @ExcelProperty("产品名称") private String name; } // 导出服务 public void exportMultiSheet() { String fileName = "multi_sheet.xlsx"; try (ExcelWriter writer = EasyExcel.write(fileName).build()) { // Sheet1: 产品列表 writer.write(productService.getProducts(), EasyExcel.writerSheet(0, "产品清单") .head(Product.class) .build()); // Sheet2: 销售统计 writer.write(salesService.getStats(), EasyExcel.writerSheet(1, "销售报表") .head(SalesStats.class) .build()); } } ``` ### 注意事项 1. **大文件优化**:单个 Sheet 超 100 万行时,参考引用[2]的分批次写入方案 2. **并发导出**:需并行导出多个独立文件时,参考引用[3]的压缩包方案 3. **资源释放**:必须确保调用 `finish()` 或使用 try-with-resources 4. **模型注解**:使用 `@ExcelProperty` 定义列名和顺序
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值