编写代码时,每种语言都有其细微差别和标准。.NET 在一般准则方面也不例外。例如,在方法签名的末尾放置括号比将括号放在下一行或将返回放在与 if 语句相同的行上要好。这更多的是个人偏好。编程指南为开发人员提供了一种在编写代码时保持护栏之间的方法。这些编程指南在整个行业中用作标准做法。
在附录中,我们将介绍以下主要主题:
- 编程指南
- 项目结构
以下部分讨论的指南在行业中很常用。它们为开发人员提供了方向,并提供了护栏,说明一个人不仅应该为自己,而且应该为未来的其他开发人员和同行(包括我们未来的自己)构建和编写代码。
在第一部分中,我们将回顾一些编程指南,例如 DRY、YAGNI 和 KISS 原则,以及关注点分离、SOLID 概念,以及重构是一个过程而不是一次性的快速修复。我们将通过介绍建议的 .NET 项目和文件夹结构组织方式以及基于其功能的代码驻留位置来完成附录。
技术要求
附录的唯一技术要求是访问编辑器,因为我们将介绍一般的编程准则。虽然我们将提供一些代码片段,但它们并不能证明自己的代码存储库。它们只是为了巩固对概念的理解。
编程准则
在整本书中,我推荐了各种中级和高级的编写与特定主题或技术相关的代码的技术。虽然这些技术旨在让开发人员在需求和技术之间取得平衡,但也需要提供通用的编程准则来遵循某些模式,使同事和同行更容易理解代码库。成功的开发人员在编写和维护代码时会考虑这些准则。
在本节中,我们将回顾 DRY、YAGNI、KISS 和 SOLID 原则,并理解关注点分离以及重构的过程。
DRY
我们将要回顾的第一个首字母缩略词可能是最简单的指导原则之一。DRY 原则代表不要重复自己**。
如果您有多个方法在应用程序的不同位置执行相同的任务,那么可能是时候重构和整合代码了。
YAGNI
我们的下一个常用首字母缩略词是 YAGNI(发音为 yag-nee),代表你不会需要它**。
也称为“搭建通往无处的桥梁”,这个首字母缩略词背后的概念是让开发人员知道他们只在有需求时才应该编写代码。他们不应该为可能不会实现的未来增强添加代码。
KISS
由于下一个首字母缩略词有如此多的含义,我们将尽量保持简单(因此得名)。KISS 代表保持简单,愚蠢。
爱因斯坦曾说过“让一切尽可能简单,但不要过于简单”,而苹果公司的史蒂夫·乔布斯也总是说“简化”。
保持代码单元足够简单易懂。这可以包括以下内容:
- 更小的方法 – 方法越小,阅读和理解就越容易
- 语言增强 – 基于多年来 .NET 的 C# 语言改进,可能有更好(更短)的代码编写方式
- 降低复杂性 – 降低复杂性后,系统变得更易于测试,并可能成为自动化测试的候选对象
目标是通过为同行和同事创建更好的代码库来创造更多价值。
关注点分离
当您开始编写自己的应用程序时,运行它并第一次在屏幕上看到它的执行是一项巨大的成就。
随着时间的推移,应用程序需要数据库。然后它需要电子邮件功能。然后是日志记录。然后是身份验证。需求不断增长,等等。
关注点分离概念涉及如何将应用程序逻辑划分为不同的层。例如,如果某个应用程序需要电子邮件模块,那么它将是解决方案中名为 MyApplication.EmailModule 的单独项目。此电子邮件模块将为应用程序提供以下好处:
- 模块化 – EmailModule 可根据需要在其他应用程序中重复使用。
- 封装 – EmailModule 不需要任何外部依赖项;它是独立的。
- 可测试 – 如果所有内容都包含在 EmailModule 中,单元测试(以及可能的集成测试)将变得更加容易。
- 可维护性 – 这使开发人员可以只关注 EmailModule 而不是整个应用程序。当专注于某个特定部分时,不需要理解整个应用程序。只需要理解项目即可。
业内经常听到的一个概念是“大泥球”。这个概念涉及一个项目中包含的所有应用程序代码,这是一个无法维护的代码库。这是一个类似于“单体”的概念,由于应用程序规模庞大,因此难以维护。因此,应用程序中的概念不会被分解成模块化的工作单元。如果应用程序中的所有内容都耦合在一起……那么系统就会变得脆弱。如果开发人员在一个位置修改代码,它可能会修复当前问题,但会在其他位置引入错误,从而对整个代码库产生连锁反应。
关注点分离是经验丰富的开发人员应该通过代码审查与同行分享的东西,以在更大范围内改进软件并就该主题进行健康的讨论。
重构是一个过程
虽然重构是开发人员的基本概念,但重构代码库需要付出不同程度的努力。
一个简单的例子可能是重命名方法。一旦开发人员重命名了某个方法,开发人员就必须更改代码库中对该方法的所有引用。一个更高级的例子是重构业务规则引擎以创造更多的灵活性。虽然两者都是重构,但一个比另一个更容易。
重构应该是一个过程。多年来我一直使用的一个流程如下:
- 使其功能化 – 编写可运行的功能代码
- 进行测试 – 创建测试以确认代码的行为符合预期
- 进行重构 – 重构和优化代码
您编写的代码(通常)应该进行测试。
话虽如此,如果您要重构代码,进行测试将是有益的,以确认您的重构努力没有白费。一旦测试到位,您就可以自由地重构和修改尽可能多的代码,以实现预期目标。
业务规则引擎是我职业生涯中的一个例子,其中代码功能齐全,并且进行了大量测试(通过了约 700 次)。但是,团队遇到了一个问题,即代码需要更灵活的方法,因此必须进行重构。两名团队成员花了三天时间重构代码。完成重构后,他们运行了最终的单元测试,发现只有两个单元测试失败。两次单元测试失败是因为他们没有正确重命名方法名称。想象一下没有测试的重构。
重构可以像代码库允许的那样复杂或简单。请始终记住,重构是一个多步骤过程,需要测试来确认重构后的代码是否按预期工作。
SOLID 原则
SOLID 原则为编写代码提供了更深入的指导方针。SOLID 是一个缩写,由 Robert C. Martin 于 2000 年创建。
多年来,SOLID 原则已成为编写高质量软件的标准,并为开发人员提供了一种根据其代码是否符合每个原则的标准来衡量其代码的方法。开发人员可能不同意什么是 SOLID 代码,但同样,这些讨论应该与同事或在团队会议上进行。
单一责任原则
单一责任原则 (SRP) 规定一个类应该只有一个更改原因。
以下代码违反了 SRP:
public class User
{
public string Name { get; set; }
public string Email { get; set; }
public bool IsValid()
{
// Validate the user data here
if (string.IsNullOrEmpty(Name) || string.IsNullOrEmpty(Email))
{
return false;
}
return true;
}
public void Save()
{
// Save user data to database here
}
}
User 类有两个属性:Name 和 Email。但是,我们还有执行其他职责的额外方法:IsValid() 方法和 Save() 方法。我们的 User 类执行的功能超出了其应有的功能。我们应该创建两个新类:一个名为 UserValidation 用于验证,一个名为 UserService 或 UserRepository 用于数据库操作。
我们创建了两个额外的类,但提供了更好的软件组合。如果我们向 User 类添加新属性并且它需要验证,开发人员只需在一个地方进行更改:UserValidation 类。
开放/封闭
开放/封闭原则 描述了软件组件应如何对扩展开放但对修改关闭。
大多数违反开放/封闭原则的情况通常由长分支语句(例如长 if…then 或 switch 语句)表示。
以下代码提供了一个示例:
public class ComicBook
{
public string Title { get; set; } = string.Empty;
public string Issue { get; set; } = string.Empty;
public decimal Grading { get; set; }
public string GetGradeName() =>
Grading switch
{
10.0m => "Gem Mint",
9.9m => "Mint",
9.8m => "NM/M",
>= 9.6m => "NM+",
>= 9.4m => "NM",
>= 9.2m => "NM-",
>= 9.0m => "VF/NM",
>= 8.5m => "VF+",
>= 8.0m => "VF",
>= 7.5m => "VF-",
>= 7.0m => "FN/VF",
>= 6.5m => "FN+",
>= 6.0m => "FN",
>= 5.5m => "FN-",
>= 5.0m => "VG/FN",
>= 4.5m => "VG+",
>= 4.0m => "VG",
>= 3.5m => "VG-",
>= 3.0m => "G/VG",
>= 2.0m => "G",
>= 1.8m => "G-",
>= 1.5m => "Fa/G",
>= 1.0m => "Fa",
_ => "Poor"
};
}
在这个 ComicBook 类中,我们有三个属性,分别是 Title、Issue 和 Grading。我们类的要求之一是根据 Grading 属性返回等级名称。这违反了开放/封闭原则。
为什么?即使我们已经有了完整的等级列表,Certified Guaranty Company (CGC) 将来可能会将等级名称重命名为其他名称。如果我们想添加新的等级名称,我们必须进入 GetGradeName() 方法并添加新的等级和名称。
支持开放/封闭原则的更好的实现如下:
public class Grade
{
public decimal Value { get; }
public string Name { get; }
private Grade(decimal value, string name)
{
Value = value;
Name = name;
}
public static Grade FromDecimal(decimal value) =>
value switch
{
10.0m => new Grade(value, "Gem Mint"),
9.9m => new Grade(value, "Mint"),
9.8m => new Grade(value, "NM/M"),
>= 9.6m => new Grade(value, "NM+"),
>= 9.4m => new Grade(value, "NM"),
>= 9.2m => new Grade(value, "NM-"),
>= 9.0m => new Grade(value, "VF/NM"),
>= 8.5m => new Grade(value, "VF+"),
>= 8.0m => new Grade(value, "VF"),
>= 7.5m => new Grade(value, "VF-"),
>= 7.0m => new Grade(value, "FN/VF"),
>= 6.5m => new Grade(value, "FN+"),
>= 6.0m => new Grade(value, "FN"),
>= 5.5m => new Grade(value, "FN-"),
>= 5.0m => new Grade(value, "VG/FN"),
>= 4.5m => new Grade(value, "VG+"),
>= 4.0m => new Grade(value, "VG"),
>= 3.5m => new Grade(value, "VG-"),
>= 3.0m => new Grade(value, "G/VG"),
>= 2.0m => new Grade(value, "G"),
>= 1.8m => new Grade(value, "G-"),
>= 1.5m => new Grade(value, "Fa/G"),
>= 1.0m => new Grade(value, "Fa"),
_ => new Grade(value, "Poor")
};
}
public class ComicBook
{
public string Title { get; set; } = string.Empty;
public string Issue { get; set; } = string.Empty;
public Grade Grading { get; set; }
}
虽然看起来我们只是移动了 switch 语句,但我们做了其他事情。我们创建了一个 Grade 类。
创建了 Grade 类后,我们可以将任何类型的等级分配给 ComicBook 类。如果创建了新的等级类型,我们可以轻松地将其添加到列表中,而无需修改 ComicBook 类。我们还在代码中实现了工厂模式。
之前,我们根据十进制值比较字符串。现在,如果等级需要其他属性,我们可以扩展 Grade 类以包含更多信息。
开放扩展,关闭修改。
里氏替换
里氏替换原则 解释了任何派生类型都可以用其基类型替换。里氏替换背后的概念基于继承类型和/或接口。
继续我们的漫画书示例,以下代码显示了一个简单的 BasePublisher 类:
public class MyNewPublisher : BasePublisher
{
public MyNewPublisher(): base(nameof(MyNewPublisher)) { }
}
public class BasePublisher
{
public string Name { get; set; }
protected BasePublisher(string name)
{
Name = name;
}
public Address GetAddress()
{
return Address.Empty;
}
}
public class Address
{
public static Address Empty => new();
public string Address1 { get; set; } = string.Empty;
public string Address2 { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string State { get; set; } = string.Empty;
public string ZipCode { get; set; } = string.Empty;
}
BasePublisher 类包含发布者的名称和地址。当我们创建新的发布者(例如前面的 MyNewPublisher 类)时,我们将可以访问基类中可用的所有内容。
用 MyNewPublisher 类替换 BasePublisher 类的能力是里氏替换原则的一个例子。
接口隔离
接口隔离原则 解释说,不应强迫客户端实现他们不会使用的不必要的方法。
对于应用程序中创建的每个接口,定义的每个方法和属性都应在具体类中实现。定义的接口不应该在实现中浪费。
例如,假设我们有一个用于 ComicBook 类的接口。接口和实现代码如下:
public interface IComicBook
{
string Title { get; set; }
string Issue { get; set; }
string Publisher { get; set; }
void SaveToDatabase();
}
public class ComicBook : IComicBook
{
public string Title { get; set; }
public string Issue { get; set; }
public string Publisher { get; set; }
public void SaveToDatabase()
{
throw new NotImplementedException();
}
}
除了 SaveToDatabase() 方法之外,我们 ComicBook 类中的所有内容都是合理的。创建一个新的 ComicBook 实例意味着我们每次都会使用数据库。这违反了接口隔离原则。
更好的实现是将数据库访问拆分为具有 SaveToDatabase() 方法的 IComicBookWriter,如以下代码所示:
public interface IComicBook
{
string Title { get; set; }
string Issue { get; set; }
string Publisher { get; set; }
}
public interface IComicBookWriter
{
void SaveToDatabase();
}
public class ComicBook : IComicBook, IComicBookWriter
{
public string Title { get; set; }
public string Issue { get; set; }
public string Publisher { get; set; }
public void SaveToDatabase()
{
// Implementation
}
}
示例代码展示了如何从 IComicBookWriter 继承,从而为 ComicBook 类提供一种持久化数据的方法。
接口隔离原则的目标是避免在接口中包含您不会使用的方法。
此示例也违反了 SRP,因为此类也在访问数据库。
依赖倒置
依赖倒置原则 解释了我们应该依赖于抽象而不是具体实现。使用 .NET,依赖注入是开箱即用的。由于依赖注入自动可用,这满足了我们依赖倒置原则的一半。
虽然我们可以将具体类依赖注入到构造函数中,但更好的实现是为具体实现创建一个接口。使用接口鼓励整个代码库的松散耦合。
例如,在 [第 5 章] 中,我们正是出于这个原因,使用 Entity Framework 为我们的 DbContext 创建了一个简单的接口。我们可以使用它的接口,而不是注册 DbContext 的具体实现。
我们注册了我们的抽象(接口)来支持我们的依赖倒置原则。
在本节中,我们将介绍术语 DRY、YAGNI 和 KISS,以及关注点分离的含义以及重构是一个过程而不是一项单一的任务。我们通过学习每个 SOLID 实践(即单一职责、开放/封闭、里氏替换、接口隔离和依赖倒置原则)来结束本节。
项目结构
如 [第 7 章]中所述,在测试中,文件夹结构可以揭示应用程序的意图并提供文档。
在本节中,我们将理解 ASP.NET Web 应用程序的文件夹结构。我们还将理解根据意图将代码放置在何处,例如将 API 代码或 Entity Framework 代码放置在何处。
理解项目格局
每个项目都有基于其类型的结构。例如,Razor Page 项目布局与 Model-View-Controller (MVC) 项目或 API 项目不同。
让我们检查一下这些常见项目中有哪些文件夹。
首先,以下是 ASP.NET Razor Page 项目的示例:
图 11.1 – Razor Page 项目的通用文件夹结构
接下来是 ASP.NET MVC 项目的示例:
图 11.2 - MVC 项目的通用文件夹结构
在介绍每个项目时,我们将解释每个文件夹的作用及其在应用程序中的用途。
wwwroot 文件夹
在前面的任何项目类型中,wwwroot 文件夹包含我们在网站上使用的所有静态内容。添加到此目录的任何文件夹都是静态内容,并且对浏览器可见。
一个例子是图像文件夹。如果我们将图像文件夹添加到 wwwroot 文件夹,则该图像文件夹的 URL 将如下所示:
https://localhost:xxx/images/funnyimage.jpg
对于 JavaScript 框架(例如 Angular、React 等),应在 wwwroot 文件夹下创建一个名为 source 或 src 的文件夹来保存客户端源代码。应将 JavaScript 框架转译到您选择的另一个文件夹,例如 js 或 app 文件夹,以供公共浏览器使用。
Pages 文件夹
在 Razor Page 项目中,Pages 文件夹是服务器端页面所在的位置。创建的每个文件夹都是页面的路径。
例如,如果我们创建一个 Setup 文件夹并添加一个 Index.cshtml 文件,则执行和查看该页面的 URL 将如下所示:
https://localhost:xxx/setup/
在 Pages 目录下创建的其他文件夹将遵循相同的路径,如图 11.3 所示:
图 11.3 – MenuManager 页面的文件夹结构
根据图 11.3 中的目录结构,MenuManager 的 URL 如下:
https://localhost:xxx/setup/menumanager/
文件夹结构越简单,就越容易找到页面并识别页面功能。
共享文件夹
Shared 文件夹用于通用组件,例如布局页面、ViewComponents、partials、EditorTemplates 和 DisplayTemplates。这些共享组件可通过 Pages 文件夹(如果是 Razor Pages 项目)或 Views 文件夹(如果是 MVC 项目)中的网页访问。
Controllers 文件夹
MVC 项目始终包含一个 Controllers 文件夹,它是 Web 应用程序的交通警察。
MVC Web 模型使用“约定优于配置”概念,其中控制器的名称是路径,控制器类中的方法是页面名称。
例如,在前面提到的 Controllers 文件夹中,我们有一个名为 HomeController 的类。如果我们查看 HomeController,我们会看到一个名为 Index() 的方法:
public class HomeController : Controller
{
public IActionResult Index()
{
return View();
}
}
HomeController 类的存在告诉我们三件事:
- 我们将有一个 /Home URL,其中包含一个用于默认页面的 Index() 方法
- Home 文件夹位于 /Views 文件夹下
- 由于 HomeController 中有一个 Index() 方法,因此 **/**Views/Home 目录中应该有一个 Index.cshtml
Index() 方法告诉我们何时调用 https://localhost:xxx/Home URL。它将自动命中此 Index() 方法,并默认在 /Views/Home 目录中查找 Index 视图。
功能文件夹
MVC 应用程序的秘密之一是能够将控制器移动到应用程序内的任何文件夹。在初始启动时,ASP.NET 框架会找到整个应用程序中可用的所有控制器,并为传入的 Web 请求创建路由表。基于这种方法,社区中的开发人员创建了 功能文件夹。
功能文件夹通常包含在根目录下的 /Features 文件夹中,其下有文件夹用于标识所实现的功能。虽然 /Features 文件夹是最常见的,但开发人员可以随意命名该文件夹。他们还可以将控制器放在项目中的任何文件夹下。ASP.NET 可以在启动时找到所有控制器。
这些文件夹通常至少包含一个控制器、一个 ViewModel 和一个 View。它们还可以包含与该功能相关的支持类。文件夹的命名基于正在实现的功能。
例如,如果您的 MVC 应用程序中有一个图像查看功能,它将看起来像 图 11.4:
图 11.4 – ImageViewer 功能文件夹示例
此文件夹结构具有以下优势:
- 重点 – 每个功能都是独立的,因此团队成员可以构建功能而不会导致合并问题
- 整合 – 在整个项目中,功能不再从一个文件夹移动到另一个文件夹,而是被限制在一个文件夹中,从而使编码过程更加高效
- 即时可见性 – 如果有人说应收账款功能存在问题,开发人员就会知道立即查看 Features/AccountsReceiveable 文件夹
在 MVC 中,可以修改视图路径以满足您的需求。在这种情况下,定义视图的自定义路径可为您的应用程序提供更灵活的配置。
通过提供垂直切片,Features 文件夹技术正成为创建可扩展、基于功能的 Web 应用程序的更可行的选择。垂直切片是在所有层(表示层、域层和数据访问层)中为整个功能编写代码的过程。功能文件夹简化了此过程并在应用程序中传达了独立的功能。
模型文件夹
模型 文件夹包含用于视图的所有模型。这与 ViewModel 不同。模型和 ViewModel 之间的区别在于 ViewModel 被传递到视图中并且可以包含支持 ViewModel 的模型。
以下代码片段显示了 ViewModel 的示例:
public class HomeController : Controller
{
public IActionResult Index()
{
return View(new IndexViewModel
{
Title = "Home Page",
Product = new ProductDto
{
Name = "Sunglasses",
Price = 9.99m
}
});
}
}
public class IndexViewModel
{
public string Title { get; set; }
public ProductDto Product { get; set; }
}
public class ProductDto
{
public string Name { get; set; }
public decimal Price { get; set; }
}
ViewModel 被发送到视图(IndexViewModel),其中模型可以是支持 ViewModel(ProductDto)的数据。
两种常见做法包括在 Models 文件夹下创建 ViewModels 目录,或在项目根目录中创建 ViewModels 目录。
Views 文件夹
在 MVC 项目中,Views 文件夹相当于 Razor Pages 项目中的 Pages 文件夹。它包含与 Razor Pages 项目相同的文件夹结构。
创建项目层
创建新的 Web 应用程序时,默认 Web 项目包含在浏览器中运行的基本要素。但是,如何对应用程序进行细分,以免产生大量难以管理的代码?
层或层级是应用程序的一部分,分为旨在以某种方式执行的模块或项目。表示层包含用户与网站交互的用户界面,而数据访问层则检索应用程序的数据。
识别项目层可能有点艰巨,但最好的方法是根据其功能创建应用程序层。每个项目都将根据其功能具有一致的命名约定。
虽然以下是推荐的项目层和名称,但架构师和团队的建议可能会否决这些选择:
- 核心域/业务规则 – 域或业务规则项目应位于应用程序的核心。这些项目通常命名为 .Domain 或 .Core。
- 用户界面 (UI) – 通过 文件 | 新建 | ASP.NET Web 应用程序 创建初始 Web 应用程序时,UI 已经可用。这些项目的推荐命名约定可以是 .Web 或 .UI。
- 数据库 – 数据库访问应包含在 .Data 或 .Infrastructure 中。
- API – API 应位于其自己的文件夹 (/api) 中,或包含在名为 .Api 的单独项目中。
- 服务 – 服务是封装更高级别功能的另一层。虽然这是一个可选层,并且这些类型的服务可以位于 基础设施 项目内,但基础设施项目中的代码量可能会变得非常大。服务项目可以为 基础设施 项目提供替代方案。服务可以包括 MailService 或 Service。这些项目通常命名为 .Services。
这些代码层在组织项目时提供最佳布局。每个项目名称都描述了意图,并为开发人员提供了整个解决方案的清晰表示。
总结
在本附录中,我们学习了 DRY、YAGNI 和 KISS 原则,以及关注点分离、SOLID 概念,以及重构是一个过程而不是一次性的快速修复。
我们继续研究了两个常见的 ASP.NET Web 应用程序的结构以及每个文件夹代表什么。一旦我们理解了项目的文件夹结构,我们就会根据其意图(例如实体框架或服务类)检查代码驻留在何处。