InfoPool

私人信息记录

0%

查询操作的三个部分

所有 LINQ 查询操作都由以下三个不同的操作组成:

  1. 获取数据源。
  2. 创建查询。
  3. 执行查询。

下面的示例演示如何用源代码表示查询操作的三个部分。 为方便起见,此示例将一个整数数组用作数据源;但其中涉及的概念同样适用于其他数据源。 本主题的其余部分也会引用此示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class IntroToLINQ
{
static void Main()
{
// The Three Parts of a LINQ Query:
// 1. Data source.
int[] numbers = new int[7] { 0, 1, 2, 3, 4, 5, 6 };

// 2. Query creation.
// numQuery is an IEnumerable<int>
var numQuery =
from num in numbers
where (num % 2) == 0
select num;

// 3. Query execution.
foreach (int num in numQuery)
{
Console.Write("{0,1} ", num);
}
}
}

数据源

上例中,数据源是一个数组,因此它隐式支持泛型 IEnumerable 接口。 这一事实意味着该数据源可以用 LINQ 进行查询。 查询在 foreach 语句中执行,且 foreach 需要 IEnumerable 或 IEnumerable。 支持 IEnumerable 或派生接口(如泛型 IQueryable)的类型称为可查询类型 。

可查询类型不需要进行修改或特殊处理就可以用作 LINQ 数据源。 如果源数据还没有作为可查询类型出现在内存中,则 LINQ 提供程序必须以此方式表示源数据。 例如,LINQ to XML 将 XML 文档加载到可查询的 XElement 类型中:

1
2
3
// Create a data source from an XML document.
// using System.Xml.Linq;
XElement contacts = XElement.Load(@"c:\myContactList.xml");

有关如何创建特定类型的数据源的详细信息,请参阅各种 LINQ 提供程序的文档。 但基本规则很简单:LINQ 数据源是支持泛型 IEnumerable 接口或从中继承的接口的任意对象。

查询

查询指定要从数据源中检索的信息。 查询还可以指定在返回这些信息之前如何对其进行排序、分组和结构化。 查询存储在查询变量中,并用查询表达式进行初始化。 为使编写查询的工作变得更加容易,C# 引入了新的查询语法。

上一个示例中的查询从整数数组中返回所有偶数。 该查询表达式包含三个子句:from、where 和 select。 (如果熟悉 SQL,会注意到这些子句的顺序与 SQL 中的顺序相反。)from 子句指定数据源,where 子句应用筛选器,select 子句指定返回的元素的类型。

查询执行

延迟执行

如前所述,查询变量本身只存储查询命令。 查询的实际执行将推迟到在 foreach 语句中循环访问查询变量之后进行。 此概念称为延迟执行,下面的示例对此进行了演示:

1
2
3
4
5
//  Query execution.
foreach (int num in numQuery)
{
Console.Write("{0,1} ", num);
}

foreach 语句也是检索查询结果的地方。 例如,在上一个查询中,迭代变量 num 保存了返回的序列中的每个值(一次保存一个值)。

由于查询变量本身从不保存查询结果,因此可以根据需要随意执行查询。 例如,可以通过一个单独的应用程序持续更新数据库。 在应用程序中,可以创建一个检索最新数据的查询,并可以按某一时间间隔反复执行该查询以便每次检索不同的结果。

强制立即执行

对一系列源元素执行聚合函数的查询必须首先循环访问这些元素。 Count、Max、Average 和 First 就属于此类查询。 由于查询本身必须使用 foreach 以便返回结果,因此这些查询在执行时不使用显式 foreach 语句。 另外还要注意,这些类型的查询返回单个值,而不是 IEnumerable 集合。 下面的查询返回源数组中偶数的计数:

1
2
3
4
5
6
var evenNumQuery =
from num in numbers
where (num % 2) == 0
select num;

int evenNumCount = evenNumQuery.Count();

要强制立即执行任何查询并缓存其结果,可调用 ToList 或 ToArray 方法。

1
2
3
4
5
6
7
8
9
10
11
12
List<int> numQuery2 =
(from num in numbers
where (num % 2) == 0
select num).ToList();

// or like this:
// numQuery3 is still an int[]

var numQuery3 =
(from num in numbers
where (num % 2) == 0
select num).ToArray();

查询表达式基础

查询是什么及其作用是什么?

查询是一组指令,描述要从给定数据源检索的数据以及返回的数据应具有的形状和组织。 查询与它生成的结果不同。

从应用程序的角度来看,原始源数据的特定类型和结构并不重要。 应用程序始终将源数据视为 IEnumerable<T>IQueryable<T> 集合。

对于此源序列,查询可能会执行三种操作之一:

  • 检索元素的子集以生成新序列,而不修改各个元素。
    1
    2
    3
    4
    5
    IEnumerable<int> highScoresQuery =
    from score in scores
    where score > 80
    orderby score descending
    select score;
  • 如前面的示例所示检索元素的序列,但是将它们转换为新类型的对象.下面的示例演示从 int 到 string 的投影。 请注意 highScoresQuery 的新类型。
    1
    2
    3
    4
    5
    IEnumerable<string> highScoresQuery2 =
    from score in scores
    where score > 80
    orderby score descending
    select $"The score is {score}";
  • 检索有关源数据的单独值
    • 与特定条件匹配的元素数。
    • 具有最大或最小值的元素。
    • 与某个条件匹配的第一个元素,或指定元素集中特定值的总和。 例如,下面的查询从 scores 整数数组返回大于 80 的分数的数量:
      1
      2
      3
      4
      5
      int highScoreCount = (
      from score in scores
      where score > 80
      select score
      ).Count();
      在前面的示例中,请注意在调用 Count 方法之前,在查询表达式两边使用了括号。 也可以通过使用新变量存储具体结果,来表示此行为。 这种方法更具可读性,因为它使存储查询的变量与存储结果的查询分开。
      1
      2
      3
      4
      5
      6
      IEnumerable<int> highScoresQuery3 =
      from score in scores
      where score > 80
      select score;

      int scoreCount = highScoresQuery3.Count();
      在上面的示例中,查询在 Count 调用中执行,因为 Count 必须循环访问结果才能确定 highScoresQuery 返回的元素数。

查询表达式是什么?

查询表达式是以查询语法表示的查询。它如同任何其他表达式一样,可以在 C# 表达式有效的任何上下文中使用。

查询表达式必须以 from 子句开头,且必须以 select 或 group 子句结尾。
在第一个 from 子句与最后一个 select 或 group 子句之间,可以包含以下这些可选子句中的一个或多个:where、orderby、join、let,甚至是其他 from 子句。
还可以使用 into 关键字,使 join 或 group 子句的结果可以充当相同查询表达式中的其他查询子句的源。

查询变量

查询变量是存储查询而不是查询结果的任何变量。
下面的代码示例演示一个简单查询表达式,它具有一个数据源、一个筛选子句、一个排序子句并且不转换源元素。 该查询以 select 子句结尾。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Data source.
int[] scores = { 90, 71, 82, 93, 75, 82 };

// Query Expression.
IEnumerable<int> scoreQuery = //query variable
from score in scores //required
where score > 80 // optional
orderby score descending // optional
select score; //must end with select or group

// Execute the query to produce the results
foreach (int testScore in scoreQuery)
{
Console.WriteLine(testScore);
}

// Output: 93 90 82 82

在上面的示例中,scoreQuery 是查询变量,它有时仅仅称为查询。 查询变量不存储在 foreach 循环生成中的任何实际结果数据。 并且当 foreach 语句执行时,查询结果不会通过查询变量 scoreQuery 返回。 而是通过迭代变量 testScore 返回。 scoreQuery 变量可以在另一个 foreach 循环中进行循环访问。 只要既没有修改它,也没有修改数据源,便会生成相同结果。

查询变量可以存储采用查询语法、方法语法或是两者的组合进行表示的查询。 在以下示例中,queryMajorCities 和 queryMajorCities2 都是查询变量:

1
2
3
4
5
6
7
8
//Query syntax
IEnumerable<City> queryMajorCities =
from city in cities
where city.Population > 100000
select city;

// Method-based syntax
IEnumerable<City> queryMajorCities2 = cities.Where(c => c.Population > 100000);

查询变量的显式和隐式类型化

通常提供查询变量的显式类型以便显示查询变量与 select 子句之间的类型关系。 但是,还可以使用 var 关键字指示编译器在编译时推断查询变量(或任何其他局部变量)的类型

开始查询表达式

查询表达式必须以 from 子句开头。 它指定数据源以及范围变量。 范围变量表示遍历源序列时,源序列中的每个连续元素。 范围变量基于数据源中元素的类型进行强类型化。 在下面的示例中,因为 countries 是 Country 对象的数组,所以范围变量也类型化为 Country。 因为范围变量是强类型,所以可以使用点运算符访问该类型的任何可用成员。

1
2
3
4
IEnumerable<Country> countryAreaQuery =
from country in countries
where country.Area > 500000 //sq km
select country;

范围变量一直处于范围中,直到查询使用分号或 continuation 子句退出。

查询表达式可能会包含多个 from 子句。 在源序列中的每个元素本身是集合或包含集合时,可使用其他 from 子句。 例如,假设具有 Country 对象的集合,其中每个对象都包含名为 Cities 的 City 对象集合。 若要查询每个 Country 中的 City 对象,请使用两个 from 子句,如下所示:

1
2
3
4
5
IEnumerable<City> cityQuery =
from country in countries
from city in country.Cities
where city.Population > 10000
select city;

结束查询表达式

查询表达式必须以 group 子句或 select 子句结尾。

group 子句

使用 group 子句可生成按指定键组织的组的序列。 键可以是任何数据类型。 例如,以下查询会创建包含一个或多个 Country 对象,并且其关键值是数值为国家/地区名称首字母的 char 类型。

1
2
3
var queryCountryGroups =
from country in countries
group country by country.Name[0];

select 子句

使用 select 子句可生成所有其他类型的序列。 简单 select 子句只生成类型与数据源中包含的对象相同的对象的序列。 在此示例中,数据源包含 Country 对象。 orderby 子句只按新顺序对元素进行排序,而 select 子句生成重新排序的 Country 对象的序列。

1
2
3
4
IEnumerable<Country> sortedQuery =
from country in countries
orderby country.Area
select country;

select 子句可以用于将源数据转换为新类型的序列。 此转换也称为投影。 在下面的示例中,select 子句对只包含原始元素中的字段子集的匿名类型序列进行投影。 请注意,新对象使用对象初始值设定项进行初始化。

1
2
3
4
5
6
7
8
9
// Here var is required because the query
// produces an anonymous type.
var queryNameAndPop =
from country in countries
select new
{
Name = country.Name,
Pop = country.Population
};
  • 选择每个源元素的子集
    有两种主要方法来选择源序列中每个元素的子集:

若要仅选择源元素的一个成员,请使用点操作。 在以下示例中,假设 Customer 对象包含多个公共属性,包括名为 City 的字符串。 在执行时,此查询将生成字符串的输出序列。

1
2
var query = from cust in Customers  
select cust.City;

若要创建包含多个源元素属性的元素,可以使用带有命名对象或匿名类型的对象初始值设定项。 以下示例演示如何使用匿名类型封装每个 Customer 元素的两个属性:

1
2
var query = from cust in Customer  
select new {Name = cust.Name, City = cust.City};
  • 将内存中对象转换为 XML
    LINQ 查询可以轻松地在内存中数据结构、SQL 数据库、ADO.NET 数据集和 XML 流或文档之间转换数据。 以下示例将内存中数据结构中的对象转换为 XML 元素。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    class XMLTransform
    {
    static void Main()
    {
    // Create the data source by using a collection initializer.
    // The Student class was defined previously in this topic.
    List<Student> students = new List<Student>()
    {
    new Student {First="Svetlana", Last="Omelchenko", ID=111, Scores = new List<int>{97, 92, 81, 60}},
    new Student {First="Claire", Last="O’Donnell", ID=112, Scores = new List<int>{75, 84, 91, 39}},
    new Student {First="Sven", Last="Mortensen", ID=113, Scores = new List<int>{88, 94, 65, 91}},
    };

    // Create the query.
    var studentsToXML = new XElement("Root",
    from student in students
    let scores = string.Join(",", student.Scores)
    select new XElement("student",
    new XElement("First", student.First),
    new XElement("Last", student.Last),
    new XElement("Scores", scores)
    ) // end "student"
    ); // end "Root"

    // Execute the query.
    Console.WriteLine(studentsToXML);

    // Keep the console open in debug mode.
    Console.WriteLine("Press any key to exit.");
    Console.ReadKey();
    }
    }
    此代码生成以下 XML 输出:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    <Root>  
    <student>
    <First>Svetlana</First>
    <Last>Omelchenko</Last>
    <Scores>97,92,81,60</Scores>
    </student>
    <student>
    <First>Claire</First>
    <Last>O'Donnell</Last>
    <Scores>75,84,91,39</Scores>
    </student>
    <student>
    <First>Sven</First>
    <Last>Mortensen</Last>
    <Scores>88,94,65,91</Scores>
    </student>
    </Root>

使用“into”延续

可以在 select 或 group 子句中使用 into 关键字创建存储查询的临时标识符。 如果在分组或选择操作之后必须对查询执行其他查询操作,则可以这样做。 在下面的示例中,countries 按 1000 万范围,根据人口进行分组。 创建这些组之后,附加子句会筛选出一些组,然后按升序对组进行排序。 若要执行这些附加操作,需要由 countryGroup 表示的延续。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// percentileQuery is an IEnumerable<IGrouping<int, Country>>
var percentileQuery =
from country in countries
let percentile = (int)country.Population / 10_000_000
group country by percentile into countryGroup
where countryGroup.Key >= 20
orderby countryGroup.Key
select countryGroup;

// grouping is an IGrouping<int, Country>
foreach (var grouping in percentileQuery)
{
Console.WriteLine(grouping.Key);
foreach (var country in grouping)
{
Console.WriteLine(country.Name + ":" + country.Population);
}
}

筛选、排序和联接

在开头 from 子句与结尾 select 或 group 子句之间,所有其他子句(where、join、orderby、from、let)都是可选的。 任何可选子句都可以在查询正文中使用零次或多次。

where 子句

使用 where 子句可基于一个或多个谓词表达式,从源数据中筛选出元素。 以下示例中的 where 子句具有一个谓词及两个条件。

1
2
3
4
IEnumerable<City> queryCityPop =
from city in cities
where city.Population < 200000 && city.Population > 100000
select city;

orderby 子句

使用 orderby 子句可按升序或降序对结果进行排序。 还可以指定次要排序顺序。 下面的示例使用 Area 属性对 country 对象执行主要排序。 然后使用 Population 属性执行次要排序。

1
2
3
4
IEnumerable<Country> querySortedCountries =
from country in countries
orderby country.Area, country.Population descending
select country;

join 子句

使用 join 子句可基于每个元素中指定的键之间的相等比较,将一个数据源中的元素与另一个数据源中的元素进行关联和/或合并。

联接操作是对元素属于不同类型的对象序列执行。 联接了两个序列之后,必须使用 select 或 group 语句指定要存储在输出序列中的元素。

还可以使用匿名类型将每组关联元素中的属性合并到输出序列的新类型中。 下面的示例关联其 Category 属性与 categories 字符串数组中一个类别匹配的 prod 对象。 筛选出 Category 与 categories 中的任何字符串均不匹配的产品。select 语句投影属性取自 cat 和 prod 的新类型。

1
2
3
4
5
6
7
8
var categoryQuery =
from cat in categories
join prod in products on cat equals prod.Category
select new
{
Category = cat,
Name = prod.Name
};

let 子句

使用 let 子句可将表达式(如方法调用)的结果存储在新范围变量中。 在下面的示例中,范围变量 firstName 存储 Split 返回的字符串数组的第一个元素。

1
2
3
4
5
6
7
8
9
10
11
12
string[] names = { "Svetlana Omelchenko", "Claire O'Donnell", "Sven Mortensen", "Cesar Garcia" };
IEnumerable<string> queryFirstNames =
from name in names
let firstName = name.Split(' ')[0]
select firstName;

foreach (string s in queryFirstNames)
{
Console.Write(s + " ");
}

//Output: Svetlana Claire Sven Cesar

查询表达式中的子查询

查询子句本身可能包含查询表达式,这有时称为子查询。 每个子查询都以自己的 from 子句开头,该子句不一定指向第一个 from 子句中的相同数据源。 例如,下面的查询演示在 select 语句用于检索分组操作结果的查询表达式。

1
2
3
4
5
6
7
8
9
10
11
var queryGroupMax =
from student in students
group student by student.Year into studentGroup
select new
{
Level = studentGroup.Key,
HighestScore = (
from student2 in studentGroup
select student2.ExamScores.Average()
).Max()
};

构建软件系统一直很复杂。尤其是在现代,即使是创建一个基本的业务解决方案,也面临着许多挑战。您通常会发现自己实现了标准的非业务需求,并深入研究了基础架构问题,而不是实现了业务代码(这是您试图构建的系统中真正有价值的部分)。

ABP框架通过提供强大的软件架构、自动化重复的细节以及提供必要的基础设施来帮助构建现代web解决方案,帮助您专注于为利益相关者增加价值的代码。它提供了端到端、一致的开发体验,并提高了您的生产力。ABP帮助您和您的团队
加快所有预先应用的现代软件开发最佳实践。

数据传输对象(DTO)用于在应用层和表示层或其他类型的客户端之间传输数据.

通常用DTO作为参数在表示层(可选)调用应用服务. 它使用领域对象执行某些特定的业务逻辑,并(可选)将DTO返回到表示层.因此表示层与领域层完全隔离.

数据隐藏

假设你有一个具有属性Id,名称,电子邮件地址和密码的 User 实体. 如果 UserAppService 的 GetAllUsers() 方法返回 List,任何人都可以访问你所有用户的密码,即使你没有在屏幕上显示它. 这不仅关乎安全,还关乎数据隐藏. 应用程序服务应该只返回表示层(或客户端)所需要的内容,不多也不少.

序列化和延迟加载问题

当你将数据(一个对象)返回到表示层时,它很可能是序列化的. 例如在返回JSON的REST API中,你的对象将被序列化为JSON并发送给客户端. 在这方面将实体返回到表示层可能会有问题,尤其是在使用关系数据库和像Entity Framework Core这样的ORM提供者时.

在真实的应用程序中你的实体可以相互引用. User 实体可以引用它的角色. 如果你想序列化用户,它的角色也必须是序列化的. Role 类可以有 List ,而 Permission 类可以有一个对 PermissionGroup 类的引用,依此类推…想象一下所有这些对象都被立即序列化了. 你可能会意外地序列化整个数据库! 同样,如果你的对象具有循环引用,则它们可能根本不会序列化成功.

几乎所有的O/RM框架都支持延迟加载. 此功能可在需要时从数据库加载实体. 假设 User 类具有对 Role 类的引用. 当你从数据库中获取用户时,Role 属性(或集合)不会被立即填充. 首次读取 Role 属性时,它是从数据库加载的. 因此如果将这样的实体返回到表示层,它将通过执行额外的查询从数据库中检索额外的实体. 如果序列化工具读取实体,它会递归读取所有属性,并且可以再次检索整个数据库(如果实体之间存在关系).

如果在表示层中使用实体,可能会出现更多问题.最好不要在表示层中引用领域/业务层程序集.

ABP并不强迫你使用DTO,但是强烈建议将DTO作为最佳实践.

标准接口和基类

DTO是一个没有依赖性的简单类,你可以用任何方式进行设计. 但是ABP引入了一些接口来确定命名标准属性和基类的约定,以免在声明公共属性时重复工作.

实体相关DTO

通常你需要创建与你的实体相对应的DTO,从而生成与实体类似的类. ABP框架在创建DTO时提供了一些基类来简化.

EntityDto

IEntityDto 是一个只定义 Id 属性的简单接口. 你可以实现它或从 EntityDto 继承.

审计DTO

如果你的实体继承自被审计的实体类(或实现审计接口)可以使用以下基类来创建DTO:

1
2
3
4
5
6
CreationAuditedEntityDto
CreationAuditedEntityWithUserDto
AuditedEntityDto
AuditedEntityWithUserDto
FullAuditedEntityDto
FullAuditedEntityWithUserDto

可扩展的DTO

如果你想为你的DTO使用对象扩展系统,你可以使用或继承以下DTO类:

1
2
3
4
5
6
7
8
ExtensibleObject 实现 IHasExtraProperties (其它类继承这个类).
ExtensibleEntityDto
ExtensibleCreationAuditedEntityDto
ExtensibleCreationAuditedEntityWithUserDto
ExtensibleAuditedEntityDto
ExtensibleAuditedEntityWithUserDto
ExtensibleFullAuditedEntityDto
ExtensibleFullAuditedEntityWithUserDto

列表结果

通常将DTO列表返回给客户端. IListResult 接口和 ListResultDto 类用于使其成为标准.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
namespace AbpDemo
{
public class ProductAppService : ApplicationService, IProductAppService
{
private readonly IRepository<Product, Guid> _productRepository;

public ProductAppService(IRepository<Product, Guid> productRepository)
{
_productRepository = productRepository;
}

public async Task<ListResultDto<ProductDto>> GetListAsync()
{
//Get entities from the repository
List<Product> products = await _productRepository.GetListAsync();

//Map entities to DTOs
List<ProductDto> productDtos =
ObjectMapper.Map<List<Product>, List<ProductDto>>(products);

//Return the result
return new ListResultDto<ProductDto>(productDtos);
}
}
}

你可以简单地返回 productDtos 对象(并更改方法的返回类型), 这也没有错. 返回一个 ListResultDto 会使List 做为 Item 属性包装到另一个对象中. 这具有一个优点:以后可以在不破坏远程客户端的情况下(当它们作为JSON结果获得值时)在返回值中添加更多属性. 在开发可重用的应用程序模块时特别建议使用这种方式.

分页 & 排序列表结果

从服务器请求分页列表并将分页列表返回给客户端是更常见的情况. ABP定义了一些接口和类来对其进行标准化:

输入 (请求) 类型

建议你继承以下基类DTO类之一,而不是手动实现接口:

  • LimitedResultRequestDto 实现了 ILimitedResultRequest.
  • PagedResultRequestDto 实现了 IPagedResultRequest (和继承自 LimitedResultRequestDto).
  • PagedAndSortedResultRequestDto 实现了 IPagedAndSortedResultRequest (和继承自 PagedResultRequestDto).

最大返回数量

LimitedResultRequestDto(和其它固有的)通过以下规则限制和验证 MaxResultCount;

最大返回数量
LimitedResultRequestDto(和其它固有的)通过以下规则限制和验证 MaxResultCount;

  • 如果客户端未设置 MaxResultCount,则假定为10(默认页面大小). 可以通过设置 LimitedResultRequestDto.DefaultMaxResultCount 静态属性来更改此值.
  • 如果客户端发送的 MaxResultCount 大于1,000,则会产生验证错误. 保护服务器免受滥用服务很重要. 如果需要可以通过设置 LimitedResultRequestDto.MaxMaxResultCount 静态属性来更改此值.

建议在应用程序启动时设置静态属性,因为它们是静态的(全局).

输出 (响应) 类型

以下接口和类用于标准化发送给客户端的输出.

建议你继承以下基类DTO类之一,而不是手动实现接口:

  • PagedResultDto 继承自 ListResultDto 和实现了 IPagedResult.

最佳实践

你可以自由设计DTO类,然而这里有一些你可能想要遵循的最佳实践和建议.

共同原则

  • DTO应该是可序列化的,因为它们通常是序列化和反序列化的(JSON或其他格式). 如果你有另一个带参数的构造函数,建议使用空(无参数)的公共构造函数.

  • 除某些验证代码外,DTO不应包含任何业务逻辑.

  • DTO不要继承实体,也不要引用实体. 应用程序启动模板已经通过分隔项目来阻止它.

  • 如果你使用自动对象到对象映射库,如AutoMapper,请启用映射配置验证以防止潜在的错误.

  • 输入DTO原则

  • 只定义用例所需的属性. 不要包含不用于用例的属性,这样做会使开发人员感到困惑.

  • 不要在不同的应用程序服务方法之间重用输入DTO. 因为不同的用例将需要和使用DTO的不同属性.

输出DTO原则

如果在所有情况下填充所有属性,就可以重用输出DTO.

应用服务实现应用程序的用例, 将领域层逻辑公开给表示层.

假设你有一个Book实体(聚合根), 如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class Book : AggregateRoot<Guid>
{
public const int MaxNameLength = 128;

public virtual string Name { get; protected set; }

public virtual BookType Type { get; set; }

public virtual float? Price { get; set; }

protected Book()
{

}

public Book(Guid id, [NotNull] string name, BookType type, float? price = 0)
{
Id = id;
Name = CheckName(name);
Type = type;
Price = price;
}

public virtual void ChangeName([NotNull] string name)
{
Name = CheckName(name);
}

private static string CheckName(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException($"name can not be empty or white space!");
}

if (name.Length > MaxNameLength)
{
throw new ArgumentException($"name can not be longer than {MaxNameLength} chars!");
}

return name;
}
}

IBookAppService接口

在ABP中应用程序服务应该实现IApplicationService接口. 推荐每个应用程序服务创建一个接口:

1
2
3
4
public interface IBookAppService : IApplicationService
{
Task CreateAsync(CreateBookDto input);
}

我们将实现Create方法作为示例. CreateBookDto定义如下:

1
2
3
4
5
6
7
8
9
10
public class CreateBookDto
{
[Required]
[StringLength(Book.MaxNameLength)]
public string Name { get; set; }

public BookType Type { get; set; }

public float? Price { get; set; }
}

BookAppService(实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class BookAppService : ApplicationService, IBookAppService
{
private readonly IRepository<Book, Guid> _bookRepository;

public BookAppService(IRepository<Book, Guid> bookRepository)
{
_bookRepository = bookRepository;
}

public async Task CreateAsync(CreateBookDto input)
{
var book = new Book(
GuidGenerator.Create(),
input.Name,
input.Type,
input.Price
);

await _bookRepository.InsertAsync(book);
}
}
  • BookAppService继承了基类ApplicationService· 这不是必需的, 但是ApplicationService提供了应用服务常见的需求(比如本示例服务中使用的GuidGenerator). 如果不继承它, 我们需要在服务中手动注入IGuidGenerator
  • BookAppService按照预期实现了IBookAppService
  • BookAppService 注入了 IRepository<Book, Guid>(请参见仓储)在CreateAsync方法内部使用仓储将新实体插入数据库.
  • CreateAsync使用Book实体的构造函数从给定的Input值创建新的Book对象

数据传输对象

应用服务使用并返回DTO而不是实体. ABP不会强制执行此规则. 但是将实体暴露给表示层(或远程客户端)存在重大问题, 所以不建议返回实体.

CRUD应用服务

如果需要创建具有Create,Update,Delete和Get方法的简单CRUD应用服务,则可以使用ABP的基类轻松构建服务. 你可以继承CrudAppService.

创建继承ICrudAppService接口的IBookAppService接口.

1
2
3
4
5
6
7
8
9
public interface IBookAppService : 
ICrudAppService< //Defines CRUD methods
BookDto, //Used to show books
Guid, //Primary key of the book entity
PagedAndSortedResultRequestDto, //Used for paging/sorting on getting a list of books
CreateUpdateBookDto, //Used to create a new book
CreateUpdateBookDto> //Used to update a book
{
}

ICrudAppService 有泛型参数来获取实体的主键类型和CRUD操作的DTO类型(它不获取实体类型,因为实体类型未向客户端公开使用此接口).

最后BookAppService实现非常简单:

1
2
3
4
5
6
7
8
9
10
public class BookAppService : 
CrudAppService<Book, BookDto, Guid, PagedAndSortedResultRequestDto,
CreateUpdateBookDto, CreateUpdateBookDto>,
IBookAppService
{
public BookAppService(IRepository<Book, Guid> repository)
: base(repository)
{
}
}

CrudAppService实现了ICrudAppService接口中声明的所有方法. 然后,你可以添加自己的自定义方法或重写和自定义实现.

AbstractKeyCrudAppService

CrudAppService 要求你的实体拥有一个Id属性做为主键. 如果你使用的是复合主键,那么你无法使用它.

AbstractKeyCrudAppService 实现了相同的 ICrudAppService 接口,但它没有要求实体有主键.

假设你有实体 District,它的CityId 和 Name 做为复合主键,使用 AbstractKeyCrudAppService 时需要你自己实现 DeleteByIdAsync 和 GetEntityByIdAsync 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class DistrictAppService
: AbstractKeyCrudAppService<District, DistrictDto, DistrictKey>
{
public DistrictAppService(IRepository<District> repository)
: base(repository)
{
}

protected async override Task DeleteByIdAsync(DistrictKey id)
{
await Repository.DeleteAsync(d => d.CityId == id.CityId && d.Name == id.Name);
}

protected async override Task<District> GetEntityByIdAsync(DistrictKey id)
{
return await AsyncQueryableExecuter.FirstOrDefaultAsync(
Repository.Where(d => d.CityId == id.CityId && d.Name == id.Name)
);
}
}

这个实现需要你创建一个类做为复合键:

1
2
3
4
5
6
public class DistrictKey
{
public Guid CityId { get; set; }

public string Name { get; set; }
}

生命周期

应用服务的生命周期是transient的,它们会自动注册到依赖注入系统.

总结

  • 将实体对象作为方法参数,而不是其id值。如果接受其id值,则需要从领域服务内的数据库中检索实体。这种方法使应用程序代码在同一请求(用例)的不同位置多次加载同一实体,这是低效的,并导致错误。

规约模式用于为实体和其他业务对象定义 命名、可复用、可组合和可测试的过滤器 .
规约是领域层的一部分.

定义规约

你可以创建一个由 Specification 派生的新规约类.

1
2
3
4
5
6
7
8
9
10
namespace MyProject
{
public class Age18PlusCustomerSpecification : Specification<Customer>
{
public override Expression<Func<Customer, bool>> ToExpression()
{
return c => c.Age >= 18;
}
}
}

你也可以直接实现ISpecification接口,但是基类Specification做了大量简化.

使用规约

这里有两种常见的规约用例.

IsSatisfiedBy

IsSatisfiedBy 方法可以用于检查单个对象是否满足规约.

例如:如果顾客不满足年龄规定,则抛出异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace MyProject
{
public class CustomerService : ITransientDependency
{
public async Task BuyAlcohol(Customer customer)
{
if (!new Age18PlusCustomerSpecification().IsSatisfiedBy(customer))
{
throw new Exception(
"这位顾客不满足年龄规定!"
);
}

//TODO...
}
}
}

ToExpression & Repositories

ToExpression() 方法可用于将规约转化为表达式.通过这种方式,你可以使用规约在数据库查询时过滤实体.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
namespace MyProject
{
public class CustomerManager : DomainService, ITransientDependency
{
private readonly IRepository<Customer, Guid> _customerRepository;

public CustomerManager(IRepository<Customer, Guid> customerRepository)
{
_customerRepository = customerRepository;
}

public async Task<List<Customer>> GetCustomersCanBuyAlcohol()
{
var queryable = await _customerRepository.GetQueryableAsync();
var query = queryable.Where(
new Age18PlusCustomerSpecification().ToExpression()
);

return await AsyncExecuter.ToListAsync(query);
}
}
}

规约被正确地转换为SQL/数据库查询语句,并且在DBMS端高效执行.虽然它与规约无关,但如果你想了解有关 AsyncExecuter 的更多信息,请参阅仓储文档.

实际上,没有必要使用 ToExpression() 方法,因为规约会自动转换为表达式.这也会起作用:

1
2
3
4
var queryable = await _customerRepository.GetQueryableAsync();
var query = queryable.Where(
new Age18PlusCustomerSpecification()
);

编写规约

规约有一个强大的功能是,它们可以与And、Or、Not以及AndNot扩展方法组合使用.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
namespace MyProject
{
public class PremiumCustomerSpecification : Specification<Customer>
{
public override Expression<Func<Customer, bool>> ToExpression()
{
return (customer) => (customer.Balance >= 100000);
}
}
}
你可以将 PremiumCustomerSpecification 和 Age18PlusCustomerSpecification 结合起来,查询优质成人顾客的数量,如下所示:

~~~C#
namespace MyProject
{
public class CustomerManager : DomainService, ITransientDependency
{
private readonly IRepository<Customer, Guid> _customerRepository;

public CustomerManager(IRepository<Customer, Guid> customerRepository)
{
_customerRepository = customerRepository;
}

public async Task<int> GetAdultPremiumCustomerCountAsync()
{
return await _customerRepository.CountAsync(
new Age18PlusCustomerSpecification()
.And(new PremiumCustomerSpecification()).ToExpression()
);
}
}
}

如果你想让这个组合成为一个可复用的规约,你可以创建这样一个组合的规约类,它派生自AndSpecification:

1
2
3
4
5
6
7
8
9
10
11
12
13
using Volo.Abp.Specifications;

namespace MyProject
{
public class AdultPremiumCustomerSpecification : AndSpecification<Customer>
{
public AdultPremiumCustomerSpecification()
: base(new Age18PlusCustomerSpecification(),
new PremiumCustomerSpecification())
{
}
}
}

何时使用规约

使用规约的一些好处:

  • 可复用:假设你在代码库的许多地方都需要用到优质顾客过滤器.如果使用表达式而不创建规约,那么如果以后更改“优质顾客”的定义会发生什么?假设你想将最低余额从100000美元更改为250000美元,并添加另一个条件,成为顾客超过3年.如果使用了规约,只需修改一个类.如果在任何其他地方重复(复制/粘贴)相同的表达式,则需要更改所有的表达式.
  • 可组合:可以组合多个规约来创建新规约.这是另一种可复用性.
  • 可命名:PremiumCustomerSpecification 更好地解释了为什么使用规约,而不是复杂的表达式.因此,如果在你的业务中使用了一个有意义的表达式,请考虑使用规约.
  • 可测试:规约是一个单独(且易于)测试的对象.

领域驱动设计(DDD) 解决方案中,核心业务逻辑通常在聚合 (实体) 和领域服务中实现. 在以下情况下特别需要创建领域服务

  • 你实现了依赖于某些服务(如存储库或其他外部服务)的核心域逻辑.
  • 你需要实现的逻辑与多个聚合/实体相关,因此它不适合任何聚合.

    ABP 领域服务基础设施

    领域服务是简单的无状态类. 虽然你不必从任何服务或接口派生,但 ABP 框架提供了一些有用的基类和约定.

DomainService 和 IDomainService

从 DomainService 基类派生领域服务或直接实现 IDomainService 接口.

1
2
3
4
5
6
7
8
using Volo.Abp.Domain.Services;
namespace MyProject.Issues
{
public class IssueManager : DomainService
{

}
}

当你这样做时:

  • ABP 框架自动将类注册为瞬态生命周期到依赖注入系统.
  • 你可以直接使用一些常用服务作为基础属性,而无需手动注入 (例如 ILogger and IGuidGenerator).

建议使用 Manager 或 Service 后缀命名领域服务. 我们通常使用如上面示例中的 Manager 后缀. 示例: 实现将问题分配给用户的领域逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class IssueManager : DomainService
{
private readonly IRepository<Issue, Guid> _issueRepository;
public IssueManager(IRepository<Issue, Guid> issueRepository)
{
_issueRepository = issueRepository;
}

public async Task AssignAsync(Issue issue, AppUser user)
{
var currentIssueCount = await _issueRepository
.CountAsync(i => i.AssignedUserId == user.Id);

//Implementing a core business validation
if (currentIssueCount >= 3)
{
throw new IssueAssignmentException(user.UserName);
}
issue.AssignedUserId = user.Id;
}
}

问题是定义如下所示的 聚合根:

1
2
3
4
5
6
public class Issue : AggregateRoot<Guid>
{
public Guid? AssignedUserId { get; internal set; }

//...
}

使用 internal 的 set 确保外层调用者不能直接在调用 set ,并强制始终使用 IssueManager 为 User 分配 Issue.

应用程序服务与领域服务

虽然 应用服务 和领域服务都实现了业务规则,但他们存在根本的逻辑和形式差异:

  • 应用程序服务实现应用程序的 用例 (典型 Web 应用程序中的用户交互), 而领域服务实现 核心的、用例独立的领域逻辑.
  • 应用程序服务获取/返回 数据传输对象(Dto), 领域服务方法通常获取和返回 领域对象 (实体, 值对象).
  • 领域服务通常由应用程序服务或其他领域服务使用,而应用程序服务由表示层或客户端应用程序使用.

生命周期

领域服务的生命周期是 瞬态 的,它们会自动注册到依赖注入服务.

总结

  • 不要在领域层,持久化实体数据到数据库中。应该在应用服务层持久化数据。
  • 将实体对象作为方法参数,而不是其id值。如果接受其id值,则需要从领域服务内的数据库中检索实体。这种方法使应用程序代码在同一请求(用例)的不同位置多次加载同一实体,这是低效的,并导致错误。

仓储

在领域层和数据映射层之间进行中介,使用类似集合的接口来操作领域对象。
实际上,仓储用于领域对象对数据库的操作, 通常每个 聚合根 或实体会创建各自对应的仓储.

通用仓储

ABP为每个聚合根和实体提供了默认的仓储。 你可以在服务中注入IRepository<TEntity, TKey>执行标准的CRUD操作。
默认通用仓储用法示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class PersonAppService : ApplicationService
{
private readonly IRepository<Person, Guid> _personRepository;

public PersonAppService(IRepository<Person, Guid> personRepository)
{
_personRepository = personRepository;
}

public async Task CreateAsync(CreatePersonDto input)
{
var person = new Person(input.Name);

await _personRepository.InsertAsync(person);
}

public async Task<int> GetCountAsync(string filter)
{
return await _personRepository.CountAsync(p => p.Name.Contains(filter));
}
}

在这个例子中;

  • PersonAppService 在它的构造函数中注入了IRepository<Person, Guid>
  • CreateAsync 方法使用了 InsertAsync 创建并保存新的实体。
  • GetCountAsync 方法用来从数据库中获取符合指定条件的的人员的数量。

标准仓储方法

通用仓储提供了一些开箱即用的标准CRUD功能:

  • GetAsync: 根据指定的Id或断言(lambda表达式)返回实体。
    • 将在指定的实体不存在时,抛出异常 EntityNotFoundException
    • 如果指定的条件存在多个实体时,抛出异常 InvalidOperationException
  • FindAsync: 根据指定的Id或断言(lambda表达式)返回实体。
    • 如果指定的实体不存在时,返回 null 。
    • 如果指定的条件存在多个实体时,抛出异常 InvalidOperationException
  • InsertAsync: 在数据库里插入一个新的实体。
  • UpdateAsync: 在数据库里更新一个已经存在的实体。
  • DeleteAsync: 从数据库里删除指定的实体。
    • 这个方法还有一个重载根据指定的断言(lambda表达式)来删除满足条件的多个实体。
  • GetListAsync: 返回数据库里的所有实体。
  • GetPagedListAsync: 返回一个指定长度的实体列表。 他拥有 skipCount, maxResultCount and sorting 参数.
  • GetCountAsync: 获取数据库里所有实体的数量

这些方法还有还一些重载。

  • 提供 UpdateAsync 和 DeleteAsync 方法根据实体对象或者id来更新或者删除实体。
  • 提供 DeleteAsync 方法用来删除符合指定条件的多个实体。

在存储上使用LINQ

仓储提供了一个GetQueryableAsync方法来获取一个IQueryable<TEntity>对象。你可以通过这个对象来对实体执行LINQ查询以操作数据库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public async Task<List<PersonDto>> GetListAsync(string filter)
{
// 获取 IQueryable<Person>
IQueryable<Person> queryable = await _personRepository.GetQueryableAsync();

// 创建一个查询
var query = from person in queryable
where person.Name == filter
orderby person.Name
select person;

// 执行查询
var people = query.ToList();

// 转DTO并返回给客户端
return people.Select(p => new PersonDto {Name = p.Name}).ToList();
}

你也可以使用LINQ扩展方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public async Task<List<PersonDto>> GetListAsync(string filter)
{
// 获取 IQueryable<Person>
IQueryable<Person> queryable = await _personRepository.GetQueryableAsync();

// 创建一个查询
var people = queryable
.Where(p => p.Name.Contains(filter))
.OrderBy(p => p.Name)
.ToList();

// 转DTO并返回给客户端
return people.Select(p => new PersonDto {Name = p.Name}).ToList();
}

你可以使用仓储返回的IQueryable 配合标准LINQ方法自由查询。

批量操作

下面这些方法可以用来对数据库执行批量操作;

1
2
3
InsertManyAsync
UpdateManyAsync
DeleteManyAsync

这些方法可以操作多个实体,如果底层数据库提供程序支持,则可以进行批量操作。

软/硬删除

如果一个实体是软删除实体(即实现了ISoftDelete接口),则仓储的DeleteSync方法不会删除该实体,而是在数据库中标记为“已删除”。数据过滤器系统确保不会从数据库中正常检索软删除的实体。

如果您的实体是软删除实体,如果您需要物理删除这个实体,您可以使用HardDeleteAsync方法强制删除。

确保实体存在

EnsureExistsAsync扩展方法通过实体id或实体查询表达式来确保实体存在,如果其不存在,它将抛出EntityNotFoundException异常。

其他通用仓储类型

IRepository<TEntity, TKey> 接口 导出 了标准 IQueryable<TEntity> 你可以使用标准LINQ方法自由查询。这对于大多数应用程序都很好。但是,某些ORM提供程序或数据库系统可能不支持IQueryable接口。如果您想使用这样的提供者,就不能依赖IQueryable

基础仓储

ABP提供了IBasicRepository<TEntity, TPrimaryKey>IBasicRepository<TEntity> 接口来支持这样的场景. 你可以扩展这些接口(并可选择性地从BasicRepositoryBase派生)为你的实体创建自定义存储库.

依赖于 IBasicRepository 而不是依赖 IRepository有一个优点, 即使它们不支持 IQueryable 也可以使用所有的数据源。

但主要的供应商, 像 Entity Framework, NHibernate 或 MongoDb 已经支持了 IQueryable.

因此, 使用 IRepository 是典型应用程序的 建议方法. 但是可重用的模块开发人员可能会考虑使用 IBasicRepository 来支持广泛的数据源.

只读仓储

对于想要使用只读仓储的开发者,我们提供了IReadOnlyRepository<TEntity, TKey>IReadOnlyBasicRepository<Tentity, TKey>接口。

无主键的通用(泛型)仓储

如果你的实体没有id主键 (例如, 它可能具有复合主键) 那么你不能使用上面定义的 IRepository<TEntity, TKey>, 在这种情况下你可以仅使用实体(类型)注入 IRepository<TEntity>

IRepository<TEntity> 有一些缺失的方法, 通常与实体的 Id 属性一起使用. 由于实体在这种情况下没有 Id 属性, 因此这些方法不可用. 比如 Get 方法通过id获取具有指定id的实体. 不过, 你仍然可以使用IQueryable<TEntity>的功能通过标准LINQ方法查询实体。

自定义仓储

ABP不会强制你实现任何接口或从存储库的任何基类继承。它可以只是一个简单的POCO类。 但是建议继承现有的仓储接口和类,获得开箱即用的标准方法使你的工作更轻松。
首先在领域层定义一个仓储接口:

1
2
3
4
public interface IPersonRepository : IRepository<Person, Guid>
{
Task<Person> FindByNameAsync(string name);
}

此接口扩展了 IRepository<Person, Guid> 以使用已有的通用仓储功能。
自定义存储库依赖于你使用的数据访问工具。 在此示例中, 我们将使用Entity Framework Core:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class PersonRepository : EfCoreRepository<MyDbContext, Person, Guid>, IPersonRepository
{
public PersonRepository(IDbContextProvider<TestAppDbContext> dbContextProvider)
: base(dbContextProvider)
{

}

public async Task<Person> FindByNameAsync(string name)
{
var dbContext = await GetDbContextAsync();
return await dbContext.Set<Person>()
.Where(p => p.Name == name)
.FirstOrDefaultAsync();
}
}

IQueryable & 异步操作

IRepository提供GetQueryableAsync()来获取IQueryable,这意味着您可以直接在其上使用LINQ扩展方法

1
2
3
4
var queryable = await _personRepository.GetQueryableAsync();
var people = queryable
.Where(p => p.Name.Contains(nameFilter))
.ToList();

.ToList, Count()… 是在 System.Linq 命名空间下定义的扩展方法.
你通常想要使用 .ToListAsync(), .CountAsync()…. 来编写真正的异步代码.

但在你使用标准的应用程序启动模板时会发现无法在应用层或领域层使用这些异步扩展方法,因为:

  • 这里异步方法不是标准LINQ方法,它们定义在Microsoft.EntityFrameworkCoreNuget包中.

  • 标准模板应用层与领域层不引用EF Core 包以实现数据库提供程序独立.
    强烈建议使用异步方法! 在执行数据库查询时不要使用同步LINQ方法,以便能够开发可伸缩的应用程序.

    选项-1: 引用EF Core

    最简单的方法是在你想要使用异步方法的项目直接引用EF Core包.
    添加Volo.Abp.EntityFrameworkCoreNuGet包到你的项目间接引用EF Core包. 这可以确保你的应用程序其余部分兼容正确版本的EF Core.

    1
    2
    3
    4
    var queryable = await _personRepository.GetQueryableAsync();
    var people = queryable
    .Where(p => p.Name.Contains(nameFilter))
    .ToListAsync();

    当以下情况时,这个方法是推荐的:

  • 如果你正在开发一个应用程序并且不打算在将来 更新FE Core,或者如果以后真的需要更改,你也能容忍它。我们认为,如果您正在开发最终应用程序,这是合理的。

    选项-2: 使用IRepository异步扩展方法

    ABP框架为仓储提供异步扩展方法,与异步LINQ扩展方法类似。

    1
    2
    3
    4
    5
    6
    7
    8
    var countAll = await _personRepository
    .CountAsync();

    var count = await _personRepository
    .CountAsync(x => x.Name.StartsWith("A"));

    var book1984 = await _bookRepository
    .FirstOrDefaultAsync(x => x.Name == "John");

    这种方法仍有局限性。您需要直接在存储库对象上调用扩展方法。例如,以下用法不受支持:

    1
    2
    var queryable = await _bookRepository.GetQueryableAsync();
    var count = await queryable.Where(x => x.Name.Contains("A")).CountAsync();

    这是因为本例中的CountAsync()方法是在IQueryable接口上调用的,而不是在存储库对象上调用的。请参见此类情况的其他选项。

    选项-3: IAsyncQueryableExecuter

    IAsyncQueryableExecuter 是一个用于异步执行 IQueryable 对象的服务,不依赖于实际的数据库提供程序.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    namespace AbpDemo
    {
    public class ProductAppService : ApplicationService, IProductAppService
    {
    private readonly IRepository<Product, Guid> _productRepository;
    private readonly IAsyncQueryableExecuter _asyncExecuter;

    public ProductAppService(
    IRepository<Product, Guid> productRepository,
    IAsyncQueryableExecuter asyncExecuter)
    {
    _productRepository = productRepository;
    _asyncExecuter = asyncExecuter;
    }

    public async Task<ListResultDto<ProductDto>> GetListAsync(string name)
    {
    //Obtain the IQueryable<T>
    var queryable = await _productRepository.GetQueryableAsync();

    //Create the query
    var query = queryable
    .Where(p => p.Name.Contains(name))
    .OrderBy(p => p.Name);

    //Run the query asynchronously
    List<Product> products = await _asyncExecuter.ToListAsync(query);

    //...
    }
    }
    }

    ApplicationService 和 DomainService 基类已经预属性注入了 AsyncExecuter 属性,所以你可直接使用.

ABP框架使用实际数据库提供程序的API异步执行查询。虽然这不是执行查询的常见方式,但它是使用异步API而不依赖于数据库提供者的最佳方式。

当以下情况时,这个方法是推荐的:

  • 如果您想开发应用程序代码而不依赖数据库提供程序。
  • 如果你正在构建一个没有数据库提供程序集成包的可重用库,但是在某些情况下需要执行 IQueryable对象.
    例如,ABP框架在 CrudAppService 基类中(参阅应用程序文档)使用 IAsyncQueryableExecuter.

选项-4: 自定义仓储方法

当以下情况时,这个方法是推荐的:

  • 如果你想完全隔离你的领域和应用层和数据库提供程序.
  • 如果你开发可重用的应用模块,并且不想强制使用特定的数据库提供程序,这应该作为一种最佳实践.

实体

实体通常映射成关系数据库中的表。

实体类

实体派生于Entity<TKey>类,如下所示:

1
2
3
4
5
6
public class Book : Entity<Guid>
{
public string Name { get; set; }

public float Price { get; set; }
}

如果你不想从基类Entity<TKey>派生你自己的实体,你也可以直接实现IEntity<TKey>接口。

Entity<TKey>类只定义了一个Id属性,用作主键。在上面的例子中是Guid。主键也可以是其他类型,比如string, int, long,或者任何你需要的类型。

Guid主键的实体

如果你的实体主键是Guid,这里有一些好的实践方法

  • 创建一个构造函数,将Id作为参数获取并传递给基类。
    • 如果你没有设置Guid主键值,ABP框架在保存时设置。但是在将实体保存到数据库之前,最好在实体上有一个有效的Id值。
  • 如果你的实体创建了带有参数的构造函数,也要创建私有或受保护的空构造函数。空构造函数在您的数据库提供者从数据库(反序列化)读取您的实体时使用。
  • 不要使用Guid.NewGuid()来设置Id值!使用IGuidGenerator服务来为实体创建Id值。通过优化IGuidGenerator,他可以生成连续的Guid值,这对于关系数据库中的聚集索引至关重要。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Book : Entity<Guid>
{
public string Name { get; set; }

public float Price { get; set; }

protected Book()
{

}

public Book(Guid id)
: base(id)
{

}
}

具有复合键的实体

有些实体可能需要 复合键 .在这种情况下,可以从非泛型Entity类派生实体.如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class UserRole : Entity
{
public Guid UserId { get; set; }

public Guid RoleId { get; set; }

public DateTime CreationTime { get; set; }

public UserRole()
{

}

public override object[] GetKeys()
{
return new object[] { UserId, RoleId };
}
}

上面的例子中,复合键由UserId和RoleId组成.在关系数据库中,它是相关表的复合主键. 具有复合键的实体应当实现上面代码中所示的GetKeys()方法.

聚合根

“聚合是域驱动设计中的一种模式.DDD的聚合是一组可以作为一个单元处理的域对象.例如,订单及订单系列的商品,这些是独立的对象,但将订单(连同订单系列的商品)视为一个聚合通常是很有用的”

AggregateRoot<TKey>类继承自Entity<TKey>类,所以默认有Id这个属性.

注意ABP框架只为聚合根创建默认的存储,当然也可以手动为所有实体创建存储

ABP不强制你使用聚合根,实际上你可以使用上面定义的Entity类,当然,如果你想实现领域驱动设计并且创建聚合根,这里有一些最佳实践仅供参考:

  • 聚合根需要维护自身的完整性,所有的实体也是这样.但是聚合根也要维护子实体的完整性.所以,聚合根必须一直有效。
  • 使用Id引用聚合根,而不使用导航属性
  • 聚合根被视为一个单元.它是作为一个单元检索和更新的.它通常被认为是一个交易边界.
  • 不单独修改聚合根中的子实体

    聚合根例子

    这是一个具有子实体集合的聚合根例子:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    public class Order : AggregateRoot<Guid>
    {
    public virtual string ReferenceNo { get; protected set; }

    public virtual int TotalItemCount { get; protected set; }

    public virtual DateTime CreationTime { get; protected set; }

    public virtual List<OrderLine> OrderLines { get; protected set; }

    protected Order()
    {

    }

    public Order(Guid id, string referenceNo)
    {
    Check.NotNull(referenceNo, nameof(referenceNo));

    Id = id;
    ReferenceNo = referenceNo;

    OrderLines = new List<OrderLine>();
    }

    public void AddProduct(Guid productId, int count)
    {
    if (count <= 0)
    {
    throw new ArgumentException(
    "You can not add zero or negative count of products!",
    nameof(count)
    );
    }

    var existingLine = OrderLines.FirstOrDefault(ol => ol.ProductId == productId);

    if (existingLine == null)
    {
    OrderLines.Add(new OrderLine(this.Id, productId, count));
    }
    else
    {
    existingLine.ChangeCount(existingLine.Count + count);
    }

    TotalItemCount += count;
    }
    }

    public class OrderLine : Entity
    {
    public virtual Guid OrderId { get; protected set; }

    public virtual Guid ProductId { get; protected set; }

    public virtual int Count { get; protected set; }

    protected OrderLine()
    {

    }

    internal OrderLine(Guid orderId, Guid productId, int count)
    {
    OrderId = orderId;
    ProductId = productId;
    Count = count;
    }

    internal void ChangeCount(int newCount)
    {
    Count = newCount;
    }

    public override object[] GetKeys()
    {
    return new Object[] {OrderId, ProductId};
    }
    }
    如果你不想你的聚合根继承AggregateRoot类,你可以直接实现IAggregateRoot接口

Order是一个具有Guid类型Id属性的 聚合根.它有一个OrderLine实体集合.OrderLine是一个具有组合键(OrderId和 ProductId)的实体.

虽然这个示例可能无法实现聚合根的所有最佳实践,但它仍然遵循良好的实践:

  • Order有一个公共的构造函数,它需要 minimal requirements 来构造一个”订单”实例.因此,在没有Id和referenceNo的时候是无法创建订单的.protected/private的构造函数只有从数据库读取对象时 反序列化 才需要.
  • OrderLine的构造函数是internal的,所以它只能由领域层来创建.在Order.AddProduct这个方法的内部被使用.
  • Order.AddProduct实现了业务规则将商品添加到订单中
  • 所有属性都有protected的set.这是为了防止实体在实体外部任意改变.因此,在没有向订单中添加新产品的情况下设置 TotalItemCount将是危险的.它的值由AddProduct方法维护.

带有组合键的聚合根

虽然这种聚合根并不常见(也不建议使用),但实际上可以按照与上面提到的跟实体相同的方式定义复合键.在这种情况下,要使用非泛型的AggregateRoot基类.

BasicAggregateRoot类

AggregateRoot 类实现了 IHasExtraProperties 和 IHasConcurrencyStamp 接口,这为派生类带来了两个属性. IHasExtraProperties 使实体可扩展(请参见下面的 额外的属性部分) 和 IHasConcurrencyStamp 添加了由ABP框架管理的 ConcurrencyStamp 属性实现乐观并发. 在大多数情况下,这些是聚合根需要的功能.

但是,如果你不需要这些功能,你的聚合根可以继承 BasicAggregateRoot(或BasicAggregateRoot).

额外的属性

ABP定义了 IHasExtraProperties 接口,可以由实体实现,以便能够动态地设置和获取的实体属性. AggregateRoot 基类已经实现了 IHasExtraProperties 接口. 如果你从这个类(或者上面定义的一个相关审计类)派生,那么你可以直接使用API​.

GetProperty 和 SetProperty 扩展方法

这些扩展方法是获取和设置实体数据的推荐方法. 例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class ExtraPropertiesDemoService : ITransientDependency
{
private readonly IIdentityUserRepository _identityUserRepository;

public ExtraPropertiesDemoService(IIdentityUserRepository identityUserRepository)
{
_identityUserRepository = identityUserRepository;
}

public async Task SetTitle(Guid userId, string title)
{
var user = await _identityUserRepository.GetAsync(userId);

//SET A PROPERTY
user.SetProperty("Title", title);

await _identityUserRepository.UpdateAsync(user);
}

public async Task<string> GetTitle(Guid userId)
{
var user = await _identityUserRepository.GetAsync(userId);

//GET A PROPERTY
return user.GetProperty<string>("Title");
}
}

值对象

一个对象,表示领域的描述方面,没有概念上的身份被称为 值对象.

两个具有相同属性但id不同的实体被认为是不同的实体。但是,值对象没有id,如果它们具有相同的属性值,则它们被视为相等的。

值对象的类

值对象是一个抽象类,可以继承它来创建值对象类

示例: An Address class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class Address : ValueObject
{
public Guid CityId { get; private set; }

public string Street { get; private set; }

public int Number { get; private set; }

private Address()
{

}

public Address(
Guid cityId,
string street,
int number)
{
CityId = cityId;
Street = street;
Number = number;
}

protected override IEnumerable<object> GetAtomicValues()
{
yield return Street;
yield return CityId;
yield return Number;
}
}
  • 值对象类必须实现 GetAtomicValues()方法来返回原始值

最佳实践

  • 如果没有充分的理由将值对象设计为可变的,则将其设计为不可变(如上面的地址).
  • 构成一个值对象的属性应该形成一个概念整体.例如:CityId,Street和Number不应是个人实体的单独属性.这也使Person实体更简

总结

  • 当实体属性,没有业务规则或其他限制条件时,属性应该设置为 public;
  • 当实体属性,只有简单的限制条件时(不需要根据数据库或其他实体判断),属性应该设置为 private;在实体上创建修改方法,不要在领域层中创建;
  • 当实体属性,有复杂业务规则限制时,属性应该设置为 interal;在领域层,创建修改方法;

创建服务端

创建领域层

启动模板中的领域层分为两个项目:

  • XXX.Domain包含你的实体, 领域服务和其他核心域对象.
  • XXX.Domain.Shared包含可与客户共享的常量,枚举或其他域相关对象.

在解决方案的领域层(XXX.Domain项目)中定义你的实体.

该应用程序的主要实体是Book. 在XXX.Domain项目中创建一个Books文件夹(命名空间),并在其中添加名为Book的类,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System;
using Volo.Abp.Domain.Entities.Auditing;

namespace Acme.BookStore.Books
{
public class Book : AuditedAggregateRoot<Guid>
{
public string Name { get; set; }

public BookType Type { get; set; }

public DateTime PublishDate { get; set; }

public float Price { get; set; }
}
}
  • ABP为实体提供了两个基本的基类:AggregateRootEntity.Aggregate Root是领域驱动设计概念之一. 可以视为直接查询和处理的根实体(请参阅实体文档).
  • Book实体继承了AuditedAggregateRoot,AuditedAggregateRoot类在AggregateRoot类的基础上添加了一些基础审计属性(例如CreationTime, CreatorId, LastModificationTime等).ABP框架自动为你管理这些属性.
  • GuidBook实体的主键类型.

BookType枚举

Book实体使用了BookType枚举. 在XXX.Domain.Shared项目中创建Books文件夹(命名空间),并在其中添加BookType:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace Acme.BookStore.Books
{
public enum BookType
{
Undefined,
Adventure,
Biography,
Dystopia,
Fantastic,
Horror,
Science,
ScienceFiction,
Poetry
}
}

将Book实体添加到DbContext中

EF Core需要你将实体和DbContext建立关联.最简单的做法是在XXX.EntityFrameworkCore项目的BookStoreDbContext类中添加DbSet属性.如下所示:

1
2
3
4
5
public class BookStoreDbContext : AbpDbContext<BookStoreDbContext>
{
public DbSet<Book> Books { get; set; }
//...
}

创建应用程序

应用程序层由两个分离的项目组成:
XXX.Application.Contracts包含你的DTO和应用服务接口.
XXX.Application包含你的应用服务实现.
在本部分中,你将创建一个应用程序服务,使用ABP FrameworkCrudAppService基类来获取,创建,更新和删除书籍.

BookDto

CrudAppService 基类需要定义实体的基本DTO. 在 XXX.Application.Contracts项目中创建Books文件夹(命名空间), 并在其中添加名为BookDto的DTO类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System;
using Volo.Abp.Application.Dtos;

namespace Acme.BookStore.Books
{
public class BookDto : AuditedEntityDto<Guid>
{
public string Name { get; set; }

public BookType Type { get; set; }

public DateTime PublishDate { get; set; }

public float Price { get; set; }
}
}
  • DTO类被用来在表示层应用层传递数据.参阅DTO文档.
  • 为了在用户界面上展示书籍信息,BookDto被用来将书籍数据传递到表示层.
  • BookDto继承自AuditedEntityDto<Guid>.与上面定义的Book实体一样具有一些审计属性.

IBookAppService

下一步是为应用程序定义接口,在XXX.Application.Contracts项目创建Books文件夹(命名空间),并在其中添加名为IBookAppService的接口:

1
2
3
4
5
6
7
8
9
10
11
12
namespace Acme.BookStore.Books
{
public interface IBookAppService :
ICrudAppService< //Defines CRUD methods
BookDto, //Used to show books
Guid, //Primary key of the book entity
PagedAndSortedResultRequestDto, //Used for paging/sorting
CreateUpdateBookDto> //Used to create/update a book
{

}
}
  • 框架定义应用程序服务的接口不是必需的. 但是,它被建议作为最佳实践.
  • ICrudAppService定义了常见的CRUD方法:GetAsync,GetListAsync,CreateAsync,UpdateAsync和DeleteAsync. 从这个接口扩展不是必需的,你可以从空的IApplicationService接口继承并手动定义自己的方法(将在下一部分中完成).
  • ICrudAppService有一些变体, 你可以在每个方法中使用单独的DTO(例如使用不同的DTO进行创建和更新).

BookAppService

是时候实现IBookAppService接口了在XXX.Application项目中创建Books文件夹(命名空间),并在其中添加名为 BookAppService的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class BookAppService :
CrudAppService<
Book, //The Book entity
BookDto, //Used to show books
Guid, //Primary key of the book entity
PagedAndSortedResultRequestDto, //Used for paging/sorting
CreateUpdateBookDto>, //Used to create/update a book
IBookAppService //implement the IBookAppService
{
public BookAppService(IRepository<Book, Guid> repository)
: base(repository)
{

}
}
  • BookAppService继承了CrudAppService<...>.它实现了ICrudAppService定义的CRUD方法.
  • BookAppService注入IRepository <Book,Guid>,这是Book实体的默认仓储.ABP自动为每个聚合根(或实体)创建默认仓储. 请参阅仓储文档
  • BookAppService使用IObjectMapperBook对象转换为BookDto对象, 将CreateUpdateBookDto对象转换为Book对象. 启动模板使用AutoMapper库作为对象映射提供程序. 我们之前定义了映射, 因此它将按预期工作.

自动生成API Controllers

在典型的ASP.NET Core应用程序中,你创建API Controller以将应用程序服务公开为HTTP API端点. 这将允许浏览器或第三方客户端通过HTTP调用它们.

ABP可以自动按照约定将你的应用程序服务配置为MVC API控制器.

图书列表页面

本地化

本地化文本位于XXX.Domain.Shared项目的Localization/BookStore文件夹下:

简体中文翻译请打开zh-Hans.json文件 ,并将”Texts”对象中对应的值替换为中文.

  • 本地化关键字名称是任意的. 你可以设置任何名称. 对于特定的文本类型,我们更喜欢遵循一些约定:
  • 为按钮项添加 Menu: 前缀.
  • 使用 Enum::. 命名约定来本地化枚举成员. 当您这样做时ABP可以在某些适当的情况下自动将枚举本地化.
  • 如果未在本地化文件中定义文本,则文本将回退到本地化键(ASP.NET Core的标准行为).

生成服务代理

ABP CLI 提供 generate-proxy 命令为HTTP APIs生成客户端代理.有了这些代理,在客户端使用HTTP APIs变得更加方便. 运行generate-proxy命令前, 你的host必须正在运行.
启动host应用程序后,在 angular 文件夹下运行以下命令:

1
abp generate-proxy -t ng

创建新书籍

集成测试

授权

值转换

值转换器可在从数据库读取或向其中写入属性值时转换属性值。 此转换可以是从同一类型的一个值转换为另一个值(例如加密字符串),也可以是从一种类型的值转换为另一种类型的值(例如数据库中枚举值和字符串的相互转换)。

值转换器的指定涉及 ModelClrType 和 ProviderClrType。 ModelClrType是实体类型中的属性的 .NET 类型。 ProviderClrType是数据库提供程序理解的 .NET 类型。 例如,若要在数据库中将枚举保存为字符串,模型类型(ModelClrType)是枚举的类型,而提供程序类型(ProviderClrType)是 String。

使用两个 Func 表达式树定义转换:一个从 ModelClrType 转换为 ProviderClrType,另一个从 ProviderClrType 转换为 ModelClrType。 使用表达式树的目的是使它们可被编译到数据库访问委托中,以便进行高效转换。 表达式树可能包含对复杂转换的转换方法的简单调用。

配置值转换器

值转换在 中 DbContext.OnModelCreating配置。 例如,假设将一个枚举和实体类型定义为:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Rider
{
public int Id { get; set; }
public EquineBeast Mount { get; set; }
}

public enum EquineBeast
{
Donkey,
Mule,
Horse,
Unicorn
}

可以在 中 OnModelCreating 配置转换,以将枚举值作为字符串(如“Donkey”、“Mule”等)存储在数据库中;只需提供一个从 ModelClrType 转换为 ProviderClrType的函数,为相反的转换提供另一个函数:

1
2
3
4
5
6
7
8
9
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder
.Entity<Rider>()
.Property(e => e.Mount)
.HasConversion(
v => v.ToString(),
v => (EquineBeast)Enum.Parse(typeof(EquineBeast), v));
}

批量配置值转换器

为使用相关 CLR 类型的每个属性配置相同的值转换器很常见。 可以使用 预约定模型配置 为整个模型执行此操作一次,而不是为每个属性手动执行此操作。 因为它在允许运行模型生成约定之前配置模型的各个方面。 通过重写 ConfigureConventions 派生自 DbContext的类型来应用此类配置。

1
2
3
4
5
6
7
8
9
public class CurrencyConverter : ValueConverter<Currency, decimal>
{
public CurrencyConverter()
: base(
v => v.Amount,
v => new Currency(v))
{
}
}

然后,在上下文类型中重写 ConfigureConventions 并配置转换器,如下所示:

1
2
3
4
5
6
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder
.Properties<Currency>()
.HaveConversion<CurrencyConverter>();
}

此示例演示如何在 类型 string的所有属性上配置一些方面:

1
2
3
4
configurationBuilder
.Properties<string>()
.AreUnicode(false)
.HaveMaxLength(1024);

预定义的转换

EF Core 含有许多预定义转换,不需要手动编写转换函数。 而是根据模型中的属性类型和请求的数据库提供程序类型选取要使用的转换。

例如,枚举到字符串的转换用作上面的示例,但当提供程序类型配置为 string 使用 的泛型类型 HasConversion时,EF Core 实际上会自动执行此操作:

1
2
3
4
5
6
7
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder
.Entity<Rider>()
.Property(e => e.Mount)
.HasConversion<string>();
}

可通过显式地指定数据库列类型实现相同的操作。 例如,如果实体类型的定义如下:

1
2
3
4
5
6
7
public class Rider2
{
public int Id { get; set; }

[Column(TypeName = "nvarchar(24)")]
public EquineBeast Mount { get; set; }
}

然后枚举值将保存为数据库中的字符串,而无需在 中 OnModelCreating进行任何进一步配置。

ValueConverter 类

如上所示调用 HasConversion 将创建一个 ValueConverter<TModel,TProvider> 实例并在 属性上设置它。 可改为显式地创建 ValueConverter。 例如:

1
2
3
4
5
6
7
8
9
10
11
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var converter = new ValueConverter<EquineBeast, string>(
v => v.ToString(),
v => (EquineBeast)Enum.Parse(typeof(EquineBeast), v));

modelBuilder
.Entity<Rider>()
.Property(e => e.Mount)
.HasConversion(converter);
}

多个属性使用同一个转换时,这非常有用。

内置转换器

如上所述,EF Core 附带了一组预定义 ValueConverter<TModel,TProvider> 的类,这些类位于 命名空间中 Microsoft.EntityFrameworkCore.Storage.ValueConversion 。 在许多情况下,EF 将根据模型中属性的类型和在数据库中请求的类型,选择适当的内置转换器,正如上面的枚举转换示例所示。 例如,对 bool 属性使用 .HasConversion() 会使 EF Core 将布尔值转换为数值零和一:

1
2
3
4
5
6
7
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder
.Entity<User>()
.Property(e => e.IsActive)
.HasConversion<int>();
}

这在功能上与创建内置 BoolToZeroOneConverter 实例并显式设置它相同:

1
2
3
4
5
6
7
8
9
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var converter = new BoolToZeroOneConverter<int>();

modelBuilder
.Entity<User>()
.Property(e => e.IsActive)
.HasConversion(converter);
}

如果默认情况下所有 EquineBeast 列都应为 varchar(20),则可以将此信息作为 提供给值转换器 ConverterMappingHints。 例如:

1
2
3
4
5
6
7
8
9
10
11
12
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var converter = new ValueConverter<EquineBeast, string>(
v => v.ToString(),
v => (EquineBeast)Enum.Parse(typeof(EquineBeast), v),
new ConverterMappingHints(size: 20, unicode: false));

modelBuilder
.Entity<Rider>()
.Property(e => e.Mount)
.HasConversion(converter);
}

现在只要使用此转换器,数据库列就不能采用 unicode,且最长为 20 个字符。 但是,这些只是提示,因为它们被映射属性上显式设置的任何方面覆盖。

示例

简单值对象

此示例使用简单类型来包装基元类型。 希望模型中的类型比基元类型更具体(因而更具类型安全性)时,这很有用。 在此示例中,该类型为 Dollars,它包装小数基元:

1
2
3
4
5
6
7
8
9
10
public readonly struct Dollars
{
public Dollars(decimal amount)
=> Amount = amount;

public decimal Amount { get; }

public override string ToString()
=> $"${Amount}";
}

这可用于实体类型中:

1
2
3
4
5
6
public class Order
{
public int Id { get; set; }

public Dollars Price { get; set; }
}

还可在存储到数据库中时被转换为基本 decimal:

1
2
3
4
5
modelBuilder.Entity<Order>()
.Property(e => e.Price)
.HasConversion(
v => v.Amount,
v => new Dollars(v));

值对象的集合

我们可以创建一个值对象集合。 例如,假设有一个 AnnualFinance 类型,它为博客一年的财务状况建模:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public readonly struct AnnualFinance
{
[JsonConstructor]
public AnnualFinance(int year, Money income, Money expenses)
{
Year = year;
Income = income;
Expenses = expenses;
}

public int Year { get; }
public Money Income { get; }
public Money Expenses { get; }
public Money Revenue => new Money(Income.Amount - Expenses.Amount, Income.Currency);
}

此类型构成几个我们先前创建的 Money 类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public readonly struct Money
{
[JsonConstructor]
public Money(decimal amount, Currency currency)
{
Amount = amount;
Currency = currency;
}

public override string ToString()
=> (Currency == Currency.UsDollars ? "$" : "£") + Amount;

public decimal Amount { get; }
public Currency Currency { get; }
}

public enum Currency
{
UsDollars,
PoundsSterling
}

然后,我们可以向实体类型添加一个 AnnualFinance 集合:

1
2
3
4
5
6
7
public class Blog
{
public int Id { get; set; }
public string Name { get; set; }

public IList<AnnualFinance> Finances { get; set; }
}

接下来再次使用序列化来进行存储:

1
2
3
4
5
6
7
8
9
modelBuilder.Entity<Blog>()
.Property(e => e.Finances)
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null),
v => JsonSerializer.Deserialize<List<AnnualFinance>>(v, (JsonSerializerOptions)null),
new ValueComparer<IList<AnnualFinance>>(
(c1, c2) => c1.SequenceEqual(c2),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => (IList<AnnualFinance>)c.ToList()));

使用不区分大小写的字符串键

一些数据库(包括 SQL Server)默认执行不区分大小写的字符串比较。 另一方面,.NET 默认执行区分大小写的字符串比较。 这意味着,“DotNet”之类的外键值将与 SQL Server 上的主键值“dotnet”匹配,但与 EF Core 中的该值不匹配。 键的值比较器可用于强制 EF Core 执行不区分大小写的字符串比较,就像在数据库中那样。 例如,请考虑使用拥有字符串键的博客/文章模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Blog
{
public string Id { get; set; }
public string Name { get; set; }

public ICollection<Post> Posts { get; set; }
}

public class Post
{
public string Id { get; set; }
public string Title { get; set; }
public string Content { get; set; }

public string BlogId { get; set; }
public Blog Blog { get; set; }
}

如果某些 Post.BlogId 值具有不同的大小写,此模型不会按预期工作。 此问题造成的错误取决于应用程序正在执行的操作,通常都涉及未正确修复的对象图和/或由于 FK 值错误而失败的更新。 值比较器可用于更正这种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var comparer = new ValueComparer<string>(
(l, r) => string.Equals(l, r, StringComparison.OrdinalIgnoreCase),
v => v.ToUpper().GetHashCode(),
v => v);

modelBuilder.Entity<Blog>()
.Property(e => e.Id)
.Metadata.SetValueComparer(comparer);

modelBuilder.Entity<Post>(
b =>
{
b.Property(e => e.Id).Metadata.SetValueComparer(comparer);
b.Property(e => e.BlogId).Metadata.SetValueComparer(comparer);
});
}

.NET 字符串比较和数据库字符串比较的区别不仅限于大小写敏感性。 此模式适用于简单的 ASCII 键,但对于具有任意一种区域性特定字符的键,可能会失败。

处理定长的数据库字符串

前一个示例不需要值转换器。 但是,对于定长数据库字符串类型(如 char(20) 或 nchar(20)),转换器很有用。 每当向数据库插入值时,都会将定长字符串填充到完整长度。 这意味着键值“dotnet”在从数据库中读回时将为“dotnet…………..”,其中 . 表示空格字符。 这样将不能与未填充的键值正确地进行比较。

值转换器可用于在读取键值时剪裁填充。 可将此与上一个示例中的值比较器结合,以正确比较定长的不区分大小写的 ASCII 键。 例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var converter = new ValueConverter<string, string>(
v => v,
v => v.Trim());

var comparer = new ValueComparer<string>(
(l, r) => string.Equals(l, r, StringComparison.OrdinalIgnoreCase),
v => v.ToUpper().GetHashCode(),
v => v);

modelBuilder.Entity<Blog>()
.Property(e => e.Id)
.HasColumnType("char(20)")
.HasConversion(converter, comparer);

modelBuilder.Entity<Post>(
b =>
{
b.Property(e => e.Id).HasColumnType("char(20)").HasConversion(converter, comparer);
b.Property(e => e.BlogId).HasColumnType("char(20)").HasConversion(converter, comparer);
});
}

加密属性值

值转换器可用于在将属性值发送到数据库之前对其加密,再在发送回来时解密。例如,使用字符串反转替代实际加密算法:

1
2
3
modelBuilder.Entity<User>().Property(e => e.Password).HasConversion(
v => new string(v.Reverse().ToArray()),
v => new string(v.Reverse().ToArray()));

目前没有任何方法可以从值转换器内获取对当前 DbContext 或其他会话状态的引用。 这限制了可以使用的加密类型。

限制

目前,值转换系统存在一些已知的限制:

  • 如上所述,不能转换 null。
  • 目前没有办法将一个属性的转换扩展到多个列,反之亦然。
  • 对于通过值转换器映射的大多数键,不支持值生成。
  • 值转换无法引用当前的 DbContext 实例。
  • 使用值转换类型的参数当前不能在原始 SQL API 中使用。

值比较器

EF Core 内置有用于快照截取和比较数据库中使用的大多数标准类型的逻辑,所以用户通常不需要担心这个问题。 但是,当通过值转换器映射属性时,EF Core 需要对任意用户类型执行比较,这可能很复杂。 默认情况下,EF Core 使用类型定义的默认相等比较, (例如 Equals 方法) ;对于快照, 将复制值类型 以生成快照,而对于 引用类型 ,不进行复制,并将同一实例用作快照。

如果内置比较行为不合适,用户可以提供值比较器,其中包含用于快照截取、比较和计算哈希代码的逻辑。 例如,下面将 List 属性的值转换设置为将值转换为数据库中的 JSON 字符串,并定义适当的值比较器:

1
2
3
4
5
6
7
8
9
10
modelBuilder
.Entity<EntityType>()
.Property(e => e.MyListProperty)
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null),
v => JsonSerializer.Deserialize<List<int>>(v, (JsonSerializerOptions)null),
new ValueComparer<List<int>>(
(c1, c2) => c1.SequenceEqual(c2),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => c.ToList()));

简单的不可变类

考虑一个使用值转换器映射简单的不可变类的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public sealed class ImmutableClass
{
public ImmutableClass(int value)
{
Value = value;
}

public int Value { get; }

private bool Equals(ImmutableClass other)
=> Value == other.Value;

public override bool Equals(object obj)
=> ReferenceEquals(this, obj) || obj is ImmutableClass other && Equals(other);

public override int GetHashCode()
=> Value.GetHashCode();
}

modelBuilder
.Entity<MyEntityType>()
.Property(e => e.MyProperty)
.HasConversion(
v => v.Value,
v => new ImmutableClass(v));

此类型的属性不需要特殊比较或快照,原因如下:

  • 相等性被覆盖,以便不同的实例可以正确比较
  • 类型是不可变的,所以不可能改变快照值
    因此,在这种情况下,EF Core 的默认行为本身就是正常的。

可变类

建议尽可能将不可变类型(类或结构)与值转换器一起使用。 这通常比使用可变类型更有效,语义更清晰。 但是,话虽如此,使用应用程序无法更改的类型的属性是很常见的。 例如,映射包含数字列表的属性:
public List<int> MyListProperty { get; set; }

1
2
3
4
5
6
7
8
9
10
modelBuilder
.Entity<EntityType>()
.Property(e => e.MyListProperty)
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null),
v => JsonSerializer.Deserialize<List<int>>(v, (JsonSerializerOptions)null),
new ValueComparer<List<int>>(
(c1, c2) => c1.SequenceEqual(c2),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => c.ToList()));

构造 ValueComparer 函数接受三个表达式:

  • 用于检查相等性的表达式
  • 用于生成哈希代码的表达式
  • 用于截取值的快照的表达式
    在这种情况下,比较是通过检查数字序列是否相同来完成的。

同样,哈希代码也是基于相同的序列构建的。 (请注意,这是可变值的哈希代码,因此 可能会导致问题。如果可以,则不可变。)

快照是通过使用 ToList 克隆列表来创建的。 同样,仅当要转变列表时,才需要这样做。 如果可以,请改为不可变。

高级表映射

表拆分

EF Core 允许将两个或多个实体映射到一个表。 这称为“表拆分”或“表共享”。

若要使用表拆分,需将实体类型映射到同一个表,将主键映射到相同的列,并且在同一个表中的一种实体类型的主键和另一种实体类型的主键之间至少配置一种关系。

表拆分的一个常见场景是仅使用表中的部分列,以提高性能或实现封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class Order
{
public int Id { get; set; }
public OrderStatus? Status { get; set; }
public DetailedOrder DetailedOrder { get; set; }
}

public class DetailedOrder
{
public int Id { get; set; }
public OrderStatus? Status { get; set; }
public string BillingAddress { get; set; }
public string ShippingAddress { get; set; }
public byte[] Version { get; set; }
}

modelBuilder.Entity<DetailedOrder>(
dob =>
{
dob.ToTable("Orders");
dob.Property(o => o.Status).HasColumnName("Status");
});

modelBuilder.Entity<Order>(
ob =>
{
ob.ToTable("Orders");
ob.Property(o => o.Status).HasColumnName("Status");
ob.HasOne(o => o.DetailedOrder).WithOne()
.HasForeignKey<DetailedOrder>(o => o.Id);
ob.Navigation(o => o.DetailedOrder).IsRequired();
});

实体拆分

EF Core 允许将实体映射到两个或多个表中的行。 这称为 实体拆分。

例如,假设有一个数据库,其中包含三个保存客户数据的表:

  • Customers客户信息的表
  • PhoneNumbers客户的电话号码表
  • Addresses客户地址表
    下面是SQL Server中这些表的定义:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    CREATE TABLE [Customers] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Customers] PRIMARY KEY ([Id])
    );

    CREATE TABLE [PhoneNumbers] (
    [CustomerId] int NOT NULL,
    [PhoneNumber] nvarchar(max) NULL,
    CONSTRAINT [PK_PhoneNumbers] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_PhoneNumbers_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
    );

    CREATE TABLE [Addresses] (
    [CustomerId] int NOT NULL,
    [Street] nvarchar(max) NOT NULL,
    [City] nvarchar(max) NOT NULL,
    [PostCode] nvarchar(max) NULL,
    [Country] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Addresses] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_Addresses_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
    );
    这些表中的每个表通常映射到自己的实体类型,并具有类型之间的关系。 但是,如果所有三个表始终一起使用,则将它们全部映射到单个实体类型会更方便。 例如:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public class Customer
    {
    public Customer(string name, string street, string city, string? postCode, string country)
    {
    Name = name;
    Street = street;
    City = city;
    PostCode = postCode;
    Country = country;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public string? PhoneNumber { get; set; }
    public string Street { get; set; }
    public string City { get; set; }
    public string? PostCode { get; set; }
    public string Country { get; set; }
    }
    这是在 EF7 中通过为实体类型中的每个拆分调用 SplitToTable 来实现的。 例如,以下代码将 Customer 实体类型拆分为上面所示的 Customers、 PhoneNumbers和 Addresses 表:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    modelBuilder.Entity<Customer>(
    entityBuilder =>
    {
    entityBuilder
    .ToTable("Customers")
    .SplitToTable(
    "PhoneNumbers",
    tableBuilder =>
    {
    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
    tableBuilder.Property(customer => customer.PhoneNumber);
    })
    .SplitToTable(
    "Addresses",
    tableBuilder =>
    {
    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
    tableBuilder.Property(customer => customer.Street);
    tableBuilder.Property(customer => customer.City);
    tableBuilder.Property(customer => customer.PostCode);
    tableBuilder.Property(customer => customer.Country);
    });
    });

    限制

  • 实体拆分不能用于继承结构中的实体类型。
  • 对于主表中的任何行,每个拆分表中都必须有一行。

TPT继承映射

例如,假设有一个简单的继承层次结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public abstract class Animal
{
public int Id { get; set; }
public string Breed { get; set; } = null!;
}

public class Cat : Animal
{
public string? EducationalLevel { get; set; }
}

public class Dog : Animal
{
public string? FavoriteToy { get; set; }
}

使用 TPT 继承映射策略,这些类型将映射到三个表。 但是,每个表中的主键列可能具有不同的名称。 例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
CREATE TABLE [Animals] (
[Id] int NOT NULL IDENTITY,
[Breed] nvarchar(max) NOT NULL,
CONSTRAINT [PK_Animals] PRIMARY KEY ([Id])
);

CREATE TABLE [Cats] (
[CatId] int NOT NULL,
[EducationalLevel] nvarchar(max) NULL,
CONSTRAINT [PK_Cats] PRIMARY KEY ([CatId]),
CONSTRAINT [FK_Cats_Animals_CatId] FOREIGN KEY ([CatId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

CREATE TABLE [Dogs] (
[DogId] int NOT NULL,
[FavoriteToy] nvarchar(max) NULL,
CONSTRAINT [PK_Dogs] PRIMARY KEY ([DogId]),
CONSTRAINT [FK_Dogs_Animals_DogId] FOREIGN KEY ([DogId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

EF7 允许使用嵌套表生成器配置此映射:

1
2
3
4
5
6
7
8
9
10
11
modelBuilder.Entity<Animal>().ToTable("Animals");

modelBuilder.Entity<Cat>()
.ToTable(
"Cats",
tableBuilder => tableBuilder.Property(cat => cat.Id).HasColumnName("CatId"));

modelBuilder.Entity<Dog>()
.ToTable(
"Dogs",
tableBuilder => tableBuilder.Property(dog => dog.Id).HasColumnName("DogId"));

从属实体类型

EF Core 使你能够对只能出现在其他实体类型的导航属性上的实体类型进行建模。 它们称为“从属实体类型”。 包含从属实体类型的实体是其所有者。

从属实体本质上是所有者的一部分,没有它就不能存在,它们在概念上类似于聚合。 这意味着,根据定义,从属实体位于与所有者关系的从属关系中。

将类型配置为Owned

在大多数提供程序中,实体类型永远不会按约定配置为已拥有,必须显式使用 OnModelCreating 中的 OwnsOne 方法或使用 OwnedAttribute 为类型做注释以将类型配置为已拥有。

在此示例中,StreetAddress 是一个无标识类型。 它用作 Order 类型的属性来指定特定订单的发货地址。我们可以使用 OwnedAttribute 将其标记为从属实体:

1
2
3
4
5
6
7
8
9
10
11
12
[Owned]
public class StreetAddress
{
public string Street { get; set; }
public string City { get; set; }
}

public class Order
{
public int Id { get; set; }
public StreetAddress ShippingAddress { get; set; }
}

还可以使用 OnModelCreating 中的 OwnsOne 方法来指定 ShippingAddress 属性是 Order 实体类型的从属实体,并根据需要配置其他方面。

1
modelBuilder.Entity<Order>().OwnsOne(p => p.ShippingAddress);

如果 ShippingAddress 属性在 Order 类型中是专用的,则可以使用 OwnsOne 方法的字符串版本:

1
modelBuilder.Entity<Order>().OwnsOne(typeof(StreetAddress), "ShippingAddress");

隐式键

使用 OwnsOne 配置的从属类型或通过引用导航发现的从属类型始终与所有者具有一对一的关系,因此它们不需要自己的键值,因为外键值是唯一的。 在上面的示例中,StreetAddress 类型不需要定义键属性。

为了了解 EF Core 如何跟踪这些对象,了解主键是作为从属类型的属性创建的很有用。 从属类型的实例的键值将与所有者实例的键值相同。

无键实体类型

除了常规实体类型外,EF Core 模型还可以包含无键实体类型,可用于对不包含键值的数据执行数据库查询。

定义无键实体类型

1
2
3
4
5
6
[Keyless]
public class BlogPostsCount
{
public string BlogName { get; set; }
public int PostCount { get; set; }
}

无键实体类型特征

无键实体类型支持与常规实体类型相同的多个映射功能,例如继承映射和导航属性。 在关系存储上,它们可以通过 Fluent API 方法或数据注释来配置目标数据库对象和列。

但是,它们不同于常规实体类型,因为它们:

  • 不能定义键。
  • 永远不会对 DbContext 中的更改进行跟踪,因此不会对数据库进行插入、更新或删除这些操作。
  • 绝不会被约定发现。
  • 仅支持导航映射功能的子集,具体如下:
    • 它们永远不能充当关系的主体端。
    • 它们可能没有指向从属实体的导航
    • 它们只能包含指向常规实体的引用导航属性。
    • 实体不能包含无键实体类型的导航属性。
  • 需要配置 [Keyless] 数据注释或 .HasNoKey() 方法调用。
  • 可以映射到定义查询。 定义查询是在模型中声明的查询,它充当无键实体类型的数据源。
  • 可以有层次结构,但必须映射为 TPH。
  • 不能使用表拆分或实体拆分。

使用方案

无键实体类型的一些主要使用场景包括:

  • 用作 SQL 查询的返回类型。
  • 映射到不包含主键的数据库视图。
  • 映射到未定义主键的表。
  • 映射到模型中定义的查询。

模型批量配置

OnModelCreating 中的批量配置

从 ModelBuilder 返回的每个生成器对象都会公开ModelMetadata 属性,该属性提供对构成模型对象的低级别访问。 具体而言,有一些方法允许循环访问模型中的特定对象,并对其应用通用配置。

在以下示例中, 模型包含自定义值类型 Currency:

1
2
3
4
5
6
7
8
9
10
public readonly struct Currency
{
public Currency(decimal amount)
=> Amount = amount;

public decimal Amount { get; }

public override string ToString()
=> $"${Amount}";
}

默认情况下不会发现此类型的属性,因为当前 EF 提供程序不知道如何将它映射到数据库类型。 此代码片段 OnModelCreating 添加 类型 Currency 的所有属性,并将值转换器配置为受支持的类型 - decimal:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
foreach (var propertyInfo in entityType.ClrType.GetProperties())
{
if (propertyInfo.PropertyType == typeof(Currency))
{
entityType.AddProperty(propertyInfo)
.SetValueConverter(typeof(CurrencyConverter));
}
}
}
public class CurrencyConverter : ValueConverter<Currency, decimal>
{
public CurrencyConverter()
: base(
v => v.Amount,
v => new Currency(v))
{
}
}

元数据 API 的缺点

与 Fluent API 不同,对模型的每个修改都需要显式完成。 例如,如果某些 Currency 属性按约定配置为导航,则需要先删除引用 CLR 属性的导航,然后再为其添加实体类型属性。 #9117 将对此进行改进。
约定在每次更改后运行。 如果删除由约定发现的导航,该约定将再次运行,并可以重新添加它。 若要防止这种情况发生,需要延迟约定,直到通过调用 DelayConventions() 并稍后释放返回的对象添加属性后,或使用 将 CLR 属性标记为已忽略。AddIgnored
此迭代发生后,可能会添加实体类型,并且配置不会应用于这些实体类型。 通常可以通过将此代码放在 末尾 OnModelCreating来防止这种情况,但如果具有两组相互依赖的配置,则可能不会有一个允许一致应用它们的顺序。

预约定配置

EF Core 允许为给定 CLR 类型指定一次映射配置;然后,该配置在发现时应用于模型中该类型的所有属性。 这称为“预约定模型配置”,因为它在允许运行模型生成约定之前配置模型的各个方面。 通过重写 ConfigureConventions 派生自 DbContext的类型来应用此类配置。

此示例演示如何将 类型 Currency 的所有属性配置为具有值转换器:

1
2
3
4
5
6
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder
.Properties<Currency>()
.HaveConversion<CurrencyConverter>();
}

它将覆盖所有约定和数据注释。 例如,使用上述配置时,所有字符串外键属性都将创建为具有 1024 的非 unicode MaxLength ,即使这与主体键不匹配也是如此。

忽略类型

约定前配置还允许忽略类型,并阻止约定将其作为实体类型或实体类型的属性发现:

1
2
configurationBuilder
.IgnoreAny(typeof(IList<>));

默认类型映射

通常,只要为此类型的属性指定了值转换器,EF 就可以使用提供程序不支持的类型常量转换查询。 但是,在不涉及此类型的任何属性的查询中,EF 无法找到正确的值转换器。 在这种情况下,可以调用 DefaultTypeMapping 以添加或替代提供程序类型映射:

1
2
3
configurationBuilder
.DefaultTypeMapping<Currency>()
.HasConversion<CurrencyConverter>();

约定

EF Core 模型生成约定是包含逻辑的类,这些逻辑基于生成模型时对模型所做的更改触发。 这会在进行显式配置、应用映射属性和其他约定时使模型保持最新状态。 为了参与此目的,每个约定实现一个或多个接口,用于确定何时触发相应的方法。 例如,每当向模型添加新实体类型时,将触发实现 IEntityTypeAddedConvention 的约定。 同样,每当向模型添加键或外键时,都会触发实现 和 IKeyAddedConvention 的IForeignKeyAddedConvention约定。

模型生成约定是控制模型配置的一种强大方法,但可能很复杂,很难正确。

添加新约定

约束鉴别器属性的长度

让我们通过首次尝试实现鉴别器长度约定,使这更具体一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class DiscriminatorLengthConvention1 : IEntityTypeBaseTypeChangedConvention
{
public void ProcessEntityTypeBaseTypeChanged(
IConventionEntityTypeBuilder entityTypeBuilder,
IConventionEntityType? newBaseType,
IConventionEntityType? oldBaseType,
IConventionContext<IConventionEntityType> context)
{
var discriminatorProperty = entityTypeBuilder.Metadata.FindDiscriminatorProperty();
if (discriminatorProperty != null
&& discriminatorProperty.ClrType == typeof(string))
{
discriminatorProperty.Builder.HasMaxLength(24);
}
}
}

此约定实现 IEntityTypeBaseTypeChangedConvention,这意味着每当实体类型的映射继承层次结构发生更改时,都会触发它。 然后,该约定查找并配置层次结构的字符串鉴别器属性。

然后,通过在 中ConfigureConventions调用 Add 来使用此约定:

1
2
3
4
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Conventions.Add(_ => new DiscriminatorLengthConvention1());
}

但是,如果我们现在显式配置不同的鉴别器属性,会发生什么情况呢? 例如:

1
2
3
4
modelBuilder.Entity<Post>()
.HasDiscriminator<string>("PostTypeDiscriminator")
.HasValue<Post>("Post")
.HasValue<FeaturedPost>("Featured");

这是因为我们在约定中配置的鉴别器属性后来在添加自定义鉴别器时被删除。 我们可以尝试通过在约定上实现另一个接口来修复此问题,以响应鉴别器更改,但找出要实现的接口并不容易。

幸运的是,有一种更简单的方法。 很多时候,只要最终模型正确,模型在生成时的外观并不重要。 此外,要应用的配置通常不需要触发其他约定来做出反应。 因此,我们的约定可以实现 IModelFinalizingConvention。 模型最终确定约定 在所有其他模型生成完成后运行,因此可以访问模型的接近最终状态。 这与响应每个模型更改并确保模型在方法执行的任何时间点处于最新状态的OnModelCreating交互式约定相反。 模型最终确定约定通常会循环访问整个模型,以根据需要配置模型元素。 因此,在这种情况下,我们会在模型中找到每个鉴别器并对其进行配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class DiscriminatorLengthConvention2 : IModelFinalizingConvention
{
public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
{
foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
.Where(entityType => entityType.BaseType == null))
{
var discriminatorProperty = entityType.FindDiscriminatorProperty();
if (discriminatorProperty != null
&& discriminatorProperty.ClrType == typeof(string))
{
discriminatorProperty.Builder.HasMaxLength(24);
}
}
}
}

使用此新约定生成模型后,我们发现现在已正确配置了鉴别器长度,即使已对其进行自定义:

我们可以更进一步,将最大长度配置为最长的鉴别器值的长度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class DiscriminatorLengthConvention3 : IModelFinalizingConvention
{
public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
{
foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
.Where(entityType => entityType.BaseType == null))
{
var discriminatorProperty = entityType.FindDiscriminatorProperty();
if (discriminatorProperty != null
&& discriminatorProperty.ClrType == typeof(string))
{
var maxDiscriminatorValueLength =
entityType.GetDerivedTypesInclusive().Select(e => ((string)e.GetDiscriminatorValue()!).Length).Max();

discriminatorProperty.Builder.HasMaxLength(maxDiscriminatorValueLength);
}
}
}
}

现在,鉴别器列的最大长度为 8,即“特别推荐”的长度,这是使用的最长的鉴别器值。

替换现有约定

有时,与其完全删除现有约定,不如将其替换为执行基本相同操作但行为已更改的约定。 这很有用,因为现有约定已实现需要适当触发的接口。

何时将每种方法用于批量配置

在以下情况下使用 元数据 API :

  • 需要在特定时间应用配置,而不是对模型中的后续更改做出反应。
  • 模型生成速度非常重要。 元数据 API 的安全检查较少,因此可能比其他方法稍快一些,但使用 已编译的模型 将产生更好的启动时间。

在以下情况下使用 约定前模型配置 :

  • 适用性条件很简单,因为它仅取决于类型。
  • 需要在模型中添加给定类型的属性并重写数据注释和约定时应用配置

在以下情况下使用 Finalizing 约定 :

  • 适用性条件很复杂。
  • 配置不应替代数据批注指定的内容。

在以下情况下使用 交互式约定 :

  • 多个约定相互依赖。 完成约定按添加顺序运行,因此无法对以后完成约定所做的更改做出反应。
  • 逻辑在多个上下文之间共享。 交互式约定比其他方法更安全。

IModelCacheKeyFactory

EF 使用 IModelCacheKeyFactory 为模型生成缓存键;默认情况下,EF 假定对于任何给定的上下文类型,模型都相同,因此该服务的默认实现将返回仅包含上下文类型的键。 若要从同一上下文类型构建不同的模型,需要将服务替换为正确的实现;生成的键将使用 IModelCacheKeyFactoryEquals 方法与其他的模型键进行比较,同时考虑影响模型的所有变量。

以下实现在生成模型缓存键时考虑 UseIntProperty:

1
2
3
4
5
6
7
public class DynamicModelCacheKeyFactory : IModelCacheKeyFactory
{
public object Create(DbContext context, bool designTime)
=> context is DynamicContext dynamicContext
? (context.GetType(), dynamicContext.UseIntProperty, designTime)
: (object)context.GetType();
}

还必须实现 Create 方法的重载,该方法也处理设计时模型缓存。 如以下示例所示:

1
2
3
4
5
6
7
8
9
10
public class DynamicModelCacheKeyFactoryDesignTimeSupport : IModelCacheKeyFactory
{
public object Create(DbContext context, bool designTime)
=> context is DynamicContext dynamicContext
? (context.GetType(), dynamicContext.UseIntProperty, designTime)
: (object)context.GetType();

public object Create(DbContext context)
=> Create(context, false);
}

最后,在上下文的 OnConfiguring 中注册新的 IModelCacheKeyFactory:

1
2
3
4
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.UseInMemoryDatabase("DynamicContext")
.ReplaceService<IModelCacheKeyFactory, DynamicModelCacheKeyFactory>();