Spring或Spring Boot无侵入实现接口统一返回

问题描述

不知道你们有没有过这样的经历,项目已经经历了几个版本,但是始终没有做过统一返回,突然某一版要做统一返回格式,包括统一异常拦截处理也一起统一,比如统一成如下常用格式

{
    "code": 0;
    "message": "成功";
    "data": {}
}

如果要改代码这心态得崩啊,接口少的话倒是问题也不大,但是像我们接口比较多的情况下,改代码的话可能会想死,而且还有原先抛的自定义异常,我们处理后返回的结果如下:

{
    "code": aaa.bbb.ccc;
    "message": "某某操作不合法";
    "detail": {}                // 详细描述,不一定存在
}

这与我们想要统一的结构也不太一样,而且HTTP状态码也并非200,我们现在希望都返回200code来标识结果是否为正确返回,这直接原来抛异常的处理也不能用了,而这个异常的拦截处理又是在我们通用的web stater中去处理的,如果直接改造,这无疑将带来巨大的工作量。

可能这么说无法体会到这之中的工作量,那就详细说一下

Controller 中现有的写法:

@PostMapping("/test13")
public TestClass test13(@RequestBody @Validated TestClass test) {
    return test;
}

@Data
public static class TestClass {
    @NotNull
    private String name;

    @NotBlank
    private String address;
}

这到前端之间就一个TestClass返回了

{
    "name": felixu;
    "address": 杭州
}

但是我们希望的结构是:

{
    "code": 0;
    "message": "成功";
    "data": {
        "name": felixu;
        "address": 杭州
    }
}

这就需要对然后结果做包装,不然全部接口都得改成

@PostMapping("/test13")
public RespDTO<TestClass> test13(@RequestBody @Validated TestClass test) {
    return RespDTO.onSuc(test);
}

另一个就是原有的自定义异常:

@GetMapping("/test1")
public String test1() {
    throw new BusinessException("param.error");
}

它会被我们web stater中的统一异常处理器所处理:

@Slf4j
@RestControllerAdvice
public class ExceptionResolver {
    /**
     * i18n 消息源
     */
    private final MessageSource messageSource;

    public ExceptionResolver(MessageSource messageSource, Environment environment) {
        this.messageSource = messageSource;
    }

    @ExceptionHandler(value = BusinessException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse<?> bizExceptionHandler(BusinessException ex,
                                                HttpServletRequest request) {
        String code = ex.getMessage();
        //优先从代码传递里获取异常描述,再从定义业务出错码的文件里获取
        String message = MoreObjects.firstNonNull(ex.getI18nDesc(), messageSource.getMessage(code, ex.getArgs(), code, LocaleContextHolder.getLocale()));
        return ErrorResponse.of(code, message);
    }

然后在国际化文件中配有:

param.error=您提交的数据不符合要求

这样到前端就是如下结果:

{
    "code": "param.error";
    "message": "您提交的数据不符合要求"
}

而这跟我们提到的结构明显不一致,首先统一异常处理得改,第二点要兼容代码中原有的如throw new BusinessException("param.error");的写法,因为经过几次迭代了,代码中存在大量的这种写法。

所以我就在想有么有没有什么“投机取巧”的方式。

问题分析

基于以上描述,我们发现要做以下事情:

  1. 对所有的Controller中接口返回结果做包装,将其处理为我们的统一返回结构;
  2. 需要重新定义统一异常拦截处理,并取代web stater中的异常处理;
  3. 兼容代码中原有的抛异常处理,并保留国际化处理。

至于为啥要改,前面版本为啥要这么做,国际化为啥在后端,我只想说emmmm,毕竟我只是个搬砖的,好了,既然要干,那就想想办法吧。

解决问题

首先是解决code的维护,我们定义一个ErrorCode来存储code,而且原有代码中抛的英文串肯定要在这个类中也映射上,所以可以看到比如之前的param.error会对应到一个PARAM_ERROR(100, "param.error")枚举项,并且定义parse方法,将原先的字符串映射到枚举项:

@Getter
@AllArgsConstructor
public enum ErrorCode {

    /*-------------------------------- 成功 ------------------------------*/
    OK(0, "成功"),

    /*------------------- 失败(大多为没有被处理到的特殊异常) ------------------*/
    FAIL(-1, "internal.server.error"),
    MISSING_CODE(-2, "missing.code"),
   
    /*-------------------------------- 通用异常 ------------------------------*/
    PARAM_ERROR(100, "param.error"),
    ;

    /**
     * 返回的 code,非 0 表示错误
     */
    private final int code;

    /**
     * 发生错误时的描述
     */
    private final String message;

    /**
     * 由于经过很长时间的迭代,才出现要统一返回,统一异常 code
     * 故而兼容原 BusinessException 和 SystemException 解析到 ErrorCode
     * 避免大规模改代码
     * 后续可能会逐步移除
     */
    public static ErrorCode parse(String message) {
        for (ErrorCode value : ErrorCode.values()) {
            if (value.message.equals(message))
                return value;
        }
        return MISSING_CODE;
    }
}

其次既然要做统一返回,首先我们需要定义一个统一返回的结构体:

@Data
@AllArgsConstructor
public class RespDTO<T> {

    /**
     * 返回的 code 码
     */
    private int code;

    /**
     * 发生错误时的错误信息
     */
    private String message;

    /**
     * 成功返回时的真正数据集
     */
    private T data;

    /**
     * 成功返回且无任何数据返回
     */
    public static <T> RespDTO<T> onSuc() {
        return onSuc(null);
    }

    /**
     * 成功返回且需要传入真正被返回的数据
     */
    public static <T> RespDTO<T> onSuc(T data) {
        return build(ErrorCode.OK.getCode(), ErrorCode.OK.getMessage(), data);
    }

    /**
     * 错误返回时,放入错误类型的枚举
     */
    public static <T> RespDTO<T> onFail(ErrorCode error) {
        return onFail(error.getCode(), error.getMessage());
    }

    /**
     * 错误返回时,放入错误的 code 码以及错误信息
     */
    public static <T> RespDTO<T> onFail(int code, String message) {
        return build(code, message, null);
    }
    
    private static <T> RespDTO<T> build(int code, String message, T data) {
        return new RespDTO<>(code, message, data);
    }
}

完成以上定义,接下来便是改造工作了,对于Controller的包装,这让我想起来ResponseBodyAdvice接口,他的Java doc上有如下注释:

/**
 * Allows customizing the response after the execution of an {@code @ResponseBody}
 * or a {@code ResponseEntity} controller method but before the body is written
 * with an {@code HttpMessageConverter}.
 *
 * <p>Implementations may be registered directly with
 * {@code RequestMappingHandlerAdapter} and {@code ExceptionHandlerExceptionResolver}
 * or more likely annotated with {@code @ControllerAdvice} in which case they
 * will be auto-detected by both.
 *
 * @author Rossen Stoyanchev
 * @since 4.1
 */

他说允许我们在执行完@ResponseBody或者ResponseEntity的控制器方法之后,但是在HttpMessageConverter执行之前做自定义的响应。下面一段则是告诉我们该怎么做以及怎么被处理的了,不想涉及太多原理,便暂且忽略。所以我们做如下实现:

@RestControllerAdvice
public class BaseResponseAdvice implements ResponseBodyAdvice<Object> {

    @Value("${api-full-prefix:}")
    private String prefix;

    // 该方法用于确定是否被处理,这里直接所有请求均处理
    @Override
    public boolean supports(MethodParameter returnType, 
                            Class<? extends HttpMessageConverter<?>> converterType) {
        return true;
    }

    // 该方法用于处理包装返回
    @Override
    public Object beforeBodyWrite(Object body, 
                                  MethodParameter returnType, 
                                  MediaType contentType,
                                  Class<? extends HttpMessageConverter<?>> converterType,
                                  ServerHttpRequest request, 
                                  ServerHttpResponse response) {
        ServletServerHttpResponse servletServerHttpResponse = (ServletServerHttpResponse) response;
        HttpServletResponse realResponse = servletServerHttpResponse.getServletResponse();
        // 均返回 Json 格式
        // 主要是为了处理 body 为 String 类型时,后面的 JsonMapper.toNonNullJson(resp) 后
        // 将会以 content-type 为 text/plain 返回 Json 字符串
        realResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
        // 没有返回,直接返回 RespDTO.onSuc() 这样能将 void 返回一起处理
        if (body == null)
            return RespDTO.onSuc();
        // 已经是 RespDTO(避免被包两层) 或者不是项目路径开头直接返回(避免其他框架如 Swagger 等的请求被包装),不要杠我用 contains 毕竟某些有苦不能言
        if (body instanceof RespDTO || !request.getURI().getPath().contains(prefix))
            return body;
        // 其他类型直接包装
        RespDTO<Object> resp = RespDTO.onSuc(body);
        // 如果是 String 需要单独处理,不然 RespDTO 继续被 StringHttpMessageConverter 处理会报错
        return (body instanceof String) ? JsonMapper.toNonNullJson(resp) : resp;
    }
}

这便是我的实现了,其中有较详细的注释,便不再多解释,到此原有的返回便已经可以处理了,需要注意的就是String类型的返回需要单独处理,不然会报类型转换异常。

接下来便是处理原有的异常了,可是这个在web stater中已经定义过了,而这个类是无法被如exclude方法排除的,这里就需要定义一个统一异常拦截处理了,想必这个都不陌生吧,然后我们需要调整多个ControllerAdvice的执行顺序,当然,为了方便,不再直接在代码里面做类似throw new BusinessException("param.error");而改成throw new MyException(ErrorCode.PARAM_ERROR)这种形式,也对上层包中的BusinessException做了扩展,类似:

@Getter
public class MyException extends BusinessException {

    private final ErrorCode error;

    private final Object[] args;

    private final String i18nDesc;

    public MyException(ErrorCode error) {
        super(error.getMessage());
        this.error = error;
        this.args = null;
        this.i18nDesc = null;
    }

    public MyException(ErrorCode error, Object... args) {
        super(error.getMessage(), args);
        this.error = error;
        this.args = args;
        this.i18nDesc = null;
    }

    public MyException(ErrorCode error, String i18nDesc, Object... args) {
        super(error.getMessage(), i18nDesc, args);
        this.error = error;
        this.args = args;
        this.i18nDesc = i18nDesc;
    }
}

我们原有的异常:

@Getter
public class BusinessException extends RuntimeException {

    private final Object[] args;
    
    private final String i18nDesc;

    /**
     * @param code 异常错误code,非message
     */
    public BusinessException(String code) {
        this(code, (String) null);
    }

    /**
     * 需要带错误码及出错提示内容出来(动态内容,无法在拦截地方properties文件定义)
     *
     * @param code 异常code
     * @param i18nDesc  国际化翻译
     */
    public BusinessException(String code, String i18nDesc) {
        this(code, i18nDesc, (Object[]) null);
    }

    /**
     * @param code 错误码
     * @param i18nDesc  国际化翻译
     * @param args i18n 业务参数
     */
    public BusinessException(String code, String i18nDesc, Object... args) {
        super(code);
        this.args = args;
        this.i18nDesc = i18nDesc;
    }

    public BusinessException(String code, Object[] args) {
        this(code, null, args);
    }
}

接下来就统一异常拦截处理了:

@Slf4j
@RestControllerAdvice
// 调整该异常处理器的执行顺序,让其优先于 web stater 包中的执行,包中的便不再执行
@Order(1)
public class MyExceptionHandler {

    /**
     * i18n 消息源
     */
    private final MessageSource messageSource;

    public MyExceptionHandler(MessageSource messageSource, Environment environment) {
        this.messageSource = messageSource;
    }

    @ExceptionHandler(MyException.class)
    public ResponseEntity<RespDTO<?>> MyExceptionHandler(MyException ex, 
                                                           HttpServletRequest request) {
        log.debug("业务异常:{} {}", request.getMethod(), request.getRequestURI(), ex);
        ErrorCode error = ex.getError();
        String message = MoreObjects.firstNonNull(null, messageSource.getMessage(error.getMessage(), ex.getArgs(), error.getMessage(), LocaleContextHolder.getLocale()));
        return new ResponseEntity<>(RespDTO.onFail(error.getCode(), message), HttpStatus.OK);
    }

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<RespDTO<?>> bizExceptionHandler(BusinessException ex, 
                                                          HttpServletRequest request) {
        log.debug("业务异常:{} {}", request.getMethod(), request.getRequestURI(), ex);
        String code = ex.getMessage();
        ErrorCode error = ErrorCode.parse(code);
        Object[] args = ex.getArgs();
        if (error == ErrorCode.MISSING_CODE)
            args = new String[]{code};
        String message = MoreObjects.firstNonNull(ex.getI18nDesc(), messageSource.getMessage(error.getMessage(), args, code, LocaleContextHolder.getLocale()));
        return new ResponseEntity<>(RespDTO.onFail(error.getCode(), message), HttpStatus.OK);
    }
    .
    .
    .
    // 其他异常的处理省略
}

总结一下这里的操作:

  1. 通过@order(1)调整多个@RestControllerAdvice的顺序,让当前定义的执行,其他的不再执行
  2. 对于新拓展的异常,直接从枚举中拿到message,之后去国际化文件中获取真正的message信息,封装为统一结构体后返回
  3. 原有的异常,我们先将其解析为ErrorCode,再取ErrorCode中的message,完事之后再去取国际化后的message信息,封装为我们的统一返回体

结语

  • 每个领导对于架构上的认知都不一样,这种变化是不可控的,但是作为搬砖的我,该偷懒的时候还是要偷懒的,一个短的迭代周期要去改一大堆代码这种事情我是不想去干的。
  • 感觉迟早要写@RestControllerAdvice的原理????
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,546评论 6 507
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,224评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,911评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,737评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,753评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,598评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,338评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,249评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,696评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,888评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,013评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,731评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,348评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,929评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,048评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,203评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,960评论 2 355