系列:Java架构实战笔记 · 文件与数据传输
阅读时间:12分钟
上一篇:大文件分片上传+断点续传(第02篇)
下一篇预告:海量数据CSV压缩导出与异步任务下载
一、问题引入
在经历了第01篇的百万数据导出实战后,你可能已经掌握了 EasyExcel 流式导出的技巧。但现在面临一个新问题:
项目中每天都有新的导出需求:
运营要导出订单报表
财务要导出对账单
产品要导出用户分析数据
客服要导出退款记录
你发现每个导出接口都在重复造轮子:
// 订单导出EasyExcel.write(response.getOutputStream(), OrderExcelDto.class).sheet().doWrite(orderList);// 用户导出EasyExcel.write(response.getOutputStream(), UserExcelDto.class).sheet().doWrite(userList);// 产品导出EasyExcel.write(response.getOutputStream(), ProductExcelDto.class).sheet().doWrite(productList);
80% 的代码是重复的:响应头设置、文件名处理、异常捕获、Excel 写入。更糟糕的是,每次新增导出都要:
创建一个新的 DTO 类
写一个新的 Controller 方法
写一个新的 Service 查询
重复设置响应头
如何设计一个通用的导出工具,让一行代码就能搞定任何列表的导出?
二、反面案例:那些年我们踩过的坑
2.1 错误代码(重复代码遍地开花)
// 订单导出@GetMapping("/export/order")public void exportOrder(HttpServletResponse response) { try { List<Order> orders = orderService.list(); List<OrderExcelDto> dtoList = orders.stream().map(this::convert).collect(Collectors.toList()); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=orders.xlsx"); EasyExcel.write(response.getOutputStream(), OrderExcelDto.class).sheet("订单").doWrite(dtoList); } catch (Exception e) { throw new RuntimeException("导出失败", e); }}// 用户导出(几乎一样的代码)@GetMapping("/export/user")public void exportUser(HttpServletResponse response) { try { List<User> users = userService.list(); List<UserExcelDto> dtoList = users.stream().map(this::convertToUserDto).collect(Collectors.toList()); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=users.xlsx"); EasyExcel.write(response.getOutputStream(), UserExcelDto.class).sheet("用户").doWrite(dtoList); } catch (Exception e) { throw new RuntimeException("导出失败", e); }}
2.2 踩坑实录
维护灾难:10 个导出接口就有 10 份重复代码,修改响应头格式需要改 10 个地方。
代码量爆炸:每个导出都需要单独写 Controller、Service、Converter、DTO,项目代码量膨胀。
类型不安全:手动转换 Order -> OrderExcelDto 容易遗漏字段,且编译期无法检查。
硬编码严重:文件名、Sheet名、列名都硬编码,改一个字段名要改多个地方。
根本原因:缺少一层通用抽象,让导出逻辑与业务数据解耦。
三、正确方案:注解驱动的通用导出工具
3.1 核心思路
注解定义导出配置:在实体类字段上使用 @ExcelExport 注解,声明列名、顺序、格式化规则。
反射获取配置:运行时通过反射读取注解,动态构建 Excel 的表头和列映射。
通用导出方法:一个静态方法接收 List<?> 和响应对象,自动完成 Excel 生成。
支持自定义转换:通过 Converter 接口处理枚举、日期等特殊类型的格式化。
3.2 技术选型
我们选择 注解+反射,这是企业级项目中最实用、最易维护的方案。
3.3 完整可运行代码
项目结构
① pom.xml(关键依赖)
<?xml version="1.0" encoding="UTF-8"?><projectxmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.0</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>excel-tool-demo</artifactId> <version>1.0.0</version> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>4.0.2</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies></xml>
② 导出注解 @ExcelExport
package com.heyou.common.excel.export.annotation;import java.lang.annotation.*;/** * Excel导出字段注解 * 放在实体类的字段上,用于定义导出时的列名、顺序、格式化等 */@Target(ElementType.FIELD)@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface ExcelExport { /** * 列名(表头) */ String value(); /** * 列顺序(从小到大排列) */ intorder() default 0; /** * 日期格式化(仅对 Date/LocalDateTime 类型生效) */ String dateFormat() default "yyyy-MM-dd HH:mm:ss"; /** * 枚举转换(实现类需提供转换方法) */ String enumMethod() default ""; /** * 是否允许为空 */ boolean allowNull() defaulttrue;}
③ 实体类(带注解)
package com.heyou.common.excel.export.entity;import com.example.excel.annotation.ExcelExport;import lombok.Data;import java.math.BigDecimal;import java.time.LocalDateTime;@Datapublic class Order { @ExcelExport(value = "订单号", order = 1) private String orderNo; @ExcelExport(value = "金额", order = 2) private BigDecimal amount; @ExcelExport(value = "状态", order = 3) private Integer status; @ExcelExport(value = "创建时间", order = 4, dateFormat = "yyyy-MM-dd") private LocalDateTime createTime; @ExcelExport(value = "用户ID", order = 5) private Long userId; // 不需要导出的字段不加注解 private String internalRemark;}
④ 枚举转换工具(状态码转文字)
package com.heyou.common.excel.export.handler;/** * 枚举转换器接口 */public interface EnumConverter { String convert(Integer code);}/** * 订单状态转换器实现 */@Componentpublic class OrderStatusConverter implements EnumConverter { private static final Map<Integer, String> STATUS_MAP = new HashMap<>(); static { STATUS_MAP.put(0, "待支付"); STATUS_MAP.put(1, "已支付"); STATUS_MAP.put(2, "已取消"); STATUS_MAP.put(3, "已完成"); } @Override public String convert(Integer code) { return STATUS_MAP.getOrDefault(code, "未知"); }}
⑤ 通用导出工具核心类
package com.heyou.common.excel.export.utils;import com.alibaba.excel.EasyExcel;import com.heyou.common.excel.export.annotation.ExcelExport;import com.heyou.common.excel.export.config.ExcelExportSpringBridge;import com.heyou.common.excel.export.service.handler.EnumConverter;import com.heyou.common.excel.export.service.handler.impl.CustomCellWriteHandler;import jakarta.servlet.http.HttpServletResponse;import lombok.extern.slf4j.Slf4j;import java.lang.reflect.Field;import java.net.URLEncoder;import java.nio.charset.StandardCharsets;import java.time.LocalDateTime;import java.time.format.DateTimeFormatter;import java.util.*;import java.util.concurrent.ConcurrentHashMap;/** * 通用Excel导出工具 * 一行代码完成任何列表的导出 */@Slf4jpublic class ExcelExportUtil { // 缓存类的字段配置,避免重复反射 private static final Map<Class<?>, List<FieldConfig>> FIELD_CONFIG_CACHE = new ConcurrentHashMap<>(); /** * 通用导出方法(核心) * @param response HttpServletResponse * @param dataList 数据列表 * @param fileName 导出文件名(不含后缀) * @param sheetName Sheet名称 */ public static void export(HttpServletResponse response, List<?> dataList, String fileName, String sheetName) { if (dataList == null || dataList.isEmpty()) { throw new IllegalArgumentException("导出数据不能为空"); } Class<?> clazz = dataList.get(0).getClass(); List<FieldConfig> fieldConfigs = getFieldConfigs(clazz); if (fieldConfigs.isEmpty()) { throw new IllegalArgumentException("类 " + clazz.getName() + " 没有配置 @ExcelExport 注解"); } try { // 设置响应头 response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); String encodedFileName = URLEncoder.encode(fileName + ".xlsx", StandardCharsets.UTF_8); response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encodedFileName); // 转换数据并写入Excel List<List<String>> excelData = convertToExcelData(dataList, fieldConfigs); List<String> headers = getHeaders(fieldConfigs); // EasyExcel 动态表头:每列对应 List<String> 的一行单元格(单列单行表头即 singletonList) List<List<String>> excelHead = headers.stream() .map(Collections::singletonList) .toList(); EasyExcel.write(response.getOutputStream()) .head(excelHead) .registerWriteHandler(new CustomCellWriteHandler()) .sheet(sheetName) .doWrite(excelData); log.info("导出成功: {}, 数据量: {} 条", fileName, dataList.size()); } catch (Exception e) { String detail = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); log.error("导出失败: {}", detail, e); throw new RuntimeException("导出失败: " + detail, e); } } /** * 简化版导出(使用默认Sheet名) */ public static void export(HttpServletResponse response, List<?> dataList, String fileName) { export(response, dataList, fileName, "Sheet1"); } /** * 获取类的字段配置(带缓存) */ private static List<FieldConfig> getFieldConfigs(Class<?> clazz) { return FIELD_CONFIG_CACHE.computeIfAbsent(clazz, c -> { List<FieldConfig> configs = new ArrayList<>(); for (Field field : c.getDeclaredFields()) { ExcelExport annotation = field.getAnnotation(ExcelExport.class); if (annotation != null) { FieldConfig config = new FieldConfig(); config.field = field; config.columnName = annotation.value(); config.order = annotation.order(); config.dateFormat = annotation.dateFormat(); config.enumMethod = annotation.enumMethod(); configs.add(config); } } // 按 order 排序 configs.sort(Comparator.comparingInt(cfg -> cfg.order)); return configs; }); } /** * 获取表头列表 */ private static List<String> getHeaders(List<FieldConfig> configs) { return configs.stream() .map(cfg -> cfg.columnName) .toList(); } /** * 将实体数据转换为Excel行数据 */ private static List<List<String>> convertToExcelData(List<?> dataList, List<FieldConfig> configs) { List<List<String>> rows = new ArrayList<>(); for (Object obj : dataList) { List<String> row = new ArrayList<>(); for (FieldConfig config : configs) { try { config.field.setAccessible(true); Object value = config.field.get(obj); String cellValue = formatCellValue(value, config); row.add(cellValue); } catch (IllegalAccessException e) { log.error("读取字段值失败: {}", config.field.getName(), e); row.add(""); } } rows.add(row); } return rows; } /** * 格式化单元格值 */ private static String formatCellValue(Object value, FieldConfig config) { if (value == null) { return ""; } // 日期类型格式化 if (value instanceof LocalDateTime) { DateTimeFormatter formatter = DateTimeFormatter.ofPattern(config.dateFormat); return ((LocalDateTime) value).format(formatter); } if (value instanceof Integer n && Integer.class.equals(config.field.getType())) { if (config.enumMethod != null && !config.enumMethod.isBlank()) { EnumConverter converter = ExcelExportSpringBridge.resolveEnumConverter(config.enumMethod); if (converter != null) { return converter.convert(n); } } return String.valueOf(value); } return String.valueOf(value); } // 内部类:字段配置 private static class FieldConfig { Field field; String columnName; int order; String dateFormat; /** Spring Bean 名,对应 {@link EnumConverter} */ String enumMethod; }}
⑥ 自定义样式处理器(可选)
package com.example.excel.util;import com.alibaba.excel.write.handler.SheetWriteHandler;import com.alibaba.excel.write.metadata.holder.WriteSheetHolder;import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder;import org.apache.poi.ss.usermodel.*;import org.apache.poi.xssf.usermodel.XSSFCellStyle;public class CustomCellWriteHandler implements SheetWriteHandler { @Override public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) { Sheet sheet = writeSheetHolder.getSheet(); // 设置表头样式 Row headerRow = sheet.getRow(0); if (headerRow != null) { Workbook workbook = writeWorkbookHolder.getWorkbook(); CellStyle headerStyle = workbook.createCellStyle(); Font font = workbook.createFont(); font.setBold(true); headerStyle.setFont(font); headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); headerStyle.setBorderBottom(BorderStyle.THIN); headerStyle.setBorderTop(BorderStyle.THIN); headerStyle.setBorderLeft(BorderStyle.THIN); headerStyle.setBorderRight(BorderStyle.THIN); headerRow.forEach(cell -> cell.setCellStyle(headerStyle)); } // 自动调整列宽 for (int i = 0; i < headerRow.getLastCellNum(); i++) { sheet.autoSizeColumn(i); sheet.setColumnWidth(i, Math.min(sheet.getColumnWidth(i) + 512, 15000)); } }}
⑦ Controller(极致简洁)
package com.example.excel.controller;import com.example.excel.entity.Order;import com.example.excel.entity.User;import com.example.excel.util.ExcelExportUtil;import com.example.excel.service.OrderService;import jakarta.servlet.http.HttpServletResponse;import lombok.RequiredArgsConstructor;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import java.util.List;@RestController@RequestMapping("/export")@RequiredArgsConstructorpublic class ExportController { private final OrderService orderService; /** * 订单导出 - 一行代码搞定! */ @GetMapping("/order") public void exportOrder(HttpServletResponse response) { List<Order> orders = orderService.list(); ExcelExportUtil.export(response, orders, "订单报表", "订单数据"); } /** * 用户导出 - 同样一行代码 */ @GetMapping("/user") public void exportUser(HttpServletResponse response) { List<User> users = userService.list(); // User类同样配置@ExcelExport注解 ExcelExportUtil.export(response, users, "用户报表"); }}
⑧ Service(模拟数据)
package com.example.excel.service;import com.example.excel.entity.Order;import org.springframework.stereotype.Service;import java.math.BigDecimal;import java.time.LocalDateTime;import java.util.ArrayList;import java.util.List;@Servicepublic class OrderService { public List<Order> list() { List<Order> orders = new ArrayList<>(); for (int i = 1; i <= 100; i++) { Order order = new Order(); order.setOrderNo("ORD" + System.currentTimeMillis() + i); order.setAmount(BigDecimal.valueOf(Math.random() * 1000)); order.setStatus(i % 3); order.setCreateTime(LocalDateTime.now().minusDays(i)); order.setUserId((long) (Math.random() * 1000)); orders.add(order); } return orders; }}
3.4 单元测试
@SpringBootTest@AutoConfigureMockMvcclassExportControllerTest{ @Autowired private MockMvc mockMvc; @Test void testExportOrder() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get("/export/order")) .andExpect(status().isOk()) .andExpect(header().string("Content-Disposition", containsString("订单报表.xlsx"))); }}
四、进阶与延伸
4.1 性能优化
反射缓存:FIELD_CONFIG_CACHE 避免重复反射,提升性能。
分批导出:大数据量时仍然使用 EasyExcel 的流式写入,本工具已支持。
构建Starter:将通用内容构建为Starter组件,以便引入项目使用。
4.2 功能扩展
// 支持动态列(用户选择导出哪些列)public static void exportWithSelection(HttpServletResponse response, List<?> dataList, List<String> selectedColumns) { // 根据 selectedColumns 过滤导出列}// 支持多Sheet导出public static void exportMultiSheet(HttpServletResponse response, Map<String, List<?>> sheetDataMap) { // 多个Sheet写入同一个Excel}
4.3 与第01篇的区别
五、总结与思考
核心三句话:
90% 的导出代码都是重复的,用注解+反射消除重复。
一行代码 ExcelExportUtil.export(response, list, "文件名") 搞定任何导出。
新增导出只需在实体类加 @ExcelExport 注解,零额外工作量。
踩坑 Checklist:
✅ 反射读取字段时记得 setAccessible(true)。
✅ 字段配置使用缓存,避免重复反射影响性能。
✅ 响应头中的中文文件名需要 URL 编码(URLEncoder.encode)。
✅ 大数据量导出时,仍建议配合第01篇的分页查询方案。
下一篇预告:海量数据CSV压缩导出与异步任务下载——当 Excel 撑不下时,CSV + GZIP 才是终极方案。