架构的核心挑战是如何处理当下或未来可能出现的快速增长的软件复杂性,因此越是大型系统在架构设计上越是要简单。
软件的复杂度为什么会增加?
先阐述观点在实际开发中软件的复杂性是随着时间愈发陡峭的,复杂度的提升近似于y=x^2的曲线。主要是基于以下几个情况得出的结论:
- 软件的复杂程度是逐渐迭代出来的
拿我之前的项目举例,最开始业务需求可能只是围绕商品的一个业务,根据这个业务设计流程,后来慢慢的迭代过程中逐渐把商品相关的属性完善从而又能支撑起开展其他的业务流程。随着业务流程的膨胀和交汇,软件的复杂度会不断的增加。 - 代码迭代过程中的理解和维护
软件对应的实体即为代码,在相同的架构下不同的理解会有不同的代码实现。由于大型项目往往是多人进行协作开发,需要统一大家的理解。通俗的说法就是多个人写出来的代码就像一个人写出来的一样,要完成这样的合作往往是很困难的。这里在补充阐述一下,并不是反对有着强烈个人代码风格的方式,主要还是想表达的是同一团队在面对同一项目时对软件的架构、技术、业务理解应该是尽量要趋同的。
业务架构师最重要的工作不是设计软件结构,而是应该通过API、团队设计准则、细节的关注把控来控制软件复杂度的增长。
如何定义软件复杂的维度?
软件的复杂度可以定义为让人理解和维护修改的困难程度,因此我们可以将软件的复杂程度拆分为两个部分
- 软件的认知成本:理解软件的接口、设计和实现上的成本,简单说就是看懂代码的时间成本和脑力成本
- 软件的协同成本:修改维护软件时,所需要付出的成本
举个例子来说,软件的扩展性不好就是协同成本过高,会导致新增功能时需要进行大量修改,并且修改后还会进一步的增加认知负担
认知负担
- 定义新的概念带来的认知负担,这种负担与所定义的概念与现实模型的关联度相关
- 逻辑符合思维习惯的程度
- 逻辑是否符合思维习惯的程度,这个因人而异,最好在实际的开发过程中有统一的规范,例如强烈建议使用卫语句、使用Optional等
- 模型失配:模型的失配指的是定义的模型与现实世界的业务模型存在较大差异所带来的
- 接口设计不当
-
需要调用者使用初始化才能正常工作的接口。将初始化的职责放到了调用方,但是调用方在面对复杂的初始化参数时,就需要去了解每个参数所代表的动作和意义,承担了本来属于接口实现所承担的职责。这里可以使用工厂模式来进行处理,在接口工厂类中根据接口提供的场景进行处理
-
一个接口中不同的方法提供了相同的功能
-
违反开闭原则,一个简单的修改需要在多处中进行更新
在业务开发中,往往会为了进度或者害怕新的改动会改变已有代码的逻辑因此去copy-past大量类似的逻辑和功能,这样会导致一个简单的修改需要更多的精力在多处中进行去更新,代码的复杂度也会提高 -
命名 尽量要做到通过的命名就知道变量、方法、类、模块等要做什么,重点在于做什么上面而不是是什么上面。
协同成本
协同成本是新增、修改功能所付出的时间成本和脑力成本。
-
在微服务架构下,模块/服务的切分是和团队对齐的,即"组织架构决定系统架构",组织架构最佳的划分是按照系统架构来进行的,当组织划分不好时,往往会导致重复的工作。
-
服务间相互依赖,服务间相互依赖主要有两种种形式组和、继承。继承会呈现出更强制的关系,因此也会有更大的协同复杂性。
-
可测试性带来的协同成本。这里指的是由于单元测试不完善需要更完善的集成测试来保证软件的正确性。
-
文档 降低协同成本的一个好方法就是完善文档,包括业务文档、设计文档、接口文档等,但是这部分工作并不会直接产生效益,因此积极性都不是很高。
如何应对软件不断增长的复杂度?
每一次无意识的代码的改动都会产生依赖/耦合从而增加系统的复杂性,软件的复杂程度恶化到一定程度后就会导致系统不可避免的失败。因此需要我们对复杂度增加采用零容忍的态度。
- 软件的复杂度带来的影响往往是滞后的,在看到影响时也许已经过去了很久
- 在进行代码review时,每一个额外的复杂度设计在整个系统的角度下都显得微不足道,但是千里之堤以蝼蚁之溃。
- 破窗效应Broken window:一个建筑,当有了一个破窗而不及时修补,这个建筑就会被侵入住认为是无人居住的、风雨更容易进来,更多的窗户被人有意打破,很快整个建筑会加速破败。这就是破窗效应,在软件的质量控制上这个效应非常恰当,所以有问题尽快修补。
零容忍,并不是不让复杂度增长:我们都知道这是不可能的。我们需要的是尽力控制。因为进度而临时打破窗户也能接受,但是要尽快补上。
基于复杂业务如何分析?
业务的差异性
if/else是由于业务上不同的场景会有不同的业务逻辑,这样的差异性可以很方便的用if/else实现,但是不符合开闭原则,在扩展时代码会堆砌的越来越庞大。
如何消除if/else?
-
多态扩展:利用面向对象的多态特性,实现代码的复用和扩展
-
代码分离:对不同的场景用不同的代码流程来实现业务和代码的隔离
-
多态扩展
多态扩展有继承和组合两种方式。继承的话不要使用重载特性,重载特性不是继承父类的方法。组合类似于策略模式,也就是把需要扩展的部分进行抽象、封装成需要被组合的对象。用多态的特性来移除业务的差异性,这样也更符合实体之间的关系。 -
代码分离
代码分离,代码的冗余和复用性不好,但是同样会达到业务代码彼此独立的状态
多维分析
根据我们的分析在面对业务的差异时,我们可以用多态扩展和代码分离来实现,但是什么时候来用多态扩展?什么时候用代码分离楠?对于这个问题我们可以采用矩阵分析法
我们可以用一个矩阵,纵轴代表业务场景、横轴代表步骤,里面的内容代表每一个动作,这样我们就可以得到这样的一个表格
- | step0 | step1 | step2 |
---|---|---|---|
business0 | 1.action0 2.action1 |
1.action1 | 1.action1 2.action2 |
business1 | 同上 | 同上 | 1.action1 |
business2 | 无 | 无 | 1.action1 |
通过这样一个矩阵我们就可以分析出business0和business1适用于多态扩展,而business2更加适用于代码分离来进行实现。
这样的矩阵在OOP里被称为分析矩阵,主要是用来分析业务涉及要素过多、信息量太大的场景。
流程分解
流程分解是对业务过程进行详细的分解,然后在使用结构化的方法聚合成一个个step,在将step组合成业务,最后形成一个类似于金字塔形状自上而下的流程认知。
领域模型
领域模型指的是模型对象避免使用贫血模式,而应该使用充血模式,让模型具有业务逻辑,从而在复用模型的时候就能复用业务逻辑。充血的模型也更符合现实模型中对象的定义。
分析矩阵
定义
分析矩阵的定义来源于《设计模式解析》第16章,作者通过以下几个方法步骤完成对系统中变化的分析,从而设计出合适的模式
1.找到某种特定情况下最重要的特性,并用矩阵将他们关联起来
2.继续处理其他情况,并且按需扩展矩阵
3.用新的概念扩展分析矩阵
4.在行维度发现规则
5.在列维度发现特定情况
6.从分析中确定模式
7.得到高层设计
案例
最开始需求很简单:只处理美国和加拿大的订单。系统必须进行处理的特性清单如下:
- 为美国和加拿大构建一个销售订单系统
- 根据所在国家计算运费
- 运费还应该以所在国家的货币进行支付
- 在美国,税额需按当地计算
- 使用美国邮政规则验证地址
- 在加拿大使用联邦快递发货时,需要同时缴纳联邦政府销售税和地方销售税
- 加拿大邮寄包裹有违禁品邮寄限制
- 美国的订单号继承于加拿大订单号规则,又有所改变
从使用场景我们可以得知,需求分为两种:美国、加拿大,因此我们可以根据需求场景分析得出这样的矩阵
情况 | 过程 |
---|---|
美国 | 1.使用美元计费 2.使用美国邮政规则校验地址 3.按照当地计算税额 4.美制加拿大规则订单号规则 |
加拿大 | 1.使用美元计费 2.使用美国邮政规则校验地址 3.按照加拿大规定计算销售税4. 违禁品邮寄限制5.加拿大规则订单号规则 |
通过这样一个具有详细功能的矩阵,我们可以直接提炼出按照步骤进行分解的矩阵
步骤 | 美国 | 加拿大 |
---|---|---|
货币单位 | 美元 | 加元 |
校验规则 | 美国邮政规则 | 加拿大邮政规则 |
计算税额 | 美国税额政策 | 加拿大税额政策 |
邮寄限制 | 未知 | 加拿大邮寄限制 |
订单号规则 | 美制加拿大规则 | 加拿大规则 |
通过这个矩阵我们发现美国的邮寄限制未知,这样我们可以询问业务专家得到答案。通过这样的矩阵我们还可以分析出
步骤 | 美国 | 加拿大 |
---|---|---|
货币单位 | 多态扩展 | 多态扩展 |
校验规则 | 代码分离 | 代码分离 |
计算税额 | 代码分离 | 代码分离 |
邮寄限制 | 代码分离 | 代码分离 |
订单号规则 | 多态扩展 | 多态扩展 |
参考文档