🌑

Shawn Fux

使用EasyExcel结合JSR303注解做数据校验

在做 Excel 数据导入时,难免会需要对输入的数据做一些规则校验来验证合法性,但是有时候表格的字段很多,规则也会随之增长,如果不对规则进行模块化,全部堆积到一个方法,后期将会变得难以维护。

前导知识

使用 EasyExcel 来解析我们的 Excel 表格,然后通过 JSR303 注解来对我们的数据规则做校验,将规则校验的代码与我们的业务代码做分离,便于维护和扩展。

EasyExcel

EasyExcel 是阿里巴巴开源的一个基于Java的、快速、简洁、解决大文件内存溢出的Excel处理工具。
他能让你在不用考虑性能、内存的等因素的情况下,快速完成Excel的读、写等功能,具体的使用可以参考 EasyExcel的官方文档。

JSR303注解

JSR-303 是 JAVA EE 6 中的一项子规范,叫做 Bean Validation。HibernateValidator 是 Bean Validation 的参考实现 . Hibernate Validator 提供了 JSR 303 规范中所有内置 constraint 的实现,除此之外还有一些附加的 constraint。

Bean Validation 中内置的 constraint

Constraint 详细信息
@Null 被注释的元素必须为 null
@NotNull 被注释的元素必须不为 null
@AssertTrue 被注释的元素必须为 true
@AssertFalse 被注释的元素必须为 false
@Min(value) 被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@Max(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@DecimalMin(value) 被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@DecimalMax(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@Size(max, min) 被注释的元素的大小必须在指定的范围内
@Digits (integer, fraction) 被注释的元素必须是一个数字,其值必须在可接受的范围内
@Past 被注释的元素必须是一个过去的日期
@Future 被注释的元素必须是一个将来的日期
@Pattern(value) 被注释的元素必须符合指定的正则表达式

Hibernate Validator 附加的 constraint

Constraint 详细信息
@Email 被注释的元素必须是电子邮箱地址
@Length 被注释的字符串的大小必须在指定的范围内
@NotEmpty 被注释的字符串的必须非空
@Range 被注释的元素必须在合适的范围内

如果你是基于SpringBoot开发的话,直接引入以下依赖即可

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

单个属性的校验

示例表格

假设我有一张如下表格,需要对年龄做一个校验,校验年龄必须大于等于18才算合法的。

姓名 年龄 生日
张三 16 2022-09-11
李四 32 1998-11-12
王五 22 1992-06-09

表格对应的实体类

EasyExcel 解析 excel 表格的时候需要一个实体类作映射,然后每一行数据对应一个 User 对象,通过给 User 对象的age属性设置一个@Min 注解。

public class User {
    
    @ExcelProperty(index = 0)
    private String name;

    @Min(value = 18, message = "年龄不能小于18")
    @ExcelProperty(index = 1)
    private Integer age;
    
    @ExcelProperty(index = 2)
    private Date birthday;
    
    // 省略get set...
}

解析数据并获取校验信息

EasyExcel 在解析Excel的时候每读取到一行数据,就会通过invoke方法进行回调让我们来消费,在这个时候我们就可以对数据进行校验。

public class UserListener extends AnalysisEventListener<User> {
    private final Validator validator;

    {
        // 初始化校验器对象
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        validator = factory.getValidator();
    }

    /**
     * 每读取到一行数据后进行回调
     *
     * @param data    数据
     * @param context 上下文对象
     */
    @Override
    public void invoke(User data, AnalysisContext context) {
        // 校验数据获取校验信息
        Set<ConstraintViolation<User>> constraintViolations = validator.validate(data);
        if (Objects.nonNull(constraintViolations) && !constraintViolations.isEmpty()) {
            System.out.println("不合规的数据:" + constraintViolations);
        } else {
            System.out.println("数据校验通过");
        }
    }

    /**
     * 当所有数据读取完毕后会被执行
     *
     * @param context 上下文对象
     */
    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {

    }
}

通过 validator 的 validate方法对我们的数据进行校验,会得到Set<ConstraintViolation<T>>集合,通过判断集合内是否有元素,如果有元素那说明该条数据有未通过校验的规则,会将不符合规则的信息存放到这个集合,如果没有元素说明数据符合规则校验通过。

多个属性作为一组校验

通过上面的例子演示,我们可以对单个属性做规则校验,还是以上面那个用户表为例子,如果现在新增一个需求,需要结合生日判断用户的年龄填写是否正确,这时候我们需要两个属性作为一组输入去做校验。那么又该如何实现了,翻看Hibernate Validator 提供的注解都是对单个属性的规则校验,很显然已经无法满足我们的需求。这时可以通过自定义注解,来完成我们的定制化规则校验需求。

自定义类级别约束注解

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = IsUpperValidator.class)
@Documented
public @interface CheckAge {
    /**
     * 错误提示信息
     */
    String message() default "生日与年龄不符合";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };
}

首先定义一个类级别的注解,然后@Constraint指定你自己的约束验证器,即可实现一个自己的约束注解。

public class CheckAgeValidator implements ConstraintValidator<CheckAge, User> {

    @Override
    public boolean isValid(User value, ConstraintValidatorContext context) {
        // 计算年龄
        int age = computeAge(value.getBirthday(), new Date());
        return age == value.getAge();
    }

    private int computeAge(Date birthday, Date date) {
        // TODO 计算出生年月距离现在的年份
    }
}

工具类封装

最后笔者对通过上面的代码示例,向上做了一些封装,将一些错误信息,统一封装到CheckFailRow的对象,方便调用者获取。

RuleCheckListener抽象工具类

public abstract class RuleCheckListener<T> extends AnalysisEventListener<T> {
    /**
     * 验证器对象,用来验证数据是否符合规范
     */
    private final Validator validator;
    /**
     * 是否继续读取数据,默认为true,当读取到不符合规则的数据时,读取结束
     */
    private boolean isContinue = true;

    {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        validator = factory.getValidator();
    }

    /**
     * 每读到一行数据都会调用这个方法进行消费,由子类去实现扩展
     *
     * @param data 数据行
     * @param context 上下文
     */
    @Override
    public abstract void invoke(T data, AnalysisContext context);

    /**
     * 读取完所有数据会来调用,可以用来做一些善后工作
     *
     * @param context 上下文
     */
    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {

    }

    /**
     * 是否读取下一行, 如果返回false将不会继续读下去
     *
     * @param context 上下文
     * @return 是否继续读
     */
    @Override
    public final boolean hasNext(AnalysisContext context) {
        return isContinue;
    }

    /**
     * 停止读取数据行
     */
    public final void stop() {
        this.isContinue = false;
    }

    /**
     * 对数据行进行规则校验
     *
     * @param data    数据行
     * @param context 上下文
     * @return 校验结果集, 如果为空说明校验通过
     */
    public final List<CheckFailRow> check(T data, AnalysisContext context) {
        List<CheckFailRow> list = new ArrayList<>();
        ReadRowHolder readRowHolder = context.readRowHolder();
        Integer rowIndex = readRowHolder.getRowIndex();
        Set<ConstraintViolation<T>> validateResult = validator.validate(data);
        if (Objects.nonNull(validateResult) && !validateResult.isEmpty()) {
            for (ConstraintViolation<T> violation : validateResult) {
                list.add(parse(rowIndex, violation));
            }
        }
        return list;
    }

    /**
     * 解析出错信息,并封装对象返回
     *
     * @param rowIndex  出错行
     * @param violation 出错信息
     * @return 出错信息对象
     */
    private CheckFailRow parse(Integer rowIndex, ConstraintViolation<T> violation) {
        CheckFailRow checkFailRow = new CheckFailRow();
        checkFailRow.setRowIndex(rowIndex);
        checkFailRow.setMessage(violation.getMessage());
        checkFailRow.setProperty(violation.getInvalidValue());
        return checkFailRow;
    }
}

CheckFailRow错误信息载体

public class CheckFailRow {

    /**
     * 不符合数据校验规则的所在行位置
     */
    private Integer rowIndex;

    /**
     * 不符合数据校验规则的提示信息
     */
    private String message;

    /**
     * 不符合数据校验规则的属性值
     */
    private Object property;
}

使用示例

上传文件的入口

@RestController
@RequestMapping
public class UserController {

    @RequestMapping("/upload")
    public void upload(@RequestParam("user") MultipartFile file) throws IOException {
        EasyExcel.read(file.getInputStream(), User.class, new UserListener()).sheet().doRead();
    }
}

继承RuleCheckListener 抽象类实现自己的监听器对象

public class UserListener extends RuleCheckListener<User> {
    /**
     * 每读取到一行数据后进行回调
     *
     * @param data    数据
     * @param context 上下文对象
     */
    @Override
    public void invoke(User data, AnalysisContext context) {
        List<CheckFailRow> check = check(data, context);
        if (CollectionUtils.isEmpty(check)){
            // 数据校验通过
        }else {
            // 数据校验未通过,可以遍历check集合拿到每一条错误信息
            // 另外还可以通过调用stop方法停止继续往下读取
            stop();
        }
    }
}

参考文档

— Aug 22, 2022