软件开发:如何表达和维护大型逻辑
合抱之木,生于毫末;九层之台,起于累土;千里之行,始于足下。
让我们暂时撇开平台、框架、技术、设计模式、对象思想、敏捷开发论等。 追问程序本质。
从本质上来说, 程序就是一系列有序执行的指令集合。 如何将指令集合组织成可靠可用可信赖的软件(美妙的逻辑之塔), 这是个问题。
程序 = 逻辑 + 控制。 what to do + when to do.
从编程角度来说, 开发者应对的就是逻辑, 逻辑的表达、组织和维护。 逻辑是事物自此及彼的合乎事物发展规律的序列。指令是逻辑的具体实现形式。
逻辑成立的先决条件是合乎事物发展规律。 程序只能处理数值, 却传入了字符串, 就只能报错而无法继续; 当处理海量数据时, 若内存不足, 就会导致程序崩溃; 若程序存在内存泄露, 随着时间的推移而耗尽内存, 也会导致程序崩溃。 多个线程同时修改一个共享变量, 若不加控制, 就会因为不同线程执行修改变量的时序的不确定导致该变量最终值的不确定。 这些就是程序执行的发展规律。 要编写程序, 必定要先通悉这些规律。
规律的表现形式是:如果条件 (C1, C2, ..., Cn) 是产生结果 (R1, R2, ... , Rn) 的充分必要条件, 那么当 C1, C2, ..., Cn 任一不满足条件时, 都不可能产生结果 (R1, R2, ..., Rn) ; 反之, 若结果 (R1, R2, ..., Rn) 没有出现, 则必定是 C1, C2, ..., Cn 某一条件不满足导致。 错误和异常即是 C1, C2, ..., Cn 任一不满足条件的表现。规律的性质是必然的, 不存在可能之说; 只存在人们探索的是否足够精确。编程开发首先应当懂得程序执行的规律, 然后才是实际的开发; 否则就会被程序的结果折腾得死去活来。
在通悉程序执行规律之后, 程序需要解决如下问题:
1. 要表达什么逻辑;
2. 如何表达该逻辑;
3. 如何维护该逻辑。
软件的复杂性表现在如何表达和维护交互复杂的大型逻辑上。
暂时先回到软件的起点, 回顾一下这一切是如何发生的。 最初, 人们使用物理的或逻辑的二进制机器指令来编写程序, 尝试着表达思想中的逻辑, 控制硬件计算和显示, 发现是可行的; 接着, 创造了助记符 —— 汇编语言, 比机器指令更容易记忆; 再接着, 创造了编译器、解释器和计算机高级语言, 能够以人类友好自然的方式去编写程序, 在牺牲少量性能的情况下, 获得比汇编语言更强且更容易使用的语句控制能力:条件、分支、循环, 以及更多的语言特性: 指针、结构体、联合体、枚举等, 还创造了函数, 能够将一系列指令封装成一个独立的逻辑块反复使用; 逐渐地,产生了面向过程的编程方法; 后来, 人们发现将数据和逻辑封装成对象, 更接近于现实世界, 且更容易维护大型软件, 又出现了面向对象的编程语言和编程方法学, 增加了新的语言特性: 继承、 多态、 模板、 异常错误。 为了不必重复开发常见工具和任务, 人们创造和封装了容器及算法、SDK, 垃圾回收器, 甚至是并发库; 为了让计算机语言更有力更有效率地表达各种现实逻辑, 消解软件开发中遇到的冲突, 还在语言中支持了元编程、 高阶函数, 闭包 等有用特性。
为了更高效率地开发可靠的软件和应用程序, 人们逐渐构建了代码编辑器、 IDE、 代码版本管理工具、公共库、应用框架、 可复用组件、系统规范、网络协议、 语言标准等, 针对遇到的问题提出了许多不同的思路和解决方案, 并总结提炼成特定的技术和设计模式, 还探讨和形成了不少软件开发过程, 用来保证最终发布的软件质量。 尽管编写的这些软件和工具还存在不少 BUG ,但是它们都“奇迹般地存活”, 并共同构建了今天蔚为壮观的软件世界。九层之台,起于累土, 这话用在软件开发上, 真是太贴切了。
此外, 软件还经历了“单机程序 => 多机程序 => 分布式程序” 的过程 , 多机联网程序因为多个子系统的交互变得更加复杂。 这里不再赘述。
但请注意, 无论软件发展到多么复杂的程度, 总有一群人, 在试图从程序的本质中探究软件开发的基本问题, 他们试图论证和确保程序的正确性、提炼软件的基本属性并进行衡量; 程序的正确性本质是逻辑学来保证的。 没有逻辑学, 程序根本就无法立足, 更不可能有今天的大规模应用。
软件开发工具让我们更有效率地创造逻辑、 远离语法错误的困扰;
公共库将常用的通用逻辑块封装成可反复使用的组件, 避免不必要的重复劳动;
设计模式体现的是如何可扩展地解决常见的逻辑交互问题;
应用框架解决的是应用的通用逻辑流的控制的问题,让开发者更多地聚焦具体业务逻辑上;
开发技术是在具体的应用情境下按照既定总体思路去探究具体问题解决的方法。
我们要解决的是更通用的问题: 如何以更不易出错的方式去表达和维护大型逻辑 ?
本文尝试从逻辑表达和维护的角度, 探索中大型软件的构建和维护。
表达和维护大型逻辑的终极诀窍就是: 将大型逻辑切分为容易消化的一小块一小块, “不急不忙地吃掉”。
在该方法的实践中, 可以充分利用现有的开发工具、公共库、设计模式、应用框架、开发技术。
1. 独立无交互的大型逻辑或接口实现
独立无交互的逻辑通常体现为公共库, 可以解决常用或公共的日常任务, 对其他逻辑无任何依赖和交互, 即自足逻辑。
应对独立无交互的大型逻辑的首要方法是分解为若干的容易实现、测试和复用的小块逻辑, 编写和严格测试。
其次是运用成熟的编程模式去表达逻辑, 尽可能复用经过严格测试的可靠的库。
独立无交互的大型逻辑通过合理的逻辑块切分、严格的单元测试可以获得充分的测试和可靠度。
2. 独立无交互的耗时长的逻辑或接口实现
快速响应的问题: “用户要求等待时间短” 与 “请求处理耗时长” 之间的矛盾导致的。
解决独立无交互的耗时长的逻辑依然可以采用切分逻辑块、严格的单元测试的做法使之更容易处理;
此外, 有两种设计思路可以考虑: 并发 与 异步。
并发思路是将切分的相互独立的逻辑块分配给不同的控制线程中执行, 从而降低请求处理时长; 并发方案获得的性能提升取决于串行操作在总操作中的时间占比。
异步思路是“先响应, 后处理, 终通知” 的"先奏后斩"方案。将一步分离成了三步, 为了让用户首先获得初步的承诺, 再去履行承诺。 这样做能让用户暂时地放心, 却增加了新的问题: 消息中间件组件的开发与部署、异步消息发送与接收、编程模型的变化和适应。如果整个过程运作良好, 将会达到很好的体验,容易为用户接受。如果其中一步发生差错, 就会导致各种问题, 比如数据不一致, 消息堆积、 请求无法被处理。最终用户等待时间并没有降低, 反而使体验更加糟糕。 当然, 如果成功率为 95%, 也是“可以接受”的, 这样用户可能会怪自己“运气不太好”, 而不会过多怪责系统的不完善。毕竟没有任何事情能够做到完美的地步。
并发与异步方案的调试难度和排查问题都比同步方案增加不少。 每一种新的设计方案都会有其优点, 同时也会有其缺点。 权衡优缺点, 择善而从之 。值得注意的是, 并发方案是针对服务端实际处理请求逻辑而言, 而异步方案是针对请求处理之前是否立即回复的方式。 并发与顺序、 异步与同步两两组合, 可得到四种方式:
顺序同步: 最初的编程模型; 优点是简单、安全、 容易维护和调试; 缺点是性能较低, 响应时间和吞吐量都不高; 若请求处理时长非常短, 采用顺序同步的方案佳;
并发同步: 改进的编程模型; 优点是通过并发提高服务端的处理速度和吞吐量, 但若请求处理耗时较长, 响应时间仍然不高, 影响客户端体验; 若通过并发方案处理请求的时长非常短, 或客户端体验要求不高, 可以采用并发同步的方案;
顺序异步: 改善客户端体验的编程模型; 优点是提高了响应时间和客户端体验, 由于其逻辑处理仍然采用顺序方式, 请求处理时长并未有改善, 因此吞吐量并没有改善。 是一种较好的折衷方案; 若请求处理耗时较长, 影响客户端体验, 且请求处理逻辑复杂, 采用并发方案容易出错或难以并发, 可采用顺序异步方案;
并发异步: 同时改善客户端体验和服务端处理速度; 优点是提高了响应时间、客户端体验和处理速度、吞吐量。 缺点是容易出错, 且不易调试; 若客户端对响应体验要求较高, 请求处理逻辑简单(比如简单的数据拉取和汇总), 采用并发方式可有效提升处理速度, 可以采用并发异步方案;
3. 逻辑块之间的交互耦合与可扩展性
软件的复杂性真正体现在逻辑块的持续长久的交互耦合和可扩展上。这是软件开发与维护中极具挑战性的部分。
逻辑块之间的交互耦合通常体现在三种情境:
a. 操作顺序的依赖。 比如资源更新操作必须在指定资源已经创建的情况下进行。
b. 对共享有限资源的并发申请。 比如打印机只有两台, 却有多个应用程序连接上去请求打印文档;
c. 对共享可变状态的并发访问。 比如两个操作同时要更新数据库中的同一条记录;
三种情境的复杂性均是由并发引起的。 假设所有操作都是串行进行的, 逻辑块的交互无非是“你方唱罢我登场”的次序控制, 而资源对单个请求通常是足够的; 一旦采用了并发方案, 就难以控制逻辑块的执行次序和资源分配的具体情况了, 容易导致某资源对单个请求不足的情况, 从而阻塞多个请求的处理甚至死锁。并发提升了应用的性能, 却增加了出错的风险和几率。并发控制是大型逻辑交互的本质性难点。并发控制的难点在于时序的合理控制和有效资源的合理分配。
对于 a 情境, 通常采用添加前置条件来求解, 在操作之前校验相关资源是否满足、实体状态是否合理, 实体之间的关联是否正确; 若前置条件不满足, 则直接返回错误提示, 或者暂时挂起以备后续继续执行;
对于 b 情境, 需要创建一个可靠适用的资源分配算法 和资源分配模块 , 应用程序不再“自行”去拉取资源, 而是向资源分配模块申请资源, 由资源分配模块根据实际申请的整体情况及申请条件来决定如何分配资源;
对于 c 情境, 需要进行安全的互斥访问, 谨慎地控制。
逻辑块之间的交互耦合应该交给交互解耦模块去完成, 而不是在自己的接口里实现。 也就是说, 只有交互解耦模块知道所有接口之间的交互, 而接口只做自己知道的事情就可以了。否则, 接口 A 与接口 B 必须知道彼此究竟做了什么, 才能正确地做自己的事情。 假设 接口 A 和接口 B 都修改某个资源的状态。 接口 A 在做某项操作执行必须执行 IF (ConditionX) do something ; DoMyOwnThing ; 接口 B 也要根据 A 的逻辑相应地执行 if (ConditionY) do anotherThing;DoMyOwnThing. 而程序员在维护和修改接口 A 的逻辑时, 不一定知道接口 B 的逻辑与之相关, 于是修改不可避免地破坏了接口 B 的逻辑。 耦合的接口数量越多, 或者耦合接口之间的耦合资源越多, 对后期维护和扩展将是一个难以应对的噩梦。
对于逻辑块之间的交互解耦, 或者通俗地说, 模块解耦, 您有怎样的高见, 敬请提出!
4. 实现逻辑时的容错考虑
程序中的逻辑主要是三类:
1. 获取值: 从数据库、网络或对象中获取值。 如果数据库或网络访问足够稳定的话, 可以看成是简单的获取值, 数据库访问和网络访问对获取值是透明的;
2. 检测值: 检测值是否合法, 通常是前置条件校验、 中间状态校验和后置结果校验, 根据检测结果执行“获取值”或“设置值”的逻辑;
3. 设置(拷贝)值: 设置数据库、对象中的值; 或者发送数据和指令给网络。如果数据库或网络访问足够稳定的话, 可以看成是简单的设置值, 数据库访问和网络访问对设置值是透明的;
这三类逻辑可以称为逻辑元。 具体业务逻辑就是基于物理的或逻辑的资源限制, 将逻辑元的组合封装成逻辑块, 有效控制逻辑块的时序交互和资源分配。 时序控制不合理和资源缺乏导致错误和异常。两个程序同时更新一个共享变量, 如果时序不控制, 就会导致错误的结果; 网络通信错误, 是因为网络带宽资源是有限的。
如何应对错误和异常 ? 请参考 《如何使错误日志更加方便排查问题》, 仔细总结了软件错误产生的各种原因及如何预防和定位。 当然, 还有一些复杂的软件错误, 比如事务与并发, 限于开发经验尚浅, 还给不出有效的方案和措施, 需要根据实践学习和深化。这是当错误和异常已经发生时, 该如何更好地定位和解决问题。
预防错误的方法就是进行防御性编程, 进行容错考虑。 多思考: 如果这一步发生错误, 会导致什么问题? 该如何做才能预防这个错误? 如果难以预防, 该如何描述, 才能在出现错误时更好地定位出这样的错误? 在出现错误时, 如何才能恢复到正常合法的状态 ? 如果无法程序自动恢复, 怎样做才能让手工处理更加简单 ?
要健壮地表达和维护大型逻辑, 首先系统整体架构必须足够稳固可靠, 在开发和维护过程中持续加固。 假设一栋建筑整体设计有问题, 那么, 无论里面的房间装饰得多么漂亮优雅, 都会随着建筑的坍塌而消亡。 这需要深入去探究所使用的应用框架, 挖出可能的不可靠风险, 并加以预防和控制。
在已确定的设计方案和业务逻辑的情况下, 如何编写BUG更少的代码:
简明扼要的注释 + 契约式/防御式编程 + 更短小的逻辑块 + 复用公共库 + 严格测试
1. 在方法前面编写简明扼要的注释: 方法用途, 接收参数, 返回值, 注意事项, 作者, 时间。
2. 契约式编程: 在方法入口处编写前置条件校验,在方法出口处编写后置结果校验 ;
3. 防御式编程: 编程时严格校验参数和前置条件; 仔细考虑各种错误与异常的定位和处理;
4. 编写和保持短小逻辑块, 易于为人的脑容量一次性处理, 容易测试;
5. 复用经过严格测试的可靠的公共库; 如果库没有经过很好的测试,但有很好的用处, 帮助其添加测试;
6. 对所编写的代码, 如果不是逻辑元, 都要进行严格测试。