从单体到SpringBoot/SpringCloud微服务架构无感升级的最佳实践

从单体到SpringBoot/SpringCloud微服务架构无感升级的最佳实践

1. 业务背景

  • 有一个单体的Python接口服务平台,提供了认证接口、多种图片业务、多种视频业务等,除了认证接口外,各个业务之间是独立的;
  • 单体的Python接口服务平台中,有一个图片业务是有状态的,且其并发量较高,占用内存较多,并同时需要占用大量的Redis缓存;

2. 当前问题

  • 多种业务代码耦合在一起,牵一发而动全身,开发维护效率非常低,且多次因为小需求上线差点导致了生产问题;
  • 上述有状态的图片业务高峰期时,导致单个进程的python协程处于拥塞状态,横向扩容增加实例也无法解决,且该问题导致了其他所有业务受影响;
  • Python接口服务平台中的认证方式不合理,其采用了类似AmazonS3的AK/SK认证 方式,但是二者的使用场景却并不一样。AmazonS3的AK/SK认证仅需要知道客户是否有访问该S3(即对象存储,一般也叫OBS)的桶的权限,不涉及其他资源的访问控制;而我们实际业务是有非常多的接口,AK/SK认证通过了,仅表示可以合法地访问我们的系统,但是却无法做到精确访问具体的哪几个接口;

3. 升级方案

  • 经过分析评估,架构升级迫在眉睫,尤其是需要往服务拆解、隔离的方向去做新的技术架构;
  • 当下比较好的方向是Java技术栈和Go技术栈,基于团队技能等综合因素考虑,最终选择了Java SpringBoot/SpringCloud微服务技术栈;
  • 在新技术架构的平台上,设计一套更规范合理的接口服务,同时在此基础上,再适配一套兼容的旧接口服务,和以前完全保持一致;
  • 通过Python接口服务平台前置的Nginx集群,按照权重(客户流量的百分比)逐步灰度切换至新平台,待新平台稳定后,逐步放量直至全部切换过来;

3.1 架构设计

  • 结合实际业务场景,需要提供新的认证方式,需要做到精确到接口的访问权限控制,同时还要把认证逻辑从业务逻辑中剥离出来;
  • 为了提升认证效率,需要做到认证和鉴权的分离,避免频繁地查询接口或者数据库;
  • 经过调研,通过扩展Jwt Token(Java Web Token)字段,可以非常容易做到精确的接口访问权限控制;
  • 采用wt Token后,Token校验和生成完全可以放在不同的服务中去完成了。Token校验采用基于SpringBoot-WebFlux的SpringCloud-Gateway,Token生成服务采用了基于SpringSecurity的Spring-Authorization-Server;
  • 除了提供新的认证方式,还必须要兼容原来的AK/SK认证认证接口,同时也要在接口中新增接口访问权限的校验;
  • 鉴于平台还需要和多个外部系统对接,单独设计了一个基于插件的对接服务。对接服务可以通过插件包扩展的方式,支持越来越多的三方对接,同时完全不会影响到其他业务;
  • 综合上面的分析,逻辑架构设计如下:
    bq逻辑架构

    逻辑架构补充说明如下:

    • 网关服务(bq-gateway) :统一的接入网关,主要负责安全鉴权,即JwtToken的合法性校验和接口方案权限的校验;同时,所有外部调用也需要通过本网关服务
    • 认证服务(bq-auth) : 负责生成JwtToken,同时对网关服务提供访问权限的查询接口,且只读数据库数据;
    • 业务服务(bq-biz) : 负责实现具体的业务逻辑,且只读数据库数据;
    • 对接服务(bq-integration) : 负责和外部平台对接,通过网关服务中转,且只读数据库数据;
    • 后台管理(bq-mgr) : 负责维护接口平台,严格来讲不属于微服务集群,也不对外暴露,仅通过数据持久层异步通知认证服务业务服务对接服务其数据变更并生效;
    • 网关服务认证服务实现了鉴权和认证的分离;
    • 后台管理认证服务业务服务对接服务实现了数据库的读写分离;
    • 以上涉及主要考虑接口平台的场景,当扩展支持Web界面时,需要在网关服务认证服务做认证和鉴权的升级改造;
  • 在逻辑架构设计的基础上,进一步完善了中间件依赖,其部署架构图如下: bq部署架构图
    • 注:在实际场景中,其实使用的是K8S自带的服务注册和服务发现,为了简化场景,暂忽略K8S容器,使用了Nacos作为服务注册中心;

    部署架构补充说明如下:

    • NacosServer:负责服务注册和服务发现,同时也是Sentinel熔断限流和网关服务路由的配置中心;
    • Sentinel:负责熔断降级和非业务的限流,同时支持基于SpringBoot-WebFlux的网关服务和基于SpringBoot-Web的其他服务;
    • ZipkinServer:负责链路追踪汇总查询,同时支持基于SpringBoot-WebFlux的网关服务和基于SpringBoot-Web的其他服务;
    • 为了后续能够平滑切换至新SpringCloud架构的组件,当然也包括切换至k8s,当下都是极简使用NacosServerZipkinServer
    • 上述架构已全部开源,参见Java开源接口微服务代码框架文档详细内容了;

    4. 详细设计

  • 接口服务平台虽然业务较多,但是其业务场景及其操作步骤基本类似,因此可以考虑对业务场景进行抽象提炼,尽量编写少的差异代码逻辑,复用公共抽象逻辑;
  • 由于业务需要和非常多的三方服务对接,安全认证方案各个不同,因此在分析总结老Python系统的基础上,结合以前积累的代码库,构建的代码架构设计如下:
    bq代码架构图

4.1 迁移阻碍

  • 上述的架构设计基本上解决了面临的几个核心问题,但是还是无法做到系统的无感迁移,原因如下:
    • Python老平台的错误码设计不合理,部分接口透传了第三方的错误码信息,部分又是自定义的错误码信息,且自定义的错误码里面还存在一码对多个Message的情况。当下根本无法对照代码、文档甚至是日志分析出所有错误码的场景;
    • Python老平台的接口设计不合理,Json报文中,有的字段为空时,是空字符串,但是有值时又是子Json对象;相同含义的同一字段,有的接口是子Json数组,有的接口却又是Json对象……情况千奇百怪;

4.2 解决思路

  • 针对错误码阻碍,想到的措施如下:
    • 错误码可变性较大,会随着业务场景变化而变化(一般只增不减),所以需要把所有错误码放到独立的配置中,与代码隔离;
    • 从业务出发,搜集近一年的生产脱敏数据进行分析,提炼出每个接口的可能错误码和对应Message;
    • 在梳理汇总错误码的基础上,做剪枝操作(即一个错误码只保留一个Message),并跟业务团队共识都认可的方案;
    • 把错误码分三层,内层是和三方服务对接的错误码,映射了三方服务和标准错误码的关系;中层为标准错误码,记录了新接口服务平台的所有场景错误码;外层是兼容错误码,映射了标准错误码和兼容错误码的关系,兼容错误码也就是上条所说的剪枝并共识的错误码;
  • 针对接口字段不规范,选用扩展性更强的格式作为统一标准,在兼容接口上预留扩展点,方便做特殊定制处理;

5. 实现过程

  • 在上述系统分析的前提下,主要分了以下几个方面来对接口平台做升级改造。

5.1 认证兼容改造

  • 通过网关服务认证服务做JwtToken的鉴权和认证的分离改造参见OAuth2在开源SpringBoot/SpringCloud微服务框架的最佳实践
  • 兼容AK/SK鉴权逻辑设计如下:
    1. 网关服务处配置一个对接认证服务的特殊AK/SK,收到客户请求时,通过这个特殊的AK/SK先生成一个JwtToken并缓存在本网关服务实例中;
    2. 网关服务JwtToken缓存存在时,调用认证服务,获取客户的真实账号和接口权限信息;JwtToken缓存不存在时,则执行上一步获取;
    3. 比对用户的接口权限信息是否合法,如不合法,则返回签名失败;合法时,则按照AK/SK的签名校验规则进行签名校验;

    由于AK/SK的设计很少出现在接口权限的认证场景中,此处暂略掉其实现;

  • 网关服务认证服务需要支持前端页面认证访问时,则需要做如下的改造设计:
    1. 在前端页面做登录认证时,认证服务需要同时生成JwtToken和刷新Token,前者有效期30分钟,后者1小时;JwtToken只能用作业务接口的调用,刷新Token只能调用刷新接口;
    2. 网关服务处确保JwtToken只用作业务接口的调用、刷新Token只调用刷新接口;
    3. 当JwtToken和刷新Token都过期时,页面要返回到登录界面,重新让用户登录;
    4. 当JwtToken过期而刷新Token未过期时,则由客户端使用刷新Token发起Token刷新调用,刷新成功后,使用新的JwtToken做业务调用;
    5. 由于带有前端页面的系统,通常角色、权限、菜单配置较多,其认证信息已不合适放在JwtToken字段中,网关服务认证服务可以通过集群共享Redis来实现;即:
      1. 认证服务生成JwtToken时,同时把JwtToken信息缓存至与网关服务共享的Redis。网关服务仅连接高性能的Redis几乎不影响性能;
      2. 网关服务在校验JwtToken签名合法时,同时还要根据JwtToken获取共享Redis中的相关权限信息,只有权限匹配通过时,才能继续调用业务接口;否则返回认证失败;
      3. 当用户的认证信息发生变更时,认证服务清理掉Redis缓存,下一次网关服务比对缓存时,就能够及时感知到认证信息的变化;
      4. 认证服务需要使用spring-data-redis做分布式缓存;

    当下开源框架已实现了前面2步;

5.2 抽象业务流程

5.2.1 抽象业务的思路
  • 因为业务相似度比较高,所以考虑使用泛型对业务流程进行抽象,包括RestController/Service/RemoteService层。
  • 业务抽象的核心是对业务模型进行抽象。步骤如下:
    • 定义了RestController的基类,RestController的入参抽象为泛型<I>,出参模型为<O>,中间服务层Service处理的标准模型为<T>;
    • RestController接口的每个<I>模型都可以直接转换成<T>模型;
    • 定义了Service/RemoteService基类,Service/RemoteService在业务处理过程中,都只使用<T>模型,Service/RemoteService获取的结果需要先适配成<O>模型;
    • 抽象的RestController自动注入抽象的Service,抽象的Service按照实际业务需求注入抽象的RemoteService;
5.2.2 抽象业务的抽象编码
  • 上面的说法基于泛型和抽象类,确实有点抽象,下面直接上代码:
    • 定义RestController基类:
      @Slf4j
      public class BaseBizController<O, T extends BaseBiz<O>, I extends BaseBizInner<T>>
      {
          /**
           * 获取批量结果(适用于接口调用)
           *
           * @param inner 入参业务模型
           * @return 批量出参模型
           */
          @ClientLogAnn
          public ResultCode<List<O>> batchExecute(I inner)
          {
              return getService().batchExecute(inner.toModel());
          }
      
          /**
           * 获取单个结果(适用于接口调用)
           *
           * @param inner 业务入参模型
           * @return 业务结果模型
           */
          @ClientLogAnn
          public ResultCode<O> execute(I inner)
          {
              return getService().execute(inner.toModel());
          }
      
          /**
           * 获取服务(支持覆写)
           *
           * @return 注入的服务对象
           */
          protected RestService<O, T> getService()
          {
              return service;
          }
      
          /**
           * 注入标准的业务服务
           */
          @Resource(name = BootConst.DEFAULT_REST_SVC)
          private RestService<O, T> service;
      }
      
    • 定义了抽象的Service基类RestService:
      @Slf4j
      public abstract class BaseRestService<O, T extends BaseBiz<O>> implements RestService<O, T>
      {
          @Override
          public ResultCode<List<O>> batchExecute(T model)
          {
              if (null == model)
              {
                  log.error("failed to get valid parameter.");
                  return ResultCode.error(ErrCodeEnum.VALID_ERROR.getCode());
              }
      
              //1.添加客户/接口定制化的参数(比如业务阈值等)
              this.appendConfig(model);
      
              //2.发起远程调用
              return this.invokeBatchResult(model);
          }
      
          @Override
          public ResultCode<O> execute(T model)
          {
              if (null == model)
              {
                  log.error("failed to get valid parameter.");
                  return ResultCode.error(ErrCodeEnum.VALID_ERROR.getCode());
              }
      
              //1.添加客户/接口定制化的参数(比如业务阈值等)
              this.appendConfig(model);
      
              //2.发起远程调用
              return this.invokeResult(model);
          }
      
          /**
           * 业务模型丰富上接口级的全局配置参数
           *
           * @param model 业务模型
           */
          protected void appendConfig(T model)
          {
              //1.查询出urlId
              Map<String, String> urls = MapUtils.invertMap(assemblyConfService.getClientUrl());
              String urlId = urls.get(model.getUrl());
              if (!StringUtils.isEmpty(urlId))
              {
                  model.setUrlId(urlId);
              }
      
              //2.添加客户/接口定制化的参数(比如业务阈值等)
              GlobalConfig config = new GlobalConfig();
              config.setClientId(model.getUserId());
              config.setUrlId(model.getUrlId());
              List<GlobalConfig> configResults = assemblyConfService.getChannelConf(config);
              model.appendConf(configResults);
          }
      
          /**
           * 获取远程调用的结果(根据业务情况去覆写,可以不需要remote服务调用)
           *
           * @param model 业务模型
           */
          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;
          }
      
          /**
           * 获取远程调用的结果(根据业务情况去覆写,可以不需要remote服务调用)
           *
           * @param model 业务模型
           */
          protected ResultCode<List<O>> invokeBatchResult(T model)
          {
              return this.getRemoteService().invokeBatch(model);
          }
      
          /**
           * 注入远程服务
           *
           * @return 远程服务
           */
          protected RemoteService<O, T> getRemoteService()
          {
              return remoteService;
          }
      
          /**
           * 注入业务服务
           */
          @Autowired(required = false)
          private BaseBizService<T> service;
      
          /**
           * 注入远程调用服务
           */
          @Resource(name = BootConst.DEFAULT_REMOTE_SVC)
          private RemoteService<O, T> remoteService;
      
          /**
           * 配置的聚合服务
           */
          @Autowired
          private AssemblyConfService assemblyConfService;
      }
      
      • Service层抽象是整个业务逻辑抽象的核心,因为核心的业务流程都在且只应该在服务层;
    • RestService基类中,主要注入了业务配置缓存服务业务参数缓存服务BaseBizService<T> serviceAssemblyConfService assemblyConfService、和远程服务RemoteService<O,T>remoteService。逻辑就是通过入参T模型拿到真正的业务参数,并丰富上相关的阈值等配置,再来调用远程服务(此步骤可选),获取最终结果。
    • RestService基类中注入的带Guava缓存的BaseBizService抽象类代码如下:
      @Slf4j
      public abstract class BaseBizService<T extends BaseSecurity> implements Service<T>
      {
          @Override
          public List<T> getBatch(T model)
          {
              List<T> results = Lists.newArrayList();
              String key = StringUtils.EMPTY;
              try
              {
                  if (hasCached())
                  {
                      key = model.toBatchKey();
                      if (!StringUtils.isEmpty(key))
                      {
                          results = batchCache.get(key);
                      }
                  }
              }
              catch (ExecutionException e)
              {
                  log.error("no batch cache[{}] found:{},with exception:{}", this.getClass().getSimpleName(), key, e);
              }
              return results;
          }
      
          /**
           * 是否有缓存(可覆写)
           *
           * @return 默认有缓存
           */
          protected boolean hasCached()
          {
              return true;
          }
      
          /**
           * 批量的本地缓存对象
           */
          private final LoadingCache<String, List<T>> batchCache = CacheFactory.create(new CacheLoader<String, List<T>>()
          {
              @Override
              public List<T> load(String key)
              {
                  return queryBatchByKey(key);
              }
      
              @Override
              public Map<String, List<T>> loadAll(Iterable<? extends String> keys)
              {
                  List<T> queryResults = queryBatchByKeys(keys);
                  if (CollectionUtils.isEmpty(queryResults))
                  {
                      return Maps.newHashMap();
                  }
      
                  Map<String, List<T>> results = Maps.newHashMap();
                  for (T model : queryResults)
                  {
                      String key = model.toKey();
                      List<T> subResults = results.get(key);
                      if (null == subResults)
                      {
                          subResults = Lists.newArrayList();
                          results.put(key, subResults);
                      }
                      subResults.add(model);
                  }
                  return results;
              }
          });
      }
      
    • 业务服务中还有注入一个定时器任务RefreshCacheScheduleTask, 会根据Redis的标记改变而清除掉Guava缓存,代码如下:
      @Component("globalCacheRefreshTask")
      public class RefreshCacheScheduleTask implements ScheduleTask
      {
          @Override
          public void doTask(String key)
          {
              //1.获取最新的缓存value
              String value = IdUtil.uuid();
              Boolean nxResult = redis.setNx(key, value);
              if (Boolean.FALSE.equals(nxResult))
              {
                  value = redis.get(key);
              }
      
              //2.如果和上次的一致,则说明缓存已经刷新过了,否则需要刷新缓存
              if (!value.equalsIgnoreCase(lastId))
              {
                  LOGGER.info("Now clearing all cached data by key:{}", key);
                  CacheFactory.invalidateAll();
                  this.lastId = value;
              }
          }
      
          /**
           * redis服务
           */
          @Autowired
          private RedisService redis;
      
          /**
           * 执行id
           */
          private String lastId;
      }
      

      总结下通用的缓存刷新机制:

      • 通过BaseBizService和RefreshCacheScheduleTask配合,就完成了带业务缓存的查询和缓存刷新;
      • 仔细观察,其实是需要Redis的数据发生变化时,缓存才会失效,谁来触发Redis的数据变化呢?答案是通过后台管理bq-mgr)工具来完成,这样就做到了缓存刷新的异步化;
      • AssemblyConfService配置缓存服务,其实核心就是内部继承了BaseBizService做了配置参数的缓存,代码略;
      • 远程服务BaseRemoteService则是抽象了查询远程接口的参数,比如应该调用的远程接口url是什么,这个远程接口是否允许访问等,代码如下:
        public abstract class BaseRemoteService<O, T extends BaseBiz<O>> implements RemoteService<O, T>
        {
            @Override
            public ResultCode<List<O>> invokeBatch(T model)
            {
                ResultCode<List<O>> resultCode = null;
                try
                {
                    //1.获取接口的配置状态(是否可用)
                    boolean channelStatus = this.queryChannelStatus(model, GlobalDict.toChannelStatus().getKey());
                    if (!channelStatus)
                    {
                        LOGGER.error("[{}]channels[{}] is not active.", model.getUrlId(), model.getChannelId());
                        resultCode = ResultCode.error(ErrCodeEnum.CHANNEL_ERROR.getCode());
                        return resultCode;
                    }
        
                    //2.获取接口是否是驼峰结构的参数
                    boolean snake = this.queryChannelStatus(model, GlobalDict.toChannelSnake().getKey());
                    String resultJson = this.call(model, snake);
        
                    if (StringUtils.isEmpty(resultJson))
                    {
                        LOGGER.error("no channel[{}] results found.", model.getUrlId());
                        resultCode = ResultCode.error(ErrCodeEnum.SERVER_ERROR.getCode());
                        return resultCode;
                    }
        
                    resultCode = toModels(resultJson, model.toTypeRefs(), snake);
                    if (null == resultCode)
                    {
                        LOGGER.error("channel[{},{}]'s results have happened error.", model.getUrlId(), model.getUrlId());
                        resultCode = ResultCode.error(ErrCodeEnum.CHANNEL_ERROR.getCode());
                        return resultCode;
                    }
                }
                finally
                {
                    if (null != resultCode)
                    {
                        resultCode.setRespId(model.getRespId());
                        resultCode.setChannelId(model.getChannelId());
                    }
                }
                return resultCode;
            }
        
            /**
             * 真实的远程调用(可覆写)
             *
             * @param model 业务模型
             * @param snake 渠道是否驼峰转下划线方式
             * @return 结果json
             */
            protected String call(T model, boolean snake)
            {
                String channelUrl = this.getChannelUrl(model);
                if (StringUtils.isEmpty(channelUrl))
                {
                    LOGGER.error("[{}]no channel[{}] url found.", model.getUrlId(), model.getChannelId());
                    return null;
                }
        
                String paramJson = JsonUtil.toJson(model.toRemote(), snake);
                return this.restTemplate.invoke(channelUrl, null, paramJson);
            }
        
            /**
             * 查询渠道对应的url
             *
             * @param model 业务模型
             * @return 渠道的url
             */
            protected String getChannelUrl(T model)
            {
                Map<String, String> channelUrls = assemblyConfService.getChannelUrl();
                return channelUrls.get(model.getChannelId());
            }
        
            /**
             * 把结果转换成标准的带返回状态标记的业务模型(可覆写)
             *
             * @param json    返回结果json
             * @param typeRef 复杂类型的jackson转换适配器
             * @param snake   渠道是否驼峰转下划线方式
             * @return 带返回状态标记的业务模型
             */
            protected ResultCode<O> toModel(String json, TypeReference<ResultCode<O>> typeRef, boolean snake)
            {
                if (StringUtils.isEmpty(json))
                {
                    return null;
                }
                return JsonUtil.toComplex(json, typeRef, snake);
            }
        
            /**
             * 把结果转换成标准的带返回状态标记的业务模型(可覆写)
             *
             * @param json     返回结果json
             * @param typeRefs 复杂类型的jackson转换适配器
             * @param snake    渠道是否驼峰转下划线方式
             * @return 带返回状态标记的业务模型
             */
            protected ResultCode<List<O>> toModels(String json, TypeReference<ResultCode<List<O>>> typeRefs, boolean snake)
            {
                if (StringUtils.isEmpty(json))
                {
                    return null;
                }
                return JsonUtil.toComplex(json, typeRefs, snake);
            }
        
            /**
             * 查询渠道配置的特定状态
             *
             * @param model 业务模型
             * @param key   渠道状态参数
             * @return 渠道配置的布尔值
             */
            private boolean queryChannelStatus(T model, String key)
            {
                GlobalConfig config = new GlobalConfig();
                config.setUrlId(model.getChannelId());
                Map<String, String> dictMap = assemblyConfService.getChannelDict(config);
                String result = Boolean.FALSE.toString();
                if (dictMap.containsKey(key))
                {
                    result = dictMap.get(key);
                }
                return Boolean.TRUE.toString().equalsIgnoreCase(result);
            }
        
            /**
             * 日志句柄
             */
            private static final Logger LOGGER = LoggerFactory.getLogger(BaseRemoteService.class);
        
            /**
             * 配置的聚合服务
             */
            @Autowired(required = false)
            private AssemblyConfService assemblyConfService;
        
            /**
             * 注入http请求
             */
            @Autowired(required = false)
            private CommonRestTemplate restTemplate;
        }
        

        在远程调用时,其实还通过AOP做了渠道限流,参见熔断降级与限流在开源SpringBoot/SpringCloud微服务框架的最佳实践 文档;

  • 上面介绍了业务抽象的整个实现过程,总结如下:
    • 在场景类似的不同业务中,其实是可以通过泛型+抽象类来实现业务抽象的,抽象后,很多业务特性可以在这些公共的代码中去实现,这样大家都不用都写一遍了。其抽象实现包括:
      • 通过SpringMVC拦截器实现了所有业务的客户限流;
      • 通过Guava+Redis+定时器实现了所有业务的业务缓存+缓存刷新机制;
      • 通过SpringAOP切面实现了所有接口的渠道限流;
      • 通过日志注解+切面+部分框架代码可以轻松实现客户调用日志记录、渠道调用日志记录;
    • 泛型+抽象类通常难以理解,需要较为深厚的编码功底;
5.2.3 抽象业务的具体实现
  • 在上述框架代码抽象的基础上,我们来看看实现一个接口开发,需要做哪些事情:
    • 编写业务Rest QrCodeController,其代码如下:
      @Slf4j
      @RestController
      public class QrCodeController extends BaseBizController<QrCodeResult, QrCode, QrCodeInner>
      {
          /**
           * 获取Jwk公钥
           *
           * @param inner 业务入参模型
           * @return 公钥结果对象
           */
          @PostMapping("/demo/jwk")
          @Override
          public ResultCode<QrCodeResult> execute(@RequestBody QrCodeInner inner)
          {
              log.info("current inner:{}", JsonUtil.toJson(inner));
              return restService.execute(inner.toModel());
          }
      
          /**
           * 注入自定义的Rest服务
           */
          @Resource(name = DemoConst.DEMO_REST_SERVICE)
          private RestService<QrCodeResult, QrCode> restService;
      }
      
    • 编写业务服务QrRestServiceImpl,其代码如下:
      @Service(DemoConst.DEMO_REST_SERVICE)
      public class QrRestServiceImpl extends BaseRestService<QrCodeResult, QrCode>
      {
          @Override
          protected RemoteService<QrCodeResult, QrCode> getRemoteService()
          {
              return remoteService;
          }
      
          /**
           * 注入远程服务名
           */
          @Resource(name = DemoConst.DEMO_REMOTE_SERVICE)
          private RemoteService<QrCodeResult, QrCode> remoteService;
      }
      
    • 编写远程服务QrRemoteServiceImpl:
      @Slf4j
      @Service(DemoConst.DEMO_REMOTE_SERVICE)
      public class QrRemoteServiceImpl extends BaseRemoteService<QrCodeResult, QrCode>
      {
          @Override
          protected String call(QrCode model, boolean snake)
          {
              log.info("current param2:{},snake:{}", JsonUtil.toJson(model), snake);
              String channelUrl = this.getChannelUrl(model);
              if (StringUtils.isEmpty(channelUrl))
              {
                  log.error("[{}]no channel[{}] url found.", model.getUrlId(), model.getChannelId());
                  return null;
              }
      
              ResponseEntity<String> jwkJson = restTemplate.getForEntity(channelUrl, String.class);
              log.info("remote result:{}", jwkJson.getBody());
              return jwkJson.getBody();
          }
      
          @Override
          protected ResultCode<QrCodeResult> toModel(String json, TypeReference<ResultCode<QrCodeResult>> typeRef,
              boolean snake)
          {
              if (null == json)
              {
                  return ResultCode.error(ErrCodeEnum.SERVER_ERROR.getCode());
              }
              QrCodeResult result = new QrCodeResult();
              result.setOpenId(Hex.toHexString(json.getBytes(StandardCharsets.UTF_8)));
              return ResultCode.ok(result);
          }
      
          /**
           * 注入远程服务
           */
          @Autowired
          private CommonRestTemplate restTemplate;
      }
      
  • 在抽象业务框架的基础上做业务实现的总结:
    • 在框架实现了较多公共能力的基础上,业务逻辑实现非常简单;
    • 上述业务逻辑的实现逻辑不太容易理解,除了有良好的编码功底,还需要较好地团队沟通能力;

5.3 错误码兼容处理

  • 错误码兼容处理的思路:
    • 错误码的抽象同上述抽象业务逻辑的模型抽象类似,分为远程调用的错误码(errcode_inner)、标准错误码(errcode)和对外的兼容错误码(errcode_outer),分别在对应3个不同的国际化文件(选择国际化配置文件是为了以后可以支持错误码的语言切换);
    • inner_errcode在远程调用结束时,就先转化成标准错误码,然后返回至核心业务服务中;
    • 业务服务的标准接口则基于标准错误码做业务逻辑处理;
    • 在兼容接口的RestController中,还需要把标准错误码转换成兼容的错误码;
  • 错误码文件内容如下:
    • 内部错误码主要基于远程调用的三方服务错误码定义,并从中映射成标准错误码。配置文件路径为:errcode/errcode_inner_zh_CN.properties。内容如下:
      101.MSG=成功.
      101.OUT=100001
      999.MSG=失败.
      999.OUT=100099
      

      其中.MSG前面的是三方服务的错误码,=后面的是三方服务的错误码描述;
      其中.OUT前面的是三方服务的错误码,=后面的是我们系统定义的标准错误码;

    • 标准错误码是我们自己的业务定义。配置文件路径为:errcode/errcode_zh_CN.properties。内容如下:
      100001.MSG=通过
      100001.OUT=0
      100002.MSG=签名验证失败
      100002.OUT=-1
      100003.MSG=流量超限
      100003.OUT=-1
      100004.MSG=认证失败
      100004.OUT=-1
      100005.MSG=参数错误
      100005.OUT=-1
      100098.MSG=内部错误
      100098.OUT=-1
      100099.MSG=未通过
      100099.OUT=-1
      
      • 其中.OUT前面的是标准错误码,=后面的是兼容老接口的错误码;
      • 从中我们还可以看出,通过这种配置方式,可以适配出老接口的一个错误码对应多个错误Message的不合理但又必须要支持的诉求;
    • 兼容错误码是兼容老接口的错误码。配置文件路径为:errcode/errcode_outer_zh_CN.properties。内容如下:
      0=通过.
      -1=未通过.
      
  • 错误码加载实现ErrCodeMgr,代码如下:
    public final class ErrCodeMgr
    {
        /**
         * 获取标准的全局的内部错误码对象
         *
         * @return 标准的内部错误的错误码对象
         */
        public static ErrCode getServerErr()
        {
            String code = ErrCodeEnum.SERVER_ERROR.getCode();
            String msgKey = code + CODE_SUFFIX;
            String msg = I18N.get(Locale.SIMPLIFIED_CHINESE, msgKey);
            if (StringUtils.isEmpty(msg))
            {
                msg = StringUtils.EMPTY;
            }
            return ErrCode.build(code, msg);
        }
    
        /**
         * 获取外部错误码对象
         *
         * @param outCode 外部错误码code
         * @return 外部错误码对象
         */
        public static ErrCode getOut(String outCode)
        {
            return getOut(outCode, Locale.SIMPLIFIED_CHINESE);
        }
    
        /**
         * 获取外部错误码对象
         *
         * @param outCode 外部错误码code
         * @param locale  语言
         * @return 外部错误码对象
         */
        public static ErrCode getOut(String outCode, Locale locale)
        {
            if (StringUtils.isEmpty(outCode))
            {
                LOGGER.error("invalid out code[{}/{}].", outCode, locale);
                return getToOut(ErrCodeEnum.SERVER_ERROR.getCode());
            }
            String outMsg = I18N.getOut(locale, outCode);
            if (StringUtils.isEmpty(outMsg))
            {
                LOGGER.error("not exist out code[{}/{}].", outCode, locale);
                return getToOut(ErrCodeEnum.SERVER_ERROR.getCode(), locale);
            }
            return ErrCode.build(outCode, outMsg, OUT_TYPE);
        }
    
        /**
         * 获取标准的错误码(默认中文)
         *
         * @param code 错误码code
         * @return 错误码对象
         */
        public static ErrCode get(String code)
        {
            return get(code, Locale.SIMPLIFIED_CHINESE);
        }
    
        /**
         * 获取参数校验标准的错误码(默认中文)
         *
         * @param code 错误码code
         * @return 错误码对象
         */
        public static ErrCode getValid(String code)
        {
            return getValid(code, Locale.SIMPLIFIED_CHINESE);
        }
    
        /**
         * 获取标准的错误码
         *
         * @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;
        }
    
        /**
         * 根据内部错误码获取标准错误码(默认获取中文)
         *
         * @param inCode 内部错误码
         * @return 标准错误码对象
         */
        public static ErrCode getFromIn(String inCode)
        {
            return getFromIn(inCode, Locale.SIMPLIFIED_CHINESE);
        }
    
        /**
         * 根据内部错误码获取标准错误码
         *
         * @param inCode 内部错误码code
         * @param locale 语言
         * @return 标准错误码对象
         */
        public static ErrCode getFromIn(String inCode, Locale locale)
        {
            String key = inCode + OUT_SUFFIX;
            String code = I18N.getIn(locale, key);
            if (StringUtils.isEmpty(code))
            {
                LOGGER.error("no in code[{}/{}] to standard.", inCode, locale);
                return getServerErr();
            }
    
            return get(code, locale);
        }
    
        /**
         * 根据标准错误码获取外部错误码(默认获取中文)
         *
         * @param code 标准错误码
         * @return 外部错误码对象
         */
        public static ErrCode getToOut(String code)
        {
            return getToOut(code, Locale.SIMPLIFIED_CHINESE);
        }
    
        /**
         * 根据标准错误码获取外部错误码
         *
         * @param code   标准错误码
         * @param locale 语言
         * @return 外部错误码对象
         */
        public static ErrCode getToOut(String code, Locale locale)
        {
            String outKey = code + OUT_SUFFIX;
            String outCode = I18N.get(locale, outKey);
            if (StringUtils.isEmpty(code) || StringUtils.isEmpty(outCode))
            {
                LOGGER.error("no code[{}/{}] to out.", code, locale);
                return getToOut(ErrCodeEnum.SERVER_ERROR.getCode());
            }
    
            return getOut(outCode, locale);
        }
    
        /**
         * 加载标准的错误码的国际化文件中的内容(默认添加中文和英文)
         *
         * @param path 文件路径(注意不能带语言及properties后缀,即不能包含zh_CN.properties)
         */
        public static void load(String path)
        {
            I18N.loadI18n(path, Locale.US);
            I18N.loadI18n(path, Locale.SIMPLIFIED_CHINESE);
        }
    
        /**
         * 加载外部错误码的国际化文件中的内容(默认添加中文和英文)
         *
         * @param path 文件路径(注意不能带语言及properties后缀,即不能包含zh_CN.properties)
         */
        public static void loadOut(String path)
        {
            I18N.loadI18nOut(path, Locale.US);
            I18N.loadI18nOut(path, Locale.SIMPLIFIED_CHINESE);
        }
    
        /**
         * 加载内部错误码的国际化文件中的内容(默认添加中文和英文)
         *
         * @param path 文件路径(注意不能带语言及properties后缀,即不能包含zh_CN.properties)
         */
        public static void loadIn(String path)
        {
            I18N.loadI18nIn(path, Locale.US);
            I18N.loadI18nIn(path, Locale.SIMPLIFIED_CHINESE);
        }
    
        /**
         * 加载参数校验错误码的国际化文件中的内容(默认添加中文和英文)
         *
         * @param path 文件路径(注意不能带语言及properties后缀,即不能包含zh_CN.properties)
         */
        public static void loadValid(String path)
        {
            I18N.loadI18nValid(path, Locale.US);
            I18N.loadI18nValid(path, Locale.SIMPLIFIED_CHINESE);
        }
    
        private ErrCodeMgr()
        {
        }
    
        /**
         * 日志句柄
         */
        private static final Logger LOGGER = LoggerFactory.getLogger(ErrCodeMgr.class);
    
        /**
         * 引入错误码的国际化管理器
         */
        private static final ErrCodeI18nMgr I18N = ErrCodeI18nMgr.getInstance();
    
        /**
         * 错误码国际化文件中的code key后缀
         */
        private static final String CODE_SUFFIX = ".MSG";
    
        /**
         * 错误码国际化文件中的code映射到外部的code的key后缀
         */
        private static final String OUT_SUFFIX = ".OUT";
    
        /**
         * 外部错误码的类型
         */
        private static final int OUT_TYPE = 1;
    }
    
    • 上述三种错误码是独立加载的,但是其API都是类似的,目前默认支持的是中文。

5.4 接口兼容适配

  • 处理思路:
    • 重新定义一套和原老接口入参和出参完全一样的接口RestController,在其调用服务层之前,先把入参模型转换成标准RestController的入参模型;
    • 这样兼容接口的RestController就可以复用标准的服务层逻辑;
    • 服务层处理完毕后,在兼容接口的RestController中根据拿到的标准出参模型,再转换成老接口的出参模型;
  • 通过接口举例来说明:
    • 标准接口的入参body json中只有一个code 字符串字段时,其返回值的body json的data字段为一个模型对象,入参出参分别如下所示:
      curl --location 'http://localhost:9992/demo/jwk' \
      --header 'Authorization: Bearer eyJ...' \
      --header 'Content-Type: application/json' \
      --data '{
          "code":"test123"
      }'
      

      {
          "req_id": "661e1cc825c94074a750c3b3ef351259",
          "resp_id": "d8fb21b7b52248caa256192116fb0e13",
          "code": "100001",
          "msg": "通过",
          "data": {
              "open_id": "7b226b657973223a..."
          },
          "cost": 201
      }
      
    • 其兼容的老接口的body json中有一个code 字符串数组字段,其返回值的body json的data字段为一个模型对象集合。
  • 对应的编码实现:
    • 定义一个兼容的入参模型OldQrCodeInner,并覆写其code属性为字符串集合,代码如下:
      @Data
      public class OldQrCodeInner extends BaseBizInner<QrCode>
      {
          @Override
          protected QrCode genModel()
          {
              String realCode = StringUtils.EMPTY;
              if (!CollectionUtils.isEmpty(code))
              {
                  realCode = code.get(0);
              }
              QrCode qrCode = new QrCode();
              qrCode.setCode(realCode);
              return qrCode;
          }
      
          /**
           * 扫码时的code
           */
          private List<String> code;
      }
      
    • 定义一个兼容的老接口OldQrCodeController,并覆写其错误码和入参转换,最终获得了完全兼容的结果。其代码如下:
      @Slf4j
      @RestController
      public class OldQrCodeController extends BaseBizController<QrCodeOuter, QrCode, QrCodeInner>
      {
          /**
           * 获取Jwk公钥
           *
           * @param inner 业务入参模型
           * @return 公钥结果对象
           */
          @PostMapping("/demo/jwk/old")
          public ResultCode<List<QrCodeOuter>> execute(@RequestBody OldQrCodeInner inner)
          {
              log.info("current inner:{}", JsonUtil.toJson(inner));
              //1.通过老接口的入参获取标准的业务模型
              QrCode qrCode = inner.toModel();
              //2.通过标准入参调用标准的服务
              ResultCode<QrCodeOuter> resultCode = restService.execute(qrCode);
              String code = resultCode.getCode();
              List<QrCodeOuter> outers = null;
              if (null != resultCode.getData())
              {
                  outers = Lists.newArrayList(resultCode.getData());
              }
              //3.根据标准服务的标准错误码,获取兼容错误码
              ErrCode oldErrCode = ErrCodeMgr.getToOut(code);
              //4.拼接兼容错误码、兼容出参的结果对象
              return ResultCode.build(oldErrCode, outers);
          }
      
          /**
           * 注入自定义的Rest服务
           */
          @Resource(name = DemoConst.DEMO_REST_SERVICE)
          private RestService<QrCodeOuter, QrCode> restService;
      }
      
  • 验证效果如下:
    • 兼容老接口的入参为:
      curl --location 'http://localhost:9992/demo/jwk/old' \
      --header 'Authorization: Bearer eyJ...' \
      --header 'Content-Type: application/json' \
      --data '{
          "code":["test123"]
      }'
      
    • 兼容老接口的出参(返回结果)为:
      {
          "code": "0",
          "msg": "通过.",
          "data": [
              {
                  "open_id": "7b226b657973223a5b7b22..."
              }
          ],
          "cost": 0
      }
      
    • 调用报错时,需要参考bq-bizREADME.MD文档配置兼容接口/demo/jwk/old访问权限,步骤如下:
      -- 1.录入请求的url(如:/demo/jwk/old)
      INSERT INTO public.bq_global_dict(id, key, value, type)
      VALUES ('d221', 'DEMO_OLD_QR_API', '/demo/jwk/old', 'ClientUrl');
      
      -- 2.录入请求的url对应的配置参数(如:/demo/jwk/old)
      INSERT INTO public.bq_global_dict(id, key, value, type)
      VALUES ('d223', 'DEMO_OLD_QR_API', 'client.to.channel', 'ChannelConfig');
      
      -- 3.录入请求的url对应的渠道参数(如:/demo/jwk/old)
      INSERT INTO public.bq_global_config(id, svc_id, client_id, url_id, svc_value, create_time)
      VALUES ('svc311', 'client.to.channel', 'app001', 'DEMO_OLD_QR_API', 'DEMO_CHANNEL_JWK_API', '1566382443412');
      
      -- 4.录入请求的url对应的渠道URL参数(如:/demo/jwk/old),存在时可以跳过
      INSERT INTO public.bq_global_dict(id, key, value, type)
      VALUES ('d414', 'DEMO_CHANNEL_JWK_API', 'http://bq-auth/oauth/jwk', 'ChannelUrl');
      
      -- 5.录入请求的url对应的渠道结果处理参数(如:/demo/jwk/old),存在时可以跳过
      INSERT INTO public.bq_global_dict(id, key, value, type)
      VALUES ('d420', 'channel.status', 'true', 'DEMO_CHANNEL_JWK_API');
      INSERT INTO public.bq_global_dict(id, key, value, type)
      VALUES ('d421', 'channel.snake', 'true', 'DEMO_CHANNEL_JWK_API');
      
      -- 6.配置客户调用限流(如:/demo/jwk/old)
      INSERT INTO public.bq_global_config(id, svc_id, client_id, url_id, svc_value, create_time)
      VALUES ('svc441', 'client.limit.qps', 'app001', 'DEMO_OLD_QR_API', '100', '1566382443412');
      INSERT INTO public.bq_global_config(id, svc_id, client_id, url_id, svc_value, create_time)
      VALUES ('svc442', 'client.limit.max', 'app001', 'DEMO_OLD_QR_API', '100000', '1566382443412');
      
      -- 7.配置客户调用对应的渠道限流(如:/demo/jwk/old)
      INSERT INTO public.bq_global_config(id, svc_id, client_id, url_id, svc_value, create_time)
      VALUES ('svc444', 'channel.limit.qps', 'DEMO_OLD_QR_API', 'DEMO_CHANNEL_JWK_API', '110', '1566382443412');
      INSERT INTO public.bq_global_config(id, svc_id, client_id, url_id, svc_value, create_time)
      VALUES ('svc445', 'channel.limit.max', 'DEMO_OLD_QR_API', 'DEMO_CHANNEL_JWK_API', '150000', '1566382443412');
      

6. 思考总结

  • 服务的兼容无感升级是非常常见的现实诉求,因为大多情况下,都是先有了系统,并且在持续迭代中,架构严重腐化,无以为继,不得不升;
  • 大多公司的业务都具有相似性,可以采用泛型+业务抽象的方式,把大部分通用能力、公共业务流程给固化下来,可以提升编码质量,同时减少业务开发工作量;
  • 上述沉淀的代码架构图中,其组件部分基本上是和业务无关的,有较强的通用性;其微服务部分的架构设计,重点考虑了业务安全和高性能,如:鉴权和认证分离、数据读写分离、缓存及刷新机制等,还考虑了系统后续的升级改造,尽量不把自己绑死在某个中间件上;
  • 本人只是列举了无感迁移的部分典型问题,实际迁移过程比这复杂很多倍,限于精力有限,无法一一列举;

7. 参考资料

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值