SpringBoot Web接口@RequestBody接受多种类型参数实现

一、背景
SpringBoot版本2.1.1-RELEASE。在工作中遇到了这样一个特殊的需求:需要接收前台传入的参数,接收参数并封装对象之后进行后续的处理。根据现有逻辑,前台请求http接口的Content-Type有两种,application/json和application/x-www-form-urlencoded。现要求两种请求方式都能够进行参数绑定。想到通过自定义一个HandlerMethodArgumentResolver来实现。

二、参数绑定的原理

测试代码1:

@RestController
@RequestMapping("/test")
@Slf4j
public class TestWebController {
 
    @RequestMapping("/form")
    public Person testFormData(Person person, HttpServletRequest request) {
        String contentType = request.getHeader("content-type");
        log.info("Content-Type:" + contentType);
        log.info("入参值:{}", JSON.toJSONString(person));
        return person;
    }
}
请求参数:

curl --request POST \
  --url http://localhost:8080/test/form \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'name=test&age=19'
控制台输出结果:

TestWebController     : Content-Type:application/x-www-form-urlencoded
TestWebController     : 入参值:{"age":19,"name":"test"}
可以看出表单提交的参数根据字段名被自动绑定到了Person这个对象。

通过查看源代码可以发现,我们的http请求进入DispatcherServlet的doDispatch方法,通过方法

HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
获取了当前请求的RequestMappingHandlerAdapter对象ha,随后,执行了方法

mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
在该方法中,执行了AbstractHandlerMethodAdapter抽象类的默认方法handle,默认方法又调用了ha的handleInternal方法。随后通过方法形参传入的HandlerMethod对象(HandlerMethod对象其实就是我们Controller里自己写的testFormData的Method对象),获取可执行的方法InvocableHandlerMethod。随后执行了可执行方法对象的getMethodArgumentValues方法。

MethodParameter[] parameters = getMethodParameters();
在方法中,获取了当前方法的所有的形参。然后循环遍历这些形参,通过HandlerMethodArgumentResolver接口的一个实现类来处理这个形参。这里我们发现,当前的resolvers是一个组合对象。这个组合对象也实现了这个接口,并且这个对象有一个私有的成员变量:一个接口的实现类的集合。在处理参数的时候,遍历当前resolver的集合,通过接口方法supportsParameter来对当前形参的MethodParameter对象进行校验。当返回了true的时候,证明当前的resolver支持当前的形参,选取当前的resolver对当前的形参进行处理。在第一次匹配到相应的resolver之后,还会进行一个内存级别的缓存。后续对同样类型的形参进行resolver选择的时候,就不再对集合进行遍历选择。

    /**
     * Find a registered {@link HandlerMethodArgumentResolver} that supports
     * the given method parameter.
     */
    @Nullable
    private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
        HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
        if (result == null) {
            for (HandlerMethodArgumentResolver methodArgumentResolver : this.argumentResolvers) {
                if (methodArgumentResolver.supportsParameter(parameter)) {
                    result = methodArgumentResolver;
                    this.argumentResolverCache.put(parameter, result);
                    break;
                }
            }
        }
        return result;
    }
选择到相应的resolver之后,通过方法传入的request对象,执行resolver的resolveArgument方法,封装形参的值。

通过观察组合对象,发现有26个内置的对象,分别负责不同场景下的形参的处理。这里也解释了为什么在controller的形参位置会自动注入HttpServletRequest、HttpServletResponse等对象。

通过观察执行过程,发现当Content-Type为application/x-www-form-urlencoded时,处理形参的resolver是ServletModelAttributeMethodProcessor。

测试代码2:

@RequestMapping("/entity")
    public Person testFromEntity(@RequestBody Person person, HttpServletRequest request) {
        String contentType = request.getHeader("content-type");
        log.info("Content-Type:" + contentType);
        log.info("入参值:{}", JSON.toJSONString(person));
        return person;
    }
请求参数:

curl --request GET \
  --url http://localhost:8080/test/entity \
  --header 'Content-Type: application/json' \
可以发现,当形参被@RequestBody注解标注时,如果没有传入请求体,则会报错。通过上面同样的步骤,不难发现,当形参被标注@RequestBody注解的时候,SpringBoot选用的resolver为RequestResponseBodyMethodProcessor。

当通过请求体传入合适的json时:

curl --request GET \
  --url http://localhost:8080/test/entity \
  --header 'Content-Type: application/json' \
  --data '{\n    "name": "json",\n    "age": 20\n}'
 可以观察到

TestWebController     : Content-Type:application/json
TestWebController     : 入参值:{"age":20,"name":"json"}
控制台输出了成功绑定的参数。 

并且,通过观察argumentResolvers集合,发现RequestResponseBodyMethodProcessor的顺序要比ServletModelAttributeMethodProcessor高很多,ServletModelAttributeMethodProcessor是最后一个resolver。

所以被标注@RequestBody注解的形参不会有机会通过ServletModelAttributeMethodProcessor去实现数据绑定,即使在url后通过地址拼接参数传递对方式请求服务器。在传入空或无法解析的json时,会直接响应400的错误。

三、自定义PostEntityHandlerMethodArgumentResolver

通过以上测试不难发现,处理形参参数绑定的resolver都是HandlerMethodArgumentResolver接口的实现类。于是我想到,通过自定义一个这样的实现类,来对我们需要处理的形参进行参数绑定处理。

新建自定义的resolver并实现接口后,发现需要实现其中的两个方法:supportsParameter和resolveArgument。

supportsParameter方法为resolver组合对象在通过形参选择resolver的时候进行判断的方法。如果该方法返回了true,代表此解析器可以处理这个类型的形参。否则就返回false,循环继续进行下一轮的选择。所以,我们需要对我们自定义的形参进行标记,以便在这里可以成功的捕捉到。

我的做法是自定义一个空的接口

public interface PostEntity {
}
让我们的实体类实现这个接口,但是什么都不需要做。 在supportsParameter方法中判断传入的类型是不是PostEntity的实现类。如果是实现类,就返回true,否则返回false,不影响其他类型的形参的值的注入。

关于resolveArgument方法,我们只需要根据Content-Type不同来直接调用上文提到的两个resolver即可,不需要自己去实现这个逻辑。同时也可以保证参数处理全局的一致性。由于判断依赖Content-Type的值,所以要求调用方必须传入Content-Type。

@Slf4j
@AllArgsConstructor
public class PostEntityHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {
 
    private RequestResponseBodyMethodProcessor requestResponseBodyMethodProcessor;
 
    private ServletModelAttributeMethodProcessor servletModelAttributeMethodProcessor;
 
    private static final String APPLICATION_JSON = "application/json";
 
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        Class<?> parameterType = parameter.getParameterType();
        String parameterName = parameter.getParameterName();
        if (PostEntity.class.isAssignableFrom(parameterType)) {
            log.info("name:{},type:{}", parameterName, parameterType.getName());
            log.info("matched");
            return true;
        }
        return false;
    }
 
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        assert request != null;
        String contentType = request.getContentType();
        log.debug("Content-Type:{}", contentType);
        if (APPLICATION_JSON.equalsIgnoreCase(contentType)) {
            return requestResponseBodyMethodProcessor.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
        } else {
            return servletModelAttributeMethodProcessor.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
        }
    }
}
四、注册自定义HandlerMethodArgumentResolver

构造好了之后就需要把我们自定义的resolver添加到resolver的组合对象中。所有的预加载resolvers在启动过程中被设置到RequestMappingHandlerAdapter对象中。

定义一个配置类实现WebMvcConfigurer接口,在成员变量位置注入RequestMappingHandlerAdapter对象。确保注入成功之后,定义一个@PostConstruct的init方法,首先通过getArgumentResolvers方法获取所有的resolvers,随后遍历这个集合,获取我们需要的两个resolvers。拿到所需参数之后构造我们自定义的PostEntityHandlerMethodArgumentResolver。

通过查看获取resolver集合的方法源代码可以发现:

return Collections.unmodifiableList(this.argumentResolvers);
这个方法返回的集合是一个不可变的集合,没有办法为其添加新的元素。所以,我们需要构造一个新的集合,大小为原有集合大小+1,并且把我们自定义的resolver添加到集合的第一位,再通过ha对象重新设置回去。这样就完成了我们自定义resolver的注册。

@Configuration
@Slf4j
public class WebMvcConfiguration implements WebMvcConfigurer {
 
    @Autowired
    private RequestMappingHandlerAdapter ha;
 
    private ServletModelAttributeMethodProcessor servletModelAttributeMethodProcessor = null;
    private RequestResponseBodyMethodProcessor requestResponseBodyMethodProcessor = null;
 
    @PostConstruct
    private void init() {
        List<HandlerMethodArgumentResolver> argumentResolvers = ha.getArgumentResolvers();
        for (HandlerMethodArgumentResolver argumentResolver : argumentResolvers) {
            if (argumentResolver instanceof ServletModelAttributeMethodProcessor) {
                servletModelAttributeMethodProcessor = (ServletModelAttributeMethodProcessor) argumentResolver;
            } else if (argumentResolver instanceof RequestResponseBodyMethodProcessor) {
                requestResponseBodyMethodProcessor = (RequestResponseBodyMethodProcessor) argumentResolver;
            }
            if (servletModelAttributeMethodProcessor != null && requestResponseBodyMethodProcessor != null) {
                break;
            }
        }
        PostEntityHandlerMethodArgumentResolver postEntityHandlerMethodArgumentResolver = new PostEntityHandlerMethodArgumentResolver(requestResponseBodyMethodProcessor, servletModelAttributeMethodProcessor);
        List<HandlerMethodArgumentResolver> newList = new ArrayList<>(argumentResolvers.size() + 1);
        newList.add(postEntityHandlerMethodArgumentResolver);
        newList.addAll(argumentResolvers);
        ha.setArgumentResolvers(newList);
    }
 
}
 

五、结论

通过测试可以发现,两种请求方式都已经实现了同一个方法形参的参数绑定。虽然此功能也无需这么复杂的实现方式也可以做到,但是通过对这个问题的研究,阅读了一些spring的源码,更清楚的知道了参数绑定数据的流转过程。

如果需要绑定的形参是外部依赖的vo,无法实现自定义的接口,还可以实现一个自定义的注解,在自定义的resolver中也是可以捕捉到的,并进行自定义的处理。

还有一个可能有用的场景,就是通过此方式,也可以自定义一种Content-Type,来实现一些不知道为什么你要这么做的需求~~

Demo的代码:https://github.com/daegis/multi-content-type-demo
--------------------- 
作者:AEGISA 
来源:CSDN 
原文:https://blog.csdn.net/daegis/article/details/86478129 
版权声明:本文为博主原创文章,转载请附上博文链接!

发布了15 篇原创文章 · 获赞 15 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/a568418299/article/details/91970171