重构:改善饿了么交易系统的设计思路

我在2017年5月加入饿了么的交易部门,前后负责搜索、订单、超时、赔付、条约、交付、金额计算以及评价等系统,后期开始作些总体系统升级的工做。
这篇文章成型于交易系统重构一期以后,主要是反思其过程当中作决策的思路,我没有使用「架构」这个词语,是由于它给人的感觉充满权利和神秘感,谈论「架构」让人有一种正在进行责任重大的决策或者深度技术分析的感受。前端


如毕玄在系统设计的套路这篇文章里所提:程序员

回顾了下本身作过的几个系统的设计,发现如今本身在作系统设计的时候确实是会按照一个套路去作,这个套路就是:系统设计的目的->系统设计的目标->围绕目标的核心设计->围绕核心设计造成的设计原则->各子系统,模块的详细设计

在进行系统设计时,摸清楚目的,并造成可衡量的目标是第一步。数据库

"Soft" ware

Software拆开来分别是soft ware,即灵活的产品 -- 鲍勃大叔

重构前的交易系统初版的代码能够追溯到8年前,这期间也经历过拆解重构,17年我来到时,主要系统是这样:编程

系统名称 主要功能
Bos c端订单管理:用户详情、列表
Nevermore b端订单管理:商户接单等
Booking 购物车、下单
Eos 订单中心
Loki 订单取消、退
Blink 订单配送履约、商户订单中心

这套系统驮着业务从百万级订单跑到了千万级订单,从压测表现来看,它能够再支撑业务多翻几倍的量,也就是说若是没有啥变化,它能够继续稳定运行着,但若是发生点变化呢,答案可能就不这么确定了。设计模式

在我入职的这两年里,系统承载的业务迭增变化:从单一的餐饮外卖到与新零售及品牌餐饮三方并行,又从到家模式衍生至到店,随之而来的是业务持续不断的差别化定制,还有并行上线的要求。另外一面,随着公司组织架构变化,有的项目须要三地协同推动才能完成,沟通协做成本翻倍提高。几方面结合起来,致使开发没有精力对大部分系统的演进都进行完善的规划。架构

几个月前,业务提了一个简单的需求:对交易的评价作自动审核并进行相应的处罚。当时评价核心“域模型”是这样的:框架

设计自身的优劣这里暂不进行讨论,只是举例说明为了知足这个诉求,会涉及多个评价子模块要改动,开发评估下来的工做量远远超出了预期,业务方对此不满意,相似的冲突在其余系统里也常常出现。但实际上,团队里没人偷懒,和以前同样努力工做,只是无论投入了多少我的时间,救了多少次火,加了多少次班,产出始终上不去,由于开发大部分时间都在系统的修修补补上,而不是真正完成实际的新功能,一直在拆东墙补西墙,周而往复。编程语言

为何会致使这样的结果,我想应该是由于大部分系统已经演变到很难响应需求的变动了,业务认为的小小变动,对开发来讲都是系统的一次大手术,但系统本不该该往这个方向发展的,它和hardware有着巨大的区别就在于:变动对软件来讲应该是简单灵活的。ide

因此咱们思考设计的核心目标:**“采用好的软件架构来节省项目构建和维护的人力成本,让每一次变动都短小简单,易于实施,而且避免缺陷,用最小的成本,最大程度地知足功能性和灵活性的要求”。函数

Source code is the design

提到软件设计,你们脑壳里可能会想到一幅幅结构清晰的架构图,认为关于软件架构的全部奥秘都隐藏在图里了,但经历过一些项目后发现,这每每是不够的。Jack Reeves在1992年发表了一篇论文《源代码即设计》,他在文中提出一个观点:

高层结构的设计不是完整的软件设计,它只是细节设计的一个结构框架。在严格地验证高层设计方面,咱们的能力是很是有限的。详细设计最终会对高层设计形成的影响至少和其余的因素同样多(或者应该容许这种影响)。对设计的各个方面进行改进,是一个应该贯穿整个设计周期的过程

在踩过一些坑以后,这种强调详细设计重要性的观点在我看来很实在接地气,简单来讲:“自顶向下的设计一般是不靠谱的,编码便是设计过程的一部分”,我的认为:系统设计应该是从下到上,随着抽象层次的提高,不断演化而获得良好的高层设计。

编程范式

从下向上,那就应该从编码开始审视,饿了么交易系统最开始是由Python编写,Python足够灵活,能够很是快速的产出mvp的系统版本,这也和当时的公司发展状态相关: 产品迭代迅速,新项目的压力很大。

最近此次重构,顺应集团趋势,咱们使用Java来进行编写,不过在这以前有一个小插曲:17年末,由于预估到当前系统框架在单量到达下一个量级时会遇到瓶颈,因此针对一些新业务逐渐开始使用Go语言编写,但在这个过程里,常常会听到一些言论:用go来写业务不舒服。为何会不舒服?大体是由于没有框架,没有泛型,没有try catch,确实,在解决业务问题的这个大的上下文中,go语言不是最优的选择,但语法简单,能够极大程度的避免普通程序员出错的几率。

那么Python呢,任何事物都有双刃剑,虽然Python具备强表达力,可是灵活性也把不少人惯坏了,代码写的糙,动态语言写太多坑也多,容易出错,在大项目上的工程管理和维护上有必定劣势,因此rails做者提到:“灵活性被过度高估——约束才是解放”也有必定道理

为避免引发语言战,这里不过多讨论,只是想引出:我从C++写到Go,又从Python写到Java,在这个过程里体会到--编程范式也许是学习任何一门编程语言时要理解的最重要的术语,简单来讲它是程序员看待程序应该具备的观点,但却容易被忽视。交易老系统的代码,无论是针对什么业务逻辑,几乎都是OPP一杆到底,相似的代码在系统里随处可见。

咱们好像彻底遗忘了OOP,这项古老的技艺被淡化了,我这里不是说必定要OOP就是完美的,准确来讲我是“面向问题”范式的拥趸者,好比,Java从骨子里就是要OOP,可是业务流程不必定须要OOP。一些交易业务就是第一步怎么样,第二步怎么样,采起OPP的范式就是好的解法。这时,弄很复杂的类设计有时并没必要要,反而还会带来麻烦

此外,同一个问题还能够拆解为不一样的层次,不一样的层次可使用各自适合的方式。好比高层的能够OOP,具体到某个执行逻辑里能够用FP,好比:针对订单的金额计算,咱们用Go写了一版FP的底层计算服务,性能高、语法简单以及出错少等是语言附带的优势,核心仍是由于该类问题自身适合。

然而,当面向整个交易领域时,针对繁复多样的业务场景,合理运用OOP的设计思想已经被证实确实能够支撑起复杂庞大的软件设计,因此咱们做出第一个决策:采用以OOP为主的“混合”范式。

原则和模式

The difference between a bad programmer and a good one is whether he considers his code or his
data structures more important. Bad programmers worry about the code. Good programmers worry about data structures and their relationships. -- Linus Torvalds

无论是采用哪一种编程范式、编程语言,构造出来的基础模块就像盖楼的砖头,若是砖头质量很差,最终大楼也不会牢固,引用里的一大段话,relationships才是我最想强调的:我理解它是指类之间的交互关系,“关系”的好坏一般等价于软件设计的优劣,设计很差的软件结构大都有些共同特征:

  • 僵化性:难以对软件进行改动,通常会引起连锁改动,好比下单时增长一个新的营销类型,订单中心和相关上下游都要感知到并去作改动
  • 脆弱性:简单的改动会引起其余意想不到的问题,甚至概念彻底不相关
  • 牢固性:设计中有对其余系统有用的部分,可是拆出来的风险和成本很高,好比订单中心针对外卖场景的支付能力并不能支持会员卡等虚拟商品的支付需求
  • 没必要要的复杂性:这个一般是指过分设计
  • 晦涩性:随时间演化,模块难以理解,代码愈来愈难读懂,好比购物车阶段的核心代码已经长成了一个近千行的大函数
  • ...

采起合适的范式后,咱们须要向上抽一个层次,来关注代码之上的逻辑,多年软件工程的发展沉淀下来了一些基本原则和模式,并被证实能够指导咱们如何把数据和函数封装起来,而后再把它们组织起来成为程序。

SOLID

有人将这些原则从新排列下顺序,将首字母组成SOLID,分别是:SRP、OCP、LSP、ISP、DIP。这里针对其中几个原则来举些例子。

SRP(单一职责):这个原则很简单,即任何一个软件模块都应该只对一类用户负责,因此代码和数据应该由于和某一类用户关系紧密而被组织到一块儿。实际上咱们大部分的工做就是在发现职责,而后拆开他们。

我认为该原则的核心在于用户的定义,18年去听Qcon时,听到俞军的分享,其中一段正好能够拿来诠释什么是用户,俞军说:“用户不是人,是需求的集合”。在咱们重构的过程当中,曾经对交易系统里的交付环节有过争论,目前饿了么支持商家自配和平台托管以及选择配送(好比跑腿),这几类配送的算价方式,配送逻辑,和使用场景都不同,因此咱们基于此作了拆解,一开始你们都认同这种分解方式。

但后来商户群体调整了,新零售商户和餐饮商户进行分拆,对应着业务方的运营方式也开始出现差别,致使在每一个配送方式下也有了不一样诉求,伴随这些变化,最后咱们选择作了第二次拆解。

对于单一职责,这里有个小tips:你们若是实在很差分析的话,能够多观察那些由于分支合并而产生冲突的代码,由于这极可能是由于针对不一样需求,你们同时改了同一个模块

DIP(依赖倒置):有人说依赖反转是OOP和OPP的分水岭,由于在过程化设计里所建立的依赖关系,策略是依赖于细节的--也就是高层依赖于底层,但这一般会让策略由于细节改变而受到影响,举个例子:在外卖场景下,一旦用户由于某些缘由收不到餐了,商户会赔代金券安抚用户,此时OPP能够这样作:

而过一阵子,由于代金券一般不能跨店使用,平台想让用户继续复购,就想经过赔付通用红包来挽留,这个时候就须要改动老的代码,经过增长对红包赔付逻辑的依赖,才能够来知足诉求。
但若是换个方式,采用DIP的话,问题也许能够被更优雅的解决了:

固然这个示例是简化后的版本,实际工做里还有不少更加复杂的场景存在,但本质都是同样:采用OOP倒置了策略对细节的依赖,使细节依赖于抽象,而且经常是客户拥有服务接口,这个过程当中的核心是须要咱们作好抽象。

OCP(开闭原则):若是仔细分析,会发现这个原则实际上是咱们一开始定的系统设计的目标,也是其余原则最终想达成的目的,好比:经过SRP,把每一个业务线的模块拆解出来,将变更隔离,可是平台还要作必定的抽象,将核心业务流程沉淀下来,并开放出去每一个业务线本身定义,这时候就又会应用到DIP。

其余的几个原则就不举例子了,固然除了SOLID,还有其余类型的原则,好比IoC卖交易平台举例子,商户向用户卖饭,一手交钱一手交货,因此,基本上来讲用户和商户必需强耦合(必需见面)。这个时候,饿了么平台出来作担保,用户把钱先垫到平台,平台让商家接单而后出餐,用户收到餐后,平台再把钱打给商家。这就是反转控制,买卖双方把对对方的直接依赖和控制,反转到了让对方来依赖一个标准的交易模型的接口

能够发现只要总结规律,总会出现这样或那样的原则,但每一个的原则的使用都不是一劳永逸的--须要不断根据实际的需求变化作代码调整,原则也不是万金油,不能无条件使用,不然会由于过度遵循也会带来没必要要的复杂性,好比常常见到一些使用了工厂模式的代码,里面一个new其实就是违反了DIP,因此适度便可。

演进到模式

这里的模式就是咱们常说的设计模式,用演进这个词,是由于我以为模式不是起点,而是设计的终点。《设计模式》这本书的内容不是做者的发明创造,而是其从大量实际的系统里提取出来的,它们大都是早已存在并已经普遍使用的作法,只不过没有被系统的梳理。换句话说,只要遵循前面叙述的某些原则,这些模式彻底可能会天然在系统代码中体现出来,在《敏捷软件开发》这本书里,就特地有一个章节,描述了一段代码随着调整慢慢演进到了观察者模式的过程。

拥有模式当然是好的,好比搜索系统里,经过Template Method模式,定义一套完整的搜索参数解析模版,只须要增长配置就能够定制不一样的查询诉求。这里最想强调的是不要设计模式驱动编程,拿交易系统里的状态机来举例子(状态机简直太常见了,简单如家里使用的台灯,都有一个开和关的状态,只是交易场景下会更加复杂),在餐饮外卖交易有以下的状态流转模型:

实现这样的一个有限状态机,最直接的方式是使用嵌套switch/case语句,简略的代码好比:

public class Order {
    // States
    public static final int ACCEPT = 5;
    public static final int SETTLED = 9;
    ..
    // Events
    public static final int ARRIVED = 1; // 订单送达

    public void event(int event) {
        switch (state) {
            case ACCEPT:
                switch (event) {
                    case ARRIVED:
                        state = SETTLED;
                        //to do action
                        break
                    case 

                 }
        }  
    }
}

由于是简写了流程,因此上面的代码看起来仍是挺能接受的,可是对于订单状态这么复杂的状态机,这个switch/case语句会无限膨胀,可读性不好,另外一个问题是状态的逻辑和动做没有拆开,《设计模式》提供了一个State 模式,具体作法是这样:

这个模式确实分离了状态机的动做和逻辑,可是随着状态的增长,不断增长State的类会让系统变得异常复杂,并且对OCP的支持也很差:对切换状态这个场景,新增类会引发状态切换类的修改,最不能忍受的是这个方式会把整个状态机的逻辑隐藏在零散的代码里。

旧版的交易系统就使用的是解释迁移表来实现的,简化版本是这样的:

# 完结订单
add_transition(trigger=ARRIVED,
               src=ACCEPT,
               dest=SETTLED,
               on_start=_set_order_settled_at,
               set_state=_set_state_with_record, // 变动状态
               on_end=_push_to_transcore)
...

# 引擎
def event_fire(event, current_state):
    for transition in transitions:
        if transition.on_start == current_state && transition.trigger == event:
            transition.on_start()
            current_state = transition.dest
            transition.on_end()

这个版本很是容易理解,状态逻辑集中在一块儿,也没有和动做耦合起来,扩展性也比较强,惟一缺点的话是遍历的时间,但也能够经过字典表来优化,但它整体带来的好处更加明显。

不过随着业务发展,交易系统须要同时支持多套状态机,意味着会出现多个迁移表,并且还有根据业务作扩展定制的需求,这套解决方案会致使代码编写变得复杂起来,咱们在重构时采用了二级编排+流程引擎的方式来优化了这个问题,只是不在咱们讨论的范围内,这里只想强调第二个决策:代码上要灵活经过设计原则分析问题,再经过合适的设计模式解决问题,不能设计模式驱动编程,好比有时候一个全局变量就能够替代所谓的单例模式。

丰富的领域含义

一旦你想解说美,而不提拥有这种特质的东西,那么就彻底没法解释清楚了

用个不那么贴切的说法,若是前面说的是针对静态问题的策略,如今咱们须要讨论面对动态问题的解决办法:即便没有风,人们也不会以为一片树叶是稳定的,因此人们定义稳定的时候和变动的频繁度无关,而是和变动须要的成本有关,由于吹一口气,树叶就会随之摇摆了。咱们除了要写好当前代码,让其足够清晰合理,还要能写好应对需求变化的“树叶”代码。

面向业务变化的设计首先就是要理解业务的核心问题,进而进行拆解划分为各个子领域,DDD--也就是领域驱动设计,已经被证实是一个很好的切入点。这里不是把它看成技术来学习,而是做为指导开发的方法论,成为第三个决策,而且我我的仍处在初级阶段,因此只说一些理解深入的点。

通用语言

设计良好的架构在行为上对系统还有一个最重要的做用:就是明确的显式的反映系统设计的意图,简单来讲,在你拉下某些服务的代码的时候,大概扫一眼就能够以为:嗯,这个“看起来”就像一个交易系统的应用。咱们不能嘴上在谈论业务逻辑,手上却敲出另外一份模样的代码,简单来讲,不能见人说人话,见鬼说鬼话。能够对比一下这两类分包的方式,哪个更容易理解:

发现领域通用语言的目的之一是能够经过抓住领域内涵来应该需求变动,这个须要不少客观条件,好比团队里有一个领域专家。但没有的时候,咱们也能够向内求解,**我有次看到一位在丁香园工做的程序员朋友,购买了一大批医学的书籍,不用去问,我就猜他必定是成了DDD的教徒。

针对这个点,咱们此次重构时还作了些让“源代码即设计”的工做:领域元素可视化,当系统领域内的一些概念已经和产品达成一致以后,便增长约定好的注解,代码编译时即可以扫描并收集起来发送给前端,用于画图。

回到前面提到的评价域模型,后来在和产品屡次沟通后意识到,产品没有但愿评价这么多种类,对它来讲商品也好、骑手也好,都属于被评价的对象,从领域模型来看,以前的设计更可能是面对场景,而不是面对行为,因此合理的域模型应该是:

限界上下文

这个在咱们平时开发过程当中会很常见。拿用户系统举例:一个User的Object,若是是从用户自身的视角来看,就能够登录、登出,修改昵称;若是是从其余普通用户来看,就只能看看昵称之类的;若是从后台管理员来看,就能够注销或者踢出登录。这时就须要界定一个Scope,来讲明如今的User究竟是哪一个Scope,这其实就是DDD中限界上下文的理念。

限界上下文能够很好的隔离相同事物的不一样内涵,经过严格规范能够进入上下文的对象模型,从而保护业务抽象行为的一致性,回到交易领域,饿了么是最开始支持超级会员玩法的,为了支持对应的结算诉求,须要接入交易系统来完成这个业务,咱们经过分解问题域来下降复杂度,这个时候就对应切割为会员域和交易域,为了保护超会卡在进入交易领域的时候,不扰乱交易内部的业务逻辑,咱们作了一次映射:

切分

当全部代码完成以后,随着程序增加,会有愈来愈多的人参与进来,为了方便协做,就必须把这些代码划分红一些方便我的或者团队维护的组。根据软件变动速度不一样,能够把上文提到的代码化为几个组件:

  • Extension:扩展包,这里存放着前面提到的业务定制包,面向对象的思想,最核心的贡献在于经过多态,容许插件化的切换一段程序的逻辑,其实软件开发技术发展的历史就是一个想法设法方便的增长插件,从而建立一个可扩展,可维护的系统架构的过程。
  • Domain: 领域包,存放着具有领域通用语言的核心业务包,它最为稳定。
  • Business:业务包,存放着具体的业务逻辑,它和Domain包的区别在于,可能Domain包会提供一个people.run()的方法,他会用这个方法去跑着送外卖,或者去健身。
  • Infra: 基础设置包,存放这对数据库及各类中间件的依赖,他们都属于业务逻辑以外的细节。

而后是分层依赖,Martin Flower已经提供了一套经典的分层封装的模式,拿简化的订单模块举例:

然而若是有的同窗避免作各类类型的转换,不想严格遵照分层依赖,以为一些查询(这里指Query,Query != Read)能够直接绕过领域层,这样就变成了CQRS模式:

可是最理想的仍是下面这种方式,领域层做为核心业务逻辑,不该该依赖基础设施的细节,经过这种方式,代码的可测性也会提高上去

单体程序的组件拆分完毕后,再向上一层,咱们开始关注四个核心服务:Booking被分拆为Cart、Buy、Calculate,Eos被分拆为Procee、Query、Timeout,Blink一部分和商户订单相关的功能被分拆到Process、Query,和物流交付的部分单独成一块Delivery,最后交易的核心服务拆解成下图:

系统名称 主要功能
Bos c端订单管理:用户详情、列表
Nevermore b端订单管理:商户接单等
Cart 购物车
Buy 下单
Calculate 金额计算
Process 订单处理,管理订单生命周期
Query 订单查询
Timeout 超时中心
Loki 订单取消、退
Delivery 交付中心

到目前,算上这个切分的方式,加起来一共就四个决策,其实也不必分序列,它们核心都是围绕着软件灵活性这个目标,从程序范式到组件编写,最后再到分层,咱们主动选择或避开的一些教条限制,因此业务架构从某种意义上来说,也是在某种领域中限制程序员的一些行为,让他往咱们所但愿的规范方向编码。从而达到整个系统的灵活可靠。

"No Silver Bullet"

“个体和交互赛过过程和工具”,敏捷宣言第一条

目前系统架构是什么样子并不重要,由于它可能会随着时间还会拆解成其余模样,重要的是,咱们要认识到对于如何建造一个灵活的交易系统——没有银弹。

若是仔细观察的话,会发现当前系统里仍有不少问题等着被解决。好比一些横跨型变动:系统链路里会由于某个服务的接口增长了字段,而致使上下游跟着一块儿改。更为尴尬的是,原本咱们拆分服务就是为了解耦合,但有时还会出现服务发布依赖的现象。系统演进是一场持久的战争,“个体和交互赛过过程和工具”,人才是胜利的核心因素。

过去的两年里,咱们没有中止过思考和实践,常常能够看到交易团队内部成员的争执,小到一个接口字段变动,大到领域之间的边界,你们为拿到一个合理的技术方案作了不少讨论,这让我想起《禅与摩托车维修艺术》里所提到的良质,有人点评说:关于良质,程序员可能有这样的经历——写出了一段绝妙的代码,你会以为“不是你写出了代码,这段代码一直存在,而你发现了它”。

本文做者:盛赫,花名白茶,就任于阿里本地生活中台研发部,多年交易系统建设开发经验,目前转入营销领域继续探索。

参考书籍

《软件设计的哲学》--John Ousterhout
《禅与摩托维修艺术》--Robert M.Pirsig
《领域驱动设计》--Eric Evans
《敏捷软件开发》--Uncle Bob
《架构整洁之道》--Uncle Bob
《极客与团队》--Brian W.FItzapatrick


本文做者:中间件小哥

原文连接

本文为云栖社区原创内容,未经容许不得转载。

相关文章
相关标签/搜索