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

如何避免以打字的速度生成遗留代码

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.88/5 (85投票s)

2015年3月2日

CPOL

7分钟阅读

viewsIcon

80855

本文提供了一种方法,说明如何通过采用正确的架构和单元测试来避免以打字的速度编写遗留代码。

引言

作为一名企业级软件开发人员,您一直在与编写遗留代码作斗争——即不再值得维护或支持的代码。您一直在努力避免反复重写代码,却总抱着微弱的希望,认为下一次就能写对。

遗留代码的特征包括但不限于糟糕的设计和架构,或者依赖于过时的框架或第三方组件。以下是一些您可能认识到的典型例子:

您和您的团队开发了一个漂亮的、功能丰富的 Windows 应用程序。之后,您意识到真正需要的是一个浏览器或移动应用程序。那时您才意识到,要为您的应用程序提供替代 UI 会付出巨大的努力,因为您将过多的领域功能嵌入到了 UI 本身。

另一种情况可能是,您构建了一个与特定 ORM(如 NHibernate 或 Entity Framework)深度集成,或者高度依赖于某个特定 RDBMS 的后端。有一次,您想更改后端策略,以避免使用 ORM 而采用基于文件的持久化,但那时您才发现几乎不可能,因为您的领域功能和数据层紧密耦合。

在上述两种情况下,您都在以打字的速度编写遗留代码。

然而,仍有希望。通过采用一些简单的技术和原则,您可以永远改变这种注定失败的模式。

架构的演变

接下来,我将描述一个标准的企业级软件开发人员典型的架构演变中的三个阶段。几乎所有开发人员都能达到第二阶段,但关键在于要完全达到第三阶段,最终才能成为一个架构大师。

An evolution into a Nija architect

阶段 1 - 做错

大多数开发人员都听说过分层架构,所以第一次尝试架构时通常会是这样的:两层,前后端功能责任分离。

2-layered architecture diagram. Frontend-Backend

到目前为止还不错,但很快您就会发现,应用程序的领域逻辑纠缠在依赖于平台的 UI 和后端中,这是一个巨大的问题。

阶段 2 - 进步

因此,下一次尝试是引入一个中间层——领域层——包含应用程序真正的业务功能。

3-layered diagram. Frontend-Domain-Backend

这种架构看起来结构良好且解耦,但实际上并非如此。问题在于红色的依赖箭头,它表明领域层与后端存在硬编码的依赖——通常是因为您在领域层中使用 `new` 关键字(C# 或 Java)创建了后端类的实例。领域层和后端紧密耦合。这有许多缺点:

  • 领域层的功能无法在孤立的另一个上下文中重用。您必须一同携带其依赖项——后端。
  • 领域层无法孤立地进行单元测试。您必须引入依赖项——后端。
  • 后端的一个实现(例如,使用 RDBMS 进行持久化)不能轻易地被另一个实现(例如,使用文件持久化)替换。

所有这些缺点都极大地缩短了领域层的潜在生命周期。这就是为什么您在以打字的速度编写遗留代码。

阶段 3 - 做对

您需要做的事情其实很简单。您必须将那个红色依赖箭头的方向反转。这是一个微妙的差异,但却带来了天壤之别。

3-layered architecture diagram. Using Dependency Inversion Principle. Frontend-Domain-Backend

这种架构遵循依赖倒置原则(DIP)——面向对象设计最重要的原则之一。关键在于,一旦建立了这种架构——一旦依赖箭头的方向反转——领域层就大大增加了其潜在生命周期。UI 的需求和趋势可能会从 Windows 切换到浏览器或移动设备,您偏好的持久化机制也可能从基于 RDBMS 变为基于文件,但现在这些都可以相对轻松地进行交换,而无需修改领域层。因为此时,前端和后端都与领域层解耦。因此,领域层变成了一个代码库,理论上您永远不必替换它——至少在您的业务领域和整体编程框架保持不变的情况下。现在,您正在有效地对抗遗留代码。

顺便说一句,我给您一个关于如何在实践中实现 DIP 的简单示例。

也许您在领域层有一个产品服务,可以对后端定义的存储库中的产品执行 CRUD 操作。这通常会导致如下所示的依赖图,依赖箭头指向错误的方向。

Dependency diagram 1

这是因为在产品服务中的某个地方,您会“new”一个对产品存储库的依赖。

var repository = new ProductRepository();

要使用 DIP 反转依赖方向,您必须在领域层中引入一个产品存储库的抽象,形式为一个 `IProductRepository` 接口,并让产品存储库成为该接口的实现。

Dependency injection diagram 2

现在,您不再在产品服务中“new”一个产品存储库的实例,而是通过构造函数参数将存储库注入服务。

private readonly IProductRepository _repository;
 
public ProductService(IProductRepository repository)
{
    _repository = repository;
}

这就是所谓的依赖注入(DI)。我之前在一篇名为“先思考业务”的博文中更详细地解释过这一点。

一旦建立了正确的整体架构,与遗留代码斗争的目标就应该很清楚了:将尽可能多的功能移入领域层。让前端和后端层变小,让领域层变得肥胖。

3-layered architecture diagram. Fat domain layer

这种架构一个非常便利的副产品是,它使得对领域功能进行单元测试变得容易。由于领域层的解耦特性,以及其所有依赖项都由抽象(如 `interface` 或 `abstract base` 类)表示的事实,因此很容易创建这些抽象的模拟对象,并在建立单元测试夹具时使用它们。因此,用单元测试来保护整个领域层“易如反掌”。您应该争取 100% 的单元测试覆盖率——让您的领域层极其健壮、坚如磐石,这反过来会增加领域层的生命周期。

您可能开始意识到,不仅是传统的前端或后端,所有其他组件——包括单元测试,或者例如基于 HTTP 的 Web API——都应该充当领域层的消费者。因此,将架构描绘成洋葱层是有意义的。

onion layer architecture diagram

外层组件消费领域库代码——要么通过提供领域抽象(接口或基类)的具体实现,要么作为领域功能的直接消费者(领域模型和业务逻辑)。

但是,请记住:耦合的方向始终是朝向中心——朝向领域层。

此时,这一切可能看起来有些理论化,而且……嗯,有点抽象。然而,在实践中这并不难做到。在我另一篇CodeProject 文章中,我描述并提供了一些符合本文所有原则的示例代码。示例代码很简单,但却非常接近实际生产代码。

摘要

作为一名企业级软件开发人员,您一直在与避免以打字的速度编写遗留代码作斗争。要取得胜利,请执行以下操作:

  • 确保所有依赖箭头都指向中心且独立的领域层,方法是应用依赖倒置原则(DIP)和依赖注入(DI)。
  • 通过将尽可能多的功能移入领域层来不断滋养它。让领域层变得又大又重,同时缩小外层。
  • 用单元测试覆盖领域层的每一个功能。

遵循这些简单的规则,一切都会水到渠成。您编写的代码的潜在生命周期将比以前大大延长,因为:

  • 领域层功能可以在许多不同的上下文中重用。
  • 领域层可以通过 100% 的单元测试覆盖率变得健壮且坚如磐石。
  • 领域层抽象的实现(例如,持久化机制)可以轻松地被替代实现替换。
  • 领域层易于维护。
© . All rights reserved.