Java Excel处理利器EasyExcel:注解驱动与流式解析实战
1. 项目概述:为什么是EasyExcel?
在Java后端开发里,处理Excel的导入导出是个高频且容易“踩坑”的需求。早年我们可能用Apache POI,功能强大但API繁琐,处理大文件时内存溢出(OOM)是家常便饭。后来有了JXL,但功能又略显单薄。直到阿里巴巴开源的EasyExcel出现,它基于POI但做了深度封装和优化,核心就两点:简单和省内存。
简单,体现在它的注解驱动模型。你不需要去记忆POI里那些复杂的Cell、Row、Sheet对象操作,通过几个注解就能完成Java对象和Excel单元格的映射。省内存,则是它采用了SAX模式解析和异步无模型刷写。简单说,它解析Excel时不是一次性把整个文件加载到内存,而是像读XML一样一行行“流式”读取;写入时也是边处理数据边写入磁盘,避免在内存中构建完整的DOM树。这对于动辄几十万、上百万行的数据报表场景,是至关重要的。
所以,当你接到一个“把用户列表导出成Excel”或者“上传一个Excel批量更新产品信息”的需求时,EasyExcel几乎成了当前Java生态下的首选工具。它解决的不仅是功能实现问题,更是生产环境下的稳定性和性能问题。接下来,我会结合一个完整的用户信息管理案例,拆解如何使用EasyExcel实现稳健的导入导出,并分享那些官方文档里不会写的实操细节和避坑指南。
2. 核心设计:模型驱动与监听器模式
EasyExcel的优雅,很大程度上源于其清晰的分层设计。理解这个设计,是灵活使用它的前提。
2.1 注解驱动的数据模型
一切始于一个普通的Java Bean。EasyExcel通过一组注解,定义了该Bean的属性如何与Excel的表头(Header)和单元格(Cell)对应。
@Data // 使用Lombok简化代码 public class UserDTO { @ExcelProperty(value = "用户ID", index = 0) private Long id; @ExcelProperty(value = "姓名", index = 1) @ColumnWidth(20) // 设置列宽 private String name; @ExcelProperty(value = "年龄", index = 2) private Integer age; @ExcelProperty(value = "邮箱", index = 3) @ColumnWidth(30) private String email; @ExcelProperty(value = "入职日期", index = 4) @DateTimeFormat("yyyy-MM-dd") // 日期格式 private LocalDate joinDate; @ExcelProperty(value = "薪资", index = 5) @NumberFormat("#,##0.00") // 数字格式,千分位,两位小数 private BigDecimal salary; }关键注解解析:
@ExcelProperty: 核心注解。value定义表头名称,index定义该字段对应Excel的第几列(从0开始)。index在导出时用于控制列顺序,在导入时用于映射数据,强烈建议显式指定,避免因Java反射获取字段顺序的不确定性导致错列。@ColumnWidth: 设置导出Excel的列宽,单位是字符宽度。@DateTimeFormat: 指定日期字段的格式化模式。写入时,Date/LocalDate/LocalDateTime等对象会按此格式转为字符串;读取时,字符串会按此格式解析为对象。@NumberFormat: 指定数字字段的格式化模式。如会计格式、百分比等。
注意:
@ExcelProperty的index属性在复杂表头(多行表头)时尤为重要。EasyExcel通过index来定位数据单元格,而不是表头文字。这意味着即使表头文字被用户修改,只要列顺序不变,导入功能依然正常。
2.2 监听器:导入的灵魂组件
如果说模型是骨骼,那么监听器就是导入功能的神经中枢。EasyExcel采用基于监听器的SAX解析模式,这是其高性能的秘诀。
为什么需要监听器?因为SAX解析是“事件驱动”的。解析器一行行读取Excel,每读一行数据(不包括表头),就会触发一次监听器的invoke方法。这意味着:
- 内存友好:永远只保持一行数据在内存中,处理完即丢弃。
- 灵活可控:你可以在
invoke方法中对每一行数据进行校验、转换、甚至中断读取。 - 异步处理:你可以在
invoke方法中将数据放入队列,由其他线程异步消费,实现极致的吞吐量。
一个基础的读取监听器长这样:
// 注意:监听器不能被Spring管理!每次读取都要new一个实例。 // 如果需要在监听器里注入Spring Bean,可以通过构造器传入。 public class UserDataListener extends AnalysisEventListener<UserDTO> { /** * 每隔100条存储数据库,然后清理列表,方便内存回收 */ private static final int BATCH_COUNT = 100; private List<UserDTO> cachedDataList = new ArrayList<>(BATCH_COUNT); private UserService userService; // 假设通过构造器传入 public UserDataListener(UserService userService) { this.userService = userService; } /** * 每解析一行数据,都会调用此方法 * @param data 一行数据对应的对象 * @param context 分析上下文 */ @Override public void invoke(UserDTO data, AnalysisContext context) { // 1. 数据校验(示例:邮箱格式) if (!isValidEmail(data.getEmail())) { throw new ExcelDataConvertException(context.readRowHolder().getRowIndex(), context.readRowHolder().getCellMap(), new IllegalArgumentException("邮箱格式错误")); } // 2. 数据转换(示例:姓名去空格) data.setName(data.getName().trim()); cachedDataList.add(data); // 达到BATCH_COUNT,模拟批量入库,并清空列表 if (cachedDataList.size() >= BATCH_COUNT) { saveData(); cachedDataList.clear(); } } /** * 所有数据解析完成后调用 */ @Override public void doAfterAllAnalysed(AnalysisContext context) { // 确保最后一批数据也被保存 if (!cachedDataList.isEmpty()) { saveData(); } log.info("Excel数据导入完成!"); } private void saveData() { // 这里调用service进行批量保存 userService.batchSave(cachedDataList); log.info("成功批量存储{}条数据", cachedDataList.size()); } private boolean isValidEmail(String email) { // 简单的邮箱格式校验,实际应用请用更严谨的正则 return email != null && email.matches("^[A-Za-z0-9+_.-]+@(.+)$"); } }监听器设计要点:
- 批量操作:务必像示例中一样使用缓存列表进行批量处理(如
BATCH_COUNT = 100)。逐条操作数据库是性能杀手。 - 异常处理:在
invoke中校验失败时,可以抛出RuntimeException(如ExcelDataConvertException)。EasyExcel会捕获并停止解析,将异常信息通过ReadListener的onException方法回调(如果需要自定义异常处理,需重写onException)。 - 状态管理:监听器对象是多例的,不要在其中保存与单次读取无关的全局状态。
3. 实战:导出功能深度实现
导出功能相对直观,但要把细节做好,同样需要一番功夫。核心类是EasyExcel.write()。
3.1 基础导出与Web响应
最常见的场景是通过HTTP接口下载Excel文件。
@RestController @RequestMapping("/api/user") public class UserExportController { @Autowired private UserService userService; @GetMapping("/export") public void exportUsers(HttpServletResponse response) throws IOException { // 1. 设置响应头 String fileName = URLEncoder.encode("用户列表", "UTF-8").replaceAll("\\+", "%20"); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); // 注意:filename* 是RFC 5987标准,支持中文等特殊字符。旧浏览器可用`filename`,但需做URL编码。 // 2. 查询数据 List<UserDTO> userList = userService.listAllUsers(); // 假设的业务方法 // 3. 使用EasyExcel写出到HttpServletResponse的OutputStream // 这里“UserDTO.class”指定了写入的数据模型和表头 EasyExcel.write(response.getOutputStream(), UserDTO.class) .sheet("用户信息") // 指定Sheet名称 .doWrite(userList); // 执行写入 } }关键点与避坑:
- 响应头:
Content-Type必须是application/vnd.openxmlformats-officedocument.spreadsheetml.sheet对应.xlsx格式。.xls格式对应application/vnd.ms-excel。 - 文件名编码:处理中文文件名是经典问题。
filename*=utf-8''是现代浏览器的推荐做法。为了兼容性,可以同时设置filename(URL编码后的)和filename*。 - 资源关闭:
EasyExcel.write()会自动关闭传入的OutputStream,无需手动关闭。 - 大数据量导出:上面的例子一次性查询所有数据并写入,如果数据量巨大(如50万行),会导致内存压力和数据库查询超时。解决方案是使用分页查询+异步刷写。
3.2 大数据量分页导出
对于海量数据导出,必须采用分页查询,并利用EasyExcel的WriteSheet和多次doWrite。
public void exportLargeData(HttpServletResponse response) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-disposition", "attachment;filename=large_data.xlsx"); ExcelWriter excelWriter = null; try { excelWriter = EasyExcel.write(response.getOutputStream(), UserDTO.class).build(); WriteSheet writeSheet = EasyExcel.writerSheet("大数据Sheet").build(); int pageNum = 1; int pageSize = 5000; // 每页5000条 List<UserDTO> pageData; do { // 分页查询数据,需业务层支持 pageData = userService.listUsersByPage(pageNum, pageSize); if (CollectionUtils.isEmpty(pageData)) { break; } // 分批写入Excel excelWriter.write(pageData, writeSheet); pageNum++; // 可选:每写几页清理一次上下文,防止内存缓慢增长(对于极端大量数据) if (pageNum % 20 == 0) { excelWriter.finish(); // 注意:finish会关闭流,这里用法不对!正确做法见下方。 // 实际上,对于同一个OutputStream,不能多次finish。更优方案是使用`SxssfSheet`的flush机制。 // 更常见的做法是信任EasyExcel的内存管理,或直接使用其“重复多次写入”API。 } } while (pageData.size() == pageSize); // 如果查不满一页,说明是最后一页 } finally { // 非常重要!最终必须finish,才会真正写出文件尾并关闭流 if (excelWriter != null) { excelWriter.finish(); } } }重要提示:上面的分页示例中,关于
finish的注释是关键。实际上,EasyExcel在设计上已经考虑了大文件写入,其底层在默认情况下会使用SXSSFWorkbook(POI的流式API),它会自动将一定行数后的数据刷写到磁盘临时文件。因此,在大多数情况下,你只需要像基础导出那样调用一次doWrite,即使数据量很大,EasyExcel和POI也会在内部进行分片处理。更优雅的大数据导出做法是使用“模板填充”或自定义WriteHandler来精确控制内存。上面的分页循环写入方式,适用于数据来源不是单一SQL查询,而是多个不同查询或混合数据源的场景。
3.3 自定义样式与复杂表头
有时我们需要导出的Excel带有特定的样式(如标题行加粗、背景色)或复杂的多级表头。
方案一:使用WriteHandler回调接口这是最灵活的方式。你可以实现CellWriteHandler、RowWriteHandler等,在单元格/行创建时介入并设置样式。
@Component // 可以注册为Spring Bean复用 public class CustomCellStyleHandler implements CellWriteHandler { // 表头样式 private CellStyle headerCellStyle(WriteSheetHolder writeSheetHolder) { Workbook workbook = writeSheetHolder.getParentWriteWorkbookHolder().getWorkbook(); CellStyle style = workbook.createCellStyle(); Font font = workbook.createFont(); font.setBold(true); // 加粗 font.setFontHeightInPoints((short)12); // 字号 style.setFont(font); style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); // 背景色 style.setFillPattern(FillPatternType.SOLID_FOREGROUND); style.setAlignment(HorizontalAlignment.CENTER); // 居中 return style; } @Override public void afterCellDispose(CellWriteHandlerContext context) { // 只在表头行设置样式 if (context.getRowIndex() == 0) { // 假设表头在第0行 Cell cell = context.getCell(); CellStyle style = headerCellStyle(context.getWriteSheetHolder()); cell.setCellStyle(style); } // 还可以在这里根据单元格内容、行号等设置不同的数据行样式 } } // 使用时注入Handler EasyExcel.write(outputStream, UserDTO.class) .registerWriteHandler(new CustomCellStyleHandler()) // 注册自定义处理器 .sheet("用户表") .doWrite(data);方案二:使用@ExcelProperty组合复杂表头对于多级表头,可以直接在注解中定义。
public class ComplexHeaderDTO { // 第一行表头是“基本信息”,它横跨了下方的“姓名”和“年龄”两列 @ExcelProperty(value = {"基本信息", "姓名"}) private String name; @ExcelProperty(value = {"基本信息", "年龄"}) private Integer age; @ExcelProperty(value = {"联系信息", "邮箱"}) private String email; @ExcelProperty(value = {"联系信息", "电话"}) private String phone; }导出后,Excel表头将呈现为:
| 基本信息 | 联系信息 | | 姓名 | 年龄 | 邮箱 | 电话 |4. 实战:导入功能深度实现
导入比导出更复杂,因为要处理用户上传文件的不确定性(格式错误、数据错误、网络中断等)。
4.1 基础导入与数据校验
基础导入的核心是配置监听器并执行读取。
@RestController @RequestMapping("/api/user") public class UserImportController { @PostMapping("/import") public ApiResponse<String> importUsers(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return ApiResponse.fail("请选择文件"); } try { // 1. 创建监听器实例。注意:监听器不能是单例,每次读取需新建。 UserDataListener listener = new UserDataListener(userService); // 2. 读取文件 EasyExcel.read(file.getInputStream(), UserDTO.class, listener) .sheet() // 默认读取第一个sheet .headRowNumber(1) // 指定表头行数,默认为1。如果有多级表头,需调整。 .doRead(); // 开始同步读取,读取完整个方法才返回 return ApiResponse.success("导入成功"); } catch (ExcelDataConvertException e) { // 捕获数据转换异常(如数字格填了文字) log.error("第{}行,第{}列数据解析失败", e.getRowIndex() + 1, e.getColumnIndex() + 1); return ApiResponse.fail(String.format("第%d行数据格式错误,请检查", e.getRowIndex() + 1)); } catch (Exception e) { log.error("导入失败", e); return ApiResponse.fail("导入失败:" + e.getMessage()); } } }数据校验的三种境界:
- 基础类型校验:由EasyExcel自动完成。例如,模型中是
Integer age,Excel单元格里是字符串“abc”,框架会抛出ExcelDataConvertException。这是第一道防线。 - 业务逻辑校验:在监听器的
invoke方法中进行。如邮箱格式、手机号格式、年龄范围、数据唯一性等。校验失败可以抛出异常,或更优雅地,将错误信息收集起来,在doAfterAllAnalysed中统一返回。 - 数据库约束校验:在批量保存(
saveData)时进行。如唯一索引冲突、外键约束等。这部分错误通常需要回滚事务,并给出明确的批处理错误报告。
4.2 复杂表头与动态表头导入
用户上传的Excel表头可能和你的模型不完全一致(比如列顺序换了,或者有额外的列)。EasyExcel默认按index或value严格匹配。
headRowNumber:如果表头有多行,通过这个参数指定。例如headRowNumber(2),会合并前两行作为最终的表头映射。extraHead与ignore:@ExcelProperty注解的index是精准定位的利器。即使表头文字不匹配,只要列顺序对,就能读。你可以设置@ExcelProperty(index = 2)来读取第三列的数据,而不管它表头叫什么。- 动态表头:对于表头完全不固定的场景(比如用户自定义报表),EasyExcel的模型映射就不适用了。此时应该使用无模型读取。
// 无模型读取:返回的数据是List<List<Object>>,每个内层List代表一行,Object是单元格的值 List<Object> list = EasyExcel.read(file.getInputStream()) .sheet() .headRowNumber(0) // 设置为0,表示没有表头,所有行都是数据 .doReadSync(); // 同步读取,直接返回结果 for (List<Object> row : list) { // 手动处理每一行数据 String name = (String) row.get(0); // ... 自己实现映射逻辑 }无模型读取给了你最大的灵活性,但代价是需要自己编写所有的解析和校验逻辑。
4.3 导入结果反馈与错误文件生成
一个专业的导入功能,不应该只返回“成功”或“失败”,而应该告诉用户:成功了哪些,失败了哪些,失败的原因是什么。通常的做法是生成一个带有错误标记的Excel文件供用户下载。
实现思路:
- 在监听器中,不再遇到错误就抛异常,而是将错误行及其原因记录到一个列表中。
- 在
doAfterAllAnalysed中,将成功的数据入库,将失败的数据列表(可能包含行号、原数据、错误原因)返回给控制器。 - 控制器根据失败数据列表,生成一个新的Excel。这个Excel可以原样包含失败行的数据,并在最后一列追加“错误原因”。
// 在监听器中定义 public class UserDataListenerWithError extends AnalysisEventListener<UserDTO> { private List<UserDTO> successList = new ArrayList<>(); private List<ImportError> errorList = new ArrayList<>(); // ImportError自定义类,包含rowIndex, data, errorMsg @Override public void invoke(UserDTO data, AnalysisContext context) { try { // 校验逻辑 validate(data); successList.add(data); } catch (ValidationException e) { errorList.add(new ImportError(context.readRowHolder().getRowIndex(), data, e.getMessage())); } } @Override public void doAfterAllAnalysed(AnalysisContext context) { if (!successList.isEmpty()) { userService.batchSave(successList); } // 将errorList通过某种方式(如ThreadLocal)传递回Controller } } // 在Controller中,根据errorList生成错误报告Excel public void generateErrorReport(List<ImportError> errors, HttpServletResponse response) { // 1. 将errors转换为一个用于导出的DTO列表(包含原数据和错误列) List<ErrorReportDTO> reportData = convert(errors); // 2. 使用EasyExcel写出 EasyExcel.write(response.getOutputStream(), ErrorReportDTO.class) .sheet("导入错误报告") .doWrite(reportData); }5. 高级特性与性能调优
5.1 读/写拦截器(ReadListener/WriteHandler)的进阶用法
除了设置样式,拦截器还能做很多事:
ReadListener:invokeHead: 在读取表头时调用,可用于校验表头是否正确。onException: 发生异常时调用,可以在这里进行自定义的异常日志记录或转换。hasNext: 在invoke之前调用,返回false可立即停止读取。
CellWriteHandler:beforeCellCreate: 在创建单元格前,可以设置行高、列宽。afterCellDispose: 在单元格创建后,可以基于单元格值设置条件格式。例如,将薪资大于10000的单元格标红。
5.2 模板填充:更灵活的导出
对于格式极其复杂、固定的报表(如合同、发票),使用模型注解导出会很吃力。此时可以用.xlsx文件作为模板,用EasyExcel进行填充。
- 用Excel设计好带有占位符的模板。占位符用
{}表示,如{name},{date}。 - 使用
EasyExcel的填充API。
// 准备填充数据 Map<String, Object> data = new HashMap<>(); data.put("name", "张三"); data.put("date", LocalDate.now()); data.put("items", listOfItems); // 可以填充列表,模板中对应 {.items} 和 {item.property} // 读取模板并填充 String templateFileName = "template.xlsx"; String resultFileName = "filled_contract.xlsx"; EasyExcel.write(resultFileName) .withTemplate(templateFileName) .sheet() .doFill(data);模板填充功能将样式设计和数据逻辑完全分离,非常适合由运营或产品人员维护报表样式的场景。
5.3 性能调优要点
- 导入侧:
headRowNumber:准确设置。如果表头只有一行却设为2,会浪费一行解析。autoTrim:默认true,会自动去除字符串首尾空格。如果确定数据无需处理,可设为false节省微末性能。- 监听器中的批量大小:
BATCH_COUNT需要权衡。太小则数据库交互频繁;太大则内存中缓存列表过大,失去流式读取的意义。通常建议在100-1000之间,根据单条数据大小调整。 - 关闭自动关闭流:
EasyExcel.read()默认会自动关闭输入流。如果你需要自己管理流生命周期,可以使用.autoCloseStream(false)。
- 导出侧:
- 使用
SXSSF:EasyExcel默认使用POI的SXSSF模式(-1表示不限制内存行数,全部刷写到磁盘)。对于超大数据,可以显式配置.inMemory(false)强制使用SXSSF,或通过.autoTrim(false)等减少内存操作。 - 避免在循环中创建样式:在
WriteHandler中创建CellStyle和Font对象是昂贵的操作。务必缓存这些样式对象,在Workbook级别只创建一次。 - 压缩临时文件:SXSSF会产生临时文件。确保系统临时目录(
java.io.tmpdir)有足够空间,并考虑在JVM参数中调整-Djava.io.tmpdir指向更快的存储设备。
- 使用
6. 常见问题排查与实战心得
问题1:导入时数字、日期格式解析错误。
- 现象:
ExcelDataConvertException: Convert data [xxxx] to Integer/LocalDate error。 - 排查:
- 检查Excel单元格的实际格式。看似“2023-01-01”的单元格,其底层格式可能是文本。
- 检查模型类注解。日期字段是否使用了
@DateTimeFormat,且格式与数据匹配?数字字段是否使用了@NumberFormat? - 在监听器的
invoke方法中,打印或日志记录原始数据data,看EasyExcel传递给你的是什么对象。有时框架已经帮你转换好了。
- 解决:
- 确保Excel单元格格式正确(右键-设置单元格格式)。
- 使用
@DateTimeFormat、@NumberFormat精确匹配。 - 在监听器中做兼容性处理,例如,如果
data.getJoinDate()是String类型,则手动用DateTimeFormatter解析。
问题2:导出文件打开报“文件已损坏”或内容为空。
- 现象:下载的
.xlsx文件无法用Excel打开。 - 排查:
- 最可能的原因:在Web导出中,在写出Excel之前或之后,向
HttpServletResponse的OutputStream写入了其他内容(如JSON错误信息),或者发生了异常被全局异常处理器包装成了JSON响应。 - 检查Controller方法是否被
@ResponseBody或@RestController注解,导致方法返回值也被写入流。 - 确保在
EasyExcel.write()之后,没有调用response.getWriter().write()等方法。
- 最可能的原因:在Web导出中,在写出Excel之前或之后,向
- 解决:
- Controller方法返回类型设为
void。 - 在方法开始正确设置响应头(
Content-Type,Content-Disposition)。 - 使用
try-with-resources或确保excelWriter.finish()被调用。 - 在全局异常处理器中,排除对导出接口的异常包装。
- Controller方法返回类型设为
问题3:导入大量数据时内存增长,最终OOM。
- 现象:导入一个200MB的Excel,内存持续上涨直至GC overhead limit exceeded。
- 排查:
- 确认是否真的使用了EasyExcel的监听器模式。错误的用法是调用
doReadSync()一次性读取所有数据到内存。 - 检查监听器中的
cachedDataList是否及时清空。确保在saveData()后执行了clear()。 - 检查业务逻辑。是否在监听器中无意间将每一行数据添加到了某个全局的、不断增长的集合中?
- 使用JProfiler等工具监控堆内存,查看大对象是谁。
- 确认是否真的使用了EasyExcel的监听器模式。错误的用法是调用
- 解决:
- 坚持使用监听器模式(
AnalysisEventListener)。 - 优化批量处理逻辑,确保缓存列表被及时回收。
- 检查并修复业务代码中的内存泄漏点。
- 坚持使用监听器模式(
问题4:复杂表头导入,数据映射错位。
- 现象:姓名读到了邮箱列里。
- 排查:
- 确认模型类
@ExcelProperty的index设置是否正确。表头行数变化可能导致索引基准变化。 - 使用EasyExcel的
head方法打印读取到的表头信息,与你的模型定义对比。
EasyExcel.read(inputStream) .head(UserDTO.class) .sheet() .doReadSync(); // 或者,在监听器的invokeHead方法中打印 - 确认模型类
- 解决:
- 使用
index而非value进行映射,更稳定。 - 如果表头动态,考虑使用无模型读取,然后自己写映射逻辑。
- 使用
个人心得:
- 测试要用真实的、脏的数据:不要只用自己生成的完美Excel测试。让业务人员给你几个他们实际使用的、可能有合并单元格、有空行、有格式问题的文件来测试,能发现90%的边界情况。
- 导入做成异步的:对于耗时较长的导入任务,最好做成异步。接口立即返回一个任务ID,前端轮询任务状态。后台使用线程池或消息队列处理导入文件,处理完成后将结果(成功/失败报告)存储起来,供用户下载。这能避免HTTP超时和浏览器等待。
- 给导出文件加上“指纹”:在文件名或Sheet名中加入导出时间戳(如
用户列表_20231027_142356.xlsx),方便区分不同时间导出的文件,也便于问题追溯。 - 依赖管理:EasyExcel的版本要与底层POI版本匹配。通过Maven引入
easyexcel,它会自动关联一个兼容的POI版本。不要自己单独引入不同版本的POI,以免冲突。 - 读写分离:读和写的模型DTO可以考虑分开(
UserReadDTO和UserWriteDTO)。因为读写需求可能不同,读可能需要更多校验注解,写可能需要更多样式注解。混在一起会导致类职责不清晰。
