在后台管理系统中,Excel 导入是一个很常见的功能。
典型需求一般包括:
下载 Excel 导入模板;
用户填写数据后上传;
后端解析 Excel;
对每一行数据进行校验;
校验通过的数据进入成功列表;
校验失败的数据进入失败列表;
前端展示失败原因,支持用户修正后重新导入。
这类需求看起来不复杂,但真正做起来会发现:Excel 导入校验和普通接口参数校验不太一样。
普通接口参数校验可以直接使用 @Validated 或 @Valid,但 Excel 导入的数据需要先解析文件,再逐行校验,最后还要把成功和失败的数据分别返回给前端。
本文记录一种基于 EasyExcel + Bean Validation 的实现方式。
一、普通接口参数校验的问题
在常规接口中,参数校验一般这样写:
public Result test1(@RequestBody @Validated TestDTO dto) {
// ...
}或者:
public Result test2(@RequestBody @Valid TestDTO dto) {
// ...
}如果需要分组校验,也可以写成:
public Result test3(@RequestBody @Validated(value = {SaveGroup.class}) TestDTO dto) {
// ...
}DTO 中通常会写一些校验注解:
@NotNull
@NotBlank
@Size
@Pattern然后在全局异常处理器中统一封装错误信息。
示例:
@ControllerAdvice
public class RestExceptionHandler {
/**
* 处理参数验证失败异常
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseBody
@ResponseStatus(HttpStatus.OK)
private Response<?> methodArgumentNotValidException(MethodArgumentNotValidException e) {
FieldError fieldError = e.getBindingResult().getFieldError();
return ResponseUtils.create(
CommonCodeEnum.VALIDATE_ERROR.getCode(),
CommonCodeEnum.VALIDATE_ERROR.getMessage(),
fieldError.getDefaultMessage()
);
}
}
这种方式适合普通接口入参校验。
但 Excel 导入场景不一样。
Excel 上传接口的入参通常是:
@RequestParam("file") MultipartFile file
后端需要先解析文件,得到一批数据,再对每一行数据进行校验。
如果其中某一行数据不合法,不能直接抛异常结束整个请求,而是要把它放到失败列表中,继续处理其他行数据。
所以普通的 @Valid 接口参数校验方式并不适合直接用于 Excel 批量导入。
二、需求示例
假设有一个用户 Excel 导入功能。
Excel 模板包含以下字段:
用户名 年龄 手机号 性别
示例数据:
张三丰1 11 23847235 男
张三丰2 12 15813847236 男
张三丰3 13 15813847237 男
张三丰4 14 17813847238 男
张三丰5 15 15818847239 男
张三丰6 16 15813847240 男
张三丰7 17 152247241 男
张三丰8 18 15813847242 男
张三丰9 19 15813847243 男
张三丰10 20 15813847244 男
张三丰11 21 15813847245 男
导入后,希望返回两个列表:
成功列表;
失败列表。
可以定义一个返回对象:
@Data
public class UserExcelVO {
/**
* 成功列表
*/
private List<UserExcel> success;
/**
* 失败列表
*/
private List<UserExcel> fail;
}
三、定义 Excel 行数据对象
使用 EasyExcel 解析时,可以定义一个 Excel 行对象。
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserExcel implements Serializable {
@NotBlank(message = "用户名不能为空")
@Size(max = 4, message = "用户名长度不能超过 4")
@ExcelProperty(value = "用户名", index = 0)
private String name;
@ExcelProperty(value = "年龄", index = 1)
private Integer age;
@Pattern(regexp = "^[1][3,4,5,7,8][0-9]{9}$", message = "手机号不合法")
@NotBlank(message = "手机号不能为空")
@ExcelProperty(value = "手机号", index = 2)
private String mobile;
@ExcelProperty(value = "性别", index = 3)
private String sex;
}
这里有两类注解。
第一类是 EasyExcel 的字段映射注解:
@ExcelProperty(value = "用户名", index = 0)
它用来指定 Excel 列和 Java 字段的对应关系。
第二类是 Bean Validation 的校验注解:
@NotBlank
@Size
@Pattern
它们用于后续逐行校验。
四、方案一:手写 if else 校验
最直接的方式是解析 Excel 后,逐行写 if else 校验。
示例:
@PostMapping("/v1/importExcel")
public UserExcelVO importExcel(@RequestParam("file") MultipartFile file) {
List<UserExcel> list = null;
List<UserExcel> fail = new ArrayList<>();
UserExcelVO userExcelVO = new UserExcelVO();
String mobileReg = "^[1][3,4,5,7,8][0-9]{9}$";
try {
list = EasyExcel.read(
file.getInputStream(),
UserExcel.class,
new ModelExcelListener()
).sheet().doReadSync();
list.forEach(data -> {
if (StringUtils.isEmpty(data.getName()) || data.getName().length() > 4) {
fail.add(data);
return;
}
if (StringUtils.isEmpty(data.getMobile()) || !data.getMobile().matches(mobileReg)) {
fail.add(data);
return;
}
// 如果字段很多,这里会继续出现大量 if else
});
userExcelVO.setFail(fail);
list.removeAll(fail);
userExcelVO.setSuccess(list);
} catch (IOException e) {
e.printStackTrace();
}
return userExcelVO;
}
这种方式能实现功能,但问题很明显。
如果 Excel 字段很多,比如订单导入、商品导入、用户批量导入,字段可能有几十个。
这时如果每个字段都手写判断,代码会变得很臃肿:
if (...) {
fail.add(data);
return;
}
if (...) {
fail.add(data);
return;
}
if (...) {
fail.add(data);
return;
}
后续如果校验规则调整,也需要反复修改业务代码。
所以这种方式只适合字段很少、规则很简单的场景。
五、方案二:使用 Bean Validation 逐行校验
更好的方式是复用 Java Bean Validation 注解。
也就是把校验规则写在 Excel 行对象上,然后在解析后逐行调用 Validator 进行校验。
先定义一个校验工具类。
public class ValidationUtils {
private static final Validator validator;
static {
ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
validator = validatorFactory.getValidator();
}
public static Validator getValidator() {
return validator;
}
}
然后在导入接口中使用:
@PostMapping("/v2/importExcel")
public UserExcelVO importExcelV2(@RequestParam("file") MultipartFile file) {
List<UserExcel> list = null;
List<UserExcel> fail = new ArrayList<>();
UserExcelVO userExcelVO = new UserExcelVO();
try {
list = EasyExcel.read(
file.getInputStream(),
UserExcel.class,
new ModelExcelListener()
).sheet().doReadSync();
list.forEach(data -> {
Set<ConstraintViolation<UserExcel>> violations =
ValidationUtils.getValidator().validate(data);
if (!violations.isEmpty()) {
fail.add(data);
}
});
userExcelVO.setFail(fail);
list.removeAll(fail);
userExcelVO.setSuccess(list);
} catch (IOException e) {
e.printStackTrace();
}
return userExcelVO;
}
这样,每一行数据都会根据 UserExcel 上的注解进行校验。
例如:
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^[1][3,4,5,7,8][0-9]{9}$", message = "手机号不合法")
private String mobile;
如果手机号为空,或者手机号格式不符合正则,就会进入失败列表。
相比手写 if else,这种方式有几个优点:
校验规则集中在字段上;
代码更简洁;
字段增加后只需要添加注解;
方便复用已有校验能力;
更容易维护。
六、EasyExcel 监听器
原文中使用了一个 EasyExcel 监听器。
示例:
public static class ModelExcelListener extends AnalysisEventListener<UserExcel> {
private List<UserExcel> datas = new ArrayList<>();
/**
* 每读取一行都会调用该方法
*/
@Override
public void invoke(UserExcel data, AnalysisContext context) {
log.info("读取到数据 {}", data);
datas.add(data);
}
/**
* 所有数据解析完成后调用
*/
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
log.info("所有数据解析完成");
}
}
AnalysisEventListener 可以拿到 Excel 解析过程中的每一行数据。
其中:
invoke()
会在每解析一行时执行。
doAfterAllAnalysed()
会在所有数据解析完成后执行。
如果数据量较大,也可以在监听器中做分批处理,避免一次性把所有数据加载到内存中。
七、返回失败原因
上面的实现只把失败数据放到了失败列表中。
如果前端需要展示具体失败原因,可以在返回对象中增加一个字段。
例如:
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserExcel implements Serializable {
@NotBlank(message = "用户名不能为空")
@Size(max = 4, message = "用户名长度不能超过 4")
@ExcelProperty(value = "用户名", index = 0)
private String name;
@ExcelProperty(value = "年龄", index = 1)
private Integer age;
@Pattern(regexp = "^[1][3,4,5,7,8][0-9]{9}$", message = "手机号不合法")
@NotBlank(message = "手机号不能为空")
@ExcelProperty(value = "手机号", index = 2)
private String mobile;
@ExcelProperty(value = "性别", index = 3)
private String sex;
/**
* 校验失败原因
*/
private String failReason;
}
校验时拼接失败原因:
list.forEach(data -> {
Set<ConstraintViolation<UserExcel>> violations =
ValidationUtils.getValidator().validate(data);
if (!violations.isEmpty()) {
String failReason = violations.stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.joining("; "));
data.setFailReason(failReason);
fail.add(data);
}
});
这样前端拿到失败列表后,可以直接展示每一行失败原因。
例如:
手机号不合法
用户名长度不能超过 4
手机号不能为空
这比只返回失败数据更适合实际业务使用。
八、成功列表和失败列表拆分
原文中使用了:
list.removeAll(fail);
这种方式可以得到成功列表,但如果后续对象比较复杂,或者涉及对象相等判断,可能不够直观。
也可以直接准备两个集合:
List<UserExcel> success = new ArrayList<>();
List<UserExcel> fail = new ArrayList<>();
逐行校验时分别放入不同集合:
for (UserExcel data : list) {
Set<ConstraintViolation<UserExcel>> violations =
ValidationUtils.getValidator().validate(data);
if (violations.isEmpty()) {
success.add(data);
} else {
String failReason = violations.stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.joining("; "));
data.setFailReason(failReason);
fail.add(data);
}
}
最后返回:
UserExcelVO userExcelVO = new UserExcelVO();
userExcelVO.setSuccess(success);
userExcelVO.setFail(fail);
return userExcelVO;
完整示例:
@PostMapping("/v2/importExcel")
public UserExcelVO importExcelV2(@RequestParam("file") MultipartFile file) {
UserExcelVO userExcelVO = new UserExcelVO();
List<UserExcel> success = new ArrayList<>();
List<UserExcel> fail = new ArrayList<>();
try {
List<UserExcel> list = EasyExcel.read(
file.getInputStream(),
UserExcel.class,
new ModelExcelListener()
).sheet().doReadSync();
for (UserExcel data : list) {
Set<ConstraintViolation<UserExcel>> violations =
ValidationUtils.getValidator().validate(data);
if (violations.isEmpty()) {
success.add(data);
} else {
String failReason = violations.stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.joining("; "));
data.setFailReason(failReason);
fail.add(data);
}
}
userExcelVO.setSuccess(success);
userExcelVO.setFail(fail);
} catch (IOException e) {
e.printStackTrace();
}
return userExcelVO;
}
这种写法比 removeAll() 更清晰。
九、这种方式的优点
使用 Bean Validation 做 Excel 导入校验,有几个明显好处。
1. 减少 if else
不需要每个字段都手写判断。
校验逻辑统一交给注解处理。
2. 校验规则更清晰
字段和校验规则放在一起。
例如:
@Pattern(regexp = "...", message = "手机号不合法")
private String mobile;
看到字段时就能知道它的校验要求。
3. 方便扩展
如果后续新增字段,只需要在字段上加对应注解。
不需要在导入逻辑里继续追加大量判断。
4. 可以复用已有校验体系
如果项目中本来就使用 Bean Validation,那么 Excel 导入也可以复用同一套校验规则。
5. 方便返回失败原因
通过 ConstraintViolation 可以拿到具体校验失败信息,方便返回给前端展示。
十、需要注意的问题
1. 不要让单行失败影响整个导入
Excel 导入通常是批量处理。
某一行失败时,不应该直接抛异常结束整个导入流程。
更合理的方式是:
成功数据进入 success 列表
失败数据进入 fail 列表
然后统一返回给前端。
2. 失败原因建议返回给前端
只告诉前端“导入失败”意义不大。
最好返回具体失败原因,例如:
第 3 行:手机号不合法
第 7 行:用户名不能为空
这样用户才能快速修正 Excel。
3. 大文件导入要注意内存
如果 Excel 数据量很大,不建议一次性全部读入内存。
可以通过 EasyExcel 的监听器分批处理。
例如每读取 500 行或 1000 行就处理一次,然后清空临时集合。
4. 校验规则要和模板保持一致
Excel 模板中的字段说明要和后端校验规则一致。
否则用户按照模板填写,后端却校验失败,会造成体验问题。
5. 字段类型转换也要处理
Excel 中的数据可能存在类型不一致的情况。
例如:
手机号被 Excel 当成数字;
日期格式不统一;
空字符串和 null 混用;
数字列出现文本。
这些问题需要在解析或校验阶段一起考虑。
十一、完整流程
整个 Excel 导入校验流程可以整理为:
1. 前端上传 Excel 文件
2. 后端使用 EasyExcel 解析文件
3. 每一行数据映射为 UserExcel 对象
4. 使用 Validator.validate(data) 执行注解校验
5. 校验通过的数据放入 success 列表
6. 校验失败的数据放入 fail 列表
7. 失败数据记录失败原因
8. 返回 success 和 fail 给前端
结论
Excel 导入的数据校验,不适合完全套用普通接口参数校验方式。
因为导入场景需要对每一行数据分别处理,并且要同时返回成功列表和失败列表。
如果用大量 if else 手写校验,字段一多,代码会很难维护。
更好的方式是:
使用 EasyExcel 解析 Excel;
在 Excel 行对象上添加 Bean Validation 注解;
使用
Validator.validate()对每一行数据执行校验;将校验成功和失败的数据分别放入不同集合;
把失败原因返回给前端。