在 Spring Web Controller 方法中的参数可用 org.springframework.web.bind.annotation 下的各种注解来说明参数值从哪儿获得,比如我们熟知的 @PathVariable, @RequestParam, @RequestHeader, @RequestBody, 还有较少使用的 @ReqeustAttribute, @SessionAttribute, @RequestPart, @MatrixVariable, @ModelAttribute, @AuthenticationPrincipal, @CurrentSecurityContext 等。其实在它们背后工作的是相应的 HandlerMethodArgumentResolver 的子孙们,当然还有 HttpMessageConverter 的各个实现类还默默的对输入数据进入类型转换。
为进一步深入了解 Spring Web 如何获得用户输入,我们先尝试一下不常用的注解,然后实现一个自己的注解参数 @ProductId, 它来从 queryString 或 requestHeader 中获得 productId。写作本文的起因是在上一篇 理解 Spring Boot Security + JWT Token 的简单应用 里, JwtTokenFilter 住 SecurityContextFilter 放一个 Authentication 实例, 在 Controller 方法中便能用 @AuthenticationPrincipal 自动注入 authentication.getPrincipal() 的值。
JwtTokenFilter: SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("foo", "bar"))
Controller 方法: public String hello(@AuthenticationPrincipal String user) ---- 这样就能获得 JwtTokenFilter 设置的 principal "foo"
@RequestBody 处理输入
我们知道在 Controller 方法中用 hello(@RequestBody String body) 可收到请求的整个 body 字符串,这儿我们尝试用 @RequestBody 直接获取一个 JavaBean
1 2 3 4 5 6 7 8 9 |
@RestController public class HelloController{ record User(String username, String password){} @PostMapping("/hello") public String hello(@RequestBody User user) { return user.toString(); } } |
为节约代码使用了 JDK 16 的 record 类型,现在我们来请求一下 POST http://localhost:8080/hello
$ curl -X POST -H "Content-type: application/json" http://localhost:8080/hello -d '{"username": "yanbin", "password": "123"}'
User[username=yanbin, password=123]
目前 @RequestBody 好像只支持 application/json Content-type, 尝试用其他 Content-Type 得到 415: Unsupported Media Type 错误。如果我们注册自己的 HttpMessageConverter,就能实现解析类似
- Content-type: application/xml -> <user><username>yanbin</username><password>123</password></user>
- Content-type: application/x-www-form-urlencoded -> username=yanbin&password=123
- 等等
不过那些应该交给 @ModelAttribute 处理
@ModelAttribute 注解参数
把上面的 @RequestBody 改为 @ModelAttribute
1 2 3 4 |
@PostMapping("/hello") public String hello(@ModelAttribute User user) { return user.toString(); } |
测试
$ curl -X POST -H "Content-type: application/x-www-form-urlencoded" http://localhost:8080/hello -d 'username=yanbin&password=123'
User[username=yanbin, password=123]
试图用 Content-type: application/json 也不能获得 username 和 password 的值,不报错,但输出的是 User[username=null,password=null]
显然 @ModelAttribute 也支持 GET 请求的 queryString, 把上面的 @PostMapping("/hello") 改成 @GetMapping("/hello"), 再试
$ curl http://localhost:8080/hello\?username\=yanbin\&password\=123
User[username=yanbin, password=123]
注:\
为 shell 下的转义,实际请求为 http://localhost:8080/hello?username=yanbin&password=123
@MatricVariable 处理 Map 输入
@MatricVariable 是用于从 a=1;b=2;c=3 这种分号分隔的字符串中提取值的,作以下几个测试
1 2 3 4 |
@GetMapping("/hello/{*}") public String hello(@MatrixVariable Map<String, String> user) { return user.toString(); } |
为避免 shell 下对 &
, =
, ;
等字符自动转译而影响阅读,只显示实际请求的 URL 及响应
http://localhost:8080/hello/;username=yanbin;password=123
{username=yanbin, password=123}
http://localhost:8080/hello/username=yanbin;password=123 -- 没有前导分号将丢失第一个值
{password=123}
不想要前导分号,还要收集到第一个值,这样定义 API
1 2 3 4 |
@GetMapping("/hello/{id:.*}") public String hello(@PathVariable String id, @MatrixVariable Map<String, String> user) { return id +";" + user.toString(); } |
测试
http://localhost:8080/hello/100;username=yanbin;password=123
100;{username=yanbin, password=123}
http://localhost:8080/hello/100;username=yanbin;password=123
username=yanbin;{password=123}
1 2 3 4 |
@GetMapping("/hello/{userId}") public String hello(@PathVariable String userId, @MatrixVariable String first, @MatrixVariable String last) { return "userId: %s, first: %s, last: %s".formatted(userId, first, last); } |
测试
http://localhost:8080/hello/100;first=scott;last=tiger
userId: 100, first: scott, last: tiger
同样需要注意 @MatrixVariable 的值是从第一个分号后开始算起,其实是第一个分号前的整个值赋给了 userId, 所
http://localhost:8080/hello/x=y;first=scott;last=tiger
userId: x=y, first:scott, last:tiger
@MatrixVariable 只能从 GET 请求的路径上拆解值,不能从 Form 或 Post Body 中拆解 a=1;b=2 中的值。
另外,在使用 @MatrixVariable 时如果看到错误
org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the URL contained a potentially malicious String ";"
那是因为启用了 Spring Security, 它禁上在 URL 中带分号,转义的分号也不行,我们可以通过声明一个 Bean 来重新允许 URL 中带分号
1 2 3 4 5 6 |
@Bean public HttpFirewall getHttpFirewall() { StrictHttpFirewall strictHttpFirewall = new StrictHttpFirewall(); strictHttpFirewall.setAllowSemicolon(true); return strictHttpFirewall; } |
自定义的 @ProductId 参数
预习完基本的 Spring Controller 参数注解后,我们来实现一个自己的 @ProductId 参数注解,它所要达成的功能是,在 Controller 方法中加上
1 2 3 4 |
@GetMapping("/hello") public String hello(@ProductId String productId) { return "productId: " + productId; } |
productId 会自动从 queryString 或 header 中获得值,相当于每次调用如下方法
1 2 3 4 |
private String getProductId(HttpServletRequest request) { String productId = request.getParameter("productId"); return productId != null ? productId : request.getHeader("x-product-id"); } |
需要自己获得 productId 的 controller 方法很多,所以每次调用 getProductId(request) 就不现实了。这时候我们要实现自己的 HandlerMethodArgumentResolver
,我们不妨看下 Spring Web 已有的实现类有哪些
要实现 @ProductId 在 Controller 方法中自动注入值,很简单,实现一个 ProductIdMethodArgumentResolver,同时通过 WebMvcConfigurer 注册 Spring MVC 中去
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
@Configuration public class ProductIdMethodArgumentResolver implements HandlerMethodArgumentResolver, WebMvcConfigurer { @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.getParameterAnnotation(ProductId.class) != null; } @Override public Object resolveArgument(@Nonnull MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { return getProductId(Objects.requireNonNull(webRequest.getNativeRequest(HttpServletRequest.class))); } private String getProductId(HttpServletRequest request) { String productId = request.getParameter("productId"); return productId != null ? productId : request.getHeader("x-product-id"); } @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { resolvers.add(this); } } |
早先的 Spring 或 Java 版本我们需通过继承 WebMvcConfigurerAdapter 的类来注册 MethodArgumentResolver, 由于 Java 8 支持接口的 default 方法,所以这个 *Adapter 就显得多余,不再被推荐使用。
现在测试
$ curl http://localhost:8080/hello?productId=newbie
productId: newbie
$ curl -H "x-product-id:xyz" http://localhost:8080/hello
productId: xyz
$ curl -H "x-product-id:xyz" http://localhost:8080/hello?productId=newbie
productId: newbie
首先从 queryString 中通过 productId
获得,没有话从 header 中取得, key 是 x-product-id
。
在 HandlerMethodArgumentResolver 中的 resolveArgument 方法中,我们有 MethodParameter, ModelAndViewContainer, NativeWebRequest, 和 WebDataBinderFactory 参数,所以能够实现更复杂的数据绑定,或者使用现有或自定义的 HttpMessageConverter 把请求数据转换成需要的数据对象。