Java基础、中级、高级、架构面试资料

100% 复现!只要加上 @Accessors(chain=true),EasyExcel 读取属性 100% 为 null

JAVA herman 22浏览
公告:“业余草”微信公众号提供免费CSDN下载服务(只下Java资源),关注业余草微信公众号,添加作者微信:xttblog2,发送下载链接帮助你免费下载!
本博客日IP超过2000,PV 3000 左右,急需赞助商。
极客时间所有课程通过我的二维码购买后返现24元微信红包,请加博主新的微信号:xttblog2,之前的微信号好友位已满,备注:返现
受密码保护的文章请关注“业余草”公众号,回复关键字“0”获得密码
所有面试题(java、前端、数据库、springboot等)一网打尽,请关注文末小程序
视频教程免费领
腾讯云】1核2G5M轻量应用服务器50元首年,高性价比,助您轻松上云

100% 复现!只要加上 @Accessors(chain=true),EasyExcel 读取属性 100% 为 null。

昨天是开年上班第一天,一个同事突然找到我,说“我用 EasyExcel 读取 Excel,明明导入了 1000 条数据,为什么所有对象的属性都是 null?他看了和其他同事的代码,写的都一样,两人都没找出问题原因,让我帮忙看看”。

我一听,感觉这个坑有印象,不会是 Lombok 的@Accessors(chain=true)注解导致的问题吧。于是,我说让他搜索搜索,可能是@Accessors(chain=true)注解的原因。

谁知,他一查,果真是这个原因。后来,他又问我具体原因与源码细节,我当时给他截了图,顺便把 EasyExcel 的读取原理、Cglib 的 Bean 拷贝机制大致给他讲了一下。这里再花一点时间,整理成一篇文章《EasyExcel 踩坑实录:一个注解引发的“血案”,让你的 Excel 数据全军覆没!》,分享给更多的网友,希望能够帮助到更多的网友避坑。

问题复现

这坑有多深?我们先来看一段“完美”的代码。

@Data
// 就是这行代码在搞事情!
@Accessors(chain = true)  
public class User {
    @ExcelProperty("编码")
    private Long id;

    @ExcelProperty("姓名")
    private String name;

    @ExcelProperty("性别")
    private String sex;

    @ExcelProperty("年龄")
    private Integer age;
}

// 读取代码
@Test
public void importExcel() {
    String filePath = "D:/student.xlsx";
    List<Object> list = EasyExcel.read(filePath, User.class, null)
         .sheet("user")
         .doReadSync();
    list.forEach(System.out::println);
}

上面这段测试代码,执行结果是:读取到了 100 条数据,但每个 User 对象的属性全是 null!

这个问题是 100% 复现的,而且最快的解决方案是:删掉 @Accessors(chain = true) 或改成 @Accessors(chain = false),数据就读出来了。

根因剖析

下面我们一起来进行一下根因剖析。

熟悉的朋友可能会知道,这其实是 Lombok 和 EasyExcel 的“世纪大战”,是一个并不新鲜的问题。因为 easyExcel 上有好几个 issue 提到了这个问题,比如:https://github.com/alibaba/easyexcel/issues/3905https://github.com/alibaba/easyexcel/issues/689https://github.com/alibaba/easyexcel/issues/4013等。

但是这里面都没有提到具体的导致问题的代码和原因,所以这里我稍微展开一下。

@Accessors(chain = true) 干了啥?

这个注解相信大家都不陌生,目的是让 Lombok 生成的 setter 方法返回 this 而不是 void,支持链式调用。

看下面的案例代码。

// 不使用 chain = true
public void setName(String name) {
    this.name = name;
}

// 使用 chain = true
public Student setName(String name) {
    this.name = name;
    return this;  // 关键区别!
}

这样写确实很爽,new User().setName("张三").setAge(18).setSex("男")一个链式调用就能把所有属性的值都给赋上。

但 Cglib 不建议这样做。

EasyExcel 的对象赋值

EasyExcel 读取 Excel 的核心代码在 ModelBuildEventListener 类中。简化代码如下所示。

private Object buildUserModel(Map<Integer, CellData> cellDataMap, 
        ReadHolder currentReadHolder,
        AnalysisContext context) {
    // 1. 创建空对象
    Object resultModel = excelReadHeadProperty.getHeadClazz().newInstance();

    // 2. 将 Excel 数据转成 Map
    Map<String, Object> map = new HashMap<>();
    for (Map.Entry<Integer, Head> entry : headMap.entrySet()) {
        // ... 省略数据转换逻辑
        // key=属性名, value=单元格值
        map.put(fieldName, value);  
    }

    // 3. 核心:使用 BeanMap 拷贝数据
    // 就是这里!
    BeanMap.create(resultModel).putAll(map);  
    return resultModel;
}

这里最为关键的点是这个 BeanMap。EasyExcel 使用 net.sf.cglib.beans.BeanMap 将 Map 中的数据拷贝到实体对象中。之所以,是用这个 BeanMap,是因为它足够快,性能好。

但这个 BeanMap 有“坑”。

BeanMap 的坑

BeanMap 的工作机制是这样的。

  • 查找目标类的setter方法(通过 setXxx 命名规范)
  • 要求 setter 必须返回void 类型
  • 通过反射调用propertyDescriptor.getWriteMethod().invoke(object, value)赋值

这就与 Lombok 的@Accessors(chain = true)注解形成了致命冲突。当 setter 返回 User 而不是 void 时,BeanMap 认为“这不是一个合法的 setter”,直接跳过该属性!结果就是所有属性都赋不上值,全部为 null。

BeanMap 之所以这样做的原因是,它认为 setter 方法应该专注于设置属性的单一职责

你看看,老外是多么的有原则。它们认为,你们这样的 setter 是不规范的,setter 就应该是 void 的。

EasyExcel 读取原理

看到这里,大家可以想象 EasyExcel 为什么它这么快?就是因为它为了快,采用的都是性能比较好的第三方类库。

接下来,我们先看看传统 POI 的痛点。

传统 POI 的痛点

  • 内存占用恐怖:一个 3MB 的 Excel,POI SAX 解析需要 100MB+ 内存
  • OOM 频发:10 万行数据就能让 JVM 哭晕在厕所
  • 全量加载:即使只需要第一行,也要把整个文件读进内存

EasyExcel 降维打击

有痛点就有机会,于是 EasyExcel 诞生了,它对 poi 形成了降维打击。

EasyExcel 的核心原理是,SAX 模式 + 磁盘缓存 + 模型转换。对应的简化版流程如下所示。

// 简化版流程
ExcelReader → ExcelAnalyserImpl
               ↓
        BaseSaxAnalyser
               ↓
        XlsxRowHandler
               ↓
      AnalysisEventListener
               ↓
   ModelBuildEventListener
               ↓
         你的实体对象

这里面的关键技术点是流式解析、监听器模式、内存优化黑科技等。接下来,我们分别讲讲。

流式解析

以 XlsxRowHandler 为例。

  • 继承自 DefaultHandler(SAX 事件处理器)
  • characters() 方法中逐行读取,不加载整个文档
  • 每读完一行触发 endElement()notifyListeners()

接下来是监听模式。

监听器模式

以 AnalysisEventListener 为例,伪代码如下所示。

public class DemoDataListener extends AnalysisEventListener<DemoData> {
   @Override
   public void invoke(DemoData data, AnalysisContext context) {
       // 每读一行触发一次,可以在这里做校验、批量入库
   }

   @Override
   public void doAfterAllAnalysed(AnalysisContext context) {
       // 所有数据读完后的收尾工作
   }
}

最后是内存优化黑科技。

内存优化黑科技

  • 共享字符串表缓存:Excel 的字符串存储在共享表中,默认 5MB 以下存内存,超过存磁盘(EhCache)
  • 批量读取:readCacheSelector(new SimpleReadCacheSelector(5, 20)) 可调整内存策略
  • 官网测试数据:16MB 内存 23 秒读取 75MB(46 万行 25 列)的 Excel

所以,当你也遇到大 Excel 文件读取时,不防试试 EasyExcel 或 FastExcel。FastExcel 是 EasyExcel 原作者推出的替代品,EasyExcel 已经停止维护了。

解决方案

上面的代码细节和缘由搞清楚后,我们再来看看遇到这个类问题的解决方案是什么。这里我想出了 3 个方案,供大家参考。

方案一,妥协方案(移除链式调用)。

// 直接删除 @Accessors 注解
@Data
public class Student {
    // ...
}

优点是简单粗暴,立竿见影。缺点是牺牲了链式调用的优雅语法。

方案二:精准控制(仅对特定类禁用)。

@Data
@Accessors(chain = true)
public class User {
    // ...

    // 手动覆盖 setter,返回 void
    public void setName(String name) {
        this.name = name;
    }
}

优点是保留大部分链式调用能力。缺点是需要手动编写部分 setter,破坏 Lombok 的纯粹性。

方案三:自定义 Converter(终极方案)。

如果你实在离不开链式调用或者说有很多链式调用,那可以自定义 AnalysisEventListener 手动映射。

public class StudentListener extends AnalysisEventListener<Map<Integer, String>> {
    private List<User> dataList = new ArrayList<>();

    @Override
    public void invoke(Map<Integer, String> data, AnalysisContext context) {
        User user = new User();
        user.setId(Long.valueOf(data.get(0)))
             .setName(data.get(1))
             .setSex(data.get(2))
             .setAge(Integer.valueOf(data.get(3)));
        dataList.add(user);
    }
}

优点是完全掌控映射逻辑,可处理复杂转换。缺点是代码量增加,失去了 EasyExcel 自动映射的便利性。

Cglib Bean 拷贝

Cglib Bean 拷贝,应该没有多少人会用它吧。一般用的都是 apache 的,或是 Spring 的,也或者是 hutool 的。

但是 Cglib 的 bean 拷贝性能真的是杆杠的。

它的优势是:

  1. 性能爆炸:通过字节码生成技术,直接调用 getter/setter,比反射快 10-100 倍
  2. 简洁 API:BeanMap.create(obj).putAll(map) 一行代码搞定
  3. 缓存机制:可缓存 BeanCopier 实例,避免重复生成字节码
// 性能对比(单位:毫秒)
Apache BeanUtils: 1500ms
Spring BeanUtils:  400ms
// 碾压级优势
Cglib BeanCopier:   50ms  

它的性能虽然好,但是它也有不少致命的缺陷,导致它的采用率并不高。

其一是,严格的 setter 规范。必须返回 void,必须遵循 setXxx 命名。
其二是,类型必须精确匹配。Cglib 不会拷贝 int 到 Integer 等。
其三是,setter 数量不能少于 getter。
其四是,Converter 一旦启用自定义转换器,所有属性都必须手动处理,失去自动拷贝意义。

copier.copy(source, target, (value, targetClass, context) -> {
    // 必须处理所有属性,否则返回 null
});

以上这些 Cglib 的 Bean 拷贝规约,大家可以多去研究实践一番。

结语

@Accessors(chain = true) 和 EasyExcel 的冲突,本质是语法糖便利底层性能的权衡。理解其背后的 Cglib BeanMap 机制,能帮我们快速排坑,也让我们在设计框架时做出更明智的选择。

EasyExcel 虽然已经停更了,不过还是有不少老系统在使用。看过这篇文章,应该会帮不少人避坑吧!

参考资料

  • https://github.com/alibaba/easyexcel/issues/4013
  • Cglib BeanCopier 深度测试blog.csdn.net/zmx729618/article/details/78363191
  • EasyExcel 官方文档https://github.com/alibaba/easyexcel
  • https://github.com/alibaba/easyexcel/issues/3905

业余草公众号

最后,欢迎关注我的个人微信公众号:业余草(yyucao)!可加作者微信号:xttblog2。备注:“1”,添加博主微信拉你进微信群。备注错误不会同意好友申请。再次感谢您的关注!后续有精彩内容会第一时间发给您!原创文章投稿请发送至532009913@qq.com邮箱。商务合作也可添加作者微信进行联系!

本文原文出处:业余草: » 100% 复现!只要加上 @Accessors(chain=true),EasyExcel 读取属性 100% 为 null