一、背景
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
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
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
版权声明:本文为博主原创文章,转载请附上博文链接!