ASP.NET Core Razor Pages in Action 7 使用依赖关系注入管理服务
本章涵盖
• 了解依赖关系注入的作用
• 检查 ASP.NET Core 中的依赖项注入
• 创建和使用您自己的服务
• 管理服务生命周期
依赖注入 (DI) 是一种软件工程技术,目前许多 Web 框架都包含在内。它存在的理由是实现软件组件之间的松散耦合,从而使代码不那么脆弱、更适应变化、更易于维护和更易于测试。如果您以前使用过依赖注入,那么所有这些对您来说都会很熟悉。如果依赖注入对你来说是一个新概念,本章将通过解释它是什么以及为什么你应该关心来提供帮助。
DI 是 ASP.NET Core 的核心。整个框架使用内置的 DI 功能来管理自己的依赖项或服务。当应用程序启动时,服务在容器中全局注册,然后在使用者需要时由容器提供。在第 2 章中,您遇到了服务容器的主入口点,Program.cs它通过 WebApplicationBuilder 的 Services 属性进行访问。您会记得,组成 Razor Pages 框架的服务是通过 AddRazorPages 方法注册到容器中的:
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorPages();
除了框架使用其容器之外,还鼓励您使用它来注册自己的服务,以便容器可以将它们注入到使用者中,容器将负责代表您管理这些服务的生命周期(创建和销毁)。这里要说明的一个关键点是,您不需要将 DI 用于您自己的服务。但是,您至少应该了解 DI 在 Razor Pages 应用程序中的工作原理,以便根据需要自定义框架服务。
在本章结束时,您将了解什么是依赖项注入、如何在 Razor Pages 应用程序中管理它,以及将 DI 用于您自己的服务的好处。您还将更清楚地了解 Razor Pages 应用程序上下文中的服务是什么。您将创建一些服务并将它们注册到依赖项注入容器中,以便它们在整个应用程序中可用。
例如,服务需要由 DI 容器管理其生命周期,因此您的应用程序不会耗尽内存。您也不应该不必要地实例化服务,尤其是在应用程序只需要一个实例的情况下。在本章中,您将了解可用的不同生命周期,以及如何在注册服务时选择正确的生命周期。
7.1 依赖注入的原因
在我们了解 ASP.NET Core 中依赖项注入的基础知识之前,我们需要清楚它所解决的问题的性质。该讨论涉及使用软件工程社区吸收的术语来描述原则和技术。许多术语都非常抽象,用于描述抽象概念。因此,它们可能很难理解,尤其是对于字面意义上的思考者。
我已经将术语松散耦合作为软件工程师在设计系统时应该努力实现的主要目标之一。我已经提到过,这种松散耦合应该发生在组件之间。在研究组件的性质之前,我想退后一步,从软件工程设计原则的角度看一下更大的图景。
7.1.1 单一责任原则
在设计系统时,您应该考虑的一个关键原则是单一责任原则 (SRP)。这就是 S in SOLID,这是一组旨在使软件更易于理解、灵活和可维护的原则。从根本上说,SRP 规定应用程序中的任何组件、模块或服务都应该只有一个更改的理由:由于某些业务原因,其唯一职责需要更改。如果您以此原则考虑 PageModel 类,则可以看到它负责处理其页面的 HTTP 请求。因此,唯一需要更改 PageModel 中代码任何方面的情况是处理请求所需的逻辑是否应更改。
如果你查看你在上一章中放在一起的属性管理器的 Create 页面的 PageModel,你可以看到这个原则被违反了。PageModel 有两个职责:处理请求和以城市集合的形式为 SelectList 生成数据(图 7.1)。
图 7.1 PageModel 目前有两个职责。
在下一章中,我们将了解如何在 Razor Pages 应用程序中使用数据库。您将需要通过从数据库中检索城市选项来更改城市选项的生成方式。该更改与 PageModel 类的主要角色 — 处理请求无关。但是,按照当前设计的方式,迁移到数据库将要求我们深入研究 CreateModel 类以更改 GetCityOptions 方法。换句话说,数据访问策略的更改当前为更改 PageModel 类提供了另一个原因。如果要遵守 SRP,则需要将生成 City 数据的逻辑移动到其自己的组件中,并负责管理 City 实体的数据。
回想一下第 3 章中讨论的 don't repeat yourself (DRY) 原则,该原则鼓励您最大程度地减少代码重复。原则指出,每个 logic 在系统中应该只有一个表示。我们也违反了这一原则。我们在主页中有代码,用于为列表框示例生成城市,从而有效地复制了刚才在 CreateModel 中讨论的 GetCityOptions 代码。此代码应该是集中的,并且再次将其移动到自己的组件将解决错误。
让我们从解决这个问题开始。您将构建一个组件,负责生成 City 对象的集合,并将其提供给需要它的应用程序的任何部分。然后,您将在 PageModel 类中使用该组件,为您目前构建的一个选择列表提供源数据。
首先,将名为 Services 的新文件夹添加到应用程序的根目录。在此范围内,添加一个名为 SimpleCityService.cs 的新 C# 类。下面的清单中提供了该文件的内容。
清单 7.1 SimpleCityService 代码
using CityBreaks.Models;
namespace CityBreaks.Services
{
public class SimpleCityService
{
public Task<List<City>> GetAllAsync()
{
return Task.FromResult(Cities);
}
private readonly List<City> Cities = new()
{
new City { Id = 1, Name = "Amsterdam", Country = new Country {
Id = 5, CountryName = "Holland", CountryCode = "nl"
} },
new City { Id = 2, Name = "Barcelona", Country = new Country {
Id = 7, CountryName = "Spain", CountryCode = "es"
} },
new City { Id = 3, Name = "Berlin", Country = new Country {
Id = 4, CountryName = "Germany", CountryCode = "de"
} },
new City { Id = 4, Name = "Copenhagen", Country = new Country {
Id = 2, CountryName = "Denmark", CountryCode = "dk"
} },
new City { Id = 5, Name = "Dubrovnik", Country = new Country {
Id = 1, CountryName = "Croatia", CountryCode = "hr"
} },
new City { Id = 6, Name = "Edinburgh", Country = new Country {
Id = 8, CountryName = "United Kingdom", CountryCode = "gb"
} },
new City { Id = 7, Name = "London", Country = new Country {
Id = 8, CountryName = "United Kingdom", CountryCode = "gb"
} },
new City { Id = 8, Name = "Madrid", Country = new Country {
Id = 7, CountryName = "Spain", CountryCode = "es"
} },
new City { Id = 9, Name = "New York", Country = new Country {
Id = 9, CountryName = "United States", CountryCode = "us"
} },
new City { Id = 10, Name = "Paris", Country = new Country {
Id = 3, CountryName = "France", CountryCode = "fr"
} },
new City { Id = 11, Name = "Rome", Country = new Country {
Id = 6, CountryName = "Italy", CountryCode = "it"
} },
new City { Id = 12, Name = "Venice", Country = new Country {
Id = 6, CountryName = "Italy", CountryCode = "it"
} }
};
}
}
这确实是一项简单的城市服务。此代码所做的只是生成城市及其所属国家/地区的集合,然后通过名为 GetAllAsync 的公共方法使它们可用。您添加了一些看起来像关系数据库主键的唯一标识符。您可以将此类代码用于概念验证应用程序,或用作单元测试的测试替身(关系数据库的替代品)。或者,如果您曾经写过一本书,您可以将这样的代码用作演示应用程序的一部分,用于学习目的!这里唯一有点奇怪的是,GetAllAsync 方法返回 Task<List<City>>
而不仅仅是 List<City>
。这是因为您将在下一章中迁移到使用数据库,因此您希望模拟数据库调用,这些调用通常在 ASP.NET Core 应用程序中异步执行。我将在下一章中更详细地讨论这一点。
现在您已经集中了城市的创建,您可以在 PropertyManager \Create.cshtml.cs 文件中使用您的新零部件。打开该选项,并更改现有处理程序方法和 GetCityOptions 方法,使各种页面属性保持原样。您还需要为 CityBreaks.Services 添加 using 指令。
清单 7.2 修改了使用新的 SimpleCityService 的 Create Property 页面
public async Task OnGetAsync() ❶
{
Cities = await GetCityOptions(); ❶
}
public async Task OnPostAsync() ❶
{
Cities = await GetCityOptions(); ❶
if (ModelState.IsValid)
{
var city = Cities.First(o => o.Value == SelectedCity.ToString());
Message = $"You selected {city.Text} with value of {SelectedCity}";
}
}
private async Task<SelectList> GetCityOptions() ❶
{
var service = new SimpleCityService(); ❷
var cities = await service.GetAllAsync(); ❷
return new SelectList(cities, nameof(City.Id),
➥ nameof(City.Name), null, "Country.CountryName");
}
❶ 修改处理程序方法和 GetCityOptions 方法,使它们异步。
❷ 从 SimpleCityService 类获取数据,而不是在 PageModel 中生成数据。
除了转换为使用异步方法之外,这里唯一真正的变化是生成城市集合的责任不再是 CreateModel 类的责任。该工作已委派给新类:SimpleCityService。CreateModel 类依赖于数据的 SimpleCityService,因此 SimpleCityService 是 CreateModel 类的依赖项。
7.1.2 松耦合
我相信是史蒂夫·史密斯(Steve Smith),又名“Ardalis”,一位著名的 ASP.NET 演说家、作家和培训师,首先创造了“新即胶水”(https://ardalis.com/new-is-glue/)这句话。他建议,无论何时在 C# 代码中使用 new 关键字,都要考虑是否在使用者(“高级模块”)与其依赖项(“低级模块”)之间创建紧密耦合,如果是,则从长远来看可能会产生什么影响。在此示例中,在将提供城市数据的逻辑转移到其自己的组件中时,您已有效地将 SimpleCityService 组件粘附到 CreateModel 类。这些参与者之间的关系如图 7.2 所示。
图 7.2 SimpleCityServices 与 Create-Model 类紧密耦合。
您在这里违反了软件工程原则,即显式依赖关系原则,该原则指出“方法和类应显式要求(通常通过方法参数或构造函数参数)它们需要的任何协作对象,以便正常运行”(http://mng.bz/9Vzj)。您的协作对象 SimpleCityService 是 CreateModel 类的隐式依赖项,因为只有在查看使用类的源代码时,才能明显地看到 CreateModel 类依赖于 SimpleCityService。应避免隐式依赖关系。它们难以测试,并使使用者 (CreateModel) 更加脆弱且难以更改。
如果你想把数据提供者的实现改为另一个,比如说从数据库获取数据,你必须遍历代码中调用 new SimpleCityService() 的所有地方,并改变它以引用你的替代实现。您将在下一章中更改实现。您可能认为使用开发工具的 Find and Replace 功能可以使这项工作相对轻松,但这不是构建应用程序的可持续方式,尤其是当有更好的选项可用于交换实现时,我们接下来将介绍这一点。
7.1.3 依赖倒置
那么如何实现松耦合呢?如何重新设计组件或服务的使用者,使它们不再与特定或具体的实现紧密耦合?一种解决方案是依赖抽象,而不是特定的实现。这种方法被称为依赖关系反转原则 (DIP),它是 SOLID 首字母缩略词中的 D。依赖关系倒置也称为控制倒置 (IoC)。
抽象类和接口表示 C# 中的抽象。根据经验,您通常会使用 interfaces 作为抽象,除非您有一些常见的默认行为,您希望所有实现共享;在这种情况下,您应该选择一个抽象类。
依赖关系倒置原则指出,“高级模块不应依赖于低级模块。两者都应该依赖于抽象。抽象不应依赖于细节。细节应该取决于抽象“(Robert C. Martin: Agile Software Development, Principles, Patterns, and Practices, Pearson, 2002)。
高级模块往往是服务的消费者,而低级模块往往是服务本身。因此,DIP 的第一部分指出,消费者和服务都应该依赖于抽象,而不是消费者依赖于特定的服务实现。在此示例中,抽象将是一个接口;Service 将实现它,并且 Consumer 将调用它(图 7.3)。
图 7.3 PageModel 和 SimpleCityService 依赖于一个抽象:ICityService 接口。
现在,依赖关系链已倒置,您需要设计 ICityService 接口。DIP 的第二部分指出,接口也应该依赖于抽象,而不是 “细节”。也就是说,接口不应绑定到特定的实现。因此,您的接口不应返回特定于实现的类型,例如 DbDataReader,它仅适用于关系数据库。它应该依赖于更通用的类型,如 List<T>
。幸运的是,您的 SimpleCityService 类已经这样做了。因此,您将基于其现有 API 创建一个接口。
将新的 C# 代码文件添加到 Services 文件夹,并将其命名为 ICityService.cs。请注意,如果您使用的是 Visual Studio,则 Add...New Item 对话框包括 Interface (界面) 选项。将现有代码替换为以下内容。
清单 7.3 ICityService 接口
using CityBreaks.Models;
namespace CityBreaks.Services
{
public interface ICityService
{
Task<List<City>> GetAllAsync();
}
}
现在你需要确保低级组件依赖于抽象。更改 SimpleCityService,使其实现以下接口:
public class SimpleCityService : ICityService
请注意,如果您使用 Visual Studio 向导提取接口,则此步骤不是必需的。
最后一步是让高级模块 CreateModel 类也依赖于抽象。你怎么做呢?请打鼓......您使用依赖项注入。
7.1.4 依赖注入
依赖注入是一种帮助我们实现依赖倒置的技术。顾名思义,您将依赖项注入到消费模块中,通常通过其构造函数作为参数,并将其分配给私有字段,以便在消费类中使用。正如您将记得的那样,将依赖项作为参数注入构造函数方法有助于我们遵守显式依赖项原则。
下面的代码清单显示了 CreateModel,它经过更改后包含一个将 ICityService 作为参数的显式构造函数。它会将其分配给私有字段,以便在需要时可以在类中引用它。
清单 7.4 通过 CreateModel 的构造函数注入 ICityService 依赖项
public class CreateModel : PageModel
{
private readonly ICityService _cityService; ❶
public CreateModel(ICityService cityService) ❷
{
_cityService = cityService; ❸
}
...
}
❶ 向类添加私有字段以存储依赖项。
❷ 通过构造函数注入依赖项。
❸ 将注入的依赖项分配给 private 字段。
现在,您的依赖项是显式的。CreateModel 类所需的协作对象(实现 ICityService 接口的任何类型的)通过它在类的构造函数中的存在来向外界标识。
7.2 控制反转容器
当然,在 C# 中,您无法实例化接口,那么此代码怎么有意义呢?传递给 constructor 参数的实际类型可以是实现指定接口的任何类型。在运行时,将向构造函数提供接口的实现。内置的依赖项注入容器提供了实现。在 Microsoft 文档中,这通常称为服务容器,但更广泛地说,您可能还会看到这种类型的组件称为 IoC 容器或 DI 容器。
所以这就只剩下一个问题:容器如何知道要提供哪个实现?答案是,您可以通过在容器中注册您的服务来告诉它。
7.2.1 服务注册
通过将注册添加到 WebApplicationBuilder 的 Services 属性,在 Program 类中进行服务注册。您可能还记得在第 2 章中讨论使用 IMiddleware 接口构建中间件时看到过这一点,尽管我当时没有详细介绍。标准 Web 应用程序模板已包含通过 AddRazorPages 方法注册 Razor Pages,该方法负责注册 Razor Pages 所依赖的所有服务,包括负责生成和匹配路由、处理程序方法选择和页面执行的服务,以及 Razor 视图引擎本身。
Services 属性是 IServiceCollection,它是框架的服务容器。它包含一组 ServiceDescriptor 对象,每个对象都表示一个已注册的服务。基本的注册服务由服务类型、实现和服务生命周期组成。下面的清单显示了如何将 ICityService 注册为新的 ServiceDescriptor。
清单 7.5 在服务容器中注册 ICityService
builder.Services.AddRazorPages();
builder.Services.Add(new ServiceDescriptor(typeof(ICityService), typeof(SimpleCityService), ServiceLifetime.Transient));
但是,您更有可能使用 IServiceCollection 上提供的特定于生命周期的扩展方法之一,该方法将服务类型和实现作为泛型参数(图 7.4)。
builder.Services.AddTransient<ICityService, SimpleCityService>();
图 7.4 注册会导致将 ServiceDescriptor 对象添加到 IServiceCollection 中,该对象由服务类型、实现和生存期组成。
服务容器的工作是在请求服务类型时(例如,注入到构造函数中)提供正确的实现。此过程也称为 解决依赖关系。因此,当容器看到对 ICityService 的请求时,它将提供 SimpleCityService 的实例(图 7.5)。
图 7.5 当容器看到服务请求时,它会提供 implementation
7.2.2 服务生命周期
服务容器不仅负责解析 implementation。它还负责管理服务的生命周期。也就是说,容器负责创建服务并销毁服务,这取决于服务注册到的生命周期。服务可以注册为具有以下三个生命周期之一:
• Singleton
• Transient
• Scoped
对于每个生命周期,都有一个扩展方法,该方法以单词 Add 开头,后跟生命周期的名称。例如,您已使用 AddTransient 方法注册具有瞬态生存期的 ICityService。
单例服务
使用 AddSingleton 方法注册的服务在首次请求服务时实例化为单一实例,并在容器的生存期内保留,这通常与正在运行的应用程序相同。顾名思义,一个单例只能存在一个实例。它被重新用于所有请求。绝大多数框架服务(模型绑定、路由、日志记录等)都注册为单一实例。它们都具有相同的特征,因为它们没有任何状态并且是线程安全的,这意味着同一个实例可以跨多个线程使用;处理并发请求可能需要这样做。相同的特征也必须适用于单一实例服务所依赖的与服务一起实例化的任何依赖项。
我将简要地偏离我们当前应用程序的主要方向,通过一个简单的演示来探讨它是如何工作的。您将创建一个服务,该服务公开在其构造函数中设置的值,然后将该服务注册为单一实例。您将对该值使用 GUID,因为几乎可以肯定,每次生成该值时,GUID 都会有所不同。然后,您将向浏览器呈现该值。您将注意到,当您刷新页面时,该值不会更改。使用以下代码将新的 C# 类添加到名为 LifetimeDemoService 的 Services 文件夹中。
清单 7.6 LifetimeDemoService 类
using System;
namespace CityBreaks.Services
{
public class LifetimeDemoService
{
public LifetimeDemoService()
{
Value = Guid.NewGuid();
}
public Guid Value { get; }
}
}
每当调用类构造函数时(即容器实例化服务时),都会设置 public Value 属性。您将此服务注册为单一实例,这应确保它在应用程序的生命周期中只实例化一次:
builder.Services.AddRazorPages();
builder.Services.AddTransient<ICityService, SimpleCityService>();
builder.Services.AddSingleton<LifetimeDemoService>();
该服务注册到 AddSingleton 方法的一个版本,该方法采用表示实现的单个泛型参数。此示例没有抽象。这不是必需的,因为这是一个简单的演示,抽象会不必要地分散对后面示例的要点的注意力。在 Pages 文件夹中创建一个名为 LifetimeDemo.cshtml 的新 Razor 页面,并在 PageModel 类文件中使用以下代码。
列表 7.7 用于演示服务生命周期如何工作的 LifetimeDemoModel 代码
using CityBreaks.Services;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace CityBreaks.Pages
{
public class LifetimeDemoModel : PageModel
{
private readonly LifetimeDemoService _lifetimeDemoService;
public LifetimeDemoModel(LifetimeDemoService lifetimeDemoService)
{
_lifetimeDemoService = lifetimeDemoService;
}
public Guid Value { get; set; }
public void OnGet()
{
Value = _lifetimeDemoService.Value;
}
}
}
通过添加以下清单中突出显示的行来更改 Razor 页面本身中的代码。
列表 7.8 LifetimeDemo Razor 页面
@page
@model CityBreaks.Pages.LifetimeDemoModel
@{
ViewData["Title"] = "Lifetime demo";
}
<h2>Service Lifetime Demo</h2>
<p>The Singleton service returned @Model.Value</p>
运行应用程序,导航到 /lifetime-demo (记住 KebabPageRouteParameterTransformer 的效果),并记下呈现给浏览器的值。刷新页面,并确认值保持不变。使用其他浏览器请求该页面。该值不会更改。这是因为该值是在首次实例化服务时设置的,并且作为单一实例,服务的所有使用者在所有请求中共享相同的服务实例。
瞬态服务
使用 AddTransient 方法注册的服务被赋予瞬态生命周期,这意味着每次解析它们时都会创建它们。这些类型的服务应该是轻量级的无状态服务,其实例化成本相对较低。当 service scope 被销毁时,它们将被销毁。在 ASP.NET Core 应用程序的上下文中,范围在 HTTP 请求结束时销毁。如果您有一个复杂的依赖关系图,其中相同的服务类型被注入到多个构造函数中,则每个使用者都将收到自己的服务实例。SimpleCityService 是瞬态生存期的良好候选者,因为它满足不维护状态且实例化成本低的服务的定义。
要查看其工作情况,您需要将服务的第二个实例注入 PageModel,并将其值呈现给浏览器。对 LifetimeDemoModel 类进行以下更改。
清单 7.9 向 PageModel 注入第二个服务
private readonly LifetimeDemoService _lifetimeDemoService;
private readonly LifetimeDemoService _secondService; ❶
public LifetimeDemoModel(LifetimeDemoService lifetimeDemoService,
LifetimeDemoService secondService) ❷
{
_lifetimeDemoService = lifetimeDemoService;
_secondService = secondService; ❸
}
public Guid Value { get; set; }
public Guid SecondValue { get; set; } ❹
public void OnGet()
{
Value = _lifetimeDemoService.Value;
SecondValue = _secondService.Value; ❺
}
❶ 为第二个服务添加私有字段。
❷ 注入 LifetimeDemoService 的第二个实例。
❸ 将其分配给 private 字段。
❹ 向 PageModel 添加另一个公共属性。
❺ 将其 value 设置为第二个服务的 Value。
接下来,更改 Razor 页面中呈现服务值的代码,如下所示。
列表 7.10 渲染来自两个服务的值
<p>The first transient service returned @Model.Value</p>
<p>The second transient service returned @Model.SecondValue</p>
最后,更改 Program.cs 中的注册以使用瞬态生存期:
builder.Services.AddTransient<LifetimeDemoService>();
运行应用程序,导航到 /lifetime-demo,并注意呈现给浏览器的值不同。每次刷新页面时,它们都会更改,从而确认每次请求时都会实例化每个服务。
范围服务
最后一个生命周期选项是 Scoped 生命周期。如前所述,在 ASP.NET Core Web 应用程序中,范围是 HTTP 请求,这意味着每个 HTTP 请求都会创建一次范围服务。实际发生的情况是,为每个 HTTP 请求创建一个容器实例,并在请求结束时销毁。作用域服务由此作用域容器解析,因此在作用域结束时销毁容器时,这些服务将被销毁。作用域服务与瞬态服务的不同之处在于,每个作用域只解析作用域内的服务的一个实例,而瞬态服务的多个实例可以实例化。
实例化后,作用域服务将在范围 (请求) 期间根据需要多次重复使用,并在请求结束时释放。每个请求都有自己的范围容器,因此对同一资源的并发请求将使用不同的容器。
要了解其工作原理,您需要做的就是将 LifetimeDemoService 的注册更改为使用 AddScoped 方法:
builder.Services.AddScoped<LifetimeDemoService>();
然后更改 Razor 页面以引用范围化的服务值:
<p>The first scoped service returned @Model.Value</p>
<p>The second scoped service returned @Model.SecondValue</p>
现在,当您运行应用程序时,您应该会看到两个服务生成相同的值。一旦 LifetimeDemoService 被实例化,它就会在 HTTP 请求范围内需要的任何地方被重用。在这方面,它类似于单一实例,其范围限定为请求,而不是应用程序的生命周期。
作用域生命周期最适合于实例化成本高昂和/或需要在请求期间保持状态的服务。在 Razor Pages 应用程序中,受益于作用域生存期的最常用服务之一是实体框架 DbContext,我们将在下一章中更详细地介绍它。DbContext 满足这两个条件,因为它创建与外部资源(数据库)的连接,并且它可能需要维护有关从数据库中检索的数据的信息。
7.2.3 捕获依赖项
在为服务选择生命周期时,您还应考虑服务所依赖的任何依赖项的生命周期。例如,如果依赖项是已注册到作用域内生存期的 DbContext,则应确保您的服务也注册到作用域内生存期。否则,您最终可能会遇到称为捕获依赖项的问题。当依赖项注册的生命周期短于其使用者时,会出现此问题。如果尝试从单一实例中使用作用域内服务,则 DI 容器将引发 InvalidOperationException,但不会收到此类保护,以防止从单一实例中使用暂时性服务。如果将临时服务注入另一个服务,然后注册为单一实例,则依赖项实际上也会成为单一实例,因为在应用程序的生命周期内,使用服务的构造函数只会被调用一次,并且只会在应用程序停止时销毁。
让我们来看看这个,这样你就可以清楚地理解它。将以下代码作为新的 C# 类添加到 Services 文件夹中。
清单 7.11 SingletonService 类
namespace CityBreaks.Services
{
public class SingletonService
{
private readonly LifetimeDemoService _dependency;
public SingletonService(LifetimeDemoService dependency)
{
_dependency = dependency;
}
public Guid DependencyValue => _dependency.Value;
}
}
代码很简单;SingletonService 类将您现有的 LifetimeDemoService 作为依赖项,并使用它来生成值。现在你需要将 SingletonService 注册为单一实例,同时让 LifetimeDemoService 注册为瞬态生命周期:
builder.Services.AddTransient<LifetimeDemoService>();
builder.Services.AddSingleton<SingletonService>();
更改 Razor 页面中的标记以输出以下内容:
<p>The singleton service's transient dependency returned @Model.Value</p>
运行页面并刷新它。请注意,该值永远不会更改。你不会在每个请求上获得 LifetimeDemoService 的新实例,因为使用者的构造函数没有被调用,因为它是一个单一实例。
7.2.4 其他服务注册选项
上述示例使用了 Add[LIFETIME] 方法之一,该方法采用两个泛型参数 — 第一个参数表示服务类型,第二个参数表示实现。这是您可能最常使用的模式。我们还查看了采用实现的 Add[LIFETIME] 方法的版本。在这里,我们将回顾一些提供额外功能的其他注册选项。
想象一下你的 SimpleCityService 需要传递给它一些构造函数参数。您可以通过传入定义要传递的参数的工厂来做到这一点:
builder.Services.AddTransient<ICityService>(provider => new
➥ SimpleCityService(args));
如果构造函数参数包含来自容器的依赖项,则工厂将提供对该服务的访问,因此您可以解析依赖项。下面的示例演示 SimpleCityService 依赖于 IMyService 和 args 的实现时,其工作原理。使用 IServiceProvider GetService 方法解析依赖项。在本章末尾,我们将介绍直接从服务提供商访问服务的其他方法:
builder.Services.AddTransient(provider =>
new SimpleCityService(args, provider.GetService<IMyService>())
);
factory 选项是首选选项,因为它将更新或激活服务的责任交给了服务容器。如果容器负责服务激活,它还负责服务处置。还有一种适用于单例服务的替代方法,该方法涉及传入构造的服务:
builder.Services.AddSingleton<IMyService>(new MyService(args));
当您使用此方法注册服务时,您也必须对其处置负责。如果使用 implementation-only 选项传入构造的服务,则情况也是如此:
builder.Services.AddSingleton(new MyService(args));
7.2.5 注册多个 implementation
可以通过重复具有相同服务类型但不同实现的相关 Add[LIFETIME] 方法来注册服务的多个实现:
builder.Services.AddTransient<ICityService, SimpleCityService>();
builder.Services.AddTransient<ICityService, CityService>();
这就提出了一个明显的问题:当它的抽象被注入到构造函数中时,哪一个会得到解决?该问题的答案是您注册的最后一个。所以另一个问题出现了:注入多个 implementation的能力有什么用?
假设您有多个不同的服务实现,但您依赖运行时数据来确定使用哪个实现。例如,您可能希望根据访客的位置计算价格、税费和折扣。你可以为你服务的每个位置填充一个服务的条件代码,但你可以想象这种方法很快就会变得非常混乱,尤其是在计算很复杂的情况下。例如,如果您需要更新代码以反映一个地区的法律变化,您也可以想象维护问题。这有可能无意中更改其他位置的代码,并引入与您需要进行的更改无关的 bug。
相反,您可以为每个位置提供单独的实施。请考虑以下简单接口:IPriceService。
示例 7.12 IPriceService 接口
public interface IPriceService
{
string GetLocation();
double CalculatePrice();
}
此接口定义了两个方法:一个返回适用于任何特定实现的位置,另一个表示计算价格的逻辑。假设此服务定义的每个实现都返回您已经知道的 ISO 3166-1 Alpha-2 代码,但默认价格服务除外,它返回“XX”。美国版本如清单 7.13 所示。其他选项可在本节随附的下载 (http://mng.bz/o54p) 中找到。
清单 7.13 美国 IPriceService 的示例实现
public class UsPriceService : IPriceService
{
public string GetLocation() => "us";
public double CalculatePrice()
{
...
}
}
您可以向服务容器注册各种实现:
builder.Services.AddScoped<IPriceService, FrPriceService>();
builder.Services.AddScoped<IPriceService, GbPriceService>();
builder.Services.AddScoped<IPriceService, UsPriceService>();
builder.Services.AddScoped<IPriceService, DefaultPriceService>();
如果要将 IPriceService 注入 PageModel 构造函数,则始终会获得 DefaultPriceService,如上所示,因为它是最后一个注册的。但是,您也可以注入 IEnumerable
我是 Cloudflare (https://www.cloudflare.com/) 的粉丝,它提供一系列与 Web 相关的服务,包括地理定位(可以使用其他地理定位服务提供商),从而根据请求的 IP 地址识别请求的位置。该位置在请求标头中作为 ISO-3166-1 Alpha-2 代码或“XX”(无法解析该位置)提供给应用程序代码。下面的清单显示了如何使用此标头值根据当前请求解析要调用的正确服务的示例。
清单 7.14 从多个已注册的服务中解析一个
public class CityModel : PageModel
{
private readonly IEnumerable<IPriceService> _priceServices;
public CityModel(IEnumerable<IPriceService> priceServices) ❶
{
_priceServices = priceServices;
}
public void OnGet()
{
var locationCode = Request.Headers["CF-IPCountry"]; ❷
var priceService = _priceServices.FirstOrDefault(s=> s.GetLocation()
➥ == locationCode); ❸
// do something with priceService
}
}
❶ 注入一个表示所有已注册实现的集合。
❷ 获取用于定义适用于此请求的实现的运行时数据。
❸ 查询与传入 FirstOrDefault 方法的谓词匹配的服务的集合。
采用此模式有两个明显的好处。首先,每个 IPriceService 实现都是特定于位置的,这减少了它们所需的代码量,从而简化了维护体验。第二个是,如果您想迎合更多位置,您只需创建一个新服务并将其与其他服务一起注册。它将自动解析为注入的集合的一部分。
还有另一种注册服务的方法,在注册了多个 implementations 的情况下,将导致第一个 registration 得到解决,而不是最后一个 implementation。即使用 TryAdd<LIFETIME>
方法。如果使用 TryAddScoped 重复注册 IPriceService 实现(如下面的清单所示),则将解析第一个实现,除非注入 IEnumerable。
列表 7.15 TryAdd<LIFETIME>
导致第一个实现被解析
builder.Services.TryAddScoped<IPriceService, FrPriceService>(); ❶
builder.Services.TryAddScoped<IPriceService, GbPriceService>();
builder.Services.TryAddScoped<IPriceService, UsPriceService>();
builder.Services.TryAddScoped<IPriceService, DefaultPriceService>();
❶ 解析首先注册的 implementation。
那么,何时使用 TryAdd 方法注册服务呢?通常,如果要确保默认情况下不使用意外进行的其他注册,则可以使用此方法。如果不清楚正在进行哪些注册,则可能会发生这种情况,因为它们隐藏在扩展方法(例如 AddRazorPages 方法)中。库作者可能希望确保他们的注册被使用,而不管框架的使用者随后尝试做什么。
7.3 访问已注册服务的其他方式
构造函数注入可能是使用已注册服务的最常见方式。但是,您应该注意其他访问服务的方法。您可能会在某个阶段使用其中一些选项,但它们有其注意事项。这些选项包括直接注入 Razor 文件、方法注入和直接从服务容器检索服务。
7.3.1 视图注入
框架提供的一些服务旨在帮助生成 HTML。一个示例是 IHtmlLocalizer 服务,该服务用于在需要处理多种语言的 Web 应用程序中本地化 HTML 代码段。它在 Razor 页面或视图之外没有任何用途。可以将此服务注入到需要它的页面的 PageModel 中,然后将其分配给公共属性,以便可以通过 Razor 页面本身中的 Model 访问它。但更好的解决方案是简单地使用 @inject 指令将服务直接注入页面。
清单 7.16 使用 @inject 指令将服务注入 Razor 页面
@page
@inject IHtmlLocalizer<IndexModel> htmlLocalizer ❶
@model IndexModel
@{
ViewData["Title"] = "Home page";
}
<div class="text-center">
<h1>Welcome</h1>
<p>@htmlLocalizer["Intro"]</p> ❷
</div>
❶ IHtmlLocalizer<T>
服务使用 @inject 指令注入,并分配给变量 htmlLocalizer。
❷ 本地化工具服务用于本地化标识为“Intro”的 HTML 片段。
我应该强调的是,当您仅将此方法用于基于 HTML 的服务时,此方法很好。您不应将任何包含业务逻辑的服务直接注入到页面中。我们的业务逻辑远离 HTML,不是吗?
7.3.2 方法注入
开箱即用,默认服务容器仅支持构造函数注入。但是,ASP.NET Core 在几个地方添加了方法参数注入。您已经在第 2 章中看到过一个例子,当时我们研究了如何创建传统的中间件。如果您还记得,您已将 ILogger<T>
注入到 InvokeAsync 方法中:
public async Task InvokeAsync(HttpContext context,
➥ ILogger<IpAddressMiddleware> logger)
但是处理程序方法呢?毕竟,处理程序方法参数被模型 Binder 视为绑定目标。当模型绑定器遇到 IPriceService 参数时会发生什么情况?您的应用程序中断。除非在 service 参数前面加上 FromServices 属性,否则就会发生这种情况:
public async Task OnGetAsync([FromServices]IPriceService service)
{
// do something with service
}
对于创建成本高昂但在 Razor 页面中仅使用一小部分时间的服务,这是一种有用的模式。例如,页面中有一个命名处理程序,该处理程序需要 OnGet 和 OnPost 处理程序不需要的服务,并且仅在某些情况下调用命名处理程序。在这种情况下,将服务注入 PageModel 构造函数几乎没有意义。FromServices 属性允许您将服务范围限定为需要它的处理程序方法,并且仅在需要时解析它。
7.3.3 使用 GetService 和 GetRequiredService 直接从服务容器
有时,您需要直接访问服务的服务容器。这种方法称为 Service Locator 模式。这听起来像是一件好事,作为一种设计模式等等,但它通常被认为是一种反模式,应该避免。但是,有时您别无选择。在本章前面,当您使用工厂注册一个服务时,您已经看到了这样一个例子,该服务将另一个服务作为依赖项。
定义反模式是针对反复出现的问题(模式)的常用解决方案,通常在某种程度上是次优的。这可能是因为该解决方案引入了新问题,或者因为它只是将问题转移到了其他位置。
IServiceProvider 服务提供对已注册服务的访问。它有一个方法 GetService,该方法返回指定的服务,如果未找到,则返回 null。此外,还有一个扩展方法 GetRequiredService,如果未找到指定的服务,则会引发异常。将 IServiceProvider 注入到使用者中,然后使用者使用它来检索所需的服务。
清单 7.17 服务定位器模式的示例用法
public class IndexModel : PageModel
{
private readonly IServiceProvider _serviceProvider; ❶
public IndexModel(IServiceProvider serviceProvider) =>]
➥ _serviceProvider = serviceProvider; ❶
public List<City> Cities { get; set; } ❶
public async Task OnGetAsync()
{
var cityService =
➥ _serviceProvider.GetRequiredService<ICityService>(); ❶
Cities = await cityService.GetAllAsync();
}
}
❶ 将 IServiceProvider 注入类构造函数。
回想一下我所说的显式依赖关系原则,您也许能够辨别为什么服务定位器是一种反模式。从清单 7.17 中的代码中不清楚 IndexModel 的依赖项是什么——除了服务提供商之外。事实上,它仍然依赖于 ICityService,但该详细信息不再对类外部的代码可见。
服务提供者也作为请求功能 (http://mng.bz/neE2) 提供,因此您甚至不需要将提供者注入可以访问 HttpContext 的类中。您可以将解析清单 7.17 中 city 服务的代码行替换为以下内容:
var cityService =
➥ HttpContext.RequestServices.GetRequiredService<ICityService>();
依赖项注入和随之而来的其他术语听起来很复杂,但现实情况是,它是一种非常简单的技术,有助于实现高质量的代码。内置服务容器应该足以满足大多数使用案例,但如果您发现自己需要更高级的东西,您可以使用支持 ASP.NET Core 的众多(通常是免费和开源的)第三方容器之一。集成通常非常简单,供应商应完整记录。
在下一章中,我们将了解如何在 Razor Pages 应用程序中处理数据。在此过程中,我们将创建一个新服务,从数据库中获取数据,并将现有服务无缝地替换为新服务,这展示了 using DI 的主要优势之一。
总结
依赖项注入 (DI) 是 ASP.NET Core 中的一项关键功能。
DI 可帮助您实现控制反转,这是一种促进代码松散耦合的技术。
服务被注入到依赖于它们的类中,这些类作为显式依赖项。
依赖关系反转原则 (DIP) 指出,高级类和低级类应依赖于抽象,例如接口。
您可以通过 WebApplication Services 属性在 Program.cs 中配置服务,该属性表示为应用程序配置的服务。
服务注册为服务容器的类型和实现。
服务使用以下三个生存期之一进行注册:单一实例、瞬态或作用域。
只能存在一个单一实例的实例。它在容器的生命周期内持续。
每次请求临时服务时,都会对其进行解析。
分区服务在 ASP.NET Core 中 Web 请求的持续时间内持续。
可以注册同一服务的多个实现。最后一个注册的问题将被解析。
您可以通过注入和 IEnumerable <ServiceType>
访问所有已注册的实现。
您可以通过在 service 参数前面加上 [FromServices] 来注入到页面处理程序方法中。
您可以通过 @inject 属性直接注入 Razor 页面。