面向业务特性的代码架构, 能够把业务开发和代码架构开发分离开, 实现架构和业务解耦。 笔者并不强调“架构与业务解耦”要比“架构与业务融合”更先进。 实际上对于大部分软件来说, 尤其是小型的软件, 架构和业务“融合”具有更大优势。 比如开发速度更快、更容易实现性能优化。
之所以要架构和业务解耦, 业务里面再划分“特性”, 是为了把大型软件按照正交的维度拆分更细。 一个大型软件往往由100+开发人员并行开发, 架构设计除了要满足代码开发的要求, 还需要兼顾团队管理、组织文化培养、个人绩效评估激励等等。 你可能会想到“分田到户”的伟大创意。 代码架构设计在复杂业务+大型软件+大量人员的加持下, 不得不考虑寻找一种支撑“分田到户”的方式。 所谓生产力决定生产关系, 而软件架构的技术水平能让我们的大型团队走得更快更远。
也许有人会提出“微服务”的架构。 那是系统交互层面的设计考虑。本文仅从代码架构的层面去讨论代码应该怎么写。
电信行业里面最神奇的一个事情: 产品里面没有业务专家能说清楚所有业务, 但是代码都写出来了, 所有业务都能转起来。 产品平均每周(甚至每天)都会新开发特性, 每个特性都会修改很多个地方的代码。 以至于业务专家都不可能清楚了解每个特性。
进行复杂业务开发的码农,常常会做出这样一个决定:如果你没有弄明白老代码该不该执行, 为了安全地新增你的特性,你会用if语句块写入你的逻辑,老代码全部放到else里面去。 这样做可以确保新增代码不会影响老的场景。
假设一个产品有500个开发人员,每人每天写10行代码。 如果你希望了解产品的每一行代码, 你需要每天review 5000行代码, 并且确保过目不忘。简单一点地说,不可能有人看过产品的全部代码。甚至达到“对代码基本掌握”都是一种奢望。
重构是代码从一种混乱到另一种混乱。 第一版的代码通常平铺直叙,缺乏抽象建模。 虽然代码能看懂,总给人一种凌乱、臃肿的感觉。 代码经过一顿整理后,代码更抽象/精炼了, 自我感觉清爽多了。 换一个人去看代码,依旧不知道里面有什么业务特性,依旧一脸懵逼…。在代码规模膨胀到一定程度后(例如100W行),代码越是抽象精炼,“新手”就越难从代码的“局部”去统揽全局。 很多人都会有过这样的经历: 阅读代码的时候忽然遇到一个函数指针调用。然后你决定先寻找另一个答案:这个指针指向哪里?
当老员工向新员工讲解本产品业务的时候,常会故作“装X”的样子说:“这个产品的业务是非常复杂的, 不是一两句话能说得清楚,你得用心学习”。 然而老员工也有翻船的时候。当他向领导介绍产品业务,看着领导“一脸懵逼”的样子, 内心也是崩溃的:难道我还没说清楚吗?其实谁也说不清这个产品代码里面到底有多少个业务,每个业务都在什么条件下触发, 触发后都做了什么事情。
对于绝大多数软件, 我们的代码并能不像“说明书”一样清晰地表达一个业务。 甚至一个业务专家试图向“领导”介绍业务,也难以找到“恰当的表达方式”。 本文尝试寻找一种代码表达形式, 使得它向程序员简洁明了地介绍业务。 如果有一天我们试图了解业务, 一句“Show me the code”就能说明一切, 那么我们的代码架构就算成功了。
把一个混乱的房间整理成一个简约风格的房间。甚至如果你有雅兴, 也可以加入一点艺术元素。《重构》这本书说的就是这些。对于一个稍微有追求的程序员来说,通常看过这本书了。
利用“模型”表达,把更低层次的“代码细节”隐藏起来。 从最初的设计模式,到工程方法, 都是为了软件建模。开始引入更多的重构理论和标准架构模型。 DDD, DCI,VMC,洋葱。
无论哪一种工程方法, 都需要不同程度地对业务进行建模。传统的方式是先建模设计再写代码。业务越来越复杂了, DDD提出先划分领域,然后一边写代码一边建模,再辅以微重构。 “极客”更为激进,先写代码然后建模, 一言不合就重构。无论怎么做,最终我们的代码形式中一定会包含模型。 模型是人们理解业务的普遍形式, 也是真正意义的“内在逻辑”。
当一个基础能力做好之后,我们总是期望它“无感知”地存在。 这里要做到的就是把上一层的“模型”隐藏起来。 注意这里强调的是隐藏, 而不是消灭。 “模型”总是必要的, 但模型的本身附带了对“领域知识”的要求。 在应用层我们期望通过弱化这种“领域知识”,从而降低大家开发业务所需的知识门槛。
通过SQL语句能表达对数据处理的诉求。 SQL不一定要在数据库中执行。 如果一个程序员鼓励师希望向另一个程序员“准确传达”一个数据处理诉求, 那么最好的方式也是用SQL语句去描述。 因为SQL语句清晰、简洁、几乎无歧义。
Select 玫瑰花 into alice’s home from 花店 where 价格>=1000RMB and number=999 when alice’s birthday. |
如果还有程序员看不懂这个语句想表达什么, 或者不知道怎么去执行这个语句, 那我们祝福他“活该单身”吧。
从“准确表达”的意义上说, 表达和执行是两回事。 本文的一个核心思想,就是带领大家把“表达”从“执行代码”中剥离出来。 让它成为一个独立的事物。 就像SQL语句和数据库软件之间的解耦那样。
业务的概念是含糊的。 我们所说的业务, 通常是客户的一个期望。 即业务至少包含了一个目标属性:人们期望达成一个目的。
When:
业务的触发条件。 它是针对经常发生改变的信息而言的。 当一个事件发生时,开始执行一系列动作, 以便达成业务目标。
Where:
在什么地方(环境)。在程序里面我们通常称为Context(上下文)。 它是程序运行的一个环境信息, 这个信息相对稳定。
What:
要做些什么。 程序所执行的一切操作, 都应该围绕它的“目标”方向努力。
我们讲解一个业务, 首先要说明的是这个业务试图达成客户的某个目的。 然后再展开说明我们应该怎么做。 接下来是提问环节: X场景下, 你没有Y资源,你怎么做Z事情? 人们最关心的依然是资源问题。 我们不得不花费很多精力去解答, 在X场景下我们是怎么拿到Y资源的。 我们会系统性地讲解一个业务过程, 并证明这个过程解决了所有的资源需求问题。 从逻辑上说, 就是解决了从起点到目的地,求解出一个可行的过程。 如果这个过程满足任意一个“小目标”的依赖关系(都是可达成的), 我们就认为它是可行的。
当故事讲完后,你会发现什么都没有做。 因为没有人给你写代码…。 当然这并不妨碍笔者继续瞎扯。 我们继续研究如何讲故事, 而不是着急去写代码。
从技术的角度去理解业务, 我们的认知大概可以分为三个层面。
顶层:
要完成一个业务目标, 我们需要谁的协助。即需要梳理依赖的资源, 以及资源分布情况。 它是我们脑袋里面的一张“静态图”,表达了资源与目标之间的关联关系。
中间层:
为了拿到这些资源, 我们应该怎么做。 制定一个策略, 先找到什么, 然后做什么, 再做什么。 这是一个策略和编排的过程, 最终我们生成了一个“执行过程的假设”。 注意, 这只是一个假设, 它不包含执行过程所需要的细节。
底层:
这一层仅做“无脑”执行。 它不需要进行任何决策,也不关心业务的目标, 仅仅负责干活。
表达业务在垂直维度上的层次划分:
1)业务目标和资源依赖关系(静态描述)
每个依赖资源都是一个子目标。 两个(或多个)有交互关系的资源, 组合在一个小的Context中。
2)策略(动态过程描述)
根据子目标, 以及多个子目标之间的依赖关系, 编排出合理的业务流程。 业务流程关注步骤的可行性。 在真正交付执行之前, 还需要对生成的执行步骤进行优化整合, 例如把多个特性、多个目的的信元合并到一个消息中承载。
3)执行(实施)
执行层相当于驱动程序, 在特定的业务实例空间内, 采用标准的数据存取接口, 对数据进行加工。 如果是对外消息通信, 还包括消息的打包(build)、解码、收发处理。
“梁山伯与祝英台”类型的爱情故事, 讲到结婚的那一刻,便戛然而止。 为什么?
往后的故事真的没有什么好讲的了, 就是结婚后生个娃, 剩下的是日复一日的“更换尿不湿”。如果小说在所有章节都这样描述“今天更换了尿不湿”, 会显得很无聊。 小说总是会选“精彩”的部分来描述, 把“乏味”的部分删掉。 “精彩”和“乏味”有什么区别? 从逻辑上说, “精彩”就是一个故事区别于另一个故事的“有价值”的部分, 读者要看了小说才知道具体故事情节。 它往往出乎读者的意料,给读者带来新的认知。 “乏味”就是读者都能预测到的故事情节(比如更换尿不湿), 它是读者已经认知的知识。 读者反复去看这部分内容, 是没有“知识增值”的。 同理, 小说也不会描述“吃喝拉撒”等常规内容, 除非它能引出新颖的故事情节。
在复杂业务系统里面, 我们应当清晰区分“表”与“不表”两部分代码。 我们挑选“精彩”的业务逻辑,用代码重点阐述。计算机的逻辑是必须面面俱到的。 对于“不表”的部分, 做成固定的代码库。 这部分代码尽管是必须的, 但它不重要, 也不会对业务逻辑有决定性的影响。从语文表达形式的“表”与“不表”, 我们应当能联想到“代码”应当如何去表达业务。
如果一个业务逻辑是“确定性”的, 即便我们不说, 它也应该是那么一回事。 如果一个业务逻辑有别于其它业务, 我们需要“重点予以说明”。 我们认为这个逻辑是“可变性”的。 当代码不能明确对业务给出定义的时候, 它就有“一百万种可能”。 因此“用代码对业务给出定义”就成为非常重要的一件事。
我们对所有业务逻辑点进行梳理, 从“可变性”到“确定性”的程度依次排序, 得到一个列表。 排在最前面的是最具有“可变性”的,我们应当用代码明确说明它, 让它成为“确定”。 然后再描述排在其后的“可变性”逻辑一一予以描述确定。 最终我们得到一个确定性的软件系统。
这个描述的顺序, 就是我们代码中的“分层”, 顶层用来描述最具“可变性”的业务逻辑。 如果业务逻辑是“确定性”的, 那么就把它放在底层。
传统的代码结构是根据依赖关系来“分层”的。 这种分层方式的确显得自然而然, 看上去也很符合“数学逻辑”。 然而人的语言表达形式和“数学逻辑”在很多时候并不一致。 在代码架构支撑下,我们用代码去描述业务时更加倾向于人的语言表达形式。 它使得我们的代码对业务描述更加清晰, 尤其对复杂业务的描述更具有优势。
这样的代码分层方式, 必然会对依赖关系构成挑战。 它会破坏了代码中关于资源的依赖顺序。 幸运的是人们发明了很多代码形式, 使得代码设计可以不严格地遵从以资源为核心的层次依赖关系。 例如依赖倒置等设计模式。 如果这些设计模式仍然不能解决问题, 那么参见本系列文章早期关于架构设计的思路, 用“自顶向下”的设计方式设计一个架构, 去支撑这样的代码表达形式。
在三国的故事里面, 我们关心在同一个时间点三国各自的发展水平;同时我们也关心一国在时间线上先后进行了什么样的改革, 以及对历史演进的“深远影响”。
三国的故事是沿着时间线、不同空间并行发生的。当我们讲三国故事的时候,如果着急把“一切”都讲出来,从空间、时间等多个维度并行地讲, 在语言组织上是非常困难的。 那我们应该怎么“讲一个复杂的故事”?
映射到我们的复杂软件领域, 我们应当如何用代码描述一个复杂的业务系统?
需求分析和系统设计已经把软件系统细分成子模块、特性。 以特性为粒度去描述业务是可行的。 但我们也注意到特性之间存在业务交互、业务冲突、共用流程代码等情况。 在一个软件内按照特性进行开发, 其颗粒仍然太大。 就如同我们讲一个小故事, 它发生在一个小的区域,在一个特定的背景下。 故事虽小,仍然存在时间、人物关系、个人性格等不同维度。 我们用代码去“讲故事”时, 如果穿插不同维度的描述, 会显得逻辑凌乱。 尤其是需要变更的场景下, 往往仅涉及一个维度的变化。 如果代码是同时对多维度进行描述的, 那么代码变更就意味着同时影响了特性的多个维度表达。
前文提到代码是按照“可变性”排序分层设计的。 在这个分层原则的基础上, 我们还应当按照“单一维度表达”的原则, 再次细分代码层次。 直到每一层代码的表达都是简单直观的。 每一层都应该能用一句话准确说清楚“这块代码是用来干嘛的”。
这里没有提“业务专家”怎么描述业务。 因为在大多数人眼里, 业务专家是懂代码的, 至少也懂那么一点点代码。 我们需要找到一个不懂代码的人去表达业务诉求。 所以这个章节我们离开代码谈业务。
u 自运营: 特性开关、单特性灰度
u 多租户: 定制化开发以插件方式隔离
u 选择困难症客户:快速交付、可改变、可反悔
u 开发人员对代码的诉求: 一个特性集中在一个代码块中。 清晰地描述什么时候发生了什么事,当时的场景是什么样子的。 修改一行代码就只影响这个特性, 其它特性出问题我可不背锅。
u 领导对代码的诉求:说说看,这一坨代码里面都实现了哪些特性? 明天就要发版本了,你改这一行代码真的会导致我失眠。
总之,我们希望代码的局部变更,其影响范围是有清晰边界的。从功能的角度看, 修改了哪个模块的代码,模块对外接口不变, 影响不会扩散到其它模块。 从业务的角度看,开关某个业务特性而不破坏其它特性的服务(特性之间解耦)。 这里有两个边界: 功能性代码的边界、业务特性边界。
软件对外的呈现一定是业务(服务)。业务的表达形式可以有很多种。 典型的是画一个流程图; 也有通过UML图来说明“代码架构”,进而试图解释业务的数据模型、运作流程。 用UML图去解释业务,能够比较清晰地说明数据模型关系。 但是表达业务过程晦涩难懂。 流程图在表达业务过程时有一定优势。 但是在“流程分支复杂”的情况下, 一张图还远远不足以“全面”概括。 按照业务特性的角度用“语言文字”说明一个业务时, 尽管简单易懂,但既不能表达数据模型关系, 也不能表达业务过程。 DDD(领域驱动开发)试图通过领域划分的方式描述业务。
时间线表达形式的问题和局限: 时间线其实不是一个“维度”。 依据时间线确定下来的“流程”从业务的视角看反而是不稳定的。 大学里面教的“状态机”编程,就是一个典型的按时间线程序设计方法。 面对稍微复杂一点的业务, 状态机图的跳转关系,能让人怀疑人生。
三个正交维度的逻辑表达(典型案例):
特性描述:客户是用语言文字来表达需求的。 转换到软件开发里面, 我们用代码来表达需求。 在这个维度里面,我们仅仅试图用代码来描述需求, 而不给出需求的实现方案。 它是“业务意图”的代码表达形式。
信元表示:使用数据对现实世界建模。 这一层不关心需求, 仅使用信元来指代相应的业务实体。 它是对业务的静态建模。 我们把通常业务模型中的“业务意图”以及“动态交互”剥离出来, 使得这个模型的表达简化,成为一个独立的维度。
消息通信:表达交互关系。 谁跟谁有联系, 如何交换数据。在微服务系统中, 消息的交互通常是一个RPC调用, 对应一个API及其目的。 在复杂的业务系统中, 一个消息通常承载了多个信元, 代表了多个“业务意图”。 由于多目的是复杂的, 因此我们把“业务意图”剥离出来, 让这一层仅仅发挥“通信”的作用。
那么,“动态交互” 的过程在哪里呢? 如果业务意图描述是清晰严谨的, 业务静态模型也是清晰明确的, 消息交互格式是确定的,那么“动态交互”的过程在数学上应当能被严格推导出来。 这个动态过程即“业务流程”。 它可以作为一个独立的模块, 在这三个正交维度之外实现。
鸡生蛋/蛋生鸡是最基本的循环。 如果通过一个过程来描述, 必须先有蛋, 不对,必须先有鸡。但一个“逻辑”表达的时候, 既没有鸡,也没有蛋。 那么“鸡生蛋和蛋生鸡”这个逻辑就不存在了么? 显然逻辑可以脱离具体的“初始状态”来表达。 这给了我们另一个启发, 即我们的程序语言在表示一个逻辑时,也是可以没有“初始状态”的。
u 顺序表达: 典型代表是程序语言/过程。
u 关系描述:典型代表是硬件描述语言。 例如:X原子随机裂变成Y原子, 半衰期是1年。
u 核心思想: 面向业务特性去描述。
u 服务系统: 解释、执行业务描述。
u 基础支撑:领域模型、数据模型。
用“第一人称”去表达业务。 环境(Context)更替,而“我”不变。先让我们“异想天开”的方式想象一下“我”怎么样的生活方式才算舒服。 如果“我”想躺在沙发上看电视, 有以下几种方式:
方式一:
我要先找到一个沙发, 然后坐下来。 这时候我发现拿不到电视机的遥控器, 于是我站起来,继续找遥控器。 然后我发现缺了一个电视机, 于是我上网买一个电视机。 然后我发现没电…
在业务流程中, 我们遇到的问题不仅仅是现在要做的事情依赖了另一个事情。 更严重的是这种依赖随时随地发生, 依赖的顺序存在不确定性或者难以找到规律。 而我们不可能在任意一个代码中都为这些“麻烦事”插入另一块代码来解决问题。
在一个缺乏合理资源准备的环境中运行, “我”的内心是崩溃的。
对应我们的代码实现方式,就是当“我”需要某个资源,我就send一个消息,从远程服务那里receive这个资源。 代码中随时随地有能遇到send/receive操作。
方式二:
电视机、遥控器、沙发都放在仓库里面。 “我”要看电视, 先从仓库中翻箱倒柜找到这些“资源”。 然后“我”可以开始看电视了。
尽管有点麻烦,但做任何事情都有一定规律:先找齐资源后使用。 这是在缺乏架构支撑的情况下完成一项“业务”的方式。 代价高,但总能实现。 对应代码架构大概是这样子: 代码集中处理 send/receive, 凑齐了资源,然后开始真正执行“业务”。 偶尔也会遇到中途要send/receive捞取资源的情况。
方式三:
有一个专门播放电视的房间。 里面有电视机、遥控器、沙发, 看电视所需要的资源都配置齐全了。“我”只需要走进这个房间,就能看电视了。
这是DCI设计方式。 它试图把“我”需要的环境创造出来。 “我”要做的事情很简单, 就是走进房间(context环境),然后看电视。 Context 中包含了我需要的role(电视机、遥控器、沙发), 而且没有多余的东西。 环境总是干净整洁的。
方式四:
“我”说了一句“看电视”。 然后我发现我已经躺在沙发上看着电视了。
这个方式“隐藏了过程”, 它是对业务意图描述。 如果一个业务过程是“固定”的, 过程的表达就不重要了。 因为无论“我”是否表达这个过程, 这个过程都能被“无歧义地推导出来”。
这时候我们再回过头来看前文讨论的一个故事。 当我们向领导介绍业务的时候,我们希望尽可能精炼地表达出来。 “精炼”就意味着我们对业务意图表达是正确的、并且不包含那些“可以被无歧义推导出来”的信息。我们对业务的描述, 是否可以精简掉那些“无歧义地推导出来”的信息?
在一个产品内有很多业务特性。 这些特性的“特征”这是我们要重点描述的。“共性”的部分,包括“无歧义地推导出来”的信息, 都可以通过“策略推导”的形式呈现。 使用“精炼”的语言描述一个产品的业务特性之后, 我们将得到这样一个表格: 每一行用来描述一个业务特性。 其中的列信息用于表达特性在不同维度下的“特殊性”。
作者:华为云专家卜基