65.9K
CodeProject 正在变化。 阅读更多。
Home

NHibernate 与 ASP.NET 最佳实践,第 1.2 版

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (308投票s)

2006年3月12日

CPOL

60分钟阅读

viewsIcon

8996811

downloadIcon

35915

本文介绍了如何结合使用 NHibernate 1.2、ASP.NET、泛型和单元测试来发挥它们优势的最佳实践。

作者于2008年6月11日添加的说明 - S#arp Architecture 公告

值得庆幸的是,技术在不断发展。相应地,微软引入了 ASP.NET MVC 作为传统 ASP.NET 的替代方案。我开发了一种新架构,它借鉴了本文的许多设计原则,适用于这个名为 S#arp Architecture 的新平台。尽管本文仍然是 S#arp Architecture 的推荐背景阅读材料,但你会发现新架构更简单、更易于维护,同时仍然充分利用了 NHibernate 的优势。

1.2nd 版前言

2006年3月,我发表了关于 NHibernate 结合 ASP.NET、泛型和单元测试的最佳实践的初步想法。我很高兴地了解到,这些想法已在许多实际场景中成功实施。从那时起,我与许多人合作,完善这些想法,从错误中学习,并利用更强大的 NHibernate 版本。因此,尽管底层架构只做了适度但重要的修改,但本文更新并解决了其他一些重要因素。

  • 简而言之,NHibernate 非常棒。在本文的前一个版本中,我假设你已经知道了这一点……但现在我也尝试说服持不同意见的人。
  • NHibernate 1.2 原生支持泛型。
  • NHibernate 1.2 原生支持可空类型。
  • NHibernate 1.2 原生支持映射特性(mapping attributes)。
  • NHibernate 1.2 可以与存储过程通信。
  • 在 ASP.NET 中使用 CallContext 存储 ISession 在高负载下容易失败。
  • 公开一个可设置的 ID 属性会产生一个易受攻击的点。
  • 通过 Ayende 非常有用的 NHibernate.Generics 提供的自动父子关联,结果是弊大于利
  • 你用过 Rhino Mocks 3.0、NUnitAsp 或 Fit 吗?嗯,这些都会在本文中讨论,并更加强调测试驱动开发。
  • 作为我建议的替代方案,也请考虑 Castle Project 的产品,例如 MonoRail 和/或 ActiveRecord,它们为 ASP.NET 提供了一个简单而强大的开箱即用框架。事实上,如果技术上可行,并且你的团队能够接受这些现成的工具,我建议使用它们而不是从头构建解决方案。但即使你使用 Castle Project 的工具,本文仍然应该为你提供许多有用的信息!
  • 除了上面列出的内容,文章和代码中还有其他重要的重构和修复。这个版本绝不是对原文的轻微修饰。
  • 除了对原始示例代码进行全面修改外,还包含一个扩展的“企业级”示例,演示了:
    • NHibernate 与 Web 服务
    • NHibernate 与多个数据库
    • Castle Windsor 集成
    • 用于数据访问组件的可重用数据层。
    • Model-View-Presenter 的一个简单示例

在此快速感谢那些在自己的工作中实施了我的想法,并为改进和思考提供了大量意见的人!现在进入第 1.2nd 版……

文章目录

引言

为什么使用 ORM?

[作者注:以下摘自我“业余”时间写的一本书。]

“展望未来十年,我们看不到任何银弹。无论是技术还是管理技巧,没有任何单一的发展能在十年内承诺在生产力、可靠性、简单性方面带来哪怕一个数量级的改进。”

这些预言性的话语是 Frederick Brooks 在他如今已成为传奇的论文《没有银弹》中于1986年提出的。完全听从 Brooks 的话,直到十多年后的1997年,软件界才迎来了 NeXT 的 Enterprise Object Framework……最早的对象关系映射器(ORM)之一,这暗示着即将出现的银弹。尽管通常不会公开宣称——通常是为了避免被视为软件教条的异端——但许多人普遍认为,ORM 技术如果使用得当,实际上是软件开发的银弹。随着 NHibernate 的成熟,ORM 这颗银弹正式被引入到 .NET 世界。

一般来说,ORM 技术最常见的反对者是使用微软技术的开发人员。(由于过去十年左右我一直把自己归于这个领域,所以我很自在地首先提出我们!)似乎有一条不成文的规定:“如果不是微软发明的,那就等到微软推出*正确*的方式再说。”为了应对挑战,微软打算在即将推出的 C# 3.0 中通过“LINQ to Entities”来实现这一点。(是的,正式放弃使用“ObjectSpaces”和“DLINQ”。)开发人员可以继续等待这项技术,或者,可以立即开始享受 ORM 的好处。公平地说,.NET 开发人员的非微软发明工具集曾经很少,但自 .NET 问世以来一直在稳步增长。大约在2004年,开源工具的“非母舰创造”工具集终于达到了一个可观的成熟水平,任何 .NET 项目都应该认真考虑。(而且,从统计上看,大多数软件项目都失败了,听起来考虑扩展工具集当然是合理的。)LINQ 的即将推出无疑为灵活查询带来了巨大的好处。幸运的是,LINQ 并非微软数据访问层独有,LINQ for NHibernate 已经由 Ayende Rahien 顺利开发中。

这些技术的其他反对者认为,ORM 会扼杀应用程序性能,并且只在开发的初始阶段对生产力有显著提升。此外,他们还认为,使用 ORM 只会在项目后期成为项目成功的严重障碍,那时性能和可维护性问题开始产生更明显的影响。对于这些抗议,我想到了三个明显的反驳。

首先,也是最重要的一点,支持 ORM 的原因是,使用像 NHibernate 这样的框架可以提高你作为开发人员的*效率*。如果你可以花 90% 的时间更少(是的,我说的是“90% 的时间更少”)在开发数据访问代码上,那么就可以将宝贵的时间用于改进领域模型和调整性能——假设有这个必要。此外,利用一个简单的性能分析工具,可以很方便地找出导致 95% 性能瓶颈的 5% 的代码。而在数据访问层*确实*是瓶颈的情况下,通常可以通过简单的调整来获得显著的改进。顺便说一句,这与不使用 ORM 的情况没什么不同。(请务必查看 Peter Weissbrod 的NHibernate 应用程序性能分析入门文章。)在极少数情况下,ORM 框架本身是瓶颈且无法调整以进行改进时,如果数据访问层已正确抽象,那么完全绕过 ORM 是非常简单的。

其次,具体来说,NHibernate 在数据加载行为的各个方面提供了令人难以置信的控制力。这对开发人员的生产力、应用程序的可伸缩性和稳定性都有积极影响。缓存当然是可用的——但这在非 ORM 解决方案中也很容易获得。其他开箱即用的功能包括延迟加载、支持继承、声明不可变类、加载只读属性、支持泛型、存储过程,等等等等。重点是,所有这些强大的功能都已在实际场景中得到验证,因此,省去了开发、调试和调整自定义数据访问层的许多小时。(如果你真的想深入研究代码,那也没问题,因为 NHibernate 是一个开源项目。)

最后,我认为那些觉得像 NHibernate 这样的 ORM 在项目后期会成为维护噩梦的人,他们所使用的编码架构会妨碍*任何*数据访问层的维护。我虽然建议 NHibernate 是银弹,但这并不意味着它消除了正确应用程序设计的需要。只要有适度的、明智的架构远见,NHibernate 很可能是将 .NET 应用程序与数据库连接起来的最可维护的解决方案。

毋庸置疑,NHibernate 和其他 ORM 工具一样,减轻了维护成千上万行代码和存储过程的工作,从而让开发人员能够将更多精力集中在项目的核心上:其领域模型和业务逻辑。即使你使用像 CodeSmith 或 LLBLGen Pro 这样的工具自动生成 ADO.NET 数据访问层,NHibernate 仍然提供了将领域模型与关系模型解耦的灵活性。你的数据库应该是一个“实现细节”,其定义是为了支持你的领域模型,而不是反过来。本文的其余部分将重点介绍使用成熟的设计模式和“从实践中获得的经验教训”,将 NHibernate 集成到 ASP.NET 中的最佳实践。

文章的目标和概述

本文假设你对 C# 和 NHibernate 有很好的理解,了解数据访问对象/仓库(Data Access Object / Repository)模式,并至少对泛型有基本的熟悉。请注意,本文的重点不是*使用* NHibernate,而是*将* NHibernate *集成*到 ASP.NET 应用程序中。如果你刚刚接触 NHibernate,我建议阅读 TheServerSide.net 上的这两篇很棒的介绍文章:第 1 部分第 2 部分。(也请关注 Pierre Kuate 即将出版的《NHibernate in Action》。)要深入了解数据访问对象模式(在示例中大量使用),请访问 J2EE 的 BluePrints 目录。尽管我通篇使用“数据访问对象”(或“DAO”)这个词,但它与 Eric Evans 在《领域驱动设计》中的“仓库”(Repository)是可互换的。我只是觉得“DAO”打字更方便!

在 ASP.NET 2.0 应用程序中构建可靠的数据集成时,我们旨在实现以下目标:

  • 表示层和领域层应该相对地不知道它们如何与数据库通信。你应该能够以最少的修改来更改数据通信方式。
  • 业务逻辑应该易于测试,而不依赖于活动的数据库。
  • NHibernate 的功能,如延迟加载,应在整个 ASPX 页面生命周期中可用。
  • .NET 2.0 泛型应被利用以减少重复代码。

我们提供了两个示例应用程序,演示了在满足上述目标的同时,如何将 NHibernate 与 ASP.NET 结合使用:

  • 基础 NHibernate 示例:该示例演示了将 NHibernate 与 ASP.NET 和单元测试结合使用的基础知识,其架构简单易懂,但并非完全可重用。
  • “企业级”NHibernate 示例:此示例提供了一个基于成熟设计模式的坚实架构基础,可让您快速在几乎任何规模的 ASP.NET 项目中重用该框架。此示例还演示了 NHibernate 与 ASP.NET 以及“一大堆其他东西”的结合,包括与多个数据库通信、使用 模型-视图-表示器 (Model-View-Presenter) 模式、设置一个使用 NHibernate 的简单 Web 服务,以及与 Castle Windsor 集成。虽然代码包含了与多个数据库通信的内容,但详细解释超出了本文的范围,可以在 CodeProject.com 的文章《在 NHibernate 中使用多个数据库》中找到。(请注意,该文章的示例应用程序与 NHibernate 1.0x 兼容;不过,总体方法仍然适用。)

接下来将描述在示例应用程序中如何实现上述每个设计目标。但在进入实现细节之前,让我们先直接运行示例程序。

运行基础 NHibernate 示例

示例应用程序,冒着非常老套的风险,利用 SQL Server 2005 中的 Northwind 数据库来查看和更新 Northwind 客户列表。为了演示延迟加载的使用,该应用程序还显示每个客户下的订单。要在本地运行这些示例,您只需要安装了 .NET 2.0 框架的 IIS,以及包含 Northwind 数据库的 SQL Server 2005(或 2000)。(由于 SQL Server 2005 默认不带 Northwind,您可以简单地从 2000 版备份 Northwind 数据库并将其还原到 2005 版中。)这些示例也支持 SQL Server 2000……只需相应地修改 NHibernate 配置设置即可。

要启动并运行基础 NHibernate 示例应用程序:

  1. 将示例应用程序解压缩到您选择的文件夹中。
  2. 在 IIS 中创建一个新的虚拟目录。别名应为 BasicNHibernateSample,目录应指向解压缩应用程序后创建的 BasicSample.Web 文件夹。
  3. 打开 BasicSample.Web/web.configBasicSample.Tests/App.config,修改数据库连接字符串,以连接到 Microsoft SQL Server 上的 Northwind 数据库。
  4. 如果且仅当您运行的是 IIS 7,请修改 web.config,注释掉“兼容 IIS 6”部分,并取消注释“兼容 IIS 7”部分。
  5. 打开您的网页浏览器访问 https:///BasicNHibernateSample/Default.aspx,您就可以开始运行了!

运行“企业级”示例的步骤将在将基础知识扩展到“企业级”解决方案中讨论。但在此之前,既然你已经能够跟着眼前的基础示例操作,我们将研究如何开发这个应用程序以满足我们的设计目标……

NHibernate 集成基础

在开发应用程序时,我的主要目标是:

  • 一次且仅一次编写代码。
  • 注重简洁性和可读性。
  • 将耦合和依赖降至最低。
  • 保持关注点的清晰分离。
  • 使用测试驱动开发完成以上所有工作。

本节讨论了将这些设计原则用于将 NHibernate 集成到 ASP.NET 应用程序中。我们将通过剖析基础 NHibernate 示例的内部结构来做到这一点。

架构说明

这个基础示例不一定应被视为您自己 ASP.NET 应用程序的可重用框架。此示例应用程序的重点在于以简单明了的方式呈现 NHibernate 的集成。如果您正在寻找一个“为实际应用做好准备”的架构,请务必在阅读本节后查看将基础知识扩展到“企业级”解决方案。话虽如此,基础示例确实展示了许多最佳实践技术和设计模式:

分离接口

分离接口(Separated Interface),也称为依赖倒置(Dependency Inversion),是一种在应用程序层之间建立清晰关注点分离的技术。这项技术由Martin FowlerObject Mentor描述,并在 Robert Martin 的《敏捷软件开发》中有更详细的介绍。该技术常用于清晰地分离数据访问层和领域层。例如,假设一个 Customer 对象——在领域层中——需要与一个数据访问对象(DAO)通信以检索一些过去的订单。 (领域层是否应该*直接*与 DOA 通信留待另一场讨论。)因此,Customer 对象对 Order DAO——在数据层中——有依赖。但为了让 Order DAO 返回订单,DAO 需要对领域层有反向依赖。

最简单的解决方案是将包含 DAO 的数据层放入与领域层相同的物理程序集中。为了维持“虚拟”的关注点分离,包含的项目可以包括两个文件夹,一个名为*Domain*,另一个名为*Data*。(我实际上在一个相当大的项目中,在过去的生活中,用过这种方法,后果令人遗憾。)这种方法带来了一些不良影响:

  • 领域层和数据层之间存在双向依赖。
  • 没有任何机制可以阻止数据层渗透到领域层,反之亦然。如果不能从结构上阻止,这种情况就*会*发生。
  • 在不使用实时数据库来支持数据层的情况下,很难对领域层进行单元测试。除了其他缺点外,这还会导致单元测试性能急剧下降;因此,开发人员会停止进行单元测试。

或者,领域层和数据层可以放置在物理上独立的程序集中;例如,分别为 *Project.Core* 和 *Project.Data*。领域层(*Project.Core* 程序集)将包含领域对象和 DAO *接口*。数据层(*Project.Data* 程序集)将包含在领域层中定义的 DAO 接口的具体实现。这在下面的包图中有所展示。请注意,箭头表示从数据层到领域层的单向依赖。

Screenshot - SeparatedInterface.jpg

这种清晰的分离带来了许多好处:

  • 开发人员在结构上无法将具体的数据访问代码放入领域层,除非添加一些容易发现的引用,例如对 *NHibernate* 或 *System.Data.SqlClient* 的引用。
  • 领域层对数据层如何通信或与谁通信保持一无所知。因此,更换数据访问实现细节(例如,从 ADO.NET 换到 NHibernate)变得更容易,而无需修改领域层。
  • 依赖于接口使得在单元测试时为领域层提供一个“模拟”的数据访问层变得容易。这使得单元测试运行飞快,并消除了在数据库中维护测试数据的需要。

示例应用程序中包含了分离接口的实现示例,并将在下文中进一步讨论。

依赖注入

如上所述,分离接口引入了一个难题:如何将具体的 DAO 实现提供给只“知道”接口的领域层?依赖注入(Dependency Injection),也称为控制反转(Inversion of Control, IoC),描述了实现这一目标的技术。通过依赖注入,可以移除许多对具体对象的直接实例化,以及直接调用 new 关键字带来的不灵活性。依赖注入可以通过手动方式或通过 IoC 容器执行。手动方法是通过对象的构造函数或属性设置器简单地将其“服务依赖”传递给它。我在 CodeProject 的文章《依赖注入实现松耦合》中介绍了这种方法的入门。(请注意,“服务依赖”可以是 DAO、电子邮件工具或任何利用外部、昂贵或不应在单元测试中使用的资源的东西。)这种手动方法在单元测试中最有用,因为明确传递具体依赖有助于描述功能。(Martin Fowler 对明确性的价值有一些明智的看法。)另外,依赖注入也可以通过由代码或 XML 驱动的 IoC 容器来执行。(Fowler 也写了一篇关于 IoC 容器、服务定位器以及在两者之间做出适当选择的精彩介绍。)可以将 IoC 容器视为提供了超强解耦。IoC 容器的好处包括一个灵活、松散耦合的框架和对面向接口编程的更强 تاکید。缺点是在应用程序中增加了一层复杂性。这是在开发应用程序时需要考虑的灵活性和复杂性之间的众多权衡之一。这里有两个很棒的工具可以将 IoC 容器付诸实践:

  • Castle Windsor:Castle Windsor IoC 容器通过结合 XML 配置和强类型声明提供了出色的 IoC 支持。与其他选项相比,Castle Windsor 带来的一些优势是更少的 XML 和更多的编译时错误捕获。 Castle Project 还有许多其他强大的模块,如果您需要的不仅仅是 IoC,它是一个很有吸引力的选择。这个 IoC 容器得到了广泛的支持,在 .NET 开发社区中引起了很大的反响。“企业级”NHibernate 示例中包含了一个使用 Castle Windsor 的例子。
  • Spring .NET:该框架通过 XML 配置文件提供声明式的 IoC。与 Castle Project 一样,Spring.NET 也提供了各种额外的开发实用工具。对于来自 Java 世界并已熟悉 Spring Framework 的开发人员来说,这个选项应该特别有吸引力。

我发现,在单元测试之外使用 IoC 容器最有用,它可以为 ASPX 页面提供依赖,从而避免直接调用 new 关键字。这可以进一步扩展到在表示层或服务层中连接依赖关系。

契约式设计

契约式设计(DBC)简直是让你再也不用调试器的最佳方法。尽管不常被讨论,但这项技术应该得到与测试驱动开发同等的赞誉。(我不是说DBC会嫉妒,但它应该。)它能提高质量,减少空值检查,缩短调试时间,并极大地提高应用程序的整体稳定性。更技术性地描述,DBC是一种技术,它通过契约方式强制你的代码使用者(通常是你自己)以特定的方式使用方法和属性,并让这些方法和属性承诺成功执行给定的请求。如果契约未被遵守,就会抛出异常。乍一看可能有点极端,但它对提高代码质量大有裨益。通过DBC,“前置条件”断言了调用方法或属性时必须遵守的契约义务。“后置条件”则断言了哪些契约义务已经得到保证。我强烈推荐阅读一篇契约式设计入门;你会很快就迷上使用它的。本文附带的示例项目使用了一个修改版的DBC类,原作者是Kevin McFarlane。原版允许条件编译,以便在调试模式下开启契约,在发布模式下关闭,而本文示例中的变体则无论编译模式如何都维持契约义务。在我看来,契约永远是契约,其行为不应改变。

在代码中,你会发现前置条件用 `Check.Require` 声明,而后置条件用 `Check.Ensure` 声明。由于契约式设计(DBC)不特定于领域,这个类被适当地提取到一个可重用的工具库中,放在“企业级”示例里。

基础示例应用程序

那么我们现在要处理的是什么?这个基础应用程序实现了以下用户故事:

  • 用户可以查看供应商及其产品的列表。
  • 用户可以查看现有客户的列表。
  • 用户可以查看客户的详细信息。
  • 用户可以查看客户过去下的订单列表。
  • 用户可以查看客户下的订单摘要列表,包括产品名称和总数量。
  • 用户可以编辑客户详情。
  • 用户可以添加新客户。

无论这个应用程序是否有价值,上述用户故事足以展示 NHibernate 集成的主要方面。为了帮助保持逻辑层的松耦合,所包含的示例应用程序分为四个项目:Tests、Web、Core 和 Data。根据惯例,我使用 <ProjectName>.<LayerName> 来命名项目;例如,BasicSample.Data。虽然这种简单的关注点分离目前可行,但稍后将讨论一个更现实的架构。在逐层剥开这个洋葱时,让我们从测试层开始,逐步走向简单的表示层。

使用 NUnit、NUnitAsp、Rhino Mocks 和 Fit 的 BasicSample.Tests

我猜你大概能猜到这个项目是干什么的。在本文的第一版中,单元测试只被轻描淡写地讨论过。正如行业和个人经验所证明的,测试驱动开发是生产高质量、更适应变化产品的关键因素。此外,采用测试驱动的方法往往能产生更好的设计,并且附带地,创造了大量完全有效的技术文档。想要了解“测试驱动开发的一天”的精彩入门介绍,可以看看 Kent Beck 的《测试驱动开发:实例入门》。检查示例应用程序的单元测试可以概述应用程序的结构和可用功能。在看了单元测试之后,我们将进一步深入研究它们所测试的代码。

关于单元测试性能的说明

单元测试必须快如闪电。如果一套单元测试运行时间太长,开发人员就会停止运行它们——而我们希望他们一直运行单元测试!事实上,如果一个测试运行时间超过0.1秒,那么这个测试可能就太慢了。现在,如果你以前做过单元测试,你就会知道任何需要访问实时数据库的单元测试运行时间都比这长得多。使用 NUnit,你可以将测试分类,这样就可以方便地一次运行不同组的测试,从而在大多数时候排除那些连接数据库的测试。但至少,这些“慢”测试应该每晚在一个持续集成环境中运行。这里是一个对 NUnit 单元测试进行分类的简单例子:

[TestFixture]
[Category("Database Tests")]
public class SomeTests
{
    [Test]
    public void TestSomethingThatDependsOnDb() { ... }
}

领域层单元测试

为简单起见,这个应用程序的领域层可以说非常轻量。但即使在最轻量的领域层中,至少要确保每个非异常路径都有单元测试覆盖。领域层测试可以在命名空间 BasicSample.Tests.Domain 中找到。(顺便说一句,CustomerTests.CanCreateCustomer 测试了几乎每个属性的 getter/setter,这是否有点小题大做?你会惊讶地发现在这个过程中能捕捉到多少微不足道的 bug。)在回顾测试时,你可能会注意到 DomainObjectIdSetter.cs 这个类;这个类的动机将在稍后讨论。

要运行单元测试,请打开 NUnit,转到“文件”/“打开项目”,然后打开 *BasicSample.Tests/bin/Debug/BasicSample.Tests.dll*。为了防止运行更耗时的测试,请转到 NUnit 内的“类别”选项卡,然后双击“数据库测试”和“Web 冒烟测试”。此外,点击底部的“排除这些类别”。现在,当您运行单元测试时,只有领域逻辑测试会运行,而不会被 HTTP 和数据库访问测试拖慢。对于这么小的应用程序,这些“慢”测试增加的开销几乎可以忽略不计,但对于大型应用程序,它们可能会给运行单元测试增加好几分钟的时间。

为数据访问层使用测试替身

在进入模拟数据库层之前,应该注意的是,存在一个术语体系来描述不同类型的模拟服务。Dummies(哑元)、fakes(伪造对象)、stubs(存根)和 mocks(模拟对象)都用来描述不同类型的模拟行为。一篇关于差异的概述强调了一些将包含在 Gerard Meszaros 即将出版的《XUnit 测试模式》中的内容。Meszaros 提出了“测试替身”(test double)来泛指任何这些行为。存根和模拟对象是示例代码中演示的两种测试替身。

除非你专门测试 DAO 类,否则通常不希望运行依赖于活动数据库的单元测试。它们天生就慢且不稳定;也就是说,如果数据改变,测试就会中断。在测试领域逻辑时,如果数据库改变,单元测试不应该中断。但主要障碍是领域对象本身可能依赖于 DAO。利用示例中采用的抽象工厂模式(稍后讨论)以及相关的 DAO 接口,我们可以将 DAO 测试替身注入到领域对象中,从而模拟与数据库的通信。在测试 CustomerTests.CanGetOrdersOrderedOnDateUsingStubbedDao 中包含了一个例子。以下来自单元测试的代码片段创建了 DAO 存根,并通过一个公共的设置器将其注入到 customer 中。由于设置器只期望 IOrderDao 接口的实现,存根 DAO 轻松地替换了所有活动数据库的行为。

Customer customer = new Customer("Acme Anvils");
customer.ID = "ACME";
customer.OrderDao = new OrderDaoStub();

作为编写 DAO 存根的替代方案,通常本质上是静态的,并且经常导致大量“未实现”代码,也可以使用像 Rhino MocksNMock 这样的工具来模拟 DAO。两种选择都很好,但 Rhino Mocks 以强类型方式调用方法,而不是像 NMock 那样使用字符串。这使得其使用在编译时得到检查,并有助于重命名属性和方法。测试 CustomerTests.CanGetOrdersOrderedOnDateUsingMockedDao 演示了使用 Rhino Mocks 3.0 创建一个模拟的 IOrderDao。尽管设置一个模拟对象看起来比设置一个存根更复杂,但增加的灵活性和大大减少的“未实现”代码是令人信服的好处。下面的代码,在 MockOrderDaoFactory.cs 类中找到,展示了如何用 Rhino Mocks 模拟 IOrderDao。它实际上创建了一个“静态”的模拟或存根,因为无论传入什么参数,它总是返回由 TestOrdersFactory 创建的相同的示例订单。但是用 Rhino Mocks 进行模拟并不仅限于像这样的简单反应,并且可以根据需要进行各种响应。

public IOrderDao CreateMockOrderDao() {
    MockRepository mocks = new MockRepository();

    IOrderDao mockedOrderDao = mocks.CreateMock<IOrderDao>();
    Expect.Call(mockedOrderDao.GetByExample(null)).IgnoreArguments()
        .Return(new TestOrdersFactory().CreateOrders());
    
    mocks.Replay(mockedOrderDao);

    return mockedOrderDao;
}

不幸的是,更多时候你维护的是遗留代码,这些代码完全没有理想的“面向接口编程”的影子,而这种编程方式才允许进行这种测试替身注入。通常,存在许多对具体对象的显式依赖,很难用测试替身替换数据访问对象来模拟一个活动的数据库。在这些情况下,你的选择要么是重构遗留代码以适应测试框架,要么是使用像 TypeMock 这样的对象模拟工具。使用 TypeMock,甚至可以模拟密封类和单例类——没有这样的工具是很难做到的。尽管功能强大,但 TypeMock 最好还是在绝对需要时才使用;过早使用 TypeMock 会让人倾向于不进行面向接口编程。处理遗留代码时更合适的做法是——在时间和预算允许的情况下——重构代码以获得更大的灵活性。Michael Feathers 的《有效地使用遗留代码》充满了将遗留代码重构到测试框架中的好点子。

单元测试 NHibernate DAO

在本文的前一个版本中,NHibernate 的 `ISession` 仅通过 `System.Runtime.Remoting.Messaging.CallContext` 进行存储和检索。虽然对于 WinForms 和 NUnit 测试来说这完全没问题,但对于 ASP.NET 来说这是一个非常糟糕的主意,因为在高负载下 `ISession` 可能会“丢失”。(这两篇文章进一步解释了为什么在 ASP.NET 应用程序中使用 `CallContext` 是一个坏主意。)为了与 ASP.NET 应用程序正确集成,`ISession` 应该存储在 `HttpContext.Current.Items` 中;但这样做会迫使你在运行单元测试时模拟一个 HTTP 上下文。它还妨碍了框架轻松移植到 WinForms。一个更好的方法是在适当的时候使用适当的存储库。因此,如果 Web 上下文可用,则使用 `HttpContext`;否则,使用 `CallContext`。这种组合方法的实现细节将在稍后讨论。(感谢文章评论中提出此问题的许多人。)请查看 *BasicSample.Tests/Data/CustomerDaoTests.cs*,了解那些在 `ISession` 管理方面与 HTTP 无关的单元测试。顺便说一下,正如你将在单元测试中看到的,除非你希望测试中所做的数据更改被提交到数据库,否则回滚事务是一个好主意。

正如示例中所示,可以创建一个适用于任何持久化对象的通用 DAO。(具体细节稍后讨论。)这就引出了一个讨论:应该测试什么?应该如何测试?每个具体的 DAO 都应该进行全面测试吗?测试数据如何维护?个人经验建议遵循以下准则:

  • 确保存在一个单元测试来测试泛型 DAO 的每个方法,一次。例如,如果你有 10 个实现了泛型 DAO 的 DAO,那么每个方法只需要在其中一个 DAO 中进行全面测试。对其他九个泛型 DAO 的实现进行单元测试只会带来很少的额外价值。
  • 确保为泛型 DAO 的每个扩展方法都存在一个单元测试。例如,如果你有一个继承自泛型 DAO 的 `CustomerDao` 类,并用一个额外的方法(如 `GetActiveCustomers()`)对其进行了扩展,那么就应该编写一个单元测试来测试这个扩展方法。
  • 确保存在一个单元测试来全面测试每个“特殊”DAO。例如,DAO *BasicSample.Data/HistoricalOrderSummaryDao.cs* 并*不*继承自泛型 DAO,它被认为是一个特殊 DAO。因此,存在一个单元测试来测试它的每个方法。
  • 使用像 NDbUnit 这样的工具,在 DAO 单元测试运行前后,将测试数据库置于一个已知状态。
  • 永远记住,单元测试不应依赖于另一个单元测试的行为!它们应该是独立的,并且能够独立运行。例如,一个删除测试不应该依赖于之前的插入测试已经成功完成。请注意,`TestFixtureSetUp` 和其他设置/拆卸方法可以在测试被认为是独立运行的情况下运行。

为表示层使用 Fit 测试替身

在测试领域层时,使用了测试替身来模拟 NHibernate 与数据库的通信。类似地,在测试表示层时,有时使用同样的方法也很方便。假设你正在一个 ASP.NET 项目中工作,有一个专门的“创意”团队。那些穿着黑色高领毛衣的创意人员负责开发应用程序的外观和感觉。当他们在进行图形布局设计时,你不应该受阻于开发一个表示层来查看领域逻辑和数据访问结果,并获取客户反馈,同时还能推迟诸如母版页设置、安全执行和其他特定于表示层的决策。在另一种情况下,假设你正在处理一些复杂的业务规则,你希望客户能够验证这些规则,而无需编写几十个单元测试来封装每个微小的变化。FIT(集成测试框架)是一个工具,可以让开发人员非常快速地伪造表示层,并促进开发人员和项目相关者之间更紧密的协作。正如 Fit 网站所说,这样做是为了“了解软件*应该*做什么以及它*确实*做什么。它会自动将客户的期望与实际结果进行比较。”可以说,像这样的工具并不“基础”,也不是测试 NHibernate 所必需的;但是测试驱动开发的重要性需要被强调,而像 Fit 这样的工具,如果使用得当,对软件质量的贡献与 NUnit 一样重要。

要查看 Fit 测试结果,你可以使用 WinFITRunnerLite,它在类似于 NUnit 的 Windows 客户端中运行 Fit 测试,或者使用 FitNesse,它提供了一个基于 Web 的维基,用于修改测试输入和查看 Fit 测试结果。尽管设置起来需要多一点功夫,但 FitNesse 提供了一个非常灵活的框架,允许客户参与验证编码逻辑和应用程序工作流程。下面的截图显示了一个使用 FitNesse 运行计算器测试的简单示例输出:

Screenshot - FitNesse.jpg

尽管 Fit 测试的实现示例超出了本文的范围,但我希望你能对学习这个强大的框架产生兴趣。除了前面列出的网站,关于使用 Fit 和 FitLibrary(Fit 的扩展)的广泛信息可以在 Rick Mugridge 和 Ward Cunningham 的《Fit for Developing Software》一书中找到。

使用 NUnitAsp 运行 ASPX “冒烟测试”

至此,我们已经对领域层和数据访问层进行了单元测试,并了解了如何使用 Fit 进行粗略的表示层测试,以便让客户更多地参与进来。现在是时候测试 ASPX 页面本身了。NUnitAsp 是一个用于执行这类单元测试的类库。虽然你可以用 NUnitAsp 对你的 WebForms 测试进行相当复杂的测试,但我发现 NUnitAsp 最适合从持续集成服务器上运行“冒烟测试”,以验证没有页面出现明显错误。将 NUnitAsp 用于更深层次的测试往往会导致大量相关的单元测试代码的维护工作。由于这些 HTTP 单元测试天生就很慢,它们很少被运行,因此维护得也比较少;所以它们应该尽可能保持简单。BasicSample.Tests/Web/WebSmokeTests.cs 演示了这些单元测试的示例。虽然非常简单,但这些冒烟测试在验证你的表示层是否响应、数据库通信是否正常工作,以及 NHibernate HBMs 是否大体上没有错误方面,起到了很大的作用。另外一个好处是,如果在部署后立即将冒烟测试指向生产环境,它们可以预加载所有 ASPX 页面,为下一个访客提供更快的响应体验。你应该为应用程序中每个可通过 URL 访问的网页都包含一个冒烟测试。为了帮助组织它们,可以为每一组冒烟测试创建一个单独的测试类。例如,网站管理部分的所有冒烟测试都可以在一个名为 AdminSmokeTests.cs 的文件中找到。

用于定义领域层的 BasicSample.Core

BasicSample.Core 项目包含领域模型和 NHibernate HBM 文件。该项目还在 BasicSample.Core.DataInterfaces 命名空间中包含了描述数据访问对象的接口。(可以说,HBM 文件在逻辑上属于 BasicSample.Data 程序集,但将 HBM 文件物理上靠近它们所描述的领域对象所带来的维护便利性,远胜于这种对封装的破坏。)

分离接口,已实现

你会注意到 *BasicSample.Core* 项目不包含数据访问对象的实现细节,只包含描述其所需服务的*接口*。实现这些接口的具体 DAO 类位于 *BasicSample.Data* 中。如前所述,这种技术称为分离接口。如果你将 *BasicSample.Core* 视为“上层”,将 *BasicSample.Data* 视为“下层”,那么,正如 Robert Martin 所描述的,“每个上层都为其所需的服务声明一个抽象接口。然后下层根据这些抽象接口实现。……因此,上层不依赖于下层。相反,下层依赖于上层*声明*的抽象服务接口”。

要看这个实际应用,数据接口在命名空间 `BasicSample.Core.DataInterfaces` 中描述。`IDao` 是一个提供典型数据访问功能的泛型接口。`IDaoFactory` 则作为一个或多个 DAO 工厂类的接口。面向 `IDaoFactory` 接口编程允许你为生产代码创建一个具体的 DAO 工厂,为单元测试目的返回 DAO 测试替身的另一个具体的 DAO 工厂。(这是一个使用抽象工厂模式的例子。)正如之前在 *BasicSample.Tests* 中所考察的,在单元测试中利用模拟对象提供了一种一次只测试一个职责的方法。

集合泛型探究

到目前为止,C# 2.0 带来的最大好处之一就是引入了泛型。通过泛型,可以有效地实现更多的代码重用,同时仍然强制执行强类型的编码“契约”。在本文的前一个版本中,使用了 Ayende 的非常有用但已弃用的 NHibernate.Generics 来将 NHibernate 与 .NET 泛型集成。但现在 NHibernate 1.2 原生支持泛型,这个类库就不再需要了。如果你过去使用过 Ayende 的库,那么要完全迁移出去还需要一些工作,特别是如果你使用了自动连线来管理父子关系。但这不应阻止你,因为你仍然可以升级到 NHibernate 1.2,而不必立即重构掉自动连线……但你迟早还是会想这么做的。关于从 Ayende 的 NHibernate.Generics 重构所需的步骤的更多信息,可以在下面的 从 NHibernate 1.0x 迁移到 1.2 中找到。Ayende 的 NHibernate.Generics 的一个缺点是,内部集合需要同时暴露 getter 和 setter。这破坏了封装,并允许集合以非预期的方式被操纵或修改——可以把它看作是集合骚扰。现在 NHibernate 原生支持泛型,可以采用更好的集合封装技术。以下来自 *Customer.cs* 和 *Customer.hbm.xml* 的代码展示了泛型集合的更好封装。

public IList<Order> Orders {
    get { return new List<Order>(orders).AsReadOnly(); }
    protected set { orders = value; }
}

public void AddOrder(Order order) {
    if (order != null && !orders.Contains(order)) {
        orders.Add(order);
    }
}

public void RemoveOrder(Order order) {
    if (orders.Contains(order)) {
        orders.Remove(order);
    }
}

private IList<Order> orders = new List<Order>();

<bag name="orders" table="Orders" inverse="true" cascade="all">
    <key column="CustomerID" />
    <one-to-many class="BasicSample.Core.Domain.Order, BasicSample.Core" />
</bag>

将订单集合的 setter 设置为 `protected` 允许 NHibernate 填充该集合,而无需直接依赖私有成员,也无需公开 setter。另外,可以使用 NHibernate 的 `access="field"` 设置直接设置私有成员,但应谨慎考虑其使用,并仅在有必要时使用。在上面的示例代码中,请注意 `AddOrder` 和 `RemoveOrder` 对 `Customer` 类做了什么。它们无耻地污染了该类,将集合管理问题添加到了包含类中。想象一下,如果 `Customer` 最终有多个集合以及管理每个集合的方法,这会变成多么头疼的事情。在这种情况下,通常最好使用自定义集合。

作为一个使用自定义集合的简单例子,`Supplier` 类可能与多个 `Product` 项关联。需要注意的是,NHibernate 需要映射到一个 `IList` 集合;因此,需要维护两个集合。第一个是 NHibernate 知道的 products 集合本身,第二个是暴露自定义集合的 products 集合包装器。以下来自 *Supplier.cs* 和 *Products.cs* 的代码演示了这一点。

// Within Supplier.cs...

public Products Products {
    get {
        if (productsWrapper == null) {
            productsWrapper = new Products(products);
        }

        return productsWrapper;
    }
}

private Products productsWrapper;
// NHibernate binds directly to this member by the access="field" setting
private IList products = new List<Product>();


// Within Products.cs

public Products(IList products) {
    this.products = products;
}

public void Add(Product product) {
    if (product != null && !products.Contains(product)) {
        products.Add(product);
    }
}

private IList products;

虽然简单,但示例代码应该能满足您大部分的自定义集合需求。但在某些情况下,需要一个功能齐全的泛型自定义集合。最常见的场景之一是创建一个实现 `BindingList` 的自定义集合。在这种情况下,有必要利用 NHibernate 的 `IUserCollectionType`。一个很好的实现示例可以在这里找到。

泛型 ID 与对象比较

在 *BasicSample.Core* 中,每个可持久化的领域对象都继承自 `DomainObject`。这个类处理了比较两个领域对象是否相等的大部分工作。(关于这个对象的详细讨论可以在一篇 devlicio.us 的博客文章中找到。)`DomainObject` 也是一个泛型类,它接受一个数据类型,声明领域对象的 ID 类型。这个泛型属性使得一个领域对象(如 `Customer`)可以使用 `string` 作为 ID,而另一个领域对象(如 `Order`)可以使用 `long` 作为 ID。需要特别指出的是,在本文的先前版本中,ID 属性同时具有公共的 getter 和 setter。虽然 getter 是必不可少的,但公共的 setter 为破坏现有数据打开了潘多拉的盒子。假设你从数据库中检索了一个客户,并意外地将其 ID 设置为另一个客户的 ID。当这个客户被保存回数据库时,它的数据会覆盖另一个客户的数据。举一个更微妙的例子,假设 `Customer` 类被用作从编辑屏幕返回的伪 DTO。通过公共的 ID setter,开发人员设置 ID 和适用的属性,然后将其返回给另一个对象,该对象负责将 DTO 信息传输到从数据库中拉取的“真实”客户。请注意,DTO 和“真实”客户都作为 `Customer` 的实例被传递。因此,如果在维护期间,开发人员没有意识到从视图返回的 `Customer` 应该被视为 DTO 而不是“真实”客户,并直接对其调用保存,那么很可能大量真实的客户数据已经丢失,因为它被稀疏的 DTO 数据覆盖了。这背后的主要弱点是 ID 有一个公共的 setter。因此,领域对象的 ID 最好只在从数据库加载对象时由 NHibernate 设置,并对公共设置隐藏。但有些情况下,领域对象有指定的 ID。(就我个人而言,我认为指定 ID 没有任何用处,并尽可能避免使用它们,但理解可以为其使用提出论据。你可以在 *BasicSample.Web/AddCustomer.aspx* 中找到进一步的吐槽。)对于需要指定 ID 的情况,包含了一个名为 `IHasAssignedId` 的接口。该接口定义了一个设置 ID 的方法。因此,虽然它为设置 ID 提供了一个入口,但需要对其使用进行更多的思考,并为包含 ID 分配业务逻辑提供了一个好地方。以下代码片段在 `Customer` 类中体现了这一点。

public class Customer : DomainObject<string>, IHasAssignedId<string>
{
    public void SetAssignedIdTo(string assignedId) {
        Check.Require(!string.IsNullOrEmpty(assignedId), 
            "assignedId may not be null or empty");
        // As an alternative to Check.Require, 
            the Validation Application Block could be used for the following
        Check.Require(assignedId.Trim().Length == 5, 
            "assignedId must be exactly 5 characters");

        ID = assignedId.Trim().ToUpper();
    }
    ...
}

不暴露公共 ID setter 的一个明显缺点是,除非使用 NHibernate 加载对象,否则该属性对于单元测试框架来说基本上是不可用的。为了规避这个问题,*BasicSample.Tests/Domain/DomainObjectIdSetter.cs* 类使你能够设置领域对象的 ID,即使它们没有实现 `IHasAssignedId`。这个能力开启了许多测试可能性,而无需为 ID 属性提供公共 setter。另一方面,为了单元测试的便利而使用反射来设置私有成员,应被视为一种非标准做法,并且只在绝对必要时使用。它增加了复杂性,并且由于是基于字符串的,所以很脆弱。但对于设置领域对象的 ID 属性,它非常适合单元测试层。

将领域映射到数据库

NHibernate 提供了两种将领域对象映射到数据库的方法:使用 XML 的 HBMs 和映射特性(mapping attributes)。HBM-XML 文件的主要优点是它们与它们所描述的领域对象物理上是分离的。这使得领域对象能够保持为 POCOs(普通的 C# 对象),相对地不知道它们如何与数据库关联。但是将映射信息与领域对象分开也可能被视为 HBMs 的主要*缺点*,因为它需要额外的维护工作来在 HBMs 和它们所映射的类之间切换。(有些人也讨厌使用 XML。)另一方面,映射特性与领域对象紧密相连,并且通常比它们的 HBM 等价物更简洁。使用映射特性使领域对象更类似于活动记录(Active Records)而不是 POCOs。(要获得真正的活动记录支持,请考虑使用Castle Project 的 ActiveRecord。)除了“弄脏”领域对象外,映射特性还需要引用 *NHibernate.Mapping.Attributes*,这使得领域层在数据访问提供者方面不那么无关。另一方面,你是否经常完全更换数据访问层呢?但作为一般规则,领域层应该保持与数据访问提供者无关,只要这对应用程序的设计目标是可行的。归根结底,在 HBMs 或映射特性之间做决定是个人偏好的问题。无论如何,在开始一个新项目时,应该决定使用哪种技术;混合使用这些技术可能会导致混淆,因为可能不清楚哪些对象被映射了,哪些没有。示例应用程序演示了使用 HBMs作为映射解决方案。以下代码片段——在示例应用程序中找不到——展示了使用映射特性而不是 HBMs 的一个例子。关于映射特性的更多信息可以在 NHibernate 文档中找到。

[NHibernate.Mapping.Attributes.Class]
public class Customer
{
    [NHibernate.Mapping.Attributes.Property]
    public string FirstName { ... }
    ...
}

NHibernate 对可空类型的支持

NHibernate 1.2 带来的一个新功能是支持可空类型。以前,需要引用 *Nullables.NHibernate* 来支持可空类型,但现在不再需要了。在 HBMs 中,无需对可空属性的映射做任何特殊处理;只需像映射任何其他属性一样映射可空属性即可。NHibernate 足够智能,可以在数据库和它所映射的可空属性之间转换 `null`。请查看 *BasicSample.Core/Domain/Order.cs*,了解使用可空 `DateTime` 的示例。尽管现在实现对可空类型的支持非常简单,但只有在仔细考虑后才应使用。我自己的经验让我几乎从不在数据库中创建允许 null 的列。在我看来,将 null 映射到原始类型在数据库内或领域层内都没有什么意义。可空 `DateTime` 属性是我想到的唯一例外。显然,在非原始类型关系中使用 `null` 有很多有效的情况;但即便如此,使用空对象模式也是一个很好的替代方案,可以避免代码中散布的 `null` 检查。

NHibernate 对存储过程的支持

NHibernate 1.2 对存储过程的支持是该框架功能的一个关键补充。但是,如果所有的 CRUD 操作已经由 NHibernate 处理了,那么与存储过程集成有什么好处呢?原因有二:处理遗留数据库和执行报表查询。创建一个存储过程来返回查询密集的报表数据是轮询数据库的最佳方式。举例来说,Northwind 数据库维护着每个客户下的订单。假设你需要返回某个特定客户曾经订购过的每种产品的数量。*BasicSample.Core/Domain/HistoricalOrderSummary.cs* 类是一个值对象,它封装了这些摘要信息。请注意,这个类不继承自 `DomainObject`,也没有相应的 HBM 文件。从 NHibernate 的角度来看,这被认为是一个“非托管”类,不要与非托管 C# 混淆。*HistoricalOrderSummary.hbm.xml* 文件声明了一个命名查询,它调用存储过程 `CustOrderHist` 来返回所需的报表数据。(关于如何将这些数据转换为领域对象的细节将在接下来剖析 *BasicSample.Data* 项目时讨论。)

提供与存储过程交互的能力引出了一个问题:决定哪些工作应该由存储过程执行,哪些应该由领域层执行。作为存储过程的替代方案,为了满足前面提到的需求,C# 代码可以检索给定客户下的所有订单,遍历所有订单并汇总每种产品的数量。但很明显,让 SQL Server 执行这种汇总效率要高得多;想象一下,如果客户下了数千个订单。另一方面,如果性能分析显示,让领域层做这项工作与使用存储过程在效率上差别不大,那么就让领域层来做。你会发现,这将有助于形成一个更完整的领域驱动设计,并提高最终代码的可重用性。正如开发过程中经常遇到的那样,这是在更好的领域驱动设计和优化性能之间取得平衡……在发现瓶颈之前,总是倾向于领域层。(如引言中所述,请参阅Peter Weissbrod 的文章,了解识别 NHibernate 相关瓶颈的介绍。)顺便说一句,我参与的第一个 NHibernate 项目,大约有 50,000 行代码,总共需要大约六个存储过程来帮助优化数据查询能力。这应该能说明,你可以在领域层投入多少工作而不会对性能造成明显影响。

用于实现 NHibernate 通信的 BasicSample.Data

BasicSample.Data 项目包含具体的 DAO 和用于与数据库通信以及管理 NHibernate 会话的实现细节。

DAO 工厂和泛型 DAO

DAO 工厂和泛型 DAO 对象已分别实现为 `NHibernateDaoFactory` 和 `AbstractNHibernateDao`。经过一些关键修改,这些是 Java 版本的 C# 移植版,在 Hibernate 的网站上有详细描述。我强烈建议您详细阅读这篇文章。(请注意,前一篇文章中的一个 bug 已被修复,即在不使用事务时调用 `CommitChanges()` 没有刷新会话;请参阅 `AbstractNHibernateDao.CommitChanges` 中的修复代码。此外,由于 NHibernate 1.2 对泛型的原生支持,DAO 列表结果现在可以直接返回,而无需先经过一个转换为泛型列表的过程。)通过使用泛型 DAO,最令人印象深刻的是,只需几行代码就可以创建一个功能齐全、随时可用的 DAO:

  1. 在 *BasicSample.Core/DataInterfaces/IDaoFactory.cs* 中添加一个新的内联接口和相关的检索方法。
  2. 在 *BasicSample.Data/NHibernateDaoFactory.cs* 中为新的 DAO 添加一个新的内联实现和检索方法。

看看基础示例应用程序中的 `ICustomerDao`/`CustomerDao` 示例,定义接口和实现具体 DAO 大约只用了五行代码……还不错。

有时,有必要用特殊方法扩展抽象的、通用的 DAO。接口 *BasicSample.Core/DataInterfaces/IOrderDao.cs* 定义了这样一个例子。在这种情况下,领域层需要订单 DAO 返回在给定日期范围内的所有订单。为了便于管理,任何扩展抽象 DAO 的 DAO 都应放在自己的文件中,而不是像 `ICustomerDao` 那样内联声明。同样,这也适用于用 *BasicSample.Data/OrderDao.cs* 演示的具体实现。

从存储过程中检索报表数据

在检查 *BasicSample.Core* 项目时,我们遇到了 *HistoricalOrderSummary.hbm.xml*,它声明了一个命名查询,用于与存储过程通信以检索报表数据。在 *BasicSample.Data* 项目中,我们找到了具体的 DAO,*HistoricalOrderSummaryDao.cs*。这个 DAO 包含以下代码来调用命名查询,接收结果,并将返回的每一行传递到值对象 `HistoricalOrderSummary` 的构造函数中。关键是对 `SetResultTransformer` 的调用,它告诉 NHibernate 如何将结果转换为 `HistoricalOrderSummary` 的新实例。请查阅 NHibernate 文档以了解有关此转换过程的其他可用选项。

IQuery query = NHibernateSession.GetNamedQuery("GetCustomerOrderHistory")
    .SetString("CustomerID", customerId)
    .SetResultTransformer(
    new NHibernate.Transform.AliasToBeanConstructorResultTransformer(
        typeof (HistoricalOrderSummary).GetConstructors()[0]));

处理 NHibernate 会话

与 NHibernate 集成的核心是 *BasicSample.Data/NHibernateSessionManager.cs*。这个线程安全的、延迟加载的单例执行以下职责:

  • 在首次实例化时构建 `ISession` 工厂。 该类是一个单例,因为构建会话工厂的开销非常大。在本文的前一个版本中,*web.config* 中的一个应用程序设置声明了哪个程序集包含 HBM 文件。这是不必要的。相反,映射属性应该包含在 `hibernate-configuration` 中。例如,*web.config* 在 NHibernate 配置设置中包含了以下声明,让 NHibernate 知道在哪里找到 HBMs:`<mapping assembly="BasicSample.Core" />`。除了比使用应用程序设置更清晰的方法外,这还有助于更好地同时管理多个数据库。
  • 处理发送到特定于上下文的 `ISession` 的命令。 示例包括创建/关闭当前 `ISession`,注册一个 `IInterceptor`(顺便说一下,这需要在事务开始之前完成),以及管理事务。
  • 存储和检索特定上下文的 `ISession`。 有多种方法可以管理 `ISession` 的生命周期;其中一种方法,称为“视图中打开会话”(Open Session in View),稍后介绍。`NHibernateSessionManager` 并不规定*何时*需要创建和销毁 `ISession`,但它规定了 `ISession` 的*存储方式*。在本文的前一个版本中,`CallContext` 是唯一的存储方式。这对于 ASP.NET 应用程序来说是个非常糟糕的主意。修正后的方法在 `HttpContext` 可用时利用它,在不可用时利用 `CallContext`。虽然这迫使你包含对 *System.Web* 的引用,但这使得在 WebForms 和 WinForms 之间无缝移植成为可能。顺便说一下,NUnit 测试的工作方式类似于 WinForms 应用程序,因此也受益于这种可移植性。以下代码摘自 `NHibernateSessionManager`,演示了在适当的上下文之间切换,值得完整展示:
    private ITransaction ThreadTransaction {
        get {
            if (IsInWebContext()) {
                return (ITransaction)HttpContext.Current.
                    Items[TRANSACTION_KEY];
            }
            else {
                return (ITransaction)CallContext.GetData(TRANSACTION_KEY);
            }
        }
        set {
            if (IsInWebContext()) {
                HttpContext.Current.Items[TRANSACTION_KEY] = value;
            }
            else {
                CallContext.SetData(TRANSACTION_KEY, value);
            }
        }
    }
    
    private ISession ThreadSession {
        get {
            if (IsInWebContext()) {
                return (ISession)HttpContext.Current.Items[SESSION_KEY];
            }
            else {
                return (ISession)CallContext.GetData(SESSION_KEY); 
            }
        }
        set {
            if (IsInWebContext()) {
                HttpContext.Current.Items[SESSION_KEY] = value;
            }
            else {
                CallContext.SetData(SESSION_KEY, value);
            }
        }
    }
    
    private bool IsInWebContext() {
        return HttpContext.Current != null;
    }
    
    private const string TRANSACTION_KEY = "CONTEXT_TRANSACTION";
    private const string SESSION_KEY = "CONTEXT_SESSION";

获取会话的基本流程如下:

  1. 客户端代码调用 `NHibernateSessionManager.Instance.GetSession()`。
  2. 如果尚未实例化,此单例对象将构建 `ISession` 工厂,从相应的程序集加载 HBM 映射文件。
  3. `GetSession` 查看是否已有 `ISession` 绑定到适当的上下文。
  4. 如果未找到打开的 NHibernate `ISession`,则会打开一个新的(绑定到可选的 `IInterceptor`)并存回相应的上下文。
  5. `GetSession` 然后返回特定于上下文的 `ISession`。

这个流程,以及 `NHibernateSessionManager` 的其余部分,都紧密遵循《Hibernate in Action》第 8 章——编写 Hibernate 应用程序中所描述的内容。接下来我们将看到会话和/或事务在哪里开始和提交。

用于将所有内容整合在一起的 BasicSample.Web

正如预期的那样,*BasicSample.Web* 项目包含应用程序配置和 ASPX 页面。在这个示例中,代码隐藏页面充当控制器,直接与领域层和数据访问层通信。这*不是*最佳实践,MVC 分离——代码隐藏页面应被视为视图的一部分,纯粹而简单。但就目前而言,它简单,并且很好地用于演示。(稍后讨论的“企业级”示例应用程序,使用模型-视图-表示器展示了一个更好的将业务逻辑从代码隐藏页面中分离出来的例子。Castle MonoRailMaverick.NET 是另外两种将逻辑与视图分离的选项。甚至有传言说一个微软支持的 MVC 框架正在开发中。但我跑题了……)

下面我们来仔细看看 *BasicSample.Web* 中一些比较有趣的部分……

在视图中打开会话 (Open Session in View)

如果你想利用 NHibernate 的延迟加载(你肯定会想的),那么Open-Session-in-View模式是最佳选择。(这里的“Session”指的是 NHibernate 的 `ISession`...而不是 ASP.NET 的 `Session` 对象。)本质上,这个模式建议每个 HTTP 请求打开一个 NHibernate 会话。尽管在 ASP.NET 页面生命周期内的会话管理在理论上很清晰,但可以使用各种实现方法。我采用的方法是创建一个专门的 `IHttpModule` 来处理该模式的细节。除了集中会话管理职责外,这种方法还提供了额外的好处,即我们可以在不向 ASPX 页面中添加任何会话管理代码的情况下实现 Open-Session-in-View 模式。

要了解这是如何实现的,请查看 *BasicSample.Web/App_Code/NHibernateSessionModule.cs*。然后在 *web.config* 中包含以下部分以激活 `IHttpModule`:

<httpModules>
  <add name="NHibernateSessionModule" 
       type="BasicSample.Web.NHibernateSessionModule" />
</httpModules>

`IHttpModule` 在 Web 请求开始时打开一个事务,在请求结束时提交/关闭它。以下是一个修改 `IHttpModule` 的示例,以便将 `IInterceptor` 绑定到会话并包含在事务中:

public void Init(HttpApplication context) {
    context.BeginRequest += 
          new EventHandler(InitNHibernateSession);
    ...
}

private void InitNHibernateSession(object sender, EventArgs e) {
    IInterceptor myNHibernateInterceptor = ...

    // Bind the interceptor to the session.
    // Using open-session-in-view, an interceptor 
    // cannot be bound to an already opened session,
    // so this must be our very first step.
    NHibernateSessionManager.Instance.RegisterInterceptor(
        myNHibernateInterceptor);

    // Encapsulate the already opened session within a transaction
    NHibernateSessionManager.Instance.BeginTransaction();
}

即使不使用,打开/关闭事务的开销也非常小,因为 NHibernate 直到需要时才会真正打开数据库连接。(话虽如此,这种方法仍有改进的空间和潜在的弱点;有关进一步研究的领域,请参见 下一步该怎么走?。)您可能想考虑的其他策略是打开一个与事务无关的会话——嘿,这在 eBay 上都行得通——和/或向 NHibernate 会话注册一个 IInterceptor。(使用 IInterceptor 非常适合审计。有关更多详细信息,请参见 《Hibernate in Action》第 8.3.2 节——审计日志记录。)

web.config 中的 NHibernate 设置

web.config 中有两个关键设置可以优化 NHibernate:hibernate.connection.isolationhibernate.default_schema。默认情况下,NHibernate 使用 IsolationLevel.Unspecified 作为其数据库隔离级别。换句话说,NHibernate 将默认隔离级别的确定工作留给了 ADO.NET 提供程序。如果您使用的提供程序默认隔离级别为 Serializable,这是一种非常严格的隔离级别,对于大多数应用程序场景来说可能过于苛刻。一个更合理的初始设置是 ReadCommitted。使用此设置,“读事务”不会阻止其他事务访问某一行。但是,未提交的“写事务”会阻止所有其他事务访问该行。其他提供程序的默认值包括(请注意,它们可能会随版本而变化):

  • SQL Server 2000 - Read Committed (已提交读)
  • SQL Server 2005 - Read Committed (已提交读)
  • Firebird - Read Committed (已提交读)
  • MySQL 的 InnoDB - Repeatable Read (可重复读)
  • PostgreSQL - Read Committed (已提交读)
  • Oracle - Read Committed (已提交读)

另一个不应忽视的可选设置是 hibernate.default_schema,它很容易被忽略,但可能对查询性能产生重大影响。默认情况下,在预准备的 NHibernate 查询中(例如来自 CreateCriteria 的查询),表名不是完全限定的;例如,是 Customers 而不是 Northwind.dbo.Customers。问题的关键在于,用于执行 NHibernate 查询的存储过程 sp_execsql 无法有效优化查询,除非表名是完全限定的。尽管这只是一个微小的语法差异,但在某些情况下,它可能使查询速度减慢一个数量级。显式设置 hibernate.default_schema 可以在数据密集型页面上带来高达 33% 的整体性能提升。以下是在 web.config 中声明这些设置的示例:

<add key="hibernate.connection.isolation" value="ReadCommitted" />
<add key="hibernate.default_schema" value="Northwind.dbo" />

一个简单的列表、添加和更新表单

这个 Web 项目包含几个网页:

  • Default.aspx:提供应用程序的导航主页。
  • ListSuppliers.aspx:实现用户故事“用户可以查看供应商及其产品的列表”。这是使用自定义集合的一个很好的例子。
  • ListCustomers.aspx:实现用户故事“用户可以查看现有客户的列表”。在企业示例中,此页面通过 Model-View-Presenter (MVP) 模式加载。
  • EditCustomer.aspx:实现用户故事“用户可以查看客户的详细信息”、“用户可以查看客户下过的历史订单列表”、“用户可以查看订单摘要列表”以及“用户可以编辑客户详细信息”。一个页面确实承担了很多功能!企业示例演示了如何使用 MVP 分割这些职责并处理来自视图的事件。
  • AddCustomer.aspx:您能猜到这个页面实现了哪个用户故事吗?

需要注意的重要一点是,后台代码页面通过 DAO 工厂与数据库通信;也就是说,代码并未绑定到数据访问对象的具体实现。这使得交换 DAO 实现和单元测试代码变得更加容易,而无需依赖于活动的数据库。一切就绪后,以下示例展示了检索数据库中所有客户是多么容易:

IDaoFactory daoFactory = new NHibernateDaoFactory();
ICustomerDao customerDao = daoFactory.GetCustomerDao();
IList<Customer> allCustomers = customerDao.GetAll();

在上述代码中,通过 new 关键字获取了对 NHibernateDaoFactory 的具体引用。在生产代码中,正如在架构说明中讨论的那样,这个引用可以(也应该)在运行时使用控制反转 (IoC) 容器(如 Castle Windsor)来注入。企业示例中包含一个使用此工具进行依赖注入的例子。

虽然在您的后台代码或控制器中使用 new 关键字来创建 NHiberanteDaoFactory 是可以接受的,但您的领域对象绝不应直接创建 DAO 依赖。相反,它们的 DAO 依赖应该通过公共的 setter 或构造函数来提供。(IoC 在这里同样能提供帮助。)如前所述,这极大地增强了您使用测试替身 DAO 进行单元测试的能力。例如,以下代码位于 BasicSample.Tests/Data/CustomerDaoTests.cs 中,它检索一个客户并为其提供 DAO 依赖以执行下一步操作:

IDaoFactory daoFactory = new NHibernateDaoFactory();
ICustomerDao customerDao = daoFactory.GetCustomerDao();

Customer customer = customerDao.GetById(Globals.TestCustomer.ID, false);
// Give the customer its DAO dependency via a public setter
customer.OrderDao = daoFactory.GetOrderDao();

使用这种技术,业务层永远不需要直接依赖于数据层。相反,它依赖于在同一层内定义的接口,正如在 BasicSample.Core 项目中定义的那样。

将基础知识扩展到“企业级”解决方案

到目前为止所讨论的内容包括了将 NHibernate 与 ASP.NET 和单元测试集成的基本技术。但尚未充分展示的是如何将这些技术融入一个可扩展、可重用的架构中。“企业级”NHibernate 示例演示了这样一个例子,并将在接下来进行讨论。由于“企业级”一词有很多含义,本文使用这个词来描述任何真实的、由数据库支持的 ASP.NET 应用程序。

要让企业级示例运行起来,除了之前在运行示例应用程序中描述的步骤外,您还需要执行以下步骤:

  1. 将企业级示例解压缩到您选择的文件夹中。
  2. 在 IIS 中创建一个新的虚拟目录。别名应为 EnterpriseNHibernateSample,目录应指向解压应用程序后创建的 EnterpriseSample.Web 文件夹。
  3. 打开 EnterpriseSample.Web/Config/NorthwindNHibernate.config 文件,修改数据库连接字符串,使其连接到 Microsoft SQL Server 上的 Northwind 数据库。
  4. 打开以下文件,并将指向 EnterpriseSample.Web/Config/NorthwindNHibernate.config 的完全限定路径更改为您机器上的正确路径:
    • EnterpriseSample.Web/Web.config
    • EnterpriseSample.Web/Config/CastleComponents.config
    • EnterpriseSample.Tests/App.config
    • EnterpriseSample.Tests/Globals.cs
    我不想在这么多地方设置这个路径,希望能将其集中到仅 Web.configApp.config 中。关于如何改进这一点,请参阅下一步该怎么走?中的其他想法。
  5. 打开您的网页浏览器访问 https:///EnterpriseNHibernateSample/Default.aspx,然后就可以开始运行了!

真实世界架构

虽然最好避免过早泛化,但在项目开发初期就规划一个审慎的架构是极有益的。一个坚实的架构基础易于扩展,能建立清晰的关注点分离,并为使用它的开发人员提供内置的指导。在过度泛化和建立坚实架构之间存在着微妙的平衡。(我在之前的一篇文章中进一步讨论了这种平衡。)尽管没有万能的解决方案,但企业级示例应用程序展示了一个我认为适用于大中型 ASP.NET 项目的坚实架构框架。其总体架构结构,包括具体实现和接口依赖的方向,如下图所示。

Application Architecture

该架构的高层结构、动机和假设在这里有讨论。

超越基础

企业级示例在以下方面对基础示例进行了扩展:

  • AbstractNHibernateDao.csNHibernateSessionManager.csNHibernateSessionModule.csIDao.cs 已被放入一个名为 ProjectBase.Data 的可重用/可扩展的项目中。
  • DesignByContract.cs 已被放入一个名为 ProjectBase.Utils 的可重用/可扩展的项目中。
  • 添加了调试 NHibernate 必不可少的 log4net,并在 web.config 中进行了配置。
  • 增加了一个用于错误日志记录的 IHttpModule
  • NHibernateSessionManager.cs 已被“升级”以支持并发使用多个数据库。相应地,文章 在 NHibernate 中使用多个数据库 中描述的功能已被移植到示例应用程序中。您当然可以通过使用基础示例项目中的代码将其恢复为单数据库支持。
  • 采用了模型-视图-呈现器 (Model-View-Presenter) 设计模式,以将业务逻辑与 ListCustomers.aspxEditCustomer.aspx 分离。(所采用的 MVP 版本与监督控制器 (Supervising Controller) 的定义一致,这是一种 MVP 的特化形式。)相应地,创建了一个名为 EnterpriseSample.Presenters 的新项目来存放视图接口和呈现器。相关的单元测试也已添加到 EnterpriseSample.Tests 中。
  • 添加了一个简单的 Web 服务 GetCustomer.asmx,它会返回由 NHibernate 检索的数据填充的 DTO。请注意,此示例不一定应被视为最佳实践示例。我是一名 Web 服务设计新手,不确定在与 NHibernate 集成方面真正的最佳实践是什么。有关此领域需要进一步研究的说明,请参见下一步该怎么走?。在 GetCustomer.cs 中也包含了一些相关的注释。
  • 集成了 Castle Windsor,用于将 DAO 工厂注入到页面、用户控件和 Web 服务的页面控制器 (Page Controller) 中。请记住,Castle Windsor 的用途远不止于此。

乍一看(甚至再看几眼之后),仔细研究示例代码可能会令人望而生畏。但一旦一切就绪,它将是强大、灵活的,并且对您正在开发的代码的侵入性最小。

接下来该何去何从?

如果您正在阅读一本关于新兴技术的书籍,这一节可能会被称为“需要进一步研究的领域”。我想这个说法在这里同样适用,因为这正是这里所描述的内容。有一些项目可以进行修改以获得更好的灵活性,或者进行扩展以获得更强的可用性。这里有一些想法,可以填补您可能有的任何空闲时间:

  • 可以利用Guidance Automation Toolkit 将这些最佳实践打包成一个可安装的基础项目。
  • 关于 NHibernate 与 Web 服务集成的最佳实践很少。探索这一领域并定义至少是“更好”的实践,将对 NHibernate 社区非常有益。如果您有兴趣尝试一下,Thomas Erl 的《面向服务的架构》是一个很好的起点。
  • 该框架支持随 HTTP 请求开始和结束的事务。更理想的情况是使用特性(attribute)来定义何时应开始事务。事务将在指定方法的末尾提交。使用特性可以避免对 NHibernateSessionManager 类的直接依赖。一个示例特性是 [Transaction],它将被放置在方法的顶部。此外,如果使用多个数据库,该特性将包含某种 ID,指定哪个数据库通信需要事务,例如 [Transaction("Primavera")]。与 Castle Project 的自动事务管理工具集成会是一个很好的起点。
  • 与此相关的是,一些开发者抱怨说,通过 NHibernateSessionModule 使用“视图内打开会话”(Open-Session-in-View)模式会为图片和 CSS 文件等数字资产打开额外的、未使用的事务。在文章的评论中可以找到相关的讨论和候选解决方案。我很有兴趣听到更多关于如何干净利落地解决这个偶然出现问题的建议。(如上一点所述,使用特性来启动事务也可以解决这个问题。)
  • 多数据库实现通过传递每个数据库的配置文件位置来正确识别哪个 ISession 属于哪个数据库。这导致配置文件的位置在 web.config 和 Castle Windsor 配置文件中都出现了重复。我更希望能够消除这种重复,改用一个更简单的 ID 字符串来传递,和/或完全消除将字符串传递给 DAO 构造函数的需求。
  • 使用所提供的框架,无法跨位于不同服务器上的两个数据库管理单个事务。或许可以利用 System.Transactions 命名空间来解决这个问题。
  • 可以为企业级示例创建一个模板化的持续集成配置,包括 CruiseControl.NETNAnt(或 MSBuild)、NDependFxCopSandCastleNDbUnitNStatic,以及 FIT/FitNesse

请随时在下面的评论中、NHibernate 论坛中或在一篇新的 CodeProject 文章中讨论您对这些主题的想法!如果您有兴趣为这些主题之一贡献解决方案,也可以通过 http://devlicio.us/blogs/billy_mccafferty 联系我。

从 NHibernate 1.0x 迁移到 1.2

如果您正从 1.0.x 迁移到 1.2,您肯定会遇到一些迁移问题。关于 API 变化的官方概述可以在NHibernate 1.2 迁移指南中找到。下面列出了一些您应特别注意的项目:

  • 将所有对 <hibernate-mapping xmlns="urn:nhibernate-mapping-2.0"> 的引用更新为 urn:nhibernate-mapping-2.2。这些引用可以在 HBM 和配置文件中找到。
  • 在 1.2 版本中,所有类和集合现在默认都是延迟加载的。因此,如果您在不修改 lazy 属性的情况下运行应用程序,很可能会收到许多“方法 x 应该是虚方法 (virtual)”的错误。要解决这个问题,请为您希望延迟加载的每个类和集合设置 lazy="false"
  • 由于现在原生支持使用泛型,您将不再需要 Ayende 极其有用的 NHibernate.Generics 工具。关于如何从 Ayende 的 NHibernate.Generics 重构的详细示例,可以在我的博客 devlicio.us 上找到。

在完成任何到 NHibernate 1.2 的迁移工作后,最好对每个父/子关系测试以下内容:

  • 通过父对象更新子对象是否仍然有效?
  • 添加到父对象的新子对象的创建是否按预期持久化(或不持久化)到数据库?
  • 从父对象中删除现有子对象,或直接删除子对象,是否能正常工作?
  • 其他 CRUD(增删改查)、级联场景是否已经过测试?

NHibernate/ASP.NET 最佳实践总结

以下是示例应用程序中传达的最佳实践的简要总结:

  • 业务对象应通过接口与数据访问对象通信;即,始终依赖于抽象。
  • 具体的数据访问对象应实现由“客户端”(业务逻辑层)定义的接口。
  • 通过抽象工厂公开数据访问对象,以帮助测试并减少耦合。
  • 将 NHibernate 会话管理的细节排除在表示层和业务逻辑层之外。
  • 使用单元测试类别来轻松关闭依赖于数据库连接的单元测试。
  • web.config 中设置 hibernate.default_schema,为 NHibernate 带来巨大的性能提升!

我希望本文有助于您将最佳实践付诸实施,以充分利用 ASP.NET、NHibernate 和单元测试的优势。我目前在自己的项目中采用这种方法取得了巨大成功,并期待听到您的经验。如果您有任何问题或建议,请随时告诉我。如果您想随时了解我的最新动态,可以随时在这里关注我的最新文章。

文章历史

  • 2006.03.12 - 首次发布。
  • 2006.03.13 - 添加了 BasicSample.Tests,并包含了模拟 DAO 及上文相关讨论。
  • 2006.03.28 - 阐明了默认隔离级别,并补充了对 Model-View-Presenter 的推荐。
  • 2006.04.27 - 修改了 NHibernateSessionManager,将 ISession 存储在 CallContext 而非 HttpContext 中;文章文本也已更新以反映更改。[作者注:这是个坏主意!]
  • 2007.04.02 - 发布了本文的第 1.2 版,包括与 NHibernate 1.2 的兼容性,更加强调测试驱动开发,并更新了建议。
  • 2007.05.01 - 修复了许多重要错误,对 DomainObject 进行了 overhaul,增加了自定义集合的使用,并扩展了 MVP 示例。
© . All rights reserved.