throwable

这是公众号《Throwable文摘》发布的第22篇原创文章,暂时收录于专辑《架构与实战》。暂定下一篇发布的长文是《图文分析JUC同步器框架》,下一篇发布的短文是《SpringBoot2.x入门:引入jdbc模块与JdbcTemplate简单使用》。

前提

在日常使用SpringMVC进行开发的时候,有可能遇到前端各种类型的请求参数,这里做一次相对全面的总结。SpringMVC中处理控制器参数的接口是HandlerMethodArgumentResolver,此接口有众多子类,分别处理不同(注解类型)的参数,下面只列举几个子类:

  • RequestParamMethodArgumentResolver:解析处理使用了@RequestParam注解的参数、MultipartFile类型参数和Simple类型(如longint等类型)参数。
  • RequestResponseBodyMethodProcessor:解析处理@RequestBody注解的参数。
  • PathVariableMapMethodArgumentResolver:解析处理@PathVariable注解的参数。

实际上,一般在解析一个控制器的请求参数的时候,用到的是HandlerMethodArgumentResolverComposite,里面装载了所有启用的HandlerMethodArgumentResolver子类。而HandlerMethodArgumentResolver子类在解析参数的时候使用到HttpMessageConverter(实际上也是一个列表,进行遍历匹配解析)子类进行匹配解析,常见的如MappingJackson2HttpMessageConverter(使用Jackson进行序列化和反序列化)。

spmvc-p-14

HandlerMethodArgumentResolver子类到底依赖什么HttpMessageConverter实例实际上是由请求头中的Content-Type(在SpringMVC中统一命名为MediaType,见org.springframework.http.MediaType)决定的,因此我们在处理控制器的请求参数之前必须要明确外部请求的Content-Type到底是什么。上面的逻辑可以直接看源码AbstractMessageConverterMethodArgumentResolver#readWithMessageConverters,思路是比较清晰的。在@RequestMapping注解中,producesconsumes属性就是和请求的Accept或者响应的Content-Type相关的:

  • consumes属性:指定处理请求的提交内容类型(Content-Type),例如application/jsontext/html等等,只有命中了对应的Content-Type的值才会接受该请求。
  • produces属性:指定返回的内容类型,仅当某个请求的请求头中的(Accept)类型中包含该指定类型才返回,如果返回的是JSON数据一般考虑使用application/json;charset=UTF-8

另外提一点,SpringMVC中默认使用Jackson作为JSON的工具包,如果不是完全理解透整套源码的运作,一般不是十分建议修改默认使用的MappingJackson2HttpMessageConverter(例如有些人喜欢使用FastJson,实现HttpMessageConverter引入FastJsonHTTP消息转换器,其实这种做法并不推荐)。

SpringMVC请求参数接收

其实一般的表单或者JSON数据的请求都是相对简单的,一些复杂的处理主要包括URL路径参数、文件上传、数组或者列表类型数据等。另外,关于参数类型中存在日期类型属性(例如java.util.Datejava.sql.Datejava.time.LocalDatejava.time.LocalDateTimejava.time.ZonedDateTime等等),解析的时候一般需要自定义实现的逻辑实现String-->日期类型的转换。其实道理很简单,日期相关的类型对于每个国家、每个时区甚至每个使用者来说认知都不一定相同,所以SpringMVC并没有对于日期时间类型的解析提供一个通用的解决方案。在演示一些例子可能用到下面的模特类:

@Data
public class User {

    private String name;
    private Integer age;
    private List<Contact> contacts;
}

@Data
public class Contact {

    private String name;
    private String phone;
}

下面主要以HTTPGET方法和POST方法提交在SpringMVC体系中正确处理参数的例子进行分析,还会花精力整理SpringMVC体系中独有的URL路径参数处理的一些技巧以及最常见的日期参数处理的合理实践(对于GET方法和POST方法提交的参数处理,基本囊括了其他如DELETEPUT等方法的参数处理,随机应变即可)。

GET方法请求参数处理

HTTP(s)协议使用GET方法进行请求的时候,提交的参数位于URL模式的Query部分,也就是URL?标识符之后的参数,格式是key1=value1&key2=value2GET方法请求参数可以有多种方法获取:

  1. 使用@RequestParam注解处理。
  2. 使用对象接收,注意对象的属性名称要和Query中的参数名称一致。
  3. 使用HttpServletRequest实例提供的方法(不推荐,存在硬编码)。

假设请求的URLhttp://localhost:8080/get?name=doge&age=26,那么控制器如下:

@Slf4j
@RestController
public class SampleController {

    @GetMapping(path = "/get1")
    public void get1(@RequestParam(name = "name") String name,
                     @RequestParam(name = "age") Integer age) {
        log.info("name:{},age:{}", name, age);
    }

    @GetMapping(path = "/get2")
    public void get2(UserVo vo) {
        log.info("name:{},age:{}", vo.getName(), vo.getAge());
    }

    @GetMapping(path = "/get3")
    public void get3(HttpServletRequest request) {
        String name = request.getParameter("name");
        String age = request.getParameter("age");
        log.info("name:{},age:{}", name, age);
    }

    @Data
    public static class UserVo {

        private String name;
        private Integer age;
    }
}

表单参数

表单参数,一般对应于页面上<form>标签内的所有<input>标签的name-value聚合而成的参数,一般Content-Type指定为application/x-www-form-urlencoded,表单参数值也就是会进行(URL)编码。下面介绍几种常见的表单参数提交的参数形式。

  • 【非对象】- 非对象类型单个参数接收。

spmvc-p-1

对应的控制器如下:

@PostMapping(value = "/post")
public String post(@RequestParam(name = "name") String name,
                   @RequestParam(name = "age") Integer age) {
    String content = String.format("name = %s,age = %d", name, age);
    log.info(content);
    return content;
}

说实话,如果有毅力的话,所有的复杂参数的提交最终都可以转化为多个单参数接收,不过这样做会产生十分多冗余的代码,而且可维护性比较低。这种情况下,用到的参数处理器是RequestParamMapMethodArgumentResolver

  • 【对象】 - 对象类型参数接收。

我们接着写一个接口用于提交用户信息,用到的是上面提到的模特类,主要包括用户姓名、年龄和联系人信息列表,这个时候,我们目标的控制器最终编码如下:

@PostMapping(value = "/user")
public User saveUser(User user) {
    log.info(user.toString());
    return user;
}

加入强行指定Content-Typeapplication/x-www-form-urlencoded,需要构造请求参数格式如下:

spmvc-p-2

因为没有使用注解,最终的参数处理器为ServletModelAttributeMethodProcessor,主要是把HttpServletRequest中的表单参数封装到MutablePropertyValues实例中,再通过参数类型实例化(通过构*射创建User实例),反射匹配属性进行值的填充。另外,请求复杂参数里面的列表属性请求参数看起来比较奇葩,实际上和在.properties文件中添加最终映射到Map类型的参数的写法是一致的,所以对于嵌套数组或者列表类型的第一层索引要写成firstLevel[index].fieldName的形式。那么,能不能把整个请求参数塞在一个字段中提交呢?

spmvc-p-3

直接这样做是不行的,因为实际提交的Form表单,keyuser字符串,value实际上也是一个字符串,缺少一个String->User类型的转换器,实际上RequestParamMethodArgumentResolver依赖WebConversionServiceConverter实例列表进行参数转换,而默认的Converter列表中肯定不会存在自定义转换String->User类型的转换器:

spmvc-p-4

解决办法还是有的,添加一个自定义的org.springframework.core.convert.converter.Converter实现即可:

@Component
public class StringUserConverter implements Converter<String, User> {

    @Autowaired
    private ObjectMapper objectMapper;

    @Override
    public User convert(String source) {
        try {
               return objectMapper.readValue(source, User.class);
            } catch (IOException e) {
               throw new IllegalArgumentException(e);
        }
    }
}

上面这种做法属于曲线救国的做法,不推荐使用在生产环境,但是如果有些第三方接口的对接无法避免这种参数(这个还真碰到多,有一些远古的遗留系统比较容易出现各种奇葩的操作),可以选择这种实现方式。

  • 【数组】 - 列表或者数组类型参数。

极度不推荐使用在application/x-www-form-urlencoded这种媒体类型的表单提交的形式下强行使用列表或者数组类型参数,除非是为了兼容处理历史遗留系统的参数提交处理。例如提交的参数形式是:

list = ["string-1", "string-2", "string-3"]

那么表单参数的形式要写成:

name value
list[0] string-1
list[1] string-2
list[2] string-3

控制器的代码如下:

@PostMapping(path = "/list")
public void list(@RequestParam(name="list") List<String> list) {
    log.info(list);
}

一个更加复杂的例子如下,假设想要提交的报文格式如下:

user = [{"name":"doge-1","age": 21},{"name":"doge-2","age": 22}]

那么表单参数的形式要写成:

name value
user[0].name doge-1
user[0].age 21
user[1].name doge-2
user[1].age 22

控制器的代码如下:

@PostMapping(path = "/user")
public void saveUsers(@RequestParam(name="user") List<UserVo> users) {
    log.info(users);
}

@Data
public class UserVo{

    private String name;
    private Integer age;
}

这种传参格式其实并不灵活,甚至有可能降低开发效率和参数可读性。

JSON参数

一般来说,直接在POST请求中的请求体提交一个JSON字符串这种方式对于SpringMVC来说是比较友好的,只需要把Content-Type设置为application/json,然后直接上传一个原始的JSON字符串即可,控制器方法参数使用@RequestBody注解处理:

spmvc-p-5

后端控制器的代码也比较简单:

@PostMapping(value = "/user-2")
public User saveUser2(@RequestBody User user) {
    log.info(user.toString());
    return user;
}

因为使用了@RequestBody注解,最终使用到的参数处理器为RequestResponseBodyMethodProcessor,实际上会用到MappingJackson2HttpMessageConverter进行参数类型的转换,底层依赖到Jackson相关的包。推荐使用这种方式,这是最常用也是最稳健的JSON参数处理方式

URL路径参数

URL路径参数,或者叫请求路径参数是基于URL模板获取到的参数,例如/user/{userId}是一个URL模板(URL模板中的参数占位符是{}),实际请求的URL/user/1,那么通过匹配实际请求的URLURL模板就能提取到userId为1。在SpringMVC中,URL模板中的路径参数叫做Path Variable,对应注解@PathVariable,对应的参数处理器为PathVariableMethodArgumentResolver注意一点是,@PathVariable的解析是按照value(name)属性进行匹配,和URL参数的顺序是无关的。举个简单的例子:

spmvc-p-6

后台的控制器如下:

@GetMapping(value = "/user/{name}/{age}")
public String findUser1(@PathVariable(value = "age") Integer age,
                        @PathVariable(value = "name") String name) {
    String content = String.format("name = %s,age = %d", name, age);
    log.info(content);
    return content;
}

这种用法被广泛使用于Representational State Transfer(REST)的软件架构风格,个人觉得这种风格是比较灵活和清晰的(从URL和请求方法就能完全理解接口的意义和功能)。下面再介绍两种相对特殊的使用方式。

  • 带条件的URL参数。

其实路径参数支持正则表达式,例如我们在使用/sex/{sex}接口的时候,要求sex必须是F(Female)或者M(Male),那么我们的URL模板可以定义为/sex/{sex:M|F},代码如下:

@GetMapping(value = "/sex/{sex:M|F}")
public String findUser2(@PathVariable(value = "sex") String sex){
    log.info(sex);
    return sex;
}

只有/sex/F或者/sex/M的请求才会进入findUser2()控制器方法,其他该路径前缀的请求都是非法的,会返回404状态码。这里仅仅是介绍了一个最简单的URL参数正则表达式的使用方式,更强大的用法可以自行摸索。

  • @MatrixVariable的使用。

MatrixVariable也是URL参数的一种,对应注解@MatrixVariable,不过它并不是URL中的一个值(这里的值指定是两个"/"之间的部分),而是值的一部分,它通过";"进行分隔,通过"="进行K-V设置。说起来有点抽象,举个例子:假如我们需要打电话给一个名字为doge,性别是男,分组是码畜的程序员,GET请求的URL可以表示为:/call/doge;gender=male;group=programmer,我们设计的控制器方法如下:

@GetMapping(value = "/call/{name}")
public String find(@PathVariable(value = "name") String name,
                   @MatrixVariable(value = "gender") String gender,
                   @MatrixVariable(value = "group") String group) {
    String content = String.format("name = %s,gender = %s,group = %s", name, gender, group);
    log.info(content);
    return content;
}

当然,如果你按照上面的例子写好代码,尝试请求一下该接口发现是报错的:400 Bad Request - Missing matrix variable 'gender' for method parameter of type String。这是因为@MatrixVariable注解的使用是不安全的,在SpringMVC中默认是关闭对其支持。要开启对@MatrixVariable的支持,需要设置RequestMappingHandlerMapping#setRemoveSemicolonContent方法为false

@Configuration
public class CustomMvcConfiguration implements InitializingBean {

    @Autowired
    private RequestMappingHandlerMapping requestMappingHandlerMapping;

    @Override
    public void afterPropertiesSet() throws Exception {
        requestMappingHandlerMapping.setRemoveSemicolonContent(false);
    }
}

除非有很特殊的需要,否则不建议使用@MatrixVariable

文件上传

文件上传在使用POSTMAN模拟请求的时候需要选择form-dataPOST方式进行提交:

spmvc-p-8

假设在电脑的磁盘D盘根目录有一个图片文件叫doge.jpg,现在要通过本地服务接口把文件上传,控制器的代码如下:

@PostMapping(value = "/file1")
public String file1(@RequestPart(name = "file1") MultipartFile multipartFile) {
    String content = String.format("name = %s,originName = %s,size = %d",
            multipartFile.getName(), multipartFile.getOriginalFilename(), multipartFile.getSize());
    log.info(content);
    return content;
}

控制台输出是:

name = file1,originName = doge.jpg,size = 68727

可能有点疑惑,参数是怎么来的,我们可以用Fildder软件抓个包看下:

spmvc-p-7

可知MultipartFile实例的主要属性分别来自Content-DispositionContent-TypeContent-Length,另外,InputStream用于读取请求体的最后部分(文件的字节序列)。参数处理器用到的是RequestPartMethodArgumentResolver(记住一点,使用了@RequestPartMultipartFile一定是使用此参数处理器)。在其他情况下,使用@RequestParamMultipartFile或者仅仅使用MultipartFile(参数的名字必须和POST表单中的Content-Disposition描述的name一致)也可以接收上传的文件数据,主要是通过RequestParamMethodArgumentResolver进行解析处理的,它的功能比较强大,具体可以看其supportsParameter方法,这两种情况的控制器方法代码如下:

@PostMapping(value = "/file2")
public String file2(MultipartFile file1) {
    String content = String.format("name = %s,originName = %s,size = %d",
            file1.getName(), file1.getOriginalFilename(), file1.getSize());
    log.info(content);
    return content;
}

@PostMapping(value = "/file3")
public String file3(@RequestParam(name = "file1") MultipartFile multipartFile) {
    String content = String.format("name = %s,originName = %s,size = %d",
            multipartFile.getName(), multipartFile.getOriginalFilename(), multipartFile.getSize());
    log.info(content);
    return content;
}

其他参数

其他参数主要包括请求头、CookieModelMap等相关参数,还有一些并不是很常用或者一些相对原生的属性值获取(例如HttpServletRequestHttpServletResponse或者它们内置的实例方法等)不做讨论。

请求头

请求头的值主要通过@RequestHeader注解的参数获取,参数处理器是RequestHeaderMethodArgumentResolver,需要在注解中指定请求头的Key。简单实用如下:

spmvc-p-9

控制器方法代码:

@PostMapping(value = "/header")
public String header(@RequestHeader(name = "Content-Type") String contentType) {
    return contentType;
}

Cookie的值主要通过@CookieValue注解的参数获取,参数处理器为ServletCookieValueMethodArgumentResolver,需要在注解中指定CookieKey。控制器方法代码如下:

@PostMapping(value = "/cookie")
public String cookie(@CookieValue(name = "JSESSIONID") String sessionId) {
    return sessionId;
}

Model类型参数

Model类型参数的处理器是ModelMethodProcessor,实际上处理此参数是直接返回ModelAndViewContainer实例中的Model(具体是ModelMap类型),因为要桥接不同的接口和类的功能,因此回调的实例是BindingAwareModelMap类型,此类型继承自ModelMap同时实现了Model接口。举个例子:

@GetMapping(value = "/model")
public String model(Model model, ModelMap modelMap) {
    log.info("{}", model == modelMap);
    return "success";
}

注意调用此接口,控制台输出INFO日志内容为:true。还要注意一点:ModelMap或者Model中添加的属性项会附加到HttpRequestServlet实例中带到页面中进行渲染,使用模板引擎的前提下可以直接在模板文件内容中直接使用占位符提取这些属性值。

@ModelAttribute参数

@ModelAttribute注解处理的参数处理器为ModelAttributeMethodProcessor@ModelAttribute的功能源码的注释如下:

Annotation that binds a method parameter or method return value to a named model attribute, exposed to a web view.

简单来说,就是通过key-value形式绑定方法参数或者方法返回值到Model(Map)中,区别下面三种情况:

  1. @ModelAttribute使用在方法(返回值)上,方法没有返回值(void类型), Model(Map)参数需要自行设置。
  2. @ModelAttribute使用在方法(返回值)上,方法有返回值(非void类型),返回值会添加到Model(Map)参数,key@ModelAttributevalue指定,否则会使用返回值类型字符串(首写字母变为小写,如返回值类型为Integer,则keyinteger)。
  3. @ModelAttribute使用在方法参数中,则可以获取同一个控制器中的已经设置的@ModelAttribute对应的值。

在一个控制器(使用了@ControllerSpring组件)中,如果存在一到多个使用了@ModelAttribute的方法,这些方法总是在进入控制器方法之前执行,并且执行顺序是由加载顺序决定的(具体的顺序是带参数的优先,并且按照方法首字母升序排序),举个例子:

@Slf4j
@RestController
public class ModelAttributeController {

    @ModelAttribute
    public void before(Model model) {
        log.info("before..........");
        model.addAttribute("before", "beforeValue");
    }

    @ModelAttribute(value = "beforeArg")
    public String beforeArg() {
        log.info("beforeArg..........");
        return "beforeArgValue";
    }

    @GetMapping(value = "/modelAttribute")
    public String modelAttribute(Model model, @ModelAttribute(value = "beforeArg") String beforeArg) {
        log.info("modelAttribute..........");
        log.info("beforeArg..........{}", beforeArg);
        log.info("{}", model);
        return "success";
    }

    @ModelAttribute
    public void after(Model model) {
        log.info("after..........");
        model.addAttribute("after", "afterValue");
    }

    @ModelAttribute(value = "afterArg")
    public String afterArg() {
        log.info("afterArg..........");
        return "afterArgValue";
    }
}

调用此接口,控制台输出日志如下:

after..........
before..........
afterArg..........
beforeArg..........
modelAttribute..........
beforeArg..........beforeArgValue
{after=afterValue, before=beforeValue, afterArg=afterArgValue, beforeArg=beforeArgValue}

可以印证排序规则和参数设置、获取的结果和前面的分析是一致的。

Errors或者BindingResult参数

Errors其实是BindingResult的父接口,BindingResult主要用于回调JSR参数校验异常的属性项,如果JSR303校验异常,一般会抛出MethodArgumentNotValidException异常,并且会返回400(Bad Request),见全局异常处理器DefaultHandlerExceptionResolverErrors类型的参数处理器为ErrorsMethodArgumentResolver。举个例子:

@PostMapping(value = "/errors")
public String errors(@RequestBody @Validated ErrorsModel errors, BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        for (ObjectError objectError : bindingResult.getAllErrors()) {
            log.warn("name={},message={}", objectError.getObjectName(), objectError.getDefaultMessage());
        }
    }
    return errors.toString();
}

//ErrorsModel
@Data
@NoArgsConstructor
public class ErrorsModel {

    @NotNull(message = "id must not be null!")
    private Integer id;

    @NotEmpty(message = "errors name must not be empty!")
    private String name;
}

调用接口控制台Warn日志如下:

name=errors,message=errors name must not be empty!

一般情况下,不建议用这种方式处理JSR校验异常的属性项,因为会涉及到大量的重复的硬编码工作,建议:方式一直接继承ResponseEntityExceptionHandler覆盖对应的方法或者方式二同时使用@ExceptionHandler@(Rest)ControllerAdvice注解进行异常处理。例如:

@RestControllerAdvice
public class ApplicationRestControllerAdvice{

    @ExceptionHandler(BusinessException.class)
    public Response handleBusinessException(BusinessException e, HttpServletRequest request){
        // 这里处理异常和返回值
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Response handleMethodArgumentNotValidException(MethodArgumentNotValidException e, HttpServletRequest request){
        // 这里处理异常和返回值
    }
}

值得注意的是,SpringBoot某个版本之后,把JSR303相关的依赖抽离到spring-boot-starter-validation依赖中,如果要使用JSR303相关相关校验功能,必须独立引入此starter

@Value参数

控制器方法的参数可以是@Value注解修饰的参数,会从Environment实例中装配和转换属性值到对应的参数中(也就是参数的来源并不是请求体,而是上下文中已经加载和处理完成的环境属性值),参数处理器为ExpressionValueMethodArgumentResolver。举个例子:

@GetMapping(value = "/value")
public String value(@Value(value = "${spring.application.name}") String name) {
    log.info("spring.application.name={}", name);
    return name;
}

spring.application.name属性一般在配置文件中指定,在加载配置文件属性的时候添加到全局的Environment中。

Map类型参数

Map类型参数的范围相对比较广,对应一系列的参数处理器,注意区别使用了上面提到的部分注解的Map类型和完全不使用注解的Map类型参数,两者的处理方式不相同。下面列举几个相对典型的Map类型参数处理例子。

不使用任何注解的Map<String,Object>参数

这种情况下参数实际上直接回调ModelAndViewContainer中的ModelMap实例,参数处理器为MapMethodProcessor,往Map参数中添加的属性将会带到页面中。

使用@RequestParam注解的Map<String,Object>参数

这种情况下的参数处理器为RequestParamMapMethodArgumentResolver,使用的请求方式需要指定Content-Typex-www-form-urlencoded,不能使用application/json的方式:

spmvc-p-10

控制器代码为:

@PostMapping(value = "/map")
public String mapArgs(@RequestParam Map<String, Object> map) {
    log.info("{}", map);
    return map.toString();
}

使用@RequestHeader注解的Map<String,Object>参数

这种情况下的参数处理器为RequestHeaderMapMethodArgumentResolver,作用是获取请求的所有请求头的Key-Value

使用@PathVariable注解的Map<String,Object>参数

这种情况下的参数处理器为PathVariableMapMethodArgumentResolver,作用是获取所有路径参数封装为Key-Value结构。

MultipartFile集合-批量文件上传

批量文件上传的时候,我们一般需要接收一个MultipartFile集合,可以有两种选择:

  1. 使用MultipartHttpServletRequest参数,直接调用getFiles方法获取MultipartFile列表。
  2. 使用@RequestParam注解修饰MultipartFile列表,参数处理器是RequestParamMethodArgumentResolver,其实就是第1种方式的封装而已。

spmvc-p-11

控制器方法代码如下:

@PostMapping(value = "/parts")
public String partArgs(@RequestParam(name = "file") List<MultipartFile> parts) {
    log.info("{}", parts);
    return parts.toString();
}

日期类型参数处理

日期参数处理个人认为是请求参数处理中最复杂的,因为一般日期处理的逻辑不是通用的,过多的定制化处理导致很难有一个统一的标准处理逻辑去处理和转换日期类型的参数。不过,这里介绍几个通用的方法,以应对各种奇葩的日期格式。下面介绍的例子中全部使用JDK8中引入的日期时间API,围绕java.util.Date为核心的日期时间API的使用方式类同。

一、统一以字符串形式接收

这种是最原始但是最奏效的方式,统一以字符串形式接收,然后自行处理类型转换,下面给个小例子:

static DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

@PostMapping(value = "/date1")
public String date1(@RequestBody UserDto userDto) {
    UserEntity userEntity = new UserEntity();
    userEntity.setUserId(userDto.getUserId());
    userEntity.setBirthdayTime(LocalDateTime.parse(userDto.getBirthdayTime(), FORMATTER));
    userEntity.setGraduationTime(LocalDateTime.parse(userDto.getGraduationTime(), FORMATTER));
    log.info(userEntity.toString());
    return "success";
}

@Data
public class UserDto {

    private String userId;
    private String birthdayTime;
    private String graduationTime;
}

@Data
public class UserEntity {

    private String userId;
    private LocalDateTime birthdayTime;
    private LocalDateTime graduationTime;
}

spmvc-p-12

使用字符串接收后再转换的缺点就是模板代码太多,编码风格不够简洁,重复性工作太多,如果有代码洁癖或者类似笔者这样是一个节能主义者,一般不会选用这种方式。

二、使用注解@DateTimeFormat或者@JsonFormat

@DateTimeFormat注解配合@RequestBody的参数使用的时候,会发现抛出InvalidFormatException异常,提示转换失败,这是因为在处理此注解的时候,只支持Form表单提交(Content-Typex-www-form-urlencoded),例子如下:

spmvc-p-13

@Data
public class UserDto2 {

    private String userId;
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime birthdayTime;
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime graduationTime;
}


@PostMapping(value = "/date2")
public String date2(UserDto2 userDto2) {
    log.info(userDto2.toString());
    return "success";
}

//或者像下面这样
@PostMapping(value = "/date2")
public String date2(@RequestParam("name"="userId")String userId,
                    @RequestParam("name"="birthdayTime") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime birthdayTime,
                    @RequestParam("name"="graduationTime") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime graduationTime) {
    return "success";
}

@JsonFormat注解可使用在Form表单或者JSON请求参数的场景,因此更推荐使用@JsonFormat注解,不过注意需要指定时区(timezone属性,例如在中国是东八区GMT+8),否则有可能导致出现时差,举个例子:

@PostMapping(value = "/date2")
public String date2(@RequestBody UserDto2 userDto2) {
    log.info(userDto2.toString());
    return "success";
}

@Data
public class UserDto2 {

    private String userId;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private LocalDateTime birthdayTime;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private LocalDateTime graduationTime;
}

一般选用LocalDateTime作为日期字段参数的类型,因为它的转换相对于其他JDK8的日期时间类型简单

三、Jackson序列化和反序列化定制

因为SpringMVC默认使用Jackson处理@RequestBody的参数转换,因此可以通过定制序列化器和反序列化器来实现日期类型的转换,这样我们就可以使用application/json的形式提交请求参数。这里的例子是转换请求JSON参数中的字符串为LocalDateTime类型,属于JSON反序列化,因此需要定制反序列化器:

@PostMapping(value = "/date3")
public String date3(@RequestBody UserDto3 userDto3) {
    log.info(userDto3.toString());
    return "success";
}

@Data
public class UserDto3 {

    private String userId;
    @JsonDeserialize(using = CustomLocalDateTimeDeserializer.class)
    private LocalDateTime birthdayTime;
    @JsonDeserialize(using = CustomLocalDateTimeDeserializer.class)
    private LocalDateTime graduationTime;
}

public class CustomLocalDateTimeDeserializer extends LocalDateTimeDeserializer {

    public CustomLocalDateTimeDeserializer() {
        super(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
    }
}

四、最佳实践

前面三种方式都存在硬编码等问题,其实最佳实践是直接修改MappingJackson2HttpMessageConverter中的ObjectMapper对于日期类型处理默认的序列化器和反序列化器,这样就能全局生效,不需要再使用其他注解或者定制序列化方案(当然,有些时候需要特殊处理定制),或者说,在需要特殊处理的场景才使用其他注解或者定制序列化方案。使用钩子接口Jackson2ObjectMapperBuilderCustomizer可以实现对容器中的ObjectMapper单例中的属性定制:

@Bean
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer(){
	return customizer->{
		customizer.serializerByType(LocalDateTime.class,new LocalDateTimeSerializer(
				DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
		customizer.deserializerByType(LocalDateTime.class,new LocalDateTimeDeserializer(
				DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
	};
}

这样就能定制化MappingJackson2HttpMessageConverter中持有的ObjectMapper,上面的LocalDateTime序列化和反序列化器对全局生效。

请求URL匹配

前面基本介绍完了主流的请求参数处理,其实SpringMVC中还会按照URL的模式进行匹配,使用的是Ant路径风格,处理工具类为org.springframework.util.AntPathMatcher,从此类的注释来看,匹配规则主要包括下面四点

  1. ?匹配1个字符。
  2. *匹配0个或者多个字符
  3. **匹配路径中0个或者多个目录
  4. 正则支持,如{spring:[a-z]+}将正则表达式[a-z]+匹配到的值,赋值给名为spring的路径变量。

举些例子:

'?'形式的URL

@GetMapping(value = "/pattern?")
public String pattern() {
	return "success";
}

/pattern  404 Not Found
/patternd  200 OK
/patterndd  404 Not Found
/pattern/  404 Not Found
/patternd/s  404 Not Found

'*'形式的URL

@GetMapping(value = "/pattern*")
public String pattern() {
	return "success";
}

/pattern  200 OK
/pattern/  200 OK
/patternd  200 OK
/pattern/a  404 Not Found

'**'形式的URL

@GetMapping(value = "/pattern/**/p")
public String pattern() {
	return "success";
}

/pattern/p  200 OK
/pattern/x/p  200 OK
/pattern/x/y/p  200 OK

{spring:[a-z]+}形式的URL

@GetMapping(value = "/pattern/{key:[a-c]+}")
public String pattern(@PathVariable(name = "key") String key) {
    return "success";
}

/pattern/a  200 OK
/pattern/ab  200 OK
/pattern/abc  200 OK
/pattern  404 Not Found
/pattern/abcd  404 Not Found

上面的四种URL模式可以组合使用,千变万化。

URL匹配还遵循精确匹配原则,也就是存在两个模式对同一个URL都能够匹配成功,则选取最精确的URL匹配,进入对应的控制器方法,举个例子:

@GetMapping(value = "/pattern/**/p")
public String pattern1() {
    return "success";
}

@GetMapping(value = "/pattern/p")
public String pattern2() {
    return "success";
}

上面两个控制器,如果请求URL/pattern/p,最终进入的方法为pattern2。上面的例子只是列举了SpringMVCURL匹配的典型例子,并没有深入展开。

最后,org.springframework.util.AntPathMatcher作为一个工具类,可以单独使用,不仅仅可以用于匹配URL,也可以用于匹配系统文件路径,不过需要使用其带参数构造改变内部的pathSeparator变量,例如:

AntPathMatcher antPathMatcher = new AntPathMatcher(File.separator);

小结

笔者在前一段时间曾经花大量时间梳理和分析过SpringSpringMVC的源码,但是后面一段很长的时间需要进行业务开发,对架构方面的东西有点生疏了,毕竟东西不用就会生疏,这个是常理。这篇文章基于一些SpringMVC的源码经验总结了请求参数的处理相关的一些知识,希望帮到自己和大家。

参考资料:

  • spring-boot-web-starter:2.3.0.RELEASE源码。

(本文完 c-7-d e-a-20180512 r-a-20200713 旧文重发 封面图来源于日漫《神风怪盗》)

公众号《Throwable文摘》(id:throwable-doge),不定期推送架构设计、并发、源码探究相关的原创文章:

相关文章: