我从阿里云学到的返回值处理技巧

作者简介:大家好,我是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

进群,大家一起学习,一起进步,一起对抗互联网寒冬
  • 21
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值