一 背景
软件开发初期,需求相对简单和明确,开发出来的应用只需要满足需求能够快速上线就好,这个时候的应用在代码和业务逻辑上都比较清晰。
随着业务的不断增长,各种各样的需求也接踵而至。有倒排需求(时间紧,项目周期根据上线时间点往前推)、紧急需求(可能是某个业务逻辑漏洞需要立刻修复上线)、定制化需求(为了某种特殊场景提出的定制化功能),当这些需求不断在应用上实现,应用内会存在大量if else的代码,就算引入了良好的设计模式,也会使应用越来越臃肿,最后难以维护。
随着时间的推移,公司人员的变动也是在所难免的,新人介入现有的应用做需求,很难按照最初的应用设计来做,也很难按照最初的代码规范来写,也会不断的加大应用维护成本。
业务的增长和人员的变动都会推进一个应用从上线到被下线的进程。
当业务快速增长,应用系统的维护跟不上需求的落地时,就需要对现有系统做重构了。重构的方式有:
- 在现有系统内部分代码慢慢迭代重构。
- 应用功能拆分:类似现在比较火的微服务架构,将属于某个领域内的功能拆分到一个应用内。上游流量可以不经过现有应用,直接调新应用。
- 可复用功能下沉:类似现在比较火的中台设计,将可多个应用都使用的某个功能下沉为一个下游应用,上游流量调用现有应用,现有应用在调下游新拆分的应用。
今天我们要说的类似可复用功能下沉的这种拆分模式。在不改变上游调用方式的情况下,将现有的某个功能下沉到新应用,并需要按照不同的策略静默切流。
二 需求
要想完成静默切流,就不能对现有功能有影响,也就需要完全兼容现有业务场景下的所有逻辑,不同的业务场景可能会有:
- 将某个接口的功能完全下沉到下游应用:调下游成功后(失败降级调本地),将下游返回结果直接返回给上游调用方;
- 将某个接口的功能部分下沉到下游应用:调下游成功后(失败降级调本地),拿到返回结果,在现有应用内继续处理接下来的逻辑;
- 新旧应用数据同步更新:本地和下游都需要调用(失败忽略,输出日志、可告警);
- 数据查询:本地和下游都调用(失败忽略,输出日志、可告警),并判断下游结果是否和本地一致,优先以本地结果为准;
在静默切流的前提下,还需要做到能够根据不同业务维度去控制切流的流量,不同的维度可能会有:
- 切流总开关,控制是否切流,存在异常可通过开关关闭;
- 流量所属业务平台;
- 用户ID;
- 订单尾号百分比;
- …
三 方案
详细时序如下:
3.1 切流请求上下文
对需要切流的接口再接收到请求后,根据请求上下文信息构造切流上下文对象,切流请求上下文可定义如下:
@Data
public class TangentFlowCondition {
/**
* 订单号
*/
private String orderNo;
/**
* 请求用户ID
*/
private Long userId;
/**
* 所属平台
*/
private Integer platform;
}
3.2 切流配置
切流配置信息随时可能会改动,需要支持改动后的配置可以动态发布,这里可以使用Apollo配置中心。
Apollo:应用在启动的时候会从Apollo拉取指定namespace下的所有配置信息缓存在本地,并启动一个异步线程通过http长轮询的方式监听配置中心下指定namespace的配置变更事件,有配置变更的话,会实时拉取最新配置并更新本地缓存。
可定义一份如下配置:
{
"switch": "y",
"items": [
{
"platform": 0,
"userIdList": [],
"orderNoTailNumberLt": 20
},
{
"platform": 1,
"userIdList": [],
"orderNoTailNumberLt": 50
}
]
}
配置信息说明:
配置项 | 描述 |
---|---|
switch | 切流开关,用于控制是否开启切流,可选枚举:y -> 开启切流、n -> 关闭切流、* -> 不判断下面条件,全部切流 |
items | 具体切流条件配置 |
items.platform | 流量所属平台,符合该平台的流量走该切流配置 |
items.userIdList | 用户ID,在配置内的用户走切流调用 |
items.orderNoTailNumberLt | 订单尾号后两位小于该配置,走切流调用 |
3.3 切流条件判断
根据切流请求上下文和配置信息,我们就可以判断这次请求是否满足切流条件,对于满足条件的请求,就可以根据具体切流规则去调用对应的实现。条件判断如下:
public boolean shouldTangentFlow(TangentFlowCondition condition) {
// 获取 Apollo 配置
TangentFlowConfig config = ApolloUtil.getTangentFlowRoleConfig();
if ("*".equals(config.getTangentFlowSwitch())) {
return true;
}
if ("n".equals(config.getTangentFlowSwitch())) {
return false;
}
if (ObjectUtils.isEmpty(config.getItems())) {
return false;
}
// 判断具体切流条件
return (matchPlatform(config, condition) && matchUser(config, condition))
|| (matchPlatform(config, condition) && matchOrderNo(config, condition));
}
3.4 切流规则实现
解决了配置和请求上下文问题,接下来就是在应用内怎么实现静默切流了,还需要考虑异常降级;
方案一:自定义注解,使用在需要切流的方法上,可配置切流后的目标Class,对应的methodName,下游调用成功后回调类Class,回调methodName,以及具体调用策略枚举类型。在注解实现处,获取当前请求切流上下文信息,判断是否满足切流条件,满足条件的情况下。再根据配置的调用策略,通过spring根据配置的Class获取目标bean,再通过反射调用目标bean的method方法。
方案二:抽象接口,本地和下游服务实现同一个接口,通过多态的方式,同一个接口有不同的实现方式,该接口再有一个代理服务的实现类,代理实现类通过has-a
的方式引用本地实现和下游服务实现,在不同方法的内部实现上判断调用策略走不同的调用。
四 实现
4.1 方案一实现
切流规则枚举:
public enum TangentFlowStrategyEn {
/**
* 无论新老方法.仅执行一个
*/
ONLY,
/**
* 新老方法都执行
*/
PARALLEL,
/**
* 执行新接口,新接口执行成功后再执行success回调接口。
* 如果失败,则执行原先的接口
*/
SUCCESS_CALL_BACK,
/**
* 新老方法都执行,并校验返回结果,结果不一致的话,返回旧方法的结果
*/
PARALLEL_CHECK_FAIL_BACK,
}
自定义注解:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface TangentFlowAnnotation {
/**
* 切流接口实现类
*/
Class clazz();
/**
* 切流接口实现类的目标方法
*/
String method() default "";
/**
* 切流成功后需要执行回调方法的class.
*/
Class successCallBackClazz() default ObjectUtils.Null.class;
/**
* 回调类的回调方法
*/
String successCallBackMethod() default "";
/**
* 切流执行策略
*/
TangentFlowStrategyEn strategy() default TangentFlowStrategyEn.ONLY;
}
注解实现:
@Component
@Aspect
public class TangentFlowAspect {
@Resource
private TangentFlowRole tangentFlowRole;
@Pointcut("@annotation(com.xxx.xxx.xxx.TangentFlowAnnotation)")
private void myPointcut() {}
@Around("com.xxx.xxx.xxx.TangentFlowAspect.myPointcut()")
public Object intercept(ProceedingJoinPoint joinPoint) throws Throwable {
Object[] invokeApiArgs = getArgs(joinPoint);
TangentFlowCondition tangentFlowCondition;
try {
tangentFlowCondition = (TangentFlowCondition) invokeApiArgs[invokeApiArgs.length - 1];
} catch (Exception e) {
// 获取切流上下文异常
return joinPoint.proceed();
}
// 判断是否需要切流
boolean shouldTangentFlow = false;
try {
shouldTangentFlow = tangentFlowRole.shouldTangentFlow(tangentFlowCondition);
} catch (Exception e) {
// 判断是否满足切流条件异常
return joinPoint.proceed();
}
if (!shouldTangentFlow) {
return joinPoint.proceed();
}
Object res;
switch (tangentFlow.strategy()) {
// 判断具体执行策略,根据不同策略执行目标方法
}
return res;
}
}
4.2 方案二实现
定义接口:
public interface TradeService {
/**
* 创建订单接口
* @param parameter
* @return
*/
Object createOrder(Object parameter);
}
切流实现:
@Service
public class TangentFlowTradeServiceImpl implements TradeService {
@Override
public Object createOrder(Object parameter) {
// 切流实现
return null;
}
}
本地实现:
@Service
public class LocalTradeServiceImpl implements TradeService {
@Override
public Object createOrder(Object parameter) {
// 本地实现
return null;
}
}
代理实现:
@Service
public class ProxyTradeServiceImpl implements TradeService {
@Resource
private LocalTradeServiceImpl localTradeService;
@Resource
private TangentFlowTradeServiceImpl tangentFlowTradeService;
@Override
public Object createOrder(Object parameter) {
// 这里判断切流策略 strategy,根据不同接口不同策略调用不同实现
if (true) {
return localTradeService.createOrder(parameter);
}
return tangentFlowTradeService.createOrder(parameter);
}
}
总结:实际场景会更复杂,这里只是简单梳理了下大概思路和设计方案,仅供参考。