演化架构和紧急设计:利用可重用代码,第1部分-代码与设计之间的

简介:识别出代码中的惯用模式后,下一步是积累和使用它们。理解设计与代码之间的关系有利于发 现可重用的代码。本期的 演化架构与紧急设计 探索代码与设计的关系,使用表达性强的语言的重要性, 以及重新考虑抽象风格的潜在价值。

通过本 系列 的前几期,您已经知道,我的观点是软件的每个部分都包括可重用的代码块。例如,公 司处理安全性的方式在整个应用程序甚至多个应用程序中可能都是一致的。这就是我所说的 惯用模式 的 实例。这些模式代表对构建软件特定部分时遇到的问题的常用解决方案。惯用模式有两种类型:

技术模式 —— 包括事务、安全性和其他基础结构元素。

域模式 —— 包括单个应用程序内或跨多个应用程序的业务问题的解决方案。

在前几期中,我将大部分注意力放在如何发现这些模式上面。但是,发现模式之后,必须能够将它们 作为可重用代码加以利用。在本文中,我将研究设计与代码之间的关系,特别是表达性强的代码如何使模 式的累积变得更容易。您将看到,有时候通过改变抽象风格,可以解决一些看似难以解决的设计问题,并 且可以简化代码。

设计即代码

早在 1992 年,Jack Reeves 写了一篇题为 “What is Software Design?” 的思维敏锐的论文。在 此文中,他将传统的工程(例如硬件工程和结构工程)与软件 “工程” 作了比较,目的是为软件开发人 员拿掉工程这个词上的引号。这篇论文得出一些有趣的结论。

Reeves 首先观察到,一项工程最终交付的成果是 “某种类型的文档”。设计桥梁的结构工程师不会 交付真正的桥。其最终成果是一座桥的设计。然后,这份设计被传到一个建筑团队手上,由他们来建造真 正的桥梁。对于软件而言,类似的设计文档是什么呢?是餐巾纸上的涂鸦、白板上的草图、UML 图、时序 图还是其他类似的工件?这些都是设计的一部分,它们合起来仍不足以让制造团队做出实际的东西来。在 软件中,制造团队是编译器和部署机制,这意味着完整的设计是源代码 — 完整的 源代码。其他工件只 能为创建代码提供帮助,但是最终的设计成果还是源代码本身,这意味着软件中的设计不能脱离源代码。

Reeves 接下来的观点是关于制造成本的,制造成本通常不算工程的一部分,但是是工件的总体成本估 计的一部分。构建物理实体较为昂贵,这通常是整个生产流程中最昂贵的部分。相反,正如 Reeves 所说 的:

“…软件构建起来很便宜。它廉价得简直就像是免费。”

记住,说这句 话的时候,他正在经历 C++ 编译和链接阶段,这可是非常消耗时间的。现在,在 Java™ 领域,每 时每刻都有团队冒出来实现您的设计!软件构建现在是如此的廉价,以至于几乎可以忽略。相对于传统的 工程师,我们有着巨大的优势。传统工程师肯定也很希望能够免费地建造他们的设计,并进行假设分析的 游戏。您能想象吗?如果桥梁工程师能够实时地试验他们的设计,而且还是免费,那么造出来的桥梁将会 是多么的精致。

制造是如此容易,这就解释了为什么在软件开发中没有那么高的数学严密性。为 了取得可预测性,传统工程师开发了一些数学模型和其他尖端技术。而软件开发人员不需要那种级别的严 密分析。构建设计并对其进行测试,比为其行为构建形式化的证明要来得容易。测试就是软件开发的工程 严谨度(engineering rigor)。这也导致了 Reeves 的论文中的一个最有趣的结论:

如果软件 设计相当容易被证实,并且基本上可以免费构建,那么毫不奇怪,软件设计必将变得极其庞大而复杂。

实际上,我认为软件设计是人类有史以来尝试过的最复杂的事情,尤其是在我们所构建的软件的 复杂性不断攀升的背景下。考虑到软件开发成为主流也才大约 50 年的光景,通常的企业软件的复杂性已 经令人瞠目。

Reeves 的论文得出的另一个结论是,在目前,软件中的设计(也就是编写整个源代 码)是最昂贵的活动。也就是说,在设计时所浪费的时间是最宝贵的资源。这将我们带回到紧急设计上来 。如果在开始编写代码之前,花费大量的时间试图参与到所有的事情中来,那么您总会浪费一些时间,因 为一开始有些事情是未知的。换句话说,在编写软件时,您总是陷入意想不到的时间黑洞,因为有些需求 比您想象的更复杂,或者您一开始并没有完全理解问题。越靠后做决定,就越有把握作出更好的决定 — 因为您所获得的上下文和知识是与时俱增的,如 图 1 所示:

图 1. 越靠后做决定,做出的决定就越符合实际

精益软件运动有一个很好的概念叫做 最后可靠时刻(last responsible moment) — 不是将决定推 迟到最后时刻,而是最后可靠时刻。等待的时间越长,就越有机会拥有适合的设计。

表达性

Reeves 论文中的另一个结论是围绕可读设计的重要性的,可读设计又转换成更加可读的代码。发现代 码中的惯用模式已经够难了,但是如果语言中再加上一些额外的晦涩的东西,那就会难上加难。例如,发 现汇编语言代码基中的惯用模式就非常困难,因为该语言强加了太多晦涩的元素,必须环顾四周才能 “ 看到” 设计。

既然设计就是代码,那么应该尽量选择表达性最强的语言。充分利用语言的表达性有利于更容易地发 现惯用模式,因为设计的媒介更清晰。

下面是一个例子。在本系列较早的一期(“组合方法和 SLAP”)中,我应用组合方法和 单一抽象层 (SLAP)原则,对一些已有代码进行了重构。清单 1 显示我得出的顶层代码:

清单 1. 改进后的 addOrder() 方法的抽象

public void addOrderFrom(ShoppingCart cart, String userName,            Order order) throws SQLException {   setupDataInfrastructure();   try {     add(order, userKeyBasedOn(userName));     addLineItemsFrom(cart, order.geTorderKey());     completeTransaction();   } catch (SQLException sqlx) {     rollbackTransaction();     throw sqlx;   } finally {     cleanUp();   }}// remainder of code omitted for brevity

这看上去可以作为不错的惯用模式积累起来。积累惯用模式的第一种途径是使用 “原生” 语言(即 Java),如 清单 2 所示:

清单 2. 重构惯用的 “工作单元” 模式

public void wrapInTransaction(Command c) {   setupDataInfrastructure();   try {     c.execute();     completeTransaction();   } catch (RuntimeException ex) {     rollbackTransaction();     throw ex;   } finally {     cleanUp();   }}public void addOrderFrom(final ShoppingCart cart, final String userName,              final Order order) throws SQLException {   wrapInTransaction(new Command() {     public void execute() {       add(order, userKeyBasedOn(userName));       addLineItemsFrom(cart, order.geTorderKey());     }   });}

以框架作为模式集合

如果您熟悉 Hibernate,那么您将注意到,wrapInTransaction() 方法很像 Hibernate 的 doInTransaction helper。最成功的框架包含的是一组符合实际的技术惯用模式。框架中模式的有用性密 切关系到框架如何得以生存。如果框架是从实用代码中提取的,那么其中的模式更多地关注现实中的问题 。良好的框架(例如 Hibernate、Spring 和 Ruby on Rails)大多经历了实际应用的严峻考验。

另一方面,如果一个框架是在象牙塔中创建的,很多模式听起来很好,但是在实际项目中却没那么有 用。我常提到的一个揣测性框架开发的例子是 JavaServer Faces(JSF)的定制呈现管道 “特性”。它 允许输出各种类型的输出格式(例如 HTML、XHTML 和 WML)。我还没遇到过需要这个特性的开发人员( 虽然我相信存在这样的开发人员),但是您在编写的每个 JSF 应用程序中都为此付出了一点代价。(它 增加了理解事件模型和管道的复杂性。)

在这个版本中,我使用 Gang of Four 的 Command 设计模式,将样板代码抽象到 wrapInTransaction() 方法。addOrderFrom() 方法现在可读性强多了 — 该方法的精华(最深处的两行 )现在更明显了。但是,为了达到那种程度的抽象,Java 语言附加了很多技术性的繁琐的东西。您必须 理解匿名内联类是如何工作的(Command 子类的内联声明),并理解 execute() 方法的含义。例如,在 匿名内联类的主体中,只能调用外部类中的 final 对象引用。

如果用表达性更强的 Java 方言来编写同样的代码,结果会怎样?清单 3 显示用 Groovy 重新编写的 同一个方法:

清单 3. 用 Groovy 重新编写的 addOrderFrom() 方法

public class OrderDbClosure {   def wrapInTransaction(command) {    setupDataInfrastructure()    try {     command()     completeTransaction()    } catch (RuntimeException ex) {     rollbackTransaction()     throw ex     } finally {     cleanUp()    }   }   def addOrderFrom(cart, userName, order) {    wrapInTransaction {     add order, userKeyBasedOn(userName)     addLineItemsFrom cart, order.geTorderKey()    }   }}

该代码(特别是 addOrderFrom() 方法)的可读性更强。Groovy 语言包括 Command 设计模式; Groovy 中任何以花括号 — { } — 括起来的代码自动成为一个代码块,可通过将左、右圆括号放在存放 代码块引用的变量之后执行。这个内置模式使 addOrderFrom() 方法的主体可具有更强的表达性(通过减 少晦涩的代码)。Groovy 还允许消除围绕参数的一些括号,从而减少干扰。

清单 4 显示一个类似的重写版本,这一次用的是 Ruby(通过 JRuby):

清单 4. 翻译成 Ruby 的 addOrderFrom() 方法

def wrap_in_transaction  setup_data_infrastructure  begin   yield   complete_transaction  rescue   rollback_transaction   throw  ensure   cleanup  endenddef add_order_from  wrap_in_transaction do   add order, user_key_based_on(user_name)   add_line_items_from cart, order.order_key  endend

与 Java 版本相比,上述代码更类似于 Groovy 代码。Groovy 代码与 Ruby 代码的主要不同点在 Command 模式特征中。在 Ruby 中,任何方法都可以使用代码块,代码块通过方法主体中的 yield 调用 执行。因此,在 Ruby 中,甚至不需要指定专门类型的基础结构元素 — 该语言中已具有处理这种常见用 法的功能。

抽象的风格

不同的语言以不同的方式处理抽象。阅读本文的人都熟悉一些普遍的抽象风格 — 例如结构化、模块 化和面向对象 — 它们出现在很多不同的语言中。当长时间使用一种特定的语言时,它就成了金锤:每个 问题看上去就像一个钉子,可以用该语言的抽象来驱动。对于纯面向对象语言(例如 Java 语言)来说, 这一点尤为明显,因为主要的抽象就是分层和易变状态。

Java 世界现在对一些函数式语言,例如 Scala 和 Clojure 表现出很大的兴趣。当使用函数式语言编 写代码时,您会以不同的方式思考问题的解决方案。例如,在大多数函数式语言中,默认方式是创建不可 变变量,而不是可变变量,这与 Java 截然相反。在 Java 代码中,默认情况下数据结构是可变的,必须 添加更多的代码,才能使它们具有不变的行为。这意味着以函数式语言编写多线程应用程序要容易得多, 因为不可变数据结构与线程交互起来非常自然,因而代码可以很简洁。

抽象不是语言设计者的专利。2006 年,OOPSLA 上有一篇题为 “Collaborative Diffusion: Programming Antiobjects”的论文,其中介绍了 antiobject 的概念,这是一种特殊的对象,其行为方 式与我们想象的刚好相反。这种方法用于解决论文中提出的一个问题: 如果我们受太多现实世界的启发 而创建对象,那么对象的隐喻可以延伸到很远。

该论文的观点是,很容易陷入特定的抽象风格,使问题愈加复杂。通过将解决方案编写为 antiobject ,可以换一个角度来解决更简单的问题。

这篇论文引用的例子非常完美地诠释了这个概念 — 这个例子就是 20 世纪 80 年代早期最初的 Pac -Man 视频控制台游戏(如 图 2 所示):

图 2. 最初的 Pac-Man 视频游戏

最初的 Pac-Man 游戏的处理器能力和内存甚至不如现在的一些腕表。在这么有限的资源下,游戏设计 者面临一个严峻的问题:如何计算迷宫中两个移动物体之间的距离?他们没有足够的处理器能力进行这样 的计算,所以他们采取一种 antiobject 方法,将所有游戏智能构建到迷宫本身当中。

Pac-Man 中的迷宫是一个状态机,其中的每个格子根据一定的规则随整个迷宫的变化而变化。设计者 发明了 Pac-Man 气味(smell) 的概念。Pac-Man 角色占用的格子有最大的 Pac-Man 气味,而最近腾出 来的格子的气味值为最大气味减去 1,并且气味迅速衰退。鬼魂(追赶 Pac-Man,移动速度比 Pac-Man 稍快)平时随机闲逛,直到闻到 Pac-Man 的气味,这时它们会追进气味更浓的格子。再为鬼魂的移动增 加一定的随机性,这就是 Pac-Man。这种设计的一个副作用是,鬼魂不能堵截 Pac-Man:即使 Pac-Man 迎面而来,鬼魂也看不到,它们只知道 Pac-Man 在哪里呆过。

换个角度简化问题使底层代码更加简单。通过转而抽象背景,Pac-Man 设计者在资源非常有限的环境 中实现了他们的目标。当遇到特别难以解决的问题时(尤其是在重构过于复杂的代码时),问问自己,是 否可以采用某种更有效的 antiobject 方法。

结束语

在本期中,我探讨了为什么表达性是重要的,以及代码中表达性的具体表现。我同意 Jack Reeves 对 于不同工程的比较;我认为,完整的源代码就是软件中的设计工件。一旦理解了这一点,就可以为过去很 多的失败找到解释(例如模型驱动的架构试图直接从 UML 工件转换到代码,最终导致失败,因为这种制 图语言的表达性不足以捕捉所需的细微差别)。这种理解会带来一些负面影响,例如意识到设计(即编写 代码)是花费最大的活动。这并不意味着在开始编写代码之前,不应该使用初期工具(例如 UML 之类的 东西)来帮助理解设计,但是一旦进入编写代码阶段,代码就成为实际的设计。

设计的可读性很重要。设计的表达性越强,就越容易修改,并最终通过紧急设计从中收获惯用模式。 在下一期,我将继续沿着这条思路,并提供利用从代码中收获的设计元素的具体方式。

无论才能知识多么卓着,如果缺乏热情,则无异纸上画饼充饥,无补于事。

演化架构和紧急设计:利用可重用代码,第1部分-代码与设计之间的

相关文章:

你感兴趣的文章:

标签云: