如何在SpringBoot中进行统一响应体增强
如何在SpringBoot中进行统一响应体增强
前言
在之前我们说到,对于REST风格的接口,我们通常需要保证标准化的响应结果集。通常情况下,返回的数据格式如下所示:
{
code: "0",
message: "success",
data: {
empoyee: {
name: "张三",
salary: 3500,
version: 2,
}
}
}
在这个结果集中共有三个字段:
- code: 在REST风格的接口中,响应状态码通常不采用Http的响应状态码,而是约定一个内部使用的统一的状态码,并存放在结果集中的code字段中。前端可以从这个状态码中得知该请求是否处理成功等信息。
- message: 后端响应给前端的信息。
- data: 该字段是响应返回的额外字段,如:查询结果、新增或者更新后的数据。
实现
要实现这一点,我们可以定义一个统一响应类,类中包含code
、message
、data
三个字段。为了方便使用,我们也可以添加一些构造方法,具体如下:
@JsonInclude(JsonInclude.Include.NON_NULL) // 序列化时,添加所有不为NULL的字段
@Schema(name = "ResponseResult", description = "通用返回对象")
public class ResponseResult<T> implements Serializable {
private Integer code;
private String msg;
private T data;
public ResponseResult() {
this.code = AppHttpCodeEnum.SUCCESS.getCode();
this.msg = AppHttpCodeEnum.SUCCESS.getMsg();
}
public ResponseResult(Integer code, T data) {
this.code = code;
this.data = data;
}
public ResponseResult(Integer code, String msg, T data) {
this.code = code;
this.msg = msg;
this.data = data;
}
public ResponseResult(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
public static ResponseResult errorResult(int code, String msg) {
ResponseResult result = new ResponseResult();
return result.error(code, msg);
}
public static ResponseResult okResult() {
ResponseResult result = new ResponseResult();
return result;
}
public static ResponseResult okResult(int code, String msg) {
ResponseResult result = new ResponseResult();
return result.ok(code, null, msg);
}
public static ResponseResult okResult(Object data) {
ResponseResult result = setAppHttpCodeEnum(AppHttpCodeEnum.SUCCESS, AppHttpCodeEnum.SUCCESS.getMsg());
if(data!=null) {
result.setData(data);
}
return result;
}
public static ResponseResult errorResult(AppHttpCodeEnum enums){
return setAppHttpCodeEnum(enums,enums.getMsg());
}
public static ResponseResult errorResult(AppHttpCodeEnum enums, String msg){
return setAppHttpCodeEnum(enums,msg);
}
public static ResponseResult setAppHttpCodeEnum(AppHttpCodeEnum enums){
return okResult(enums.getCode(),enums.getMsg());
}
private static ResponseResult setAppHttpCodeEnum(AppHttpCodeEnum enums, String msg){
return okResult(enums.getCode(),msg);
}
public ResponseResult<?> error(Integer code, String msg) {
this.code = code;
this.msg = msg;
return this;
}
public ResponseResult<?> ok(Integer code, T data) {
this.code = code;
this.data = data;
return this;
}
public ResponseResult<?> ok(Integer code, T data, String msg) {
this.code = code;
this.data = data;
this.msg = msg;
return this;
}
public ResponseResult<?> ok(T data) {
this.data = data;
return this;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
}
定义完成后,我们只需要在每个Controller方法中都将返回的数据封装到ResponseResult
类中即可。Controller方法示例如下:
@RestController
public class UserController {
@GetMapping("/user")
public ResponseResult queryUser() {
User user = ...; // 略
return ResponseResult.ok(user);
}
}
这样,我们就可以得到统一的响应体。
改进
在上一步中,我们想要得到统一的响应体,则需要对所有Controller方法进行改动,让所有Controller方法都进行返回值封装。这似乎有点太过麻烦?有没有什么改进的方法呢?实际上Spring MVC为我们提供了ResponseBodyAdvice
接口,它允许我们在HttpMessageConverter
将消息写入正文之前自定义响应。因此,我们可以通过实现该接口,并使用@RestControllerAdvice
注解进行注释。其实现类如下所示:
@RestControllerAdvice
public class CommonResponseAdvice implements ResponseBodyAdvice<Object> {
/**
* 判断当前返回消息是否需要进行响应体增强
*/
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
/**
* 响应体增强
* @param body the body to be written
* @param returnType the return type of the controller method
* @param selectedContentType the content type selected through content negotiation
* @param selectedConverterType the converter type selected to write to the response
* @param request the current request
* @param response the current response
* @return
*/
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
// 对返回消息进行封装
return ResponseResult.okResult(body);
}
}
到这里,Controller中方法的返回值就能被自动封装为统一返回值。
存在的问题
虽然这样的操作似乎实现了统一响应体增强,但若仔细思考,我们仍然能够发现一些问题。
-
对于某些特殊的接口,我们不希望它的返回值被增强(比如文件下载),但在上述实现的
CommonResponseAdvice
类中,supports()
的判断永远为true,无论什么情况都会被封装为同归响应体,这显然不是我们所希望的。 -
对于某一些已经返回统一响应体
ResponseResult
类型的控制器方法,我们不再需要进行增强。 -
在上述实现方案中,当控制器方法返回值类型为String时,会抛出异常,具体如下:
定义如下返回String类型的控制器:@RestController public class HelloController { @GetMapping("/hello") public String hello() { return "hello"; } }
当访问时,抛出如下异常:
java.lang.ClassCastException: class com.yuewatch.domain.ResponseResult cannot be cast to class java.lang.String (com.yuewatch.domain.ResponseResult is in unnamed module of loader org.springframework.boot.devtools.restart.classloader.RestartClassLoader @77bdb172; java.lang.String is in module java.base of loader 'bootstrap')
解决之道
-
如何忽略那些我们不希望进行增强的方法?
通过ResponseBodyAdvice
接口的描述,其中的supports
方法用于判断当前返回值是否需要增强,因此我们可以很自然地想到在该方法上做文章。我的解决思路为自定义一个注解,并在supports
方法中判断该注解是否存在方法或类上,若存在则返回false。自定义注解IgnoreResponseAdvice
如下:/** * 注释所在的Controller方法无需进行响应体增强 */ @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface IgnoreResponseAdvice { String value() default ""; }
改进后的
CommonResponseAdvice
类如下:@RestControllerAdvice(basePackages = "com.yuewatch.controller") public class CommonResponseAdvice implements ResponseBodyAdvice<Object> { @Override public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) { // 若为以下情况,则不进行响应体封装 // 1. 该注解存在在方法上 // 2. 该注解存在在类上 return !(returnType.getContainingClass().isAnnotationPresent(IgnoreResponseAdvice.class) || Objects.requireNonNull(returnType.getMethod()).isAnnotationPresent(IgnoreResponseAdvice.class)); } /** * 响应体增强 * @param body the body to be written * @param returnType the return type of the controller method * @param selectedContentType the content type selected through content negotiation * @param selectedConverterType the converter type selected to write to the response * @param request the current request * @param response the current response * @return */ @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { return ResponseResult.okResult(body); }
-
如何忽略返回值已经是
ResponseResult
类型的方法?
其实思路与第一个问题异曲同工,只需要在supports
方法中判断返回值类型即可。改进后CommonResponseAdvice
类如下:@RestControllerAdvice public class CommonResponseAdvice implements ResponseBodyAdvice<Object> { @Override public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) { // 若为以下情况,则不进行响应体封装 // 1. 该注解存在在方法上 // 2. 该注解存在在类上 // 3. 返回体已经是ResponseResult类型 return !(returnType.getContainingClass().isAnnotationPresent(IgnoreResponseAdvice.class) || Objects.requireNonNull(returnType.getMethod()).isAnnotationPresent(IgnoreResponseAdvice.class) || returnType.getParameterType().isAssignableFrom(ResponseResult.class)); } /** * 响应体增强 * @param body the body to be written * @param returnType the return type of the controller method * @param selectedContentType the content type selected through content negotiation * @param selectedConverterType the converter type selected to write to the response * @param request the current request * @param response the current response * @return */ @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { return ResponseResult.okResult(body); }
-
如何解决返回String类型时,响应体增强报错问题?
要解决这个问题,我们首先要知道为什么。从报错消息中可以看到,报错实际上是因为ResponseResult
不能被转换为String
类型,从报错的堆栈信息中我们也可以得知,该异常是在StringHttpMessageConverter
中发生的。奇怪,我们的响应体不是已经增强为ResponseResult
类型了吗,怎么还是StringHttpMessageConverter
来处理呢?我们可以一步一步调试看看,最重要的是要关注消息转换器。
根据调试,我们可以来到AbstractMessageConverterMethodProcessor
类中的第280行到311行,截取代码块如下:if (selectedMediaType != null) { selectedMediaType = selectedMediaType.removeQualityValue(); for (HttpMessageConverter<?> converter : this.messageConverters) { GenericHttpMessageConverter genericConverter = (converter instanceof GenericHttpMessageConverter ? (GenericHttpMessageConverter<?>) converter : null); if (genericConverter != null ? ((GenericHttpMessageConverter) converter).canWrite(targetType, valueType, selectedMediaType) : converter.canWrite(valueType, selectedMediaType)) { body = getAdvice().beforeBodyWrite(body, returnType, selectedMediaType, (Class<? extends HttpMessageConverter<?>>) converter.getClass(), inputMessage, outputMessage); if (body != null) { Object theBody = body; LogFormatUtils.traceDebug(logger, traceOn -> "Writing [" + LogFormatUtils.formatValue(theBody, !traceOn) + "]"); addContentDispositionHeader(inputMessage, outputMessage); if (genericConverter != null) { genericConverter.write(body, targetType, selectedMediaType, outputMessage); } else { ((HttpMessageConverter) converter).write(body, selectedMediaType, outputMessage); } } else { if (logger.isDebugEnabled()) { logger.debug("Nothing to write: null body"); } } return; } } }
在这段循环中,就完成了挑选合适的
HttpMessageConverter
类,并进行消息转换的过程。而本段代码块中的第21行,就是使用消息转换器的write
方法完成消息转换。从报错信息可知,当我们返回String类型的值的时候,SpringBoot选择了StringHttpMessageConverter
消息转换器,而不是通常情况下的MappingJackson2HttpMessageConverter
,自然无法处理ResponseResult
类型的返回值。该代码块中的第6行到第8行即为SpringBoot挑选消息转换器的判断过程,而响应体增强的调用在第9行,也就是说,执行顺序是先挑选消息转换器,再进行响应体增强。而原始返回值类型为String类型,所以挑选时就选择了
StringHttpMessageConverter
。知道了为什么,我们解决这个问题也有了一定头绪,那就是在
CommonResponseAdvice
中对String类型进行单独处理。我们手动将增强后的ResponseResult
类型使用Jackson
序列化为String类型再返回,就不会有问题了。同时我们也要注意手动设置响应头中的Content-Type
为application/json
类型。最终版本如下:@RestControllerAdvice public class CommonResponseAdvice implements ResponseBodyAdvice<Object> { private final ObjectMapper objectMapper; public CommonResponseAdvice(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } @Override public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) { // 若为以下情况,则不进行响应体封装 // 1. 该注解存在在方法上 // 2. 该注解存在在类上 // 3. 返回体已经是ResponseResult类型 return !(returnType.getContainingClass().isAnnotationPresent(IgnoreResponseAdvice.class) || Objects.requireNonNull(returnType.getMethod()).isAnnotationPresent(IgnoreResponseAdvice.class) || returnType.getParameterType().isAssignableFrom(ResponseResult.class)); } /** * 响应体增强 * @param body the body to be written * @param returnType the return type of the controller method * @param selectedContentType the content type selected through content negotiation * @param selectedConverterType the converter type selected to write to the response * @param request the current request * @param response the current response * @return */ @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { // String类型需要特别处理,否则会发生类型转换异常 if (body instanceof String) { // 设置响应头 response.getHeaders().add("Content-Type", "application/json"); try { return this.objectMapper.writeValueAsString(ResponseResult.okResult(body)); } catch (JsonProcessingException e) { throw new SystemException(AppHttpCodeEnum.SYSTEM_ERROR.getCode(), "响应体增强异常"); } } // 其他类型则不会发生异常,直接返回 return ResponseResult.okResult(body); } }
总结
至此未知,我们已经实现了一个较为完善的统一响应体增强,无需在每个控制器方法中都构造一个ResponseResult
返回值。如若将来遇到其他问题,会再进行改进。