演化架构和紧急设计: 利用可重用代码,第 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()); } }); } |
在这个版本中,我使用 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 end end def add_order_from wrap_in_transaction do add order, user_key_based_on(user_name) add_line_items_from cart, order.order_key end end |
与 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 之类的东西)来帮助理解设计,但是一旦进入编写代码阶段,代码就成为实际的设计。
设计的可读性很重要。设计的表达性越强,就越容易修改,并最终通过紧急设计从中收获惯用模式。在下一期,我将继续沿着这条思路,并提供利用从代码中收获的设计元素的具体方式。
学习
- The Productive Programmer (Neal Ford,O'Reilly Media,2008):Neal Ford 最近撰写的这本书对本系列中的很多话题作了详细的阐述。
- “What is Software Design?”(Jack Reeves,C++ Journal,1992; developerdotstar.com 上转载):阅读这篇关于编程与软件设计的关系的论文。
- Design Patterns (Erich Gamma 等,Addison-Wesley,1995):关于设计模式(包括 Command 模式)的经典著作。
- “实战 Groovy:Groovy:Java 程序员的 DSL”(Scott Davis,developerWorks,2009 年 2 月):开始阅读 实战 Groovy 系列,了解 Groove 的先进语法如何让您编写可读性更强(且更少)的代码。
- “脱离 Rails 看 Ruby”(Andrew Glover,developerWorks,2005 年 12 月):从 Java 开发人员的角度认识 Ruby。
- Hibernate:这个流行的开放源代码对象-关系映射框架封装了很多便利的惯用模式。
- Spring:Spring 框架被认为是 Java 领域中最有用的框架之一。
- Ruby on Rails:Rails 是用于使用 Ruby(包括 JRuby)语言创建 Web 应用程序的高级框架。
- “Collaborative Diffusion: Programming Antiobjects”(Alexander Repenning,OOPSLA 2006):这篇论文描述了 antiobject 抽象方法。
- 浏览 技术书店,找到关于这些技术专题和其他技术专题的书籍。
- developerWorks Java 技术专区:这里有数百篇关于 Java 编程各个方面的文章。
获得产品和技术
- 下载 IBM 产品评估试用版软件,或者 IBM SOA Sandbox for People,获得来自 DB2®、 Lotus®、Rational®、Tivoli® 和 WebSphere® 的应用程序开发工具和中间件产品。
讨论