Spring中ResponseBodyAdvice的设计错误

转载请注明作者及来源

以下文章首发于我的个人网站:http://riun.xyz/work/101

简介

在日常web编程中我们经常需要统一返回值,Spring为我们提供了一个接口叫做ResponseBodyAdvice,我们可以使用它来统一controllre层中的返回值。
本文从使用开始讲起,再到某个场景ResponseBodyAdvice设计错误的地方

以下基于:jdk1.8、spring-boot-starter-parent 2.3.3.RELEASE

使用

这里介绍的是我的使用方法

1、定义BaseResponse统一返回类,我们controller层返回值就统一为这个类。

/**
 * @author: HanXu
 * on 2020/9/1
 * Class description: controller对外统一返回体
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class BaseResponse<T> {
    private int code;
    private String msg;
    private T data;

    public BaseResponse(T data) {
        code = HttpServletResponse.SC_OK;
        msg = "success";
        this.data = data;
    }

    public BaseResponse(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }
}

2、定义注解@ResultUnite,我们需要统一返回的方法或类,可以添加此注解。

/**
 * @author: HanXu
 * on 2020/9/10
 * Class description: 统一返回结果
 */

@Target({ ElementType.TYPE, ElementType.METHOD }) //作用范围
@Retention(RetentionPolicy.RUNTIME) //生命周期
@Documented //可被文档化
@Inherited //
public @interface ResultUnite {
}

3、定义BaseResponseBodyAdvice类,实现ResponseBodyAdvice接口,对controller的接口返回进行拦截,然后做统一返回处理。

package xyz.riun.blog.wrapper.advice;

import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import xyz.riun.blog.entity.vo.BaseResponse;
import xyz.riun.blog.util.JacksonUtil;
import xyz.riun.blog.wrapper.annotation.ResultUnite;

import java.lang.reflect.Method;

/**
 * @author: HanXu
 * on 2020/9/1
 * Class description: 拦截响应:做统一响应体的处理
 */
@ControllerAdvice
public class BaseResponseBodyAdvice implements ResponseBodyAdvice<Object> {

    private static final String CONVERT_NAME = "org.springframework.http.converter.StringHttpMessageConverter";//String的消息转换器


    private boolean isResultUnite(MethodParameter methodParameter, Class aClass) {
        Method method = methodParameter.getMethod();
        return aClass.isAnnotationPresent(ResultUnite.class) ||method.isAnnotationPresent(ResultUnite.class);//是否被 @ResultUnite 注解
    }

    //先执行supports判断是否拦截
    @Override
    public boolean supports(MethodParameter methodParameter, Class aClass) {
        return isResultUnite(methodParameter, aClass);
    }

    //确认需要拦截后执行此方法对 Response 返回值进行拦截处理
    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        //处理String类型的返回值
        if (CONVERT_NAME.equalsIgnoreCase(selectedConverterType.getName())) {
            return JacksonUtil.toJson(new BaseResponse(body));
        }
        return new BaseResponse(body);
    }
}

添加此类后,controller层返回就会先被此类拦截,然后返回。拦截时先执行supports方法,判断返回值为true时,才会执行beforeBodyWrite方法,将返回值包装为BaseResponse类型;返回值为false时,则不会执行beforeBodyWrite方法。

(beforeBodyWrite中添加了对接口返回值是String类型的处理,是必须的,具体原因不再赘述)

supports中调用了isResultUnite,在isResultUnite判断当前执行的controller方法是否被@ResultUnite注解了,是,则返回true,那么就会将返回值包装为BaseResponse类型;否,则返回false,不会执行beforeBodyWrite,就不会对返回值做处理。

4、controller方法添加@ResultUnite注解

4.1、有具体的data返回值

给controller中的方法添加@ResultUnite注解,在业务中只需要返回我们的业务结果article。当调用此方法时就会自动将返回值包装为BaseResponse。

@Api(tags = "文章")
@RestController
@RequestMapping("api/article")
public class ArticleController {
    
    @Resource
    private ArticleService articleService;
    
	@ApiOperation(value = "通过id查看文章")
    @GetMapping("{id}")
    @ResultUnite
    public Article getById(@PathVariable Long id) {
        ApiAssert.notNull(id);
        Article article = articleService.getById(id);
        return article;
    }
}

postman测试,返回结果:

{
    "code": 200,
    "msg": "success",
    "data": {
        "id": 1303970889319481344,
        "title": "string",
        "intro": "string",
        "img": "string",
        "content": "string",
        "sign": 1,
        "createTime": "2020-09-10 16:17:41",
        "updateTime": "2020-09-10 16:17:41",
        "isactive": 1
    }
}

4.2、无具体的data返回值

当返回值为void时

    @NeedRole(value = RolesEnum.ADMIN)
    @ApiOperation(value = "新增")
    @PostMapping
    @ResultUnite
    public void save(@RequestBody Article article) {
        ApiAssert.notNull(article);
        articleService.save(article);
    }

postman测试,返回结果:

{
    "code": 200,
    "msg": "success",
    "data": null
}

问题

上述测试都成功了,可是我在做登陆时却发现了一个问题:

这里只是将业务代码列了出来。

package xyz.riun.blog.controller;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.*;
import xyz.riun.blog.entity.enums.RolesEnum;
import xyz.riun.blog.entity.model.Admin;
import xyz.riun.blog.exception.ApiAssert;
import xyz.riun.blog.service.AdminService;
import xyz.riun.blog.util.JwtUtil;
import xyz.riun.blog.wrapper.annotation.ResultUnite;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;

import static xyz.riun.blog.entity.constant.RequestConstant.*;


@Api(tags = "管理员")
@RestController
@RequestMapping("api/auth/admin")
public class AdminController {
    
    @Resource
    private AdminService adminService;
    @Resource
    private JwtUtil jwtUtil;
    
    @ApiOperation(value = "登陆")
    @GetMapping
    @ResultUnite
    public void login(@RequestBody Admin admin, HttpServletResponse response) {
        Admin resultAdmin = adminService.login(admin);
        if (resultAdmin == null) {
            ApiAssert.badRequest("登陆失败");
        }

        String token = jwtUtil.createJWT(resultAdmin.getId() + "", resultAdmin.getUsername(), RolesEnum.ADMIN);
        response.addHeader(TOKEN_NAME, TOKEN_PREFIXX + token);
    }
}

登陆接口中,我们先判断能否登陆,登陆成功后生成token并设置到response中,返回值为void跟前面返回值为void是一样的。但是测试却发现登陆成功后并没有返回值:

【图片1】

返回的response是有我们添加的token的,说明业务代码是执行成功了的:

【图片2】

解决

经过一番源码追踪后,我发现当controller的请求入参存在HttpServletResponse response时,spring底层在执行某个方法的时候,与其他普通入参走的是不同的逻辑。

以下为源码追踪,没有耐心的小伙伴可直接跳过。

源码追踪

调用controller接口时,会先执行ServletInvocableHandlerMethod的invokeAndHandle方法,在此方法中调用其(ServletInvocableHandlerMethod)父类InvocableHandlerMethod的invokeForRequest方法。

【图片3】

在InvocableHandlerMethod的invokeForRequest方法中调用它自己的getMethodArgumentValues方法。

【图片4】

在getMethodArgumentValues方法中有一个 (i<入参数量) 循环,循环中有一句重要的是调用了HandlerMethodArgumentResolverComposite的resolveArgument方法。

【图片5】

在HandlerMethodArgumentResolverComposite的resolveArgument方法内,先通过自身的getArgumentResolver方法得到一个实现了HandlerMethodArgumentResolver接口的对象resolver,当得到的此对象resolver不为null时,执行此对象的resolveArgument方法。

【图片6】

重点就在这里,HandlerMethodArgumentResolver是一个接口,有很多实现类,如下图。

【图片15】

不同的类对resolveArgument方法实现的内容不同,所以在每次循环时,调用到这里是有可能执行不同的事情的。具体来说就是:普通入参在这里得到的对象类型是ModelAttributeMethodProcessor,即resolver是ModelAttributeMethodProcessor类型的;而HttpServletResponse入参在这里得到的对象类型是ServletResponseMethodArgumentResolver,即resolver是ServletResponseMethodArgumentResolver。他们执行resolveArgument方法是有区别的:简单来说就是ServletResponseMethodArgumentResolver的resolveArgument方法里,会把mavContainer的requestHandled设置为true

【图片7】

而ModelAttributeMethodProcessor的resolveArgument方法则不会

【图片8】

所以在for循环中,当普通入参时,mavContainer对象的requestHandled是为false的

【图片9】

当循环到HttpServletResponse时,mavContainer对象的requestHandled就被设置为了true

【图片10】

getMethodArgumentValues方法执行完毕后得到Object数组,拿着这个数组执行doInvoke方法,此方法会执行我们的controller接口代码,即我们写的业务代码。业务代码执行完毕并返回,则继续在最开始的invokeAndHandle方法中向下执行。

(以上步骤执行的是invokeAndHandle方法中的invokeForRequest)

让我们回到ServletInvocableHandlerMethod类中的invokeAndHandle方法,在此方法内,执行invokeForRequest方法完毕后,业务代码已经返回,如果controller接口的返回值是void,那么这里得到的returnValue为null;当returnValue为null时,会进一步判断,下面这个判断中,当mavContainer.isRequestHandled()为true会直接返回掉。不会执行我们的包装方法,就是说不会进入我们的BaseResponseBodyAdvice类。

【图片11】

同样是controller接口返回值是void,现在我把入参的HttpServletResponse去掉,当执行到此时,mavContainer.isRequestHandled()是false(前两个条件不管有没有HttpServletResponse都是false,不是影响因素),那么就进不去这个if语句。

【图片12】

进不去if语句那么就不会结束掉,而是继续向下执行,向下执行就会执行到我们的包装返回值的类BaseResponseBodyAdvice,对返回值进行包装。

【图片13】

【图片14】

当我们返回值不为void,有数据时,入参含有HttpServletResponse,虽然也会把mavContainer的requestHandled设置为true,但是在执行ServletInvocableHandlerMethod的invokeAndHandle方法时,执行invokeForRequest方法完毕后,由于controller接口有返回值,所以returnValue不会为null,所以不会进入这个if语句,也就不会因为mavContainer的requestHandled为true而结束掉。

【图片16】

所以当返回值不为void时,入参无论是否含有HttpServletResponse都不影响我们对返回值的包装。

执行代码流程总结:(画的很差,实在是不知道该怎么画更好了= = !)

【总结图】

总结

总的来说,通过追踪源码我们发现:

1、如果controller返回值不为void,那么无论入参是否含有HttpServletResponse都不会影响包装类BaseResponseBodyAdvice的使用。

2、当controller返回值是void,如果入参含有HttpServletResponse,那么就不会走我们的包装类BaseResponseBodyAdvice;如果入参不含HttpServletResponse,则仍会执行包装类BaseResponseBodyAdvice的代码。

更简洁的一句话是:如果controller的入参需要使用HttpServletResponse,那么返回值不要为void,否则不会走我们的BaseResponseBodyAdvice类。即在此情况下spring提供给我们的统一返回值接口ResponseBodyAdvice失效。

我觉得这就是spring的设计失误,既然提供给了外界统一返回值的ResponseBodyAdvice接口,那么就应该由我们决定返回值要不要包装,spring的代码里不应该有这些因为入参影响ResponseBodyAdvice效果的代码。拿上面的情况举例子,如果我用了ResponseBodyAdvice,那么就由我来决定统一返回值的场景,我需要包装返回值的话,就给方法上添加@ResultUnite注解;我不要包装返回值的话,就不给方法添加@ResultUnite注解就行了。入参对功能造成影响是不合理的。

如果各位能联系到spring的团队,欢迎转载这篇文章,或将我的看法转述,谢谢。


帮我一起找到这个问题的人还有:

  • 单长江
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值