目录导读
- 从单体到SpringBoot/SpringCloud微服务架构无感升级的最佳实践
- 1. 业务背景
- 2. 当前问题
- 3. 升级方案
- 3.1 架构设计
- 4. 详细设计
- 4.1 迁移阻碍
- 4.2 解决思路
- 5. 实现过程
- 5.1 认证兼容改造
- 5.2 抽象业务流程
- 5.2.1 抽象业务的思路
- 5.2.2 抽象业务的抽象编码
- 5.2.3 抽象业务的具体实现
- 5.3 错误码兼容处理
- 5.4 接口兼容适配
- 6. 思考总结
- 7. 参考资料
从单体到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-gateway)
:统一的接入网关,主要负责安全鉴权,即JwtToken的合法性校验和接口方案权限的校验;同时,所有外部调用也需要通过本网关服务
;认证服务(bq-auth)
: 负责生成JwtToken,同时对网关服务
提供访问权限的查询接口,且只读数据库数据;业务服务(bq-biz)
: 负责实现具体的业务逻辑,且只读数据库数据;对接服务(bq-integration)
: 负责和外部平台对接,通过网关服务
中转,且只读数据库数据;后台管理(bq-mgr)
: 负责维护接口平台,严格来讲不属于微服务集群,也不对外暴露,仅通过数据持久层异步通知认证服务
、业务服务
、对接服务
其数据变更并生效;网关服务
和认证服务
实现了鉴权和认证的分离;后台管理
和认证服务
、业务服务
、对接服务
实现了数据库的读写分离;- 以上涉及主要考虑接口平台的场景,当扩展支持Web界面时,需要在
网关服务
和认证服务
做认证和鉴权的升级改造;
- 在逻辑架构设计的基础上,进一步完善了中间件依赖,其部署架构图如下:
- 注:在实际场景中,其实使用的是K8S自带的服务注册和服务发现,为了简化场景,暂忽略K8S容器,使用了Nacos作为服务注册中心;
部署架构补充说明如下:
NacosServer
:负责服务注册和服务发现,同时也是Sentinel熔断限流和网关服务
路由的配置中心;Sentinel
:负责熔断降级和非业务的限流,同时支持基于SpringBoot-WebFlux的网关服务
和基于SpringBoot-Web的其他服务;ZipkinServer
:负责链路追踪汇总查询,同时支持基于SpringBoot-WebFlux的网关服务
和基于SpringBoot-Web的其他服务;- 为了后续能够平滑切换至新SpringCloud架构的组件,当然也包括切换至k8s,当下都是极简使用
NacosServer
和ZipkinServer
; - 上述架构已全部开源,参见Java开源接口微服务代码框架文档详细内容了;
4. 详细设计
- 接口服务平台虽然业务较多,但是其业务场景及其操作步骤基本类似,因此可以考虑对业务场景进行抽象提炼,尽量编写少的差异代码逻辑,复用公共抽象逻辑;
- 由于业务需要和非常多的三方服务对接,安全认证方案各个不同,因此在分析总结老Python系统的基础上,结合以前积累的代码库,构建的代码架构设计如下:
4.1 迁移阻碍
- 上述的架构设计基本上解决了面临的几个核心问题,但是还是无法做到系统的无感迁移,原因如下:
- Python老平台的错误码设计不合理,部分接口透传了第三方的错误码信息,部分又是自定义的错误码信息,且自定义的错误码里面还存在一码对多个Message的情况。当下根本无法对照代码、文档甚至是日志分析出所有错误码的场景;
- Python老平台的接口设计不合理,Json报文中,有的字段为空时,是空字符串,但是有值时又是子Json对象;相同含义的同一字段,有的接口是子Json数组,有的接口却又是Json对象……情况千奇百怪;
4.2 解决思路
- 针对错误码阻碍,想到的措施如下:
- 错误码可变性较大,会随着业务场景变化而变化(一般只增不减),所以需要把所有错误码放到独立的配置中,与代码隔离;
- 从业务出发,搜集近一年的生产脱敏数据进行分析,提炼出每个接口的可能错误码和对应Message;
- 在梳理汇总错误码的基础上,做剪枝操作(即一个错误码只保留一个Message),并跟业务团队共识都认可的方案;
- 把错误码分三层,内层是和三方服务对接的错误码,映射了三方服务和标准错误码的关系;中层为标准错误码,记录了新接口服务平台的所有场景错误码;外层是兼容错误码,映射了标准错误码和兼容错误码的关系,兼容错误码也就是上条所说的剪枝并共识的错误码;
- 针对接口字段不规范,选用扩展性更强的格式作为统一标准,在兼容接口上预留扩展点,方便做特殊定制处理;
5. 实现过程
- 在上述系统分析的前提下,主要分了以下几个方面来对接口平台做升级改造。
5.1 认证兼容改造
- 通过
网关服务
和认证服务
做JwtToken的鉴权和认证的分离改造参见OAuth2在开源SpringBoot/SpringCloud微服务框架的最佳实践 ; - 兼容AK/SK鉴权逻辑设计如下:
- 在
网关服务
处配置一个对接认证服务
的特殊AK/SK,收到客户请求时,通过这个特殊的AK/SK先生成一个JwtToken并缓存在本网关服务
实例中; - 当
网关服务
JwtToken缓存存在时,调用认证服务
,获取客户的真实账号和接口权限信息;JwtToken缓存不存在时,则执行上一步获取; - 比对用户的接口权限信息是否合法,如不合法,则返回签名失败;合法时,则按照AK/SK的签名校验规则进行签名校验;
由于AK/SK的设计很少出现在接口权限的认证场景中,此处暂略掉其实现;
- 在
- 当
网关服务
和认证服务
需要支持前端页面认证访问时,则需要做如下的改造设计:- 在前端页面做登录认证时,
认证服务
需要同时生成JwtToken和刷新Token,前者有效期30分钟,后者1小时;JwtToken只能用作业务接口的调用,刷新Token只能调用刷新接口; 网关服务
处确保JwtToken只用作业务接口的调用、刷新Token只调用刷新接口;- 当JwtToken和刷新Token都过期时,页面要返回到登录界面,重新让用户登录;
- 当JwtToken过期而刷新Token未过期时,则由客户端使用刷新Token发起Token刷新调用,刷新成功后,使用新的JwtToken做业务调用;
- 由于带有前端页面的系统,通常角色、权限、菜单配置较多,其认证信息已不合适放在JwtToken字段中,
网关服务
和认证服务
可以通过集群共享Redis来实现;即:认证服务
生成JwtToken时,同时把JwtToken信息缓存至与网关服务
共享的Redis。网关服务
仅连接高性能的Redis几乎不影响性能;网关服务
在校验JwtToken签名合法时,同时还要根据JwtToken获取共享Redis中的相关权限信息,只有权限匹配通过时,才能继续调用业务接口;否则返回认证失败;- 当用户的认证信息发生变更时,
认证服务
清理掉Redis缓存,下一次网关服务
比对缓存时,就能够及时感知到认证信息的变化; 认证服务
需要使用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; }
- 按照上面的做法统一定义RestController后,就可以统一使用Redis对继承于BaseBiz(<T extends BaseBiz>)的标准模型做统一的Redis业务限流了;
- RedisRedis业务限流逻辑参见:熔断降级与限流在开源SpringBoot/SpringCloud微服务框架的最佳实践 说明;
- 定义了抽象的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> service
、AssemblyConfService 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微服务框架的最佳实践 文档;
- 定义RestController基类:
- 上面介绍了业务抽象的整个实现过程,总结如下:
- 在场景类似的不同业务中,其实是可以通过泛型+抽象类来实现业务抽象的,抽象后,很多业务特性可以在这些公共的代码中去实现,这样大家都不用都写一遍了。其抽象实现包括:
- 通过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; }
- 编写业务Rest QrCodeController,其代码如下:
- 在抽象业务框架的基础上做业务实现的总结:
- 在框架实现了较多公共能力的基础上,业务逻辑实现非常简单;
- 上述业务逻辑的实现逻辑不太容易理解,除了有良好的编码功底,还需要较好地团队沟通能力;
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=未通过.
- 内部错误码主要基于远程调用的三方服务错误码定义,并从中映射成标准错误码。配置文件路径为:errcode/errcode_inner_zh_CN.properties。内容如下:
- 错误码加载实现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字段为一个模型对象集合。
- 标准接口的入参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; }
- 定义一个兼容的入参模型OldQrCodeInner,并覆写其code属性为字符串集合,代码如下:
- 验证效果如下:
- 兼容老接口的入参为:
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-biz
的README.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] AWS S3签署和对 REST 请求进行身份验证