通常,架构要么是在Word文档中描述的一些软件系统中无形的、概念性的方面,要么就完全是由技术驱动的(“我们使用了一个XML架构”)。这两种方式都很糟糕:前者很难派上用场,而后者架构上的概念被技术宣传所掩盖。
什么才是好的表达?应该是随着架构的发展,演化出一门语言,让你得以从架构的角度来描述系统。根据我在多个真实项目中获得的经验,这种表达方式能够形象、无歧义地描述架构构建模块和具体系统,同时又不至于深入到技术决策的细节(技术决策应该有意识地放到另一个单独的步骤中)。
本篇论文的第一部分通过一个真实故事演示了这一思想。第二部分则总结了这一方法的关键点。
系统背景
我正与一位客户在一起,他是我负责定期咨询工作中的其中一位客户。客户决定构建一个全新的航空管理系统。航空公司使用该系统跟踪和发布不同的信息,如:飞机是否降落在指定的机场;航班是否延迟;飞机的技术状态等等。系统同时还要为Internet的在线跟踪系统以及在机场等地设置的信息监控器提供数据。无论从哪个方面来看,该系统都属于一个典型的分布式系统,系统的各个部分分别运行在不同的机器上。它有一个中央数据中心负责处理繁重的数字运算,还有其他机器分布放置在相对广阔的区域中。多年来,我的客户一直在构建类似这样的系统,现在他们计划引入新一代的系统。新系统必须能够支持15-20年时间的演进。单单从这一项需求就可以清楚地看出,他们需要对技术进行某种抽象,因为在这15-20年期间可能要经历8次技术潮流的变迁。对技术进行抽象还有另一个重要的理由,那就是系统的不同部分采用了不同的技术来构建,有Java,C++,C#。采用多种技术对于大型分布式系统而言并非特殊的需求。通常,我们会在后端使用Java技术,而在Windows前端使用.NET技术。
由于系统的分布式本质,不可能在同一时间更新系统的所有组成部分。这就产生了另一项需求,就是能够一部分一部分地更新该系统。这就反过来要求能够管理不同系统组件之间的版本冲突问题(确保组件A在组件B被升级到一个新的版本之后,仍然能够与之协作)。
起点
在我进入项目的时候,他们已经决定系统的主干应该是一个基于消息传递的基础架构(对于这类系统而言,这是一个不错的决策),并且他们评估了不同的消息传递主干在性能和吞吐量方面的表现。他们已经确定了在整个系统中使用一个业务对象模型,对系统操作的数据进行描述(对于这类系统而言,这实际上不是一个好的决策,但它不影响这个故事的结论)。
因此,当我进入项目后,他们向我简要地介绍了系统的所有细节,以及他们已经做出的架构决策,然后询问我这些决策是否正确。但是我很快就发现,虽然他们了解了很多需求,也已经在架构的某些方面做出了细致的决策,但是却没有形成我所说的一致的架构(consistent
architecture):即对组成实际系统构建模块的定义,也就是定义系统中的各种事物。他们没有掌握谈论这个系统的语言。
实际上,这只是我进入项目时的一个初步印象。当然,我认为该项目存在一个巨大的问题:如果你并不知道组成系统的各种事物,就很难一致地谈论和描述该系统,当然更无法一致地构建该系统。你需要定义一门语言。
背景:这门语言是什么?
当你拥有一门语言,并能够从架构的角度谈论系统时,你就拥有了一个一致的架构1。那么语言应该是什么样的呢?显然,它首先并且至少是一套定义良好的术语。定义良好首先意味着所有的利益相关者都要认同术语的含义。如果从非正式的角度来看,术语和术语的含义可能就足以定义一门语言了。
然而——这里可能显得有些突然——我一向鼓吹的是要用一门正式语言来描述架构2。要定义一门正式语言,你需要的不仅仅是术语和术语的含义。你还需要一种语法来描述如何通过这些术语组成“语句”(或者模型),同时需要一种具体的句法去表示它们3。
使用一门正式的语言来描述你的架构,会带来许多好处,随着故事的逐渐展开,这些好处也会展露无遗。同时,在本文的末尾我会对其进行总结。
发展出一门语言以描述架构
让我们继续这个故事。我的客户与我都同意值得花上一天的时间去审阅某些技术需求,并为架构建立一门正式语言来体现这些需求。实际上,我们一边讨论整个架构,一边构建出语法、某些约束以及一个编辑器(使用oAW的Xtext工具)。
开始
我们首先从组件的概念开始。我们对组件概念的定义是相对比较宽松的。它只是与架构相关的构建模块的最小单元,封装了应用程序的功能。同时,我们假定组件是能够被实例化的,以便使架构中的组件概念对应上OO编程中的类。因此,根据我们定义的初始语法,首先构建的模型应该是这样:
component DelayCalculator {}
component InfoScreen {}
component AircraftModule {}
|
注意,在这里我们做了两件事情:我们首先定义了系统中存在组件的概念(使得组件成为我们要构建的系统的构建模块),其次我们还(初步)决定系统中存在三个组件DelayCalculator,InfoScreen和AircraftModule。我们为架构提出了一套构建模块,作为一个概念型的架构,并将这些构建模块的一套具体范本作为应用程序架构4。
接口
当然,上述关于组件的概念并无太大用处,因为组件无法交互。领域逻辑清晰地表明,DelayCalculator必须接收来自AircraftModules的消息,从而计算航班的延误状态,然后将结果转发给InfoScreens。我们知道,它们应该以某种方式交换信息(记住:已经作出了消息传递决策)。但是,我们决定不引入消息,而是将一组相关的消息抽象为接口5。
component DelayCalculator implements IDelayCalculator {}
component InfoScreen implements IInfoScreen {}
component AircraftModule implements IAircraftModule {}
interface IDelayCalculator {}
interface IInfoScreen {}
interface IAircraftModule {}
|
我们认识到,上面的代码看起来有几分像是Java代码。无需惊讶,既然我的客户具有Java背景,那么系统的首选目标语言自然就是Java。因此,我们就要从他们习惯使用的语言中,抽取出广为人知的概念衍生为我们自己的语言。然而,我们很快注意到这样的表示方式没有太大用处:我们无法表示组件“使用了某个特定的接口(与提供接口相对)”。了解一个组件需要哪些接口是很重要的,因为我们希望能够了解(而且之后要用工具进行分析)组件具有的依赖关系。这对于任何一个系统都很重要,而对于版本管理的需求而言,则尤为重要。
因此,我们对语法稍加修改,支持如下的表达形式:
component DelayCalculator {
provides IDelayCalculator
requires IInfoScreen
}
component InfoScreen {
provides IInfoScreen
}
component AircraftModule {
provides IAircraftModule
requires IDelayCalculator
}
interface IDelayCalculator {}
interface IInfoScreen {}
interface IAircraftModule {}
|
描述系统
那么,我们来看看这些组件是如何被使用的。我们清晰地认识到组件需要支持实例化。很显然,系统中有许多架飞机,每架飞机都运行了一个AircraftModule组件,而InfoScreens的实例数量更多。不够明确的是我们是否需要多个DelayCalculators,但我们决定推迟对它的讨论,先处理实例化的问题。
因此,我们需要能够表示组件的实例化。
instance screen1: InfoScreen
instance screen2: InfoScreen
...
|
接着,我们讨论了如何把系统的各实例“接上线”:如何表示某个InfoScreen与某个DelayCalculator“交谈”?我们必须找出某种方式来表示实例之间的关系。由于这两个类型各自具有了“可兼容”的接口,因此,DelayCalculator可以与InfoScreen“交谈”。但是暂时还难以把握这种“交谈”关系。我们还注意到一个DelayCalculator实例通常会与多个InfoScreen实例“对话”。因此,我们必须以某种方式在语言中引入下标来表示实例的个数。
经过几番修改,我引入了端口(Port)的概念(实际上在组件技术以及UML中,这是一个众所周知的概念,但是相对于我的客户而言,却是一个新名词)。端口是在组件类型上定义的一个通信端点,当拥有端口的组件被实例化时,端口也会一同被实例化。因此,我们对组件描述语言进行重构,以支持如下的表示形式。端口通过provides和requires关键字进行定义,紧接着是端口的名称和下标,一个冒号以及与端口相关联的接口。
component DelayCalculator {
provides default: IDelayCalculator
requires screens[0..n]: IInfoScreen
}
component InfoScreen {
provides default: IInfoScreen
}
component AircraftModule {
provides default: IAircraftModule
requires calculator[1]: IDelayCalculator
}
|
以上模型表示,任何一个DelayCalculator实例都要连接多个InfoScreens。从DelayCalculator实现代码的角度来看,通过screen端口可以访问到一组InfoScreen。而AircraftModule则只能与一个DelayCalculator“对话”,正如下标[1]所示。
新的接口标识启发了我的客户对IDelayCalculator进行了修改,因为他们注意到对于不同的通信对象,应该有不同的接口(因此还应该有不同的端口)。我们对应用程序架构作出了如下修改:
component DelayCalculator {
provides aircraft: IAircraftStatus
provides managementConsole: IManagementConsole
requires screens[0..n]: IInfoScreen
}
component Manager {
requires backend[1]: IManagementConsole
}
component InfoScreen {
provides default: IInfoScreen
}
component AircraftModule {
requires calculator[1]: IAircraftStatus
}
|
注意,端口的引入改善了应用程序架构,因为我们拥有了体现角色的接口(IAircraftStatus,IManagementConsole)。
现在,我们拥有了端口,因此我们能够命名通信端点。这就使得我们能够轻而易举地描绘出系统:互连的组件实例。注意,引入了新的结构connect。
instance dc: DelayCalculator
instance screen1: InfoScreen
instance screen2: InfoScreen
connect dc.screens to (screen1.default, screen2.default)
|
保持大局观
当然,从某种情况来看,为了不至于混淆所有的组件、实例和连接器(connectors),我们无疑需要引入某种命名空间的概念。自然,我们也可以将这些内容分别放到不同的文件中(工具支持保证了“转到定义”和“查找引用”仍然正常)。
namespace com.mycompany {
namespace datacenter {
component DelayCalculator {
provides aircraft: IAircraftStatus
provides managementConsole: IManagementConsole
requires screens[0..n]: IInfoScreen
}
component Manager {
requires backend[1]: IManagementConsole
}
}
namespace mobile {
component InfoScreen {
provides default: IInfoScreen
}
component AircraftModule {
requires calculator[1]: IAircraftStatus
}
}
}
|
当然,将组件和接口的定义(本质上是类型的定义)与系统的定义(连接的实例)分开,是一个很好的想法,因次,我们如下定义了一个系统:
namespace com.mycompany.test {
system testSystem {
instance dc: DelayCalculator
instance screen1: InfoScreen
instance screen2: InfoScreen
connect dc.screens to (screen1.default, screen2.default)
}
}
|
在一个真实的系统中,DelayCalculator必须能够在运行时动态地发现所有可用的InfoScreens。手动地描述这些连接是没有什么意义的。因此,我们需要继续前进。我们定义了一个查询,它可以采用naming/trader/lookup/registry的基础架构在运行时执行。每隔60秒,查询会被执行一次,查找任何上线的InfoScreens。
namespace com.mycompany.production {
instance dc: DelayCalculator
// InfoScreen instances are created and
// started in other configurations
dynamic connect dc.screens every 60 query {
type = IInfoScreen
status = active
}
}
|
可以使用相似的办法实现负载均衡或者容错能力。一个静态的连接器能够指向一个主要实例以及备份实例。或者,在当前使用的组件实例变为不可用时,可以重新执行一个动态查询。
为了支持实例的注册,我们在它们的定义中添加了额外的语法。一个registered的实例会在注册记录中使用自己的名称(通过命名空间识别)以及所有提供的接口,自动注册其本身。还可以指定额外的参数,如下的例子就为DelayCalculator注册了一个主要的实例和一个备份的实例。
namespace com.mycompany.datacenter {
registered instance dc1: DelayCalculator {
registration parameters {role = primary}
}
registered instance dc2: DelayCalculator {
registration parameters {role = backup}
}
}
|
|