软件设计是怎样炼成的——Gregory T. Brown

作者:Gregory T. Brown,期刊 Practicing Ruby 出版人;非常流行的 PDF 生成库 Prawn PDF 的原作者;IT 咨询顾问,帮助过各种规模的公司确定核心业务问题,力求以最少的代码解决问题。

一、谨记自底向上,优化软件设计

假设你是一门软件设计课程的客座讲师,并且你希望缩小理论与实践的差距

这门课程是你的朋友 Nasir 开设的,但他目前的教学效果不太理想。于是,他请你来帮忙。

在进行案例分析的时候,Nasir 的学生很容易就能抓住要点,并能提出富有创意的问题,从而引出精彩的讨论。但是一旦涉及在自己的项目中运用设计概念,大部分学生都很难把理论和实践联系起来。

目前的问题是,大部分学生并没有构建软件系统的实战经验。这使得学生把软件设计看成是抽象的练习,而不是具象且必要的技能。

课本上的例子强化了自顶向下的软件设计方式,其中的设计理念出现得很突兀。真正的设计不同于此,但是学生经常照葫芦画瓢,进而产生气馁情绪。

为了揭示设计决策的来龙去脉,你将搭建一个小型的实时项目,并在此过程中与学生进行讨论。通过这样的形式,学生将有机会参与迭代设计过程,发挥主动性,一砖一瓦地完成系统的构建。

1. 找出关键词,认清问题

在第一堂课上,Nasir 简单地介绍了你将要搭建的系统:一个即时制生产工作流的小型仿真。

Nasir 没有用理论引入这个主题,而是描述了即时配送如何使网上购物变得更加可行。

  • 当顾客从网上购买商品后,只要其住处与出货点的距离不超过 160 公里,货物一般都可以在一天内送达,最多也不超过两天。

  • 地方仓库的库存量会在防止产品脱销的前提下保持最低水平。补货会持续进行:每当地方仓库向顾客卖出一件商品,紧接着就会有相应的订单发往更大的仓储中心,以便及时为此仓库补货。

  • 仓储中心到地方仓库的货流是持续不断的,所以任何一个需要补充的物品可以立即被扔到卡车、飞机或火车上,前往目的地。

  • 一旦货物从仓储中心运抵地方仓库,补货订单便会自动提交至第三方供应商。很多供应商会使用即时制生产工作流,这样就可以进行小批量的补货。

  • 虽然整个订货流程从头至尾可能会需要几周的时间,但由于有效设置了货流,顾客能够从离其最近的地方仓库接收商品,而且仓库永远都有库存。同时,制造商提供的产品数量和实际卖出的数量大致相同。

在这一模式下,货物即时流向需要它们的地方,这样能使整个生产系统中的浪费和等待时间最小化。这种工作方式在现在已经很常见了,但在几十年前却被看作开创性的行业创新。

Nasir 花了一点时间进行介绍,然后示意你开始上课。为了跟上节奏,你给学生讲了一个小趣闻,以此引入今天所讲内容的几个细节。

:我父亲一生都工作在流水线上,见证了他所在的公司从大批量生产到即时制工作流的变迁过程。

学生:那变化一定很大!好像是完全不同的两种工作方式。

:对,就是这样。公司在业务层面经历了巨大变迁,但生产层面几乎没什么变化。

公司转型之前,器件要一箱一箱地从上游供应方运过来,然后由工人进行加工,再被运往生产线下游。

当公司转型为即时制生产后,这一过程几乎和以前一样——只有一个很小的变化。工作流转向了:只有当空箱子从下游返回时,才会加工新的器件。

学生:所以换句话说,你父亲只有在下一个站点需要货物时才会开始工作?

:没错!从单个站点看不出什么变化,但是整个系统从一开始最简单的部件到最后的成品都被串了起来。

以客户订单为准,从后往前进行生产。整条生产线只需要通过保持相邻站点一致,就能确定需要生产多少元件,以及何时生产。

这种过程深深吸引着我,因为它很有趣,看似简单的基础单元也具有实时性需要。出于这个原因,我觉得从无到有地对这种行为进行建模会很有意思,而且在此过程中我们也可以讨论一些有趣的软件设计原则。

Nasir 问学生是否通过刚才的故事理解了即时制生产工作流并已做好仿真的准备。学生尴尬地笑了,好像不知道他到底是在开玩笑还是认真的。但他随即又问了一个更清楚的问题:这个故事中的关键词是什么?

过了好几分钟,学生终于找出了和仿真相关的很多关键词,如器件(widget)、箱子(crate)、供应方(supplier)、订单(order),以及生产(produce)。

然后,你让学生从这些词中选出两个,组成一个比较容易实现的简单句子。沉思了一会儿后,其中一个学生喊出了他的建议:

“我知道了!我们来做一个箱子,然后往里面放一个器件!”

从这点着手很好,你谢过了那名学生,然后开始工作。

2. 从实现最小化功能入手

开始演示时,你准备了几个极小的 UI 元素,包含的全都是简单的几何图形。你梳理了几个基本逻辑,同时学生看着你工作。

几分钟之内,你就在屏幕上做出一个小矩形,其中有一个圆形,初步代表“箱子中的器件”。

当你按下笔记本电脑上的空格键时,圆形消失了。再次按下时,圆形又出现了。唯恐学生不理解,你重复演示了好几遍……

再次集中注意力后,你列了一个图表,用以描述对象 Crate 的 API。

软件设计是怎样炼成的——Gregory T. Brown_第1张图片

这第一步就需要你做几个设计决策。尽管都是一些小事,但它们会影响到项目剩余部分的设计。

Nasir:我来简要概括一下到目前为止你所做的工作。现在系统中有两个对象:箱子和器件。箱子是可以装器件的容器,而且箱子是可以被装满的。器件还几乎没有被定义,我猜它可以用来表示任何产品吧?

:没错。更进一步来说,我的建模对象是即时制生产系统中材料的流动情况,而真正被处理的内容并不重要。重点是这些箱子的情况,因为我们要确定是否需要生产新的材料。

学生:哦,我感觉有点明白了。你打算把这些箱子作为定制器件的信号,就和你父亲的工厂里的那些一样。

:没错。现在来具体聊一聊这个功能。我们已经做好了箱子,并且可以查看是否需要补货。但是器件是我们凭空造出来的。这里缺了什么模块呢?

学生:某种供应来源?这是整个问题的重点,对吗?我们希望看到从箱子中移除器件的动作可以触发自动生产新器件的动作。所以,每一个箱子都要有一个供应方,并且供应方应该能够察觉箱子何时需要补货。

Nasir:听起来你是在暗示供应方应该监听箱子的状态,这不完全正确。不应该要求供应方主动检查箱子是否需要补货;相反,当器件从箱子中被移除时,供应方应该收到通知。

学生:为供应方加一个监听器,每当 pop() 被调用时就调用这个监听器,如何?

:这些点子都非常好,但是有点太超前了。现在我们缩小范围,然后思考:“好,我们已经收到一个补货请求了。这种情况需要哪些对象的协同参与?”

Nasir:这是个好主意。弄清楚系统中的事件流,与知道事件发生时需要做什么是两回事。我们一步一步地慢慢来吧。

学生开始发现,自底向上进行系统设计的一个难点是将对象之间的纽带解开,以便一小部分一小部分地进行实现,而不是一次实现一整块。这个技能非常重要,因为它有助于实现增量式设计。


你快速画了一张草图来展示填充箱子的过程。其中,你引入了对象 Order,用来将供应方与箱子联系起来。

{95%}

一个学生问对象 Order 的意义是什么。如果让 Supplier 直接操作 Crate,不是更好吗?

这个问题问得很好,尤其是在项目的早期开发阶段。在设计中引入的任何对象都会增加概念包袱,所以无疑需要避免引入多余的对象。

但在此例中,如果不为 Order 建模,就很难区分系统的物理行为和逻辑行为。

在真实的生产车间里,上游的供应方直接将材料装入箱子,让人觉得好像 Crate 才是需要操作的对象。然而,箱子本身只是容器,其传达的信息不过是容量。

关于箱子目的地的真实信息可能存储在工人脑中,也可能打印在一张纸上或箱子外的标签上。这些信息就是 Order 所代表的内容。这个对象很容易被忽略,因为它并不像箱子中进进出出的材料那么明显,但无论如何它仍是模型的一部分。

总结完关于 Order 的全部问题之后,你开始实现填充箱子的工作流。过了一小会儿,你的仿真中又增加了一个三角形和一条线,而且你已经准备好要讲一堂基础几何课了。

软件设计是怎样炼成的——Gregory T. Brown_第2张图片

这些简单图形的含义远比其外表有意思得多。因此,尽管看起来简单,但它们却标志着项目有了实质性的进展。

你向学生解释说,当按下空格键时,方法 order.submit() 就会被调用,从而触发供应方生产器件。一旦生产完成,器件就会被送入目标箱子,从而完成订单。学生开始明白,这些基本单元最终将以某种方式组合在一起,产生更有趣的仿真模型。

3. 避免对象间不必要的时间耦合

几天之后,你该进行第 2 次演示了。自从上次见面之后,你对仿真代码所做的唯一的大改动就是放大箱子的尺寸,这样就可以装更多的器件了。

软件设计是怎样炼成的——Gregory T. Brown_第3张图片

这个很小但很重要的改动使你的模型可以支持软件设计中的 3 个至关重要的量:0、1 和“许多”。{1[这被称为软件设计的 0-1-无穷规则(Zero-One-Infinity Rule),由 Willem van der Poel 提出。]} 前面的例子只涉及前两种情况,但是从现在开始,你需要考虑所有情况。

由于已经建立了箱子填充机制,因此你需要实现的便是,一有物品从箱子里移出,就自动触发填充动作。你问学生如何实现这一功能,一名学生建议在调用 crate.pop() 后立即调用 order.submit()

于是,你按照学生的建议做了细微的变更,然后启动了仿真器。一个被装满的箱子出现了。你告诉学生,已经按照他们的建议设置好了空格键。随后,你按下空格键,屏幕上没有任何变化。你又按了一下,还是没有任何变化。然后你狂按键盘,屏幕闪了一下,但那个被装满的箱子仍然没有变化。

你加了几处日志记录代码,以确定仿真器能接收键盘输入,crate.pop()order.submit() 被成功调用,以及没有产生死循环或递归调用等。看起来一切正常。你注释掉 order.submit() 那一行,又按了几下空格键,器件被一个一个地移除了。你从空箱子开始,注释掉 crate.pop() 调用,然后器件又一个一个地填满了箱子。

Nasir 问学生是否知道哪里出了问题。一名学生很快指出,对器件的移除操作和填充操作发生在同一帧里。因为两个动作之间没有间隔,所以箱子看起来没有任何变化。

为了验证此猜想,你暂时给生产出的器件随机配了颜色。虽然演示结果乱七八糟,但它很好地证实了学生的猜想。

:现在我们已经知道哪里出了问题,那么怎样修复呢?学生:在产出新的器件之前,让 Supplier 暂停一秒,如何?

:这个想法很好,但我们现在的编程环境是异步的,所以并没有直接让进程休眠的方法。需要设置某种回调函数,令其在预设好的延迟之后执行。

学生:好的,那就这样做吧。

:我会的,但是没那么容易。目前,调用 order.submit() 会立即触发对 supplier.produce() 的调用,后者会返回一个 Widget 对象。该对象随即会被送入 Crate。如果在 supplier.produce() 中使用异步的回调函数,就没法得到有效的返回值,这样整条供应链就会断掉。

Nasir:所以现在的情况是典型的时间耦合。在 OrderSupplierCrate 这几个对象之间,存在着时间依赖,这是由它们的设计方式导致的。如果要彻底解决这个问题,就需要重新设计,但现在暂时可以延迟订单提交进程,让它在系统接收到键盘输入之后一秒左右再运行。

你根据 Nasir 的建议做了修改,然后又试了一次。果然,刚一按下空格键,就有一个器件从箱子中消失了。过了大约一秒钟,箱子才被再次填充。随后,你连续快速移除 3 个器件,把箱子清空。过了一会儿,箱子又满了,而且所有新器件都几乎同时出现在箱子中。

看到系统正常工作,学生都很高兴。但你马上提醒他们,这样做治标不治本,其实有点投机取巧。为了使一切正常,需要改良工作流。

你绘制了一张顺序图,用来描述当有订单提交时,系统中的新事件流。

软件设计是怎样炼成的——Gregory T. Brown_第4张图片

实现这个改进后的工作流并不需要对原系统做很大更改。

首先划分对象 Order 的责任,使提交订单和完成订单成为不同的事件。然后修改方法 supplier.produce(),允许它以回调函数的形式进行通信,而不是返回值。

在新的设计中,order.submit() 还是会立即调用 supplier.produce(),但现在是对象 Supplier 决定是否调用以及何时调用 order.fulfill(),从而完成对事件的处理。

Nasir 问了学生几个问题,以确定他们理解了这个小型的重构。很明显,学生能够正确理解执行过程,但仍不清楚做此更改的动机是什么。

你怀疑学生现在还没有理解新工作流如何生成灵活的定时模型。为了说明这一点,你快速实现了 3 种不同的 supplier.produce()

  1. 同步模型

    直接调用 order.fulfill()。可以立即填充器件,也就是像最初设计的那样。

  2. 异步并发模型

    使用异步定时器,让 order.fulfill() 延迟一秒再运行,允许同时处理订单对象。

  3. 异步时序模型

    将新到的所有订单对象全部放入队列,以每秒一个订单的速率相继进行处理。

上述各种实现方式的表现大相径庭,但都用了同一个 Order 接口。这证明已经去除了最初设计中的时间耦合,现在系统已经可以支持任何定时模型了。

全班简短地讨论了一下不同的定时模型以及它们各自的优缺点。

  • 同步模型在逐步实现的仿真中很好用,因为一个事件循环在单位时间内只运行一次。但这样一来,要么需要放弃与系统的实时交互,要么得写一堆乱七八糟的代码鱼目混珠。
  • 异步并发模型很有意思,但是如果不设计更复杂的 UI,那么用它很难说清楚订单的同步处理。
  • 异步时序模型可以在其他可选项之间实现很好的平衡。它可以通过接受新订单,在整体上与系统进行实时交互。但是,器件在系统中的流通过程会随之产生连续且可预测的节奏。

你提出自己的建议:异步时序模型应该能在“有趣”和“易实现”之间找到很好的平衡点。学生也同意你的决定。如果这是一个有预设条件的真实项目,你可能没有条件自己做这个决定。但是,由于去掉了对象间的时间耦合,因此这个决定早晚还是要做的。

4. 逐步提取可复用的组件与协议

至此,你已经构建了供应方和箱子,并提出了按需填充箱子的机制。这些基本结构单元已经提供了运行即时制生产仿真器的大部分组件;剩下的任务只是构建一个“机器”(Machine),既作为器件的消费者,也作为其生产者。

和学生仔细讨论了一些想法后,你决定,这个机器应该负责将两个输入源转换为一个联合的输出流。为了使每个人都参与思考,你做了一个图样,展示仿真器在添加这一新功能之后的样子。

软件设计是怎样炼成的——Gregory T. Brown_第5张图片

Nasir 想让学生解释一下怎样实现这一新模型,但看起来他们都被这问题难住了。你思考了一会儿,寻找其中的原因。你发现学生的注意力都集中在寻找新系统和之前有什么不同上,所以他们看不到二者的相同点。

你降低了要求,让学生考虑如何使用他们已经熟悉的组件实现一个简化的系统。

软件设计是怎样炼成的——Gregory T. Brown_第6张图片

:这个例子有 3 个供应方和 3 个箱子。为了更容易理解,假设子系统是完全独立的。如果我们从其中任意一个箱子中移除一个器件,会发生什么呢?

学生:那样会触发提交一个填充订单。过一会儿后,供应方会完成订单,然后就会出现一个新器件。

:很正确!现在我们对系统做一个小小的改变。假设最右边的这个供应方每次完成一个订单时,会消耗其左边每个箱子中的一个器件。这时会发生什么呢?

学生:左边的箱子就需要填充,所以订单会被自动送到它们的供应方那里。

:完全正确。现在如果回头再去看之前的图样,就会更容易理解机器的工作方式。它和供应方一样会产出器件,但在此过程中,也会消耗上游箱子中的器件,继而触发向上游箱子的供应方提交订单。一个麻雀虽小五脏俱全的即时制生产工作流就这样产生了。

听了你的解释后,一名学生建议为对象 Supplier 建一个名为 Machine 的子类,这样就可以复用现在的 Order。你没有直接回应,而是请全班同学复查 Supplier 的实现,让他们自己总结并得出结论。

学生现在明白,对象 Supplier 的工作其实很简单:生成一个新器件,然后调用 order.fulfill() 完成填充操作。如果让 Supplier 立即完成订单,那么一行代码就可以实现。但是定时模型使问题变得有些复杂。

Supplier 自己有一段代码用来实现初步的异步时序工作队列。Nasir 很快指出可以复用这段代码,因为机器也需要实现延时订单处理,而且供应方已经实现了这部分功能。剩下的唯一问题是如何复用这段代码。

学生:如果创建一个子类会不会比较好?这样一来,MachineSupplier 这两个对象就可以共享不少代码。

Nasir:咱们现在先不考虑二者之间有什么相同点,单纯考虑这个工作队列如何实现。它只是一个由函数构成的有序队列,这些函数逐个执行,间隔时间固定。那么这一过程对于 Supplier 的概念有什么特别之处呢?

学生:我觉得应该没什么特别的吧。你是说这只是一个实现细节问题吗?

Nasir:不完全是。我想说这是我们所用的工具链中缺失的一环。异步工作队列是极其普通的结构,但是因为我们用的语言没有内置这一结构,所以需要从头开始构建。

:我刚开始就想到给工作队列创建自己的对象,但是之后意识到,如果再等一下,就会引出你们刚才的精彩对话。

Nasir:换句话说,你把选择权给了大家?够可以的啊!

虽然 Nasir 有些贫嘴,但是推迟决策确实是自底向上设计的重要部分。过早提取对象,然后尝试去想象未来的使用情况,可能导致接口变得乱七八糟;一旦考虑实际需求,就能更

轻松地进行接口设计。

再回到手头这个问题,你用了一点时间调整一些函数在代码库中的位置,然后为新建的对象 Worker 编写了 API 文档,如下所示。

软件设计是怎样炼成的——Gregory T. Brown_第7张图片

重构之后,对象 Supplier 没剩多少代码了,所以不能把它当作基类。你从剩余部分中复制并粘贴了一些有用的代码,然后开始实现对象 Machine

你首先加入了几个基本功能,使机器和上游箱子联系起来,这部分进展顺利。但此后的工作就变得有些复杂了:需要对 Crate 进行一些调整,以支持新添加的 Machine 结构。

你最终做的变更并不大,但很有代表性。当对象在与其设计初衷不同的场景中被复用时,经常需要这样的变更。

  • 在只包含一个供应方和一个箱子的场景中,知道箱子是否空着并不重要。只要在器件从箱子中被移除时,能够立即提交填充订单即可。但机器只有在其上游箱子都装有器件时才能完成订单,所以你实现了方法 crate.inStock() 来获取此信息。

  • 每个订单对象都会引用一个箱子对象,但反过来则不会。Crate 和与之对应的 Order 都已定义,因此系统在顶层运行良好。但在其中引入机器时,一切就变得乱七八糟了。为了让机器在消耗上游器件的同时提交填充订单,你用了一个涉及闭包的技巧,但解释起来既不简洁也不容易。{2[对这个问题的正确处理方法应该是回过头,在对象 Crate 中给特定的 Order 加引用,但假设客座讲师的时间非常有限,你不想再去仔细考虑设计决策了。而这时出现了一种补救方法,可以掩盖种种细节,让学生能够将注意力集中在更重要的知识点上。]}

你坦言,这种意外出现在对象连接点上的设计瑕疵,其实正是自底向上进行设计的缺点。但为了让大家重拾乐观情绪,你给学生展示了机器的一个可用版本,其订单数量可以实时更新。

软件设计是怎样炼成的——Gregory T. Brown_第8张图片

为了实现这个新特性,你只在系统内部做了一些小改动,并增加了几个帮助函数。除此之外,没有对 API 进行大的变更。这表示,整体设计到目前为止效果良好。

5. 进行大量实验,发掘隐藏抽象

目前,工作中最困难的部分已经完成。Nasir 给了学生一些时间,让他们讨论如何对这个仿真做一些小的变更,以便测试此设计的优缺点。

开始时,学生提出的建议正如你所料,比如使不同的供应方和机器拥有不同的生产速度和箱子大小。看着系统根据瓶颈限制动态平衡工作量,大家觉得很有意思。他们对这些想法又继续探讨了一段时间,但结果并没有揭示任何与仿真设计相关的问题。

为了引导学生进行更有趣的讨论,Nasir 让他们实现一种新的机器。一名学生提议建立一个“纯化”模型:机器接收单个输入,并且产生单个输出,但在此过程中改变器件的类型。

Nasir 开始回应这名学生。但他还没说完,你就已经实现了这种新机器,并使它运转了起来。你将其输出放进一个“合并器”中,让这个例子更加生动。

软件设计是怎样炼成的——Gregory T. Brown_第9张图片

刚开始,Nasir 以为你已经预料到学生可能会问这种问题,所以提前写好了部分代码,但你很快指出并非如此。

实际上,这和你对合并器的定义有关:这种机器会从它的每一个供应箱中获取一个器件,然后输出一个器件。

根据这一定义,很容易实现纯化器(即只有一个供应箱的合并器)。因此,你可以很快实现这一新特性,而不用写任何新代码。

另一名学生甚至提出了更深一层的建议。他认为可以创建一种新机器,使其和对象 Supplier 的工作方式一样,即没有供应箱,因为任何集合与空集取并,其结果总为该集合本身。

这个提议令你很吃惊,因为你在创建对象 Supplier 时,从来没想过这个问题。但是果然,这个想法行得通!

软件设计是怎样炼成的——Gregory T. Brown_第10张图片

学生们就这一主题提出了很多别的设想,包括在机器之间建立环形依赖、单一输入源为多个机器提供输入,等等。所有设想都如愿实现,虽然你在创建系统时并没有明确地针对这些用例做计划。以自底向上的方式进行设计的系统会有一些令人惊喜的性质。

Nasir 认为这节课该结束了,所以试着进行总结。他告诉学生,虽然这种实验很有趣,但只是为了帮助探索一些抽象概念。至于这些抽象概念是否能被正式支持,还要看它们是否能被证明有用。这种实验的目的并不是请大家去发现“隐藏特性”,然后不假思索地立即使用它。

学生似乎很好地理解了这一点。你对 Nasir 的提醒感到欣慰,因为你自己也时常忘记这一点。

软件设计是怎样炼成的——Gregory T. Brown_第11张图片

你可能感兴趣的:(程序人生,The,Coder,Vol.1,:,向上生长)