原文:
zh.annas-archive.org/md5/b71782931b1ec965a99239b2cd142e50译者:飞龙
第八章:第八章:关于缓存你需要知道的一切
缓存是帮助企业应用扩展并提高响应时间的关键系统设计模式之一。任何 Web 应用通常都涉及从数据存储中读取和写入数据,这些数据存储通常是关系型数据库,如 SQL Server,或 NoSQL 数据库,如 Cosmos DB。然而,对于每个请求从数据库中读取数据并不高效,尤其是当数据没有变化时。这是因为数据库通常将数据持久化到磁盘,从磁盘加载数据并将其发送回浏览器客户端(或移动/桌面应用程序中的设备)或用户的操作是成本高昂的。这就是缓存发挥作用的地方。
缓存存储可以用作检索数据的主要来源,只有当所需数据不在缓存中时才回退到原始数据存储,从而为消费应用程序提供更快的响应。当以这种方式使用缓存时,我们还需要确保当原始数据存储中的数据更新时,缓存中的数据会过期/刷新。
在本章中,我们将学习在.NET 6 应用程序中缓存数据的各种技术,以及可以与.NET 6 应用程序集成的各种缓存组件和平台。我们将涵盖以下主题:
-
缓存简介
-
理解缓存组件
-
缓存平台
-
使用分布式缓存设计缓存抽象层
技术要求
需要基本了解.NET Core、C#、Azure 和.NET CLI。本章的代码可以在此处找到:github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Chapter08。
代码示例的说明可以在此处找到:github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Enterprise%20Application。
缓存简介
有多种方法可以提高应用程序的性能,缓存是企业在应用中使用的关键技术之一。缓存就像一个临时数据存储,具有有限的大小和有限的数据,但与原始数据源相比,数据访问速度要快得多,通常只存储数据的一个子集,即最常使用且不经常变化的数据。
缓存存储可能只是计算机的 RAM,该 RAM 在执行过程中被进程使用,或者它可能是像 Redis 这样的东西,它使用内存和磁盘来存储数据。这里的关键是它通常位于比原始存储层访问时间更低的硬件上。
缓存可以在架构的每一层实现,以便从最接近用户的那一层检索数据。例如,在任何 Web 应用中,当我们输入 URL 并在浏览器中按下Enter键时,它会通过加载 Web 应用所涉及的各个 Web 组件,从浏览器、代理和 DNS 开始,到 Web 服务器和数据库。缓存是可以在所有这些层应用的东西。
如果数据在浏览器中缓存,它可以立即加载。如果没有在用户最近的那一层找到数据,它可以回退到更高一层,从而减少共享多个用户的更高层(如应用服务器和数据库层)的负载。
下图以高层次描述了这一讨论,其中请求在各个层之间流动,并且只有在缓存中没有数据(用虚线表示)时才会移动到更高层:
图 8.1 – 请求流程中的缓存层
让我们讨论一下应用架构中可以缓存数据的一些这些层。
客户端缓存
常请求的数据可以在客户端缓存,以避免不必要的往返服务器。例如,微软的 Outlook 应用从服务器下载最新的电子邮件,并在客户端保留副本,然后定期同步新邮件。如果需要搜索尚未下载的电子邮件,它将返回服务器。
同样,浏览器可以根据某些头部信息缓存来自 Web 应用的多种资源和响应,并且对相同资源的后续请求将从浏览器缓存中加载。例如,所有 JavaScript 文件、图像文件和 CSS 通常会在浏览器中缓存一定时间。此外,通过发送适当的响应头部,API 的响应也可以被缓存。这被称为HTTP 缓存或响应缓存,将在后面的章节中详细讨论。
内容分发网络(CDN)
内容分发网络(CDN)是一组全球分布的服务器,通常用于提供静态内容,如 HTML、CSS 和视频。每当应用请求一个资源时,如果启用了 CDN,系统将首先尝试从物理上最接近用户的 CDN 服务器加载该资源。然而,如果该资源不在 CDN 服务器上,它将从服务器检索并缓存到 CDN 中,以服务于后续请求。Netflix 就是这样一个很好的例子,它严重依赖其定制的 CDN 来向用户交付内容。
微软还提供了 Azure CDN,主要用于服务静态内容。此外,微软的 CDN 还提供了与 Azure 存储集成的选项,我们将在我们的电子商务应用中使用它来服务各种产品图片。类似地,AWS 有 Amazon Cloudfront,而 Google Cloud 则在其各自的云存储服务中提供 Cloud CDN。
Web 服务器缓存
虽然 CDN 对于静态内容非常出色,但它们在从应用服务器刷新数据方面会带来额外的成本和维护开销。为了克服这些限制,应用可以使用 Web 服务器或反向代理来服务静态内容。一个轻量级的 NGINX 服务器就是这样一个例子,它可以用来服务静态内容。
Web 服务器也可以缓存动态内容,例如来自应用服务器的 API 响应。当配置为反向代理时,如 NGINX 或 IIS 这样的 Web 服务器可以进一步用于缓存动态内容,从而通过从其缓存中服务请求来减轻应用服务器的负载。
注意
NGINX 是一个开源解决方案,主要以其 Web 服务器功能而闻名;然而,它也可以用作反向代理、负载均衡等。欲了解更多信息,请参阅www.nginx.com/。
数据库缓存
越来越多的数据库服务器会缓存查询的某些组件;例如,SQL Server 通常有缓存执行计划,并且还有一个数据缓冲区用于缓存,MongoDB 则将最近查询的数据保存在内存中以实现快速检索。因此,调整这些设置以改善应用性能是很好的。
注意
数据库缓存并不能保证相同查询的后续执行不会消耗 CPU 资源;也就是说,它并不是真正免费的。在后续请求中,相同的查询执行速度会更快。
应用缓存
应用缓存可以通过在应用服务器内部缓存从存储层检索到的数据来实现。这通常以下两种方式完成:
-
存储在应用服务器的内存中,也称为内存缓存
-
存储在外部存储中,如 Redis 或 Memcached,其访问速度比底层原始数据存储更快
应用缓存通常涉及在应用逻辑中集成额外的代码以缓存数据。因此,每当请求数据时,应用首先会在缓存中查找。但如果缓存中没有,应用将回退到原始数据存储,如数据库。通常,应用缓存的大小与原始数据存储相比有限,因此,应用缓存平台将采用各种算法,如最近最少使用(LRU)或最少使用频率(LFU)来清理缓存中存储的数据。我们将在缓存平台部分讨论更多关于缓存平台的内容。
对于应用程序缓存来说,另一个需要考虑的重要点是数据失效,即数据需要多频繁地过期或与原始数据源同步。因此,需要考虑诸如缓存过期以及用原始数据存储更新缓存的各种策略(读透,写透)。我们将在缓存访问模式部分讨论更多的缓存失效/刷新策略。
理解缓存的组件
在我们了解.NET 6 应用程序中可用的各种可能的缓存存储/平台之前,我们需要了解.NET 6 中可用的各种缓存组件以及如何在企业应用程序中使用它们。在这个过程中,我们还将涵盖各种缓存淘汰策略和技术,以保持缓存与原始数据存储同步。
响应缓存
响应缓存是 HTTP 支持的一种缓存技术,用于缓存使用 HTTP 或 HTTPS 发出的请求的响应,无论是在客户端(例如,浏览器)还是中间代理服务器上。从实现的角度来看,这通过在请求和响应中设置适当的Cache-Control头值来控制。一个典型的Cache-Control头将如下所示:
Cache-Control:public,max-age=10
在这种情况下,如果响应中存在该头,服务器正在告诉客户端/代理(公开)客户端可以缓存响应 10 秒(max-age=10)。然而,客户端仍然可以覆盖它并缓存更短的时间;也就是说,如果请求和响应都设置了缓存头,则缓存持续时间将是两者中的最小值。
除了max-age之外,根据 HTTP 规范(tools.ietf.org/html/rfc7234#section-5.2),Cache-Control还可以包含以下值:
-
公开:响应可以缓存在任何地方 – 客户端/服务器/中间代理服务器。
-
私有:响应可以存储在特定用户处,但不能存储在共享缓存服务器上;例如,它可以存储在客户端浏览器或应用服务器中。
-
No-cache:响应不能被缓存。
在响应缓存中起作用的其它头包括以下内容:
-
Age:这是一个响应头,表示对象在缓存(代理/浏览器)中存在的时间长度。接受的值是一个整数,表示秒数。
-
Vary设置为user-agent值,每个user-agent的响应将唯一缓存。
下面的截图显示了 Postman 中一个示例请求相关的缓存响应头:
图 8.2 – 带有 Cache-Control 和 Vary 头的示例响应
下面的序列图显示了使用 ASP.NET Core 6 构建的具有启用响应缓存中间件的示例 API:
图 8.3 – 响应缓存序列图
在创建新的 ASP.NET Core 6 MVC/Web API 应用程序或使用现有的 ASP.NET Core 6 MVC/Web API 应用程序后,要配置响应缓存,需要以下代码更改:
-
在
Program.cs中添加builder.Services.AddResponseCaching(),并使用app.UseResponseCaching()添加所需的中间件。此中间件包含缓存数据的所需逻辑。确保在app.UseEndpoints之前注入此中间件。 -
通过自定义中间件或使用
ResponseCache属性来处理响应以设置缓存头。注意
在使用 CORS 中间件时,必须先调用
UseCors然后调用UseResponseCaching。有关此顺序的更多信息,请参阅github.com/dotnet/AspNetCore.Docs/blob/master/aspnetcore/fundamentals/middleware/index.md。
ResponseCache属性可用于整个控制器或控制器中的特定方法,并接受以下键属性:
-
Duration:一个数值,用于设置响应头中的max-age值 -
ResponseCacheLocation:一个枚举,接受三个值 -Any、Client和None- 并进一步设置Cache-Control头为public、private或no-store -
VaryByHeader:一个字符串,用于控制基于特定头的缓存行为 -
VaryByQueryKeys:一个字符串数组,接受基于其缓存数据的关键值
带有ResponseCache属性的一个典型方法看起来像这样:
[HttpGet]
[ResponseCache(Duration = 500, VaryByHeader =
"user-agent", Location =
ResponseCacheLocation.Any, VaryByQueryKeys =
new[] { "Id" })]
public async Task<IActionResult>
Get([FromQuery]int Id = 0)
此方法将基于唯一的user-agent头和Id值缓存500秒。如果这些值中的任何一个发生变化,则从服务器提供响应,否则从缓存中间件提供。
如您在此处所见,我们需要为每个控制器/方法前缀添加ResponseCache属性。因此,如果应用程序有多个控制器/方法,这可能是一个维护开销,因为为了更改数据缓存的方式(例如更改Duration值),我们需要在控制器/方法级别应用更改,这正是缓存配置文件发挥作用的地方。
因此,我们不必单独设置属性,我们可以将它们分组,并在Program.cs中给它们一个名称,该名称可以在ResponseCache属性中使用。因此,对于前面的属性,我们可以在Program.cs中通过添加以下代码创建一个缓存配置文件:
builder.Services.AddControllers(options =>
{
options.CacheProfiles.Add("Default",
new CacheProfile {
Duration = 500,
VaryByHeader = "user-agent",
Location = ResponseCacheLocation.Any,
VaryByQueryKeys = new[] { "Id" } });
});
在控制器上,使用CacheProfileName调用此缓存配置文件:
[ResponseCache(CacheProfileName = "Default")]
对于 MVC 应用程序,可以在services.AddControllersWithViews()中配置CacheProfile。
分布式缓存
正如我们所知,在分布式系统中,数据存储被分散在多个服务器上。同样,分布式缓存是传统缓存的扩展,其中缓存数据存储在网络的多个服务器上。在我们深入了解分布式缓存之前,这里快速回顾一下CAP 定理:
-
C 代表一致性,意味着数据在所有节点上都是一致的,每个节点都有相同的数据副本。
-
A 代表可用性,意味着系统是可用的,一个节点的故障不会导致系统崩溃。
-
P 代表分区容错,意味着即使节点之间的通信中断,系统也不会崩溃。
根据 CAP 定理,任何分布式系统只能实现上述两个原则中的两个,并且由于分布式系统必须是分区容错的(P),我们只能实现数据的一致性(C)或数据的高可用性(A)。
因此,分布式缓存是一种缓存策略,其中数据存储在应用服务器之外的多台服务器/节点/分片中。由于数据分布在多台服务器上,如果一台服务器宕机,另一台服务器可以作为备份来检索数据。
例如,如果我们的系统想要缓存国家、州和城市,并且在一个分布式缓存系统中有三台缓存服务器,假设其中一台缓存服务器将缓存国家,另一台缓存州,还有一台缓存城市(当然,在实际应用中,数据的分割方式要复杂得多)。
此外,每台服务器还将作为一个或多个实体的备份。因此,从高层次来看,一种分布式缓存系统看起来如下所示:
图 8.4 – 分布式缓存高级表示
如您所见,在读取数据时,数据是从主服务器读取的,如果主服务器不可用,缓存系统将回退到辅助服务器。同样,对于写入操作,只有在数据被写入主服务器和辅助服务器之后,写入操作才算完成。在此操作完成之前,读取操作可能会被阻塞,从而影响系统的可用性。对于写入操作,另一种策略可以是后台同步,这将导致数据最终一致性,因此在同步完成之前,数据的一致性可能会受到影响。回到 CAP 定理,大多数分布式缓存系统属于 CP 或 AP 类别。
以下是一些与.NET 6 应用程序集成的分布式缓存提供商:
-
Redis Cache
-
Memcached
-
Couchbase
-
SQL Server
-
NCache
这可以进一步扩展到任何集群编排平台,例如,Terracotta,它负责管理各种节点,并将数据分发到所有节点。
尽管分布式缓存有很多好处,但与单服务器缓存或进程内缓存相比,分布式缓存的一个可能缺点可能是由于可能的额外跳转和序列化/反序列化而引入的延迟。因此,如果应用严重依赖于缓存数据,设计可以考虑内存缓存和分布式缓存的组合。然而,大多数场景都可以通过集成一个良好实现的分布式缓存系统(如 Redis)来覆盖,我们将在本章后面讨论这一点。
Cache access patterns
一旦对象数据被缓存,就需要一个设计来处理缓存的刷新。可以实现多种缓存访问模式来处理这种情况。以下是一些关键模式:
-
Cache-aside pattern
-
Read-through/write-through
-
Refresh-ahead
-
Write-behind
让我们逐一详细讨论。
Cache-aside pattern
如其名所示,在缓存-旁路模式中,缓存存储与数据存储一起保留。在这个模式中,应用程序代码会检查缓存存储中的数据可用性。如果缓存存储中没有数据,则从底层数据存储中检索数据,并在缓存中更新。后续请求将再次查询缓存中的数据,如果数据在缓存中可用,则将从缓存中提供服务。缓存-旁路模式依赖于懒加载的概念,这在第四章中讨论过,线程和异步操作,并且当数据首次被访问时填充;对同一实体的后续请求将加载自缓存。
在更新原始数据存储中的数据时,应该处理缓存存储中数据的过期。
以下是这个模式的优势:
-
与下一节中介绍的读取/写入模式相比,实现简化。由于缓存不是应用中的主要数据源,我们不需要额外的类来同步缓存存储与数据存储。
-
由于它依赖于懒加载原则,缓存仅在访问任何数据至少一次时才会被填充。
然而,这个模式也有一些缺点:
-
这可能导致缓存未命中次数增加的可能性。缓存未命中应该始终保持在最小,因为它们会由于额外的跳转而引入应用的延迟。
-
如果在数据更新期间错过缓存过期,可能会导致缓存中的数据过时。这可能发生在数据由没有缓存系统信息的后台/外部进程更新时。
一种减轻过期问题的方法是为每个实体设置生存时间(TTL),这样对象在一段时间后就会自动过期。然而,在监控数据刷新率后,需要仔细评估 TTL 持续时间。在缓存旁路模式的情况下,另一种常见做法是在应用程序启动时预先填充缓存存储,因为这有助于减少缓存未命中次数。大多数企业应用程序通常使用缓存旁路模式实现缓存层,并用主数据而不是事务数据预先填充它。
读取/写入
在读取/写入中,应用程序直接从缓存存储读取/写入数据;也就是说,应用程序将其用作主存储,缓存层负责在缓存中加载数据,并负责将任何从缓存存储更新的数据写回原始存储。
当应用程序想要读取一个实体时,它将直接从缓存存储请求它。如果该实体存在于缓存中,则返回响应。然而,如果它不在缓存中,缓存层将从原始数据存储请求它,该数据存储在缓存中更新以供将来使用,然后从缓存层返回响应。
更新实体时,以下步骤发生:
-
首先更新到缓存中。
-
缓存层将其写回原始数据存储。
这种类型系统的优点如下:
-
在原始数据存储(通常是一个数据库)上显著减少负载,因为在大多数企业应用程序中,所有调用都会从缓存提供服务,除了从缓存层到数据存储的调用。
-
简化的应用程序代码,因为它只与一个存储进行交互,而与缓存旁路模式不同,后者不仅与缓存存储交互,还与应用程序代码内的数据存储交互。
这种模式的几个缺点如下:
-
需要一个额外的机制来同步缓存和数据存储之间的数据。
-
缓存更新变得有些棘手,因为它涉及到刷新缓存存储的额外复杂性。
预刷新
预刷新策略允许您异步地将数据加载到缓存存储中;也就是说,在这个设计中,应用程序仍然直接与缓存存储通信。然而,缓存层会在缓存中的数据过期之前定期刷新数据。对于最近访问过的条目,刷新是异步发生的,并在它们过期之前从原始存储异步刷新。这样,如果任何缓存项已过期,应用程序中就不会有任何延迟。
写入后
在写入后策略中,数据首先更新到缓存存储,然后异步更新回数据存储,这与写入策略不同,在写入策略中,数据会立即更新到数据存储。这种策略的一个关键优点是降低了延迟。然而,由于数据是异步更新的(写入数据存储和缓存存储是两个不同的交易),因此应该实现回滚机制,以防万一出现故障。
通常,与写入策略相比,实现起来要复杂得多,因为需要额外的处理来避免在异步更新期间发生数据丢失,但如果需要将缓存存储作为主要数据源,这仍然是一个很好的集成模式。
到目前为止讨论的所有模式都可以在以下图中以高层次可视化:
图 8.5 – 缓存模式
到目前为止,我们已经看到了各种缓存模式和策略。在下一节中,我们将讨论各种缓存提供程序及其与 .NET 6 应用程序的集成。
缓存平台
.NET 6 支持多个缓存平台。以下是一些常用的缓存平台:
-
w3wp.exe。 -
分布式缓存:数据存储在多个服务器上。可以与 .NET 6 应用程序集成的数据存储包括 SQL Server、Redis 和 NCache。
内存缓存
要配置内存缓存,在创建新的 ASP.NET Core 6 MVC/Web API 应用程序后,或者对于现有的 ASP.NET Core 6 MVC/Web API 应用程序,需要以下代码更改:
-
在
Program.cs中添加builder.Services.AddMemoryCache()。MemoryCache类是 .NET 6 中IMemoryCache的内置实现,可以在任何 C# 类中通过IMemoryCache使用。它通过构造函数注入进行实例化。对象创建是通过IMemoryCache进行的,并使用构造函数注入创建MemoryCache的实例。 -
MemoryCache提供了许多方法,但其中一些重要的是Get(获取键的值)、Set(插入键及其值)和Remove(移除键(缓存过期))。 -
在创建缓存对象(使用
Set或其他方法)时,可以使用MemoryCacheEntryOptions对内存缓存进行各种参数的配置。以下属性是支持的:
a. SetAbsoluteExpiration: 缓存的绝对有效时间,即缓存有效的确切日期和时间(DateTime)。
b. SetSlidingExpiration: 缓存条目在缓存中变为非活动状态后的无效时间。例如,5 秒的滑动过期值将等待缓存条目非活动 5 秒。滑动过期值应始终小于绝对过期值,因为无论滑动过期值如何,缓存都会在达到绝对过期时间后过期。
c. SetPriority:在执行缓存淘汰时,缓存大小是有限的(可以使用 SetPriority 通过 CacheItemPriority 枚举设置缓存条目的优先级。其默认值是 CacheItemPriority.Normal)。
按照前面的步骤集成的简单 Web API 控制器,带有内存缓存集成,将如下所示:
public class WeatherForecastController : ControllerBase
{
private IMemoryCache cache;
public WeatherForecastController(IMemoryCache
cache)
{
this.cache = cache;
}
[HttpGet]
public IActionResult Get()
{
DateTime? cacheEntry;
if (!cache.TryGetValue("Weather",
out cacheEntry))
{
cacheEntry = DateTime.Now;
var cacheEntryOptions = new
MemoryCacheEntryOptions()
.SetSlidingExpiration(
TimeSpan.FromSeconds(50))
.SetAbsoluteExpiration(
TimeSpan.FromSeconds(100))
.SetPriority(
CacheItemPriority.NeverRemove);
cache.Set("Weather", cacheEntry,
cacheEntryOptions);
}
cache.TryGetValue("Weather", out cacheEntry);
var rng = new Random();
return Ok(from temp in Enumerable.Range(1, 5)
select new
{
Date = cacheEntry,
TemperatureC = rng.Next(-20, 55),
Summary = "Rainy day"
});
}
}
如您所见,此代码是自我解释的,并使用内存缓存提供了一个 API。
MemoryCache 中可用的一种附加方法是使用 RegisterPostEvictionCallback 集成回调方法。这是 MemoryCacheEntryOptions 中的一个扩展方法,它接受一个 PostEvictionDelegate 委托,并在缓存条目过期时触发回调。PostEvictionDelegate 的签名如下:
public delegate void PostEvictionDelegate(object key, object value, EvictionReason reason, object state);
因此,这意味着我们传递给 RegisterPostEvictionCallback 的回调应该遵循相同的签名,如您所见,所有输入参数都是自我解释的。因此,让我们添加一个回调方法并更新 cacheEntryOptions,如下所示:
private void EvictionCallback(object key, object value, EvictionReason reason, object state)
{
Debug.WriteLine(reason);
}
cacheEntryOptions.RegisterPostEvictionCallback(EvictionCallback);
天气控制器的代码映射如下截图所示:
图 8.6 – 天气控制器代码映射
一旦我们运行此代码,我们就可以看到在 50 秒的绝对过期后对控制器的任何后续调用都将触发回调,并将原因记录为 Expiration。一旦部署到 AppService,回调将自动触发。只有出于调试目的,我们才需要再次调用。
分布式缓存
在讨论了内存缓存之后,让我们继续探讨其他可以配置为分布式缓存的缓存平台。分布式缓存,就像分布式存储系统一样,将缓存数据分布到多个服务器上,主要是为了支持扩展。在本节中,我们将查看不同类型的分布式缓存,从 SQL 开始。
SQL
分布式缓存可以使用各种存储实现,其中之一是 SQL Server。使用 SQL Server 进行分布式缓存的第一步是创建存储缓存条目的所需 SQL 表。将 SQL 作为分布式缓存存储的整个设置涉及以下步骤:
-
在管理员提示符中打开命令行并运行以下命令以全局安装
dotnet-sql-cache包:dotnet tool install ––global dotnet-sql-cache
这就是它的样子:
图 8.7 – 使用 .NET CLI 安装 sql-cache 包
-
创建所需的数据库(本地或使用 Azure SQL)并运行以下命令以创建存储缓存数据的表:
dotnet sql-cache create "Data Source=.;Initial Catalog=DistributedCache;Integrated Security=true;" dbo cache
在这个命令中,我们传递数据库的连接字符串(在本地运行时相应更新)作为参数之一,另一个是表的名称(cache 是前一个片段中表的名称)。
图 8.8 – 为分布式缓存创建 SQL 表
- 一旦命令成功运行,如果我们打开 SSMS 中的 SQL 服务器,我们将看到如下所示的表,其中包含用于优化的列和索引:
图 8.9 – SSMS 中 SQL 分布式缓存的缓存表
-
创建一个 Web API 应用程序并安装 NuGet 包
Microsoft.Extensions.Caching.SqlServer(通过 包管理控制台(PMC)或使用 .NET CLI)。 -
在
Program.cs中添加以下代码:builder.Services.AddDistributedSqlServerCache(options => { options.ConnectionString = "Data Source=.;Initial Catalog=DistributedCache;Integrated Security=true;"; options.SchemaName = "dbo"; options.TableName = "Cache"; }); -
要将数据插入缓存,我们需要使用
IDistributedCache,该对象将通过构造函数注入创建。因此,清理WeatherForecastController(在创建 ASP.NET Core 6 Web API 项目期间创建的默认控制器)中的所有代码,并添加以下代码(一个具有Get方法的 Web API 控制器):public class WeatherForecastController : ControllerBase { private readonly IDistributedCache distributedCache; public WeatherForecastController( IDistributedCache distributedCache) { this.distributedCache = distributedCache; } } -
添加以下
Get方法,它使用distributedCache并将数据保存到缓存存储(在这种情况下为 SQL):[HttpGet] public IActionResult Get() { DateTime? cacheEntry; if (distributedCache.Get("Weather") == null) { cacheEntry = DateTime.Now; var cacheEntryOptions = new DistributedCacheEntryOptions() .SetSlidingExpiration(TimeSpan .FromSeconds(50)) .SetAbsoluteExpiration(TimeSpan .FromSeconds(100)); distributedCache.SetString("Weather", cacheEntry.ToString(), cacheEntryOptions); } var cachedDate = distributedCache.GetString("Weather"); var rng = new Random(); return Ok(from temp in Enumerable.Range(1, 5) select new { Date = cachedDate, TemperatureC = rng.Next(-20, 55), Summary = "Rainy day" }); } -
运行应用程序后,我们可以看到缓存条目正被存储在 SQL 数据库中,如下面的屏幕截图所示:
图 8.10 – SQL 分布式缓存的缓存表
如您所见,代码与 MemoryCache 代码非常相似,只是我们在这里使用 IDistributedCache 来读写缓存数据,并使用 DistributedCacheEntryOptions 在创建缓存条目时设置额外的属性。
使用 SQL Server 作为分布式缓存存储的一些建议如下:
-
如果现有应用程序不支持 Redis 等存储,可以选择 SQL Server。例如,仅与 SQL Server 集成的本地应用程序可以轻松扩展 SQL Server 以用于缓存目的。
-
缓存数据库应与应用程序数据库不同,因为使用相同的数据库可能会导致瓶颈并违背使用缓存的目的。
-
SQL Server 的
IDistributedCache内置实现是SqlServerCache,它不支持为缓存表序列化不同的模式。任何自定义都必须通过在自定义类中实现IDistributedCache来手动覆盖。
到目前为止,我们已经看到了使用 SQL Server 进行内存缓存和分布式缓存的示例。在下一节中,我们将看到如何在 .NET 6 应用程序中使用 Redis(推荐的存储之一,也是广泛用于缓存的存储)进行分布式缓存。
Redis
Redis 是一个内存数据存储,用于各种目的,如数据库、缓存存储,甚至作为消息代理。Redis 支持的核心数据结构是键值对,其中值可以是简单的字符串,也可以是自定义的复杂数据类型(嵌套类)。Redis 与内存数据集一起工作,并且可以根据需要将数据持久化到磁盘。Redis 还内部支持 Redis 集群的复制和自动分区。由于所有这些功能都是开箱即用的,因此它是一个理想的分布式缓存存储。
Azure 提供了一个名为 Azure Cache for Redis 的 Redis 服务器托管实例,就像任何其他 PaaS 服务一样,由 Microsoft 管理。这允许应用程序开发者直接集成它,并将维护、扩展和升级 Redis 服务器的基础设施开销留给 Microsoft。Azure Cache for Redis 可用于分布式缓存,并且可以使用以下步骤轻松集成到 .NET 6 应用程序中:
- 首先,根据
docs.microsoft.com/en-in/azure/azure-cache-for-redis/quickstart-create-redis中的说明创建 Azure Cache for Redis 的实例。导航到 访问密钥 并复制 主连接字符串 下的值,如图所示:
图 8.11 – Azure Cache for Redis 的缓存键
-
创建一个 ASP.NET Core 6 Web API 应用程序并安装 NuGet 包
Microsoft.Extensions.Caching.StackExchangeRedis。 -
在
Program.cs中添加以下代码:builder.Services.AddStackExchangeRedisCache( options => { options.Configuration = "<Connection string copied in step 1>"; }); -
使用与上一节 SQL 中所示相同的代码更新默认的
WeatherForecastController控制器。 -
运行应用程序后,我们可以看到数据在缓存中存储了 10 秒。在 50 秒内对 API 的任何调用都将从缓存中检索数据。
-
Azure Cache for Redis 还附带一个控制台,允许我们使用 Redis CLI 命令查询 Redis 服务器。可以通过导航到 Redis 实例的左侧菜单概览来在 Azure 门户中找到该控制台。查询
Weather键将给出以下截图所示的结果:
图 8.12 – Redis 控制台
如果我们选择 Azure Cache for Redis 的 Premium 层,它还支持多个分片以支持更高的数据量,并支持高可用性的地理复制。
- 此外,为了从缓存存储中添加/删除键,还有
GetAsync和SetAsync方法,可以用来存储更复杂的数据类型或任何非字符串类型。然而,这些方法返回/接受Task<byte[]>,因此应用程序需要处理序列化/反序列化,这在可重用的缓存库中可以看到。
Redis 是企业应用程序中最受欢迎的缓存存储,在我们的电子商务应用程序中,我们将使用 Azure Cache for Redis 作为我们的缓存存储。有关 Azure Cache for Redis 的更多信息,可以在docs.microsoft.com/en-in/azure/azure-cache-for-redis/找到。
其他提供者
如您所见,.NET 6 应用程序中的分布式缓存是由IDistributedCache驱动的,并且Program类中的缓存存储配置将根据注入的存储实现进行相应配置。此外,.NET 6 还内置了两个其他提供者:
-
IDistributedCache。NCache 可以像 Redis 或 SQL 一样集成。然而,NCache 服务器需要在本地进行配置以用于开发,并且可以使用 IaaS 中的虚拟机或 PaaS 中的应用服务进行配置。 -
AddDistributedMemoryCache): 这是IDistributedCache的另一个内置实现,可以类似地使用。它可以用于单元测试。由于它不是分布式缓存并且使用进程内存,因此不建议在多个应用程序服务器场景中使用。AddMemoryCache(IMemoryCache)和AddDistributedMemoryCache(IDistributedCache)之间的唯一区别是后者需要序列化以存储复杂数据。因此,如果存在无法序列化且需要缓存的类型,请使用IMemoryCache,否则请使用IDistributedCache。
在企业应用程序中,IDistributedCache可以处理所有缓存层实现,对于开发/测试环境使用内存缓存,对于生产环境使用 Redis 将是一个理想的选择。如果您的应用程序托管在单个服务器上,您可以使用内存缓存,但在生产应用程序中这种情况非常罕见,因此最推荐使用分布式缓存。
因此,基于我们讨论的所有原则和模式,我们将设计一个用于电子商务应用程序的缓存抽象层,这将在下一节中讨论。
使用分布式缓存设计缓存抽象层
在企业应用程序中,在底层缓存实现之上有一个包装类总是很好的,因为它抽象了缓存的核心理念,也可以用作一个包含应用程序范围内默认缓存条目选项的单个类。
我们将实现一个缓存包装类,其底层存储为 Azure Cache for Redis,使用IDistributedCache实现。这是一个.NET Standard 2.1 类库;该库的源代码位于Packt.Ecommerce.Caching项目中。任何想要缓存数据的类都应该使用构造函数注入IDistributedCacheService,并可以调用以下各种方法:
-
AddOrUpdateCacheAsync<T>: 异步添加或更新类型为T的缓存条目 -
AddOrUpdateCacheStringAsync:异步添加或更新字符串类型的缓存条目 -
GetCacheAsync<T>:异步获取类型为T的缓存条目 -
GetCacheStringAsync:异步获取字符串类型的缓存条目 -
RefreshCacheAsync:异步刷新缓存条目 -
RemoveCacheAsync:异步移除缓存条目
DistributedCacheService 是继承自 IDistributedCacheService 并实现所有上述方法的包装类。此外,在这个类中配置了 IDistributedCache 和 DistributedCacheEntryOptions 以使用分布式缓存。
对于序列化和反序列化,我们将使用 System.Text.Json,一个自定义的 IEntitySerializer 接口,以及使用以下方法创建的 EntitySerializer 类:
-
SerializeEntityAsync<T>:异步将指定的对象序列化为字节数组 -
DeserializeEntityAsync<T>:异步反序列化指定的流
IEntitySerializer 的实现通过构造函数注入注入到 DistributedCacheService 类中,并用于序列化和反序列化。
注意
请参阅 序列化性能比较 文章,该文章讨论了各种序列化程序的基准测试。您可以在 maxondev.com/serialization-performance-comparison-c-net-formats-frameworks-xmldatacontractserializer-xmlserializer-binaryformatter-json-newtonsoft-servicestack-text/ 找到它。
DistributedCacheService 和 EntitySerializer 的实现遵循在 第四章,线程和异步操作 中讨论的所有异步原则,以及在第 第六章*,.NET 6 中的配置*中解释的配置。
最后,在 API/MVC 应用程序中,执行以下步骤:
-
安装 NuGet 包
Microsoft.Extensions.Caching.StackExchangeRedis。 -
通过将以下代码片段添加到
Program.cs来配置缓存:if (this.Configuration.GetValue<bool>("AppSettings:UseRedis")) { builder.Services.AddStackExchangeRedisCache( options => { options.Configuration = this.Configuration .GetConnectionString("Redis"); }); } else { services.AddDistributedMemoryCache(); } -
从配置的角度来看,向
appsettings.json中添加了两个属性,如下所示:"ConnectionStrings": { //removed other values for brevity "Redis": "" //Azure Cache for Redis connection //string. }, "AppSettings": { //removed other values for brevity "UseRedis": false //Flag to fallback to in memory //distributed caching, usually false for local //development. },
任何想要缓存数据的类都需要添加对 Packt.Ecommerce.Caching 项目的引用,并注入 IDistributedCacheService,然后可以调用上述方法来读取/更新/插入缓存存储中的数据。以下是一个使用缓存服务的代码片段:
public class Country
{
public int Id { get; set; }
public string Name { get; set; }
}
public async Task<Country> GetCountryAsync()
{
var country = await
this.cacheService.GetAsync<Country>("Country"); // cacheservice is of Type IDistributedCacheService and is
// injected using constructor injection.
if (country == null)
{
country = await
this.countryRepository.GetCountryAsync(); // Retrieving data from database using Repository pattern.
if (country != null)
{
await this.cacheService
.AddOrUpdateAsync<Country>("Country",
country, TimeSpan.FromMinutes(5));
}
}
return country;
}
在这里,我们使用缓存旁路模式,首先检查缓存存储中的 Country 键。如果找到,则从函数中返回它,否则从数据库中检索它并将其插入缓存,然后从函数中返回。我们将在 第十章,创建 ASP.NET Core 6 Web API 中大量使用缓存服务。
正如你所看到的,我们已经使用了之前章节中讨论的一些模式。在下一节中还将讨论一些额外的考虑因素,这些因素在设计企业应用中的缓存层时需要牢记。
缓存考虑因素
在企业应用中,拥有一个缓存层对于提高性能和可扩展性至关重要。因此,在开发缓存层时需要考虑以下因素:
-
如果我们正在构建一个新的应用程序,那么可以使用
IDistributedCache实现作为起点,使用 Azure Cache for Redis,因为它可以很容易地通过几行代码集成到应用程序中。然而,这需要评估相应的成本。 -
对于一个现有的项目,当前的基础设施起着至关重要的作用,如果已经使用 SQL Server 作为数据存储,那么 SQL 可以成为默认的选择。然而,对 SQL 与 Redis 的性能进行基准测试是个好主意,并据此做出相应的决策。
-
在底层缓存存储上有一个包装类是一个好的方法,因为它将应用程序与缓存存储解耦,并使得代码在缓存存储未来发生变化时更加灵活且易于维护。
-
IMemoryCache和IDistributedCache的方法不是线程安全的。例如,假设一个线程从缓存中查询一个键,发现它不在缓存中,然后回退到原始数据存储。当从原始存储检索数据时,如果另一个线程查询相同的键,它不会等待第一个线程完成从数据库的读取。第二个线程也将回退到数据库。因此,需要显式处理线程安全性,可能是在包装类中。 -
除了应用缓存之外,还应实现响应缓存以实现进一步的优化。
-
If-None-Match请求头,如果匹配,服务器返回304(无变化),客户端可以重用数据的缓存版本。对于 ETag,服务器端没有内置的实现,因此可以使用过滤器或中间件来实现服务器端逻辑。 -
尽管我们在实现中使用了 JSON 序列化,但还有其他格式,如 BSON 或协议缓冲区,应该评估用于序列化和反序列化。
正如任何其他用于缓存的开发中的应用组件一样,没有一种适合所有情况的解决方案。因此,应该评估前面提到的点,并相应地实施适当的缓存解决方案。
摘要
在本章中,我们学习了各种缓存技术、模式和它们在提高应用程序性能方面的好处。此外,我们还学习了 HTTP 缓存,如何将响应缓存集成到 API 响应中,以及各种可用的缓存提供者和它们与.NET 6 应用程序的集成。我们还学习了如何使用IDistributedCache实现分布式缓存,并构建了一个缓存抽象层,该层将在后续章节中用于缓存需求。我们在学习过程中了解的一些关键信息和技能包括为什么和何时需要缓存,以及如何在.NET 6 应用程序中实现缓存。
在下一章中,我们将探讨.NET 6 中的各种数据存储和提供者及其与.NET 6 应用程序的集成。
问题
- 以下哪个
Cache-Control头部的值允许响应存储在任何服务器(客户端/服务器/代理)中?
a. 私有
b. 公共
c. No-cache
答案:b
- 在多应用服务器场景中,我们应该选择以下哪个缓存平台?
a. 分布式缓存
b. 内存缓存
答案:a
- 判断对错:在缓存旁路模式中,数据首先更新在缓存存储中,然后是在底层数据存储中。
a. 正确
b. 错误
答案:b
- 以下哪个缓存最适合存储静态文件和图像文件并支持地理复制?
a. 网络服务器缓存
b. 应用程序缓存
c. 内容分发网络(CDN)
答案:c
进一步阅读
你可以在此处了解更多有关缓存的信息:
-
docs.microsoft.com/en-us/azure/azure-cache-for-redis/cache-configure#redis-console -
功能(terracotta.org)
第三部分:开发企业应用程序
在本部分中,我们将开发应用程序的不同层。我们将从数据层开始,然后开发 API 和 Web 层。在开发过程中,我们将集成在第二部分中开发的用于跨领域关注的库。
本部分包括以下章节:
-
第九章*,在 .NET 6 中处理数据*
-
第十章*,创建 ASP.NET Core 6 Web API*
-
第十一章*,创建 ASP.NET Core 6 Web 应用程序*
第九章:第九章:在 .NET 6 中处理数据
任何应用程序的一个基本组件是将数据持久化到永久数据存储的能力;在选择合适的持久化存储时进行一些前瞻性思考可以帮助系统在未来更好地扩展。
任何应用程序中常见的操作之一是登录系统,执行一些读取/更新操作,注销,然后稍后再回来查看是否保留了更改。数据库在持久化这些通常称为 用户事务 的操作中发挥着重要作用。除了事务数据外,为了监控和调试目的,应用程序可能还需要存储日志数据和审计数据,例如谁修改了日期。设计任何此类应用程序的一个重要步骤是理解需求并相应地设计数据库。选择/设计数据库时,还必须考虑各种数据保留要求和任何数据保护政策,例如 通用数据保护条例(GDPR)。
一个应用程序可以有多个数据提供者,例如结构化查询语言(SQL)数据提供者、NoSQL 数据提供者和文件数据提供者。在本章中,我们将讨论可用于 .NET 6 中存储和数据处理的多种数据提供者。我们将涵盖以下主题:
-
数据简介
-
磁盘、文件和目录
-
SQL、Azure Cosmos DB 和 Azure Storage
-
使用 EF Core 进行操作
-
使用 Azure Cosmos DB 设计数据访问服务
技术要求
需要基本了解 .NET Core、C#、Azure 和 .NET 命令行界面(CLI)。
本章的代码文件可以在以下链接找到:github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Chapter08.
数据简介
任何网络应用程序,无论是内容管理系统、社交网络平台还是电子商务应用程序,都需要将数据持久化到永久存储中,以便用户可以按需检索、消费和处理数据。在第八章,“所有你需要知道的缓存知识”中,我们讨论了使用缓存存储;然而,缓存存储是临时存储,数据仍然需要持久化在永久存储中。因此,我们需要一个不仅支持对各种实体进行创建/读取/更新/删除(CRUD)操作,而且支持高可用性并在出现故障时恢复任何数据的存储,即灾难恢复。
优秀系统设计的关键标准之一是在系统设计的早期阶段就设计数据模型。数据模型应尝试定义系统运行和实体间交互所需的所有可能的实体。在系统设计早期定义数据模型有助于确定如何管理数据以及可以使用哪种数据存储,以及决定各种复制/分区策略。
在接下来的章节中,将解释两种常见的数据存储分类。
关系型数据库管理系统(RDBMS)
关系型数据库将数据存储在表中。每个实体被定义为一张或多张表,数据库通过多张表来定义。将表分割成多张表的过程称为规范化。各种表之间的关系通过外键约束来定义。实体的属性定义为列,相同类型的多个实体存储为行。一些常用的关系型数据库包括 Microsoft SQL Server、MySQL、PostgreSQL 和 Oracle。
一个典型的用于存储员工信息的关系型数据库可能有一个employee表,定义员工的各项属性,如姓名、员工 ID 等,以及以员工 ID 作为主键的列。多个员工以单独的行存储在这个表中。员工的任何属性都可以进一步规范化到单独的表中;例如,员工的工程项目可以存储在单独的表中(因为可能有多个项目),比如employeeproject,并且可以通过员工 ID 与employee表链接,如下面的图所示:
图 9.1 – 员工实体关系图
以下是一些关系型数据库的关键特性:
-
关系型数据库使用 SQL 进行查询。
-
表通常具有明确的模式和约束,并且不太可能发生变化。
-
所有交易都具有原子性/一致性/隔离性/持久性(ACID)属性,因此维护数据完整性和一致性。
-
由于数据已规范化,冗余最小化。
-
关系型数据库通常支持垂直扩展,即向上扩展(它们确实支持复制,但与 NoSQL 数据库中的复制相比,这是一个昂贵的操作)。
NoSQL
另一种类型的数据存储是 NoSQL 数据库,它们以非结构化格式存储数据,其中数据不需要有预定义的模式。最常见的是,数据以键值对的形式存储(例如在 Redis 中),以文档的形式存储(例如在 MongoDB 和 CouchDB 中),或使用图结构存储为图(例如在 Neo4j 中)。
如果我们将相同的员工示例持久化到 NoSQL 数据库中,例如 MongoDB,我们最终会在一个名为employee的集合中存储它,每个文档存储员工的全部属性,如下所示:
{
"employee": [
{
"employeeid": 1,
"name": "Ravindra",
"salary": 100,
"Projects": [
{
"id": 1,
"name": "project1",
},
{
"id": 2,
"name": "project2",
}
]
}
]
}
以下是一些 NoSQL 数据库的关键特性:
-
实体不一定需要支持固定的模式,并且在任何时间点都可以添加额外的属性。
-
它们非常适合非结构化数据,例如,在共享出行应用中存储位置。
-
它们可以以比关系型数据库低得多的成本轻松支持水平扩展。
-
数据高度冗余;然而,这提供了显著的性能提升,因为数据可以随时使用,而无需在表之间执行连接操作。
Azure Cosmos DB 是我们将在我们的电子商务应用程序中用作数据存储的一种云管理的 NoSQL 数据库。
让我们在下一节中详细查看各种存储选项。
SQL、Azure Cosmos DB 和 Azure Storage
之前,我们讨论了将数据存储更广泛地分类为 RDBMS 和 NoSQL。在本节中,让我们深入了解 Microsoft 生态系统中的一些数据提供者及其与.NET 6 的集成。提供者的种类繁多,包括 SQL、Azure Cosmos DB 和 Azure Storage,数据提供者的选择完全由应用程序需求驱动。然而,在现实生活中,应用程序需求变化很大,因此关键是使用业务层和用户界面(UI)来抽象数据框架实现,这有助于根据需要演进设计。有了这个,让我们在下一节中看看我们的第一个数据提供者,SQL。
SQL Server
在 RDBMS 市场中占主导地位的一个数据库是微软的 SQL Server,通常被称为 SQL Server,它使用 SQL 与数据库交互。SQL Server 支持所有基于 RDBMS 的实体,如表、视图、存储过程和索引,并且主要在 Windows 环境中运行。然而,从 SQL Server 2017 开始,它支持 Windows 和 Linux 环境。
SQL Server 的主要组件是其数据库引擎,负责处理查询和管理文件中的数据。除了数据库引擎之外,SQL Server 还附带各种数据管理工具,如下所示:
-
SQL Server 管理工具(SSMS):用于连接到 SQL Server 并执行创建数据库、监控数据库、查询数据库和备份数据库等操作
-
SQL Server 集成服务(SSIS):用于数据集成和转换
-
SQL Server 分析服务(SSAS):用于数据分析
-
SQL Server 报表服务(SSRS):用于报表和可视化
要在本地计算机上配置 SQL Server,我们需要安装 SQL Server 的一个版本,该版本安装数据库引擎和一个或多个先前组件。安装通常涉及下载安装程序并通过图形用户界面(GUI)或命令行进行安装。有关安装的更多详细信息,请参阅docs.microsoft.com/en-us/sql/database-engine/install-windows/install-sql-server?view=sql-server-ver15。
小贴士
SQL Server 还有其他版本,如开发者版和 Express 版,这些版本轻量级且免费,可以从www.microsoft.com/en-in/sql-server/sql-server-downloads下载。
虽然 SQL Server 在本地环境中已被广泛使用,但管理数据库、升级等始终存在开销,这就是微软推出了 Azure SQL 的原因,它是一个完全托管的平台即服务(PaaS)组件,在相同的数据库引擎上运行,就像本地 SQL Server 一样。
Azure SQL 提供了以下变体:
-
Azure SQL 数据库(单数据库):这是一个托管数据库服务器,允许您创建一个完全隔离的数据库,并拥有专用资源。
-
Azure SQL 数据库(弹性池):弹性池允许您在单个服务器上运行多个单数据库,这些数据库位于预定义的资源池中(就 CPU、内存和输入/输出(I/O)而言)。这对于拥有多个数据库且使用量高低不一的企业来说非常理想。在这种情况下使用弹性池的优势在于,需要更多 CPU 使用的数据库可以在需求高峰时使用它,在需求低时释放它。使用弹性池的理想情况是,有一组数据库,其消耗量不可预测。任何时候,如果您看到数据库持续消耗相同的一组资源,它可以从弹性池移动到一个单独的数据库,反之亦然。
-
Azure SQL 托管实例:此模型提供了一种无缝迁移本地 SQL 基础设施到 Azure SQL 的方法,无需重新架构本地应用程序,并允许您利用平台即服务(PaaS)。这对于拥有庞大的本地数据库基础设施并需要迁移到云而无需太多运营开销的应用程序来说非常理想。
-
虚拟机上的 SQL Server (Windows/Linux):SQL 虚拟机属于 基础设施即服务(IaaS)类别,与本地 SQL Server 非常相似,只是虚拟机位于 Azure 而不是您的本地网络中。
小贴士
建议安装 SSMS 以执行 SQL Server(本地或云)的各种操作,因为它支持所有数据库操作。还有 Azure Data Studio,它轻量级,可以连接到本地或云 SQL Server,并且可以从
docs.microsoft.com/en-us/sql/azure-data-studio/download-azure-data-studio?view=sql-server-ver15下载。
从 .NET 6 应用程序的角度来看,连接到 Azure SQL 与连接到本地 SQL Server 相同。您可以使用 ADO.NET,我们通过 System.Data.SqlClient 导入它,然后使用 SqlConnection 对象连接到 SQL;然后,使用 SqlCommand 对象执行 SQL 查询,并使用 SQLReader 类返回值。除此之外,我们还可以使用 对象关系映射(ORM)例如 Entity Framework Core(EF Core)来与 Azure SQL 一起工作,这在 使用 EF Core 部分中进行了讨论。
因此,在本节中,我们简要介绍了 Azure SQL。然而,我建议在这里查看 Azure SQL 的所有功能:docs.microsoft.com/en-us/azure/azure-sql/。更多示例请参阅 github.com/microsoft/sql-server-samples。
因此,让我们继续了解 Azure Cosmos DB,这是我们电子商务应用将用作持久存储的数据库。
Azure Cosmos DB
Azure Cosmos DB 是一个完全托管的(PaaS)NoSQL、全球分布式的、高度可扩展的数据库。Azure Cosmos DB 的一个关键特性是其多模型特性,这有助于使用不同的 API 模型,如 SQL、MongoDB 和 Gremlin,以不同的格式传递数据,例如 JSON 和 BSON。开发者可以根据自己的舒适度使用 API 来查询数据库。例如,SQL 开发者可以继续使用 SQL 查询语法查询数据库,MongoDB 开发者可以继续使用 MongoDB 语法查询数据库,等等。在底层,Azure Cosmos DB 以称为 原子记录序列(ARS)的格式存储数据库,并根据在创建数据库时选择的模式暴露数据作为 API。
Azure Cosmos DB 的另一个重要特性是其能够自动索引所有数据,而不管使用的是哪种 API 模型。所有这些操作都无需开发者额外创建索引,从而实现数据的快速检索。
Azure Cosmos DB 支持以下 API 来执行数据库操作,我们在创建数据库时选择这些 API:
-
SELECT * FROM product WHERE product.Name = 'Mastering enterprise application development Book'。 -
db.product.find({"Name": 'Mastering enterprise application development Book'})。就像 MongoDB 一样,数据以 BSON 表示。 -
Gremlin(图)API:此 API 支持使用 Gremlin 语言查询和遍历以图格式表示的数据。这在数据可以表示为图形式并通过其关系进行查询的情况下非常理想。一个典型的例子是一个推荐引擎,它可以建立两个实体之间的关系并提出推荐。
此外,还有 Cassandra API,它使用Cassandra 查询语言(CQL)在数据库上操作,然后是 Table API,它可以用作构建在 Azure 表存储之上的应用程序的数据存储。
如您所见,有相当多的 API,并且还有更多正在添加。选择正确的 API 完全取决于应用程序需求;然而,以下几点可以用来缩小选择范围:
-
如果是新的应用程序,请选择核心(SQL)API。
-
如果它是一个基于 NoSQL 的现有应用程序,请根据底层数据存储选择相关的 API。例如,如果现有数据库是 MongoDB,请选择 Mongo API 等。
-
对于处理特定场景,例如建立数据之间的关系,请使用 Gremlin API。
对于我们的企业应用程序,由于我们是从头开始构建此应用程序的,因此我们将选择核心(SQL)API 作为与 Azure Cosmos DB 交互的 API。
让我们从创建一个简单的控制台应用程序开始,并在 Azure Cosmos DB 上执行一些操作,我们稍后将在构建我们的数据访问服务时重用这些概念:
-
首先,我们需要有一个 Azure Cosmos DB 账户,因此登录到 Azure 门户,点击创建资源,然后选择数据库 | Azure Cosmos DB。
-
这将打开创建 Azure Cosmos DB 账户页面。填写以下截图所示的详细信息,然后点击审查 + 创建。这是我们选择要选择的 API 的页面,在我们的情况下是核心(SQL)API:
图 9.2 – 创建 Azure Cosmos DB 账户页面
-
账户创建完成后,导航到Azure Cosmos DB 账户 | 密钥。复制URI和PRIMARY KEY值。
-
打开命令行,使用以下命令创建控制台应用程序:
dotnet new console --framework net6.0 --name EcommerceSample -
导航到
EcommerceSample文件夹,并使用以下命令安装 Azure Cosmos DB SDK:dotnet add package Microsoft.Azure.Cosmos -s https://api.nuget.org/v3/index.json -
在此阶段,我们可以将文件夹在 VS Code 中打开。一旦我们在 VS Code 中打开文件夹,它将看起来如下所示:
图 9.3 – VS Code 中的 EcommerceSample
-
打开
Program.cs文件,并将以下静态变量添加到Program类中,这些变量将保存 步骤 3 中复制的 URI 和 主键 值:string Uri = "YOUR URI HERE "; string PrimaryKey = "YOUR PRIMARY KEY HERE"; -
现在,让我们添加代码来创建
CosmosClient类的实例,并使用它来创建 Azure Cosmos DB 数据库。随后,此对象将被用于与我们的 Azure Cosmos DB 数据库进行通信。由于CosmosClient实现了IDisposable接口,我们将在using块内创建它,以便对象可以在using块之后自动释放。一旦运行此代码并导航到Ecommerce,就会创建数据库。由于我们使用 Core (SQL) API 创建了 Azure Cosmos DB 账户,因此此数据库将支持使用 SQL 语法进行查询:using (CosmosClient cosmosClient = new CosmosClient(Uri, PrimaryKey)) { DatabaseResponse createDatabaseResponse = await cosmosClient.CreateDatabaseIfNotExistsAsync ("ECommerce"); Database database = createDatabaseResponse.Database; } -
现在,让我们在
createDatabaseResponse之后添加代码来创建一个类似于 SQL 表的容器。由于我们正在使用CreateDatabaseIfNotExistsAsync来创建数据库,运行相同的代码不会引发任何异常:var containerProperties = new ContainerProperties ("Products", "/Name"); var createContainerResponse = await database.CreateContainerIfNotExistsAsync( containerProperties, 10000); var productContainer = createContainerResponse. Container;
一旦运行此代码,我们可以在 Azure 门户中看到在 Ecommerce 数据库下创建了一个名为 Products 的容器:
图 9.4 – 产品容器
容器是 Azure Cosmos DB 中的一个单元,它在多个区域中进行水平分区和复制。在前面的代码中,我们在创建容器时传递了 ContainerProperties,你可以看到其中一个值是 Name,这实际上就是一个分区键。
分区是 Azure Cosmos DB 的一个关键特性,它根据分区键将容器内的数据分割成多个逻辑分区,即具有相同分区键的所有项都属于同一个逻辑分区。使用分区键,Azure Cosmos DB 实现了数据库的水平扩展,因此满足了应用程序的可扩展性和性能需求。
选择分区键是一个关键的设计决策,因为它将极大地帮助数据库进行扩展并提高性能。此外,分区键不能更改,必须在创建容器时定义。在选择分区键时,以下几点可以牢记在心:
-
它应该具有最大数量的唯一值;唯一值的数量越多,分区效果越好。例如,如果我们正在创建一个用于产品的容器,产品 ID 或名称可以作为分区键,因为这两个属性可以唯一标识大多数产品。在底层,如果选择产品名称作为分区键,并且内部有 100 个产品,那么在 Azure Cosmos DB 中将表示为 100 个逻辑容器。在这里,产品类别也可以作为分区键,但在将其作为分区键选择之前,我们需要评估样本数据并根据需求做出决定。
-
如果没有明显的唯一选择,我们可以选择在过滤查询中最常用的字段,所以基本上是一个在
where子句中经常使用的列。小贴士
在实际应用中,创建 Azure Cosmos DB 账户应使用 ARM 模板或使用 Terraform 实现,这样模板可以轻松地与持续部署(CD)集成。
基于此,让我们向我们的产品容器添加一些数据并查询它:
- 我们将根据以下示例 JSON 添加此实体。根据产品类别,可能会有不同的属性。
例如,如果产品类别是Books,则Authors和Format等字段中会有值;然而,如果类别是Clothing,则Size和Color等字段会有值。这种模式可以在我们的电子商务应用程序中重用:
{
"Id": "Book.1",
"Name": "Mastering enterprise application
development Book",
"Category": "Books",
"Price": 100,
"Quantity": 100,
"CreatedDate": "20-02-2020T00:00:00Z",
"ImageUrls": [],
"Rating": [
{"Stars": 5, "Percentage": 95},
{"Stars": 4, "Percentage": 5}
],
"Format": ["PDF","Hard Cover"],
"Authors": ["Rishabh Verma","Neha Shrivastava",
"Ravindra Akela","Bhupesh Guptha"],
"Size": [],
"Color": []
}
-
现在,让我们创建
Product。在 Azure Cosmos DB 的 Core (SQL) API 中,任何实体的必填字段之一是id字段,它类似于主键。因此,对于我们的父模型来说,定义id字段是必要的。这些类看起来如下所示:public class Rating{ public int Stars { get; set; } public int Percentage { get; set; } } public class Product{ [JsonProperty(PropertyName = "id")] public string ProductId { get; set; } public string Name { get; set; } public string Category { get; set; } public int Price { get; set; } public int Quantity { get; set; } public DateTime CreatedDate { get; set; } public List<string> ImageUrls { get; set; } public List<Rating> Rating { get; set; } public List<string> Format { get; set; } public List<string> Authors { get; set; } public List<int> Size { get; set; } public List<string> Color { get; set; } } -
现在,让我们创建以下
Product类的对象并将其插入到数据库中:Product book = new Product() { ProductId = "Book.1", Category = "Books", Price = 100, Name = "Mastering enterprise application development Book", Rating = new List<Rating>() { new Rating { Stars = 5, Percentage = 95 }, new Rating { Stars = 4, Percentage = 5 } }, Format = new List<string>() { "PDF", "Hard Cover" }, Authors = new List<string>() { "Suneel", "Arun", "Ravindra", "Bhupesh" } }; -
现在,我们将使用
productContainer对象调用CreateItemAsync方法,如下面的代码片段所示。(还有其他从数据库检索记录的方法,其中一种将在下一点中展示。)我们还应该确保没有已经存在具有相同ProductId值的对象:try { // Check if item it exists. ItemResponse<Product> productBookResponse = await productContainer.ReadItemAsync<Product>( book.ProductId, new PartitionKey(book.Name)); } catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) { ItemResponse<Product> productBookResponse = await productContainer.CreateItemAsync<Product>(book, new PartitionKey(book.Name)); Console.WriteLine($"Created item {productBookResponse.Resource.ProductId}"); }
一旦我们运行此代码,数据应该会被插入到Ecommerce数据库下的Products容器中。
-
如果我们想要以不同于前一点提到的方式查询此记录,我们可以使用以下代码来查询数据库。正如您所看到的,语法与从 SQL 数据库查询数据非常相似:
string getAllProductsByBooksCAtegory = "SELECT * FROM p WHERE p.Category = 'Books'"; QueryDefinition query = new QueryDefinition(getAllProductsByBooksCAtegory); FeedIterator<Product> iterator = productContainer.GetItemQueryIterator<Product>(query); while (iterator.HasMoreResults) { FeedResponse<Product> result = await iterator.ReadNextAsync(); foreach (Product product in result) { Console.WriteLine($"Book retrived – {product.Name}"); } }
同样,ContainerClass提供了可用于各种 CRUD 操作的所有相关方法。所有这些 API 都可以在以下位置找到:docs.microsoft.com/en-us/dotnet/api/microsoft.azure.cosmos.container?view=azure-dotnet。
在这个基础上,我们将设计电子商务应用程序所需的数据模型和相关数据服务层,以便由各种 API 使用。到目前为止,我们已经看到了 SQL 和 NoSQL 提供商。让我们看看我们还有哪些其他选项来持久化数据。
Azure 存储
Azure Storage 是一个高度可用和可扩展的数据存储,支持以各种格式存储数据,包括文件。主要来说,Azure Storage 支持以下四种类型的数据:
-
Azure Table:一种支持持久化无模式数据的 NoSQL 实现。
-
Azure Blob:Blob 是适合于需要上传、下载或流式传输大量文件的未结构化数据。
-
Azure 队列:这允许您以任何可序列化的格式排队消息,然后由服务进行处理。队列非常适合具有大量服务间通信的场景,并作为消息的持久层。
-
Azure 文件/Azure 磁盘:一个文件数据存储,非常适合基于本地文件 API 构建的系统。
以下是一些使 Azure 存储成为应用程序开发重要组件的几个要点:
-
高可用性:存储在 Azure 存储中的数据提供了数据中心/区域之间的复制支持,这进一步确保了某个区域的硬件故障不会导致数据丢失。
-
性能:开箱即用的 CDN 集成支持,有助于从更靠近用户的位置(边缘服务器)缓存和加载数据(尤其是静态文件),从而进一步提高性能。此外,存储类型可以升级到高级存储,利用 SSD 进一步加快磁盘 I/O 并提高性能。
-
完全托管:硬件由 Azure 完全管理,包括任何更新/维护。
-
安全性:所有存储在磁盘上的数据都进行了加密,Azure 存储中对数据的访问进一步支持私有、公共和匿名模式。
-
按量付费:与所有其他 Azure 服务一样,Azure 存储也支持基于数据量/操作的按量付费模式。
Azure 存储帐户
让我们创建一个简单的控制台应用程序,将文件上传到 Blob 并从 Blob 下载文件。要与 Azure 存储服务通信,先决条件是创建一个 Azure 存储帐户,该帐户提供对所有 Azure 存储服务的访问,并通过一个唯一的命名空间让我们能够通过 HTTP/HTTPS 访问 Azure 存储中存储的数据。要创建 Azure 存储帐户,请执行以下步骤:
- 登录 Azure 门户,点击创建资源,然后选择存储帐户。这将打开创建存储帐户页面。填写以下屏幕截图中显示的详细信息,然后点击审查 + 创建:
图 9.5 – 创建 Azure 存储帐户
对于标准层,有两个重要属性帐户类型和复制。对于帐户类型,我们有以下可能的值:
-
StorageV2(通用 v2):帐户类型的最新版本,提供对所有存储类型的访问,例如文件、块和队列。这对于新创建的存储帐户来说更可取。
-
存储(通用 v1):帐户类型的较旧版本,提供对所有存储类型的访问,例如文件、块和队列。
-
BlobStorage:仅支持 blob 存储的帐户类型。
另一个是复制,它支持在数据中心/区域之间复制存储数据。可能的值在以下屏幕截图中显示:
图 9.6 – Azure 存储账户中的复制选项
-
一旦创建账户,导航到存储账户 | 密钥。复制连接字符串值。
-
创建一个新的.NET 6 控制台应用程序并安装
Azure.Storage.BlobsNuGet 包。 -
要将内容上传到 Azure 存储,我们首先需要创建一个容器。我们将使用
Azure.Storage.Blobs.BlobContainerClient类及其CreateIfNotExistsAsync方法来创建容器(如果不存在)。有了这个,更新Program类,如下面的代码片段所示:string connectionString = «CONNECTION_STRING"; string containerName = «fileuploadsample"; string blobFileName = «sample.png"; // Upload file to blob BlobContainerClient containerClient = new BlobContainerClient(connectionString, containerName); await containerClient.CreateIfNotExistsAsync( PublicAccessType.None);//Making blob private. -
接下来,我们需要将文件上传到容器,我们将使用
Azure.Storage.Blobs.BlobClient,它接受连接字符串、容器名称和 blob 名称作为输入参数。对于此示例,我们将上传一个本地的sample.png文件到 blob,我们将使用FileStream类读取它,并将其传递给Azure.Storage.Blobs.BlobClient类的UploadAsync方法。在Main方法中容器创建后添加以下代码片段:BlobClient blobClient = new BlobClient(connectionString, containerName, blobFileName); using FileStream fileStream = File.OpenRead(blobFileName); // blobFileName is relative path of the file. await blobClient.UploadAsync(fileStream, true); fileStream.Close(); Console.WriteLine(blobClient.Uri.ToString());
在此阶段运行示例将文件上传到 blob,并在命令行中显示 blob URL。然而,如果我们尝试访问 URL,由于创建的 blob 是私有的,它将无法访问。要访问私有 blob,我们需要生成一个Main方法:
BlobSasBuilder sasBuilder = new BlobSasBuilder()
{
BlobContainerName = containerClient.Name,
Resource = "b", // c for container
BlobName = blobClient.Name
};
sasBuilder.ExpiresOn = DateTimeOffset.UtcNow.AddHours(1); // Setting expiry time of the SAS link to 1 hour
sasBuilder.SetPermissions(BlobContainerSasPermissions.Read);
if (blobClient.CanGenerateSasUri)
{
Uri blobSasUri =
blobClient.GenerateSasUri(sasBuilder);
Console.WriteLine(blobSasUri.ToString());
}
Console.ReadLine();
在这里,我们使用Azure.Storage.Sas.BlobSasBuilder类来配置各种参数,例如权限和过期时间,以生成上传文件的 SAS URI。最后,前面代码的输出如下所示:
图 9.7 – Blob 上传输出和存储资源管理器
这是一个使用 Azure Storage 进行文件上传的小示例。这可以进一步扩展为一个 API,最终可用于文件上传和下载场景。对于我们的电子商务应用程序,我们将使用 Azure Blob 来存储产品的图片。
注意
关于 Azure 存储的更高级概念和示例,请参考以下链接:
docs.microsoft.com/en-us/azure/storage/common/storage-account-overview
github.com/Azure/azure-sdk-for-net/tree/master/sdk/storage/Azure.Storage.Blobs/samples
在本节中,我们讨论了.NET 6 中可用的各种数据提供者。然而,一个简化数据持久化的重要库是 EF。让我们看看如何在.NET 6 应用程序中集成 EF。
使用 EF Core
EF Core 是一个 ORM,它被推荐用于任何使用关系数据库作为数据存储的 ASP.NET Core 6 应用程序。之前,我们看到了在 ADO.NET 中,我们必须创建Connection、Command和Reader对象。EF 通过提供抽象并允许开发者编写应用程序代码来简化这个过程,并且像任何其他 ORM 一样,EF 帮助开发者使用对象模型范式在数据库上执行各种操作。
配置 EF Core 就像安装所需的 NuGet 包,在Program类中注入所需的服务,然后在需要的地方使用它们。在这个过程中,需要定义的一个关键类是数据库上下文,它需要继承Microsoft.EntityFrameworkCore.DbContext类。让我们看看我们如何做到这一点,以及剩余的 EF Core 配置。
配置和查询
EF Core 中的DbContext类包含我们应用程序与数据库通信所需的所有抽象,因此集成 EF Core 的关键设置之一是定义我们的应用程序特定上下文类。这个类将主要以DbSet类型的公共属性的形式持有所有 SQL 表/视图,如下面的代码所示:
public virtual DbSet<Employee> Employees { get; set; }
在这里,Employee是代表我们数据库中表的 POCO 类。应用程序上下文类应该有一个参数化构造函数,它接受DbContextOptions或DbContextOptions<T>并将其传递给基类。
让我们基于 Razor Pages 和 SQLite 创建一个简单的 Web 应用程序,并使用 EF Core 读取数据。对于这个示例,我们将使用 SQLite 作为数据存储,选择一个简单的员工数据库,其中包含以下数据模型:
图 9.8 – 员工数据库模型
如果你之前没有使用过 Razor Pages,不要担心;它是一个基于页面的框架,可以用于在 ASP.NET Core 6 中构建数据驱动应用程序,并在第十一章中介绍,即创建 ASP.NET Core 6 Web 应用程序。
现在,按照以下步骤创建我们的应用程序:
-
使用以下命令从命令行创建一个新的 Razor Pages 应用程序,这将创建一个名为
EmployeeEF的新 Razor Pages 应用程序:dotnet new webapp --framework net6.0 --name EmployeeEF -
导航到
EmployeeEF文件夹,并在 Visual Studio Code 中打开它,然后安装以下 NuGet 包:-
Microsoft.EntityFrameworkCore.Sqlite -
Microsoft.EntityFrameworkCore.Design
-
前一个包是 SQLite 的 EF Core 提供程序,后一个包用于使用 EF Core 迁移创建基于 C# POCOs 的数据库。
-
现在,添加
Models文件夹,并按照以下方式添加必要的 POCO 类。这些类代表来自图 9.8的数据库模式:public class Address { public int AddressId { get; set; } public int EmployeeId { get; set; } public string City { get; set; } public Employee Employee { get; set; } } public class Employee { public int EmployeeId { get; set; } public string Name { get; set; } public string Email { get; set; } public ICollection<Address> Address { get; set; } } -
在这里,数据库表中的所有列都表示为具有相关数据类型的属性。对于如外键这样的关系,会在子类型中创建一个属性(称为
ICollection),同时在父类类型中创建另一个属性。例如,在前面的代码中,这通过public Icollection<Address> Addresses和public Employee Employee属性表示,这些属性定义了Employee和Address表之间的外键约束。任何名为ID或<class name>ID (EmployeeID)的属性都会自动被认为是主键。可以在OnModelCreating期间使用 Fluent API 或在System.ComponentModel.DataAnnotations中使用注解进一步定义约束。有关模型创建的更多示例和详细信息,请参阅docs.microsoft.com/en-us/ef/core/modeling。 -
添加一个继承自
Microsoft.EntityFrameworkCore.DbContext的类,并将其命名为EmployeeContext。添加以下代码以定义我们的数据库上下文:public class EmployeeContext : DbContext { public DbSet<Employee> Employees { get; set;} public DbSet<Address> Addresses { get; set;} public EmployeeContext (DbContextOptions <EmployeeContext> options) : base(options) {} protected override void OnModelCreating (ModelBuilder modelBuilder) { modelBuilder.Entity<Employee>().ToTable ("Employee"); modelBuilder.Entity<Address>().ToTable ("Address"); } } -
在
appsettings.json中添加连接字符串。由于我们使用 SQLite,指定数据源中的文件名应该足够。但是,这会根据提供程序而变化:"ConnectionStrings": { "EmployeeContext": "Data Source=Employee.db" } -
现在,在
Program类中注入数据库上下文类,以便在整个应用程序中可用。在这里,我们还传递连接字符串并配置任何其他选项,例如重试策略和查询记录:builder.Services.AddDbContext<EmployeeContext>(options => { options.UseSqlite(builder.Configuration.GetConnectionString("EmployeeContext")); });
我们几乎完成了 EF Core 的设置。因此,现在让我们创建一些可以用于填充数据库的示例数据。
-
为了这个目的,我们将在我们的数据库上下文中创建一个扩展方法并在启动时调用它。创建一个名为
DbContextExtension的静态类,并向其中添加以下代码。此代码只是向数据库中添加一些记录:public static void SeedData(this EmployeeContext context) { SeedEmployees(context); } private static void SeedEmployees(EmployeeContext context) { if (context.Employees.Any()) { return; } var employees = new Employee[] { new Employee{EmployeeId = 1, Name = "Sample1", Email="Sample@sample.com"}, new Employee{EmployeeId = 2, Name = "Sample2", Email="Sample2@sample.com"}, new Employee{EmployeeId = 3, Name = "Sample3", Email="Sample3@sample.com"} }; context.Employees.AddRange(employees); var adresses = new Address[] { new Address{AddressId = 1, City = "City1", EmployeeId = 1}, new Address{AddressId = 2, City = "City2", EmployeeId = 1}, new Address{AddressId = 3, City = "City1", EmployeeId = 2}, }; context.Addresses.AddRange(adresses); context.SaveChanges(); } -
打开
Program类并添加以下代码,该代码在应用程序启动时填充数据。由于这是开发环境,我们可以检查环境是否为开发环境并添加它。由于我们在插入之前检查员工表中的内容,因此应用程序的多次运行不会覆盖数据:using (var serviceScope = ((IApplicationBuilder)app).ApplicationServices?.GetService<IServiceScopeFactory>()?.CreateScope()) { using (var context = serviceScope?.ServiceProvider .GetRequiredService<EmployeeContext>()) { context?.SeedData(); } } -
现在,在 VS Code 终端中运行
dotnet build以修复任何构建错误。为了从我们的模型生成数据库并填充数据库,我们需要在本地或全局安装dotnet-ef并在 VS Code 终端中运行迁移命令,如下所示,这将生成Migrations文件夹,然后是Employee.db文件,这是我们的 SQLite 数据库:dotnet tool install --global dotnet-ef --ignore-failed-sources //Installing dotnet ef. dotnet ef migrations add InitialCreate //Generate DB migrations. dotnet ef database update //Update database. -
现在,要读取
Employee表,导航到Index.cshtml.cs并粘贴以下代码。在这里,我们注入EmployeeContext并从员工表中读取数据:public class IndexModel : PageModel { private readonly EmployeeContext context; public IndexModel(EmployeeContext context) { this.context = context; } public Ilist<Employee> Employees { get; set; } public async Task OnGetAsync() { this.Employees = await this.context. Employees.Include(x => x.Address). AsNoTracking().ToListAsync(); } } -
使用以下代码更新
Index.cshtml,该代码遍历IndexModel的Employees属性中填充的员工记录并显示它们:<table class="table"> <tbody> @foreach (var item in Model.Employees) {<tr> <td>@Html.DisplayFor(modelItem => item.EmployeeId)</td> <td>@Html.DisplayFor(modelItem => item.Name)</td> <td>@Html.DisplayFor(modelItem => item.Email)</td> <td> @foreach (var address in item.Address) { @Html.DisplayFor(modelItem => address.City) @Html.DisplayName(" ") } </td> </tr> } </tbody> </table>
一旦运行此代码,我们可以在浏览器中看到以下输出:
图 9.9 – 员工应用输出
类似地,DbContext 类中还有其他可用方法,例如 Add()、Remove() 和 Find(),用于执行各种 CRUD 操作,以及 FromSqlRaw() 方法用于执行原始 SQL 查询或存储过程。
这是一个非常简单的例子,其主要目的是展示 EF Core 在实际应用中的能力。我们可以使用存储库模式,其中包含所有 CRUD 方法的通用存储库和特定存储库来执行表上的专用查询。此外,可以使用工作单元模式进行事务处理。
代码优先与数据库优先
在前面的示例中,我们创建了新的 POCOs 并从它们中生成了一个数据库;这种从 POCOs 生成数据库的风格被称为 代码优先方法。正如定义所暗示的,我们首先定义了 POCOs,然后生成了数据库。
然而,很多时候,尤其是在迁移场景中或者有专门的数据库团队的情况下,我们需要从数据库表中生成 POCOs。EF Core 通过 数据库优先方法 支持这种场景,其中模型和应用程序数据库上下文类是从现有数据库生成的。
从数据库模型生成 POCOs 的这个过程被称为 Scaffold-DbContext 命令,它接受各种参数,例如数据库连接字符串和应用程序数据库上下文类的名称,然后生成所有必需的类,用于 EF Core。
其余的配置与代码优先方法保持一致。一个带有各种参数的示例脚手架命令如下所示:
Scaffold-DbContext "Data Source=.;Initial Catalog=Employee.DB;Trusted_Connection=True;" Microsoft.EntityFrameworkCore.SqlServer -Namespace Api.Data.Models -ContextNamespaceApi.Data -ContextDir Api.Data/Abstraction -Context EmployeeContext -Force
在这个命令中,我们正在读取一个数据库,Employee.DB,在 Namespace Api.Data.Models 内生成所有模型,在 Api.Data/Abstraction 内生成上下文,并将上下文命名为 EmployeeContext。在数据库优先方法中,类之间的关系使用 Fluent API 定义,而不是使用注解。
这里有一点需要注意,每次我们运行这个命令时,所有的 POCOs 都会被覆盖,包括应用程序上下文类。其次,这个命令生成一个包含 protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 方法的上下文类。只有当上下文类需要维护连接字符串和其他 EF Core 选项时,这个方法才是必需的。然而,在大多数实际应用中,连接字符串保存在 appsettings.json 中,EF Core 在 Program 类中配置,因此这个方法可以被删除。
这意味着每次我们使用 Scaffold 时都涉及清理,为了避免任何自定义,更好的方法是创建一个用于我们的应用程序数据库上下文的局部类,并在那里进行所有自定义,例如添加特定的存储过程模型或定义任何应用程序特定的约束。这样,每次我们 Scaffold 应用程序时,自定义都不会被覆盖,这仍然允许我们从数据库自动生成类。
选择数据库优先方法或代码优先方法是完全由开发团队决定的,因为两种方法都有优点和缺点,并且没有一种方法具有另一种方法所不具备的特定功能。
注意
Scaffold-DbContext 支持多个参数;例如,您可以为生成 POCOs 的架构指定一个模式。有关进一步阅读,请参阅 docs.microsoft.com/en-us/ef/core/managing-schemas/scaffolding?tabs=dotnet-core-cli。
基于这种理解,让我们创建将在下一节中用于我们企业应用程序的数据访问服务。
使用 Azure Cosmos DB 设计数据访问服务
由于 NoSQL 数据库都是关于快速访问和高可扩展性,因此 NoSQL 的架构是非规范化的,因此数据冗余的可能性很高。让我们将我们从 第一章,设计和架构企业应用程序,的需求映射到各种实体。以下图显示了架构中各种服务的快速回顾:
](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/entp-appdev-csp10-dn6/img/Figure_9.10_18507.jpg)
图 9.10 – 电子商务应用程序中的服务
为了便于理解,在转向 POCOs 之前,我们将以 JSON 格式表示实体:
-
Email字段用作分区键:{ "Id": "1", "Name": "John", "Email": "John@xyz.com", "Address":[{"Address1":"Gachibowli","City": "Hyderabad","Country":"India"}], "PhoneNumber":12345 } -
Name字段用作分区键。 -
Id字段用作分区键:{ "Id": "1", "UserId": "1", "Products": [{"Id":"1","Name": "T-Shirt","Quantity": 1,"Price": 10}], "OrderStatus" : "Processed", "OrderPlacedDate" : "20-02-2020T00:00:00Z", "ShippingAddress": {"Address1":"Gachibowli", "City":"Hyderabad","Country":"India"}, "TrackingId": 1, "DeliveryDate":"28-02-2020T00:00:00Z" } -
Id字段用作分区键:{ "Id": "1", "OrderId": "1", "PaymentMode": "Credit Card", "ShippingAddress": {"Address1":"Gachibowli", "City":"Hyderabad","Country":"India"}, "SoldBy": {"SellerName": "Seller1", "Email": "seller@ecommerce.com", "Phone": "98765432"}, "Products": [{"Id":"1", "Name": "T-Shirt", "Quantity": 1, "Price": 10}] }
下面的截图显示了 Product 和 Order 的组合:
图 9.11 – 电子商务数据库模型的 Product 和 Order 架构
如您所见,所有 1:N 关系都通过将子项作为数组嵌入来处理。同样,Invoice 和 User 实体架构如图下截图所示:
](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/entp-appdev-csp10-dn6/img/Figure_9.12_18507.jpg)
图 9.12 – 电子商务数据库模型的 Invoice 和 User 架构
在我们的企业应用程序中,github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Enterprise%20Application,我们将有一个服务与 Azure Cosmos DB 数据库进行交互。此服务包含以下三个项目,接下来将进行解释:
-
Packt.Ecommerce.Data.Models -
Packt.Ecommerce.DataStore -
Packt.Ecommerce.DataAccess
第一个项目是Packt.Ecommerce.Data.Models,这是一个.NET Standard 2.1 库,包含我们与数据库通信的所有 POCOs。如前所述,所有 POCOs 都将有一个共同的id属性和前一小节中 JSON 模式中描述的其他属性。
小贴士
如果有示例 JSON,我们可以在 C#类生成工具中使用 JSON。
Packt.Ecommerce.DataStore是一个.NET Standard 2.1 库,是存储库层,包含一个通用存储库和特定实体的存储库。此项目中的一个重要类是BaseRepository,它具有以下方法,并且每个方法都调用CosmosClient类的相应方法:
-
GetAsync(string filterCriteria): 此方法根据filterCriteria从容器中获取记录。如果filterCriteria为空,则检索该容器中的所有记录。 -
GetByIdAsync(string id, string partitionKey): 此方法通过 ID 和分区键从容器中检索任何记录。 -
AddAsync(Tentity entity, string partitionKey): 此方法允许我们将一条记录插入到容器中。 -
ModifyAsync(Tentity entity, string partitionKey): 此方法允许我们在容器中UPSERT(如果记录存在则修改,否则插入)一条记录。 -
RemoveAsync(string id, string partitionKey): 此方法允许从容器中删除一条记录。
由于在 Azure Cosmos DB 中,每个记录都由 ID 和分区键的组合唯一标识,因此所有这些方法都接受一个分区键和id。由于这是一个通用存储库,类的签名如下,这允许我们传递任何应用程序的 POCO 并对其对应的容器执行 CRUD 操作:
public class BaseRepository<TEntity> : IBaseRepository<TEntity>
where TEntity : class
所有这些方法都需要一个Microsoft.Azure.Cosmos.Continer的对象,我们创建一个readonly私有成员,并在类的构造函数中初始化,如下所示:
private readonly Container container;
public BaseRepository(CosmosClient cosmosClient,
string databaseName, string containerName)
{
if (cosmosClient == null)
{
throw new Exception("Cosmos client is
null");
}
this.container = cosmosClient.GetContainer
(databaseName, containerName);
}
现在,CosmosClient将通过依赖注入集成到系统中,并在static类中进行配置。作为最佳实践,建议在整个应用程序的生命周期内只使用一个CosmosClient实例,以便更好地重用连接,因此我们将在我们的 ASP.NET Core 6 依赖注入容器中将它配置为单例。我们稍后会讨论这个问题。
回到存储库层,BaseRepository 在以下具体类中额外继承,每个存储库代表一个相应的容器:
-
ProductRepository -
UserRepository -
OrderRepository -
InvoiceRepository
以 ProductRepository 为例,它将具有以下实现,其中我们通过 Ioptions 模式传递 CosmosClient 的单例实例和额外的属性:
public class ProductRepository :
BaseRepository<Product>, IProductRepository
{
private readonly IOptions<DatabaseSettingsOptions>
databaseSettings;
public ProductRepository(CosmosClient,
IOptions<DatabaseSettingsOptions>
databaseSettingsOption)
: base(cosmosClient, databaseSettingsOption.
Value.DataBaseName, "Products")
{
this.databaseSettings = databaseSettingsOption;
}
}
所有其他存储库都将遵循类似的架构。每个存储库都将实现自己的接口以支持依赖注入。
注意
这些存储库将随着我们应用程序实现的进展而发展和演变。
下一个项目是 Packt.Ecommerce.DataAccess,这是一个针对 .NET 6 的 Web API 项目,它将主要包含所有控制器以公开我们的存储库。每个存储库将与相应的控制器进行1:1映射。例如,将会有 ProductsController 以 REST API 的形式公开 ProductRepository 方法。所有控制器都将使用构造函数注入来实例化它们对应的存储库。在 Packt.Ecommerce.DataAccess 中一个重要的事情是 Azure Cosmos DB 数据库的配置。各种控制器的设计将与 Packt.Ecommerce.Product Web API 的设计非常相似,这在第十章中讨论,创建 ASP.NET Core 6 Web API。
首先,我们将在 appsettings.json 中有一个相应的部分,如下所示:
"CosmosDB": {
"DataBaseName": "Ecommerce",
"AccountEndPoint": "",
"AuthKey": ""
}
注意
对于本地开发环境,我们将使用管理用户密钥,如这里所述:docs.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-6.0。我们将设置以下值:
{
"CosmosDB:AccountEndPoint": "", //Cosmos DB End Point
"CosmosDB:AuthKey": "" //Cosmos DB Auth key
}
然而,一旦服务部署完成,它应该使用 Azure Key Vault,如第六章中所述,在 .NET 6 中进行配置。
我们将定义一个扩展类来保存依赖注入映射。以下是其片段:
public static class RepositoryExtensions
{
public static IServiceCollection
AddRepositories(this IServiceCollection services)
{
services.AddScoped<IproductRepository,
ProductRepository>();
return services;
}
}
类似地,所有存储库都将进行映射。然后,我们将在 Program 类中配置此设置,包括 Azure Cosmos DB 配置,通过添加以下代码:
builder.Services.AddOptions();
builder.Services.Configure<DatabaseSettingsOptions>(builder.Configuration.GetSection("CosmosDB"));
string accountEndPoint = builder.Configuration.GetValue<string>("CosmosDB:AccountEndPoint");
string authKey = builder.Configuration.GetValue<string>("CosmosDB:AuthKey");
builder.Services.AddSingleton(s => new CosmosClient(accountEndPoint, authKey));
builder.Services.AddRepositories();
一旦完成配置,此服务就准备好在其他服务中使用,例如 Products、Orders 和 Invoice。此库将具有执行各种实体 CRUD 操作的所有必要的 REST API。
这标志着创建了一个执行各种实体 CRUD 操作的数据访问服务的完成,并且所有操作都作为 API 公开。这个服务将从我们将在第十章“创建 ASP.NET Core 6 Web API”中开发的其它所有服务中调用。
摘要
在本章中,我们了解了 .NET 6 中可用的各种持久化选项,从与文件和目录一起工作的 API 到如 Microsoft SQL Server 和 Azure Cosmos DB 这样的数据库。
我们还了解了对象关系映射(ORM)及其重要性,以及在使用 Microsoft SQL Server 时如何使用 EF Core 来构建持久化层。在这个过程中,我们使用 Azure Cosmos DB SDK 为我们的电子商务应用程序构建了一个数据访问层。一些关键收获是我们对 SQL 与 NoSQL 之间的设计决策,以及我们如何通过应用逻辑和 UI 层来抽象数据层,这将帮助您构建可扩展的企业应用程序。
在下一章中,我们将探讨 RESTful API 的基础和 ASP.NET Core 6 Web API 的内部结构,并进一步为电子商务应用程序构建各种 RESTful 服务。
问题
- 假设你正在将现有的 Web 应用程序迁移到使用 EF Core,但是数据库模式没有变化,现有的数据库可以直接使用。使用 EF Core 的首选模式是什么?
a. Database-first
b. Code-first
c. Both
答案:a
- 如果我们正在为我们的电子商务应用程序构建一个推荐系统,并且我们正在使用 Azure Cosmos DB,那么在这种情况下哪种 API 是最佳推荐?
a. The Core (SQL) API
b. The Mongo API
c. The Cassandra API
d. The Gremlin (graph) API
答案:d
- 我在基于 SQL API 的数据库中创建了一个容器来存储用户配置文件信息,并将
Email定义为分区键。我的系统有 100 个唯一的电子邮件。我的容器将有多少个逻辑分区?
a. 1.
b. 0.
c. 100.
d. Azure Cosmos DB 不支持逻辑分区。
答案:c
进一步阅读
以下是一些链接,可以帮助您进一步了解本章的主题:
-
docs.microsoft.com/en-us/ef/core/performance/advanced-performance-topics -
docs.microsoft.com/en-us/aspnet/core/security/gdpr?view=aspnetcore-6.0
第十章:第十章:创建 ASP.NET Core 6 Web API
近年来,Web 服务已成为 Web 应用程序开发的重要组成部分。随着需求的不断变化和商业复杂性的增加,松散耦合 Web 应用程序开发中涉及的各个组件/层非常重要,而与应用程序的核心业务逻辑解耦的方案则更为出色。这就是使用 RESTful 方法(其中 REST 代表 REpresentational State Transfer)的 Web 服务的简单性帮助我们开发可扩展的 Web 应用程序的地方。
在本章中,我们将学习如何使用 ASP.NET Core Web 应用程序编程接口(API)构建 RESTful 服务,并且在这个过程中,我们将为我们的电子商务应用程序构建所有必需的 API。
我们将详细介绍以下主题:
-
REST 简介
-
理解 ASP.NET Core 6 Web API 的内部结构
-
使用控制器和操作处理请求
-
与数据层的集成
-
理解 Google 远程过程调用(gRPC)
技术要求
对于本章,你需要具备基本的 C#、.NET Core、Web API、超文本传输协议(HTTP)、Azure、依赖注入(DI)、Postman 以及 .NET 命令行界面(CLI)知识。
本章的代码可以在以下位置找到:github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Chapter10/TestApi。
更多代码示例,请参考以下链接:github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Enterprise%20Application。
REST 简介
REST 是构建 Web 服务的架构指南。主要来说,它定义了一组在设计 Web 服务时可以遵循的约束。REST 方法的关键原则之一建议 API 应该围绕资源设计,并且应该是媒体和协议无关的。API 的底层实现与使用 API 的客户端是独立的。
以我们的电子商务应用程序为例,假设我们在 UI 中使用产品搜索字段搜索产品。应该有一个为产品创建的 API,在这里,产品只是电子商务应用程序上下文中的一个资源。对产品实体执行 GET 操作:
GET http://ecommerce.packt.com/products
API 的响应应该独立于调用 API 的客户端——也就是说,在这种情况下,我们正在使用浏览器在产品搜索页面上加载产品列表。然而,相同的 API 可以在移动应用程序中消费,而无需任何更改。其次,在这种情况下,为了内部检索产品信息,应用程序可能使用一个或多个物理数据存储;然而,这种复杂性被隐藏在客户端应用程序中,API 作为单个业务实体——产品——暴露给客户端。尽管 REST 原则并没有规定必须使用 HTTP 协议,但大多数 RESTful 服务都是建立在 HTTP 之上的。这里概述了基于 HTTP 的 RESTful API 的一些关键设计原则/约束/规则:
-
识别系统的业务实体,并围绕这些资源设计 API。在我们的电子商务应用程序中,我们所有的 API 都将围绕产品、订单、支付和用户等资源进行设计。
-
RESTful API 应该有一个统一的接口,有助于使它们独立于客户端。由于所有 API 都需要面向资源,每个资源都由 URI 唯一标识;此外,对资源的各种操作由 HTTP 动词(如
GET、POST、PUT、PATCH和DELETE)唯一标识。例如,使用GET(http://ecommerce.packt.com/products/1) 应该用来检索一个产品。同样,使用DELETE(http://ecommerce.packt.com/products/1) 应该用来删除一个产品。 -
由于 HTTP 是无状态的,REST 对 RESTful API 规定了一系列内容。这意味着 API 应该是原子的,并在同一调用中完成请求的处理。任何后续请求,即使是来自同一客户端(相同的 Internet Protocol(IP)地址),都被视为新的请求。例如,如果 API 接受身份验证令牌,它应该为每个请求接受身份验证。无状态的一个主要优点是服务器最终可以实现的可扩展性,因为客户端可以向任何可用的服务器发出 API 调用,并仍然收到相同的响应。
-
除了发送回响应之外,API 应该利用 HTTP 状态码和响应头来向客户端发送任何额外的信息。例如,如果响应可以被缓存,API 应该向客户端发送相关的响应头,以便它可以被缓存。
1xx表示信息,2xx表示成功,3xx表示重定向,4xx表示客户端错误,5xx表示服务器错误。 -
API 应该提供关于资源的信息,使得客户端能够轻松地发现它,而无需任何与资源相关的先验信息——也就是说,应该遵循超媒体作为应用状态引擎(HATEOAS)的原则。例如,如果有一个创建产品的 API,一旦产品创建成功,API 应该返回该资源的 URI,以便客户端可以用来稍后检索该产品。
有关检索所有产品列表(GET /products)和获取每个产品详细信息的信息,请参阅以下响应:
{
"Products": [
{
"Id": "1",
"Name": "Men's T-Shirt",
"Category": "Clothing"
"Uri": "http://ecommerce.packt.com/products/1"
}
{
"Id": "2",
"Name": "Mastering enterprise application development Book",
"Category": "books"
"Uri": "http://ecommerce.packt.com/products/2"
}
]
}
上述示例是实施HATEOAS原则的一种方式,但它可以设计得更加描述性,例如包含有关接受的 HTTP 动词和关系的响应信息。
REST 成熟度模型
这些是 API 应该遵循的各种指南,以便使其成为 RESTful API。然而,并非所有原则都需要遵循才能使其完美地符合 RESTful;更重要的是,API 应该实现业务目标,而不是 100%符合 REST 规范。RESTful API 设计专家 Leonard Richardson 提出了以下模型来分类 API 的成熟度:
-
执行所有操作的 URI 属于这一类别。一个例子是基于简单对象访问协议(SOAP)的 Web 服务,它有一个单一的 URI,所有操作都是基于 SOAP 信封进行隔离的。
-
级别 1—资源:所有资源都是 URI 驱动的,每个资源都有专门的 URI 模式,这些 API 属于此成熟度模型。
-
使用相同 URI 但不同 HTTP 动词进行
GET和DELETE操作属于此成熟度模型。大多数企业应用 RESTful API 都属于这一类别。 -
级别 3—HATEOAS:设计时包含所有附加发现信息(资源的 URI;资源支持的各种操作)的 API 属于此成熟度模型。很少有 API 符合此成熟度级别;然而,如前所述,重要的是我们的 API 应该实现业务目标,并且尽可能符合 RESTful 原则,而不是 100%符合但未实现业务目标。
下图说明了 Richardson 成熟度模型:
![图 10.1 – Richardson 的成熟度模型
![img/Figure_10.1_B18507.jpg]
图 10.1 – Richardson 的成熟度模型
到目前为止,我们已经讨论了 REST 架构的各种原则。在下一节中,让我们深入了解使用 ASP.NET Core Web API,我们将在我们的电子商务应用程序中创建各种 RESTful 服务。
理解 ASP.NET Core 6 Web API 的内部结构
ASP.NET Core是一个在.NET Core 之上运行的统一框架,用于开发 Web 应用程序(MVC/Razor)、RESTful 服务(Web API)以及最近基于 Web Assembly 的客户端应用程序(Blazor 应用程序)。ASP.NET Core 应用程序的基本设计基于模型-视图-控制器(MVC)模式,将代码分为三个主要类别,如下所示:
- 模型:这是一个简单的普通 CLR 对象(POCO)类,用于存储数据并在应用程序的各个层之间传递数据。层包括在仓库类和服务类之间传递数据,或者在与客户端和服务器之间来回传递信息。模型主要表示资源状态或应用程序的领域模型,并包含您请求的信息。
例如,如果我们想存储用户配置文件信息,这可以通过UserInformation POCO 类表示,并可以包含所有配置文件信息。这将进一步用于在仓库和服务类之间传递,也可以在发送回客户端之前将其序列化为JavaScript 对象表示法(JSON)/可扩展标记语言(XML)。在企业应用程序中,在与数据层集成部分创建我们的电子商务应用程序模型时,我们将遇到不同类型的模型。
-
视图:这些是表示 UI 的页面。我们从控制器检索的所有模型都绑定到视图上的各种超文本标记语言(HTML)控件,并向用户展示。视图在 MVC/Razor 应用程序中通常是常见的;对于 Web API 应用程序,过程以将模型序列化为响应结束。
-
使用
Microsoft.AspNetCore.Mvc.ControllerBase类来定义控制器。
因此,在用 ASP.NET Core 开发的 Web 应用程序中,每当客户端(浏览器、移动应用等类似来源)发起请求时,它将通过 ASP.NET Core 请求管道,到达与数据存储交互的控制器,以填充模型/视图模型并将它们以 JSON/XML 形式作为响应发送回去,或者发送到视图中以进一步绑定响应并向用户展示。
如您所见,存在一个清晰的关注点分离(SOC),其中控制器不了解任何 UI 方面,并在当前上下文中执行业务逻辑,并通过模型进行响应;另一方面,视图接收模型并使用它们在 HTML 页面上向用户展示。这种 SOC 有助于轻松地进行单元测试,以及根据需要维护和扩展应用程序。MVC 模式不仅适用于 Web 应用程序,还可以用于任何需要 SOC 的应用程序。
由于本章的重点是构建 RESTful 服务,因此在本章中我们将关注 ASP.NET Core Web API,并在第十一章 创建 ASP.NET Core 6 Web 应用程序中讨论 ASP.NET MVC 和 Razor Pages。
为了开发 RESTful 服务,有许多框架可供选择,但选择在.NET 6 上使用 ASP.NET Core 有以下一些优势:
-
跨平台支持:与 ASP.NET 不同,它曾经是.NET Framework 的一部分(与 Windows 操作系统耦合),ASP.NET Core 现在是应用程序的一部分,从而消除了平台依赖性,使其与所有平台兼容。
-
高度可定制的请求管道: 使用中间件和支持注入各种开箱即用的模块,例如日志和配置。
-
IIS和HTTP.sys。注意
默认情况下,Kestrel 是 ASP.NET Core 模板中使用的 HTTP 服务器;然而,这可以根据需要覆盖。
-
强大的工具支持: 这包括Visual Studio Code(VS Code)、Visual Studio 和 DOTNET CLI,以及项目模板,这意味着开发者可以以非常少的设置开始实现业务逻辑。
-
开源: 最后,整个框架已经开源,可在
github.com/aspnet/AspNetCore找到。
因此,我们现在知道为什么我们选择 ASP.NET Core 作为开发 RESTful 服务的框架。现在让我们看看一些关键组件,这些组件有助于请求的执行,并使用以下命令创建一个示例 Web API:
dotnet new webapi -o TestApi
在成功执行上述命令后,让我们导航到TestApi文件夹,并在 VS Code 中打开它,查看生成的各种文件,如下面的截图所示:
![Figure 10.2 – 在 VS Code 中测试 Web API 项目
![img/Figure_10.2_B18507.jpg]
Figure 10.2 – 在 VS Code 中测试 Web API 项目
在这里,你可以看到用于启动应用的Program类,以及用于运行 Web API 项目的设置文件,如appsettings.json,还有WeatherForecast,这是一个在控制器类中使用的模型类。接下来几节将逐一检查TESTAPI的各个组件。
Program类
Program类用于在 ASP.NET Core 6 中启动 Web API 项目。接下来几步将查看这个类执行的活动:
-
Program类是我们 Web API 的入口点,它告诉 ASP.NET Core 每当有人执行 Web API 项目时开始执行。主要来说,这是一个用于启动应用的类。与 ASP.NET Core 的早期版本应用不同,默认情况下我们没有Startup类——也就是说,Program类包含了我们所需的一切——为了保持代码最小化,我们进一步依赖于 C# 10 的顶级语句和全局 using 语句。 -
由于这是入口点,我们需要确保所有组件,如 Web 服务器、路由和配置,都得到初始化和加载,这正是
WebApplication类的CreateBuilder方法所帮助实现的。它主要创建一个WebApplicationBuilder对象,该对象可用于配置 HTTP 请求管道。 -
WebApplicationBuilder继承自IApplicationBuilder接口,这实际上只是一个封装了这些组件的对象,例如默认的 Kestrel HTTP 服务器,所有中间件组件,以及任何额外的服务——例如日志记录——这些服务被注入。最后,调用Build()方法来运行操作并初始化Host对象。调用Run()方法来保持Host对象的运行。
既然我们已经加载了包含所有默认组件的 Host 对象,并且它正在运行,让我们检查我们是否可以注入额外的 ASP.NET Core 类/应用程序特定的类(存储库、服务、选项)和中间件。考虑以下要点:
-
此
WebApplicationBuilder对象用于注入任何 ASP.NET Core 提供的服务,以便应用程序可以使用这些服务。以下代码片段展示了企业应用程序可以注入的一些常见服务:var builder = WebApplication.CreateBuilder(args); builder.Services.AddAuthentication() // To enable //authentication. builder.Services.AddControllers(); // To enable //controllers like web API. builder.Services.AddControllersWithViews(); // To //enable controller with views. builder.Services.AddDistributedMemoryCache(); // To enable distributed caching. // App insights. string appinsightsInstrumentationKey = builder.Configuration.GetValue<string>("ApplicationSettings:InstrumentationKey"); builder.Services.AddApplicationInsightsTelemetry(appInsightInstrumentKey); // To enable application insights //telemetry.
除了 ASP.NET Core 提供的服务之外,我们还可以注入任何特定于我们应用程序的自定义服务——例如,ProductService 可以映射到 IProductService 并在整个应用程序中可用。主要来说,这是我们可以使用来将任何内容集成到依赖注入容器中的地方,如 第五章 中所述,.NET 6 中的依赖注入。
此外,所有服务,包括 ASP.NET Core 服务和自定义服务,都可以集成到应用程序中,并作为 IServiceCollection 的扩展方法可用。
- 接下来,我们有一个
WebApplication类的对象,可以用来集成所有需要应用到请求管道的中间件。此对象主要控制应用程序如何响应 HTTP 请求——即应用程序应该如何响应异常,如何响应静态文件,或者如何进行 URI 路由。所有这些都可以使用此对象进行配置。
此外,任何对请求管道的特定处理——例如调用自定义中间件或添加特定的响应头,甚至定义特定的端点——都可以使用 WebApplication 对象注入。所以,除了我们之前看到的之外,以下代码片段展示了使用 WebApplication 对象可以集成的几个常见额外配置:
var app = builder.Build();
// Endpoint that responds to /subscribe route.
app.UseEndpoints(endpoints =>
{
endpoints.MapGet("/subscribe", async context =>
{
await context.Response.WriteAsync("subscribed");
});
});
// removing any unwanted headers.
app.Use(async (context, next) =>
{
context.Response.Headers.Remove("X-Powered-By");
context.Response.Headers.Remove("Server");
await next().ConfigureAwait(false);
});
在这里,app.UseEndpoints 正在配置一个匹配 /subscribe URI 的响应。app.UseEndPoints 与路由规则协同工作,并在 使用控制器和操作处理请求 部分中解释,而 app.Use 则用于添加内联中间件。在这种情况下,我们正在从响应中移除 X-Powered-By 和 Server 响应头。
由于 ASP.NET Core 6 支持使用 Program 类,因此可以单独使用它来构建一个完全工作的 API;然而,对于企业应用程序来说,为了便于维护和更好的可读性,最好将 API 与控制器分离,因此在我们的企业应用程序中,我们将使用由 MapControllers 中间件支持的 API,并通过调用 app.MapControllers() 来配置。
总结来说,Program 类在启动应用程序和根据需要自定义应用程序服务和 HTTP 请求/响应管道方面发挥着至关重要的作用。
现在我们来看看中间件如何帮助定制 HTTP 请求/响应管道。
注意
ASP.NET Core 6 仍然支持使用 Startup 类,并且可以直接使用 Configure 和 ConfigureServices 方法。
理解中间件
我们已经提到了中间件一段时间了,现在让我们了解中间件是什么,以及我们如何构建一个中间件并在我们的企业应用程序中使用它。中间件是拦截传入请求、对请求执行一些处理并将它们传递给下一个中间件或按需跳过的类。中间件是双向的,因此所有中间件都会拦截请求和响应。假设一个 API 获取产品信息,在这个过程中,它会通过各种中间件。以图形形式表示,看起来可能像这样:
图 10.3 – 中间件处理
每个中间件都有一个 Microsoft.AspNetCore.Http.RequestDelegate 的实例。因此,使用这个实例,中间件会调用下一个中间件。所以,流通常会按照你希望中间件在请求上执行的处理逻辑来处理请求,然后调用 RequestDelegate 将请求传递给管道中的下一个中间件。
如果我们从制造业中找一个类比,那就像制造过程中的装配线,部件从工作站到工作站添加/修改,直到最终产品生产出来。在前面的图中,让我们将每个中间件视为一个工作站,因此它将经历以下步骤:
注意
下面每个中间件的解释只是为了我们理解而做的假设性解释;这些中间件的内部工作原理与这里所解释的略有不同。更多详情可以在这里找到:docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.builder?view=aspnetcore-3.1&viewFallbackFrom=aspnetcore-6.0。
-
UseHttpsRedirection:一个 HTTP 请求到达GET/Products,并检查协议。如果请求是通过 HTTP,则通过 HTTP 状态码发送重定向;如果请求是通过 HTTPS,则将其传递给下一个中间件。 -
UseStaticFiles: 如果请求是针对静态文件(通常基于扩展名检测——多用途媒体类型(MIME)类型),这个中间件会处理请求并发送响应,或者将请求传递给下一个中间件。正如你所看到的,如果请求是针对静态文件,那么整个管道的其他部分甚至都不会被执行,因为这个中间件可以处理完整的请求,从而减少服务器上任何不需要的处理负载,并减少响应时间。这个过程也被称为短路,这是每个中间件都可以支持的。 -
UseRouting: 请求将进一步检查,并识别可以处理该请求的控制器/操作。如果没有匹配项,这个中间件通常会以404HTTP 状态码响应。 -
UseAuthorization: 在这里,如果控制器/操作需要可供认证用户使用,那么这个中间件将查找头中的任何有效令牌并相应地响应。
一旦控制器从服务/存储库中获取数据,响应将通过相同的中间件以相反的顺序处理——即首先UseAuthorization,然后是UseHttpsRedirection——并根据需要处理响应。
如前所述,所有中间件都是通过Program类安装的,并使用WebApplication类的对象进行配置。中间件执行的顺序将精确地遵循在Program类中配置的方式。
带着这种理解,让我们创建一个中间件,它将用于处理我们电子商务应用程序的 RESTful 服务的异常,这样我们就不需要在代码中添加try…catch块,而是在请求管道的开始处安装一个中间件,然后捕获任何异常。
构建自定义中间件
由于中间件将在所有 RESTful 服务中重用,我们将中间件添加到Middlewares文件夹中的Packt.Ecommerce.Common项目。
让我们首先创建一个 Poco 类,它代表错误情况下的响应。这个模型通常将包含一个错误消息,一个位于Packt.Ecommerce.Common项目的Models文件夹中的ExceptionResponse,并向其中添加以下代码:
public class ExceptionResponse
{
public string ErrorMessage { get; set; }
public string CorrelationIdentifier { get; set; }
public string InnerException { get; set; }
}
现在,创建另一个 Poco 类,它可以保存发送内部异常行为的配置。这个类将使用Options模式进行填充,该模式在第六章中讨论过,在.NET 6 中的配置。因为它只需要保存一个设置,所以它将有一个属性。在Options文件夹中添加一个名为ApplicationSettings的类文件,然后向其中添加以下代码:
public class ApplicationSettings
{
public bool IncludeExceptionStackInResponse { get; set; }
}
这个类将进一步扩展,以包含所有 API 中通用的任何配置。
导航到Middlewares文件夹,创建一个名为ErrorHandlingMiddleware的类。正如我们讨论的那样,任何中间件中的关键属性之一是RequestDelegate类型的属性。此外,我们将添加一个ILogger属性以将异常记录到我们的日志提供程序,最后,我们将添加一个bool类型的includeExceptionDetailsInResponse属性来保存一个控制是否屏蔽内部异常的标志。有了这个,ErrorHandlingMiddleware类将看起来如下:
public class ErrorHandlingMiddleware
{
private readonly RequestDelegate requestDelegate;
private readonly ILogger logger;
private readonly bool includeExceptionDetailsInResponse;
}
添加一个参数化构造函数,其中我们注入RequestDelegate和ILogger以用于我们的日志提供程序,以及IOptions<ApplicationSettings>以用于配置,并将它们分配给之前创建的属性。在这里,我们再次依赖于 ASP.NET Core 的构造函数注入来实例化相应的对象。有了这个,ErrorHandlingMiddleWare的构造函数将看起来如下:
public ErrorHandlingMiddleware(RequestDelegate, ILogger<ErrorHandlingMiddleware> logger, IOptions<ApplicationSettings> applicationSettings)
{
NotNullValidator.ThrowIfNull(applicationSettings,
nameof(applicationSettings));
this.requestDelegate = requestDelegate;
this.logger = logger;
this.includeExceptionDetailsInResponse = applicationSettings.Value.IncludeExceptionStackInResponse;
}
最后,添加一个InvokeAsync方法,该方法将包含处理请求的逻辑,然后使用RequestDelegate调用下一个中间件。由于这是一个作为我们逻辑一部分的异常处理中间件,我们所有要做的就是将请求包裹在一个try…catch块中。在catch块中,我们将使用ILogger将其记录到相应的日志提供程序,并最终发送一个对象ExceptionResponse作为响应。有了这个,InvokeAsync将看起来如下:
public async Task InvokeAsync(HttpContext context)
{
try
{
if (this.requestDelegate != null)
{
// invoking next middleware.
this.requestDelegate.Invoke(context)
.ConfigureAwait(false);
}
}
catch (Exception innerException)
{
this.logger.LogCritical(1001, innerException,
"Exception captured in error handling
middleware"); // logging.
ExceptionResponse currentException = new
ExceptionResponse()
{
ErrorMessage = Constants.ErrorMiddlewareLog,
// Exception captured in error handling middleware
CorrelationIdentifier =
System.Diagnostics.Activity.Current?.RootId,
};
if (this.includeExceptionDetailsInResponse)
{
currentException.InnerException =
$"{innerException.Message}
{innerException.StackTrace}";
}
context.Response.StatusCode =
StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(JsonSerializer.Serialize(innerException)).ConfigureAwait(false);
}
}
现在,我们可以使用以下代码将此中间件注入到Program类中:
app.UseMiddleware<GlobalExceptionHandlingMiddleware>();
由于这是一个异常处理器,建议在Program类中尽早配置它,以便捕获所有后续中间件中的任何异常。此外,我们需要确保将ApplicationSettings类映射到配置,因此将以下代码添加到Program类中:
Builder.Services.Configure<ApplicationSettings>(this.Configuration.GetSection("ApplicationSettings"));
将相关部分添加到appsettings.json中,如下所示:
"ApplicationSettings": {
"IncludeExceptionStackInResponse": true
}
现在,如果我们的任何 API 中发生错误,响应将类似于以下代码片段所示:
{
"ErrorMessage": "Exception captured in error handling middleware",
"CorrelationIdentifier": "03410a51b0475843936943d3ae04240c ",
"InnerException": "No connection could be made because the target machine actively refused it. at System.Net.Http.ConnectHelper.ConnectAsync(String host, Int32 port, CancellationToken cancellationToken)\r\n at System.Net.Http.HttpConnectionPool.ConnectAsync(HttpRequestMessage request, Boolean allowHttp2, CancellationToken cancellationToken)\r\n at System.Net.Http.HttpConnectionPool.CreateHttp11ConnectionAsync(HttpRequestMessage request, CancellationToken cancellationToken)\r\n at System.Net.Http.HttpConnectionPool.GetHttpConnectionAsync(HttpRequestMessage request, CancellationToken cancellationToken)\r\n at System.Net.Http.HttpConnectionPool.SendWithRetryAsync(HttpRequestMessage request, Boolean doRequestAuth, CancellationToken cancellationToken)\r\n at System.Net.Http.RedirectHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)\r\n at System.Net.Http.DiagnosticsHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)\r\n at Microsoft.Extensions.Http.Logging.LoggingHttpMessageHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)\r\n at Microsoft.Extensions.Http.Logging.LoggingScopeHttpMessageHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)\r\n at System.Net.Http.HttpClient.FinishSendAsyncBuffered(Task`1 sendTask, HttpRequestMessage request, CancellationTokenSource cts, Boolean disposeCts, CancellationToken callerToken, Int64 timeoutTime)\r\n at Packt.Ecommerce.Product.Services.ProductsService.GetProductsAsync(String filterCriteria) in src\\platform-apis\\services\\Packt.Ecommerce.Product\\Services\\ProductsService.cs:line 82\r\n at Packt.Ecommerce.Product.Controllers.ProductsController.GetProductsAsync(String filterCriteria) in src\\platform-apis\\services\\Packt.Ecommerce.Product\\Controllers\\ProductsController.cs:line 46\r\n at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.TaskOfIActionResultExecutor.Execute(IActionResultTypeMapper mapper, ObjectMethodExecutor executor, Object controller, Object[] arguments)\r\n at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeActionMethodAsync>g__Logged|12_1(ControllerActionInvoker invoker)\r\n at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeNextActionFilterAsync>g__Awaited|10_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)\r\n at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context)\r\n at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)\r\n at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeInnerFilterAsync>g__Awaited|13_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)\r\n at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|19_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)\r\n at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)\r\n at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|6_0(Endpoint endpoint, Task requestTask, ILogger logger)\r\n at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)\r\n at Packt.Ecommerce.Common.Middlewares.ErrorHandlingMiddleware.InvokeAsync(HttpContext context) in src\\platform-apis\\core\\Packt.Ecommerce.Common\\Middlewares\\ErrorHandlingMiddleware.cs:line 65"
}
从前面的代码片段中,我们可以获取CorrelationIdentifier,即03410a51b0475843936943d3ae04240c,在我们的日志提供程序Application Insights中搜索该值,我们可以确定有关异常的更多信息,如图下所示:
图 10.4 – 在 Application Insights 中跟踪 CorrelationIdentifier
CorrelationIdentifier在生产环境中非常有用,尤其是在没有内部异常的情况下。
这就结束了我们对中间件的讨论。在下一节中,我们将探讨控制器和操作是什么,以及它们如何帮助处理请求。
使用控制器和操作处理请求
控制器是处理请求的基本块,用于使用 ASP.NET Core Web API 设计 RESTful 服务。这些是包含处理请求逻辑的主要类,包括从数据库检索数据、将记录插入数据库等。控制器是我们定义处理请求方法的类。这些方法通常包括验证输入、与数据存储通信、应用业务逻辑(在企业应用程序中,控制器还将调用服务类),最后使用 HTTP 协议以 JSON/XML 格式序列化响应并发送回客户端。
所有这些包含处理请求逻辑的方法都称为操作。HTTP 服务器接收到的所有请求都通过路由引擎交给操作方法。然而,路由引擎根据可以在请求管道中定义的某些规则将请求转移到操作。这些规则就是我们定义的路由。让我们看看如何将处理请求的 URI 映射到控制器中的特定操作。
理解 ASP.NET Core 路由
到目前为止,我们已经看到任何 HTTP 请求都会通过中间件,最终交给控制器或 configure 方法中定义的端点,但谁负责将请求交给控制器/端点,ASP.NET Core 如何知道触发哪个控制器和控制器内的哪个方法?这正是路由引擎的作用,这也是在添加以下中间件时注入的:
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
在这里,app.UseRouting() 注入 Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware,它用于根据 URI 进行所有路由决策。这个中间件的主要任务是设置 Microsoft.AspNetCore.Http.Endpoint 方法的实例,该实例包含特定 URI 需要执行的操作的值。
例如,如果我们试图根据产品 ID 获取产品详情,并且有一个具有 GetProductById 方法的控制器来满足这个请求,当我们对 api/products/1 URI 进行 API 调用时,在 EndpointRoutingMiddleware 后的中间件中设置断点会显示有一个 Endpoint 类的实例可用,其中包含与 URI 匹配的操作信息以及应该执行的操作。我们可以在以下屏幕截图中看到这一点:
图 10.5 – 路由中间件
如果没有匹配的控制器/操作,这个对象将是 null。内部,EndpointRoutingMiddleware 使用 URI、查询字符串参数、HTTP 动词和请求头来找到正确的匹配项。
一旦确定了正确的操作方法,app.UseEndPoints的任务就是将控制权交给由Endpoint对象指定的操作方法并执行它。UseEndPoints注入Microsoft.AspNetCore.Routing.EndpointMiddleware以执行满足请求的适当方法。填充适当的EndPoint对象的一个重要方面是UseEndPoints内部配置的各种 URI,这些可以通过 ASP.NET Core 中可用的静态扩展方法实现。例如,如果我们只想配置控制器,我们可以使用MapControllers扩展方法,这些方法为UseRouting匹配的所有操作添加端点。如果我们正在构建 RESTful API,建议使用MapControllers扩展方法。然而,对于以下扩展,有许多这样的扩展方法被广泛使用:
-
MapGet/MapPost:这些是扩展方法,可以匹配特定的GET/POST动词模式并执行请求。它们接受两个参数,一个是 URI 的模式,另一个是当模式匹配时可以用来执行的请求委托。例如,以下代码可以用来匹配/aboutus路由并返回文本欢迎使用默认产品路由:endpoints.MapGet("/aboutus", async context => { await context.Response.WriteAsync("Welcome to default products route"); }); -
MapRazorPages:这个扩展方法在如果我们使用 Razor Pages 并且需要根据路由路由到适当的页面时使用。 -
MapControllerRoute:这个扩展方法可以用来匹配具有特定模式的控制器;例如,以下代码可以在 ASP.NET Core MVC 模板中看到,它根据模式匹配方法:endpoints.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");
请求 URI 基于正斜杠(/)进行分割,并与控制器、操作方法和 ID 进行匹配。因此,如果您想匹配控制器中的方法,您需要在 URI 中传递控制器名称(ASP.NET Core 会自动添加controller关键字后缀)和方法名称。
可选地,可以将 ID 作为参数传递给该方法。例如,如果我在ProductsController中有GetProducts,您将使用绝对 URI products/GetProducts 来调用它。这种路由方式被称为传统路由,非常适合基于 UI 的 Web 应用程序,因此在 ASP.NET Core MVC 模板中可以看到。
这就结束了我们对路由基础知识的讨论;根据应用需求,ASP.NET Core 中有许多这样的扩展方法可以集成到请求管道中。现在,让我们看看基于属性的路由,这是一种推荐用于使用 ASP.NET Core 构建的 RESTful 服务的路由技术。
注意
路由的另一个重要方面,就像任何其他中间件序列一样,是注入非常重要,并且应该在UseEndpoints之前调用UseRouting。
基于属性的路由
对于 RESTful 服务,传统的路由违反了一些 REST 原则,特别是指出操作方法对实体执行的操作应基于 HTTP 动词的原则;因此,理想情况下,为了获取产品,URI 应该是GET api/products。
这就是基于属性的路由发挥作用的地方,其中路由是通过在控制器级别或操作方法级别使用属性来定义的,或者两者都使用。这是通过使用Microsoft.AspNetCore.Mvc.Route属性实现的,它接受一个字符串值作为输入参数,并用于映射控制器和操作。让我们以ProductsController为例,它具有以下代码:
[Route("api/[controller]")]
[ApiController]
public class ProductsController : ControllerBase
{
[HttpGet]
[Route("{id}")]
public IActionResult GetProductById(int id)
{
return Ok($"Product {id}");
}
[HttpGet]
public IActionResult GetProducts()
{
return Ok("Products");
}
}
在这里,在控制器级别的Route属性中,我们传递的值是api/[controller],这意味着任何匹配api/products的 URI 都将映射到这个控制器,其中products是控制器的名称。在方括号中使用controller关键字是一种特定的方式,告诉 ASP.NET Core 自动将控制器名称映射到路由。
然而,如果您想坚持特定的名称,而不管控制器名称如何,则可以使用不带方括号的名称。作为最佳实践,建议将控制器名称与路由解耦。因此,对于我们的电子商务应用程序,我们将在路由中使用精确值——也就是说,ProductsController将具有[Route("api/products")]的路由前缀。
Route属性也可以添加到操作方法中,并可以用来唯一地识别特定的方法。在这里,我们也在传递一个可以用来识别方法的字符串。例如,[Route("GetProductById/{id}")]将与api/products/GetProductById/1 URI 相匹配,并且花括号内的值是一个动态值,可以作为参数传递给操作方法并与参数名称匹配。
这意味着在先前的代码中,有一个 ID 参数,并且花括号内的值也应该命名为ID,这样 ASP.NET Core 才能将 URI 中的值映射到method参数。因此,对于api/products/1 URI,如果路由属性看起来像这样:[Route("{id}")],则GetProductById方法中的 ID 参数将具有值为1。
最后,HTTP 动词由如[HttpGet]之类的属性表示,它将被用来将 URI 中的 HTTP 动词映射到方法。以下表格显示了各种示例和可能的匹配,假设ProductsController具有[Route("api/products")]:
表 10.1
如您所见,方法名称在这里是不重要的,因此除非在Route属性中指定,否则它不是 URI 匹配的一部分。
注意
一个重要的方面是,Web API 支持从请求中的各个位置读取参数,无论是请求体、头部、查询字符串还是 URI。以下文档涵盖了可用的各种选项:docs.microsoft.com/en-us/aspnet/core/web-api/?view=aspnetcore-6.0#binding-source-parameter-inference.
ASP.NET Core 中整个 API 路由的总结可以表示如下:
图 10.6 – ASP.NET Core API 路由
基于属性的路由更符合 RESTful 风格,我们将在我们的电子商务服务中采用这种路由方式。现在,让我们看看 ASP.NET Core 中可用的各种辅助类,这些类可以帮助简化 RESTful 服务的构建。
小贴士
路由中的 {id} 表达式被称为 路由约束,ASP.NET Core 提供了一系列这样的路由约束,也可以在这里找到:docs.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-6.0#route-constraint-reference.
ControllerBase 类、ApiController 属性和 ActionResult 类
如果我们回顾之前创建的任何控制器,你可以看到所有控制器都是继承自 ControllerBase 类。在 ASP.NET Core 中,ControllerBase 是一个抽象类,它提供了各种辅助方法,有助于处理请求和响应。例如,如果我想发送 HTTP 状态码 400(错误请求),ControllerBase 中有一个 BadRequest 辅助方法可以用来发送 HTTP 状态码 400;否则,我们必须手动创建一个对象并填充它为 HTTP 状态码 400。ControllerBase 类中有许多这样的辅助方法,它们是开箱即用的;然而,并非每个 API 控制器都需要从 ControllerBase 类继承。这里列出了 ControllerBase 类中的所有辅助方法:docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.controllerbase?view=aspnetcore-3.1&viewFallbackFrom=aspnetcore-6.0.
这引出了关于我们的控制器方法返回类型应该是什么的讨论,因为任何 API 在一般情况下都可能有至少两种可能的响应,如下所示:
-
带有 2xx 状态码的成功响应,可能响应资源或资源列表
-
带有 4xx 状态码的验证失败案例
为了处理此类场景,我们需要创建一个泛型类型,可以用来发送不同的响应类型,这就是 ASP.NET Core 的IActionResult和ActionResult类型发挥作用的地方,为我们提供了针对各种场景的派生响应类型。以下是IActionResult支持的一些重要响应类型:
-
OkObjectResult: 这是一个将 HTTP 状态码设置为200并将包含资源详细信息的资源添加到响应正文中的响应类型。对于所有响应资源或资源列表的 API 来说,这种类型非常理想——例如获取产品。 -
NotFoundResult: 这是一个将 HTTP 状态码设置为404且正文为空的响应类型。如果特定资源未找到,则可以使用它。然而,在资源未找到的情况下,我们将使用NoContentResult(204),因为404也将用于 API 未找到的情况。 -
BadRequestResult: 这是一个将 HTTP 状态码设置为400并在响应正文中包含错误消息的响应类型。这对于任何验证失败来说非常理想。 -
CreatedAtActionResult: 这是一个将 HTTP 状态码设置为201并可以将新创建的资源 URI 添加到响应中的响应类型。这对于创建资源的 API 来说非常理想。
所有这些响应类型都继承自IActionResult,ControllerBase类中提供了创建这些对象的方法;因此,IActionResult与ControllerBase结合将解决大多数业务需求,这就是我们所有 API 控制器方法的返回类型。
在 ASP.NET Core 中,另一个非常有用的类是ApiController类,它可以作为属性添加到控制器类或程序集,并为我们的控制器添加以下行为:
-
它禁用了传统路由,并强制使用基于属性的路由。
-
它会自动验证模型,因此我们不需要在每个方法中显式调用
ModelState.IsValid。在插入/更新方法的情况下,这种行为非常有用。 -
它简化了从正文/路由/头部/查询字符串到参数的自动映射。这意味着我们不需要指定 API 参数是否将作为正文或路由的一部分。例如,在下面的代码片段中,我们不需要显式说明 ID 参数将作为路由的一部分,因为
ApiController自动使用名为[FromRoute]的某种机制:[Route("{id}")] public IActionResult GetProductById(int id) { return Ok($"Product {id}"); } -
类似地,在下面的代码片段中,
ApiController将根据推断规则自动添加[FromBody]:public IActionResult CreateProduct(Product product) { // } -
ApiController添加的其他一些行为包括推断请求内容为 multipart/form 数据以及更详细的错误响应,具体请参阅tools.ietf.org/html/rfc7807.
因此,总的来说,ControllerBase、ApiController和ActionResult提供了各种辅助方法和行为,从而为开发者提供了编写 RESTful API 所需的所有工具,并允许他们在使用 ASP.NET Core 编写 API 时专注于业务逻辑。
在这个基础上,让我们在下一节设计我们电子商务应用的各个 API。
与数据层的集成
我们 API 的响应可能看起来像或不像我们的领域模型。相反,它们的结构可能类似于 UI 或视图需要绑定的字段;因此,建议创建一组独立的 POCO(Plain Old CLR Object)类,这些类与我们的 UI 集成。这些 POCO 被称为数据传输对象(DTOs)。
在本节中,我们将实现我们的 DTOs(数据传输对象)的领域逻辑,并将其与数据层集成,同时使用第八章中讨论的缓存服务,即关于缓存的全部知识,采用 Cache-Aside 模式进行集成,然后——最终——使用控制器和操作实现所需的 RESTful API。在这个过程中,我们将使用HTTPClient工厂进行服务间的通信,并使用AutoMapper库将领域模型映射到 DTOs。
我们将选择一个作为Packt.Ecommerce.Product一部分的产品服务,这是一个使用.NET 6 的 Web API 项目,并详细讨论其实现。在本节结束时,我们将实现以下屏幕截图中所突出的项目:
![Figure 10.7 – Product service and DTOs
![img/Figure_10.7_B18507.png]
图 10.7 – 产品服务和 DTOs
在所有 RESTful 服务中,都有一个类似的实现,根据需要对业务逻辑进行轻微修改,但以下各种服务的高级实现保持不变:
-
Packt.Ecommerce.DataAccess -
Packt.Ecommerce.Invoice -
Packt.Ecommerce.Order -
Packt.Ecommerce.Payment -
Packt.Ecommerce.UserManagement
首先,我们将在appsettings.json中有一个相应的部分,如下所示:
"ApplicationSettings": {
"UseRedisCache": false, // For in-memory
"IncludeExceptionStackInResponse": true,
"DataStoreEndpoint": "",
"InstrumentationKey": ""
},
"ConnectionStrings": {
"Redis": ""
}
对于本地开发环境,我们将使用管理用户密钥(如docs.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-6.0&tabs=windows中所述)并设置以下值。然而,一旦服务部署,它将使用 Azure Key Vault,如第六章中所述,在.NET 6 中的配置:
{
"ApplicationSettings:InstrumentationKey": "", //relevant
//key
"ConnectionStrings:Redis": "" //connection string
}
让我们先为产品 API 创建 DTOs。
创建 DTOs
在产品服务方面,关键要求是提供搜索产品、查看与产品相关的额外详细信息,然后进行购买的能力。由于产品列表可能包含有限的信息,让我们创建一个 POCO(所有 DTO 都在Packt.Ecommerce.DTO.Models项目中创建)并命名为ProductListViewModel。这个类将包含我们希望在产品列表页面上显示的所有属性,并且应该看起来像这样:
public class ProductListViewModel
{
[JsonProperty(PropertyName = "id")]
public string Id { get; set; }
public string Name { get; set; }
public int Price { get; set; }
public Uri ImageUrl { get; set; }
public double AverageRating { get; set; }
}
如您所见,这些是在任何电子商务应用程序上通常显示的最小字段。因此,我们将采用这些字段,但想法是随着应用程序的发展而扩展。在这里,Id和Name属性是重要的属性,因为它们将被用于在用户想要检索有关产品的所有进一步详细信息时查询数据库。我们使用JsonProperty(PropertyName = "id")属性来注释Id属性,以确保在序列化和反序列化过程中属性名称保持为Id。这很重要,因为在我们 Cosmos DB 实例中,我们使用Id作为大多数容器的主键。现在让我们创建另一个 POCO,它表示产品的详细信息,如下面的代码片段所示:
public class ProductDetailsViewModel
{
[Required]
public string Id { get; set; }
[Required]
public string Name { get; set; }
[Required]
public string Category { get; set; }
[Required]
[Range(0, 9999)]
public int Price { get; set; }
[Required]
[Range(0, 999, ErrorMessage = "Large quantity,
please reach out to support to process request.")]
public int Quantity { get; set; }
public DateTime CreatedDate { get; set; }
public List<string> ImageUrls { get; set; }
public List<RatingViewModel> Rating { get; set; }
public List<string> Format { get; set; }
public List<string> Authors { get; set; }
public List<int> Size { get; set; }
public List<string> Color { get; set; }
public string Etag { get; set; }
}
public class RatingViewModel
{
public int Stars { get; set; }
public int Percentage { get; set; }
}
因此,在这个 DTO 中,除了Id和Name之外,另一个重要的属性是Etag,它将被用于实体跟踪,以避免在实体上并发覆盖。例如,如果有两个用户访问一个产品,并且用户 A 在用户 B 之前更新它,使用Etag,我们可以阻止用户 B 覆盖用户 A 的更改,并强制用户 B 在更新之前获取产品的最新副本。《AddProductAsync(ProductDetailsViewModel product)》方法在github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Enterprise%20Application/src/platform-apis/services/Packt.Ecommerce.Product/Controllers遵循此模式。
另一个重要方面是我们正在使用 ASP.NET Core 内置的验证属性在我们的模型上定义所有约束。主要,我们将使用[Required]属性和任何相关属性,如docs.microsoft.com/en-us/aspnet/core/mvc/models/validation?view=aspnetcore-6.0#built-in-attributes。
所有 DTO 都将作为Packt.Ecommerce.DTO.Models项目的一部分,因为它们将在我们的 ASP.NET MVC 应用程序中重用,该应用程序将用于构建我们电子商务应用程序的 UI。现在,让我们看看Products服务所需的合同。
服务类合同
在 Packt.Ecommerce.Product 中添加一个 Contracts 文件夹,并创建一个产品服务类的合同/接口,我们将参考我们的需求并根据需要定义方法。最初,它将包含所有基于该接口在产品上执行 创建、读取、更新和删除(CRUD)操作的方法,如下所示:
public interface IProductService
{
Task<IEnumerable<ProductListViewModel>>
GetProductsAsync(string filterCriteria = null);
Task<ProductDetailsViewModel>
GetProductByIdAsync(string productId,
string productName);
Task<ProductDetailsViewModel>
AddProductAsync(ProductDetailsViewModel product);
Task<HttpResponseMessage>
UpdateProductAsync(ProductDetailsViewModel
product);
Task<HttpResponseMessage> DeleteProductAsync(
string productId, string productName);
}
在这里,你可以看到我们在所有方法中返回 Task,从而坚持我们在 第四章,线程和异步操作 中讨论的异步方法。
使用 AutoMapper 的映射类
下一步,我们需要一种将领域模型转换为 DTO 的方法,这里我们将使用一个名为 AutoMapper 的知名库(请参阅 docs.automapper.org/en/stable/Getting-started.html 获取更多详细信息)来配置并添加以下包:
-
Automapper -
AutoMapper.Extensions.Microsoft.DependencyInjection
要配置 AutoMapper,我们需要定义一个继承自 AutoMapper.Profile 的类,然后定义各种领域模型和 DTO 之间的映射。让我们在 Packt.Ecommerce.Product 项目中添加一个 AutoMapperProfile 类,如下所示:
public class AutoMapperProfile : Profile
{
public AutoMapperProfile()
{
}
}
AutoMapper 包含许多内置的映射方法,其中之一是 CreateMap,它接受源和目标类,并根据相同的属性名称进行映射。任何没有相同名称的属性都可以使用 ForMember 方法手动映射。由于 ProductDetailsViewModel 与我们的领域模型有一对一的映射,因此 CreateMap 对于它们的映射应该是足够的。对于 ProductListViewModel,我们有一个额外的字段 AverageRating,我们希望计算特定产品的所有评分的平均值。为了简化,我们将使用来自 Linq 的 Average 方法,并将其映射到平均评分。为了模块化,我们将将其放在一个单独的方法 MapEntity 中,如下所示:
private void MapEntity()
{
this.CreateMap<Data.Models.Product,
DTO.Models.ProductDetailsViewModel>();
this.CreateMap<Data.Models.Rating,
DTO.Models.RatingViewModel>();
this.CreateMap<Data.Models.Product,
DTO.Models.ProductListViewModel>()
.ForMember(x => x.AverageRating, o =>
o.MapFrom(a => a.Rating != null ?
a.Rating.Average(y => y.Stars) : 0));
}
现在,修改构造函数以调用此方法。有关完整实现,请参阅 github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/blob/main/Enterprise%20Application/src/platform-apis/services/Packt.Ecommerce.Product/AutoMapperProfile.cs。
设置 AutoMapper 的最后一步是将它注入为服务之一,我们将使用 Program 类的 WebApplicationBuilder 对象,使用以下代码行:
Builder.Services.AddAutoMapper(typeof(AutoMapperProfile));
如前所述,这将把 AutoMapper 库注入到我们的 API 中,然后这将允许我们将 AutoMapper 注入到各种服务和控制器中。现在,让我们看看 HttpClient 工厂的配置,该工厂用于调用数据访问服务。
服务间调用的 HttpClient 工厂
要检索数据,我们必须调用定义在 Packt.Ecommerce.DataAccess 中的数据访问服务公开的 API。为此,我们需要一个能够有效使用可用套接字的弹性库,允许我们定义断路器以及重试/超时策略。IHttpClientFactory 对于此类场景非常理想。
注意
HttpClient 中的一个常见问题是潜在的 SocketException 错误,这发生在 HttpClient 在连接到多个服务时将 HttpClient 作为静态/单例使用——这有其自身的开销。这些问题在 softwareengineering.stackexchange.com/questions/330364/should-we-create-a-new-single-instance-of-httpclient-for-all-requests 中进行了总结,现在这些都可以通过 IhttpClientFactory 解决。
要配置 IHttpClientFactory,请执行以下步骤:
-
安装
Microsoft.Extensions.Http。 -
我们将使用类型化客户端来配置
IHttpClientFactory,因此添加一个Services文件夹和一个ProductsService类,并从IProductService继承。目前,请保持实现为空。现在,在Program类中使用以下代码映射IProductService和ProductsService:builder.Services.AddHttpClient<IProductService, ProductsService>() .SetHandlerLifetime(TimeSpan.FromMinutes(5)) .AddPolicyHandler(RetryPolicy()) // Retry // policy. .AddPolicyHandler(CircuitBreakerPolicy()); // Circuit breakerpolicy
在这里,我们为 ProductsService 使用的 HttpClient 定义了 5 分钟的超时,并额外配置了重试和断路器的策略。
实现断路器策略
为了定义这些策略,我们将使用一个名为 Polly 的库(有关官方文档,请参阅github.com/App-vNext/Polly),它提供了开箱即用的弹性和错误处理能力。安装 Microsoft.Extensions.Http.Polly 包,然后向定义我们断路器策略的 Program 类中添加以下静态方法:
static IAsyncPolicy<HttpResponseMessage> CircuitBreakerPolicy()
{
return HttpPolicyExtensions
.HandleTransientHttpError()
.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30));
}
在这里,我们表示如果 30 秒内有 5 次失败,则会打开电路。断路器有助于避免在无法通过重试修复的严重故障时进行不必要的 HTTP 调用。
实现重试策略
现在,让我们添加我们的重试策略,它比在指定时间范围内退出的标准重试更智能。因此,我们定义了一个将在五次重试和 HTTP 服务调用上产生影响的政策,并且每次重试都会以 2 的幂次方的时间差。代码如下所示:
为了在时间变化方面添加一些随机性,我们将使用 C# 的 Random 类生成一个随机数并将其添加到时间间隔中。这种随机生成将如下所示:
private static IAsyncPolicy<HttpResponseMessage> RetryPolicy()
{
Random random = new Random();
var retryPolicy = HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(msg => msg.StatusCode ==
System.Net.HttpStatusCode.NotFound)
.WaitAndRetryAsync(
5,
retry => TimeSpan.FromSeconds(Math.Pow(2, retry))
+ TimeSpan.FromMilliseconds(random.Next(0, 100)));
return retryPolicy;
}
在这里,retry 是一个整数,每次重试时都会增加一。为此,在 Program 类中添加一个具有前面逻辑的静态方法。
这完成了我们的 HTTPClient 工厂配置,ProductsService 可以使用构造函数注入来实例化 IHttpClientFactory,然后可以进一步用来创建 HttpClient。
在所有这些配置完成后,我们现在可以实施我们的服务类。让我们在下一节中查看它。
在服务类中实现
现在我们来实现 ProductsService,首先定义我们已构建的各种属性,并使用构造函数注入来实例化它们,如下面的代码块所示:
private readonly IOptions<ApplicationSettings> applicationSettings;
private readonly HttpClient httpClient;
private readonly IMapper autoMapper;
private readonly IDistributedCacheService cacheService;
public ProductsService(IHttpClientFactory httpClientFactory, IOptions<ApplicationSettings> applicationSettings, IMapper autoMapper, IDistributedCacheService cacheService)
{
NotNullValidator.ThrowIfNull(applicationSettings,
nameof(applicationSettings));
IHttpClientFactory httpclientFactory =
httpClientFactory;
this.applicationSettings = applicationSettings;
this.httpClient = httpclientFactory.CreateClient();
this.autoMapper = autoMapper;
this.cacheService = cacheService;
}
我们的所有服务都将使用我们在本章中定义的相同的异常处理中间件,因此在服务之间的调用过程中,如果另一个服务出现故障,响应将属于 ExceptionResponse 类型。因此,让我们创建一个私有方法,以反序列化 ExceptionResponse 类并相应地抛出异常。这是必需的,因为在使用 IsSuccessStatusCode 和 StatusCode 属性时,HttpClient 会表示成功或失败,所以如果出现异常,我们需要检查 IsSuccessStatusCode 并重新抛出它。让我们将此方法命名为 ThrowServiceToServiceErrors 并参考以下代码片段:
private async Task ThrowServiceToServiceErrors(HttpResponseMessage response)
{
var exceptionReponse = await response.Content.ReadFromJsonAsync<ExceptionResponse>().ConfigureAwait(false);
throw new Exception(exceptionReponse.InnerException);
}
现在我们来实现 GetProductsAsync 方法,在这个方法中,我们将使用 CacheService 从缓存中检索数据,如果缓存中没有数据,我们将使用 HttpClient 调用数据访问服务,并将 Product 领域模型映射到 DTO,然后异步返回。代码将如下所示:
public async Task<IEnumerable<ProductListViewModel>> GetProductsAsync(string filterCriteria = null)
{
var products = await this.cacheService
.GetCacheAsync<IEnumerable<Packt.Ecommerce
.Data.Models.Product>>($"products{filterCriteria}")
.ConfigureAwait(false);
if (products == null)
{
using var productRequest = new
HttpRequestMessage(HttpMethod.Get,
$"{this.applicationSettings.Value
.DataStoreEndpoint}api/products
?filterCriteria={filterCriteria}");
var productResponse = await this.httpClient
.SendAsync(productRequest).ConfigureAwait(false);
if (!productResponse.IsSuccessStatusCode)
{
await this.ThrowServiceToServiceErrors(
productResponse).ConfigureAwait(false);
}
products = await productResponse.Content
.ReadFromJsonAsync<IEnumerable<Packt
.Ecommerce.Data.Models.Product>>()
.ConfigureAwait(false);
if (products.Any())
{
await this.cacheService.AddOrUpdateCacheAsync
<IEnumerable<Packt.Ecommerce.Data.Models
.Product>>($"products{filterCriteria}",
products).ConfigureAwait(false);
}
}
var productList = this.autoMapper.Map<List
<ProductListViewModel>>(products);
return productList;
}
我们将遵循类似的模式并实现 AddProductAsync、UpdateProductAsync、GetProductByIdAsync 和 DeleteProductAsync。这些方法中的唯一区别将是使用相关的 HttpClient 方法并相应地处理它们。现在我们已经实现了服务,让我们来实现我们的控制器。
在控制器中实现操作方法
让我们先将在上一节中创建的服务注入到 ASP.NET Core 6 DI 容器中,这样我们就可以使用构造函数注入来创建 ProductsService 的对象。我们将在 Program 类中使用以下代码来完成此操作:
builder.Services.AddScoped<IProductService, ProductsService>();
同时,确保所有必需的框架组件(如 ApplicationSettings、CacheService 和 AutoMapper)都已配置。
在 Controllers 文件夹中添加一个控制器,命名为 ProductsController,默认路由为 api/products,然后添加一个 IProductService 属性,并使用构造函数注入。控制器应实现五个操作方法,每个方法调用一个服务方法,并使用本章中讨论的各种现成辅助方法和属性,如 The ControllerBase class, the ApiController attribute, and the ActionResult class 部分。获取特定产品和创建新产品的代码块如下所示:
[HttpGet]
[Route("{id}")]
public async Task<IActionResult> GetProductById(string id, [FromQuery][Required]string name)
{
// FromQuery supports reading parameters from query
// string, here the value of the query string parameter
// 'name' will be mapped to name parameter.
var product = await
this.productService.GetProductByIdAsync(id,
name).ConfigureAwait(false);
if (product != null)
{
return this.Ok(product);
}
else
{
return this.NoContent();
}
}
[HttpPost]
public async Task<IActionResult> AddProductAsync(ProductDetailsViewModel product)
{
// Product null check is to avoid null attribute
// validation error.
if (product == null || product.Etag != null)
{
return this.BadRequest();
}
var result = await this.productService
.AddProductAsync(product).ConfigureAwait(false);
return this.CreatedAtAction(nameof(
this.GetProductById), new { id = result.Id, name =
result.Name }, result); // HATEOS principle
}
方法实现是显而易见的,并完全基于本章 Handling requests using controllers and actions 部分讨论的基本原理。同样,我们将通过调用相应的服务方法并返回相关的 ActionResult 来实现所有其他方法(Delete、Update 和获取所有产品的 Get)。这样,我们将拥有以下表格中显示的 API 来处理与产品实体相关的各种场景:
表 10.2
小贴士
与 API 相关的另一个常见场景是拥有支持文件上传/下载的 API。上传场景是通过将 IFormFile 作为 API 的输入参数来实现的。这会将上传的文件序列化,并可以将其保存到服务器上。同样,对于文件下载,FileContentResult 也是可用的,可以将文件流式传输到任何客户端。这留给你作为一项活动来进一步探索。
对于测试 API,我们将使用 Postman (www.postman.com/downloads/)。所有 Postman 集合都可以在 Solution Items 文件夹中的 Mastering enterprise application development Book.postman_collection.json 文件下找到。一旦安装了 Postman,导入集合的步骤如下:
-
打开 Postman,然后点击 文件。
-
点击
Mastering enterprise application development Book.postman_collection.json文件,然后点击 导入。
成功导入后,Postman 的 集合 菜单中将显示该集合,如下截图所示:
图 10.8 – Postman 中的集合
这完成了我们的 Products RESTful 服务实现。本节开头提到的其他所有服务都是以类似的方式实现的,其中每个服务都是一个独立的 Web API 项目,并处理该实体的相关领域逻辑。
理解 gRPC
根据 grpc.io,gRPC 是一个高性能、开源的通用 RPC 框架。最初由 Google 开发,gRPC 使用 HTTP/2 进行传输,并使用 协议缓冲区(protobuf)作为接口描述语言。gRPC 是基于合同的二进制通信系统,并且可在多个生态系统中使用。以下来自 gRPC 官方文档(https://grpc.io)的图表展示了使用 gRPC 的客户端-服务器交互:
图 10.9 – gRPC 客户端-服务器交互
与许多分布式系统一样,gRPC 基于定义服务并指定具有可远程调用的方法的接口以及合同的想法。在 gRPC 中,服务器实现接口并运行 gRPC 服务器以处理客户端调用。客户端方面有存根,它提供了与服务器定义相同的接口。客户端以调用任何其他本地对象上的方法相同的方式调用存根以在服务器上调用方法。
默认情况下,数据合同使用 .proto 扩展。在 protobuf 中,数据以字段中包含的信息的逻辑记录结构化。在接下来的章节中,我们将学习如何在 Visual Studio 中为 .NET 6 应用程序定义 protobuf。
注意
请参阅官方文档了解有关 gRPC 的更多信息:grpc.io。要了解更多关于 protobuf 的信息,请参阅 developers.google.com/protocol-buffers/docs/overview。
由于 gRPC 的 protobuf 与高性能、语言无关的实现和减少的网络使用相关联,许多团队正在探索在其构建微服务的努力中使用 gRPC。
在下一节中,我们将学习如何在 .NET 6 中构建 gRPC 服务器和客户端。
在 .NET 中构建 gRPC 服务器
在 .NET Core 3.0 首次出现后,gRPC 已成为 .NET 生态系统中的第一公民。现在在 .NET 中提供了完全托管的 gRPC 实现。使用 Visual Studio 2022 和 .NET 6,我们可以轻松地创建 gRPC 服务器和客户端应用程序。让我们使用 Visual Studio 中的 gRPC 服务模板创建一个 gRPC 服务,如下面的截图所示,并将其命名为 gRPCDemoService:
图 10.10 – gRPC Visual Studio 2022 项目模板
这将创建一个名为 GreetService 的示例 gRPC 服务解决方案。现在让我们了解使用模板创建的解决方案。创建的解决方案将引用 Grpc.AspNetCore 包。这将包含托管 gRPC 服务所需的库以及 .proto 文件的代码生成器。此解决方案将在 Protos 解决方案文件夹下创建一个 GreetService 的 proto 文件。以下代码定义了 Greeter 服务:
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply);
}
Greeter 服务只有一个名为 SayHello 的方法,它接受输入参数 HelloRequest 并返回 HelloReply 类型的消息。HelloRequest 和 HelloReply 消息在同一个 proto 文件中定义,如下面的代码片段所示:
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
HelloRequest 有一个名为 name 的字段,HelloReply 有一个名为 message 的字段。字段旁边的数字显示字段在缓冲区中的序号位置。proto 文件使用 Protobuf 编译器编译,以生成包含所有管道的存根类。我们可以从 proto 文件的属性中指定要生成的存根类的类型。由于这是一个服务器,它将配置设置为 仅服务器。
现在,让我们看看 GreetService 的实现。它将如下所示:
public class GreeterService : Greeter.GreeterBase
{
private readonly ILogger<GreeterService> _logger;
public GreeterService(ILogger<GreeterService> logger)
{
_logger = logger;
}
public override Task<HelloReply> SayHello(
HelloRequest request, ServerCallContext context)
{
return Task.FromResult(new HelloReply
{
Message = "Hello " + request.Name
});
}
}
GreetService 继承自由 protobuf 编译器生成的 Greeter.GreeterBase。SayHello 方法被重写以提供实现,通过构造 HelloReply 来返回给调用者,正如在 proto 文件中定义的那样。
要在 .NET 6 应用程序中公开 gRPC 服务,需要通过在 Program 类中调用 AddGrpc 将所有必需的 gRPC 服务添加到服务集合中。通过调用 MapGrpcService 来公开 GreeterService gRPC 服务,如下面的代码片段所示:
app.MapGrpcService<GreeterService>();
这就是要在 .NET 6 应用程序中公开 gRPC 服务所需的一切。在下一节中,我们将实现一个 .NET 6 客户端来消费 GreeterService。
在 .NET 中构建 gRPC 客户端
如本节开头所述的 理解 gRPC,.NET 6 为构建 gRPC 客户端提供了非常好的工具。在本节中,我们将在控制台应用程序中构建一个 gRPC 客户端。以下是您需要遵循的步骤来完成此操作:
-
创建一个名为
gRPCDemoClient的 .NET 6 控制台应用程序。 -
现在,右键单击项目,然后点击 添加 | 服务引用… 菜单项。这将打开 连接服务 选项卡,如下面的屏幕截图所示:
图 10.11 – gRPC 连接服务选项卡
-
在 添加服务引用 对话框中选择 gRPC,然后点击 下一步。
-
在
greet.proto文件中的gRPCDemoService,然后点击Client存根类:
图 10.12 – 添加 gRPC 服务引用
这也将添加所需的 Google.Protobuf、Grpc.Net.ClientFactory 和 Grpc.Tools NuGet 包到项目中。
-
现在,将以下代码添加到
gRPCDemoClient项目的Program类中:var channel = GrpcChannel.ForAddress("https://localhost:5001"); var client = new Greeter.GreeterClient(channel); HelloReply response = await client.SayHelloAsync(new HelloRequest { Name="Suneel" }); Console.WriteLine(response.Message);
在此代码片段中,我们正在创建一个指向 gRPCDemoService 端点的 gRPC 通道,并通过传递 gRPC 通道来实例化 Greeter.GreeterClient,这是一个 gRPCDemoService 的存根。
- 现在,要调用该服务,我们只需通过传递
HelloRequest消息在存根上调用SayHelloAsync方法。这个调用将从服务返回HelloReply。
到目前为止,我们已经创建了一个简单的 gRPC 服务和该服务的控制台客户端。在下一节中,我们将学习关于grpcurl的内容,它是一个通用的客户端,用于测试 gRPC 服务。
测试 gRPC 服务
要测试或调用 REST 服务,我们使用 Postman 或 Fiddler 等工具。grpcurl是一个命令行实用程序,帮助我们与 gRPC 服务交互。使用grpcurl,我们可以测试 gRPC 服务而无需构建客户端应用程序。grpcurl可以从github.com/fullstorydev/grpcurl下载。
一旦下载了grpcurl,我们可以使用以下命令调用GreeterService:
grpcurl -d "{\"name\": \"World\"}" localhost:5001 greet.Greeter/SayHello
注意
目前,gRPC 应用程序只能托管在 Azure App Service 和Internet Information Services(IIS)中,因此我们没有在托管在 Azure App Service 上的演示电子商务应用中使用 gRPC。然而,在本章的演示中,有一个电子商务应用的版本,其中根据 ID 获取产品作为自托管服务中的 gRPC 端点公开。
摘要
在本章中,我们介绍了 REST 的基本原则,并为我们的电子商务应用设计了企业级 RESTful 服务。
在这个过程中,我们掌握了 ASP.NET Core 6 Web API 的各种 Web API 内部机制,包括路由和示例中间件,并熟悉了测试我们服务的工具,同时学习了如何使用控制器及其操作来处理请求,我们还学习了如何构建。我们还看到了如何在.NET 6 中创建和测试基本的 gRPC 客户端和服务器应用程序。你现在应该能够自信地使用 ASP.NET Core 6 Web API 构建 RESTful 服务。
在下一章中,我们将学习 ASP.NET MVC 的基础知识,使用 ASP.NET MVC 构建我们的 UI 层,并将其与我们的 API 集成。
问题
- 以下哪个 HTTP 动词建议用于创建资源?
a. GET
b. POST
c. DELETE
d. PUT
答案:b
- 以下哪个 HTTP 状态码代表
无内容?
a. 200
b. 201
c. 202
d. 204
答案:d
- 以下哪个中间件用于配置路由?
a. UseDeveloperExceptionPage()
b. UseHttpsRedirection()
c. UseRouting()
d. UseAuthorization()
答案:c
- 如果一个控制器被
[ApiController]属性注释,我需要在每个操作方法中显式调用ModelState.IsValid吗?
a. 是的——模型验证不是ApiController属性的一部分,因此你需要在每个操作方法中调用ModelState.Valid。
b. 不需要——模型验证作为ApiController属性的一部分处理,因此对于所有操作项自动触发ModelState.Valid。
答案:b
1918



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



