第 2 章 依赖和分层

第 2 章 依赖和分层

完成本章学习之后,你将学到以下技能。

  • 管理从方法层到程序集层的复杂依赖关系。

  • 识别最复杂的依赖关系并使用工具降低复杂度。

  • 将代码拆分为更小的、更有适应能力的、更易重用的功能代码块。

  • 在最恰当的地方应用分层模式。

  • 清楚如何解决依赖以及调试依赖问题。

  • 使用简洁的接口隐藏实现。

所有软件都有依赖。这些依赖要么是对同一个解决方案内的第一方程序集的依赖,要么是对第三方外部程序集的依赖,或者是处处可见的对Microsoft .NET Framework的依赖。几乎所有稍微有些规模的项目都会存在这三种依赖关系。

依赖项抽象了你编写的客户端代码要使用的功能。你不需要知道依赖在做什么,更不需要知道依赖内部是怎么做的,但是应该确保正确管理所有的依赖。如果不能很好地管理依赖链,开发过程中就会很容易引入根本就不需要的依赖,结果就是代码与很多不必要的程序集紧紧地纠缠在一起。你也许听过这么一句老话:“没编写的代码就是最正确的代码。”同样,不存在的依赖就是管理得最好的依赖。

为了让代码能够自适应变更,你需要高效管理所有的依赖关系。这对软件的所有层次,从架构层子系统间的依赖到实现层每个方法之间的依赖,都是必要的。糟糕的架构会拖延可工作软件的交付,甚至导致项目夭折。

我认为无论怎样强调高效管理依赖的重要性都不为过。如果在重要的问题上采取了折中的临时方案,也许短时间内可以提高开发效率,但是长期的副作用很可能会对项目造成致命的伤害。下面是一个我们再熟悉不过的场景:一开始,随着代码和模块数量的增加,短期的开发效率非常高。慢慢地,代码变得死板和混乱,由此进度也变得缓慢甚至停滞。用Scrum的术语描述就是,在发现根本问题前,缺陷数目在增加且无法获得故事点数,也就无法完成任何故事和特性,因此冲刺燃尽图和特性燃耗图也会没有一丝变化。当依赖结构混乱且无法理解时,一个模块的变更很可能会给另外一个看起来无关的模块带来副作用。

要想轻松管理依赖,需要有清醒的认识并按照指导原则行动。应用一些现有的模式可以帮助代码适应后期的变更。分层就是一种最常见的架构模式,本章会详细讲解几种不同的分层方法,此外也会介绍其他一些依赖管理方法。

2.1 依赖的定义

什么是依赖?通常来讲,依赖(dependency)是指两个不同实体间的一种联系,如果没有其中一个,另一个就会缺少某些功能甚至会不存在。一个比较好的类比是,某个人在财务上对另外一个人有依赖。通常的法律文件都需要你声明是否有家属,也就是说,是否有人需要你来承担他们的生活和其他必需品的费用,家属通常是指你的配偶和孩子。举个我的例子,当我在英国的百慕大工作时,我的工作许可证上写着:“一旦我的工作许可证失效,我的妻子和女儿就需要和我一起离开工作地。”这种情况下,她们作为我的家属需要依靠我在当地生活。

在代码上下文中看依赖的定义,实体通常是指程序集。程序集A使用程序集B,就可以说A依赖B。这种关系的一种常见说法是:A是B的客户(client),B是A的服务(service)。没有B的话,A无法起作用。然而,B并不依赖A这一点也很重要,接下来你会学到,B不可以也不能依赖A。图2-1展示了这种客户/服务关系。

图 2-1 在依赖关系中,依赖者称为客户,被依赖者称为服务

本书全篇都是以客户端和服务端的视角来讨论代码的。有些服务是在远程主机上,比如使用Windows Communication Foundation(WCF)创建的服务。无论是否为远程代码,都可以称为服务。代码是服务端代码还是客户端代码取决于你看待代码角色的角度。任何类、方法或程序集都可以调用其他方法、类和程序集,因而代码是客户端代码。相同的类、方法或程序集也可以由其他方法、类和程序集调用,因而代码也是服务端代码。

2.1.1 一个简单的例子

我们来看看具体程序中的依赖行为。这个例子就是著名的“Hello world”范例,这个简单的控制台程序只负责打印一句消息。我选择这么简洁的例子是为了能更清楚地展示程序中存在的依赖问题。

你可以按照下面步骤创建示例,也可以从GitHub上直接下载源代码。有关如何使用Git的基本介绍请参见附录。

(1) 打开Microsoft Visual Studio,创建一个控制台程序。我把它命名为SimpleDependency,这里名称不重要,可以随意更改。如图2-2所示。

{%}

图 2-2 Visual Studio的新建项目对话框中有很多不同的项目模板可选

(2) 给解决方案再添加一个新的类库项目。我的命名是MessagePrinter。

(3) 右键单击控制台应用的References(引用)节点并选择Add Reference(添加引用)。

(4) 在弹出的Add Reference对话框中,导航至Projects(项目),然后选择类库项目 。

如图2-3所示,两个程序集之间现在有了依赖关系。控制台程序依赖类库,但是类库并不依赖控制台程序。控制台程序是客户,类库是服务。这个应用还没有什么功能,先选择构建解决方案,然后打开SimpleDependency项目下存放可执行文件的bin目录。

{%}

图 2-3 任何项目的References节点下都会列出该项目的引用程序集

bin目录不仅包含了SimpleDependency.exe文件,还包含MessagePrinter.dll文件。构建解决方案的过程中,Visual Studio会自动将MessagePrinter.dll文件复制到SimpleDependency项目的bin目录下,因为它发现项目MessagePrinter被项目SimpleDependency引用为一个依赖项。我想用这个示例给大家展示一个实验过程,不过要先对控制台程序做些小小的修改。因为这个控制台程序什么都没做,它运行起来后会马上直接退出。现在打开控制台程序的Program.cs文件。

代码清单2-1展示了在Main方法内增加的代码(加粗)。Main方法是控制台应用的入口,修改前它没有任何动作并且会直接退出。通过增加调用Console.ReadKey(),可以让应用一直保持运行状态直到有任何键盘输入。

代码清单2-1 通过调用Readkey来阻止控制台程序立即退出

namespace SimpleDependency
{
    class Program
    {
        static void Main()
        {
            Console.ReadKey();
        }
    }
}

修改代码后重新构建解决方案,然后运行应用程序。与期望的一样,应用程序会显示控制台窗口,并一直等待键盘输入直至退出。使用Visual Studio在代码行Console.ReadKey()前设置断点,然后选择调试运行。

当程序运行到达断点时,你能看到为该应用程序加载到内存的程序集列表。Visual Studio有两种方式可以查看:使用菜单栏依次选择Debug(调试)> Windows > Modules(模块),或者直接使用快捷组合键Ctrl+D+M。图2-4展示了为该应用加载到内存的模块列表。

{%}

图 2-4 调试时,Modules窗口会显示所有当前已经加载的程序集

在图2-4中,你有没有看到一些奇怪的地方?列表中并没有包含你刚刚创建的类库。在这个例子里,不是应该将MessagePrinter.dll文件加载到内存中吗?实际上,不应该加载,而且这也的确是期望的正确行为。原因是:应用程序并没有使用MessagePrinter程序集内的任何功能,所以.NET运行时并不会加载它。

为了进一步证明项目中引用的MessagePrinter程序集并非真的所需,可以直接到应用程序的bin目录下删除MessagePrinter.dll文件。然后再次运行程序,结果一切正常,并不会看到任何异常发生。

我们再重复几次这个实验,看看到底会发生什么。首先,在Program.cs文件顶部加入MessagePrinter命名空间的using指令。你认为这样公共语言运行时(Common Language Runtime,CLR)就会加载这个模块吗?答案还是否定的。公共语言运行时会再次忽略这个依赖项,也不会加载这个程序集。这是因为用于导入命名空间的using语句只是一个语法糖,它的设计目的只是为了减少你编写代码的工作量。当你需要使用命名空间中的任意类型时,只需要导入命名空间然后直接引用这些类型定义,而不需要在类型名前带上完整的命名空间。因此,编译using语句并不会生成公共语言运行时要执行的指令。

在上面实验的基础上,保留Program.cs文件顶部的using语句,然后在Console.ReadLine()调用前再加入一个对MessagePrintingService构造函数的调用。如代码清单2-2所示。

代码清单2-2 通过调用一个实例方法来引入依赖关系

using System;
using MessagePrinter;

namespace SimpleDependency
{
    class Program
    {
        static void Main()
        {
            var service = new MessagePrintingService();
            service.PrintMessage();
            Console.ReadKey();
        }
    }
}

这一次调试时的Modules窗口显示MessagePrinter.dll程序集已经被加载了,因为如果不把该程序集内容加载到内存,就无法创建MessagePrintingService类的实例。

如果想作进一步确认,可以尝试删除bin目录下的MessagePrinter.dll文件并再次运行应用程序。这次你会看到应用程序引发了一个如下的异常。

Unhandled Exception: System.IO.FileNotFoundException: Could not load file or assembly 'MessagePrinter, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' or one of its dependencies. The system cannot find the file specified.

1. 对.NET Framework的依赖

上一部分展示的依赖被称为第一方(first-party)依赖。控制台应用程序和它所依赖的类库都在同一个Visual Studio解决方案下。这就意味着第一方依赖总是可用的,因为在需要的时候,被依赖的项目总是可以通过源代码重新构建。也代表你能够直接修改第一方依赖的源代码。

这个例子中的两个项目都依赖其他一些.NET Framework程序集。它们并不是项目本身的一部分,但是依然需要它们是可用的。每个.NET Framework程序集都带有自己的目标框架版本:1、1.1、2、3.5、4、4.5等。有些程序集只属于某个新版本的.NET Framework,无法被使用早期框架版本的项目引用。剩下的程序集虽然在每个版本的.NET Framework都有,但是不同版本之间是有功能差别的,所以在使用时需要指定具体的版本号。

如前面的图2-3所示,SimpleDependency项目对.NET Framework有多个引用。这些依赖中很多都是Visual Studio默认加入到所有控制台应用程序项目中的。这个示例控制台应用程序并没有使用它们的任何功能,因此可以放心地移除对它们的引用。实际上对于这个例子中的两个项目,除了System和System.Core之外,其他的.NET Framework程序集都是多余的,可以把它们从引用列表中删除。删除后,程序仍然可以正常运行。

通过移除不必要的对.NET Framework的依赖,你会更容易看清每个项目必需的依赖清单。

框架程序集总是会加载

不像其他依赖,对.NET Framework程序集的引用总是会导致加载这些程序集。即使你并没有真正使用某个.NET Framework程序集,它依然会在应用程序启动的时候被加载到内存中。幸运的是,如果同一个解决方案下的多个项目都在引用同样的.NET Framework程序集,在运行时,所有依赖它们的项目会共享同一份加载到内存中的.NET Framework程序集实例。

  • 默认的引用列表

    Microsoft Visual Studio中,不同的项目类型有不同的默认引用清单。每个项目类型都有一个项目模板,其中包括了需要的引用清单。Windows Form应用程序的模板中默认指定的引用包括了System.Windows.Forms程序集,而Windows Presentation Foundation(WPF)应用程序的引用则包括了WindowsBase、PresentationCore和PresentationFramework这几个程序集。

    代码清单2-3展示了控制台应用程序的默认引用清单。Visual Studio把所有项目类型的模板文件都存放在安装目录下的子目录/Common7/IDE/ProjectTemplates/下,其中每个语言都有对应版本的模板文件。

    代码清单2-3 Visual Studio项目模板的一个片段,可以根据条件引用不同的程序集

    <ItemGroup>
        <Reference Include="System"/>
        $if$ ($targetframeworkversion$ >= 3.5)
        <Reference Include="System.Core"/>
        <Reference Include="System.Xml.Linq"/>
        <Reference Include="System.Data.DataSetExtensions"/>
        $endif$
        $if$ ($targetframeworkversion$ >= 4.0)
        <Reference Include="Microsoft.CSharp"/>
        $endif$
        <Reference Include="System.Data"/>
        <Reference Include="System.Xml"/>
    </ItemGroup>
    
    

    类似于上面的代码清单2-3,在所有项目模板中都会根据不同情况创建实际的项目实例。特别是,使用不同版本的.NET Framework的项目会引用不同的程序集。上面的例子中,我们可以看到,只有当项目使用.NET Framework 4、4.5或4.5.1时,项目实例才会引用Microsoft.CSharp程序集。这样做的意义是,只有使用从.NET Framework 4引入的dynamic关键字时,你的项目才需要引用这个程序集。

2. 第三方依赖

最后一种要依赖的是由第三方开发的程序集。通常,第三方程序集不是由.NET Framework提供的,你也可以选择方案并直接实现为第一方程序集。有时候,如果方案的规模比较大,自己编码实现的工作量也会比较大,此时,可以选择现有可用的方案实现。举个例子,你很可能不会想去自己重新实现诸如对象/关系映射器(Object/Relational Mapper,ORM)之类的大型解决方案,因为首先你可能需要好几个月来编码,然后可能再耗费好几年时间来完成全部测试。这种情况下,你应该首先看看.NET Framework提供的Entity Framework是否够用,如果它不能满足你的需求,你还可以使用NHibernate,它是一个经过了广泛测试的、成熟的对象/关系映射器库。

只需集成现有可用的、满足需求的特性或基础构件的实现,而无需完全重新实现它们,这是使用第三方依赖的主要原因。当然,集成工作的难度也可能会比较大,这个取决于你的第一方代码以及第三方代码接口的结构。当你需要使用迭代方法以增量方式(就像Scrum流程)发布有业务价值的特性时,使用第三方程序集能够帮助你聚焦在业务相关的工作重点上。

  • 组织第三方依赖

    最简单的组织方式就是直接在项目解决方案下创建一个名为Dependencies的解决方案文件夹,然后把所有的第三方.dll文件存放在这个文件夹下。当项目需要引用这些程序集时,你只需要使用引用管理器浏览这个文件夹(如图2-5所示)。

    {%}

    图 2-5 可以将第三方引用存储在Visual Studio解决方案下的Dependencies文件夹中

    这种方式的优点是所有外部依赖都存放到了源代码控制平台上。其他的开发人员从中心代码库中获取最新源代码时也能获得最新的依赖程序集,这样就不需要所有开发人员单独下载和安装这些要依赖的程序集。

    本章稍后的2.2.7节会讲解一个更好的组织第三方依赖的方式。简单来讲,NuGet依赖管理工具能够为开发人员自动管理项目的第三方依赖,它可以下载依赖安装包(包括相关文档),引用程序集,以及将依赖库升级到最新版本。

2.1.2 使用有向图对依赖建模

(graph)是一种数学建构,它包括两种元素:节点和边线。边线只能用于连接两个节点,代表了两个节点之间的某种联系。图中的任一节点可以与其他节点通过边线连接起来。不同属性的图所属的种类不同。比如,图2-6中展示的图是无向图(undirected graph)。这个图中,节点之间的边线是没有方向的:节点A和C之间的边线可以是从A至C,也可以是从C至A,图中其他边线也一样是无向的。

图 2-6 图形由用边线连接起来的节点组成

而图2-7展示的图则是有向图(directed graph,即digraph)。其中节点间的边线一端都有代表方向的箭头符号:节点A和C之间边线的方向是从A到C的,而不是从C到A的。

图 2-7 这个图中的边线是标明了方向的,所以只有A到B的有向边线,而没有B到A的有向边线

图在软件工程的很多领域都有很好的应用,但是它更适合用来对代码间的依赖关系建模。前面几节已经讲解了,一个依赖关系包含了两个不同的代码实体,它们之间的联系方向是从依赖者到被依赖者。你可以把实体看作节点,并从依赖者到被依赖者的方向绘制有向边线。反复在所有其他实体上应用节点和有向边线的概念进行建模,你就能得到一个完整的依赖有向图(dependency digraph)。

如图2-8所示,可以在项目的不同层次上应用这种依赖关系的建模方式。图中节点可以用来表达项目中的类、程序集或者子系统中的程序集组。无论在哪个层次上,节点间边线的箭头都表达了不同组件之间的依赖关系。箭头从依赖组件指向被依赖组件。

图 2-8 各个级别的依赖都可以使用图形来建模

每个大粒度的节点都可以分解成为一组更小粒度的节点。比如:子系统可以分解为一组程序集;程序集可以分解为一组类;类内部仍可以分解为一组方法。图2-8这个示例展示了在整个子系统依赖链中一个单独方法上依赖关系所处的位置。

然而,上面所有的示例只能展示出有依赖关系,但是并没有展示出依赖的分类(比如继承、聚合、复合以及关联)。但是这依然是有用的,因为依赖关系只需要知道两个二进制实体之间的关系:有或没有依赖?

循环依赖

图论中还提到有向图中会形成循环,也就是说从一个节点沿着有向边线遍历后还能够回到这个节点。前面几节中展示的图都没有循环,称为有向无环图(acyclic digraph)。图2-9展示了一个有向有环图(cyclic digraph)的示例。从节点D开始,可以沿着边线经过节点E和B,最后又回到节点D。

图 2-9 这个有向图包含多个循环

如果用节点表示程序集。图中程序集D显式或隐式地依赖一些程序集,因此它也隐式依赖这些程序集所依赖的其他一些程序集。上面图表中,节点D显式依赖节点E,隐式依赖节点B和D,因此节点D也依赖自身。

如果节点是程序集,这种循环依赖是不可能出现的。不允许在Visual Studio中尝试构建这种循环依赖关系。在项目E中添加对B程序集的引用时,会看到如图2-10所示的警告信息。

{%}

图 2-10 不允许在Visual Studio中创建循环依赖关系

尽管使用图对依赖关系进行建模似乎有点太过学术,但是这样组织依赖关系还是很有好处的。理论上,可以存在的循环依赖关系在软件工程的实际应用中完全不允许,而且一定要避免。

有向图中有一种特殊的循环叫作自循环(loop)。如果节点通过一条边线直接连接自身,那么这条边线就变成了一个自循环。图2-11展示了带有一个自循环的有向图。

图 2-11 这个有向图中,节点B通过一个自循环连接到自身

实际应用中,程序集通常都是显式自依赖的,这一点通常不会被注意到。此外,方法层的递归(recursion)就是一个很好的自循环的例子,如代码清单2-4所示。

代码清单2-4 有向图中的自循环可以用来表示递归方法

namespace Graphs
{
    public class RecursionLoop
    {
        public void A()
        {
            int x = 6;
            Console.WriteLine("{0}! = {1}", x, B(x));
        }

        public int B(int number)
        {
            if(number == 0)
            {
                return 1;
            }
            else
            {
                return number * B(number - 1);
          }
        }
    }
}

代码清单2-4中的类与图2-11中的依赖图表达了相同的功能。方法A调用了方法B,那么方法A依赖方法B。然而,更有趣的是递归方法B,它显式地依赖自身。递归方法就是一个调用自身的方法。

2.2 依赖管理

现在你已经知道,依赖关系是必要的,但是必须小心管理以免产生的问题影响了后期开发。通常,当这些问题暴露出来时,就已经很难解决了。因此,最好从一开始就谨慎正确地管理所有依赖关系,以免不经意间引入问题。糟糕管理的依赖关系引起的微小局部问题会很快升级为项目整体架构上的严重问题。

本章剩余几节会更多地集中讲解如何持续管理依赖(包括避免反模式),更重要的是,要理解为何有些常见的模式是反模式。相反,有些模式是真的有益并值得推广的,它们可以用来替代相应的反模式。

模式和反模式

软件工程历史上,面向对象软件开发算是个相对比较新的尝试。在过去的几十年里,已经有一些类和接口间的协作方法被识别和总结出来,它们是可重用的,并称之为模式(pattern)。

软件开发模式有很多种,每种模式都可以在某些特定的问题域重复应用。有些模式协同其他一些模式后还能够为复杂问题提供优雅的解决方案。当然,并不是所有模式总是可应用的,需要花费时间进行实践并积累经验,才能识别出应用这些模式的合适场合。

有些模式并没有多少益处,相反它们实际上还有很多副作用,这类模式被称为反模式(anti-pattern)。反模式会破坏代码的自适应能力,应该避免在代码中引入它们。随着很多副作用被发现,一些模式会逐渐被抛弃并归类为反模式。

2.2.1 实现与接口

通常,对于刚刚接触面向接口编程概念的开发人员而言,不让他们去考虑接口后面的实现细节会很困难。

编译时,接口的所有客户端都不应该知道接口要使用的具体实现。如果知道具体实现,会让人错误地认为客户端代码与接口的这个具体实现是直接关联的。

考虑一个常见的例子:一个类能够向永久存储介质中存放记录数据。为了达到这个目的,正确的做法是定义一个隐藏具体永久存储机制细节的接口。另外切记,不要假设运行时会使用某种具体的实现。比如,不要在代码中将接口转换为任何具体实现。

2.2.2 new代码味道

接口描述能做什么,接口的实现类则描述如何做。只有类才会涉及实现细节,接口应该对如何实现的细节一无所知。这是因为只有类才有构造函数,构造函数则包含了实现细节。在此基础上会得到一个有意思的结论,那就是,除了一些特殊情况外,凡是出现new关键字的地方都是代码味道(code smell)。

代码味道

如果某段代码可能存在问题,就可以说有代码味道。这里使用“可能”是因为少量的代码味道并不一定就是问题。反模式总被认为是坏的实践,但代码味道不一样,它们不一定是坏的实践。代码味道可以警告可能有错误发生,需要根据根本原因来判断是否要修正。

代码味道还可能表明有技术债务存在,而技术债务的修复是有代价的。背负技术债务越久,债务修复就会越难。

代码味道有很多分类。使用关键字new创建对象实例属于“狎昵关系”。因为构造函数是实现细节,客户端代码调用构造函数会引入意外的(也是不希望的)依赖关系。

与反模式一样,可以通过重构代码来清除代码味道,重构后的代码有着更好的、更具有适应能力的设计。实现了次优设计的代码可能满足了当前的需求,但是将来还是可能会引起问题。代码重构无疑是一个无法立即产生可见效益的开发任务,因为所有修复问题的重构工作都不会附加有相应的业务价值。然而,与金融债务可能导致支付高额利息类似,技术债务也有可能会逐渐失去控制,进而摧毁良好的依赖管理实践,危及后续的代码设计改进和问题修复。

代码清单2-5展示了使用new关键字的代码味道,两个例子都直接创建了对象实例。

代码清单2-5 一个示例,展示如何通过实例化对象来破坏代码的适应能力

public class AccountController
{
    private readonly SecurityService securityService;

    public AccountController()
    {
        this.securityService = new SecurityService();
    }

    [HttpPost]
    public void ChangePassword(Guid userID, string newPassword)
    {
        var userRepository = new UserRepository();
        var user = userRepository.GetByID(userID);
        this.securityService.ChangeUsersPassword(user, newPassword);
    }
}

AccountController类是从一个假定的ASP.NET MVC应用程序中提取的。我们先抛开该类的实现细节,着重看看这些加粗的不恰当的对象构造语句。这个控制器类的职责是允许用户执行账户查询和其他命令。这个示例中只展示了一个命令:ChangePassword

代码中有下面一些问题,这些问题是由于两个显式调用new关键字的构造对象实例引起的。

  • AccountController类永远依赖SecurityService类以及UserRepository类的具体实现。

  • AccountController类隐式依赖SecurityService类和UserRepository类的所有依赖。

  • AccountController类很难测试,因为无法用伪实现来模拟和替代SecurityService类和UserRepository类。

  • SecurityService类的ChangeUsersPassword方法需要客户端代码先加载好User类的实例对象。

下面会详细剖析这几个问题。

1. 无法增强实现

当你想要改变SecruityService类的实现时,只有两个选择,要么改动AccountController来直接引用新的实现,要么给现有的SecurityService添加新功能。阅读本书之后,你会发现这两个选项都不好。现在,我们先把目标定为AccountControllerSecurityService在创建后都不允许再做任何改动。

2. 依赖关系链

SecurityService类也会有自己的依赖关系。代码清单2-5中,加粗的SecurityService类的默认构造函数看起来似乎没有任何依赖。但是,如果SecurityService类的构造函数的实现如下面的代码清单2-6所示呢?

代码清单2-6 SecurityServiceAccountController两个类有着同样的问题

public SecurityService()
{
    this.Session = SessionFactory.GetSession();
}

SecurityService类实际上依赖NHibernate(一种对象/关系映射器)库,它被用来获取一个会话(session)。NHibernate使用会话来表示指向持久关系型存储(比如Micorsoft SQL Server、Oracle或MySQL等)的连接。正如前面所讲的,这意味着AccountController类也隐式依赖NHibernate库。

再者,如果SecurityService类的构造函数签名也要改变呢?也就是说,如果SecurityService类的构造函数突然需要客户端代码提供数据库连接字符串给会话呢?任何使用SecurityService类的客户,包括AccountController类在内,都必须改动代码来提供连接字符串。再强调一次,一定要避免这种糟糕的变更。

3. 缺乏可测试性

可测试性也非常重要,它需要代码以一定的模式构建。如果不这样做,测试将变得极其困难。不幸的是,在代码清单2-5中,AccountControllerSecurityService这两个类都很难测试,因为你无法使用不执行任何动作的模拟实现来替代这两个类的实现。举个例子,在测试SecurityService类时,你并不想建立到数据库的连接。因为连接数据库的过程不仅慢也没有必要,而且还会引入另外一个更高失败率的测试点:连接数据库失败。有几种方法可以在运行时使用模拟实现来替代对这两个类的依赖。诸如Microsoft Moles和Typemock之类的工具可以钩入构造函数中,并确保它们返回的对象是Fakes。但是,这些方法要谨慎使用,因为它们只能治标但不能治本。

4. 更多的狎昵关系

AccountController类的ChangePassword方法会先创建一个UserRepository类的实例,然后通过它获取一个User类的实例。它这样做的唯一原因就是SecurityService类的ChangePassword方法的需要。如果没有传入这个User类的实例,就无法调用该方法,这也表明这个方法的签名设计很糟糕。如果所有客户端代码都需要获取一个User类的实例,这种情况下,SecurityService类就应该在自己内部获取User类的实例。代码清单2-7展示了重构后用于更改用户密码的两个方法。

代码清单2-7 对客户调用SecurityService类代码的一个改进

[HttpPost]
public void ChangePassword(Guid userID, string newPassword)
{
    this.securityService.ChangeUsersPassword(userID, newPassword);
}
//...
public void ChangeUsersPassword(Guid userID, string newPassword)
{
    var userRepository = new UserRepository();
    var user = userRepository.GetByID(userID);
    user.ChangePassword(newPassword);
}

对于AccountController类而言,这绝对是个改进,但是ChangeUsersPassword方法仍然会直接实例化UserRepository类。

2.2.3 对象构造的替代方法

怎么做才可以同时改进AccountControllerSecurityService这两个类,或者其他任何不合适的对象构造调用呢? 如何才能正确设计和实现这两个类以避免出现上节所讲述的任何问题呢? 下面有一些能够互补的方式可供选择。

1. 针对接口编码

你应该做的首要改动是将SecurityService类的实现隐藏在一个接口后。这样AccountController类就会只依赖SecurityService类的接口而不是它的具体实现。第一个代码重构就是为SecurityService类提取一个接口,如代码清单2-8所示。

代码清单2-8 为SecurityService类提取一个接口

public interface ISecurityService
{
    void ChangeUsersPassword(Guid userID, string newPassword);
}
//...
public class SecurityService : ISecurityService
{
    public ChangeUsersPassword(Guid userID, string newPassword)
    {
        //...
    }
}

下一步就是改动客户端代码来调用ISecurityService接口,而不是SecurityService类。代码清单2-9展示了应用这个重构后的AccountController类的情况。

代码清单2-9 AccountController类现在依赖ISecurityService接口

public class AccountController
{
    private readonly ISecurityService securityService;

    public AccountController()
    {
        this.securityService = new SecurityService();
    }

    [HttpPost]
    public void ChangePassword(Guid userID, string newPassword)
    {
        securityService.ChangeUsersPassword(user, newPassword);
    }
}

这个示例仍然没有结束,因为依然直接调用了SecurityService类的构造函数,所以重构后的AccountController类依然依赖SecurityService类的具体实现。AccountController类的构造函数还是会实例化具体的SecurityService类实例。要将这两个具体类完全解耦,你还需要作进一步的重构,即引入依赖注入(Dependency Injection,DI)。

2. 使用依赖注入

这个主题比较大,无法用很短的篇幅讲完。实际上,第9章会专门讲解这个主题,此外还有一些依赖注入的专题书籍。幸运的是,依赖注入并不是很复杂或者困难,所以本节会从使用依赖注入的类的角度来讲解一些基本的要点。代码清单2-10展示了新的对AccountController类的构造函数进行的代码重构。重构后的构造函数代码部分已经加粗显示,重构动作的改动非常小,但是管理依赖的能力却大不相同。AccountController类不再要求构造SecurityService类的实例,而是要求它的客户端代码提供一个ISecurityService接口的实现。不仅如此,构造函数中还加入了前置条件的检查语句,用于防止从securityService参数上传入空值的异常情况。这个检查确保了,在ChangePassword方法中需要使用securityService类的实例时,该实例始终有效且无需处处检查空值。

代码清单2-10 使用依赖注入从AccountController类中移除对SecurityService类的依赖

public class AccountController
{
    private readonly ISecurityService securityService;

    public AccountController(ISecurityService securityService)
    {
        if(securityService == null) throw new ArgumentNullException("securityService");

        this.securityService = securityService;
    }

    [HttpPost]
    public void ChangePassword(Guid userID, string newPassword)
    {
       this.securityService.ChangeUsersPassword(user, newPassword);
    }
}

SecurityService类也同样需要应用依赖注入。代码清单2-11展示了重构后的类实现。

代码清单2-11 依赖注入是一种很常见的模式,几乎可以不受限制地应用于代码中的任意位置

public class SecurityService : ISecurityService
{
    private readonly IUserRepository userRepository;

    public SecurityService(IUserRepository userRepository)
    {
        if(userRepository == null) throw new ArgumentNullException("userRepository");
        this.userRepository = userRepository;
    }

    public ChangeUsersPassword()
    {
        var user = userRepository.GetByID(userID);
        user.ChangePassword(newPassword);
    }
}

AccountController类改为依赖一个有效的ISecurityService接口实例一样,SecurityService类也改为依赖一个有效的IUserRepository类的实例(构造函数在检测到传入的是空值时会引发异常)。同样地,通过引入IUserRepository接口,SecurityService类对UserRepository类的依赖也被全部移除了。

2.2.4 随从反模式

随从反模式(entourage anti-pattern )这个名称缘于这样的事实:即使你只想要个简单的东西,也总是会得到包括它在内的很多东西。就像歌星或电影明星一样,他们总是带着他们的随从(跟班或助理之类的人)一同进进出出。这是我为这种模式起的名称,用于更恰当地表达这些并不需要的依赖关系。

随从反模式是开发人员使用针对接口编程时很容易犯的一个错误。无需赘述,这里要直接给出的结论是:接口和接口的依赖项肯定不应该布置在同一个程序集内。

图2-12中的UML图展示了AccountController示例中包层次上的组织关系。AccountController类依赖ISecurityService接口,后者则由SecurityService类实现。图中的包(.NET程序集或者Visual Studio项目)就是依赖关系中实体的容器。图2-12就是一个随从反模式的例子:接口本身和接口的实现类被布局在同一个程序集内。

{%}

图 2-12 AccountController类所在的程序集依赖Services程序集

相信你已经知道,SecurityService类也有自己的一些依赖关系,依赖关系链也会导致客户端之间的隐式依赖。通过扩展包图,图2-13展示了一个完整的随从问题。

图 2-13 AccountController类依然隐式依赖了太多的实现

如果你单独构建Controllers项目,会发现bin目录下也会有NHibernate程序集,这表明了Controllers程序集依然隐式依赖了NHibernate程序集。此时,尽管你已经通过多次重构移除了AccountController类中那些不必要的依赖关系,但是它与每个要依赖程序集的实现之间依然不是松散耦合的关系。

随从反模式会带来两个问题。第一个问题与开发人员的自律有关。每个包(Services、Domain和NHibernate)都要有设置为public的接口定义。然而,你还需要实现具体的类并标记为public,因为你总是要在某些地方构造接口实现类的实例(只是不要在客户端类中直接调用)。这就意味着不够自律的开发人员仍然可以直接引用具体实现。很多人都会走“捷径”去直接调用new关键字来获取接口实现类的实例。

第二个问题,如果你准备创建一个新的SecurityService实现类,它不依赖使用NHibernate程序集的Domain模型,而是使用一个第三方服务总线(比如NServiceBus)来给句柄发送命令消息呢? 把这个新的实现加入到Services程序集里仍然会引入对NServiceBus的依赖,不断膨胀的代码库会变得更加脆弱,很难适应后期的新需求。

有个常用的规则就是把接口的实现与接口本身拆分开,分别布置在不同的程序集中。可以使用下节要讲解的阶梯模式来拆分接口和相应的实现类。

2.2.5 阶梯模式

阶梯模式是一种正确组织类和接口的方法。因为接口和接口的实现类布置在不同的程序集内,二者可以独立更改,客户端代码始终只需要引用接口所在的程序集。

你可能会想:“这样做会产生多少程序集啊?如果需要把每个接口和类都拆分到自己独有的程序集内,一个解决方案会不会包含两百个项目啊!” 不用担心,因为应用阶梯模式只会增加少量的项目,同时又能让解决方案的结构始终保持清晰明了。如果项目的组织很糟糕,通过应用阶梯模式来减少项目总体数目是有一定效果的。

可以应用阶梯模式再次重构前面的AccountController示例,图2-14展示了这次重构的结果。每个实现,也就是每个类,只引用要依赖接口所在的程序集,它不会显式或隐式地引用接口实现所在的程序集。每个实现类也会引用自己接口所在的程序集。阶梯模式的组织方式好处很多:接口没有任何依赖,调用接口的客户端代码也不会有任何隐性依赖,接口的实现也同样只依赖其他仅包含接口的程序集。

{%}

图 2-14 阶梯模式的名称恰当地描述了应用该模式后形成的包结构

这里,我想再详细强调阶梯模式的好处之一:接口不应该有任何外部依赖。开发人员要尽量坚持好这个原则。接口的方法和属性不应当暴露出任何第三方引用中定义的数据对象或类。尽管接口可以(也一定会需要)依赖同一解决方案下的其他项目以及常见的.NET Framework库定义的类,但是应该避免对基础构件实体的依赖。第三方库通常都是用来提供基础构件的。即使使用的是第三方库(比如Log4Net、NHibernate和MongoDB等)的接口,你的接口依然会与库的实现绑定在一起。这是因为这些第三方库的包都应用了随从反模式,而不是阶梯模式。它们都只提供了单个程序集,其中既包含了需要依赖的接口,也包含了不希望依赖的实现。

为了避免这个问题,可以引用用于记录日志、域持久化和文档存储的自定义接口。你的简单接口可以把对第三方的依赖隐藏在对第一方程序集的依赖后面。如果后面需要更换对第三方的依赖,只需要为更换后的新接口编写一个新的适配器就可以。

从实际应用的角度来看,为所有第三方引用都编写适配器和接口并不现实。如果工作量很大,开发团队应当认识到项目不得不保持对第三方程序集的依赖,这种依赖还会慢慢渗透到整个项目。第三方库的规模越大,替换它就越难,耗时也会越长。相比单个巨型第三方库的更好选择应该是框架,后者比前者的规模大得多。

2.2.6 依赖解析

只知道如何组织项目和相关依赖对调试运行时程序集间依赖并没有多大帮助。有时候程序集在运行时并不可用,这就需要找出根本的原因。

1. 程序集

公共语言运行时(Common Language Runtime,CLR)是.NET Framework用来执行代码指令的虚拟机。它也是一个软件产品,作为.NET应用程序的宿主,它以一种可预测的符合逻辑的方式运行。清楚程序集依赖以及修复方案的理论和实践都会很有用。如果知之甚少,在需要诊断程序集问题时可能会走些弯路。

  • 解析流程

    程序集解析流程在公共语言运行时中很重要。引用程序集或项目后,在运行时加载程序集前就需要解析程序集。这个流程包括几个步骤,期间如果出错,你可以诊断问题可能的原因。

    图2-15以流程图的形式展示了程序集解析的过程。这个流程图只是个没有包含所有细节的高层框架,但是用来展示流程的要点已经足够了。流程步骤如下所示。

    {%}

    图 2-15 程序集解析流程的概要图

    • 公共语言运行时使用即时(just-in-time,JIT)模型来解析程序集。正如本章开始几节已证实的那样,启动应用时是不会解析应用中包含的引用的,而是直到首次使用程序集的某个特性时才会解析应用中包含的引用(真的是即时解析)。

    • 每个程序集都有一个标识符,它们由程序集名称、版本、文化和公钥令牌组合构成。诸如绑定重定向等特性可以改变程序集的标识符,所以确定标识符也不像看起来那么容易。

    • 当程序集标识符确定后,在当前应用程序执行期间,公共语言运行时能够决定是否已经尝试解析过依赖。下面这个Visual Studio项目文件内容片段展示了引用的标识信息。

      <reference include="MyAssembly, Version=2.1.0.0, Culture=neutral,
         PublicKeyToken=17fac983cbea459c" />
      
    • 公共语言运行时会根据是否已经尝试过解析来走不同的分支。如果已经尝试过解析该程序集,那么解析过程要么成功要么失败。如果解析成功了,公共语言运行时会使用已经加载的有效程序集。如果解析失败了,公共语言运行时会知道自己无需再尝试解析了,因为一定会失败。

    • 或者,如果是第一次尝试解析该程序集,公共语言运行时会首先检查全局程序集缓存(Global Assembly Cache,GAC)。全局程序集缓存是一个整个机器可见的程序集库,它允许同一个程序集的不同版本在同一个应用程序上执行。如果在全局程序集缓存中找到了该程序集,那么解析过程就成功了,并且会加载找到的程序集。现在你知道了,因为公共语言运行时会首先搜索全局程序集缓存,因此全局程序集缓区中合适的程序集要比程序集文件有更高的优先级。

    • 如果在全局程序集缓存中没有找到合适的程序集,公共语言运行时会开始尝试搜索一系列文件夹。可以使用app.config文件中的codeBase元素来指定目标文件夹,公共语言运行时会检索这个元素下列出的位置,如果还没有找到合适的程序集,那么后续也不会再去检索其他位置了。此外,默认情况下,公共语言运行时还会去搜索应用的安装根目录,通常情况下是程序的入口文件所在的bin目录。如果在安装目录下也没有找到合适的程序集,解析过程就失败了,公共语言运行时会引发一个异常。典型情况下,应用程序会被终止。

  • Fusion日志

    Fusion是个很有用的工具,可以用来调试公共语言运行时加载程序集失败的问题。比起尝试使用Visual Studio调试器调试应用程序,更好的办法是打开Fusion日志开关然后查看记录到的日志结果。

    要启用Fusion日志,你需要编辑Windows注册表。下面是具体的注册表位置信息。

    HKLM\Software\Microsoft\Fusion\ForceLog 1
    HKLM\Software\Microsoft\Fusion\LogPath C:\FusionLogs
    
    

    其中ForceLog值是DWORD类型,而LogPath是个字符串。你可以将LogPath设置为任何你选择的位置。代码清单2-12是个绑定程序集失败的例子。

    代码清单2-12 一个尝试绑定程序集失败的Fusion日志示例

    * Assembly Binder Log Entry  (6/21/2013 @ 1:50:14 PM) *
     
    The operation failed.
    Bind result: hr = 0x80070002. The system cannot find the file specified.
     
    Assembly manager loaded from:  C:\Windows\Microsoft.NET\Framework64\v4.0.30319\clr.dll
    Running under executable  C:\Program Files\1UPIndustries\Bins\v1.1.0.242\Bins.exe
    --- A detailed error log follows.
     
    === Pre-bind state information ===
    LOG: User = DEV\gmclean
    LOG: DisplayName = TaskbarDockUI.Xtensions.Bins.resources, Version=1.0.0.0, Culture=en-US,
       PublicKeyToken=null (Fully-specified)
    LOG: Appbase = file:///C:/Program Files/1UPIndustries/Bins/v1.1.0.242/
    LOG: Initial PrivatePath = NULL
    LOG: Dynamic Base = NULL
    LOG: Cache Base = NULL
    LOG: AppName = Bins.exe
    Calling assembly : TaskbarDockUI.Xtensions.Bins, Version=1.0.0.0, Culture=neutral,
       PublicKeyToken=null.
    ===
    LOG: This bind starts in default load context.
    LOG: No application configuration file found.
    LOG: Using host configuration file:
    LOG: Using machine configuration file from C:\Windows\Microsoft.NET\Framework64
       \v4.0.30319\config\machine.config.
    LOG: Policy not being applied to reference at this time (private, custom, partial, or
       location-based assembly bind).
    LOG: Attempting download of new URL file:///C:/Program Files/1UPIndustries/
       Bins/v1.1.0.242/en-US/TaskbarDockUI.Xtensions.Bins.resources.DLL.
    LOG: Attempting download of new URL file:///C:/Program Files/1UPIndustries/
       Bins/v1.1.0.242/en-US/TaskbarDockUI.Xtensions.Bins.resources/
       TaskbarDockUI.Xtensions.Bins.resources.DLL.
    LOG: Attempting download of new URL file:///C:/Program Files/1UPIndustries/Bins/
       v1.1.0.242/en-US/TaskbarDockUI.Xtensions.Bins.resources.EXE.
    LOG: Attempting download of new URL file:///C:/Program Files/1UPIndustries/
       Bins/v1.1.0.242/en-US/TaskbarDockUI.Xtensions.Bins.resources/
       TaskbarDockUI.Xtensions.Bins.resources.EXE.
    LOG: All probing URLs attempted and failed.
    
    

    编辑完注册表后,任何托管应用程序的所有解析程序集的尝试(无论成功与否)都会被记录到相应的Fusion日志文件中。显然Fusion下会有大量有用的日志文件产生,但是在大量日志文件中查找问题就像大海捞针一样困难。

    幸运的是,Fusion还有个用户界面应用程序,可以帮助开发人员更容易找到自己程序的日志文件,而不用直接在众多的日志文件中苦苦寻找。图2-16展示了Fusion的用户界面。

    {%}

    图 2-16 Fusion的用户界面可以迅速找到特定程序的日志文件

    不是所有的依赖都需要直接引用程序集。一种方式就是将服务代码部署为宿主服务。这种做法需要进程或网络间的数据通讯能力的支持,但是它能最小化客户端和服务之间必需的程序集引用。下一节会详细讲解这一主题。

2. 服务

与程序集相比,客户端和服务之间的耦合关系更加松散,这种方式有利也有弊。根据应用程序需求的不同,客户端可能很清楚服务的位置,也可能知之甚少。同样,实现服务的方式不多,因此有关服务的需求也不多。选择不同的实现方式,要考虑的取舍不同。

  • 已知端点

    如果客户端代码编译时就知道服务位置,可以为客户端创建一个服务代理。至少有两种创建代理的方式:使用Visual Studio为项目添加一个服务引用,或者使用.NET Framework的ChannelFactory类编码创建服务代理。

    在Visual Studio中为项目添加一个服务引用非常简单:只需要在项目的快捷菜单上选择Add Service Reference(添加服务引用)即可。Add Service Reference对话框只需要知道Web服务定义语言(Web Service Definition Language,WSDL)文件的具体位置,该文件定义了服务的元数据描述、数据类型和可用的行为。选定服务文件后,Visual Studio能够很快为该服务快速生成一组代理类,非常节省时间。Visual Stduio甚至可以生成异步的服务方法以避免出现阻塞。然而,这种方式也有缺点,开发人员对自动生成的代码缺乏控制。如果你自己的编码标准要求比较高,Visual Studio生成的代码可能会不符合要求。另外一个问题是,自动生成的服务代理代码只包含实现类,它不但没有匹配的单元测试,也没有相应的接口定义。

    另一个添加服务引用的方式就是通过编码创建服务代理。这种方式最好用于客户端代码能够访问服务接口并且可以通过引用重复使用时。代码清单2-13展示了一个使用ChannelFactory类编码创建服务代理的示例。

    代码清单2-13 ChannelFactory能够创建服务代理

    var binding = new BasicHttpBinding();
    var address = new EndpointAddress("http://localhost/MyService");
    var channelFactory = new ChannelFactory<IService>(binding, address);
    var service = channelFactory.CreateChannel();
    service.MyOperation();
    service.Close();
    channelFactory.Close();
    
    

    ChannelFactory是个泛型类,它的构造函数需要指定服务代理接口。另外,它的构造函数需要传入BindingEndpointAddress类的对象,因此必须给ChannelFactory类提供完整的地址/绑定/协定(address/binding/contract,ABC)信息。在这个示例中,IService接口就是具体服务实现的接口。ChannelFactory类的CreateChannel方法会返回一个服务代理实例,所有对服务代理实例方法的调用都会调用服务端具体实现中对等的方法。因为是使用同一个服务接口,客户端类型可以通过依赖注入从构造函数传入服务接口参数,这样客户端类型就可以立即变得可测试了。此外,客户端类型也无需知道它们调用的是远程服务。

  • 服务发现

    有时候,你可能只知道服务的绑定类型或者协定,但并不清楚服务的宿主地址。这种情况下,你可以使用在.NET Framework 4中引入的服务发现特性。

    服务发现有两种方式:托管的和自组网的。在托管模式下,有一个被称为发现代理的中心服务对所有客户端都是公开的,客户端可以直接请求这个中心服务来查找其他可用的服务。这种模式没有多大吸引力,因为它的设计引入了单一故障点(single point of failure,SPOF)的反模式:如果发现代理服务不可用,所有客户端都无法访问其他任何服务,因为它们是不可发现的。

    自组网模式不需要发现代理这个中心服务,它采用了组播网络消息的机制。这种模式的默认实现使用了用户数据报协议(User Datagram Protocol,UDP),每个可发现的服务都会在一个特定IP地址1和端口上等待查询请求。客户会通过发送组播消息来向网络查询是否有符合查询条件(协议或者绑定类型)的可用服务。举个例子,在自组网模式的场景中,如果一个服务不可用,那么就只有它是不可发现的,而其他的可用服务依然可以响应收到的查询请求。代码清单2-14展示了如何编码托管一个可发现的服务,代码清单2-15则展示了如何通过配置来托管一个可发现的服务。

    代码清单2-14 编码托管某个可发现的服务

    class Program
    {
        static void Main(string[] args)
        {
            using (ServiceHost serviceHost = new ServiceHost(typeof(CalculatorService)))
            {
                serviceHost.Description.Behaviors.Add(new ServiceDiscoveryBehavior());
     
                serviceHost.AddServiceEndpoint(typeof(ICalculator), new BasicHttpBinding(),
       new Uri("http://localhost:8090/CalculatorService"));
                serviceHost.AddServiceEndpoint(new UdpDiscoveryEndpoint());
     
                serviceHost.Open();
                Console.WriteLine("Discoverable Calculator Service is running...");
                Console.ReadKey();
            }
        }
    }
    
    

    代码清单2-15 通过配置托管某个可发现的服务

    <system.serviceModel>
        <behaviors>
          <serviceBehaviors>
            <behavior>
              <serviceMetadata httpGetEnabled="true" httpsGetEnabled="true"/>
              <serviceDebug includeExceptionDetailInFaults="false"/>
            </behavior>
            <behavior name="calculatorServiceDiscovery">
              <serviceDiscovery />
            </behavior>
          </serviceBehaviors>
          <endpointBehaviors>
            <behavior name="calculatorHttpEndpointDiscovery">
              <endpointDiscovery enabled="true" />
            </behavior>
          </endpointBehaviors>
        </behaviors>
        <protocolMapping>
            <add binding="basicHttpsBinding" scheme="https" />
        </protocolMapping>
        <serviceHostingEnvironment aspNetCompatibilityEnabled="true"
       multipleSiteBindingsEnabled="true" />
        <services>
          <service name="ConfigDiscoverableService.CalculatorService"
       behaviorConfiguration="calculatorServiceDiscovery">
            <endpoint address="CalculatorService.svc"
       behaviorConfiguration="calculatorHttpEndpointDiscovery"
       contract="ServiceContract.ICalculator" binding="basicHttpBinding" />
            <endpoint kind="udpDiscoveryEndpoint" />
          </service>
        </services>
      </system.serviceModel>
    
    

    要让服务成为可发现的,要做的就是为服务添加ServiceDiscoveryBehavior并托管一个DiscoveryEndpoint。上面两个例子中的UdpDiscoveryEndpoint用来接收客户端发出的组播网络消息。

    注意 WFC提供的服务发现特性是符合WS-Discovery标准的,因此它也可以与.NET Framework之外的其他不同平台和语言的协议实现互通。

    客户端可以使用DiscoveryClient类来查找可发现的服务。首先需要给DiscoveryClient类的实例传入一个DiscoveryEndpoint类的实例;然后创建一个FindCriteria类的实例,该类描述了目标服务的各种属性;最后Find方法使用这个FindCriteria类实例进行查找并返回一个FindResponse类的实例,它的Endpoints属性包含了一组EndpointDiscoveryMetadata实例,其中的每个实例都是一个符合查询条件的服务。代码清单2-16展示了查找可发现服务的这些步骤。

    代码清单2-16 服务发现是一种很好的代码解耦方式

    class Program
    {
        private const int a = 11894;
        private const int b = 27834;
     
        static void Main(string[] args)
        {
            var foundEndpoints = FindEndpointsByContract<ICalculator>();
     
            if (!foundEndpoints.Any())
            {
                Console.WriteLine("No endpoints were found.");
            }
            else
            {
                var binding = new BasicHttpBinding();
                var channelFactory = new ChannelFactory<ICalculator>(binding);
                foreach (var endpointAddress in foundEndpoints)
                {
                    var service = channelFactory.CreateChannel(endpointAddress);
                    var additionResult = service.Add(a, b);
                    Console.WriteLine("Service Found: {0}", endpointAddress.Uri);
                    Console.WriteLine("{0} + {1} = {2}", a, b, additionResult);
                }
            }
     
            Console.ReadKey();
        }
     
        private static IEnumerable<EndpointAddress> FindEndpointsByContract
        <TServiceContract>()
        {
            var discoveryClient = new DiscoveryClient(new UdpDiscoveryEndpoint());
            var findResponse = discoveryClient.Find(new
       FindCriteria(typeof(TServiceContract)));
            return findResponse.Endpoints.Select(metadata => metadata.Address);
        }
    }
    
    

    请牢记自组网模式使用的是UDP,而不是TCP,因此无法保证消息投递的结果一定是成功的。可能出现的数据包丢失情况,要么是因为请求包无法到达服务端,要么是因为响应包无法返回客户端。无论是哪种丢包方式,在客户端看来就是处理请求的服务当前不可用。

    提示 当使用Internet信息服务(Internet Information Service,IIS)或Windows进程激活服务(Windows Process Activation Service,WAS)托管可发现的服务时,一定要确保使用Microsoft AppFabric AutoStart功能。服务要能被发现,首先服务必须是可用的,这就意味着为了接收客户端的请求,服务必须首先处于运行状态。AppFabric AutoStart特性允许应用程序在IIS中启动时也能自动启动服务。如果没有自动启动,只有在第一次请求该服务时才会启动它。

  • REST化服务

    创建REST(REpresentational State Transfer,表述性状态转移)化服务的最大好处是客户端几乎没有任何依赖,只需要一个所有语言的框架和库都提供的HTTP client实例。因此REST化服务非常适合开发需要跨平台的功能强大的服务。举例来说,Facebook和Twitter都为各种查询和命令提供了丰富的REST API。这样能够很容易为各种平台开发客户端,包括Windows Phone 8、iPhone、Android、Windows 8、Linux和其他平台。如果不使用REST,单一的服务端实现很难做到同时支持所有平台上的客户端。

    ASP.NET Web API用来创建基于.NET Framework的REST服务。与ASP.NET MVC框架类似,它也允许开发人员创建能直接映射为网络请求的方法。ASP.NET Web API提供了一个名为ApiController的基础控制器类,通过继承这个类并实现一些使用HTTP动作(GETPOSTPUTDELETE. HEADOPTIONSPATCH等)作为名称的方法后,当接收到一个带有某个动作名称的HTTP请求时,相应名称的方法就会被执行。代码清单2-17展示了一个实现了所有HTTP动作命名方法的服务。

    代码清单2-17 ASP.NET Web API几乎支持所有HTTP动作

    public class ValuesController : ApiController
    {
        public IEnumerable<string> Get()
        {
            return new string[] { "value1", "value2" };
        }
     
        public string Get(int id)
        {
            return "value";
        }
     
        public void Post([FromBody]string value)
        {
        }
     
        public void Put(int id, [FromBody]string value)
        {
        }
     
        public void Head()
        {
        }
     
        public void Options()
        {
        }
     
        public void Patch()
        {
        }
     
        public void Delete(int id)
        {
        }
    }
    
    

    代码清单2-18 客户端可以使用HttpClient类访问任何REST化服务

    class Program
    {
        static void Main(string[] args)
        {
            string uri = "http://localhost:7617/api/values";
     
            MakeGetRequest(uri);
            MakePostRequest(uri);
     
            Console.ReadKey();
        }
     
        private static async void MakeGetRequest(string uri)
        {
            var restClient = new HttpClient();
            var getRequest = await restClient.GetStringAsync(uri);
     
            Console.WriteLine(getRequest);
        }
     
        private static async void MakePostRequest(string uri)
        {
            var restClient = new HttpClient();
            var postRequest = await restClient.PostAsync(uri,
                    new StringContent("Data to send to the server"));
     
                var responseContent = await postRequest.Content.ReadAsStringAsync();
                Console.WriteLine(responseContent);
            }
        }
    
    

    为了强调所有平台上的客户端都几乎一样没有依赖,代码清单2-19展示了一个使用Windows PowerShell 3脚本编码访问服务GETPOST方法的示例。

    代码清单2-19 使用Windows PowerShell 3访问REST化服务一样非常简单

    $request = [System.Net.WebRequest]::Create("http://localhost:7617/api/values")
    $request.Method ="GET"
    $request.ContentLength = 0
     
    $response = $request.GetResponse()
    $reader = new-object System.IO.StreamReader($response.GetResponseStream())
    $responseContent = $reader.ReadToEnd()
    Write-Host $responseContent
    
    

    在上面的示例中,脚本代码使用.NET Framework的WebRequest类的对象来访问REST化服务。其中,WebRequest类是HttpRequest的父类,它的Create方法是个工厂方法,会根据传入的http://开头的URI字符串返回一个HttpRequest类的实例。

1这个IP地址是239.255.255.250(IPv4)或[FF02:C](Ipv6),端口是3702。这是由WS-Discovery标准设置的,无法进行更改配置。

2.2.7 使用NuGet管理依赖

依赖管理工具能够大大简化依赖管理工作。它们会负责跟踪依赖链并准备好所有要依赖的程序集和相关资料,同时还会负责管理依赖版本。开发人员只需要指定依赖的具体版本,剩下的工作会由管理工具自动完成。

NuGet是一个.NET Framework的包管理工具。这里,NuGet将依赖称为包,其中不仅可以包括程序集,还可以包括配置、脚本以及图像等任何你需要的数据。使用诸如NuGet之类的包管理器的最有说服力的理由之一就是,这些工具对包的依赖关系非常了解。它们能为需要引用包的项目自动导入整个依赖链上所有需要的文件和数据。

从Visual Studio 2013开始,NuGet已经作为默认的包管理工具被完全集成到Visual Studio IDE当中了。

1. 使用包

NuGet为Visual Studio解决方案浏览窗口增添了一些新的快捷菜单项。选择任一菜单,你就可以打开NuGet包管理窗口并添加对依赖的引用。

举个例子,我打算引用Corrugatedlron,它是一个用于存储Riak非Sql键值对的.NET Framework客户驱动程序。图2-17是NuGet包管理窗口的截图。

{%}

图 2-17 NuGet包带有很多有用的元数据

在NuGet包管理窗口的列表中选择了一个包时,右侧的信息面板就会显示出这个包的一些元数据,其中包括:独一无二的命名、作者、版本、最后修改日期、描述以及自身的所有依赖。在引用一个包前,首先要根据版本要求安装并引用这个包自身的所有依赖。以Corrugatedlron为例,它需要一个版本不低于4.5.10的Newtonsoft.Json包、一个.NET Framework JSON/类序列化器和一个版本不低于2.0.0.602的protobuf-net包。而这些被Corrugatedlron依赖的包自身也都有一些依赖。

当你选择安装包后,NuGet首先会尝试下载所有相关文件并把它们存放在解决方案的packages/文件夹下。这样做的好处是,与本章开始介绍的手动创建dependencies/文件夹一样,也可以对整个文件夹进行源代码管理。当你需要使用这些库时,NuGet会自动将下载好的程序集加入到引用列表中。图2-18展示了添加Riak包后的项目引用列表。

{%}

图 2-18 NuGet工具会将目标包及其所有依赖一起自动加入到了项目的引用列表中

除了添加对包的引用外,NuGet工具还会创建一个包含项目所引用包及其版本信息的packages.config文件。这些信息会在NuGet工具升级和卸载包时用到。

在真正使用Riak的功能前,还需要进行一些默认的配置。所以NuGet不只是为项目下载和引用了很多程序集,它还自动根据Riak功能的需要在项目的app.config文件中设置了一些默认值。代码清单2-20展示了安装Riak之后的app.config文件内容。

代码清单2-20 NuGet工具在app.config文件中专门为Riak添加了一个新的configSection

<configuration>
  <configSections>
    <section name="riakConfig" type="CorrugatedIron.Config.RiakClusterConfiguration,
   CorrugatedIron" />
  </configSections>
  <riakConfig nodePollTime="5000" defaultRetryWaitTime="200" defaultRetryCount="3">
    <nodes>
      <node name="dev1" hostAddress="riak-test" pbcPort="10017" restScheme="http" restPort="10018" poolSize="20" />
      <node name="dev2" hostAddress="riak-test" pbcPort="10027" restScheme="http" restPort="10028" poolSize="20" />
      <node name="dev3" hostAddress="riak-test" pbcPort="10037" restScheme="http" restPort="10038" poolSize="20" />
      <node name="dev4" hostAddress="riak-test" pbcPort="10047" restScheme="http" restPort="10048" poolSize="20" />
    </nodes>
  </riakConfig>
</configuration>

很显然,使用NuGet 工具能为你节省大量的时间。你无需花费精力在Riak的官网上下载Corrugatedlron及其所有依赖程序集。这有助于你专注于真正的开发工作上。当需要将Corrugatedlron升级到新版本时,你只需要使用NuGet工具自动为整个解决方案更新所有相关的包即可。

2. 制作包

NuGet工具还提供了创建新包的功能。你可以自己创建包并将其发布到NuGet的官方商店,这样其他开发人员就可以使用你的自制包,或者你想把所有第一方依赖制作成包以便在项目内部同步引用。图2-19展示了使用NuGet包浏览器创建自制包的截图。在我的这个自制包里,我把CorrugatedIron设置为依赖项,因此它也会隐式依赖Newtonsoft.Json和Protobuf-net这两个包。我还为这个包添加了一个专门针对.NET Framework4.5.1的库工件,还添加了一个将在My folder/ NewFile.txt下引用的程序集中创建的文本文件。包含NewFile.txt的文件夹MyFolder,它们会被复制到包的安装目录下。此外,还有一个Windows PowerShell脚本,它会在包的安装过程中执行。可以在这个脚本中完成很多自定义动作,在此需要感谢一下强大的Windows PowerShell。

{%}

图 2-19 使用NuGet包浏览器可以轻松创建自己的包

NuGet生成的每个包都会有一个XML文件,其中包含了要在安装窗口中显示的详细元数据。代码清单2-21展示了XML文件内容的一个示例。

代码清单2-21 包含了包的详细元数据的XML定义

<package xmlns="http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd">
    <metadata>
        <id>MyTestPackage</id>
        <version>1.0.0</version>
        <authors>Gary McLean Hall</authors>
        <requireLicenseAcceptance>false</requireLicenseAcceptance>
        <description>My package description.</description>
        <dependencies>
            <dependency id="CorrugatedIron" version="1.0.1" />
        </dependencies>
    </metadata>
</package>

对于一直痛苦地手动打理大量第三方依赖的人来说,NuGet这个高效的工具真可谓是个天大的福利。而实际上,NuGet也并不局限于管理第三方依赖。当一个解决方案的规模变得足够大时,最好是能通过分层将整个解决方案划分为多个部分。可以把每一层的所有程序集放入一个NuGet包以供上层使用。这样划分后得到的多个小规模的解决方案很容易进行协调管理。

3. 工具Chocolatey

与NuGet工具类似,Chocolatey也是一种包管理工具。不同的地方是,NuGet的包是一些程序集,而Chocolatey的包是一些应用程序和工具。了解Linux的开发人员会发现Chocolatey有点像Debian和Ubuntu系统上的包管理器apt-get。再啰嗦一遍,包管理工具的好处有:简易安装、依赖管理以及轻松使用。

下面的Windows PowerShell脚本用来下载和安装Chocolatey。

@powershell -NoProfile -ExecutionPolicy unrestricted -Command "iex ((new-object
   net.webclient).DownloadString('https://chocolatey.org/install.ps1'))" && SET
   PATH=%PATH%;%systemdrive%\chocolatey\bin

安装好Chocolatey后,你可以使用命令行搜索和安装各种应用和工具。Chocolatey的安装程序已经更新了命令行路径,以包含Chocolatey.exe应用。Chocolatey和Git一样也有一些诸如listinstall之类的子命令,不同的是,它还分别为这些子命令提供了诸如clistcinst之类的快捷方式。代码清单2-22展示了一个Chocolatey会话示例,用来搜索和安装名为FileZilla(一个FTP客户端应用程序)的包。

代码清单2-22 查找并安装需要的应用程序包

C:\dev> clist filezilla
ferventcoder.chocolatey.utilities 1.0.20130622
filezilla 3.7.1
filezilla.commandline 3.7.1
filezilla.server 0.9.41.20120523
jivkok.tools 1.1.0.2
kareemsultan.developer.toolkit 1.4
UAdevelopers.utils 1.9
C:\dev> cinst filezilla
Chocolatey (v0.9.8.20) is installing filezilla and dependencies. By installing you accept the license for filezilla and each dependency you are installing.
 . . .
 This Finished installing 'filezilla' and dependencies - if errors not shown in console, none detected. Check log for errors if unsure.

只要Chocolatey没有报错,请求的包就已经成功安装了。有一点要警惕的是,Chocolatey为了从命令行执行新安装应用程序或工具的二进制文件,可能会修改系统PATH环境变量。Chocolatey工具能够搜索到大量的应用程序和工具包,这就是它的强大优势。

2.3 分层

至此,本章前面都是在讲解程序集层次的依赖管理。当然,管理好程序集层次的依赖很自然是组织应用程序的第一步,因为所有类和接口都包含在程序集中,而且这些程序集之间如何关联也是开发人员普遍担心的问题。组织好程序集层次的依赖关系后,所有程序集会包含附属于一组相关功能的类和接口。然而,你又如何保证程序集的组划分是正确的呢?

在开发的软件系统中,几个相关的程序集会形成一个组件。以相似且定义良好的结构化方式管理组件间的互动和管理程序集层次上的依赖关系一样重要(甚至更重要)。如图2-20所示,一个组件通常不是要最终部署的DLL或EXE文件,而是一个逻辑程序集组,组中的这些程序集在功能上是相关的。

{%}

图 2-20 可以通过将功能相关的程序集划分到同一个组来定义逻辑组件

图中包括了三个程序集:视图、控制器和视图模型。每个程序集包含了两个类型,它们的功能不同,需要的依赖也可能不同。图2-20中用户界面包所包含的类型和程序集都是逻辑上的概念,而不是实际的.NET Framework类和程序集实体。你可以把这三个程序集放置在解决方案下的一个名为UserInterfaces的文件夹下,也可以再将一个与用户界面没有任何关系的程序集加入进来,当然,这会让用户界面这个分组变得名不副实。你自己要多多留意,因为没有其他办法能防止出现这种情况。

在依赖管理的上下文中,组件和其他更低层次的编码概念没有差别。与方法、类以及程序集一样,层次(layer)也可以作为本章前面所讲的依赖图中的节点。因此,对层次节点也可以应用相同的规则:确保有向图无环且提供单一职责。

分层(layering)是一种架构模式,它鼓励开发人员将软件组件看作是水平功能层,而一个完整的应用程序可以划分为多个水平功能层。分层形成的组件一个叠加在另外一个上面,它们的依赖关系方向必须朝下。也就是说,程序最底层的组件没有依赖2,每个层只能依赖它的直接下层。通常情况下,应用程序的顶层都是用户界面,服务程序的顶层都是客户端用来与服务端交互的API。

2严格来讲,还可能有对第三方的基础构件程序集的依赖,“没有依赖”在这里只是为了表示最低层不再依赖任何第一方的代码。

2.3.1 常见的模式

任何项目都可以从常见的几个分层模式中找到适合自身的模式。本节要介绍的几个分层模式只是仅供参考,你还需要对它们进行定制以符合实际项目的具体需求和限制。这几种分层模式之间唯一的区别就是分层数目不同。本节会先介绍最简单的两层划分模式,然后讲解加入中间层的三层划分模式,最后引入任意分层模式的介绍。

需要的分层数目与方案的复杂度相关,而方案的复杂度又与问题的复杂度相关。因此,问题越复杂,越可能引入更多分层的架构。在这种情况下,复杂度由很多因素决定,其中包括:项目的时间限制、需要的持久度、需求的变更频率以及开发团队对模式及其实践的重视程度等。

因为本书旨在讲解如何适应需求变更,因此,我主张尽量从最简单的方案开始,后面有需要时才将它重构为更复杂的方案。这种方式对项目开发有很多好处。能尽快交付一些成果给客户并且能够尽早获得大量的重要反馈。总是追求很完美的方案是没有意义的,因为客户心中的完美与开发团队想象的完美有可能不一样。多层架构要比简单的两层划分方案耗费更多的开发时间,也无法及时获取重要的用户反馈。

逻辑层与物理层

逻辑层和物理层之间的区别就是代码逻辑组织和物理部署的区别。逻辑上,你可以把应用程序的代码拆分为多个逻辑层(layer),但是物理上,你依然可以把它们部署在同一个物理层(tier)上。物理层的数目就是单个应用程序拆分部署的宿主机器数目。如果整个应用程序被部署在同一台机器上,也就是说应用被部署在单个物理层上了。如果应用程序(至少有两个逻辑层)被拆分部署在两台独立的机器上,也就是说应用被部署在两个物理层上了。

采用多物理层的部署方式,就意味着不同物理层上同一应用程序的不同逻辑层间的交互会跨越网络边界,这自然也会产生时间性能上的相应代价。同一台机器上的跨进程交互的时间代价已经比较高了,而跨越网络边界交互的时间代价比前者还要高出很多。尽管如此,多物理层的部署方式依然有一个明显的优势,那就是它赋予应用程序更好的扩展能力。假设有一个三层(包括用户界面层、逻辑层和数据访问层)划分架构的网络应用程序,它被部署在单台机器上(也就是单个物理层上),那么这台机器能够支持的用户数目一定不高,因为它本身需要完成所有三个逻辑层的众多任务。如果将应用程序拆分部署在两个物理层上(把用户界面和逻辑层部署在一台机器上,而把数据访问层部署在另外一台独立的机器上),你不仅可以横向扩展用户接口逻辑层,还可以纵向扩展它。

为了纵向扩展,你只需要通过添加内存和处理单元等方式增加机器的能力,因为机器的能力增强了,本身就能完成更多的任务了。此外,你也可以通过增加执行相同任务的新的独立机器来实现横向扩展。这样,会有多台机器托管同样的网页用户界面代码,负载均衡器会实时将客户端的请求分配给最空闲的机器处理。当然,这种部署方式并不能解决网络应用场景中的多用户并发访问的问题。因为同一用户的多个不同请求可能由不同的机器来处理,这需要你谨慎处理好数据缓存和用户身份验证。3

3后面当逻辑层和物理层同时出现时才会使用全称,否则layer和tier都会简称为层。——译者注

1. 两层划分

对没有明确分层方案的最简单改进就是两层划分的方案。尽管两层方案的应用场景也不多,但是实现它所需的时间非常短。这两个层次分别是用户界面层和数据访问层。但是请记住,只有两个层次并不是只有两个程序集,而是有两组逻辑相关的程序集:一组与用户界面直接相关,另外一组与数据访问相关。

图2-21的UML图展示了两个层次的依赖关系,每个层次都包含了一组逻辑相关的程序集。无论分层数目的多少,每个层次都必须且只能依赖直接下层。

图 2-21 一个两层划分的应用由界面组件和数据访问组件组成

  • 用户界面层

    用户界面层的职责包括以下四项。

    • 为用户提供与应用程序交互的方式(比如:桌面窗口和控件、网页或者带有命令行或菜单的控制台应用程序)。

    • 向用户展示数据和信息。

    • 接收用户的查询或命令请求。

    • 验证用户的输入。

    用户界面层有很多种不同的实现方式。它可以是一个带有绚丽图像和动画的WPF客户端,也可以是一组导航网页,抑或是一个带有命令行开关参数或简单菜单(供用户选择以及执行查询或命令)的控制台应用程序。

    注意 有些情况下,用户界面会被一组为客户端提供功能的服务代替。这组服务并不是真 正可视的用户界面,但是两层划分的架构依然很清晰,只是用服务API层代替了用户界面层。

    用户界面层可以使用数据访问层的功能,然而,正如本章前面所讲的,用户界面层不应该直接引用数据访问层具体实现所在的程序集。这两个层次的接口和实现程序集也应该是严格分开的。图2-21的分层经过改进后看起来如图2-22所示。

    实际上,这就是在通过阶梯模式解决随从反模式带来的缺陷,只是这里的缺陷是架构级别上的问题。每个层次都是由上层所需功能的抽象以及该抽象的实现组合而成。如果一个层次开始引用直接下层的部分实现,那么这个下层被称为抽象漏洞(leaky abstraction)。因为对该层实现的依赖会逐渐蔓延到更高的上层中,从而引入了本来可以避免的依赖关系。

    {%}

    图 2-22 两个层次会各自分割为各自相关的实现程序集和接口程序集

  • 数据访问层

    数据访问层的职责包括以下两项。

    • 响应数据查询请求。

    • 序列化对象模型到关系模型,反序列化关系模型到对象模型。

    与用户界面层一样,数据访问层的实现也可以有很多种。这一层通常会包含某种持久数据存储器,它可能是诸如SQL Server、Oracle、MySQL或PostgreSQL等关系型数据库,也可以是诸如MongoDB、RavenDB或Riak等文档型数据库。除了数据存储机制之外,还可能存在一个或多个辅助程序集。这些辅助程序集要么通过调用存储过程来执行查询或插入/更新/删除命令,要么通过Entity Framework或NHibernate将数据映射到关系型数据库。

    数据访问层的所有接口都应该隐藏所有与技术相关的事情,也不应该引入任何对第三方的依赖,这样才可以保证客户端完全不受具体实现选择的影响。

    设计良好的数据应用层能够在多个应用程序中重用。如果两个用户界面需要把相同的数据展示为不同的表格形式时,它们就可以共享同一个数据访问层。假设一个应用程序需要同时支持Windows 8和Windows Phone 8,虽然两个平台上的用户界面需求不同,但是都可以使用同样的数据访问层。

    与其他架构方式一样,在实际采用两层划分之前需要清楚该方案的优缺点。以下是适合采用两层划分架构的一些场景。

    • 应用程序只有一些琐碎的数据验证且没有多少业务逻辑,可以将它们直接归到数据访问层或用户界面层中。

    • 应用程序主要执行数据的创建、读取、更新和删除(creating, reading, updating, and deleting,CRUD)操作。在用户界面和数据访问层间增加额外层会导致CRUD变得更加困难。

    • 时间太仓促。如果只是需要开发一个原型或模拟程序,限制分层数目会节省很多开发时间,也能让概念验证的可行性更加明确。如果你能坚持用好诸如阶梯模式等好的开发实践,后续需要额外的层次时再添加也会更容易。

    但是,两层架构也有明显的缺陷,它不适合在以下场景中应用。

    • 应用程序预期或已确定会有复杂的业务逻辑。从技术角度讲,将业务逻辑放入用户界面层或数据访问层会破坏这两层的设计初衷,导致它们变得不够灵活且难以维护。

    • 应用程序已明确在一两个冲刺后会需要多于两层的架构。如果一个临时架构只维持短短的几周时间,那么为了能尽快得到反馈而快速实现这个临时方案其实是不值得的。

    两层架构依然是一个很实用的选择,但很多开发人员都会着迷于最新的架构趋势而忽视这些简洁的设计,他们会把一个简单的应用复杂化,不仅错失及时的用户反馈,而且难以维护。通常情况下,最简单的方案可能就是正确的方案。

2. 三层划分

三层划分的架构是在用户界面层和数据访问层之间增加了一个业务逻辑层。应用会在增加的业务逻辑层中封装更复杂的处理逻辑。与数据访问层一样,同一份业务逻辑层也可以由不同的用户界面层重用。图2-23是一种典型的三层架构。

图 2-23 中间层包含了应用的处理或业务逻辑

再强调一次,业务逻辑层和数据访问层一样,需要为客户端提供接口和实现两个程序集,要避免成为抽象漏洞。

注意 尽管网络应用大多数都采用三个逻辑层的架构方式,但是通常是部署在两个物理层 上的。一个物理层专门负责数据库,另外一个物理层负责其余事情:用户界面、业务逻辑甚至部分数据访问工作。

  • 业务逻辑层

    业务逻辑层的职责包括以下两项。

    • 处理来自用户界面层的命令。

    • 为业务域的流程、规则和工作流建模。

    业务逻辑层有可能是一个命令处理器,它会在接收用户通过用户界面层下达的命令后,协同数据访问层一起解决某个具体问题或执行某项特别任务。业务逻辑层也可以是一个业务域模型,它把整个业务的所有过程映射在软件设计和实现当中。对于后者,通常会在数据访问层中增加一个对象/关系映射(Object/Relational Mapping,ORM)组件,这样可以通过域驱动设计(domain-driven design,DDD)的方式直接实现逻辑层的类型。域模型应该没有任何依赖,既不依赖任何下层,也不依赖具体的实现技术。举个例子,域模型程序集不应该依赖对象/关系映射库,而应该创建一个独立的映射程序集,它包含了如何指导对象/关系映射库映射到域模型的具体实现。这样做,就可以重用那些不依赖对象关系映射库的域模型核心类型,更换对象关系映射库也不会影响域模型和客户端代码。图2-24展示了带有域模型的逻辑层的一个可能的实现。

    图 2-24 域模型的程序集如何协作来形成逻辑层

    如果应用的逻辑比较复杂,比如是那些会影响人们真实工作流的业务规则,就有必要为此增加一个逻辑层。另外,即使逻辑不是特别复杂但变更却很频繁,也应该引入逻辑层来封装这部分逻辑。增加的逻辑层能够简化用户界面层以及数据访问层的实现,以便它们集中完成自身的本职工作。

2.3.2 纵切关注点

有时候,一个组件的职责很难集中在单个层次内。诸如审核、安全以及缓存等功能在应用程序的每个逻辑层都有可能存在。如果无法使用Visual Studio调试器单步调试那些已经部署到终端机器上的应用代码,可以使用日志手动追踪每个方法在调用和返回点处的代码行为来辅助调试。代码清单2-23展示了一个记录方法的传入参数值和返回值的示例。

代码清单2-23 手动记录纵切关注点可以很快了解到代码的意图

public void OpenNewAccount(Guid ownerID, string accountName, decimal openingBalance)
{
    log.WriteInfo("Creating new account for owner {0} with name '{1}' and an opening
   balance of {2}", ownerID, accountName, openingBalance");

    using(var transaction = session.BeginTransaction())
    {
        var user = userRepository.GetByID(ownerID);
        user.CreateAccount(accountName);
        var account = user.FindAccount(accountName);
        account.SetBalance(openingBalance);

        transaction.Commit();
    }
}

示例中的日志方式耗时费力且容易出错,每个方法都会出现这些与方法主题无关的代码,从而导致有效代码率降低。更好的方式应该是把这些提取出的纵切关注点进行封装并以一种更优雅的方式应用到源代码中。面向切面编程(Aspect-Oriented Programming,AOP)是一种很常见的增加功能的优雅方式。

切面

面向切面编程是代码中跨层次的纵切关注点(也称为切面)的运用。.NET Framework有多个面向切面编程库以供选择(可以使用NuGet搜索AOP),但是下面的示例使用的是PostSharp,它有个免费但受限的版本可用。代码清单2-24展示了如何使用PostSharp定义的扩展属性追踪代码行为。

代码清单2-24 切面是个实现纵切关注点的好方式

[Logged]
[Transactional]
public void OpenNewAccount(Guid ownerID, string accountName, decimal openingBalance)
{
    var user = userRepository.GetByID(ownerID);
    user.CreateAccount(accountName);
    var account = user.FindAccount(accountName);
    account.SetBalance(openingBalance);
}

附加在OpenNewAccount方法上的两个扩展属性提供了与代码清单2-23一样的功能,但是明显更优雅简洁。Logged属性能够将方法调用及其参数值记录到日志文件中。Transactional属性实现了数据库事务处理功能,如果动作成功就提交事务,如果失败就会回滚事务。这两个属性的最大优势就是它们能够应用于任何方法,而不仅仅局限于示例中的这个方法,因此这种属性可以在代码中大量重用。

2.3.3 非对称分层

所有用户的请求都是通过应用的用户界面传达的,然而,收到请求后的处理过程不一定完全相同。取决于请求类型,分层也可以是非对称的。恰当的分层要考虑是否对于处理有些请求太过复杂或不足,还要考虑是否实用。

最近几年,命令/查询职责分离(Command/Query Responsibility Segregation,CQRS)这种非对称分层模式变得很流行。下面在讲解命令/查询职责分离这个架构模式之前,需要先讨论一个方法层次的基础原则:命令/查询分离(Command/Query Separation,CQS)。

1. 命令/查询分离

命令/查询分离是Bertrand Meyer在其著作Object-Oriented Software Construction(1997年由Prentice Hall出版)中首次提出的一个方法层次的原则,它认为任一对象方法要么是命令,要么是查询。

命令是对动作的强制调用,需要代码做某些动作。这种命令方法可以改变某些系统状态但不应返回值。代码清单2-25展示了两个命令方法,第一个符合命令/查询分离原则,第二个则不符合。

代码清单2-25 一个符合命令/查询分离原则的命令方法和另一个不符合命令/查询分离原则的命令方法

// Compliant command
Public void SaveUser(string name)
{
    session.Save(new User(name));
}
// Non-compliant command
public User SaveUser(string name)
{
    var user = new User(name);
    session.Save(user);
    return user;
}

查询是对数据的请求,需要代码获取某些数据。这种查询方法为客户端代码返回数据但不应改变任何系统状态。代码清单2-26展示了两个查询方法,第一个符合命令/查询分离原则,第二个则不符合。

代码清单2-26 一个符合命令/查询分离原则的查询方法和另一个不符合命令/查询分离原则的查询方法

// Compliant query
Public IEnumerable<User> FindUserByID(Guid userID)
{
    return session.Get<User>(userID);
}
// Non-compliant query
public IEnumerable<User> FindUserByID(Guid userID)
{
    var user = session.Get<User>(userID);
    user.LastAccessed = DateTime.Now;
    return user;
}

命令方法和查询方法签名上的唯一区别就是有无返回值。如果一个符合命令/查询分离原则的方法返回一个值,那么你就可以大胆假设该方法不会改变任何系统对象状态。这样做带来的一个优势就是,你可以任意调整查询方法的顺序,因为它们对对象状态没有任何影响。如果一个符合命令/查询分离原则的方法没有返回值,你就可以认为它改变了对象的状态。对于命令方法,你需要留意不要随意改变它们的调用顺序。

2. 命令/查询职责分离

命令/查询职责分离模式是由Greg Young首先提出的。命令/查询职责分离模式是方法层次上的命令/查询分离原则在架构层上的应用,也是一种常见的非对称分层模式。基于命令/查询分离原则,命令/查询职责分离模式提出:命令和查询可能需要以不同的路径通过不同的逻辑层达到最优处理的效果。

举个例子,带有域模型的三层架构可以应用最简单的命令/查询职责分离模式。此时,只有来自应用程序的命令才会使用域模型,而来自应用程序的查询则会跳过逻辑层。图2-25展示了这个设计。

图 2-25 域模型仅应该用于处理来自上层的命令

查询数据通常需要足够快,也不保证具有事务一致性:为了及时响应,不完整或混乱的数据读取是可以接受的。不同的是,命令处理通常都需要保证具有事务一致性,因此由不同的层次处理命令和查询是有意义的。有些时候,数据访问层也可以区分命令和查询。由完全符合ACID标准(ACID是atomic, consistent, isolated, durable的缩写,它们的意思分别是原子的、一致的、可隔离的和持久的)的数据库处理命令,而简单的文档存储对查询来说就已经够用了。为了保证查询结果最终是一致的,可以由命令层触发事件来异步更新文档存储。

2.4 总结

本章已经展示了开发软件时依赖组织可能导致的严重问题。合理的依赖管理可以为项目带来长期的健康、良好的自适应和生存能力。如果开发人员鲁莽地创建了相互引用的类型,必然会引入乱作一团的依赖关系,这会严重影响团队持续地定期交付业务价值的能力。

从互有交互的独立方法和类型,到程序集引用,再到架构层的组件划分,所有层次上的依赖关系都需要认真管理。开发人员必须要时刻警惕那些从自己的方法、类型、程序集或层上泄漏的错误依赖。

在某种程度上,本章是本书很多剩余内容的基础。后续所有章节还会不断地讲解如何编写可维护的自适应代码。当然,如果程序集的引用是一团乱麻,层接口对外暴露了最底层的依赖,那么代码就一定会变得很难测试、修改和理解,而此时才去尝试使用任何模式或最佳实践则都已为时过晚。

目录