这一部分将介绍:
- 项目中应用的各种模式。
- 选择它们的理由。
- 针对模式应用和模式间的交互(或关系)展开的讨论。
分层 模式为软件体系结构提供了总体的基础结构,以支持项目的产品线开发策略。这一模式是上述体系结构远景的一部分,它从一开始就为团队的思维过程建立起一个框架。
选择分层模式,以便将高价值的可重用服务和服务相关代码移植到服务层中,只能通过与 平台 层的严格接口来使用核心平台功能。应用程序层中已移植的代码和平台层,都是特定于每个计算平台的。
这一模式对项目的其他部分有深远的影响。已经建立的各层为其后的所有实现决策提供了一个框架。与下列的大多数模式不同,该模式实际上并没有与之相关的代码;它针对以后的实现形成了一个基本的概念化框架。
下面是与分层模式相关的两个具体构件:初始团队结构和初始配置管理数据存储结构。两者都是围绕着已经建立的层形成的,在项目中,这种做法为分层赋予了更大的实质性意义。这是一个逻辑化的方法,在产品线开发策略中,会随时间的推移,围绕不同的体系结构块建立和解散不同的团队,且不同的块可以被访问、修改,并在软件的生命期间内以多种方式使用。"Conway 定律和相关的组织模式,如组织遵循模式能使您深入了解团队的结构是如何与体系结构联系起来的。
有趣的是,稍后在项目中还会第二次应用分层模式。由于在先前建立的层和配置管理数据存储(以原始分层为基础)中观察到的某些其他体系结构的抽象过于庞大,会导致基础设施出现技术问题。服务层被拆分为(自上而下)业务服务、基础设施服务和框架。除了原始平台层之外的所有分层都并不严格;这些分层是以软件要素的可观测逻辑化分组为基础的。在应用层内部也有类似的拆分工作,分为应用程序、应用程序框架和表示层。
分层的第二项应用是作为最终的、基于 UML 包的方法的中间步骤。由模式的第二项应用引入的各层会被相继转换为包,这些包经过显式定义,具有独立性。这可以对各层之间的依存关系进行更为精确的表示和分析。
引入分层不会使项目出现可觉察的性能问题,这主要是因为目标设备硬件有处理相关开销的功能。这并非对所有场景都适用;如果最终的软件应该在资源有限的环境中运行,那么您在应用这种模式时必须小心从事。
在下面的讨论中,各种模式主要应用在框架的上下文、基础设施服务和业务服务中,只有包装外观模式是个例外,它被应用在平台层的上下文中。
下一个步骤是引入平台独立性。如果没有平台独立性,要在不违反体系结构远景的核心原则的前提下做任何事情都是很困难的。包装外观方法被用来封装针对特定主机的低级别功能、数据结构,以及对遗留代码和第三方代码的调用。
包装外观模式的应用,使定义纯粹的操作系统抽象成为可能,可以按照需要,通过语音产品的开发,针对特定的操作系统实现这些抽象。某些已知具有可维护性问题的遗留软件组件可以集成到项目中。为这些组件创建包装外观,会隐藏遗留组件间复杂的交互细节,并提供了一个抽象,使新旧代码不致混淆。类似的方法也可用于某些第三方软件组件。
包装外观中所含的遗留代码的典型示例是一组相关模块,这些模块负责与远程语音设备管理平台进行交互。操作系统的示例包括文件访问权和网络访问权。
对操作系统、遗留代码和第三方代码的抽象为平台独立性打下了基础。这一方法的结果是出现了支持和开发抽象的需求。如果项目具有可迭代性和增量性,那么只会在需要的时候添加抽象,但这意味着为了创建抽象必须继续投入时间和精力,在某些情况下,在使用抽象之前还应重写代码。
如果在一开始就引入了平台抽象的概念,就能更容易地推动项目期间抽象的创建和维护工作。
在建立了平台独立性之后,现在应该将服务引入系统了。创建和删除服务看来是一个合理的起始点。组件配置器,此模式在项目的早期即被选中,用来管理服务生命周期,该模式的应用范围很广(如该模式中所述)。
在组件配置器的初期实现中,提供了一套服务创建和始初化的机制,并为将来迭代过程中服务的重新初始化和关闭打下了基础。当组件配置器实现发出提示时,由服务执行初始化的典型示例有:
- 服务发现
- 订阅其他用于事件通知的服务
- 平台和第三方资源的创建和初始化,如网络套接化或电信协议栈。
稍后,组件配置器的角色会得到扩展,以发现和管理服务间的连接。项目的体系结构目标是将服务与基础通信细节(如传输、消息协议、位置等等)分离。该实现将得到扩展,提供一套动态查询机制,服务可以调用这一机制以发现其他服务。
组件配置器的实现将“发现”责任的一部分指派给系统中的另一个要素,但根据事后反思,在此区域内指定责任的做法是不合适的。服务间的连接应当单独管理,但由于组件配置器实现在系统中居于中心地位,修改它的责任是很容易的。这会使相关的类更加难以理解;创建一个单独的类,让它负责管理服务连接,并可以在必要时咨询组件配置器,这种做法可能更好。
幸运的是,如果责任被错误地分配给少量的类,我们还可以限制它的影响,方法是将封装上下文对象和分离上下文接口应用于组件配置器实现,有选择地公开发现方法。组件配置器实现经过修改,以实现一个服务上下文接口,使用服务的初始化方法,可以把上下文接口传递到这些服务。类似地,在实现框架上下文接口后,可以将其传递给几个不同的框架层要素,以公开相关的方法。
根据事后反思,框架上下文接口的命名和定义过于宽泛,作为接口传递目标的框架层要素也显得太多了。如果对这个上下文接口进行拆分,而不是使之成为一个供各个框架层要素使用的全方位接口,这种做法会更好。
接下来的问题是,服务应当彼此通信,而且某个服务不应知道另一个服务的位置。
这两个问题同时得到了解决。引入的服务总线子系统将命名端点间的通信封装起来。这是实现服务间通信的一个关键步骤,它能在开始时创建命名服务总线端点的实例,以便与其他服务通信。这种方法是从 Qt "CopChannel" 机制 (© TrollTech) 和中介模式借鉴而来的。该实现是模式中的消息传递中介系统的一个变体实例,如 POSA 模式文档中所述。
中介模式的初期实现提供了进程内部和进程之间的通信。其结果是,服务可以通过组件配置器轻易地部署到不同的进程中去,这在以后根据操作的重要性划分服务时会被证明是有用的。例如,一个处理关键语音事件的服务与用来收集非关键性能统计数据的服务,两者处于不同的进程中。
稍后会添加一个桥接以启用远程通信,这将使中介实现得到扩展。体系结构中的这个要素的初始设计并没有直接借鉴中介模式,而是独立构思出来的。稍后项目中的桥接角色和有问题的要素之间的相关性会被标识出来。这一系列事件对桥接角色与系统中其他部分的融合有不利影响;如果整个项目团队对中介模式有更细致的了解,可能会减轻这一影响。
有些远程应用程序会调用带有桥接的服务,这样的例子包括测试工具和语音设备管理应用程序。在本项目中没有对使用桥接的对外服务调用进行阐述。
在迄今为止的系统中,各项服务都可以与彼此对话,但服务并不理解它们之间的交互性。
客户机-服务器的交互是作为系统整体行为的中心建立起来的,服务可望具有客户机和服务器的双重身份。系统的其他要素(如应用程序表示逻辑)只能成为客户机。
引入的客户机-服务器交互有两种类型:同步和异步。如果服务总线子系统是纯粹基于消息的,而且支持某种用来等待传入消息的读取方法,它将自动提供这两种类型的交互。
同步交互的一个典型例子是由某个服务发送给另一个服务的订阅请求,发出请求的服务必须在收到成功的响应消息之后才能继续。接下来的事件通知则是一个典型的异步交互示例。
除了在服务间建立交互模式之外,客户机-服务器应用程序服务器还支持将服务划分为易于理解的客户机和服务器角色。
一旦在客户机和服务器间建立了异步交互,会应用异步完成令牌模式,以使客户机能有效地处理异步响应。起初这种模式被尝试着应用在系统基础设施上,但后来没有继续这样做,因为该模式更适合与服务进行直接交互,而不是用来支持基础设施。完成这一活动,为体系结构内的模式提供一般性支持,这是合理的做法。模式不容易被服务开发人员理解,因此只在较少的服务交互中得到应用。
由于服务可以彼此进行通信和交互,很明显,服务会自行处理执行状况,并运行自己的消息循环以处理通过服务总线接收到的进程消息。体系结构远景说明,服务应当关注业务逻辑,而不是执行的细节,因此下一个关键的步骤是对服务执行进行抽象。为了实现这一目的,将应用执行程序 (executor)模式。
如果服务间的所有交互都是通过服务总线进行的,可以创建一个执行程序,用它把服务总线中的队列消息取出来,并根据接收到的消息确定执行上下文,然后将消息传递给服务进行处理。
起初创建的是一个单线程执行程序,但它很快经过修改,引入一个线程类,并使用池模式创建一个进程池,这使执行程序有了支持服务并发执行的能力。进一步的修改提供了可配置性,使每个具有配置文件的服务都可以选择单线程或多线程的执行模型。
实际上,线程配置机制并没有得到广泛使用,因为大多数服务必须同时处理多个请求以保证拥有可接受的系统响应能力。
服务通信、交互和执行抽象都已完成,因此下一个步骤就是在服务间建立经过良好定义的接口。系统要求服务处理它们自己收到的消息。体系结构的关键在于,要让服务把重点放在业务逻辑而不是通信的细节(如消息格式)上。显式接口 (explicit interface) 模式被用来为服务间的那些经过良好定义的接口提供支持,而代理 (proxy)模式则被用来完成业务逻辑与通信细节分离的工作。
需要某个特定接口能力的客户将会利用“服务上下文”发现某个支持该接口的服务,然后将该服务作为参数,进行方法调用。服务接口都被赋以唯一的名称,供标识之用。对于那些可进行本地解析的服务,可以使用该接口调用实际的服务对象。对于远程服务,则应调用代理对象。本地调用机制不久就被禁用了,因为该机制会绕过抽象执行。不过在某些条件下,出于性能的原因,它能提供一条用来优化服务调用的有效方法,特别是在可以通过配置启用它的时候。
在纯粹的抽象基类中定义显式接口,以启用代理和服务类并轻松地实现它们,而且可以在本地和远程调用之间进行无缝的转换。支持接口的每项服务或代理必须继承定义该接口的抽象基类。支持多接口的服务会采用一个 Java™ 风格的多重继承方法,如采用多个纯粹的抽象基类,每个抽象基类都定义了一个由服务提供支持的接口。
在 C++ 中使用多重继承,对于某个使用了多重继承的特定类而言,可能会使基类出现多个副本,从而导致问题发生。不过,多重继承只能用在被认为可以减轻这一问题的接口实现中。
服务接口的典型操作包括:
- 订阅语音相关事件。
- 调用或改变存在状态等操作。
- 请求访问或修改配置数值或数据变量。
随后,这一方法会与项目的平台独立性目标发生冲突。抽象 C++ 基类中定义了每个显式接口,对于没有共享同一基础平台的服务客户机(如远程系统中基于 Java 的客户机)而言,这种做法并不合适。我们必须为其他平台上的客户机提供一种备选的接口定义,该定义是以基础消息格式文档的形式出现的。根据事后反思,更适当的做法是以一种具有平台独立性的形式提供接口定义,如使用接口定义语言,包括 CORBA IDL 或 Web 服务描述语言 (Web Services Description Language , WSDL)。
对称调用程序与代理对象对应,它借鉴了命令模式(出自《Design Patterns》一书),但在《Remoting Patterns》中它被正式描述为一种模式,引入该模式,可以在远程服务中调用显式接口。代理和调用程序对象之间建立了一对一的关系,而显式接口则充当了常用的关联方式。
执行程序类经过修改,以便使某个特定服务接收到的每条消息都能使用框架上下文发现适当的调用程序类。执行程序把调用服务的责任指派给调用程序对象,同时将消息以及被调用服务(该服务为所需的接口提供支持)的引用传递给它。
代理和调用程序组件都需要框架上下文对象的支持以建立某些通信细节,如用于返回消息的服务总线位置。
使用显式接口、代理和调用程序模式的结果是,提高了服务交互的复杂性,使不熟悉模式和面向对象开发的开发人员难于理解。如果要建立相对简单的服务交互,则需要大量的开发工作。根据事后反思,如果代理和调用程序类是自动生成的,则会更好。“服务生成器”的功能是根据显式接口为代理、调用程序和服务类生成框架代码,它已经被部分编写出来。不幸的是,迫于项目的时间压力,不可能对它进行深入开发。
观察器此后将得到应用,它使用经过良好定义的接口,支持向多个订阅者异步发出事件通知。该模式的应用是在客户机-服务器引入的异步行为以及显式接口引入的各个接口的基础上构建的。通知事件通过回调型显式接口以及与之对应的代理和调用程序对象,与观察器进行通信。
有时候,观察器会在软件系统中得到广泛使用。它与代理模式一起被用来解决几种不同的问题,并成为设计会议中常常讨论的一项核心项目课题。应用这一模式,可以解决诸如如何将物理设备事件、托管数据更新事件和其他语音相关事件通知给多个订阅者等问题。观察器是用来处理体系结构内的事件通知的关键模式。
图 2 显示了服务调用的流程。
虽然服务之间拥有经过良好定义的接口,但是如果要确保特定接口的客户机和实现这些接口的服务间不存在依存关系,仍然有问题存在。为了解决这一问题,服务透明性是必不可少的。
服务上下文提供了本地和远程两种方式,用来发现为所需的显式接口提供支持的服务。查询被用来实现这个目标。在发现期间查询了两个来源:由组件配置器实现进行管理的本地服务“存储库”,以及位于一个已知位置的“服务注册中心”。
如果在本地存储库中找到了一个支持所需的显式接口的服务,将返回对该服务对象的直接引用。如果没有在本地找到支持该接口的服务,或是已禁用了本地发现功能(如提供经过良好定义的接口中所述),则会查询服务注册中心,并根据发现的信息创建一个代理。服务总线地址表示支持所需接口的服务的位置,该地址与服务总线对象一起被传递给代理对象,以使它能发送消息。类似的查询机制也被建立起来,使执行程序实现能根据调用的接口查询相应的调用程序对象。
该方法能保证服务客户机对实现其调用的显式接口的具体服务不具有依赖性。它还能确保代理和调用程序对象与显式接口的特定远程实现没有关联。相反,它们只在接口处提供一种调用转换方法,将调用转换为基于消息的形式,或将基于消息的形式转换为其他形式。
这个方法的重要后果是显而易见的。
显式接口、代理、调用程序和组件配置器实现间的交互严重依赖于 C++ 实时类型信息 (Run-Time Type Information, RTTI)。当在不同的共享库或执行文件中创建强制转换对象时(如客户机服务将代理对象强制转换为特定的接口,以发出服务请求),动态强制转换是必不可少的。如果项目中的某些目标资源是有限的,对于 RTTI 的需求会成为这种环境中的限制性因素。
最初的服务发现机制纯粹以所需的接口为基础。但在将来,该机制有可能利用服务上下文扩展可用的发现选项。例如,可能会支持按某些条件(如“支持的数据库条目数量”或“安全性级别”等)发现服务。
图 3显示了由组件配置器实现支持的上下文。
现在,系统的核心框架已经搭建好了。托管的各项服务:
- 可以通过一条支持位置独立性的服务总线,以同步和异步的方式彼此进行交互
- 不了解通信和执行的细节
- 是利用经过良好定义的接口从服务提供者的细节中抽象出来的,这些接口由显式接口以及代理和调用程序对象提供支持。
拦截器模式在此后会得到应用,以实现在扩展性方面的核心项目目标。它被表示为一个应用于总体体系结构远景的模式。它的目的是,在服务总线这样一个负责在服务和服务客户机之间传送信息的核心框架中引入一个拦截点。
为了在不同的拦截点间保持一定程度的一致性而做出的决定是,创建一组基类,可以根据这些基类为每个具体的拦截点创建子类。模板方法模式的应用,旨在建立一个将事件分派给拦截器的机制,在其中,具体的实现决策会被指派给与某个特定的拦截点相关的子类。
团队认为,稍后在项目中,当设计出拦截器的动态管理时(可能是使用组件配置器设计的),基类的作用将被证实。回想起来,在没有显式需求的情况下引入这些类,这种做法并没有必要。使初期的实现尽量简单,只在必要时引入某个抽象,这可能是更适当的做法。
服务总线构成了整个软件的基础,因此拦截点是通用的,这样可以最大限度地保证其适应性和适用性。人们感到这种做法可能会带来对性能的影响,特别是插入了许多具体的拦截器的情况下。在第一个使用场景中对拦截点的要求是最低的,迄今还没有出现性能问题。
为了确保将性能保持在可接受的水平,必须仔细选择拦截器,在最坏的场景中,可能有必要进行性能分析和代码效率增强处理。在开始时创建的概念日志记录拦截器是用来拦截和记录由服务总线发出的消息的,稍后还会在执行程序实现的服务调用点引入一个更深入的拦截点。所编写的性能记录拦截器是用来记录每个服务调用的性能统计数据的,它会使用这个拦截点。在将来会创建与安全相关的拦截器。
中介模式的“桥接”角色是使用模板方法模式实现的,它通过几种不同的基础通信技术,为远程服务调用提供支持。在项目的早期阶段会发现远程调用服务方面的问题。用多种不同的远程传输机制和技术(如 UDP/IP 和 USB)调用服务,这是可能的,也是必要的。必须使用某种通用方法以避免将复杂性和不一致性引入系统,这也可以对平台独立性的目标提供支持。
引入一个模板类,这个模板类能理解如何与服务总线进行本地交互,并利用模板方法,将与外部交互的实现细节指派给子类。
在项目的后续阶段中,这提供了一种简单的方法,为不同类型的外部通信添加新的子类。它还能实现内外服务通信细节的分离。后来该项目中的一项后果是,实现的模板类并不能理想地适用于所有类型的通信技术。初期实现是以第一项必需的技术为基础建立起来的,但是引入的其他技术无法与模板很好地结合。在开始之前,更好地理解可能使用的通信技术,并根据这些理解,以一种通用性更强的方式定义模板,这可能是更合理的做法。
系统核心与某些扩展机制已经就位。下一个任务是确保系统在一个可接受的水平上运行。为了实现这一目标,有几种模式得到了应用,不过它们不是作为补救措施而应用的。在开发期间,会按照需要将它们引入。为了清晰起见,我们将一起讨论它们,揭示它们与先前应用的其他模式的关系。
执行程序中应用了池,这可以有效地使用线程资源。异步完成令牌也能对线程的有效使用提供支持。例如,正在等待异步响应的服务可能会释放线程,将其放回线程池中。
缓存模式被用来存储代理和调用程序对象,以供将来使用。可以使用框架和服务上下文,对代理和调用程序的中央缓存进行查询。执行程序内部也可以使用缓存;例如,可以对与某个特定服务相关的最新调用程序对象进行缓存。
对显式接口和代理模式支持的服务进行本地解析(如提供经过良好定义的接口中所述),可以绕过通信和执行基础设施,这对实现可接受的性能有所帮助。
最后,利用框架上下文接口公开一个启动方法,从而将延迟获取模式应用在服务的初始化中。组件配置器经过修改,可以根据某个配置参数,有选择性地初始化服务,对于那些在启动时不会自动初始化的服务,可以使用上下文启动它们。执行程序实现经过修改,以便在服务第一次接收到消息时对它们进行初始化。
在建立了系统核心、可扩展机制和可接受的性能之后,最后一个步骤就是将横切关注点应用到系统。
独立模式在设计会议中往往受到贬斥,因为它是一种“反模式”,而且“对于某个全局变量而言是个‘政治上正确’的术语”(Wikipedia 中对独立模式的定义)。不过本项目中有两个紧密相关的场景可以证明独立模式是有用的:可以用于日志记录和异常处理。不幸的是,面向方面的编程并没有被列入考虑范围。它能提供更好的解决方案,但是当时并没有启用面向方面编程的合适 C++ 工具,因此没有考虑使用它。
项目的目标之一是重用现有的软件组件。团队认为,在软件体系结构中,只有某些形式的全局实例才能处理特定的横切关注点。在开发期间,有几个来自先前项目的大型软件模块被引入系统中,对于现有代码和新代码而言,一种用来横切关注点的通用方法是必不可少的。
改变现有代码以引入用来横切关注点的新机制,这需要花费太多的精力,独立日志记录处理程序和异常发布程序类就是因此创建的。为了重用现有代码,对日志独立对象的调用被封装在宏中,这些宏与遗留代码库中的宏十分类似。这可以对现有代码进行一次轻松的转换,改用新的日志记录机制。
独立异常发布程序负责确保将异常报告给系统中某个单一的位置。这种方法保证将系统中的所有异常都报告给体系结构中的某个要素,并因此提供更高的系统可用性级别。这遵循的是《Software Architecture In Practice》第 6 章中所述的“容错层次结构”方法。
虽然独立模式可对项目的重用目标提供支持,但该模式会导致所有代码对横切关注点类的特殊实例产生依赖性。如果允许日志记录和异常处理类有多个实例则会更好。使用服务和框架上下文,可以将这些类的实例提供给服务和框架要素,而全局实例则会支持现有代码中的横切关注点。这将:
- 提供一种更为灵活的方法以便在系统中应用横切关注点,并使现有代码和新代码之间拥有一定的共同性。
- 避免将所有代码与横切关注点类的特殊实例联系起来。
“纯粹”的独立模式实现在后来会掺入其他因素,以使核心基础设施管理代码能动态管理相应的对象实例。
因为执行程序模式将所有服务的执行封装了起来,所以可以将一个通用的异常处理策略应用于所有服务。这一目标是通过在 Thread 类中添加一个通用的“catch-all”代码块来实现的。如果发生了异常,这个 catch 块只是简单地调用异常发布程序,然后将正在执行的线程返回到线程池中。