Excel 数据导出实战指南

Excel 数据导出实战指南

一、概述

Excel 导出是后台管理系统中的标配功能,用于将系统数据导出为 Excel 文件供用户下载、分析或归档。一个完善的导出功能需要考虑:查询条件过滤、数据补充(远程服务)、Excel 生成、文件上传、大数据量性能等环节。

本文以一个"员工信息导出"场景为例,系统介绍 Excel 导出的完整实现流程。


二、整体流程

  1. 前端提交导出请求(携带筛选条件)
  2. Controller 接收请求,委托 Service
  3. Service 查询数据(不分页,按条件查全部)
  4. 补充远程数据(Feign 调用)
  5. 枚举翻译、格式化
  6. 使用 POI 生成 Excel 文件
  7. 写入本地临时文件
  8. 上传到 OSS
  9. 删除临时文件
  10. 返回 OSS 下载 URL

三、技术选型

3.1 Excel 生成方式对比

方式 文件格式 最大行数 内存占用 适用场景
XSSFWorkbook poi-ooxml .xlsx 104万行 高(全部在内存) 数据量 < 5万
SXSSFWorkbook poi-ooxml .xlsx 104万行 低(流式写入) 数据量 5万~100万
HSSFWorkbook poi .xls 6.5万行 兼容旧版 Excel
EasyExcel alibaba .xlsx 104万行 极低 大数据量首选

3.2 文件交付方式对比

方式 实现 优点 缺点 适用场景
OSS 上传返回 URL 生成文件 → 上传 → 返回链接 不占用接口连接时间 依赖 OSS 服务 生产环境推荐
直接流式下载 HttpServletResponse 输出流 无需 OSS 大文件可能超时 小数据量/内网
异步导出 + 通知 MQ 异步生成 → 通知用户下载 不阻塞用户 实现复杂 超大数据量

注:

博客:

https://blog.csdn.net/badao_liumang_qizhi

四、完整实现

4.1 API 接口定义

java 复制代码
@Tag(name = "员工管理")
@RestController
@RequestMapping("/api/page/employee")
public interface EmployeePageApi {

    @Operation(summary = "员工信息导出")
    @PostMapping("/export-employee")
    RestControllerResult<String> exportEmployee(
            @RequestBody EmployeeQueryParamsDto paramsDto);
}

4.2 Controller 实现

java 复制代码
@Slf4j
@RestController
public class EmployeePageApiController implements EmployeePageApi {

    @Resource
    private EmployeeService employeeService;

    @Override
    public RestControllerResult<String> exportEmployee(
            @RequestBody EmployeeQueryParamsDto paramsDto) {
        RestControllerResult<String> result = new RestControllerResult<>();
        result.setSuccess(Boolean.TRUE);
        result.setData(employeeService.exportEmployee(paramsDto));
        return result;
    }
}

4.3 Service 实现

java 复制代码
@Slf4j
@Service
public class EmployeeServiceImpl implements EmployeeService {

    @Resource
    private EmployeeMapper employeeMapper;

    @Resource
    private DepartmentFeign departmentFeign;

    @Resource
    private AliOssTemplate aliOssTemplate;

    @Override
    public String exportEmployee(EmployeeQueryParamsDto paramsDto) {
        // 1. 查询数据(不分页)
        List<EmployeeExportDto> dataList = employeeMapper.listEmployeeForExport(paramsDto);

        // 2. 数据补充(Feign 远程调用)
        if (dataList != null && !dataList.isEmpty()) {
            enrichData(dataList);
        }

        // 3. 生成 Excel 并上传 OSS
        return generateAndUploadExcel(dataList);
    }

    /**
     * 补充远程数据 + 枚举翻译.
     */
    private void enrichData(List<EmployeeExportDto> dataList) {
        dataList.forEach(dto -> {
            // 补充部门名称(Feign 调用)
            if (StringUtils.isNotBlank(dto.getDeptCode())) {
                try {
                    RestControllerResult<DeptInfoDto> deptResult =
                            departmentFeign.getDeptByCode(dto.getDeptCode());
                    if (deptResult != null && Boolean.TRUE.equals(deptResult.getSuccess())
                            && deptResult.getData() != null) {
                        dto.setDeptName(deptResult.getData().getDeptName());
                    }
                } catch (Exception e) {
                    log.warn("查询部门信息失败, deptCode={}", dto.getDeptCode());
                }
            }

            // 枚举翻译:状态
            if (dto.getStatus() != null) {
                dto.setStatusName(EmployeeStatusEnum.getNameByCode(dto.getStatus()));
            }

            // 格式化:操作人
            if (StringUtils.isNotBlank(dto.getStaffNo())
                    && StringUtils.isNotBlank(dto.getOperatorName())) {
                dto.setOperatorDisplay(dto.getStaffNo() + " " + dto.getOperatorName());
            }
        });
    }

    /**
     * 生成 Excel 文件并上传 OSS.
     */
    private String generateAndUploadExcel(List<EmployeeExportDto> dataList) {
        String url = "";
        XSSFWorkbook workbook = null;
        File file = null;

        try {
            workbook = new XSSFWorkbook();
            Sheet sheet = workbook.createSheet("员工信息");

            // 1. 创建表头
            createHeader(workbook, sheet);

            // 2. 填充数据
            fillData(sheet, dataList);

            // 3. 设置列宽(可选)
            setColumnWidth(sheet);

            // 4. 写入临时文件
            file = writeToTempFile(workbook);

            // 5. 上传 OSS
            url = aliOssTemplate.uploadFile(file);

        } catch (Exception e) {
            log.error("导出Excel失败", e);
            throw new JshCheckException("导出失败,请稍后重试");
        } finally {
            // 6. 关闭资源
            closeWorkbook(workbook);
            // 7. 删除临时文件
            deleteTempFile(file);
        }

        return url;
    }

    /**
     * 创建表头.
     */
    private void createHeader(XSSFWorkbook workbook, Sheet sheet) {
        // 表头样式
        Font fontStyle = workbook.createFont();
        fontStyle.setBold(true);
        fontStyle.setFontHeightInPoints((short) 11);
        CellStyle headerStyle = workbook.createCellStyle();
        headerStyle.setFont(fontStyle);
        headerStyle.setAlignment(HorizontalAlignment.CENTER);

        // 表头内容
        String[] headers = {"工号", "姓名", "部门", "手机号", "状态", "操作人", "创建时间"};
        Row headerRow = sheet.createRow(0);
        for (int i = 0; i < headers.length; i++) {
            Cell cell = headerRow.createCell(i);
            cell.setCellValue(headers[i]);
            cell.setCellStyle(headerStyle);
        }
    }

    /**
     * 填充数据行.
     */
    private void fillData(Sheet sheet, List<EmployeeExportDto> dataList) {
        if (dataList == null || dataList.isEmpty()) {
            return;
        }
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        for (int i = 0; i < dataList.size(); i++) {
            EmployeeExportDto dto = dataList.get(i);
            Row row = sheet.createRow(i + 1);
            row.createCell(0).setCellValue(nullToEmpty(dto.getStaffNo()));
            row.createCell(1).setCellValue(nullToEmpty(dto.getStaffName()));
            row.createCell(2).setCellValue(nullToEmpty(dto.getDeptName()));
            row.createCell(3).setCellValue(nullToEmpty(dto.getPhone()));
            row.createCell(4).setCellValue(nullToEmpty(dto.getStatusName()));
            row.createCell(5).setCellValue(nullToEmpty(dto.getOperatorDisplay()));
            row.createCell(6).setCellValue(
                    dto.getCreateTime() != null ? sdf.format(dto.getCreateTime()) : "");
        }
    }

    /**
     * 设置列宽.
     */
    private void setColumnWidth(Sheet sheet) {
        sheet.setColumnWidth(0, 4000);  // 工号
        sheet.setColumnWidth(1, 4000);  // 姓名
        sheet.setColumnWidth(2, 6000);  // 部门
        sheet.setColumnWidth(3, 5000);  // 手机号
        sheet.setColumnWidth(4, 3000);  // 状态
        sheet.setColumnWidth(5, 6000);  // 操作人
        sheet.setColumnWidth(6, 6000);  // 创建时间
    }

    /**
     * 写入临时文件.
     */
    private File writeToTempFile(XSSFWorkbook workbook) throws IOException {
        SimpleDateFormat dateFmt = new SimpleDateFormat("yyyyMMdd");
        String dateStr = dateFmt.format(new Date());
        int randomNum = new Random().nextInt(9000) + 1000;
        String fileName = "员工信息导出-" + dateStr + randomNum + ".xlsx";
        String tempPath = System.getProperty("java.io.tmpdir") + File.separator + fileName;
        File file = new File(tempPath);
        try (FileOutputStream fos = new FileOutputStream(file)) {
            workbook.write(fos);
        }
        return file;
    }

    /**
     * 关闭 Workbook.
     */
    private void closeWorkbook(XSSFWorkbook workbook) {
        if (workbook != null) {
            try {
                workbook.close();
            } catch (IOException e) {
                log.warn("关闭Workbook失败", e);
            }
        }
    }

    /**
     * 删除临时文件.
     */
    private void deleteTempFile(File file) {
        if (file != null && file.exists()) {
            try {
                file.delete();
            } catch (Exception e) {
                log.warn("删除临时文件失败: {}", file.getAbsolutePath());
            }
        }
    }

    private String nullToEmpty(String value) {
        return value != null ? value : "";
    }
}

4.4 Mapper(不分页查询)

java 复制代码
public interface EmployeeMapper {

    List<EmployeeExportDto> listEmployeeForExport(
            @Param("param") EmployeeQueryParamsDto param);
}
<select id="listEmployeeForExport"
    resultType="com.example.dto.EmployeeExportDto">
    SELECT
        e.staff_no AS staffNo,
        e.staff_name AS staffName,
        e.dept_code AS deptCode,
        e.phone AS phone,
        e.status AS status,
        e.create_time AS createTime,
        e.create_user_id AS createUserId
    FROM employee e
    <where>
        <if test="param.staffName != null and param.staffName != ''">
            AND e.staff_name LIKE CONCAT('%', #{param.staffName}, '%')
        </if>
        <if test="param.deptCode != null and param.deptCode != ''">
            AND e.dept_code = #{param.deptCode}
        </if>
        <if test="param.status != null">
            AND e.status = #{param.status}
        </if>
        <if test="param.createTimeStart != null and param.createTimeStart != ''">
            AND e.create_time &gt;= #{param.createTimeStart}
        </if>
        <if test="param.createTimeEnd != null and param.createTimeEnd != ''">
            AND e.create_time <= #{param.createTimeEnd}
        </if>
    </where>
    ORDER BY e.id DESC
</select>

五、文件命名规范

5.1 命名规则

复制代码
{业务名称}-{日期}{随机数}.xlsx

5.2 实现方式

java 复制代码
SimpleDateFormat dateFmt = new SimpleDateFormat("yyyyMMdd");
String dateStr = dateFmt.format(new Date());
int randomNum = new Random().nextInt(9000) + 1000; // 4位随机数
String fileName = "员工信息导出-" + dateStr + randomNum + ".xlsx";
// 结果示例:员工信息导出-202605291234.xlsx

5.3 为什么加随机数

  • 避免同一秒内多次导出文件名冲突
  • OSS 上传时如果文件名相同会覆盖

六、大数据量导出优化

6.1 使用 SXSSFWorkbook(流式写入)

java 复制代码
// XSSFWorkbook:所有数据在内存中,10万行可能占用 1GB+
// SXSSFWorkbook:只保留最近 N 行在内存中,其余写入临时文件

SXSSFWorkbook workbook = new SXSSFWorkbook(100); // 内存中保留100行
Sheet sheet = workbook.createSheet("数据");

// 使用方式与 XSSFWorkbook 完全一致
for (int i = 0; i < dataList.size(); i++) {
    Row row = sheet.createRow(i + 1);
    row.createCell(0).setCellValue(dataList.get(i).getName());
}

// 写入文件
workbook.write(outputStream);
workbook.dispose(); // 清理临时文件(SXSSFWorkbook 特有)

6.2 分页查询 + 流式写入

java 复制代码
// 避免一次性加载全部数据到内存
int pageSize = 5000;
int pageNum = 1;
int rowIndex = 1;

while (true) {
    List<EmployeeExportDto> pageData = employeeMapper.listEmployeePager(
            paramsDto, pageSize, pageNum);
    if (pageData == null || pageData.isEmpty()) {
        break;
    }
    for (EmployeeExportDto dto : pageData) {
        Row row = sheet.createRow(rowIndex++);
        fillRow(row, dto);
    }
    pageNum++;
}

6.3 数据量与方案选择

数据量 推荐方案 说明
< 1万 XSSFWorkbook + 同步 简单直接
1万~10万 SXSSFWorkbook + 同步 流式写入控制内存
10万~50万 SXSSFWorkbook + 分页查询 避免一次性加载
> 50万 异步导出 + MQ + 通知 避免接口超时

七、Feign 数据补充优化

7.1 逐条调用(简单但慢)

java 复制代码
// 每条数据调用一次 Feign,N 条数据 = N 次网络请求
dataList.forEach(dto -> {
    DeptInfoDto dept = departmentFeign.getDeptByCode(dto.getDeptCode());
    dto.setDeptName(dept.getDeptName());
});

7.2 批量查询 + Map 映射(推荐)

java 复制代码
// 收集所有部门编码,一次性查询
List<String> deptCodes = dataList.stream()
        .map(EmployeeExportDto::getDeptCode)
        .filter(StringUtils::isNotBlank)
        .distinct()
        .collect(Collectors.toList());

// 一次 Feign 调用
Map<String, String> deptMap = departmentFeign.batchGetDeptNames(deptCodes);

// 遍历赋值
dataList.forEach(dto -> {
    if (deptMap.containsKey(dto.getDeptCode())) {
        dto.setDeptName(deptMap.get(dto.getDeptCode()));
    }
});

7.3 无批量接口时的折中

java 复制代码
// 加 try-catch,单条失败不影响整体导出
dataList.forEach(dto -> {
    try {
        // Feign 调用
    } catch (Exception e) {
        log.warn("补充数据失败, id={}", dto.getId());
        // 该字段留空,不影响导出
    }
});

八、异常处理

异常场景 处理方式
查询数据为空 生成只有表头的空 Excel(或提示"无数据")
Feign 调用失败 该字段留空,不影响导出
Excel 生成异常 抛出业务异常"导出失败,请稍后重试"
OSS 上传失败 抛出业务异常"文件上传失败"
临时文件删除失败 仅记录 warn 日志,不影响返回

九、资源管理

9.1 try-finally 模式

java 复制代码
XSSFWorkbook workbook = null;
File file = null;
try {
    workbook = new XSSFWorkbook();
    // ... 生成 Excel ...
    file = writeToTempFile(workbook);
    url = aliOssTemplate.uploadFile(file);
} catch (Exception e) {
    throw new JshCheckException("导出失败");
} finally {
    // 确保资源释放
    if (workbook != null) {
        try { workbook.close(); } catch (Exception ignored) {}
    }
    if (file != null && file.exists()) {
        file.delete();
    }
}

9.2 try-with-resources(FileOutputStream)

java 复制代码
// FileOutputStream 用 try-with-resources 自动关闭
try (FileOutputStream fos = new FileOutputStream(file)) {
    workbook.write(fos);
}

十、导出与列表查询的关系

维度 列表查询 导出
分页 有(pageNum/pageSize) 无(查全部)
筛选条件 相同 相同
返回格式 JSON Excel 文件 URL
Feign 补充 当前页数据 全部数据
SQL 带分页参数 不带分页参数

建议 :Mapper 中定义两个方法,一个带分页(列表用),一个不带分页(导出用),SQL 条件部分可以用 <sql> 片段复用。

xml 复制代码
<sql id="queryCondition">
    <if test="param.staffName != null and param.staffName != ''">
        AND e.staff_name LIKE CONCAT('%', #{param.staffName}, '%')
    </if>
    <!-- 其他条件 -->
</sql>

<select id="listEmployeePager">
    SELECT ... FROM employee e
    <where><include refid="queryCondition"/></where>
    ORDER BY e.id DESC
</select>

<select id="listEmployeeForExport">
    SELECT ... FROM employee e
    <where><include refid="queryCondition"/></where>
    ORDER BY e.id DESC
</select>

十一、最佳实践清单

  1. 导出 SQL 不带分页:与列表查询共用筛选条件,但不限制条数
  2. Feign 补充加 try-catch:单条失败不影响整体导出
  3. 优先批量查询:有批量接口时一次性获取,避免 N+1
  4. 临时文件及时清理:finally 中删除,避免磁盘堆积
  5. Workbook 及时关闭:避免内存泄漏
  6. 文件名加随机数:避免并发导出时文件名冲突
  7. 大数据量用 SXSSFWorkbook:流式写入控制内存
  8. 设置合理列宽:提升用户体验
  9. 日期格式化:统一使用 yyyy-MM-dd HH:mm:ss
  10. 空数据处理:null 转空字符串,避免 Excel 中显示 "null"
相关推荐
布局呆星1 小时前
HTML+fastAPI+Dify|打通前后端至智能体的路
状态模式
雨季mo浅忆2 小时前
记录利用Cursor快速实现Excel共享编辑
前端·excel
霸道流氓气质3 小时前
批量异步处理 + MQ + Redis 进度追踪实战指南
数据库·redis·状态模式
会编程的土豆3 小时前
前端和后端是怎么配合工作的(Go后端视角)
前端·golang·状态模式
前端不太难4 小时前
鸿蒙游戏 HUD 如何设计?
游戏·状态模式·harmonyos
神奇的代码在哪里4 小时前
【单机离线版】excel转json软件,纯HTML+JS零依赖实现Excel转JSON工具,一个index.html搞定所有转换!
html·json·excel·excel转json·xlsx转json·xls转json
俏皮小混子1 天前
山东大学软件学院项目实训-创新实训-计科智伴(五)——个人博客(从接口对接到边界问题修复的完整记录)
笔记·学习·状态模式·山东大学
前端不太难1 天前
从语言生成到世界交互:AGI的具身化演进之路
状态模式·交互·agi
前端不太难1 天前
具身智能:下一代人工智能的产业新范式
人工智能·状态模式