代码的语文修养

2020 年 1 月 15 日

代码的语文修养

1 前言


面向业务特性的代码架构, 能够把业务开发和代码架构开发分离开, 实现架构和业务解耦。 笔者并不强调“架构与业务解耦”要比“架构与业务融合”更先进。 实际上对于大部分软件来说, 尤其是小型的软件, 架构和业务“融合”具有更大优势。 比如开发速度更快、更容易实现性能优化。


之所以要架构和业务解耦, 业务里面再划分“特性”, 是为了把大型软件按照正交的维度拆分更细。 一个大型软件往往由 100+开发人员并行开发, 架构设计除了要满足代码开发的要求, 还需要兼顾团队管理、组织文化培养、个人绩效评估激励等等。 你可能会想到“分田到户”的伟大创意。 代码架构设计在复杂业务+大型软件+大量人员的加持下, 不得不考虑寻找一种支撑“分田到户”的方式。 所谓生产力决定生产关系, 而软件架构的技术水平能让我们的大型团队走得更快更远。


也许有人会提出“微服务”的架构。 那是系统交互层面的设计考虑。本文仅从代码架构的层面去讨论代码应该怎么写。


2 我们的问题


电信行业里面最神奇的一个事情: 产品里面没有业务专家能说清楚所有业务, 但是代码都写出来了, 所有业务都能转起来。 产品平均每周(甚至每天)都会新开发特性, 每个特性都会修改很多个地方的代码。 以至于业务专家都不可能清楚了解每个特性。


进行复杂业务开发的码农,常常会做出这样一个决定:如果你没有弄明白老代码该不该执行, 为了安全地新增你的特性,你会用 if 语句块写入你的逻辑,老代码全部放到 else 里面去。 这样做可以确保新增代码不会影响老的场景。


假设一个产品有 500 个开发人员,每人每天写 10 行代码。 如果你希望了解产品的每一行代码, 你需要每天 review 5000 行代码, 并且确保过目不忘。简单一点地说,不可能有人看过产品的全部代码。甚至达到“对代码基本掌握”都是一种奢望。


重构是代码从一种混乱到另一种混乱。 第一版的代码通常平铺直叙,缺乏抽象建模。 虽然代码能看懂,总给人一种凌乱、臃肿的感觉。 代码经过一顿整理后,代码更抽象/精炼了, 自我感觉清爽多了。 换一个人去看代码,依旧不知道里面有什么业务特性,依旧一脸懵逼…。在代码规模膨胀到一定程度后(例如 100W 行),代码越是抽象精炼,“新手”就越难从代码的“局部”去统揽全局。 很多人都会有过这样的经历: 阅读代码的时候忽然遇到一个函数指针调用。然后你决定先寻找另一个答案:这个指针指向哪里?


当老员工向新员工讲解本产品业务的时候,常会故作“装 X”的样子说:“这个产品的业务是非常复杂的, 不是一两句话能说得清楚,你得用心学习”。 然而老员工也有翻船的时候。当他向领导介绍产品业务,看着领导“一脸懵逼”的样子, 内心也是崩溃的:难道我还没说清楚吗?其实谁也说不清这个产品代码里面到底有多少个业务,每个业务都在什么条件下触发, 触发后都做了什么事情。


对于绝大多数软件, 我们的代码并能不像“说明书”一样清晰地表达一个业务。 甚至一个业务专家试图向“领导”介绍业务,也难以找到“恰当的表达方式”。 本文尝试寻找一种代码表达形式, 使得它向程序员简洁明了地介绍业务。 如果有一天我们试图了解业务, 一句“Show me the code”就能说明一切, 那么我们的代码架构就算成功了。


3 代码的三层境界


3.1 第一层:整理


把一个混乱的房间整理成一个简约风格的房间。甚至如果你有雅兴, 也可以加入一点艺术元素。《重构》这本书说的就是这些。对于一个稍微有追求的程序员来说,通常看过这本书了。


3.2 第二层:建模


利用“模型”表达,把更低层次的“代码细节”隐藏起来。 从最初的设计模式,到工程方法, 都是为了软件建模。开始引入更多的重构理论和标准架构模型。 DDD, DCI,VMC,洋葱。


无论哪一种工程方法, 都需要不同程度地对业务进行建模。传统的方式是先建模设计再写代码。业务越来越复杂了, DDD 提出先划分领域,然后一边写代码一边建模,再辅以微重构。 “极客”更为激进,先写代码然后建模, 一言不合就重构。无论怎么做,最终我们的代码形式中一定会包含模型。 模型是人们理解业务的普遍形式, 也是真正意义的“内在逻辑”。


3.3 第三层:描述


当一个基础能力做好之后,我们总是期望它“无感知”地存在。 这里要做到的就是把上一层的“模型”隐藏起来。 注意这里强调的是隐藏, 而不是消灭。 “模型”总是必要的, 但模型的本身附带了对“领域知识”的要求。 在应用层我们期望通过弱化这种“领域知识”,从而降低大家开发业务所需的知识门槛。


通过 SQL 语句能表达对数据处理的诉求。 SQL 不一定要在数据库中执行。 如果一个程序员鼓励师希望向另一个程序员“准确传达”一个数据处理诉求, 那么最好的方式也是用 SQL 语句去描述。 因为 SQL 语句清晰、简洁、几乎无歧义。


Select 玫瑰花 into alice’s home from 花店 where 价格>=1000RMB and number=999 when alice’s birthday.


如果还有程序员看不懂这个语句想表达什么, 或者不知道怎么去执行这个语句, 那我们祝福他“活该单身”吧。


从“准确表达”的意义上说, 表达和执行是两回事。 本文的一个核心思想,就是带领大家把“表达”从“执行代码”中剥离出来。 让它成为一个独立的事物。 就像 SQL 语句和数据库软件之间的解耦那样。


4 如何用代码来讲故事


4.1 故事的三要素


业务的概念是含糊的。 我们所说的业务, 通常是客户的一个期望。 即业务至少包含了一个目标属性:人们期望达成一个目的。


When:


业务的触发条件。 它是针对经常发生改变的信息而言的。 当一个事件发生时,开始执行一系列动作, 以便达成业务目标。


Where:


在什么地方(环境)。在程序里面我们通常称为 Context(上下文)。 它是程序运行的一个环境信息, 这个信息相对稳定。


What:


要做些什么。 程序所执行的一切操作, 都应该围绕它的“目标”方向努力。


4.2 一个业务就是讲一个故事


我们讲解一个业务, 首先要说明的是这个业务试图达成客户的某个目的。 然后再展开说明我们应该怎么做。 接下来是提问环节: X 场景下, 你没有 Y 资源,你怎么做 Z 事情? 人们最关心的依然是资源问题。 我们不得不花费很多精力去解答, 在 X 场景下我们是怎么拿到 Y 资源的。 我们会系统性地讲解一个业务过程, 并证明这个过程解决了所有的资源需求问题。 从逻辑上说, 就是解决了从起点到目的地,求解出一个可行的过程。 如果这个过程满足任意一个“小目标”的依赖关系(都是可达成的), 我们就认为它是可行的。


当故事讲完后,你会发现什么都没有做。 因为没有人给你写代码…。 当然这并不妨碍笔者继续瞎扯。 我们继续研究如何讲故事, 而不是着急去写代码。


4.3 如何更好地表达一个业务


从技术的角度去理解业务, 我们的认知大概可以分为三个层面。


顶层:


要完成一个业务目标, 我们需要谁的协助。即需要梳理依赖的资源, 以及资源分布情况。 它是我们脑袋里面的一张“静态图”,表达了资源与目标之间的关联关系。


中间层:


为了拿到这些资源, 我们应该怎么做。 制定一个策略, 先找到什么, 然后做什么, 再做什么。 这是一个策略和编排的过程, 最终我们生成了一个“执行过程的假设”。 注意, 这只是一个假设, 它不包含执行过程所需要的细节。


底层:


这一层仅做“无脑”执行。 它不需要进行任何决策,也不关心业务的目标, 仅仅负责干活。


表达业务在垂直维度上的层次划分:


1)业务目标和资源依赖关系(静态描述)


每个依赖资源都是一个子目标。 两个(或多个)有交互关系的资源, 组合在一个小的 Context 中。


2)策略(动态过程描述)


根据子目标, 以及多个子目标之间的依赖关系, 编排出合理的业务流程。 业务流程关注步骤的可行性。 在真正交付执行之前, 还需要对生成的执行步骤进行优化整合, 例如把多个特性、多个目的的信元合并到一个消息中承载。


3)执行(实施)


执行层相当于驱动程序, 在特定的业务实例空间内, 采用标准的数据存取接口, 对数据进行加工。 如果是对外消息通信, 还包括消息的打包(build)、解码、收发处理。


5 语文表达形式与代码分层设计原则


5.1 原则 1:代码表达中的“表”与“不表”


“梁山伯与祝英台”类型的爱情故事, 讲到结婚的那一刻,便戛然而止。 为什么?


往后的故事真的没有什么好讲的了, 就是结婚后生个娃, 剩下的是日复一日的“更换尿不湿”。如果小说在所有章节都这样描述“今天更换了尿不湿”, 会显得很无聊。 小说总是会选“精彩”的部分来描述, 把“乏味”的部分删掉。 “精彩”和“乏味”有什么区别? 从逻辑上说, “精彩”就是一个故事区别于另一个故事的“有价值”的部分, 读者要看了小说才知道具体故事情节。 它往往出乎读者的意料,给读者带来新的认知。 “乏味”就是读者都能预测到的故事情节(比如更换尿不湿), 它是读者已经认知的知识。 读者反复去看这部分内容, 是没有“知识增值”的。 同理, 小说也不会描述“吃喝拉撒”等常规内容, 除非它能引出新颖的故事情节。


在复杂业务系统里面, 我们应当清晰区分“表”与“不表”两部分代码。 我们挑选“精彩”的业务逻辑,用代码重点阐述。计算机的逻辑是必须面面俱到的。 对于“不表”的部分, 做成固定的代码库。 这部分代码尽管是必须的, 但它不重要, 也不会对业务逻辑有决定性的影响。从语文表达形式的“表”与“不表”, 我们应当能联想到“代码”应当如何去表达业务。


5.2 原则二:按业务逻辑的可变性分层


如果一个业务逻辑是“确定性”的, 即便我们不说, 它也应该是那么一回事。 如果一个业务逻辑有别于其它业务, 我们需要“重点予以说明”。 我们认为这个逻辑是“可变性”的。 当代码不能明确对业务给出定义的时候, 它就有“一百万种可能”。 因此“用代码对业务给出定义”就成为非常重要的一件事。


我们对所有业务逻辑点进行梳理, 从“可变性”到“确定性”的程度依次排序, 得到一个列表。 排在最前面的是最具有“可变性”的,我们应当用代码明确说明它, 让它成为“确定”。 然后再描述排在其后的“可变性”逻辑一一予以描述确定。 最终我们得到一个确定性的软件系统。


这个描述的顺序, 就是我们代码中的“分层”, 顶层用来描述最具“可变性”的业务逻辑。 如果业务逻辑是“确定性”的, 那么就把它放在底层。


传统的代码结构是根据依赖关系来“分层”的。 这种分层方式的确显得自然而然, 看上去也很符合“数学逻辑”。 然而人的语言表达形式和“数学逻辑”在很多时候并不一致。 在代码架构支撑下,我们用代码去描述业务时更加倾向于人的语言表达形式。 它使得我们的代码对业务描述更加清晰, 尤其对复杂业务的描述更具有优势。


这样的代码分层方式, 必然会对依赖关系构成挑战。 它会破坏了代码中关于资源的依赖顺序。 幸运的是人们发明了很多代码形式, 使得代码设计可以不严格地遵从以资源为核心的层次依赖关系。 例如依赖倒置等设计模式。 如果这些设计模式仍然不能解决问题, 那么参见本系列文章早期关于架构设计的思路, 用“自顶向下”的设计方式设计一个架构, 去支撑这样的代码表达形式。


5.3 原则三:单一维度的表达


在三国的故事里面, 我们关心在同一个时间点三国各自的发展水平;同时我们也关心一国在时间线上先后进行了什么样的改革, 以及对历史演进的“深远影响”。


三国的故事是沿着时间线、不同空间并行发生的。当我们讲三国故事的时候,如果着急把“一切”都讲出来,从空间、时间等多个维度并行地讲, 在语言组织上是非常困难的。 那我们应该怎么“讲一个复杂的故事”?


映射到我们的复杂软件领域, 我们应当如何用代码描述一个复杂的业务系统?


需求分析和系统设计已经把软件系统细分成子模块、特性。 以特性为粒度去描述业务是可行的。 但我们也注意到特性之间存在业务交互、业务冲突、共用流程代码等情况。 在一个软件内按照特性进行开发, 其颗粒仍然太大。 就如同我们讲一个小故事, 它发生在一个小的区域,在一个特定的背景下。 故事虽小,仍然存在时间、人物关系、个人性格等不同维度。 我们用代码去“讲故事”时, 如果穿插不同维度的描述, 会显得逻辑凌乱。 尤其是需要变更的场景下, 往往仅涉及一个维度的变化。 如果代码是同时对多维度进行描述的, 那么代码变更就意味着同时影响了特性的多个维度表达。


前文提到代码是按照“可变性”排序分层设计的。 在这个分层原则的基础上, 我们还应当按照“单一维度表达”的原则, 再次细分代码层次。 直到每一层代码的表达都是简单直观的。 每一层都应该能用一句话准确说清楚“这块代码是用来干嘛的”。


6 业务的本质表达


这里没有提“业务专家”怎么描述业务。 因为在大多数人眼里, 业务专家是懂代码的, 至少也懂那么一点点代码。 我们需要找到一个不懂代码的人去表达业务诉求。 所以这个章节我们离开代码谈业务。


6.1 产品形态对业务特性的诉求


u 自运营: 特性开关、单特性灰度


u 多租户: 定制化开发以插件方式隔离


u 选择困难症客户:快速交付、可改变、可反悔


u 开发人员对代码的诉求: 一个特性集中在一个代码块中。 清晰地描述什么时候发生了什么事,当时的场景是什么样子的。 修改一行代码就只影响这个特性, 其它特性出问题我可不背锅。


u 领导对代码的诉求:说说看,这一坨代码里面都实现了哪些特性? 明天就要发版本了,你改这一行代码真的会导致我失眠。


总之,我们希望代码的局部变更,其影响范围是有清晰边界的。从功能的角度看, 修改了哪个模块的代码,模块对外接口不变, 影响不会扩散到其它模块。 从业务的角度看,开关某个业务特性而不破坏其它特性的服务(特性之间解耦)。 这里有两个边界: 功能性代码的边界、业务特性边界。


6.2 业务的正交维度表达


软件对外的呈现一定是业务(服务)。业务的表达形式可以有很多种。 典型的是画一个流程图; 也有通过 UML 图来说明“代码架构”,进而试图解释业务的数据模型、运作流程。 用 UML 图去解释业务,能够比较清晰地说明数据模型关系。 但是表达业务过程晦涩难懂。 流程图在表达业务过程时有一定优势。 但是在“流程分支复杂”的情况下, 一张图还远远不足以“全面”概括。 按照业务特性的角度用“语言文字”说明一个业务时, 尽管简单易懂,但既不能表达数据模型关系, 也不能表达业务过程。 DDD(领域驱动开发)试图通过领域划分的方式描述业务。


时间线表达形式的问题和局限: 时间线其实不是一个“维度”。 依据时间线确定下来的“流程”从业务的视角看反而是不稳定的。 大学里面教的“状态机”编程,就是一个典型的按时间线程序设计方法。 面对稍微复杂一点的业务, 状态机图的跳转关系,能让人怀疑人生。


三个正交维度的逻辑表达(典型案例):


特性描述:客户是用语言文字来表达需求的。 转换到软件开发里面, 我们用代码来表达需求。 在这个维度里面,我们仅仅试图用代码来描述需求, 而不给出需求的实现方案。 它是“业务意图”的代码表达形式。


信元表示:使用数据对现实世界建模。 这一层不关心需求, 仅使用信元来指代相应的业务实体。 它是对业务的静态建模。 我们把通常业务模型中的“业务意图”以及“动态交互”剥离出来, 使得这个模型的表达简化,成为一个独立的维度。


消息通信:表达交互关系。 谁跟谁有联系, 如何交换数据。在微服务系统中, 消息的交互通常是一个 RPC 调用, 对应一个 API 及其目的。 在复杂的业务系统中, 一个消息通常承载了多个信元, 代表了多个“业务意图”。 由于多目的是复杂的, 因此我们把“业务意图”剥离出来, 让这一层仅仅发挥“通信”的作用。


那么,“动态交互” 的过程在哪里呢? 如果业务意图描述是清晰严谨的, 业务静态模型也是清晰明确的, 消息交互格式是确定的,那么“动态交互”的过程在数学上应当能被严格推导出来。 这个动态过程即“业务流程”。 它可以作为一个独立的模块, 在这三个正交维度之外实现。


6.3 逻辑的两种典型表达形式


鸡生蛋/蛋生鸡是最基本的循环。 如果通过一个过程来描述, 必须先有蛋, 不对,必须先有鸡。但一个“逻辑”表达的时候, 既没有鸡,也没有蛋。 那么“鸡生蛋和蛋生鸡”这个逻辑就不存在了么? 显然逻辑可以脱离具体的“初始状态”来表达。 这给了我们另一个启发, 即我们的程序语言在表示一个逻辑时,也是可以没有“初始状态”的。


u 顺序表达: 典型代表是程序语言/过程。


u 关系描述:典型代表是硬件描述语言。 例如:X 原子随机裂变成 Y 原子, 半衰期是 1 年。


6.4 让代码架构实现革命性的突破


u 核心思想: 面向业务特性去描述。


u 服务系统: 解释、执行业务描述。


u 基础支撑:领域模型、数据模型。


用“第一人称”去表达业务。 环境(Context)更替,而“我”不变。先让我们“异想天开”的方式想象一下“我”怎么样的生活方式才算舒服。 如果“我”想躺在沙发上看电视, 有以下几种方式:


方式一:


我要先找到一个沙发, 然后坐下来。 这时候我发现拿不到电视机的遥控器, 于是我站起来,继续找遥控器。 然后我发现缺了一个电视机, 于是我上网买一个电视机。 然后我发现没电…


在业务流程中, 我们遇到的问题不仅仅是现在要做的事情依赖了另一个事情。 更严重的是这种依赖随时随地发生, 依赖的顺序存在不确定性或者难以找到规律。 而我们不可能在任意一个代码中都为这些“麻烦事”插入另一块代码来解决问题。


在一个缺乏合理资源准备的环境中运行, “我”的内心是崩溃的。


对应我们的代码实现方式,就是当“我”需要某个资源,我就 send 一个消息,从远程服务那里 receive 这个资源。 代码中随时随地有能遇到 send/receive 操作。


方式二:


电视机、遥控器、沙发都放在仓库里面。 “我”要看电视, 先从仓库中翻箱倒柜找到这些“资源”。 然后“我”可以开始看电视了。


尽管有点麻烦,但做任何事情都有一定规律:先找齐资源后使用。 这是在缺乏架构支撑的情况下完成一项“业务”的方式。 代价高,但总能实现。 对应代码架构大概是这样子: 代码集中处理 send/receive, 凑齐了资源,然后开始真正执行“业务”。 偶尔也会遇到中途要 send/receive 捞取资源的情况。


方式三:


有一个专门播放电视的房间。 里面有电视机、遥控器、沙发, 看电视所需要的资源都配置齐全了。“我”只需要走进这个房间,就能看电视了。


这是 DCI 设计方式。 它试图把“我”需要的环境创造出来。 “我”要做的事情很简单, 就是走进房间(context 环境),然后看电视。 Context 中包含了我需要的 role(电视机、遥控器、沙发), 而且没有多余的东西。 环境总是干净整洁的。


方式四:


“我”说了一句“看电视”。 然后我发现我已经躺在沙发上看着电视了。


这个方式“隐藏了过程”, 它是对业务意图描述。 如果一个业务过程是“固定”的, 过程的表达就不重要了。 因为无论“我”是否表达这个过程, 这个过程都能被“无歧义地推导出来”。


这时候我们再回过头来看前文讨论的一个故事。 当我们向领导介绍业务的时候,我们希望尽可能精炼地表达出来。 “精炼”就意味着我们对业务意图表达是正确的、并且不包含那些“可以被无歧义推导出来”的信息。我们对业务的描述, 是否可以精简掉那些“无歧义地推导出来”的信息?


在一个产品内有很多业务特性。 这些特性的“特征”这是我们要重点描述的。“共性”的部分,包括“无歧义地推导出来”的信息, 都可以通过“策略推导”的形式呈现。 使用“精炼”的语言描述一个产品的业务特性之后, 我们将得到这样一个表格: 每一行用来描述一个业务特性。 其中的列信息用于表达特性在不同维度下的“特殊性”。


本文转载自华为云开发者社区


2020 年 1 月 15 日 15:34201

评论

发布
暂无评论
发现更多内容
代码的语文修养-InfoQ