异常处理在开源SpringBoot/SpringCloud微服务框架的最佳实践

前期内容导读:

  1. Java开源RSA/AES/SHA1/PGP/SM2/SM3/SM4加密算法介绍
  2. Java开源AES/SM4/3DES对称加密算法介绍及其实现
  3. Java开源AES/SM4/3DES对称加密算法的验证说明
  4. Java开源RSA/SM2非对称加密算法对比介绍
  5. Java开源RSA非对称加密算法实现
  6. Java开源SM2非对称加密算法实现
  7. Java开源接口微服务代码框架
  8. Json在开源SpringBoot/SpringCloud微服务框架中的最佳实践
  9. 加解密在开源SpringBoot/SpringCloud微服务框架的最佳实践
  10. 链路追踪在开源SpringBoot/SpringCloud微服务框架的最简实践
  11. OAuth2在开源SpringBoot/SpringCloud微服务框架的最佳实践
  12. 熔断降级与限流在开源SpringBoot/SpringCloud微服务框架的最佳实践
  • Java开源接口微服务代码框架代码已全部开源
    )基础上,站在过来人的角度,总结了分布式微服务中非常容易被忽略的异常处理逻辑,有助于更好地理解和开发微服务;
  • 本文不是简单讲解异常的分类,而是告诉你异常相关的场景、处理思路和具体实现,力求简洁优雅;

1. Java为什么要有异常

  • 按照个人见解,可以从两个维度去解释:
    • 从Java面向对象的设计理念来讲,设计的类主要是面向对象的,对象有特定的行为和属性,但是对象的行为和属性解释不了现实世界的真实活动。如:设计了一个汽车对象,有方向盘、轮子、发动机等属性,有前进、左转、右转、倒车等行为。当汽车对象发生前进动作时,可能因为车轮爆胎而左转了。这个左转行为跟前进动作就完全不匹配了。汽车对象正常的前进行为属于正常的常规行为,但是前进时突然发生的意外左转就属于异常行为。编码时,类似的例子比比皆是。
    • 从Java虚拟机的设计理念来讲,程序执行的过程,就是做计数标记,然后不停地压栈和出栈的过程,异常就是程序段(方法)返回到执行前的行为,注意:栈内改变的数据并未回滚。如:执行一个复杂的加密方法时,突然发生了数组越界,则加密方法已经执行的部分就会全部出栈,这时程序就得不到加密的结果了。简单来说,虚拟机无法正常进行下去的行为都属于异常。这样的异常非常多,比如:内存溢出、内存不足、空指针、数组越界等。
  • 在网上也看到一些关于异常产生的观点,和本人理解的维度有点不同,也建议大家看看:

2 Java异常分类

  • 按照JDK的异常体系去分,根异常是Throwable(就好比所有类的父类Object),Throwable可以分为错误(Error)和异常(Exception),我们通常需要捕获Exception异常;
  • 除了JDK异常外,还有大量三方件和用户自定义的Exception,这些Exception以运行时异常为主。自定义异常肯定是无法穷举的,只能大致分类。按照个人理解,无论是JDK异常还是自定义异常,均可分为模块化异常和服务化异常。模块化异常只是代码内部的异常,客户端无感知;服务化异常则是把最终的异常结果响应到客户端,影响客户体验。

    上述多个维度的异常划分并不矛盾,要想做好微服务研发,就必须处理好服务化异常;而服务化异常内部有大量的模块化异常;模块化异常则是由JDK异常和自定义异常组成的。

3. JDK异常处理

  • JDK的异常体系如图所示:
    Java异常分类
    图片来源:java 异常分类和处理机制
    • 通常情况下,Error是我们无法掌控的,但是在特殊场景下,需要捕获Throwable(为了兼顾捕获Exception和Error,一般不直接捕获Error),主要是捕获异常的堆栈信息,之后还要继续往外抛异常。比如:虚拟机的内存溢出异常,不会因为你不往外抛就不崩溃。
    • 我们需要非常关注Exception,包括非运行时异常和运行时异常(RuntimeException),而且是二者都要处理好。
  • 自定义异常种类繁多,可以在后面的模块化和服务化异常中去附带说明;

4. 模块化异常处理

  • 个人理解的模块化异常是指异常没有最终暴露给客户端,且是通过自己的业务逻辑代码响应给客户端的。

  • 按照Java开源接口微服务代码框架 场景规划,模块化异常列表如下:

    异常类型所属分类示例备注
    业务校验异常运行时异常1.用户名和密码不匹配异常;
    2. …
    也可以用自定义异常
    切面异常自定义异常1.渠道限流(Rest调用第三方时调用量超限)异常;
    2. …
    也可以用自定义异常
    逻辑不当异常运行时异常1.数组越界异常;
    2. 加解密失败异常;
    3. …
    也可以用自定义异常
    依赖服务异常非运行时异常1.请求超时异常,如SocketTimeoutException;
    2.服务拒绝异常;
    3. …
    也可以用自定义异常
    中间件异常非运行时异常1.数据库连不上导致查不到数据的异常;
    2.磁盘无法写入异常;
    3. …
    也可以用自定义异常
    • 表格的模块化异常包含了非运行时异常和非运行时异常,所以一般为了兼顾,采取捕获Exception;
    • 模块化异常基本上都可以用自定义的异常去替代;
  • 模块化异常的核心问题是在做业务逻辑处理的时候,内部发生了异常,但最终会被外层的代码转换成正常的结果返回到客户端。如:在BaseRestService 发起远程调用时,会捕获渠道限流切面、网络超时、数据库异常等所有的Exception异常,并转换成正常的结果响应对象ResultCode,只是把错误码填充到结果对象了而已。

        protected ResultCode<O> invokeResult(T model)
        {
            ResultCode<O> resultCode = ResultCode.error(ErrCodeEnum.SERVER_ERROR.getCode());
            try
            {
                //此处被切面做了限流判定逻辑
                resultCode = this.getRemoteService().invoke(model);
            }
            catch (CommonException e)
            {
                resultCode = ResultCode.error(e.getErrCode().getCode());
            }
            catch (Exception e)
            {
                log.error("unknown error in channel.", e);
            }
            finally
            {
                resultCode.setReqId(model.getReqId());
                resultCode.setCost(System.currentTimeMillis() - model.getStart());
            }
            return resultCode;
        }
    

    通过切面实现的渠道限流详细设计参见文档:熔断降级与限流在开源SpringBoot/SpringCloud微服务框架的最佳实践

  • 在模块化异常中,还有一类非常特殊的异常,就是线程池异常。由于线程是异步执行的,通常无法通知到发起方。这时候就要记录好相应的异常堆栈信息,CommonThreadFactory 代码如下:

    public class CommonThreadFactory implements ThreadFactory
    {
        @Override
        public Thread newThread(Runnable r)
        {
            //获取主线程的链路信息
            MDCAdapter mdc = MDC.getMDCAdapter();
            Map<String, String> map = mdc.getCopyOfContextMap();
            Thread t = new Thread(r, this.poolPrefix + "-thread-" + THREAD_ID.getAndIncrement())
            {
                @Override
                public void run()
                {
                    try
                    {
                        //把链路追踪设置到线程池中的线程
                        if (null != map)
                        {
                            MDC.getMDCAdapter().setContextMap(map);
                        }
                        super.run();
                    }
                    finally
                    {
                        //使用完毕后,清理缓存,避免内存溢出
                        MDC.clear();
                    }
                }
            };
            return t;
        }
    
        static
        {
            Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler()
            {
                @Override
                public void uncaughtException(Thread t, Throwable e)
                {
                    LOGGER.error("Current rejected thread is {},error:{}", t.getName(), e);
                }
            });
        }
    }
    

5. 服务化异常处理

  • 服务化异常承载了一部分异常场景的业务逻辑,是业务响应的一部分。

  • 按照Java开源接口微服务代码框架 场景规划,服务化异常列表如下:

    异常类型所在服务示例实现技术
    参数校验异常业务服务
    1.用户名不存在异常;
    2. …
    spring-boot-starter-validation改造
    过滤器校验异常网关服务
    认证服务
    1.签名验证失败异常;
    2. 用户认证失败异常;
    3. …
    1.GlobalFilter扩展;
    2.spring-authorization-server Filter扩展;
    熔断降级限流异常网关服务
    业务服务
    1.熔断降级异常;
    2. 限流异常;
    自定义Sentinel异常扩展
    客户限流异常业务服务1.客户A QPS超限异常;
    2. …
    Redis实现
    全局异常网关服务
    认证服务
    业务服务
    1.内部错误异常;
    2. …
    webflux全局异常扩展;
    spring-authorization-server Filter扩展+web全局异常扩展;
    web全局异常异常扩展
    • 参数校验异常是每个微服务必备的,当前虽然从场景分析,仅需业务服务做校验,但是长远考虑,还是所有服务都得做。因此需要把校验框架加在公共代码模块,保证所有微服务随时可以添加上;
    • 此处的客户限流和上一章节的渠道限流含义不同:客户限流表示限制客户端的调用;渠道限流表示限制我们对第三方服务的调用。在熔断降级与限流在开源SpringBoot/SpringCloud微服务框架的最佳实践 文档中有详细的设计与实现。
  • 根据实现的技术不同,分为web服务化异常、spring-authorization-server服务化异常和webflux服务化异常。

5.1 web服务化异常处理

  • web服务化异常单独汇总如下:

    所在服务异常类型示例实现技术直接响应
    业务服务参数校验异常1.用户名不存在异常;
    2. …
    spring-boot-starter-validation改造
    业务服务熔断降级异常1.熔断降级异常自定义Sentinel异常扩展
    业务服务非功能限流异常1. 限流异常自定义Sentinel异常扩展
    业务服务客户限流异常1.客户A QPS超限异常;
    2. …
    Redis实现
    业务服务全局异常1.内部错误异常;
    2. …
    web全局异常异常扩展
    • 你可能觉得参数异常可以单独返回错误信息到客户端,比如:参数校验,完全可以在校验失败的注解中直接返回错误的message。但是如果你做过非常严格的海外业务(比如:国外重量级企业的项目),就会知道编译后的代码是不允许出现中文的;如果你做过多语言项目(如:跨境电商等),就会知道响应的错误Message的国际化整改非常痛苦;
    • 熔断降级和非功能限流,如果只是针对Rest接口而言,都是可以在自定义的异常中返回的,但是考虑到使用@SentinelResource注解对其中的部分资源做熔断降级和限流时,则只能通过全局异常来做统一把控;
    • 以上所有不能直接响应的服务化异常,都是通过全局异常来响应到客户端的;
5.1.1 web参数校验服务化异常最佳实践
5.1.1.1 web参数校验服务化异常分析
  • 参数校验可以非常好的结合spring-boot-starter-validation框架,可以支持各种复杂的校验场景;

  • 参数校验不是我们的最终目的,参数校验完,我们还需要返回响应的异常信息到客户端;

  • 做接口服务时,通常要求一个错误码一个对应的描述,比如:{"code":"001","msg":"参数错误"}。但是在接口的参数校验时,又希望一个错误码对应多个描述,比如:{"code":"001","msg":"用户名不能为空"}{"code":"001","msg":"用户名长度不符合要求"}

    基于上面的分析,设计思路如下:

    • 使用spring-boot-starter-validation,并通过Spring框架的@ControllerAdvice@ExceptionHandler注解组合,获取到未捕获到或者主动抛到web容器中的异常,并单独定义一个参数异常处理方法,对参数校验进行处理;
    • 给错误码设计多个字段:code表示错误码;msg表示错误码对应的异常信息,如:参数错误detail表示错误详情,对应msg的更详细信息,如:用户名不能为空
    • spring-boot-starter-validation校验的注解中,不能把msg直接返回,而是要通过错误码对象的code+detail拼接的国际化key去定义,然后再从国际化错误码中去获取相应的显示内容;
5.1.1.2 web参数校验服务化异常实现过程
  • 引入校验框架的maven pom依赖:
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
        <version>2.7.4</version>
    </dependency>
    <dependency>
        <groupId>org.hibernate.validator</groupId>
        <artifactId>hibernate-validator</artifactId>
        <version>6.2.5.Final</version>
    </dependency>
    
  • 为了接收校验框架抛出到容器的参数异常,编写全局异常的处理器GlobalExceptionHandler:
    @Slf4j
    @ControllerAdvice
    public class GlobalExceptionHandler extends BaseExceptionHandler
    {
        @ExceptionHandler({MethodArgumentNotValidException.class})
        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
        @ResponseBody
        public ResultCode<?> handleValidErr(HttpServletRequest req, MethodArgumentNotValidException e)
        {
            List<ObjectError> errors = e.getBindingResult().getAllErrors();
            log.error("current[{}] request happened err:{}", req.getRequestURI(), errors);
            return validHandler.handleValidErr(errors);
        }
    
        /**
         * 自动注入校验处理器
         */
        @Autowired
        private ValidHandler validHandler;
    }
    
    • 其中专门通过ValidHandler参数校验器对异常结果进行处理,其实现会在后面介绍;
    • 本文所列的代码全部开源,参见文档开头介绍。如需获取完整源码仅需下载到本地即可,下同。
  • 为了把校验框架抛出的异常解析成错误码对象,定义了默认的参数校验器DefaultValidHandlerImpl:
    @Slf4j
    public class DefaultValidHandlerImpl implements ValidHandler
    {
        @Override
        public ResultCode<?> handleValidErr(List<ObjectError> errors)
        {
            if (CollectionUtils.isEmpty(errors))
            {
                log.error("failed to get valid error.");
                ErrCode errCode = ErrCodeMgr.getServerErr();
                return ResultCode.error(errCode.getCode());
            }
            else
            {
                for (ObjectError error : errors)
                {
                    String code = error.getDefaultMessage();
                    log.error("get valid error:{}", code);
                    if (!StringUtils.isEmpty(code) && code.contains(Const.LINK))
                    {
                        return ResultCode.build(ErrCodeMgr.getValid(code));
                    }
                }
                return ResultCode.build(ErrCodeMgr.getServerErr());
            }
        }
    }
    
  • 定义一个国际化的错误码文件errcode_zh_CN.properties:
    100001.MSG=通过
    100002.MSG=签名验证失败
    100003.MSG=流量超限
    100004.MSG=认证失败
    100005.MSG=参数错误
    100098.MSG=内部错误
    100099.MSG=未通过
    
    • 国际化文件都是会有多种语言的,限于篇幅,只列了中文部分。下同。
  • 定义一个国际化的参数校验错误码文件errcode_valid_zh_CN.properties:
    100005_bq.tips.name.invalid-length=用户名长度非法
    100005_bq.tips.name.not-empty=用户名长度不能为空
    
  • 编写读取上述错误码信息的错误码管理器ErrCodeMgr:
    public final class ErrCodeMgr
    {
        /**
         * 获取标准的错误码
         *
         * @param code   错误码code
         * @param locale 语言
         * @return 错误码对象
         */
        public static ErrCode get(String code, Locale locale)
        {
            if (StringUtils.isEmpty(code))
            {
                LOGGER.error("no standard code[{}/{}].", code, locale);
                return getServerErr();
            }
            String msgKey = code + CODE_SUFFIX;
            String msg = I18N.get(locale, msgKey);
            if (StringUtils.isEmpty(msg))
            {
                LOGGER.error("not exist standard code[{}/{}].", code, locale);
                return getServerErr();
            }
            return ErrCode.build(code, msg);
        }
    
        /**
         * 获取标准的错误码(带detail,适用于参数校验场景)
         *
         * @param code   错误码code
         * @param locale 语言
         * @return 错误码对象
         */
        public static ErrCode getValid(String code, Locale locale)
        {
            if (StringUtils.isEmpty(code))
            {
                LOGGER.error("no standard parameter code[{}/{}].", code, locale);
                return getServerErr();
            }
            String[] codes = StringUtils.split(code, Const.LINK);
            String realCode = codes[0];
            ErrCode errCode = get(realCode);
            if (null == errCode)
            {
                LOGGER.error("no standard code[{}/{}] in parameter config.", code, locale);
                return getServerErr();
            }
            //只有当参数校验对应的标准错误码存在时,才添加detail
            if (code.contains(errCode.getCode()))
            {
                errCode.setDetail(I18N.getValid(code));
            }
            return errCode;
        }
    }
    
    • 错误码的映射关系和读取,设计和实现上稍微有点复杂,以后会写一篇文章单独介绍。
  • 为了支持复杂场景,需要编写校验的组ValidGroup.java:
    public interface ValidGroup extends Default
    {
        interface Add extends ValidGroup
        {
        }
    
        interface Get extends ValidGroup
        {
        }
    
        interface GetBatch extends ValidGroup
        {
        }
    
        interface Update extends ValidGroup
        {
        }
    
        interface Delete extends ValidGroup
        {
        }
    }
    
  • 至此,基于spring-boot-starter-validation改造的校验框架就全部完成了。
5.1.1.3 web参数校验服务化异常验证
  • Controller入参的校验注解有@Validated@Valid,一个是Spring校验框架提供,一个是Servlet标准注解,其实现是由hibernate-validator完成,而hibernate-validator也是Spring校验框架的核心模块;
  • 基于上述分析,编写测试RestController的入参对象UserInner,代码如下:
    @Data
    public class UserInner
    {
        /**
         * 用户名
         */
        @NotNull(message = "100005_bq.tips.name.not-empty")
        @Length(min = 5, max = 9, message = "100005_bq.tips.name.invalid-length")
        @Length(min = 8, max = 15, message = "100005_bq.tips.name.invalid-length", groups = ValidGroup.Get.class)
        @JsonMaskAnn
        private String name;
    
        /**
         * 真实姓名
         */
        @Length(min = 5, max = 15, message = "100005_bq.tips.name.invalid-length", groups = ValidGroup.Get.class)
        @Length(min = 5, max = 20, message = "100005_bq.tips.name.invalid-length", groups = ValidGroup.Add.class)
        @JsonMaskAnn
        private String realName;
    }
    
  • 编写测试Rest服务DemoUserController,分别对带分组和不带分组的情况进行验证:
    @Slf4j
    @RestController
    public class DemoUserController
    {
        @PostMapping("/demo/user/valid-get")
        public ResultCode<UserOuter> validate1(@RequestBody @Validated UserInner user)
        {
            UserOuter outer = new UserOuter();
            BeanUtils.copyProperties(user, outer);
            ResultCode<UserOuter> resultCode = ResultCode.ok(outer);
            return resultCode;
        }
    
        @PostMapping("/demo/user/valid-get2")
        public ResultCode<UserOuter> validate2(@RequestBody @Valid UserInner user)
        {
            UserOuter outer = new UserOuter();
            BeanUtils.copyProperties(user, outer);
            ResultCode<UserOuter> resultCode = ResultCode.ok(outer);
            return resultCode;
        }
    
        @PostMapping("/demo/user/valid-group-get")
        public ResultCode<UserOuter> validateGroupGet(@RequestBody @Validated(ValidGroup.Get.class) UserInner user)
        {
            UserOuter outer = new UserOuter();
            BeanUtils.copyProperties(user, outer);
            ResultCode<UserOuter> resultCode = ResultCode.ok(outer);
            return resultCode;
        }
    }
    
  • 验证过程如下:
    • 先验证不带校验组的情况,分别通过curl命名调用@Validated@Valid注解的请求,命名如下:
          curl --location 'http://localhost:9993/demo/user/valid-get' \
          --header 'Content-Type: application/json' \
          --data '{
              "name": "hao"
          }'
      
          curl --location 'http://localhost:9993/demo/user/valid-get2' \
          --header 'Content-Type: application/json' \
          --data '{
              "name": "hao"
          }'    
      
    • 其返回结果完全相同,json如下:
      {
          "code": "100005",
          "msg": "参数错误",
          "detail": "用户名长度非法",
          "cost": 0
      }
      
      • 进一步印证@Validated@Valid注解是等效的,但是基于Servlet标准(或者JavaEE标准)的@Valid注解不支持分组校验,所以建议使用@Validated,既能达到@Valid的校验效果,还能扩展支持分组;
      • 从验证结果Json来看,错误code,错误message,错误detail全部都能显示了,这样就可以满足不同的参数异常展示效果了;
    • 再来验证下不带分组和带分组的组合情况,分别构造如下请求报文:
      curl --location 'http://localhost:9993/demo/user/valid-group-get' \
      --header 'Content-Type: application/json' \
      --data '{
          "name": "hao1234567",
          "real_name":"name123"
      }'
      
      curl --location 'http://localhost:9993/demo/user/valid-group-get' \
      --header 'Content-Type: application/json' \
      --data '{
          "name": "hao123456",
          "real_name":"name123456789123"
      }'
      
    • 二者的响应结果均为:
      {
          "code": "100005",
          "msg": "参数错误",
          "detail": "用户名长度非法",
          "cost": 0
      }
      
      • 从请求1及结果分析可知:当带不带分组和带分组的校验规则同时存在时,二者同时生效,而且必须都满足才能校验通过,否则就校验不通过;
      • 从请求2及结果分析可知:只有匹配的分组校验规则是生效的,其它分组的校验规则是无效的;
  • 前面也讲了校验失败时,返回错误code,错误message,错误detail会显得比较繁琐,实现的代码框架实际也可以支持扩展定制成只需要错误code,错误message,只不过错误message内存放的是错误detail。为了达成这个效果,仅需新增自定义的校验处理器ValidHandlerImpl即可,其它代码无须做任何改动。
    @Slf4j
    @Component
    public class ValidHandlerImpl implements ValidHandler
    {
        @Override
        public ResultCode<?> handleValidErr(List<ObjectError> errors)
        {
            if (CollectionUtils.isEmpty(errors))
            {
                log.error("failed to get valid error.");
                ErrCode errCode = ErrCodeMgr.getServerErr();
                return ResultCode.error(errCode.getCode());
            }
            else
            {
                for (ObjectError error : errors)
                {
                    String message = error.getDefaultMessage();
                    log.error("get valid error:{}", message);
                    if (!StringUtils.isEmpty(message) && message.contains(Const.LINK))
                    {
                        ErrCode errCode = ErrCodeMgr.getValid(message);
                        log.error("current valid error:{}", JsonUtil.toJson(errCode));
                        errCode.setMsg(errCode.getDetail());
                        errCode.setDetail(null);
                        return ResultCode.build(errCode);
                    }
                }
                return ResultCode.build(ErrCodeMgr.getServerErr());
            }
        }
    }
    

    注意:ValidHandlerImpl这个类也已提交并生效了,如果要达成前面示例的返回效果,需要注释掉这个类的代码。这个类的验证效果略。

5.1.2 web熔断降级和限流服务化异常最佳实践
5.1.2.1 web熔断降级和限流服务化异常实现
  • 熔断降级与限流在开源SpringBoot/SpringCloud微服务框架的最佳实践
    中已经介绍了基于Sentinel的熔断降级和限流。Sentinel主要分为基于Rest请求和基于资源标签的熔断降级和限流2类。二者均需引入pom依赖:
    <!--sentinel熔断降级-->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
        <version>2021.0.5.0</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-alibaba-sentinel-datasource</artifactId>
        <version>2021.0.5.0</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba.csp</groupId>
        <artifactId>sentinel-datasource-nacos</artifactId>
        <version>1.8.6</version>
    </dependency>
    
  • Sentinel基于Rest请求的熔断降级和限流已经能够满足大部分场景了,建议优先考虑。Sentinel基于Rest请求的统一异常配置服务SentinelWebConfigurer定义如下:
    @Slf4j
    @Configuration
    public class SentinelWebConfigurer
    {
        /**
         * 定义异常时的处理器(使用自定义的错误码)
         *
         * @return 熔断降级异常时的处理器
         */
        @Bean
        public BlockExceptionHandler blockSentinelHandler()
        {
            return (request, response, e) ->
            {
                log.error("limit block happened.", e);
                ResultCode<?> resultCode = ResultCode.error(ErrCodeEnum.LIMIT_ERROR.getCode());
                ResponseUtil.writeErrorBody(response, JsonUtil.toJson(resultCode, snakeCase));
            };
        }
    
        /**
         * 是否驼峰式json(默认支持)
         */
        @Value("${bq.json.snake-case:true}")
        private boolean snakeCase;
    }
    
5.1.2.2 web接口熔断降级和限流服务化异常验证
  • 因为sentinel默认是懒加载,需要先请求下接口。先执行命令获取JwtToken:

    curl --location --request POST 'http://localhost:9992/oauth/enc/token?scope=read&grant_type=client_credentials' \
    --header 'bq-integrity: ef5b4373e5c24c2c0ccd6700a3e9b70f2b3a80268f9d4b1cc5bf8740e5dd81ca' \
    --header 'bq-enc: app001' \
    --header 'Authorization: 04d726fd8806b1c0763fead3a90592e592d8abb8290a6b638208c19977ae2d52e3553e507ad72f7e62b33a70fdaca4d8416ec091a59fe7eb8246745b5b6c88c15db580847186e895b29b47a569d2fc4e70e1e63e44aa529b396db710663d9a0c0c0f3a171885f74e0d8eec10469cb757d02b57a850547dc03bfa23'
    
  • 获取到jwtToken后,替换到如下命令中的JwtToken后,执行命令发起对/demo/jwk接口的调用:

    curl --location 'http://localhost:9992/demo/jwk' \
    --header 'Authorization: Bearer yyy...' \
    --header 'Content-Type: application/json' \
    --data '{
        "code":"test123"
    }'
    
  • 打开Sentinel控制面板就可以看到如下请求:

    业务服务-1

  • 继续点击图注的流控按钮,添加限流规则:

    业务服务-2

  • 多次执行上述命令,请求/demo/jwk接口,会看到如下返回结果:

    {
        "code": "100003",
        "msg": "流量超限",
        "cost": 0
    }
    
  • 观测运行日志,发现也从我们自定义的handler中打印出了相关异常:

    [bq-biz][Tid:2e534ec2f199a69b,Sid:c64a17f11048a522][ERROR][c.b.b.c.SentinelWebConfigurer_lambda$blockSentinelHandler$0] - limit block happened.
    com.alibaba.csp.sentinel.slots.block.flow.FlowException: null
    

    总结:Sentinel对单个接口的限流已经演示完毕。但是Sentinel熔断降级和限流并不仅限于接口,还可以对里面的部分资源进行熔断降级和限流。比如接口中,有查询数据库/请求其他服务时,可以在其Service方法上添加@SentinelResource注解。

  • 接口的熔断降级实现过程和上述的限流逻辑相比,除了配置规则不一样,其它完全一致,验证过程略。

5.1.2.3 web资源注解熔断降级和限流服务化异常验证
  • Sentinel中可以使用@SentinelResource注解对资源做限流(参见文档 )。简单总结下:@SentinelResource使用场景比较苛刻,要求方法的参数名和接口的入参一致,没法做全局统一的异常管控,也就是每个限流的@SentinelResource资源都得写个异常处理逻辑,非常不优雅。代码如下:
    @Slf4j
    @RestController
    public class QrCodeController extends BaseBizController<QrCodeResult, QrCode, QrCodeInner>
    {
        @SentinelResource(value = "demo_jwk", blockHandler = "blockHandler")
        @PostMapping("/demo/jwk")
        @Override
        public ResultCode<QrCodeResult> execute(@RequestBody QrCodeInner inner)
        {
            log.info("current inner:{}", JsonUtil.toJson(inner));
            return restService.execute(inner.toModel());
        }
    
        protected ResultCode<QrCodeResult> blockHandler(QrCodeInner inner, BlockException e)
        {
            log.error("current inner:{},block exception.", JsonUtil.toJson(inner), e);
            return ResultCode.error(ErrCodeEnum.LIMIT_ERROR.getCode());
        }
    
        /**
         * 注入自定义的Rest服务
         */
        @Resource(name = DemoConst.DEMO_REST_SERVICE)
        private RestService<QrCodeResult, QrCode> restService;
    }
    
  • 于是想到干脆直接在Spring的全局异常处理器GlobalExceptionHandler中,新增熔断降级与限流的中断异常BlockException处理逻辑:
    @Slf4j
    @ControllerAdvice
    public class GlobalExceptionHandler extends BaseExceptionHandler
    {
        @ExceptionHandler({CommonException.class, NoHandlerFoundException.class, Exception.class})
        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
        @ResponseBody
        public ResultCode<?> handleErr(HttpServletRequest req, Exception e)
        {
            Throwable ex = e;
            if (e instanceof UndeclaredThrowableException)
            {
                UndeclaredThrowableException realEx = (UndeclaredThrowableException)e;
                ex = realEx.getUndeclaredThrowable();
            }
            if (ex instanceof BlockException)
            {
                log.error("sentinel block happened.", ex);
                return ResultCode.error(ErrCodeEnum.LIMIT_ERROR.getCode());
            }
            return handle(req.getRequestURI(), e);
        }
    }
    
  • 配置资源相关的限流见下图:

    业务服务-3
  • 多次执行上述命令,请求/demo/jwk接口,会看到如下返回结果:
    {
        "code": "100003",
        "msg": "流量超限",
        "cost": 0
    }
    
  • 观测运行日志,发现也从我们自定义的handler中打印出了相关异常:
    [ERROR][c.b.b.w.d.QrCodeController_blockHandler] - current inner:{"code":"test123"},block exception.
    com.alibaba.csp.sentinel.slots.block.flow.FlowException: null
    
  • 资源的熔断降级实现过程和上述的限流逻辑相比,除了配置规则不一样,其它完全一致,验证过程略。
5.1.3 web客户限流服务化异常最佳实践
  • 熔断降级与限流在开源SpringBoot/SpringCloud微服务框架的最佳实践
    中已经介绍了基于Redis的客户限流。现在再简单阐述下与异常相关的实践过程。
  • 在SpringMvc中定义一个HandlerInterceptor切入点,代码如下:
    @Component(BootConst.CLIENT_LIMIT_SVC)
    public class ClientLimitHandler implements HandlerInterceptor
    {
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception
        {
            JwtToken token = JwtUtil.getJwtToken(request.getHeader(HttpHeaders.AUTHORIZATION));
            if (null == token || StringUtils.isEmpty(token.toClientId()))
            {
                //没有token,不做限流控制
                return true;
            }
    
            Map<String, String> urls = MapUtils.invertMap(assemblyConfService.getClientUrl());
            String urlId = urls.get(UrlUtil.shortUrl(request.getRequestURI()));
            if (StringUtils.isEmpty(urlId))
            {
                //没有配置限流
                return true;
            }
            AccessLimit model = new AccessLimit();
            model.setUrlId(urlId);
            model.setAccessId(token.toClientId());
            model.setConfig(LimitConfig.clientConf());
    
            boolean needLimit = limitHandler.limit(model);
            if (needLimit)
            {
                ResultCode<?> resultCode = ResultCode.error(ErrCodeEnum.LIMIT_ERROR.getCode());
                ResponseUtil.writeErrorBody(response, JsonUtil.toJson(resultCode, snakeCase));
                return false;
            }
            return true;
        }
    }
    
  • 当超过redis的限流阈值时,直接通过切入点的response对象响应到客户侧(限流异常被limitHandler限流器处理true和false了)。验证结果参见熔断降级与限流在开源SpringBoot/SpringCloud微服务框架的最佳实践 4.3.1.2 业务微服务的客户限流验证章节。
5.1.4 web全局异常服务化最佳实践
  • 前面讲了接口微服务框架涉及返回到客户端的各种场景,并且分别做了编码设计和验证。但是,还是有可能会漏掉一些异常场景。所有有必要在SpringWeb(也可以叫SpringMVC)的全局异常GlobalExceptionHandler中捕获Exception,代码如下:
    @Slf4j
    @ControllerAdvice
    public class GlobalExceptionHandler extends BaseExceptionHandler
    {
        @ExceptionHandler({CommonException.class, NoHandlerFoundException.class, Exception.class})
        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
        @ResponseBody
        public ResultCode<?> handleErr(HttpServletRequest req, Exception e)
        {
            Throwable ex = e;
            if (e instanceof UndeclaredThrowableException)
            {
                UndeclaredThrowableException realEx = (UndeclaredThrowableException)e;
                ex = realEx.getUndeclaredThrowable();
            }
            if (ex instanceof BlockException)
            {
                log.error("sentinel block happened.", ex);
                return ResultCode.error(ErrCodeEnum.LIMIT_ERROR.getCode());
            }
            return handle(req.getRequestURI(), e);
        }
    }
    

    作为基础框架代码,此处还可以优化成:只在项目中存在Sentinel时,才捕获并处理Sentinel异常。本人计划在下个版本就完成优化。

  • 我们还要在业务服务的logback-spring.xml中配置全局异常打印到console/default和error日志文件中去。配置如下:
    <?xml version="1.0" encoding="UTF-8"?>
    <!--日志级别以及优先级排序: FATAL > ERROR > WARN > INFO > DEBUG-->
    <configuration debug="false">
        <!--控制台日志-->
        <appender name="consoleAppender" class="ch.qos.logback.core.ConsoleAppender">
            <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
                <pattern>${LOG_PATTERN}</pattern>
                <charset>UTF-8</charset>
            </encoder>
        </appender>
    
        <!--default日志 -->
        <appender name="defaultAppender" class="ch.qos.logback.core.rolling.RollingFileAppender">
            <file>${LOG_PATH}/default.log</file>
            <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
                <FileNamePattern>${LOG_PATH}/%d{yy-MM-dd}/default-%d{yy-MM-dd}.log</FileNamePattern>
                <maxHistory>30</maxHistory>
            </rollingPolicy>
            <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
                <pattern>${LOG_PATTERN}</pattern>
                <charset>UTF-8</charset>
            </encoder>
        </appender>
        <!--error日志 -->
        <appender name="errorAppender" class="ch.qos.logback.core.rolling.RollingFileAppender">
            <file>${LOG_PATH}/error.log</file>
            <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
                <FileNamePattern>${LOG_PATH}/%d{yy-MM-dd}/error-%d{yy-MM-dd}.log</FileNamePattern>
                <maxHistory>30</maxHistory>
            </rollingPolicy>
            <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
                <pattern>${LOG_PATTERN}</pattern>
                <charset>UTF-8</charset>
            </encoder>
            <filter class="ch.qos.logback.classic.filter.LevelFilter">
                <level>ERROR</level>
                <onMatch>ACCEPT</onMatch>
                <onMismatch>DENY</onMismatch>
            </filter>
        </appender>
    
    
        <!--全局异常日志-->
        <logger name="com.biuqu.boot.handler.GlobalExceptionHandler" additivity="false">
            <appender-ref ref="errorAppender"/>
            <appender-ref ref="consoleAppender"/>
            <appender-ref ref="defaultAppender"/>
        </logger>
    
        <!--建立一个默认的root的logger -->
        <root level="${LOG_LEVEL}">
            <appender-ref ref="consoleAppender"/>
            <appender-ref ref="defaultAppender"/>
        </root>
    </configuration>
    

5.2 spring-authorization-server服务化异常

  • spring-authorization-server服务化异常单独汇总如下:

    所在服务异常类型示例实现技术直接响应
    认证服务过滤器校验异常1. 用户认证失败异常;spring-authorization-server Filter扩展
    认证服务全局异常1.内部错误异常;
    2. …
    spring-authorization-server Filter扩展+web全局异常扩展
    • SpringMVC全局异常所在的扩展点本质上也是挂在某个外层的过滤器上,所以二者均可以直接响应到客户端;
  • 在本文5.1.1 web参数校验服务化异常最佳实践章节的基础上,可知spring-authorization-server也是建立在SpringMvc的基础上,所以共用了GlobalExceptionHandler全局异常,即:未捕获的异常都会被GlobalExceptionHandler处理。

  • 参考OAuth2在开源SpringBoot/SpringCloud微服务框架的最佳实践 文章的介绍,spring-authorization-server本身就是由很多的过滤器来实现的。很多异常会被spring-authorization-server的过滤器给处理掉,导致返回到客户端的异常不统一,需要通过过滤器的配置点来扩展。

  • 定义认证失败时的处理器JwtAuthFailureHandlerImpl:

    @Slf4j
    @Component
    public class JwtAuthFailureHandlerImpl extends BaseJwtExceptionHandler<AuthenticationException>
        implements AuthenticationFailureHandler
    {
        @Override
        public void onAuthenticationFailure(HttpServletRequest req, HttpServletResponse resp, AuthenticationException e)
        {
            handle(resp, e);
        }
    
        @Override
        protected void log(AuthenticationException exception)
        {
            OAuth2Error error = ((OAuth2AuthenticationException)exception).getError();
            log.error("jwt auth failed:{}", JsonUtil.toJson(error));
            super.log(exception);
        }
    }
    
  • 定义认证失败且抛到Web容器的异常处理器:

    @Component
    public class JwtExceptionHandlerImpl extends BaseJwtExceptionHandler<AccessDeniedException>
        implements AccessDeniedHandler
    {
        @Override
        public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException exception)
        {
            handle(response, exception);
        }
    }
    
    @Slf4j
    public abstract class BaseJwtExceptionHandler<T extends RuntimeException>
    {
        /**
         * 认证失败的异常处理
         *
         * @param response  响应对象
         * @param exception 异常对象
         */
        public void handle(HttpServletResponse response, T exception)
        {
            try
            {
                log(exception);
    
                response.setStatus(HttpStatus.OK.value());
                MediaType mediaType = new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8);
                response.setHeader(HttpHeaders.CONTENT_TYPE, mediaType.toString());
    
                ResultCode<?> fail = ResultCode.error(ErrCodeEnum.AUTH_ERROR.getCode());
                String json = JsonUtil.toJson(fail, snakeCase);
                response.getWriter().write(json);
                response.getWriter().flush();
            }
            catch (Exception e)
            {
                log.error("auth failed with unknown exception.", exception);
            }
        }
    
        /**
         * 认证失败的日志处理
         *
         * @param exception 失败的异常
         */
        protected void log(T exception)
        {
            log.error("auth failed.", exception);
        }
    
        /**
         * 是否驼峰式json(默认支持)
         */
        @Value("${bq.json.snake-case:true}")
        private boolean snakeCase;
    }
    
  • 再通过spring-authorization-server框架自定义的过滤器配置服务ServerConfigurer分别注入认证失败和全局异常:

    @Slf4j
    @EnableWebSecurity
    @Configuration(proxyBeanMethods = false)
    public class ServerConfigurer
    {
        @Bean
        @Order(Ordered.HIGHEST_PRECEDENCE)
        public SecurityFilterChain serverChain(HttpSecurity http, AuthenticationManager authManager,
            JWKSource<SecurityContext> jwkSource, JwtGenerator jwtGen) throws Exception
        {
            //1.前后端分离,禁用会话管理和csrf(跨站攻击)
            http.sessionManagement().disable();
            http.csrf().disable();
    
            //2.添加匿名访问的url(应该包括jwk)
            Set<String> anonymous = Sets.newHashSet();
            if (!CollectionUtils.isEmpty(ignoreUrls))
            {
                anonymous.addAll(ignoreUrls);
            }
            String[] anonUrls = anonymous.toArray(new String[] {});
            http.authorizeRequests(registry -> registry.antMatchers(anonUrls).permitAll().anyRequest().authenticated());
    
            //3.设置服务端配置(指定jwt生成器等)
            OAuth2AuthorizationServerConfigurer<HttpSecurity> serverConf = new OAuth2AuthorizationServerConfigurer<>();
            http.apply(serverConf);
    
            serverConf.tokenGenerator(jwtGen);
            //设置认证信息匹配失败的异常
            serverConf.clientAuthentication(clientConf -> clientConf.errorResponseHandler(this.failureHandler));
            //设置token生成失败的异常
            serverConf.tokenEndpoint(tokenConf -> tokenConf.errorResponseHandler(this.failureHandler));
            //设置全局处理异常
            http.exceptionHandling(exceptionHandler -> exceptionHandler.accessDeniedHandler(this.exceptionHandler));
    
            //4.设置业务请求的jwt解析配置
            http.oauth2ResourceServer(resourceConf ->
            {
                resourceConf.bearerTokenResolver(new DefaultBearerTokenResolver());
                OAuth2ResourceServerConfigurer<HttpSecurity>.JwtConfigurer resourceJwtConf = resourceConf.jwt();
                resourceJwtConf.decoder(OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource));
                //设置资源解析失败的异常(主要是资源带的token解析/认证失败)
                resourceConf.accessDeniedHandler(this.exceptionHandler);
            });
    
            return filterMgr.custom(http, authManager);
        }
    
        /**
         * 定制的过滤器管理器
         */
        @Autowired
        private SecurityFilterMgr filterMgr;
    
        /**
         * 鉴权拒绝(出现了非失败的异常)时的异常处理(参见{@link JwtExceptionHandlerImpl}实现)
         */
        @Autowired
        private AccessDeniedHandler exceptionHandler;
    
        /**
         * 鉴权不通过的异常处理(参见{@link JwtAuthFailureHandlerImpl}实现)
         */
        @Autowired
        private AuthenticationFailureHandler failureHandler;
    }
    
  • 基于spring-authorization-server框架的微服务只要调用生成Token的接口/oauth/enc/token/oauth/token接口验证即可。验证仅需要通过模拟错误的密码、过期或者非法的刷新JwtToken来调用,过程略。

5.3 webflux服务化异常

  • webflux服务化异常单独汇总如下:

    所在服务异常类型示例实现技术直接响应
    网关服务过滤器校验异常1.签名验证失败异常GlobalFilter扩展
    网关服务熔断降级限流异常1.熔断降级异常自定义Sentinel异常扩展
    网关服务全局异常1.内部错误异常;
    2. …
    webflux全局异常扩展;
  • 本微服务框架主要用到SpringCloud-Gateway框架,而SpringCloud-Gateway是基于webflux的。网关服务仅需要关注公共的加解密、报文签名校验和JwtToken校验,全是在过滤器里面完成的。

  • 在网关中引入Sentinel pom依赖:

    <!--sentinel熔断降级-->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-alibaba-sentinel-gateway</artifactId>
        <version>2021.0.5.0</version>
    </dependency>
    
5.3.1 webflux过滤器校验异常最佳实践
  • 网关过滤器校验主要是做加解密、报文完整性和JwtToken校验。以完整性过滤器校验为例,进行说明:

    @Slf4j
    @Component
    public class IntegrityCheckGatewayFilter implements GlobalFilter, Ordered
    {
        @Override
        public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain)
        {
            //1.解析出该请求的摘要配置和加密配置
            ServerHttpRequest request = exchange.getRequest();
            String url = request.getURI().getPath();
            boolean signed = checkConf.needSign(url);
            PathMatcher pathMatcher = new AntPathMatcher();
            boolean ignore = this.whitelist.stream().anyMatch(pattern -> pathMatcher.match(pattern, url));
    
            //2.没有摘要或者在白名单里面的请求则直接放过请求
            if (!signed || ignore)
            {
                return chain.filter(exchange);
            }
    
            //3.做完整性校验(使用加密器门面的默认摘要算法)
            String body = exchange.getAttribute(GatewayConst.BODY_CACHE_KEY);
            boolean result = checkIntegrity(request, body);
            if (!result)
            {
                log.error("[{}]check integrity failed.", url);
                return ServerUtil.writeErr(exchange, ErrCodeEnum.SIGNATURE_ERROR.getCode(), snakeCase);
            }
            log.info("[{}]check integrity successfully.", url);
            return chain.filter(exchange);
        }
    
        /**
         * 使用本地秘钥的默认加密器做摘要验证
         * <p>
         * 拼接header认证头和body: `${Authorization}|${body}`,字段不存在或者为空时,使用空串代替
         *
         * @param request 请求对象
         * @param body    缓存的body
         * @return true表示检验通过
         */
        private boolean checkIntegrity(ServerHttpRequest request, String body)
        {
            String sign = request.getHeaders().getFirst(GatewayConst.HEADER_INTEGRITY);
            if (StringUtils.isEmpty(sign))
            {
                return false;
            }
    
            String auth = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
            if (StringUtils.isEmpty(auth))
            {
                auth = StringUtils.EMPTY;
            }
            StringBuilder builder = new StringBuilder();
            builder.append(auth);
    
            String encId = request.getHeaders().getFirst(GatewayConst.HEADER_ENC_ID);
            if (StringUtils.isEmpty(encId))
            {
                encId = StringUtils.EMPTY;
            }
            builder.append(Const.JOIN).append(encId);
    
            if (StringUtils.isEmpty(body))
            {
                body = StringUtils.EMPTY;
            }
            builder.append(Const.JOIN).append(body);
            String integrity = this.securityFacade.hash(builder.toString());
            log.info("current signature:{},src:{}", integrity, sign);
            return sign.equals(integrity);
        }
    
        @Override
        public int getOrder()
        {
            return -40;
        }
    
        /**
         * 不用做鉴权的白名单
         */
        @Resource(name = GatewayConst.WHITELIST)
        private Set<String> whitelist;
    
        /**
         * 注入安全服务服务
         */
        @Autowired
        private SecurityFacade securityFacade;
    
        /**
         * 注入校验规则
         */
        @Autowired
        private IntegrityCheckConfig checkConf;
    
        /**
         * 是否驼峰式json(默认支持)
         */
        @Value("${bq.json.snake-case:true}")
        private boolean snakeCase;
    }
    
  • 在校验失败时,会通过封装的ServerUtil工具类直接响应到客户端:

    @Slf4j
    public final class ServerUtil
    {
        /**
         * 回写异常结果
         *
         * @param exchange server对象(包含request和response)
         * @param json     带错误码的resultCode json
         * @return 标准的异常结果对象
         */
        public static Mono<Void> writeErr(ServerWebExchange exchange, String json)
        {
            ServerHttpResponse response = exchange.getResponse();
            response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
            MediaType utf8Type = new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8);
            response.getHeaders().add(HttpHeaders.CONTENT_TYPE, utf8Type.toString());
            DataBuffer dataBuffer = response.bufferFactory().wrap(json.getBytes(StandardCharsets.UTF_8));
            return response.writeWith(Flux.just(dataBuffer));
        }
    
        /**
         * 回写异常结果
         *
         * @param exchange server对象(包含request和response)
         * @param code     错误码
         * @param snake    驼峰转换
         * @return 标准的异常结果对象
         */
        public static Mono<Void> writeErr(ServerWebExchange exchange, String code, boolean snake)
        {
            ResultCode<?> resultCode = ResultCode.error(code);
            long start = Long.parseLong(exchange.getAttribute(GatewayConst.START_CACHE_KEY).toString());
            resultCode.setCost(System.currentTimeMillis() - start);
            String json = JsonUtil.toJson(resultCode, snake);
    
            return writeErr(exchange, json);
        }
    }
    
  • Jwt OAuth2 Basic认证加密过滤器、报文加解密过滤器、JwtToken校验过滤器的详细编码逻辑参见文档加解密在开源SpringBoot/SpringCloud微服务框架的最佳实践

  • 过滤器的验证过程亦参见加解密在开源SpringBoot/SpringCloud微服务框架的最佳实践 ,此处略。

5.3.2 webflux全局异常最佳实践
  • 基于Webflux的网关配置全局异常的方式与SpringWeb完全不同,Webflux框架的全局异常扩展点代码ErrorFluxAutoConfigurer如下:
    @AutoConfiguration(before = WebFluxAutoConfiguration.class)
    @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
    @ConditionalOnClass(WebFluxConfigurer.class)
    @EnableConfigurationProperties({ServerProperties.class, WebProperties.class})
    public class ErrorFluxAutoConfigurer
    {
        private final ServerProperties serverProperties;
    
        public ErrorFluxAutoConfigurer(ServerProperties serverProperties)
        {
            this.serverProperties = serverProperties;
        }
    
        @Bean
        @ConditionalOnMissingBean(value = ErrorWebExceptionHandler.class, search = SearchStrategy.CURRENT)
        @Order(-1)
        public ErrorWebExceptionHandler errorWebExceptionHandler(ErrorAttributes errAttr, WebProperties webProperties,
            ObjectProvider<ViewResolver> viewResolvers, ServerCodecConfigurer serverCodecConfigurer,
            ApplicationContext context)
        {
            WebProperties.Resources resources = webProperties.getResources();
            ErrorProperties errProperties = this.serverProperties.getError();
            DefaultErrorWebExceptionHandler errHandler =
                new GlobalExceptionHandler(errAttr, resources, errProperties, context);
            errHandler.setViewResolvers(viewResolvers.orderedStream().collect(Collectors.toList()));
            errHandler.setMessageWriters(serverCodecConfigurer.getWriters());
            errHandler.setMessageReaders(serverCodecConfigurer.getReaders());
            return errHandler;
        }
    
        @Bean
        @ConditionalOnMissingBean(value = ErrorAttributes.class, search = SearchStrategy.CURRENT)
        public DefaultErrorAttributes errorAttributes()
        {
            return new DefaultErrorAttributes();
        }
    }
    
  • 其中,我们自定义的全局异常GlobalExceptionHandler代码如下:
    @Slf4j
    public class GlobalExceptionHandler extends DefaultErrorWebExceptionHandler
    {
        /**
         * Create a new {@code DefaultErrorWebExceptionHandler} instance.
         *
         * @param errorAttributes    the error attributes
         * @param resources          the resources configuration properties
         * @param errorProperties    the error configuration properties
         * @param applicationContext the current application context
         * @since 2.4.0
         */
        public GlobalExceptionHandler(ErrorAttributes errorAttributes, WebProperties.Resources resources,
            ErrorProperties errorProperties, ApplicationContext applicationContext)
        {
            super(errorAttributes, resources, errorProperties, applicationContext);
        }
    
        /**
         * 覆盖默认的异常处理类别(屏蔽掉默认的响应值)
         *
         * @param errorAttributes 异常属性
         * @return 路由异常的处理函数
         */
        @Override
        protected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes)
        {
            return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse);
        }
    
        @Override
        protected Mono<ServerResponse> renderErrorResponse(ServerRequest request)
        {
            Map<String, Object> error = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
            Throwable e = getError(request);
            log.error("happened exception by global catching:{}.", e.getMessage());
            String code = ErrCodeEnum.SERVER_ERROR.getCode();
            if (e instanceof CommonException)
            {
                code = ((CommonException)e).getErrCode().getCode();
            }
            ResultCode<?> resultCode = ResultCode.error(code);
            int httpCode = getHttpStatus(error);
            if (httpCode == HttpStatus.OK.value())
            {
                httpCode = HttpStatus.INTERNAL_SERVER_ERROR.value();
            }
            else if (ErrCodeEnum.SIGNATURE_ERROR.getCode().equals(code) || ErrCodeEnum.AUTH_ERROR.getCode().equals(code))
            {
                httpCode = HttpStatus.UNAUTHORIZED.value();
            }
            ServerResponse.BodyBuilder bodyBuilder = ServerResponse.status(httpCode);
            bodyBuilder.contentType(MediaType.APPLICATION_JSON);
            return bodyBuilder.bodyValue(resultCode);
        }
    }
    
  • 我们还要在网关的logback-spring.xml中配置全局异常打印到console/default和error日志文件中去。配置如下:
    <?xml version="1.0" encoding="UTF-8"?>
    <!--日志级别以及优先级排序: FATAL > ERROR > WARN > INFO > DEBUG-->
    <configuration debug="false">
        <springProperty scope="context" name="LOG_SERVICE" source="spring.application.name" defaultValue="bq-service"/>
        <!--控制台日志-->
        <appender name="consoleAppender" class="ch.qos.logback.core.ConsoleAppender">
            <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
                <pattern>${LOG_PATTERN}</pattern>
                <charset>UTF-8</charset>
            </encoder>
        </appender>
    
        <!--default日志 -->
        <appender name="defaultAppender" class="ch.qos.logback.core.rolling.RollingFileAppender">
            <file>${LOG_PATH}/default.log</file>
            <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
                <FileNamePattern>${LOG_PATH}/%d{yy-MM-dd}/default-%d{yy-MM-dd}.log</FileNamePattern>
                <maxHistory>30</maxHistory>
            </rollingPolicy>
            <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
                <pattern>${LOG_PATTERN}</pattern>
                <charset>UTF-8</charset>
            </encoder>
        </appender>
        <appender name="asyncNettyLog" class="ch.qos.logback.classic.AsyncAppender">
            <appender-ref ref="consoleAppender"/>
            <appender-ref ref="defaultAppender"/>
        </appender>
    
        <!--error日志 -->
        <appender name="errorAppender" class="ch.qos.logback.core.rolling.RollingFileAppender">
            <file>${LOG_PATH}/error.log</file>
            <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
                <FileNamePattern>${LOG_PATH}/%d{yy-MM-dd}/error-%d{yy-MM-dd}.log</FileNamePattern>
                <maxHistory>30</maxHistory>
            </rollingPolicy>
            <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
                <pattern>${LOG_PATTERN}</pattern>
                <charset>UTF-8</charset>
            </encoder>
            <filter class="ch.qos.logback.classic.filter.LevelFilter">
                <level>ERROR</level>
                <onMatch>ACCEPT</onMatch>
                <onMismatch>DENY</onMismatch>
            </filter>
        </appender>
    
        <!--全局异常日志-->
        <logger name="com.biuqu.boot.service.gateway.handler.GlobalExceptionHandler" additivity="false">
            <appender-ref ref="defaultAppender"/>
            <appender-ref ref="consoleAppender"/>
            <appender-ref ref="errorAppender"/>
        </logger>
    </configuration>
    

6. 微服务异常处理小结

  • 本文从Java异常分类开始讲起,在分类的基础上,又按照场景维度对异常进行拆分,以便对异常有更深刻的认识;
  • 在了解异常的基础上,又从开源微服务框架的技术栈构成讲起,分别讲述了SpringWeb/Spring-Authorization-Server/SpringWebFlux底座下的异常场景及处理思路,目标是模块化异常(服务的内部异常)尽量捕获归总到服务的最外层(如:过滤器/RestController),并通过Exception异常来实现,这样可以确保异常的统一;
  • 但是并不是在所有的过滤器/RestController都捕获了Exception异常就能保证返回到客户端的异常是统一的,还必须得通过Spring框架的基础底座捕获全局未知异常。因为有些异常可能是异步的,比如说线程池中的线程异常;
  • 开源微服务框架基本上涉及到了SpringCloud的大部分技术栈,通过对SpringBoot参数校验异常、Sentinel熔断降级和限流异常(基于SpringBoot/SpringCloud-Gateway
    2种场景,3种实现方式)、Redis限流异常、Spring-Authorization-Server过滤器认证异常、SpringCloud-Gateway过滤器校验异常,以及SpringBoot全局异常、Spring-Authorization-Server全局异常、SpringCloud-Gateway全局异常的分析和最佳实践,也可以帮助大家站在更高的位置,更好更全面的思考问题。

7. 参考资料

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐