人人都讨厌代码腐化,人人都在腐化代码!本文介绍app消息推送开权提醒能力的服务端实现,并说明如何通过手搓一个简易的流程引擎来实现横向的业务场景隔离,纵向的业务流程编排,从而灵活支持业务需求,抑制代码腐化。
背景
消息推送是电商APP引流促活的重要手段,如何引导用户打开消息推送权限,接收APP的推送消息是各大电商APP都需要思考的问题。消息平台在过去的两个季度通过技术手段引导了近百万的用户开启推送权限:在用户关闭app消息推送权限情况下,在某些特定页面提示用户去打开权限。开权提示又可以分为弱提示,强提示。
弱提示如下,只展示开权提醒。
强提示如下,展示开权弹框。
产品需求
从产品侧来分析需求,我们希望能够有效引导用户开权,同时又不能过于频繁的打扰用户,引起用户反感。因此我们的提示要有针对性,且要能够控制提示频率,具体需求点如下:
- 不同页面提示文案不同,第一期迭代支持【消息】【商品】【订单】三个页面的开权提醒。每个页面的提示文案不同。如消息页面,提示:“开启消息通知,互动消息、优惠活动不错过~”,订单页面,提示:“开启消息通知,及时了解订单物流状态”等等。
- 提示文案支持动态配置,业务方需要修改提示文案时,可以通过修改配置快速生效。
- 提示次数要可控,不能频繁提示打扰用户。
- 一天最多一次
- 一周内最多n次,n可配置
- 个别页面可以永久提示,不同页面的频控次数可配置
- 用户主动关闭提示后一段时间内不能再提示
请求流程
- 用户进入特定页面后,客户端判断若用户未打开推送权限,并且满足某些特定业务需求,比如在商品页面,若用户主动收藏过该商品,则认为需要给用户开权提示。
- 此时客户端调用服务端接口来获取开权提示的文案。
- 服务端接收到请求,会先进行防疲劳判断,比如本周该用户看到过开权提示的次数超过7次了,那就不再提示。当然不同页面防疲劳规则也不同,对于消息页面,业务就需要开权提示一直展示。
获取提示文案流程
- 客户端获取开权提示后,会在业务页面展示出来,并且回调服务端,告诉服务端该用户在某个页面成功展示过一次提示。
- 用户看到提示后,可以选择去打开推送权限,也可以忽略甚至关闭掉开权提示。这时候客户端需要回调给服务端,记录用户主动关闭一次提示。用户关闭超过3次,则半年内不再提示。当然不同的业务具体规则会有变化,且要支持可配置。
提示回调流程
服务端设计
只考虑单一页面的提示,那么方案很简单,流程如下
伪代码如下
public String getTip(String page){
获取配置();
if(单页面防疲劳()){
return "";
}
获取提示文案();
修改单页面防疲劳数据();
修改全局防疲劳数据();
}
如果需求变化不大,只有一个页面需要开权提示,那么以上方案完全可以满足。但是对于app开权提醒绝不可能只在一个页面提示,且不同页面的提示文案,频控方案都不相同,在这种需求背景下,以上的方案有哪些弊端呢?
- 代码耦合度高,易腐化
比如新增一个页面订单页面,他不需要全局防疲劳,怎么改呢?需要在主流程中增加订单页面相关的判断条件,伪代码如下。新增页面越来越多的情况下,代码会在一两个迭代周期内迅速腐化,连开发者自己都看不明白逻辑。
代码腐化的主要原因
缺乏设计。缺少必要的封装和抽象,代码逻辑完全是从业务逻辑直接翻译过来的,也就是直译型代码。这类代码直观直接,但是难以应对业务变化,一写出来就已经腐化了。
public String getTip(String page){
获取配置();
if(单页面防疲劳()){
return "";
}
if(!订单页面()){
if(全局防疲劳()){
return "";
}
}
获取提示文案();
修改单页面防疲劳数据();
if(!订单页面()){
修改全局防疲劳数据();
}
}
- 测试成本高
还是以新增页面来看,对于测试来说,他不仅需要测试新页面的功能,而且还必须要回归老的消息页面的功能,因为新页面的改动影响了原有的业务流程。
我们都知道面向对象的开闭原则,对扩展开放,对修改关闭。对扩展开放好理解,因为需求一直在变,我们的代码必须能够灵活扩展以适应变化。对修改封闭,我们的扩展尽量不对现有的代码改动太大。为什么?因为修改意味着成本上升。成本不仅仅是代码实现,维护的成本,还包括测试的成本。
- 不易扩展,难以应对变化
考虑以下几个变化,a. 业务需要临时关闭某个页面的提示,而客户端又来不及发版, b. 调整某个页面的防疲劳次数,且不影响其他页面现有的防疲劳,c. 某个页面需要对防疲劳规则做ab实验,如何修改代码影响效率最高,影响最小?。对于目前的方案来说,这类需求都需要对主流程进行改动,必然可能会影响其他页面的功能,从而导致线上功能不稳定。
解决方案
上述几个问题相信大家都耳熟能详,根本原因还是代码设计过程中未充分解耦,随着迭代推进最终导致代码腐化,难以维护。下面分享一个比较有效的解耦设计:横向业务隔离,纵向流程编排。
场景隔离
我们用一个抽象的模型来描述这类需求。如下图,有A,B,C...等多个场景。每个场景都有step1, step2, step3,step4...等等多个步骤,不同场景可能有不同的步骤,同一个步骤在不同场景的实现可能也有细微差别。在未解耦前,我们的代码结构如下图所示,各个场景的代码通过判断语句耦合在一起。
伪代码如下
public void execute(String scene){
if(!scene.equals("C")){
step1();
}
if(scene.equals("A")){
step2();
}
if(scene.equals("A")){
//判断语句耦合不同场景的逻辑
step3();
}else if(scene.equals("B")){
step3_EXT_B();
}else if(scene.equals("C")){
step3_EXT_C();
}
}
解耦后的效果如下,各场景在逻辑上是隔离的,每个场景有自己的业务流程。
这样解耦的好处如下
- 各个场景的业务流程在逻辑上是相互隔离的,不会因为修改某个场景逻辑导致所有其他场景的代码都受影响。
- 各场景之间的步骤可以抽象为action实例,相同action可以实现快速复用。
- 快速支持新场景,新场景只要提供新的场景流程实现即可,对已有场景无影响,且测试时无需回归全部场景,极大降低测试成本。
想法很好,那如何落地呢?答案是手搓流程引擎!
流程引擎
这里的流程引擎不是Activiti, JBPM这类重量级流程编排工具,相对我们的需求来说,使用这类工具有点大材小用,而且有过度设计的嫌疑,反而会大大增加开发成本。
代码腐化的另外两个原因
- 过度设计。多余的设计不仅不产生业务价值,而且无端提升理解维护成本,尤其需求之外的功能代码本身就是已腐化的死代码,针对这类代码要尽早做减法,应删尽删。
- 设计弃用。软件开发经常会碰到有些同学拿着电锯当菜刀使。比如已经引入ORM框架,他还是要手写SQL;比如有了AOP,他还是要到处嵌入重复代码。这类做法也会加剧代码腐化。
根据需求,我们只需要实现一个轻量级的流程编排工具,帮我们实现如下两点能力:
- 在横向上,通过不同的流程上下文装配各自的步骤节点action,把不同场景的逻辑隔离开来。
- 在纵向上,通过在流程上下文中的节点处理器handler,执行各流程的业务步骤。
- 提供上下文工厂类,根据场景code来提供不同场景的上下文实例。
整体结构如下
-
流程装配
新增场景时,提供场景上下文生成器实现类,在generate方法中装配流程步骤Action类实例。
@Service
public class AGenerator<P, R> implements IContextGenerator<P, R> {
private final ProcessAwareContext processAwareContext;
public AGenerator(ProcessAwareContext processAwareContext) {
this.processAwareContext = processAwareContext;
}
@Override
public boolean check(P paramDTO) {
return false;
}
@Override
public ProcessContext<P, R> generate(P para) throws Exception {
ProcessContext<P, R> context = new ProcessContext<>(para);
context.addAction(processAwareContext.getBean(Step1Action.class));
context.addAction(processAwareContext.getBean(Step2Action.class));
context.addAction(processAwareContext.getBean(Step3Action.class));
return context;
}
}
场景流程的各个步骤用Action类来封装。通过步骤的封装,实现业务步骤在不同场景流程中的复用。Action类型又可以分为以下几类:
- 网关节点:控制流程是否继续执行的节点,比如判断用户开权提示已经被频控拦截了,那么可以快速结束流程
- 值节点:修改流程返回值的节点,即对流程返回值有影响的节点。
- 空节点:对流程走向和返回值都无影响的节点,比如缓存数据,写数据库的节点。
上下文装配流程步骤时,根据步骤实例Action的类型,提供相应的执行器,在引擎执行时调用处理器来处理Action对应的业务操作。
每个节点类型都有对应的处理器。
- 网关处理器:执行网关节点。网关节点的执行结果是一个布尔值,用于判断该流程是否结束,如果结束,则停止后续节点执行。目前的网关处理器设计比较轻量,后续业务有需要,可以支持复杂的网关设计,比如根据网关节点的返回值控制流程的执行路径等能力。
- 值节点处理器:执行值节点。值节点的执行结果会被设置到上下文对象的result字段,从而设置整个流程的返回值。
- 空节点处理器:执行空节点。这些节点不影响节点执行,也不影响流程结果,主要用于修改缓存,数据库等数据操作。
-
流程执行
执行场景流程时,从流程上下文工厂类获取流程上下文实例,提交给流程引擎执行。
@Override
public R execute(P paramDTO) {
if (!validParam(paramDTO)) {
return null;
}
try {
ProcessContext<P, R> context = processFactory.get(paramDTO);
ProcessEngine.execute(context);
return context.getResult();
} catch (Exception ex) {
log.error("processExecuteFailed", ex);
}
return null;
}
工厂类只有一个get方法,根据场景参数,获取场景对应的上下文生成器,由生成器动态装配出该场景对应的上下文对象。
@Service
public class ProcessFactory<P, R> {
List<IContextGenerator> generators;
public ProcessContext<P, R> get(P p){
try {
IContextGenerator generator = generators.stream().filter(ig -> ig.check(p)).findFirst().get();
if (generator != null) {
return generator.generate(p);
}
}catch (Exception ex){
}
return null;
}
}
引擎执行的逻辑很简单,从上下文对象中获取流程中的各个Action对象的处理器,依次执行处理器的handle方法,从而完成流程步骤的执行。
public static void execute(ProcessContext context) {
if (Collections.isEmpty(context.getHandlers())) {
return;
}
context.getHandlers().forEach(handler -> {
if (!context.isDone()) {//快速结束
((BaseHandler) handler).handle(context);
}
});
}
收益与展望
推送开权引导能力一期支持3个场景,对开权率有明显的提升效果,因此迅速吸引数十个不同场景接入。大多数场景的业务流程都是一样的,可以通过复用既有的流程上下文生成器,生成各自的流程实例来处理。对于有特殊防疲劳逻辑的场景,可以通过拼装各自的流程上下文来实现。比如
【消息中心】场景不需要全局防疲劳,亦即【消息中心】的展示次数不受全局其他场景展示次数影响,
【我的购买页】需要针对不同的ab实验支持不同的防疲劳逻辑
这些场景可以通过提供各自的上下文生成器的实现类来实现不同业务流程的编排,且新的流程不影响既有流程的执行逻辑。如此我们便实现了面向对象编程的开闭原则,既对新增的流程扩展开放,对已有的流程改变关闭,充分满足了目前多个业务场景的开权提示需求。
当然这个方案远非完美,比如新增业务流程时,我们依然需要修改代码,通过代码去编排业务流程,是否可以通过修改配置流实现流程编排,甚至通过在画布上拖拽节点来实现流程编排等等。这些就涉及到通用流程编排引擎的范畴了,有兴趣的同学可以参考activiti, JBPM这些通用流程引擎的实现,此处不做赘述。
总结
以上介绍了APP消息推送开权提示的背景和实现逻辑,并说明了服务端如何通过流程引擎实现对业务场景的隔离,从而达到降低维护、测试成本,抑制代码腐化的目的。事实上,业务场景隔离本身并不复杂,方法也很多,除了本文介绍的流程隔离外,还可以借助接口隔离,依赖包隔离,甚至微服务隔离等多种形式。核心问题在于如何预知业务的潜在变化,提前合理设计,而不要等变化发生后才去重构,事后重构往往意味着不重构。
限于水平,文尽于此,欢迎大家批评指正。