Excel 数据导入时做数据校验

作者:old wang 发布时间: 2024-05-23 阅读量:3 评论数:0

在后台管理系统中,Excel 导入是一个很常见的功能。

典型需求一般包括:

  1. 下载 Excel 导入模板;

  2. 用户填写数据后上传;

  3. 后端解析 Excel;

  4. 对每一行数据进行校验;

  5. 校验通过的数据进入成功列表;

  6. 校验失败的数据进入失败列表;

  7. 前端展示失败原因,支持用户修正后重新导入。

这类需求看起来不复杂,但真正做起来会发现: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,这种方式有几个优点:

  1. 校验规则集中在字段上;

  2. 代码更简洁;

  3. 字段增加后只需要添加注解;

  4. 方便复用已有校验能力;

  5. 更容易维护。

六、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 手写校验,字段一多,代码会很难维护。

更好的方式是:

  1. 使用 EasyExcel 解析 Excel;

  2. 在 Excel 行对象上添加 Bean Validation 注解;

  3. 使用 Validator.validate() 对每一行数据执行校验;

  4. 将校验成功和失败的数据分别放入不同集合;

  5. 把失败原因返回给前端。

评论