在一个项目中,特别是互联网项目,基础数据的录入是相当头疼的一件事;比如,定义产品、产品规格、产品规格颜色;想像一下,用户先创建一个产品,再为这个产品创建N个规格,再为N个规格创建不同的颜色;理论上是没有任何问题的,但实际上用户将非常痛苦;那么EasyExcel无疑是一个不错的选择;
那么,新的问题又来了,Excel纯文本内容导入,但相关产品图片却需要后期一个一个补进去,如果能连图片一并导入岂不是更好;EasyExcel仅支持文本读取,无法对EXCEL中的图片进行处理(把图片提取出来存到服务 器的指定位置);
本文我们将来讨论并实现它们。
实现 SpringBoot + EasyExcel导入功能
1.项目目录

2.pom文件
html
<?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 https://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.5.7</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>demo1</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo1</name>
<description>demo1</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- EasyExcel -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.3.2</version>
</dependency>
<!-- SpringDoc OpenAPI (Swagger) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</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>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
3.项目配置
html
spring:
application:
name: demo1
servlet:
multipart:
max-file-size: 1MB
max-request-size: 1MB
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
operations-sorter: alpha
tags-sorter: alpha
default-consumes-media-type: application/json
server:
port: 9999
4.实现代码
4.1 Excel映射类(用来与Excel对应)
java
package com.example.demo.entity;
import com.alibaba.excel.annotation.ExcelProperty;
import lombok.Data;
@Data
public class UserExcel {
@ExcelProperty("姓名")
private String name;
@ExcelProperty("年龄")
private Integer age;
@ExcelProperty("邮箱")
private String email;
@ExcelProperty("部门")
private String department;
}
4.2 服务类(直接向数据库中写入)
接口
java
package com.example.demo.service;
import com.example.demo.entity.UserExcel;
import java.util.List;
public interface ExcelDataService {
void save(List<UserExcel> dataList);
List<UserExcel> findAll();
}
实现
java
package com.example.demo.service.impl;
import com.example.demo.entity.UserExcel;
import com.example.demo.service.ExcelDataService;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ExcelDataServiceImpl implements ExcelDataService {
@Override
public void save(List<UserExcel> dataList) {
System.out.println("服务层保存数据: " + dataList);
}
@Override
public List<UserExcel> findAll() {
return List.of();
}
}
4.3 监听类(监听可以做来源数据进行过滤等操作)
java
package com.example.demo.entity;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.read.listener.ReadListener;
import com.example.demo.service.ExcelDataService;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
/**
* Excel监听器
*/
@Slf4j
public class UserExcelListener implements ReadListener<UserExcel> {
/**
* 每隔100条存储数据库,实际使用中可以3000条,然后清理list ,方便内存回收
*/
private static final int BATCH_COUNT = 100;
private final ExcelDataService excelDataService;
/**
* 缓存的数据
*/
private List<UserExcel> cachedDataList = new ArrayList<>(BATCH_COUNT);
public UserExcelListener(ExcelDataService excelDataService) {
this.excelDataService = excelDataService;
}
@Override
public void invoke(UserExcel userExcel, AnalysisContext analysisContext) {
System.out.println("读取到数据: " + userExcel);
log.info("读取到数据: {}", userExcel);
/**
* 缓存的数据 进行清洗
*/
if (cachedDataList.size()==0){
cachedDataList.add(userExcel);
}else {
//校验代码
cachedDataList.add(userExcel);
}
}
@Override
public void doAfterAllAnalysed(AnalysisContext analysisContext) {
log.info("读取完成");
excelDataService.save(cachedDataList);
System.out.println("读取完成");
//全部完成后把数据做提交处理
}
}
4.4 控制层
java
package com.example.demo.controller;
import com.alibaba.excel.EasyExcel;
import com.example.demo.entity.UserExcel;
import com.example.demo.entity.UserExcelListener;
import com.example.demo.service.ExcelDataService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@Slf4j
@RestController
@RequestMapping("/api/excel")
@Tag(name = "Excel文件处理", description = "Excel文件上传和下载相关接口")
public class UserController {
@Autowired
private ExcelDataService excelDataService;
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "上传Excel文件", description = "上传Excel文件并解析其中的数据")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "文件上传成功",
content = @Content(mediaType = "application/json",
schema = @Schema(implementation = String.class))),
@ApiResponse(responseCode = "400", description = "无效的请求")
})
public ResponseEntity<String> uploadExcel(
@Parameter(description = "Excel文件", required = true,
content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestPart("file") MultipartFile file) {
try {
if (file.isEmpty()){
return ResponseEntity.badRequest().body("文件不能为空");
}
String originalFilename = file.getOriginalFilename();
if (originalFilename == null ||
(!originalFilename.toLowerCase().endsWith(".xlsx") &&
!originalFilename.toLowerCase().endsWith(".xls"))) {
return ResponseEntity.badRequest().body("文件格式不正确,请上传.xlsx或.xls文件");
}
EasyExcel.read(file.getInputStream(), UserExcel.class, new UserExcelListener((ExcelDataService) excelDataService)).sheet().doRead();
return ResponseEntity.ok("文件上传成功");
}catch (Exception e){
log.error("上传失败", e);
return ResponseEntity.status(500).body("上传失败: " + e.getMessage());
}
}
}
效果


改造升级支持导入EXCEL中的图片
这个是重点,同时融合了前面EasyExcel的使用,本身EasyExcel不支持行内图片的读存,所以需要引用POI依赖进行额外处理。
1.准备资料
1.1 EXCEL数据文件

1.2 配置项目
html
server:
port: 9999
spring:
datasource:
url: jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 24568096
driver-class-name: com.mysql.cj.jdbc.Driver
sql:
init:
mode: always
schema-locations: classpath:schema.sql
servlet:
multipart:
max-file-size: 50MB
max-request-size: 50MB
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
global-config:
db-config:
id-type: auto
# 应用自定义配置
app:
# 图片根目录(相对项目根目录或绝对路径均可)。生产可改为绝对路径。
file-storage:
base-dir: /users/images
import:
# 邮箱唯一性检查,重复则跳过
email-unique: true
springdoc:
swagger-ui:
path: /swagger-ui.html
api-docs:
enabled: true
1.3 POM依赖
html
<?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.1.11</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>employee-import</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>employee-import</name>
<description>Excel 导入员工信息(内嵌图片落地为文件路径)</description>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.5</mybatis-plus.version>
<easyexcel.version>3.3.2</easyexcel.version>
<springdoc.version>2.1.0</springdoc.version>
</properties>
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 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>${mybatis-plus.version}</version>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- EasyExcel(会自动引入兼容的 POI 版本) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>${easyexcel.version}</version>
</dependency>
<!-- commons-io -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.15.1</version>
</dependency>
<!-- Lombok(可选) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- OpenAPI/Swagger UI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
1.4 数据库表
sql
CREATE TABLE IF NOT EXISTS employee (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
age INT NOT NULL,
email VARCHAR(150) NOT NULL,
department VARCHAR(100) NULL,
photo_path VARCHAR(500) NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
1.5 mybatisPlus配置类
java
/**
* MyBatis-Plus 基础配置
*/
@Configuration
@MapperScan("com.example.employeeimport.mapper")
public class MyBatisPlusConfig {
/**
* 分页插件(虽本项目未分页使用,但保留基础配置便于扩展)
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
}
2.工具类
2.1 Excel图片处理类
java
/**
* Excel 图片读取工具
* 专门用于读取 Excel 中的内嵌图片
*/
public class ExcelImageReader {
/**
* 读取 Excel 文件中的所有图片
* 返回 Map: key=行号(从0开始),value=图片字节数组列表
*/
public static Map<Integer, List<byte[]>> readImages(InputStream inputStream) throws IOException {
Map<Integer, List<byte[]>> result = new HashMap<>();
try (Workbook workbook = WorkbookFactory.create(inputStream)) {
// 读取第一个sheet
workbook.getSheetAt(0);
if (workbook instanceof XSSFWorkbook) {
XSSFWorkbook xssfWorkbook = (XSSFWorkbook) workbook;
XSSFSheet xssfSheet = xssfWorkbook.getSheetAt(0);
// 获取所有的图片
XSSFDrawing drawing = xssfSheet.getDrawingPatriarch();
if (drawing != null) {
List<XSSFShape> shapes = drawing.getShapes();
for (XSSFShape shape : shapes) {
if (shape instanceof XSSFPicture) {
XSSFPicture picture = (XSSFPicture) shape;
XSSFClientAnchor anchor = (XSSFClientAnchor) picture.getAnchor();
// 获取图片所在的行号
int rowIndex = anchor.getRow1();
// 获取图片数据
XSSFPictureData pictureData = picture.getPictureData();
byte[] imageBytes = pictureData.getData();
// 存储到结果map中
result.computeIfAbsent(rowIndex, k -> new ArrayList<>()).add(imageBytes);
System.out.println("找到图片:行号=" + rowIndex + ",大小=" + imageBytes.length + " bytes");
}
}
}
}
}
return result;
}
/**
* 获取指定行的第一张图片
*/
public static byte[] getImageByRow(Map<Integer, List<byte[]>> imageMap, int rowIndex) {
List<byte[]> images = imageMap.get(rowIndex);
if (images != null && !images.isEmpty()) {
return images.get(0);
}
return null;
}
}
2.2 文件存储类
java
/**
* 文件存储工具:用于保存图片到按日期分目录,并返回相对路径
*/
public class FileStorageUtil {
/**
* 根据图片字节流简单判断扩展名(仅支持 jpg/png/gif,默认 jpg)
*/
public static String detectImageExt(byte[] bytes) {
if (bytes == null || bytes.length < 4) return "jpg";
int b0 = bytes[0] & 0xFF;
int b1 = bytes[1] & 0xFF;
int b2 = bytes[2] & 0xFF;
int b3 = bytes[3] & 0xFF;
// PNG: 89 50 4E 47
if (b0 == 0x89 && b1 == 0x50 && b2 == 0x4E && b3 == 0x47) return "png";
// JPG: FF D8
if (b0 == 0xFF && b1 == 0xD8) return "jpg";
// GIF: 47 49 46
if (b0 == 0x47 && b1 == 0x49 && b2 == 0x46) return "gif";
return "jpg";
}
/**
* 将字节保存到以日期分组的目录中,返回相对路径(yyyy/MM/dd/filename.ext)
* baseDir 可以是绝对路径或相对路径;若目录不存在会自动创建
*/
public static String saveBytesToDateDir(String baseDir, String filename, byte[] bytes) throws IOException {
LocalDate now = LocalDate.now();
String datePath = now.format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
File dir = new File(baseDir, datePath);
if (!dir.exists() && !dir.mkdirs()) {
throw new IOException("创建目录失败:" + dir.getAbsolutePath());
}
File target = new File(dir, filename);
try (FileOutputStream fos = new FileOutputStream(target)) {
fos.write(bytes);
}
// 返回相对 baseDir 的路径,用于入库
return datePath + "/" + filename;
}
}
3.类
3.1 实体类
java
/**
* 员工实体
* 数据库仅存储图片的文件相对路径(含文件名和扩展名)
*/
@Data
@TableName("employee")
public class Employee {
/** 主键ID(自增) */
@TableId(type = IdType.AUTO)
private Long id;
/** 姓名 */
private String name;
/** 年龄 */
private Integer age;
/** 邮箱 */
private String email;
/** 部门 */
private String department;
/** 员工图片相对路径(例如:yyyy/MM/dd/uuid.png) */
private String photoPath;
}
3.2 中间类
java
@Data
@ExcelIgnoreUnannotated
public class EmployeeExcelDTO {
/** 姓名(表头:姓名) */
@ExcelProperty("姓名")
private String name;
/** 年龄(表头:年龄) */
@ExcelProperty("年龄")
private Integer age;
/** 邮箱(表头:邮箱) */
@ExcelProperty("邮箱")
private String email;
/** 部门(表头:部门) */
@ExcelProperty("部门")
private String department;
}
3.3 MyBtais Plus Mapper
java
/**
* 员工表 Mapper(使用 MyBatis-Plus BaseMapper)
*/
@Mapper
public interface EmployeeMapper extends BaseMapper<Employee> {
}
4.服务层
4.1 接口
java
public interface EmployeeService {
/**
* 从 Excel 导入员工数据(含内嵌图片),并将图片保存到本地目录,仅将相对路径保存到数据库
*
* @param file Excel 文件(multipart)
* @return 失败的错误信息列表(空列表表示全部成功)
*/
List<String> importFromExcel(MultipartFile file);
}
4.2 接口实现
注意这里的监听类在实现层处理的,而非再做一个监听类
java
/**
* 员工导入服务实现
*/
@Service
public class EmployeeServiceImpl extends ServiceImpl<EmployeeMapper, Employee> implements EmployeeService {
/** 图片根目录(相对或绝对),例如 ./uploads/images */
@Value("${app.file-storage.base-dir:./uploads/images}")
private String baseDir;
/** 邮箱是否唯一(重复处理:跳过) */
@Value("${app.import.email-unique:true}")
private boolean emailUnique;
@Resource
private EmployeeMapper employeeMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public List<String> importFromExcel(@NotNull MultipartFile file) {
List<String> errors = new ArrayList<>();
// 第一步:读取所有图片(key=行号,value=图片字节数组列表)
Map<Integer, List<byte[]>> imageMap = new HashMap<>();
try (InputStream imageIs = file.getInputStream()) {
imageMap = ExcelImageReader.readImages(imageIs);
System.out.println("共读取到 " + imageMap.size() + " 行有图片");
} catch (Exception e) {
errors.add("读取图片失败:" + e.getMessage());
return errors;
}
// 第二步:读取数据并匹配图片
try (InputStream is = file.getInputStream()) {
// 使用 EasyExcel 逐行读取数据
Map<Integer, List<byte[]>> finalImageMap = imageMap;
ReadListener<EmployeeExcelDTO> listener = new ReadListener<>() {
@Override
public void invoke(EmployeeExcelDTO data, com.alibaba.excel.context.AnalysisContext context) {
try {
// 基础校验
if (data == null) {
errors.add("空行");
return;
}
if (data.getName() == null || data.getName().isBlank()) {
errors.add("姓名不能为空,行:" + (context.readRowHolder().getRowIndex() + 1));
return;
}
if (data.getAge() == null || data.getAge() < 0 || data.getAge() > 120) {
errors.add("年龄不合法,行:" + (context.readRowHolder().getRowIndex() + 1));
return;
}
if (data.getEmail() == null || !data.getEmail().matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$")) {
errors.add("邮箱不合法,行:" + (context.readRowHolder().getRowIndex() + 1));
return;
}
if (emailUnique) {
Long count = employeeMapper.selectCount(new QueryWrapper<Employee>().eq("email", data.getEmail()));
if (count != null && count > 0) {
errors.add("邮箱已存在(跳过):" + data.getEmail());
return;
}
}
// 处理图片:从 imageMap 中获取当前行的图片
String relativePath = null;
int currentRow = context.readRowHolder().getRowIndex();
byte[] photoBytes = ExcelImageReader.getImageByRow(finalImageMap, currentRow);
if (photoBytes != null && photoBytes.length > 0) {
String ext = FileStorageUtil.detectImageExt(photoBytes);
String filename = UUID.randomUUID().toString().replaceAll("-", "") + "." + ext;
relativePath = FileStorageUtil.saveBytesToDateDir(baseDir, filename, photoBytes);
System.out.println("保存图片成功:" + relativePath + ",员工:" + data.getName());
} else {
System.out.println("行 " + (currentRow + 1) + " 的员工 " + data.getName() + " 没有检测到图片");
}
// 入库 也可以使用一个LIST 批量插入 总之可以在这里实现相关业务逻辑的处理;比如主从表的关联
Employee emp = new Employee();
emp.setName(data.getName());
emp.setAge(data.getAge());
emp.setEmail(data.getEmail());
emp.setDepartment(Objects.toString(data.getDepartment(), null));
emp.setPhotoPath(relativePath);
employeeMapper.insert(emp);
} catch (Exception e) {
errors.add("行处理失败:" + (context.readRowHolder().getRowIndex() + 1) + ",原因:" + e.getMessage());
}
}
@Override
public void doAfterAllAnalysed(com.alibaba.excel.context.AnalysisContext context) {
// 读取完成回调 一般作为从数据的批量插入
}
};
EasyExcel.read(is, EmployeeExcelDTO.class, listener).sheet().doRead();
} catch (Exception e) {
errors.add("读取 Excel 失败:" + e.getMessage());
}
return errors;
}
}
5.控制层
java
@RestController
@RequestMapping("/employees")
@Validated
@Tag(name = "员工导入")
public class EmployeeImportController {
@Resource
private EmployeeService employeeService;
/**
* 导入 Excel(multipart/form-data,字段名 file)
* Excel 模板表头顺序:姓名、年龄、邮箱、部门、相片(相片为内嵌图片)
*/
@PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "导入员工 Excel(内嵌图片将保存为文件路径)")
public Map<String, Object> importExcel(@RequestPart("file") MultipartFile file) {
List<String> errors = employeeService.importFromExcel(file);
Map<String, Object> resp = new HashMap<>();
resp.put("success", errors.isEmpty());
resp.put("errors", errors);
return resp;
}
}
6.效果
6.1 Swagger

6.2 数据库

6.3 文件
