如何在SpringBoot中进行统一响应体增强

前言

在之前我们说到,对于REST风格的接口,我们通常需要保证标准化的响应结果集。通常情况下,返回的数据格式如下所示:

{
	code: "0",
	message: "success",
	data: {
		empoyee: {
			name: "张三",
			salary: 3500,
			version: 2,
		}
	}
}

在这个结果集中共有三个字段:

  • code: 在REST风格的接口中,响应状态码通常不采用Http的响应状态码,而是约定一个内部使用的统一的状态码,并存放在结果集中的code字段中。前端可以从这个状态码中得知该请求是否处理成功等信息。
  • message: 后端响应给前端的信息。
  • data: 该字段是响应返回的额外字段,如:查询结果、新增或者更新后的数据。

实现

要实现这一点,我们可以定义一个统一响应类,类中包含codemessagedata三个字段。为了方便使用,我们也可以添加一些构造方法,具体如下:

@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')
    

解决之道

  1. 如何忽略那些我们不希望进行增强的方法?
    通过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);
        }
    
  2. 如何忽略返回值已经是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);
        }
    
  3. 如何解决返回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-Typeapplication/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返回值。如若将来遇到其他问题,会再进行改进。

文章作者: Serendipity
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 闲人亭
Spring全家桶 Java SpringBoot
喜欢就支持一下吧