java中优雅的参数校验方法

一、引子

要对方法的参数进行校验,最简单暴力的写法是这个样子:

    public static void utilA(String a,BigDecimal b){
        if (StringUtils.isEmpty(a)){
            System.out.println("a不可为空");
            return;
        }
        if (b == null){
            System.out.println("b不可为空");
            return;
        }
        if (b.compareTo(BigDecimal.ZERO) != 1){
            System.out.println("b的取值范围不正确");
            return;
        }
        System.out.println("do something");
    }

这样做从功能角度来说一点问题也没有。

但是从代码的长期维护性上来说,代码复用率低,校验规则一旦多起来很难维护,而且怎么看怎么显得笨拙,对于有一点追求的工程师来说,这么一大坨还是挺难接受的。

虽然有一些诸如 Preconditions(com.google) 的解决方案,但很难适应所有的场景,用起来也没到非常得心应有的地步。

二、如何优雅地校验参数

下面介绍Spring官方推荐的,语义清晰的优雅的方法级别校验(入参校验、返回值校验)

2-1、“官方指导意见”

Spring官方在SpringBoot文档中,关于参数校验(Validation)给出的解决方案是这样的:

  1. Validation

The method validation feature supported by Bean Validation 1.1 is automatically enabled as long as a JSR-303 implementation (such as Hibernate validator) is on the classpath. This lets bean methods be annotated with javax.validation constraints on their parameters and/or on their return value. Target classes with such annotated methods need to be annotated with the @Validated annotation at the type level for their methods to be searched for inline constraint annotations.

For instance, the following service triggers the validation of the first argument, making sure its size is between 8 and 10:

@Service
@Validated
public class MyBean {

    public Archive findByCodeAndAuthor(@Size(min = 8, max = 10) String code,
            Author author) {
        ...
    }

}

Spring Boot 官网文档 《37. Validation》

也就是说,使用 JSR-303 规范,直接利用注解进行参数校验。

(JSR-303 是 JAVA EE 6 中的一项子规范,叫做 Bean Validation,官方参考实现是 Hibernate Validator)

2-2、注解用法说明

  1. 对于简单类型参数(非Bean),直接在参数前,使用注解添加约束规则。比如 @NotNull @Length 等(参看上面的官网示例)
  2. 在类名前追加 @Validated 注解,否则添加的约束规则不生效。
  3. 方法被调用时,如果传入的实际参数与约束规则不符,会直接抛出 ConstraintViolationException ,表明参数校验失败
  4. 对于Bean类型的参数,在Bean内部的各个字段上面追加约束注解,然后在方法的参数前面添加 @Valid 注解即可。示例:
public class CreateProjectReqVO extends BaseVO {
    /**
     * 请求序列号
     */
    @NotNull(message = "请求序列号不可为空")
    private Integer requestNo;
    /**
     * 项目名称
     */
    @NotNull(message = "项目名称不可为空")
    private String projectName;
    ……
}    
    /**
     * 项目创建
     */
    public CreateProjectRespVO createProject(@Valid CreateProjectReqVO reqVO) {
    ……

对于Bean里面套Bean的,同样在外层Bean里面写@Valid即可。

  1. 常用校验注解
@AssertTrue / @AssertFalse 
验证适用字段:boolean
注解说明:验证值是否为true / false

@DecimalMax / @DecimalMin
验证适用字段:BigDecimal,BigInteger,String,byte,short,int,long
注解说明:验证值是否小于或者等于指定的小数值,要注意小数存在精度问题

@Digits
验证适用字段:BigDecimal,BigInteger,String,byte,short,int,long
注解说明:验证值的数字构成是否合法
属性说明:integer:指定整数部分的数字的位数。fraction: 指定小数部分的数字的位数。

@Future / @Past
验证适用字段:Date,Calendar
注解说明:验证值是否在当前时间之后 / 之前
属性说明:公共

@Max / @Min
验证适用字段:BigDecimal,BigInteger,String,byte,short,int,long
注解说明:验证值是否小于或者等于指定的整数值
属性说明:公共
注意事项:建议使用在Stirng,Integer类型,不建议使用在int类型上,因为表单提交的值为“”时无法转换为int

@NotNull / @Null
验证适用字段:引用数据类型
注解说明:验证值是否为非空 / 空
属性说明:公共

@NotBlank 检查约束字符串是不是Null还有被Trim的长度是否大于0,只对字符串,且会去掉前后空格. 
@NotEmpty 检查约束元素是否为Null或者是EMPTY.
@NotBlank 与 @NotEmpty 的区别:空格(" ")对于 NotEmpty 是合法的,而 NotBlank 会抛出校验异常

@Pattern
验证适用字段:String
注解说明:验证值是否配备正则表达式
属性说明:regexp:正则表达式flags: 指定Pattern.Flag 的数组,表示正则表达式的相关选项。

@Size
验证适用字段:String,Collection,Map,数组
注解说明:验证值是否满足长度要求
属性说明:max:指定最大长度,min:指定最小长度。

@Length(min=, max=):专门应用于String类型

@Valid
验证适用字段:递归的对关联对象进行校验
注解说明:如果关联对象是个集合或者数组,那么对其中的元素进行递归校验,如果是一个map,则对其中的值部分进行校验(是否进行递归验证)
属性说明:无

@Range(min=, max=) 被指定的元素必须在合适的范围内 

@CreditCardNumber信用卡验证 

@Email 验证是否是邮件地址,如果为null,不进行验证,算通过验证。 

@URL(protocol=,host=, port=,regexp=, flags=)

2-3、进阶用法提示

  1. 校验规则是可以自己定义的,也就是说可以自己写注解类。具体写法本文略

  2. 约束规则可以“分组”(group),比如在A场景下只需要一部分规则生效,B场景下需要全部生效,那么直接在注解上指定生效范围即可。具体写法本文略

  3. 方法的返回值也是可以校验的,在方法前面追加约束规则即可:

public @NotNull CreateProjectRespVO createProject(CreateProjectReqVO reqVO) {
  1. 约束用的注解,一般需要带上message参数,方便自定义错误信息,比如前面例子中的“@NotNull(message = "项目名称不可为空")”。而这个message参数是支持EL表达式的
@NotNull(message = "${member.id.null}") 

再定义一个比如叫做 messages.properties 的配置文件来统一管理错误信息

member.id.null=用户编号不能为空
  1. 约束规则支持正则表达式。具体写法本文略

  2. 支持跨参数校验(即通过直接写注解的方式验证多个参数之间的逻辑关系)

三、需要注意的坑

注解不能(只)放在实现类上

还是之前举的例子,如果是这么写的话

public interface IProjectService {
    /**
     * 项目创建
     */
    CreateProjectRespVO createProject(CreateProjectReqVO reqVO);

@Service
public class ProjectServiceImpl implements IProjectService {

    /**
     * 项目创建
     */
    @Override
    public CreateProjectRespVO createProject(@Valid CreateProjectReqVO reqVO) {
    ……
    

在进行校验时会发生“javax.validation.ConstraintDeclarationException”异常(注意跟校验不通过发生的异常不是一个)

解决方法:

@Override父类/接口的方法,入参约束只能写在父类/接口上面。

或者两边都写上也可(但是这样维护时容易出问题,不推荐)。

另外 @Validated 这个注解写在哪边都可以。

四、如何优雅地处理校验结果

知道怎么写注解了,这事还不算完。

使用上面的方式做校验,当校验不通过时,是通过抛出异常(ConstraintViolationException)进行反馈的。所以异常怎么处理也是必须要考虑的问题。

尤其是需要向调用方正常返回处理结果的时候,无论如何也不能直接返一个e.printStackTrace()吧。

否则就白“优雅”了。

4-1、方法一:改为手动触发校验

首先,去掉类上面的@Validated注解,将自动触发改为手动触发。

然后,为了全工程公用,注入一个校验器

@Configuration
public class ProjectBeanFactory {
    @Bean(name = "projectValidator")
    public Validator getValidator() {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        return factory.getValidator();
    }
}

之后,追加一个公共方法

    @Autowired
    @Qualifier("projectValidator")
    private Validator validator;


    /**
     * 根据VO注解检查入参
     */
    private <T> RespVO checkParam(T t) {
        RespVO result = new RespVO();
        // 参数校验
        Set<ConstraintViolation<T>> violations = validator.validate(t);
        if (violations.size() > 0) {
            Set<String> messages = new HashSet<>(violations.size());
            result.setResult(RespCodeEnum.PARAMS_CHECK_FAIL.getCode());
            messages.addAll(violations.stream()
                    .map(violation -> String.format("%s ( '%s' ): %s", violation.getPropertyPath().toString(),
                            violation.getInvalidValue(), violation.getMessage()))
                    .collect(Collectors.toList()));
            result.setMsg(messages.toString());
            return result;
        }
        result.setResult(RespCodeEnum.SUCCESS.getCode());
        result.setMsg(RespCodeEnum.SUCCESS.getDesc());
        return result;
    }

在需要做参数校验的方法里面,调用这个共通处理即可

  • 缺点:无法校验简单类型参数(非Bean的参数);无法校验返回值

4-2、方法二:自定义ExceptionHandler拦截校验异常

@ControllerAdvice
public class ValidationExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(ConstraintViolationException.class)
    @ResponseBody
    ResponseEntity<Set<String>> handleConstraintViolation(ConstraintViolationException e) {
        Set<ConstraintViolation<?>> constraintViolations = e.getConstraintViolations();

        Set<String> messages = new HashSet<>(constraintViolations.size());
        messages.addAll(constraintViolations.stream()
                .map(violation -> String.format("%s ( '%s' ): %s", violation.getPropertyPath().toString(),
                        violation.getInvalidValue(), violation.getMessage()))
                .collect(Collectors.toList()));

        return new ResponseEntity<>(messages, HttpStatus.BAD_REQUEST);
    }

}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,132评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,802评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,566评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,858评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,867评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,695评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,064评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,705评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,915评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,677评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,796评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,432评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,041评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,992评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,223评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,185评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,535评论 2 343

推荐阅读更多精彩内容