SpringBoot 如何自定义请求参数校验

2022-07-21,,,,

目录
  • 一、bean validation基本概念

    最近在工作中遇到写一些api,这些api的请求参数非常多,嵌套也非常复杂,如果参数的校验代码全部都手动去实现,写起来真的非常痛苦。

    正好spring轮子里面有一个validation,这里记录一下怎么使用,以及怎么自定义它的返回结果。

    一、bean validation基本概念

    bean validation是java中的一项标准,它通过一些注解表达了对实体的限制规则。通过提出了一些api和扩展性的规范,这个规范是没有提供具体实现的,希望能够constrain once, validate everywhere。现在它已经发展到了2.0,兼容java8。

    hibernate validation实现了bean validation标准,里面还增加了一些注解,在程序中引入它我们就可以直接使用。

    spring mvc也支持bean validation,它对hibernate validation进行了二次封装,添加了自动校验,并将校验信息封装进了特定的bindingresult类中,在springboot中我们可以添加implementation(‘org.springframework.boot:spring-boot-starter-validation')引入这个库,实现对bean的校验功能。

    二、基本用法

    gradle dependencies如下:

    dependencies {
        implementation('org.springframework.boot:spring-boot-starter-validation')
        implementation('org.springframework.boot:spring-boot-starter-web')
    }
    

    定义一个示例的bean,例如下面的user.java。

    public class user {
        @notblank
        @size(max=10)
        private string name;
        private string password;
        public string getname() {
            return name;
        }
        public void setname(string name) {
            this.name = name;
        }
        public string getpassword() {
            return password;
        }
        public void setpassword(string password) {
            this.password = password;
        }
    }
    

    在name属性上,添加@notblank和@size(max=10)的注解,表示user对象的name属性不能为字符串且长度不能超过10个字符。

    然后我们暂时不添加任何多余的代码,直接写一个usercontroller对外提供一个restful的get接口,注意接口的参数用到了@validated注解。

    // usercontroller.java,省略其他代码
    @restcontroller
    public class usercontroller {
        @requestmapping(value = "/validation/get", method = requestmethod.get)
        public serviceresponse validateget(@validated user user) {
            serviceresponse serviceresponse = new serviceresponse();
            serviceresponse.setcode(0);
            serviceresponse.setmessage("test");
            return serviceresponse;
        }
    }
    // serviceresponse.java,简单包含了code、message字段返回结果。
    public class serviceresponse {
        private int code;
        private string message;
        ... 省略getter、setter ...
    }
    

    启动springboot程序,发一个测试请求看一下:

    http://127.0.0.1:8080/validation/get?name=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&password=1

    此时已经可以实现参数的校验了,但是返回的结果不太友好,下面看一下怎么定制返回的消息。在定制返回结果前,先看下一下内置的校验注解有哪些,在这里我不一个个去贴了,写代码的时候根据需要进入到源码里面去看即可。

    早期spring版本中,都是在controller的方法中添加errors/bindingresult参数,由spring注入errors/bindingresult对象,再在controller中手写校验逻辑实现校验。

    新版本提供注解的方式(controller上面bean加一个@validated注解),将校验逻辑和controller分离。

    三、自定义校验

    3.1 自定义注解

    显然除了自带的notnull、notblank、size等注解,实际业务上还会需要特定的校验规则。

    假设我们有一个参数address,必须以beijing开头,那我们可以定义一个注解和一个自定义的validator。

    // startwithvalidator.java
    public class startwithvalidator implements constraintvalidator<startwithvalidation, string> {
        private string start;
        @override
        public void initialize(startwithvalidation constraintannotation) {
            start = constraintannotation.start();
        }
        @override
        public boolean isvalid(string value, constraintvalidatorcontext context) {
            if (!stringutils.isempty(value)) {
                return value.startswith(start);
            }
            return true;
        }
    }
    // startwithvalidation.java
    @documented
    @constraint(validatedby = startwithvalidator.class)
    @target({method, field})
    @retention(runtime)
    public @interface startwithvalidation {
        string message() default "不是正确的性别取值范围";
        string start() default "_";
        class[] groups() default {};
        class[] payload() default {};
        @target({method, field, annotation_type, constructor, parameter})
        @retention(runtime)
        @documented
        @interface list {
            gendervalidation[] value();
        }
    }
    

    然后在user.java中增加一个address属性,并给它加上上面这个自定义的注解,这里我们定义了一个可以传入start参数的注解,表示应该以什么开头。

    @startwithvalidation(message = "param 'address' must be start with 'beijing'.", start = "beijing")
    private string address;
    

    除了定义可以作用于属性的注解外,其实还可以定义作用于class的注解(@target({type})),用于校验class的实例。

    3.2 自定义validator

    第一步,实现一个validator。(这种方法不需要我们的bean里面有任何注解之类的东西)

    package com.example.validation.demo;
    import org.springframework.stereotype.component;
    import org.springframework.validation.errors;
    import org.springframework.validation.validationutils;
    import org.springframework.validation.validator;
    @component
    public class uservalidator implements validator {
        @override
        public boolean supports(class clazz) {
            return user2.class.equals(clazz);
        }
        @override
        public void validate(object target, errors errors) {
            validationutils.rejectifempty(errors, "name", "name.empty");
            user2 p = (user2) target;
            if (p.getid() == 0) {
                errors.rejectvalue("id", "can not be zero");
            }
        }
    }
    

    第二步,修改controller代码,注入上面的uservalidator实例,并给controller的方法参数加上@validated注解,即可完成和前面自定义注解一样的校验功能。

    @restcontroller
    public class usercontroller {
        @autowired
        uservalidator validator;
        @initbinder
        public void initbinder(webdatabinder binder) {
            binder.setvalidator(validator);
        }
        @requestmapping(value = "/user/post", method = requestmethod.post)
        public serviceresponse handvalidatepost(@validated @requestbody user user) {
            serviceresponse serviceresponse = new serviceresponse();
            serviceresponse.setcode(0);
            serviceresponse.setmessage("test");
            return serviceresponse;
        }
    }
    

    这个方法和自定义注解的区别在于不需要在bean里面添加注解,并且可以更加灵活的把一个bean里面所有的field的校验代码都搬到一起,而不是每一个属性都去加注解,如果校验的属性非常多,且默认注解的能力又不够的话,这种方式也是不错的,可以避免大量的自定义注解。

    3.3 以编程的方式校验(手动)

    这种方式可以算是原始的hibernate-validation的方式。直接看代码,这里有一个比较不同的是,可以使用hibernate-validation的fail fast mode。因为前面的方式,都将所有的参数都验证完了,再把错误返回。有时我们希望遇到一个参数错误,就立即返回。

    设置fast-fail为true可以达到这个目的。不过貌似不能再用@validated注解方法参数了,而是要用validatorfactory创建validator。

    在实际开发中,不必每次都编写代码创建validator,可以采用@configuration的方式创建,然后再@autowired注入到每个需要使用validator的controller当中。

    @restcontroller
    public class usercontroller {
        ...
        @requestmapping(value = "/validation/poststudent", method = requestmethod.post)
        public serviceresponse validatepoststudent(@requestbody user user) {
            // user参数前面没有@validated注解了,user类里面那些注解还是保留着即可。
            hibernatevalidatorconfiguration configuration = validation.byprovider(hibernatevalidator.class).configure();
            validatorfactory factory = configuration.failfast(true).buildvalidatorfactory(); // fastfail
            validator validator = factory.getvalidator();
            set<constraintviolation> set = validator.validate(user);
            // 根据set的size,大于0时,抛异常。由于设置了failfast,这里set最多就一个元素
            serviceresponse serviceresponse = new serviceresponse();
            serviceresponse.setcode(0);
            serviceresponse.setmessage("test");
            return serviceresponse;
        }
    }
    

    3.4 定义分组校验

    有的时候,我们会有两个不同的接口,但是会使用到同一个bean来作为vo(意思是两个接口的uri不同,但参数中都用到了同一个bean)。

    而在不同的接口上,对bean的校验需求可能不一样,比如接口2需要校验studentid,而接口1不需要。那么此时就可以用到校验注解的分组groups。

    // user.java
    public class user {
        ... 省略其他属性
        // 指明在groups={student.class}时才需要校验studentid
        @notnull(groups = {student.class}, message = "param 'studentid' must not be null.")
        private long studentid;
        // 增加student interface
        public interface student {
        }
    }
    // usercontroller.java,增加了一个/getstudent接口
    @restcontroller
    public class usercontroller {
        @requestmapping(value = "/validation/get", method = requestmethod.get)
        public serviceresponse validateget(@validated user user) {
            serviceresponse serviceresponse = new serviceresponse();
            serviceresponse.setcode(200);
            serviceresponse.setmessage("test");
            return serviceresponse;
        }
        @requestmapping(value = "/validation/getstudent", method = requestmethod.get)
        public serviceresponse validategetstudent(@validated({user.student.class}) user user) {
            serviceresponse serviceresponse = new serviceresponse();
            serviceresponse.setcode(0);
            serviceresponse.setmessage("test");
            return serviceresponse;
        }
    }
    

    到这里,也可以带一嘴valid和validated注解的区别,其代码注释写着后者是对前者的一个扩展,支持了group分组的功能。

    3.5 定制返回码和消息

    第二节中定义了一个serviceresponse,其实作为一个开放的api,不论用户传入任何参数,返回的结果都应该是预先定义好的格式,并且可以写明在接口文档中,即使发生了校验失败,应该返回一个包含错误码code(发生错误时一般大于0)和message字段。

    {
        "code": 51000,
        "message": "param 'name' must be less than 10 characters."
    }
    

    的结果,而http status code一直都是200。

    为了实现这个目的,我们加一个全局异常处理方法。

    // serviceexceptionhandler.java
    package com.example.validation.demo;
    import org.slf4j.logger;
    import org.slf4j.loggerfactory;
    import org.springframework.util.collectionutils;
    import org.springframework.validation.bindexception;
    import org.springframework.validation.fielderror;
    import org.springframework.web.bind.methodargumentnotvalidexception;
    import org.springframework.web.bind.annotation.exceptionhandler;
    import org.springframework.web.bind.annotation.restcontrolleradvice;
    import java.util.list;
    @restcontrolleradvice
    public class serviceexceptionhandler {
        static final logger log = loggerfactory.getlogger(serviceexceptionhandler.class);
        @exceptionhandler(value = {exception.class})
        public serviceresponse handlebindexception(exception ex) {
            log.error("{}", ex);
            stringbuilder message = new stringbuilder();
            if (ex instanceof bindexception) {
                list fielderrorlist = ((bindexception) ex).getfielderrors();
                if (!collectionutils.isempty(fielderrorlist)) {
                    for (fielderror fielderror : fielderrorlist) {
                        if (fielderror != null && fielderror.getdefaultmessage() != null) {
                            message.append(fielderror.getdefaultmessage()).append(" ");
                        }
                    }
                }
            } else if (ex instanceof methodargumentnotvalidexception) {
                list fielderrorlist = ((methodargumentnotvalidexception) ex).getbindingresult().getfielderrors();
                if (!collectionutils.isempty(fielderrorlist)) {
                    for (fielderror fielderror : fielderrorlist) {
                        if (fielderror != null && fielderror.getdefaultmessage() != null) {
                            message.append(fielderror.getdefaultmessage()).append(" ");
                        }
                    }
                }
            }
            // 生成返回结果
            serviceresponse errorresult = new serviceresponse();
            errorresult.setcode(51000); // errorcode.param_error = 51000
            errorresult.setmessage(message.tostring());
            return errorresult;
        }
    }
    // user.java,注解传入指定message
    public class user {
        @notblank(message = "param 'name' can't be blank.")
        @size(max=10, message = "param 'name' must be less than 10 characters.")
        private string name;
        ...
    }
    

    在上面的方法中,我们处理了bindexception(非请求body参数,例如@requestparam接收的)和methodargumentnotvalidexception(请求body里面的参数,例如@requestbody接收的),这两类exception里面都有一个bindingresult对象,它里面有一个包装成fielderror的list,保存着bean对象出现错误的field等信息。

    取出它里面defaultmessage,放到统一的serviceresponse返回即可实现返回码和消息的定制。由于消息内容是有注解默认的defaultmessage决定的,为了按照自定义的描述返回,在bean对象的注解上需要手动赋值为希望返回的消息内容。

    @notblank(message = "param 'name' can't be blank.")
    @size(max=10,message = "param 'name' must be less than 10 characters.")
    private string name;
    

    这样当name参数长度超过10时,就会返回

    {
        "code": 51000,
        "message": "param 'name' must be less than 10 characters."
    }
    

    这里的fielderror fielderror = ex.getfielderror();只会随机返回一个出错的属性,如果bean对象的多个属性都出错了,可以调用ex.getfielderrors()来获得,这里也可以看到spring validation在参数校验时不会在第一次碰到参数错误时就返回,而是会校验完成所有的参数。

    如果不想手动编程去校验,那么这里可以只读取一个随机的fielderror,返回它的错误消息即可。

    3.6 更加细致的返回码和消息

    其实还有一种比较典型的自定义返回,就是错误码(code)和消息(message)是一一对应的,比如:

    • 51001:字符串长度过长
    • 51002:参数取值过大

    这种情况比较特殊,一般当参数错误的时候,会返回一个整体的参数错误的错误码,然后携带参数的错误信息。但有时,业务

    上就要不同的参数错误,既要错误码不同,错误信息也要不同。我想了下,有两种思路。

    • 第一种:通过message同时包含错误码和错误信息,在全局异常捕获方法中,再把它们拆开。
    • 第二种:手动校验,抛出自定义的exception(里面带有code、message)。手动校验这里,如果每一个controller都去写一遍,确实比较费劲,可以结合aop来实现,或者抽出一个基类basecontroller的方式。

    四、小结

    其实在实际的工作中,肯定还有更复杂的校验逻辑,但是不一定非要都用框架去实现,框架里面的实现(比如注解)应该是一个比较简单通用的校验,能够达到复用,减少重复的劳动。

    而更加复杂的逻辑校验,一定是存在具体业务当中的,最好是在业务代码里面实现。

    还有一点需要注意,spring validation的isvalid方法,如果返回false,那么controller不再会被调用,而是直接返回。如果你在controller上面加了aop进行接口调用统计的话,可能会漏掉。

    这个时候,我们不应该让controller不调用,建议这种情况在aop里面对controller的参数切面进行校验后,抛出统一的业务异常。

    以上为个人经验,希望能给大家一个参考,也希望大家多多支持。

    《SpringBoot 如何自定义请求参数校验.doc》

    下载本文的Word格式文档,以方便收藏与打印。