开发Web应用程序是痛苦的。无论是组织与测试AJAX脚本,还是在无状态环境下模拟状态,开发Web应用程序需要在计划和开发的各个阶段中,全神贯注,专心致志。Web开发人员还要面对开发中的诸多挑战,例如对象与关系的不匹配;在纷繁复杂的选项中选择最合适的工具提高开发效率;在项目中应用良好的架构,在保证代码具有可维护性的同时,不至于影响项目的交付。种种问题都使得开发形势变得越发严峻。
一切都在发展之中。不断涌现出的技术与技能虽然正在逐步解决这些开发中的难题,但没有任何一个可以单独扮演银弹的角色。但是通过对各种技术的权衡,精心挑选技术与技能,还是可以在不牺牲质量的前提下,大幅度地提高开发效率与可维护性。本文关注于Web开发的主流发展方向,通过使用S#arp架构,这是基于ASP.NET MVC的一个框架,荟萃了这些技术与技能的精华,从而为客户贡献价值。
赢得关键阶段的技术趋势
如果要用一个词来描述软件开发这个行业,毫无疑问,这个词语就是“变化”。比起那些历史久远的学科如土木工程学,我们这个行业不过是刚刚兴起,还处于萌芽状态。我们正在成长,而成长的烦恼就是行业自身经历的大量变化,而且看起来这样的变化还会持续一段时间。
这种变化可谓屡见不鲜。一个例子是项目管理方法学,由于其走向误区而带来的惨痛经历,它经历了大起大落,从一棵冉冉升起的“明星”,迅速衰落至默默无闻。另一个例子是有关技术的兴衰,长江后浪推前浪,新技术总是后来居上,取代旧的技术。以ASP.NET中的MVP(Model View Presenter)模式为例,该设计模式虽然为ASP.NET引入了更好的可测试性,但却增加了复杂程度。最近,微软引入了ASP.NET MVC用 来取代ASP.NET,它将之前ASP.NET实现的可测试性提升了一个新的台阶。由于要考虑.NET web开发的可测试性,MVP模式总是将控制器逻辑放在ASP.NET页面后面的代码(code behind)中,而ASP.NET MVC则抛弃了MVP的这种做法。这并不是说MVP隐含的原则到现在已经无效,而是随着这项技术的出现,通过使用适当的关注分离,可以更好的简化MVP实 现可测试性的目标。
因此,虽然软件行业仍然变化莫测,但技术发展的特定趋势与设计理念,仍然构成了开发与交付高质量可维护项目的基础。或许,这些理念的实现会随着时间 的推移而发生变化,但理念本身却是成功软件的坚实基础,能够持续地影响着软件开发。下面,我将简要地回顾这些设计理念,它们在关键阶段所获得的成功,已被 开发社区所广泛接受,因而对未来的软件开发产生了深远的影响。
抽象的基础功能
在不久之前,我还在为一个新对象编写CRUD功 能而被搞得焦头烂额。这项工作就像重新粉刷我的房子一样,费力而不讨好。充斥的大量重复代码,也成为了错误的多发地带。从编写存储过程与ADO.NET的 适配器,到测试片断的JavaScript验证代码,我发现我每天都将大把精力投入到了这些基础功能细节的实现上,以至于在我写完这些代码之后,巴不得赶 尽将它们抛诸脑外。
范式的转换在过去十年间已经发展成熟,其中关于基础功能的实现细节,属于最底层的工作,最好能够交给专门的工具来完成。面临的挑战是需要为这项工作找到合适的工具,既要允许软件忽略这些实现细节,又要保证基础功能可以使用。NHibernate是 这类工具的典范。它能够处理普通的.NET对象与关系数据库之间的持久化。采用这种方式,它就能够让对象自身完全忽略如何实现持久化,又能够解决对象与关 系之间的不匹配。而且,它不需要编写任何一行ADO.NET代码或者存储过程。NHibernate是一个非常棒的工具,更重要的是它实现了一个远大目 标,通过提供一个固定的解决方案,避免乏味而又琐碎的基础功能实现。要知道,这些实现在过去可是开发活动的重要组成部分。
随着时间的推移,软件行业引入了大量成熟的工具与技术,对这些乏味无趣的基础设施构建进行抽象,然后在开发完成后再进行设置。例如,随着NHibernate的逐渐成熟,那些附加的插件,例如Fluent NHibernate具有自动映射的能力,完全能够减轻管理数据访问的负担。这一现象印证了Douglas Hofstandter著作Gödel, Escher, Bach中提到的预言,即:采用合理的抽象是软件开发的发展之道,需大力提倡。
松散耦合
遗留软件系统最常见的毒瘤是紧耦合。(我在这儿使用的“遗留”一词,指的是那些其他开发人员强加给你的破烂软件,或者是你自己在很多年前开发的破旧 系统)紧耦合的例子多不胜数,例如两个对象之间存在双向依赖;具体依赖于服务的对象如数据访问对象;还有在单元测试中,如果依赖的服务断开或不可用,就无 法测试服务的行为。紧耦合会导致脆弱的代码,使代码难以测试。修改紧耦合的代码,会让开发人员视如畏途,不战而降,甚至逃之夭夭。毫无疑问,一个成功软件 的关键就是松散耦合。
维基百科将松散耦合解释为“两个或多个系统之间的弹性关系。”因此,松散耦合带来的好处就是你能够修改编程关系的任何一方,却不会影响另外一方。举例说明,在我看来,没有哪个设计模式能够比接口隔离,别名为依赖倒置(不要与依赖注入混为一谈)更能够说明松散耦合的思想了。该技术通常会用于将数据访问层从领域层中分离。
例如,在一个MVC应用程序中,控制器或者应用服务需 要与数据访问的仓储(repository)对象通信,以获取数据库中的项目个数。(本例中的仓储指的是“服务”)要满足这一需求,最简单的办法是让控制 器建立一个新的仓储对象的实例。换句话说,控制器创建了一个指向仓储对象的具体依赖;例如通过new关键字。遗憾的是,这种方法会导致紧耦合的诸多不良后 果:
- 没有真实的数据库支持对仓储的查询,就很难对控制器进行单元测试。要求真实的数据库,会导致单元测试变得脆弱,一旦数据被前 一次运行的测试所修改,就会带来问题。在测试控制器逻辑时,我们应集中关注验证控制器的行为,而不是它所依赖的仓储对象能否成功地与数据库进行通信。此 外,测试一个“真实的服务”,例如在刚才提及的例子中,与真实数据库通信的仓储服务,会使单元测试的运行变得像老牛拉破车一般的缓慢;结果会让开发人员停 止运行单元测试,从而损害了代码的质量。
- 如果不修改实例化服务的控制器,就难以替换服务(仓储服务)的实现细节。假如你希 望将仓储从ADO.NET改为支持Web Service,由于包含了一个与前者之间的具体依赖,如果不对实例化以及使用它的控制器进行大量修改,就无法轻易地将其替换为后者。在多数情形下,一旦 变化发生,就会导致霰弹式修改——这是说明这一问题的另一种坏味道。
- 无法确定控制器实际拥有的服务依赖的个数。换句话说,如果控制器正在调用一些服务依赖的创建功能,则开发人员很难确定其逻辑边界,或职责范围。作为替代,控制器的依赖可以通过其构造函数传入,这样就能够使开发人员更容易理解控制器的整个职责范围。
替代方案是把服务依赖作为控制器构造函数的参数。这种做法带来的关键改善就是控制器只需要了解服务依赖的接口,而不是具体的实现。为了进一步说明,可以比较下列两段在ASP.NET MVC应用程序中的控制器代码。
如下的控制器直接创建了它的服务依赖CustomerRepository,也就相应拥有了一个具体的依赖:
public class CustomerController {
public CustomerController() {}
public ActionResult Index() {
CustomerRepository customerRepository = new CustomerRepository();
return View(customerRepository.GetAll());
}
}
相反,如下的控制器则将服务依赖作为接口参数传递给它的构造函数。也就是它与服务依赖形成了松耦合关系。
public class CustomerController {
public CustomerController(ICustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
public ActionResult Index() {
return View(customerRepository.GetAll());
}
private ICustomerRepository customerRepository;
}
对比紧耦合的缺陷,这种清晰的分离方式带来了诸多好处:
- 领域层对于如何创建仓储对象以及它的实现细节一无所知,它只需调用公开暴露的接口。因此,它不需要修改控制器自身,就能很容易地更换数据访问的实现细节(例如从ADO.NET切换到Web Service)。这基于一个假定,两者实现的接口是相同的。
- 依赖于接口而不是具体实现,在单元测试时更容易将仓储的测试替身(test double)对象注入。这就保证了单元测试能够快速运行,避免维护数据库中的测试数据,从而将测试的注意力放到控制器的行为上,而不是与数据库的集成。
这里并没有详细阐述依赖注入必须支持一个分离的接口和其它松耦合技术。更详细的讨论请参考文章依赖注入实现松耦合。(注意,在这篇文章中描述的“仿(mock)”对象实际上是“桩(stub)”对象。它与“测试替身(test double)”的术语来源于Martin Fowler的Mocks不是Stubs)。此外,将服务依赖重构到接口隔离一文详细地介绍了如何将设计转为接口隔离设计模式。
测试驱动开发
简而言之,测试驱动开发(TDD)能够有效交付高质量、可维护的软件,全面地简化设计。作为一项开发技术,测试驱动开发绝不会是昙花一现;它受到了越来越多的吹捧,是能够决定软件开发成败的关键,推动了我们这个行业逐渐走向成熟。
TDD隐含的基本思想是带着疑问开始软件开发,在开发过程中要不停地询问系统。例如,倘若你正在开发一个银行系统,可能需要询问系统,它有能力成功 处理客户的存款业务吗?关键之处在于要在实现行为细节之前提出问题。随之而来的好处就是在编写系统之前,它能够让开发者将注意力放到系统要求的行为上。
若要遵循测试驱动开发的指导原则,需按照如下步骤进行编码:
- 假设目标对象以及要求的行为已经存在,编写测试。
- 编译解决方案,此时会看到编译失败。
- 编写足够的代码使程序通过编译。
- 运行单元测试,测试失败。
- 编写足够的代码使得单元测试通过。
- 若有必要,进行重构!
测试驱动开发虽然没有什么变化,但在日常的开发活动中对它的应用却还在发展之中。例如,测试驱动开发最近的发展方向是行为驱动开发;该方法试图弥补“代码编写的技术语言与商务运用中的领域语言之间”的鸿沟。换句话说,行为驱动开发将TDD与后面介绍的领域驱动设计(或者是与你喜欢的其它方法)结合了起来。
领域驱动设计
从近日的发展趋势来看,领域驱动设计(DDD)可 谓风光无限。该方法将软件开发的注意力放在了领域与领域逻辑上,而不是技术以及支持技术方案的关系数据库模型。和行为驱动设计一样,DDD提出了大量的技 术与模式,以改善客户与开发团队的协作关系,在描述语言上达成一致。理想情况下,客户应该能够理解DDD应用程序的领域层,而编码逻辑也能够完整地反映客 户的业务逻辑。
我试验了各种方法,得出的结论是:领域驱动设计是早期编程方法的一种自然进化,例如针对数据库数据进行模型驱动开发,其对应的模型可以视为应用程序的核心,要做的工作就是操作数据。(Castle ActiveRecord和ADO.NET实体框架都是非常稳定和优秀的模型驱动设计工具。)相反,在DDD中,数据库则被视为一种必要的基础设施细节,能够支持领域及相关逻辑。事实上,正所谓“万法皆空”,在DDD中本来就没有数据库。虽然,领域驱动设计需要数据库,但关键在于领域对于持久化机制(实现数据存储与获取的机制)应该是一无所知的。
而且,领域驱动设计不仅仅是将数据持久化从领域中分离出来。它的主要目的是让领域对象自身拥有领域行为。例如,我们不能分离出 CustomerAccountLogic类,并由它来决定CustomerAccount是否取决于付款日期,而是要求CustomerAccount 自身维护这一信息。采用这种方式,领域的行为是与模型自身是合二为一的。
以上介绍仅仅是运用DDD进行软件开发技术的冰山一角。若要了解领域驱动设计的更多信息,可以阅读文档领域驱动设计快速入门,它是Eric Evans经典著作《领域驱动设计》的简明摘要。
在S#arp架构中融入这些思想
每个项目都其独特的需求,也没有哪个框架能够尽善尽美,对于开发Web应用程序而言,这是机会与挑战共存的局面。但是在面临众多选择时,开发人员很 难做出判断,哪些工具和技术适合给定的项目,并让开发中通常遇到的挑战能够迎刃而解。例如,如果你正在寻觅一个.NET依赖注入工具——或者说控制反转容器——可以选择Spring.NET(它远远不只是提供IoC功能)、Unity、Castle Windsor、Ninject、StructureMap,以及更多。这还只是对于IoC的选择!让事情变得更糟糕的是,我们需要明智地规划,恰如其分地在架构中权衡各种工具与技术,这是一项极具挑战的工作。
在.NET Web开发中,至少在当前还缺乏一个通用的架构与基础,可以在程序开发中对各种技术与技巧进行最优组合,根据已经被证实的实践来选择最近的技术,以及由开源社区开发的优质工具。S#arp架构正是基于这样的前提应运而生。开源的S#arp架构试图利用本文介绍的业已证明行之有效的实践,谨慎地选择工具以提高开发效率,保证系统高质高量,以及良好的可维护性。
S#arp架构使用的工具与技术如下所示:
- 接口隔离模式,它结合依赖注入模式从数据访问层中移除对领域和控制逻辑的依赖;
- 仓储模式,根据单一职责原则,利用分散的类对数据访问进行封装;
- 模型-视图-控制器模式,通过ASP.NET MVC实现,在视图与控制器逻辑之间引入了清晰的关注点分离;
- NHibernate以及它的扩展Fluent NHibernate,不再需要开发和维护底层数据存储与获取的代码,保证领域对持久化机制一无所知;
- 使用了Castle Windsor默认提供的通用服务定位器,通过开发人员首选的IoC容器实现松散耦合;
- 内存数据库SQLite用来运行行为驱动测试,从而避免与持久数据库的集成;
- Visual Studio模板和T4工具箱,为每个领域对象生成项目的基础设施以及通用的CRUD脚手架,免除了枯燥乏味的编码工作。
选择的这些技术与工具表明,虽然软件开发没有奇妙的银弹,但选择一个稳定的开发实践,并结合适当的工具,就能带来巨大的价值。
领域驱动架构将它们串联在一起
我认为,被封装到S#arp架构中的关键思想,就是颠倒领域与数据访问层之间关系的一种技术。在通常的应用架构中,尤其是与微软推荐的架构紧密相关的,其依赖关系都是从表示层开始自上而下,依赖于业务层,最后依赖于数据层。虽然这是对实现细节的过度简化,但它通常会建议将数据层作为最底层,被其他层依赖;这正是根据设计进行模型驱动。
虽然模型驱动方式自有其优势,也适用于众多解决方案,但却不符合本文提出的领域驱动的目标。让领域直接依赖于数据层,会造成另一种弊端,就是引入领 域对象与数据访问代码之间的双向依赖。我的前任教授Lang博士说道,只有不断犯错误,最后才能成为这个领域的专家。我正在努力成为一名专家,我已经认识 到领域对象与数据访问代码之间存在的双向依赖,它们正是麻烦的渊薮。(这个深刻教训使我在成为专家的道路上能够登堂入室。)
那么,我们该如何冲破这重重迷雾,让设计清晰地呈现这些思想呢?解决之道就是明确地分离各种应用关注点,颠倒领域与数据访问层之间的关系,也就是在 领域层定义分离的接口,让数据访问层进行实现。采用这种方式,则应用程序的所有层都只依赖于数据访问(或其它外部服务)层的接口,从而保证对实现细节的一 无所知。这会让设计变得松散耦合,更易于进行单元测试,而在项目开发的维护阶段,系统会变得更加地稳定。
下图演示了S#arp架构所主张的体系架构,使用接口隔离模式反转了传统的领域与数据访问层之间的依赖关系。每个box表示一个单独的物理程序集,箭头则表示了依赖关系及其依赖方向。
注意,图中的数据层定义了仓储的具体实现细节,它依赖于核心的领域层。在核心层中,除了定义领域模型和逻辑,还定义了仓储接口,该接口可以被其他各 层所调用,例如应用服务层会与仓储通信,这就保证了它独立于其实现细节。某些建议认为表示层(在图上方的YourProject.Web)不应直接依赖于 领域层。作为替代,可以引入数据传输对象(DTO),从而在数据传递给视图展现时,更好地将领域层从表示层中分离。
怎么做?
我们工作的底线是,软件交付专家们必须及时交付解决方案,且要符合客户的需求,并具有高质量和可维护性。很多实践都经过了千锤百炼,所谓“前人栽 树,后人乘凉”,在个人的应用程序中,我们不必重新创造,只需求助于经过验证而又真实的设计模式,以及基本工具的实现,就能够解决开发中的常见问题,例如 数据持久化和单元测试。真正的挑战在于要合理地制定计划,事先做出权衡,既要提高开发效率,又不能扼杀我们的能力,企图以创新的方式去迎合不切实际的需求 是不可取的。我希望本文所描述的技术与工具,以及S#arp架构对它们的组合,足以说明,虽然条条道路都能够通罗马,然而前车之鉴,若要避免重蹈覆辙,就必须从一开始就要汲取经验教训,并要有足够的智慧来赢得一个稳定的开端。
了解更多信息
S#arp架构项目已经开发了接近一年的时间,它为快速开发稳定的领域驱动程序提供了简单但又强大的架构基础。你可以从http://code.google.com/p/sharp-architecture下载S#arp架构的RC版本。1.0的GA版则与ASP.NET MVC 1.0密切相关。S#arp架构的论坛欢迎大家踊跃发言,畅谈自己的体验。
关于作者
若要提到编写优美的软件,那么Billy McCafferty可以说是经验老到,久经沙场,偏生他又一味追求编程的罗曼蒂克,以至于无可救药。Billy目前身兼两职,一方面他负责管理一家小规模的培训与咨询公司Codai(很快会推出新的网站),同时又在Parsons Brinckerhoff带领开发人员与架构师团队。在发布S#arp架构1.0版本之后,Billy的生活又将重回正规,在不久的ALT.NET以及其他开发大会上,你能够见到他的身影。