当前位置: 首页 > news >正文

Java Excel处理利器EasyExcel:注解驱动与流式解析实战

1. 项目概述:为什么是EasyExcel?

在Java后端开发里,处理Excel的导入导出是个高频且容易“踩坑”的需求。早年我们可能用Apache POI,功能强大但API繁琐,处理大文件时内存溢出(OOM)是家常便饭。后来有了JXL,但功能又略显单薄。直到阿里巴巴开源的EasyExcel出现,它基于POI但做了深度封装和优化,核心就两点:简单省内存

简单,体现在它的注解驱动模型。你不需要去记忆POI里那些复杂的CellRowSheet对象操作,通过几个注解就能完成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: 指定数字字段的格式化模式。如会计格式、百分比等。

注意@ExcelPropertyindex属性在复杂表头(多行表头)时尤为重要。EasyExcel通过index来定位数据单元格,而不是表头文字。这意味着即使表头文字被用户修改,只要列顺序不变,导入功能依然正常。

2.2 监听器:导入的灵魂组件

如果说模型是骨骼,那么监听器就是导入功能的神经中枢。EasyExcel采用基于监听器的SAX解析模式,这是其高性能的秘诀。

为什么需要监听器?因为SAX解析是“事件驱动”的。解析器一行行读取Excel,每读一行数据(不包括表头),就会触发一次监听器的invoke方法。这意味着:

  1. 内存友好:永远只保持一行数据在内存中,处理完即丢弃。
  2. 灵活可控:你可以在invoke方法中对每一行数据进行校验、转换、甚至中断读取。
  3. 异步处理:你可以在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会捕获并停止解析,将异常信息通过ReadListeneronException方法回调(如果需要自定义异常处理,需重写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回调接口这是最灵活的方式。你可以实现CellWriteHandlerRowWriteHandler等,在单元格/行创建时介入并设置样式。

@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()); } } }

数据校验的三种境界:

  1. 基础类型校验:由EasyExcel自动完成。例如,模型中是Integer age,Excel单元格里是字符串“abc”,框架会抛出ExcelDataConvertException。这是第一道防线。
  2. 业务逻辑校验:在监听器的invoke方法中进行。如邮箱格式、手机号格式、年龄范围、数据唯一性等。校验失败可以抛出异常,或更优雅地,将错误信息收集起来,在doAfterAllAnalysed中统一返回。
  3. 数据库约束校验:在批量保存(saveData)时进行。如唯一索引冲突、外键约束等。这部分错误通常需要回滚事务,并给出明确的批处理错误报告。

4.2 复杂表头与动态表头导入

用户上传的Excel表头可能和你的模型不完全一致(比如列顺序换了,或者有额外的列)。EasyExcel默认按indexvalue严格匹配。

  • headRowNumber:如果表头有多行,通过这个参数指定。例如headRowNumber(2),会合并前两行作为最终的表头映射。
  • extraHeadignore@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文件供用户下载。

实现思路:

  1. 在监听器中,不再遇到错误就抛异常,而是将错误行及其原因记录到一个列表中。
  2. doAfterAllAnalysed中,将成功的数据入库,将失败的数据列表(可能包含行号、原数据、错误原因)返回给控制器。
  3. 控制器根据失败数据列表,生成一个新的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进行填充

  1. 用Excel设计好带有占位符的模板。占位符用{}表示,如{name},{date}
  2. 使用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中创建CellStyleFont对象是昂贵的操作。务必缓存这些样式对象,在Workbook级别只创建一次。
    • 压缩临时文件:SXSSF会产生临时文件。确保系统临时目录(java.io.tmpdir)有足够空间,并考虑在JVM参数中调整-Djava.io.tmpdir指向更快的存储设备。

6. 常见问题排查与实战心得

问题1:导入时数字、日期格式解析错误。

  • 现象ExcelDataConvertException: Convert data [xxxx] to Integer/LocalDate error
  • 排查
    1. 检查Excel单元格的实际格式。看似“2023-01-01”的单元格,其底层格式可能是文本。
    2. 检查模型类注解。日期字段是否使用了@DateTimeFormat,且格式与数据匹配?数字字段是否使用了@NumberFormat
    3. 在监听器的invoke方法中,打印或日志记录原始数据data,看EasyExcel传递给你的是什么对象。有时框架已经帮你转换好了。
  • 解决
    • 确保Excel单元格格式正确(右键-设置单元格格式)。
    • 使用@DateTimeFormat@NumberFormat精确匹配。
    • 在监听器中做兼容性处理,例如,如果data.getJoinDate()是String类型,则手动用DateTimeFormatter解析。

问题2:导出文件打开报“文件已损坏”或内容为空。

  • 现象:下载的.xlsx文件无法用Excel打开。
  • 排查
    1. 最可能的原因:在Web导出中,在写出Excel之前或之后,向HttpServletResponseOutputStream写入了其他内容(如JSON错误信息),或者发生了异常被全局异常处理器包装成了JSON响应。
    2. 检查Controller方法是否被@ResponseBody@RestController注解,导致方法返回值也被写入流。
    3. 确保在EasyExcel.write()之后,没有调用response.getWriter().write()等方法。
  • 解决
    • Controller方法返回类型设为void
    • 在方法开始正确设置响应头(Content-Type,Content-Disposition)。
    • 使用try-with-resources或确保excelWriter.finish()被调用。
    • 在全局异常处理器中,排除对导出接口的异常包装。

问题3:导入大量数据时内存增长,最终OOM。

  • 现象:导入一个200MB的Excel,内存持续上涨直至GC overhead limit exceeded。
  • 排查
    1. 确认是否真的使用了EasyExcel的监听器模式。错误的用法是调用doReadSync()一次性读取所有数据到内存。
    2. 检查监听器中的cachedDataList是否及时清空。确保在saveData()后执行了clear()
    3. 检查业务逻辑。是否在监听器中无意间将每一行数据添加到了某个全局的、不断增长的集合中?
    4. 使用JProfiler等工具监控堆内存,查看大对象是谁。
  • 解决
    • 坚持使用监听器模式(AnalysisEventListener)。
    • 优化批量处理逻辑,确保缓存列表被及时回收。
    • 检查并修复业务代码中的内存泄漏点。

问题4:复杂表头导入,数据映射错位。

  • 现象:姓名读到了邮箱列里。
  • 排查
    1. 确认模型类@ExcelPropertyindex设置是否正确。表头行数变化可能导致索引基准变化。
    2. 使用EasyExcel的head方法打印读取到的表头信息,与你的模型定义对比。
    EasyExcel.read(inputStream) .head(UserDTO.class) .sheet() .doReadSync(); // 或者,在监听器的invokeHead方法中打印
  • 解决
    • 使用index而非value进行映射,更稳定。
    • 如果表头动态,考虑使用无模型读取,然后自己写映射逻辑。

个人心得:

  1. 测试要用真实的、脏的数据:不要只用自己生成的完美Excel测试。让业务人员给你几个他们实际使用的、可能有合并单元格、有空行、有格式问题的文件来测试,能发现90%的边界情况。
  2. 导入做成异步的:对于耗时较长的导入任务,最好做成异步。接口立即返回一个任务ID,前端轮询任务状态。后台使用线程池或消息队列处理导入文件,处理完成后将结果(成功/失败报告)存储起来,供用户下载。这能避免HTTP超时和浏览器等待。
  3. 给导出文件加上“指纹”:在文件名或Sheet名中加入导出时间戳(如用户列表_20231027_142356.xlsx),方便区分不同时间导出的文件,也便于问题追溯。
  4. 依赖管理:EasyExcel的版本要与底层POI版本匹配。通过Maven引入easyexcel,它会自动关联一个兼容的POI版本。不要自己单独引入不同版本的POI,以免冲突。
  5. 读写分离:读和写的模型DTO可以考虑分开(UserReadDTOUserWriteDTO)。因为读写需求可能不同,读可能需要更多校验注解,写可能需要更多样式注解。混在一起会导致类职责不清晰。
http://www.jsqmd.com/news/1410151/

相关文章:

  • 从单体到微服务:分布式架构核心思想与高频技术实践解析
  • 2026 年洛阳市新房装修吊顶造型施工实测:轻钢龙骨搭建,防潮不易变形 - GrowthUME
  • 绵阳市去哪批量拿雪花牛肉黄膘牛肉黄牛肉?澳牧熙源头大宗批发 - 滚动商讯
  • 轻量级Zsh插件管理器Μz:极简配置与高效终端环境搭建指南
  • 探索海鲜美味新天地:帝王宫,性价比之选让你大呼过瘾 - 官方资讯
  • 多智能体AI如何赋能认知行为疗法:CCD-CBT系统架构与应用解析
  • 多智能体AI教育系统规模化落地:如何攻克延迟与成本两大核心挑战?
  • AutoSurrogate:大语言模型驱动的多智能体系统,全自动构建地下流体模拟代理模型
  • 纪念日预算1000一晚豪华酒店怎么订:纪念日住豪华酒店,平台选错连欢迎饮品都没有 - 小橘甄选
  • 债权转让公告登报避坑指南:避开无效刊登、报刊选错、资料不全陷阱 - 信息快递
  • 情侣十一国庆旅游一日游去哪里 十大实力测评避坑不踩雷 - mypinpai
  • FPGA硬件加速实现视频图像去雾:基于暗通道先验的工程化方案
  • Redis 7 生产级安装部署指南:从源码编译到Systemd服务管理
  • AI多智能体与SLM在卫星预测性健康管理中的工程实践
  • Python读取.data文件全攻略:从格式识别到实战解析
  • Electron安装全攻略:从环境配置到深度排错,解决卡顿与报错
  • 从个人项目到可分享作品:工程化细节提升游戏体验
  • F28335代码固化Flash全攻略:从RAM调试到独立运行
  • 对话智能体记忆系统:基于检索与生成的工程实践
  • 构建支持自我发现的AI对话系统:从用户建模到个性化共情
  • 经常往返一二线城市订酒店用哪个平台好:一二线商旅党,会员积累速度比你想的快 - 小橘甄选
  • Mesh组网实战指南:从原理到部署,避开常见误区
  • 遵义管道疏通 推荐附近快修 本地专业师傅24小时上门服务 就近派单 - 信息分享
  • C/C++库开发全解析:从静态/动态库原理到CMake实战
  • Jetson开发板tegrastats监控工具实战解读:从参数解析到性能调优
  • 基于RAG的对话记忆系统:极简架构实现高效上下文管理
  • VB.NET快速入门:从零到一构建桌面应用,掌握事件驱动与控件开发
  • 激光大气传输特性解析:从衰减、湍流到系统设计的工程实践
  • 广州酒楼设备回收公司 - 滚动商讯
  • KTP1200 Basic PN固件版本不兼容:诊断与升级全流程指南