4

自定义 Spring Web Controller 方法的参数

 1 year ago
source link: https://yanbin.blog/customize-spring-web-controller-method-parameter/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

自定义 Spring Web Controller 方法的参数

2022-07-07 — Yanbin

在 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

@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,就能实现解析类似

  1. Content-type: application/xml  -> <user><username>yanbin</username><password>123</password></user>
  2. Content-type: application/x-www-form-urlencoded -> username=yanbin&password=123

不过那些应该交给 @ModelAttribute 处理

@ModelAttribute 注解参数

把上面的 @RequestBody 改为 @ModelAttribute

@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 这种分号分隔的字符串中提取值的,作以下几个测试

@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

@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}

@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 中带分号

@Bean
public HttpFirewall getHttpFirewall() {
    StrictHttpFirewall strictHttpFirewall = new StrictHttpFirewall();
    strictHttpFirewall.setAllowSemicolon(true);
    return strictHttpFirewall;

自定义的 @ProductId 参数

预习完基本的 Spring Controller 参数注解后,我们来实现一个自己的 @ProductId 参数注解,它所要达成的功能是,在 Controller 方法中加上

@GetMapping("/hello")
public String hello(@ProductId String productId) {
    return "productId: " + productId;

productId 会自动从 queryString 或 header 中获得值,相当于每次调用如下方法

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 已有的实现类有哪些

HandlerMethodArgumentResolver-800x796.png

要实现 @ProductId 在 Controller 方法中自动注入值,很简单,实现一个  ProductIdMethodArgumentResolver,同时通过 WebMvcConfigurer 注册 Spring MVC 中去

@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 把请求数据转换成需要的数据对象。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK