支付系统 - Hibernator-Validator 验证框架完成入参校验

848 阅读10分钟

前言

这篇文章主要分享笔者在Java应用程序中做参数验证时经常使用的方法,希望能帮助大家做一些代码上的优化,同时减少一些丑陋的代码片段。

参数校验的常规做法

直接判断

这种是在一些代码风格要求不高的工程中出现频率最高的做法。常见的代码片段如下:

public void validate(Long uid, String username) {
    if (uid == null || uid == 0L) {
        throw new IllegalArgumentException("uid 不能为空");
    }
    if (StringUtils.isBlank(username)) {
        throw new IllegalArgumentException("username 不能为空");
    }
}

或者参数很多,入参用对象封装起来:

public void validate(UserDTO userDTO) {
    Long uid = userDTO.getUid();
    String username = userDTO.getName();
    if (uid == null || uid == 0L) {
        throw new IllegalArgumentException("uid 不能为空");
    }
    if (StringUtils.isBlank(username)) {
        throw new IllegalArgumentException("username 不能为空");
    }
}

这种代码站在功能的角度来看,一点毛病也没有,甚至还很简单易懂。但是随着参数的增多,大家在阅读时可能需要看很多行才能找到自己关注的字段,每个人抛出的异常都不同,代码冗余,看着很傻,对于有追求的科技从业者这是不能忍受的。

使用 Guava

有一些有想法的同学不想去写这种代码,于是使用Guava的断言工具进行判断。

public void validate (UserDTO userDTO){
    Long uid = userDTO.getUid();
    String username = userDTO.getName();
    Preconditions.checkNotNull(uid, "uid 不能为空");
    Preconditions.checkState(uid != 0, "uid 不能为空");
    Preconditions.checkState(StringUtils.isNotBlank(username), "username 不能为空");
}

这种本质上和上面的没什么区别,仅仅只是使用了一下工具类去简化下代码的书写。也有的同学喜欢自己写这种类似的工具类,覆盖的断言范围更广一些。究其缺点还是不够灵活。

自己造轮子

得益于Java中对反射的支持,定义一些自定义注解,就能很容易的实现参数验证的功能。稍微麻烦的在于嵌套验证,这种实现也只是工作量的问题。可以先做一个简单的工具类,最后再配合切面也能达到简化代码的目的。

使用验证框架

来龙去脉

终于到了本文的重头戏,使用一些标准化的规范来帮助我们简化参数验证。在使用验证框架之前,先来了解一下基础知识JSR规范。

JSR全称是Java Specification Requests,中文的意思是Java 规范要求。这个规范是由全世界的Java开发者发现了一些共性的问题,然后反馈给JCP国际社区,通过投票审核后制定的一些针对特定领域问题的标准。

题外话:有了针对某领域问题的标准,各大厂商就可以按照此规范去实现。比如JDBC是 SUN 公司提供的一套操作数据库的标准规范,各大数据库厂商都遵守该规范进行实现,应用程序就可以以较低成本切换底层数据库实现。

在本文撰写之际,针对参数验证领域的规范有:

  • JSR-303 : Bean Validation
  • JSR 349 : Bean Validation 1.1
  • JSR 380 : Bean Validation 2.0

在这里做个简要的说明,303是初版,349303的增强版,380是为了使用JDK8而做的改进版本。

想详细了解的同学请移步 JSR-303 官网页面,提案中会说明现存规范为什么不能满足要求的原因。

目前的模式是开源组织定义标准Bean Validation API,然后让各大厂商去实现。那这些标准API也是需要发布的,同时针对一种规范定义的API还需要不断的升级维护。负责维护该验证规范组织的官网是 beanvalidation.org,以后大家想看看有没有更新这里是最权威的发布地。包括规范的实现它都有收录,并且还会指出各个规范的区别以及升级后能得到什么新功能的支持。

以上三种规范建议的包名为javax.validation,并且组织之前也是这么实现的。为什么是之前?请继续往下看。总是,希望大家看到这个包名就意识到知道这是参数验证规范包。当然这个包需要单独引入,不是JDK自带的。

为了方便大家了解,我在官网截了个图:

beanvalidation 支持
beanvalidation 支持

图中标红的部分就是该组织对三种规范的接口定义。这里可能大家有个疑问,不是说好的Bean Validation吗,怎么还有Jakarta Bean Validation呢?贴一段引用大家了解下:

Java 完全开源这件事其实很早之前就该发生了。虽然 Sun 早在 2006 年 11 就开源了部分 Java,但以开源的方式使用 Java 其实相当麻烦。但对于 Java 企业版用户来说,这个情况好像有了一些变化。

9 月 10 日,Eclipse 基金会宣布 了 Jakarta EE 8 的完整平台和 Web 配置规范以及相关的兼容套件(TCK)的完全开源发布。

此举发生在 Oracle 放弃了大部分 Java EE 知识产权之后,但由于 Oracle 目前仍保留了 Java 的商标权,因此该版本以印度尼西亚首都雅加达来命名——Jakarta EE 8。

Jakarta EE 8 的规范与 Java EE 8 的规范完全兼容,是在 Jakarta EE 规范流程和 Eclipse 开发流程下开发的。Jakarta EE 8 同样还包含了 Java 开发者们一贯使用的相同的编程模型中的 API 和 Javadoc。Jakarta EE 8 的兼容包 TCK 与 Java EE 8 TCK 也是完全兼容的。所有的这些都意味着企业版用户将可以对程序不做任何修改就将项目迁移到 Jakarta EE 8 上。

Eclipse 基金会不只是发布了规范,还发布了 Eclipse GlassFish 5.1,一个可即时运行 Jakarta EE 8 的实现。

对于开发者而言,可以简单的理解为改名了。好了,以下继续正文。

下图Jakarta Bean Validation 2.0的定义,对应的就是JSR-380:

看到下面标红的地方没?除了GAV版本外没有区别,GAV版本的验证API导进去不再是我们熟悉的javax.validation包了。还有,官网通过认证的规范实现来自Hibernate Validator,很多同学可能之前见过这个包,也产生过好奇,Hibernate不是ORM框架吗,怎么还搞验证?别问,问就是两开花。

下面开始介绍如何寻找API和对应的实现,由于笔者之前一直使用的是老版本,在使用过程中也够用。为了不误导读者,故此次以老版本举例。如果想尝试使用新版本,也可按照如下给出的步骤进行摸索,相信对大家都是很轻松的事情。

首先,我们查看1.1的页面,如下:

标红的地方正是该规范的实现厂商,以Hibernate Validator进行说明,点击5.1.1.Final进入Hibernate Validator的官网,看到以下画面:

Hibernate Validator
Hibernate Validator

查看更旧的版本,找到5.1点击进入。

点击进入后,会看到该版本兼容Bean Validation 1.1,也就是JSR-349。下面的文档区域我们也可以点开看看,里面有详细的介绍。

这里我进入文档后找到了安装说明:

之后得到以下Maven坐标:

<dependency>
  <groupId>org.hibernate</groupId>
  <artifactId>hibernate-validator</artifactId>
  <version>5.1.3.Final</version>
</dependency>
<dependency>
  <groupId>javax.el</groupId>
  <artifactId>javax.el-api</artifactId>
  <version>2.2.4</version>
</dependency>
<dependency>
  <groupId>org.glassfish.web</groupId>
  <artifactId>javax.el</artifactId>
  <version>2.2.4</version>
</dependency>

如此,我们便做好了正式开始前的准备工作。费这么大工夫主要是为了说明这些东西的来龙去脉做到知其所以然。当然了,这里选取的5.1.3.Final并不一定是死的,如果你想选新点的版本也可以,只要兼容都可以大胆尝试。

使用实战

直接上代码吧,基本上覆盖了常用的基本类型。

public class PayFinishDto {

    @NotNull(message = "推单请求的唯一ID不能为空")
    private Long flowId;

    @NotEmpty(message = "业务商品名称不能为空")
    private String productName;

    @NotNull(message = "支付渠道ID不能为空")
    private Integer payChannelId;

    @NotNull(message = "支付金额不能为空")
    @Digits(integer = 10, fraction = 2)
    @DecimalMin(value = "0.01", message = "payAmount支付金额必须大于或等于0.01")
    private BigDecimal payAmount;

    @Valid //注意这里不能少
    @NotNull(message = "channelDetails不能为空")
    @Size(min = 1, message = "至少需要一条退款渠道明细")
    private List<OrderRefundPayDetailDto> channelDetails;
}
public class OrderRefundPayDetailDto {

    @NotNull(message = "payChannelId不能为空")
    private Integer payChannelId;
}

当然了,还需要封装一下验证工具类:


import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;

import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.groups.Default;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * 验证工具,提供了Spring中自动BindingResult验证的封装以及手动验证实体的功能
 */
public class HibernateValidatorUtils {

    private static Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

    private BindingResult bindingResult;
    private boolean existLengthError = true;
    private String resultErrorInfo;

    public static HibernateValidatorUtils create() {
        HibernateValidatorUtils validatorUtils = new HibernateValidatorUtils();
        return validatorUtils;
    }

    public HibernateValidatorUtils setBindingResult(BindingResult bindingResult) {
        this.bindingResult = bindingResult;
        return this;
    }

    public HibernateValidatorUtils generate() {
        StringBuffer buffer = new StringBuffer();
        List<FieldError> fieldErrors = this.bindingResult.getFieldErrors();
        for (FieldError error : fieldErrors) {
            buffer.append("字段:").append(error.getField()).append(",错误原因:").append(error.getDefaultMessage()).append(";");
            if (StringUtils.equals(error.getCode(), "Length")) {
                this.existLengthError = false;
            }
        }
        this.resultErrorInfo = buffer.toString();
        return this;
    }

    public boolean isNotExistLengthError() {
        return existLengthError;
    }

    public String getResultErrorInfo() {
        return resultErrorInfo;
    }

    public String getRandomFieldError() {
        return this.bindingResult.getFieldError().getField() + this.bindingResult.getFieldError().getDefaultMessage();
    }

    /**
     * 校验某个实体类,只校验默认分组,其它分组不会校验
     */
    public static <T> ValidationResult validateEntity(T obj) {
        ValidationResult result = new ValidationResult();
        Set<ConstraintViolation<T>> set = validator.validate(obj, Default.class);
        if (!CollectionUtils.isEmpty(set)) {
            result.setHasErrors(true);
            Map<String, String> errorMsg = new HashMap<String, String>();
            for (ConstraintViolation<T> cv : set) {
                errorMsg.put(cv.getPropertyPath().toString(), cv.getMessage());
            }
            result.setErrorMsg(errorMsg);
        }
        return result;
    }

    /**
     * 按指定的分组校验某个实体
     */
    public static <T> ValidationResult validateEntity(T obj, Class<?>... groups) {
        ValidationResult result = new ValidationResult();
        Set<ConstraintViolation<T>> set = validator.validate(obj, groups);
        if (!CollectionUtils.isEmpty(set)) {
            result.setHasErrors(true);
            Map<String, String> errorMsg = new HashMap<String, String>();
            for (ConstraintViolation<T> cv : set) {
                errorMsg.put(cv.getPropertyPath().toString(), cv.getMessage());
            }
            result.setErrorMsg(errorMsg);
        }
        return result;
    }

    /**
     * 校验某个实体类中的某个字段
     */
    public static <T> ValidationResult validateProperty(T obj, String propertyName) {
        ValidationResult result = new ValidationResult();
        Set<ConstraintViolation<T>> set = validator.validateProperty(obj, propertyName, Default.class);
        if (!CollectionUtils.isEmpty(set)) {
            result.setHasErrors(true);
            Map<String, String> errorMsg = new HashMap<String, String>();
            for (ConstraintViolation<T> cv : set) {
                errorMsg.put(propertyName, cv.getMessage());
            }
            result.setErrorMsg(errorMsg);
        }
        return result;
    }

    /**
     * 按指定的分组校验某个实体类中的某个字段
     */
    public static <T> ValidationResult validateProperty(T obj, String propertyName, Class<?>... groups) {
        ValidationResult result = new ValidationResult();
        Set<ConstraintViolation<T>> set = validator.validateProperty(obj, propertyName, groups);
        if (!CollectionUtils.isEmpty(set)) {
            result.setHasErrors(true);
            Map<String, String> errorMsg = new HashMap<String, String>();
            for (ConstraintViolation<T> cv : set) {
                errorMsg.put(propertyName, cv.getMessage());
            }
            result.setErrorMsg(errorMsg);
        }
        return result;
    }
}

以上面的对象举例,用法如下:

PayFinishDto requestDto = new PayFinishDto();
//省略属性的设置
ValidationResult validationResult = HibernateValidatorUtils.validateEntity(requestDto);
if (validationResult.isHasErrors()) {
    log.info("入参校验失败 -=- {},{}", validationResult.isHasErrors(),
            validationResult.getErrorMsg());
    throw new BusinessException(ResultCodeEnum.COMMON_CODE_PARAMETER_ERROR,
            validationResult.getErrorMessageOneway());
}

配合切面食用更佳:

public Result<PayFinishResultDto> payFinish(@Valid PayFinishDto finishDto) {
    try {
        PayFinishResultDto finishResultDto = payFinishService.payFinish(finishDto);
        return ResultWrapper.success(finishResultDto);
    } catch (BusinessException e) {
        return ResultWrapper.fail(e.getResultCode());
    }
}

这里可以使用@Valid去标记,注解来自javax.validation包。

@Aspect
@Component
@Slf4j
public class ValidationAspect {

    @Around("execution(* io.github.pleuvoir.gateway..*.*(..))")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        MethodSignature methodSignature = (MethodSignature) point.getSignature();
        Method method = methodSignature.getMethod();
        Object[] args = point.getArgs();
        Parameter[] parameters = method.getParameters();
        for (int i = 0; i < parameters.length; i++) {
            if (parameters[i].isAnnotationPresent(Valid.class)) {
                Object val = args[i];
                ValidationResult validationResult = HibernateValidatorUtils.validateEntity(val);
                if (validationResult.isHasErrors()) {
                    return ResultMessageVO.fail(ResultCodeEnum.PARAM_ERROR, validationResult.getErrorMessageOneway());
                }
            }
        }
        return point.proceed();
    }
}

OK,大功告成。

注意事项

由于选取的hibernate-validator版本不同(新版本会默认导入Bean Validation API),你可能会遇到注解报错的情况,此时需要去中央仓库找Bean Validation API

看到这样的结果,相信大家都知道选哪个了,根据自己的需求找相应的支持包即可。

后语

这篇文章是对过去经验的总结,在使用方面并没有讲到面面俱到,不过理解了规范的来龙去脉剩下的就是查找API的枯燥工作了。希望对大家有所帮助。