通常,架构要么是在 Word 文档中描述的一些软件系统中无形的、概念性的方面,要么就完全是由技术驱动的(“我们使用了一个 XML 架构”)。这两种方式都很糟糕:前者很难派上用场,而后者架构上的概念被技术宣传所掩盖。
什么才是好的表达?应该是随着架构的发展,演化出一门语言,让你得以从架构的角度来描述系统。根据我在多个真实项目中获得的经验,这种表达方式能够形象、无歧义地描述架构构建模块和具体系统,同时又不至于深入到技术决策的细节(技术决策应该有意识地放到另一个单独的步骤中)。
本篇论文的第一部份通过一个真实故事演示了这一思想。第二部分则总结了这一方法的关键点。
一个故事
系统背景
我正与一位客户在一起,他是我负责定期咨询工作中的其中一位客户。客户决定构建一个全新的航空管理系统。航空公司使用该系统跟踪和发布不同的信息,如:飞机是否降落在指定的机场;航班是否延迟;飞机的技术状态等等。系统同时还要为 Internet 的在线跟踪系统以及在机场等地设置的信息监控器提供数据。无论从哪个方面来看,该系统都属于一个典型的分布式系统,系统的各个部分分别运行在不同的机器上。它有一个中央数据中心负责处理繁重的数字运算,还有其他机器分布放置在相对广阔的区域中。多年来,我的客户一直在构建类似这样的系统,现在他们计划引入新一代的系统。新系统必须能够支持 15-20 年时间的演进。单单从这一项需求就可以清楚地看出,他们需要对技术进行某种抽象,因为在这 15-20 年期间可能要经历 8 次技术潮流的变迁。对技术进行抽象还有另一个重要的理由,那就是系统的不同部分采用了不同的技术来构建,有 Java,C++,C#。采用多种技术对于大型分布式系统而言并非特殊的需求。通常,我们会在后端使用 Java 技术,而在 Windows 前端使用.NET 技术。
由于系统的分布式本质,不可能在同一时间更新系统的所有组成部分。这就产生了另一项需求,就是能够一部分一部分地更新该系统。这就反过来要求能够管理不同系统组件之间的版本冲突问题(确保组件 A 在组件 B 被升级到一个新的版本之后,仍然能够与之协作)。
起点
在我进入项目的时候,他们已经决定系统的主干应该是一个基于消息传递的基础架构(对于这类系统而言,这是一个不错的决策),并且他们评估了不同的消息传递主干在性能和吞吐量方面的表现。他们已经确定了在整个系统中使用一个业务对象模型,对系统操作的数据进行描述(对于这类系统而言,这实际上不是一个好的决策,但它不影响这个故事的结论)。
因此,当我进入项目后,他们向我简要地介绍了系统的所有细节,以及他们已经做出的架构决策,然后询问我这些决策是否正确。但是我很快就发现,虽然他们了解了很多需求,也已经在架构的某些方面做出了细致的决策,但是却没有形成我所说的一致的架构(consistent architecture):即对组成实际系统构建模块的定义,也就是定义系统中的各种事物。他们没有掌握谈论这个系统的语言。
实际上,这只是我进入项目时的一个初步印象。当然,我认为该项目存在一个巨大的问题:如果你并不知道组成系统的各种事物,就很难一致地谈论和描述该系统,当然更无法一致地构建该系统。你需要定义一门语言。
背景:这门语言是什么?
当你拥有一门语言,并能够从架构的角度谈论系统时,你就拥有了一个一致的架构 1 。那么语言应该是什么样的呢?显然,它首先并且至少是一套定义良好的术语。定义良好首先意味着所有的利益相关者都要认同术语的含义。如果从非正式的角度来看,术语和术语的含义可能就足以定义一门语言了。
然而——这里可能显得有些突然——我一向鼓吹的是要用一门正式语言来描述架构 2 。要定义一门正式语言,你需要的不仅仅是术语和术语的含义。你还需要一种语法来描述如何通过这些术语组成“语句”(或者模型),同时需要一种具体的句法去表示它们 3 。
使用一门正式的语言来描述你的架构,会带来许多好处,随着故事的逐渐展开,这些好处也会展露无遗。同时,在本文的末尾我会对其进行总结。
发展出一门语言以描述架构
让我们继续这个故事。我的客户与我都同意值得花上一天的时间去审阅某些技术需求,并为架构建立一门正式语言来体现这些需求。实际上,我们一边讨论整个架构,一边构建出语法、某些约束以及一个编辑器(使用 oAW 的 Xtext 工具)。
开始
我们首先从组件的概念开始。我们对组件概念的定义是相对比较宽松的。它只是与架构相关的构建模块的最小单元,封装了应用程序的功能。同时,我们假定组件是能够被实例化的,以便使架构中的组件概念对应上 OO 编程中的类。因此,根据我们定义的初始语法,首先构建的模型应该是这样:
<span color="#ff0000">component</span> DelayCalculator {}<br></br><span color="#ff0000">component</span> InfoScreen {}<br></br><span color="#ff0000">component</span> AircraftModule {}
注意,在这里我们做了两件事情:我们首先定义了系统中存在组件的概念(使得组件成为我们要构建的系统的构建模块),其次我们还(初步)决定系统中存在三个组件DelayCalculator,InfoScreen和AircraftModule。我们为架构提出了一套构建模块,作为一个概念型的架构,并将这些构建模块的一套具体范本作为应用程序架构 4 。
接口
当然,上述关于组件的概念并无太大用处,因为组件无法交互。领域逻辑清晰地表明,DelayCalculator必须接收来自AircraftModules的消息,从而计算航班的延误状态,然后将结果转发给InfoScreens。我们知道,它们应该以某种方式交换信息(记住:已经作出了消息传递决策)。但是,我们决定不引入消息,而是将一组相关的消息抽象为接口 5 。
<span color="#ff0000">component</span> DelayCalculator <span color="#ff0000">implements</span> IDelayCalculator {}<br></br><span color="#ff0000">component</span> InfoScreen <span color="#ff0000">implements</span> IInfoScreen {}<br></br><span color="#ff0000">component</span> AircraftModule <span color="#ff0000">implements</span> IAircraftModule {}<br></br><span color="#ff0000">interface</span> IDelayCalculator {}<br></br><span color="#ff0000">interface</span> IInfoScreen {}<br></br><span color="#ff0000">interface</span> IAircraftModule {}
我们认识到,上面的代码看起来有几分像是 Java 代码。无需惊讶,既然我的客户具有 Java 背景,那么系统的首选目标语言自然就是 Java。因此,我们就要从他们习惯使用的语言中,抽取出广为人知的概念衍生为我们自己的语言。然而,我们很快注意到这样的表示方式没有太大用处:我们无法表示组件“使用了某个特定的接口(与提供接口相对)”。了解一个组件需要哪些接口是很重要的,因为我们希望能够了解(而且之后要用工具进行分析)组件具有的依赖关系。这对于任何一个系统都很重要,而对于版本管理的需求而言,则尤为重要。
因此,我们对语法稍加修改,支持如下的表达形式:
<span color="#ff0000">component</span> DelayCalculator {<br></br><span color="#ff0000">provides</span> IDelayCalculator<br></br><span color="#ff0000">requires</span> IInfoScreen<br></br>}<br></br><span color="#ff0000">component</span> InfoScreen {<br></br><span color="#ff0000">provides</span> IInfoScreen<br></br>}<br></br><span color="#ff0000">component</span> AircraftModule {<br></br><span color="#ff0000">provides</span> IAircraftModule<br></br><span color="#ff0000">requires</span> IDelayCalculator<br></br>}<br></br><span color="#ff0000">interface</span> IDelayCalculator {}<br></br><span color="#ff0000">interface</span> IInfoScreen {}<br></br><span color="#ff0000">interface</span> IAircraftModule {}
#### 描述系统
那么,我们来看看这些组件是如何被使用的。我们清晰地认识到组件需要支持实例化。很显然,系统中有许多架飞机,每架飞机都运行了一个 _AircraftModule_ 组件,而 _InfoScreens_ 的实例数量更多。不够明确的是我们是否需要多个 _DelayCalculators_,但我们决定推迟对它的讨论,先处理实例化的问题。
因此,我们需要能够表示组件的实例化。
<span color="#ff0000">instance</span> screen1: InfoScreen<br></br><span color="#ff0000">instance</span> screen2: InfoScreen<br></br>...
接着,我们讨论了如何把系统的各实例“接上线”:如何表示某个 _InfoScreen_ 与某个 _DelayCalculator_“交谈”?我们必须找出某种方式来表示实例之间的关系。由于这两个类型各自具有了“可兼容”的接口,因此,DelayCalculator_ 可以与 _InfoScreen“交谈”。但是暂时还难以把握这种“交谈”关系。我们还注意到一个 _DelayCalculator_ 实例通常会与多个 _InfoScreen_ 实例“对话”。因此,我们必须以某种方式在语言中引入下标来表示实例的个数。
经过几番修改,我引入了端口(Port)的概念(实际上在组件技术以及 UML 中,这是一个众所周知的概念,但是相对于我的客户而言,却是一个新名词)。端口是在组件类型上定义的一个通信端点,当拥有端口的组件被实例化时,端口也会一同被实例化。因此,我们对组件描述语言进行重构,以支持如下的表示形式。端口通过 _provides_ 和 _requires_ 关键字进行定义,紧接着是端口的名称和下标,一个冒号以及与端口相关联的接口。
<span color="#ff0000">component</span> DelayCalculator {<br></br><span color="#ff0000">provides</span> default: IDelayCalculator<br></br><span color="#ff0000">requires</span> screens[0..n]: IInfoScreen<br></br>}<br></br><span color="#ff0000">component</span> InfoScreen {<br></br><span color="#ff0000">provides</span> default: IInfoScreen<br></br>}<br></br><span color="#ff0000">component</span> AircraftModule {<br></br><span color="#ff0000">provides</span> default: IAircraftModule<br></br><span color="#ff0000">requires</span> calculator[1]: IDelayCalculator<br></br>}
以上模型表示,任何一个 _DelayCalculator_ 实例都要连接多个 _InfoScreens_。从 _DelayCalculator_ 实现代码的角度来看,通过 _screen_ 端口可以访问到一组 _InfoScreen_。而 _AircraftModule_ 则只能与一个 _DelayCalculator_“对话”,正如下标 [1] 所示。
新的接口标识启发了我的客户对 _IDelayCalculator_ 进行了修改,因为他们注意到对于不同的通信对象,应该有不同的接口(因此还应该有不同的端口)。我们对应用程序架构作出了如下修改:
<span color="#ff0000">component</span> DelayCalculator {<br></br><span color="#ff0000">provides</span> aircraft: IAircraftStatus<br></br><span color="#ff0000">provides</span> managementConsole: IManagementConsole<br></br><span color="#ff0000">requires</span> screens[0..n]: IInfoScreen<br></br>}<br></br><span color="#ff0000">component</span> Manager {<br></br><span color="#ff0000">requires</span> backend[1]: IManagementConsole<br></br>}<br></br><span color="#ff0000">component</span> InfoScreen {<br></br><span color="#ff0000">provides</span> default: IInfoScreen<br></br>}<br></br><span color="#ff0000">component</span> AircraftModule {<br></br><span color="#ff0000">requires</span> calculator[1]: IAircraftStatus<br></br>}
注意,端口的引入改善了应用程序架构,因为我们拥有了体现角色的接口(IAircraftStatus,IManagementConsole)。
现在,我们拥有了端口,因此我们能够命名通信端点。这就使得我们能够轻而易举地描绘出系统:互连的组件实例。注意,引入了新的结构 _connect_。
<span color="#ff0000">instance</span> dc: DelayCalculator<br></br><span color="#ff0000">instance</span> screen1: InfoScreen<br></br><span color="#ff0000">instance</span> screen2: InfoScreen<p><span color="#ff0000">connect</span> dc.screens <span color="#ff0000">to</span> (screen1.default, screen2.default)</p>
#### 保持大局观
当然,从某种情况来看,为了不至于混淆所有的组件、实例和连接器(connectors),我们无疑需要引入某种命名空间的概念。自然,我们也可以将这些内容分别放到不同的文件中(工具支持保证了“转到定义”和“查找引用”仍然正常)。
<span color="#ff0000">namespace</span> com.mycompany {<br></br><span color="#ff0000">namespace</span> datacenter {<br></br><span color="#ff0000">component</span> DelayCalculator {<br></br><span color="#ff0000">provides</span> aircraft: IAircraftStatus<br></br><span color="#ff0000">provides</span> managementConsole: IManagementConsole<br></br><span color="#ff0000">requires</span> screens[0..n]: IInfoScreen<br></br> }<br></br><span color="#ff0000">component</span> Manager {<br></br><span color="#ff0000">requires</span> backend[1]: IManagementConsole<br></br> }<br></br> }<br></br><span color="#ff0000">namespace</span> mobile {<br></br><span color="#ff0000">component</span> InfoScreen {<br></br><span color="#ff0000">provides</span> default: IInfoScreen<br></br> }<br></br><span color="#ff0000">component</span> AircraftModule {<br></br><span color="#ff0000">requires</span> calculator[1]: IAircraftStatus<br></br> }<br></br> }<br></br>}
当然,将组件和接口的定义(本质上是类型的定义)与系统的定义(连接的实例)分开,是一个很好的想法,因次,我们如下定义了一个系统:
<span color="#ff0000">namespace</span> com.mycompany.test {<br></br><span color="#ff0000">system</span> testSystem {<br></br><span color="#ff0000">instance</span> dc: DelayCalculator<br></br><span color="#ff0000">instance</span> screen1: InfoScreen<br></br><span color="#ff0000">instance</span> screen2: InfoScreen<br></br><span color="#ff0000">connect</span> dc.screens <span color="#ff0000">to</span> (screen1.default, screen2.default)<br></br> }<br></br>}
在一个真实的系统中,DelayCalculator_ 必须能够在运行时动态地发现所有可用的 _InfoScreens。手动地描述这些连接是没有什么意义的。因此,我们需要继续前进。我们定义了一个查询,它可以采用 naming/trader/lookup/registry 的基础架构在运行时执行。每隔 60 秒,查询会被执行一次,查找任何上线的 InfoScreens。
<span color="#ff0000">namespace</span> com.mycompany.production {<br></br><span color="#ff0000">instance</span> dc: DelayCalculator<p><span color="#008000">// InfoScreen instances are created and<br></br> // started in other configurations</span><span color="#ff0000">dynamic connect</span> dc.screens <span color="#ff0000">every</span> 60 <span color="#ff0000">query</span> {</p><br></br> type = IInfoScreen<br></br> status = active<br></br> }<br></br>}
可以使用相似的办法实现负载均衡或者容错能力。一个静态的连接器能够指向一个主要实例以及备份实例。或者,在当前使用的组件实例变为不可用时,可以重新执行一个动态查询。
为了支持实例的注册,我们在它们的定义中添加了额外的语法。一个 _registered_ 的实例会在注册记录中使用自己的名称(通过命名空间识别)以及所有提供的接口,自动注册其本身。还可以指定额外的参数,如下的例子就为 _DelayCalculator_ 注册了一个主要的实例和一个备份的实例。
<span color="#ff0000">namespace</span> com.mycompany.datacenter {<br></br><span color="#ff0000">registered</span> instance dc1: DelayCalculator {<br></br><span color="#ff0000">registration</span> parameters {role = primary}<br></br> }<br></br><span color="#ff0000">registered</span> instance dc2: DelayCalculator {<br></br><span color="#ff0000">registration</span> parameters {role = backup}<br></br> }<br></br>}
#### 第二部分,接口
至今我们仍然没有真正定义一个接口究竟是什么。我们知道,我们更愿意基于一个消息传递的基础架构来构建系统,因此,接口显然必须定义为消息的集合。于是就有了我们最初的想法:一组消息的集合,其中每条消息都有名称,以及一组类型化的参数。
<span color="#ff0000">interface</span> IInfoScreen {<br></br><span color="#ff0000">message</span> expectedAircraftArrivalUpdate(id: ID, time: Time)<br></br><span color="#ff0000">message</span> flightCancelled(flightID: ID)<br></br>...<br></br>}
当然,同时还需要具备定义数据结构的能力。因此,我们添加了这样的内容:
<span color="#ff0000">typedef</span> long ID<br></br><span color="#ff0000">struct</span> Time {<br></br> hour: int<br></br> min: int<br></br> seconds: int<br></br>}
在对接口进行了一段时间的讨论之后,我们现在注意到简单地将接口定义为一组消息还远远不够。我们希望做到的最小要求是能够定义消息的方向:它是流入端口还是流出端口?或者更一般地说,系统中存在哪些消息交互模式?我们识别出了好几个,这里是 _oneway_ 和 _request-reply_ 的范例:
<span color="#ff0000">interface</span> IAircraftStatus {<br></br><span color="#ff0000">oneway message</span> reportPosition(aircraft: ID, pos: Position )<br></br><span color="#ff0000">request-reply message</span> reportProblem {<br></br> request (aircraft: ID, problem: Problem, comment: String)<br></br> reply (repairProcedure: ID)<br></br> }<br></br>}
#### 真的是消息吗?
我们对各种消息交互模式进行了长时间的讨论。显然,消息的其中一种核心用例就是将各种资源的状态更新发送到各个对其关注的部分。例如,如果航班因为飞机的一个技术问题而延误,则该信息就会被发送到系统的所有 _InfoScreens_ 中。我们为一个确切状态项的“广播式”完整更新、增量更新、无效更新等方式建立了必需的几种消息原型。
然而,现实却给了我们沉重的打击:我们一直在一种错误的抽象中工作!虽然消息传递对于这些事项而言是一种适合的传输抽象,但我们真正谈论的其实应该是复制的数据结构(replicated data structures)。基本上,所有的这些结构都采用同样的方式工作:
- 定义了一个数据结构(例如 _FlightInfo_)。
- 系统保持对这样一组数据结构的跟踪。
- 一组数据结构会被几个组件所更新,而且,通常这组数据结构会被众多其他的组件所读取。
- 从发布者到接收者的更新策略总是包括对这组数据结构中所有项的完整更新,对一个或多个项的增量更新,无效更新等。
当然,一旦我们了解到除了消息之外,系统还包括额外的核心的抽象,我们就应该将它添加到我们的架构语言中,并能够像下面所示的方式进行编写。我们定义了数据结构和复制项。然后,组件能够发布(publish)或者使用(consume)这些复制的数据结构。
<span color="#ff0000">struct</span> FlightInfo {<br></br> from: Airport<br></br> to: Airport<br></br> scheduled: Time<br></br> expected: Time<br></br> ...<br></br>}<p><span color="#ff0000">replicated singleton</span> flights {</p><br></br> flights: FlightInfo[]<br></br>}<p><span color="#ff0000">component</span> DelayCalculator {</p><br></br><span color="#ff0000">publishes</span> flights<br></br>}<p><span color="#ff0000">component</span> InfoScreen {</p><br></br><span color="#ff0000">consumes</span> flights<br></br>}
毫无疑问,上面的描述比基于消息的描述更准确。系统能够自动地衍生出完整更新、增量更新和无效更新等需要的各种消息。这一描述同样清晰地反映了实际的架构意图:比起那种仅仅表达了我们希望如何去做(发送状态更新消息)的较低级的描述,新的描述方式更好的表达了我们希望做什么(复制状态)。
当然,我们还不能停下前进的脚步。现在,我们拥有了作为“头等公民”的状态复制,就能够为它的技术规范添加更多的信息:
<span color="#ff0000">component</span> DelayCalculator {<br></br><span color="#ff0000">publishes</span> flights { <span color="#ff0000">publication</span> = onchange }<br></br>}<br></br><span color="#ff0000">component</span> InfoScreen {<br></br><span color="#ff0000">consumes</span> flights { <span color="#ff0000">init</span> = all <span color="#ff0000">update</span> = every(60) }<br></br>}
上例的意思是,只要底层的数据结构的内容发生改变,发布者就会发布复制的数据。然而,_InfoScreen_ 只需要每隔 60 秒进行一次更新(当它刚启动的时候,会对数据作一次完整的加载)。根据这一信息,我们能够产生出所有需要的消息,同时为参与者生成一个更新时间表。
更多内容?
在余下的讨论中,我们识别了架构的其他几个方面,并为它们添加了语言抽象:
- 为了解决版本冲突,我们增加了一种方法,可以将一个已经存在的组件指定为按照新版本(替换)方式执行。工具能够确保“即插即用的兼容性”。
- 为了能够表达消息的语义,以及它们对系统状态的影响,我们引入了前置条件和后置条件。我们还扩充了组件的概念,将 stateful 作为可选项。
- 最后,我们为组件添加了可配置参数。组件会指定参数,而组件实例则必须为它们指定值。
结论
采用这种方法,我们能够快速地把握系统的整体架构。我们还因此能够区分开“希望系统做什么”和“系统如何实现它”:这样一来,技术层面的讨论仅仅属于为此处给出的概念性描述提供实现细节(当然是非常重要的实现细节)。我们明白无误地理解了不同术语所代表的含义,并给出了明确的定义。组件这一模糊的概念在这个系统中具有了正式的、明确界定的含义。
当然,它并没有到此为止。下一步要讨论的是如何为组件的实现进行编码,以及讨论系统的哪一部份可以被自动生成。更多内容参见下一节。
扼要总结 & 优势
我们做了什么
这种方法包括为项目或系统的概念性架构定义一门正式的语言。随着你对架构的深入理解,逐步发展了这门语言。因此,语言总是与你对架构完整而又明晰的理解相对应。随着我们对语言的增强,我们就能够使用该语言对应用程序架构进行描述。
背景:DSL
我们前面前面建立起来的语言是一种 DSL——领域特定语言。以下是我对 DSLs 定义:
DSL 是一种目的明确的、可处理的语言,当我们在一个特定领域内构建系统时,可以用它来描述一个特定的关注点。它所使用的抽象与标识符号是为那些指定特定关注点的利益相关人定制的。
DSLs 可以用来指定软件系统的各个方面。其中一大看点是使用 DSL 可以描述业务功能(例如,在保险系统中的计算规则)。DSL 尤其在描述业务功能时倍显其价值所在,同样,也完全值得用 DSL 描述软件架构:正如我们在这里所做的那样。
因此,我们先前构建的架构语言——以及我在本篇论文中倡导的方法——其意义在于使用 DSL 技术去定义一种描述特定架构的 DSL。
优势
参与的每个人都能清晰地理解用于描述系统的概念。提供清晰明确的词汇来描述应用程序。创建的模型可以被分析,并作为代码生成(如下所示)的基础被使用。架构总是与实现细节无关,或者换句话说:概念型架构与技术决策是解耦的,从而使得它们更加便于各自的演化发展。我们同样能够根据概念型架构定义一个清晰的编程模型(如何使用之前定义的所有架构特征对组件进行建模和编码)。最后,现在架构师就可以通过构建(或者帮助构建)团队其余成员实际能够使用的工件,直接为项目作出贡献。
为何使用文本形式?
……或者为什么不使用图形标识?文本型的 DSLs 有几大优势。首先是更加容易建立语言以及一个好的编辑器。其次,文本型的工件比图形化的模型库更加容易集成到现有的开发工具(CVS/SVN diff/merge)中。第三,文本型的 DSLs 通常更容易被开发者接受,因为“真正的开发人员不画图”。
如果对于系统的某些方面,图形标识有助于看清楚架构元素之间的关系,你可以使用类似于 Graphviz 或者 Prefuse 之类的工具。既然模型以一种清晰而又干净的形式包含了相关的数据,我们就可以轻易的将模型数据导出成 GraphViz 或者 Prefuse 工具能够阅读的形式。
工具
要使得前面介绍的方法具有可行性,你需要用工具来支持 DSLs 的高效定义。我们使用了 openArchitectureWare 的 Xtext。Xtext 能够为你完成如下事情:
- 它提供了一种定义语法的手段。
- 根据语法,工具生成一个 antlr 语法以完成实际的解析。
- 它同样会生成以一个 EMF Ecore 元模型;生成的解析器会实例化从语言的句子中得到的元模型。然后,你能够使用所有基于 EMF 的工具去处理这些模型。
- 你同样可以根据生成的 Ecore 模型指定约束。约束可以使用 oAW 的 Check 语言(本质上是一个简化了的 OCL)来指定。
- 最后,工具还可以为你的 DSL 生成一个强有力的编辑器,它提供了代码折叠、语法着色和可自定义的代码完成功能,以及一个整体概要视图和跨文件的转向定义(go-to-definition)和查找引用(find reference)。它还可以实时评估你的语言约束,并输出错误消息。
经过一点实践就可以掌握 Xtext,它真正让你能够按照自己对架构细节的理解和架构决策来设计语言。自行订制代码完成功能可能需要比较长的时间,但是你可以在对语言的摸索告一段落的时候再做这件事。
验证模型
如果我们要正式而且准确的描述一个架构,除了语法,我们还需要实施验证规则,对模型进行约束。简单的例子比如典型的名称唯一性约束、类型检查或非空检查。要表示这些(相对的)局部约束,可以直接使用 OCL 或者类似于 OCL 的语言。
但是,我们还需要验证更加复杂,而且不那么局部的约束。例如,在前面介绍的故事里,约束会检查组件和接口的新版本是否与它们的旧版本实际上是兼容的,因此可以用在相同的上下文中。要能够实现这样重要的约束,有两个前置条件是非常必要的:
- 约束自身必须在形式上是可描述的,即必须有某种算法能够判断约束是否满足。一旦你理解了这一算法,就能够实现它,而不用考虑你的工具支持哪种约束语言(在我们的例子中,约束语言为类似于 OCL 的 Xtend 或者 Java)
- 另一个前置条件是运行前述约束检测算法所需的数据,要在模型中是实际可用的。例如,如果你想检验一个确切的部署方案是否可行,就必须将可用的网络带宽、消息的确切时间以及基本日期类型的长度放到模型中 6 。要捕获这些数据听起来是一个负担,然而,这实际上是一个优势,因为这是核心的架构知识。
生成代码
从本篇论文中可以逐渐清晰地了解到,发展架构的 DSL(以及使用 DSL)的关键优势在于:清晰无误地理解概念,并正式地定义它们。它有助于你理解你的系统,以及去除那些不必要的技术干扰。
当然,现在我们已经拥有了一个概念型架构的正式模型,以及我们正在构建的系统的正式描述(使用语言定义的语句(或模型)),我们将利用它获得更多的好处:
- 我们将为实现代码生成 API。该 API 功能强大,考虑了各种消息传递范式,复制状态等等。生成的 API 允许开发人员用一种不依赖于任何技术决策的方法对实现进行编码:生成的 API 隐藏了组件实现代码的相关内容。我们将调用这一生成的 API,以及用于编程模型的一套术语。
- 记住,我们期望通过某种组件容器或中间件平台运行组件。因此,我们用选定的实现技术生成了运行组件(及组件的技术中立的实现)所必需的代码。我们将这一层代码称作技术映射代码(或胶合代码 [glue code])。它通常还会包含各相关平台的配置文件。有时候,它还需要额外的“混合模型(mix in models)”,为平台指定配置细节。生成器将采用开发人员决定使用的技术的最佳实践。
当然,为多种目标语言生成 API(支持用多种语言来实现组件)以及 / 或者为多个目标平台(支持在不同中间件平台执行相同的组件)生成胶合代码都是完全可行的。这就很好地支持了可能的多平台的需求,同时也提供了一种方法使得基础架构能够随着时间的推移扩展规模,或者进行演化。
另一个值得注意的是,你通常应该分为几个阶段来生成代码:第一个阶段是使用类型定义(组件、数据结构、接口)去生成 API 代码,这样你才能对实现进行编码。第二个阶段是生成胶合代码以及系统配置代码。最后,将类型定义从模型中的系统定义分离出来,这是一种明智的做法:因为在整个过程中,它们会在不同的时刻被使用,而且通常会被不同的人创建、修改与处理。
总的说来,生成的代码支持有效的、独立于技术的实现,能够隐藏大多数潜在的技术复杂性,从而使得开发更加高效。
如何比较它与 ADLs 和 UML
用正式的语言描述架构并非一个新的想法。各个社区都推荐使用架构描述语言(ADLs)或者统一建模语言(UML)描述架构。有的甚至可以(试图)从结果模型中生成代码。但是,所有这些方法都主张使用现有的通用语言来记录架构(虽然有一些语言能够被定制化,包括 UML)。
然而(你可能从上述的故事中看出端倪)这完全忽略了重点!我并没有看到这种将架构描述硬塞到预定义 / 标准化语言提供的(通常是非常有限的)结构中,会带来多少好处。在本篇论文所阐释的方法中,其中一个核心活动是实际构建你自己的语言去捕捉系统的概念型架构的过程。让你的架构适配于 ADL 或者 UML 提供的不多的概念,对架构设计并无多大帮助。
关于 UML Profile:是的,你可以把前面介绍的方法用在 UML 上面,建立一个 UML Profile,而不是文本型语言。我在好多个项目中采用了这个方法,得到的结论是它在大多数环境下工作得并不好。原因如下:
- 使用 UML 需要更多地考虑如何能够使用 UML 现有的结构准确地表现你的意图,无法专注地考虑你的架构概念。这是错误的关注方式!
- 而且,UML 工具通常都无法与你现有的开发基础架构(编辑器,CVS/SVN,diff/merge)相集成。在某个分析或设计阶段,使用 UML 还不会出现太大的问题,但是一旦你将你的模型作为源代码(它们实际上反映了系统的架构,通过它们生成真正的代码),就会成为一个很大的问题。
- 最后,UML 工具通常都是复杂而又重量级的,通常被“真正的”开发人员认为是“臃肿的软件”或者“绘图工具”。使用一门好的文本型语言能够降低接受的门槛。
为什么不直接使用编程语言
架构的抽象,例如消息或组件在现今的第 3 代编程语言中,并非“头等公民”。当然,你可以使用类来表示它们。使用注解(也称为特性),你甚至可以关联元数据与类和类的其他内容(操作、字段)。因此,你总是可以使用第三代语言来表示这些内容的。但是,这种方法存在问题:
- 正如前面阐释的 UML 的例子一样,这种方法会强迫你将清晰的领域特定概念硬塞进预先构建的抽象中。在许多方面,注解 / 特性与 UML 的 stereotype 和 tagged value 相比,不过是“五十步笑百步”而已,你会遇到相似的问题。
- 模型的可分析性是有限的。虽然有 Spoon for Java 这样的工具能够对模型进行分析,但是这种分析处理起来并不比一个正式模型更加容易。
- 最后,用“架构增强型 Java 或 C#”来表达架构,也意味着你试图混淆架构的关注点与实现的关注点。这会使得这种泾渭分明的区别变得浑浊起来,可能加剧对技术的依赖。
我对组件的观点
对于什么是组件存在着许多种(或正式或非正式)定义。从软件系统的构建模块,到有显式定义的上下文依赖关系的物件,到包含了业务逻辑并运行在容器中的物体,都可以称之为组件。
我的理解为(注意,我并不是说我提出了一个真正的定义)组件是最小的架构构建模块。在定义系统的架构时,无需关注组件的内部。组件必须以声明方式指定它们与架构相关的属性(即以元数据或模型的方式指定)。因此,组件可以通过工具进行分析和组合。通常,它们都运行在容器中,而容器则体现为框架,处理着元数据中与运行时相关的部分。容器在哪个层次上提供技术服务(日志、监控、故障转移等),那就是组件的边界。
对于组件实际包含的元数据(以及元数据描述了什么属性),我并无任何具体的要求。我认为,组件的具体概念必须针对每个(系统 / 平台 / 产品类型)架构来定义。而这实际上也是我们在前面介绍的通过语言方式所要做到的。
组件实现
默认情况下,组件的实现都是手动完成的。实现代码可以针对之前介绍的生成的 API 代码来编写。要想在组件的骨架中增加手工编写的代码,开发者可以直接将代码添加到生成的类中,或者——更好的方式是——使用组合例如继承或者局部类(partial classes)。
还有其他替代方法可以实现组件,它们不使用第 3 代编程语言,而是针对需要描述的行为采用专门的形式化手段。
- 常规的行为可用生成器实现。只要先在模型中通过设置少量的定义良好的参数,对其进行参数化之后,即可使用生成器实现。特征模型(Feature Models)善于表示这种需要进行判断的多样性,如此才能够生成实现的内容。
- 对于基于状态的行为,可以使用状态机。
- 对于诸如业务规则的内容,你可以定义一个 DSL 直接表达这些规则,并使用规则引擎对它们进行运算。现在已经有多个规则引擎可以使用。
- 对于领域特定的计算,例如在保险领域所常见的情形,你可能需要提供一种专门的表示法来支持领域所需的数学操作。这样的语言通常是解释型的:组件在技术上的实现包括一个解释器,用来对运行的程序进行参数化。
同样可以使用动作语义语言(ASLs,Action Semantics Languages)作为替代的方法。但是,需要指明的重要一点是,该语言没有提供领域特定抽象,而是采用与通用建模语言例如 UML 相同的方式。然而,即使你使用了更特定的标记法,仍然免不了需要泛化地指定小个片段的行为。一个很好的范例就是在状态机中的动作。
为了有效地结合用组件概念来定义行为的各种方法,可以使用元层次的子类化手段去定义各种组件,让每个组件都有自己的一组表示法去定义行为。下图说明了这一原理。
既然从技术上讲,组件实现就是与行为有关,因此通常来说使用封装在组件内部的解释器是有效的。
最后,值得一提的是,我们要认识到本节讨论的内容只涵盖应用程序特定的行为,而不是指所有的实现代码。大量的实现代码都与应用程序的技术基础架构息息相关——远程处理、持久化、工作流等——而它们都可以从架构模型衍生出来。
模式的角色
在今天的软件工程实践中,模式是相当重要的一部分。对于重复出现的问题,模式是一种经过验证的有效解决方案,模式的适用性、利弊和后果都是经过检验的。那么,模式在前面描述的方法中,又扮演了怎样的角色呢?
- 架构模式和模式语言描绘了一些已经被成功运用的架构的蓝图。它们可以启发你对自己系统架构的构建。一旦你决定使用模式(并且调整它,使其适用于你的特定上下文),就能够使得在模式中定义的概念成为 DSL 中的“头等公民”。换句话说,模式影响着架构,因而影响着 DSL 的语法。
- 设计模式,顾名思义,它比架构模式更加具体,更加地贴近特定的实现。设计模式虽然不可能最终成为架构 DSL 的中心概念,但是,在通过模型生成代码时,你的代码生成器生成的代码通常会类似于一些模式的结构。然而需要注意,生成器并不能决定是否应用某个模式:这需要(生成器的)开发人员人工地作出权衡。
在谈到 DSLs、代码生成以及模式时,需要提及的是你不能完全地自动化模式!一个模式并不是只包含 UML 图表的解决方案。在模式的定义中有很大的篇幅用来解释模式受到哪些力量的影响,何时可以应用模式何时不应该应用模式,以及使用模式会带来何种结果。模式的文档中通常还会记录下模式的多种变体,每种变体都各有不同的优势与缺点。如果环境有特殊之处,开发人员在实现模式的时候必须将之考虑在内,对它们进行评估,相应作出决策。
哪些内容需要记入文档?
我一直在鼓吹上述方法可以作为正式描述系统概念与应用程序架构的一种方法。因此,就意味着它起到了某种文档的作用,对吗?
是的,但这并不意味着你不需要将其他任何内容纳入文档。下列内容仍然需要编档:
- 基本原理 / 架构的决策:DSLs 描述了架构的轮廓,却没有阐释其原因。你仍然需要对架构的基本原理与技术决策进行编档。通常在这里应该指出相关的(非功能性)需求作为依据。注意,架构的 DSL 语法是非常好的着手点。在架构的 DSL 语法中,每个结构都源自于大量的架构决策。因此,如果你解释了每个语法元素为何能占据一席之地(以及为何没有选择其他替代物),那么正好就记录下了重要的架构决策。相似的方法也可以用于应用程序的架构,即 DSL 的实例。
- 用户指南:一门语言的语法可以作为获得架构的一种定义良好的正式方法,但是它却并非一种好的教学工具。因此,你需要为你的用户(即应用程序的编程人员)就如何使用架构创建指导文档。它包括建模的内容与方式(使用 DSL),以及如何生成代码和如何使用编程模型(如何将实现代码填充到生成的框架中)。
架构还有许多方面可能值得我们去编档,但上述两点是其中最重要的。
进一步阅读
如果你喜欢本篇论文所阐释的方法,你可能需要阅读我整理的架构模式。它们延续了本文的模式话题,并为本文介绍的内容提供了理论基础。这篇论文虽然有点旧,但是本质上论述的话题仍然是相同的。可以通过如下地址获得 http://www.voelter.de/data/pub/ArchitecturePatterns.pdf 。
另外一个值得了解的内容是领域特定语言和模型驱动软件开发的完整知识。我撰写了许多关于这方面的文章,最主要的,我还是《模型驱动软件开发》一书的合作者——从中你可以了解到关于技术、工程学、管理学的内容。更多信息请访问: http://www.voelter.de/publications/books-mdsd-en.html 。
当然,通常情况下你还需要了解关于 Eclipse 建模、openArchitectureWare 和 Xtext 的更多细节内容。在 eclipse.org/gmt/oaw 上可以访问到许多相关的信息,包括官方的 oAW 文档以及大量的入门视频。
致谢
我要感谢 Iris Groher、Alex Chatziparaskewas、Axel Uhl、Michael Kircher、Tom Quas 和 Stefan Tilkov,感谢他们对本篇论文的前一个版本提出的精彩评论。
关于作者
Markus Völter 是一名独立的咨询师,软件技术和软件工程的指导教练。他专注于软件架构、模型驱动软件开发与领域特定语言以及产品线工程(product line engineering)。Markus 就中间件和模型驱动软件开发领域(合作)撰写了多篇杂志文章、书籍以及提出了多种模式。他经常在各种世界大会上发表演讲。你可以给他发送邮件 voelter@acm.org,或者访问 www.voelter.de 与他取得联系。
评论