乐于分享
好东西不私藏

第03篇:通用Excel动态导出工具设计—一行代码搞定任意列表导出

第03篇:通用Excel动态导出工具设计—一行代码搞定任意列表导出

系列: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 写入。更糟糕的是,每次新增导出都要:

  1. 创建一个新的 DTO 类

  2. 写一个新的 Controller 方法

  3. 写一个新的 Service 查询

  4. 重复设置响应头

如何设计一个通用的导出工具,让一行代码就能搞定任何列表的导出?

二、反面案例:那些年我们踩过的坑

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 核心思路

  1. 注解定义导出配置:在实体类字段上使用 @ExcelExport 注解,声明列名、顺序、格式化规则。

  2. 反射获取配置:运行时通过反射读取注解,动态构建 Excel 的表头和列映射。

  3. 通用导出方法:一个静态方法接收 List<?> 和响应对象,自动完成 Excel 生成。

  4. 支持自定义转换:通过 Converter 接口处理枚举、日期等特殊类型的格式化。

3.2 技术选型

方案
优点
缺点
硬编码 DTO
类型安全、性能好
重复代码多、维护成本高
注解+反射
一行代码搞定、易扩展
反射有轻微性能开销(导出场景可接受)
JSON Schema
灵活
过于复杂,不适合简单导出

我们选择 注解+反射,这是企业级项目中最实用、最易维护的方案。

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篇的区别

  对比项
第01篇
第03篇
  适用场景
百万级大数据量导出
常规数据导出(< 10w)
  特点
流式、分页、极致性能
注解驱动、一行代码、易维护
  代码量
每个导出需单独实现
新增导出仅需加注解
  灵活性
较高
极高

五、总结与思考

核心三句话:

  1. 90% 的导出代码都是重复的,用注解+反射消除重复。

  2. 一行代码 ExcelExportUtil.export(response, list, "文件名") 搞定任何导出。

  3. 新增导出只需在实体类加 @ExcelExport 注解,零额外工作量。

踩坑 Checklist:

  • ✅ 反射读取字段时记得 setAccessible(true)。

  • ✅ 字段配置使用缓存,避免重复反射影响性能。

  • ✅ 响应头中的中文文件名需要 URL 编码(URLEncoder.encode)。

  • ✅ 大数据量导出时,仍建议配合第01篇的分页查询方案。

下一篇预告:海量数据CSV压缩导出与异步任务下载——当 Excel 撑不下时,CSV + GZIP 才是终极方案。

相关学习资料