Spring中ResponseBodyAdvice的设计错误 2020-09-11 19:39 > 转载请注明作者及来源 ### 简介 在日常web编程中我们经常需要统一返回值,Spring为我们提供了一个接口叫做ResponseBodyAdvice,我们可以使用它来统一controllre层中的返回值。 本文从使用开始讲起,再到具体某个场景ResponseBodyAdvice设计错误的地方 以下基于:jdk1.8、spring-boot-starter-parent 2.3.3.RELEASE ### 使用 > 这里介绍的是我的使用方法 1、定义BaseResponse统一返回类,我们controller层返回值就统一为这个类。 ```java /** * @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,我们需要统一返回的方法或类,可以添加此注解。 ```java /** * @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的接口返回进行拦截,然后做统一返回处理。 ```java 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。 ```java @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测试,返回结果: ```json { "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时 ```java @NeedRole(value = RolesEnum.ADMIN) @ApiOperation(value = "新增") @PostMapping @ResultUnite public void save(@RequestBody Article article) { ApiAssert.notNull(article); articleService.save(article); } ``` postman测试,返回结果: ```json { "code": 200, "msg": "success", "data": null } ``` ### 问题 上述测试都成功了,可是我在做登陆时却发现了一个问题: > 这里只是将业务代码列了出来。 ```java 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是一样的。但是测试却发现登陆成功后并没有返回值:  返回的response是有我们添加的token的,说明业务代码是执行成功了的:  ### 解决 经过一番源码追踪后,我发现当controller的请求入参存在HttpServletResponse response时,spring底层在执行某个方法的时候,与其他普通入参走的是不同的逻辑。 > 以下为源码追踪,没有耐心的小伙伴可直接跳过。 #### 源码追踪 调用controller接口时,会先执行ServletInvocableHandlerMethod的invokeAndHandle方法,在此方法中调用其(ServletInvocableHandlerMethod)父类InvocableHandlerMethod的invokeForRequest方法。  在InvocableHandlerMethod的invokeForRequest方法中调用它自己的getMethodArgumentValues方法。  在getMethodArgumentValues方法中有一个 (i<入参数量])循环,循环中有一句重要的是调用了HandlerMethodArgumentResolverComposite的resolveArgument方法。  在HandlerMethodArgumentResolverComposite的resolveArgument方法内,先通过自身的getArgumentResolver方法得到一个实现了HandlerMethodArgumentResolver接口的对象resolver,当得到的此对象resolver不为null时,执行此对象的resolveArgument方法。  **重点就在这里**,HandlerMethodArgumentResolver是一个接口,有很多实现类,如下图。  不同的类对resolveArgument方法实现的内容不同,所以在每次循环时,调用到这里是有可能执行不同的事情的。具体来说就是:普通入参在这里得到的对象类型是ModelAttributeMethodProcessor,即resolver是ModelAttributeMethodProcessor类型的;而HttpServletResponse入参在这里得到的对象类型是ServletResponseMethodArgumentResolver,即resolver是ServletResponseMethodArgumentResolver。他们执行resolveArgument方法是有区别的:简单来说就是ServletResponseMethodArgumentResolver的resolveArgument方法里,会把mavContainer的requestHandled设置为true  而ModelAttributeMethodProcessor的resolveArgument方法则不会  所以在for循环中,当普通入参时,mavContainer对象的requestHandled是为false的  当循环到HttpServletResponse时,mavContainer对象的requestHandled就被设置为了true  getMethodArgumentValues方法执行完毕后得到Object数组,拿着这个数组执行doInvoke方法,此方法会执行我们的controller接口代码,即我们写的业务代码。业务代码执行完毕并返回,则继续在最开始的invokeAndHandle方法中向下执行。 (以上步骤执行的是invokeAndHandle方法中的invokeForRequest) 让我们回到ServletInvocableHandlerMethod类中的invokeAndHandle方法,在此方法内,执行invokeForRequest方法完毕后,业务代码已经返回,如果controller接口的返回值是void,那么这里得到的returnValue为null;当returnValue为null时,会进一步判断,下面这个判断中,当mavContainer.isRequestHandled()为true会直接返回掉。不会执行我们的包装方法,就是说不会进入我们的BaseResponseBodyAdvice类。  同样是controller接口返回值是void,现在我把入参的HttpServletResponse去掉,当执行到此时,mavContainer.isRequestHandled()是false(前两个条件不管有没有HttpServletResponse都是false,不是影响因素),那么就进不去第二个if语句。  进不去if语句那么就不会结束掉,而是继续向下执行,向下执行就会执行到我们的包装返回值的类BaseResponseBodyAdvice,对返回值进行包装。   当我们返回值不为void,有数据时,入参含有HttpServletResponse,虽然也会把mavContainer的requestHandled设置为true,但是在执行ServletInvocableHandlerMethod的invokeAndHandle方法时,执行invokeForRequest方法完毕后,由于controller接口有返回值,所以returnValue不会为null,所以不会进入这个if语句,也就不会因为mavContainer的requestHandled为true而结束掉。  所以当返回值不为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的团队,欢迎转载这篇文章,或将我的看法转述,谢谢。 ------ 帮我一起找到这个问题的人还有: - 单长江 --- 感兴趣的话还可以阅读下面的文章: - [三目运算符的错误](http://riun.xyz/work/76 "三目运算符的错误") --END--
发表评论