SpringBoot统一结果集的处理与原理

2023年 10月 6日 70.1k 0

作者:三哥,j3code.cn

个人项目:社交支付项目(小老板)

1、简介

现在的项目开发基本都是采用 前后端分离 的方式,也即将项目的页面开发与服务端开发分离开,各干各的。

这样一来前端与后端的职责就非常清楚了,但随之而来的问题就是两者之间如何进行数据的交互。以前前后端不分离的时候数据要么交给后端来渲染,要么就是通过 JSON 来交互,而当采用分离式开发则后端渲染数据就不太行了,所以只能通过 JSON 这种方式来交互数据了。

当数据用 JSON 来交互的时候,就不得不定一个 JSON 的格式了,也即后端返回的 JSON 数据样式,就如下面这样:

1)空数据的成功数据

{
  "result": true,
  "code": 200,
  "message": "操作成功"
}

2)带数据的成功数据

{
  "result": true,
  "code": 200,
  "message": "操作成功"
  "data": "你请求成功了,这是一个成功响应!"
}

3)出错数据

{
    "result": false,
    "code": 500,
    "message": "操作失败",
    "exception": "出错了,这是错误信息:XXXXXXXXXXXXXXXXX"
}

通过这样返回数据,前端就可以很好的对后端返回的数据做处理,而且这样一来,后端只需要提前把返回数据的格式提前写好,前后端就可以同步进行开发,极大的缩短开发时间和提高开发效率。

2、实践

介绍了这么,那我们如何实现上面说的功能呢!这里我编写两种实现方式,大家看我操作。

在实现之前,我们需要先定义一下基本的数据返回对象,如下(注:代码使用了 lombok):

基本数据格式对象

@Getter
public class ResultInfo implements Serializable {

    protected Boolean result;
    protected Integer code;
    /**
     * message = null , 不序列化出去
     */
    @JsonInclude(JsonInclude.Include.NON_NULL)
    protected String message;

    protected ResultInfo(Boolean result, Integer code, String message) {
        this.result = result;
        this.code = code;
        this.message = message;
    }

}

上面的这个对象只是一个基本格式对象,里面并不存在数据和错误字段,所以我们由基本对象衍生出成功数据对象和失败数据对象,如下:

成功:

@Builder
@ToString
@Getter
public class SuccessInfo extends ResultInfo{

    protected static final Integer DEFAULT_CODE = 200;
    protected static final String DEFAULT_MESSAGE = "操作成功";

    @JsonInclude(JsonInclude.Include.NON_NULL)
    protected Object data;

    protected SuccessInfo(Object data) {
        super(true, DEFAULT_CODE, DEFAULT_MESSAGE);
        this.data = data;
    }
}

失败:

@Builder
@ToString
@Getter
public class FailInfo extends ResultInfo{

    public static final Integer DEFAULT_CODE = 500;
    protected static final String DEFAULT_MESSAGE = "操作失败";

    @JsonInclude(JsonInclude.Include.NON_NULL)
    private final String exception;

    public FailInfo(String exception) {
        super(false, DEFAULT_CODE, DEFAULT_MESSAGE);
        this.exception = exception;
    }

    public FailInfo(Integer code, String exception) {
        super(false, code, DEFAULT_MESSAGE);
        this.exception = exception;
    }
}

这样一来,如果程序运行成功我们就只需要将成功的数据格式对象返回出去即可,而失败则返回失败的数据格式出去。

2.1 写死 Controller 方法返回对象

顾名思义,就是每次编写 controller 方法的时候都将方法的返回对象写为 ResultInfo 对象,然后让具体的业务方法来决定是将 SuccessInfo 返回出去还是 FailInfo 。

案例:

@Slf4j
@AllArgsConstructor
@RestController
@RequestMapping("/open-api/demo")
public class DemoController {

    @GetMapping("/testSuccess")
    public ResultInfo testSuccess(){
        return SuccessInfo.builder().data("你访问成功了").build();
    }
    @GetMapping("/testError")
    public ResultInfo testError(){
        return FailInfo.builder().exception("你失败了").build();
    }
}

结果:

虽然这样已经实现了我们当初与前端约定的数据格式,但是你们有没有发现这样一来对与后端来说就有点不太正常或者说合理了。后端所有的接口都是 ResultInfo 类型的返回,这样一来,我们不能一下子就看出给到前端的真正业务对象是那个,并且每次都需要手动的创建 ResultInfo 类型的对象并设置相关值,冗余了很多和业务无关的代码并且重复性很多。

那有没有好的办法解决呢,可能有人会说 AOP 对象 Controller 进行切面,将需要处理的返回对象统一交给切面做不久 ok 了嘛!这样确实可以,但 Spring 有更好的做法,我们继续看下面这个方案。

2.2 @ControllerAdvice + ResponseBodyAdvice 实现

先编写一个结果集返回的注解:

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
@Documented
// @RestController // 组合
public @interface ResponseResult {
    // 是否忽略
    boolean ignore() default false;
}

定义的这个注解可以放在类上也可以放在方法上,表明被该注解标注的类或者方法需要做统一结果处理。

接着编写一个 ResponseBodyAdvice 接口的实现类,如下:

@Slf4j
@ControllerAdvice
@AllArgsConstructor
public class ResponseResultHandler implements ResponseBodyAdvice {
}

重写里面的两个方法

方法一:

@Override
public boolean supports(MethodParameter methodParameter, Class> aClass) {
    // 获取 方法 和 class 对象
    final var method = methodParameter.getMethod();
    final var clazz = Objects.requireNonNull(method, "method is null").getDeclaringClass();

    // 只处理 ResponseResult 标注的类或方法
    var annotation = clazz.getAnnotation(ResponseResult.class);
    if (Objects.isNull(annotation)) {
        annotation = method.getAnnotation(ResponseResult.class);
    }

    //如果是FileSystemResource 则不拦截
    if (method.getAnnotatedReturnType().getType().getTypeName()
        .equals(FileSystemResource.class.getTypeName())) {
        return false;
    }
    // 判断是否需要处理
    return annotation != null && !annotation.ignore();
}

supports 方法很简单,就是判断方法或者类是否标注了我们自定义的统一结果处理的注解,有则表明需要做统一结果处理,进入到后续的 beforeBodyWrite 方法做处理,没有则表示不需要做处理,也即 beforeBodyWrite 方法不会执行。

方法二:

@SneakyThrows
@Override
public Object beforeBodyWrite(Object data,
                              MethodParameter mediaType,
                              MediaType selectedContentType,
                              Class> selectedConverterType,
                              ServerHttpRequest request,
                              ServerHttpResponse serverHttpResponse) {
    // 处理结果集
    var successInfo = SuccessInfo.builder()
    .data(data)
    .build();

    // 处理 String 类型情况
    if ((data instanceof String) && !MediaType.APPLICATION_XML_VALUE.equals(mediaType.toString())) {
        ObjectMapper om = new ObjectMapper();
        serverHttpResponse.getHeaders().set("Content-Type", "application/json");
        return om.writeValueAsString(successInfo);
    }

        // 处理 void 类型情况
    if (Objects.isNull(data) && MediaType.TEXT_HTML_VALUE.equals(mediaType.toString())) {
        ObjectMapper om = new ObjectMapper();
        serverHttpResponse.getHeaders().set("Content-Type", "application/json");
        return om.writeValueAsString(successInfo);
    }

    return successInfo;
}

这个方法就是将方法返回的结果统一包装到定义好的 SuccessInfo 对象中,然后返回处理就 ok 了。这样经过统一处理,所有成功的方法都会在结果到达页面之前进行这一步的处理,从而达到我们需要的效果。

注意:这只是成功的处理,也即业务出错了并不会走到这里进行统一的错误处理。

测试:

@Slf4j
@AllArgsConstructor
@ResponseResult
@RestController
@RequestMapping("/open-api/demo")
public class DemoController {

    @GetMapping("/testSuccess01")
    public String testSuccess01(){
        return "你访问成功了";
    }
    @GetMapping("/testSuccess02")
    public User testSuccess02(){
        User user = new User();
        return user.setNickName("三哥!");
    }
}

现在类上标注了 @ResponseResult 注解表明这个类下的所有方法都需要做结果统一处理,然后就是接口方法返回值直接写你需要返回给前端的数据类型对象即可。

2.3 错误处理

前面章节我们只是分析了业务成功的时候做的统一处理,那如果业务失败了,如何处理呢,总不能返回一大堆错误出去吧!

对于这种情况,Spring 早就提供了解决方法:@RestControllerAdvice 注解 + @ExceptionHandler 注解

案例:

编写 SysExceptionHandler 统一错误处理类,在其类上标注 @RestControllerAdvice 注解表示该类是一个出现错误时候需要起作用的切面,并且类下的所有方法返回值都会以 JSON 形式返回处理。

@Slf4j
@RestControllerAdvice
public class SysExceptionHandler {

}

紧接着,在类中编写你对那些错误需要处理的方法

/**
 * 最大的兜底错误处理
 *
 * @param ex
 * @return
 */
@ExceptionHandler(value = Exception.class)
public FailInfo exception(Exception ex) {
    log.error("Exception_info:{}", ex.getMessage());
    log.error("Exception_info:", ex);
    var failInfo = FailInfo.builder().exception(ex.getMessage()).build();
    return failInfo;
}

这样一来,如果业务中出现了 Exception 及其子类都会被这个方法统一处理,并将 FailInfo 对象返回到前端统一展示。

注意:@ExceptionHandler 中 value 的值会匹配最近下业务抛出错的类型的值(你们应该能懂吧!)

3、ResponseBodyAdvice 原理

在上面案例中我们是 @ControllerAdvice 注解 + ResponseBodyAdvice 实现类完成了统一结果集的处理,那想一下 @ControllerAdvice 注解是否可以不加或者点击这个注解中里面组合了一个 @Component 注解,是否表明将 @ControllerAdvice 替换为 @Component 也能达到对应我们需要的效果呢!

答案:不加 @ControllerAdvice 注解和将 @ControllerAdvice 替换为 @Component 都达不到统一结果集的目的。

上面猜想的坑我帮你们踩了,那现在如何探究其原理呢!

现在我们要探究 ResponseBodyAdvice 的实现原理,然而却不知道从何入手,那教给大家一个办法就是先搞清楚咱们要探究源码的目的是啥!

第一:需要搞清楚 ResponseBodyAdvice 实现类存在何处(这里不是指其存在 IOC 中,肯定还有其他特别的地方)

第二:需要搞清楚 ResponseBodyAdvice 实现类是在那个时间点起作用的

搞清楚了目的之后还是不知道从哪里入手,咋办,只有先对实现类的 supports 方法 debug 了。这里用的是逆向思维,从生效的点,反过来推他是切入点。

这里 debug 只需要请求一下接口就行

根据调用栈,我们往下可以往下走一步,来到下面这个图:

现在知道了,我们的实现类是从 org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyAdviceChain#getMatchingAdvice 方法中获取到的,那我们理应进去看看。

进入 getAdvice 方法看看是如何获取 Bean 的。

private List getAdvice(Class adviceType) {
    // 根据 adviceType 的不同获取不同类型的值
    if (RequestBodyAdvice.class == adviceType) {
        return this.requestBodyAdvice;
    }
    else if (ResponseBodyAdvice.class == adviceType) {
        // 我们此次的目的是他,也即他是直接返回这个属性值(默认是一个集合)
        return this.responseBodyAdvice;
    }
    else {
        throw new IllegalArgumentException("Unexpected adviceType: " + adviceType);
    }
}

好了现在我们知道了我们自定义处理结果集的 Bean 是从 responseBodyAdvice 集合中获取的,那下面是不是就应该去探究一下这个属性的值是怎么加入的,明白了这个是不是就知道了自定义处理结果 Bean 是如何生效了了。

现在的切入点是自定义结果集对象如何被设置到这里的

继续 debug (这里需要重启项目,因为现在是初始化阶段了)

现在我们知道了 requestResponseBodyAdvice 参数就已经有了我们需要的类,紧接着就是从该参数中根据类型的不同分别存入 requestBodyAdvice 和 responseBodyAdvice 两个集合中。

ok,现在根据调用栈,往下走看看这个方法的调用共源头是谁?

我直接根据调用栈,一步到位的来到 RequestMappingHandlerAdapter 类的 getDefaultArgumentResolvers 方法找到了我们需要的答案,原理统一结果处理对象被存在了 org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter#requestResponseBodyAdvice 属性中。所以,现在我们的目标就是看看这个属性的值是在什么时候加入的。

继续 debug(重启项目)

重启项目后,我们会发现咱们自定义的结果处理类是再下面的代码中进行处理的

好了,兜兜转转这么久终于是找到重点了,这段代码是在 initControllerAdviceCache 方法中,所以我们需要仔细看看这个方法源码了。

private void initControllerAdviceCache() {
    // 上下文对象为空 直接结束
    if (getApplicationContext() == null) {
        return;
    }
    // 显然这是重点,从我们的上下文中找到 ControllerAdviceBean(这里肯定是找到我们定义的自定义结果集 bean)
    List adviceBeans = ControllerAdviceBean.findAnnotatedBeans(getApplicationContext());
    // 定义一个集合(可能有多个)所以用集合存
    List requestResponseBodyAdviceBeans = new ArrayList();
    // 开始遍历
    for (ControllerAdviceBean adviceBean : adviceBeans) {
        // 一些基本的校验
        Class beanType = adviceBean.getBeanType();
        // 类型
        if (beanType == null) {
            throw new IllegalStateException("Unresolvable type for ControllerAdviceBean: " + adviceBean);
        }
        // 获取 Bean 的 ModelAttribute 和 InitBinder 方法
        Set attrMethods = MethodIntrospector.selectMethods(beanType, MODEL_ATTRIBUTE_METHODS);
        if (!attrMethods.isEmpty()) {
            this.modelAttributeAdviceCache.put(adviceBean, attrMethods);
        }
        Set binderMethods = MethodIntrospector.selectMethods(beanType, INIT_BINDER_METHODS);
        if (!binderMethods.isEmpty()) {
            this.initBinderAdviceCache.put(adviceBean, binderMethods);
        }
        // 判断 bean 是否和 RequestBodyAdvice 或者 ResponseBodyAdvice 有父子关系(这里仅表示是否有关系,所以统一用就用父子关系表述了)
        if (RequestBodyAdvice.class.isAssignableFrom(beanType) || ResponseBodyAdvice.class.isAssignableFrom(beanType)) {
            // true 表明找到了请求或者结果集处理的 bean,而这里就肯定会有我们自定义的结果处理 Bean
            requestResponseBodyAdviceBeans.add(adviceBean);
        }
    }

    if (!requestResponseBodyAdviceBeans.isEmpty()) {
        // 不为空就加入到 requestResponseBodyAdvice 集合中存起来
        this.requestResponseBodyAdvice.addAll(0, requestResponseBodyAdviceBeans);
    }
    // 一些日志信息,不重要
    if (logger.isDebugEnabled()) {
        int modelSize = this.modelAttributeAdviceCache.size();
        int binderSize = this.initBinderAdviceCache.size();
        int reqCount = getBodyAdviceCount(RequestBodyAdvice.class);
        int resCount = getBodyAdviceCount(ResponseBodyAdvice.class);
        if (modelSize == 0 && binderSize == 0 && reqCount == 0 && resCount == 0) {
            logger.debug("ControllerAdvice beans: none");
        }
        else {
            logger.debug("ControllerAdvice beans: " + modelSize + " @ModelAttribute, " + binderSize +
                         " @InitBinder, " + reqCount + " RequestBodyAdvice, " + resCount + " ResponseBodyAdvice");
        }
    }
}

相信你们看我的代码注释应该能看懂,下面我们需要继续去看看 findAnnotatedBeans 方法是如何获取结果处理 Bean 的。

public static List findAnnotatedBeans(ApplicationContext context) {
    // 获取 bean 工厂
	ListableBeanFactory beanFactory = context;
    if (context instanceof ConfigurableApplicationContext) {
        // Use internal BeanFactory for potential downcast to ConfigurableBeanFactory above
        beanFactory = ((ConfigurableApplicationContext) context).getBeanFactory();
    }
    List adviceBeans = new ArrayList();
	// 获取所有 bean 的名称进行遍历,(传入的是 Object ,所以就是所有 Bean)
    for (String name : BeanFactoryUtils.beanNamesForTypeIncludingAncestors(beanFactory, Object.class)) {
        // 名称不为空且 不是以 scopedTarget 开头
        if (!ScopedProxyUtils.isScopedTarget(name)) {
            // 重点!!!!!!!!!!!!!!
            // 根据名称 和 ControllerAdvice 注解找到对应的 Bean
            ControllerAdvice controllerAdvice = beanFactory.findAnnotationOnBean(name, ControllerAdvice.class);
            if (controllerAdvice != null) {
                // Use the @ControllerAdvice annotation found by findAnnotationOnBean()
                // in order to avoid a subsequent lookup of the same annotation.
                // 存入集合
                adviceBeans.add(new ControllerAdviceBean(name, beanFactory, controllerAdvice));
            }
        }
    }
	// 根据 sort 排序
    OrderComparator.sort(adviceBeans);
	// 返回出去
    return adviceBeans;
}

看到这里,应该就达到我们的目的,也知道了为啥自定义的结果集处理类需要实现 ResponseBodyAdvice 结合且需要被 ControllerAdvice 注解标注。

不知道看到这里的小伙伴有多少对 SpringMVC 启动及请求流程比较熟悉的,如果不熟悉,推荐你看看下面这个视频:

www.bilibili.com/video/BV1rd…

当你了解用户的一个请求从匹配接口、执行请求到结果处理,你就能知道咱们统一结果集的处理点就肯定是再执行完请求之后,对结果处理的部分,也即下面这个代码点:

org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod#invokeAndHandle

这个方法进去之后就是先找到能够处理这个结果集的处理器,之后再调用处理器的 handleReturnValue 方法进行结果集处理

毫无疑问我们这个是 JSON 相关的结果集所以会找到 RequestResponseBodyMethodProcessor 处理器从而来到他的处理方法

这里面的代码就不一步步的跟进去了,代码量很多,我直接给结论:

判断出返回的结果集是 application/json 类型,那么就将启动阶段存入的 responseBodyAdvice 集合对象封装成一个执行器链(RequestResponseBodyAdviceChain),然后调用链依次执行看看 supports 方法返回的结果是否可以处理结果集,如果返回 true 则调用共 beforeBodyWrite 方法处理结果集,完成本次处理。

好了,这就是本篇的所有内容了,希望通过本篇你们能对统一结果集的处理有一个更深层的理解,而不是只知道使用。

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论