系列: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.0http://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, "已完成");}@Overridepublic 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);// 转换数据并写入ExcelList<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 {@Overridepublic 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{@Autowiredprivate MockMvc mockMvc;@Testvoid 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 才是终极方案。
夜雨聆风