Spring Boot 3 + EasyExcel 3.x 实战:构建高效、可靠的Excel导入导出服务

目录

一、前言:为什么选择EasyExcel?

二、项目初始化与依赖引入

[2.1 开发环境](#2.1 开发环境)

[2.2 Maven依赖配置 (pom.xml)](#2.2 Maven依赖配置 (pom.xml))

三、定义核心数据模型:学生信息实体

[3.1 实体类代码](#3.1 实体类代码)

[3.2 注解详解](#3.2 注解详解)

四、实现Excel监听器:核心逻辑处理器

[4.1 监听器设计要点](#4.1 监听器设计要点)

五、构建Service层:业务逻辑与校验

[5.1 服务接口](#5.1 服务接口)

[5.2 服务实现类](#5.2 服务实现类)

六、Excel导入响应对象与异常处理

[6.1 自定义异常类](#6.1 自定义异常类)

[6.2 统一响应对象](#6.2 统一响应对象)

[七、打造Controller层:提供RESTful API](#七、打造Controller层:提供RESTful API)

[7.1 完整Controller代码](#7.1 完整Controller代码)

八、全局异常处理

九、配置文件 (application.yml)

十、测试与验证

[10.1 使用Postman测试导入](#10.1 使用Postman测试导入)

[10.2 测试导出](#10.2 测试导出)

十一、性能优化建议

十二、总结


一、前言:为什么选择EasyExcel?

在企业管理系统中,Excel导入导出是永恒的需求------报表生成、数据迁移、批量录入...然而,传统的Apache POI在处理大文件时,会将全部数据加载到内存中,一个几十MB的Excel就可能消耗几百MB甚至GB的内存,极易引发OOM。

阿里巴巴开源的EasyExcel 解决了这一痛点。它采用SAX解析模式,一行一行读取数据,内存占用极低。即使文件有几十万行数据,也能轻松应对。本文将带你从零开始,在Spring Boot 3项目中集成EasyExcel 3.x,实现一套结构清晰、健壮可靠的Excel导入导出功能。

最终成果预览:

  • 支持带数据校验的Excel导入,错误信息逐行反馈

  • 支持百万级数据的Excel导出,内存安全

  • 包含完整的异常处理机制

二、项目初始化与依赖引入

2.1 开发环境

  • JDK 17+

  • Spring Boot 3.2.0

  • EasyExcel 3.3.2

2.2 Maven依赖配置 (pom.xml)

XML 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="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-demo</artifactId>
    <version>1.0.0</version>
    
    <properties>
        <java.version>17</java.version>
        <easyexcel.version>3.3.2</easyexcel.version>
    </properties>
    
    <dependencies>
        <!-- Spring Boot Web 基础依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <!-- EasyExcel 核心库 -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>easyexcel</artifactId>
            <version>${easyexcel.version}</version>
        </dependency>
        
        <!-- Lombok 简化代码 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        
        <!-- Bean Validation 用于数据校验 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        
        <!-- 可选:数据库操作 (本文以MyBatis-Plus为例) -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.3.1</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.33</version>
            <scope>runtime</scope>
        </dependency>
    </dependencies>
</project>

依赖说明:

  • spring-boot-starter-web: 提供RESTful API能力

  • easyexcel: 核心Excel处理库

  • lombok: 简化实体类代码

  • validation: 提供数据校验注解(如@NotBlank、@Email等)

  • mybatis-plus: 可选,用于数据持久化演示

三、定义核心数据模型:学生信息实体

3.1 实体类代码

java 复制代码
package com.example.exceldemo.entity;

import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.format.DateTimeFormat;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.alibaba.excel.annotation.write.style.ContentStyle;
import com.alibaba.excel.enums.poi.HorizontalAlignmentEnum;
import jakarta.validation.constraints.*;
import lombok.Data;

import java.util.Date;

/**
 * 学生信息实体
 * 使用EasyExcel注解控制Excel列映射和样式
 */
@Data
public class Student {
    
    @ExcelProperty("学生ID")
    @ColumnWidth(10)
    private Long id;
    
    @NotBlank(message = "姓名不能为空")
    @Size(max = 20, message = "姓名长度不能超过20")
    @ExcelProperty("姓名")
    @ColumnWidth(15)
    private String name;
    
    @NotNull(message = "年龄不能为空")
    @Min(value = 1, message = "年龄最小为1岁")
    @Max(value = 150, message = "年龄最大为150岁")
    @ExcelProperty("年龄")
    @ColumnWidth(10)
    @ContentStyle(horizontalAlignment = HorizontalAlignmentEnum.CENTER)
    private Integer age;
    
    @NotBlank(message = "邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    @ExcelProperty("邮箱")
    @ColumnWidth(25)
    private String email;
    
    @NotNull(message = "入学日期不能为空")
    @Past(message = "入学日期必须是过去时间")
    @DateTimeFormat("yyyy-MM-dd")
    @ExcelProperty("入学日期")
    @ColumnWidth(15)
    private Date enrollmentDate;
    
    @ExcelProperty("班级")
    @ColumnWidth(15)
    private String className;
}

3.2 注解详解

注解 作用 使用场景
@ExcelProperty 指定Excel列头名称,可配合index属性指定列顺序 必须,用于映射Java字段和Excel列
@ColumnWidth 设置Excel列宽 导出时控制显示效果
@ContentStyle 设置单元格样式(对齐、字体等) 导出时美化表格
@DateTimeFormat 日期格式化,如"yyyy-MM-dd HH:mm:ss" 处理日期类型字段
@NumberFormat 数字格式化,如"#.##%" 处理百分比等格式

四、实现Excel监听器:核心逻辑处理器

EasyExcel通过监听器逐行读取数据,我们需要继承AnalysisEventListener并重写核心方法。

java 复制代码
package com.example.exceldemo.listener;

import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.read.listener.ReadListener;
import com.alibaba.excel.util.ListUtils;
import com.example.exceldemo.entity.Student;
import com.example.exceldemo.service.StudentService;
import com.example.exceldemo.exception.ExcelImportException;
import lombok.extern.slf4j.Slf4j;

import java.util.ArrayList;
import java.util.List;

/**
 * 学生数据导入监听器
 * 核心特性:批量处理 + 错误收集
 */
@Slf4j
public class StudentDataListener implements ReadListener<Student> {
    
    /**
     * 批量处理阈值:每100条存入数据库
     */
    private static final int BATCH_COUNT = 100;
    
    /**
     * 缓存数据列表
     */
    private List<Student> cachedDataList = ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);
    
    /**
     * 错误信息收集
     */
    private List<String> errorMessages = new ArrayList<>();
    
    /**
     * 总行数统计
     */
    private int totalRows = 0;
    
    /**
     * 业务服务(通过构造方法注入)
     */
    private final StudentService studentService;
    
    /**
     * 构造方法传入Service,解决Spring Bean注入问题
     */
    public StudentDataListener(StudentService studentService) {
        this.studentService = studentService;
    }
    
    /**
     * 每解析一行数据,都会调用一次此方法
     */
    @Override
    public void invoke(Student student, AnalysisContext context) {
        totalRows++;
        log.info("解析第{}行数据:{}", totalRows, student);
        
        // 1. 数据校验(可在此处做复杂业务校验)
        try {
            // 调用服务层进行校验(包含JSR-380校验)
            studentService.validateAndProcess(student);
            
            // 校验通过,加入缓存
            cachedDataList.add(student);
            
        } catch (Exception e) {
            // 收集错误信息:记录是哪一行出错了
            String errorMsg = String.format("第%d行校验失败:%s", 
                totalRows + 1, // +1是因为Excel第一行是表头
                e.getMessage()
            );
            errorMessages.add(errorMsg);
            log.warn(errorMsg);
        }
        
        // 2. 达到批量处理阈值,存入数据库
        if (cachedDataList.size() >= BATCH_COUNT) {
            saveData();
            cachedDataList = ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);
        }
    }
    
    /**
     * 所有数据解析完成后调用
     */
    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {
        // 处理剩余数据
        saveData();
        log.info("所有数据解析完成,共{}行,成功{}行,失败{}行", 
            totalRows, 
            totalRows - errorMessages.size(), 
            errorMessages.size()
        );
        
        // 如果有错误,抛出异常携带错误信息
        if (!errorMessages.isEmpty()) {
            throw new ExcelImportException(errorMessages, totalRows);
        }
    }
    
    /**
     * 批量保存数据到数据库
     */
    private void saveData() {
        if (!cachedDataList.isEmpty()) {
            log.info("批量保存{}条数据到数据库", cachedDataList.size());
            studentService.saveBatch(cachedDataList);
        }
    }
}

4.1 监听器设计要点

  1. 批量处理 :通过BATCH_COUNT控制每批处理数量,避免数据积压导致OOM

  2. 错误收集:将校验失败的信息收集起来,解析完成后统一抛出,而不是中断导入

  3. Service注入 :通过构造方法传入Service,解决监听器无法使用@Autowired的问题

  4. 行号记录 :利用totalRows记录当前处理行数,方便定位错误位置

五、构建Service层:业务逻辑与校验

5.1 服务接口

java 复制代码
package com.example.exceldemo.service;

import com.example.exceldemo.entity.Student;
import java.util.List;

public interface StudentService {
    
    /**
     * 校验并处理单条学生数据
     * @param student 学生数据
     * @throws IllegalArgumentException 校验失败时抛出异常
     */
    void validateAndProcess(Student student);
    
    /**
     * 批量保存学生数据
     * @param students 学生列表
     */
    void saveBatch(List<Student> students);
    
    /**
     * 查询所有学生(用于导出)
     */
    List<Student> listAll();
}

5.2 服务实现类

java 复制代码
package com.example.exceldemo.service.impl;

import com.example.exceldemo.entity.Student;
import com.example.exceldemo.service.StudentService;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class StudentServiceImpl implements StudentService {
    
    // 使用JSR-380 Validator进行声明式校验
    private final Validator validator;
    
    // 模拟数据库操作(实际项目中注入Mapper)
    // private final StudentMapper studentMapper;
    
    @Override
    public void validateAndProcess(Student student) {
        // 使用Validator进行JSR-380校验
        Set<ConstraintViolation<Student>> violations = validator.validate(student);
        
        if (!violations.isEmpty()) {
            String errorMsg = violations.stream()
                .map(violation -> violation.getPropertyPath() + " " + violation.getMessage())
                .collect(Collectors.joining("; "));
            throw new IllegalArgumentException(errorMsg);
        }
        
        // 可以在此处添加更多业务校验,如:检查邮箱是否已存在
        // if (studentMapper.existsByEmail(student.getEmail())) {
        //     throw new IllegalArgumentException("邮箱已存在");
        // }
    }
    
    @Override
    public void saveBatch(List<Student> students) {
        // 批量插入数据库
        // studentMapper.insertBatch(students);
        log.info("批量保存{}条数据", students.size());
        // 模拟保存成功
    }
    
    @Override
    public List<Student> listAll() {
        // return studentMapper.selectList(null);
        // 模拟数据
        return List.of();
    }
}

六、Excel导入响应对象与异常处理

6.1 自定义异常类

java 复制代码
package com.example.exceldemo.exception;

import lombok.Getter;
import java.util.List;

/**
 * Excel导入异常
 * 封装导入过程中的错误信息和统计信息
 */
@Getter
public class ExcelImportException extends RuntimeException {
    
    private final List<String> errors;      // 错误详情列表
    private final int totalRows;             // 总行数
    private final int successRows;           // 成功行数
    private final int failedRows;             // 失败行数
    
    public ExcelImportException(List<String> errors, int totalRows) {
        super("Excel导入完成,但有部分数据校验失败,请查看详细信息");
        this.errors = errors;
        this.totalRows = totalRows;
        this.successRows = totalRows - errors.size();
        this.failedRows = errors.size();
    }
    
    /**
     * 获取简化的错误摘要信息
     */
    public String getErrorSummary() {
        if (errors.isEmpty()) {
            return "无错误";
        }
        StringBuilder summary = new StringBuilder();
        summary.append("共").append(errors.size()).append("处错误:");
        errors.forEach(msg -> summary.append(msg).append("; "));
        return summary.toString();
    }
}

6.2 统一响应对象

java 复制代码
package com.example.exceldemo.vo;

import lombok.Data;
import lombok.Builder;

@Data
@Builder
public class Result<T> {
    private Integer code;
    private String message;
    private T data;
    
    public static <T> Result<T> success(T data) {
        return Result.<T>builder()
            .code(200)
            .message("操作成功")
            .data(data)
            .build();
    }
    
    public static <T> Result<T> error(String message) {
        return Result.<T>builder()
            .code(500)
            .message(message)
            .build();
    }
    
    public static <T> Result<T> error(Integer code, String message) {
        return Result.<T>builder()
            .code(code)
            .message(message)
            .build();
    }
}

七、打造Controller层:提供RESTful API

7.1 完整Controller代码

java 复制代码
package com.example.exceldemo.controller;

import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.support.ExcelTypeEnum;
import com.example.exceldemo.entity.Student;
import com.example.exceldemo.exception.ExcelImportException;
import com.example.exceldemo.listener.StudentDataListener;
import com.example.exceldemo.service.StudentService;
import com.example.exceldemo.vo.Result;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Slf4j
@RestController
@RequestMapping("/api/students")
@RequiredArgsConstructor
public class StudentController {
    
    private final StudentService studentService;
    
    /**
     * Excel导入接口
     * @param file Excel文件
     * @return 导入结果(包含成功/失败统计)
     */
    @PostMapping("/import")
    public Result<Map<String, Object>> importExcel(@RequestParam("file") MultipartFile file) {
        try {
            // 创建监听器(传入Service)
            StudentDataListener listener = new StudentDataListener(studentService);
            
            // 执行读取
            EasyExcel.read(file.getInputStream(), Student.class, listener)
                .excelType(ExcelTypeEnum.XLSX)  // 指定Excel类型
                .sheet()                         // 读取第一个sheet
                .headRowNumber(1)                 // 表头行数(默认第一行是表头)
                .doRead();
            
            // 如果没有异常,说明全部成功
            return Result.success(Map.of(
                "totalRows", listener.getTotalRows(),
                "successRows", listener.getTotalRows(),
                "failedRows", 0,
                "errors", List.of()
            ));
            
        } catch (ExcelImportException e) {
            // 部分数据校验失败
            Map<String, Object> result = new HashMap<>();
            result.put("totalRows", e.getTotalRows());
            result.put("successRows", e.getSuccessRows());
            result.put("failedRows", e.getFailedRows());
            result.put("errors", e.getErrors());
            return Result.error(400, "导入完成,但有" + e.getFailedRows() + "条数据校验失败")
                .withData(result);
                
        } catch (Exception e) {
            log.error("Excel导入失败", e);
            return Result.error("导入失败:" + e.getMessage());
        }
    }
    
    /**
     * Excel导出接口
     * @param response HTTP响应
     */
    @GetMapping("/export")
    public void exportExcel(HttpServletResponse response) throws IOException {
        try {
            // 1. 设置响应头
            response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
            response.setCharacterEncoding("utf-8");
            String fileName = URLEncoder.encode("学生数据", StandardCharsets.UTF_8)
                .replaceAll("\\+", "%20");
            response.setHeader("Content-disposition", 
                "attachment;filename*=utf-8''" + fileName + ".xlsx");
            
            // 2. 查询数据
            List<Student> studentList = studentService.listAll();
            
            // 3. 写入Excel
            EasyExcel.write(response.getOutputStream(), Student.class)
                .excelType(ExcelTypeEnum.XLSX)
                .sheet("学生信息")
                .doWrite(studentList);
                
        } catch (Exception e) {
            log.error("Excel导出失败", e);
            // 重置response,避免返回乱码
            response.reset();
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().println(Result.error("导出失败:" + e.getMessage()));
        }
    }
    
    /**
     * 模板下载接口
     * @param response HTTP响应
     */
    @GetMapping("/template")
    public void downloadTemplate(HttpServletResponse response) throws IOException {
        try {
            response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
            response.setCharacterEncoding("utf-8");
            String fileName = URLEncoder.encode("学生导入模板", StandardCharsets.UTF_8)
                .replaceAll("\\+", "%20");
            response.setHeader("Content-disposition", 
                "attachment;filename*=utf-8''" + fileName + ".xlsx");
            
            // 创建一个空模板(只有表头)
            EasyExcel.write(response.getOutputStream(), Student.class)
                .excelType(ExcelTypeEnum.XLSX)
                .sheet("模板")
                .doWrite(List.of());  // 写入空数据,只生成表头
                
        } catch (Exception e) {
            log.error("模板下载失败", e);
            response.reset();
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().println(Result.error("模板下载失败:" + e.getMessage()));
        }
    }
}

八、全局异常处理

java 复制代码
package com.example.exceldemo.handler;

import com.example.exceldemo.exception.ExcelImportException;
import com.example.exceldemo.vo.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(ExcelImportException.class)
    public Result<?> handleExcelImportException(ExcelImportException e) {
        log.warn("Excel导入异常:{}", e.getErrorSummary());
        return Result.error(400, e.getMessage())
            .withData(Map.of(
                "totalRows", e.getTotalRows(),
                "successRows", e.getSuccessRows(),
                "failedRows", e.getFailedRows(),
                "errors", e.getErrors()
            ));
    }
    
    @ExceptionHandler(Exception.class)
    public Result<?> handleException(Exception e) {
        log.error("系统异常", e);
        return Result.error("系统繁忙,请稍后重试");
    }
}

九、配置文件 (application.yml)

XML 复制代码
spring:
  application:
    name: excel-demo
  servlet:
    multipart:
      max-file-size: 50MB
      max-request-size: 50MB
  datasource:
    url: jdbc:mysql://localhost:3306/excel_demo?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver

# 日志配置
logging:
  level:
    com.example.exceldemo: debug
    com.alibaba.excel: warn

十、测试与验证

10.1 使用Postman测试导入

请求参数:

  • URL: http://localhost:8080/api/students/import

  • Method: POST

  • Body: form-data

    • Key: file

    • Value: 选择Excel文件

测试数据示例 (Excel内容):

姓名 年龄 邮箱 入学日期 班级
张三 20 zhangsan@example.com 2023-09-01 计算机1班
李四 25 lisi@example.com 2022-09-01 计算机2班
王五 17 wangwu@example.com 2024-09-01 计算机1班

成功响应示例:

java 复制代码
{
    "code": 200,
    "message": "操作成功",
    "data": {
        "totalRows": 3,
        "successRows": 3,
        "failedRows": 0,
        "errors": []
    }
}

失败响应示例(年龄校验失败):

java 复制代码
{
    "code": 400,
    "message": "导入完成,但有1条数据校验失败",
    "data": {
        "totalRows": 3,
        "successRows": 2,
        "failedRows": 1,
        "errors": [
            "第4行校验失败:age 年龄最小为1岁"
        ]
    }
}

10.2 测试导出

访问:http://localhost:8080/api/students/export

浏览器会自动下载名为"学生数据.xlsx"的文件。

相关推荐
EatFan7 分钟前
JunoYi 框架实践:Spring Boot 项目为什么拆成 framework、module、server 三层?
java·spring boot·后端·framework·module·模块化·junoyi
汉堡大王952712 分钟前
一张图三句需求,我用 Trae Work 做了一块能看日出日落和月相的天文机械表
前端·后端·github
mudtools16 分钟前
在.NET现有系统中快速集成飞书任务分配能力
后端·c#·.net
知守观18 分钟前
ThreadLocal + 异步线程导致用户数据串号:一次跨请求数据泄漏的完整复盘
后端
前端冒菜师19 分钟前
我为什么做了 Iris,又为什么停下了它
后端·ai编程
子一!!21 分钟前
集成Spring家族的Spring论坛实战==一阶段
java·后端·spring
Ticnix21 分钟前
你的 Agent 聊到第 20 轮就"失忆"?你管理的是历史,高手管理的是上下文
后端·python·agent
不合格的程序员22 分钟前
Agent Memory架构设计与实现
后端·ai编程
用户938169125536028 分钟前
分页查询 Out of sort memory 问题
后端
xyLJ34 分钟前
为什么 Spring Boot 自动配置了 Redis,还要自己写 RedisTemplate?
后端