原文链接http://alistair.cockburn.us/Hexagonal+architecture,作者Alistair Cockburn,由于作者网站上没有明显标注使用何种授权方式,这里默认CC 3.0 BY。由于翻译有多处不通顺,若要深入了解”六角架构“。请Google其他博文一起整体来看。
创建的应用应该在没有UI或数据库(这样你可以对应用进行自动回归测试)时能工作,当数据库不可达时能工作,并且可以在无用户参与的情况下将应用连接在一起。
Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases.
允许应用可以平等地被用户、程序、自动化测试或批处理脚本驱动,而且可以被单独开发和测试,并不受最终运行时所处的设备和数据库的影响。
当外界事件到达一个端口时,某个特定技术的适配器把它转换为可用的过程调用或者消息传递给应用。应用完全不知道输入设备的特征。当应用有东西需要发送出去时,它会通过一个端口,发送给某个适配器,由适配器来生成接受端技术(人或自动化的)所适合的信号。应用在其所有边界上都可以与适配器进行语义上的良性互动,而不用知道适配器另一端上东西的特征。
软件应用的一个很恐怖的东西就是随着时间的增长,业务逻辑对用户界面代码的渗透。它引起的问题有三重:
首先,系统不能被自动化测试套件简洁地测试了,因为需要测试的一部分逻辑依赖于经常变化的视觉细节,比如字段/文本框大小和按钮位置;
同理,它不可能从人操作的系统转变为批处理的系统;
也同理,它更加困难或不可能允许程序被另一个更有吸引力的程序来操作。
在很多机构中不断尝试的解决方案,是在架构中新增一层,保证千真万确不再有业务逻辑混入这层。但是,因为没有机制来检查违反这个保证的发生,几年后,这个机构又发现该层到处都是混乱的业务逻辑,老问题再次出现了。
现在假设每个应用提供的功能都可以通过API(应用的程序接口)或函数调用来访问。这样,测试或QA部门可以在应用上允许自动化测试脚本来检查新代码是否破坏了此前正常的功能。业务专家可以在GUI细节完成之前,编写自动化测试用例,通知程序员是否有bug(这些测试也可以是测试部门编写的)。应用可以部署为”无头(headless)“模式,只要API可用,其他程序就可以使用其功能 - 这简化了复杂应用套件的整体设计,并且允许业务到业务的服务应用间相互使用,而不需要通过web的人工交互。最后,自动化的功能回归测试可以检查到违反保证的发生,确保业务逻辑不会混入表示层。这个机构就可以检查并修正逻辑泄露了。
一个很有意思的类似问题位于通常认为应用的”另一边“,这里应用逻辑绑定到外部的数据库或其他服务。当数据库挂掉了或者正在经受重大返工/替换,程序员就不能正常工作了,因为他们的工作需要数据库可用。这引发延期成本以及员工的反感。
两个问题之间关联的不太明显,但是在解决方案中它们是对称的。
用户端和服务器端的问题其实都由设计和编程中相同的错误导致 — 业务逻辑、与外部实体的交互这两者的牵连。使用的不对称(the asymmetry to exploit)不是在应用的左右两边,而是在应用的内外两边。应该遵循的规则是,适用于内部的代码不应该泄露到外部。
暂时先不管左右或上下的不对称,我们看到应用与外部的通信是通过”端口“进行的。”端口“应当引起大家对操作系统中”端口“的回忆,任何遵循某端口协议的设备均可以插入其上;电子器件的”端口“,也同理,任何适合机械和电子协议的设备也可以插入其上。
端口的协议由两个设备之间会话的目的决定。
协议的形式采用应用程序接口(API)。
对任一个外部设备,都有一个”适配器“将API的定义转换为设备所需的信号,反之亦然。图形用户接口/GUI是个适配器的例子,因为它将人的行动映射到端口的API上。其他符合相同端口的适配器是自动化测试工具,比如FIT/Fitness,批处理驱动,以及任何用于企业间或网络间应用通信的代码。
应用的另一边,应用与外部实体进行通信是为了获取数据。协议通常是数据库协议。从应用的角度讲,如果数据库从SQL数据库变成文本文件或其他类型的数据库,API间的通信协议应该不会变。该端口的其他适配器包括SQL适配器,文本文件适配器,以及最重要的,”模仿“数据库的适配器,它驻留在内存,不依赖于真正数据库是否存在。
很多应用只有2个端口:用户端的对话框(dialog)和数据库端的对话框。它们的外观因而不对称,使构建单维度的,3层、4层或5层的分层架构更显得自然。
这幅图有2个问题。第一个,也是最糟的一个,人们不认真对待分层图中的”线(lines)“。他们让应用的逻辑泄露越过层的边界,引发上文提到的问题。第二,应用可能有超过2个端口,这样架构不适用于一维层状图(one-dimentional layered drawing)。
这个六角架构,或者端口和适配器架构,通过指出此情形下的对称性而解决了这个问题:内部应用通过端口来与外部通信。应用外的东西也可以相同处理。
六角架构目的是直观地强调
(a) 内外部的不对称和端口的类似特征,摆脱一维层状图及其引发的一切,及
(b) 固定数量不同端口的存在性 – 2个,3个,或者4个(4个是目前我遇到的最大的数量)。
这个六角形不是因为6很重要而是个六角形,而是容许人们根据自身需求插入端口和适配器,而不受一维层状图的限制。”六角架构“这个术语来自这个直观的效果。
”端口和适配器“这个术语采用了这幅图的”目的“。端口定义了一个有目的的会话。任一端口一般都有多个适配器,为了应对插入其上的不同技术。一般,其中包括电话答录机,人的声音,按键式电话,图形用户接口,测试工具,批处理驱动,http接口,直接程序到程序的接口,(内存)模拟数据库(in-memory mock database, mock),真正的数据库(开发、测试、生产用的数据库可能不同)。
"应用手册"中,左右的不对称将会被再次提到。但是,这个模式的主要目的是集中在内外的不对称,先简单假装对应用来说,所有的外部项都是完全相同的。
Figure 2显示应用有两个活动端口,每个端口都有很多适配器。这两个端口是应用的控制端和数据的获取端。这幅图显示,应用可以平等地被自动化、系统级回归测试套件,人类用户,远程http应用,或其他本地应用驱动。在数据端,应用可以通过使用驻留内存的oracle、“模拟”数据库、数据库备份被配置为与外部数据库解耦 ;或者使用测试或运行时数据库来运行。应用的功能规范,可能在某些用例中,不利于内部六角形的接口,但不可以与可能使用的外部技术相冲突。
Figure 3显示同一个应用映射到三层架构图的情况。为了简化这幅图,每个端口只显示了两个适配器。这幅图的目的是显示适配器是如何嵌入到顶层和底层的,以及系统开发中,不同适配器的使用顺序。带数字标号的箭头显示一个团队可能开发和使用这个应用的顺序:
使用FIT测试工具(test harness)来驱动应用并且使用模拟(驻留内存)数据库来代替真正的数据库;
给应用添加GUI,仍然运行模拟数据库;
在集成测试中,使用自动化测试脚本(如来自Cruise Control的)驱动应用,使用包含测试数据的真正的数据库;
正式使用中,用户使用应用访问实时数据库(live database)。
能展示端口和适配器的最简单应用幸好可以在FIT文档中找到。它是个简单的折扣计算应用:
discount(amount) = amount * rate(amount);
在我们改编的中,amount将来自用户,rate来自数据库,这样就会有2个端口。我们逐步实现它们:
测试中使用常量rate而不是模拟数据库,
然后是GUI,
接着模拟数据库(可以被替换为真正数据库)。
Thanks to Gyan Sharma at IHC for providing the code for this example.
首先我们创建HTML表作为测试用例(查看本例相关FIT文档):
TestDiscounter | |
amount | discount() |
100 | 5 |
200 | 10 |
注意列名将会在程序中变成类和函数名字。FIT提供了一些方式来避免这种程序员行话(programmerese),但是对这篇文章先不要管它们。
知道了测试数据是什么,接着我们创建用户端适配器,ColumnFixture摘自FIT:
import fit.ColumnFixture; public class TestDiscounter extends ColumnFixture { private Discounter app = new Discounter(); public double amount; public double discount() { return app.discount(amount); } }
这就是事实上适配器的所有代码。目前,测试通过命令行来运行(查看FIT文档中你可能需要的路径)。我们使用这个:
set FIT_HOME=/FIT/FitLibraryForFit15Feb2005 java -cp %FIT_HOME%/lib/javaFit1.1b.jar;%FIT_HOME%/dist/fitLibraryForFit.jar;src;bin fit.FileRunner test/Discounter.html TestDiscount_Output.html
FIT生成一份输出文件,并用不同颜色标示哪些通过(或失败,以防我们在某些地方有打印错误)。
现在,代码准备完毕,可以挂到Cruise Control或者你自己的自动构建程序,并且要包含在构建/测试套件中。
我将让你来实现自己的UI来驱动Discounter应用,因为如果把代码贴过来会很长。其中的关键代码如下:
... Discounter app = new Discounter(); public void actionPerformed(ActionEvent event) { ... String amountStr = text1.getText(); double amount = Double.parseDouble(amountStr); discount = app.discount(amount)); text3.setText( "" + discount ); ...
现在,应用可以展示和回归测试了。用户端适配器都可以运行了。
为数据库端创建可替换的适配器,我们先为一个仓库创建“接口”,’’RepositoryFactory’’将生成模拟数据库或真正的服务对象,以及数据库的内存模拟。
public interface RateRepository { double getRate(double amount); } public class RepositoryFactory { public RepositoryFactory() { super(); } public static RateRepository getMockRateRepository() { return new MockRateRepository(); } } public class MockRateRepository implements RateRepository { public double getRate(double amount) { if(amount <= 100) return 0.01; if(amount <= 1000) return 0.02; return 0.05; } }
要将这个适配器挂到Discounter应用,我们需要更新应用本身来接受仓库适配器,并使用(FIT或UI)用户端适配器把选用的仓库(真正或模拟)传递给应用自己的构造器。这里就是更新后的应用,还有将模拟仓库传递过来的FIT适配器(选择模拟或真正仓库的FIT适配器代码更长,即使不添加新信息,所以这里我忽略了那个版本)。
import repository.RepositoryFactory; import repository.RateRepository; public class Discounter { private RateRepository rateRepository; public Discounter(RateRepository r) { super(); rateRepository = r; } public double discount(double amount) { double rate = rateRepository.getRate( amount ); return amount * rate; } } import app.Discounter; import fit.ColumnFixture; public class TestDiscounter extends ColumnFixture { private Discounter app = new Discounter(RepositoryFactory.getMockRateRepository()); public double amount; public double discount() { return app.discount( amount ); } }
这包含了最简版本的六角架构实现。
如果想看使用浏览器的Ruby和Rack的实现,在这里 https://github.com/totheralistair/SmallerWebHexagon
端口和适配器模式故意假设所有端口基本类似。在架构层面,这个假设很有用。在实现中,端口和适配器有两种风格,我叫做"主要/primary"和“次要/secondary”,你马上将会看到为什么这样叫。也可以叫“驱动的/driving”适配器和"被驱动的/driven"适配器。
警觉的读者可能已经发现,在所有给出的例子中,FIT夹具(fixture,用于测试的数据)用在左边的端口,而模拟数据库/mocks用在右面。在三层架构中,FIT位于顶层,模拟数据库位于底层。
这与用例中"主要参与者/primary actors"和“次要参与者/secondary actors”的观点相关。“主要参与者”是驱动应用的参与者(离开静默状态去执行其公开的功能)。“次要参与者”是应用为了获取响应或仅仅唤醒而驱动的参与者。“主要”和“次要”的本质区别在于,谁触发,或者谁控制整个会话。
替代“主要”参与者的合适测试适配器是FIT,因为这个框架被设计为读取脚本并驱动应用。替代“次要”参与者(如数据库)的合适测试适配器是模拟数据库,因为模拟数据库被设计用于回应查询或记录应用的事件。
这些观察使我们遵守系统的用例前后关系图(context diagram),把“主要端口”和“主要适配器”画在六角形的左边(或顶上),把“次要端口”和“次要适配器”画在右边(或底下)。
最好能记住主要和次要端口/适配器间的关系,以及它们在FIT和模拟数据库中的对应实现,但是它是使用端口和适配器架构的结果,而不是越过它。使用端口和适配器实现的最终好处是可以在完全隔离的模式下运行应用。
使用六角架构模式来提升编写用例的最佳方法,会非常有用。
编写用例的一个常犯的错误是过度熟悉每个端口外部的技术。这些用例在业界理所当然地获取了以下坏名声:太长,难读,令人厌烦,琐碎,难于维护。
理解了端口和适配器架构,我们看到用例一般应该写在应用边界上(内六角形),来指定应用支持的功能和事件,而不管外部技术。这些用例会更短,更易读,更容易维护,而且更加稳定。
端口是或者不是什么,很大程度上仁者见仁。在一个极端上,每个用例都应该分配自己的端口,这样应用就会有数百个端口。或者,我们可以假想把所有的主要端口和所有的次要端口分别合并到一起,这样只会有两个端口,一个左边,一个右边。
这两个哪一个看起来都不是最佳的。
在“已知例子”中提到的天气系统有4个正常端口:天气订阅源(feed),管理员,被通知的订阅者,订阅者数据库。咖啡机控制器有4个正常端口:用户,包含菜单和价格的数据库,自动发放机,硬币箱。医院药物系统可能有3个:护士一个,药方数据库一个,电脑控制的药物自动发送机一个。
虽然看不出选错端口数量有何特定问题,所以这可能是个直觉问题。我倾向于一个较小的数量,比如2,3或4个端口,在上文以及“已知例子”中都有提到。
Figure 4的应用有4个端口,每个端口都有很多适配器。它来自某个应用,这个应用监听全国天气服务发出的各种警告,比如地震、台风、火灾、洪灾,并通过电话或电话答录机来通知人们。我们讨论这个系统时,系统的接口被认为是“基于技术的,与目的相关”。系统里有一个等待送线马达(wire feed)的触发数据的接口,一个用于发送通知数据到答录机(answering machine)的接口,一个用于GUI实现的管理接口,一个获取订阅者数据的数据库接口。
开发人员非常头疼,因为他们需要添加一个来自天气服务的http接口,面向订阅者的email接口,他们必须找到一个方法根据不同客户的购买偏好来打包和分拆不断增长的应用套件。当他们尝试实现、测试和维护用于所有组合和排列的不同版本时,他们害怕遇到了维护和测试的噩梦。
他们在设计上的转变是“按照目的”而不是按照技术来架构系统,并使得这些技术(各个边上)可以被适配器替代。他们立即可以添加http源和email通知了(新的适配器在图中用虚线表示)。每个应用通过API方式运行在无头模式,这使得他们可以添加一个app-to-app适配器,分拆应用套件,按需连接子应用。最后,每个应用可以使用恰当的测试和模拟适配器完全隔离地执行,这样,他们可以使用独立自动化测试脚本对应用进行回归测试。
20世纪90年代初,Macintosh应用如文字处理应用,需要API驱动的接口,这样应用和用户编写的脚本可以访问应用的所有功能。Windows桌面应用也进化出相同的能力(我不了解其中的历史知识,不敢说哪个首先出现,也不确定是否与此有关)。
当前(2005)的web应用的趋势是公开API,让其他web应用直接访问这些API。因此,可以在Google地图上公布本地犯罪数据,或者创建一个包含Flickr图片归档并带注释的web应用。
这些所有的例子都是关于公开“主要”端口的API。我们这里没有找到关于次要端口的信息。
Willem Bogaerts在C2 wiki上写了一个例子:
“我遇到了类似的问题,但主要是因为我的应用层因为处理了它不该做的事情,而几乎成了一个电话交换机(很混乱)。我的应用生成输出,展示给用户,然后可能也存储下输出。我主要的问题是,你不必总是存储。所以我的应用生成输出,不得不缓存下,展示给用户。然后用户希望存储输出的时候,应用从缓冲区获取输出,才真正存储。
我一点都不喜欢这样。然后我想到了一个方案:给存储设备加个显示控制(presentation control)。现在应用不再在不同目录间切换,它只需要输出给显示控制就好了。显示控制来缓存答案,让用户来选择是否存储。
传统分层架构强调“UI”和“存储”是不同的。端口和适配器架构可以通过简单地再输出来减少输出。”
“在我所在的项目,我们对组合式立体声系统(component stereo system)使用系统隐喻(SystemMetaphor)。每个组件都有定义好的接口,每个接口都有特定的目的。我们几乎有无数种方法用电缆和适配器把组件组合起来。”
这个仍在试验阶段,还不算这个模式的一个例子。但是思考下也很有趣。
各地的团队都按照六角架构使用FIT和模拟数据库,这样应用或组件可以在独立模式下测试。CruiseControl构建每半小时运行一次,运行的所有应用都采用FIT+模拟数据库的组合。当应用子系统和数据库完成了,模拟数据库会被替换为测试数据库。
这个还在早期试验中,也不算这个模式的例子。但是思考下也很有趣。
UI设计是不稳定的,因为它们没有建立在一种强有力的技术或者某种隐喻之上。后端服务架构还没确定,事实上会在接下来的6个月内多次变更。但是,项目已经正式开始,并且时间紧迫。
应用团队创建FIT测试和模拟数据库来隔离他们的应用,创建可测试、可展示的功能展示给用户看。当UI和后端服务最终确定,就可以直接向应用中添加这些元素。开始学习这是如何工作的(或者自己尝试下,给我写信告诉我)。
“设计模式”这本书包含了对一般化的“适配器”模式的描述:“将一个类的接口转换为客户端期望的另一个接口”。端口和适配器模式是“适配器”模式的一个特殊例子。
MVC模式最早在1974年一个Smalltalk项目中实现。多年以来,已经出现了很多变体,如模型-交互器(Model-Interactor)和模型-视图-表示器(Model-View-Presenter)。它们每个都只实现了端口和适配器的主要端口,而不是次要端口。
"模拟对象(mock object)是个用于测试其他对象的“双面间谍(double agent, 双面代理)“。首先,模拟对象模仿一个外部的正确实现的行为,但本身却是接口的错误实现。第二,模拟对象观察其他对象如何通过方法交互,并根据预先设定的期望与事实上的行为比较。差异发生时,模拟对象中断测试并报告反常。如果差异在测试中无法被察觉到,一个称作tester的验证方法会保证所有的期望都会达到或失败被报告” — Fromhttp://MockObjects.com
完全根据模拟对象的规程(mock-object agenda)来实现,模拟对象用在整个应用中,而不仅仅在外部接口上。模拟对象运动最主要的推动在于单个类和对象级别的特定协议的一致性。我借用"mock/模拟"一次来作为对外部次要参与者的内存替代品的最简单描述。
回环(loopback)模式是为外部设备创建内部替代品的显式模式。
在”创建分层架构的模式“中,Barry Rubel描述了一个在控制软件上非常类似端口和适配器,关于创建对称轴的模式。The ‘’底座/Pedestal’’模式提倡在系统内实现一个对象来表示一个硬件设备,并在控制层将对象关联到一起。“底座”模式可以用于描述六角架构的某一边,但不强调适配器间的相似性。而且,因为针对机械控制环境编写的,将这个模式用于IT应用也不是很容易。
Ward Cunningham针对检测和处理用户输入错误的模式语言,对内部六角形边界上的错误处理很有好处。
Bob Martin的依赖反转原则(也被Martin Fowler叫做依赖注入)意思是:“高层模块不应该依赖低层模块。两者都应该依赖抽象。抽象不应该依赖细节。细节应该依赖抽象。”Martin Fowler的"依赖注入"模式给出了一些实现。这些展示了如何创建可交换的次要参与者适配器。代码直接打出来(就像本文的示例代码一样),或者使用配置文件并通过SPRING框架来生成等价代码。
Thanks to Gyan Sharma at Intermountain Health Care for providing the sample code used here. Thanks to Rebecca Wirfs-Brock for her book ‘’Object Design’’, which when read together with the ‘’Adapter’’ pattern from the ‘’Design Patterns’’ book, helped me to understand what the hexagon was about. Thanks also to the people on Ward’s wiki, who provided comments about this pattern over the years (e.g., particularly Kevin Rutherford’s http://silkandspinach.net/blog/2004/07/hexagonal_soup.html).
【FIT】FIT, A Framework for Integrating Testing: Cunningham, W., online at http://fit.c2.com, and Mugridge, R. and Cunningham, W., ‘’Fit for Developing Software’’, Prentice-Hall PTR, 2005.
【适配器模式】The ‘’Adapter’’ pattern: in Gamma, E., Helm, R., Johnson, R., Vlissides, J., ‘’Design Patterns’’, Addison-Wesley, 1995, pp. 139-150.
【底座模式】The ‘’Pedestal’’ pattern: in Rubel, B., “Patterns for Generating a Layered Architecture”, in Coplien, J., Schmidt, D., ‘’PatternLanguages of Program Design’’, Addison-Wesley, 1995, pp. 119-150.
【检查模式】The ‘’Checks’’ pattern: by Cunningham, W., online at http://c2.com/ppr/checks.html
【依赖注入原则】The ‘’Dependency Inversion Principle’‘: Martin, R., in ‘’Agile Software Development Principles Patterns and Practices’’, Prentice Hall, 2003, Chapter 11: “The Dependency-Inversion Principle”, and online athttp://www.objectmentor.com/resources/articles/dip.pdf
【依赖注入模式】The ‘’Dependency Injection’’ pattern: Fowler, M., online at http://www.martinfowler.com/articles/injection.html
【模拟对象模式】The ‘’Mock Object’’ pattern: Freeman, S. online at http://MockObjects.com
【回环模式】The ‘’Loopback’’ pattern: Cockburn, A., online at http://c2.com/cgi/wiki?LoopBack
‘【用例】’Use cases:’’ Cockburn, A., ‘’Writing Effective Use Cases’’, Addison-Wesley, 2001, and Cockburn, A., “Structuring Use Cases with Goals”, online athttp://alistair.cockburn.us/crystal/articles/sucwg/structuringucswithgoals.htm