1. 引言
在实际的企业级应用开发中,Excel 数据导入是一个常见的需求。Apache POI 虽然功能强大,但 API 较为复杂,而 EasyExcel 作为阿里巴巴开源的 Excel 处理框架,以其高性能、低内存占用和简洁的 API 设计受到广泛欢迎。
然而,在实际项目中,我们经常遇到以下痛点:
- 每次导入都需要重复编写解析逻辑
- Excel 列与 Java 对象字段的映射关系硬编码在代码中
- 校验逻辑分散在各个业务模块
- 错误处理机制不统一
本文将介绍如何基于 EasyExcel 构建一个通用的 Excel 导入工具类,通过自定义注解和反射机制,实现字段映射的声明式配置,大幅提升开发效率和代码可维护性。
2. 核心设计思路
2.1 整体架构设计
我们的通用导入工具类将采用以下设计模式:
- 注解驱动:通过自定义注解声明 Excel 列与 Java 字段的映射关系
- 反射机制:动态解析注解并完成数据绑定
- 模板方法:定义导入的标准流程,允许子类扩展特定步骤
- 责任链模式:构建校验器链,支持多种校验规则
2.2 技术栈选择
- EasyExcel 3.x:核心 Excel 解析库
- Java 反射 API:动态处理字段映射
- Spring Validation:数据校验框架(可选集成)
- Lombok:简化实体类编写(可选)
3. 核心注解定义
3.1 ExcelColumn 注解
java
package com.example.excel.annotation;
import java.lang.annotation.*;
/**
* Excel 列注解
* 用于标记实体类字段与 Excel 列的映射关系
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ExcelColumn {
/**
* Excel 列标题(支持多级标题,用"."分隔)
*/
String value() default "";
/**
* Excel 列索引(从0开始)
* 优先级:index > value
*/
int index() default -1;
/**
* 是否必填
*/
boolean required() default false;
/**
* 字段描述(用于错误提示)
*/
String description() default "";
/**
* 数据格式(如:yyyy-MM-dd)
*/
String format() default "";
/**
* 正则表达式校验
*/
String regex() default "";
/**
* 正则校验失败提示信息
*/
String regexMessage() default "格式不正确";
/**
* 是否忽略该字段(不参与导入)
*/
boolean ignore() default false;
}
3.2 ExcelSheet 注解
java
package com.example.excel.annotation;
import java.lang.annotation.*;
/**
* Excel Sheet 注解
* 用于标记导入的 Sheet 配置
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ExcelSheet {
/**
* Sheet 名称
*/
String name() default "";
/**
* Sheet 索引(从0开始)
*/
int index() default 0;
/**
* 标题行号(从0开始)
*/
int headRowNumber() default 0;
/**
* 数据起始行号(从0开始)
*/
int dataRowNumber() default 1;
/**
* 是否忽略未知列
*/
boolean ignoreUnknownColumn() default true;
}
4. 基础实体类示例
java
package com.example.excel.entity;
import com.example.excel.annotation.ExcelColumn;
import com.example.excel.annotation.ExcelSheet;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/**
* 员工信息导入实体
*/
@Data
@ExcelSheet(name = "员工信息", headRowNumber = 0)
public class EmployeeImportDTO {
@ExcelColumn(value = "员工编号", required = true, description = "员工唯一编号")
private String employeeNo;
@ExcelColumn(value = "姓名", required = true, description = "员工姓名")
private String name;
@ExcelColumn(value = "部门", required = true, description = "所属部门")
private String department;
@ExcelColumn(value = "职位", description = "岗位名称")
private String position;
@ExcelColumn(value = "入职日期", format = "yyyy-MM-dd", description = "入职时间")
private Date hireDate;
@ExcelColumn(value = "基本工资", format = "#,##0.00", description = "月基本工资")
private BigDecimal baseSalary;
@ExcelColumn(value = "邮箱", regex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$",
regexMessage = "邮箱格式不正确")
private String email;
@ExcelColumn(value = "手机号", regex = "^1[3-9]\\d{9}$",
regexMessage = "手机号格式不正确")
private String phone;
@ExcelColumn(value = "状态", description = "在职状态")
private String status;
// 非Excel字段,用于存储校验错误信息
private String errorMsg;
// 非Excel字段,用于标记是否校验通过
private Boolean valid = true;
}
5. 核心工具类实现
5.1 注解解析器
java
package com.example.excel.util;
import com.example.excel.annotation.ExcelColumn;
import com.example.excel.annotation.ExcelSheet;
import lombok.extern.slf4j.Slf4j;
import java.lang.reflect.Field;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* Excel 注解解析器
*/
@Slf4j
public class ExcelAnnotationParser {
private static final Map<Class<?>, List<FieldMapping>> FIELD_MAPPING_CACHE = new ConcurrentHashMap<>();
/**
* 字段映射信息
*/
@Data
@AllArgsConstructor
public static class FieldMapping {
private Field field;
private ExcelColumn annotation;
private String columnName;
private int columnIndex;
private boolean required;
}
/**
* 获取实体类的 Sheet 配置
*/
public static SheetConfig getSheetConfig(Class<?> clazz) {
ExcelSheet sheetAnnotation = clazz.getAnnotation(ExcelSheet.class);
if (sheetAnnotation == null) {
return new SheetConfig(); // 返回默认配置
}
return new SheetConfig(
sheetAnnotation.name(),
sheetAnnotation.index(),
sheetAnnotation.headRowNumber(),
sheetAnnotation.dataRowNumber(),
sheetAnnotation.ignoreUnknownColumn()
);
}
/**
* 获取字段映射列表
*/
public static List<FieldMapping> getFieldMappings(Class<?> clazz) {
return FIELD_MAPPING_CACHE.computeIfAbsent(clazz, ExcelAnnotationParser::parseFieldMappings);
}
/**
* 解析字段映射
*/
private static List<FieldMapping> parseFieldMappings(Class<?> clazz) {
List<FieldMapping> mappings = new ArrayList<>();
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
ExcelColumn annotation = field.getAnnotation(ExcelColumn.class);
if (annotation == null || annotation.ignore()) {
continue;
}
field.setAccessible(true);
String columnName = annotation.value();
int columnIndex = annotation.index();
mappings.add(new FieldMapping(
field,
annotation,
columnName,
columnIndex,
annotation.required()
));
}
// 按列索引排序(索引为-1的按列名排序)
mappings.sort((m1, m2) -> {
if (m1.getColumnIndex() >= 0 && m2.getColumnIndex() >= 0) {
return Integer.compare(m1.getColumnIndex(), m2.getColumnIndex());
} else if (m1.getColumnIndex() >= 0) {
return -1;
} else if (m2.getColumnIndex() >= 0) {
return 1;
} else {
return m1.getColumnName().compareTo(m2.getColumnName());
}
});
log.debug("解析类 {} 的字段映射完成,共 {} 个字段", clazz.getName(), mappings.size());
return mappings;
}
/**
* Sheet 配置类
*/
@Data
@AllArgsConstructor
public static class SheetConfig {
private String sheetName;
private int sheetIndex;
private int headRowNumber;
private int dataRowNumber;
private boolean ignoreUnknownColumn;
public SheetConfig() {
this.sheetName = "";
this.sheetIndex = 0;
this.headRowNumber = 0;
this.dataRowNumber = 1;
this.ignoreUnknownColumn = true;
}
}
}
5.2 通用导入工具类
java
package com.example.excel.util;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import com.alibaba.excel.read.listener.ReadListener;
import com.example.excel.annotation.ExcelColumn;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.util.*;
import java.util.function.Consumer;
/**
* 通用 Excel 导入工具类
*/
@Slf4j
public class ExcelImportUtil {
/**
* 导入 Excel 文件
*
* @param inputStream Excel 文件流
* @param clazz 目标实体类
* @param dataConsumer 数据处理消费者
* @param <T> 实体类型
* @return 导入结果
*/
public static <T> ImportResult<T> importExcel(InputStream inputStream,
Class<T> clazz,
Consumer<T> dataConsumer) {
return importExcel(inputStream, clazz, dataConsumer, null);
}
/**
* 导入 Excel 文件(带自定义监听器)
*/
public static <T> ImportResult<T> importExcel(InputStream inputStream,
Class<T> clazz,
Consumer<T> dataConsumer,
ReadListener<T> customListener) {
ImportResult<T> result = new ImportResult<>();
List<T> successList = new ArrayList<>();
List<ImportError<T>> errorList = new ArrayList<>();
try {
// 获取 Sheet 配置
ExcelAnnotationParser.SheetConfig sheetConfig =
ExcelAnnotationParser.getSheetConfig(clazz);
// 获取字段映射
List<ExcelAnnotationParser.FieldMapping> fieldMappings =
ExcelAnnotationParser.getFieldMappings(clazz);
// 创建监听器
AnalysisEventListener<T> listener = new AnalysisEventListener<T>() {
private int rowIndex = sheetConfig.getDataRowNumber();
@Override
public void invoke(T data, AnalysisContext context) {
try {
// 数据校验
List<String> errors = validateData(data, fieldMappings, rowIndex);
if (errors.isEmpty()) {
// 校验通过
successList.add(data);
if (dataConsumer != null) {
dataConsumer.accept(data);
}
} else {
// 校验失败
ImportError<T> error = new ImportError<>();
error.setRowIndex(rowIndex);
error.setData(data);
error.setErrorMessages(errors);
errorList.add(error);
// 设置错误信息到实体
setErrorInfo(data, errors);
}
rowIndex++;
} catch (Exception e) {
log.error("处理第 {} 行数据时发生异常", rowIndex, e);
ImportError<T> error = new ImportError<>();
error.setRowIndex(rowIndex);
error.setData(data);
error.setErrorMessages(Collections.singletonList("系统异常: " + e.getMessage()));
errorList.add(error);
rowIndex++;
}
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
log.info("Excel 解析完成,共处理 {} 行数据", rowIndex - sheetConfig.getDataRowNumber());
}
@Override
public void onException(Exception exception, AnalysisContext context) {
log.error("解析 Excel 时发生异常", exception);
result.setHasParseError(true);
result.setParseErrorMessage(exception.getMessage());
}
};
// 构建监听器链
ReadListener<T> finalListener = customListener != null ?
customListener : listener;
// 读取 Excel
EasyExcel.read(inputStream, clazz, finalListener)
.sheet(sheetConfig.getSheetIndex())
.headRowNumber(sheetConfig.getHeadRowNumber())
.ignoreUnknownColumn(sheetConfig.isIgnoreUnknownColumn())
.doRead();
// 设置结果
result.setSuccessList(successList);
result.setErrorList(errorList);
result.setTotalCount(successList.size() + errorList.size());
result.setSuccessCount(successList.size());
result.setErrorCount(errorList.size());
} catch (Exception e) {
log.error("导入 Excel 失败", e);
result.setSuccess(false);
result.setMessage("导入失败: " + e.getMessage());
}
return result;
}
/**
* 数据校验
*/
private static <T> List<String> validateData(T data,
List<ExcelAnnotationParser.FieldMapping> fieldMappings,
int rowIndex) {
List<String> errors = new ArrayList<>();
for (ExcelAnnotationParser.FieldMapping mapping : fieldMappings) {
try {
Field field = mapping.getField();
ExcelColumn annotation = mapping.getAnnotation();
Object value = field.get(data);
// 必填校验
if (annotation.required() && isEmpty(value)) {
errors.add(String.format("第%d行: %s不能为空",
rowIndex + 1, annotation.description()));
continue;
}
// 正则校验
if (StringUtils.hasText(annotation.regex()) && !isEmpty(value)) {
String strValue = value.toString();
if (!strValue.matches(annotation.regex())) {
errors.add(String.format("第%d行: %s%s",
rowIndex + 1, annotation.description(), annotation.regexMessage()));
}
}
// 格式校验(日期、数字等)
if (StringUtils.hasText(annotation.format()) && !isEmpty(value)) {
// 这里可以扩展格式校验逻辑
// 例如日期格式、数字格式等
}
} catch (IllegalAccessException e) {
log.error("访问字段值时发生异常", e);
errors.add(String.format("第%d行: 字段访问异常", rowIndex + 1));
}
}
return errors;
}
/**
* 设置错误信息到实体
*/
private static <T> void setErrorInfo(T data, List<String> errors) {
try {
// 尝试设置 errorMsg 字段
Field errorField = data.getClass().getDeclaredField("errorMsg");
if (errorField != null) {
errorField.setAccessible(true);
errorField.set(data, String.join("; ", errors));
}
// 尝试设置 valid 字段
Field validField = data.getClass().getDeclaredField("valid");
if (validField != null) {
validField.setAccessible(true);
validField.set(data, false);
}
} catch (Exception e) {
// 忽略字段不存在的情况
}
}
/**
* 判断对象是否为空
*/
private static boolean isEmpty(Object value) {
if (value == null) {
return true;
}
if (value instanceof String) {
return ((String) value).trim().isEmpty();
}
if (value instanceof Collection) {
return ((Collection<?>) value).isEmpty();
}
if (value instanceof Map) {
return ((Map<?, ?>) value).isEmpty();
}
if (value instanceof Object[]) {
return ((Object[]) value).length == 0;
}
return false;
}
/**
* 导入结果类
*/
@Data
public static class ImportResult<T> {
private boolean success = true;
private String message = "导入成功";
private int totalCount;
private int successCount;
private int errorCount;
private List<T> successList;
private List<ImportError<T>> errorList;
private boolean hasParseError;
private String parseErrorMessage;
}
/**
* 导入错误信息类
*/
@Data
public static class ImportError<T> {
private int rowIndex;
private T data;
private List<String> errorMessages;
}
}
6. 使用示例
6.1 基础使用
java
package com.example.excel.service;
import com.example.excel.entity.EmployeeImportDTO;
import com.example.excel.util.ExcelImportUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.util.List;
@Service
@Slf4j
public class EmployeeImportService {
/**
* 导入员工信息
*/
public ImportResult<EmployeeImportDTO> importEmployees(MultipartFile file) {
try (InputStream inputStream = file.getInputStream()) {
// 导入 Excel
ImportResult<EmployeeImportDTO> result = ExcelImportUtil.importExcel(
inputStream,
EmployeeImportDTO.class,
this::processEmployeeData
);
// 处理导入结果
if (result.getErrorCount() > 0) {
log.warn("导入完成,成功 {} 条,失败 {} 条",
result.getSuccessCount(), result.getErrorCount());
// 生成错误报告
generateErrorReport(result.getEr