C#8 和 .NET Core3 软件架构实用指南(二)

C# 8.0 & .NET Core 3.1 跨平台开发教程 《C# 8.0 & .NET Core 3.1 跨平台开发教程》 你好!本课程适用与具有一定编程基础,打算转型到 Microsoft 的 .NET 平台的学生及初学者。 本课程的难度较低,属于入门级课程。可选取阅读。 本课程参考了两本英文书籍: Mark J Price所著的《C# 8.0 and .NET Core 3.0 - Modern Cross-Platform Development》 Ian Griffiths所著的《Programming C# 8.0》 课程目录 0.0 阅读详情

原文:zh.annas-archive.org/md5/e94fc480b4a73285f00b144d1072c83f

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:在 C# 中与数据交互 - Entity Framework Core

正如我们在第五章中提到的,将微服务架构应用于企业应用程序,软件系统被组织成层,并且每一层通过不依赖于层实现方式的接口与前一层和后一层进行通信。当软件是业务/企业系统时,它通常至少包含三个层:数据层、业务层和表示层。一般来说,每一层提供的接口以及层的实现方式取决于应用程序。

然而,结果证明数据层提供的功能相当标准化,因为它们只是将数据从数据存储子系统映射到对象,反之亦然。这导致了在相当声明式的方式下实现数据层的通用框架的概念。这些工具被称为 对象关系映射(ORM)工具,因为它们是基于关系数据库的数据存储子系统。然而,它们与现代非关系存储(如 MongoDB 和 Azure Cosmos DB)也很好地协同工作,因为它们的数据模型比纯关系模型更接近目标对象模型。

本章将涵盖以下主题:

  • 理解 ORM 基础知识

  • 配置 Entity Framework Core

  • Entity Framework Core 迁移

  • 使用 Entity Framework Core 查询和更新数据

  • 部署您的数据层

  • 理解 Entity Framework Core 高级功能 - 全局过滤器

本章描述了 ORM 以及如何对其进行配置,然后重点介绍了 Entity Framework Core,它是 .NET Core 中包含的 ORM。

技术要求

本章需要 Visual Studio 2017 或 2019 免费社区版或更高版本,并安装所有数据库工具。

本章中的所有概念都将通过基于 WWTravelClub 书籍用例的实用示例进行说明。您可以在github.com/PacktPublishing/Hands-On-Software-Architecture-with-CSharp-8找到本章的代码。

理解 ORM 基础知识

ORM 将关系数据库表映射到内存中的对象集合,其中对象属性对应于数据库表字段。来自 C# 的类型,如布尔值、数值类型和字符串,有对应的数据库类型。如果映射的数据库中没有 GUID,而单个字符映射到数据库的单字符字符串,那么像 GUID 这样的类型则映射到它们的等效字符串表示。所有日期和时间类型要么映射到 C# 的 DateTime(当日期/时间不包含时区信息时),要么映射到 DateTimeOffset(当日期/时间也包含显式时区信息时)。任何数据库时间长度都映射到 TimeSpan。

由于大多数面向对象语言的字符串属性没有与之关联的长度限制(而数据库字符串字段通常有长度限制),因此在数据库映射配置中考虑了数据库限制。一般来说,当需要指定数据库类型和面向对象语言类型之间的映射选项时,这些选项在映射配置中声明。

整个配置的定义方式取决于具体的 ORM。Entity Framework Core 提供了三种选项:

  • 数据注释(属性属性)

  • 命名约定

  • 基于配置对象和方法的流畅配置接口

虽然流畅式接口可以用来指定任何配置选项,但数据注释和命名约定可以用于其中较小的一部分。

每个 ORM 都适应特定的数据库类型(Oracle、MySQL、SQL Server 等),并使用称为提供程序或连接器的数据库特定适配器。Entity Framework Core 为大多数可用的数据库引擎提供了提供程序。

可以在docs.microsoft.com/en-US/ef/core/providers/找到提供程序的完整列表。

适配器对于数据库类型之间的差异、事务处理方式以及 SQL 语言未标准化的所有其他功能都是必要的。

表之间的关系用对象指针表示。例如,在一对多关系中,映射到关系一方的类包含一个集合,该集合填充了关系多方相关的对象。另一方面,映射到关系多方的类有一个简单的属性,该属性填充了关系一方唯一相关的对象。

整个数据库(或其一部分)由一个内存缓存类表示,该类包含映射到数据库表的每个集合的属性。首先,在内存缓存类的实例上执行查询和更新操作,然后与此实例同步数据库。Entity Framework Core 使用的内存缓存类称为DBContext,它还包含映射配置。更具体地说,通过继承DBContext并向所有映射集合和所有必要的配置信息添加,可以获得特定于应用程序的内存缓存类。

总结来说,DBContext子类实例包含与数据库同步的数据库的部分快照,以获取/更新实际数据。

数据库查询是通过在内存缓存类集合上调用方法来执行的查询语言。实际的 SQL 是在同步阶段创建和执行的。例如,Entity Framework Core 在映射到数据库表的集合上执行语言集成查询(LINQ)。

通常,LINQ 查询生成IEnumerable实例,即元素在查询结束时创建IEnumerable时并未计算,而是在你实际尝试从IEnumerable检索集合元素时计算。其工作原理如下:

  • 从DBContext的映射集合开始的 LINQ 查询创建一个名为IQueryable的特定IEnumerable子类。

  • IQueryable 包含发出数据库查询所需的所有信息,但实际的 SQL 是在检索IQueryable的第一个元素时生成和执行的。

  • 因此,在 Entity Framework Core 的情况下,数据库同步是在从最终的IQueryable检索元素时执行的。

  • 通常,每个 Entity Framework 查询都以ToList或ToArray操作结束,该操作将IQueryable转换为列表或数组,从而在数据库上实际执行查询。

  • 如果预期查询将仅返回单个元素或根本不返回任何元素,我们通常执行一个FirstOrDefault操作,该操作返回一个元素(如果有的话),或者返回null。

此外,对 DB 表的新实体进行更新、删除和添加操作是通过模拟在表示数据库表的DBContext集合属性上执行这些操作来完成的。然而,实体只能通过查询将其加载到内存集合中后以这种方式更新或删除。更新查询需要根据需要修改实体的内存表示,而删除查询则需要从其内存映射集合中删除实体的内存表示。在 Entity Framework Core 中,通过调用集合的Remove(entity)方法执行删除操作。

添加新实体没有其他要求。只需将新实体添加到内存集合中即可。在内存集合上执行的各种更新、删除和添加操作实际上是通过显式调用 DB 同步方法传递给数据库的。例如,当你调用DBContext.SaveChanges()方法时,Entity Framework Core 会将DBContext实例上执行的所有更改传递到数据库。

在同步操作期间传递给数据库的更改将在单个事务中执行。此外,对于具有显式事务表示的 ORM,例如 Entity Framework Core,同步操作是在事务的作用域内执行的,因为它使用该事务而不是创建一个新的。

本章的其余部分解释了如何使用 Entity Framework Core,以及一些基于本书 WWTravelClub 用例的示例代码。

配置 Entity Framework Core

由于数据库处理被限制在专用应用程序层内,将 Entity Framework Core (DBContext)定义在单独的库中是一种良好的做法。相应地,我们需要定义一个.NET Core 类库项目。正如我们在第二章,功能和非功能需求部分的书籍用例 - .NET Core 的实际应用,.NET Core 项目的类型中讨论的,我们有两种不同类型的库项目:.NET Standard 和 .NET Core。

虽然.NET Core 库与特定的.NET Core 版本相关联,但.NET Standard 2.0 库具有广泛的应用范围,因为它们可以与任何大于 2.0 的.NET 版本以及经典的.NET Framework 一起工作。

然而,Microsoft.EntityFrameworkCore包(我们在数据库层需要它)仅依赖于.NET Standard 2.0。它被设计为与特定的.NET Core 版本一起工作(其版本号与.NET Core 版本相同)。因此,如果我们定义我们的数据库层为.NET Standard 2.0,我们添加作为依赖项的特定Microsoft.EntityFrameworkCore包可能与另一个系统组件中包含的同一库的另一个版本发生冲突,该组件与特定的.NET Core 版本相关联。

由于我们的库不是通用库(它只是特定应用程序的一个组件),我们更倾向于将其绑定到特定的.NET Core 版本,而不是在整个应用程序设计中跟踪其版本依赖关系。因此,让我们选择一个.NET Core 库项目,用于安装在我们机器上的最新.NET Core 版本。我们的.NET Core 库项目可以创建和准备如下:

  1. 打开 Visual Studio,定义一个名为WWTravelClubDB的新解决方案,然后选择适用于最新.NET Core 版本的类库 (.NET Core)。

  2. 我们必须安装所有与 Entity Framework Core 相关的依赖项。安装所有必需依赖项的最简单方法是将我们打算使用的数据库引擎提供者的 NuGet 包添加到项目中——在我们的例子中,是 SQL Server——正如我们在第四章,选择最佳云解决方案中提到的。实际上,任何提供者都会安装所有所需的包,因为它将它们作为依赖项。因此,让我们添加Microsoft.EntityFrameworkCore.SqlServer的最新稳定版本。如果你打算使用多个数据库引擎,你也可以添加其他提供者,因为它们可以并行工作。在本章的后面部分,我们将安装其他包含我们需要的工具的 NuGet 包,然后我们将解释如何安装进一步处理 Entity Framework Core 配置所需的工具。

  3. 让我们将默认的Class1类重命名为MainDBContext。这被自动添加到类库中。

  4. 现在,让我们用以下代码替换其内容:

using System;
using Microsoft.EntityFrameworkCore;

namespace WWTravelClubDB
{
    public class MainDBContext: DbContext
    {
        public MainDBContext(DbContextOptions options)
            : base(options)
        {
        }
        protected override void OnModelCreating(ModelBuilder 
        builder)
        {
        } 
    }
}
  1. 我们从DbContext继承,并且必须将DbContextOptions传递给DBContext构造函数。DbContextOptions包含创建选项,如数据库连接字符串,这些选项取决于目标数据库引擎。

  2. 所有已映射到数据库表中的集合都将作为MainDBContext的属性添加。映射配置将在重写的OnModelCreating方法中定义,该方法通过参数传递的ModelBuilder对象来实现。

下一步是创建所有代表数据库表行的类。这些被称为实体。我们需要为每个想要映射的数据库表创建一个实体类。让我们在项目根目录下创建一个Models文件夹来存放所有这些类。下一个小节将解释如何定义所有必需的实体。

定义数据库实体

数据库设计,就像整个应用程序设计一样,是有组织的迭代过程。让我们假设在第一次迭代中,我们需要一个包含两个数据库表的原型:一个用于所有旅行套餐,另一个用于所有由套餐引用的位置。每个套餐只覆盖一个位置,而单个位置可能被多个套餐覆盖,因此两个表通过一对一的关系连接。

因此,让我们从位置数据库表开始。正如我们在上一节末尾提到的,我们需要一个实体类来表示这个表的行。让我们将实体类命名为Destination:

namespace WWTravelClubDB.Models
{
    public class Destination
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Country { get; set; }
        public string Description { get; set; }
    }
}

所有数据库字段都必须由可读/写的 C#属性表示。假设每个目的地就像一个城镇或地区,可以通过其名称和所在国家来定义,并且所有相关信息都包含在其Description中。在未来的迭代中,我们可能会添加更多字段。Id是一个自动生成的键。

然而,现在,我们需要添加有关所有字段如何映射到数据库字段的信息。在 Entity Framework Core 中,所有原始类型都由特定于数据库引擎的提供程序自动映射到数据库类型(在我们的情况下是 SQL Server 提供程序)。我们唯一关心的是以下内容:

  • 字符串长度限制:可以通过为每个字符串属性应用适当的MaxLength和MinLength属性来考虑。所有对实体配置有用的属性都包含在System.ComponentModel.DataAnnotation和System.ComponentModel.DataAnnotations.Schema命名空间中。因此,将它们都添加到所有实体定义中是一个好的实践。

  • 指定哪些字段是必需的,哪些是可选的:默认情况下,所有引用类型(如所有字符串)都被假定为可选的,而所有值类型(如数字和 GUID)都被假定为必需的。如果我们想使一个引用类型成为必需的,那么我们必须用Required属性来装饰它。然而,如果我们想使T值类型属性成为可选的,那么我们必须用T?来替换它。

  • 指定哪个属性代表主键:键可以通过使用 Key 属性来指定。然而,如果没有找到 Key 属性,则将名为 Id 的属性(如果有的话)视为主键。在我们的案例中,不需要 Key 属性。如果主键由多个属性组成,只需将 Key 属性添加到所有这些属性上即可。

由于每个目的地在一对多关系的一侧,它必须包含一个相关包实体的集合;否则,我们无法在我们的 LINQ 查询子句中引用相关实体。

将一切整合,Destination 类的最终版本如下:

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace WWTravelClubDB.Models
{
    public class Destination
    {
        public int Id { get; set; }
        [MaxLength(128), Required]
        public string Name { get; set; }
        [MaxLength(128), Required]
        public string Country { get; set; }
        public string Description { get; set; }
        public ICollection<Package> Packages { get; set; }
    }
}

由于 Description 属性没有长度限制,它将使用不定长度的 SQL Server ntext 字段实现。我们可以用类似的方式编写 Package 类的代码:

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace WWTravelClubDB.Models
{
    public class Package
    {
        public int Id { get; set; }
        [MaxLength(128), Required]
        public string Name { get; set; }
        [MaxLength(128)]
        public string Description { get; set; }
        public decimal Price { get; set; }
        public int DuratioInDays { get; set; }
        public DateTime? StartValidityDate { get; set; }
        public DateTime? EndValidityDate { get; set; }
        public Destination MyDestination { get; set; }
        public int DestinationId { get; set; }
    }
}

每个包都有持续的天数,以及可选的开始和结束日期,这些日期是包优惠有效的日期。MyDestination 通过与 Destination 实体之间的一对多关系将包与其目的地连接起来,而 DestinationId 是同一关系的外部键。

虽然指定外部键不是强制性的,但这是一个好的实践,因为这是指定关系某些属性的唯一方式。例如,在我们的案例中,由于 DestinationId 是一个 int(值类型),这是强制性的。因此,这里的关系是一对多,而不是(0,1)到多。将 DestinationId 定义为 int?,而不是 int,将使一对多关系变为(0,1)到多关系。

在下一节中,我们将解释如何定义表示数据库表的内存集合。

定义映射的集合

一旦我们定义了所有表示数据库行面向对象的实体,我们需要定义表示数据库表的内存集合。正如我们在 ORM 基础 小节中提到的,所有数据库操作都映射到这些集合的操作上(本章的 使用 Entity Framework Core 查询和更新数据 小节解释了 如何)。对于每个实体 T,我们只需要在我们的 DBContext 中为每个实体添加一个 DbSet<T> 集合属性。通常,这些属性的名称是通过将实体名称复数化得到的。因此,我们需要向我们的 MainDBContext 添加以下两个属性:

public DbSet<Package> Packages { get; set; }
public DbSet<Destination> Destinations { get; set; }

到目前为止,我们已经将数据库内容转换为属性、类和数据注释。然而,Entity Framework 需要更多信息才能与数据库交互。下一小节将解释如何提供这些信息。

完成映射配置

在实体定义中无法指定的映射配置信息必须在 OnModelCreating DBContext 方法中添加。每个与实体 T 相关的配置信息从 builder.Entity<T>() 开始,然后通过调用指定该类型约束的方法继续。进一步嵌套的调用指定约束的进一步属性。例如,我们的多对一关系可以配置如下:

builder.Entity<Destination>()
    .HasMany(m => m.Packages)
    .WithOne(m => m.MyDestination)
    .HasForeignKey(m => m.DestinationId)
    .OnDelete(DeleteBehavior.Cascade);

关系的两侧通过我们添加到实体的导航属性来指定。HasForeignKey 指定外部键。最后,OnDelete 指定在删除目标时对包的处理。在我们的例子中,它执行与该目标相关的所有包的级联删除。

同样的配置可以从关系的另一端开始定义,即从 builder.Entity<Package>() 开始:

builder.Entity<Package>()
    .HasOne(m => m.MyDestination)
    .WithMany(m => m.Packages)
    .HasForeignKey(m => m.DestinationId)
    .OnDelete(DeleteBehavior.Cascade);

唯一的区别是,由于我们从关系的另一端开始,所以之前的 HasMany-WithOne 方法被 HasOne-WithMany 方法所取代。

ModelBuilder builder 对象允许我们使用如下方式指定数据库索引:

builder.Entity<T>()
   .HasIndex(m => m.PropertyName);

多属性索引的定义如下:

builder.Entity<T>()
    .HasIndex("propertyName1", "propertyName2", ...);

如果我们添加所有必要的配置信息,那么我们的 OnModelCreating 方法将如下所示:

protected override void OnModelCreating(ModelBuilder builder)
{
    builder.Entity<Destination>()
        .HasMany(m => m.Packages)
        .WithOne(m => m.MyDestination)
        .HasForeignKey(m => m.DestinationId)
        .OnDelete(DeleteBehavior.Cascade);

    builder.Entity<Destination>()
        .HasIndex(m => m.Country);

    builder.Entity<Destination>()
        .HasIndex(m => m.Name);

    builder.Entity<Package>()
 .HasIndex(m => m.Name);

    builder.Entity<Package>()
        .HasIndex("StartValidityDate", "EndValidityDate");
} 

一旦配置了 Entity Framework Core,我们就可以使用所有配置信息来创建实际的数据库,并将所有需要的工具放置到位,以便随着应用程序的发展更新数据库的结构。下一节将解释如何操作。

Entity Framework Core 迁移

现在我们已经配置了 Entity Framework 并定义了我们的应用程序特定的 DBContext 子类,我们可以使用 Entity Framework Core 设计工具生成物理数据库并创建 Entity Framework Core 与数据库交互所需的数据库结构快照。

Entity Framework Core 设计工具必须作为 NuGet 包安装在每个需要它们的项目中。有两个等效选项:

  • 在任何 Windows 控制台中工作的工具:这些通过 Microsoft.EntityFrameworkCore.Design NuGet 包提供。所有 Entity Framework Core 命令都在 dotnet ef ...... 格式中,因为它们包含在 .NET Core 命令行应用程序的 ef 命令中。

  • Visual Studio 特定的工具:这些包含在 Microsoft.EntityFrameworkCore.Tools NuGet 包中。由于它们只能从 Visual Studio 内部的包管理器控制台启动,因此不需要 dotnet ef 前缀。

Entity Framework Core 的设计工具在设计/更新过程中使用。该过程如下:

  1. 我们根据需要修改 DBContext 和实体的定义。

  2. 我们启动设计工具,要求 Entity Framework Core 检测和处理我们所做的所有更改。

  3. 一旦启动,设计工具将更新数据库结构快照并生成一个新的迁移,即包含所有我们需要修改物理数据库以反映所有更改的指令的文件。

  4. 我们推出另一个工具来更新数据库,以包含新创建的迁移。

  5. 我们测试新配置的 DB 层,如果需要新的更改,我们将返回到步骤 1。

  6. 当数据层准备就绪时,它将在预发布或生产环境中部署,此时所有迁移将再次应用到实际的预发布/生产数据库中。

这在各个软件项目迭代和应用程序的生命周期中重复多次。如果我们操作的是已经存在的数据库,我们需要配置DBContext及其模型以反映我们想要映射的所有表的现有结构。然后,我们使用IgnoreChanges选项调用设计工具,以便它们生成一个空迁移。此外,这个空迁移必须传递到物理数据库,以便它可以同步与物理数据库关联的数据库结构版本与数据库快照中记录的版本。这个版本很重要,因为它决定了哪些迁移必须应用到数据库以及哪些已经应用。

整个设计过程需要一个测试/设计数据库,如果我们操作的是现有数据库,则该测试/设计数据库的结构必须反映实际数据库——至少在我们想要映射的表方面。为了使设计工具能够与数据库交互,我们必须定义它们传递给DBContext构造函数的DbContextOptions选项。这些选项在设计时很重要,因为它们包含测试/设计数据库的连接字符串。如果我们创建一个实现IDesignTimeDbContextFactory<T>接口的类,其中T是我们的DBContext子类,设计工具就可以了解我们的DbContextOptions选项:

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;

namespace WWTravelClubDB
{
    public class LibraryDesignTimeDbContextFactory
        : IDesignTimeDbContextFactory<MainDBContext>
    {
        private const string connectionString =
            @"Server=(localdb)\mssqllocaldb;Database=wwtravelclub;
                Trusted_Connection=True;MultipleActiveResultSets=true";
        public MainDBContext CreateDbContext(params string[] args)
        {
            var builder = new DbContextOptionsBuilder<MainDBContext>();

            builder.UseSqlServer(connectionString);
            return new MainDBContext(builder.Options);
        }
    }
}

connectionString将由 Entity Framework 用于在开发机器上安装的本地 SQL Server 实例中创建一个新的数据库,并通过 Windows 凭据进行连接。你可以自由地更改它以反映你的需求。

现在,我们准备好创建我们的第一个迁移了!让我们开始吧:

  1. 让我们转到包管理控制台,并确保 WWTravelClubDB 被选为我们的默认项目。

  2. 现在,输入Add-Migration initial并按Enter键来执行此命令:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/d81b11a6-78ee-4f9f-b286-002c0b88ddb1.png

initial是我们给第一个迁移取的名字。所以,一般来说,命令是Add-Migration <迁移名称>。当我们操作现有数据库时,我们必须将-IgnoreChanges选项添加到第一个迁移(仅添加到该迁移)以创建一个空迁移。有关所有命令的引用可以在进一步阅读部分找到。

  1. 如果在创建迁移之后但在将迁移应用到数据库之前,我们意识到我们犯了错误,我们可以使用Remove-Migration命令撤销我们的操作。如果迁移已经应用到数据库,纠正我们的错误的最简单方法是对代码进行所有必要的更改,然后应用另一个迁移。

  2. 一旦执行了Add-Migration命令,我们的项目中就会出现一个新的文件夹:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/bb865a32-ee95-460d-ab24-c9b86ee779a6.png

20190205102637_initial.cs是我们用易于理解的语言表达的迁移。

您可以审查代码以验证一切是否正常,并且您也可以修改迁移内容(只有当您足够专业才能可靠地完成时)。每个迁移都包含一个Up方法和一个Down方法。Up方法表示迁移,而Down方法则撤销其更改。因此,Down方法包含Up方法中包含的所有操作的逆序操作。

20190205102637_initial.Designer.cs是您必须不修改的 Visual Studio 设计器代码,而MainDBContextModelSnapshot.cs是整体数据库结构快照。如果您添加了更多的迁移,新的迁移文件及其设计器对应文件将出现,并且唯一的MainDBContextModelSnapshot.cs数据库结构快照将被更新以反映数据库的整体结构。

您可以在 Windows 控制台中通过输入dotnet ef migrations add initial来发出相同的命令。然而,此命令必须从项目的根目录(而不是从解决方案的根目录)发出。

您可以通过在包管理器控制台中输入Update-Database来将迁移应用到数据库。等效的 Windows 控制台命令是dotnet ef database update。让我们尝试使用这个命令来创建物理数据库!

下一个子节解释了如何创建 Entity Framework 无法自动创建的数据库内容。之后,在下一节中,我们将使用 Entity Framework 的配置以及我们使用dotnet ef database update生成的数据库来创建、查询和更新数据。

理解存储过程和直接 SQL 命令

一些数据库结构不能通过我们之前描述的 Entity Framework Core 命令和声明自动生成。例如,Entity Framework Core 不能自动生成存储过程。如通用 SQL 字符串之类的存储过程可以通过migrationBuilder.Sql("<sql scommand>")方法手动包含在Up和Down方法中。

做这件事最安全的方式是添加一个不执行任何配置更改的迁移,这样在创建时迁移是空的。然后,我们可以将必要的 SQL 命令添加到这个迁移的空 Up 方法中,以及它们的逆命令在空 Down 方法中。将所有 SQL 字符串放在资源文件(.resx 文件)的属性中是一种良好的做法。

现在,你已经准备好通过 Entity Framework Core 与数据库进行交互了。

使用 Entity Framework Core 查询和更新数据

为了测试我们的数据库层,我们需要将一个基于与我们的库相同 .NET Core 版本的控制台项目添加到解决方案中。让我们开始吧:

  1. 让我们将新的控制台项目命名为 WWTravelClubDBTest。

  2. 现在,我们需要通过在控制台项目的 References 节点右键单击并选择 Add reference 来将我们的数据层作为控制台项目的依赖项添加。

  3. 从 program.cs 文件中的 Main 静态方法中删除内容,并开始编写以下内容:

Console.WriteLine("program start: populate database");
Console.ReadKey();
  1. 然后,在文件顶部添加以下命名空间:
using WWTravelClubDB;
using WWTravelClubDB.Models;
using Microsoft.EntityFrameworkCore;
using System.Linq;

现在我们已经完成了测试项目的准备工作,我们可以尝试查询和数据更新。让我们首先创建一些数据库对象,即一些目的地和包。按照以下步骤操作:

  1. 首先,我们必须使用适当的连接字符串创建我们的 DBContext 子类的一个实例。我们可以使用设计工具使用的相同 LibraryDesignTimeDbContextFactory 类来获取它:
var context = new LibraryDesignTimeDbContextFactory()
    .CreateDbContext(); 
  1. 可以通过简单地将类实例添加到我们 DBContext 子类的映射集合中来创建新行。如果一个 Destination 实例与包相关联,我们可以简单地将其添加到其 Packages 属性中:
var firstDestination= new Destination
{
    Name = "Florence",
    Country = "Italy",
    Packages = new List<Package>()
    {
        new Package
        {
            Name = "Summer in Florence",
            StartValidityDate = new DateTime(2019, 6, 1),
            EndValidityDate = new DateTime(2019, 10, 1),
            DuratioInDays=7,
            Price=1000
        },
        new Package
        {
            Name = "Winter in Florence",
            StartValidityDate = new DateTime(2019, 12, 1),
            EndValidityDate = new DateTime(2020, 2, 1),
            DuratioInDays=7,
            Price=500
        }
    }
};
context.Destinations.Add(firstDestination);
context.SaveChanges();
Console.WriteLine(
    "DB populated: first destination id is "+
    firstDestination.Id);
Console.ReadKey();

没有必要指定主键,因为它们是自动生成的,将由数据库填充。实际上,在 SaveChanges() 操作同步我们的上下文与实际数据库后,firstDestination.Id 属性有一个非零值。对于 Package 的主键也是如此。

当我们通过将其插入父实体集合(在我们的例子中是 Packages 集合)来声明一个实体(在我们的例子中是 Package)是另一个实体(在我们的例子中是 Destination)的子实体时,没有必要显式设置其外键(在我们的例子中是 DestinationId),因为 Entity Framework Core 会自动推断它。一旦创建并与 firstDestination 数据库同步,我们可以通过两种不同的方式添加更多包:

  • 创建一个 Package 类实例,将其 DestinationId 外键设置为 firstDestination.Id,并将其添加到 context.Packages

  • 创建一个不需要设置外键的 Package 类实例,然后将其添加到其父 Destination 实例的 Packages 集合中。

后一种选项是唯一的选择,当添加具有其父实体(Destination)的子实体(Package)且父实体具有自动生成的主键时,在这种情况下,外部键在我们执行添加操作时不可用。在大多数其他情况下,前一种选项更简单,因为第二种选项需要将父Destination实体及其Packages集合加载到内存中,即与所有与Destination对象关联的套餐一起(默认情况下,通过查询不会加载连接的实体)。

现在,假设我们想修改佛罗伦萨目的地,并将所有佛罗伦萨套餐的价格提高 10%。我们该如何操作?按照以下步骤了解如何进行:

  1. 首先,我们需要通过查询将实体加载到内存中,修改它,并调用SaveChanges()来同步我们的更改与数据库。如果我们只想修改,比如描述,以下查询就足够了:
var toModify = context.Destinations
    .Where(m => m.Name == "Florence").FirstOrDefault();
  1. 我们需要加载所有默认未加载的相关目的地套餐。这可以通过Include子句完成,如下所示:
var toModify = context.Destinations
    .Where(m => m.Name == "Florence")
    .Include(m => m.Packages)
    .FirstOrDefault();
  1. 之后,我们可以修改描述和套餐价格,如下所示:
toModify.Description = 
  "Florence is a famous historical Italian town";
foreach (var package in toModify.Packages)
   package.Price = package.Price * 1.1m;
context.SaveChanges();

var verifyChanges= context.Destinations
    .Where(m => m.Name == "Florence")
    .FirstOrDefault();

Console.WriteLine(
    "New Florence description: " +
    verifyChanges.Description);
Console.ReadKey();

到目前为止,我们执行了查询,其唯一目的是更新检索到的实体。接下来,我们将解释如何检索将显示给用户以及/或用于复杂业务操作的信息。

将数据返回到表示层

为了保持层之间的分离,并使查询适应每个用例实际需要的数据,数据库实体不会直接发送到表示层。相反,数据被投影到包含用例所需信息的更小的类中。这些类由表示层的调用方法实现。在将数据从一个层移动到另一个层的对象被称为数据传输对象(DTOs)。例如,让我们创建一个包含当向用户返回套餐列表时值得显示的摘要信息的 DTO(我们假设如果需要,用户可以通过点击他们感兴趣的套餐来获取更多详细信息):

  1. 让我们在我们的 WWTravelClubDBTest 项目中添加一个 DTO,其中包含需要在套餐列表中显示的所有信息:
namespace WWTravelClubDBTest
{
    public class PackagesListDTO
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
        public int DuratioInDays { get; set; }
        public DateTime? StartValidityDate { get; set; }
        public DateTime? EndValidityDate { get; set; }
        public string DestinationName { get; set; }
        public int DestinationId { get; set; }
        public override string ToString()
        {
            return string.Format("{0}. {1} days in {2}, price: 
            {3}", Name, DuratioInDays, DestinationName, Price);
        }
    }
}

我们不需要在内存中加载实体并将它们的数据复制到 DTO 中,但数据库数据可以直接投影到 DTO 中,这得益于 LINQ 的Select子句。这最小化了与数据库交换的数据量。

  1. 例如,我们可以使用查询来填充我们的 DTOs,该查询检查所有在 8 月 10 日左右可用的套餐:
var period = new DateTime(2019, 8, 10);
var list = context.Packages
    .Where(m => period >= m.StartValidityDate
    && period <= m.EndValidityDate)
    .Select(m => new PackagesListDTO
    {
        StartValidityDate=m.StartValidityDate,
        EndValidityDate=m.EndValidityDate,
        Name=m.Name,
        DuratioInDays=m.DuratioInDays,
        Id=m.Id,
        Price=m.Price,
        DestinationName=m.MyDestination.Name,
        DestinationId = m.DestinationId
    })
    .ToList();
foreach (var result in list)
    Console.WriteLine(result.ToString());
Console.ReadKey();
  1. 在Select子句中,我们还可以导航到任何相关实体以获取所需的数据。例如,前面的查询导航到相关的Destination实体以获取Package的目的地名称。

  2. 现在,在解决方案资源管理器中右键单击 WWTravelClubDBTest 项目,将其设置为启动项目。然后,运行解决方案。

  3. 程序在每个 Console.ReadKey() 方法处停止,等待您按任意键。这样,您就有时间分析所有代码片段产生的输出,这些代码片段都已添加到 Main 方法中。

现在,我们将学习如何处理无法有效地映射到表示数据库表的内存集合的即时操作的运算。

发出直接 SQL 命令

并非所有数据库操作都可以通过使用 LINQ 查询数据库并在内存中更新实体来高效执行。例如,计数器增加可以通过单个 SQL 指令更高效地执行。此外,如果我们定义了适当的存储过程/SQL 命令,一些操作可以以可接受的性能执行。在这些情况下,我们被迫直接向数据库发出 SQL 命令或从我们的 Entity Framework 代码中调用数据库存储过程。有两种可能性:执行数据库操作但不返回实体的 SQL 语句,以及返回实体的 SQL 语句。

不返回实体的 SQL 命令可以使用 DBContext 方法执行,如下所示:

int DBContext.Database.ExecuteSqlRaw(string sql, params object[] parameters)

参数可以在字符串中以 {0}, {1}, ..., {n} 的形式引用。每个 {m} 都会被填充到 parameters 数组的 m 索引处的对象,该对象从 .NET 类型转换为相应的 SQL 类型。该方法返回受影响的行数。

返回实体集合的 SQL 命令必须通过与这些实体关联的映射集合的 FromSqlRaw 方法发出:

context.<mapped collection>.FromSqlRaw(string sql, params object[] parameters)

因此,例如,返回 Package 实例的命令可能看起来像这样:

var results = context.Packages.FromSqlRaw("<some sql>", par1, par2, ...).ToList();

在 ExecuteSqlRaw 方法中,SQL 字符串和参数是这样工作的。以下是一个简单的示例:

var allPackages =context.Packages.FromSqlRaw(
    "SELECT * FROM Products WHERE Name = {0}",
    myPackageName)

将所有 SQL 字符串放入资源文件中,并将所有 ExecuteSqlRaw 和 FromSqlRaw 调用封装在您在 DBContext 子类中定义的公共方法内,是一种良好的实践,这样可以保持您的 Entity Framework Core 数据层内部对特定数据库的依赖。

处理事务

对 DBContext 实例所做的所有更改都在第一次 SaveChanges 调用中以单个事务传递。然而,有时有必要在同一个事务中包含查询和更新。在这些情况下,我们必须显式处理事务。如果我们将几个实体 Framework Core 命令放入与事务对象关联的 using 块中,那么这些命令可以包含在一个事务中:

using (var dbContextTransaction = context.Database.BeginTransaction())
{
    try{
        ...
        ...
        dbContextTransaction.Commit();
    }
    catch
    {
        dbContextTransaction.Rollback();
    }
}

在前面的代码中,context是我们DBContext子类的一个实例。在using块内部,可以通过调用其Rollback和Commit方法来中止和提交事务。任何包含在事务块中的SaveChanges调用都将使用它们已经所在的交易,而不是创建新的交易。

部署你的数据层

当你的数据库层在生产或预发布环境中部署时,通常已经存在一个空数据库,因此你必须应用所有迁移以创建所有数据库对象。这可以通过调用context.Database.Migrate()来完成。Migrate方法应用尚未应用到数据库中的迁移,因此它可以在应用程序的生命周期中安全地多次调用。context是我们DBContext类的一个实例,必须通过一个具有足够权限创建表和执行我们迁移中包含的所有操作的连接字符串来传递。因此,通常,此连接字符串与我们在正常应用程序操作中使用的字符串不同。

在将 Web 应用程序部署到 Azure 的过程中,我们有机会使用我们提供的连接字符串来检查迁移。我们还可以在应用程序启动时通过调用context.Database.Migrate()方法手动检查迁移。这将在第十三章展示 ASP.NET Core MVC 中详细讨论,该章节专门介绍 ASP.NET MVC Web 应用程序。

对于桌面应用程序,我们可以在应用程序安装及其后续更新期间应用迁移。

在第一次应用程序安装和/或后续应用程序更新时,我们可能需要用初始数据填充一些表。对于 Web 应用程序,此操作可以在应用程序启动时执行,而对于桌面应用程序,此操作可以包含在安装过程中。

可以使用 Entity Framework Core 命令填充数据库表。首先,我们需要验证表是否为空,以避免多次添加相同的表行。这可以通过以下代码中的Any() LINQ 方法来完成:

if(!context.Destinations.Any())
{
    //populate here the Destinations table
}

让我们看看 Entity Framework Core 要分享的一些高级功能。

理解 Entity Framework Core 高级功能——全局过滤器

全局过滤器是在 2017 年底引入的。它们使诸如软删除和多租户表等技术成为可能,这些技术被多个用户共享,其中每个用户只需看到自己的记录。

全局过滤器是通过modelBuilder对象定义的,该对象在DBContext.OnModelCreating方法中可用。此方法的语法如下:

modelBuilder.Entity<MyEntity>().HasQueryFilter(m => <define filter condition here>);

例如,如果我们向我们的Package类添加一个IsDeleted属性,我们可以通过定义以下过滤器来对 Package 进行软删除,而不从数据库中删除它:

modelBuilder.Entity<Package>().HasQueryFilter(m => !m.IsDeleted);

然而,过滤器包含DBContext属性。因此,例如,如果我们向我们的DBContext子类(其值在创建DBContext实例时设置)添加一个CurrentUserID属性,那么我们可以向所有引用用户 ID 的实体添加如下过滤器:

modelBuilder.Entity<Document>().HasQueryFilter(m => m.UserId == CurrentUserId);

在设置上述过滤器后,当前登录的用户只能访问他们拥有的文档(具有其UserId的文档)。类似的技术在多租户应用程序的实现中非常有用。

摘要

在本章中,我们探讨了 ORM 基础知识的要点以及为什么它们如此有用。然后,我们描述了 Entity Framework Core。特别是,我们讨论了如何使用类注解和其他包含在DBContext子类中的声明和命令来配置数据库映射。

然后,我们讨论了如何使用迁移自动创建和更新物理数据库结构,以及如何通过 Entity Framework Core 查询和传递数据库更新。最后,我们学习了如何通过 Entity Framework Core 传递直接 SQL 命令和事务,以及如何基于 Entity Framework Core 部署数据层。

本章还回顾了最新 Entity Framework Core 版本中引入的一些高级功能。

在下一章中,我们将讨论如何使用 Entity Framework Core 与 NoSQL 数据模型结合,以及云中(特别是 Azure 中)可用的各种存储选项。

问题

  1. Entity Framework Core 如何适应几个不同的数据库引擎?

  2. 在 Entity Framework Core 中如何声明主键?

  3. 在 Entity Framework Core 中如何声明字符串字段的长度?

  4. 在 Entity Framework Core 中如何声明索引?

  5. 在 Entity Framework Core 中如何声明关系?

  6. 两个重要的迁移命令是什么?

  7. 默认情况下,LINQ 查询是否加载相关实体?

  8. 是否可以在一个不是数据库实体的类实例中返回数据库数据?如果是,如何操作?

  9. 迁移如何在生产环境和预览环境中应用?

进一步阅读

第七章:如何在云中选择您的数据存储

与其他云服务一样,Azure 提供了广泛的存储设备。最简单的方法是在云中定义一组可扩展的虚拟机,我们可以在这些虚拟机上实现我们的自定义解决方案。例如,我们可以在云托管的虚拟机上创建一个 SQL Server 集群,以增加可靠性和计算能力。然而,通常,自定义架构并不是最佳解决方案,也没有充分利用云基础设施提供的机遇。

因此,本章将不会讨论此类自定义架构,而将主要关注云中可用的各种 存储即服务(SaaS) 提供方案,特别是 Azure。这些方案包括基于普通磁盘空间的可扩展解决方案、关系型数据库、NoSQL 数据库以及如 Redis 这样的内存数据存储。

选择更合适的存储类型不仅基于应用程序的功能需求,还基于性能和扩展需求。实际上,在处理资源时进行扩展会导致性能的线性增长,但扩展存储资源并不一定意味着性能的合理增长。简而言之,无论你复制多少数据存储设备,如果多个请求影响的是同一数据块,它们将始终需要相同的时间来排队访问它!

扩展数据会导致读取操作吞吐量的线性增长,因为每个副本可以服务不同的请求,但这并不意味着写入操作吞吐量会有相同的增长,因为同一数据块的所有副本都必须更新!因此,需要更复杂的技巧来扩展存储设备,并且并非所有存储引擎都能同等程度地扩展。

尤其是在所有场景中,关系型数据库的扩展性都不好。因此,扩展需求和地理分布数据的需求在存储引擎的选择中起着基本作用,以及在 SaaS 提供商的选择中也是如此。

在本章中,我们将涵盖以下主题:

  • 理解不同目的的不同存储库

  • 在结构化或 NoSQL 存储之间进行选择

  • Azure Cosmos DB - 管理多大陆数据库的机会

  • 用例 - 存储数据

让我们开始吧:

技术要求

本章要求您具备以下条件:

  • Visual Studio 2017 或 2019 的免费社区版或更高版本,并安装所有数据库工具。

  • 一个免费的 Azure 账户。第一章中的“创建 Azure 账户”小节解释了如何创建一个。

  • 为了获得更好的开发体验,我们建议您还安装 Cosmos DB 的本地模拟器,可在aka.ms/cosmosdb-emulator找到。

理解不同目的的不同存储库

本节描述了最流行的数据存储技术所提供的功能。主要,我们将关注它们能够满足的功能需求。性能和扩展功能将在下一节中分析,该节专门用于比较关系型数据库和 NoSQL 数据库。在 Azure 中,可以通过在所有 Azure 门户页面顶部的搜索栏中输入产品名称来找到各种服务。

以下小节描述了我们可以用于我们的 C#项目的各种数据库类型。

关系型数据库

通常,云提供多种数据库引擎。Azure 提供各种流行的数据库引擎,如 SQL Server(Azure SQL Server)、MySQL 和 Oracle。

关于 Oracle 数据库引擎,Azure 提供可配置的虚拟机,这些虚拟机上安装了各种 Oracle 版本,您可以通过在 Azure 门户搜索栏中输入Oracle后得到的建议轻松验证。Azure 费用不包括 Oracle 许可证;它们仅提供计算时间,因此您必须将许可证带到 Azure。

在 Azure 上使用 MySQL 时,您需要为使用私有服务器实例付费。您产生的费用取决于您拥有的核心数量、需要分配多少内存以及备份保留时间。MySQL 实例是冗余的,您可以选择本地或地理分布式的冗余:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/3a871e4b-5bbe-4c0f-906e-5e2a3377be2d.png

Azure SQL Server 是最灵活的提供方案。在这里,您可以配置每个数据库使用的资源。当您创建数据库时,您可以选择将其放置在现有的服务器实例上或创建一个新的实例。费用基于已预留的数据库内存以及所需的数据库事务单元(DTUs)。在这里,一个 DTU 是由参考工作负载确定的 I/O 操作、CPU 使用率和内存使用的线性组合。大致来说,当您增加 DTUs 时,最大数据库性能的增加是线性的:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/04490184-bfc4-4eec-9eee-281af77ed4d3.png

您还可以通过启用读扩展来配置数据复制。这样,您可以提高读取操作的性能。备份保留时间对于每个服务级别(基本、标准和高级)都是固定的。

如果您选择“是”以回答“是否想使用 SQL 弹性池?”,则数据库将被添加到弹性池中。添加到同一弹性池的数据库将共享其资源,因此未被数据库使用的资源可以在其他数据库的使用高峰期间使用。弹性池可以包含托管在不同服务器实例上的数据库。弹性池是优化资源使用以降低成本的有效方式。

NoSQL 数据库

在 NoSQL 数据库中,关系表被更通用的集合所取代,这些集合可以包含异构的 JSON 对象。也就是说,集合没有预定义的结构和没有预定义的字段长度限制(在字符串的情况下),但可以包含任何类型的对象。与每个集合相关联的唯一结构约束是作为主键的属性名称。

更具体地说,每个集合条目可以包含嵌套对象和嵌套在对象属性中的对象集合,即相关实体,在关系数据库中,这些实体包含在不同的表中并通过外部键连接。在 NoSQL 中,数据库可以嵌套在其父实体中。由于集合条目包含复杂的嵌套对象,而不是像关系数据库那样简单的属性/值对,因此条目不被称为元组或行,而是文档。

在同一集合或不同集合中属于同一文档的文档之间不能定义关系和/或外部键约束。如果一个文档在其属性中包含另一个文档的主键,那么它这样做是有风险的。开发者有责任维护并保持这些一致性的引用。

最后,由于 NoSQL 存储相对便宜,整个二进制文件可以作为文档属性的值以 Base64 字符串的形式存储。开发者可以定义规则来决定在集合中索引哪些属性。由于文档是嵌套对象,属性实际上是树路径。通常情况下,默认情况下,所有路径都会被索引,但你也可以指定要索引哪些路径集合和子路径。

NoSQL 数据库可以通过 SQL 的子集或基于 JSON 的语言进行查询,其中查询是 JSON 对象,其路径表示要查询的属性,其值表示已应用于它们的查询约束。

在关系数据库中,可以通过一对多关系使用嵌套子对象的可能性。然而,在使用关系数据库时,我们被迫重新定义所有相关表的精确结构,而 NoSQL 集合不对它们包含的对象施加任何预定义的结构。唯一的约束是每个文档必须为主键属性提供一个唯一的值。因此,当我们的对象结构极其多变时,NoSQL 数据库是唯一的选择。然而,它们通常被选择用于扩展读/写操作的方式,以及更普遍的,在分布式环境中的性能优势。它们的功能将在下一节中讨论,该节将它们与关系数据库进行比较。

图数据模型是完全无结构文档的极端情况。整个数据库是一个图,查询可以在其中添加、更改和删除图文档。

在这种情况下,我们有两种类型的文档:节点和关系。虽然关系有一个定义良好的结构(通过关系连接的节点的主键,加上关系名称),但节点没有任何结构,因为属性及其值是在节点更新操作中一起添加的。图数据模型是为了表示人们及其操作的对象(媒体、帖子等)的特征以及他们在社交应用中的关系而设计的。Gremlin 语言是为了查询图数据模型而专门设计的。我们不会在本章中讨论这个问题,但在进一步阅读部分有相关参考资料。

在本章剩余的部分,我们将详细分析 NoSQL 数据库,这些部分专门用于描述 Azure Cosmos DB 并将其与关系数据库进行比较。

Redis

Redis 是一种基于键值对的分布式并发内存存储,支持分布式队列。它可以作为永久性内存存储,也可以作为数据库数据的 Web 应用程序缓存。或者,它可以渲染内容变化不大的页面。

Redis 还可以用于存储 Web 应用程序的用户会话数据。实际上,ASP.NET MVC、Pages 和 WebForms 支持会话数据以克服 HTTP 协议无状态的事实。更具体地说,用户数据在页面变化之间保持不变,并存储在 Redis 等服务器端存储中,并通过存储在 cookie 中的会话键进行索引。

与云中的 Redis 服务器交互通常基于 REST 接口;也就是说,每个 Redis 资源通过 HTTP GET 在 URI 上访问,命令通过查询字符串传递,而答案以 JSON 格式返回。然而,所有流行的语言中都有提供易于使用的接口的客户端。.NET 和.NET Core 的客户端可以通过StackExchange.Redis NuGet 包获得。StackExchange.Redis客户端的基本操作已在stackexchange.github.io/StackExchange.Redis/Basics中记录,而完整文档可以在stackexchange.github.io/StackExchange.Redis找到。

在 Azure 上定义 Redis 服务器的用户界面相当简单:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/df820d64-6224-4523-94ef-2c30f6a9e50d.png

价格层级下拉菜单允许我们选择可用的内存/复制选项之一。一个快速入门指南,解释如何使用 Azure Redis 凭据和StackExchange.Redis .NET Core 客户端的 URI,可以在docs.microsoft.com/en-us/azure/azure-cache-for-redis/cache-dotnet-core-quickstart找到。

磁盘内存

所有云都提供可扩展和冗余的通用磁盘内存,您可以用作虚拟机中的虚拟磁盘或作为外部文件存储。Azure 存储帐户 磁盘空间也可以在表和队列中进行结构化。然而,这两个存储选项仅支持向后兼容,因为 Azure NoSQL 数据库比表更好,Azure Redis 比 Azure 存储队列更好:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/67b90062-5d45-4cec-8625-9fa827a64189.png

在本章的剩余部分,我们将重点关注 NoSQL 数据库以及它们与关系数据库的不同之处。接下来,我们将探讨如何在这两者之间进行选择。

在结构化存储或 NoSQL 存储之间进行选择

在上一节中,我们提到当数据几乎没有任何预定义结构时,应该优先考虑 NoSQL 数据库。实际上,非结构化数据可以在关系数据库中表示,因为元组t的可变属性可以放置在一个包含属性名称、属性值和t的外部键的连接表中。然而,问题是性能。事实上,属于单个对象的属性值会散布在可用的内存空间中。在一个小型数据库中,“散布在可用的内存空间中”意味着距离较远但位于同一磁盘上;在一个大型数据库中,这意味着距离较远但位于不同的磁盘单元中;在分布式云环境中,这意味着距离较远,可能位于不同的——甚至地理上分布的——服务器上。

另一方面,NoSQL 数据库不仅将可变属性与其所有者保持接近,而且由于它们允许相关对象嵌套在属性和集合中,它们还将一些相关对象保持接近。

因此,我们可以得出结论,当通常一起访问的表可以存储在内存附近时,关系数据库表现良好。另一方面,NoSQL 数据库自动确保相关数据保持接近,因为每个条目都将其相关的大部分数据作为嵌套对象包含在内。因此,当 NoSQL 数据库分布到不同的内存以及不同的地理上分布的服务器上时,它们的表现更好。

不幸的是,扩展存储写操作的唯一方法是将集合条目根据分片键的值分散到几个服务器上。例如,我们可以将所有以 A 开头的用户名记录放在一个服务器上,以 B 开头的记录放在另一个服务器上,依此类推。这样,不同起始字母的用户名的写操作可以并行执行,确保写吞吐量随着服务器数量的线性增长。

然而,如果一个分片集合与几个其他集合相关联,不能保证相关记录会被放置在同一个服务器上。此外,在不使用集合分片的情况下,将不同的集合放在不同的服务器上可以线性地增加写入吞吐量,直到达到每个服务器单个集合的极限,但这并不能解决在检索或更新通常一起处理的数据时被迫在多个服务器上执行多个操作的问题。

如果必须以事务性方式访问相关分布式对象,并且必须确保结构约束(如外部键约束)不被违反,这个问题将对性能造成灾难性的影响。在这种情况下,所有相关对象必须在事务期间被阻塞,防止其他请求在整个耗时的分布式操作期间访问它们。

NoSQL 数据库不受此问题的影响,并且通过分片以及随之而来的写入扩展输出表现更好。这是因为它们不会将相关数据分布到不同的存储单元,而是将它们存储为同一数据库条目的嵌套对象。

在 NoSQL 数据库设计中,我们总是试图将所有可能一起处理的相关对象放入一个单独的条目中。访问频率较低的相关对象被放置在不同的条目中。由于外部键约束不是自动强制执行的,并且 NoSQL 事务非常灵活,开发者可以在性能和一致性之间选择最佳折衷方案。

值得注意的是,在某些情况下,关系数据库在分片方面表现良好。一个典型的例子是多租户应用程序。在多租户应用程序中,所有条目集合可以被划分为不重叠的集合,称为租户。只有属于同一租户的条目可以相互引用,因此如果所有集合都根据它们的对象租户以相同的方式分片,所有相关记录最终都会落在同一个分片上,即同一个服务器上,并且可以有效地进行导航。

在云中,多租户应用程序并不罕见,因为向几个不同用户提供相同服务的所有应用程序通常都作为多租户应用程序实现,其中每个租户对应一个用户订阅。因此,关系数据库被设计在云中工作,例如 Azure SQL Server,并且通常为多租户应用程序提供分片选项。通常,分片不是云服务,必须使用数据库引擎命令定义。在这里,我们不会描述如何使用 Azure SQL Server 定义分片,但进一步阅读部分包含了一个指向官方 Microsoft 文档的链接。

总之,关系数据库提供了独立于实际存储方式的数据的纯粹逻辑视图,并使用声明性语言来查询和更新它们。这简化了开发和系统维护,但在需要写入扩展的分布式环境中可能会引起性能问题。在 NoSQL 数据库中,您必须手动处理更多关于如何存储数据的细节,以及所有更新和查询操作的一些过程性细节,但这也允许您在需要读取和写入扩展的分布式环境中优化性能。

在下一节中,我们将探讨 Azure Cosmos DB,这是 Azure 的主要 NoSQL 服务。

Azure Cosmos DB – 管理跨大陆数据库的机会

Azure Cosmos DB 是 Azure 的主要 NoSQL 服务。Azure Cosmos DB 拥有自己的接口,它是 SQL 的一个子集,但可以用 MongoDB 接口进行配置。它也可以配置为图数据模型,可以用 Gremlin 查询。Cosmos DB 允许进行复制以实现容错和读取扩展,副本可以地理分布以优化通信性能。此外,您可以指定所有副本放置在哪个数据中心。用户还可以选择启用所有副本的写入功能,以便在写入的地理区域立即可用。通过分片实现写入扩展,用户可以通过定义哪些属性用作分片键来配置它。

您可以通过在 Azure 门户搜索栏中键入 Cosmos DB 并点击添加来定义一个 Cosmos DB 账户。以下页面将出现:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/83d9ca9f-5a23-4509-8a8a-5487e31c2409.png

您选择的账户名称在资源 URI 中用作{账户名称}.documents.azure.com。API 下拉菜单允许您选择您喜欢的接口类型(SQL、MongoDB 或 Gremlin)。然后,您可以决定主数据库将放置在哪个数据中心,以及您是否希望启用地理分布复制。一旦启用了地理分布复制,您可以选择您想要使用的副本数量以及它们放置的位置。

最后,多区域写入切换功能允许您在地理分布的副本上启用写入。如果您不这样做,所有写入操作都将路由到主数据中心。

  1. 访问资源:一旦您创建了账户,选择数据探索器以在内部创建数据库和集合:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/3893d21f-a74e-491d-9845-4563487b5afb.png

  1. 创建集合:由于数据库只有名称而没有配置,您可以直接添加集合,然后选择您希望放置它的数据库:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/6e63a071-b4a1-48b4-80b2-db48df1aff90.png

在这里,您可以决定数据库和集合名称以及用于分片(分区键)的属性。由于 NoSQL 条目是对象树,属性名称指定为路径。您还可以添加值必须唯一的属性。然而,唯一性 ID 是在每个分片中检查的,因此此选项仅在特定情况下有用,例如多租户应用程序(其中每个租户都包含在单个分片中)。费用取决于您选择的集合吞吐量。

  1. 将所有资源参数针对您的需求进行定位:吞吐量以每秒请求单位表示,其中每秒请求单位定义为每秒执行 1 KB 读取时的吞吐量。因此,如果您检查“配置数据库吞吐量”选项,所选的吞吐量将共享给整个数据库,而不是作为单个集合保留。

  2. 获取连接信息:通过选择“键”菜单,您将看到连接到您的应用中的 Cosmos DB 账户所需的所有信息:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/2d565e3b-bb79-41aa-aa36-09615968a7c7.png

  1. 连接信息页面:在这里,您将找到账户 URI 和两个连接键,这些键可以互换使用来连接到账户:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/503dcd27-b61c-450e-871a-0232bedf3cab.png

此外,还有一些具有只读权限的键。每个键都可以重新生成,并且每个账户都有两个等效的键,以便此操作可以高效地处理;也就是说,当一个键更改时,另一个键保持不变。因此,现有的应用程序可以在升级到新键之前继续使用另一个键。

  1. 选择默认一致性级别:通过选择“默认一致性”,您可以选择要应用于所有集合的默认复制一致性:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/32f7d541-7f05-4246-962b-6f83293c0d75.png

此默认值可以在每个集合中覆盖,无论是从数据资源管理器还是通过编程方式。读写操作中的一致性问题是数据复制的结果。更具体地说,如果读取操作在不同的副本上执行,并且这些副本接收到了不同的部分更新,那么各种读取操作的结果可能是不一致的。

以下是可以用的可用一致性级别。这些级别已按从最弱到最强排序:

  • 最终一致性:经过足够的时间后,如果没有进一步的写操作,所有的读取将收敛并应用所有的写操作。

  • 一致前缀:所有写操作都在所有副本上以相同的顺序执行。因此,如果有n个写操作,每个读取都与应用前m个写操作的结果一致,其中m小于或等于n。

  • 会话一致性:这与一致性前缀相同,但也保证了每个写入者在其所有后续读取操作中都能看到其自己的写入结果,并且每个读者的后续读取都是一致的(要么是相同的数据库,要么是其更新版本)。

  • 有限不一致性:这与延迟时间Delta或操作次数N相关联。每次读取都会看到在时间Delta之前(或最后N个操作之前)执行的所有写入操作的结果。也就是说,其读取与所有写入的结果收敛,最大时间延迟为Delta(或最大操作延迟为N)。

  • 强一致性:这是结合了Delta = 0的有限不一致性。在这里,每次读取都反映了所有之前的写入操作的结果。

可以获得最强的一致性,但会损害性能。默认情况下,一致性设置为 Session,这是在一致性和性能之间的一种良好折衷。在应用程序中处理较低级别的一致性比较困难,并且通常只有在会话是只读或只写的情况下才可接受。

如果你选择数据探索器中的“缩放和设置”选项,你可以配置要索引哪些路径以及将哪种索引应用于每个路径的每个数据类型。配置由一个 JSON 对象组成。让我们分析其各种属性:

{
    "indexingMode": "consistent",
    "automatic": true,
    ...

如果你将indexingMode设置为none而不是consistent,则不会生成索引,集合可以用作以集合主键为索引的键值字典。当automatic设置为true时,所有文档属性都会自动索引:

{
    ...
    "includedPaths": [
        {
            "path": "/*",
            "indexes": [
                {
                    "kind": "Range",
                    "dataType": "Number",
                    "precision": -1
                },
                {
                    "kind": "Range",
                    "dataType": "String",
                    "precision": -1
                },
                {
                    "kind": "Spatial",
                    "dataType": "Point"
                }
            ]
        }
    ]
},
...

“包含的路径”中的每个条目指定了一个路径模式,例如/subpath1/subpath2/?(设置仅应用于/subpath1/subpath2/属性)或/subpath1/subpath2/*(设置应用于以/subpath1/subpath2/开头的所有路径)。

当设置必须应用于集合属性中包含的子对象时,模式包含[]符号;例如,/subpath1/subpath2/[]/?,/subpath1/subpath2/[]/childpath1/?等等。设置指定要应用于每个数据类型(字符串、数字、地理点等)的索引类型。范围索引用于比较操作,而如果需要进行相等比较,则散列索引更有效。

可以指定精度,即所有索引键中使用的最大字符数或数字数。-1表示无限制。对于字符串,-1是可以接受的,而对于数字,应使用有限的精度。另一方面,使用有限的精度与字符串可能会导致意外的行为,因为字符串键会被截断。在散列索引中,精度可能从 1 到 8 不等,而在范围索引中,精度可能从 1 到 100 不等:

    ...
    "excludedPaths": [
        {
            "path": "/\"_etag\"/?"
        }
    ]

excludedPaths中包含的路径根本不会被索引。索引设置也可以通过编程方式指定。

在这里,你有两种连接到 Cosmos DB 的选项:使用你首选编程语言的官方客户端版本,或者使用 Cosmos DB 的 Entity Framework Core 提供器,截至本书编写时,它仍在预览中。在接下来的小节中,我们将探讨这两种选项。然后,我们将通过一个实际示例描述如何使用 Cosmos DB 的 Entity Framework Core 提供器。

Cosmos DB 客户端

.NET Core 的 Cosmos DB 客户端可以通过 Microsoft.Azure.DocumentDB.Core NuGet 包获得。它提供了对所有 Cosmos DB 功能的完全控制,而 Cosmos DB Entity Framework 提供器更容易使用,但隐藏了一些 Cosmos DB 的特性。按照以下步骤通过官方 Cosmos DB .NET Core 客户端与 Cosmos DB 进行交互:

  1. 任何操作都需要创建一个客户端对象:
var client = new DocumentClient(new Uri("service endpoint"), "auth key")
  1. 不要忘记,当不再需要客户端时,必须通过调用其 Dispose 方法(或通过将引用它的代码封装在 using 语句中)来释放客户端。

  2. 然后,你可以使用以下代码获取数据库的引用并创建它(如果它不存在):

Database db = client.CreateDatabaseIfNotExistsAsync(new Database { Id = "MyDatabase" }).Result;
  1. 最后,你可以使用以下代码获取集合的引用或创建它(如果它不存在):
var collection = client.CreateDocumentCollectionIfNotExistsAsync(
    UriFactory.CreateDatabaseUri("MyDatabase"), 
    new DocumentCollection { Id = "MyCollection" }).Result;
  1. 在创建集合期间,你可以传递一个 option 对象,其中你可以指定一致性级别、如何索引属性以及所有其他集合功能。

  2. 然后,你必须定义与你在集合中需要操作的 JSON 文档结构相对应的 .NET 类。你也可以使用 JsonProperty 属性将类属性名称映射到 JSON 名称,如果它们不相等的话。

  3. 一旦你有了所有必要的类,你可以使用客户端方法来添加、更新和写入集合条目,以及客户端 CreateDocumentQuery 方法,它返回一个 IQueryable 值,你可以使用 LINQ 来查询。

当你读取文档,进行一些修改,然后尝试上传你修改后的文档版本时,其他人可能已经修改了相同的文档。通常,只有当没有其他人修改了相同的文档时,你才需要执行更新。这可以通过 _etag 属性来完成,Cosmos DB 会自动将其附加到每个文档上。此属性值在每次更新后都会改变,因此你需要遵循以下步骤:

  1. 将 _etag JSON 属性映射到你的 .NET 类的一个属性上,这样在读取文档时你就能得到它的值。

  2. 将 _etag 属性的原始值作为传递给 ReplaceDocumentAsync 客户端方法的 option 对象中 AccessCondition 属性的值。

  3. 如果 _etag 发生了变化,则调用 ReplaceDocumentAsync,中止操作并返回异常。

此外,还有 MvcControlsToolkit.Business.DocumentDB NuGet 包,它简化并自动化了 Microsoft.Azure.DocumentDB.Core 库所需的所有操作,并克服了 Cosmos DB SQL 的一些限制。进一步阅读部分包含了对 Microsoft.Azure.DocumentDB.Core 和 MvcControlsToolkit.Business.DocumentDB 的教程的引用。

Cosmos DB 实体框架核心提供程序

Cosmos DB 实体框架核心提供程序包含在 Microsoft.EntityFrameworkCore.Cosmos NuGet 包中。一旦将其添加到项目中,你就可以以与在 第六章 中使用 SQL Server 提供程序时类似的方式进行操作,但在一些方面有所不同。让我们来看看:

  1. 由于 Cosmos DB 数据库没有结构需要更新,因此没有迁移。相反,它有一个确保数据库以及所有必要的集合被创建的方法:
context.Database.EnsureCreated();
  1. DbSet<T> 和 DBContext 属性并不一一对应数据库集合,因为集合可以包含具有不同结构的对象,所以几个 DbSet<T> 属性可以映射到同一个集合。此外,默认情况下,所有 DbSet<T> 属性都映射到唯一的集合,因为这是最经济的选项,但你可以通过以下配置指令显式指定要将某些实体映射到哪个集合来覆盖此默认设置:
builder.Entity<MyEntity>()
     .ToContainer("collection-name");
  1. 实体类上唯一有用的注解是 Key 属性,当主键被称作 Id 时,该属性变得强制使用。

  2. 主键必须是字符串,并且不能自动递增,以避免在分布式环境中的同步问题。可以通过生成 GUID 并将其转换为字符串来确保主键的唯一性。

  3. 当定义实体之间的关系时,你可以指定一个实体或实体集合属于另一个实体,在这种情况下,它将与父实体一起存储。

在下一节中,我们将探讨 Cosmos DB 实体框架提供程序的使用。

用例 - 存储数据

现在我们已经学会了如何使用 NoSQL,我们必须决定 NoSQL 数据库是否适合我们的 WWTravelClub 应用程序。我们需要存储以下数据系列:

  • 关于可用目的地和套餐的信息:对此数据的相关操作主要是读取,因为套餐和目的地不经常改变。然而,它们必须尽可能快地从世界各地访问,以确保用户浏览可用选项时的良好用户体验。因此,一个具有地理分布副本的分布式关系数据库是可能的,但不是必要的,因为套餐可以存储在其目的地内的更便宜的 NoSQL 数据库中。

  • 目的地评论:在这种情况下,分布式写入操作有不可忽视的影响。此外,大多数写入都是添加,因为评论通常不会被更新。添加操作从分片中获得很大好处,并且不会像更新那样引起一致性问题。因此,此数据的最佳选择是一个 NoSQL 集合。

  • 预留:在这种情况下,一致性错误是不可接受的,因为它们可能导致超订。读取和写入有相似的影响,但我们需要可靠的交易和良好的一致性检查。幸运的是,数据可以组织在一个多租户数据库中,其中租户是目的地,因为不同目的地的预订信息完全无关。因此,我们可能使用分片 SQL Azure 数据库实例。

总之,对于第一和第二点中的数据,最佳选择是 Cosmos DB,而对于第三点,最佳选择是 Azure SQL Server。实际应用程序可能需要对所有数据操作及其频率进行更详细的分析。在某些情况下,为各种可能的选项实现原型并在所有这些上执行典型工作负载的性能测试是值得的。

在本节的剩余部分,我们将把我们在第六章,“使用 C#与数据交互 - Entity Framework Core”中查看的 destinations/packages 数据层迁移到 Cosmos DB。

使用 Cosmos DB 实现目的地/包数据库

让我们继续到我们在第六章,“使用 C#与数据交互 – Entity Framework Core”中构建的数据库示例,按照以下步骤将其迁移到 Cosmos DB:

  1. 首先,我们需要复制 WWTravelClubDB 项目,并将 WWTravelClubDBCosmo 设置为新根文件夹。

  2. 打开项目并删除迁移文件夹,因为不再需要迁移了。

  3. 我们需要替换 SQL Server Entity Framework 提供程序与 Cosmos DB 提供程序。为此,转到管理 NuGet 包并卸载 Microsoft.EntityFrameworkCore.SqlServer NuGet 包。然后,安装 Microsoft.EntityFrameworkCore.Cosmos NuGet 包。

  4. 然后,对 Destination 和 Package 实体执行以下操作:

    • 删除所有数据注释。

    • 将 [Key] 属性添加到它们的 Id 属性,因为这对于 Cosmos DB 提供程序是强制性的。

    • 将 Package 和 Destination 的 Id 属性类型以及 PackagesListDTO 类从 int 转换为 string。我们还需要将 Package 中的 DestinationId 外部引用和在 PackagesListDTO 类中的 DestinationId 转换为 string。实际上,对于分布式数据库中的键,最佳选择是从 GUID 生成的字符串,因为当表数据分布在多个服务器之间时,很难维护一个身份计数器。
  1. 在 MainDBContext 文件中,我们需要指定与目的地相关的包必须存储在目的地文档本身中。这可以通过在 OnModelCreating 方法中将 Destination-Package 关系配置替换为以下代码来实现:
builder.Entity<Destination>()
    .OwnsMany(m => m.Packages); 
  1. 在这里,我们必须将 HasMany 替换为 OwnsMany。没有 WithOne 的等效项,因为一旦实体被拥有,它必须只有一个所有者,而 MyDestination 属性包含对父实体的指针的事实可以从其类型中看出。Cosmos DB 也允许使用 HasMany,但在这种情况下,两个实体并不是嵌套在一另一个中。还有用于在实体内部嵌套单个实体的 OwnOne 配置方法。

  2. 实际上,OwnsMany 和 OwnsOne 都适用于关系数据库,但在这个案例中,HasMany 和 HasOne 之间的区别在于,子实体会自动包含在返回其父实体的所有查询中,无需指定 Include LINQ 子句。然而,子实体仍然存储在单独的表中。

  3. LibraryDesignTimeDbContextFactory 必须修改为使用 Cosmos DB 连接数据,如下面的代码所示:

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;

namespace WWTravelClubDB
{
    public class LibraryDesignTimeDbContextFactory
        : IDesignTimeDbContextFactory<MainDBContext>
    {
        private const string endpoint = "<your account endpoint>";
        private const string key = "<your account key>";
        private const string datbaseName = "packagesdb";
        public MainDBContext CreateDbContext(params string[] args)
        {
            var builder = new DbContextOptionsBuilder<Main
            DBContext>();

            builder.UseCosmos(endpoint, key, datbaseName);
            return new MainDBContext(builder.Options);
        }
    }
}
  1. 最后,在我们的测试控制台中,我们必须明确使用 GUIDS 创建所有实体主键:
var context = new LibraryDesignTimeDbContextFactory()
    .CreateDbContext();
context.Database.EnsureCreated();
var firstDestination = new Destination
{
    Id = Guid.NewGuid().ToString(),
    Name = "Florence",
    Country = "Italy",
    Packages = new List<Package>()
    {
    new Package
    {
        Id=Guid.NewGuid().ToString(),
        Name = "Summer in Florence",
        StartValidityDate = new DateTime(2019, 6, 1),
        EndValidityDate = new DateTime(2019, 10, 1),
        DuratioInDays=7,
        Price=1000
    },
    new Package
    {
        Id=Guid.NewGuid().ToString(),
        Name = "Winter in Florence",
        StartValidityDate = new DateTime(2019, 12, 1),
        EndValidityDate = new DateTime(2020, 2, 1),
        DuratioInDays=7,
        Price=500
    }
    }
};

在这里,我们调用 context.Database.EnsureCreated() 而不是应用迁移,因为我们只需要创建数据库。一旦数据库和集合被创建,我们就可以从 Azure Portal 调整它们的设置。希望未来版本的 Cosmos DB Entity Framework Core 提供程序将允许我们指定所有集合选项。

  1. 最后,以 context.Packages.Where... 开头的最终查询必须进行修改,因为查询不能从嵌套在其他文档中的实体开始(在我们的情况下,Package 实体)。因此,我们必须从我们的 DBContext 中的唯一根 DbSet<T> 属性开始我们的查询,即 Destinations。我们可以借助 SelectMany 方法从列出外部集合切换到列出所有内部集合,该方法执行所有嵌套 Packages 集合的逻辑合并。然而,由于 CosmosDB SQL 不支持 SelectMany,我们必须使用 AsIenumerable() 在客户端强制模拟 SelectMany,如下面的代码所示:
var list = context.Destinations
    .AsEnumerable() // move computation on the client side
    .SelectMany(m => m.Packages)
    .Where(m => period >= m.StartValidityDate....)
    ...
  1. 查询的其余部分保持不变。如果您现在运行项目,应该看到与 SQL Server 的情况相同的输出(除了主键值之外)。

  2. 执行程序后,前往您的 Cosmos DB 账户。您应该看到以下类似的内容:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/32c20c41-6c2d-466e-9048-f45ae8d0d406.png

包已经按照要求嵌套在其目的地内部,Entity Framework Core 创建了一个与 DBContext 类同名的唯一集合。

如果您想在不用完所有免费 Azure Portal 信用额度的情况下继续实验 Cosmos DB 开发,您可以安装此链接提供的 Cosmos DB 模拟器:aka.ms/cosmosdb-emulator。

摘要

在本章中,我们探讨了 Azure 中可用的主要存储选项,并学习了何时使用它们。然后,我们比较了关系型数据库和 NoSQL 数据库。我们指出,关系型数据库提供自动一致性检查和事务隔离,但 NoSQL 数据库更便宜,并提供更好的性能,尤其是在平均负载中分布式写入占很大比例时。

然后,我们介绍了 Azure 的主要 NoSQL 选项 Cosmos DB,并解释了如何配置它以及如何与客户端连接。

最后,我们学习了如何使用 Entity Framework Core 与 Cosmos DB 交互,并查看了一个基于 WWTravelClubDB 用例的实用示例。在这里,我们学习了如何为应用程序中涉及的所有数据家族决定使用关系型数据库还是 NoSQL 数据库。这样,您可以选择确保数据一致性、速度和每个应用程序中数据并行访问之间最佳折衷的数据存储类型。

在下一章中,我们将学习有关 Serverless 和 Azure Functions 的所有内容。

问题

  1. Redis 是否是关系型数据库的有效替代品?

  2. NoSQL 数据库是否是关系型数据库的有效替代品?

  3. 在关系型数据库中,哪种操作更难进行扩展?

  4. NoSQL 数据库的主要弱点是什么?它们的主要优势是什么?

  5. 您能否列出所有 Cosmos DB 的一致性级别?

  6. 我们可以使用自增整数键与 Cosmos DB 一起使用吗?

  7. 哪种 Entity Framework 配置方法用于在相关父文档中存储实体?

  8. 使用 Cosmos DB 是否可以有效地搜索嵌套集合?

进一步阅读

第八章:使用 Azure Functions

正如我们在第四章,“选择最佳云解决方案”中提到的,无服务器架构是提供灵活软件解决方案的最新方式之一。为此,Microsoft Azure 提供了 Azure Functions,这是一种事件驱动的、无服务器的、可扩展的技术,可以加速您的项目开发。本章的主要目标是向您介绍 Azure Functions 以及您在使用它时可以实施的最佳实践。

在本章中,我们将涵盖以下主题:

  • 理解 Azure Functions 应用程序

  • 使用 C# 编程 Azure Functions

  • 维护 Azure Functions

  • 用例 - 实现使用 Azure Functions 发送电子邮件

到本章结束时,您将了解如何使用 C# 的 Azure Functions。

技术要求

本章要求您具备以下条件:

  • 安装了所有数据库工具的 Visual Studio 2017 或 2019 免费社区版或更高版本。

  • 一个免费的 Azure 账户。第一章,“理解软件架构的重要性”中的“创建 Azure 账户”部分解释了如何创建一个。

您可以在github.com/PacktPublishing/Hands-On-Software-Architecture-with-CSharp-8/tree/master/ch08找到本章的示例代码。

理解 Azure Functions 应用程序

Azure Functions 应用程序是一个 Azure PaaS,您可以在其中构建代码片段(函数),并将它们连接到您的应用程序,并使用触发器来启动它们。这个概念相当简单 - 您可以使用您喜欢的语言构建一个函数,并决定启动它的触发器。您可以在系统中编写尽可能多的函数。有些情况下,系统完全使用函数编写。

创建必要环境的步骤与创建函数本身需要遵循的步骤一样简单。以下截图显示了您在创建环境时必须决定的参数。在您选择在 Azure 中创建资源并按 Function App 过滤后,您将看到以下屏幕:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/10f24913-eea6-47e6-801c-18fcc84ee695.png

在创建环境时,您应该考虑几个关键点。第一个是托管计划,您将在其中运行您的函数。托管计划有两种选择:消费计划和应用程序服务计划。让我们现在来谈谈这些。

消费计划

如果您选择消费计划,您的函数只有在执行时才会浪费资源。这意味着您只有在函数运行时才会被收费。可扩展性和内存资源将由 Azure 自动管理。

在此计划中编写函数时,我们需要注意的超时问题。默认情况下,5 分钟后函数将超时。您可以使用functionTimeout参数更改超时值。最大值为 10 分钟。

当您选择消费计划时,您将被收取的费用将取决于您正在执行的内容、它们的执行时间和它们的内存使用情况。更多信息可以在azure.microsoft.com/en-us/pricing/details/functions/找到。

注意,如果您环境中没有 App Service 并且正在运行低周期性的函数,这可以是一个不错的选择。另一方面,如果您需要连续处理,您可能需要考虑 App Service Plan。

App Service Plan

当您想要创建 Azure Functions 应用时,App Service Plan 是您可以选择的选项之一。以下是由 Microsoft 建议的几个原因,说明为什么您应该使用 App Service Plan 而不是消费计划来维护您的函数:

  • 您可以使用未充分利用的现有 App Service 实例。

  • 函数应用可以持续运行或几乎持续运行。

  • 您需要的 CPU 或内存选项比消费计划提供的更多。

  • 您的代码需要运行超过 10 分钟。

  • 您需要诸如 VNET/VPN 连接等特性。

  • 您希望将您的函数应用运行在 Linux 或自定义镜像上。

在 App Service Plan 的场景中,functionTimeout值根据 Azure Function 运行时版本而变化。然而,该值至少为 30 分钟。

使用 C#编程 Azure Functions

在本节中,您将学习如何创建 Azure Functions。值得一提的是,有几种方法可以使用 C#创建它们。第一种是在 Azure Portal 本身创建函数并开发它们。为此,请按照以下步骤操作:

  1. 从主页进入所有资源,搜索wwtravelclub应用,并点击它。您将看到以下屏幕:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/cc02e250-df6d-42f6-bc94-d4499a72b34e.png

  1. 点击“在门户中创建”选项。在这里,您将被提示选择您想要用于启动执行的触发类型。最常用的触发类型是HTTP 请求和定时器触发,如下面的截图所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/6e568874-17e7-461a-a28a-62e874894f58.png

当您决定使用哪个触发器时,您必须为其命名。

  1. 根据您选择的触发器,您将必须安装一些扩展并设置其他参数。例如,HTTP 触发器要求您设置授权级别。有三个选项可供选择,即函数、匿名和管理员,其中我们选择了函数选项,如下面的截图所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/df03fc3b-2b75-470a-b324-b7f3fc449d46.png

值得注意的是,这本书并没有涵盖构建函数时所有可用的选项。作为一名软件架构师,您应该了解 Azure 在函数方面为无服务器架构提供了一个良好的服务。这在几种情况下都可能很有用。这已在第四章,决定最佳云解决方案中进行了更详细的讨论。

  1. 结果如下。请注意,Azure 提供了一个编辑器,允许我们运行代码、检查日志并测试我们创建的函数。这是一个用于测试和编写基本函数的好界面:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/d9e2bd25-f352-4a8c-84f9-17fb2e52eaf3.png

  1. 然而,如果您想创建更复杂的函数,您可能需要一个更复杂的开发环境,以便更有效地进行编码和调试。这就是 Visual Studio Azure Functions 项目能帮到您的所在。

在 Visual Studio 中,您可以通过访问“新建项目”|“云”菜单来创建一个专门用于 Azure Functions 的项目。

  1. 一旦您提交了项目,Visual Studio 将询问您使用的触发类型以及函数将运行的 Azure 版本:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/28194bbf-1190-4717-bef8-f46ee96451d8.png

在撰写本文时,Azure Functions 有两个版本:

  • 在第一个版本中,您可以创建在 .NET Framework 上运行的函数。

  • 在第二个版本中,您可以创建在 .NET Core 上运行的函数。

作为软件架构师,您始终需要考虑代码的可重用性。在这种情况下,您应该注意您将决定在哪个版本的 Azure Functions 项目中构建函数。

默认情况下,生成的代码与您在 Azure Portal 中创建 Azure Functions 时生成的代码类似。发布方法遵循我们在第一章,理解软件架构的重要性中描述的 Web 应用程序发布过程相同的步骤。

列出 Azure Functions 模板

Azure Portal 中有多个模板可供您创建 Azure Functions。您可以选择的模板数量会持续更新。以下只是其中的一些:

  • Blob 存储:您可能希望在文件上传到您的 blob 存储后立即处理该文件。这可能是 Azure 函数的一个很好的用例。

  • Cosmos DB:您可能希望将到达 Cosmos DB 数据库中的数据与处理方法同步。Cosmos DB 在第七章,如何在云中选择您的数据存储中进行了详细讨论。

  • 事件网格:这是一种管理 Azure 事件的好方法。函数可以被触发以管理每个事件。

  • 事件中心:这些可以与 Azure Functions 一起使用来管理每个连接设备到达的数据。

  • HTTP:这个触发器对于构建无服务器 API 和 Web 应用事件非常有用。

  • Microsoft Graph Events:Graph API 允许您提供与 Office 365 相关的功能。例如,使用此触发器,您可以将日历事件连接到函数。

  • 队列存储:您可以使用函数作为服务解决方案来处理队列处理。

  • 服务总线:这是另一种可以触发函数的消息服务。Azure 服务总线将在第九章中更详细地介绍,设计模式和.NET Core 实现。

  • 定时器:这通常与函数一起使用,您在这里指定时间触发器,以便可以持续处理系统中的数据。

  • WebHooks:WebHooks 是一种允许您的应用程序避免从 API 中池化数据的技术。您可以将它们连接到函数,以了解您已挂钩的事件是如何被处理的。

维护 Azure Functions

一旦您创建并编程了函数,您就需要监控和维护它。为此,您可以使用各种工具——所有这些工具您都可以在 Azure 门户中找到。这些工具将帮助您解决由于您将能够收集的大量信息而引起的问题。

当您需要监控函数时,第一个选项是使用 Azure 门户中的 Azure Functions 界面内的监控菜单。在那里,您将能够检查所有函数执行情况,包括成功的结果和失败:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/457f702c-541d-40fe-a83a-06726885644a.png

等待结果大约需要 5 分钟。网格中显示的日期是协调世界时(UTC)。

同一个界面允许您连接到应用洞察(Application Insights)。这将带您进入一个几乎无限的选择世界,您可以使用这些选择来分析您的函数数据。应用洞察(Application Insights)是目前可用的最佳应用性能管理(APM)系统之一:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/dda9710f-d76d-49dd-b6d7-505cefce5689.png

除了查询界面之外,您还可以使用 Azure 门户中的洞察界面检查您函数的所有性能问题。在那里,您可以分析和筛选您的解决方案接收到的所有请求,并检查它们的性能和依赖关系。您还可以在您的端点之一发生异常时触发警报:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/69145b32-05c8-4d37-8782-e2f68c52eebf.png

作为软件架构师,您将在项目中找到这个工具的真正有用的日常助手。值得一提的是,应用洞察(Application Insights)还适用于其他几个 Azure 服务,如 Web 应用和虚拟机。这意味着您可以使用 Azure 提供的出色功能来监控和维护您系统的健康状态。

用例 - 实现 Azure Functions 发送电子邮件

在这里,我们将使用之前描述的 Azure 组件的子集。WWTravelClub 的用例提出了服务的全球实施,并且有可能这个服务需要不同的架构设计来应对我们在第一章中描述的所有性能关键点,即理解软件架构的重要性。

如果你回顾一下在第一章中描述的用户故事,即理解软件架构的重要性,你会发现许多需求都与通信相关。正因为如此,在解决方案中通过电子邮件提供一些警报是非常常见的。本章的用例将专注于如何发送电子邮件。架构将是完全无服务器的。

以下图表显示了架构的基本结构。为了给用户提供良好的体验,应用程序发送的所有电子邮件都将异步排队,从而避免系统响应中的高延迟:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/3916fea9-5954-43af-926e-4156542b61ef.png

注意,没有服务器来管理 Azure Functions 以插入消息到队列存储,也没有服务器来从队列存储获取消息。这正是我们所说的无服务器。值得一提的是,这种架构不仅限于发送电子邮件,还可以用于处理任何 HTTP POST 请求。

现在,我们将学习如何在 API 中设置安全设置,以便只有授权的应用程序可以使用给定的解决方案。

第一步 – 创建 Azure 队列存储

在 Azure Portal 中创建存储非常简单。让我们来学习如何操作:

  1. 首先,你需要创建一个存储账户并设置其名称、安全性和网络,如下面的截图所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/44690125-2574-4cd9-a1c7-144b8c084895.png

  1. 一旦你设置了存储账户,你将能够设置一个队列。你只需要提供队列的名称:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/b50cc828-03a4-44db-ac6a-b810319c49ba.png

  1. 创建的队列将为你提供 Azure Portal 的概览。在那里,你可以找到你的队列 URL 并使用 Storage Explorer:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/e1cd3823-bfe2-47a8-a94b-d1198341894c.png

注意,你还可以使用 Microsoft Azure Storage Explorer (azure.microsoft.com/en-us/features/storage-explorer/) 来连接到此存储:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/1c874dcd-a218-46ba-890b-82167a3f9552.png

  1. 现在,你可以开始你的函数式编程,通知队列有电子邮件等待发送。在这里,我们需要使用 HTTP 触发器。请注意,该函数是一个静态类,它异步运行。以下代码正在收集来自 HTTP 触发器的请求数据并将其插入到稍后处理的队列中:
public static class SendEmail
{
    [FunctionName(nameof(SendEmail))]
    public static async Task<HttpResponseMessage> RunAsync(
    [HttpTrigger(AuthorizationLevel.Function, "post")]   
    HttpRequestMessage req, ILogger log)
    {
        var requestData = await req.Content.ReadAsStringAsync();
        var YOUR_CONNECTION_STRING = 
        "YOUR_AZURE_STORAGE_ACCOUNT_CONNECTION_STRING_HERE";
        var storageAccount = 
        CloudStorageAccount.Parse(YOUR_CONNECTION_STRING);
        var queueClient = storageAccount.CreateCloudQueueClient();
        var messageQueue = queueClient.GetQueueReference("email");
        var message = new CloudQueueMessage(requestData);
        await messageQueue.AddMessageAsync(message);
        log.LogInformation("HTTP trigger from SendEmail function 
        processed a request.");
        return req.CreateResponse(HttpStatusCode.OK, new { success 
        = true }, JsonMediaTypeFormatter.DefaultMediaType);
    }
}
  1. 你可以使用 Postman 这样的工具通过运行 Azure Functions Emulator 来测试你的函数:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/e39b6278-ff70-4d79-8675-95c855e8f3d8.png

  1. 结果将出现在 Microsoft Azure 存储资源管理器和 Azure 门户中。在 Azure 门户中,你可以管理每条消息,对每条消息进行出队,甚至清除队列存储:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/404c9bbe-33ed-48aa-a26b-9e39cd8c13ff.png

  1. 然后,你可以创建第二个函数。这个函数将由进入你的队列的数据触发。值得注意的是,对于 Azure Functions v2,你需要将 Microsoft.Azure.WebJobs.Extensions.Storage 库作为 NuGet 引用添加:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/62b1db91-f9bd-476b-9703-a4dc9eca5b9f.png

  1. 一旦你在 local.settings.json 中设置了连接字符串,你将能够运行这两个函数,并使用 Postman 测试它们。区别在于,当第二个函数正在运行时,如果你在它的开始处设置断点,你会检查消息是否已发送:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/5b1b32ef-bf7e-4be0-bafd-aef379ccc879.png

  1. 从这个点开始,发送邮件的方式将取决于你拥有的邮件选项。你可能决定使用代理,或者直接连接到你的电子邮件服务器。

以这种方式创建电子邮件服务有几个优点:

  • 一旦你的服务被编码和测试,你就可以使用它从你的任何应用程序中发送电子邮件。这意味着你的代码始终可以重用。

  • 使用此服务的应用程序不会因为 HTTP 服务的异步优势而停止发送电子邮件。

  • 你不需要对队列进行池化以检查数据是否准备好处理。

最后,队列处理是并发运行的,这在大多数情况下提供了更好的体验。你可以通过在 host.json 中设置一些属性来关闭它。所有这些选项都可以在本章末尾的 进一步阅读 部分找到。

摘要

在本章中,我们探讨了使用无服务器 Azure Functions 开发功能的一些优点。你可以将其用作检查 Azure Functions 中可用的不同类型触发器的指南,以及规划如何监控它们的计划。我们还看到了如何编程和维护 Azure 函数。最后,我们查看了一个连接多个函数以避免数据池化和启用并发处理的架构示例。

在下一章中,我们将分析设计模式的概念,了解为什么它们如此有用,以及一些常见的模式。

问题

  1. 什么是 Azure Functions?

  2. Azure Functions 的编程选项有哪些?

  3. 可以与 Azure Functions 一起使用的计划有哪些?

  4. 如何使用 Visual Studio 部署 Azure Functions?

  5. 你可以使用哪些触发器来开发 Azure Functions?

  6. Azure Functions v1 和 v2 之间的区别是什么?

  7. Application Insights 如何帮助我们维护和监控 Azure Functions?

进一步阅读

如果你想了解更多关于创建 Azure Functions 的信息,请查看以下链接:

第三部分:应用 21 世纪交付的软件设计原则

在本节中,您将了解现代企业架构中使用的的主要模式、最佳实践和框架。所有示例都是在 C# 8 和 .NET Core 3.0 上运行的。

在第九章,设计模式和 .NET Core 实现中,您将了解知名和通用模式以及最佳实践的 .NET Core 实现,而第十一章,在 C# 8 中实现代码复用,描述了增强代码复用的技术和最佳实践。

第十章,理解软件解决方案中的不同领域,描述了现代领域驱动设计软件生产方法论,这将使您充分利用基于云和微服务架构,并应对需要多个知识领域的复杂应用程序。在那里,您将了解分析技术和涉及基于领域驱动设计项目的架构和工具。

第十二章,使用 .NET Core 应用服务导向架构,以及第十三章,展示 ASP.NET Core MVC,描述了构成现代企业应用骨架的 Web 架构。这两章都基于 ASP.NET Core 3.0,这是随 .Net Core 3.0 一起提供的 Web 框架。第十二章,使用 .NET Core 应用服务导向架构,描述了企业系统可以通过在 Web 或私有网络上公开的端点被外部客户端应用程序访问的 Web 架构。第十二章,使用 .NET Core 应用服务导向架构,专注于 ASP.NET Core MVC Web 应用程序,因为它们不需要特定的客户端,因为它们使用浏览器作为客户端。更具体地说,它们通过发送到标准浏览器的 HTML 与用户交互。

本节包括以下章节:

  • 第九章,设计模式和 .NET Core 实现

  • 第十章,理解软件解决方案中的不同领域

  • 第十一章,在 C# 8 中实现代码复用

  • 第十二章,使用 .NET Core 应用服务导向架构

  • 第十三章,展示 ASP.NET Core MVC

第九章:设计模式与.NET Core 实现

设计模式可以被定义为针对你在软件开发过程中遇到的常见问题的现成架构解决方案。它们对于理解.NET Core 架构至关重要,并且在解决我们在设计任何软件时面临的普通问题非常有用。在本章中,我们将探讨一些设计模式的实现。值得一提的是,这本书并没有解释我们可以使用的所有已知模式。这里的重点是解释研究和应用它们的重要性。

在本章中,我们将涵盖以下主题:

  • 理解设计模式及其目的

  • 理解.NET Core 中可用的设计模式

在本章结束时,你将了解一些来自 WWTravelClub 的使用案例,你可以使用设计模式来实现。

技术要求

你需要以下内容来完成本章:

  • Visual Studio 2017 或 2019 免费社区版或更高版本,并安装了所有数据库工具。

  • 一个免费的 Azure 账户。第一章的创建 Azure 账户小节,理解软件架构的重要性,解释了如何创建一个。

你可以在github.com/PacktPublishing/Hands-On-Software-Architecture-with-CSharp-8/tree/master/ch09找到本章的示例代码。

理解设计模式及其目的

能够决定系统的设计是一项挑战,与此任务相关的责任是巨大的。作为软件架构师,我们必须始终牢记,诸如高度可重用性、良好性能和良好可维护性等特性是关键。这就是设计模式帮助并加速设计过程的地方。

正如我们之前提到的,设计模式是已经被讨论和定义的解决方案,以便它们可以解决常见的软件架构问题。这种做法在《设计模式 – 可重用面向对象软件的元素》一书发布后变得流行起来,其中四人帮(GoF)将这些模式分为三类:

  • 创建

  • 结构

  • 行为

一段时间后,Uncle Bob 向开发者社区介绍了 SOLID 原则,这给了我们有效地将每个系统的函数和数据结构组织到类中的机会。SOLID 原则指出了这些类应该如何连接。值得一提的是,与 GoF 提出的设计模式相比,SOLID 原则并不提供代码配方。相反,它们为你提供了在设计解决方案时应该遵循的基本原则。

随着技术和软件问题的变化,更多的模式被构思出来。云计算的进步带来了一大堆模式,所有这些都可以在docs.microsoft.com/azure/architecture/patterns/找到。

您应该始终考虑它们的原因很简单——作为一个软件架构师,您不能花时间重新发明轮子。然而,使用它们的另一个很好的原因是:您会发现许多模式在.NET Core 中得到了实现。

在接下来的几个小节中,我们将介绍一些最著名的模式。然而,本章的目的是让您知道它们的存在并需要对其进行研究,以便您能够加速并简化您的项目。此外,每个模式都将通过一个 C#代码片段进行展示,这样您就可以轻松地将它们应用到您的项目中。

构建者模式

有时候,您会拥有一个具有不同行为的复杂对象,这是由于其配置造成的。在您使用它的时候设置这个对象,您可能希望将其配置与其使用解耦,使用已经构建的定制配置。这样,您就有您正在构建的实例的不同表示。这就是您应该使用构建者模式的地方。

以下类图显示了本书用例场景中实现的模式。这个设计选择背后的想法是简化 WWTravelClub 中房间描述的方式:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/d7b5096e-d9e3-49f8-a384-606831fec9be.png

如以下代码所示,这个实现是以一种方式进行的,其中实例的配置不是在主程序中设置的。相反,您只需使用Construct()方法构建对象。这个例子是在模拟 WWTravelClub 中不同房间风格(单个房间和家庭房间)的创建:

using DesignPatternsSample.BuilderSample;
using System;

namespace DesignPatternsSample
{
    class Program
    {
        static void Main()
        {
            #region Builder Sample
            Console.WriteLine("Builder Sample");

            var directorRoom = new DirectorRooms(new SimpleRoomBuilder());
            var simpleRoom = directorRoom.Construct();
            simpleRoom.Describe();

            directorRoom = new DirectorRooms(new FamilyRoomBuilder());
            var familyRoom = directorRoom.Construct();
            familyRoom.Describe();
            #endregion

            Console.ReadKey();
        }
    }
}

这种实现的成果相当简单,但阐明了为什么您需要实现模式的原因:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/9a770151-f3f3-4c83-b37e-a4effe5208ea.png

一旦您有了实现,代码的演进就变得简单和容易。例如,如果您需要构建不同风格的房间,您只需创建相应的具体构建者,然后您就可以使用它。幸运的是,如果您需要增加产品的配置设置,您之前使用的所有具体类都将定义在 Builder 接口中并存储在那里,这样您就可以轻松地更新它们。

工厂模式

工厂模式在您有多个来自同一抽象的对象,并且您在开始编码时不知道哪个需要被创建的情况下非常有用。这意味着您将不得不根据某种配置或根据软件当前所在的位置来创建实例。

例如,让我们看看 WWTravelClub 示例。在这里,有一个用户故事描述了该应用程序将拥有来自世界各地的客户支付他们的旅行费用。然而,在现实世界中,每个国家都有不同的支付服务。支付过程在每个国家都是相似的,但这个系统将提供多个支付服务。简化这种支付实现的一个好方法是使用工厂模式。以下图表显示了其架构实现的初步概念:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/94214e08-4bd1-45fc-9b37-fa26ce54fa97.png

注意,由于你有一个描述应用程序支付服务的接口,你可以使用工厂根据可用的服务来更改具体的类:

static void Main()
{
    #region Factory Sample
    var psCreator = new PaymentServiceCreator();
    var brazilianPaymentService = (IPaymentService)psCreator.Factory
        (PaymentServiceCreator.ServicesAvailable.Brazilian);
    brazilianPaymentService.EmailToCharge = "gabriel@sample.com";
    brazilianPaymentService.MoneyToCharge = 178.90f;
    brazilianPaymentService.OptionToCharge =
        FactorySample.Enums.EnumChargingOptions.CreditCard;
    brazilianPaymentService.ProcessCharging();

    var italianPaymentService = (IPaymentService)psCreator.Factory
        (PaymentServiceCreator.ServicesAvailable.Italian);
    italianPaymentService.EmailToCharge = "francesco@sample.com";
    italianPaymentService.MoneyToCharge = 188.70f;
    italianPaymentService.OptionToCharge =
        FactorySample.Enums.EnumChargingOptions.DebitCard;
    italianPaymentService.ProcessCharging();
    #endregion
    Console.ReadKey();
}

再次强调,由于实现了模式,服务的使用已经简化。如果你要在实际应用中使用此代码,你将通过在工厂中定义所需的服务的实例来改变实例的行为。

单例模式

当你在应用程序中实现单例时,你将在整个解决方案中实现该对象的单个实例。这可以被认为是每个应用程序中最常用的模式之一。原因很简单——有许多用例需要某些类只有一个实例。单例通过提供比全局变量更好的解决方案来解决这个问题。

在单例模式中,类负责创建和提供单个对象,该对象将被应用程序使用。换句话说,单例类创建一个单例实例:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/73659067-540d-4379-a698-9df3fe07fafa.png

要做到这一点,创建的对象是static的,并通过静态属性或方法提供。以下代码实现了单例模式,它有一个Message属性和一个Print()方法:

public sealed class SingletonDemo
{
    #region This is the Singleton definition
    private static SingletonDemo _instance;
    public static SingletonDemo Current
    {
        get
        {
            if (_instance == null)
                _instance = new SingletonDemo();
            return _instance;
        }
    }
    #endregion

    public string Message { get; set; }

    public void Print()
    {
        Console.WriteLine(Message);
    }
}

其用法非常简单——你只需在需要使用单例对象时调用静态属性:

SingletonDemo.Current.Message = "This text will be printed by the singleton.";
SingletonDemo.Current.Print();

你可能使用此模式的一个地方是当你需要以易于从解决方案的任何地方访问的方式提供应用程序配置时。例如,假设你有一些配置参数存储在一个你的应用程序需要在几个决策点查询的表中。而不是直接查询配置表,你可以创建一个单例类来帮助你:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/93719043-7e2b-43f3-9d1c-182662e4de97.png

此外,你还需要在这个单例中实现一个缓存,从而提高系统的性能,因为你可以决定系统是否每次需要配置时都检查数据库中的每个配置,或者是否使用缓存。以下截图显示了配置每 5 秒加载的缓存实现。在这种情况下正在读取的参数只是一个随机数:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/f2dfb9f8-0240-451d-826b-9ae519f91d8b.png

这对于应用程序的性能来说非常好。此外,在代码的几个地方使用参数更简单,因为你不需要在代码的每个地方都创建配置实例。

代理模式

当你需要提供一个控制对另一个对象访问的对象时,会使用代理模式。你应该这样做的一个最大的原因与被控制对象的创建成本有关。例如,如果被控制的对象创建时间过长或消耗过多内存,可以使用代理来确保对象的大部分只有在需要时才会被创建。

下面的类图显示了从 Room 加载图片的代理模式实现类图,但仅在请求时:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/17bd01c8-99ca-44a8-8311-d618f50ae1d7.png

这个代理的客户将请求其创建。在这里,代理将只从真实对象收集基本信息(Id、FileName和Tags),而不会查询PictureData。当请求PictureData时,代理将加载它:

static void Main()
{
    Console.WriteLine("Proxy Sample");
    var roomPicture = new ProxyRoomPicture();
    Console.WriteLine($"Picture Id: {roomPicture.Id}");
    Console.WriteLine($"Picture FileName: {roomPicture.FileName}");
    Console.WriteLine($"Tags: {string.Join(";", roomPicture.Tags)}");
    Console.WriteLine($"1st call: Picture Data");
    Console.WriteLine($"Image: {roomPicture.PictureData}");
    Console.WriteLine($"2nd call: Picture Data");
    Console.WriteLine($"Image: {roomPicture.PictureData}");
}

如果再次请求PictureData,由于图像数据已经就绪,代理将确保不会重复加载图像。以下截图显示了运行前面代码的结果:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/54cf03cd-511a-4ad9-9fa6-98d6444855e9.png

这种技术可以被称为另一个众所周知的模式:延迟加载。实际上,代理模式是实现延迟加载的一种方式。例如,在 Entity Framework Core 2.1 中,如第六章“使用 C#与数据交互 - Entity Framework Core”中讨论的那样,你可以使用代理来开启延迟加载。你可以在docs.microsoft.com/en-us/ef/core/querying/related-data#lazy-loading了解更多信息。

命令模式

在许多情况下,你需要执行一个将影响对象行为的命令。命令模式可以通过封装这种请求在对象中来帮助你处理这个问题。该模式还描述了如何处理请求的撤销/重做支持。

例如,让我们想象在 WWTravelClub 网站上,用户有权限通过指定他们是否喜欢、不喜欢甚至热爱来评估套餐。下面的类图是使用命令模式创建此评分系统的示例:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/69730dee-9393-4ae2-b992-7ba7e8fcbcc1.png

注意这种模式的工作方式——如果你需要不同的命令,比如 Hate,你不需要更改使用该命令的代码和类。可以通过类似的方式添加 Undo 方法到 Redo 方法。这个完整的代码示例可以在本书的 GitHub 仓库中找到。

发布者/订阅者模式

在所有应用程序中,从对象提供信息到一组其他对象是很常见的。当有大量组件(订阅者)将接收包含由对象(发布者)发送的信息的消息时,发布/订阅模式几乎是强制性的。

这里的概念非常简单易懂,如下面的图示所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/52ef2ea5-0ab4-4c21-8abe-e8ba22065fa3.png

当你有不定数量的不同可能的订阅者时,将广播信息的组件与消费它的组件解耦是至关重要的。发布/订阅模式为我们做到了这一点。

实现这个模式是复杂的,因为分布式环境不是一项简单任务。因此,建议您考虑现有的技术来实现连接输入通道到输出通道的消息代理,而不是从头开始构建。Azure Service Bus 是这个模式的可靠实现,所以你只需要连接到它。

我们在第五章中提到的 RabbitMQ,将微服务架构应用于您的企业应用,是另一个可以用来实现消息代理的服务,但它是对该模式的低级实现,需要执行几个相关任务,例如重试,以防需要手动编码错误。

依赖注入模式

依赖注入模式被认为是实现依赖倒置原则的好方法。此外,它强制所有其他 SOLID 原则在实现中遵循。正如我们在本章开头讨论的,保持软件结构强大和可靠的一种方法是遵循 Uncle Bob 提出的 SOLID 设计原则。这些可以定义为如下:

  • 单一职责:模块或函数应该只负责单一目的。

  • 开闭原则:软件工件应该对扩展开放,但对修改封闭。

  • 里氏替换:当你用初级对象的上类型定义的另一个组件替换程序中的一个组件时,程序的行为需要保持不变。

  • 接口隔离:创建巨大的接口会在构建具体对象时产生依赖,但这些对系统架构是有害的。

  • 依赖倒置:最灵活的系统是那些对象依赖仅指向抽象的系统。

这个概念很简单。你不需要创建组件所依赖的对象的实例,只需定义它们的依赖关系,声明它们的接口,并通过注入使对象接收功能。

有三种方法来实现依赖注入:

  • 使用类的构造函数来接收对象。

  • 标记一些类属性以接收对象。

  • 定义一个带有注入所有必要组件的方法的接口。

下图展示了依赖注入模式的实现:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/2489a07f-eb5f-42fd-9aaf-320472e5bc56.png

除了这个之外,依赖注入还可以与控制反转(Inversion of Control,IoC)容器一起使用。这个容器可以在需要时自动注入依赖。市场上有多款 IoC 容器框架可供选择,但在 .NET Core 中,由于它包含了 Microsoft.Extensions.DependencyInjection 命名空间中解决此问题的库,因此无需使用第三方软件。

这个 IoC 容器负责创建和销毁请求的对象。依赖注入的实现基于构造函数类型。注入组件的生命周期有三种选项:

  • 瞬态(Transient):每次请求对象时都会创建对象。

  • 作用域(Scoped):为应用程序中定义的每个作用域创建对象。在一个 Web 应用中,作用域 与一个 Web 请求相关联。

  • 单例(Singleton):每个对象具有相同的应用程序生命周期,因此单个对象被重用来服务给定类型的所有请求。

你将如何使用这些选项取决于你正在开发的项目中的业务规则。在决定正确的选项时,你需要小心,因为应用程序的行为将根据你注入的对象类型而改变。

理解 .NET Core 中可用的设计模式

正如我们在前面的章节中所发现的,C# 允许我们实现上述任何一种模式。.NET Core 在其 SDK 中提供了许多遵循我们讨论的所有模式的实现,例如 Entity Framework Core 代理懒加载。另一个自 .NET Core 2.1 以来可用的好例子是 .NET Generic Host。

在 第十三章,展示 ASP.NET Core MVC 中,我们将详细介绍 .NET Core 中为 Web 应用提供的托管服务。这个 Web 托管程序有助于我们,因为应用程序的启动和生命周期管理是与它一起设置的。.NET Generic Host 的想法是使这种模式适用于不需要 HTTP 实现的应用程序。使用这个通用宿主,任何 .NET Core 程序都可以有一个 Startup 类,在其中我们可以配置依赖注入引擎。这对于创建多服务应用程序非常有用。

您可以在.NET 通用宿主docs.microsoft.com/en-us/aspnet/core/fundamentals/host/generic-host中找到更多信息,其中包含一些示例代码。本书 GitHub 仓库中提供的代码更简单,但它专注于创建一个可以运行监控服务的控制台应用程序。关于这一点,控制台应用程序的设置方式很出色,其中建造者配置应用程序将提供的服务,以及日志管理的方式。以下代码展示了这一点:

public static void Main()
{
    var host = new HostBuilder()
        .ConfigureServices((hostContext, services) =>
        {
            services.AddHostedService<HostedService>();
            services.AddHostedService<MonitoringService>();
        })
        .ConfigureLogging((hostContext, configLogging) =>
        {
            configLogging.AddConsole();
        })
        .Build();
    host.Run();

    Console.WriteLine("Host has terminated. Press any key to finish the 
    App.");
    Console.ReadKey();
 }

上述代码让我们了解了.NET Core 如何使用设计模式。使用建造者模式,.NET 通用宿主允许您设置将被注入为服务的类。除此之外,建造者模式还帮助您配置一些其他功能,例如日志的显示/存储方式。这种配置允许服务将ILogger<out TCategoryName>对象注入到任何实例中。

摘要

在本章中,我们了解了为什么设计模式有助于提高您所构建的系统部分的维护性和可重用性。我们还探讨了您可以在项目中使用的典型用例和代码片段。最后,我们介绍了.NET 通用宿主,这是.NET 如何使用设计模式来实现代码重用和强制最佳实践的良例。所有这些内容都将帮助您在架构新的软件或维护现有软件时,因为设计模式是针对软件开发中一些现实生活问题的已知解决方案。

在下一章中,我们将介绍领域驱动设计方法。我们还将学习如何使用 SOLID 设计原则,以便我们可以将不同的领域映射到我们的软件解决方案中。

问题

  1. 什么是设计模式?

  2. 设计模式和设计原则之间的区别是什么?

  3. 在什么情况下实现建造者模式是一个好主意?

  4. 在什么情况下实现工厂模式是一个好主意?

  5. 在什么情况下实现单例模式是一个好主意?

  6. 在什么情况下实现代理模式是一个好主意?

  7. 在什么情况下实现命令模式是一个好主意?

  8. 在什么情况下实现发布/订阅模式是一个好主意?

  9. 在什么情况下实现依赖注入模式是一个好主意?

进一步阅读

以下是一些书籍和网站,您可以在其中找到更多关于本章所涵盖内容的信息:

  • 《整洁架构:软件结构和设计的工匠指南》,Martin, Robert C. Pearson Education,2018。

  • 《设计模式:可复用面向对象软件元素》,Erica Gamma 等。Addison-Wesley,1994。

  • 《设计原则与设计模式》,Robert C. Martin,2000。

如果您需要获取有关设计模式和架构原则的更多信息,请查看以下链接:

如果你想更好地理解通用宿主的概念,请点击此链接:

在此链接中有一个关于服务总线消息的非常好的解释:

你可以通过以下链接了解更多关于依赖注入的信息:

第十章:理解软件解决方案中的不同领域

本章致力于一种现代软件开发技术,称为领域驱动设计(DDD),它最初由 Eric Evans 提出。虽然 DDD 已经存在了 15 多年,但由于其解决两个重要问题的能力,它在过去几年取得了巨大的成功:

  • 在没有单个专家对整个领域有深入知识的情况下建模复杂系统。这种知识被分散在几个人之间。

  • 面对由多个开发团队组成的大型项目。一个项目被分成几个团队的原因有很多,最常见的是团队规模以及所有成员拥有不同的技能和/或不同的地点。事实上,经验证明,超过 6-8 人的团队效率不高,而且不同的技能和地点显然会阻止紧密的互动。团队划分阻止了项目所有相关人员之间的紧密互动。

相反,上述两个问题的重要性在过去几年中增长,原因如下:

  • 软件系统始终占据每个组织内部的大量空间,并且变得越来越复杂和地理上分散。

  • 同时,频繁更新的需求增加,以便这些复杂的软件系统能够根据快速变化的市场需求进行适应。

  • 前述问题导致了更复杂的 CI/CD 周期的构想和采用复杂的分布式架构,这些架构可能利用可靠性、高吞吐量、快速更新以及逐步演进旧子系统的能力。是的——我们正在谈论的是我们在第五章,“将微服务架构应用于您的企业应用”中分析过的基于微服务和容器架构。

在这个场景中,通常需要实现具有相关快速 CI/CD 周期的复杂软件系统,这些系统始终需要更多的人来演进和维护。反过来,这导致了开发适合高复杂度领域和多个松散耦合开发团队协作的技术需求。

在本章中,我们将分析与领域驱动设计(DDD)相关的基本原则、优势和常见模式,以及如何在我们的解决方案中使用它们。更具体地说,我们将涵盖以下主题:

  • 软件领域是什么?

  • 理解领域驱动设计

  • 使用 SOLID 原则映射您的领域

  • 用例场景 - 理解用例的应用领域

让我们开始吧。

技术要求

本章需要 Visual Studio 2017 或 2019 免费社区版或更高版本,并安装所有数据库工具。

本章中所有的代码片段都可以在本书相关的 GitHub 仓库中找到,github.com/PacktPublishing/Hands-On-Software-Architecture-with-CSharp-8。

软件领域是什么?

如我们在第二章“功能和非功能需求”和第三章“使用 Azure DevOps 记录需求”中讨论的,从领域专家到开发团队的知识转移在软件设计中起着根本的作用。开发者试图与专家沟通,并使用领域专家和利益相关者能够理解的语言来描述他们的解决方案。然而,在组织的各个部分,同一个词可能有不同的含义,而在不同的上下文中,看似相同的概念实体可能有完全不同的形态。

例如,在我们的 WWTravelClub 用例中,订单支付和包裹处理子系统对客户使用完全不同的模型。订单支付通过支付方式、货币、银行账户和信用卡来表征客户,而包裹处理则更关注过去访问和/或购买过的位置和包裹、用户的偏好以及他们的地理位置。此外,虽然订单支付使用我们可能粗略定义为“银行语言”的语言来指代各种概念,但包裹处理则使用典型的旅行社/运营商语言。

应对这些差异的经典方式是使用一个独特的抽象实体称为客户,它映射到两个不同的视图——订单支付视图和包裹处理视图。每个投影操作都从客户抽象实体中提取一些操作和属性,并更改它们的名称。由于领域专家只给我们提供投影视图,我们作为系统设计者的主要任务是创建一个可以解释所有视图的概念模型。以下图表显示了如何处理不同的视图:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/68ac81ab-ebad-4a37-921f-c8e3bf813822.png

经典方法的主要优势是我们有一个独特且一致的数据表示。如果这个概念模型构建成功,所有操作都将有一个正式的定义和目的,整个抽象将是整个组织应该工作方式的合理化,可能突出和纠正错误,并简化一些程序。

然而,这种方法的缺点是什么?首先,工作组织的这种方式可能会对现有的组织产生过度的影响,可能阻止它在一定时间内正确运行,因为使用唯一连贯模型的约束没有提供足够的选项来减轻这种影响。必须消除错误,必须消除重复,并且一切必须完美连贯,以便有一个最小的不可减轻的影响,因为我们减轻影响的唯一方式就是放弃整体连贯性。

在小型组织、软件旨在服务于整体组织的小部分,或者软件自动化的数据流百分比足够小的情况下,这种最小影响是可以接受的。然而,随着软件成为整个地理分布组织的骨干,剧烈的变化变得更加难以接受和不可行。此外,随着软件系统复杂性的增加,还会出现其他几个问题,如下所示:

  • 由于我们不能通过将这些任务分解成更小的松散耦合任务来面对复杂性,因此形成唯一连贯的数据视图变得更加困难。

  • 随着复杂性的增加,需要频繁的系统变更,但更新和维护一个独特的全局模型相当困难。此外,由于系统小部分变更引入的 bug/错误可能会通过唯一共享的模型传播到整个组织。

  • 系统建模必须由几个团队分担,并且只能面对由单独团队处理的松散耦合任务。

  • 需要转向基于微服务架构,这使得唯一数据库的瓶颈更加难以接受。

  • 随着系统的增长,我们需要与更多的领域专家沟通,每位专家都说不同的语言,并且对数据模型有不同的看法。因此,我们需要将我们独特模型的属性和操作翻译成/从更多语言,以便与他们沟通。

  • 随着系统的增长,处理具有数百/数千个字段的记录变得更加低效。这种低效源于数据库引擎在处理具有多个字段的较大记录时效率低下(内存碎片化、太多相关索引的问题等)。然而,主要的不效率发生在 ORM 和业务层,它们被迫在更新操作中处理这些大记录。事实上,虽然查询操作通常只需要从存储引擎检索的几个字段,但更新和业务处理涉及整个实体。

  • 随着数据存储子系统流量的增长,我们需要在所有数据操作中实现读取和更新/写入并行性。正如我们在第七章“如何在云中选择您的数据存储”中讨论的那样,虽然读取并行性可以通过数据复制轻松实现,但写入并行性需要分片,而将一个独特且紧密连接的数据模型分片是困难的。

这些问题是领域驱动设计在过去几年中取得成功的原因,因为它们代表了更复杂的软件系统,这些系统成为了整个组织的支柱。下一节将详细讨论 DDD 的基本原则。

理解领域驱动设计

领域驱动设计是关于构建一个独特的领域模型,并保持所有视图作为独立的模型。因此,整个应用程序领域被分割成更小的领域,每个领域都有一个独立的模型。这些独立的领域被称为边界上下文。每个领域以其专家所使用的语言为特征,并用于命名所有领域概念和操作。因此,每个领域定义了一种由专家和开发团队共同使用的通用语言,称为通用语言。不再需要翻译,如果开发团队以接口作为其代码的基础,领域专家能够理解和验证它们,因为所有操作和属性都是使用专家使用的相同语言表达的。

在这里,我们正在摒弃一个繁琐的唯一抽象模型,但现在我们有了几个需要以某种方式关联的独立模型。DDD 提出将处理所有这些独立模型,即所有边界上下文,如下所示:

  • 每当语言术语的含义发生变化时,我们需要添加边界上下文的边界。例如,在 WWTravelClub 用例中,订单支付和套餐处理属于不同的边界上下文,因为它们对单词客户赋予了不同的含义。

  • 我们需要明确表示边界上下文之间的关系。不同的开发团队可能在不同边界上下文中工作,但每个团队都必须对其正在工作的边界上下文与其他所有模型之间的关系有一个清晰的了解。为此,这种关系在一份独特的文档中表示,并与每个团队共享。

  • 我们需要确保所有边界上下文与持续集成保持一致。会议被组织和简化系统原型被构建,以验证所有边界上下文是否协调一致地发展,也就是说,所有边界上下文都可以集成到期望的应用程序行为中。

下面的图显示了我们在上一节中讨论的 WWTravelClub 示例在采用领域驱动设计(DDD)后的变化:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/f7b60e75-1f5b-4d4f-88cd-fb420114cd19.png

两个边界上下文的客户实体之间存在关系,而包裹处理边界上下文的购买实体与支付相关。识别映射到各个边界上下文中彼此对应的实体是正式定义表示所有上下文之间可能通信的接口的第一步。

例如,从前面的图中,我们知道支付是在购买之后进行的,因此我们可以推断出支付订单边界上下文必须有一个为特定客户创建支付的操作。在这个领域,如果新客户不存在,则会创建新客户。支付创建操作在购买后立即触发。由于在购买商品后还会触发更多操作,我们可以使用我们在第九章中解释的发布/订阅模式来实现与购买事件相关的所有通信,该模式称为设计模式和.NET Core 实现。这些在领域驱动设计中被称为领域事件。使用事件来实现边界上下文之间的通信非常常见,因为它有助于保持边界上下文松散耦合。

一旦在边界上下文接口中定义的事件或操作的实例跨越了上下文边界,它就会立即被翻译成接收上下文的通用语言。在输入数据开始与其他领域实体交互之前执行这种翻译非常重要,以避免其他领域的通用语言被额外的上下文术语污染。

每个边界上下文实现都必须包含一个数据模型层,该层完全用边界上下文通用语言(类和接口名称以及属性和方法名称)表达,没有任何其他边界上下文通用语言的污染,也没有编程技术内容的污染。这是确保与领域专家良好沟通并确保将领域规则正确翻译成代码,以便领域专家可以轻松验证的必要条件。

当通信语言与目标通用语言之间存在强烈不匹配时,会在接收边界上下文边界处添加一个反腐败层。这个反腐败层的唯一目的是执行语言翻译。

包含所有 Bounded Context 表示、Bounded Context 之间的相互关系和接口定义的文档称为上下文映射。上下文之间的关系包含组织约束,指定了在不同 Bounded Context 上工作的团队之间所需的那种合作类型。这些关系不约束 Bounded Context 接口,但会影响它们在软件 CI/CD 周期中可能的发展方式。它们代表了团队合作模式。最常见的模式如下:

  • 合作伙伴:这是 Eric Evans 最初提出的模式。其想法是两个团队在交付上相互依赖。换句话说,他们共同决定,如果需要,在软件 CI/CD 周期中更改 Bounded Context 的相互通信规范。

  • 客户/供应商开发团队:在这种情况下,一个团队充当客户,另一个团队充当供应商。两个团队定义 Bounded Context 的客户端接口以及一些自动化验收测试来验证它。之后,供应商可以独立工作。当客户的 Bounded Context 是唯一活跃的部分,调用其他 Bounded Context 暴露的接口方法时,这种模式是有效的。对于订单-支付和包裹处理之间的交互,订单-支付作为供应商,因为其功能从属于包裹处理的需求。当这种模式可以应用时,它将两个 Bounded Context 完全解耦。

  • 遵从者:这与客户/供应商类似,但在这个情况下,客户端接受由供应商端强加的接口,没有协商阶段。这种模式对其他模式没有提供任何优势,但有时我们被迫处于模式所描述的情况,因为供应商的 Bounded Context 是在一个无法过多配置/修改的现有产品中实现的,或者因为它是一个我们不希望修改的遗留子系统。

值得指出的是,Bounded Context 的分离只有在结果 Bounded Context 是松散耦合的情况下才是有效的;否则,通过将整个系统分解为子部分而获得的复杂性降低将被协调和通信过程的复杂性所克服。然而,如果 Bounded Context 是以语言标准定义的,即每当通用语言发生变化时,就添加 Bounded Context 边界,那么这实际上应该是这种情况。事实上,不同的语言可能只是由于组织子部分之间松散交互的结果而产生的,因为一个组织要与之互动的子部分越多,它们最终使用的共同语言就越多。

此外,所有人类组织都可以通过演变成松散耦合的子部分来增长,这与复杂软件系统可能仅为了松散耦合子模块的合作而实现的原因相同:这是人类能够应对复杂性的唯一方式。由此,我们可以得出结论,复杂组织/人工系统总是可以被分解成松散耦合的子部分。我们只需要理解如何。

除了我们之前提到的基本原理之外,DDD 提供了一些基本原语来描述每个边界上下文,以及一些实现模式。虽然边界上下文原语是 DDD 的一个组成部分,但这些模式只是我们在实现中可以使用的有用启发式方法,因此一旦我们选择采用 DDD,在某些或所有边界上下文中使用这些模式并不是强制性的。

在下一节中,我们将描述原语和模式。

实体和价值对象

DDD 实体代表具有明确定义的标识以及在其上定义的所有操作的领域对象。它们与其他更经典方法中的实体差别不大。此外,DDD 实体是存储层设计的起点。主要区别在于,DDD 更强调它们的面向对象特性,而其他方法主要将它们用作记录,其属性可以在没有太多约束的情况下写入/更新。另一方面,DDD 强制实施强 SOLID 原则,以确保只有某些信息被封装在其中,并且只有某些信息可以从外部访问,哪些操作允许在它们上执行,以及哪些业务级别的验证标准适用于它们。

换句话说,DDD 实体比基于记录的方法中的实体更丰富。在其他方法中,操作实体是在代表业务和/或领域操作的类外部定义的。在 DDD 中,这些操作被移动到实体定义中,作为它们的类方法。这样做的原因是它们提供了更好的模块化,并将相关的软件块保持在同一位置,以便可以轻松维护和测试。

由于同样的原因,业务验证规则被移动到 DDD 实体内部。DDD 实体验证规则是业务级别的规则,因此它们不能与数据库完整性规则或用户输入验证规则混淆。它们通过编码所表示对象必须遵守的约束,有助于实体以某种方式表示领域对象。在 .NET Core 中,可以使用以下列出的技术之一执行业务验证:

  • 在修改实体的所有类方法中调用验证方法

  • 将验证方法连接到所有属性设置器

  • 通过使用自定义验证属性装饰类及其属性,然后在每次修改实体时调用System.ComponentModel.DataAnnotations.Validator类的TryValidateObject静态方法

一旦检测到验证错误,就必须以某种方式处理,也就是说,必须中止当前操作,并将错误报告给适当的错误处理器。处理验证错误的最简单方法是通过抛出异常。这样,两个目的都很容易实现,我们可以选择在哪里拦截和处理它们。不幸的是,正如我们在第二章“在 C#编程时需要考虑的性能问题”部分中讨论的那样,异常意味着巨大的性能损失,因此,通常需要考虑不同的选项。在正常流程控制中处理错误会破坏模块化,因为需要处理错误的代码会散布到导致错误的各个方法栈中,并且代码中会有一个不断变化的条件集。因此,需要更复杂的选择。

异常的一个好替代方案是通知错误处理器在依赖注入引擎中定义的任何错误。由于作用域,在处理每个请求时都会返回相同的服务实例,这样控制整个调用栈执行的处理器就可以在控制流返回时检查可能出现的错误,并适当地处理它们。不幸的是,这种复杂的技巧不能立即中止操作执行或将其返回给控制处理器。因此,开发者被迫添加适当的控制条件来防止操作继续进行。这就是为什么尽管存在性能问题,但仍然建议在这种情况下使用异常。

业务级验证不应与输入验证混淆,输入验证将在第十三章“展示 ASP.NET Core MVC”中更详细地讨论,因为这两种类型的验证有不同的和补充的目的。虽然业务级验证规则编码了领域规则,但输入验证强制执行每个单个输入的格式(字符串长度、电子邮件和 URL 的正确格式等),确保提供了所有必要的输入,强制执行所选的用户-机器交互协议,并提供快速和即时的反馈,引导用户与系统交互。

由于 DDD 实体必须有一个明确定义的身份,它们必须具有作为主键的属性。通常,会覆盖所有 DDD 实体的Object.Equal方法,使得当两个对象具有相同的主键时,它们被认为是相等的。这可以通过让所有实体继承自一个抽象的Entity类来实现,如下面的代码所示:

public abstract class Entity<K>: IEntity<K>
    where K: IEqualityComparer<K>
{

    public virtual K Id { get; protected set; }
    public bool IsTransient()
    {
        return Object.Equals(Id, default(K));
    }
    public override bool Equals(object obj)
    {
        if (obj == null || !(obj is Entity<K>))
            return false;
        if (Object.ReferenceEquals(this, obj))
            return true;
        if (this.GetType() != obj.GetType())
            return false;
        Entity<K> item = (Entity<K>)obj;
        if (item.IsTransient() || this.IsTransient())
            return false;
        else
            return Object.Equals(item.Id, Id);
    }
    int? _requestedHashCode;
    public override int GetHashCode()
    {
        if (!IsTransient())
        {
            if (!_requestedHashCode.HasValue)
                _requestedHashCode = this.Id.GetHashCode() ^ 31;
            return _requestedHashCode.Value;
        }
        else
            return base.GetHashCode();
    }
    public static bool operator ==(Entity<K> left, Entity<K> right)
    {
        if (Object.Equals(left, null))
            return (Object.Equals(right, null));
        else
            return left.Equals(right);
    }
    public static bool operator !=(Entity<K> left, Entity<K> right)
    {
        return !(left == right);
    }
}

值得指出的是,一旦我们在Entity类中重新定义了Object.Equal方法,我们也可以通过==和!=运算符来覆盖它们。

IsTransient谓词在实体最近被创建且尚未记录在持久存储中时返回true,因此其主键尚未定义。

在.NET 中,当覆盖一个类的Object.Equal方法时,也覆盖其Object.GetHashCode方法是一个好的实践,以便类实例可以有效地存储在字典和集合等数据结构中。这就是为什么 Entity 类覆盖了它。

值得注意的是,实现一个定义了Entity<K>的所有属性/方法的IEntity<K>接口。当我们需要在接口后面隐藏数据类时,这个接口非常有用。

另一方面,值对象代表无法用数字或字符串编码的复杂类型。因此,它们没有标识符和主键。它们上没有定义操作,且是不可变的;也就是说,一旦它们被创建,所有字段都可以读取但不能修改。因此,它们通常使用具有受保护的/私有设置器的类进行编码。当所有独立的属性都相等时,两个值对象被认为是相等的(有些属性不是独立的,因为它们只是以不同的方式显示其他属性编码的数据,例如DateTime的 ticks 及其日期和时间字段的表示)。

典型的值对象包括以数字和货币符号表示的成本、以经纬度表示的位置、地址、联系信息等。当存储引擎的接口是 Entity Framework,正如我们在第六章[8c8a9dbc-3bfc-4291-866f-fdd1a62c16ef.xhtml]“C#中的数据交互 - Entity Framework Core”和第七章[77cdecb5-cef4-4b02-80a1-052ad366b9f3.xhtml]“如何在云中选择您的数据存储”中分析的那样,值对象通过OwnsMany和OwnsOne关系与使用它们的实体连接。实际上,这样的关系也接受没有定义主键的类。

当存储引擎是 NoSQL 数据库时,值对象存储在使用它们的实体的记录中。另一方面,在关系型数据库的情况下,它们可以与分离的表实现,这些表的主键由 Entity Framework 自动处理,并对开发者隐藏(没有属性被声明为主键)或者,在OwnsOne的情况下,它们被扁平化并添加到使用它们的实体关联的表中。

使用 SOLID 原则映射您的领域

在以下子节中,我们将描述一些与 DDD(领域驱动设计)常用的一些模式。其中一些可以在所有项目中采用,而另一些则只能用于特定的边界上下文。总体思路是将业务层分为两层:

  • 应用层

  • 领域层

在这里,领域层基于通用语言,并操纵 DDD 实体和值对象。DDD 实体和值对象在领域层中定义。整个业务层通过在领域层定义但在数据层实现的接口与使用 Entity Framework 实现的数据层进行通信。这些接口方法传递/返回的数据被称为 DDD 实体(查询及其结果的表示)。领域层没有直接引用实现数据层的库,但领域层接口与其数据层实现之间的连接是在应用层的依赖注入引擎中完成的。从这一点我们可以理解以下内容:

  • 数据层必须实现领域层的接口,并必须创建领域层中定义的 DDD 实体和值对象,因此它引用了领域层。

  • 应用层引用领域层和数据层,但数据层类型的引用仅出现在依赖引擎中,在那里它们与在领域层中定义的相应接口相关联。

因此,领域层包含领域对象的表示、在其上使用的函数、验证约束以及与各种实体的关系。为了增加模块化和解耦,实体之间的通信通常使用事件编码,即使用发布/订阅模式。这意味着实体更新可以触发连接到业务操作的事件。

这种分层架构允许我们更改整个数据层,而不会影响领域层,领域层只依赖于领域规范和语言,不依赖于处理数据的技术细节。

应用层包含所有可能影响多个实体的操作定义以及所有应用程序所需的查询定义。业务操作和查询都使用领域层定义的接口与数据层交互。然而,虽然业务操作通过这些接口操纵和交换实体,但查询则向它们发送查询规范并从它们接收通用的 DTO(数据传输对象)。业务操作可以通过其他层(通常是表示层)或与应用层的通信来调用。业务操作还可以连接到由其他操作修改实体时触发的事件。

因此,应用层在领域层定义的接口上操作,而不是直接与它们的数据层实现交互,这意味着应用层与数据层解耦。更具体地说,数据层对象仅在依赖注入引擎定义中提及。所有其他应用层组件都引用在领域层中定义的接口,依赖注入引擎注入适当的实现。

应用层通过以下一个或多个模式与其他应用程序组件进行通信:

  • 它在通信端点(如 HTTP Web API)上暴露业务操作和查询(见第十二章,使用.NET Core 应用服务架构)。在这种情况下,表示层可能连接到这个端点或连接到其他端点,这些端点反过来又从这些端点和其他端点获取信息。从多个端点收集信息并在唯一端点暴露这些信息的应用程序组件被称为网关。它们可以是定制的,也可以是通用目的的,例如 Ocelot。

  • 它被直接实现表示层的应用程序(如 ASP.NET Core MVC Web 应用程序)作为库引用。

  • 它不是通过端点暴露所有信息,而是将其处理/创建的一些数据通信给其他应用程序组件,这些组件反过来又暴露端点。

在我们描述这些模式之前,我们需要理解聚合的概念。

聚合

到目前为止,我们讨论了实体作为基于 DDD 的业务层处理的单元。然而,几个实体可以被操作并合并成单个实体。一个例子是采购订单及其所有项目。实际上,独立于所属订单处理单个订单项是完全没有意义的。这是因为订单项实际上是订单的子部分,而不是独立的实体。

没有任何交易可以单独影响一个订单项而不影响该订单项所在的订单。想象一下,同一家公司的两个不同的人都在尝试增加水泥的总数量,但一个人增加了 1 型水泥(项目 1)的数量,而另一个人增加了 2 型水泥(项目 2)的数量。如果每个项目都被当作一个独立的实体来处理,那么这两个数量都会增加,这可能会导致一个不连贯的采购订单,因为水泥的总数量会被增加两次。

另一方面,如果整个订单及其所有订单项在每次交易中都被两个人加载和保存,那么其中一个人将覆盖另一个人的更改,因此最终做出更改的人将满足他们的需求。在 Web 应用程序中,不可能在整个用户查看和修改订单的过程中锁定整个采购订单,因此使用乐观并发策略。例如,只需为每个采购订单添加一个版本号,并执行以下操作:

  1. 不打开任何事务的情况下读取订单。

  2. 在保存修改后的订单之前,我们打开一个事务并执行第二次读取。

  3. 如果新检索到的订单的版本号与用户修改的订单的版本号不同,则操作将被中止,因为有人在第一次读取后立即修改了显示给用户的订单。在这种情况下,用户会被告知问题,并且新检索到的订单将再次显示给用户。

  4. 如果版本号没有更改,我们增加版本号,继续保存,并提交事务。

一个采购订单及其所有子部分(其订单项)被称为聚合,而订单实体被称为聚合的根。由于聚合是由通过子部分关系连接的实体层次结构,因此它们总是有根。

由于每个聚合代表一个单独的复杂实体,因此对其进行的所有操作都必须通过一个唯一的接口来暴露。因此,聚合根通常代表整个聚合,对聚合的所有操作都定义为根实体的方法。

当使用聚合模式时,在业务层和数据层之间传输的信息单元被称为聚合、查询和查询结果。因此,聚合取代了单个实体。

那么,我们在第六章交互式数据 C# - Entity Framework Core 和第七章如何在云中选择您的数据存储中看到的 WWTravelClub 位置和包实体怎么办?包是根植于它们相关位置的唯一聚合的一部分吗?不!实际上,位置很少更新,对包所做的更改不会影响其位置以及与同一位置相关联的其他包。

仓库模式和单元工作模式

存储库模式是我们实现领域数据层之间接口的方式。由存储库实现的接口在领域层中定义,而它们的实现则在数据层中定义。这种实现数据层接口的独特之处在于其实体中心性质,意味着每个根聚合应该有一个不同的存储库。每个存储库包含对相关聚合执行的所有保存/创建操作,以及所有对构成聚合的实体执行的查询操作。

由于也存在跨越多个聚合的事务,通常情况下,会应用工作单元模式与存储库模式结合。工作单元模式指出,每个数据层接口(在我们的案例中,每个存储库)都包含一个对工作单元接口的引用,该接口代表当前事务的标识。这意味着具有相同工作单元引用的多个存储库属于同一事务。

这两种模式都可以通过定义一些种子接口来实现:

public interface IUnitOfWork 
{ 
    Task<bool> SaveEntitiesAsync();
    Task StartAsync();
    Task CommitAsync();
    Task RollbackAsync();
}

public interface IRepository<T>: IRepository
{
   IUnitOfWork UnitOfWork { get; }
}

所有存储库接口都继承自IRepository<T>并将T绑定到它们关联的聚合根,而工作单元仅实现IUnitOfWork。当使用 Entity Framework 时,IUnitOfWork通常由DBContext实现,这意味着SaveEntitiesAsync()可以执行其他操作,然后调用DBContext的SaveChangeAsync方法,以便将所有挂起的更改通过单个事务保存。如果需要从存储引擎检索数据时启动的更广泛的交易,它必须由负责整个操作的应用程序层处理程序启动和提交/回滚。IRepository<T>从空的IRepisotory接口继承,以帮助自动存储库发现。与本书相关的 GitHub 存储库包含一个RepositoryExtensions类,其AddAllRepositories IServiceCollection扩展方法可以自动发现一个程序集中包含的所有存储库实现,并将它们添加到依赖注入引擎中。

下图是基于存储库和 Unity of Work 模式的存储层/领域层/数据层架构图:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/6e4ba8d3-66e0-4aa9-8975-e6f64347fdb8.png

避免直接引用存储库实现的优点在于,如果我们对这些接口进行模拟,则可以轻松地对各个模块进行测试。

DDD 实体和 Entity Framework Core

DDD 要求实体以与我们在第六章中定义实体的方式不同。实际上,Entity Framework 实体是具有几乎无方法的公共属性记录列表,而 DDD 实体应该具有编码领域逻辑、更复杂的验证逻辑和只读属性的方法。虽然可以添加进一步的验证逻辑和方法而不会破坏 Entity Framework 的操作,但添加必须映射到数据库属性之外的只读属性可能会产生必须妥善处理的问题。防止属性映射到数据库相当简单——我们只需要用 NotMapped 属性装饰它们即可。

只读属性存在的问题稍微复杂一些,可以通过三种基本方式解决:

  • 将 DDD 实体定义为不同的类,并在实体返回/传递到存储库方法时在它们之间复制数据。这是最简单的解决方案,但需要您编写一些代码以在两种格式之间转换实体。DDD 实体在领域层定义,而 EF 实体继续在数据层定义。

  • 让 Entity Framework Core 将字段映射到类私有字段,这样您就可以通过编写自定义获取器和/或设置器来决定如何将它们公开为属性。这可以在实体的配置代码中完成,如下所示:

modelBuilder.Entity<MyEntity>()
      .Property("_myPrivatefield");

这种方法的主要缺点是字段以字符串的形式提供,这阻止了任何编译时检查,也阻止了自动重构,从而可能成为错误和维护问题的来源。此外,由于整个配置必须使用 OnModelCreating DBContext 方法的流畅接口执行,因此我们无法使用数据注释来配置属性。在这种情况下,实体定义必须按照 DDD 的规定从领域层移动。

  • 将包含所有公共属性的 Entity Framework 类隐藏在接口后面,该接口在需要时仅暴露属性获取器。接口在领域层定义,而实体继续在数据层定义。

    在这种情况下,存储库必须公开一个 Create 方法,该方法返回接口的实现;否则,高层无法创建新的实例并将其添加到存储引擎中,因为接口不能使用 new 创建。

例如,假设我们想要为第六章中“定义数据库实体”小节中定义的Destination类定义一个 DDD 接口,称为IDestination,并且假设我们希望将Id、Name和Country属性公开为只读,因为一旦目的地创建后,它们就不能再修改。在这里,让Destination实现IDestination并在IDestination中将Id、Name和Country定义为只读就足够了:

public interface IDestination
{ 
    int Id { get; }
    string Name { get; }   
    string Country { get; }
    string Description { get; set; }
    ...
}

既然我们已经讨论了 DDD 的基本模式和如何为 DDD 的需求适配 Entity Framework,我们可以讨论更高级的 DDD 模式。在下一个小节中,我们将介绍 CQRS 模式。

命令查询责任分离(Command Query Responsibility Segregation,CQRS)模式

在其更一般的形式中,该模式的用法相当简单:使用不同的结构来存储和查询数据。在这里,关于如何存储和更新数据的要求与查询的要求不同。在 DDD 的情况下,存储的单位是聚合,因此增加、删除和更新涉及聚合,而查询通常涉及从几个聚合中提取的属性的更多或更少的复杂转换。

此外,通常我们不会在查询结果上执行业务操作——我们只是使用它们来计算其他数据(平均值、总和等)。因此,虽然更新需要具有完整面向对象语义(方法、验证规则、封装信息等)的实体,但查询结果只需要属性/值对的集合,因此只有公共属性而没有方法的 DTOs 工作得很好。

在其更常见的形式中,该模式可以描述如下:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/c535aa97-526c-4dff-8fe5-4fe14d85f1fb.png

从这个例子中我们可以得出的主要结论是,查询结果的提取不需要通过实体的构建和聚合的构建,但查询中显示的字段必须从存储引擎中提取并投影到临时的 DTOs 中。如果查询是用 LINQ 实现的,我们需要使用Select子句将必要的属性投影到 DTOs 中:

ctx.MyTable.Where(...)....Select(new MyDto{...}).ToList();

然而,在更复杂的情况下,CQRS 可能以更强的形式实现。具体来说,我们可以使用不同的边界上下文来存储预处理后的查询结果。这种方法在涉及由不同分布式微服务处理的不同边界上下文中存储的数据的查询中很常见。

实际上,另一个选择是一个聚合器微服务,它会查询所有必要的微服务以组装每个查询结果。然而,对其他微服务的递归调用以构建答案可能会导致不可接受的响应时间。此外,将一些预处理提取出来可以确保更好地利用可用资源。这种模式通过将由边界上下文更新引起的更改发送到所有需要它们来计算其预处理的查询结果的微服务来实现。

这种更强大的 CQRS 模式的使用将通常的本地数据库事务转换为复杂且耗时的分布式事务,因为单个查询预处理器微服务中的失败应该使整个事务无效。正如我们在第五章中解释的,将微服务架构应用于您的企业应用,由于性能原因,通常不接受实现分布式事务,因此常见的解决方案是放弃立即的整体一致数据库,并接受整体数据库将在每次更新后最终一致。瞬态故障可以通过我们在第五章中分析的重试策略来解决,将微服务架构应用于您的企业应用,而永久性故障则通过在已提交的本地事务上执行纠正操作来处理,而不是假装实现一个全局分布式的整体事务。

正如我们在第五章中讨论的,将微服务架构应用于您的企业应用,微服务之间的通信通常使用发布/订阅模式来实现以改善微服务分离。

在这一点上,你可能会有以下疑问:

“为什么我们一旦有了所有预处理的查询结果,还需要保留原始数据?我们永远不会用它们来回答查询!”

对此的一些答案如下:

  • 它们是我们可能需要从失败中恢复的真理的来源。

  • 当我们添加新的查询时,我们需要它们来计算新的预处理结果。

  • 我们需要它们来处理新的更新。实际上,处理更新通常需要从数据库中检索一些数据,可能显示给用户,然后进行修改。例如,要修改现有采购订单中的项目,我们需要整个订单,以便我们可以将其显示给用户并计算更改,以便我们可以将其转发给其他微服务。此外,无论何时我们修改或向存储引擎添加数据,都必须验证整个数据库的一致性(唯一键约束、外键约束等)。

在下一节中,我们将描述一个用于处理跨越多个聚合或多个边界上下文的操作的常见模式。

命令处理器和领域事件

为了保持聚合分离,通常,与其他聚合和其他边界上下文之间的交互是通过事件完成的。在计算每个聚合过程期间存储所有事件,而不是立即处理它们,以避免它们干扰正在进行的计算,这是一种良好的实践。这可以通过向本章 实体和价值对象 小节中定义的抽象实体类添加以下代码轻松实现,如下所示:

public List<IEventNotification> DomainEvents { get; private set; }
public void AddDomainEvent(IEventNotification evt)
{
    DomainEvents = DomainEvents ?? new List<IEventNotification>();
    DomainEvents.Add(evt);
}
public void RemoveDomainEvent(IEventNotification evt)
{
    DomainEvents?.Remove(evt);
}

在这里,IEventNotification 是一个空接口,用于标记类作为事件。

事件处理通常在更改存储在存储引擎之前立即执行。因此,在 IUnitOfWork 实现的 SaveEntitiesAsync() 方法中执行事件处理是一个好地方(见 存储库和单元工作模式 小节)。

对事件 T 的订阅可以提供为 IEventHandler<T> 接口的一个实现:

public interface IEventHandler<T>
    where T: IEventNotification
{
    Task HandleAsync(T ev);
}

类似地,业务操作可以通过包含操作所有输入数据的 command 对象来描述,而实现实际操作的代码可以通过 ICommandHandler<T> 接口的实现来提供:

public interface ICommandHandler<T>
    where T: ICommand
{
    Task HandleAsync(T command);
}

在这里,ICommand 是一个空接口,用于标记类作为命令。ICommandHandler<T> 和 IEventHandler<T> 是我们在 第九章 中描述的命令模式的示例,设计模式和 .NET Core 实现。

每个 ICommandHandler<T> 都可以在依赖注入引擎中注册,以便需要执行命令 T 的类可以在它们的构造函数中使用 ICommandHandler<T>。这样,我们就将命令(命令类)的抽象定义与其执行方式解耦。

同样的构造不能应用于事件 T 和它们的 IEventHandler<T>,因为当一个事件被触发时,我们需要检索几个 IEventHandler<T> 而不是只有一个。我们需要这样做,因为每个事件可能有多个订阅。然而,几行代码就可以轻松解决这个问题。首先,我们需要定义一个类,它托管给定事件类型的所有处理器:

public class EventTrigger<T>
        where T: IEventNotification
    {
        private IEnumerable<IEventHandler<T>> handlers;
        public EventTrigger(IEnumerable<IEventHandler<T>> handlers)
        {
            this.handlers = handlers;
        }
        public async Task Trigger(T ev)
        {
            foreach (var handler in handlers)
                await handler.HandleAsync(ev);
        }
    }

理念是每个需要触发事件 T 的类都需要一个 EventTrigger<T>,然后将要触发的事件传递给它的 Trigger 方法,该方法反过来调用所有处理器。

然后,我们需要在依赖注入引擎中注册 EventTrigger<T>。一个不错的方法是定义依赖注入扩展,我们可以调用它来声明每个事件,如下所示:

        service.AddEventHandler<MyEventType, MyHandlerType>()

这个 AddEventHandler 扩展必须自动生成 EventTrigger<T> 的 DI 定义,并且必须处理每个类型 T 使用 AddEventHandler 声明的所有处理器。

以下扩展类为我们做了这件事:

public static class EventDIExtensions
{
    private static IDictionary<Type, List<Type>> eventDictionary = 
        new Dictionary<Type, List<Type>>();
    public static IServiceCollection AddEventHandler<T, H>
        (this IServiceCollection service)
        where T : IEventNotification
        where H: class, IEventHandler<T> 
    {
        service.AddScoped<H>();
        List<Type> list = null;
        eventDictionary.TryGetValue(typeof(T), out list);
        if(list == null)
        {
            list = new List<Type>();
            eventDictionary.Add(typeof(T), list);
            service.AddScoped<EventTrigger<T>>(p =>
            {
                var handlers = new List<IEventHandler<T>>();
                foreach(var type in eventDictionary[typeof(T)])
                {
                    handlers.Add(p.GetService(type) as IEventHandler<T>);
                }
                return new EventTrigger<T>(handlers);
            });
        }
        list.Add(typeof(H));

        return service;
    }
    ...
    ...
}

与每个事件 T 相关的所有处理程序的 H 类型记录在字典条目中,该字典条目由事件的 T 类型索引。然后,每个 H 都记录在依赖注入引擎中。

当第一次添加事件 T 的条目时,会创建相应的字典条目(一个 List<Type>)并将相应的 EventTrigger<T> 添加到依赖注入引擎中。EventTrigger<T> 实例是通过传递给 AddSingleton<EventTrigger<T>> 的函数创建的,该函数使用 T 的字典条目来获取所有处理程序类型。然后,使用所有处理程序类型通过 p.GetService(type) 从依赖注入引擎中检索它们的实例。我们可以使用这个操作,因为所有处理程序类型都已注册在依赖注入引擎中。最后,使用所有处理程序列表创建所需的 EventTrigger<T> 实例。

当程序启动时,所有 ICommandHandler<T> 和 IEventHandler<T> 的实现都可以通过反射自动检索并注册。为了帮助自动发现,它们继承自 ICommandHandler 和 IEventHandler,这两个接口都是空的。本书的 GitHub 仓库中提供的 EventDIExtensions 类包含用于自动发现和注册命令处理程序和事件处理程序的方法。GitHub 仓库还包含一个 IEventMediator 接口及其 EventMediator 接口,其 TriggerEvents(IEnumerable<IEventNotification> events) 方法从依赖注入引擎中检索其参数中接收到的所有与事件关联的处理程序并执行它们。只要将 IEventMediator 注入到类中,就可以触发事件。EventDIExtensions 还包含一个扩展方法,用于发现实现空 IQuery 接口的查询,并将它们添加到依赖注入引擎中。

更为复杂的实现由 MediatR NuGet 包提供。前一小节专门介绍了一种极端的 CQRS 模式实现。

事件溯源

事件溯源是 CQRS 强化形式的一种极端实现。当原始的边界上下文根本不用于检索信息,而仅作为 真相来源 用于新的查询和从故障中恢复时,它非常有用。在这种情况下,我们不是更新数据,而是简单地添加描述已执行操作的 事件:删除记录 ID 15,将名称更改为 ID 21 中的 John,等等。这些事件立即发送到所有依赖的边界上下文,在出现故障和/或添加新查询的情况下,我们只需重新处理其中的一些即可。

虽然我们之前描述的所有技术都可以在所有类型的项目中使用,只要进行一些小的修改,但事件溯源需要在采用之前进行深入分析,因为,在几种情况下,它可能比它能解决的问题造成更大的问题。为了了解它误用时可能引起的问题,想象一下我们将它应用于已经由多个用户修改和验证但尚未批准的采购订单。由于采购订单需要在更新/验证之前检索,因此采购订单的边界上下文不仅仅被用作真相来源,所以不应将其应用于事件溯源。如果不是这种情况,那么我们可以将其应用于事件溯源,在这种情况下,我们的代码将被迫在每次更新订单时从记录的事件中重建整个订单。

它的一个使用示例是我们描述在第五章,“将微服务架构应用于您的企业应用”末尾的收入日志系统。单个收入通过事件溯源记录,然后发送到我们在第五章,“将微服务架构应用于您的企业应用”中描述的微服务,该微服务反过来使用它们来预处理未来的查询,即计算每日收入。

在下一节中,我们将学习如何将 DDD 应用于本书的 WWTravelClub 用例。

用例 – 理解用例的领域

从第一章,“理解软件架构的重要性”中案例研究 – WWTravelClub部分列出的需求和第七章,“如何在云中选择您的数据存储”中*用例 – 我在哪里存储数据?*部分的分析中,我们知道 WWTravelClub 系统由以下部分组成:

  • 可用目的地和套餐的信息。我们在第七章,“如何在云中选择您的数据存储”中实现了该子系统数据层的第一个原型。

  • 预订/购买订单子系统。

  • 与专家/审查子系统的通信。

  • 支付子系统。我们在本章领域驱动设计部分的开始简要分析了该子系统的功能和它与预订购买子系统的关系。

  • 用户账户子系统。

  • 统计报告子系统。

前面的子系统代表不同的边界上下文吗?某些子系统可以被拆分为不同的边界上下文吗?这些问题的答案由每个子系统使用的语言提供:

  • 子系统 1 使用的语言是旅行社的语言。没有客户的概念;只有地点、套餐及其特性。

  • 子系统 2 中使用的语言对所有服务购买都是通用的,例如可用的资源、预订和采购订单。这是一个独立的边界上下文。

  • 子系统 3 中使用的语言与子系统 1 的语言有很多相似之处。然而,也存在典型的社交媒体概念,如评分、聊天、帖子分享、媒体分享等。这个子系统可以分为两部分:一个具有新边界上下文的社交媒体子系统和一个属于子系统 1 边界上下文的可用信息子系统。

  • 正如我们在领域驱动设计部分所指出的,在子系统 4 中,我们使用银行的语言。这个子系统与预订/采购子系统进行通信并执行执行购买所需的任务。从这些观察结果中,我们可以看出它是一个不同的边界上下文,并且与购买/预订系统有客户/供应商关系。

  • 子系统 5 肯定是一个独立的边界上下文(就像几乎所有网络应用一样)。它与所有具有用户或客户概念的边界上下文都有关系,因为用户账户的概念总是映射到这些概念上。但这是如何实现的呢?简单——当前登录的用户被认为是社交媒体边界上下文的社会媒体用户、预订/采购边界上下文的客户以及支付边界上下文的付款人。

  • 查询仅子系统,即 6,使用分析和统计的语言,与其他子系统中使用的语言有很大不同。然而,它几乎与所有边界上下文都有联系,因为它从它们那里获取所有输入。前面的约束迫使我们采用强形式的 CQRS,因此将其视为一个查询仅分离的边界上下文。我们在第五章,将微服务架构应用于您的企业应用中实现了一部分,使用了一个符合强形式 CQRS 的微服务。

总之,列出的每个子系统定义了一个不同的边界上下文,但部分与专家/审查子系统的通信必须包含在关于可用目的地和套餐的信息边界上下文中。

随着分析的继续和原型的实现,一些边界上下文可能会分裂,而另一些边界上下文可能会被添加,但立即开始建模系统并立即开始分析我们拥有的部分信息之间的边界上下文关系是基本的,因为这将推动进一步的调查,并帮助我们定义所需的通信协议和通用语言,以便我们可以与领域专家互动。

以下是该领域映射的基本初步草图:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/070f378b-16a3-47ff-bfd1-dd1a800fade6.png

为了简单起见,我们省略了统计报告边界上下文。在这里,我们假设用户账户和社交边界上下文与所有与之通信的其他边界上下文具有一致的关系,因为它们使用现有的软件实现,所以所有其他组件都必须适应它们。

如我们之前提到的,预订和支付之间的关系是客户/供应商,因为支付提供用于执行预订任务的服务。所有其他关系都被归类为合作伙伴。大多数边界上下文都有的客户/用户的各种概念由用户账户授权令牌协调,它间接地负责在所有边界上下文之间映射这些概念。

包裹/位置子系统不仅传达了执行预订/购买所需的包裹信息,而且还负责通知待处理的购买订单可能的价格变化。最后,我们可以看到社交互动是从现有的评论或位置开始的,从而与包裹/位置边界上下文建立通信。

摘要

在本章中,我们分析了采用领域驱动设计的主要原因以及它如何满足市场需求。在这里,我们描述了如何识别领域以及如何使用领域图来协调同一应用程序中不同领域的工作团队。然后,我们分析了 DDD 如何通过实体、值对象和聚合来表示数据,并提供建议和代码片段,以便我们可以在实践中实现它们。

我们还介绍了一些与领域驱动设计(DDD)一起使用的典型模式,即存储库和单元工作模式、领域事件模式、CQRS 和事件溯源。然后,我们学习了如何在实践中实现它们。我们还展示了如何使用解耦处理来实现领域事件和命令模式,以便我们可以将填充代码片段添加到现实世界的项目中。

最后,我们利用 DDD 的原则在实践上定义领域,并为本书的 WWTravelClub 用例创建了领域图的第一个草图。

在下一章中,你将学习如何在项目中最大化代码重用。

问题

  1. 什么提供了主要的提示,以便我们可以发现领域边界?

  2. 用于协调独立边界上下文开发的主体工具是什么?

  3. 构成聚合的每个条目是否确实使用其自己的方法与系统的其余部分进行通信?

  4. 为什么只有一个聚合根?

  5. 多少个存储库可以管理一个聚合?

  6. 存储库如何与应用程序层交互?

  7. 为什么需要单元工作模式?

  8. CQRS 轻量级形式的原因是什么?它的最强形式的原因又是什么?

  9. 主要允许我们将命令/领域事件与其处理程序耦合的工具是什么?

  10. 事件源是否可以用来实现任何有界上下文?

进一步阅读

第十一章:在 C# 8 中实现代码重用

代码重用性是软件架构中最重要的主题之一。本章旨在讨论启用代码重用的方法,并了解 .NET Standard 如何朝这个方向努力以解决管理和维护可重用库的问题。

本章将涵盖以下主题:

  • 理解代码重用的原则

  • 与 .NET Standard 合作的优势

  • 创建可重用库

技术要求

本章需要以下内容:

  • 你需要安装所有数据库工具的 Visual Studio 2017 或 2019 免费社区版或更高版本。

  • 免费 Azure 账户:第一章中的“理解软件架构的重要性”部分,创建 Azure 账户,解释了如何创建一个。

  • Azure DevOps 账户:第三章中的“什么是 Azure DevOps”部分,使用 Azure DevOps 记录需求,解释了如何创建一个。

你可以在github.com/PacktPublishing/Hands-On-Software-Architecture-with-CSharp-8/tree/master/ch11找到本章的示例代码。

理解代码重用性原则

你可以始终使用单一的理由来证明代码重用——如果你在其他场景中已经运行良好,你不能浪费宝贵的时间重新造轮子。这就是为什么大多数工程领域都基于可重用性原则。想想你家里的灯开关。

你能想象出使用相同的界面组件可以制作多少个应用程序吗?代码重用的基本原则是相同的。再次强调,这是一个规划良好解决方案的问题,其中一部分可以在以后重用。

在软件工程中,代码重用是能够为软件项目带来一系列优势的技术之一,例如以下内容:

  • 考虑到重用的代码部分已在另一个应用程序中经过测试,因此对软件有信心

  • 由于软件架构师可以专注于解决这类问题,因此他们有更好的使用方式

  • 有可能将市场上已经接受的模式引入项目

  • 由于已经实现了组件,开发速度提高了

  • 维护更容易

这些方面表明,只要可能,就应该进行代码重用。作为软件架构师,确保上述优势并激励你的团队在创建的软件中启用重用是你的责任。

什么是非代码重用?

你必须首先理解的是,代码复用并不意味着从一类复制粘贴代码到另一类。即使这段代码是由另一个团队或项目编写的,这也不表明你正确地运用了复用原则。让我们想象一个场景,我们将在本书的使用案例中找到这个场景,即 WWTravelClub 评估。

在项目场景中,你可能想评估不同类型的主题,例如包、目的地专家、城市、评论等。无论你指的是哪个主题,获取评估平均值的流程都是相同的。因此,你可能希望通过复制粘贴每个评估的代码来启用复用。(不好的)结果可能如下所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/e162496d-58fb-41be-a669-778be8f1316d.png

在前面的图中,计算评估平均值的流程是分散的,这意味着相同的代码将在不同的类中重复。这将造成很多麻烦,尤其是如果相同的做法开始在其他应用程序中发生。例如,如果有一个关于如何计算平均值的新规范,或者如果你只是计算公式中出现了错误,你将不得不在所有代码实例中修复它。

什么是代码复用?

上节中提到的问题的解决方案相当简单。你必须分析你的代码,并选择那些从你的应用程序中解耦的部分。你应该解耦的最大原因与你如何确保这段代码可以在应用程序的其他部分或另一个应用程序中复用有关:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/bb4e6ffc-dda0-4379-847f-24cbfa7ded04.png

代码的集中化给你,作为一个软件架构师,带来了不同的责任。你必须记住,这段代码的任何错误或不兼容都可能对应用程序的许多部分或不同的应用程序造成损害。另一方面,一旦你测试并运行了这段代码,你将能够无忧无虑地传播其使用。此外,如果你需要进化平均计算过程,你将不得不在一个类中更改代码。

值得注意的是,你使用相同代码的次数越多,这种开发方式就越便宜。需要提到成本,因为通常情况下,可复用软件的概念在开始时成本更高。

将复用引入你的开发周期

如果你理解了复用将带你进入代码实现的另一个层次,你就应该开始思考如何在你的开发周期中实现这种技术。实际上,创建和维护组件库并不容易,因为你将承担的责任以及缺乏支持现有组件搜索的良好工具。

另一方面,有一些事情你可能在每次开始新的开发时都应考虑在软件开发过程中实施:

  • 使用用户库中已实现的组件,选择软件需求规范中需要它们的特性。

  • 识别软件需求规范中作为库组件设计候选者的功能。

  • 修改规范,考虑到这些功能将使用可重用组件来开发。

  • 设计可重用组件,并确保它们具有适当的接口,以便在多个项目中使用。

  • 构建使用新组件库版本的项目架构。

  • 记录组件库版本,以便每个开发者和团队都知道它。

使用-识别-修改-设计-构建 过程是一种技术,你可能每次需要启用软件重用时都应考虑实施。一旦你有了为这个库编写所需的组件,你将需要决定将提供这些组件的技术。

在软件开发的历史中,有许多实现这一目标的方法;其中一些在第五章,“将微服务架构应用于您的企业应用程序”的微服务作为模块概念的演变部分中进行了讨论。

使用 .NET Standard 进行代码重用

.NET 自从第一个版本发布以来已经发展了很多。这种发展不仅与命令数量和性能问题相关,还与支持的平台相关。正如在第一章,“理解软件架构的重要性”中讨论的那样,你可以在数十亿台设备上运行 C# .NET,即使它们运行的是 Linux、Android、macOS 或 iOS。因此,.NET Standard 首次与 .NET Core 1.0 一起宣布,但破坏性变化发生在 .NET Standard 2.0,当时 .NET Framework 4.6、.NET Core 和 Xamarin 都与之兼容。

关键点在于 .NET Standard 不仅是一种 Visual Studio 项目。更重要的是,它是一种对所有 .NET 实现都适用的正式规范。正如你在下表中所见,它涵盖了从 .NET Framework 到 Unity (github.com/dotnet/standard/tree/master/docs/versions) 的所有内容:

.NET Standard1.01.11.21.31.41.51.62.02.1
.NET Core1.01.01.01.01.01.01.02.03.0
.NET Framework4.54.54.5.14.64.6.14.6.1¹4.6.1¹4.6.1¹N/A²
单元4.64.64.64.64.64.64.65.46.2
Xamarin.iOS10.010.010.010.010.010.010.010.1412.12
Xamarin.Mac3.03.03.03.03.03.03.03.85.12
Xamarin.Android7.07.07.07.07.07.07.08.09.3
Unity2018.12018.12018.12018.12018.12018.12018.12018.1TBD
通用 Windows 平台8.08.08.110.010.010.0.1629910.0.1629910.0.16299TBD

前面的表格表明,如果你构建一个与该标准兼容的类库,你将能够在所展示的任何平台上重用它。想想看,如果你计划在所有项目中这样做,你的开发过程会变得多快。

显然,一些组件不包括在 .NET Standard 中,但其发展是持续的。值得一提的是,微软的官方文档指出,版本越高,可用的 API 越多。

创建 .NET Standard 库

创建一个与 .NET Standard 兼容的类库相当简单。基本上,你需要在创建库时选择以下项目:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/00882bf3-8b23-4eae-9648-94bd40168c5f.png

一旦你完成了这部分,你会注意到,一个普通类库和你创建的类库之间的唯一区别是项目文件中定义的目标框架:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>
</Project>

一旦你的项目加载完毕,你就可以开始编写你打算重用的类。使用这种方法构建可重用类的优点是,你将能够在之前检查的所有项目类型中重用所编写的代码。另一方面,你会发现一些在 .NET Framework 中可用的 API 在这类项目中不存在。你可以通过github.com/dotnet/standard/tree/master/docs/planning/netstandard-2.1来关注标准的未来。

C# 如何处理代码重用?

C# 有许多方法帮助我们处理代码重用。我们之前检查的构建库的能力是其中之一。最重要的是,这种语言是面向对象的。此外,还值得一提的是泛型为 C# 语言带来的便利。本主题将讨论最后两个提到的内容。

面向对象分析

面向对象分析的方法使我们能够以不同的方式重用代码,从继承的便利性到多态的可变性。完全采用面向对象编程将让你也能够实现抽象和封装。

以下截图展示了使用面向对象的方法来简化重用。正如您所看到的,有不同方式来计算评估的等级,考虑到您可以是系统的基本用户或高级用户:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/0b8c4c20-f04e-4b81-a553-3305d15e75c4.png

在这个设计中,代码重用有两个方面需要分析。第一个方面是,由于继承为你做了这件事,所以没有必要在每个子类中声明属性。

第二个机会是我们使用多态,为相同的方法启用不同的行为:

public class PrimeUsersEvaluation : Evaluation
{
    /// <summary>
    /// The business rule implemented here indicates that grades that 
    /// came from prime users have 20% of increase
    /// </summary>
    /// <returns>the final grade from a prime user</returns>
    public override double CalculateGrade()
    {
         return Grade * 1.2;
    }
}

你可以在前面的代码中检查多态原则的使用,其中对初级用户的评估计算将增加 20%。现在,看看调用同一类继承的不同对象是多么容易。由于集合内容实现了相同的接口 IContentEvaluated,它也可以有基本用户和初级用户:

public class EvaluationService
{
    public IContentEvaluated content { get; set; }
    /// <summary>
    /// No matter the Evaluation, the calculation will always get     
    /// values from the method CalculateGrade
    /// </summary>
    /// <returns>The average of the grade from Evaluations</returns>
    public double CalculateEvaluationAverage()
    {
        var count = 0;
        double evaluationGrade = 0;
        foreach (var evaluation in content.Evaluations)
        {
            evaluationGrade += evaluation.CalculateGrade();
            count++;
        }
        return evaluationGrade/count;
    }
}

在使用 C# 时,面向对象的采用可以被认为是强制性的。然而,更具体的用法需要学习和实践。作为软件架构师,你应该始终鼓励你的团队学习面向对象的分析。他们的抽象能力越强,代码复用就越容易。

泛型

泛型是在 C# 2.0 版本中引入的,它被认为是一种提高代码复用的方法。它还最大限度地提高了类型安全和性能。

泛型的基本原理是,你可以在接口、类、方法、属性、事件、委托或甚至一个占位符中定义,这个占位符将在稍后某个实体被使用时,用特定的类型替换。这个特性为你提供了不可思议的机会,因为你可以使用相同的代码来运行不同版本的类型,实现泛型。

以下代码是对上一节中介绍的 EvaluationService 的修改。这里的想法是使服务泛化,从其创建时就定义评估的目标:

public class EvaluationService<T> where T: IContentEvaluated

这个声明表明,任何实现了 IContentEvaluaded 接口的类都可以用于这个服务。此外,服务将负责创建评估内容。

以下代码实现了自服务构建以来创建的评估内容。这段代码使用了 System.Reflection 和来自类的泛型定义:

public EvaluationService()
{
    var name = GetTypeOfEvaluation();
    content = (T)Assembly.GetExecutingAssembly().CreateInstance(name);
}

值得注意的是,这段代码将能够工作,因为所有类都在同一个程序集中。这个修改的结果可以在服务的实例创建中进行检查:

var service = new EvaluationService<CityEvaluation>();

好消息是,现在你有一个泛型服务,它将自动实例化所需内容的评估列表对象。值得一提的是,泛型显然需要更多时间用于第一个项目的构建。然而,一旦设计完成,你将拥有一个良好、快速且易于维护的代码。这就是我们所说的复用!

用例 - 代码复用作为快速交付优质和安全的软件的途径

评估 WWTravelClub 内容的解决方案的最终设计可以按以下方式检查。这种方法包括使用本章讨论的许多主题。首先,所有代码都放置在一个 .NET Standard 类库中。这意味着您可以将此代码添加到不同类型的解决方案中,例如 .NET Core 网络应用程序和 Android 和 iOS 平台的 Xamarin 应用程序:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-sw-arch-csp8-dncr3/img/68fc31e7-4bde-409d-a30a-b6f1d2d55f2c.png

此设计利用了面向对象原则,如继承,因此您不需要在多个类中多次编写可被使用的属性和方法;以及多态,这样您可以在不更改方法名称的情况下更改代码的行为。

最后,该设计通过引入泛型作为可以促进类似类(如我们在 WWTravelClub 中用于评估城市、评论、目的地专家和旅游套餐的类)操作的工具,抽象了内容的概念。

在鼓励代码复用的团队和不鼓励代码复用的团队之间,最大的区别在于向最终用户提供优质软件的速度。当然,开始这种做法并不容易,但请放心,经过一段时间的工作,你将获得良好的结果。

摘要

本章旨在帮助您了解代码复用的优势。它还让您对什么是未正确复用的代码有了概念。本章还介绍了代码复用的方法。

考虑到没有流程的技术无法带您走得更远,本章提出了一种流程来启用代码复用。此流程与使用库中已经完成的组件相关;在软件需求规格说明书中识别可以作为库组件设计的候选功能;根据这些功能修改规格说明;设计可复用的组件;以及使用新的组件库版本构建项目架构。

最后,本章将 .NET Standard 库作为在不同 C# 平台上复用代码的方法,强调了面向对象编程作为复用代码的方式,并介绍了泛型作为简化具有相同特性的对象处理的复杂实现。在下一章中,我们将看到如何使用 .NET Core 应用 服务导向架构(SOA)。

值得注意的是,SOA 被认为是复杂环境中实现代码复用的一种方式。

问题

  1. 复制和粘贴可以被认为是代码复用吗?这种方法的有哪些影响?

  2. 如何在不复制和粘贴代码的情况下利用代码复用?

  3. 有没有可以帮助代码复用的流程?

  4. .NET Standard 和 .NET Core 之间的区别是什么?

  5. 创建 .NET Standard 库有哪些优势?

  6. 面向对象分析如何帮助代码复用?

  7. 泛型如何帮助代码复用?

进一步阅读

这些是一些书籍和网站,您可以在其中找到更多关于本章的信息:

构建高效且可维护的 SQLAlchemy 数据库操作层 本文详细介绍了如何构建高效且可维护的 SQLAlchemy 数据库操作层。首先,概述了 SQLAlchemy 的核心组件,包括 Core 和 ORM,并提供了安装和初始化的基本步骤。接着,详细讲解了定义模型、事务管理、CRUD 操作以及代码优化的方法。文章还探讨了异步数据库操作、数据库索引、缓存优化和连接池配置等进阶技巧,以提高系统性能。此外,推荐使用 Repository Pattern 提升代码可维护性。最后,展望了 SQLAlchemy 未来的发展方向,强调掌握这些最佳实践可以显著提升数据库操作的效率 阅读详情

相关推荐

尚硅谷课程【笔记】——大数据之Shell【一】

【【尚硅谷】Shell脚本从入门到实战】笔记

n04j04h06的博客 1321

ASP.NET Core 企业级开发架构简介及框架汇总

ASP.NET Core 企业级开发架构简介及框架汇总 企业开发框架包括垂直方向架构和水平方向架构。垂直方向架构是指一个应用程序的由下到上叠加多层的架构,同时这样的程序又叫整体式程序。水平方向架构是指将大应用分成若干小的应用实现系统功能的架构,同时这样的系统叫做分布式系统。在架构上Java和.Net世界都有优秀的框架支持构建垂直和水平方向架构。ASP....

aydh696的博客 2166

【AI实战】用Coze工作流打造抖音视频文案提取神器——从单链接到批量处理的进阶指南

本文详细介绍了如何利用Coze工作流打造抖音视频文案提取工具,从单链接处理到批量提取的进阶指南。通过AI技术和工作流的结合,用户可以高效提取抖音视频的ASR和OCR文本,大幅提升内容创作效率。文章还涵盖了性能优化、异常处理及实际应用场景扩展,助力开发者快速实现抖音文案自动化提取。

weixin_29210759的博客 280

C#使用IEaseCore组件实现西门子PLC,AB PLC,ModbusTCP等通讯读写

系统兼容:Windows 系统(全模块支持;Linux/macOS 需.NET Core 2.1+,Http/Modbus/Siemens/WebSocket/EtherNet/IP 模块兼容)监听格式:监听前缀需遵循 ws://IP:端口 格式,如ws://0.0.0.0:8181(监听所有 IP)、ws://localhost:8181(仅监听本机);通信要求:工业设备 / 网络通信需确保网络可达,且设备已启用对应通信协议(S7/Modbus TCP/WebSocket/EtherNet/IP)

qq_41668654的博客 618

DC-DC电源PCB布局实战:从EMI优化到热管理

本文深入探讨了DC-DC电源PCB布局的核心实战技巧。文章从分析电流环路入手,强调通过优化“黄金三角”布局以最小化环路面积,从而有效抑制EMI并提升效率。详细阐述了功率器件摆放、地线分割、敏感信号布线、电感下方铺铜策略以及热管理设计等关键步骤,为工程师提供了一套从理论到实践的完整布局指南。

z5a6b的博客 721

ETL第八章无人售货机零售项目实践

ETL实战项目——无人售货机

m0_52058217的博客 4418

C#学习笔记—.Net Core

在学习C#过程中做的笔记

WhiteBear_Um的博客 3311

以高通camera 申请ion内存看dma-buf

1 fd 与ion buffer, file绑定 简单总结就是申请一个buffer,再创建一个dmabuf 结构体然后将,然后将dmabuf中得指针指向buffer,dmabuf 再传递给一个匿名的inode,获取到一个file,这样file和dmabuf绑定起来,也就和buffer关联上。然后再从进程中分配一个空闲的fd,将fd 和file囊绑定。这样就能通过fd 快速查找到buffer。file 是个全系统的,他和进程无关,但是fd 是每个进程都是自己独立的,所以再跨进程传输时只需要保证fd ...

兔兔里个花兔的博客 6444

SmartSoftHelp 极速开发代码生成(应用场景一 )NetCore 三层架构解析:SmartSoftHelp DeepCore XSuite深度优化版- 编程如此简单

在当今的企业级应用开发领域,NetCore 凭借其高性能、跨平台等诸多优势,成为了众多开发者的首选框架。而在 NetCore 开发过程中,采用经典的三层架构(数据访问层 DAL、业务逻辑层 BLL 以及用户界面层 UI)能够有效提升代码的可维护性、可扩展性和可测试性。然而,手动搭建这样的架构往往需要编写大量重复的代码,耗费大量的时间和精力。{Ado.Coding} 菜单的出现,正是为了解决这一痛点。借助此菜单功能,开发者能够一键快速生成基于原生 SQL 的经典三层架构代码。

qq512929249的专栏 1145

SAP ABAP Web Service 的创建与消费:保姆级教程

SOAP 定义了消息的格式和传输规则,使得 Web Service 可以在不同的操作系统和网络环境中通信。通过 WSDL,服务的提供者可以明确地定义服务的功能,而服务的使用者则可以通过解析 WSDL 文件,了解如何正确地调用该服务。它封装了与 Web 服务通信的所有复杂性,如消息格式的构建、传输协议的选择、请求的发送和响应的处理等。除了本文介绍的 ABAP 编程语言之外,我们还可以通过 Java,Node.js,C++,C#,Python 等其他编程语言来消费本文创建的 ABAP Web Service.

2007 年 ~ 2025 年,深耕 SAP 技术 18 年 713

在 ABAP Cloud 里优雅读取与解析 XML:用 sXML Reader 把接口数据落成结构化 ABAP

ABAP Cloud中XML解析实践 摘要:本文介绍了在ABAP Cloud环境下解析XML数据的最佳实践。针对企业系统中常见的XML格式数据,文章推荐使用sXML库中的CL_SXML_STRING_READER进行流式解析,而非传统的DOM方式。重点解析了命名空间(Namespace)、属性(Attribute)和CDATA等关键概念的处理方法,并比较了两种读取属性的策略:通扫所有属性(NEXT_ATTRIBUTE)和精准取值(GET_ATTRIBUTE_VALUE)。文章提供了完整的ABAP Cloud

2007 年 ~ 2025 年,深耕 SAP 技术 18 年 190

C#8 和 .NET Core3 软件架构实用指南(一)

本书涵盖了软件架构中涉及的最常见的设计模式和框架。它通过提供实际的真实场景,讨论何时以及如何使用每个模式。本书还介绍了 DevOps、微服务、持续集成和云计算等技术流程,以便你能够为你的客户提供一流的软件解决方案。本书将帮助你理解客户希望从你这里得到的产品。它将指导你解决开发过程中可能遇到的最大问题。它还涵盖了你在基于云的环境中管理应用时需要遵循的“做”和“不做”事项。你将了解不同的架构方法,如分层架构、面向服务的架构、微服务和云架构,并了解如何将它们应用于特定的业务需求。最后,你将使用 Azure 在远程

龙哥盟 1115

多摄像头场景下 ISP 的同步与调度机制实战解析

在智能手机多摄系统中,主摄、广角、长焦、微距等摄像头的协同工作能力直接决定了影像系统的体验上限。而支撑这一能力的核心之一,正是 ISP 的同步与调度机制。ISP 需要处理多个 Sensor 的输入流、统一时序、资源仲裁、并发调度,并支持在不同模式间快速切换(如预览 → 拍照 → 视频)。本文将基于 Qualcomm、MTK、HiSilicon 平台的实际架构,系统性剖析 ISP 在多摄场景下的时序控制、Pipeline 配置、Buffer 管理与故障恢复机制,并分享实战中的典型问题与调试方案。

努力分享一些人工智能、计算机视觉、影像等相关的知识干货! 2347

C#8 和 .NET Core3 并行编程实用指南(二)

类位于命名空间和程序集中。除了支持 LINQ 定义的大多数标准查询操作符之外,: 这是并行化所需的种子方法。:通过改变并行行为,启用并行查询的顺序执行。: 默认情况下,PLINQ 不保留任务执行和结果返回的顺序。我们可以通过调用方法来保留这个顺序。:这是的默认行为,可以通过方法覆盖。我们可以通过调用这个方法将行为从有序改为无序。ForAll():启用并行执行查询。: 这个方法可以用来聚合并行查询中各个线程本地分区的结果。:使用这个方法,我们可以指定用于并行化查询执行的最大处理器数量。

龙哥盟 1858

文档:亲测可用解决vnc灰屏,黑屏,鼠标大黑叉情况

主要解决vnc-viewer灰屏,黑屏,鼠标大黑叉等现象,详细见文档。

RU DOS版、EFI版32bit/64bit BIOS常用工具

DOS版、EFI版的RU工具,可用来查看IO、MEM、PCI、SMBIOS等资源。亲测在EFI SHELL下可用。

C#8 和 .NET Core3 软件架构实用指南(四)

Visual Studio 包含三种单元测试框架的项目模板,分别是 MSTest、xUnit 和 NUnit。一旦你启动新项目向导,为了可视化所有这些框架的版本,适合 .NET Core C# 应用程序,请将项目类型设置为测试,语言设置为 C#,平台设置为 Linux,因为 .NET Core 项目是唯一可以在 Linux 上部署的项目。所有的前述项目都自动包含在 Visual Studio 测试用户界面(Visual Studio 测试运行器)中运行所有测试的 NuGet 包。

龙哥盟 783

VB摄像头监控

该VB模块可对摄像头捕获的画面进行录像等,可在创建的窗口进行控制

C#8 和 .NET Core3 软件架构实用指南(三)

ASP.NET Core 应用程序是基于我们在第五章将微服务架构应用于您的企业应用程序的使用通用宿主子节中描述的Host概念的 .NET Core 应用程序。每个 ASP.NET 应用程序的program.cs});();});设置了一个标准的宿主,而则配置它以处理 HTTP 管道。更具体地说,它执行以下操作:它设置了接口的属性为当前目录。它从和加载配置信息。一旦加载,JSON 对象属性中包含的配置信息可以使用 ASP.NET Core 选项框架映射到 .NET 对象属性。

龙哥盟 1280

jdk-8u144-linux-x64.tar.gz.

该资源为搭建Linux系统Java环境用,资源的后缀为tar.gz格式的压缩包

C#8 和 .NET Core3 微服务实用手册(一)

这本书的目的是让你对现代软件开发中的微服务有一个广泛的了解,同时也用具体的例子来深入了解更多的细节.NET Core 应用接口。这本书涵盖了微服务的理论,然后是对用于开发虚拟应用的模式的高级理解,其中我们将涵盖解决方案中使用的概念,这些解决方案可以轻松配置为在云中或内部运行。值得注意的是,微服务架构确实具有关键优势,尤其是在支持敏捷改进和复杂风险应用的传输方面。然而,对于如何在微软生态系统中实现微服务,尤其是如何利用 Azure 和.NET Core 框架。这本书试图填补这一空白。

龙哥盟 1270

C#8 和 .NET Core3 Azure 项目(一)

.NET Core 是. NET 的通用、模块化、跨平台和开源实现.NET Core 3 具有改进的性能以及对桌面应用的支持。.NET Core 3 不仅应该吸引新开发人员开始学习框架,还应该说服旧开发人员开始迁移他们的应用。这本书是第二版 C# 7 和.NET Core 2.0 蓝图,更新了 C# 8 和的最新特性和增强功能.NET Core 3.0。这本书是一个全面的指南,提供了 10 个真实世界的企业应用。它将帮助您同时学习和实现这些概念,并通过在 ASP.NET Core 和 Azure 上构建满足

龙哥盟 1783

C#8 和 .NET Core3 并行编程实用指南(三)

在之前的章节中,我们已经看到并行编程是如何工作的。并行性是关于创建称为工作单元的小任务,可以由一个或多个应用程序线程同时执行。由于线程在应用程序进程内运行,它们在使用委托通知调用线程完成后通知调用线程。在本章中,我们将首先介绍同步代码和异步代码之间的区别。然后,我们将讨论何时使用异步代码以及何时避免使用它。我们还将讨论异步模式如何随时间演变。最后,我们将看到并行编程中的新特性如何帮助我们解决异步代码的复杂性。在本章中,我们将涵盖以下主题:同步与异步代码何时使用异步编程何时避免异步编程。

龙哥盟 960

C#8 和 .NET Core3 Azure 项目(四)

LUIS 代表语言理解智能服务。它允许你利用机器学习算法来训练预先构建的语言模型。事实上,这个服务(和 Bot 框架一样)比我们在这里看到的要强大得多。出于我们的目的,我们只需要训练一个简单的模型,它只有很少的关键词和短语。如果您还没有这样做,您需要首先在注册。在这里,如果我们选择意图,我们可以确定对话的关键领域;如您所见,我在这里确定了一些领域。您选择“创建新意图”选项,然后给该意图命名(事实上,给该意图命名并不重要)。一旦你做到了这一点,你就可以训练和测试你的模型。让我们试着训练和测试我们的模型。

龙哥盟 1224

C#8 和 .NET Core3 并行编程实用指南(一)

Packt 几乎一年前首次联系我撰写这本书。这是一段漫长的旅程,有时比我预期的更艰难,我学到了很多。你现在拥有的这本书是许多漫长日子的结晶,我很自豪能最终呈现它。撰写这本关于 C#的书对我意义重大,因为我一直梦想着写关于我职业生涯起步的语言。自从首次推出以来,C#确实有了长足的发展。.NET Core 实际上增强了 C#在开发者社区中的力量和声誉。为了使这本书对广大读者有意义,我们将涵盖经典线程模型和任务并行库TPL),并使用代码来解释它们。我们将首先研究使编写多线程代码成为可能的操作系统的基本概念。

龙哥盟 1523

C#8 和 .NET Core3 Azure 项目(二)

通常,在客户机-服务器关系中,例如访问网站,通信是由客户机发起的。你可以访问一个显示股价的网站,点击一个特定的股票代码,网站就会为你检索该股票的价格。一旦你得到了价格,你可能会让页面打开,并在一个小时内返回。股票的价格完全一样;你刷新网页,股票价格被重新提取,现在显示正确。解决这个问题的一个可能的方法是让服务器在准备好的时候发送信息给客户端。SignalR 提供这种能力。然而,SignalR 不是一种单一的技术——它实际上是一堆技术,被抽象化了。

龙哥盟 1296

【DotNetGuide】C#/.NET/.NET Core学习、工作、面试指南

现如今网上关于Java、前端、Android、Golang...等相关技术的学习资料、工作心得、面试指南一搜都是一大把,但是咱们C#/.NET的相关学习资料、工作心得、面试指南都是寥寥无几。我在微信群和知乎里面经常会看到这样的提问:有没有好的C#/.NET相关的学习书籍、视频教程、项目框架和常见面试题推荐的?。并不是没有人写,而是因为网上的资料和文章太零散了,缺少一个这方面汇总的知识库。

专注于C#/.NET/.NET Core学习、工作、面试干货和实战教程分享。这里聚焦了大量的C#/.NET/.NET Core优质文章、开源项目、实用工具和学习、工作、面试心得。 3079

《C# 8.0 and .NET Core 3.0 – Modern Cross-Platform Development》是一本全面介绍C# 8.0和.NET Core 3.0的书籍

C# 8.0 and .NET Core 3.0 – Modern Cross-Platform Development》(第四版,作者:Mark J. Price)是一本全面介绍C# 8.0和.NET Core 3.0的书籍,适合初学者和希望更新知识的开发者。以下是对该书每章的详细介绍,包括核心内容、具体实例Demo、经典案例、评价、常见问题与易错点总结,结合《CLR via C#》的异步编程和反射知识,以及现代.NET特性(如异步流)。本书第7章介绍反射,但未深入性能优化。

专注于前沿技术的探索与实践,热衷于从代码逻辑、架构设计到行业应用等多维度深挖技术本质。期待在技术之路上与你共同成长~ 1101
上一篇: C#8 和 .NET Core3 软件架构实用指南(一)
下一篇: C#8 和 .NET Core3 软件架构实用指南(三)
绝不原创的飞龙
绝不原创的飞龙 优质创作者: 人工智能、数据科学与机器学习技术领域 优质创作者: 人工智能、数据科学与机器学习技术领域
博客等级 码龄15年 4万+粉丝 2万+原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值