得物消息中心主要承接上游各业务的消息推送请求,如营销推送、商品推广、订单信息等。消息中心接受业务请求后,会根据业务需求去执行【消息内容检验,防疲劳,防重复,用户信息查询,厂商推送】等节点,最后再通过各手机厂商及得物自研的在线推送通道触达用户。整体推送流程简化如下:
我们希望能够对各个节点提供准确的SLA监控指标和告警能力,从而实现对整体系统的稳定性保证。下面是我们设计的部分指标:
监控指标
节点推送量
节点推送耗时
节点耗时达标率
整体耗时达标率
节点阻塞量
其他指标
那我们如何实现对这些指标的统计呢?最简单的方案就是在每个节点的入口和出口增加统计代码,如下:
项目开发不好分工,通常意味着代码耦合度过高,是典型的代码坏味道,需要及时重构。
从上面的几个问题出发,我们总结出 方案0 的几个痛点,以及我们后续重构的目标。
监控节点不清晰。消息推送服务涉及多个不同的操作步骤。这些步骤我们称之为节点。但是这些节点的定义并不明确,只是我们团队内部约定俗成的一些概念。这就导致日常沟通和开发中有很多模糊空间。
在项目开发过程中,经常会碰到长时间的争论找不到解法。原因往往是大家对基础的概念理解没有打通,各说各话。这时候要果断停止争论,首先对基本概念给出一致的定义,然后再开始讨论。模糊的概念是高效沟通的死敌。
维护困难。
每个节点的统计都需要修改业务节点的代码,统计代码分散在整个系统的各个节点,维护起来很麻烦;
同时推送流程的主要逻辑被淹没在统计代码中。典型的代码如下,一个业务方法中可能有三分之一是SLA统计的代码。
protected void doRepeatFilter(MessageDTO messageDTO) {
//业务逻辑:防重复过滤
//...
//业务逻辑:防重复过滤
if (messageSwitchApi.isOpenPushSla && messageDTO.getPushModelList().stream()
.anyMatch(pushModel -> pushModel.getReadType().equals(MessageChannelEnums.PUSH.getChannelCode()))) {
messageDTO.setCheckRepeatTime(System.currentTimeMillis());
if (messageDTO.getQueryUserTime() > 0) {
long consumeTime = messageDTO.getCheckRepeatTime() - messageDTO.getQueryUserTime();
//SLA耗时统计逻辑
messageMonitorService.monitorPushNodeTimeCost(
MessageConstants.MsgTimeConsumeNode.checkRepeatTime.name(), consumeTime, messageDTO);
}
}
}
难以扩展
新的节点需要监控时,没办法快速接入,需要到处复制监控逻辑。
新的监控指标要实现的话,需要修改每个业务节点的代码。
理清问题之后,针对系统既有的缺陷,我们提出了以下的重构目标:
主流程的归主流程,SLA 的归 SLA。
SLA 监控代码从主流程逻辑中剥离出来,彻底避免SLA代码对主流程代码的污染。
异步执行SLA 逻辑计算,独立于推送业务主流程,防止SLA异常拖垮主流程。
SLA 是基于节点来实现的,那么节点的概念不容许有模糊的空间。因此在重构开始之前,我们把节点的概念通过代码的形式固定下来。
public enum NodeEnum {
MESSAGE_TO_PUSH("msg","调用推送接口"),
FREQUENCY_FILTER("msg","防疲劳"),
REPEAT_FILTER("push","防重复"),
CHANNEL_PUSH("push","手机厂商通道推送"),
LC_PUSH("push","自研长连推送")
//其他节点...
}
SLA有很多个统计指标。我们不希望把所有指标的统计逻辑都堆积在一起。那么如何进行解耦呢?答案是观察者模式。我们在AOP切点之前发出节点进入事件(EnterEvent),切点退出之后发出节点退出事件(ExitEvent)。把各个指标统计逻辑抽象成节点事件处理器。各个处理器去监听节点事件,从而实现统计指标间的逻辑解耦。
现在我们对每个问题都找到了相应的解法,再把这些解法串起来,形成完整的重构方案。重构之后,SLA逻辑流程如下:
方案原型是指对既有方案的一个最简单的实现,用于技术评估和方案说明。
正所谓talk is cheap, show me the code。对程序员来说,方案设计的再完美,也没有可运行的代码有说服力。我们大概花了两个小时时间基于现有设计快速实现了一个原型。原型代码如下:
AOP切面类EventAop,负责把增强代码织入切点执行前后。
public class EventAop {
@Autowired
private EventConfig eventConfig;
@Autowired
private AdaptorFactory adaptorFactory;
@Around("@annotation(messageNode)")
public Object around(ProceedingJoinPoint joinPoint, MessageNode messageNode) throws Throwable {
Object result = null;
MessageEvent enterEvent = adaptorFactory.beforProceed(joinPoint, messageNode);
eventConfig.publishEvent(enterEvent);
result = joinPoint.proceed();
MessageEvent exitEvent = adaptorFactory.postProceed(joinPoint, messageNode);
eventConfig.publishEvent(exitEvent);
return result;
}
}
事件配置类EventConfig, 这里直接使用Spring event 广播器,负责分发event。
public class EventConfig {
@Bean
public ApplicationEventMulticaster applicationEventMulticaster() { //@1
//创建一个事件广播器
SimpleApplicationEventMulticaster result = new SimpleApplicationEventMulticaster();
return result;
}
public void publishEvent(MessageEvent event) {
this.applicationEventMulticaster().multicastEvent(event);
}
}
MessageEvent, 继承Spring event提供的ApplicationEvent类。
public class MessageEvent extends ApplicationEvent {}
public class AdaptorFactory {
@Autowired
private DefaultNodeAdaptor defaultNodeAdaptor;
//支持切点之前产生事件
public MessageEvent beforeProceed(Object[] args, MessageNode messageNode) {
INodeAdaptor adaptor = getAdaptor(messageNode.node());
return adaptor.beforeProceedEvent(args, messageNode);
}
//支持切点之后产生事件
public MessageEvent afterProceed(Object[] args, MessageNode messageNode, MessageEvent event) {
INodeAdaptor adaptor = getAdaptor(messageNode.node());
return adaptor.postProceedEvent(args, event);
}
private INodeAdaptor getAdaptor(NodeEnum nodeEnum) {
return defaultNodeAdaptor;
}
}
Spring AOP的问题:Spring AOP中私有方法无法增强。bean自己调用自己的public方法也无法增强。
潜在的技术问题要充分沟通。每个成员的技术背景不同,你眼里很简单的技术问题,可能别人半天就爬不出来。方案设计者要充分预知潜在的技术问题,提前沟通,避免无谓的问题排查,进而提升开发效率。
成本项 | 重构前 | 重构后 | 备注 | |
Spring AOP实现 | 0 | 1 | 使用现有成熟技术,成本较低,且风险可控。 | |
Spring Event实现 | 0 | 1 | ||
统计指标实现 | 节点耗时 | 2 | 1 | 重构前统计指标需要在整个业务链路上添加,重构之后各个统计指标只需专注于本身指标初拉力器的开发,开发成本至少降低50% |
节点阻塞 | 2 | 1 | ||
其他指标 | 2n | n | ||
测试 | n | 1 | 重构之后,各个指标的测试、推送主流程的测试可以独立进行,互不影响,能明显提升测试效率。 |
代码重构最难的不是技术,而是决策。决定系统是否要重构,何时重构才是最难的部分。往往一个团队会花费大量时间去纠结是否要重构,但是到最后都没人敢做出最终决策。之所以难是因为缺乏决策材料。可以考虑引入成本收益表等决策工具,对重构进行定性、定量分析,帮助我们决策。
实现的过程也碰到的一些比较有意思的坑,下面列出来供大家参考。
public static Object invokeJoinpointUsingReflection(@Nullable Object target, Method method, Object[] args) throws Throwable {
//使用反射调用原始对象的方法
ReflectionUtils.makeAccessible(method);
return method.invoke(target, args);
}
@Service("aService")
public class AServiceImpl implements AService{
@MessageNode(node = NodeEnum.Node1)
public void method1(){
this.method2();
}
@MessageNode(node = NodeEnum.Node2)
public void method2(){
}
}
@Component("eventAop")
@Aspect
@Slf4j
public class EventAop {
@Around("@annotation(messageNode)")
public Object around(ProceedingJoinPoint joinPoint, MessageNode messageNode) throws Throwable {
/**
节点开始统计逻辑...
**/
//执行切点
result = joinPoint.proceed();
/**
节点结束统计逻辑...
**/
return result;
}
}
注入代理对象aopSelf,主动调用代理对象aopSelf的方法。
示例代码如下:
@Service("aService")
public class AServiceImpl implements AService{
@Autowired
@Lazy
private AService aopSelf;
@MessageNode(node = NodeEnum.Node1)
public void method1(){
aopSelft.method2();
}
@MessageNode(node = NodeEnum.Node2)
public void method2(){
}
}
@Service("aService")
public class AServiceImpl implements AService{
@Autowired
private BService bService;
@MessageNode(node = NodeEnum.Node1)
public void method1(){
bService.method2();
}
}
@Service("bService")
public class BServiceImpl implements BService{
@MessageNode(node = NodeEnum.Node2)
public void method2(){
}
}
重构能够帮助我们发现并且定位代码坏味道,从而指导我们对坏代码进行重新抽象和优化。
@Bean
public ThreadPoolExecutorFactoryBean applicationEventMulticasterThreadPool() {
ThreadPoolExecutorFactoryBean result = new ThreadPoolExecutorFactoryBean();
result.setThreadNamePrefix("slaEventMulticasterThreadPool-");
result.setCorePoolSize(5);
result.setMaxPoolSize(5);
result.setQueueCapacity(100000);
return result;
}
约定大于配置,也可以叫做约定优于配置(convention overconfiguration),也称作按约定编程,是一种软件设计范式,指在减少软件开发人员需做决定的数量,获得简单的好处,而又不失灵活性。
@MessageNode(node = NodeEnum.OPPO_PUSH, needEnterEvent = true)
public MessageStatisticsDTO sendPush(PushChannelBO bo) {
if (bo.getPushTokenDTOList().size() == 1) {
return sendToSingle(bo);
} else {
return sendToList(bo);
}
}
SLA上线一周之内,我们就已经依赖这套技术发现系统中的潜在问题,但是事实上对SLA的业务收益我们完全可以有更大的想象空间。比如说:
其次我们可以统计各业务推送的ROI数据,目前消息服务支持数亿的消息,但是这里面哪些推送收益高,哪些收益低目前是没有明确的指标的。我们可以收集各业务的推送量,点击量等信息去计算业务推送的ROI指标。
*文/吴国锋