转载请注明作者及来源
以下文章首发于我的个人网站: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是一样的。但是测试却发现登陆成功后并没有返回值:
返回的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的团队,欢迎转载这篇文章,或将我的看法转述,谢谢。
帮我一起找到这个问题的人还有:
- 单长江