[译] 当我们说“事件驱动”时,我们在说什么?

去年年底,我和ThoughtWorks同事一起参加了一个研讨会,讨论“事件驱动”的本质。过去的几年里,我们构建的很多系统都大量使用了事件。对于这些系统,人们常常赞誉有加,但批评的声音也不绝于耳。我们的北美办公室组织了一次峰会,来自世界各地的ThoughtWorks资深开发者出席会议并分享了他们的想法。

这次峰会的最大成果是认识到当人们谈论“事件”时,实际上说的是完全不同的东西,所以我们花了很多时间来梳理一些有用的模式。本文简要总结我们的成果。

事件通知

当领域内有变化发生时,发送事件消息来通知其它系统。事件通知的一个关键点是源系统并不关心外部系统的响应。通常它根本不期待任何结果,即使有也是间接的。 发送事件的逻辑流与响应该事件的逻辑流之间会有显著的隔离。

事件通知非常有用,因为它意味着低耦合,并且结构也非常简单。但是,当逻辑处理流跨越各种事件通知时,它也可能成为问题。因为没有任何代码显式地描述这个流程,所以这个流程是不可见的。通常,唯一的办法是通过监控系统来观察它。这会导致调试和修改流程变得很困难。这里的危险在于,当你使用事件通知来优雅地做系统解耦时,没有意识到更大规模的流程,而这会让你在未来几年中陷入困境。不论如何,此模式仍然非常有用,但你必须小心陷阱。

将事件用作被动操控型命令(Passive-aggressive command),就是这种陷阱的简单示例。它指的是源系统期待接收方执行一个动作,此时本该使用命令消息(Command message)来展现此意图,然而却使用了事件。

事件不需要包含太多数据,通常只有一些id信息和一个指向发送方、可供查询更多信息的链接。 接收方知道它已发生变化并且接收到关于变化的最少信息,随后会向发送方发出请求,以决定下一步该做什么。

事件携带的状态转移(Event-Carried State Transfer)

采用此模式时,可以在不需要访问源系统的情况下,更新客户端的信息。客户管理系统可能在客户修改自己的详细信息(如地址)时抛出事件,事件包含了详细的修改数据。因此,接收方无需与客户管理系统通信,就可以更新自己的客户数据副本,以进行下一步的操作。

这种模式的一个明显缺点是,有很多冗余数据和副本。但在存储很便宜的时代,这不是一个问题。我们获得了更好的弹性,因为即使客户管理系统不可用时,接收方系统仍然可以正常工作。我们减少了延迟,因为访问客户信息不需要远程调用。我们也不必担心所有来自消费端的查询给客户管理系统带来的负载。但它确实给事件接收端带来了更多复杂性,因为它必须维护所有状态,而如果它直接访问事件发送方查询信息,通常会更加容易。

事件源

事件源(Event Sourcing)的核心思想是,每当系统状态发生变化时,都将状态更改记录为事件,这样我们就有信心在任何时间都能够通过重新处理事件来重建系统状态。事件库成为事实的主要来源,系统状态完全来源于它。对于程序员来说,最好的例子就是版本控制系统。所有的提交日志就是事件库,源码树的工作副本是系统状态。

事件源引入了很多问题,我不会在这里讨论,但我想强调一些常见的误解。事件处理不必是异步的,以更新本地Git库为例,这完全是一个同步操作,就像更新Subversion这样的集中式版本控制系统一样。当然拥有所有这些提交允许你做各种有趣的事情,Git就是一个很好的例子,但核心提交从根本上说是一个简单的动作。

另一个常见错误是,假定使用事件源系统的每个人都应该理解并访问事件日志以确定有用的数据。但实际上很可能对事件日志只具备有限的了解。我正在使用编辑器写这篇文章,编辑器不知道我的源代码树中的所有提交,它只是假设磁盘上有一个文件。事件源系统中的大部分处理可以基于有效的工作副本。只有当真正需要事件日志中的信息时才必须处理它。如果需要的话,我们可以有多个不同Schema的工作副本,但通常应该在领域处理和通过事件日志派生工作副本之间做明确区分。

使用事件日志时,构建工作副本的快照通常很有用,这样你就不必在每次需要工作副本时都从头开始处理所有事件。实际上这里存在二元性,我们可以将事件日志视为变更列表或状态列表。 我们可以从一个派生出另一个。版本控制系统通常在事件日志中混合快照和增量变更,以获得最佳性能。[1]

考虑一下版本控制系统带来的价值,就很容易明白事件源有许多有趣的收益。事件日志提供了强大的审计功能(账户交易是帐户余额的事件源)。我们可以重放事件日志到某个点来重新创建历史状态。在重放时注入假设事件可以探索不一样的历史。事件源使得非持久化的工作副本(例如Memory Image)变得合理可行。

事件源也有自己的问题。 当结果依赖于与外部系统的交互时,重放事件就会成为问题。随着时间的推移,我们必须清楚如何处理事件Schema的变化。许多人发现事件处理给系统增加了很多复杂性(尽管我很想知道,工作副本派生组件和领域处理组件之间糟糕的隔离,是不是更主要的原因)。

CQRS

命令查询职责分离(CQRS)是指读取和写入分别拥有单独的数据结构。 严格地说,CQRS跟事件没有关系,因为你完全不需要任何事件就可以使用CQRS。但通常人们会将CQRS与之前的模式结合起来,因此我们在峰会上就此进行了讨论。

使用CQRS的理由是,在复杂领域中,使用单一模型处理读取和写入过于复杂,我们可以通过分离模型来简化。当访问模式有区别时(例如大量读取和非常少的写入),这一点尤其具有吸引力。但是,需要注意平衡CQRS的收益和分离模型所带来的额外复杂度。我发现很多同事对使用CQRS非常警惕,发现它经常被滥用。

理解这些模式

作为一名热衷于收集样本的软件植物学家,我发现这是一个棘手的地带。 核心问题是不同模式的混淆。 在某个项目中,一位能力很强,经验丰富的项目经理告诉我,事件源是一场灾难,任何变化都需要两倍的时间来修改读和写模型。 在他这句话中,我可以发现事件源和CQRS之间可能存在混淆,我们如何找出哪个是罪魁祸首? 该项目的技术主管声称主要问题是大量的异步通信,这当然是一个已知的复杂性助推器,但这不是事件源或CQRS的必要组成部分。 总的来说,我们必须要注意这些模式在对的地方都很好,反之则很糟糕。 但是当我们混淆了这些模式时,很难弄清楚哪里是对的地方。

我想写一篇论文来梳理清楚所有的混乱,提供强有力的指导,告诉你何时使用,以及如何使用好每一种模式。遗憾的是,我没有那么多时间。 我希望这篇文章有用,但它可能缺少你真正需要的信息。

Best Regards

文/Martin Fowler

译/梅雪松


更多精彩商业洞见,请关注微信公众号:思特沃克

Share

微服务 | Martin Fowler

作者:Martin Fowler & James Lewis 译者:伍斌

微服务

“微服务架构”这一术语在前几年横空出世,用于描述这样一种特定的软件设计方法,即以若干组可独立部署的服务的方式进行软件应用系统的设计。尽管这种架构风格尚无明确的定义,但其在下述方面还是存在一定的共性,即围绕业务功能的组织、自动化部署、端点智能、以及在编程语言和数据方面进行去中心化的控制。

本文目录

  • 微服务架构的九大特性
    • 特性一:“组件化”与“多服务”
    • 特性二:围绕“业务功能”组织团队
    • 特性三:“做产品”而不是“做项目”
    • 特性四:“智能端点”与“傻瓜管道”
    • 特性五:“去中心化”地治理技术
    • 特性六:“去中心化”地管理数据
    • 特性七:“基础设施”自动化
    • 特性八:“容错”设计
    • 特性九:“演进式”设计
  • 未来的方向是“微服务”吗?

“微服务”——这是在“软件架构”这条熙熙攘攘的大街上出现的又一个新词语。我们很容易对它不屑一顾,但是这个小小的术语却描述了一种引人入胜的软件系统风格。在近几年中,我们越来越多的看到许多项目使用了这种风格,而且就目前来说结果都是不错的,以至于许多ThoughtWorker都把它看作构建企业应用系统的默认风格。然而,很不幸的是,我们找不到有关它的概要信息,即什么是微服务风格,以及如何设计微服务风格的架构。

简而言之,微服务架构风格[1]这种开发方法,是以开发一组小型服务的方式来开发一个独立的应用系统。其中每个小型服务都运行在自己的进程中,并经常采用HTTP资源API这样轻量的机制来相互通信。这些服务围绕业务功能进行构建,并能通过全自动的部署机制来进行独立部署。这些微服务可以使用不同的语言来编写,并且可以使用不同的数据存储技术。对这些微服务,我们仅做最低限度的集中管理。

在开始介绍微服务风格之前,将其与单块(monolithic)风格进行对比还是很有意义的:一个单块应用系统是以一个单个单元的方式来构建的。企业应用系统经常包含三个主要部分:客户端用户界面、数据库和服务端应用系统。客户端用户界面包括HTML页面和运行在用户机器的浏览器中的JavaScript。数据库中包括许多表,这些表被插入一个公共的且通常为关系型的数据库管理系统中。这个服务端的应用系统就是一个单块应用——一个单个可执行的逻辑程序[2]。对于该系统的任何改变,都会涉及构建和部署上述服务端应用系统的一个新版本。

这样的单块服务器是构建上述系统的一种自然的方式。处理用户请求的所有逻辑都运行在一个单个的进程内,因此能使用编程语言的基本特性,来把应用系统划分为类、函数和命名空间。通过精心设计,得以在开发人员的笔记本电脑上运行和测试这样的应用系统,并且使用一个部署流水线来确保变更被很好地进行了测试,并被部署到生产环境中。通过负载均衡器运行许多实例,来将这个单块应用进行横向扩展。

单块应用系统可以被成功地实现,但是渐渐地,特别是随着越来越多的应用系统正被部署到云端,人们对它们开始表现出不满。软件变更受到了很大的限制,应用系统中一个很小部分的一处变更,也需要将整个单块应用系统进行重新构建和部署。随着时间的推移,单块应用逐渐难以保持一个良好的模块化结构,这使得它变得越来越难以将一个模块的变更所产生的影响控制在该模块内。当对系统进行扩展时,不得不扩展整个应用系统,而不能仅扩展该系统中需要更多资源的那些部分。

图1: 单块应用和微服务

这些不满催生出了微服务架构风格:以构建一组小型服务的方式来构建应用系统。除了这些服务能被独立地部署和扩展之外,每一个服务还能提供一个稳固的模块边界,甚至能允许使用不同的编程语言来编写不同的服务。这些服务也能被不同的团队来管理。

我们并不认为微服务风格是一个新颖或创新的概念,它的起源至少可以追溯到Unix的设计原则。但是我们觉得,考虑微服务架构的人还不够多,并且如果对其加以使用,许多软件的开发工作能变得更好。

微服务架构的九大特性

虽然不能说存在微服务架构风格的正式定义,但是可以尝试描述我们所见到的、能够被贴上“微服务”标签的那些架构的共性。下面所描述的这些共性,并不是所有的微服务架构都完全具备,但是我们确实期望大多数微服务架构都具备这些共性中的大多数特性。尽管我们两位作者已经成为这个相当松散的社区中的活跃成员,但我们的本意还是描述我们两人在自己所在和所了解的团队工作中所看到的情况。特别要指出,我们不会制定大家需要遵循的微服务的定义。

特性一:“组件化”与“多服务”

自我们从事软件行业以来,发现大家都有“把组件插在一起来构建系统”的愿望,就像在物理世界中所看到的那样。在过去几十年中,我们已经看到,在公共软件库方面已经取得了相当大的进展,这些软件库是大多数编程语言平台的组成部分。

当谈到组件时,会碰到一个有关定义的难题,即什么是组件?我们的定义是:一个组件就是一个可以独立更换和升级的软件单元。

微服务架构也会使用软件库,但其将自身软件进行组件化的主要方法是将软件分解为诸多服务。我们将软件库(libraries)定义为这样的组件,即它能被链接到一段程序,且能通过内存中的函数来进行调用。然而,服务(services)是进程外的组件,它们通过诸如web service请求或远程过程调用这样的机制来进行通信(这不同于许多面向对象的程序中的service object概念[3])。

以使用服务(而不是以软件库)的方式来实现组件化的一个主要原因是,服务可被独立部署。如果一个应用系统[4]由在单个进程中的多个软件库所组成,那么对任一组件做一处修改,都不得不重新部署整个应用系统。但是如果该应用系统被分解为多个服务,那么对于一个服务的多处修改,仅需要重新部署这一个服务。当然这也不是绝对的,一些变更服务接口的修改会导致多个服务之间的协同修改。但是一个良好的微服务架构的目的,是通过内聚的服务边界和服务协议方面的演进机制,来将这样的修改变得最小化。

以服务的方式来实现组件化的另一个结果,是能获得更加显式的(explicit)组件接口。大多数编程语言并没有一个良好的机制来定义显式的发布接口。通常情况下,这样的接口仅仅是文档声明和团队纪律,来避免客户端破坏组件的封装,从而导致组件间出现过度紧密的耦合。通过使用显式的远程调用机制,服务能更容易地规避这种情况。

如此使用服务,也会有不足之处。比起进程内调用,远程调用更加昂贵。所以远程调用API接口必须是粗粒度的,而这往往更加难以使用。如果需要修改组件间的职责分配,那么当跨越进程边界时,这种组件行为的改动会更加难以实现。

近似地,我们可以把一个个服务映射为一个个运行时的进程,但这仅仅是一个近似。一个服务可能包括总是在一起被开发和部署的多个进程,比如一个应用系统的进程和仅被该服务使用的数据库。

特性二:围绕“业务功能”组织团队

当在寻求将一个大型应用系统分解成几部分时,公司管理层往往会聚焦在技术层面上,这就意味着要组建用户界面团队、服务器端团队和数据库团队。当团队沿着这些技术线分开后,即使要实现软件中一个简单的变更,也会发生跨团队的项目时延和预算审批。在这种情况下,聪明的团队会进行局部优化,“两害相权取其轻”,来直接把代码逻辑塞到他们能访问到的任意应用系统中。换句话说,这种情况会导致代码逻辑散布在系统各处。这就是康威定律[5]的鲜活实例。

任何设计(广义上的)系统的组织,都会产生这样一个设计,即该设计的结构与该组织的沟通结构相一致。——梅尔文•康威(Melvyn Conway), 1967年

图2:康威定律在起作用

微服务使用不同的方法来分解系统,即根据业务功能(business capability)来将系统分解为若干服务。这些服务针对该业务领域提供多层次、广泛的软件实现,包括用户界面、持久性存储以及任何对外的协作性操作。因此,团队是跨职能的,它拥有软件开发所需的全方位的技能:用户体验、数据库和项目管理。

图3:被团队边界所强化的服务边界

以上述方式来组织团队的公司是www.comparethemarket.com。跨职能团队负责构建和运维每个产品,而每个产品被拆分为多个独立的服务,彼此通过一个消息总线来通信。

一个微服务应该有多大?

尽管许多人已经习惯于用“微服务”来概括描述这种这种架构风格,但是这个名字确实会不幸地引发大家对服务规模的关注,并且产生有关什么是“微”的争论。在与微服务从业者的交谈中,我们看到了有关服务的一系列规模。所听到的最大的一个服务规模,是遵循了亚马逊的“两个比萨团队”(即一个团队可以被两个比萨所喂饱)的理念所形成的,这意味着这个团队不会多于12人。对于规模较小的服务,我们已经看到一个6人的团队在支持6个服务。

这引出了一个问题,即“每12人做一个服务”和“每人做一个服务”这样有关服务规模的差距,是否已经大到不能将两者都纳入微服务之下?此时,我们认为最好还是把它们归为一类,但是随着探索的深入,我们将来极有可能会改变主意。

大型单块应用系统也可以始终根据业务功能来进行模块化设计,虽然这并不常见。当然,我们会敦促构建单块应用系统的大型团队根据业务线来将自己分解为若干小团队。在这方面,我们已经看到的主要问题是,他们往往是一个团队包含了太多的业务功能。如果这个“单块”跨越了许多模块的边界,那么这个团队的每一个成员都难以记住所有模块的业务功能。此外,我们看到这些模块的边界需要大量的团队纪律来强制维持。而实现组件化的服务所必要的更加显式的边界,能更加容易地保持团队边界的清晰性。

特性三:“做产品”而不是“做项目”

我们所看的大部分应用系统的开发工作都使用项目模型:目标是交付某一块软件,之后就认为完工了。一旦完工后,软件就被移交给维护团队,接着那个构建该软件的项目团队就会被解散。

微服务的支持者们倾向于避免使用上述模型,而宁愿采纳“一个团队在一个产品的整个生命周期中都应该保持对其拥有”的理念。通常认为这一点源自亚马逊的“谁构建,谁运行”的理念,即一个开发团队对一个在生产环境下运行的软件负全责。这会使开发人员每天都关注软件是如何在生产环境下运行的,并且增进他们与用户的联系,因为他们必须承担某些支持工作。

这样的“产品”理念,是与业务功能的联动绑定在一起的。它不会将软件看作是一个待完成的功能集合,而是认为存在这样一个持续的关系,即软件如何能助其客户来持续增进业务功能。

当然,单块应用系统的开发工作也可以遵循上述“产品”理念,但是更细粒度的服务,能让服务的开发者与其用户之间的个人关系的创建变得更加容易。

特性四:“智能端点”与“傻瓜管道”

当在不同的进程之间构建各种通信结构时,我们已经看到许多产品和方法,来强调将大量的智能特性纳入通信机制本身。其中一个典型例子,就是“企业服务总线”(Enterprise Service Bus, ESB)。ESB产品经常包括高度智能的设施,来进行消息的路由、编制(choreography)、转换,并应用业务规则。

微服务社区主张采用另一种做法:智能端点(smart endpoints)和傻瓜管道(dumb pipes)。使用微服务所构建的各个应用的目标,都是尽可能地实现“高内聚和低耦合”——他们拥有自己的领域逻辑,并且更像是经典Unix的“过滤器”(filter)那样来工作——即接收一个请求,酌情对其应用业务逻辑,并产生一个响应。这些应用通过使用一些简单的REST风格的协议来进行编制,而不去使用诸如下面这些复杂的协议,即”WS-编制”(WS-Choreography)、BPEL或通过位于中心的工具来进行编排(orchestration)。

微服务最常用的两种协议是:带有资源API的HTTP“请求-响应”协议,和轻量级的消息发送协议[6]。对于前一种协议的最佳表述是:

成为Web,而不是躲着Web (Be of the web, not behind the web)——Ian Robinson

这些微服务团队在开发中,使用在构建万维网(world wide web)时所使用的原则和协议(并且在很大程度上,这些原则和协议也是在构建Unix系统时所使用的)。那些被使用过的HTTP资源,通常能被开发或运维人员轻易地缓存起来。

最常用的第二种协议,是通过一个轻量级的消息总线来进行消息发送。此时所选择的基础设施,通常是“傻瓜”(dumb)型的(仅仅像消息路由器所做的事情那样傻瓜)——像RabbitMQ或ZeroMQ那样的简单实现,即除了提供可靠的异步机制(fabric)以外不做其他任何事情——智能功能存在于那些生产和消费诸多消息的各个端点中,即存在于各个服务中。

在一个单块系统中,各个组件在同一个进程中运行。它们相互之间的通信,要么通过方法调用,要么通过函数调用来进行。将一个单块系统改造为若干微服务的最大问题,在于对通信模式的改变。仅仅将内存中的方法调用转换为RPC调用这样天真的做法,会导致微服务之间产生繁琐的通信,使得系统表现变糟。取而代之的是,需要用更粗粒度的协议来替代细粒度的服务间通信。

特性五:“去中心化”的治理技术

使用中心化的方式来对开发进行治理,其中一个后果,就是趋向于在单一技术平台上制定标准。经验表明,这种做法会带来局限性——不是每一个问题都是钉子,不是每一个方案都是锤子。我们更喜欢根据工作的不同来选用合理的工具。尽管那些单块应用系统能在一定程度上利用不同的编程语言,但是这并不常见。

如果能将单块应用的那些组件拆分成多个服务,那么在构建每个服务时,就可以有选择不同技术栈的机会。想要使用Node.js来搞出一个简单的报表页面?尽管去搞。想用C++来做一个特别出彩的近乎实时的组件?没有问题。想要换一种不同风格的数据库,来更好地适应一个组件的读取数据的行为?可以重建。

微服务和SOA

当我们谈起微服务时,一个常见的问题就会出现:是否微服务仅仅是十多年前所看到的“面向服务的架构”(Service Oriented Architecture, SOA)?这样问是有道理的,因为微服务风格非常类似于一些支持SOA的人所赞成的观点。然而,问题在于SOA这个词儿意味着太多不同的东西。而且大多数时候,我们所遇到的某些被称作”SOA”的事物,明显不同于本文所描述的风格。这通常由于它们专注于ESB,来集成各个单块应用。

特别地,我们已经看到如此之多的面向服务的拙劣实现——从将系统复杂性隐藏于ESB中的趋势[7],到花费数百万进行多年却没有交付任何价值的失败项目,到顽固抑制变化发生的中心化技术治理模型——以至于有时觉得其所造成的种种问题真的不堪回首。

当然,在微服务社区投入使用的许多技术,源自各个开发人员将各种服务集成到各个大型组织的经验。“容错读取”(Tolerant Reader)模式就是这样一个例子。对于Web的广泛使用,使得人们不再使用一些中心化的标准,而使用一些简单的协议。坦率地说,这些中心化的标准,其复杂性已经达到令人吃惊的程度。(任何时候,如果需要一个本体来管理其他各个本体,那么麻烦就大了。)

这种常见的SOA表现,已使得一些微服务的倡导者完全拒绝将自己贴上SOA的标签。尽管其他人会将微服务看作是SOA的一种形式[8],也许微服务就是以正确的形式来实现面向服务的SOA。不管是哪种情况,SOA意味着如此之多的不同事物,这表明用一个更加干净利落的术语来命名这种架构风格是很有价值的。

当然,仅仅能做事情,并不意味着这些事情就应该被做——不过用微服务的方法把系统进行拆分后,就拥有了技术选型的机会。

相比选用业界一般常用的技术,构建微服务的那些团队更喜欢采用不同的方法。与其选用一组写在纸上已经定义好的标准,他们更喜欢编写一些有用的工具,来让其他开发者能够使用,以便解决那些和他们所面临的问题相似的问题。这些工具通常源自他们的微服务实施过程,并且被分享到更大规模的组织中,这种分享有时会使用内部开源的模式来进行。事实上,现在git和github已经成为首选版本控制系统。在企业内部,开源的做法正在变得越来越普遍。

Netflix公司是遵循上述理念的好例子。将实用且经过实战检验的代码以软件库的形式共享出来,能鼓励其他开发人员以相似的方式来解决相似的问题,当然也为在需要的时候选用不同的方案留了一扇门。共享软件库往往聚焦于解决这样的常见问题,即数据存储、进程间的通信和下面要进一步讨论的基础设施的自动化。

对于微服务社区来说,日常管理开销这一点不是特别吸引人。这并不是说这个社区并不重视服务契约。恰恰相反,它们在社区里出现得更多。这正说明这个社区正在寻找对其进行管理的各种方法。像“容错读取”和“消费者驱动的契约”(Consumer-Driven Contracts)这样的模式,经常被运用到微服务中。这些都有助于服务契约进行独立演进。将执行“消费者驱动的契约”做为软件构建的一部分,能增强开发团队的信心,并提供所依赖的服务是否正常工作的快速反馈。实际上,我们了解到一个在澳洲的团队就是使用“消费者驱动的契约”来驱动构建多个新服务的。他们使用了一些简单的工具,来针对每一个服务定义契约。甚至在新服务的代码编写之前,这件事就已经成为自动化构建的一部分了。接下来服务仅被构建到刚好能满足契约的程度——这是一个在构建新软件时避免YAGNI[9]困境的优雅方法。这些技术和工具在契约周边生长出来,由于减少了服务之间在时域(temporal)上的耦合,从而抑制了对中心契约管理的需求。

多种编程语言,多种选择可能

做为一个平台,JVM的发展仅仅是一个将各种编程语言混合到一个通用平台的最新例证。近十年以来,通过在平台外层实现更高层次的编程语言,来利用更高层次的抽象,已经成为一个普遍做法。同样,在平台底层以更低层次的编程语言编写性能敏感的代码也很普遍。然而,许多单块系统并不需要这种级别的性能优化,另外DSL和更高层次的抽象也不常用(这令我们感到失望)。相反,许多单块应用通常就使用单一编程语言,并且有对所使用的技术数量进行限制的趋势[10]。

或许去中心化地治理技术的极盛时期,就是亚马逊的“谁构建,谁运行”的理念开始普及的时候。各个团队负责其所构建的软件的所有工作,其中包括7×24地对软件进行运维。“将运维这一级别的职责下放到团队”这种做法,目前绝对不是主流。但是我们确实看到越来越多的公司,将运维的职责交给各个开发团队。Netflix就是已经形成这种风气的另一个组织[11]。避免每天凌晨3点被枕边的寻呼机叫醒,无疑是在程序员编写代码时令其专注质量的强大动力。而这些想法,与那些传统的中心化技术治理的模式具有天壤之别。

特性六:“去中心化”地管理数据

去中心化地管理数据,其表现形式多种多样。从最抽象的层面看,这意味着各个系统对客观世界所构建的概念模型各不相同。当在一个大型的企业中进行系统集成时,这是一个常见的问题。比如对于“客户”这个概念,从销售人员的视角看,就与从支持人员的视角看有所不同。从销售人员的视角所看到的一些被称之为“客户”的事物,或许在支持人员的视角中根本找不到。而那些在两个视角中都能看到的事物,或许各自具有不同的属性。更糟糕的是,那些在两个视角中具有相同属性的事物,或许在语义上有微妙的不同。

上述问题在不同的应用程序之间经常出现,同时也会出现在这些应用程序内部,特别是当一个应用程序被分成不同组件时就会出现。思考这类问题的一个有效方法,就是使用领域驱动设计(Domain-Driven Design, DDD)中的“限界上下文”(Bounded Context)的概念。DDD将一个复杂的领域划分为多个限界上下文,并且将其相互之间的关系用图画出来。这一划分过程对于单块和微服务架构两者都是有用的,而且就像前面有关“业务功能”一节中所讨论的那样,在服务和各个限界上下文之间所存在的自然的联动关系,有助于澄清和强化这种划分。

“实战检验”的标准与“强制执行”的标准

微服务的下述做法有点泾渭分明的味道,即他们趋向于避开被那些企业架构组织所制定的硬性实施标准,而愉快地使用甚至传播一些开放标准,比如HTTP、ATOM和其他微格式的协议。

这里的关键区别是,这些标准是如何被制定以及如何被实施的。像诸如IETF这样的组织所管理的各种标准,只有达到下述条件才能称为标准,即该标准在全球更广阔的地区有一些正在运行的实现案例,而且这些标准经常源自一些成功的开源项目。

这些标准组成了一个世界,它区别于来自下述另一个世界的许多标准,即企业世界。企业世界中的标准,经常由这样特点的组织来开发,即缺乏用较新技术进行编程的经验,或受到供应商的过度影响。

如同在概念模型上进行去中心化的决策一样,微服务也在数据存储上进行去中心化的决策。尽管各个单块应用更愿意在逻辑上各自使用一个单独的数据库来持久化数据,但是各家企业往往喜欢一系列单块应用共用一个单独的数据库——许多这样的决策是被供应商的各种版权商业模式所驱动出来的。微服务更喜欢让每一个服务来管理其自有数据库。其实现可以采用相同数据库技术的不同数据库实例,也可以采用完全不同的数据库系统。这种方法被称作“多语种持久化”(Polyglot Persistence)。在一个单块系统中也能使用多语种持久化,但是看起来这种方法在微服务中出现得更加频繁。

图4:微服务更喜欢让每一个服务来管理其自有数据库

在各个微服务之间将数据的职责进行“去中心化”的管理,会影响软件更新的管理。处理软件更新的常用方法,是当更新多个资源的时候,使用事务来保证一致性。这种方法经常在单块系统中被采用。

像这样使用事务,有助于保持数据一致性。但是在时域上会引发明显的耦合,这样一来,在多个服务之间处理事务时会出现一致性问题。分布式事务实现难度之大是不必多言的。为此,微服务架构更强调在各个服务之间进行“无事务”的协调。这源自微服务社区明确地认识到下述两点,即数据一致性可能只要求数据在最终达到一致,并且一致性问题能够通过补偿操作来进行处理。

对于许多开发团队来说,选择这种方式来管理数据的“非一致性”,是一个新的挑战。但这通常也符合在商业上的实践做法。通常情况下,为了快速响应需求,商家们都会处理一定程度上的数据“非一致性”,通过做某种反向过程来进行错误处理。只要修复错误的成本低于“保持更大的数据一致性却导致丢了生意所产生”的成本相比,那么进行这种“非一致性”地数据管理就是值得的。

特性七:“基础设施”自动化

基础设施自动化技术在过去几年里已经得到长足的发展。云的演进,特别是AWS的发展,已经降低了构建、部署和运维微服务的操作复杂性。

许多使用微服务构建的产品和系统,正在被这样的团队所构建,即他们都具备极其丰富的“持续交付”和其前身“持续集成”的经验。用这种方法构建软件的各个团队,广泛采用了基础设施的自动化技术。如下图的构建流水线所示:

图5:基本的构建流水线

由于本文并不是一篇有关持续交付的文章,所以下面仅提请大家注意两个持续交付的关键特点。为了尽可能地获得对正在运行的软件的信心,需要运行大量的自动化测试。让可工作的软件达到“晋级”(Promotion)状态、从而“推上”流水线,就意味着可以在每一个新的环境中,对软件进行自动化部署。

一个单块应用程序,能够相当愉快地在上述各个环境中,被构建、测试和推送。其结果是,一旦在下述工作中进行了投入,即针对一个单块系统将其通往生产环境的通道进行自动化,那么部署更多的应用系统似乎就不再可怕。记住,持续交付的目的之一,是让“部署”工作变得“无聊”。所以不管是一个还是三个应用系统,只要部署工作依旧很“无聊”,那么就没什么可担心的了[12]。

让“沿着正确的方向做事”更容易

那些因实现持续交付和持续集成所增加的自动化工作的副产品,是一些对开发和运维人员有用的工具。现在,能完成下述工作的工具已经相当常见了,即创建工件(artefacts)、管理代码库、启动一些简单的服务、或增加标准的监控和日志功能。Web上最好的例子可能是Netflix提供的一套开源工具集,但也有其他一些好工具,包括我们已经广泛使用的Dropwizard

我们所看到的各个团队在广泛使用基础设施自动化实践的另一个领域,是在生产环境中管理各个微服务。与前面我们对比单块系统和微服务所说的正相反,只要部署工作很无聊,那么在这一点上单块系统和微服务就没什么区别。然而,两者在运维领域的情况却截然不同。

图6:两者的模块部署经常会有差异

特性八:“容错”设计

使用各个微服务来替代组件,其结果是各个应用程序需要被设计的能够容忍这些服务所出现的故障。如果服务提供方不可用,那么任何对该服务的调用都会出现故障。客户端要尽可能优雅地应对这种情况。与一个单块设计相比,这是一个劣势。因为在处理这种情况时会引入额外的复杂性。为此,各个微服务团队在不断地反思:这些服务故障是如何影响用户体验的。Netflix公司所研发的开源测试工具Simian Army,能够诱导服务发生故障,甚至能诱导一个数据中心在工作日发生故障,来测试该应用的弹性和监控能力。

这种在生产环境中所进行的自动化测试,足以让大多数运维组织兴奋得浑身颤栗,就像即将迎来一周的长假那样。这并不是说单块架构风格不能构建先进的监控系统——只是根据我们的经验,这在单块系统中并不常见罢了。

“断路器”与“可随时上线的代码”

断路器”(Circuit Breaker)一词与其他一些模式一起出现在《Release It!》一书中,例如隔板(Bulkhead)和超时(Timeout)。当构建彼此通信的应用系统时,将这些模式加以综合运用就变得至关重要。Netflix公司的这篇很精彩的博客解释了这些模式是如何应用的。

因为各个服务可以在任何时候发生故障,所以下面两件事就变得很重要,即能够快速地检测出故障,而且在可能的情况下能够自动恢复服务。各个微服务的应用都将大量的精力放到了应用程序的实时监控上,来检查“架构元素指标”(例如数据库每秒收到多少请求)和“业务相关指标”(例如系统每分钟收到多少订单)。当系统某个地方出现问题,语义监控系统能提供一个预警,来触发开发团队进行后续的跟进和调查工作。

这对于一个微服务架构是尤其重要的,因为微服务对于服务编制(choreography)和事件协作的偏好,会导致“突发行为”。尽管许多权威人士对于偶发事件的价值持积极态度,但事实上,“突发行为”有时是一件坏事。在能够快速发现有坏处的“突发行为”并进行修复方面,监控是至关重要的。

单块系统也能构建的像微服务一样来实现透明的监控系统——实际上,它们也应该如此。差别是,绝对需要知道那些运行在不同进程中的服务,在何时断掉了。而如果在同一个进程内使用软件库的话,这种透明的监控系统就用处不大了。

“同步调用”有害

一旦在一些服务之间进行多个同步调用,就会遇到宕机的乘法效应。简而言之,这意味着整个系统的宕机时间,是每一个单独模块各自宕机时间的乘积。此时面临着一个选择:是让模块之间调用异步,还是去管理宕机时间?在英国卫报网站,他们在新平台上实现了一个简单的规则——每一个用户请求都对应一个同步调用。然而在Netflix公司,他们重新设计的平台API将异步性构建到API的机制(fabric)中。

那些微服务团队希望在每一个单独的服务中,都能看到先进的监控和日志记录装置。例如显示“运行/宕机”状态的仪表盘,和各种运维、业务相关的指标。另外我们经常在工作中会碰到这样一些细节,即断路器的状态、当前的吞吐率和延迟,以及其他一些例子。

特性九:“演进式”设计

那些微服务的从业者们,通常具有演进式设计的背景,而且通常将服务的分解,视作一个额外的工具,来让应用开发人员能够控制应用系统中的变化,而无须减少变化的发生。控制变化并不一定意味着要减少变化——在正确的态度和工具的帮助下,软件中的变化也可以发生得频繁、快速且得到良好的控制。

每当要试图将软件系统分解为各个组件时,就会面临这样的决策,即如何进行切分——我们决定切分应用系统时应该遵循的原则是什么?一个组件的关键属性,是具有独立更换和升级的特点[13]——这意味着,需要寻找这些点,即想象着能否在其中一个点上重写该组件,而无须影响该组件的其他合作组件。事实上,许多做微服务的团队会更进一步,他们明确地预期许多服务将来会报废,而不是守着这些服务做长期演进。

英国卫报网站是一个好例子。原先该网站是一个以单块系统的方式来设计和构建的应用系统,然而它已经开始向微服务方向进行演进了。原先的单块系统依旧是该网站的核心,但是在添加新特性时他们愿意以构建一些微服务的方式来进行添加,而这些微服务会去调用原先那个单块系统的API。当开发那些本身就带有临时性特点的新特性时,这种方法就特别方便,例如开发报道一个体育赛事的专门页面。当使用一些快速的开发语言时,这样的网站页面就能被快速地整合起来。而一旦赛事结束,这样页面就可以被删除。在一个金融机构中,我们已经看到了一些相似的做法,即针对一个市场机会,一些新的服务可以被添加进来。然后在几个月甚至几周之后,这些新服务就作废了。

这种强调“可更换性”的特点,是模块化设计一般性原则的一个特例,通过“变化模式”(pattern of change)[14]来驱动模块化的实现。大家都愿意将那些能在同时发生变化的东西,放到同一个模块中。系统中那些很少发生变化的部分,应该被放到不同的服务中,以区别于那些正在经历大量变动(churn)的部分。当发现两个服务需要被同时、反复变更,就意味着它们两个需要被合并。

把一个个组件放入一个个服务中,提高了软件发布精细化的程度。对于一个单块系统,任何变化都需要做一次整个应用系统的全量构建和部署。然而,对于一个个微服务来说,只需要重新部署修改过的那些服务就够了。这能简化并加快发布过程。但缺点是:必须要考虑当一个服务发生变化时,依赖它并对其进行消费的其他服务将无法工作。传统的集成方法是使用版本化来解决这个问题。但在微服务世界中,大家更喜欢将版本化作为最后万不得已的手段来使用。我们可以通过下述方法来避免许多版本化的工作,即把各个服务设计得尽量能够容错,来应对其所依赖的服务所发生的变化。

未来的方向是“微服务”吗?

我们写这篇文章的主要目的,是解释有关微服务的主要思路和原则。在花了一点时间做了这件事后,我们清楚地认识到,微服务架构风格是一个重要的理念——在研发企业应用系统时,值得对它进行认真考虑。我们最近已经使用这种风格构建了一些系统,并且了解到其他一些团队也赞同并正在使用这种方法。

我们所了解到的那些在某种程度上可以被称作这种架构风格的实践先驱包括:亚马逊、Netflix、英国卫报英国政府数字化服务中心、realestate.com.au、Forward和comparethemarket.com。2013年的技术大会圈子充满了各种各样的、正在转向可归类为微服务的公司案例——包括Travis CI。另外还有大量的组织,它们长期以来一直在做着我们认为可以归类为微服务的产品,却从未使用过这个名字(这通常被标记为SOA——尽管正如我们所说,SOA会表现出各种自相矛盾的形式[15])。

尽管有这些正面的经验,但这并不意味着我们确信微服务是软件架构未来的方向。尽管到目前为止,与单块应用系统相比,我们对于所经历过的微服务的评价是积极的,但是我们也意识到这样的事实,即能供我们做出完整判断的时间还不够长。

通常,架构决策所产生的真正效果,只有在该决策做出若干年后才能真正显现。我们已经看到由带着强烈的模块化愿望的优秀团队所做的一些项目,最终构建出一个单块架构,并在几年之内不断腐化。许多人认为,如果使用微服务就不大可能出现这种腐化,因为服务的边界是明确的,而且难以随意搞乱。然而,对于那些开发时间足够长的各种系统,除非我们已经见识得足够多,否则我们无法真正评价微服务架构是如何成熟的。

我们的同事Sam Newman花了2014年的大部分时间撰写了一本书,来记述我们构建微服务的经验。如果想对这个话题进行更深入的了解,下一步就应该是阅读这本书。

有人觉得微服务或许很难成熟起来,这当然是有原因的。在组件化上所做的任何工作的成功度,取决于软件与组件的匹配程度。准确地搞清楚某个组件的边界位置应该出现在哪里,是一件困难的工作。演进式设计承认难以对边界进行正确定位,所以它将工作的重点放到了易于对边界进行重构之上。但是当各个组件成为各个进行远程通信的服务后,比起在单一进程内调用各个软件库,此时的重构就变得更加困难。跨越服务边界的代码移动就变得困难起来。接口的任何变化,都需要在其各个参与者之间进行协调。向后兼容的层次也需要被添加进来。测试也会变得更加复杂。

另一个问题是,如果这些组件不能干净利落地组合成一个系统,那么所做的一切工作,仅仅是将组件内的复杂性转移到组件之间的连接之上。这样做的后果,不仅仅是将复杂性搬了家,它还将复杂性转移到那些不再明确且难以控制的边界之上。在观察一个小型且简单的组件内部时,人们很容易觉得事情已经变得更好了,然而他们却忽视了服务之间杂乱的连接。

最后,还要考虑团队成员的技能水平。新技术往往会被技术更硬的团队所采用。对于技术更加过硬的团队而更有效的一项技术,不一定适用于技术略逊一筹的团队。我们已经看到大量这样的案例,那些技术略逊一筹的团队构建出了杂乱的单块架构。当这种杂乱发生到微服务身上时,会出现什么情况?这需要花时间来观察。一个糟糕的团队,总会构建一个糟糕的系统——在这种情况下,很难讲微服务究竟是减少了杂乱,还是让事情变得更糟。

我们听到一个合理的说法:不要一上来就以微服务架构做为起点。相反,要用一个单块系统做为起点,并保持其模块化。当这个单块系统出现了问题后,再将其分解为微服务。(尽管这个建议并不理想,因为一个良好的单一进程内的接口,通常不是一个良好的服务接口。)

因此,我们持谨慎乐观的态度来撰写此文。到目前为止,我们已经看到足够多的有关微服务风格的内容,并且觉得这是一条值得去跋涉的道路。我们不能肯定地说,道路的尽头在哪里。但是,软件开发的挑战之一,就是只能基于“目前手上拥有但还不够完善”的信息来做出决策。

若欲获取最新参考资料列表以得到更多信息,请参见微服务资源指南:http://martinfowler.com/microservices/

注:

[1]. 2011年5月在威尼斯附近的一个软件架构工作坊中,大家开始讨论“微服务”这个术语,因为这个词可以描述参会者们在架构领域进行探索时所见到的一种通用的架构风格。2012年5月,这群参会者决定将“微服务”作为描述这种架构风格的最贴切的名字。在2012年3月波兰的克拉科夫市举办的“33rd Degree”技术大会上,本文作者之一James在其“Microservices – Java, the Unix Way”演讲中以案例的形式谈到了这些微服务的观点,与此同时,Fred George也表达了同样的观点。Netflix公司的Adrian Cockcroft将这种方法描述为“细粒度的SOA”,并且作为先行者和本文下面所提到的众人已经着手在Web领域进行了实践——Joe Walnes, Dan North, Evan Botcher 和 Graham Tackley。

[2]. “单块”(monolith)这个术语已经被Unix社区使用一段时间了。它出现在The Art of Unix Programming一书中,来描述那些变得庞大的系统。

[3]. 许多面向对象的设计者,包括我们自己,都使用领域驱动设计中“service object”这个术语,来描述那种执行一段未被绑定到一个entity对象上的重要逻辑过程的对象。这不同于本文所讨论的”service”的概念。可悲的是,service这个术语同时具有这两个含义,我们必须忍受这样的多义词。

[4]. 我们认为一个应用系统是一个社会性的构建单元,来将一个代码库、功能组和资金体(body of funding)结合起来。

[5]. 原始论文参见梅尔文•康威的网站:http://www.melconway.com/Home/Committees_Paper.html

[6]. 在极度强调高效性(Scale)的情况下,一些组织经常会使用一些二进制的消息发送协议——例如protobuf。即使是这样,这些系统仍然会呈现出“智能端点和傻瓜管道”的特点——来在易读性(transparency)与高效性之间取得平衡。当然,大多数Web属性和绝大多数企业并不需要作出这样的权衡——获得易读性就已经是一个很大的胜利了。

[7]. 忍不住要提一下Jim Webber的说法:ESB表示Egregious Spaghetti Box(一盒极烂的意大利面条)。

[8]. Netflix让SOA与微服务之间的联系更加明确——直到最近这家公司还将他们的架构风格称为“细粒度的SOA”。

[9]. “YAGNI” 或者 “You Aren’t Going To Need It”(你不会需要它)是极限编程的一条原则和劝诫,指的是“除非到了需要的时候,否则不要添加新功能”。

[10]. 单块系统使用单一编程语言,这样讲有点言不由衷——为了在今天的Web上构建各种系统,可能要了解JavaScript、XHTML、CSS、服务器端的编程语言、SQL和一种ORM的方言。很难说只有一种单一编程语言,但是我们的意思你是懂得的。

[11]. Adrian Cockcroft在他2013年11月于Flowcon技术大会所做的一次精彩的演讲中,特别提到了“开发人员自服务”和“开发人员运行他们写的东西”(原文如此)。

[12]. 这里我们又有点言不由衷了。 很明显,在更复杂的网络拓扑里,部署更多的服务,会比部署一个单独的单块系统要更加困难。幸运的是,有一些模式能够减少其中的复杂性——但对于工具的投资还是必须的。

[13]. 事实上,Dan North将这种架构风格称作“可更换的组件架构”,而不是微服务。因为这看起来似乎是在谈微服务特性的一个子集,所以我们选择将其归类为微服务。

[14]. Kent Beck在《实现模式》(Implementation Patterns)一书中,将其作为他的一条设计原则而强调出来。

[15]. 当SOA这个词在本世纪初刚刚出现时,有人曾说:“我们很多年以来一直是这样做的。”有一派观点说,SOA这种风格,将企业级计算早期COBOL程序通过数据文件来进行通信的方式,视作自己的“根”。在另一个方向上,有人说“Erlang编程模型”与微服务是同一回事,只不过它被应用到一个企业应用的上下文中去了。


更多精彩洞见,请关注微信公众号:思特沃克

Share

IoC容器和Dependency Injection模式

译者:熊节

英文原文:Inversion of Control Containers and the Dependency Injection pattern

(最初发表于2004年1月)

摘要:Java社群近来掀起了一阵轻量级容器的热潮,这些容器能够帮助开发者将来自不同项目的组件组装成为一个内聚的应用程序。在它们的背后有着同一个模式,这个模式决定了这些容器进行组件装配的方式。人们用一个大而化之的名字来称呼这个模式:”控制反转”(Inversion ofControl,IoC)。在本文中,我将深入探索这个模式的工作原理,给它一个更能描述其特点的名字——”依赖注入”(Dependency Injection),并将其与”服务定位器”(Service Locator)模式作一个比较。不过,这两者之间的差异并不太重要,更重要的是:应该将组件的配置与使用分离开——两个模式的目标都是这个。

 

在企业级Java的世界里存在一个有趣的现象:有很多人投入很多精力来研究主流J2EE 技术的替代品——自然,这大多发生在open source社群。在很大程度上,这可以看作是开发者对主流J2EE技术的笨重和复杂作出的回应,但其中的确有很多极富创意的想法,的确提供了一些可供选择的方案。J2EE开发者常遇到的一个问题就是如何组装不同的程序元素:如果web控制器体系结构和数据库接口是由不同的团队所开发的,彼此几乎一无所知,你应该如何让它们配合工作?很多框架尝试过解决这个问题,有几个框架索性朝这个方向发展,提供了更通用的”组装各层组件”的方案。这样的框架通常被称为”轻量级容器”,PicoContainer和Spring都在此列中。

在这些容器背后,一些有趣的设计原则发挥着作用。这些原则已经超越了特定容器的范畴,甚至已经超越了Java平台的范畴。在本文中,我就要初步揭示这些原则。我使用的范例是Java代码,但正如我的大多数文章一样,这些原则也同样适用于别的OO环境,特别是.NET。

组件和服务

装配程序元素,这样的话题立即将我拖进了一个棘手的术语问题:如何区分”服务”(service)和”组件”(component)?你可以毫不费力地找出关于这两个词定义的长篇大论,各种彼此矛盾的定义会让你感受到我所处的窘境。有鉴于此,对于这两个遭到了严重滥用的词汇,我将首先说明它们在本文中的用法。

所谓”组件”是指这样一个软件单元:它将被作者无法控制的其他应用程序使用,但后者不能对组件进行修改。也就是说,使用一个组件的应用程序不能修改组件的源代码,但可以通过作者预留的某种途径对其进行扩展,以改变组件的行为。

服务和组件有某种相似之处:它们都将被外部的应用程序使用。在我看来,两者之间最大的差异在于:组件是在本地使用的(例如JAR文件、程序集、DLL、或者源码导入);而服务是要通过同步或异步的远程接口来远程使用的(例如web service、消息系统、RPC,或者socket)。

在本文中,我将主要使用”服务”这个词,但文中的大多数逻辑也同样适用于本地组件。实际上,为了方便地访问远程服务,你往往需要某种本地组件框架。不过,”组件或者服务”这样一个词组实在太麻烦了,而且”服务”这个词当下也很流行,所以本文将用”服务”指代这两者。

一个简单的例子

为了更好地说明问题,我要引入一个例子。和我以前用的所有例子一样,这是一个超级简单的例子:它非常小,小得有点不够真实,但足以帮助你看清其中的道理,而不至于陷入真实例子的泥潭中无法自拔。

在这个例子中,我编写了一个组件,用于提供一份电影清单,清单上列出的影片都是由一位特定的导演执导的。实现这个伟大的功能只需要一个方法:

class MovieLister...

    public Movie[] moviesDirectedBy(String arg)
    {
        List allMovies = finder.findAll();
        for (Iterator it = allMovies.iterator(); it.hasNext();)
        {
            Movie movie = (Movie) it.next();
            if (!movie.getDirector().equals(arg))
            {
                it.remove();
            }

        }
        return (Movie[]) allMovies.toArray(new Movie[allMovies.size()]);
}

你可以看到,这个功能的实现极其简单:moviesDirectedBy方法首先请求finder(影片搜寻者)对象(我们稍后会谈到这个对象)返回后者所知道的所有影片,然后遍历finder对象返回的清单,并返回其中由特定的某个导演执导的影片。非常简单,不过不必担心,这只是整个例子的脚手架罢了。我们真正想要考察的是finder对象,或者说,如何将MovieLister对象与特定的finder对象连接起来。为什么我们对这个问题特别感兴趣?因为我希望上面这个漂亮的moviesDirectedBy方法完全不依赖于影片的实际存储方式。所以,这个方法只能引用一个finder对象,而finder对象则必须知道如何对findAll 方法作出回应。为了帮助读者更清楚地理解,我给finder定义了一个接口:

public interface MovieFinder
{
    List findAll();
}

现在,两个对象之间没有什么耦合关系。但是,当我要实际寻找影片时,就必须涉及到MovieFinder的某个具体子类。在这里,我把涉及具体子类的代码放在MovieLister类的构造函数中。

class MovieLister...
    private MovieFinder finder;
    public MovieLister()
    {
        finder = new ColonDelimitedMovieFinder("movies1.txt");
    }

这个实现类的名字就说明:我将要从一个逗号分隔的文本文件中获得影片列表。你不必操心具体的实现细节,只要设想这样一个实现类就可以了。如果这个类只由我自己使用,一切都没问题。但是,如果我的朋友叹服于这个精彩的功能,也想使用我的程序,那又会怎么样呢?如果他们也把影片清单保存在一个逗号分隔的文本文件中,并且也把这个文件命名为” movie1.txt “,那么一切还是没问题。如果他们只是给这个文件改改名,我也可以从一个配置文件获得文件名,这也很容易。但是,如果他们用完全不同的方式——例如SQL 数据库、XML 文件、web service,或者另一种格式的文本文件——来存储影片清单呢?在这种情况下,我们需要用另一个类来获取数据。由于已经定义了MovieFinder接口,我可以不用修改moviesDirectedBy方法。但是,我仍然需要通过某种途径获得合适的MovieFinder实现类的实例。

图1:在MovieLister 类中直接创建MovieFinder 实例时的依赖关系

图1展现了这种情况下的依赖关系:MovieLister类既依赖于MovieFinder接口,也依赖于具体的实现类。我们当然希望MovieLister类只依赖于接口,但我们要如何获得一个MovieFinder子类的实例呢?

在Patterns of Enterprise Application Architecture一书中,我们把这种情况称为插件(plugin):MovieFinder的实现类不是在编译期连入程序之中的,因为我并不知道我的朋友会使用哪个实现类。我们希望MovieLister类能够与MovieFinder的任何实现类配合工作,并且允许在运行期插入具体的实现类,插入动作完全脱离我(原作者)的控制。这里的问题就是:如何设计这个连接过程,使MovieLister类在不知道实现类细节的前提下与其实例协同工作。

将这个例子推而广之,在一个真实的系统中,我们可能有数十个服务和组件。在任何时候,我们总可以对使用组件的情形加以抽象,通过接口与具体的组件交流(如果组件并没有设计一个接口,也可以通过适配器与之交流)。但是,如果我们希望以不同的方式部署这个系统,就需要用插件机制来处理服务之间的交互过程,这样我们才可能在不同的部署方案中使用不同的实现。所以,现在的核心问题就是:如何将这些插件组合成一个应用程序?这正是新生的轻量级容器所面临的主要问题,而它们解决这个问题的手段无一例外地是控制反转(Inversion of Control)模式。

控制反转

几位轻量级容器的作者曾骄傲地对我说:这些容器非常有用,因为它们实现了控制反转。这样的说辞让我深感迷惑:控制反转是框架所共有的特征,如果仅仅因为使用了控制反转就认为这些轻量级容器与众不同,就好象在说我的轿车是与众不同的,因为它有四个轮子。

问题的关键在于:它们反转了哪方面的控制?我第一次接触到的控制反转针对的是用户界面的主控权。早期的用户界面是完全由应用程序来控制的,你预先设计一系列命令,例如输入姓名、输入地址等,应用程序逐条输出提示信息,并取回用户的响应。而在图形用户界面环境下,UI框架将负责执行一个主循环,你的应用程序只需为屏幕的各个区域提供事件处理函数即可。在这里,程序的主控权发生了反转:从应用程序移到了框架。对于这些新生的容器,它们反转的是如何定位插件的具体实现。在前面那个简单的例子中,MovieLister类负责定位MovieFinder的具体实现——它直接实例化后者的一个子类。这样一来,MovieFinder也就不成其为一个插件了,因为它并不是在运行期插入应用程序中的。而这些轻量级容器则使用了更为灵活的办法,只要插件遵循一定的规则,一个独立的组装模块就能够将插件的具体实现注射到应用程序中。因此,我想我们需要给这个模式起一个更能说明其特点的名字——”控制反转”这个名字太泛了,常常让人有些迷惑。与多位IoC 爱好者讨论之后,我们决定将这个模式叫做”依赖注入”(Dependency Injection)。

下面,我将开始介绍Dependency Injection模式的几种不同形式。不过,在此之前,我要首先指出:要消除应用程序对插件实现的依赖,依赖注入并不是唯一的选择,你也可以用ServiceLocator模式获得同样的效果。介绍完Dependency Injection模式之后,我也会谈到ServiceLocator 模式。

依赖注入的几种形式

Dependency Injection模式的基本思想是:用一个单独的对象(装配器)来获得MovieFinder的一个合适的实现,并将其实例赋给MovieLister类的一个字段。这样一来,我们就得到了图2所示的依赖图:

图2:引入依赖注入器之后的依赖关系

依赖注入的形式主要有三种,我分别将它们叫做构造函数注入(Constructor Injection)、设值方法注入(Setter Injection)和接口注入(Interface Injection)。如果读过最近关于IoC的一些讨论材料,你不难看出:这三种注入形式分别就是type 1 IoC(接口注入)、type 2 IoC(设值方法注入)和type 3 IoC(构造函数注入)。我发现数字编号往往比较难记,所以我使用了这里的命名方式。

使用PicoContainer 进行构造函数注入

首先,我要向读者展示如何用一个名为PicoContainer的轻量级容器完成依赖注入。之所以从这里开始,主要是因为我在ThoughtWorks公司的几个同事在PicoContainer的开发社群中非常活跃——没错,也可以说是某种偏袒吧。

PicoContainer通过构造函数来判断如何将MovieFinder实例注入MovieLister 类。因此,MovieLister类必须声明一个构造函数,并在其中包含所有需要注入的元素:

class MovieLister...
    public MovieLister(MovieFinder finder)
    {
        this.finder = finder;
    }

MovieFinder实例本身也将由PicoContainer来管理,因此文本文件的名字也可以由容器注入:

class ColonMovieFinder...
    public ColonMovieFinder(String filename)
    {
        this.filename = filename;
    }

随后,需要告诉PicoContainer:各个接口分别与哪个实现类关联、将哪个字符串注入MovieFinder组件。

    private MutablePicoContainer configureContainer()
    {
        MutablePicoContainer pico = new DefaultPicoContainer();
        Parameter[] finderParams = {newConstantParameter("movies1.txt")};
        pico.registerComponentImplementation(MovieFinder.class,ColonMovieFinder.class, finderParams);
        pico.registerComponentImplementation(MovieLister.class);
        return pico;
    }

这段配置代码通常位于另一个类。对于我们这个例子,使用我的MovieLister 类的朋友需要在自己的设置类中编写合适的配置代码。当然,还可以将这些配置信息放在一个单独的配置文件中,这也是一种常见的做法。你可以编写一个类来读取配置文件,然后对容器进行合适的设置。尽管PicoContainer本身并不包含这项功能,但另一个与它关系紧密的项目NanoContainer提供了一些包装,允许开发者使用XML配置文件保存配置信息。NanoContainer能够解析XML文件,并对底下的PicoContainer进行配置。这个项目的哲学观念就是:将配置文件的格式与底下的配置机制分离开。

使用这个容器,你写出的代码大概会是这样:

    public void testWithPico()
    {
        MutablePicoContainer pico = configureContainer();
        MovieLister lister = (MovieLister)pico.getComponentInstance(MovieLister.class);
        Movie[] movies = lister.moviesDirectedBy("Sergio Leone");
        assertEquals("Once Upon a Time in the West",movies[0].getTitle());
    }

尽管在这里我使用了构造函数注入,实际上PicoContainer也支持设值方法注入,不过该项目的开发者更推荐使用构造函数注入。

使用Spring 进行设值方法注入

Spring 框架是一个用途广泛的企业级Java 开发框架,其中包括了针对事务、持久化框架、web应用开发和JDBC等常用功能的抽象。和PicoContainer一样,它也同时支持构造函数注入和设值方法注入,但该项目的开发者更推荐使用设值方法注入——恰好适合这个例子。为了让MovieLister类接受注入,我需要为它定义一个设值方法,该方法接受类型为MovieFinder的参数:

class MovieLister...
    private MovieFinder finder;
    public void setFinder(MovieFinder finder)
    {
        this.finder = finder;
    }

类似地,在MovieFinder的实现类中,我也定义了一个设值方法,接受类型为String 的参数:

class ColonMovieFinder...
    public void setFilename(String filename)
    {
        this.filename = filename;
    }

第三步是设定配置文件。Spring 支持多种配置方式,你可以通过XML 文件进行配置,也可以直接在代码中配置。不过,XML 文件是比较理想的配置方式。

<beans>
    <bean id="MovieLister" class="spring.MovieLister">
        <property name="finder">
            <ref local="MovieFinder"/>
        </property>
    </bean>
    <bean id="MovieFinder" class="spring.ColonMovieFinder">
        <property name="filename">
            <value>movies1.txt</value>
        </property>
    </bean>
</beans>

于是,测试代码大概就像下面这样:

    public void testWithSpring() throws Exception
    {
        ApplicationContext ctx = newFileSystemXmlApplicationContext("spring.xml");
        MovieLister lister = (MovieLister) ctx.getBean("MovieLister");
        Movie[] movies = lister.moviesDirectedBy("Sergio Leone");
        assertEquals("Once Upon a Time in the West",movies[0].getTitle());
    }

接口注入

除了前面两种注入技术,还可以在接口中定义需要注入的信息,并通过接口完成注入。Avalon框架就使用了类似的技术。在这里,我首先用简单的范例代码说明它的用法,后面还会有更深入的讨论。首先,我需要定义一个接口,组件的注入将通过这个接口进行。在本例中,这个接口的用途是将一个MovieFinder实例注入继承了该接口的对象。

public interface InjectFinder
{
    void injectFinder(MovieFinder finder);
}

这个接口应该由提供MovieFinder接口的人一并提供。任何想要使用MovieFinder实例的类(例如MovieLister类)都必须实现这个接口。

class MovieLister implements InjectFinder...
    public void injectFinder(MovieFinder finder)
    {
        this.finder = finder;
    }

然后,我使用类似的方法将文件名注入MovieFinder的实现类:

public interface InjectFilename
{
    void injectFilename (String filename);
}

class ColonMovieFinder implements MovieFinder, InjectFilename...
    public void injectFilename(String filename)
    {
        this.filename = filename;
    }

现在,还需要用一些配置代码将所有的组件实现装配起来。简单起见,我直接在代码中完成配置,并将配置好的MovieLister 对象保存在名为lister的字段中:

class IfaceTester...
    private MovieLister lister;
    private void configureLister()
    {
        ColonMovieFinder finder = new ColonMovieFinder();
        finder.injectFilename("movies1.txt");
        lister = new MovieLister();
        lister.injectFinder(finder);
    }

测试代码则可以直接使用这个字段:

class IfaceTester...
    public void testIface()
    {
        configureLister();
        Movie[] movies = lister.moviesDirectedBy("Sergio Leone");
        assertEquals("Once Upon a Time in the West",movies[0].getTitle());
    }

使用Service Locator

依赖注入的最大好处在于:它消除了MovieLister类对具体MovieFinder实现类的依赖。这样一来,我就可以把MovieLister类交给朋友,让他们根据自己的环境插入一个合适的MovieFinder实现即可。不过,Dependency Injection模式并不是打破这层依赖关系的唯一手段,另一种方法是使用Service Locator模式。

Service Locator模式背后的基本思想是:有一个对象(即服务定位器)知道如何获得一个应用程序所需的所有服务。也就是说,在我们的例子中,服务定位器应该有一个方法,用于获得一个MovieFinder实例。当然,这不过是把麻烦换了一个样子,我们仍然必须在MovieLister中获得服务定位器,最终得到的依赖关系如图3 所示:

图3:使用Service Locator 模式之后的依赖关系

在这里,我把ServiceLocator类实现为一个Singleton的注册表,于是MovieLister就可以在实例化时通过ServiceLocator获得一个MovieFinder实例。

class MovieLister...
    MovieFinder finder = ServiceLocator.movieFinder();

class ServiceLocator...
    public static MovieFinder movieFinder()
    {
        return soleInstance.movieFinder;
    }
    private static ServiceLocator soleInstance;
    private MovieFinder movieFinder;

和注入的方式一样,我们也必须对服务定位器加以配置。在这里,我直接在代码中进行配置,但设计一种通过配置文件获得数据的机制也并非难事。

class Tester...
    private void configure()
    {
        ServiceLocator.load(new ServiceLocator(
        newColonMovieFinder("movies1.txt")));
    }

class ServiceLocator...
    public static void load(ServiceLocator arg)
    {
        soleInstance = arg;
    }
    public ServiceLocator(MovieFinder movieFinder)
    {
    this.movieFinder = movieFinder;
    }

下面是测试代码:

class Tester...
    public void testSimple()
    {
        configure();
        MovieLister lister = new MovieLister();
        Movie[] movies = lister.moviesDirectedBy("Sergio Leone");
        assertEquals("Once Upon a Time in the West",movies[0].getTitle());

    }

我时常听到这样的论调:这样的服务定位器不是什么好东西,因为你无法替换它返回的服务实现,从而导致无法对它们进行测试。当然,如果你的设计很糟糕,你的确会遇到这样的麻烦;但你也可以选择良好的设计。在这个例子中,ServiceLocator实例仅仅是一个简单的数据容器,只需要对它做一些简单的修改,就可以让它返回用于测试的服务实现。

对于更复杂的情况,我可以从ServiceLocator派生出多个子类,并将子类型的实例传递给注册表的类变量。另外,我可以修改ServiceLocator的静态方法,使其调用ServiceLocator实例的方法,而不是直接访问实例变量。我还可以使用特定于线程的存储机制,从而提供特定于线程的服务定位器。所有这一切改进都无须修改ServiceLocator的使用者。

一种改进的思路是:服务定位器仍然是一个注册表,但不是Singleton。Singleton的确是实现注册表的一种简单途径,但这只是一个实现时的决定,可以很轻松地改变它。

为定位器提供分离的接口

上面这种简单的实现方式有一个问题:MovieLister类将依赖于整个ServiceLocator类,但它需要使用的却只是后者所提供的一项服务。我们可以针对这项服务提供一个单独的接口,减少MovieLister对ServiceLocator的依赖程度。这样一来,MovieLister就不必使用整个的ServiceLocator 接口,只需声明它想要使用的那部分接口。

此时,MovieLister 类的提供者也应该一并提供一个定位器接口,使用者可以通过这个接口获得MovieFinder实例。

public interface MovieFinderLocator
{
    public MovieFinder movieFinder();

真实的服务定位器需要实现上述接口,提供访问MovieFinder实例的能力:

    MovieFinderLocator locator = ServiceLocator.locator();
    MovieFinder finder = locator.movieFinder();
    public static ServiceLocator locator()
    {
    return soleInstance;
    }
    public MovieFinder movieFinder()
    {
        return movieFinder;
    }
    private static ServiceLocator soleInstance;
    private MovieFinder movieFinder;

你应该已经注意到了:由于想要使用接口,我们不能再通过静态方法直接访问服务——我们必须首先通过ServiceLocator类获得定位器实例,然后使用定位器实例得到我们想要的服务。

动态服务定位器

上面是一个静态定位器的例子——对于你所需要的每项服务,ServiceLocator类都有对应的方法。这并不是实现服务定位器的唯一方式,你也可以创建一个动态服务定位器,你可以在其中注册需要的任何服务,并在运行期决定获得哪一项服务。

在本例中,ServiceLocator使用一个map来保存服务信息,而不再是将这些信息保存在字段中。此外,ServiceLocator还提供了一个通用的方法,用于获取和加载服务对象。

class ServiceLocator...
    private static ServiceLocator soleInstance;
    public static void load(ServiceLocator arg)
    {
        soleInstance = arg;
    }
    private Map services = new HashMap();
    public static Object getService(String key)
    {
        return soleInstance.services.get(key);
    }
    public void loadService (String key, Object service)
    {
        services.put(key, service);
    }

同样需要对服务定位器进行配置,将服务对象与适当的关键字加载到定位器中:

class Tester...
    private void configure()
    {
        ServiceLocator locator = new ServiceLocator();
        locator.loadService("MovieFinder", newColonMovieFinder("movies1.txt"));
        ServiceLocator.load(locator);
    }

我使用与服务对象类名称相同的字符串作为服务对象的关键字:

class MovieLister...
    MovieFinder finder = (MovieFinder)
    ServiceLocator.getService("MovieFinder");

总体而言,我不喜欢这种方式。无疑,这样实现的服务定位器具有更强的灵活性,但它的使用方式不够直观明朗。我只有通过文本形式的关键字才能找到一个服务对象。相比之下,我更欣赏通过一个方法明确获得服务对象的方式,因为这让使用者能够从接口定义中清楚地知道如何获得某项服务。

用Avalon 兼顾服务定位器和依赖注入

Dependency Injection和Service Locator两个模式并不是互斥的,你可以同时使用它们,Avalon框架就是这样的一个例子。Avalon使用了服务定位器,但如何获得定位器的信息则是通过注入的方式告知组件的。

对于前面一直使用的例子,Berin Loritsch发送给了我一个简单的Avalon实现版本:

public class MyMovieLister implements MovieLister, Serviceable
{
    private MovieFinder finder;
    public void service( ServiceManager manager )throws ServiceException
    {
        finder = (MovieFinder)manager.lookup("finder");
    }

service方法就是接口注入的例子,它使容器可以将一个ServiceManager对象注入MyMovieLister对象。ServiceManager则是一个服务定位器。在这个例子中,MyMovieLister并不把ServiceManager对象保存在字段中,而是马上借助它找到MovieFinder 实例,并将后者保存起来。

作出一个选择

到现在为止,我一直在阐述自己对这两个模式(Dependency Injection模式和ServiceLocator模式)以及它们的变化形式的看法。现在,我要开始讨论他们的优点和缺点,以便指出它们各自适用的场景。

Service Locator vs. Dependency Injection

首先,我们面临Service Locator和Dependency Injection之间的选择。应该注意,尽管我们前面那个简单的例子不足以表现出来,实际上这两个模式都提供了基本的解耦合能力。无论使用哪个模式,应用程序代码都不依赖于服务接口的具体实现。两者之间最重要的区别在于:具体实现以什么方式提供给应用程序代码。使用Service Locator模式时,应用程序代码直接向服务定位器发送一个消息,明确要求服务的实现;使用Dependency Injection模式时,应用程序代码不发出显式的请求,服务的实现自然会出现在应用程序代码中,这也就是所谓控制反转。

控制反转是框架的共同特征,但它也要求你付出一定的代价:它会增加理解的难度,并且给调试带来一定的困难。所以,整体来说,除非必要,否则我会尽量避免使用它。这并不意味着控制反转不好,只是我认为在很多时候使用一个更为直观的方案(例如Service Locator模式)会比较合适。

一个关键的区别在于:使用Service Locator模式时,服务的使用者必须依赖于服务定位器。定位器可以隐藏使用者对服务具体实现的依赖,但你必须首先看到定位器本身。所以,问题的答案就很明朗了:选择Service Locator还是Dependency Injection,取决于对定位器的依赖是否会给你带来麻烦。

Dependency Injection模式可以帮助你看清组件之间的依赖关系:你只需观察依赖注入的机制(例如构造函数),就可以掌握整个依赖关系。而使用Service Locator模式时,你就必须在源代码中到处搜索对服务定位器的调用。具备全文检索能力的IDE可以略微简化这一工作,但还是不如直接观察构造函数或者设值方法来得轻松。

这个选择主要取决于服务使用者的性质。如果你的应用程序中有很多不同的类要使用一个服务,那么应用程序代码对服务定位器的依赖就不是什么大问题。在前面的例子中,我要把MovieLister类交给朋友去用,这种情况下使用服务定位器就很好:我的朋友们只需要对定位器做一点配置(通过配置文件或者某些配置性的代码),使其提供合适的服务实现就可以了。在这种情况下,我看不出Dependency Injection模式提供的控制反转有什么吸引人的地方。但是,如果把MovieLister 看作一个组件,要将它提供给别人写的应用程序去使用,情况就不同了。在这种时候,我无法预测使用者会使用什么样的服务定位器API,每个使用者都可能有自己的服务定位器,而且彼此之间无法兼容。一种解决办法是为每项服务提供单独的接口,使用者可以编写一个适配器,让我的接口与他们的服务定位器相配合。但即便如此,我仍然需要到第一个服务定位器中寻找我规定的接口。而且一旦用上了适配器,服务定位器所提供的简单性就被大大削弱了。

另一方面,如果使用Dependency Injection模式,组件与注入器之间不会有依赖关系,因此组件无法从注入器那里获得更多的服务,只能获得配置信息中所提供的那些。这也是Dependency Injection 模式的局限性之一。

人们倾向于使用Dependency Injection模式的一个常见理由是:它简化了测试工作。这里的关键是:出于测试的需要,你必须能够轻松地在真实的服务实现与供测试用的伪组件之间切换。但是,如果单从这个角度来考虑,Dependency Injection模式和Service Locator模式其实并没有太大区别:两者都能够很好地支持伪组件的插入。之所以很多人有Dependency Injection模式更利于测试的印象,我猜是因为他们并没有努力保证服务定位器的可替换性。这正是持续测试起作用的地方:如果你不能轻松地用一些伪组件将一个服务架起来以便测试,这就意味着你的设计出现了严重的问题。

当然,如果组件环境具有非常强的侵略性(就像EJB框架那样),测试的问题会更加严重。我的观点是:应该尽量减少这类框架对应用程序代码的影响,特别是不要做任何可能使编辑-执行的循环变慢的事情。用插件(plugin)机制取代重量级组件会对测试过程有很大帮助,这正是测试驱动开发(Test Driven Development,TDD)之类实践的关键所在。

所以,主要的问题在于:代码的作者是否希望自己编写的组件能够脱离自己的控制、被使用在另一个应用程序中。如果答案是肯定的,那么他就不能对服务定位器做任何假设——哪怕最小的假设也会给使用者带来麻烦。

构造函数注入 vs. 设值方法注入

在组合服务时,你总得遵循一定的约定,才可能将所有东西拼装起来。依赖注入的优点主要在于:它只需要非常简单的约定——至少对于构造函数注入和设值方法注入来说是这样。相比于这两者,接口注入的侵略性要强得多,比起Service Locator模式的优势也不那么明显。所以,如果你想要提供一个组件给多个使用者,构造函数注入和设值方法注入看起来很有吸引力。你不必在组件中加入什么希奇古怪的东西,注入器可以相当轻松地把所有东西配置起来。

设值函数注入和构造函数注入之间的选择相当有趣,因为它折射出面向对象编程的一些更普遍的问题:应该在哪里填充对象的字段,构造函数还是设值方法?

一直以来,我首选的做法是尽量在构造阶段就创建完整、合法的对象——也就是说,在构造函数中填充对象字段。这样做的好处可以追溯到Kent Beck在Smalltalk Best Practice Patterns一书中介绍的两个模式:Constructor Method和Constructor Parameter Method。带有参数的构造函数可以明确地告诉你如何创建一个合法的对象。如果创建合法对象的方式不止一种,你还可以提供多个构造函数,以说明不同的组合方式。

构造函数初始化的另一个好处是:你可以隐藏任何不可变的字段——只要不为它提供设值方法就行了。我认为这很重要:如果某个字段是不应该被改变的,没有针对该字段的设值方法就很清楚地说明了这一点。如果你通过设值方法完成初始化,暴露出来的设值方法很可能成为你心头永远的痛。(实际上,在这种时候我更愿意回避通常的设值方法约定,而是使用诸如initFoo之类的方法名,以表明该方法只应该在对象创建之初调用。)

不过,世事总有例外。如果参数太多,构造函数会显得凌乱不堪,特别是对于不支持关键字参数的语言更是如此。的确,如果构造函数参数列表太长,通常标志着对象太过繁忙,理应将其拆分成几个对象,但有些时候也确实需要那么多的参数。如果有不止一种的方式可以构造一个合法的对象,也很难通过构造函数描述这一信息,因为构造函数之间只能通过参数的个数和类型加以区分。这就是Factory Method模式适用的场合了,工厂方法可以借助多个私有构造函数和设值方法的组合来完成自己的任务。经典Factory Method模式的问题在于:它们往往以静态方法的形式出现,你无法在接口中声明它们。你可以创建一个工厂类,但那又变成另一个服务实体了。工厂服务是一种不错的技巧,但你仍然需要以某种方式实例化这个工厂对象,问题仍然没有解决。

如果要传入的参数是像字符串这样的简单类型,构造函数注入也会带来一些麻烦。使用设值方法注入时,你可以在每个设值方法的名字中说明参数的用途;而使用构造函数注入时,你只能靠参数的位置来决定每个参数的作用,而记住参数的正确位置显然要困难得多。

如果对象有多个构造函数,对象之间又存在继承关系,事情就会变得特别讨厌。为了让所有东西都正确地初始化,你必须将对子类构造函数的调用转发给超类的构造函数,然后处理自己的参数。这可能造成构造函数规模的进一步膨胀。

尽管有这些缺陷,但我仍然建议你首先考虑构造函数注入。不过,一旦前面提到的问题真的成了问题,你就应该准备转为使用设值方法注入。

在将Dependecy Injection 模式作为框架的核心部分的几支团队之间,构造函数注入还是设值方法注入引发了很多的争论。不过,现在看来,开发这些框架的大多数人都已经意识到:不管更喜欢哪种注入机制,同时为两者提供支持都是有必要的。

代码配置 vs. 配置文件

另一个问题相对独立,但也经常与其他问题牵涉在一起:如何配置服务的组装,通过配置文件还是直接编码组装?对于大多数需要在多处部署的应用程序来说,一个单独的配置文件会更合适。配置文件几乎都是XML 文件,XML 也的确很适合这一用途。不过,有些时候直接在程序代码中实现装配会更简单。譬如一个简单的应用程序,也没有很多部署上的变化,这时用几句代码来配置就比XML 文件要清晰得多。

与之相对的,有时应用程序的组装非常复杂,涉及大量的条件步骤。一旦编程语言中的配置逻辑开始变得复杂,你就应该用一种合适的语言来描述配置信息,使程序逻辑变得更清晰。然后,你可以编写一个构造器(builder)类来完成装配工作。如果使用构造器的情景不止一种,你可以提供多个构造器类,然后通过一个简单的配置文件在它们之间选择。

我常常发现,人们太急于定义配置文件。编程语言通常会提供简捷而强大的配置管理机制,现代编程语言也可以将程序编译成小的模块,并将其插入大型系统中。如果编译过程会很费力,脚本语言也可以在这方面提供帮助。通常认为,配置文件不应该用编程语言来编写,因为它们需要能够被不懂编程的系统管理人员编辑。但是,这种情况出现的几率有多大呢?我们真的希望不懂编程的系统管理人员来改变一个复杂的服务器端应用程序的事务隔离等级吗?只有在非常简单的时候,非编程语言的配置文件才有最好的效果。如果配置信息开始变得复杂,就应该考虑选择一种合适的编程语言来编写配置文件。

在Java 世界里,我们听到了来自配置文件的不和谐音——每个组件都有它自己的配置文件,而且格式还各不相同。如果你要使用一打这样的组件,你就得维护一打的配置文件,那会很快让你烦死。

在这里,我的建议是:始终提供一种标准的配置方式,使程序员能够通过同一个编程接口轻松地完成配置工作。至于其他的配置文件,仅仅把它们当作一种可选的功能。借助这个编程接口,开发者可以轻松地管理配置文件。如果你编写了一个组件,则可以由组件的使用者来选择如何管理配置信息:使用你的编程接口、直接操作配置文件格式,或者定义他们自己的配置文件格式,并将其与你的编程接口相结合。

分离配置与使用

所有这一切的关键在于:服务的配置应该与使用分开。实际上,这是一个基本的设计原则——分离接口与实现。在面向对象程序里,我们在一个地方用条件逻辑来决定具体实例化哪一个类,以后的条件分支都由多态来实现,而不是继续重复前面的条件逻辑,这就是分离接口与实现的原则。

如果对于一段代码而言,接口与实现的分离还只是有用的话,那么当你需要使用外部元素(例如组件和服务)时,它就是生死攸关的大事。这里的第一个问题是:你是否希望将选择具体实现类的决策推迟到部署阶段。如果是,那么你需要使用插入技术。使用了插入技术之后,插件的装配原则上是与应用程序的其余部分分开的,这样你就可以轻松地针对不同的部署替换不同的配置。这种配置机制可以通过服务定位器来实现(Service Locator模式),也可以借助依赖注入直接完成(Dependency Injection 模式)。

更多的问题

在本文中,我关注的焦点是使用Dependency Injection模式和Service Locator模式进行服务配置的基本问题。还有一些与之相关的话题值得关注,但我已经没有时间继续深入下去了。特别值得注意的是生命周期行为的问题:某些组件具有特定的生命周期事件,例如停止、开始等等。另一个值得注意的问题是:越来越多的人对如何在这些容器中运用面向方面(aspectoriented)的思想产生了兴趣。尽管目前还没有认真准备过这方面的材料,但我也很希望以后能在这个话题上写一些东西。

关于这些问题,你在专注于轻量级容器的网站上可以找到很多资料。浏览PicoContainer( http://www.picocontainer.org)或者Spring( http://www.springframework.org)的网站,你可以找到大量相关的讨论,并由此引申出更多的话题。

结论和思考

在时下流行的轻量级容器都使用了一个共同的模式来组装应用程序所需的服务,我把这个模式称为Dependency Injection,它可以有效地替代Service Locator模式。在开发应用程序时,两者不相上下,但我认为Service Locator模式略有优势,因为它的行为方式更为直观。但是,如果你开发的组件要交给多个应用程序去使用,那么Dependency Injection模式会是更好的选择。

如果你决定使用Dependency Injection模式,这里还有几种不同的风格可供选择。我建议你首先考虑构造函数注入;如果遇到了某些特定的问题,再改用设值方法注入。如果你要选择一个容器,在其之上进行开发,我建议你选择同时支持这两种注入方式的容器。

Service Locator 模式和Dependency Injection 模式之间的选择并是最重要的,更重要的是:应该将服务的配置和应用程序内部对服务的使用分离开。

致谢

在此,我要向帮助我理解本文中所提到的问题、并对本文提出宝贵意见的几个人表示感谢,他们是Rod Johnson、Paul Hammant、Joe Walnes、Aslak Hellesoy、Jon Tirsen和Bill Caputo。另外,Berin Loritsch和Hamilton Verissimo de Oliveira在Avalon方面给了我非常有用的建议,一并向他们表示感谢。

Share

估算的目的

我第一次与敏捷软件开发的邂逅,是在极限编程刚刚兴起时,源自跟Kent Beck一起工作的经历。其中让我印象深刻的事情之一,就是我们做计划的方式。这里面包括一种估算方式,比起我之前见到过的其他方法,它既轻量,还更有效。这样过了十年,现在一些有经验的敏捷实践者,开始了一场关于估算是否值得甚至是否有害的争论。我想,为了回答这个问题,我们必须审视一下估算的目的。

通常的场景是这样的:

  • 开发者被要求给出对于即将开始的工作的估算。人们大多是乐观派,在没有压力的情况下(一般至少也会有点压力),这些估算通常会比较小。
  • 这些任务和估算会被转化成发布计划,然后用燃尽图跟踪。
  • 接着,人们就会按照这些计划,持续监控着团队为完成任务所投入的时间和资源。当实际消耗的时间和资源,超过当初的估算时,每个人都会变得失望。为了迎合当初的估算, 开发者被要求牺牲软件的质量,但这只会让事情变得更糟。

这种情形下,对估算的投入充其量就是一种浪费——因为“估算就是在干净的衬衫上猜测”。只有当估算被当做追逐更多特性的手段时,它才会变成实质上有害的行为。过分追逐特性是一种很糟糕的情形,人们只是始热衷于完成一个又一个特性,而不是追踪项目的真实结果。

估算还会设定期望值,既然估算通常会偏低,所以它们设定的期望值也多是不切实际的。任何时间上的增长,或者软件特性被砍掉,都会被视作是失败。出于对风险的逃避,这些失败的后果往往会被放大。

面对类似这样的情况,我们就很容易看到人们把愤怒对准了估算本身。这样也导致越来越多的人认为,任何沉迷于估算的人并不是真正的敏捷实践者。而批评敏捷的人则说,这意味着敏捷软件开发的本质就是,开发者很快动手开始做,却并不明确要做什么,而且承诺说,该做完的时候肯定会做完它,而且你肯定会喜欢它。

我并不同意估算是天生有害的活动。如果有人问我,估算是不是件糟糕的事情,我的答案会是一名标准咨询师的答案:“不一定”。而接下来的问题就会是“取决于什么”。为了回答这个问题,我们就不得不问,我们为什么要估算——因为我想说:“如果事情值得做好,就值得问清楚,我们到底为什么要做它”。

对于我来说,当你面临重大的决策时,估算就是有价值的。

我的第一个得益于估算决策的例子是:资源的分配。一般来说,组织大多拥有固定数目的钱和人,而且通常有太多值得做的事情。因此人们就面临选择:我们是做A还是B?面对这样的问题,了解A和B分别要涉及多少投入(以及成本)是有必要的。为了做出一个明智的决策,你需要有对成本和收益有个大致的了解。

另外一个例子是估算对协调的帮助。蓝色团队想在他们的网站上发布一个新的特性,但直到绿色团队创建新的服务提供给他们关键数据后才能发布。如果绿色团队估算他们会在两个月后才能完成新的服务,而蓝色团队估算需要一个月去能完成新的特性,那么蓝色团队就知道不值得现在就开始实现这个新特性。他们可以花费至少一个月时间,工作在其他可以早点发布的特性上。

所以任何时候当你想做估算时,你应当非常清楚哪一项决策需要依赖这个估算。如果你找不到这样一项决策,或者那个决策并不是那么重要,这就是一个信号:此时做估算是在浪费时间。当你找到这样一个决策时,那要知道问题的上下文是什么,为什么估算会很重要。同样还要搞清楚期望的精度和准确性。

同时也要明白,有时候为了做决策,可能会是其他替代的方案,而未必需要估算。也许任务A比起B要重要得多,以至于你都不需要一开始把你所有的空闲精力都放在B上。也许有办法让蓝色团队和绿色团队合作,更快地创建出服务来。

类似地,跟踪计划也应该由它如何影响决策来驱动。通常我的意见是,计划扮演的是基线角色,帮助评估变化——如果我们想要添加一个新的特性,我们应该如何把它放进既定的“五磅篮”里呢?估算可以帮我们理解这些取舍,并因此决定如何响应变化。在更大范围下,重新评估整个发布计划,可以帮助我们理解整个项目是否仍然充分有效利用了我们的能力。几年前,我们曾经有一个规模达一年之久的项目,在重估时发现还要多花几个月进去,之后我们取消了这个项目。我们把这视作成功,因为重新估算发现,项目会比我们最初期望的会花费更长时间——早点取消可以让客户把资源转移到更好的目标上。

但跟踪计划的同时,也要记住估算是有适用期限的。我曾经记得有一位经历颇丰的项目经理说过,计划和估算就像是生菜,刚过几天还很新鲜,过了一周有点枯萎了,几个月后就完全看不出来是什么了。

许多团队发现,估算提供了一种有用的机制,可以促使团队成员间彼此交流。估算会议可以帮助大家以不同的方式,对实现即将开始的故事、未来的架构方向和代码库中的设计问题,有更好的理解。在这种情况下,任何输出的估算数字可能都不重要。这样的对话可能以很多方式发生,但如果这些对话没有发生,就可以引入关于估算的讨论。相反地,如果你考虑停止估算,你需要确保估算时会发生的任何有效的对话,在其他地方还能够继续进行。

在任何敏捷相关的会议上,你都会听到很多团队在谈论,没有估算他们也可以工作得很有效。通常这是因为,他们以及他们的客户明白做估算并不会影响重大的决定。举个例子,一支小团队在和业务人员紧密协作。如果广阔的商业前景很乐意分配一些人到那个业务单元,那么就可以按照优先级开展工作;通常这得益于团队把工作拆分成足够小的单元。团队在敏捷流畅度模型中的等级,在这里起到非常重要的作用。在团队前进时,他们首先会纠缠于估算本身,然后开始会做很好的估算,最后达到不再需要估算的境界。

估算本身并无好坏之分。如果你不用估算就可以有效地工作,那就这么干。如果你需要一些估算,那就要确认你很清楚估算在决策时起到的作用。如果估算会影响到重大的决定,那就尽可能做出最好的估算。一定要小心那帮告诉你任何时候都要做估算,或者从来不需要估算的人。任何关于估算用法的争论,都要遵从于敏捷的原则,即针对你特定的上下文,决定你该采用的什么样的方法。

Share