红茶的个人站点

  • 首页
  • 专栏
  • 开发工具
  • 其它
  • 隐私政策
Awalon
Talk is cheap,show me the code.
  1. 首页
  2. 专栏
  3. Spring Boot 学习笔记
  4. 正文

从零开始 Spring Boot 13:参数校验

2022年7月31日 1013点热度 0人点赞 0条评论

spring boot

图源:简书 (jianshu.com)

我在上篇文章从零开始 Spring Boot 12:接收请求 - 魔芋红茶's blog (icexmoon.cn)中介绍了如何在Spring Boot构建的Web应用中接收HTTP请求附带的参数。

在Web开发中很重要的一点是——不要相信客户端。

因为HTTP客户端是位于服务端开发之外的,其安全性是难以掌控的,关于HTTP客户端安全方面最广泛的问题之一就是应当使用Session而非Cookie,因为前者是服务端存储技术,后者是客户端存储技术,而客户端存储的数据更容易被伪造。

同样的,客户端发来的请求也应当进行各种形式的安全验证,因为篇幅关系,本文只讨论传入参数的合法性验证。

添加依赖

Spring Boot官方提供了参数校验所需组件,只要添加相应的starter依赖即可:

        <!-- 参数校验 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

添加后就可以使用各种注解来完成参数校验工作。

使用注解

路径参数

首先我们看对路径参数进行验证:

    @ApiOperation("获取书籍详情")
    @GetMapping("/book/detail/{id}")
    public GetBookInfoVO getBookInfo(@PathVariable Integer id) {
        ...
    }

因为路径参数是URL的一部分,所以如果不传路径参数或者路径参数类型与目标类型不符,比如:

http://localhost:8080/book/detail/xxx

都会报错,只不过报错内容不同。

所以路径参数可以看作是强制性的必传参数。

所以这里我们不需要检测路径参数为Null的情况,只需要检查路径参数的值是不是在合理范围。比如这里,显然作为自增主键,书籍id都是正整数,这里就可以这样:

    @ApiOperation("获取书籍详情")
    @GetMapping("/book/detail/{id}")
    public GetBookInfoVO getBookInfo(@Min(1) @PathVariable Integer id) {
        ...
    }

注解@Min可以规定所修饰变量的最小值,因此@Min(1)意味着id必须大于等于1。

如果此时运行程序并请求http://localhost:8080/book/detail/0,你会发现@Min注解并未生效。这是因为默认情况下Controller的处理器方法并不会开启参数校验,如果要开启,需要在给Controller添加@Validated注解:

@RestController
@RequestMapping("")
@Api(tags = "书籍管理")
@Validated
public class BookController {
    ...
}

现在请求http://localhost:8080/book/detail/0就会看到

{
    "timestamp": "2022-07-26T09:33:32.901+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "trace": "javax.validation.ConstraintViolationException: getBookInfo.id: 最小不能小于1\r\n\tat org.springframework.validation.beanvalidation..."
}

这说明@Min注解生效了,当然这样的错误输出显然是不友好的,需要添加异常拦截器将其转换为我们定义的标准错误输出:

    /**
     * 拦截参数校验异常
     *
     * @param exp
     * @return
     */
    @ExceptionHandler(ConstraintViolationException.class)
    public Result doHandleResultException(ConstraintViolationException exp, Model model) {
        exp.printStackTrace();
        String msg = exp.getMessage();
        return Result.fail(Result.ErrorCode.PARAM_CHECK, msg);
    }

现在再请求就能看到返回标准错误输出了:

{
    "success": false,
    "msg": "getBookInfo.id: 最小不能小于1",
    "data": null,
    "code": "PARAM_CHECK"
}

表单提交

类似的,对于表单提交,我们同样可以添加参数校验:

    @PostMapping("/book/form/add")
    @ApiOperation("添加书籍(使用表单)")
    public Result addBookByForm(@NotBlank @RequestParam String name,
                                @NotBlank @RequestParam String desc) {
        ...
    }

这里的注解@NotBlank是给其修饰的形参添加上“非空字符串”这样的校验逻辑,相当于name!=null && !name.isEmpty()。对字符串类型参数使用@NotBlank后就不需要再添加@NotNull注解。

如果不使用参数提交表单,服务端就会抛出一个MissingServletRequestParameterException异常,因此同样需要在全局异常拦截中进行处理:

    @ExceptionHandler(MissingServletRequestParameterException.class)
    public Result doHandleResultException(MissingServletRequestParameterException exp, Model model){
        exp.printStackTrace();
        String msg = exp.getMessage();
        return Result.fail(Result.ErrorCode.PARAM_CHECK, msg);
    }

JSON

上文说了,POST传参更常见的是通过请求报文体传输JSON格式的字符串,这里看对于这种类型的传参如何添加参数校验:

    @Data
    private static class AddBookDTO {
        @NotBlank
        @ApiModelProperty("书籍名称")
        private String name;
        @ApiModelProperty("书籍说明")
        @NotBlank
        private String desc;
    }
​
    @RequiresRoles("manager")
    @PostMapping("/book/add")
    @ApiOperation("添加书籍")
    public Result addBook(@Validated @RequestBody AddBookDTO dto) {
        ...
    }

与之前类似,只要将@NotBlank注解用于解析JSON数据的相应类属性即可。需要注意的是,要在接收JSON解析后数据的DTO类形参前添加一个@Validated注解,这样相应类中作用于具体属性的参数校验注解才能生效。Controller中充当处理器的方法中参数的@Validated注解的用途就是表明“对应类型中的参数校验注解生效”。

同样的,为了格式化输出,需要拦截相应的异常并处理:

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result doHandleCheckException(MethodArgumentNotValidException exp, Model model) {
        String msg = "请求参数出错";
        //如果存在错误信息,写入第一条错误的信息
        List<ObjectError> errors = exp.getAllErrors();
        System.out.println(exp.getLocalizedMessage());
        if (errors.size() > 0) {
            ObjectError error = errors.get(0);
            if (error != null) {
                //获取错误字段名
                String fieldName = "";
                Object[] args = error.getArguments();
                if (args != null && args.length > 0) {
                    Object arg = args[0];
                    if (arg instanceof DefaultMessageSourceResolvable) {
                        DefaultMessageSourceResolvable dmr = (DefaultMessageSourceResolvable) arg;
                        fieldName = dmr.getDefaultMessage();
                    }
                }
                msg = fieldName + error.getDefaultMessage();
            }
        }
        return Result.fail(Result.ErrorCode.PARAM_CHECK, msg);
    }

因为原始的exp.getLocalizedMessage()中包含了大量内容,所以这里进行了一些处理,仅输出关键信息。

下面看一个更复杂一些的例子,假如我们需要在应用中对查询到的结果分页,那就需要在请求接口时指定分页条数和当前页码,而这种机构肯定是可以复用的,所以我们可能会创建下面这这样的类型:

@Data
public class PageRequest {
    @ApiModelProperty("当前页码")
    @Min(1)
    @NotNull
    private Integer current;
    @ApiModelProperty("每页分页数据条数")
    @Min(1)
    @NotNull
    private Integer paging;
}

为了让输入的内容合法,这里给PageRequest添加了一些验证用的注解,以确保页码和数据条数都是正整数。

为了演示,给Controller编写一个方法作为处理器:

    @Data
    private static class GetPagedBooksDTO {
        @NotNull
        private PageRequest pageRequest;
    }
​
    @PostMapping("/book/page")
    @ApiOperation("获取分页的书籍列表")
    public Result getPagedBooks(@Validated @RequestBody GetPagedBooksDTO dto) {
        log.info("current:" + dto.getPageRequest().getCurrent());
        log.info("paging:" + dto.getPageRequest().getPaging());
        return Result.success();
    }

这里仅用于说明参数校验,所以并没有实际的返回分页数据的逻辑,相关内容会在后续介绍Mybatis Plus进阶内容时说明。

看上去没有什么问题,但如果实际测试就会发现,类似下面这样的调用方式也会通过参数校验:

image-20220731085919957

这是因为在getPagedBooks方法参数签名中添加的@Validated注解仅仅会“启用”GetPagedBooksDTO类中相应属性的参数校验注解,比如GetPagedBooksDTO.pageRequest就因为@NotNull注解的存在不能为null。但是这样做并不会让PageRequest中的参数校验注解生效,如果要让PageRequest中的注解生效,就需要在属性pageRequest上添加上@Valid注解:

    @Data
    private static class GetPagedBooksDTO {
        @NotNull
        @Valid
        private PageRequest pageRequest;
    }

再尝试就会看到以下的返回信息:

{
    "success": false,
    "msg": "pageRequest.current不能为null",
    "data": null,
    "code": "400"
}

并且现在只有传输页码和分页条数都是正整数的参数才能通过验证。而如果有其他需要返回分页的接口也只需要依样画葫芦就行,这样就可以很高效地复用入参的分页部分代码。

正则

前边介绍了一些简单的用于参数校验的注解,实际上参数校验注解还支持正则表达式,利用它我们就可以实现自己定义的输入参数验证,这里用一些常见的输入用正则举例:

public class RegexpUtil {
    //大陆地区手机号(允许为空字符串)
    public static final String PHONE = "^(1[3456789]\\d{9}|)$";
    //手机号验证出错的提示信息
    public static final String MSG_PHONE = "不是合法的手机号";
    //大陆地区车牌号
    public static final String PLATE = "^(([京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领][A-Z](([0-9]{5}[DF])|([DABCEFGHJK]([A-HJ-NP-Z0-9])[0-9]{4})))|([京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领][A-Z][A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9挂学警港澳使领]))$";
    //姓名(包含译名)
    public static final String REAL_NAME = "^[\u4E00-\u9FA5]{2,10}(·[\u4E00-\u9FA5]{2,10}){0,2}$";
    public static final String PURE_NUMBER = "^\\d+$";
    public static final String IP = "^((2(5[0-5]|[0-4]\\d))|[0-1]?\\d{1,2})(\\.((2(5[0-5]|[0-4]\\d))|[0-1]?\\d{1,2})){3}$";
    //日期
    public static final String TIME = "\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}";
    public static final String DATE = "\\d{4}-\\d{2}-\\d{2}";
    public static final String TIME_NO_SECONDS = "\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}";
}

假设我们需要一个用户注册接口:

    @Data
    private static class AddUserDTO{
        @ApiModelProperty(value = "手机号",required = true)
        @NotBlank
        @Pattern(regexp = RegexpUtil.PHONE, message = RegexpUtil.MSG_PHONE)
        private String phone;
    }
​
    @ApiOperation("注册账号")
    @PostMapping("/user/add")
    public Result addUser(@Validated @RequestBody AddUserDTO dto){
        return Result.success();
    }

这里@Pattern就是一个使用指定正则表达式进行参数校验的注解,其中value属性指定的是用于检验的正则表达式,message属性指定的是校验失败后的提示信息(包含在相应异常中)。

此时如果用错误的手机号调用接口,比如:

{
    "phone": "123456"
}

就不会通过参数校验,会返回有错误提示的信息。

需要说明的是,有时候对于非必传参数,客户端会传输空字符串,这时候正则就需要能让空字符串通过检验,这也是为什么我这里的手机号正则是^(1[3456789]\\d{9}|)$,分组第二部分|)正是为了让空字符串通过校验。

如果手机号是可选参数,参数校验部分就需要这么写:

    @Data
    private static class AddUserDTO {
        @ApiModelProperty(value = "手机号")
        @Pattern(regexp = RegexpUtil.PHONE, message = RegexpUtil.MSG_PHONE)
        private String phone;
    }
​
    @ApiOperation("注册账号")
    @PostMapping("/user/add")
    public Result addUser(@Validated @RequestBody AddUserDTO dto) {
        if (dto.getPhone() == null) {
            dto.setPhone("");
        }
        return Result.success();
    }

这样,入参中的phone就可以是空字符串、null或不传,而服务端会统一处理成空字符串以方便后续处理。

关于参数校验的部分就说到这里了,本文依然以实际示例为主,并没有介绍完整的参数校验注解,关于更多的参数校验内容可以阅读SpringBoot 如何进行参数校验,老鸟们都这么玩的!-阿里云开发者社区 (aliyun.com)。

本文完整的最终示例代码见learn_spring_boot/ch13。

谢谢阅读。

参考资料

  • SpringBoot 如何进行参数校验,老鸟们都这么玩的!-阿里云开发者社区 (aliyun.com)

本作品采用 知识共享署名 4.0 国际许可协议 进行许可
标签: spring boot 参数校验
最后更新:2022年8月29日

魔芋红茶

加一点PHP,加一点Go,加一点Python......

点赞
< 上一篇
下一篇 >

文章评论

取消回复

*

code

COPYRIGHT © 2021 icexmoon.cn. ALL RIGHTS RESERVED.
本网站由提供CDN加速/云存储服务

Theme Kratos Made By Seaton Jiang

宁ICP备2021001508号

宁公网安备64040202000141号