❝这是公众号《Throwable文摘》发布的第「22」篇原创文章,暂时收录于专辑《架构与实战》。暂定下一篇发布的长文是《图文分析JUC同步器框架》,下一篇发布的短文是《SpringBoot2.x入门:引入jdbc模块与JdbcTemplate简单使用》。
❞
前提
在日常使用SpringMVC进行开发的时候,有可能遇到前端各种类型的请求参数,这里做一次相对全面的总结。SpringMVC中处理控制器参数的接口是HandlerMethodArgumentResolver,此接口有众多子类,分别处理不同(注解类型)的参数,下面只列举几个子类:
RequestParamMethodArgumentResolver:解析处理使用了@RequestParam注解的参数、MultipartFile类型参数和Simple类型(如long、int等类型)参数。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注解中,produces和consumes属性就是和请求的Accept或者响应的Content-Type相关的:
consumes属性:指定处理请求的提交内容类型(Content-Type),例如application/json、text/html等等,只有命中了对应的Content-Type的值才会接受该请求。produces属性:指定返回的内容类型,仅当某个请求的请求头中的(Accept)类型中包含该指定类型才返回,如果返回的是JSON数据一般考虑使用application/json;charset=UTF-8。
另外提一点,SpringMVC中默认使用Jackson作为JSON的工具包,如果不是完全理解透整套源码的运作,一般不是十分建议修改默认使用的MappingJackson2HttpMessageConverter(例如有些人喜欢使用FastJson,实现HttpMessageConverter引入FastJson做HTTP消息转换器,其实这种做法并不推荐)。
SpringMVC请求参数接收
其实一般的表单或者JSON数据的请求都是相对简单的,一些复杂的处理主要包括URL路径参数、文件上传、数组或者列表类型数据等。另外,关于参数类型中存在日期类型属性(例如java.util.Date、java.sql.Date、java.time.LocalDate、java.time.LocalDateTime、java.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; } 复制代码
下面主要以HTTP的GET方法和POST方法提交在SpringMVC体系中正确处理参数的例子进行分析,还会花精力整理SpringMVC体系中「独有的URL路径参数」处理的一些技巧以及最常见的「日期参数」处理的合理实践(对于GET方法和POST方法提交的参数处理,基本囊括了其他如DELETE、PUT等方法的参数处理,随机应变即可)。
GET方法请求参数处理
HTTP(s)协议使用GET方法进行请求的时候,提交的参数位于URL模式的Query部分,也就是URL的?标识符之后的参数,格式是key1=value1&key2=value2。GET方法请求参数可以有多种方法获取:
- 使用
@RequestParam注解处理。 - 使用对象接收,注意对象的属性名称要和
Query中的参数名称一致。 - 使用
HttpServletRequest实例提供的方法(「不推荐,存在硬编码」)。
假设请求的URL为http://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-Type为application/x-www-form-urlencoded,需要构造请求参数格式如下:
spmvc-p-2
因为没有使用注解,最终的参数处理器为ServletModelAttributeMethodProcessor,主要是把HttpServletRequest中的表单参数封装到MutablePropertyValues实例中,再通过参数类型实例化(通过构造反射创建User实例),反射匹配属性进行值的填充。另外,请求复杂参数里面的列表属性请求参数看起来比较奇葩,实际上和在.properties文件中添加最终映射到Map类型的参数的写法是一致的,所以对于嵌套数组或者列表类型的第一层索引要写成firstLevel[index].fieldName的形式。那么,能不能把整个请求参数塞在一个字段中提交呢?
spmvc-p-3
直接这样做是不行的,因为实际提交的Form表单,key是user字符串,value实际上也是一个字符串,缺少一个String->User类型的转换器,实际上RequestParamMethodArgumentResolver依赖WebConversionService中Converter实例列表进行参数转换,而默认的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,那么通过匹配实际请求的URL和URL模板就能提取到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-data,POST方式进行提交:
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-Disposition、Content-Type和Content-Length,另外,InputStream用于读取请求体的最后部分(文件的字节序列)。参数处理器用到的是RequestPartMethodArgumentResolver(记住一点,使用了@RequestPart和MultipartFile一定是使用此参数处理器)。在其他情况下,使用@RequestParam和MultipartFile或者仅仅使用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; } 复制代码









