2019 年 6 月

第 34 卷,第 6 期

[模式和实施方案]

适合 ASP.NET Core 的超级-DRY开发

作者 Thomas Hansen

DRY 是那些非常重要的软件体系结构缩写之一。它的意思是“不要自我重复”,并向维护旧源代码项目的任何用户阐明了一个重要原则。也就是说,如果你在代码中自我重复,会发现每个 bug 修复和功能更新都会重复你的修改。

代码重复降低了项目的可维护性,并使应用更改变得更加困难。重复次数越多,最终得到的混乱不堪的代码就越多。另一方面,如果避免重复,最终将获得更易于维护和修复 bug 的项目,并且你将成为一名更快乐、更高效的软件开发人员。简而言之,编写 DRY 代码可以帮助创建优秀的代码。

一旦开始以 DRY 方式思考,就可以将此重要体系结构原则带到一个新层次,在该层次上,你会感觉项目从一开始便奇迹般地增长,毫不夸张地说,不需要付出任何精力来创建功能。对于缺乏经验的人来说,代码似乎是通过“超级 DRY”机制凭空出现的。 优秀的代码几乎总是很小,但出色的代码更小。

在本文中,我将介绍超级 DRY 开发的神奇之处,以及多年来我使用的一些技巧,它们可以帮助你轻松创建 ASP.NET Core Web API。本文中的所有内容都基于通用解决方案和 DRY 代码的概念,并且只使用来自我们行业的最佳做法。但首先是一些背景理论。

CRUD、HTTP REST 和 SQL

Create、Read、Update 和 Delete (CRUD) 是大多数数据模型的基础行为。在大多数情况下,数据实体类型需要这四个操作,事实上 HTTP 和 SQL 都是围绕它们生成的。HTTP POST 用于创建项目,HTTP GET 用于读取项目,HTTP PUT 用于更新项目,HTTP DELETE 用于删除项目。SQL 同样围绕 CRUD 发展,包括 insert、select、update 和 delete。一旦对此进行一些思考,就会发现它基本上都是关于 CRUD,假设你不想“全力以赴”并实现 CQRS 体系结构。

这样,你就有了必要的语言机制来讨论 HTTP 谓词,它们可以从客户端的 HTTP 层通过 C# 代码一直传播到关系数据库。现在,你需要的是一种通用方法来通过层实现这些想法。并且你希望通过出色的体系结构基础在不自我重复的情况下完成此操作。下面我们就开始。

首先,在 github.com/polterguy/magic/releases 下载代码。解压缩文件并在 Visual Studio 中打开 magic.sln。启动调试器,并注意在 Swagger UI 中已经有五个 HTTP REST 终结点。这些 HTTP 终结点来自何处?让我们来看一下代码,因为这个问题的答案可能会让你大吃一惊。

你看,没有代码!

当你开始浏览代码时,首先会注意到 ASP.NET Core Web 项目本身实际上是空的。这可能是由于 ASP.NET Core 功能,该功能允许你动态地包含控制器。如果想了解它背后的内部机制,可以查看 Startup.cs 文件。大体而言,它是从文件夹的所有程序集中动态地将每个控制器添加到 AppDomain。这个简单的想法让你可以重用控制器,并在编写解决方案时以模块化的方式进行思考。跨多个项目重用控制器的能力是成为超级 DRY 实践者的第一步。

打开 web/controller/magic.todo.web.controller,然后查看 TodoController.cs 文件。你会发现它是空的。那么这五个 HTTP REST 终结点来自何处?答案是通过面向对象的编程 (OOP) 和 C# 泛型机制。TodoController 类继承自 CrudController,传递视图模型及其数据库模型。此外,它还使用依赖关系注入来创建 ITodoService 实例,并将其移交给 CrudController 基类。

因为 ITodoService 接口从 ICrudService 继承了正确的泛型类,所以 CrudController 基类会乐于接受你的服务实例。此外,此时它已经可以多态地使用服务,就像它是一个简单的 ICrudService 一样,当然 ICrudService 是一个带有参数化类型的泛型接口。这提供了对 CrudController 中五个通用定义的服务方法的访问。要理解其含义,请认识到,使用下面的简单代码,你实际上已经创建了所需的所有 CRUD 操作,并将它们从 HTTP REST 层通过服务层传播到域类层次结构,最后到达关系数据库层。下面是控制器终结点的整个代码:

[Route("api/todo")]
public class TodoController : CrudController<www.Todo, db.Todo>
{
  public TodoController(ITodoService service)
    : base(service)
  { }
}

此代码提供五个 HTTP REST 终结点,允许你创建、读取、更新、删除和计数数据库项,这简直是奇迹。整个代码均“已声明”,并且不包含任何一行功能。当然,代码本身不会生成,而且大部分工作都是在幕后完成的,但此处的代码已经成为“超级 DRY”。 使用更高级别的抽象具有真正的优势。一个贴切的例子是 C# if-then 语句和基础程序集语言代码之间的关系。我所概述的方法只是比硬编码控制器代码更高层次的抽象。

在本例中,www.Code 类型是视图模型,db.Todo 类型是数据库模型,而 ITodoService 是服务实现。只需向基类暗示你想要持久化的类型,可以说你已经完成了你的工作。服务层同样是空的。可以在此处看到它的整个代码:

public class TodoService : CrudService<Todo>, ITodoService
{
  public TodoService([Named("default")] ISession session)
    : base(session, LogManager.GetLogger(typeof(TodoService)))
  { }
}

零方法、零属性、零字段,但仍然有一个完整的 TODO 项服务层。事实上,甚至服务接口也是空的。下面显示了服务接口的整个代码:

public interface ITodoService : ICrudService<Todo>
{ }

同样为空!不过,sim salabim, abra kadabra,并且你有一个完整的 TODO HTTP REST Web API 应用程序。如果打开数据库模型,将看到以下内容:

public class Todo : Model
{
  public virtual string Header { get; set; }
  public virtual string Description { get; set; }
  public virtual bool Done { get; set; }
}

同样,这里什么也没有,只有几个虚属性和一个基类。但是,你仍然能够将类型持久化到数据库中。数据库和域类型之间的实际映射发生在 magic.todo 项目中的 TodoMap.cs 类中。这里,你可以看到整个类:

public class TodoMap : ClassMap<Todo>
{
  public TodoMap()
  {
    Table("todos");
    Id(x => x.Id);
    Map(x => x.Header).Not.Nullable().Length(256);
    Map(x => x.Description).Not.Nullable().Length(4096);
    Map(x => x.Done).Not.Nullable();
  }
}

此代码指示 ORM 库使用 todos 表,其中 Id 属性作为主键,并为其余列/属性设置几个附加属性。注意,当你启动此项目时,甚至没有数据库。这是因为如果数据库表不存在,NHibernate 会自动创建它们。由于 Magic 默认使用 SQLite,它甚至不需要连接字符串。它将自动在相对文件路径上创建基于文件的 SQLite 数据库,除非你在 appsettings.config 中覆盖其连接设置以使用 MySQL 或 MSSQL。

信不信由你,你的解决方案已经透明地支持几乎所有你能想到的关系数据库。实际上,为了使此操作生效,你需要添加的惟一一行代码可以在 magic.todo.services 项目中找到,它位于 ConfigureNinject 类中,只是绑定在服务接口和服务实现之间。因此可以说,添加一行代码,最终将得到整个应用程序。下面是用来创建 TODO 应用程序的唯一实际“代码”行:

public class ConfigureNinject : IConfigureNinject
{
  public void Configure(IKernel kernel, Configuration configuration)
  {
    // Warning, this is a line of C# code!
    kernel.Bind<ITodoService>().To<TodoService>();
  }
}

通过对 OOP、泛型和 DRY 原则的智能运用,我们已经成为超级 DRY 魔术师。因此,问题来了:如何使用此方法使代码变得更好?

回答是:从数据库模型开始,创建你自己的模型类,这可以通过在模型文件夹中添加一个新项目来完成,也可以通过向现有 magic.todo.model 项目添加一个新类来完成。然后在协定文件夹中创建服务接口。现在在服务文件夹中实现服务,并创建视图模型和控制器。确保在服务接口和服务实现之间进行绑定。然后,如果你选择创建新项目,必须确保 ASP.NET Core 通过在 magic.back 项目中添加对它的引用来加载程序集。注意,只有服务项目和控制器需要被后端引用。

如果选择使用现有项目,最后一部分甚至是不必要的。只需在服务实现和服务接口之间绑定一行实际代码,就可以创建一个完整的 ASP.NET Core Web API 解决方案。如果你问我,我会说这是一行强大的代码。可以访问 youtu.be/M3uKdPAvS1I 在我的“ASP.NET Core 超级 DRY Magic”视频中看到早期版本代码的整个操作过程。

然后想象一下,当你意识到可以为此代码搭建基架并根据数据库架构自动生成它时会出现什么情况。此时,计算机基架软件系统正在编写代码,在此过程中生成一个完全有效的域驱动设计 (DDD) 体系结构。

没有代码,没有 Bug,没有问题

大多数基架框架都应用快捷方式,或者阻止扩展和修改其生成的代码,这样就无法在实际应用程序中使用它们。就 Magic 而言,这根本不算缺点。它为你创建一个服务层,并使用依赖关系注入将服务接口注入到控制器。它还为你生成完全有效的 DDD 模式。在创建初始代码之后,解决方案的每个部分都可以根据需要进行扩展和修改。项目完全验证了 SOLID 中的每个字母。

例如,在我自己的解决方案中,我的服务有一个 POP3 服务器提取程序线程,它是为 EmailAccount 域模型类型声明的。此 POP3 服务在后台线程上运行,将电子邮件从我的 POP3 服务器存储到数据库中。删除电子邮件时,我希望确保还以物理方式删除了存储中的附件,如果用户删除了一个 EmailAccount,我显然要删除它的关联电子邮件。

图 1 中的代码显示了我如何重写 EmailAccount 的删除操作,该操作还应删除所有电子邮件和附件。对于记录,它使用 Hibernate 查询语言 (HQL) 与数据库进行通信。这确保 NHibernate 将自动创建正确的 SQL 语法,具体取决于它以物理方式连接到哪个数据库。

图 1 重写 EmailAccount 删除

public sealed class EmailAccountService : CrudService<EmailAccount>,
  IEmailAccountService
{
  public EmailAccountService(ISession session)
    : base(session, LogManager.GetLogger(typeof(EmailAccountService)))
  { }
  public override void Delete(Guid id)
  {
    var attachments = Session.CreateQuery(
      "select Path from EmailAttachment where Email.EmailAccount.Id = :id");
    attachments.SetParameter("id", id);
    foreach (var idx in attachments.Enumerable<string>())
    {
      if (File.Exists(idx))
        File.Delete(idx);
    }
    var deleteEmails = Session.CreateQuery(
      "delete from Email where EmailAccount.Id = :id");
    deleteEmails.SetParameter("id", id);
    deleteEmails.ExecuteUpdate();
    base.Delete(id);
  }
}

算算看

一旦你开始围绕这些概念进行哲学思考,灵感就会来袭。例如,想象一个围绕 Magic 构建的基架框架。从数学角度看,如果你有一个包含 100 个表的数据库,每个表平均有 10 个列,你会发现以总代码行数计算的成本会很快增加。例如,要将所有这些表封装到 HTTP REST API,每个服务接口需要 7 行代码,每个服务的每个表需要 14 行代码,每个控制器的每个表则需要 19 行代码。图 2**** 运行所涉及的元素和所需的代码行。

图 2 将代码中的成本相加

组件 合约 平均代码行数 总代码行数
服务接口 100 7 700
服务 100 14 1,400
控制器 100 19 1,900
服务接口和实现 100 1 100
视图模型 100 17 1,700
数据库模型 100 17 1,700
数据库映射 100 20 2,000
总代码行数: 9,500

 

在介绍和操作完毕后,你将看到 9,500 行代码。如果生成一个能够提取现有数据­库架构的元服务,那么很明显,你可以使用基架来生成此代码,可以说,这完全避免了任何编码,但仍然生成 9,500 行架构完善的代码,易于扩展,使用所有相关的设计模式和最佳做法。只需两秒的基架搭建,计算机便完成了 80% 的工作。

现在所要做的就是遍历基架搭建过程的结果,并重写服务的方法和域类型的控制器,无论出于什么原因,都需要对此特别注意。你已经完成了 Web API。因为控制器终结点都具有完全相同的结构,所以在客户端层复制此基架搭建过程就像读取 Swagger 生成的 API JSON 声明文件一样简单。这使你能够为 Angular 或 React 等创建服务层。所有这些都是因为代码和 Web API 具有可预测的结构,基于泛化原则和避免重复。

从这个角度来看,你已经成功创建了一个 HTTP REST Web API 项目,其复杂性可能是开源 Sugar CRM 项目的两倍,并且你在几秒钟内完成了 80% 的工作。你简化了基于组件标准化和结构重用的软件工厂装配线,同时使所有项目的代码更易于阅读和维护。需要修改和特殊行为的部分甚至可以在下一个项目中重用,这要感谢将控制器终结点和服务动态加载到 Web API 中的方式,而不需要任何依赖项。

如果你在一家咨询公司工作,可能每年都会启动几个具有类似需求类型的新项目,其中需要解决每个新项目的共性。了解了客户端需求和一些初始实现之后,便可以使用超级 DRY 方法在几秒钟内完成整个项目。当然,通过标识常见模块(如身份验证和授权),可以进一步重用项目中的元素组合。通过在常见 Web API 项目中实现这些模块,可以将它们应用到任何新项目中,这些新项目会带来与你以前看到过的类似的问题。

为了方便记录,此操作听起来很简单,但事实上避免重复很难。它需要反复重构的意愿。完成重构时,还需要进一步进行重构。但好处是不容忽视的。DRY 原则可以让你近乎魔术般地创建代码,只需挥动基架魔杖,并借用现有部件组合模块。

最后,这里阐述的原则可以帮助你利用现有最佳做法创建自己的 Web API,同时避免重复。这种方法可以带来很多好处,希望它可以帮助你领会到 DRY 的美妙之处。


Thomas Hansen 是一个禅宗软件奇才,目前居住在塞浦路斯,他通过金融技术和贸易系统使软件代码以其独有的方式运作。

衷心感谢以下 Microsoft 技术专家对本文的审阅:James McCaffrey


在 MSDN 杂志论坛讨论这篇文章