作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬
阿里云@CosmoController
来思考一下前面两篇都做了什么。
一开始,我们发现前后端交互没有统一的数据格式,于是封装了Result/PageResult等工具类,统一JSON格式:
{
"data": {},
"success": true,
"message": "success"
}
随后,我们又发现出现异常时SpringBoot默认返回的JSON和正常响应时的JSON仍旧不统一,于是尝试使用Result处理异常,将自定义异常转为Result输出,并让@RestControllerAdvice对抛出的异常进行兜底处理。
@PostMapping("insertUser")
public Result<Boolean> insertUser(@RequestBody User user) {
if (user == null) {
// 常见处理1:只传入定义好的错误
return Result.error(ExceptionCodeEnum.EMPTY_PARAM)
}
if (user.getUserType() == null) {
// 常见处理2:抛出自定义的错误信息
return Result.error(ExceptionCodeEnum.ERROR_PARAM, "userType不能为空");
}
if (user.getAge() < 18) {
// 常见处理3:抛出自定义的错误信息
return Result.error("年龄不能小于18");
}
return Result.success(userService.save(user));
}
/**
* 全局异常处理
*
* @author mx
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 业务异常
*
* @param
* @return
*/
@ExceptionHandler(BizException.class)
public Result<ExceptionCodeEnum> handleBizException(BizException bizException) {
log.error("业务异常:{}", bizException.getMessage(), bizException);
return Result.error(bizException.getError());
}
/**
* 运行时异常
*
* @param e
* @return
*/
@ExceptionHandler(RuntimeException.class)
public Result<ExceptionCodeEnum> handleRunTimeException(RuntimeException e) {
log.error("运行时异常: {}", e.getMessage(), e);
return Result.error(ExceptionCodeEnum.ERROR);
}
}
但我曾见过阿里云的代码类似这样:
你会发现,人家返回的是CourseDTO,而不是Result.success(courseDTO)。但是,前端得到的JSON却是这样的:
还是做了统一结果封装!
于是你感到很困惑:我靠,怎么搞的?
秘密就在@CosmoController这个阿里云自定义的注解上!
认识ResponseBodyAdvice
我们直接看代码,后面再解释ResponseBodyAdvice是什么。
最简单的一个Controller是这样的:
@RestController
public class UserController {
@Autowired
private UserService userService;
@GetMapping("getUser")
public User getUser(Long id) {
return userService.getById(id);
}
}
得到的JSON是这样的:
{
"id": 1,
"name": "测试1",
"age": 18,
"userType": 1,
"createTime": "2021-01-13T19:18:20",
"updateTime": "2021-01-13T19:18:20",
"deleted": false,
"version": 0
}
我们加一个ResponseBodyAdvice:
@RestControllerAdvice
public class CommonResponseDataAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
return false;
}
@Override
public Object beforeBodyWrite(Object o,
MethodParameter methodParameter,
MediaType mediaType,
Class<? extends HttpMessageConverter<?>> aClass,
ServerHttpRequest serverHttpRequest,
ServerHttpResponse serverHttpResponse) {
return "mock result";
}
}
重新请求,你会发现!没什么变化...
不好意思,忘了把上面的CommonResponseDataAdvice#supports()返回值改成true了,重新请求:
怎么返回值变成了"mock result"了,JSON呢?打个断点观察一下:
哦,原来这个Object就是原先Controller的返回值。
整理一下ResponseBodyAdvice:
- Spring提供的一个接口,和AOP一样的,XxxAdvice都是用来增强的
- 配合@RestControllerAdvice注解,可以“拦截”返回值
- 通过supports()方法判断是否需要“拦截”
模拟阿里云@CosmoController
有了ResponseBodyAdvice,我们很容易想到:只要在beforeBodyWrite()方法内对返回值进行统一结果封装,就能达到@CosmoController一样的效果!
只需改一行代码:
@RestControllerAdvice
public class CommonResponseDataAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
// 对所有返回值起作用
return true;
}
@Override
public Object beforeBodyWrite(Object o,
MethodParameter methodParameter,
MediaType mediaType,
Class<? extends HttpMessageConverter<?>> aClass,
ServerHttpRequest serverHttpRequest,
ServerHttpResponse serverHttpResponse) {
// 改一行代码即可:把Object返回值用Result封装
return Result.success(o);
}
}
有@CosmoController的味道了,但我们用的是@RestController,而阿里云用的是自定义@CosmoController,逼格高一些。
怎么改成一样的呢?
分两步走:
- 定义@CosmoController注解
- 在CommonResponseDataAdvice中判断:如果使用了@CosmoController,就对该类所有返回值进行包装
定义@CosmoController
要明确一点,SpringBoot其实只会处理@Controller/@RestController,包括Controller Bean的实例化及返回值处理。@CosmoController哪位?没听过。
但我们可以学习@RestController的逆袭之路:
看到没,SpringBoot准确来说只认@Controller+@ResponseBody,但@RestController为了让SpringBoot承认自己,直接把两位大哥带在身边了(注解上面加注解,并不是什么新鲜事,你看@Target)。
所以,我们可以在@CosmoController上面套一个@RestController:
@RestController
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface CosmoController {
}
这样的好处是,原先@RestController有的功能@CosmoController都“继承”了(让你模仿,也希望你超越)。
ResponseBodyAdvice统一结果封装
我们的目标是:
- 如果使用了@CosmoController,就在CommonResponseDataAdvice中使用Result封装结果
- 如果使用了原生的@RestController,就原样返回,不做任何处理
@RestControllerAdvice
public class CommonResponseDataAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
// 对标注了@CosmoController注解的Controller返回值进行处理。methodParameter.getDeclaringClass()表示得到方法所在的类。
return methodParameter.getDeclaringClass().isAnnotationPresent(CosmoController.class);
}
@Override
public Object beforeBodyWrite(Object o,
MethodParameter methodParameter,
MediaType mediaType,
Class<? extends HttpMessageConverter<?>> aClass,
ServerHttpRequest serverHttpRequest,
ServerHttpResponse serverHttpResponse) {
return Result.success(o);
}
}
对UserController分别使用@RestController和@CosmoController,发现已经达到预期效果。
优化
上面的代码还不够健壮,有些情况没考虑到:
- 如果Controller返回值已经用Result封装过了呢,此时会造成重复嵌套!
- 标注了@CosmoController后,内部个别方法不希望用Result封装该怎么做?
- 诸如参数校验失败等情况怎么处理呢?
如果Controller中的返回值已经用Result封装过,应该直接返回,否则会出现重复嵌套:
{
"code": 200,
"message": "成功",
"data": {
"code": 200,
"message": "成功",
"data": {
"id": 1,
"name": "测试1",
"age": 18,
"userType": 1,
"createTime": "2021-01-13T19:18:20",
"updateTime": "2021-01-13T19:18:20",
"deleted": false,
"version": 0
}
}
}
解决办法是,在beforeBodyWrite()里判断并排除:
@Override
public Object beforeBodyWrite(Object o,
MethodParameter methodParameter,
MediaType mediaType,
Class<? extends HttpMessageConverter<?>> aClass,
ServerHttpRequest serverHttpRequest,
ServerHttpResponse serverHttpResponse) {
// 已经包装过的,不再重复包装
if (o instanceof Result) {
return o;
}
return Result.success(o);
}
如果个别方法希望忽略Result封装,可以单独再定一个注解:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface IgnoreCosmoResult {
}
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
// 标注了@CosmoController,且类及方法上都没有标注@IgnoreCosmoResult的方法才进行包装
return methodParameter.getDeclaringClass().isAnnotationPresent(CosmoController.class)
&& !methodParameter.getDeclaringClass().isAnnotationPresent(IgnoreCosmoResult.class)
&& !methodParameter.getMethod().isAnnotationPresent(IgnoreCosmoResult.class);
}
@Slf4j
@CosmoController
public class UserController {
@IgnoreCosmoResult
@GetMapping("getUser")
public User getUser(Long id) {
return null;
}
@GetMapping("getUser2")
public User getUser2(Long id) {
return null;
}
}
完整的代码:
@RestControllerAdvice
public class CommonResponseDataAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
// 标注了@CosmoController,且类及方法上都没有标注@IgnoreCosmoResult的方法才进行包装
return methodParameter.getDeclaringClass().isAnnotationPresent(CosmoController.class)
&& !methodParameter.getDeclaringClass().isAnnotationPresent(IgnoreCosmoResult.class)
&& !methodParameter.getMethod().isAnnotationPresent(IgnoreCosmoResult.class);
}
@Override
public Object beforeBodyWrite(Object o,
MethodParameter methodParameter,
MediaType mediaType,
Class<? extends HttpMessageConverter<?>> aClass,
ServerHttpRequest serverHttpRequest,
ServerHttpResponse serverHttpResponse) {
// 已经包装过的,不再重复包装
if (o instanceof Result) {
return o;
}
return Result.success(o);
}
}
第三个问题,你仔细想想,其实解决第一个问题时顺便搞定了。如果参数校验错误,处理方式大致有两种:
- 转为自定义异常抛出,由@RestControllerAdvice兜底处理
- 在当前方法中用Result.error()封装错误信息返回
ResponseBodyAdvice对第一种策略没有影响,异常仍旧会被@RestControllerAdvice全局异常捕获,而第二种策略由于已经用Result封装,会被ResponseBodyAdvice忽略,不再重复包装,所以前端收到的是正确的格式:
{
"code": -1
"message": "用户不存在",
"data": null
}
最后我想说,这种封装意义好像也不大~后面介绍一些其它用法吧。
作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
进群,大家一起学习,一起进步,一起对抗互联网寒冬