原文:
zh.annas-archive.org/md5/5f122bf1150958c3b3ee735b37781de3译者:飞龙
第十一章:使用 LINQ 查询和操作数据
本章是关于语言集成查询(LINQ)表达式。LINQ 是一组语言扩展,使您能够处理数据序列,然后过滤、排序并将它们投影到不同的输出中。
本章将涵盖以下主题:
-
编写 LINQ 表达式
-
LINQ 实践
-
排序等更多功能
-
使用 LINQ 与 EF Core
-
连接、分组和查找
编写 LINQ 表达式
我们需要回答的第一个基本问题是:为什么 LINQ 存在?
比较命令式和声明式语言特性
LINQ 于 2008 年随 C# 3 和.NET Framework 3.0 一起推出。在此之前,如果 C#和.NET 程序员想要处理一系列项目,他们必须使用过程式,即命令式的代码语句。例如,一个循环:
-
将当前位置设置为第一个项目。
-
通过将一个或多个属性与指定的值进行比较来检查项目是否应该被处理。例如,单价是否大于 50,或者国家是否等于比利时?
-
如果有匹配项,处理该项目。例如,将一个或多个属性输出给用户,将一个或多个属性更新为新值,删除项目,或执行聚合计算,如计数或求和值。
-
移动到下一个项目。重复,直到所有项目都已处理。
过程式代码告诉编译器如何实现目标。这样做,然后那样做。由于编译器不知道你试图实现什么,因此它无法提供太多帮助。你必须 100%负责确保每个如何做步骤都是正确的。
LINQ 使这些常见任务变得更加容易,减少了引入微妙错误的可能。不再需要明确地声明每个单独的操作,如移动、读取、更新等,LINQ 使程序员能够使用声明性即函数式风格的语句编写。
声明性,即函数式,代码告诉编译器要实现什么目标。编译器会找出实现它的最佳方式。这些语句通常也更简洁。
良好实践:如果你不完全理解 LINQ 的工作原理,那么你编写的语句可能会引入自己的微妙错误!2022 年流传的一个代码谜题涉及一系列任务和了解它们何时执行(twitter.com/amantinband/status/1559187912218099714)。大多数经验丰富的开发者都答错了!公平地说,是 LINQ 行为与多线程行为的组合让大多数人感到困惑。但到本章结束时,你将更好地了解为什么代码因为 LINQ 行为而危险。
尽管我们在第十章使用 Entity Framework Core 处理数据中编写了一些 LINQ 表达式,但它们并不是重点,因此我没有正确解释 LINQ 是如何工作的。现在让我们花时间正确理解它们。
LINQ 组件
LINQ 有几个部分;一些是必需的,一些是可选的:
-
扩展方法(必需):这些包括
Where、OrderBy和Select等示例。这些提供了 LINQ 的功能。 -
LINQ 提供程序(必需):这些包括用于处理内存中对象的 LINQ to Objects,用于处理存储在外部数据库中并由 EF Core 模型的 LINQ to Entities,以及用于处理存储为 XML 的数据的 LINQ to XML。这些提供程序是 LINQ 的组成部分,以针对不同类型数据的方式执行 LINQ 表达式。
-
Lambda 表达式(可选):这些可以用作替代命名方法来简化 LINQ 查询,例如,用于
Where方法的条件逻辑进行过滤。 -
LINQ 查询理解语法(可选):这些包括
from、in、where、orderby、descending和select等 C# 关键字。这些是某些 LINQ 扩展方法的别名,它们的使用可以简化你编写的查询,特别是如果你已经熟悉其他查询语言,如 结构化查询语言(SQL)。
当程序员第一次接触 LINQ 时,他们常常认为 LINQ 查询理解语法就是 LINQ,但讽刺的是,这正是 LINQ 中可选的部分之一!
使用 Enumerable 类构建 LINQ 表达式
LINQ 扩展方法,如 Where 和 Select,由 Enumerable 静态类附加到任何实现 IEnumerable<T> 的类型上,称为 序列。一个序列包含零个、一个或多个项。
例如,任何类型的数组都实现了 IEnumerable<T> 类,其中 T 是数组中项的类型。这意味着所有数组都支持 LINQ 进行查询和操作。
所有泛型集合,如 List<T>、Dictionary<TKey, TValue>、Stack<T> 和 Queue<T>,都实现了 IEnumerable<T>,因此它们也可以使用 LINQ 进行查询和操作。
Enumerable 定义了 50 多个扩展方法,总结在 表 11.1 和 表 11.2 中。
这些表格将对你未来的参考很有用,但就目前而言,你可能想简要地浏览它们,以了解存在哪些扩展方法,稍后再回来仔细审查。这些表格的在线版本可在以下链接找到:github.com/markjprice/cs13net9/blob/main/docs/ch11-linq-methods.md。
首先,这里有一些返回新的 IEnumerable<T> 项序列的延迟方法:
| 方法 | 描述 |
|---|---|
Where | 返回与指定过滤器匹配的项的序列。 |
索引 | 返回一个包含项及其索引的序列。从 .NET 9 开始引入。 |
Select 和 SelectMany | 将项投影到不同的形状,即不同的类型,并扁平化项的嵌套层次结构。 |
Skip | 跳过一定数量的项。 |
SkipWhile | 在表达式为 true 时跳过项目。 |
SkipLast | 返回一个新的可枚举集合,包含从源中获取的元素,但省略了源集合的最后 count 个元素。 |
Take | 获取一定数量的项目。.NET 6 引入了一个可以传递 Range 的重载,例如,Take(range: 3..⁵),意味着从开始处取三个项目,并在结束处取五个项目,或者而不是 Skip(4),你可以使用 Take(4..)。 |
TakeWhile | 在表达式为 true 时获取项目。 |
TakeLast | 返回一个新的可枚举集合,包含从源中获取的最后一个 count 个元素。 |
OrderBy, OrderByDescending, ThenBy, 和 ThenByDescending | 根据指定的字段或属性对项目进行排序。 |
Order 和 OrderDescending | 根据项目本身进行排序。 |
Reverse | 反转项目的顺序。 |
GroupBy, GroupJoin, 和 Join | 对两个序列进行分组和/或连接。 |
AggregateBy, CountBy, DistinctBy, ExceptBy, IntersectBy, UnionBy, MinBy, 和 MaxBy | 允许在项目的一个子集上而不是所有项目上执行比较。例如,而不是通过比较整个 Person 对象来使用 Distinct 移除重复项,你可以使用 DistinctBy 通过比较它们的 LastName 和 DateOfBirth 属性来移除重复项。CountBy 和 AggregateBy 扩展方法是在 .NET 9 中引入的。 |
AsEnumerable | 返回输入序列作为 IEnumerable<T> 类型。这在类型有自己的 Where 等 LINQ 扩展方法实现时很有用,而你想要调用标准的 LINQ Where 方法。 |
DefaultIfEmpty | 返回 IEnumerable<T> 的元素,如果序列为空,则返回默认值的单例集合。例如,如果序列是一个空的 IEnumerable<int>,它将返回一个包含单个项目 0 的 IEnumerable<int>。 |
Cast<T> | 将项目转换为指定的类型。在编译器会报错的情况下,将非泛型对象转换为泛型类型很有用。 |
OfType<T> | 移除不匹配指定类型的项。 |
Distinct | 移除重复项。 |
Except, Intersect, 和 Union | 执行返回集合的操作。集合不能有重复的项目。尽管输入可以是任何序列,因此输入可以有重复,但结果始终是集合。 |
Chunk | 将序列分割成固定大小的批次。size 参数指定每个批次中的项目数量。最后一个批次将包含剩余的项目,并且可能小于 size。 |
Append, Concat, 和 Prepend | 执行序列组合操作。 |
Zip | 根据项目位置在两个或三个序列上执行匹配操作;例如,第一个序列位置 1 的项目与第二个序列位置 1 的项目匹配。 |
表 11.1:延迟的 LINQ 扩展方法
接下来,这里是一些返回单个标量值的非延迟方法,例如单个 TSource 项目、一个数字或一个 bool:
First, FirstOrDefault, Last, 和 LastOrDefault | 获取序列中的第一个或最后一个项目,如果没有则抛出异常,或返回该类型的默认值,例如对于 int 类型是 0,对于引用类型是 null。 |
|---|---|
Single 和 SingleOrDefault | 返回匹配特定过滤器的项目,如果没有则抛出异常,或返回该类型的默认值。 |
ElementAt 和 ElementAtOrDefault | 返回指定索引位置的项目,如果没有则抛出异常,或返回该类型的默认值。.NET 6 引入了可以传递 Index 而不是 int 的重载,这在处理 Span<T> 序列时更有效。 |
Aggregate, Average, Count, LongCount, Max, Min, 和 Sum | 计算聚合值。 |
TryGetNonEnumeratedCount | Count() 检查序列是否实现了 Count 属性并返回其值,或者枚举整个序列以计数其项目。在 .NET 6 中引入,此方法仅检查 Count;如果它不存在,则返回 false 并将 out 参数设置为 0 以避免潜在的较差性能操作。 |
SequenceEqual | 根据相等比较器判断两个序列是否相等,返回 true 或 false。 |
All, Any, 和 Contains | 如果所有或任何项目匹配过滤器,或者序列包含指定的项目,则返回 true。如果序列是 List<T>,则它们使用其本地的 TrueForAll 方法而不是 LINQ 的 All 方法。 |
ToArray, ToList, ToDictionary, ToHashSet, 和 ToLookup | 将序列转换为数组或集合。这些是唯一强制立即执行 LINQ 表达式而不是延迟执行的扩展方法,你将在稍后了解。 |
表 11.2:非延迟 LINQ 扩展方法
良好实践:确保你理解并记住以 As 和 To 开头的 LINQ 扩展方法之间的区别。AsEnumerable 方法将序列转换为不同的类型但不分配内存,因此该方法很快。以 To 开头的方法,如 ToList,为新的项目序列分配内存,因此它们可能较慢,并且总是使用更多的内存资源。
Enumerable 类还有一些不是扩展方法的方法,如表 11.3 所示:
| 方法 | 描述 |
|---|---|
Empty<T> | 返回指定类型 T 的空序列。当需要传递空序列给需要 IEnumerable<T> 的方法时很有用。 |
Range | 返回从 start 值开始的整数序列,包含 count 个项目。例如,Enumerable.Range(start: 5, count: 3) 将包含整数 5、6 和 7。 |
Repeat | 返回一个包含重复 count 次相同元素的序列。例如,Enumerable.Repeat(element: "5", count: 3) 将包含 string 值 "5"、"5" 和 "5"。 |
表 11.3:Enumerable 非扩展方法
实践中的 LINQ
现在,我们可以构建一个控制台应用程序来探索使用 LINQ 的实际示例。
理解延迟执行
LINQ 使用延迟执行。重要的是要理解,调用上述大多数扩展方法并不会执行查询并获取结果。这些扩展方法中的大多数返回一个表示问题而不是答案的 LINQ 表达式。让我们来探索:
-
使用您喜欢的代码编辑器创建一个新项目,如下列列表中定义:
-
项目模板:控制台应用程序 /
console -
项目文件和文件夹:
LinqWithObjects -
解决方案文件和文件夹:
Chapter11
-
-
在项目文件中,全局和静态导入
System.Console类。 -
添加一个名为
Program.Helpers.cs的新类文件。 -
在
Program.Helpers.cs中,删除任何现有语句,然后定义一个部分Program类,其中包含一个用于输出部分标题的方法,如下面的代码所示:partial class Program { private static void SectionTitle(string title) { ConsoleColor previousColor = ForegroundColor; ForegroundColor = ConsoleColor.DarkYellow; WriteLine($"*** {title} ***"); ForegroundColor = previousColor; } } -
添加一个名为
Program.Functions.cs的新类文件。 -
在
Program.Functions.cs中,删除任何现有语句,定义一个部分Program类,其中包含一个名为DeferredExecution的方法,该方法接受一个string值数组作为参数,然后定义两个查询,如下面的代码所示:partial class Program { private static void DeferredExecution(string[] names) { SectionTitle("Deferred execution"); // Question: Which names end with an M? // (using a LINQ extension method) var query1 = names.Where(name => name.EndsWith("m")); // Question: Which names end with an M? // (using LINQ query comprehension syntax) var query2 = from name in names where name.EndsWith("m") select name; } } -
在
Program.cs中,删除现有语句,添加定义一个包含在办公室工作的人的string值序列的语句,然后将它作为参数传递给DeferredExecution方法,如下面的代码所示:// A string array is a sequence that implements IEnumerable<string>. string[] names = { "Michael", "Pam", "Jim", "Dwight", "Angela", "Kevin", "Toby", "Creed" }; DeferredExecution(names); -
在
Program.Functions.cs的DeferredExecution方法中,要获取答案(换句话说,要执行查询),您必须通过调用To方法之一,如ToArray、ToDictionary或ToLookup,或通过枚举查询来实现它。添加执行此操作的语句,如下面的代码所示:// Answer returned as an array of strings containing Pam and Jim. string[] result1 = query1.ToArray(); // Answer returned as a list of strings containing Pam and Jim. List<string> result2 = query2.ToList(); // Answer returned as we enumerate over the results. foreach (string name in query1) { WriteLine(name); // outputs Pam names[2] = "Jimmy"; // Change Jim to Jimmy. // On the second iteration Jimmy does not // end with an "m" so it does not get output. } -
运行控制台应用程序并注意结果,如下面的输出所示:
*** Deferred execution *** Pam
由于延迟执行,在输出第一个结果 Pam 后,如果原始数组值发生变化,那么当我们循环回来时,将没有更多匹配项,因为 Jim 变成了 Jimmy 并且不以 m 结尾,所以只有 Pam 被输出。
在我们深入探讨之前,让我们放慢速度,看看一些常见的 LINQ 扩展方法和如何逐个使用它们。
使用 Where 过滤实体
使用 LINQ 最常见的原因是使用 Where 扩展方法来过滤序列中的项目。让我们通过定义一个名称序列并对其应用 LINQ 操作来探索过滤:
-
在项目文件中,添加一个元素以防止
System.Linq命名空间自动全局导入,如下面高亮显示的标记所示:<ItemGroup> <Using Include="System.Console" Static="true" /> **<Using Remove=****"****System.Linq"** **/>** </ItemGroup> -
在
Program.Functions.cs中,添加一个名为FilteringUsingWhere的新方法,如下面的代码所示:private static void FilteringUsingWhere(string[] names) { } -
如果你正在使用 Visual Studio,导航到 工具 | 选项。在 选项 对话框中,导航到 文本编辑器 | C# | IntelliSense,清除 显示未导入命名空间中的项 复选框,然后点击 确定。
-
在
FilteringUsingWhere中,尝试在名称数组上调用Where扩展方法,如下面的代码所示:SectionTitle("Filtering entities using Where"); var query = names.W -
当你输入
W时,注意在较老的代码编辑器(或者禁用了显示未导入命名空间项的选项的代码编辑器)中,Where方法在string数组的 IntelliSense 成员列表中缺失,如图 11.1 所示:
图 11.1:缺少 Where 扩展方法的 IntelliSense
这是因为 Where 是一个扩展方法。它不存在于数组类型上。为了使 Where 扩展方法可用,我们必须导入 System.Linq 命名空间。在新的 .NET 6 及以后的项目中,它默认隐式导入,但我们移除了它以说明这一点。代码编辑器的最新版本足够智能,会建议使用 Where 方法,并指示它们将自动为你导入 System.Linq 命名空间。
-
如果你正在使用 Visual Studio,导航到 工具 | 选项。在 选项 对话框中,导航到 文本编辑器 | C# | IntelliSense,选择 显示未导入命名空间中的项 复选框,然后点击 确定。
-
在项目文件中,注释掉移除
System.Linq的元素,如下面的代码所示:<!--<Using Remove="System.Linq" />--> -
保存更改并构建项目。
-
重新输入
Where方法的W,注意 IntelliSense 列表现在包括由Enumerable类添加的扩展方法,如图 11.2 所示:
图 11.2:导入 System.Linq 时 IntelliSense 显示 LINQ 扩展方法
有趣的是,正如你在我的计算机上 Visual Studio 的截图中所见,GitHub Copilot 甚至建议使用 lambda 表达式自动完成,这与我们最终将编写的表达式非常相似。但在我们到达那里之前,有一些重要的中间步骤你需要看到,所以如果你启用了该功能,请不要插入任何 GitHub Copilot 建议。
-
当你输入
Where方法的括号时,IntelliSense 告诉我们,要调用Where,我们必须传递一个Func<string, bool>委托实例。 -
输入一个表达式来创建一个
Func<string, bool>委托的新实例,目前请注意,我们尚未提供方法名,因为我们将在下一步定义它,如下面的代码所示:var query = names.Where(new Func<string, bool>( )) -
目前先不要完成该语句。
Func<string, bool> 委托告诉我们,对于传递给方法的每个 string 变量,方法必须返回一个 bool 值。如果方法返回 true,则表示我们应该将 string 包含在结果中,如果方法返回 false,则表示我们应该排除它。
针对命名方法的定位
让我们定义一个只包含长度超过四个字符的名称的方法:
-
在
Program.Functions.cs中,添加一个方法,该方法仅对长度超过四个字符的名称返回true,如下面的代码所示:static bool NameLongerThanFour(string name) { // Returns true for a name longer than four characters. return name.Length > 4; } -
在
FilteringUsingWhere方法中,将方法名称传递给Func<string, bool>委托,如下面的代码所示(高亮显示):var query = names.Where( new Func<string, bool>(**NameLongerThanFour**)); -
在
FilteringUsingWhere方法中,添加使用foreach遍历names数组的语句,如下面的代码所示:foreach (string item in query) { WriteLine(item); } -
在
Program.cs中,注释掉对DeferredExecution的调用,然后将names作为参数传递给FilteringUsingWhere方法,如下面的代码所示:// DeferredExecution(names); FilteringUsingWhere(names); -
运行代码并查看结果,注意只列出了长度超过四个字母的名称,如下面的输出所示:
Michael Dwight Angela Kevin Creed
通过移除显式委托实例化简化代码
我们可以通过删除 Func<string, bool> 委托的显式实例化来简化代码,因为 C# 编译器可以为我们实例化委托:
-
为了帮助你通过查看逐步改进的代码来学习,在
FilteringUsingWhere方法中,注释掉查询并添加关于其工作方式的注释,如下面的代码所示:// Explicitly creating the required delegate. // var query = names.Where( // new Func<string, bool>(NameLongerThanFour)); -
再次输入查询,但这次,不要显式实例化委托,如下面的代码所示:
// The compiler creates the delegate automatically. var query = names.Where(NameLongerThanFour); -
运行代码,并注意它具有相同的行为。
针对 lambda 表达式
我们可以使用 lambda 表达式 代替命名方法来进一步简化我们的代码。
虽然一开始看起来可能很复杂,但 lambda 表达式实际上是一个无名的函数。它使用 =>(读作“走向”)符号来表示返回值:
-
将第二个查询注释掉,然后添加一个使用 lambda 表达式的查询的第三个版本,如下面的代码所示:
// Using a lambda expression instead of a named method. var query = names.Where(name => name.Length > 4);
注意,lambda 表达式的语法包括了 NameLongerThanFour 方法的重要部分,没有更多。lambda 表达式只需要定义以下内容:
-
输入参数的名称:
name -
返回值表达式:
name.Length > 4
name 输入参数的类型是根据序列包含 string 值这一事实推断出来的,并且返回类型必须是委托定义的 bool 值,以便 Where 方法能够工作;因此,=> 符号后面的表达式必须返回一个 bool 值。编译器为我们做了大部分工作,所以我们的代码可以尽可能简洁。
- 运行代码,并注意它具有相同的行为。
带有默认参数值的 lambda 表达式
从 C# 12 开始引入,你现在可以为 lambda 表达式中的参数提供默认值,如下面的代码所示:
var query = names.Where((string name = "Bob") => name.Length > 4);
使用此 lambda 表达式的目的是,设置默认值不是必要的,但稍后你将看到更多有用的示例。
排序及其他
其他常用扩展方法有 OrderBy 和 ThenBy,用于对序列进行排序。
使用 OrderBy 按单个属性排序
如果前一个方法返回另一个序列,即实现 IEnumerable<T> 接口的类型,则可以链式调用扩展方法。
让我们继续使用当前项目来探索排序:
-
在
FilteringUsingWhere方法中,将OrderBy方法的调用附加到现有查询的末尾,如下所示代码:var query = names .Where(name => name.Length > 4) .OrderBy(name => name.Length);
良好实践:格式化 LINQ 语句,使每个扩展方法调用都单独一行,这样更容易阅读。
-
运行代码,并注意现在名字是按最短的先排序,如下所示输出:
Kevin Creed Dwight Angela Michael
要将最长的名字放在第一位,可以使用 OrderByDescending。
使用 ThenBy 按后续属性排序
我们可能想要按多个属性排序,例如,按相同长度的名字按字母顺序排序:
-
在
FilteringUsingWhere方法中,将ThenBy方法的调用附加到现有查询的末尾,如下所示代码高亮显示:var query = names .Where(name => name.Length > 4) .OrderBy(name => name.Length) **.ThenBy(name => name)**; -
运行代码,注意以下排序顺序中的细微差别。在相同长度的名字组中,名字按字符串的完整值进行字母排序,因此
Creed排在Kevin之前,而Angela排在Dwight之前,如下所示输出:Creed Kevin Angela Dwight Michael
按项目本身排序
.NET 7 引入了 Order 和 OrderDescending 扩展方法。这些简化了按项目本身的排序。例如,如果您有一个 string 值的序列,那么在 .NET 7 之前,您必须调用 OrderBy 方法并传递一个选择项目的 lambda 表达式,如下所示代码:
var query = names.OrderBy(name => name);
在 .NET 7 或更高版本中,我们可以简化语句,如下所示代码:
var query = names.Order();
OrderDescending 做类似的事情,但按降序排列。
记住 names 数组包含 string 类型的实例,该类型实现了 IComparable 接口。这就是为什么它们可以被排序,也就是排序。如果数组包含 Person 或 Product 等复杂类型的实例,那么这些类型必须实现 IComparable 接口,以便它们也可以被排序。
使用 var 或指定类型声明查询
在编写 LINQ 表达式时,使用 var 声明查询对象很方便。这是因为返回类型在编写 LINQ 表达式时经常变化。例如,我们的查询最初是 IEnumerable<string>,现在是 IOrderedEnumerable<string>:
- 将鼠标悬停在
var关键字上,并注意其类型为IOrderedEnumerable<string>,如图 11.3 所示:
图 11.3:将鼠标悬停在 var 上,查看查询表达式的实际隐含类型
在 图 11.3 中,我在 names 和 .Where 之间添加了额外的垂直空间,这样工具提示就不会覆盖查询。
-
将
var替换为实际类型,如下所示代码高亮显示:**IOrderedEnumerable<****string****>** query = names .Where(name => name.Length > 4) .OrderBy(name => name.Length) .ThenBy(name => name);
良好实践:一旦你完成对一个查询的工作,你可以将声明的类型从 var 改为实际类型,以使类型更清晰。这很容易,因为你的代码编辑器可以告诉你它是什么。这样做只是为了清晰。它对性能没有影响,因为 C# 在编译时会将所有 var 声明转换为实际类型。
- 运行代码,注意它具有相同的行为。
按类型过滤
Where 扩展方法非常适合按值过滤,例如文本和数字。但如果序列包含多个类型,并且你想按特定类型过滤,同时尊重任何继承层次结构,该怎么办呢?
假设你有一个异常序列。有数百种异常类型构成了一个复杂的继承层次结构,部分如图 11.4 所示:
图 11.4:部分异常继承层次结构
让我们探索按类型过滤:
-
在
Program.Functions.cs中,定义一个新的方法来列出,然后使用OfType<T>扩展方法过滤异常派生对象,以移除非算术异常的异常,只将算术异常写入控制台,如下面的代码所示:static void FilteringByType() { SectionTitle("Filtering by type"); List<Exception> exceptions = new() { new ArgumentException(), new SystemException(), new IndexOutOfRangeException(), new InvalidOperationException(), new NullReferenceException(), new InvalidCastException(), new OverflowException(), new DivideByZeroException(), new ApplicationException() }; IEnumerable<ArithmeticException> arithmeticExceptionsQuery = exceptions.OfType<ArithmeticException>(); foreach (ArithmeticException exception in arithmeticExceptionsQuery) { WriteLine(exception); } } -
在
Program.cs中,注释掉对FilteringUsingWhere的调用,然后添加对FilteringByType方法的调用,如下面的代码所示:// FilteringUsingWhere(names); FilteringByType(); -
运行代码,注意结果只包括
ArithmeticException类型或ArithmeticException派生类型的异常,如下面的输出所示:System.OverflowException: Arithmetic operation resulted in an overflow. System.DivideByZeroException: Attempted to divide by zero.
使用集合和包
集合是数学中最基本的概念之一。集合是一组一个或多个独特的对象。多集,也称为包,是一组一个或多个可以重复的对象。
你可能记得在学校学过关于文氏图的内容。常见的集合操作包括集合之间的交集或并集。
让我们编写一些代码来定义三个代表学徒群体的 string 值数组,然后我们将对它们执行一些常见的集合和多集操作:
-
在
Program.Functions.cs中,添加一个方法,该方法将任何string变量的序列输出为逗号分隔的单个string到控制台输出,并可选地包含一个描述,如下面的代码所示:static void Output(IEnumerable<string> cohort, string description = "") { if (!string.IsNullOrEmpty(description)) { WriteLine(description); } Write(" "); WriteLine(string.Join(", ", cohort.ToArray())); WriteLine(); } -
在
Program.Functions.cs中,添加一个方法,该方法定义三个名称数组,输出它们,然后对它们执行各种集合操作,如下面的代码所示:static void WorkingWithSets() { string[] cohort1 = { "Rachel", "Gareth", "Jonathan", "George" }; string[] cohort2 = { "Jack", "Stephen", "Daniel", "Jack", "Jared" }; string[] cohort3 = { "Declan", "Jack", "Jack", "Jasmine", "Conor" }; SectionTitle("The cohorts"); Output(cohort1, "Cohort 1"); Output(cohort2, "Cohort 2"); Output(cohort3, "Cohort 3"); SectionTitle("Set operations"); Output(cohort2.Distinct(), "cohort2.Distinct()"); Output(cohort2.DistinctBy(name => name.Substring(0, 2)), "cohort2.DistinctBy(name => name.Substring(0, 2)):"); Output(cohort2.Union(cohort3), "cohort2.Union(cohort3)"); Output(cohort2.Concat(cohort3), "cohort2.Concat(cohort3)"); Output(cohort2.Intersect(cohort3), "cohort2.Intersect(cohort3)"); Output(cohort2.Except(cohort3), "cohort2.Except(cohort3)"); Output(cohort1.Zip(cohort2,(c1, c2) => $"{c1} matched with {c2}"), "cohort1.Zip(cohort2)"); } -
在
Program.cs中,注释掉对FilteringByType的调用,然后添加对WorkingWithSets方法的调用,如下面的代码所示:// FilteringByType(); WorkingWithSets(); -
运行代码并查看结果,如下面的输出所示:
Cohort 1 Rachel, Gareth, Jonathan, George Cohort 2 Jack, Stephen, Daniel, Jack, Jared Cohort 3 Declan, Jack, Jack, Jasmine, Conor cohort2.Distinct() Jack, Stephen, Daniel, Jared cohort2.DistinctBy(name => name.Substring(0, 2)): Jack, Stephen, Daniel cohort2.Union(cohort3) Jack, Stephen, Daniel, Jared, Declan, Jasmine, Conor cohort2.Concat(cohort3) Jack, Stephen, Daniel, Jack, Jared, Declan, Jack, Jack, Jasmine, Conor cohort2.Intersect(cohort3) Jack cohort2.Except(cohort3) Stephen, Daniel, Jared cohort1.Zip(cohort2) Rachel matched with Jack, Gareth matched with Stephen, Jonathan matched with Daniel, George matched with Jack
使用 Zip,如果两个序列中的项目数量不相等,则某些项目将没有匹配的伙伴。那些没有伙伴的,比如 Jared,将不会包含在结果中。
对于 DistinctBy 示例,我们不是通过比较整个名称来移除重复项,而是定义了一个 lambda 键选择器,通过比较前两个字符来移除重复项,因此 Jared 被移除,因为 Jack 已经是一个以 Ja 开头的名字。
获取索引以及项目。
.NET 9 引入了 Index LINQ 扩展方法。在 .NET 的早期版本中,如果你想获取每个项目的索引位置以及项目本身,你可以使用 Select 方法,但这有点混乱。
让我们看看旧方法和新方法的示例:
-
在
Program.Functions.cs中,添加一个方法,定义一个包含名称的数组,并使用旧方法(Select方法)和新技术(Index方法)输出它们及其索引位置,如下所示:static void WorkingWithIndices() { string[] theSeven = { "Homelander", "Black Noir", "The Deep", "A-Train", "Queen Maeve", "Starlight", "Stormfront" }; SectionTitle("Working With Indices (old)"); foreach (var (item, index) in theSeven.Select((item, index) => (item, index))) { WriteLine($"{index}: {item}"); } SectionTitle("Working With Indices (new)"); foreach (var (index, item) in theSeven.Index()) { WriteLine($"{index}: {item}"); } }
警告! 注意两个声明变量的顺序,这两个变量将保存索引和项目。当使用 Select 方法时,你必须先声明 item,然后是 index。当使用 Index 方法时,你必须先声明 index,然后是 item。
-
在
Program.cs中,注释掉对WorkingWithSets的调用,然后添加对WorkingWithIndices方法的调用,如下所示:// WorkingWithSets(); WorkingWithIndices(); -
运行代码并查看结果,如下所示:
*** Working With Indices (old) *** 0: Homelander 1: Black Noir 2: The Deep 3: A-Train 4: Queen Maeve 5: Starlight 6: Stormfront *** Working With Indices (new) *** 0: Homelander 1: Black Noir 2: The Deep 3: A-Train 4: Queen Maeve 5: Starlight 6: Stormfront
到目前为止,我们已经使用了 LINQ to Objects 提供程序来处理内存中的对象。接下来,我们将使用 LINQ to Entities 提供程序来处理存储在数据库中的实体。
使用 EF Core 中的 LINQ。
我们已经看到了过滤和排序的 LINQ 查询,但没有改变序列中项目形状的查询。这种操作称为 投影,因为它涉及到将一个形状的项目投影到另一个形状。要了解投影,最好有一些更复杂的数据类型来处理,因此,在下一个项目中,我们不会使用 string 序列,而是使用你在 第十章 中介绍的 Northwind 示例数据库中的实体序列。
我将给出使用 SQLite 的说明,因为它跨平台,但如果你更喜欢使用 SQL Server,那么请随意这样做。如果你选择使用 SQL Server,我已经包含了一些注释代码来启用 SQL Server。
创建用于探索 LINQ to Entities 的控制台应用程序。
首先,我们必须创建一个控制台应用程序和 Northwind 数据库来工作:
-
使用你喜欢的代码编辑器添加一个新的 Console App /
console项目,命名为LinqWithEFCore,到Chapter11解决方案中。 -
在项目文件中,全局和静态导入
System.Console类。 -
在
LinqWithEFCore项目中,添加对 SQLite 和/或 SQL Server 的 EF Core 提供程序的包引用,如下所示:<ItemGroup> <!--To use SQLite--> <PackageReference Version="9.0.0" Include="Microsoft.EntityFrameworkCore.Sqlite" /> <!--To use SQL Server--> <PackageReference Version="9.0.0" Include="Microsoft.EntityFrameworkCore.SqlServer" /> </ItemGroup> -
构建用于恢复包的
LinqWithEFCore项目。 -
将
Northwind4Sqlite.sql文件复制到LinqWithEFCore文件夹。 -
在
LinqWithEFCore文件夹的命令提示符或终端中,通过执行以下命令创建 Northwind 数据库:sqlite3 Northwind.db -init Northwind4Sqlite.sql -
请耐心等待,因为这个命令可能需要一段时间来创建数据库结构。最终,您将看到如下所示的 SQLite 命令提示符:
-- Loading resources from Northwind4Sqlite.sql SQLite version 3.38.0 2022-02-22 15:20:15 Enter ".help" for usage hints. sqlite> -
要退出 SQLite 命令模式,在 Windows 上按 Ctrl + C 两次,在 macOS 或 Linux 上按 Cmd + D。
-
如果您更喜欢使用 SQL Server,那么您应该已经从上一章创建了 SQL Server 中的 Northwind 数据库。
构建 EF Core 模型
我们必须定义一个 EF Core 模型来表示我们将要工作的数据库和表。我们将手动定义模型以完全控制,并防止在 Categories 和 Products 表之间自动定义关系。稍后,您将使用 LINQ 来连接这两个实体集:
-
在
LinqWithEFCore项目中,添加一个名为EntityModels的新文件夹。 -
在
EntityModels文件夹中,向项目添加三个类文件,分别命名为NorthwindDb.cs、Category.cs和Product.cs。 -
修改名为
Category.cs的类文件,如下所示:// To use [Required] and [StringLength]. using System.ComponentModel.DataAnnotations; namespace Northwind.EntityModels; public class Category { public int CategoryId { get; set; } [Required] [StringLength(15)] public string CategoryName { get; set; } = null!; public string? Description { get; set; } } -
修改名为
Product.cs的类文件,如下所示:// To use [Required] and [StringLength]. using System.ComponentModel.DataAnnotations; // To use [Column]. using System.ComponentModel.DataAnnotations.Schema; namespace Northwind.EntityModels; public class Product { public int ProductId { get; set; } [Required] [StringLength(40)] public string ProductName { get; set; } = null!; public int? SupplierId { get; set; } public int? CategoryId { get; set; } [StringLength(20)] public string? QuantityPerUnit { get; set; } // Required for SQL Server provider. [Column(TypeName = "money")] public decimal? UnitPrice { get; set; } public short? UnitsInStock { get; set; } public short? UnitsOnOrder { get; set; } public short? ReorderLevel { get; set; } public bool Discontinued { get; set; } }
我们故意没有定义 Category 和 Product 之间的任何关系,以便我们可以在稍后使用 LINQ 手动将它们关联起来。
-
修改名为
NorthwindDb.cs的类文件,如下所示:using Microsoft.Data.SqlClient; // To use SqlConnectionStringBuilder. using Microsoft.EntityFrameworkCore; // To use DbContext, DbSet<T>. namespace Northwind.EntityModels; public class NorthwindDb : DbContext { public DbSet<Category> Categories { get; set; } = null!; public DbSet<Product> Products { get; set; } = null!; protected override void OnConfiguring( DbContextOptionsBuilder optionsBuilder) { #region To use SQLite string database = "Northwind.db"; string dir = Environment.CurrentDirectory; string path = string.Empty; // The database file will stay in the project folder. // We will automatically adjust the relative path to // account for running in Visual Studio or CLI. if (dir.EndsWith("net9.0")) { // Running in the <project>\bin\<Debug|Release>\net9.0 directory. path = Path.Combine("..", "..", "..", database); } else { // Running in the <project> directory. path = database; } path = Path.GetFullPath(path); // Convert to absolute path. WriteLine($"SQLite database path: {path}"); if (!File.Exists(path)) { throw new FileNotFoundException( message: $"{path} not found.", fileName: path); } // To use SQLite. optionsBuilder.UseSqlite($"Data Source={path}"); #endregion #region To use SQL Server SqlConnectionStringBuilder builder = new(); builder.DataSource = "."; builder.InitialCatalog = "Northwind"; builder.IntegratedSecurity = true; builder.Encrypt = true; builder.TrustServerCertificate = true; builder.MultipleActiveResultSets = true; string connection = builder.ConnectionString; // WriteLine($"SQL Server connection: {connection}"); // To use SQL Server. // optionsBuilder.UseSqlServer(connection); #endregion } protected override void OnModelCreating( ModelBuilder modelBuilder) { if (Database.ProviderName is not null && Database.ProviderName.Contains("Sqlite")) { // SQLite data provider does not directly support the // decimal type so we can convert to double instead. modelBuilder.Entity<Product>() .Property(product => product.UnitPrice) .HasConversion<double>(); } } }
如果您想使用 SQL Server,那么请注释掉调用 UseSqlite 的语句,并取消注释调用 UseSqlServer 的语句。
- 构建项目并修复任何编译错误。
过滤和排序序列
现在,让我们编写语句来过滤和排序来自表的行序列:
-
在
LinqWithEFCore项目中,添加一个名为Program.Helpers.cs的新类文件。 -
在
Program.Helpers.cs中,定义一个部分Program类,其中包含一个配置控制台以支持特殊字符(如欧元货币符号)、控制当前区域设置以及输出部分标题的函数,如下所示:using System.Globalization; // To use CultureInfo. partial class Program { private static void ConfigureConsole(string culture = "en-US", bool useComputerCulture = false) { // To enable Unicode characters like Euro symbol in the console. OutputEncoding = System.Text.Encoding.UTF8; if (!useComputerCulture) { CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture); } WriteLine($"CurrentCulture: {CultureInfo.CurrentCulture.DisplayName}"); } private static void SectionTitle(string title) { ConsoleColor previousColor = ForegroundColor; ForegroundColor = ConsoleColor.DarkYellow; WriteLine($"*** {title} ***"); ForegroundColor = previousColor; } } -
在
LinqWithEFCore项目中,添加一个名为Program.Functions.cs的新类文件。 -
在
Program.Functions.cs中定义一个部分Program类,并添加一个过滤和排序产品的函数,如下所示:using Northwind.EntityModels; // To use NorthwindDb, Category, Product. using Microsoft.EntityFrameworkCore; // To use DbSet<T>. partial class Program { private static void FilterAndSort() { SectionTitle("Filter and sort"); using NorthwindDb db = new(); DbSet<Product> allProducts = db.Products; IQueryable<Product> filteredProducts = allProducts.Where(product => product.UnitPrice < 10M); IOrderedQueryable<Product> sortedAndFilteredProducts = filteredProducts.OrderByDescending(product => product.UnitPrice); WriteLine("Products that cost less than $10:"); foreach (Product p in sortedAndFilteredProducts) { WriteLine("{0}: {1} costs {2:$#,##0.00}", p.ProductId, p.ProductName, p.UnitPrice); } WriteLine(); } }
注意以下关于前面代码的内容:
-
DbSet<T>实现IEnumerable<T>,因此 LINQ 可以用于查询和操作为 EF Core 构建的模型中的实体序列。(实际上,我应该说是TEntity而不是T,但这个泛型类型的名称没有功能上的影响。唯一的要求是这个类型是一个class。这个名称只是表明这个类预期是一个实体模型。) -
序列实现
IQueryable<T>(或调用排序 LINQ 方法后的IOrderedQueryable<T>),而不是IEnumerable<T>或IOrderedEnumerable<T>。这表明我们正在使用一个 LINQ 提供程序,该提供程序使用表达式树构建查询。它们代表以树状数据结构中的代码,并允许创建动态查询,这对于构建针对外部数据提供程序(如 SQLite)的 LINQ 查询非常有用。 -
LINQ 表达式将被转换为另一种查询语言,例如 SQL。使用
foreach遍历查询或调用ToArray等方法将强制执行查询并使结果具体化。
-
在
Program.cs中,删除任何现有语句,然后调用ConfigureConsole和FilterAndSort方法,如下所示代码:ConfigureConsole(); // Sets US English by default. FilterAndSort(); -
运行项目并查看结果,如下所示输出:
CurrentCulture: English (United States) *** Filter and sort *** SQLite database path: C:\cs13net9\Chapter11\LinqWithEFCore\Northwind.db Products that cost less than $10: 41: Jack's New England Clam Chowder costs $9.65 45: Rogede sild costs $9.50 47: Zaanse koeken costs $9.50 19: Teatime Chocolate Biscuits costs $9.20 23: Tunnbröd costs $9.00 75: Rhönbräu Klosterbier costs $7.75 54: Tourtière costs $7.45 52: Filo Mix costs $7.00 13: Konbu costs $6.00 24: Guaraná Fantástica costs $4.50 33: Geitost costs $2.50
虽然此查询输出了我们想要的信息,但它效率低下,因为它从 Products 表中获取所有列,而不是我们需要的三个列。让我们记录生成的 SQL。
-
在
FilterAndSort方法中,在用foreach遍历结果之前,添加一个输出 SQL 的语句,如下所示的高亮代码:WriteLine("Products that cost less than $10:"); **WriteLine(sortedAndFilteredProducts.ToQueryString());** -
运行代码,查看显示在产品详情之前执行的 SQL 的结果,如下所示的部分输出:
Products that cost less than $10: SELECT "p"."ProductId", "p"."CategoryId", "p"."Discontinued", "p"."ProductName", "p"."QuantityPerUnit", "p"."ReorderLevel", "p"."SupplierId", "p"."UnitPrice", "p"."UnitsInStock", "p"."UnitsOnOrder" FROM "Products" AS "p" WHERE "p"."UnitPrice" < 10.0 ORDER BY "p"."UnitPrice" DESC 41: Jack's New England Clam Chowder costs $9.65 ...
将序列投影到新类型
在我们查看投影之前,我们应该回顾对象初始化语法。如果您有一个类定义,则可以使用类名、new() 和花括号来设置字段和属性的初始值,如下所示代码:
// Person.cs
public class Person
{
public string Name { get; set; }
public DateTime DateOfBirth { get; set; }
}
// Program.cs
Person knownTypeObject = new()
{
Name = "Boris Johnson",
DateOfBirth = new(year: 1964, month: 6, day: 19)
};
C# 3 及以后的版本允许使用 var 关键字实例化匿名类型实例,如下所示代码:
var anonymouslyTypedObject = new
{
Name = "Boris Johnson",
DateOfBirth = new DateTime(year: 1964, month: 6, day: 19)
};
虽然我们没有指定类型,但编译器可以从两个属性的设置中推断出匿名类型,这两个属性分别命名为 Name 和 DateOfBirth。编译器可以从分配的值推断出两个属性的类型:一个字面量 string 和一个日期/时间值的新实例。
当编写 LINQ 查询将现有类型投影到新类型时,此功能特别有用,无需显式定义新类型。由于类型是匿名的,这只能与 var 声明的局部变量一起工作。
通过添加对 Select 方法的调用,使针对数据库表的 SQL 命令更高效,将 Product 类的实例投影到仅具有三个属性的新匿名类型实例:
-
在
Program.Functions.cs文件中,在FilterAndSort方法中,添加一条语句以扩展 LINQ 查询,使用Select方法仅返回我们需要的三个属性(即表列),修改对ToQueryString的调用以使用新的projectedProducts查询,并修改foreach语句以使用var关键字和新的projectedProducts查询,如下所示(代码高亮):IOrderedQueryable<Product> sortedAndFilteredProducts = filteredProducts.OrderByDescending(product => product.UnitPrice); **var** **projectedProducts = sortedAndFilteredProducts** **.Select(product =>** **new****// Anonymous type.** **{** **product.ProductId,** **product.ProductName,** **product.UnitPrice** **});** WriteLine("Products that cost less than $10:"); WriteLine(**projectedProducts**.ToQueryString()); foreach (**var** p in **projectedProducts**) { -
将鼠标悬停在
Select方法调用中的new关键字或foreach语句中的var关键字上,并注意它是一个匿名类型,如图 图 11.5 所示:
图 11.5:在 LINQ 投影期间使用的匿名类型
-
运行项目,并确认输出与之前相同,生成的 SQL 更高效,如下所示(输出):
SELECT "p"."ProductId", "p"."ProductName", "p"."UnitPrice" FROM "Products" AS "p" WHERE "p"."UnitPrice" < 10.0 ORDER BY "p"."UnitPrice" DESC
更多信息:您可以在以下链接中了解更多关于使用 Select 方法进行投影的信息:learn.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/linq/projection-operations。
让我们继续通过学习如何连接、分组和执行查找来查看常见的 LINQ 查询。
加入、分组和查找
有三个用于连接、分组和创建分组查找的扩展方法:
-
Join:此方法有四个参数:您想要与之连接的序列、在 左侧 序列上匹配的属性或属性、在 右侧 序列上匹配的属性或属性,以及一个投影。 -
GroupJoin:此方法具有相同的参数,但它将匹配项组合成一个具有Key属性的组对象,该属性用于匹配值,以及用于多个匹配项的IEnumerable<T>类型。 -
ToLookup:此方法创建一个新的数据结构,该结构按键对序列进行分组。
连接序列
让我们探索在处理两个表 Categories 和 Products 时这些方法:
-
在
Program.Functions.cs中,添加一个选择类别和产品、将它们连接并输出的方法,如下所示(代码):private static void JoinCategoriesAndProducts() { SectionTitle("Join categories and products"); using NorthwindDb db = new(); // Join every product to its category to return 77 matches. var queryJoin = db.Categories.Join( inner: db.Products, outerKeySelector: category => category.CategoryId, innerKeySelector: product => product.CategoryId, resultSelector: (c, p) => new { c.CategoryName, p.ProductName, p.ProductId }); foreach (var p in queryJoin) { WriteLine($"{p.ProductId}: {p.ProductName} in {p.CategoryName}."); } }
在连接中,有两个序列,outer 和 inner。在上面的示例中,categories 是外部序列,products 是内部序列。
-
在
Program.cs中,注释掉对FilterAndSort的调用,然后调用JoinCategoriesAndProducts方法,如下所示(代码高亮):ConfigureConsole(); // Sets US English by default. **//** FilterAndSort(); **JoinCategoriesAndProducts();** -
运行代码并查看结果。注意,对于每个 77 个产品,都有一行输出,如下所示(输出,仅包括前四项):
1: Chai in Beverages. 2: Chang in Beverages. 3: Aniseed Syrup in Condiments. 4: Chef Anton's Cajun Seasoning in Condiments. ... -
在
Program.Functions.cs中,在JoinCategoriesAndProducts方法中,在现有查询的末尾调用OrderBy方法按CategoryName排序,如下所示(代码高亮):var queryJoin = db.Categories.Join( inner: db.Products, outerKeySelector: category => category.CategoryId, innerKeySelector: product => product.CategoryId, resultSelector: (c, p) => new { c.CategoryName, p.ProductName, p.ProductId }) **.OrderBy(cp => cp.CategoryName)**; -
运行代码并查看结果。请注意,每个 77 个产品都有一行输出,结果首先显示
Beverages类别中的所有产品,然后是Condiments类别,依此类推,如下面的部分输出所示:1: Chai in Beverages. 2: Chang in Beverages. 24: Guaraná Fantástica in Beverages. 34: Sasquatch Ale in Beverages. ...
分组连接序列
让我们探索使用与探索连接时相同的两个表(Categories 和 Products),以便我们可以比较细微的差异:
-
在
Program.Functions.cs中,添加一个分组和连接的方法,显示组名,然后显示每个组中的所有项目,如下面的代码所示:private static void GroupJoinCategoriesAndProducts() { SectionTitle("Group join categories and products"); using NorthwindDb db = new(); // Group all products by their category to return 8 matches. var queryGroup = db.Categories.AsEnumerable().GroupJoin( inner: db.Products, outerKeySelector: category => category.CategoryId, innerKeySelector: product => product.CategoryId, resultSelector: (c, matchingProducts) => new { c.CategoryName, Products = matchingProducts.OrderBy(p => p.ProductName) }); foreach (var c in queryGroup) { WriteLine($"{c.CategoryName} has {c.Products.Count()} products."); foreach (var product in c.Products) { WriteLine($" {product.ProductName}"); } } }
如果我们没有调用 AsEnumerable 方法,那么将抛出一个运行时异常,如下面的输出所示:
Unhandled exception. System.ArgumentException: Argument type 'System.Linq.IOrderedQueryable`1[Packt.Shared.Product]' does not match the corresponding member type 'System.Linq.IOrderedEnumerable`1[Packt.Shared.Product]' (Parameter 'arguments[1]')
这是因为并非所有 LINQ 扩展方法都可以从表达式树转换为其他查询语法,如 SQL。在这些情况下,我们可以通过调用 AsEnumerable 方法将 IQueryable<T> 转换为 IEnumerable<T>,这强制查询处理仅使用 LINQ to EF Core 将数据带入应用程序,然后使用 LINQ to Objects 在内存中执行更复杂的处理。但是,这通常效率较低。
-
在
Program.cs中,调用GroupJoinCategoriesAndProducts方法。 -
运行代码,查看结果,并注意每个类别内的产品已按查询中定义的名称排序,如下面的部分输出所示:
Beverages has 12 products. Chai Chang ... Condiments has 12 products. Aniseed Syrup Chef Anton's Cajun Seasoning ...
查询分组
而不是编写一个 LINQ 查询表达式来连接和分组,然后运行一次,您可能希望使用 LINQ 扩展方法来创建,然后存储一个可重用的内存集合,该集合包含已分组的实体。
在 Northwind 数据库中有一个名为 Products 的表,其中包含一个列,表示它们所在的类别,部分如下所示 表 11.4:
| 产品名称 | 分类 ID |
|---|---|
| 奶茶 | 1 |
| 长颈瓶 | 1 |
| 八角糖浆 | 2 |
| 安东大厨的卡真调味料 | 2 |
| 安东大厨的 gumbo 混合料 | 2 |
| … | … |
表 11.4:产品表的前五行
您可能希望在内存中创建一个数据结构,可以按类别对 Product 实体进行分组,然后提供一个快速的方法来查找特定类别中的所有产品。
您可以使用 ToLookup LINQ 方法创建此内容,如下面的代码所示:
ILookup<int, Product>? productsByCategoryId =
db.Products.ToLookup(keySelector: category => category.CategoryId);
当您调用 ToLookup 方法时,您必须指定一个 键选择器 来选择您想要按什么值分组。然后,此值可以稍后用于查找组和其项目。
ToLookup 方法在内存中创建一个类似于字典的数据结构,其中包含键值对,键是唯一的类别 ID,值是 Product 对象的集合,部分如下所示 表 11.5:
| 键 | 值(每个都是一个 Product 对象的集合) |
|---|---|
| 1 | [奶茶], [长颈瓶],等等 |
| 2 | [八角糖浆], [安东大厨的卡真调味料], [安东大厨的 gumbo 混合料],等等 |
| … | … |
表 11.5:查找的前两行
注意,方括号中的产品名称,如 [Chai],代表一个完整的 Product 对象。
我们可以使用相关类别表中的类别名称,而不是使用 CategoryId 值作为查找的关键。
让我们在代码示例中这样做:
-
在
Program.Functions.cs中添加一个方法,将产品与类别名称连接起来,然后将其转换为查找,使用IGrouping<string, Product>枚举整个查找,以表示查找字典中的每一行,并查找特定类别的单个产品集合,如下面的代码所示:private static void ProductsLookup() { SectionTitle("Products lookup"); using NorthwindDb db = new(); // Join all products to their category to return 77 matches. var productQuery = db.Categories.Join( inner: db.Products, outerKeySelector: category => category.CategoryId, innerKeySelector: product => product.CategoryId, resultSelector: (c, p) => new { c.CategoryName, Product = p }); ILookup<string, Product> productLookup = productQuery.ToLookup( keySelector: cp => cp.CategoryName, elementSelector: cp => cp.Product); foreach (IGrouping<string, Product> group in productLookup) { // Key is Beverages, Condiments, and so on. WriteLine($"{group.Key} has {group.Count()} products."); foreach (Product product in group) { WriteLine($" {product.ProductName}"); } } // We can look up the products by a category name. Write("Enter a category name: "); string categoryName = ReadLine()!; WriteLine(); WriteLine($"Products in {categoryName}:"); IEnumerable<Product> productsInCategory = productLookup[categoryName]; foreach (Product product in productsInCategory) { WriteLine($" {product.ProductName}"); } }
选择器参数是用于选择不同目的的 lambda 表达式。例如,ToLookup 有一个 keySelector 用于选择每个项的部分,该部分将成为键,还有一个 elementSelector 用于选择每个项的部分,该部分将成为值。你可以在以下链接中了解更多信息:learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.tolookup。
-
在
Program.cs中调用ProductsLookup方法。 -
运行代码,查看结果,输入一个类别名称,例如
Seafoods,并注意该类别下的产品已经被查找并列出,如下面的部分输出所示:Beverages has 12 products. Chai Chang ... Condiments has 12 products. Aniseed Syrup Chef Anton's Cajun Seasoning ... Enter a category name: Seafood Products in Seafood: Ikura Konbu Carnarvon Tigers Nord-Ost Matjeshering Inlagd Sill Gravad lax Boston Crab Meat Jack's New England Clam Chowder Rogede sild Spegesild Escargots de Bourgogne Röd Kaviar
LINQ 有很多内容,在最后一节中,你将有机会通过一些在线材料进一步探索。
练习和探索
通过回答一些问题、进行一些动手实践,并深入探索本章涵盖的主题来测试你的知识和理解。
练习 11.1 – 在线材料
在线材料可以是为我这本书写的额外内容,也可以是引用 Microsoft 或第三方创建的内容。
聚合和分页序列
你可以在以下链接中学习如何使用 LINQ 聚合方法和实现数据分页:
github.com/markjprice/cs13net9/blob/main/docs/ch11-aggregating.md
使用并行 LINQ 进行多线程操作
你可以通过使用多线程运行 LINQ 查询来提高性能和可伸缩性。通过完成以下链接中找到的仅在线部分来学习如何操作:
github.com/markjprice/cs13net9/blob/main/docs/ch11-plinq.md
使用 LINQ to XML 进行操作
如果你想要使用 LINQ 处理或生成 XML,那么你可以通过完成以下链接中找到的在线部分来学习其基础知识:
github.com/markjprice/cs13net9/blob/main/docs/ch11-linq-to-xml.md
创建自己的 LINQ 扩展方法
如果您想创建自己的 LINQ 扩展方法,那么您可以通过完成以下链接中仅在线部分来学习基础知识:
github.com/markjprice/cs13net9/blob/main/docs/ch11-custom-linq-methods.md
.NET 9 中新 LINQ 方法的设计
您可以在以下链接中阅读有关 .NET 9 中引入的新 LINQ 扩展方法的设计:
-
AggregateBy:github.com/dotnet/runtime/issues/91533. -
CountBy:github.com/dotnet/runtime/issues/77716.
练习 11.2 – 使用 LINQ 进行查询练习
在 Chapter11 解决方案中,创建一个名为 Exercise_LinqQueries 的控制台应用程序,提示用户输入一个城市,然后列出在该城市中 Northwind 客户的公司名称,如下面的输出所示:
Enter the name of a city: London
There are 6 customers in London:
Around the Horn
B's Beverages
Consolidated Holdings
Eastern Connection
North/South
Seven Seas Imports
然后,通过在用户输入首选城市之前显示所有已居住的独特城市列表来增强应用程序,如下面的输出所示:
Aachen, Albuquerque, Anchorage, Århus, Barcelona, Barquisimeto, Bergamo, Berlin, Bern, Boise, Bräcke, Brandenburg, Bruxelles, Buenos Aires, Butte, Campinas, Caracas, Charleroi, Cork, Cowes, Cunewalde, Elgin, Eugene, Frankfurt a.M., Genève, Graz, Helsinki, I. de Margarita, Kirkland, Kobenhavn, Köln, Lander, Leipzig, Lille, Lisboa, London, Luleå, Lyon, Madrid, Mannheim, Marseille, México D.F., Montréal, München, Münster, Nantes, Oulu, Paris, Portland, Reggio Emilia, Reims, Resende, Rio de Janeiro, Salzburg, San Cristóbal, San Francisco, Sao Paulo, Seattle, Sevilla, Stavern, Strasbourg, Stuttgart, Torino, Toulouse, Tsawassen, Vancouver, Versailles, Walla Walla, Warszawa
练习 11.3 – 测试您的知识
回答以下问题:
-
LINQ 的两个必需部分是什么?
-
您会使用哪个 LINQ 扩展方法来返回类型的一个子集属性?
-
您会使用哪个 LINQ 扩展方法来过滤序列?
-
列出五个执行聚合操作的 LINQ 扩展方法。
-
Select和SelectMany扩展方法之间的区别是什么? -
IEnumerable<T>和IQueryable<T>之间的区别是什么?您如何在这两者之间切换? -
泛型
Func委托(如Func<T1, T2, T>)中的最后一个类型参数T代表什么? -
以
OrDefault结尾的 LINQ 扩展方法有什么好处? -
为什么查询理解语法是可选的?
-
您如何创建自己的 LINQ 扩展方法?
练习 11.4 – 探索主题
使用以下页面上的链接了解本章涵盖主题的更多详细信息:
摘要
在本章中,您学习了如何编写 LINQ 查询以执行常见任务,如:
-
仅选择您需要的项目属性。
-
根据条件过滤项目。
-
排序项目。
-
将项目投影到不同的类型中。
-
连接和分组项目。
在下一章中,您将了解如何使用 ASP.NET Core 进行 Web 开发。在剩余的章节中,您将学习如何实现 ASP.NET Core 的现代功能,如 Blazor 和 Minimal APIs。
第十二章:使用 .NET 引入现代 Web 开发
本书第三部分和最后一部分是关于使用 .NET 进行现代 Web 开发,这意味着 ASP.NET Core、Blazor 和 Minimal APIs。您将学习如何构建跨平台的项目,例如网站和 Web 服务。
微软将用于构建应用程序的平台称为 app models 或 workloads。
我建议您按顺序阅读本章和后续章节,因为后续章节将引用早期章节中的项目,并且您将积累足够的知识和技能来应对后续章节中更复杂的问题。
在本章中,我们将涵盖以下主题:
-
理解 ASP.NET Core
-
ASP.NET Core 的新功能
-
项目结构
-
为本书其余部分构建实体模型
-
理解 Web 开发
理解 ASP.NET Core
由于本书是关于 C# 和 .NET 的,我们将学习用于构建本书剩余章节中我们将遇到的实际应用程序的应用程序模型。
更多信息:微软在其 .NET 架构指南 文档中提供了广泛的关于实现应用程序模型的指导,您可以通过以下链接阅读:dotnet.microsoft.com/en-us/learn/dotnet/architecture-guides。
ASP.NET Core 是微软用于构建与数据交互的网站和服务的演变技术历史的一部分:
-
ActiveX 数据对象 (ADO) 于 1996 年发布,是微软尝试提供一套单一的 组件对象模型 (COM) 组件以处理数据。随着 .NET 的发布,创建了一个名为 ADO.NET 的等效产品,它仍然是 .NET 中处理数据更快的方法,其核心类包括
DbConnection、DbCommand和DbDataReader。像 EF Core 这样的 ORM(对象关系映射器) 在内部使用 ADO.NET。 -
Active Server Pages (ASP) 于 1996 年发布,是微软首次尝试的平台,用于在服务器端动态执行网站代码。ASP 文件包含 HTML 和代码的混合体,这些代码在服务器上执行,使用 VBScript 语言编写。
-
ASP.NET Web Forms 于 2002 年与 .NET Framework 一起发布,旨在使熟悉 Visual Basic 等非 Web 开发者能够通过拖放视觉组件和在 Visual Basic 或 C# 中编写事件驱动代码来快速创建网站。在新的 .NET Framework Web 项目中应避免使用 Web Forms,而应使用 ASP.NET MVC。
-
Windows Communication Foundation (WCF) 于 2006 年发布,使开发者能够构建 SOAP 和 REST 服务。SOAP 功能强大但复杂,因此除非您需要高级功能,例如分布式事务和复杂消息拓扑,否则应避免使用。
-
ASP.NET MVC 于 2009 年发布,旨在在 Web 开发者之间清晰分离 模型(临时存储数据)、视图(在 UI 中使用各种格式展示数据)和 控制器(获取模型并将其传递给视图)的职责。这种分离使得重用和单元测试得到改进。
-
ASP.NET Web API 于 2012 年发布,使开发者能够创建比 SOAP 服务更简单、更可扩展的 HTTP 服务(也称为 REST 服务)。
-
ASP.NET SignalR 于 2013 年发布,通过抽象底层技术和技术,如 WebSockets 和长轮询,为网站提供实时通信功能。这使得网站功能,如实时聊天,以及更新对时间敏感的数据,如股票价格,在广泛的 Web 浏览器中成为可能,即使它们不支持底层技术,如 WebSockets。
-
ASP.NET Core 于 2016 年发布,结合了 .NET Framework 技术的现代实现,如 MVC、Web API 和 SignalR,以及替代技术,如 Razor Pages、gRPC 和 Blazor,所有这些都在现代 .NET 上运行。因此,ASP.NET Core 可以跨平台执行。ASP.NET Core 提供了许多项目模板,以帮助您开始使用其支持的技术。
良好实践:选择 ASP.NET Core 开发网站和 Web 服务,因为它包括现代且跨平台的 Web 相关技术。
经典 ASP.NET 与现代 ASP.NET Core 对比
直到现代 .NET,ASP.NET 都是基于 .NET Framework 中的一个大型程序集构建的,名为 System.Web.dll,并且它与微软仅适用于 Windows 的 Web 服务器 Internet Information Services(IIS)紧密耦合。多年来,这个程序集积累了大量功能,其中许多不适合现代跨平台开发。
ASP.NET Core 是 ASP.NET 的重大重构。它移除了对 System.Web.dll 程序集和 IIS 的依赖,并由模块化轻量级包组成,就像现代 .NET 的其余部分一样。ASP.NET Core 仍然支持使用 IIS 作为 Web 服务器,但有一个更好的选择。
您可以在 Windows、macOS 和 Linux 等平台上开发和运行 ASP.NET Core 应用程序。微软甚至创建了一个跨平台、高性能的 Web 服务器,名为 Kestrel,整个堆栈都是开源的。
ASP.NET Core 2.2 或更高版本的项目默认使用新的进程内托管模型。当在 Microsoft IIS 中托管时,这提供了 400% 的性能提升,但微软仍然推荐使用 Kestrel 以获得更好的性能。
使用 ASP.NET Core 构建网站
网站由多个网页组成,这些网页可以从文件系统静态加载,或由服务器端技术(如 ASP.NET Core)动态生成。Web 浏览器使用 唯一资源定位符(URLs)进行 GET 请求,以标识每个页面,并可以使用 POST、PUT 和 DELETE 请求操作服务器上存储的数据。
在许多网站上,网页浏览器被视为一个表示层,几乎所有处理都在服务器端完成。客户端可能会使用一些 JavaScript 来实现表单验证警告和一些展示功能,例如轮播图。
ASP.NET Core 提供了多种技术来构建网站的用户界面:
-
ASP.NET Core Razor Pages 是一种简单的方法,可以动态生成简单网站的 HTML。我建议将其视为一种遗留技术,并使用 Blazor 代替。
-
ASP.NET Core MVC 是一种流行的 模型-视图-控制器(MVC)设计模式的实现,适用于开发复杂的网站。
-
Blazor 允许您使用 C#和.NET 构建用户界面组件,而不是像 Angular、React 和 Vue 这样的基于 JavaScript 的 UI 框架。Blazor 的早期版本要求开发者选择一个 托管模型。Blazor WebAssembly 托管模型像基于 JavaScript 的框架一样在浏览器中运行您的代码。Blazor Server 托管模型在服务器上运行您的代码,并动态更新网页。.NET 8 引入了一个统一的、全栈的托管模型,允许单个组件在服务器或客户端上执行,甚至可以在运行时动态适应。您将在第十四章“使用 Blazor 构建交互式 Web 组件”中详细了解 Blazor。
那么,您应该选择哪一个?
“Blazor 现在是我们的推荐方法,用于使用 ASP.NET Core 构建 Web UI,但 MVC 和 Razor Pages 现在并没有过时。MVC 和 Razor Pages 都是成熟、全面支持且广泛使用的框架,我们计划在未来一段时间内继续支持它们。也没有要求或指导将现有的 MVC 或 Razor Pages 应用程序迁移到 Blazor。对于基于 MVC 的现有、成熟的项目,继续使用 MVC 进行开发是一个完全合理且可行的方法。”
– 丹·罗斯
您可以在以下链接中查看丹的原始评论帖子:github.com/dotnet/aspnetcore/issues/51834#issuecomment-1913282747。
丹·罗斯是 ASP.NET 的首席产品经理,因此他对 ASP.NET Core 的未来比任何人都了解:devblogs.microsoft.com/dotnet/author/danroth27/。
我同意丹·罗斯的引言。对我来说,在 Web 开发中有两个主要选择:
-
对于使用现代 Web 开发的网站或 Web 服务:选择 Blazor 作为 Web 用户界面,并使用 Minimal APIs 作为 Web 服务。这些技术在本书中及其配套书籍《使用.NET 8 构建应用程序和服务》中有详细说明。
-
对于使用成熟和经过验证的 Web 开发的网站或 Web 服务:选择基于控制器的 ASP.NET Core MVC 用于 Web 用户界面,Web API 用于 Web 服务。为了获得更高的生产力,你可以在这些之上添加第三方平台,例如,一个像 Umbraco 这样的 .NET CMS。这些技术在我的新书 Real-World Web Development with .NET 9 中有所介绍。
在这些选择中,ASP.NET Core 的许多部分都是共享的,所以你只需要学习这些共享组件一次,如图 12.1 所示:
图 12.1:基于现代或控制器和共享的 ASP.NET Core 组件
ASP.NET Core 中使用的文件类型比较
总结这些技术使用的文件类型是有用的,因为它们相似但不同。如果你不理解一些微妙但重要的差异,在尝试实现自己的项目时可能会造成很多困惑。请注意 表 12.1 中的差异:
| 技术 | 特殊文件名 | 文件扩展名 | 指令 |
|---|---|---|---|
| Razor 组件(Blazor) | .razor | ||
| Razor 组件(Blazor 与页面路由) | .razor | @page "<path>" | |
| Razor 组件导入(Blazor) | _Imports | .razor | |
| Razor 页面 | .cshtml | @page | |
| Razor 视图(MVC) | .cshtml | ||
| Razor 布局 | .cshtml | ||
| Razor 视图启动 | _ViewStart | .cshtml | |
| Razor 视图导入 | _ViewImports | .cshtml |
表 12.1:ASP.NET Core 中使用的文件类型比较
指令如 @page 被添加到文件内容的顶部。
如果一个文件没有特殊文件名,那么它可以被命名为任何东西。例如,你可能会为 Blazor 项目创建一个名为 Customer.razor 的 Razor 组件,或者你可能会为 MVC 或 Razor Pages 项目创建一个名为 _MobileLayout.cshtml 的 Razor 布局。
共享 Razor 文件的命名约定,例如布局和部分视图,是在文件名前加上下划线 _。例如,_ViewStart.cshtml、_Layout.cshtml 或 _Product.cshtml(这可能是一个用于渲染产品的部分视图)。
一个像 _MyCustomLayout.cshtml 这样的 Razor 布局文件与一个 Razor 视图相同。使文件成为布局的是将其设置为另一个 Razor 文件的 Layout 属性,如下面的代码所示:
@{
Layout = "_MyCustomLayout"; // File extension is not needed.
}
警告! 请注意在文件顶部使用正确的文件扩展名和指令;否则,你将得到意外的行为。
使用内容管理系统构建网站
大多数网站都有大量的内容,如果每次需要更改某些内容时都需要开发者介入,那么这不会很好地扩展。
内容管理系统(CMS)使开发者能够定义内容结构和模板,以提供一致性和良好的设计,同时使非技术内容所有者轻松管理实际内容。他们可以创建新页面或内容块,并更新现有内容,知道它将为访客提供极小的努力。
所有 Web 平台都提供了多种 CMS,如用于 PHP 的 WordPress 或用于 Python 的 Django。支持现代.NET 的 CMS 包括 Optimizely Content Cloud、Umbraco、Piranha 和 Orchard Core。
使用 CMS 的关键好处是它提供了一个友好的内容管理用户界面。内容所有者登录到网站并自行管理内容。然后使用 ASP.NET Core MVC 控制器和视图,或通过称为无头 CMS的 Web 服务端点,将内容渲染并返回给访客,以将内容提供给作为移动或桌面应用程序、店内触摸点或使用 JavaScript 框架或 Blazor 构建的客户端。
本书不涵盖.NET CMS,因此我在 GitHub 存储库中包含了链接,您可以在其中了解更多关于它们的信息:github.com/markjprice/cs13net9/blob/main/docs/book-links.md#net-content-management-systems。我还在我新书《Real-World Web Development with .NET 9》中涵盖了 Umbraco CMS。
使用 SPA 框架构建 Web 应用程序
Web 应用程序通常使用被称为单页应用程序(SPA)框架的技术构建,例如 Blazor、Angular、React、Vue 或专有的 JavaScript 库。
当需要更多数据时,它们可以向后端 Web 服务发出请求,并使用常见的序列化格式,如 XML 和 JSON,发布更新后的数据。典型的例子是 Google 的 Web 应用程序,如 Gmail、地图和文档。
在 Web 应用程序中,客户端使用 JavaScript 框架或 Blazor 来实现复杂的用户交互,但大多数重要的处理和数据访问仍然在服务器端进行,因为 Web 浏览器对本地系统资源的访问有限。
JavaScript 是弱类型且不是为复杂项目设计的,因此如今大多数 JavaScript 库都使用 TypeScript,它为 JavaScript 添加了强类型,并设计了许多现代语言特性来处理复杂实现。
.NET SDK 为基于 JavaScript 和 TypeScript 的 SPA 提供了项目模板,但我们在本书中不会花费时间学习如何构建基于 JavaScript 和 TypeScript 的 SPA。尽管这些 SPA 通常与 ASP.NET Core 作为后端一起使用,但本书的重点是 C#而不是其他语言。
总结来说,C#和.NET 可以在服务器端和客户端上使用来构建网站,如图12.2所示:
图 12.2:使用 C# 和 .NET 在服务器端和客户端构建网站
构建网页和其他服务
尽管我们不会学习基于 JavaScript 和 TypeScript 的 SPA,但我们将学习如何使用 ASP.NET Core Minimal API 构建一个网络服务,然后从 Blazor 组件中调用该网络服务。
尽管没有正式的定义,但服务有时会根据其复杂性来描述:
-
服务: 一个统一的服务中包含客户端应用所需的所有功能。
-
微服务: 多个服务,每个服务专注于更小的功能集。
-
纳米服务: 作为服务提供的一个单一功能。与 24/7/365 运行的服务和微服务不同,纳米服务通常在需要时才激活,以减少资源和成本。
在本书第一部分的开头,我们简要回顾了 C# 语言特性及其引入的版本。在本书第二部分的开头,我们简要回顾了 .NET 库特性及其引入的版本。现在,在本书的第三和最后一部分,我们将简要回顾 ASP.NET Core 特性及其引入的版本。
您可以在以下链接的 GitHub 仓库中阅读此信息:github.com/markjprice/cs13net9/blob/main/docs/ch12-features.md。
为了总结 ASP.NET Core 9 的新特性,让我们以 Dan Roth 的另一段话结束本节:
“我们正在优化 WebAssembly 上的 .NET 运行时初始化方式,以便您启动更快;我们通过利用源生成进行 JSON 序列化来提高 Blazor 初始化逻辑的效率。我们还优化了处理所有 ASP.NET Core 应用中的静态网页资源的方式,以便您的文件在发布应用时作为预压缩文件。对于 API 开发者,我们提供了内置的 OpenAPI 文档生成支持。”
– Dan Roth
构建桌面和移动应用
由于本书是关于使用 C# 和 .NET 进行现代跨平台开发的,因此它不包括使用 Windows Forms、Windows Presentation Foundation (WPF) 或 WinUI 3 应用构建桌面应用的内容,因为它们仅适用于 Windows。
如果您需要为 Windows 构建应用,以下链接将很有帮助:
-
开始为 Windows 构建应用的官方文档:
learn.microsoft.com/en-us/windows/apps/get-started/ -
WPF 是否已死?:
avaloniaui.net/Blog/is-wpf-dead -
2024 年 WPF 相比 WinUI 和 MAUI 的流行程度如何?:
twitter.com/DrAndrewBT/status/1759557538805108860 -
在 64 位世界中的 WinForms – 我们未来的策略:
devblogs.microsoft.com/dotnet/winforms-designer-64-bit-path-forward/
移动应用程序平台
有两个主要的移动平台,苹果的 iOS 和谷歌的 Android,每个平台都有自己的编程语言和平台 API。还有两个主要的桌面平台,苹果的 macOS 和微软的 Windows,每个平台都有自己的编程语言和平台 API,如下表所示:
-
iOS:Objective C 或 Swift 和 UIKit
-
Android:Java 或 Kotlin 和 Android API
-
macOS:Objective C 或 Swift 和 AppKit 或 Catalyst
-
Windows:C、C++ 或许多其他语言,以及 Win32 API 或 Windows App SDK
由于学习进行原生移动开发有许多组合,如果有一个单一的技术可以针对所有这些移动平台,那将非常有用。
.NET MAUI
可以为 .NET Multi-platform App User Interfaces (MAUI) 平台一次性构建跨平台移动和桌面应用程序,然后它们可以在许多移动和桌面平台上运行。
.NET MAUI 通过共享用户界面组件以及业务逻辑,使开发这些应用程序变得容易。它们可以针对与控制台应用程序、网站和 Web 服务相同的 .NET API。这些应用程序将在移动设备上的 Mono 运行时和桌面设备上的 CoreCLR 运行时上执行。与正常的 .NET CoreCLR 运行时相比,Mono 运行时在移动设备上进行了更好的优化。Blazor WebAssembly 也使用 Mono 运行时,因为它像移动应用程序一样,资源受限。
这些应用程序可以独立存在,但它们通常调用服务以提供跨越所有计算设备(从服务器和笔记本电脑到手机和游戏系统)的体验。
我在我的配套书籍《使用 .NET 8 开发应用程序和服务》中介绍了 .NET MAUI,Packt 还有许多其他深入探讨 .NET MAUI 的书籍,所以如果你对学习 MAUI 严肃认真,请查看以下 Packt 书籍:
-
.NET MAUI 跨平台应用程序开发:
www.packtpub.com/en-us/product/net-maui-cross-platform-application-development-9781835080597 -
《.NET MAUI 中的 MVVM 模式》:
www.packtpub.com/en-us/product/the-mvvm-pattern-in-net-maui-9781805125006 -
.NET MAUI 项目:
www.packtpub.com/en-us/product/net-maui-projects-9781837634910
在微软创建 .NET MAUI 之前,第三方创建了开源项目,以使 .NET 开发者能够使用 XAML 构建跨平台应用程序,这些项目被称为 Uno 和 Avalonia。
警告! 我自己没有尝试过任何真实世界的项目使用 Uno 或 Avalonia,因此我无法为它们中的任何一个提供基于证据的建议。我在这本书中提到它们只是为了让你了解它们。
Uno 平台
如其在网站platform.uno/上所述,Uno 是一个*“快速构建单一代码库原生移动、Web、桌面和嵌入式应用程序的开源平台”*。
开发者可以在原生移动、Web 和桌面应用程序之间重用 99%的业务逻辑和 UI 层。
Uno 平台使用 Xamarin 原生平台,但不使用 Xamarin.Forms。对于 WebAssembly,Uno 使用 Mono-WASM 运行时。对于 Linux,Uno 使用 Skia 在画布上绘制用户界面。
Avalonia
如其在网站avaloniaui.net/上所述,Avalonia 是一个*“从单个.NET 代码库构建美丽、跨平台应用程序的开源框架”*。
你可以将 Avalonia 视为 WPF 的精神继承者。熟悉 WPF 的 WPF、Silverlight 和 UWP 开发者可以继续从他们多年的现有知识和技能中受益。
它被 JetBrains 用来现代化他们的基于 WPF 的工具,并使它们跨平台。
Avalonia 的 Visual Studio 扩展和与 Rider 的深度集成使开发更加容易和高效。
构建项目
你应该如何构建你的项目?到目前为止,我们主要构建了小型个体控制台应用程序来展示语言或库功能,偶尔会有类库和单元测试项目来支持它们。在这本书的其余部分,我们将使用不同的技术构建多个项目,这些技术协同工作以提供单一解决方案。
对于大型、复杂的项目,导航所有代码可能很困难。因此,构建项目的首要原因是为了更容易地找到组件。有一个反映应用程序或解决方案的解决方案整体名称是好的。
我们将为一家名为Northwind的虚构公司构建多个项目。我们将解决方案命名为ModernWeb,并将Northwind用作所有项目名称的前缀。
有许多方法可以构建和命名项目和解决方案,例如,使用文件夹层次结构和命名约定。如果你在一个团队中工作,确保你知道你的团队是如何做的。
在解决方案中构建项目结构
在解决方案中为你的项目有一个命名约定是好的,这样任何开发者都可以立即知道每个项目做什么。一个常见的做法是使用项目类型,例如,类库、控制台应用程序、网站等。
由于你可能需要同时运行多个网络项目,并且它们将托管在本地网络服务器上,我们需要通过为它们的端点分配不同的端口号来区分每个项目,无论是 HTTP 还是 HTTPS。
常用的本地端口号码为 HTTP 的 5000 和 HTTPS 的 5001。我们将使用 5<chapter>0 作为 HTTP 的编号约定,5<chapter>1 作为 HTTPS 的编号约定。例如,对于我们在 第十三章 中创建的网站项目,我们将分配 5130 给 HTTP,5131 给 HTTPS。
因此,我们将使用以下项目名称和端口号,如表 12.2 所示:
| 名称 | 端口 | 描述 |
|---|---|---|
Northwind.Common | N/A | 用于跨多个项目的常见类型(如接口、枚举、类、记录和结构体)的类库项目。 |
Northwind.EntityModels | N/A | 用于常见 EF Core 实体模型的类库项目。实体模型通常在服务器端和客户端都使用,因此最好将特定数据库提供程序的依赖项分开。 |
Northwind.DataContext | N/A | 用于 EF Core 数据库上下文的类库项目,具有对特定数据库提供程序的依赖。 |
Northwind.UnitTests | N/A | 用于解决方案的 xUnit 测试项目。 |
Northwind.Web | http 5130 和 https 5131 | 一个用于简单网站(混合使用静态 HTML 文件和 Blazor 静态 服务器端渲染(SSR))的 ASP.NET Core 项目。 |
Northwind.Blazor | http 5140 和 https 5141 | 一个 ASP.NET Core Blazor 项目。 |
Northwind.WebApi | http 5150 和 https 5151 | 一个用于 Web API(即 HTTP 服务)的 ASP.NET Core 项目。它是与网站集成的良好选择,因为它可以使用任何 JavaScript 库或 Blazor 与服务交互。 |
表 12.2:各种项目类型的示例项目名称
中央包管理
在本书的所有先前项目中,如果我们需要引用 NuGet 包,我们直接在项目文件中包含对包名称和版本的引用。
中央包管理(CPM)是一个简化解决方案内多个项目之间 NuGet 包版本管理的功能。这对于包含许多项目的大型解决方案尤其有用,在这些解决方案中,单独管理包版本可能会变得繁琐且容易出错。
CPM 的关键功能和优势包括:
-
集中控制:CPM 允许您在单个文件中定义包版本,通常是
Directory.Packages.props文件,该文件位于您解决方案的根目录中。此文件集中了您解决方案中所有项目使用的所有 NuGet 包的版本信息。 -
一致性:它确保多个项目之间包版本的一致性。通过拥有包版本的单一真实来源,CPM 消除了不同项目指定相同包的不同版本时可能出现的差异。
-
简化更新:在大型解决方案中更新包版本变得简单直接。您只需在中央文件中更新版本,所有引用该包的项目将自动使用更新后的版本。这显著降低了维护开销。
-
减少冗余:它消除了在单个项目文件(
.csproj)中指定包版本的需求。这使得项目文件更干净,更容易管理,因为它们不再包含重复的版本信息。
让我们为本书其余章节中将要使用的一个解决方案设置 CPM:
-
在
cs13net9文件夹中,创建一个名为ModernWeb的新文件夹。 -
在
ModernWeb文件夹中,创建一个名为Directory.Packages.props的新文件。 -
在
Directory.Packages.props中,修改其内容,如下面的标记所示:<Project> <PropertyGroup> <ManagePackageVersionsCentrally>true</Man agePackageVersionsCentrally> </PropertyGroup> <ItemGroup Label="For EF Core 9." > <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0" /> </ItemGroup> <ItemGroup Label="For unit testing."> <PackageVersion Include="coverlet.collector" Version="6.0.2" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> <PackageVersion Include="xunit" Version="2.9.0" /> <PackageVersion Include="xunit.runner.visualstudio" Version="3.0.0" /> </ItemGroup> <ItemGroup Label="For Blazor."> <PackageVersion Include= "Microsoft.AspNetCore.Components.WebAssembly.Server" Version="9.0.0" /> <PackageVersion Include= "Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.0" /> <PackageVersion Include= "Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.0" /> </ItemGroup> <ItemGroup Label="For web services."> <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" /> <PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="9.0.0" /> </ItemGroup> </Project>
警告! <ManagePackageVersionsCentrally> 元素及其 true 值必须全部位于一行上。此外,您不能使用在单个项目中可以使用的浮点通配符版本号,如 10.0-*,以在预览期间自动获取 EF Core 10 的最新补丁版本。
对于我们添加到包含此文件的文件夹下的任何项目,我们可以引用这些包,而无需明确指定版本,如下面的标记所示:
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
</ItemGroup>
您应该定期审查和更新 Directory.Packages.props 文件中的包版本,以确保您使用的是最新的稳定版本,其中包含重要的错误修复和性能改进。
我建议你在日历中为每个月的第二个星期三设置一个事件。这将在每个月第二个星期二之后发生,即补丁星期二,届时微软会发布 .NET 和相关包的错误修复和补丁。
例如,在 2024 年中旬,可能会有新版本,因此你可以访问所有包的 NuGet 页面,并在必要时更新版本,如下面的标记所示:
<ItemGroup>
<PackageVersion
Include="Microsoft.EntityFrameworkCore.Sqlite"
Version="9.0.1" />
<PackageVersion
Include="Microsoft.EntityFrameworkCore.Design"
Version="9.0.1" />
</ItemGroup>
在更新包版本之前,检查包的发布说明中是否有任何破坏性更改。更新后彻底测试您的解决方案以确保兼容性。
教育您的团队,并记录 Directory.Packages.props 文件的目的和用法,以确保每个人都了解如何集中管理包版本。
您可以通过在 <PackageReference /> 元素上使用 VersionOverride 属性来覆盖单个包版本,如下面的标记所示:
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design"
VersionOverride="9.0.0" />
</ItemGroup>
这在引入回归错误的新版本中可能很有用。
更多信息:您可以在以下链接中了解更多关于 CPM 的信息:learn.microsoft.com/en-us/nuget/consume-packages/central-package-management。
为本书其余部分构建实体模型
网站和 Web 服务通常需要与关系数据库或其他数据存储中的数据进行交互。在本节中,我们将为存储在 SQL Server 或 SQLite 中的 Northwind 数据库定义一个实体数据模型。它将用于我们在后续章节中创建的大多数应用程序。
创建 Northwind 数据库
创建 Northwind 数据库用于 SQLite 和 SQL Server 的脚本文件不同。SQL Server 的脚本创建 13 个表以及相关的视图和存储过程。SQLite 的脚本是一个简化版本,仅创建 10 个表,因为 SQLite 不支持那么多功能。本书中的主要项目只需要这 10 个表,因此你可以使用任一数据库完成本书中的所有任务。
SQL 脚本可以在以下链接找到:github.com/markjprice/cs13net9/tree/main/scripts/sql-scripts。
如下列表所述,有多种 SQL 脚本可供选择:
-
Northwind4Sqlite.sql脚本:在本地 Windows、macOS 或 Linux 计算机上使用 SQLite。这个脚本可能也可以用于其他 SQL 系统,如 PostgreSQL 或 MySQL,但尚未测试过这些系统的使用! -
Northwind4SqlServer.sql脚本:在本地 Windows 计算机上使用 SQL Server。该脚本检查 Northwind 数据库是否已存在,如果数据库已存在,则在重新创建之前将其删除(即删除)。 -
Northwind4AzureSqlDatabaseCloud.sql脚本:在 Azure 云中创建的 Azure SQL 数据库资源上使用 SQL Server。只要这些资源存在,就会产生费用!该脚本不会删除或创建 Northwind 数据库,因为您应该使用 Azure 门户用户界面手动创建 Northwind 数据库。 -
Northwind4AzureSqlEdgeDocker.sql脚本:在 Docker 中的本地计算机上使用 SQL Server。该脚本创建 Northwind 数据库。如果数据库已存在,则不会删除它,因为 Docker 容器应该始终为空,因为每次都会启动一个新的容器。
在 第十章,使用 Entity Framework Core 处理数据 中可以找到安装 SQLite 的说明。在该章中,你还可以找到安装 dotnet-ef 工具的说明,你将使用它从现有数据库生成实体模型。
在本地 Windows 计算机上安装 SQL Server Developer Edition(免费版)的说明可以在本书的 GitHub 仓库中找到,链接如下:github.com/markjprice/cs13net9/blob/main/docs/sql-server/README.md。
在 Docker for Windows、macOS 或 Linux 上设置 Azure SQL Edge 的说明可以在本书的 GitHub 仓库中找到,链接如下:github.com/markjprice/cs13net9/blob/main/docs/sql-server/sql-edge.md。
使用 SQLite 创建实体模型类库
你现在将在类库中定义实体数据模型,以便它们可以在其他类型的项目中重用,包括客户端应用程序模型。
良好实践:您应该为您的实体数据模型从数据上下文类库中创建一个单独的类库项目。这允许在后台 Web 服务器和前端桌面、移动和 Blazor 客户端之间更容易地共享实体模型,并且只有后台需要引用数据上下文类库。
我们将使用 EF Core 命令行工具自动生成一些实体模型:
-
使用您首选的代码编辑器创建一个新的项目和解决方案,如下面的列表所示:
-
项目模板:类库 /
classlib -
项目文件和文件夹:
Northwind.EntityModels.Sqlite -
解决方案文件和文件夹:
ModernWeb
-
-
在
Northwind.EntityModels.Sqlite项目中,添加对 SQLite 数据库提供程序和 EF Core 设计时支持的包引用,如下面的标记所示:<ItemGroup> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> </ItemGroup> -
删除
Class1.cs文件。 -
构建项目
Northwind.EntityModels.Sqlite以恢复包。 -
将
Northwind4Sqlite.sql文件复制到ModernWeb解决方案文件夹中(不是项目文件夹!)。 -
在
ModernWeb文件夹中的命令提示符或终端中,输入一个命令来创建 SQLite 的Northwind.db文件,如下面的命令所示:sqlite3 Northwind.db -init Northwind4SQLite.sql
请耐心等待,因为这个命令可能需要一段时间来创建数据库结构。
-
要退出 SQLite 命令模式,在 Windows 上请按两次Ctrl + C,在 macOS 或 Linux 上请按Cmd + D。
-
在
ModernWeb文件夹中的命令提示符或终端中,输入一个命令来列出当前目录中的文件,如下面的命令所示:dir -
您应该看到已创建一个名为
Northwind.db的新文件,如下面的输出所示:Directory: C:\cs13net9\ModernWeb Length Name ------ ---- Northwind.EntityModels.Sqlite 382 Directory.Packages.props 1193 ModernWeb.sln 557056 Northwind.db 480790 Northwind4SQLite.sql -
切换到项目文件夹:
cd Northwind.EntityModels.Sqlite -
在
Northwind.EntityModels.Sqlite项目文件夹中(包含.csproj项目文件的文件夹),为所有表生成实体类模型,如下面的命令所示:dotnet ef dbcontext scaffold "Data Source=../Northwind.db" Microsoft.EntityFrameworkCore.Sqlite --namespace Northwind.EntityModels --data-annotations
注意以下事项:
-
执行的命令:
dbcontext scaffold -
连接字符串指的是解决方案文件夹中的数据库文件,位于当前项目文件夹的上一个文件夹中:“数据源=…/Northwind.db”
-
数据库提供程序:
Microsoft.EntityFrameworkCore.Sqlite -
命名空间:
--namespace Northwind.EntityModels -
要使用数据注释以及 Fluent API:
--data-annotations警告!
dotnet-ef命令必须在一行中输入,并且在一个包含项目的文件夹中;否则,您将看到以下错误:"未找到项目。更改当前工作目录或使用–project 选项。"请记住,所有命令行都可以在以下链接中找到并复制:github.com/markjprice/cs13net9/blob/main/docs/command-lines.md。如果您使用 SQLite,您将看到有关实体类模型中的表列和属性之间不兼容类型映射的警告。例如,
Employees表上的列 ‘BirthDate’ 应映射到类型为 ‘DateOnly’ 的属性,但其值处于不兼容的格式。使用不同的类型`。这是由于 SQLite 使用动态类型。我们将在下一节中修复这些问题。
使用 SQLite 创建数据库上下文类库
您现在将定义一个数据库上下文类库:
-
根据以下列表添加一个新的项目到解决方案中:
-
项目模板:类库 /
classlib -
项目文件和文件夹:
Northwind.DataContext.Sqlite -
解决方案文件和文件夹:
ModernWeb
-
-
在
Northwind.DataContext.Sqlite项目中,静态和全局导入Console类,添加对 SQLite EF Core 数据提供程序的包引用,并添加对Northwind.EntityModels.Sqlite项目的项目引用,如下所示:<ItemGroup Label="To simplify use of WriteLine."> <Using Include="System.Console" Static="true" /> </ItemGroup> <ItemGroup Label="Versions are set at solution-level."> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" /> </ItemGroup> <ItemGroup> <ProjectReference Include= "..\Northwind.EntityModels.Sqlite \Northwind.EntityModels.Sqlite.csproj" /> </ItemGroup>
**警告!**项目引用的路径在项目文件中不应有换行符。
-
在
Northwind.DataContext.Sqlite项目中,删除Class1.cs文件。 -
构建北
wind.DataContext.Sqlite项目以还原包。 -
在
Northwind.DataContext.Sqlite项目中,添加一个名为NorthwindContextLogger.cs的类。 -
修改其内容以定义一个名为
WriteLine的静态方法,该方法将字符串追加到桌面上的book-logs文件夹中名为northwindlog-<date_time>.txt的文本文件的末尾,如下所示:using static System.Environment; namespace Northwind.EntityModels; public class NorthwindContextLogger { public static void WriteLine(string message) { string folder = Path.Combine(GetFolderPath( SpecialFolder.DesktopDirectory), "book-logs"); if (!Directory.Exists(folder)) Directory.CreateDirectory(folder); string dateTimeStamp = DateTime.Now.ToString( "yyyyMMdd_HHmmss"); string path = Path.Combine(folder, $"northwindlog-{dateTimeStamp}.txt"); StreamWriter textFile = File.AppendText(path); textFile.WriteLine(message); textFile.Close(); } } -
将
NorthwindContext.cs文件从Northwind.EntityModels.Sqlite项目/文件夹移动到Northwind.DataContext.Sqlite项目/文件夹。
在 Visual Studio 解决方案资源管理器中,如果您在项目之间拖放文件,它将被复制。如果您在拖放时按住 Shift 键,它将被移动。在 VS Code 资源管理器中,如果您在项目之间拖放文件,它将被移动。如果您在拖放时按住 Ctrl 键,它将被复制。
-
在
NorthwindContext.cs文件中,注意第二个构造函数可以接受options作为参数,这允许我们在任何项目中覆盖默认的数据库连接字符串,例如需要与 Northwind 数据库一起工作的网站,如下所示:public NorthwindContext(DbContextOptions<NorthwindContext> options) : base(options) { } -
在
NorthwindContext.cs文件中的OnConfiguring方法中,移除关于连接字符串的编译器 #warning,然后添加语句以检查当前目录的末尾,以便在 Visual Studio 中运行时与使用 VS Code 的命令提示符相比,如下所示:protected override void OnConfiguring( DbContextOptionsBuilder optionsBuilder) { if (!optionsBuilder.IsConfigured) { string database = "Northwind.db"; string dir = Environment.CurrentDirectory; string path = string.Empty; if (dir.EndsWith("net9.0")) { // In the <project>\bin\<Debug|Release>\net9.0 directory. path = Path.Combine("..", "..", "..", "..", database); } else { // In the <project> directory. path = Path.Combine("..", database); } path = Path.GetFullPath(path); // Convert to absolute path. try { NorthwindContextLogger.WriteLine($"Database path: {path}"); } catch (Exception ex) { WriteLine(ex.Message); } if (!File.Exists(path)) { throw new FileNotFoundException( message: $"{path} not found.", fileName: path); } optionsBuilder.UseSqlite($"Data Source={path}"); optionsBuilder.LogTo(NorthwindContextLogger.WriteLine, new[] { Microsoft.EntityFrameworkCore .Diagnostics.RelationalEventId.CommandExecuting }); } }
抛出异常是很重要的,因为如果数据库文件缺失,那么 SQLite 数据库提供者将创建一个空数据库文件,因此如果你测试连接到它,它将正常工作。但是如果你查询它,你将看到与缺失表相关的异常,因为它没有任何表!在将相对路径转换为绝对路径后,你可以在调试时设置断点,以便更容易地看到数据库文件预期所在的位置,或者添加一条记录该路径的语句。
自定义模型和定义扩展方法
现在,我们将简化 OnModelCreating 方法。我将简要解释各个步骤,然后展示完整的最终方法。你可以尝试执行各个步骤,或者直接使用最终方法代码:
-
在
OnModelCreating方法中,删除所有调用ValueGeneratedNever方法的 Fluent API 语句,如下代码所示。这将配置主键属性,如CategoryId,以从不自动生成值:modelBuilder.Entity<Category>(entity => { entity.Property(e => e. CategoryId).ValueGeneratedNever(); });
如果我们不删除上述类似配置的语句,那么当我们添加新的供应商时,CategoryId 的值将始终是 0,我们只能添加一个具有该值的供应商;所有其他尝试都将抛出异常。你可以将你的 NorthwindContext.cs 与以下链接中的 GitHub 仓库中的版本进行比较:github.com/markjprice/cs13net9/blob/main/code/ModernWeb/Northwind.DataContext.Sqlite/NorthwindContext.cs。
-
在
OnModelCreating方法中,对于Product实体,告诉 SQLite 将UnitPrice从decimal转换为double,如下代码所示:entity.Property(product => product.UnitPrice) .HasConversion<double>(); -
在
OnModelCreating方法中,对于Order实体,将十进制值0.0M传递给HasDefaultValue方法,如下代码所示:modelBuilder.Entity<Order>(entity => { entity.Property(e => e.Freight).HasDefaultValue(0.0M); }); -
在
OnModelCreating方法中,对于Product实体,将十进制值0.0M传递给HasDefaultValue方法,如下代码所示:modelBuilder.Entity<Product>(entity => { ... entity.Property(e => e.UnitPrice).HasDefaultValue(0.0M); -
如下代码所示,
OnModelCreating方法现在应该更简单:protected override void OnModelCreating( ModelBuilder modelBuilder) { modelBuilder.Entity<Order>(entity => { entity.Property(e => e.Freight).HasDefaultValue(0.0M); }); modelBuilder.Entity<OrderDetail>(entity => { entity.Property(e => e.Quantity).HasDefaultValue((short)1); entity.HasOne(d => d.Order) .WithMany(p => p.OrderDetails) .OnDelete(DeleteBehavior.ClientSetNull); entity.HasOne(d => d.Product) .WithMany(p => p.OrderDetails) .OnDelete(DeleteBehavior.ClientSetNull); }); modelBuilder.Entity<Product>(entity => { entity.Property(e => e.Discontinued) .HasDefaultValue((short)0); entity.Property(e => e.ReorderLevel) .HasDefaultValue((short)0); entity.Property(e => e.UnitPrice) .HasDefaultValue(0.0M); entity.Property(e => e.UnitsInStock) .HasDefaultValue((short)0); entity.Property(e => e.UnitsOnOrder) .HasDefaultValue((short)0); entity.Property(product => product.UnitPrice) .HasConversion<double>(); }); OnModelCreatingPartial(modelBuilder); } -
在
Northwind.DataContext.Sqlite项目中,添加一个名为NorthwindContextExtensions.cs的类。修改其内容以定义一个扩展方法,将 Northwind 数据库上下文添加到依赖服务集合中,如下代码所示:using Microsoft.EntityFrameworkCore; // To use UseSqlite. using Microsoft.Extensions.DependencyInjection; // To use IServiceCollection. namespace Northwind.EntityModels; public static class NorthwindContextExtensions { /// <summary> /// Adds NorthwindContext to the specified IServiceCollection. Uses the Sqlite database provider. /// </summary> /// <param name="services">The service collection.</param> /// <param name="relativePath">Default is ".."</param> /// <param name="databaseName">Default is "Northwind.db"</param> /// <returns>An IServiceCollection that can be used to add more services.</returns> public static IServiceCollection AddNorthwindContext( this IServiceCollection services, // The type to extend. string relativePath = "..", string databaseName = "Northwind.db") { string path = Path.Combine(relativePath, databaseName); path = Path.GetFullPath(path); NorthwindContextLogger.WriteLine($"Database path: {path}"); if (!File.Exists(path)) { throw new FileNotFoundException( message: $"{path} not found.", fileName: path); } services.AddDbContext<NorthwindContext>(options => { // Data Source is the modern equivalent of Filename. options.UseSqlite($"Data Source={path}"); options.LogTo(NorthwindContextLogger.WriteLine, new[] { Microsoft.EntityFrameworkCore .Diagnostics.RelationalEventId.CommandExecuting }); }, // Register with a transient lifetime to avoid concurrency // issues in Blazor server-side projects. contextLifetime: ServiceLifetime.Transient, optionsLifetime: ServiceLifetime.Transient); return services; } } -
构建这两个类库,并修复任何编译器错误。
HasDefaultValue 和 HasDefaultValueSql
这两个方法的区别是什么?
当你需要一个作为列默认值的常量、静态值,并且该值不依赖于任何条件或不需要在插入时动态计算时,你应该使用 HasDefaultValue()。此常量值在模型级别设置,并由 EF Core 在没有提供其他值时用于向数据库插入。
对于前面示例的等效操作,你会使用entity.Property(e => e.Freight).HasDefaultValue(0M);,因为0M使用了十进制后缀M。将其视为在客户端设置默认值。
当默认值应该在插入时由数据库计算,特别是如果它涉及到数据库应该评估的 SQL 函数或动态数据时,你应该使用HasDefaultValueSql()。默认值是一个字符串"0",因为它将被连接到 SQL 语句中,如下面的代码所示:
`CREATE TABLE "Orders" ( ... "Freight" "money" NULL CONSTRAINT "DF_Orders_Freight" DEFAULT (0), ... );`
将其视为配置数据库以在服务器端设置默认值。
EF Core 8 及更早版本的 SQLite 数据库反向工程使用HasDefaultValueSql。EF Core 9 数据库反向工程使用HasDefaultValue。
注册依赖服务的作用域
默认情况下,使用Scope生命周期注册DbContext类,这意味着多个线程可以共享同一个实例。但是DbContext不支持多线程。如果有多个线程同时尝试使用同一个NorthwindContext类实例,那么你将看到以下运行时异常被抛出:在完成之前的操作之前,在此上下文中启动了第二个操作。这通常是由不同的线程使用同一个 DbContext 实例引起的,然而实例成员不一定保证是线程安全的。
这种情况发生在 Blazor 项目中,当组件被设置为在服务器端运行时,因为每当客户端发生交互时,都会向服务器发起一个 SignalR 调用,在服务器端,多个客户端之间共享单个数据库上下文实例。如果组件被设置为在客户端运行,则不会出现此问题。
使用 SQL Server 创建实体模型的类库
如果你想使用 SQL Server 而不是 SQLite,那么以下链接中有相应的说明:
改进类到表的映射
dotnet-ef命令行工具为 SQL Server 和 SQLite 生成不同的代码,因为它们支持不同的功能级别,并且 SQLite 使用动态类型。例如,在 EF Core 7 中,SQLite 中的所有整数列都被映射为可空的long属性,以实现最大的灵活性。
使用 EF Core 8 及更高版本时,会检查实际存储的值,如果它们都可以存储在int中,那么 EF Core 8 及更高版本会将映射属性声明为int。如果存储的值都可以存储在short中,那么 EF Core 8 及更高版本会将映射属性声明为short。
在这一版中,我们需要做更少的工作来改进映射。太好了!
作为另一个例子,SQL Server 的文本列可以限制字符数。SQLite 不支持这一点。因此,dotnet-ef将为 SQL Server 生成验证属性以确保string属性限制在指定的字符数内,但不适用于 SQLite,如下面的代码所示:
// SQLite database provider-generated code.
[Column(TypeName = "nvarchar (15)")]
public string CategoryName { get; set; } = null!;
// SQL Server database provider-generated code.
[StringLength(15)]
public string CategoryName { get; set; } = null!;
我们将对 SQLite 的实体模型映射和验证规则进行一些小的修改。SQL Server 的类似修改可在在线说明中找到。
请记住,所有代码都可在本书的 GitHub 仓库中找到。虽然您通过亲自输入代码会学到更多,但您不必这样做。访问以下链接并按*.*以在浏览器中获得实时代码编辑器:github.com/markjprice/cs13net9。
首先,我们将添加一个正则表达式来验证CustomerId值正好是五个大写字母。其次,我们将添加字符串长度要求来验证实体模型中的多个属性知道其文本值允许的最大长度:
-
激活您的代码编辑器的查找和替换功能:
- 在 Visual Studio 中,导航到编辑 | 查找和替换 | 快速替换,然后切换使用正则表达式。
-
在查找框中输入正则表达式,如图 12.3和以下表达式所示:
\[Column\(TypeName = "(nchar|nvarchar) \((.*)\)"\)\] -
在替换框中,输入替换正则表达式,如下面的表达式所示:
$0\n [StringLength($2)]
在换行符\n之后,我包含了四个空格字符,以便在我的系统中正确缩进,每级缩进使用两个空格字符。您可以插入任意多个。
-
将查找和替换设置为在当前项目中搜索文件。
-
执行查找和替换以替换所有文件,如图 12.3所示:
图 12.3:在 Visual Studio 中使用正则表达式查找和替换所有匹配项
-
将任何日期/时间列,例如在
Employee.cs中,更改为使用可空的DateTime而不是字符串,如下面的代码所示:// Before: [Column(TypeName = "datetime")] public string? BirthDate { get; set; } // After: [Column(TypeName = "datetime")] public DateTime? BirthDate { get; set; }使用您的代码编辑器的查找功能搜索
"datetime"以查找所有需要更改的属性。在Employee.cs中应有两个,在Order.cs中应有三个。 -
将任何
money列,例如在Order.cs中,更改为使用可空的decimal而不是double,如下面的代码所示:// Before: [Column(TypeName = "money")] public double? Freight { get; set; } // After: [Column(TypeName = "money")] public decimal? Freight { get; set; }
使用您的代码编辑器的查找功能搜索"money"以查找所有需要更改的属性。在Order.cs中应有一个,在Orderdetail.cs中应有一个,在Product.cs中应有一个。
-
在
Category.cs中,使CategoryName属性成为必填项,如下所示,代码中已高亮显示:**[****Required****]** [Column(TypeName = "nvarchar (15)")] [StringLength(15)] public string CategoryName { get; set; } -
在
Customer.cs中,添加一个正则表达式来验证其主键CustomerId,只允许大写西文字符,并使CompanyName属性成为必填项,如下所示,代码中已高亮显示:[Key] [Column(TypeName = "nchar (5)")] [StringLength(5)] **[****RegularExpression(****"[A-Z]{5}"****)****]** public string CustomerId { get; set; } = null!; **[****Required****]** [Column(TypeName = "nvarchar (40)")] [StringLength(40)] public string CompanyName { get; set; } -
在
Order.cs中,用正则表达式装饰CustomerId属性以强制五个大写字母。 -
在
Employee.cs中,将FirstName和LastName属性设置为必需。 -
在
Product.cs中,将ProductName属性设置为必需。 -
在
Shipper.cs中,将CompanyName属性设置为必需。 -
在
Supplier.cs中,将CompanyName属性设置为必需。
测试类库
现在,让我们构建一些单元测试以确保类库正常工作。
**警告!**如果你使用 SQLite 数据库提供程序,那么当你使用错误或缺失的数据库文件调用CanConnect方法时,提供程序会创建一个 0 字节的Northwind.db!这就是为什么在我们的NorthwindContext类中,我们明确检查数据库文件是否存在,并在不存在时抛出异常,以防止这种行为。
让我们编写测试:
-
使用你喜欢的编码工具将新的xUnit 测试项目 [C#] /
xunit项目,命名为Northwind.UnitTests,添加到ModernWeb解决方案中。 -
在
Northwind.UnitTests项目中,为 SQLite 或 SQL Server 添加对Northwind.DataContext项目的项目引用,如下面配置中突出显示:<ItemGroup> **<!-- change Sqlite to SqlServer** **if** **you prefer -->** **<ProjectReference Include=****"..\Northwind.DataContext** **.Sqlite\Northwind.DataContext.Sqlite.csproj"** **/>** </ItemGroup>
**警告!**项目引用必须全部在一行中,不能有换行符。
-
如有必要,删除项目文件中指定的测试包版本号。(如果你有应该使用 CPM 的项目但指定了它们自己的包版本,而没有使用
VersionOverride属性,Visual Studio 和其他代码编辑器将给出错误。) -
构建项目
Northwind.UnitTests以构建引用的项目。 -
将
UnitTest1.cs重命名为EntityModelTests.cs。 -
修改文件内容以定义两个测试,第一个测试连接到数据库,第二个测试确认数据库中有八个类别,如下面的代码所示:
using Northwind.EntityModels; // To use NorthwindContext. namespace Northwind.UnitTests { public class EntityModelTests { [Fact] public void DatabaseConnectTest() { using NorthwindContext db = new(); Assert.True(db.Database.CanConnect()); } [Fact] public void CategoryCountTest() { using NorthwindContext db = new(); int expected = 8; int actual = db.Categories.Count(); Assert.Equal(expected, actual); } [Fact] public void ProductId1IsChaiTest() { using NorthwindContext db = new(); string expected = "Chai"; Product? product = db.Products.Find(keyValues: 1); string actual = product?.ProductName ?? string.Empty; Assert.Equal(expected, actual); } } } -
运行单元测试:
-
如果你使用 Visual Studio,请导航到测试 | 运行所有测试,然后在测试资源管理器中查看结果。
-
如果你使用 VS Code,在
Northwind.UnitTests项目的终端窗口中,使用以下命令运行测试:dotnet test。或者,如果你已安装 C#开发工具包,可以使用测试窗口。
-
-
注意,结果应显示三个测试已运行且全部通过,如图12.4所示:
图 12.4:运行了三个成功的单元测试
如果任何测试失败,请修复问题。例如,如果你使用 SQLite,那么请检查Northwind.db文件是否位于解决方案目录中(位于项目目录之上)。检查你桌面上的book-logs文件夹中的northwindlog-<date_time>.txt文件中的数据库路径,它应该为三个测试输出三次使用的数据库路径,如下面的日志所示:
Database path: C:\cs13net9\ModernWeb\Northwind.db
Database path: C:\cs13net9\ModernWeb\Northwind.db
dbug: 18/09/2024 14:20:16.712 RelationalEventId.CommandExecuting[20100] (Microsoft.EntityFrameworkCore.Database.Command)
Executing DbCommand [Parameters=[@__p_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
SELECT "p"."ProductId", "p"."CategoryId", "p"."Discontinued", "p"."ProductName", "p"."QuantityPerUnit", "p"."ReorderLevel", "p"."SupplierId", "p"."UnitPrice", "p"."UnitsInStock", "p"."UnitsOnOrder"
FROM "Products" AS "p"
WHERE "p"."ProductId" = @__p_0
LIMIT 1
Database path: C:\cs13net9\ModernWeb\Northwind.db
dbug: 18/09/2024 14:20:16.832 RelationalEventId.CommandExecuting[20100] (Microsoft.EntityFrameworkCore.Database.Command)
Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT COUNT(*)
FROM "Categories" AS "c"
最后,在本章中,让我们回顾一些关于 Web 开发的关键概念,以便我们为下一章深入探讨 ASP.NET Core Razor Pages 做好更好的准备。
理解 Web 开发
为 Web 开发意味着使用 超文本传输协议(HTTP)进行开发,因此我们将从回顾这项重要的基础技术开始。
理解超文本传输协议
为了与 Web 服务器通信,客户端(也称为用户代理)通过网络使用 HTTP 进行调用。因此,HTTP 是 Web 的技术基础。所以当我们谈论网站和 Web 服务时,我们是指它们使用 HTTP 在客户端(通常是 Web 浏览器)和服务器之间进行通信。
客户端向由 URL 唯一标识的资源(如页面)发出 HTTP 请求,服务器随后返回 HTTP 响应,如图 12.5 所示:
图 12.5:HTTP 请求和响应
您可以使用 Google Chrome 和其他浏览器来记录请求和响应。
良好实践:目前全球大约三分之二的网站访客使用的是 Google Chrome,它拥有强大的内置开发者工具,因此它是尝试您的网站时的一个很好的首选。请使用 Chrome 和至少另外两种浏览器来测试您的网站,例如,对于 macOS 和 iPhone,分别是 Firefox 和 Safari。Microsoft Edge 在 2019 年从使用微软自己的渲染引擎切换到使用 Chromium,因此使用它进行测试的重要性较低,尽管有些人认为 Edge 拥有最好的开发者工具。如果使用 Microsoft 的 Internet Explorer,则通常是在组织内部用于内联网。
理解 URL 的组件
URL 由几个组件组成:
-
方案:
http(明文)或https(加密)。 -
域名:对于生产网站或服务,顶级域名(TLD)可能是
example.com。您可能有子域名,如www、jobs或extranet。在开发过程中,您通常使用localhost来表示所有网站和服务。 -
端口号:对于生产网站或服务,使用
80作为http的端口号,以及443作为https的端口号。这些端口号通常从方案中推断出来。在开发过程中,通常使用其他端口号,例如5000、5001等,以区分使用共享域名localhost的网站和服务。 -
路径:到资源的相对路径,例如,
/customers/germany。 -
查询字符串:传递参数值的一种方式,例如,
?country=Germany&searchtext=shoes。 -
片段:使用其
id对网页上的元素进行引用,例如,#toc。
URL 是 统一资源标识符(URI)的一个子集。URL 指定了资源的位置以及如何获取它。URI 通过 URL 或 URN(统一资源名称)来标识资源。
使用 Google Chrome 进行 HTTP 请求
让我们探索如何使用 Google Chrome 来进行 HTTP 请求:
-
启动 Google Chrome。
-
导航到 更多工具 | 开发者工具。
-
点击 网络 选项卡,Chrome 应立即开始记录浏览器与任何 Web 服务器之间的网络流量(注意红色圆圈),如图 图 12.6 所示:
图 12.6:Chrome 开发者工具记录网络流量
- 在 Chrome 的地址框中,输入 Microsoft 学习 ASP.NET 网站的地址,该地址如下:
dotnet.microsoft.com/en-us/learn/aspnet
- 在 开发者工具 中,在记录的请求列表中滚动到顶部并点击第一个条目,即 类型 为 document 的那一行,如图 图 12.7 所示:
图 12.7:开发者工具中记录的请求
- 在右侧,点击 头 选项卡,你将看到 请求头 和 响应头 的详细信息,如图 图 12.8 所示:
图 12.8:请求和响应头
注意以下方面:
-
请求方法是
GET。你在这里可能看到的其他 HTTP 方法包括POST、PUT、DELETE、HEAD和PATCH。 -
状态码 是
200OK。这意味着服务器找到了浏览器请求的资源,并将其返回在响应体中。对GET请求的响应中可能看到的其他状态码包括301 永久移动、400 错误请求、401 未授权和404 未找到。 -
浏览器发送给 Web 服务器的 请求头 包括:
-
accept,列出了浏览器接受的格式。在这种情况下,浏览器表示它理解 HTML、XHTML、XML 和一些图像格式,但它将接受所有其他文件 (*/*)。默认权重,也称为质量值,是1.0。XML 使用质量值0.9指定,因此不如 HTML 或 XHTML 更受欢迎。所有其他文件类型都赋予质量值0.8,因此最不受欢迎。 -
accept-encoding,列出了浏览器理解的压缩算法 – 在这种情况下,GZIP、DEFLATE 和 Brotli。 -
accept-language,列出了它希望内容使用的人类语言 – 在这种情况下,美国英语,其默认质量值为1.0;任何英语方言,其明确指定的质量值为0.9;然后是任何瑞典方言,其明确指定的质量值为0.8。
-
-
响应头 (
content-encoding) 告诉我,服务器已使用gzip算法压缩发送回的 HTML 网页响应,因为它知道客户端可以解压缩该格式。(这在 图 12.8 中不可见,因为空间不足以展开 响应头 部分。)
- 关闭 Chrome。
理解客户端 Web 开发技术
在构建网站时,开发者需要了解的不仅仅是 C#和.NET。在客户端(即在网页浏览器中),你将使用以下技术的组合:
-
HTML5:用于网页的内容和结构。
-
CSS3:用于网页上元素的样式。
-
JavaScript:用于在网页上编写任何需要的业务逻辑,例如验证表单输入或调用网络服务以获取网页所需的数据。
虽然 HTML5、CSS3 和 JavaScript 是前端网站开发的基本组件,但还有许多其他技术可以使前端网站开发更加高效,包括:
-
Bootstrap,世界上最受欢迎的前端开源工具包
-
SASS和LESS,用于样式的 CSS 预处理器
-
用于编写更健壮代码的 Microsoft 的TypeScript语言
-
如Angular、jQuery、React和Vue等 JavaScript 库
所有这些高级技术最终都会转换或编译为底层三个核心技术,因此它们可以在所有现代浏览器中工作。
作为构建和部署过程的一部分,你可能会使用以下技术:
-
Node.js,一个用于服务器端开发的 JavaScript 框架
-
Node Package Manager(npm)和Yarn,都是客户端包管理器
-
webpack,一个流行的模块打包器,也是用于编译、转换和打包网站源文件的工具
练习和探索
通过回答一些问题并深入探讨本章的主题来测试你的知识和理解。
练习 12.1 – 在线材料
在线材料可以是我为这本书写的额外内容,也可以是 Microsoft 或第三方创建的内容的引用。
W3Schools 是学习客户端网站开发的最佳网站之一,链接如下:www.w3schools.com/
可以在以下链接中找到关于 ASP.NET Core 9 的新功能的总结:
learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-9.0
可以在这里找到 Microsoft 官方推荐的如何选择 ASP.NET Core Web UI 的建议:
learn.microsoft.com/en-us/aspnet/core/tutorials/choose-web-ui
可以在这里找到 Microsoft 官方推荐的 ASP.NET Core 最佳实践的指南:
learn.microsoft.com/en-us/aspnet/core/fundamentals/best-practices
练习 12.2 – 实践练习
实践练习将深入探讨本章的主题。
网站开发故障排除
由于网站开发中有许多动态部分,因此出现临时问题是很常见的。有时,经典的“关闭再打开”方法可以解决这些问题!
-
删除项目的
bin和release文件夹。 -
重新启动 Web 服务器以清除其缓存。
-
重新启动计算机。
练习 12.3 – 测试你的知识
回答以下问题:
-
微软的第一个动态服务器端执行网页技术叫什么名字,为什么今天仍然需要了解这段历史?
-
两个 Microsoft 网络服务器的名字是什么?
-
微服务和纳米服务之间有哪些区别?
-
什么是 Blazor?
-
第一个不能在 .NET Framework 上托管的 ASP.NET Core 版本是什么?
-
用户代理是什么?
-
HTTP 请求-响应通信模型对 Web 开发者有什么影响?
-
描述 URL 的四个组成部分。
-
开发者工具提供了哪些功能?
-
三种主要的客户端 Web 开发技术是什么,它们做什么?
了解你的网络缩写
以下网络缩写代表什么,它们做什么?
-
URI
-
URL
-
WCF
-
TLD
-
API
-
SPA
-
CMS
-
Wasm
-
SASS
-
REST
练习 12.4 – 探索主题
使用下一页上的链接了解本章涵盖主题的更多详细信息:
摘要
在本章中,您已经:
-
已介绍了一些可用于使用 C# 和 .NET 构建网站和 Web 服务的应用程序模型
-
创建类库以定义用于与 Northwind 数据库一起工作的实体数据模型,使用 SQLite、SQL Server 或两者兼用
在以下章节中,您将学习如何构建以下内容的详细信息:
-
使用静态 HTML 页面和动态生成的 Blazor 静态 SSR 页面的简单网站
-
可以托管在 Web 服务器上、浏览器中或混合 Web 原生移动和桌面应用程序上的 Blazor 用户界面组件
-
可以由任何可以发出 HTTP 请求的平台调用的 Web 服务,以及调用这些 Web 服务的客户端网站
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本发布——请扫描下面的二维码:
留下评论!
感谢您从 Packt Publishing 购买这本书——我们希望您喜欢它!您的反馈对我们来说是无价的,它帮助我们改进和成长。一旦您阅读完毕,请花一点时间在亚马逊上留下评论;这只需一分钟,但对像您这样的读者来说意义重大。
扫描二维码或访问链接以获得您选择的免费电子书。
2657

被折叠的 条评论
为什么被折叠?



