舌尖上的状态机

9

题记

真的猛士,敢于不做设计,直接开始编码——面对业务系统中最复杂的部分:状态模型,有多少程序员就有多少种实现。聊聊状态模型设计上常遇到的问题和解决的思路吧。

正文

有时候我们想做一个富含业务行为,而又足够通用的技术架构时,刚开始都是信心满满,采用各种设计方法,充分考虑未来的需求,画出系统依赖、数据模型甚至核心类图,上线时各种性能爆表或者扩展轻松;上线半年之后画风一转,代码堆得到处都是,哪怕再小心维护依然无法逃离“一年一重构”的魔咒,两年过后连测试同学的TC都写不出来,发生了什么?

写到这里又到晚上了,不凑巧零食都被清理干净,饿得天昏地暗(⊙o⊙)…就设想一下这样一个场景吧(下面的讨论只做场景讨论,并非真实业务系统的设计,请专业的同行们不要见怪。

接到一个炒菜机器人的项目,要求能够按照吃货的设计做出各种菜式

一分钟速成方案

大家应该比较熟悉需求或者领域驱动的套路吧,抄起自上而下设计的锤子开始敲钉子:首先,我们的平台中会有

  • 厨具:各种厨具的基本使用接口和参数规范

  • 菜谱:操作指导

先在脑海中预演一下这样的设计是怎么运作的:

首先系统应该能够认识各种不同的厨具,并且知道如何操作它们
案板:切菜程序
炒锅:翻炒程序、油炸程序
炖锅:水煮程序、焯水程序
搅拌机:搅拌程序
……
以及它们的清洗程序没有一一列出

接下来是菜谱,就先来个番茄炒蛋吧

  1. 打蛋流程:使用搅拌机,调整参数使其能够打出均匀的蛋液

  2. 番茄流程:使用案板,调整参数使其能够切出合适的番茄块

  3. 炒锅流程:使用炒锅,先放油,烧热,放蛋,翻炒,放番茄,翻炒,加盐

  4. 出锅流程:使用盘子,出锅

图片描述

毕竟我们花了1分钟设计出来的厨具+菜谱架构,看看再做几个需求会变成什么样子,需求方要求在能做番茄炒蛋的基础上,做个辣椒丝炒蛋:

  1. 打蛋流程:使用搅拌机,调整参数使其能够打出均匀的蛋液

  2. 辣椒流程:使用案板,调整参数使其能够切出合适的辣椒丝

  3. 炒锅流程:使用炒锅,先放油,烧热,放蛋,翻炒,放辣椒,翻炒,加盐

  4. 出锅流程:使用盘子,出锅

图片描述

做到这里,一些同学指出,多数鸡蛋搭配的菜谱都是拥有四个标准流程节点:打蛋、切菜、翻炒、装盘,而在切菜环节中,我们只需要调整参数类型和数值,就可以搭配出“*炒鸡蛋”的菜色,至于翻炒环节相对麻烦一些,需要加入很多细粒度的操作才能做出适用于业务发展的扩展性来;接下来的工作重点,要放在翻炒流程的设计上,开放出尽可能多的SPI,让第三方在我们这个平台上共同实现翻炒市场。

系统上线半年,出现了各种业务分支:不仅原先官方提供的的番茄炒蛋和辣椒炒蛋获得很好的市场反馈,微调参数就轻松支持了苦瓜炒蛋、木耳炒蛋甚至榴莲炒蛋;业务方出现了:我们要开辟汤类市场,先从番茄蛋汤开始吧:

  1. 打蛋流程:使用搅拌机,调整参数使其能够打出均匀的蛋液

  2. 切菜流程:使用案板,调整参数切出番茄丁和香葱段

  3. 汤锅流程:使用汤锅,加水,烧热,放蛋,加热,放番茄,放葱段,加盐

  4. 出锅流程:使用汤碗,出锅

针对原来设计的四套流程,在切菜流程中加入了类似炒锅流程的多操作支持,接下来又实现了一套全新的汤锅流程,出锅也做了些定制。

脑补一下接下来的红烧肘子、鱼香肉丝、清炖羊肉、回锅肉该怎么实现吧(唉,快饿死了,话说好多程序员做饭都是好手,是真的吧?)

图片描述
图片描述
图片描述
图片描述

回过头看看之前的实现,核心流程节点不一定只有4个,每个流程节点下面的子节点可能有多个,如果现在要针对业务方提出的这些荤菜做个重构,该怎么做?

将菜谱系统做成一个多维数组,就像这个样子:
菜谱ID[子流程ID],然后分别实现这些流程节点并将它们存储在这个菜谱表格中

看起来应该比较完善了吧,可程序员的第六感还是隐隐约约觉得有哪里不对劲,譬如,在番茄炒蛋和辣椒丝炒蛋这两个大体相同的流程中,“翻炒”、“加盐”这两个节点真的是可复用的吗?

没错,现实架构中往往没这么简单,因为

番茄炒蛋的汤多,盐可以在最后加,也可以在打蛋的时候加,而辣椒炒蛋没什么汤,盐要在打蛋的时候加进去

好吧,在不影响流程系统的情况下,我们硬着头皮在打蛋和炒锅流程节点上加了个IF判断(is 番茄炒蛋),然后就……中招了。

图片描述

从逻辑来讲,这一个小小的IF,将我们原先设计的三维数组变了个味,把流程图画出来可能是这样的:

图片描述

IF...ELSE分支就像小说里边的二向箔,看起来像是没有改变原有系统的逻辑,但可是可但是它可是混杂在源码中而不是存在于配置中的逻辑,慢慢的,这种随意的维护和简单实现,开始模糊系统中的主子流程的边界,接着模糊菜谱和主流程的边界,将一个有层次的设计一步一步的煮成一锅皮蛋瘦肉东北乱炖粥。写代码的时候很爽,做维护的时候骂娘

这是一个小小的开始,我们可以抱着取舍的心态说:我们可以通过编码规范的方式要求开发人员在涉及主流程节点的逻辑上不允许使用IF分支来保护架构,只有细枝末节的流程可以使用不规范的编码方式。

可现实往往没有那么简单,流程节点之间也不是完全没有上下文依赖的情况,绝大多数采用状态机架构的系统是不会用多维数组划分状态的(不信你可以去review代码),通过逻辑分支搭建的桥梁,整个系统变成一个巨大的状态机,那么一个高维的状态机系统投影到单维的系统中会发生什么?(很多视频网站上有个很好的教学系列《Dimensions》,有一部分内容关于如何通过球极投影理解四维空间)状态机爆炸了,囧

这种实现的问题还不止于此,由于细粒度的流程是建立在厨具的维度上,而每种厨具对付不同的食材时,还是需要做很多定制化的工作,譬如:

  • 打蛋器/打蛋碗是否能够加盐

  • 锅里倒油/倒水/倒酱油

  • 菜板切圈/切片/切丝

就拿菜板举例,胡萝卜切片的手法,和包心菜切片的手法必然是不同的;习惯上对待这种问题的解决手段通常有两种,一种是把切片的代码放在菜板上实现,另一种是将复杂性下沉到各种食材上分别实现

如果在菜板上实现,我们就将获得一个能够加工天下食材的“超级菜板”,要么是个上帝类,要么是个错综复杂的巨型Service;

如果在食材上实现,为了能让菜板接受各种不同类型食材作为输入参数,我们很可能会在各种食材的上层抽象一个BaseEdible的基类好传递参数,然后要么在菜板上做switch逻辑,要么在BaseEdible中提供各种加工方法的实现,譬如切片/切丝,但粉条或者大米怎么切片?它和胡萝卜除了都能吃以外还有什么共性?

也许有细心的同学开始考虑在食材上用Command方式来实现行为,这可能也是一种很纠结的做法,Debug成本暂且不说,一个几乎可以发送所有命令的菜板加上一堆看起来什么命令都能接受的食材实现,怎么保证系统不会让菜板去把面粉切个丝,也要做不少工作。

一个看上去很美好的设计,在实施的过程中很可能成为下面三者兼备的糟糕实现

  • 状态机爆炸

  • 上帝类

  • 过度继承/无用代码

这时候,比程序员先疯掉的,大概是听到程序员说“我做了个小改动,你们回归一下”这句话的测试同学吧?

图片描述

换个姿势

怎样才能让这个系统像亲爱的母上大人一样,什么菜都会做呢?

回顾前面的设计,锅碗瓢盆作为容器,它们本身其实没有发生过任何变化,只是在盛有不同的食材时,样子看起来有些不同。按照加工的流程设计状态机踩了坑,按照容器状态设计状态机子节点也踩了坑,那么我们是不是一开始的出发点就跑偏了?

我们不妨换个角度来思考,《舌尖上的中国》教育我们:食材很重要,那么是不是可以从食材入手来设计这个系统?

刚才的设计都是以厨师的工作状态为出发点做的:我们手上有各种工具,可以通过各种手段来加工食材。但考虑“做菜”这件事情本身,输入的是食材,输出的也是食材,真正发生状态转换的元素是工具吗?显然不是,鸡蛋到蛋液到炒蛋这个过程中,锅碗瓢盆没有任何变化,从食材的角度入手,也是一个好玩的尝试。

鸡蛋蛋有很多加工方法,煮蛋,煎蛋,荷包蛋,蛋液
辣椒也有很多加工方法,辣椒丝,辣椒圈,辣椒片

根据每一种食材进行抽象,会有一个很好的附带效果:每个状态之间的变迁不可逆而且转移条件没有多态行为

  • 碗 -> 洗干净 -> 干净的碗

  • 鸡蛋 -> 敲开,放入干净的碗并用筷子搅动 -> 蛋液

  • 鸡蛋 -> 敲开,放入干净的碗并用筷子搅动,加盐,继续搅动 -> 咸蛋液

  • 锅 -> 洗干净 -> 干净的锅

  • 干净的锅 -> 放油,加热 -> 热油锅

  • 蛋液 -> 放入热油锅,翻炒 -> 炒鸡蛋

  • 番茄 -> 切碎 -> 碎番茄

  • 辣椒 -> 切丝 -> 辣椒丝

  • 辣椒 -> 切圈 -> 辣椒圈

荷包蛋很难变回生鸡蛋的样子,对吧?话说还真有人能做到,不过即使能变回来也不影响整体的设计

这样就有了材料的状态机和转移函数,之前的架构中,加工工具变成了Services或者Utils,锅具仍然维持细粒度状态机,但不再是接受各种食材的上帝组件,我们可以在这样细粒度的状态机模型上进行很细致的加工

在不同菜色的加工过程中,这些细粒度的状态机节点和转移函数其实都是可以完整复用的,列一个番茄炒蛋的上游流程图,是不是变得清晰一些?

剩下的部分就不剧透了,设计的乐趣不就在这里吗?真的饿到全身无力扯不动蛋啦!有机会大家直接讨论下,因为类似的问题在交易、物流、工单系统中都有大量的实例,偶尔换个思路,收获没准不小滴。

炒蛋·交易核心实战

在进入这个章节之前,我们再回顾一下,大家是否已经通过脑补,消灭了炒蛋系统中的那些问题设计?

  • 上帝类

  • 过度继承

  • 非原子的状态机转移函数

  • 状态机爆炸

如果大体上没什么问题的话,咱们继续向交易核心系统的设计上折腾起来!

图片描述

发现了吗,炒蛋和交易核心系统的架构也有许多相似之处,复杂业务系统编码中,最困难的工作是:知道在什么情况下做什么事;我们从炒蛋的思路入手,以两个交易场景看看能不能跑通

按照前文的套路,交易系统的设计同样也可以例举出两种典型的设计路径:
1、按照资金流、信息流、物流等人们直接感知到的交易元素,自上而下的设计
2、按照商品、资金、优惠券、红包等交易物料,自下而上的设计

很多同学对于前者的思路应该比较熟悉了,我们在这里重点看一看怎样以后者的路径进行交易核心的设计,是否能够找到一种可以适用于更多业务场景、健壮的交易架构

首先需要明确,交易的本质是什么:多个参与者按照约定,进行财物的转移或在参与者之间发生服务行为

接下来就是如何在系统中体现这些转移或者服务行为了,会计记账法,在资产核算、资金审计等领域都有广泛的应用,在我们的交易系统设计中,参照会计手段,对每个交易元素的状态进行建模(主要体现在数据库Schema上,本文篇幅所限就不展开介绍会计记账在订单存储上的应用了,以后有机会再叙)

单独抽出状态机来看,用会计术语描述,可以把财物转移或服务行为抽象成一个简单的流程

  1. [A]签订

  2. [D]借方已履行

  3. [C]贷方已确认

对于任何一种交易元素,无论是否采用了担保交易或第三方介入服务的情况(如对于商品而言,从卖家发货,经过快递,到达买家的过程中:发货及物流进行中的状态为D,买家确认收货为C)当然,完全可以引入更多复杂的细粒度状态机。为了叙事简便,在后面的表格中,我们都用A、D、C三种状态来描述交易元素的转移状态。

在一个典型的一口价交易流程中,交易流程如下(默认全部都是担保交易)

  1. 下单

  2. 买家已付款

  3. 卖家已发货

  4. 买家确认收货

  5. 交易成功

在最朴素的一口价交易流程中,就是买家卖家一手交钱一手交货的过程,交易元素只有两个

  1. 资金

  2. 商品

那么,在交易过程中,这两种交易元素的状态是如何变更的?

类型 下单 付款 发货 收货 成功
资金 A D D D C
商品 A A D C C

表格中,不难看出交易的各个环节中需要进行的操作

  1. 下单(钱A->D,引导买家付款)

  2. 买家已付款(货A->D,引导卖家发货)

  3. 卖家已发货(货D->C,引导买家确认收货)

  4. 买家确认收货(钱D->C,系统打款给卖家)

  5. 交易成功

就这样,从最细粒度的状态机入手,我们获得了一个能够直接明确表示每个State和Transition的设计原型

下面我们再找一个更复杂一点的场景入手

某烘焙供应商接入在线交易,由其分销商以代理的方式引导用户选购,用户在分销商页面在线选购商品后,付定金,均分给分销商及供应商,等店铺备货完成之后,通知买家付尾款给供应商,然后供应商发货,买家收货,完成交易

首先列出交易流程列表

  1. 下单

  2. 买家付定金,供应商与分销商均分

  3. 供应商完成备货

  4. 买家付尾款给供应商

  5. 供应商发货

  6. 买家确认收货

  7. 交易成功

再列出交易元素

  1. 分销商佣金

  2. 供应商定金

  3. 供应商尾款

  4. 供应商备货

  5. 供应商商品

列出交易流程状态表格

类型 下单 定金 备货 尾款 发货 收货 成功
佣金 A D D D D D C
定金 A D D D D D C
备货 A A C C C C C
尾款 A A A D D D C
商品 A A A A A D C

当然也可以简化一下

类型 下单 定金 备货 尾款 发货 收货 成功
佣金 A D - - - - C
定金 A D - - - - C
备货 A - DC - - - -
尾款 A - - D - - C
商品 A - - - - D C

如果需要支持使用优惠券的交易呢?再加一行就行

类型 下单 定金 备货 尾款 发货 收货 成功
券(付款减) A D - - - - C
券(下单减) D - - - - - C

用这样的细粒度表格,很轻松就可以获得每个State(下单、付定金),以及State之间的Transitions,表格中,不同的列表示交易环节,而不同的行,则表示不同的交易元素

至于逆向流程的支持,其实也很简单,因为表格中已经清晰的描述了每种交易元素的状态,将交易元素的发起人和接收人互换,进一步区分交易元素

需要区分的交易元素主要有

  • 平台中转或担保类元素(如现金、红包、优惠券)

  • 不可退换元素(优惠券、充值卡)

  • 实物(通常所说)

  • 服务

按照交易元素的逆向特征来设计对应的逆向元素生成策略,就可以不用考虑太多细节,简便的支持逆向流程

至此,我们获得了一个通过细粒度状态机表示的交易核心模块,大家也可以再用其他的交易场景试着套用一下,看看有没有比较好或者不适合的场景

在线上应用的设计上,还要进一步考虑一些其他的工程因素

  • 如何进行状态机编码

  • 如何借助TCC实现最终事务一致性

  • 合约化的交易数据库Schema设计

这些内容我们在以后的篇幅中慢慢探讨吧 :)

小结

在很多复杂业务系统的设计中,往往因为建模角度的选取造成后续维护中的困难

在状态机/流程引擎的设计上,建议考虑

  • 节点之间的转移函数是否多态?

  • 节点本身是否多态?

  • 节点是否清晰的映射了需求场景中那些真正发生改变的对象?

  • 新增流程是否需要修改原有代码?

在类层次设计上,建议考虑

  • 是否存在无用代码?

  • 是否存在上帝类?

如果存在这些情况,就像蛋糕上的霉斑,看起来只有一星半点,但你敢吃长霉的食物吗

附录

开闭原则

还记得“开闭原则”吗,就一句话:系统(或者理解为系统中的类、模块、函数)对于“扩展”应该开放,而对于“修改”应该是封闭的。

这里首先需要界定“扩展”的含义:在不改动原有系统代码的情况下,新增一个类算不算?新增一个方法呢?如果将它们套用“修改”的语义,对于模块而言新增一个类是修改,对于类而言新增方法也是修改。开闭原则的边界似乎没有那么清晰。

我们可以用一个更简单的思路来界定开闭原则:是否违背了原有的设计初衷

继承的问题

《重构》书中花了很大的篇幅向读者介绍“代码的坏味道”,有一个“Unnecessary Code”的说法,大致的意思是继承体系的基类中存在下游子类不需要的行为,或者必须被纠正的情况,工作个三五年的朋友们都或多或少的遇到过某个类的所有子类都在复写基类方法的情况吧。

在不少实用主义的架构文章中,都提过“使用组合来代替继承”的观点,其中流传最广的一个段子(抱歉我也不知道是不是真事)是:James Gosling 的某次演讲会后Q&A环节中,有人问他,如果重新设计Java语言,你会做什么?JG回答说,我会干掉“类”,倒不是因为“类”本身有问题,而是会用实现(implements)来取代继承(extends)

就平时项目的经历而言,基类,尤其是业务系统中的各种基类,是非常难设计的。因为很难在业务刚开始的时候就预想到它最终(结束维护下线时)的样子,也常常因为这样,我们看到的大多数Base*命名的类时,除了限定参数类型,它的代码行为和Object基本无异(譬如BaseCommand,BaseItem,BaseAction我去太多了),让后续维护的同事边骂边写代码。

上帝类

又是来自《重构》的点子:“One Class to rule them all, and in the darkness bind them.”,听起来有点像《魔戒》的台词哈,这个理解起来就不像为什么避免继承那么纠结了,毕竟谁都不愿意维护一段三五千行而且看起来什么事情都能做的代码吧?哦,有人用一个汇编文件写了个操作系统,咱们的平台看起来最多需要两三个类就够了。

AfternoonLeaf · 2016年06月13日

用工作流把流转剥离出来,极大减少复杂性

回复

李大伟_大不发音 · 2016年06月22日

可否展开讲一讲

回复

载入中...