Spring MVC返回值处理踩坑笔记

最近将部分接口按照Open API重新封装, 其中涉及HTTP状态码转换, 错误码转换, 返回消息转换等细节, 主要工作都在web层完成. 项目使用Spring MVC作为web层框架, 本以为实现起来较为简单, 然而在实现过程中还是踩了些坑, 在此总结梳理一下.

背景

Open API规范主要对URI格式, 字段命名格式, HTTP状态码, 错误码, 错误消息格式进行了统一规定. 具体要求:

  • a. 返回JSON字段采用小写+下划线方式命名.
  • b. 返回非成功响应时, HTTP状态码要求非200, 而是对应到具体的HTTP状态类型, 如: 参数错误返回400, 资源不存在返回404.
  • c. 错误码要求采用HTTP状态码 + 错误码的形式表示. 如: 资源a不存在(错误码311), 需要返回404311.
  • d. 错误消息格式要求采用英文表示.

而原有接口与以上规范不兼容, 字段采用驼峰法命名; 业务处理的HTTP状态码全部为200, 而框架会返回400, 404, 500等; 错误码为3位, 4位不等; 错误消息为中文.

1
2
3
4
5
6
7
8
9
10
11
12
public enum ErrCode {
SUCCESS(2000, "成功");
PARAM_ERROR(4000, "参数错误");
NOT_FOUND_A(4041, "资源a不存在");
NOT_FOUND_B(4042, "资源b不存在");
SYSTEM_ERROR(5001, "服务器错误");
...
private int code;
private String message;
...
// constructor, getter and setter
}

思路

对OpenAPI要求a, 驼峰法很容易通过Spring MVC内置的MessageConverter转换成小写+下划线方式. 由于OpenAPI和原有接口的Controller定义了不同的返回值类型, 因此可以用返回值类型对Controller进行区分, 进而采用不同的MessageConverter处理. 为Spring MVC配置一个自定义的OpenApiHttpMessageConverter, 这个类继承自AbstractJackson2HttpMessageConverter, 覆写其canWrite()方法, 根据返回值类型匹配OpenAPI Controller.

1
2
3
4
5
6
7
8
9
10
11
12
public class OpenApiHttpMessageConverter extends AbstractJackson2HttpMessageConverter {
public OpenApiHttpMessageConverter(ObjectMapper objectMapper) {
super(objectMapper, MediaType.APPLICATION_JSON, new MediaType("application", "*+json"));
}
@Override
public boolean canWrite(Class<?> clazz, MediaType mediaType) {
return canWrite(mediaType) && OpenApiCommonResult.class.equals(clazz);
}
}

在xml配置文件中配置自定义MessageConverter.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<beans>
...
<mvc:annotation-driven>
<mvc:message-converters>
<ref bean="openApiHttpMessageConverter"/>
</mvc:message-converters>
</mvc:annotation-driven>
...
<bean id="openApiHttpMessageConverter" class="com.netease.permission.web.handler.OpenApiHttpMessageConverter">
<property name="objectMapper">
<bean class="com.fasterxml.jackson.databind.ObjectMapper">
<property name="propertyNamingStrategy">
<bean class="com.fasterxml.jackson.databind.PropertyNamingStrategy.SnakeCaseStrategy"/>
</property>
</bean>
</property>
</bean>
...
<beans>

由于原有的错误码采用枚举类型定义, 枚举对象名为英文, 且其名称基本能够反映错误原因, 因此直接用Enum#name()替代枚举对象中的中文错误消息. 而b和c都是对HTTP状态码和错误码的转换, 由于错误码位数不同, 不得不重新定义一套供Open API使用的错误码.

1
2
3
4
5
6
7
8
9
10
11
public enum ApiErrCode {
SUCCESS(200001);
PARAM_ERROR(400001);
NOT_FOUND_A(404001);
NOT_FOUND_B(404002);
SYSTEM_ERROR(500001);
...
private int code;
...
// constructor, getter and setter
}

调用ApiErrCode.NOT_FOUND_A.name().toUpperCase().replace(“_“, “ “)即可转换为符合Open API规范的错误消息, 调用ApiErrorCode.NOT_FOUND_A.getCode() / 10000即可得到Open API的HTTP状态码.得到HTTP状态码后, 还需要将其设置到HTTP响应中.

在什么地方设置HTTP状态码需要进行选择, 而选择的前提是: 能够同时获得Controller的返回值和response对象. 由于编码风格倾向于只用Controller进行输入输出参数相关处理, 因此在Controller的方法签名中没有包含request和response. 这样就无法采用对Controller添加切面的方式设置HTTP状态码了(因为拿不到response对象). 而如果使用Interceptor, 虽然能拿到response对象, 但是无法获取Controller方法的返回值(因为返回值在handler中已经处理完了, 拦截器的postHandl()是拿不到的), 也无法满足要求. 如果将控制器方法的返回值改为ResponseEntity, 虽然能够同时设置返回值和HTTP状态码, 但是只能设置控制器处理范围内的HTTP状态码(即正常情况), 如果抛出异常, 仍会导致HTTP状态码无法设置的问题.

经过简单研究, Spring的HandlerMethodReturnValueHandler接口能够满足以上要求. 这是一个策略接口, 看看接口定义:

1
2
3
4
5
6
7
8
public interface HandlerMethodReturnValueHandler {
// 定义了支持的返回值类型
boolean supportsReturnType(MethodParameter returnType);
// 定义了处理返回值的方式
void handleReturnValue(Object returnValue, MethodParameter returnType,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception;

Spring还提供了一个RequestResponseBodyMethodProcessor实现类, 用于处理@RequestBody和@ResponseBody注解, 这里只看处理@ResponseBody注解的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {
...
@Override
public boolean supportsReturnType(MethodParameter returnType) {
return (AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ResponseBody.class) ||
returnType.hasMethodAnnotation(ResponseBody.class));
}
...
@Override
public void handleReturnValue(Object returnValue, MethodParameter returnType,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest)
throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
mavContainer.setRequestHandled(true); // 设置为true以后, 其他的MethodProcessor就不会再进行处理了
ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);
// Try even with null return value. ResponseBodyAdvice could get involved.
writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage);
}
...
}

主要意思就是如果返回值对应的方法或其所在的类打上了@ResponseBody注解, 则返回true, 即使用这个MethodProcessor进行处理. 处理过程在handleReturnValue()中, 主要是借助MessageConverter将returnValue写入response中. 解决方法明晰了, 首先创建一个OpenApiRequestResponseBodyMethodProcessor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class OpenApiRequestResponseBodyMethodProcessor extends RequestResponseBodyMethodProcessor {
public OpenApiRequestResponseBodyMethodProcessor(List<HttpMessageConverter<?>> converters) {
super(converters);
}
// 打@ResponseBody注解且返回类型为OpenApiCommonResult的方法
@Override
public boolean supportsReturnType(MethodParameter returnType) {
return (AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ResponseBody.class)
|| returnType.hasMethodAnnotation(ResponseBody.class))
&& OpenApiCommonResult.class.equals(returnType.getParameterType());
}
@Override
public void handleReturnValue(Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest)
throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
OpenApiCommonResult openApiCommonResult = (OpenApiCommonResult) returnValue;
mavContainer.setStatus(HttpStatus.valueOf(openApiCommonResult.getStatus())); // 拿到HTTP状态码并设置
mavContainer.setRequestHandled(true);
ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);
writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage);
}
}

再在配置文件中配置OpenApiRequestResponseBodyMethodProcessor, 并把刚才配置的MessageConverter作为constructor的参数传入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<beans>
...
<mvc:annotation-driven>
<mvc:return-value-handlers>
<ref bean="openApiRequestResponseBodyMethodProcessor"/>
</mvc:return-value-handlers>
</mvc:annotation-driven>
...
<bean id="openApiRequestResponseBodyMethodProcessor"
class="com.netease.permission.web.handler.OpenApiRequestResponseBodyMethodProcessor">
<constructor-arg>
<list>
<ref bean="openApiHttpMessageConverter"/>
</list>
</constructor-arg>
</bean>
</beans>

看起来似乎不错, 然而结果显示HTTP状态码并没有设置成功, 查看源码发现以上方法踩了两个坑. 在了解了Spring MVC相关原理后, 填坑就手到擒来了.

原理

Spring MVC 的相关处理流程

Spring MVC处理请求并返回响应的整个流程如下图(盗图~~):

spring_mvc_processing

引用”Spring技术内幕”书中的一句话: Spring MVC通过ContextLoaderListener的初始化, 建立起一个IoC容器的体系, 把DispatcherServlet作为Spring MVC处理Web请求的转发器建立起来, 完成响应HTTP请求的准备. 由ContextLoaderListener启动的上下文为根上下文. 在根上下文的基础上, 还有一个与Web MVC相关的子上下文用来保存控制器(DispatcherServler)需要的MVC对象.

Spring MVC的核心处理流程都在DispatcherServlet类的doDispatch()方法中实现, 其中最关键的流程在以下源码中列出.

1
2
3
4
5
6
7
8
9
10
11
12
13
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
HandlerExecutionChain mappedHandler = null;
...
mappedHandler = getHandler(processedRequest);
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler()); // 获取与mappedHandler匹配的HandlerAdapter对象
if (!mappedHandler.applyPreHandle(processedRequest, response)) { // 调用拦截器链的preHandle()方法
return;
}
mv = ha.handle(processedRequest, response, mappedHandler.getHandler()); // HandlerAdapter回调handler对象表示的控制器方法
applyDefaultViewName(processedRequest, mv); // 设置视图名
mappedHandler.applyPostHandle(processedRequest, response, mv); // 调用拦截器链的postHandle()方法
processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException); // 渲染视图, 处理异常, 调用拦截器链的afterCompletion()方法
...

HandlerExecutionChain由一个控制器Handler和一个拦截器链组成.

1
2
3
4
5
6
public class HandlerExecutionChain {
private static final Log logger = LogFactory.getLog(HandlerExecutionChain.class);
private final Object handler; // 可以简单地理解为Controller方法
private HandlerInterceptor[] interceptors; // 拦截器链
private List<HandlerInterceptor> interceptorList;
private int interceptorIndex = -1;

HandlerAdapter接口有一个重要的实现类RequestMappingHandlerAdapter, 用于表示@RequestMapping注解驱动的控制器. 这个类持有一系列的ArgumentResolver(用于处理入参解析), ReturnValueHandler(用于处理返回值)和MessageConverter(处理HTTP请求和响应).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter
implements BeanFactoryAware, InitializingBean {
private List<HandlerMethodArgumentResolver> customArgumentResolvers;
private HandlerMethodArgumentResolverComposite argumentResolvers;
private HandlerMethodArgumentResolverComposite initBinderArgumentResolvers;
private List<HandlerMethodReturnValueHandler> customReturnValueHandlers;
private HandlerMethodReturnValueHandlerComposite returnValueHandlers;
private List<ModelAndViewResolver> modelAndViewResolvers;
private ContentNegotiationManager contentNegotiationManager = new ContentNegotiationManager();
private List<HttpMessageConverter<?>> messageConverters;
private List<Object> requestResponseBodyAdvice = new ArrayList<Object>();
private WebBindingInitializer webBindingInitializer;
...

下图给出了RequestMappingHandlerAdapter也就是我们常说的控制器对HTTP请求进行处理的过程.

request_mapping_handler_adapter

doDispatch()调用HandlerAdapter的handle()方法, 传入HandlerExecutionChain对象中的Handler对象, handleInternal()调用invokeHandlerMethod(), 最终通过反射调用对应的控制器方法得到返回值对象, 然后用对应的ReturnValueHandler处理返回值对象.

RequestMappingHandlerAdapter中持有的HandlerMethodArgumentResolver表示用于解析传入参数的处理器, 如处理@RequestBody注解, @RequestParam注解等. ArgumentResolver分为两类, 一类是Spring默认的处理器, 一类是用户自定义的处理器. Spring MVC在默认情况下会配置常用类型的处理器, 按顺序(默认处理器先, 用户自定义后)依次用各个处理器去匹配请求参数, 直到找到第一个满足匹配要求的处理器. 一旦找到第一个满足要求的处理器, 就不会继续寻找其他满足要求的了, 也就是说, 在相同的匹配条件下, 系统默认处理器比自定义处理器优先级高. ReturnValueHandler与ArgumentResolver情况类似, 但其功能是处理返回值. 这是踩的第一个坑.

需要说明, 采用@ResponseBody注解的REST控制器, 其返回值处理是由RequestResponseBodyMethodProcessor来完成的.

基于以上原因, 为使用特定的处理器对Open API返回的OpenApiCommonResult进行处理, 必须配置一个用于处理@ResponseBody注解, 且返回类型为OpenApiCommonResult的处理器. 然而, 由于Spring MVC默认的@ResponseBody返回值处理器的处理优先级高于用户自定义处理器, 自定义的处理器是不会生效的. 采用的解决方法是重新定义一个与@ResponseBody内容相同的注解类@OpenApiResponseBody, 同时配置一个处理器对该注解进行处理. 而设置HTTP状态码需要注意, 在在handleReturnValue()方法中对mavContainer设置status是无效的, 这是因为@ResponseBody注解的返回值不包含视图, Spring MVC不会继续执行视图部分的处理逻辑, 而mavContaier设置status是在视图部分完成的, 因此不会被执行. 正确做法是直接向Response对象设置status状态码. 这是踩的第二个坑.

1
2
outputMessage.getServletResponse().setStatus(returnStatus);
// mavContainer.setStatus(returnStatus); // 这样是无效的

对正常情况的Open API返回值处理就算完成了. 然而, 对异常情况还需要进行单独处理. 考虑到Spring MVC提供了全局异常处理器, 可以针对特定的异常类型进行处理, 在这里针对Open API配置了常见异常类型的异常处理器, 并设置对应的HTTP状态码. 将Spring参数绑定异常和参数非法异常处理为400错误, 而将运行时异常处理为500错误.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@ControllerAdvice("com.object.web.controller.openapi") // 只对Open API控制器抛出的异常进行拦截
public class OpenApiExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(OpenApiExceptionHandler.class);
@OpenApiResponseBody
@ExceptionHandler(value = {ServletRequestBindingException.class, IllegalArgumentException.class})
@ResponseStatus(HttpStatus.BAD_REQUEST)
public OpenApiCommonResult handlerParamBindingException(Exception e) throws Exception {
logger.error("caught inner exception:{}.", e.getMessage(), e);
return OpenApiCommonResult.errorOf(ErrorCode.PARAM_ERROR);
}
@OpenApiResponseBody
@ExceptionHandler(value = RuntimeException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public OpenApiCommonResult handleServerErrorException(Exception e) throws Exception {
logger.error("caught inner exception:{}.", e.getMessage(), e);
return OpenApiCommonResult.errorOf(ErrorCode.SYSTEM_ERROR);
}
}

这样就以相对简单的方式, 在不对原有API做任何改动的情况下, 按照Open API要求的输入输出参数格式完成了新接口的开发.

总结与反思

  • Spring MVC的启动流程, 请求处理流程都是非常好的Java学习资料, 在看懂流程的基础上, 还要学习代码设计的思路.
  • 遇到问题时, 查阅资料的质量很重要, 优先看文档, StackOverflow上面的解答也很有帮助.
  • 修bug一定要彻底, 不要出现”表面解决了问题, 但是bug还存在”这种情况, 技术提升之大忌.