八、选项和日志模式
在本章中,我们将介绍特定于.NET 的模式,如选项模式和日志记录。我们将探索这些抽象,同时将其保持在我们使用它们的水平,而不是掌握它们的每个方面。阅读本章后,您应该知道如何利用.NET 5 选项和设置基础架构,以及如何编写应用日志。我们还将简要探讨如何定制这些系统。
本章将介绍以下主题:
- 选项模式概述
- 熟悉.NET 日志抽象
让我们开始吧!
期权模式概述
在 ASP.NET 5 中,我们可以使用预定义的机制来提高应用设置的使用率。这些允许我们将配置划分为多个较小的对象,在启动流程的多个阶段对它们进行配置,验证它们,甚至以最小的工作量观察运行时的更改。
选项模式的目标是在运行时使用设置,允许在不更改代码的情况下更改应用。设置可以是简单的string
、bool
、数据库连接字符串或包含整个子系统配置的复杂对象。
本节探讨 ASP.NET Core 提供的不同工具,用于管理、注入配置和选项,并将其加载到我们的程序中。我们将处理不同的场景,从普通场景到更高级场景。
开始
ASP.NET Core 5 中的选项模式允许我们无缝地从多个源加载设置。我们可以在创建IHostBuilder
时自定义这些源,甚至可以使用通过调用Host.CreateDefaultBuilder(args)
设置的默认源。按顺序排列的默认源如下:
appsettings.json
。appsettings.{Environment}.json
。- 用户机密;这些仅在环境为
Development
时加载。 - 环境变量。
- 命令行参数。
顺序也非常重要,因为最后一个要加载的值会覆盖任何以前的值。例如,您可以在appsettings.json
中设置一个值,并在appsettings.Staging.json
中重写该值,方法是重新定义该文件、用户机密或环境变量中的值,或者在运行应用时将其作为命令行参数传递。
设置的使用方式主要有四种:IOptionsMonitor<TOptions>
、IOptionsFactory<TOptions>
、IOptionsSnapshot<TOptions>
和IOptions<TOptions>
。在所有这些情况下,我们可以将该依赖项注入到类中以使用可用的设置。TOptions
是表示我们要访问的设置的类型。
正如下面三个注释中提到的,如果您没有配置 options 类,框架通常会返回一个空的 options 类实例。我们将在下一小节中学习如何正确配置选项,但请记住,在 options 类中使用属性初始值设定项也是确保使用某些默认值的一种好方法。不要对根据环境(开发、登台或生产)更改的默认值或连接字符串和密码等机密使用初始值设定项。您还可以使用常量将这些默认值集中在代码库中的某个位置(使它们更易于维护)。尽管如此,适当的配置和验证始终是首选,但两者的结合可以增加一个安全网。
基于MyListOption
类,由于int
的默认值为 0,因此每页显示的默认项数为 0,导致列表为空。但是,我们可以使用属性初始值设定项对此进行配置,如下例所示:
public class MyListOption
{
public int ItemsPerPage { get; set; } = 20;
}
现在每页显示的默认项目数为 20。
笔记
在第 8 章、选项和日志模式的源代码中,我在CommonScenarios.Tests
项目中包含了一些测试,这些测试断言了不同选项接口的生命周期。为了简洁起见,我这里没有包含这段代码,但它通过单元测试描述了不同选项的行为。参见https://net5.link/T8Ro 了解更多信息。
接下来,我们将探讨.NET5 提供的不同接口。
IOptionsMonitor
这个接口是所有接口中最通用的。它允许我们接收有关重新加载配置的通知。它还支持缓存,可以有多个配置,每个配置都与一个名称(命名配置)关联。注入的IOptionsMonitor<TOptions>
实例总是相同的(单例生存期。它还通过其Value
属性支持默认设置(无名称)。
笔记
如果您只配置了命名选项或根本没有配置实例,则会收到一个空的TOptions
实例(new TOptions()
。
IOPTIONS 工厂
这个界面是一个工厂,正如我们在第 6 章中看到的,理解策略、抽象工厂和单例设计模式和第 7 章深入到依赖注入。我们使用工厂来创建实例;这个也一样。除非是绝对必要的,否则我建议改为使用IOptionsMonitor<TOptions>
或IOptionsSnapshot<TOptions>
。
这个工厂的一个用途可能是创建多个设置实例,同时只注入一个依赖项,但这听起来更像是一个设计缺陷,而不是解决方案。然而,这在某些罕见的情况下可能有用。为什么是缺陷?您将恢复到控制依赖项,而不是执行 IoC。
其工作原理很简单:每次您请求一个工厂(瞬态寿命)时,都会创建一个新的工厂,每次您调用其Create(name)
方法(瞬态寿命时,每个工厂都会创建一个新的选项实例。
笔记
如果您在调用factory.Create(Options.DefaultName)
时只配置了命名选项,或者根本没有配置实例,那么您将收到一个空的TOptions
实例(new TOptions()
)。
Options.DefaultName
是非命名选项的名称;这通常由框架为您处理。
IOptionsSnapshot
当您需要设置的快照时,此界面非常有用,并且每个请求创建一次作用域生存期。我们也可以使用它来获取命名选项,例如IOptionsMonitor<TOptions>
。我们可以使用CurrentValue
属性访问默认选项。
笔记
如果只配置了命名选项或根本没有配置实例,则会收到一个空的TOptions
实例(new TOptions()
。
选项
此接口是添加到 ASP.NET Core 的第一个接口。它不支持高级方案,例如快照和监视器的功能。无论何时您请求IOptions<TOptions>
,您都会得到相同的实例(单例生存期。
笔记
IOptions<TOptions>
不支持命名选项,只能访问默认选项。
项目-公共场合
第一个示例涵盖多个基本用例,例如注入选项、使用命名选项以及在设置中存储选项值。
常用
在第一个用例中,我们将学习如何使用IOptions<TOptions>
。我们还将定义多个其他场景的共同点。让我们一步一步地回顾一下我们将要做的事情:
- 为我们的服务创建一个名为
IMyNameService
的接口。 - 创建选项类。
- 针对该接口编写一些单元测试代码。我们将为每个实现重用这些测试。
- 为我们的第一个实现编写代码。
- 对它运行我们的测试。
我们的界面非常简单,如下所示:
public interface IMyNameService
{
string GetName(bool someCondition);
}
它包含一个GetName
方法,该方法将someCondition
作为参数并返回一个字符串,从中我们可以预期其内容是一个名称。
接下来,我们创建两个选项类:一个用于此场景,另一个用于其他场景。我们将在其他场景中使用的类如下所示:
public class MyOptions
{
public string Name { get; set; }
}
我们将在本场景中使用的类如下:
public class MyDoubleNameOptions
{
public string FirstName { get; set; }
public string SecondName { get; set; }
}
笔记
我在这里展示了这两个类,以节省以后的空间,这样我就不必再复制相同的测试,而只需做一个小的更改。此外,它们是小而简单的类。
现在,作为 TDD 的实践者,让我们看看作为初始代码使用者的单元测试。我们的简单说明是当someCondition
为true
时,GetName
返回Option1Name
的值,而当someCondition
为false
时,GetName
返回Option2Name
的值。
笔记
Option1Name
和Option2Name
是两个包含不相关值的常数(但不同的值,以便我们可以比较它们的输出)。
让我们从单元测试开始:
public abstract class MyNameServiceTest<TMyNameService>
where TMyNameService : class, IMyNameService
{
protected readonly IMyNameService _sut;
public const string Option1Name = "Options 1";
public const string Option2Name = "Options 2";
public MyNameServiceTest()
{
var services = new ServiceCollection();
services.AddTransient<IMyNameService, TMyNameService>();
services.Configure<MyOptions>("Options1", myOptions =>
{
myOptions.Name = Option1Name;
});
services.Configure<MyOptions>("Options2", myOptions =>
{
myOptions.Name = Option2Name;
});
services.Configure<MyDoubleNameOptions>(options =>
{
options.FirstName = Option1Name;
options.SecondName = Option2Name;
});
_sut = services.BuildServiceProvider()
.GetRequiredService<IMyNameService>();
}
让我们分析一下测试课程的第一部分:
- 我们的测试类是抽象的和泛型的,使它成为所有
IMyNameService
测试的基类。 - 我们使用泛型
TMyNameService
类型作为实现,创建了测试中的主题。 -
We configured two named
MyOptions
and oneMyDoubleNameOptions
. These are injected into the service's implementations; more on that later. In this case, we have configured the options' properties manually. In programs, we usually move those values to configuration files or other providers, such asappsettings.json
; more on that later.笔记
每个选项的名称作为参数传递给
services.Configure<MyOptions>("name", ...)
方法。
然后,我们需要创建前面讨论过的两个测试用例:
[Fact]
public void GetName_should_return_Name_from_options1_when_someCondition_is_true()
{
var result = _sut.GetName(true);
Assert.Equal(Option1Name, result);
}
[Fact]
public void GetName_should_return_Name_from_options2_when_someCondition_is_false()
{
var result = _sut.GetName(false);
Assert.Equal(Option2Name, result);
}
}
现在,让我们创建一个名为MyNameServiceUsingDoubleNameOptions
的实现。它使用IOptions<MyDoubleNameOptions>
接口,这使它成为我们可以使用的最简单的实现:
public class MyNameServiceUsingDoubleNameOptions : IMyNameService
{
private readonly MyDoubleNameOptions _options;
public MyNameServiceUsingDoubleNameOptions (IOptions<MyDoubleNameOptions> options)
{
_options = options.Value;
}
public string GetName(bool someCondition)
{
return someCondition ? _options.FirstName : _options.SecondName;
}
}
这是一个相当简单的实现;我们将IOptions<MyDoubleNameOptions>
注入构造函数,并使用第三级运算符从选项返回第一个或第二个名称。现在我们有了可重用的测试和MyNameServiceUsingDoubleNameOptions
类,我们可以添加具体的测试类,它针对我们的实现运行实际的测试:
public class MyNameServiceUsingDoubleNameOptionsTest : MyNameServiceTest<MyNameServiceUsingDoubleNameOptions> { }
是的,就是这样;所有管道都已在基础测试中完成。如果我们运行测试,一切都应该是绿色的!
命名选项
使用选项模式,我们可以注册相同类型的多个实例并按名称访问它们。
笔记
这样做打破了控制反转、依赖反转和打开/关闭原则,将依赖项的创建控制权返还给消费类,而不是将责任从消费类转移到合成根。
由于.NET 团队认为实现命名选项是合适的,所以我们将在这里介绍它。通过动态访问要创建的选项的名称,而不是在构造函数中硬编码魔术字符串,我们可以使用此模式构建动态应用,而不损害任何原则。例如,这可以使用数据库或其他一些设置。
该功能本身并没有错,但在使用过程中可能会出现问题。
对于这个示例,我们将创建三个不同的实现:一个使用IOptionsFactory<MyOptions>
,一个使用IOptionsMonitor<MyOptions>
,一个使用IOptionsSnapshot<MyOptions>
。这三个测试都使用我们在上一个示例中创建的测试套件。让我们看一下代码:
public class MyNameServiceUsingNamedOptionsFactory : IMyNameService
{
private readonly MyOptions _options1;
private readonly MyOptions _options2;
public MyNameServiceUsingNamedOptionsFactory (IOptionsFactory<MyOptions> myOptions)
{
_options1 = myOptions.Create("Options1");
_options2 = myOptions.Create("Options2");
}
public string GetName(bool someCondition)
{
return someCondition ? _options1.Name : _options2.Name;
}
}
public class MyNameServiceUsingNamedOptionsMonitor : IMyNameService
{
private readonly MyOptions _options1;
private readonly MyOptions _options2;
public MyNameServiceUsingNamedOptionsMonitor (IOptionsMonitor<MyOptions> myOptions)
{
_options1 = myOptions.Get("Options1");
_options2 = myOptions.Get("Options2");
}
public string GetName(bool someCondition)
{
return someCondition ? _options1.Name : _options2.Name;
}
}
public class MyNameServiceUsingNamedOptionsSnapshot : IMyNameService
{
private readonly MyOptions _options1;
private readonly MyOptions _options2;
public MyNameServiceUsingNamedOptionsSnapshot (IOptionsSnapshot<MyOptions> myOptions)
{
_options1 = myOptions.Get("Options1");
_options2 = myOptions.Get("Options2");
}
public string GetName(bool someCondition)
{
return someCondition ? _options1.Name : _options2.Name;
}
}
这三个类非常相似,除了它们的构造函数;每个人都期望有不同的依赖关系。
笔记
我关于魔术弦的笔记现在可能更有意义了;每个类使用硬编码名称请求一组特定的选项;就是一根魔弦。这样做限制了我们从组合根更改任何单个类中的注入选项的能力。要进行更改,我们需要打开该类,更改神奇字符串,保存该类,然后重新编译。此外,使用该工具不会自动重构字符串,从而导致不一致和运行时错误。所以,总而言之,魔术弦更难维护,应该尽量避免。
接下来我们需要,创建以下三个简单类:
public class MyNameServiceUsingNamedOptionsFactoryTest : MyNameServiceTest<MyNameServiceUsingNamedOptionsFactory> {}
public class MyNameServiceUsingNamedOptionsMonitorTest : MyNameServiceTest<MyNameServiceUsingNamedOptionsMonitor> {}
public class MyNameServiceUsingNamedOptionsSnapshotTest : MyNameServiceTest<MyNameServiceUsingNamedOptionsSnapshot> {}
运行这些测试证明我们的三个新类尊重我们的用例。有了这些,我们创建了多个使用命名选项的类。
使用设置
既然我们已经探索了如何手动创建选项,那么让我们来探索如何使用appsettings.json
在 ASP.NET Core 5 应用中实现这一点。
appsettings.json
文件允许您使用 JSON 语法定义任意设置,并根据需要进行结构化。与 ASP.NET Core 之前的web.config
文件中的键/值设置相比,这是一个很大的改进。现在,您可以定义复杂的对象层次结构,从而实现更好的组织。您也不需要编写复杂的管道代码,就像创建自定义web.config
部分一样。
以下是我们的 JSON 设置:
{
"options1": {
"name": "Options 1"
},
"options2": {
"name": "Options 2"
},
"myDoubleNameOptions": {
"firstName": "Options 1",
"secondName": "Options 2"
}
}
这里的数据结构与我们之前定义的类相同;也就是说,MyOptions
和MyDoubleNameOptions
。这是因为我们将要使用提供的选项实用程序将这些设置加载(反序列化)到我们的类中。
在Startup.ConfigureServices
方法中,我们可以添加以下内容:
services.Configure<MyOptions>(
"Options1",
_configuration.GetSection("options1")
);
services.Configure<MyOptions>(
"Options2",
_configuration.GetSection("options2")
);
services.Configure<MyDoubleNameOptions>(
_configuration.GetSection("myDoubleNameOptions")
);
我们在这里使用两种不同的扩展方法,而不是像以前那样手动配置选项。_configuration
字段为IConfiguration
类型,允许我们访问整个配置。调用_configuration.GetSection("...")
会给我们提供另一个IConfiguration
对象,更准确地说,是一个IConfigurationSection
对象,其中键与我们的设置匹配。
笔记
如果您需要访问某个分区,您可以使用:
标志。例如,对于类似于{ "object1": { "object2": {} } }
的配置,您可以GetSection("object1:object2")
直接获取嵌套的配置对象。
之后,我们需要注册我们的服务。在这种情况下,我们需要使用以下代码:
services.AddTransient<MyNameServiceUsingDoubleNameOptions>();
services.AddTransient<MyNameServiceUsingNamedOptionsFactory>();
services.AddTransient<MyNameServiceUsingNamedOptionsMonitor>();
services.AddTransient<MyNameServiceUsingNamedOptionsSnapshot>();
然后,我们可以在任何地方注入这些依赖项;对于这个例子,我决定使用MapGet
扩展方法来创建一个简单的端点:
endpoints.MapGet("/{serviceName}/{someCondition?}", async context =>
{
var serviceName = (string)context.Request.RouteValues["serviceName"];
if (bool.TryParse((string)context.Request .RouteValues["someCondition"], out var someCondition))
{
var myNameService = GetMyNameService(serviceName);
var name = myNameService.GetName(someCondition);
context.Response.ContentType = "application/json";
await context.Response.WriteAsync("{");
await context.Response.WriteAsync("\"name\":");
await context.Response.WriteAsync($"\"{name}\"");
await context.Response.WriteAsync("}");
}
IMyNameService GetMyNameService(string serviceName) => serviceName
switch
{
"factory" => context.RequestServices
.GetRequiredService<MyNameServiceUsingNamed OptionsFactory>(),
"monitor" => context.RequestServices
.GetRequiredService<MyNameServiceUsingNamed OptionsMonitor>(),
"snapshot" => context.RequestServices
.GetRequiredService<MyNameServiceUsingNamed OptionsSnapshot>(),
"options" => context.RequestServices
.GetRequiredService<MyNameServiceUsingDoubleName Options>(),
_ => throw new NotSupportedException($"The service named '{serviceName}' is not supported."),
};
});
笔记
我还添加了一个端点,当用户调用GET /
时,该端点会以链接列表进行响应。我省略了代码,因为它与示例无关,但在运行它时很方便。如果您的浏览器中有一个插件为您格式化 JSON 字符串,那么链接应该是可点击的。为此,我在 Chrome 和 Edge on Chrome 中使用了JSON 查看器,这是一个开源项目。
就这样!我们已经使用几行代码将 JSON 中的选项加载到我们的对象中。您可以随意运行代码、添加断点并探索应用的行为方式。
项目-选项配置
现在我们已经介绍了一些简单的场景,让我们来探讨一些更高级的可能性,例如创建有助于配置、初始化和验证选项的类型。
配置选项
我们可以创建实现IConfigureOptions<TOptions>
的类型,然后将这些实现注册为服务,以动态配置我们的选项。
笔记
我们也可以为命名的选项实现IConfigureNamedOptions<TOptions>
。
首先,让我们为我们的小节目奠定基础:
-
第一个构建块是我们要配置的选项类:
cs public class ConfigureMeOptions { public string Title { get; set; } public IEnumerable<string> Lines { get; set; } }
-
现在,让我们定义我们的应用设置(
appsettings.json
):cs { "configureMe": { "title": "Configure Me!", "lines": [ "This comes from appsettings!" ] } }
-
Next, let's make an endpoint that accesses the settings, serializes the result to a JSON string, and then writes it to the response stream:
cs endpoints.MapGet("/configure-me", async context => { var options = context.RequestServices .GetRequiredService<IOptionsMonitor<ConfigureMe Options>>(); var json = JsonSerializer.Serialize(options); context.Response.ContentType = "application/json"; await context.Response.WriteAsync(json); });
此端点显示最新选项,即使我们在不更改代码或重新启动服务器的情况下更改
appsettings.json
的内容。 -
在运行程序之前,我们需要告诉 ASP.NET 这些设置:
cs services.Configure<ConfigureMeOptions>(_configuration.GetSection("configureMe"));
-
Now, when running the program and navigating to /configure-me, we should see the following:
cs { "CurrentValue": { "Title": "Configure Me!", "Lines": [ "This comes from appsettings!" ] } }
CurrentValue
为属性名称,可从IOptionsMonitor<TOptions>
访问。除此之外,代码的其余部分看起来与我们在appsettings.json
文件中配置的值非常相似。唯一的例外是套管;我们没有配置序列化程序,因此它使用了我们属性的大小写(帕斯卡大小写或上驼峰大小写)。 -
To take care of this, we can pass an instance of
JsonSerializerOptions
to theSerialize
method, like this:cs var json = JsonSerializer.Serialize( options, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase } );
笔记
驼色大小写(也称为下驼色大小写)是一个广泛接受的 JSON 和 JavaScript 标准。
i 配置选项
现在,让我们在另一个类中配置选项。该类型将在我们的选项中动态添加一行。
为此,我们可以创建一个实现IConfigureOptions<TOptions>
接口的类,如下所示:
public class Configure1ConfigureMeOptions : IConfigureOptions<ConfigureMeOptions>
{
public void Configure(ConfigureMeOptions options)
{
options.Lines = options.Lines.Append("Added line 1!");
}
}
现在,我们必须在服务集合中注册它:
services.AddSingleton<IConfigureOptions<ConfigureMeOptions>, Configure1ConfigureMeOptions>();
从此处导航到/configure-me
应输出以下内容:
{
"currentValue": {
"title": "Configure Me!",
"lines": [
"This comes from appsettings!",
"Added line 1!"
]
}
}
瞧,我们得到了一个几乎不费吹灰之力的完美结果。这会带来很多可能性!实现IConfigureOptions<TOptions>
是配置选项类默认值的最佳方式。
多个 IConfigureOptions
您可能会发现前面的示例很有趣,但我们才刚刚开始!我们可以为同一个TOptions
注册多个IConfigureOptions<TOptions>
,这与注册一个新绑定一样简单。出于我们的目的,让我们创建另一个类,将另一行添加到数组中,因为这允许我们跟踪背景中发生的事情:
public class Configure2ConfigureMeOptions : IConfigureOptions<ConfigureMeOptions>
{
public void Configure(ConfigureMeOptions options)
{
options.Lines = options.Lines.Append("Added line 2!");
}
}
现在,我们可以注册它:
services.AddSingleton<IConfigureOptions<ConfigureMeOptions>, Configure2ConfigureMeOptions>();
新的输出应如下所示:
{
"currentValue": {
"title": "Configure Me!",
"lines": [
"This comes from appsettings!",
"Added line 1!",
"Added line 2!"
]
}
}
需要注意的是,已向IServiceCollection
注册的依赖项是有序的,因此通过交换Configure1ConfigureMeOptions
和Configure2ConfigureMeOptions
的注册,我们将得到以下输出:
{
"currentValue": {
"title": "Configure Me!",
"lines": [
"This comes from appsettings!",
"Added line 2!",
"Added line 1!"
]
}
}
很好,对吗?
更多配置
配置选项还有其他方式;例如:
- 我们可以多次调用
Configure
扩展方法。 ConfigureAll
允许我们在任何给定TOptions
的所有选项上运行配置。这主要用于配置命名选项,但未命名选项只是与默认名称关联的命名选项,因此在我们的示例中,这也适用。PostConfigure
和PostConfigureAll
分别是Configure
和ConfigureAll
的等价物,但它们在初始化过程结束时运行。
通过向合成根添加以下内容,
services.Configure<ConfigureMeOptions>(options =>
{
options.Lines = options.Lines.Append("Another Configure call");
});
services.PostConfigure<ConfigureMeOptions>(options =>
{
options.Lines = options.Lines.Append("What about PostConfigure?");
});
services.PostConfigureAll<ConfigureMeOptions>(options =>
{
options.Lines = options.Lines.Append("Did you forgot about PostConfigureAll?");
});
services.ConfigureAll<ConfigureMeOptions>(options =>
{
options.Lines = options.Lines.Append("Or ConfigureAll?");
});
我们应该以以下输出结束:
{
"currentValue": {
"title": "Configure Me!",
"lines": [
"This comes from appsettings!",
"Added line 1!",
"Added line 2!",
"Another Configure call",
"Or ConfigureAll?",
"What about PostConfigure?",
"Did you forgot about PostConfigureAll?"
]
}
}
注册顺序在这里很重要,因为在后台,框架正在创建实现IConfigureOptions<ConfigureMeOptions>
的实例,并向服务集合注册它们。
post 配置有点违反规则,因为它们遵循配置方法运行,但它们之间的顺序也很重要。如果选项配置仍然不清楚,请使用此示例。我发现实验是学习和提高的最好方法之一。
项目–选项验证
另一个特性是在创建TOptions
对象时运行验证代码。此代码保证在第一次创建选项时运行,并且不考虑后续的选项修改。如果生存期是短暂的,则每次获取选项对象时都会运行验证。如果其生存期是限定范围的,则它在每个范围内运行一次(最有可能是在每个 HTTP 请求中)。如果它的生存期是单例的,那么它将为每个应用运行一次。
为了验证我们的选项,我们可以创建实现IValidateOptions<TOptions>
接口的验证类型,或者使用[Required]
等数据注释。
数据注释
让我们从开始,使用System.ComponentModel.DataAnnotations
类型用验证属性装饰我们的选项。为了演示这一点,让我们看两个小测试:
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Xunit;
namespace OptionsValidation
{
public class ValidateOptionsWithDataAnnotations
{
[Fact]
public void Should_pass_validation()
{
var services = new ServiceCollection();
services.AddOptions<Options>()
.Configure(o => o.MyImportantProperty = "Some important value")
.ValidateDataAnnotations();
var serviceProvider = services.BuildServiceProvider();
var options = serviceProvider.GetService <IOptionsMonitor<Options>>();
Assert.Equal("Some important value", options.CurrentValue.MyImportantProperty);
}
[Fact]
public void Should_fail_validation()
{
var services = new ServiceCollection();
services.AddOptions<Options>()
.ValidateDataAnnotations();
var serviceProvider = services.BuildServiceProvider();
var options = serviceProvider.GetService <IOptionsMonitor<Options>>();
var error = Assert.Throws<OptionsValidationException>(() => options.CurrentValue);
Assert.Collection(error.Failures,
f => Assert.Equal("DataAnnotation validation failed for members: 'MyImportantProperty' with the error: 'The MyImportantProperty field is required.'.", f)
);
}
private class Options
{
[Required]
public string MyImportantProperty { get; set; }
}
}
}
从这些测试中,我们可以看到设置MyImportantProperty
允许我们使用选项对象,而不设置它会抛出一个OptionsValidationException
,提醒我们错误。
笔记
在撰写本文时,不可能进行即时验证(启动时失败),但正在考虑中。
因此,验证在第一次使用选项值时运行,而不是在注入选项时运行。例如,在IOptionsMonitor<TOptions>
的情况下,在检索CurrentValue
属性时运行验证。
为了让.NET 验证选项上的数据注释,我们必须调用ValidateDataAnnotations
扩展方法,该方法可从Microsoft.Extensions.Options.DataAnnotations
程序集中获得,如下所示:
services.AddOptions<Options>().ValidateDataAnnotations();
就这样–.NET 从那里为我们完成了任务。
验证类型
为了实现选项(选项验证器)的验证类型,我们可以创建一个实现一个或多个IValidateOptions<TOptions>
接口的类。一个类型可以验证多个选项,多个类型可以验证相同的选项,因此可能的组合应该涵盖所有可能的用例。
使用自定义类并不比使用数据注释困难。然而,它允许我们将验证问题从 options 类本身移除,并编写更复杂的验证逻辑。你应该选择对你的项目更有意义的方式。
以下是如何通过代码执行此操作:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Xunit;
namespace OptionsValidation
{
public class ValidateOptionsWithTypes
{
[Fact]
public void Should_pass_validation()
{
var services = new ServiceCollection();
services.AddSingleton<IValidateOptions<Options>, OptionsValidator>();
services.AddOptions<Options>()
.Configure(o => o.MyImportantProperty = "Some important value");
var serviceProvider = services.BuildServiceProvider();
var options = serviceProvider.GetService <IOptionsMonitor<Options>>();
Assert.Equal("Some important value", options.CurrentValue.MyImportantProperty);
}
[Fact]
public void Should_fail_validation()
{
var services = new ServiceCollection();
services.AddSingleton<IValidateOptions<Options>, OptionsValidator>();
services.AddOptions<Options>();
var serviceProvider = services.BuildServiceProvider();
var options = serviceProvider.GetService <IOptionsMonitor<Options>>();
var error = Assert.Throws<OptionsValidationException>(() => options.CurrentValue);
Assert.Collection(error.Failures,
f => Assert.Equal("'MyImportantProperty' is required.", f)
);
}
private class Options
{
public string MyImportantProperty { get; set; }
}
private class OptionsValidator : IValidateOptions<Options>
{
public ValidateOptionsResult Validate(string name, Options options)
{
if (string.IsNullOrEmpty(options.MyImportantProperty))
{
return ValidateOptionsResult.Fail ("'MyImportantProperty' is required.");
}
return ValidateOptionsResult.Success;
}
}
}
}
正如您所看到的,这个与我们在前面的示例中使用的选项相同,没有数据注释,并且两个测试用例也非常相似。不同之处在于,我们没有使用[Required]
属性,而是创建了OptionsValidator
类,其中包含验证逻辑。
OptionsValidator
实现IValidateOptions<Options>
,只包含ValidateOptionsResult Validate(string name, Options options)
方法。此方法允许验证命名选项和默认选项。name
参数表示选项的名称。在我们的例子中,我们实现了所有选项所需的逻辑。ValidateOptionsResult
类公开了一些成员来帮助我们,例如Success
和Skip
字段,以及两个Fail()
方法。
ValidateOptionsResult.Success
表示成功。ValidateOptionsResult.Skip
表示验证器没有验证选项,很可能是因为它只验证某些命名的选项,而不是给定的选项。对于失败,我们可以通过调用ValidateOptionsResult.Fail(message)
或ValidateOptionsResult.Fail(messages)
以单个消息或消息集合失败。
下一步是在 IoC 容器中提供验证器。在我们的例子中,我们可以使用一个简单的services.AddSingleton<IValidateOptions<Options>, OptionsValidator>()
调用来实现这一点,但我们也可以扫描一个或多个程序集以“自动”注册所有验证器
然后,与数据注释一样,当我们第一次使用选项时,将针对该实例执行验证。
直接注入选项
关于.NET 选项模式,我唯一的负面观点是我们需要将代码绑定到框架的接口,这意味着我们需要注入IOptionsMonitor<Options>
而不是Options
。我更愿意直接注入Options
,从组合根控制其生存期,而不是让类本身控制其依赖项。我有点反控制狂,我知道。
碰巧我们可以用一个小技巧很容易地绕过这个问题。在这里,我们需要做两件事:
- 设置选项模式,如本章前面所示。
- 创建一个依赖项绑定,告诉容器如何直接注入我们想要的 options 类。
下面的代码执行与前面的示例相同的操作,并使用作用域来演示作用域:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Xunit;
namespace OptionsValidation
{
public class ByPassingInterfaces
{
[Fact]
public void Should_support_any_scope()
{
var services = new ServiceCollection();
services.AddOptions<Options>()
.Configure(o => o.MyImportantProperty = "Some important value");
这里我们注册Options
类,然后配置MyImportantProperty
的默认值。
services.AddScoped(serviceProvider => serviceProvider.GetService <IOptionsSnapshot<Options>>().Value);
在前面的代码块中,我们使用工厂方法注册了Options
类。这样,我们就可以直接注入Options
类(具有作用域生存期)。我们从该委托(突出显示的代码)控制创建和生存期。
瞧,我们现在可以直接将Options
注入到我们的系统中,而无需将我们的类与任何特定于.NET 的选项接口绑定。接下来,我们测试一下:
var serviceProvider = services.BuildServiceProvider();
using var scope1 = serviceProvider.CreateScope();
var options1 = scope1.ServiceProvider. GetService<Options>();
var options2 = scope1.ServiceProvider. GetService<Options>();
Assert.Same(options1, options2);
前面的代码断言从同一范围获取的两个实例是相同的。
using var scope2 = serviceProvider.CreateScope();
var options3 = scope2.ServiceProvider. GetService<Options>();
Assert.NotSame(options2, options3);
}
前面的代码断言来自两个不同作用域的选项不相同。
private class Options
{
public string MyImportantProperty { get; set; }
}
}
}
最后,我们有了Options
类,它允许我们编写这些测试。这里没有什么特别的。
对于现有系统来说,这也是一个很好的解决方案,假设系统DI 就绪,我们无需更新其代码即可从选项模式中获益。我们还可以使用此技巧编译不依赖于Microsoft.Extensions.Options
的程序集。
结论
选项模式是一种注入选项的好方法,这样我们就可以配置我们的应用。我们看到我们可以在不同的选项中进行选择:
IOptionsMonitor<TOptions>
或IOptions<TOptions>
适用于单身人士IOptionsSnapshot<TOptions>
适用于范围IOptionsFactory<TOptions>
用于瞬态
我们还介绍了配置和验证选项类的多种方法。总而言之,这些.NET 选项为我们提供了非常灵活的方法,可以将选项注入到我们的系统中,我强烈建议您今天就开始使用它们,如果您还没有使用它们的话。这可以帮助您测试您的系统,还可以更轻松地管理应用的更改。
熟悉.NET 日志摘要
.NET Core 对.NET 框架的另一个改进是它的日志抽象。新的统一系统不再依赖于第三方库,而是提供了干净的界面,这些界面由一种灵活而健壮的机制支持,有助于实现登录到应用。它还支持通过该抽象简化的第三方库。在我们更详细地研究实现之前,让我们先谈谈日志记录。
关于伐木
日志记录是将消息写入日志并对信息进行编目以备将来使用的实践。这些信息可用于调试错误、跟踪操作、分析使用情况,或创造性人员提出的任何其他理由。日志记录是一个交叉关注点,这意味着它适用于应用的每一部分。我们将在第 12 章理解分层中讨论层,但在此之前,我们只能说一个横切关注点影响所有层,不能集中在一个层中。
日志由日志条目组成。我们可以将每个日志条目视为程序执行期间发生的事件。然后将这些事件写入日志。此日志可以是文件、远程系统、简单的stdout
或多个目标的组合。
在创建日志条目时,我们还必须考虑该日志条目的级别。在某种程度上,该级别表示我们要记录的消息的类型或重要级别。它还可以用来过滤那些日志。Trace
、Error
和Debug
是日志条目级别的示例。这些级别在Microsoft.Extensions.Logging.LogLevel
枚举中定义。
日志条目的另一个重要方面是它的结构。您可以记录单个字符串。团队中的每个人都可以用自己的方式记录单个字符串。但是当有人搜索信息时会发生什么呢?混乱接踵而至!有一种压力,就是找不到那个人要找的东西,还有那个人对原木结构的不满。解决这个问题的一种方法是使用结构化日志记录。它简单而复杂;您需要创建一个每个日志条目都遵循的结构。这种结构可能或多或少复杂。它可以序列化为 JSON。重要的是日志条目是结构化的。我们这里不讨论这个主题,但如果您必须决定日志策略,我建议您首先深入研究结构化日志。如果你是一个团队的一员,那么其他人可能已经这样做了。如果不是这样的话,你可以随时提出来。持续改进是生活的一个关键方面。
我们可以写一整本关于日志记录、最佳日志记录实践、结构化日志记录和分布式日志记录的书,但本章的目的是学习如何使用.NET 日志记录抽象。
写日志
首先,日志记录系统是基于提供商的,这意味着您必须注册一个或多个ILoggerProvider
,如果您希望您的日志条目进入某个地方。默认情况下,调用Host.CreateDefaultBuilder(args)
时,注册控制台、调试、事件源和事件日志(仅限 Windows)提供程序,但此列表可以修改。如果需要,可以添加和删除提供程序。CreateDefaultBuilder
还注册在应用中使用日志记录所需的依赖项。
在查看代码之前,让我们先了解如何创建日志条目,这是日志记录背后的目标。要创建条目,我们可以使用以下接口之一:ILogger
、ILogger<T>
或ILoggerFactory
。让我们更详细地看一看:
ILogger
是基础抽象。ILogger<T>
使用T
自动创建日志类别。ILoggerFactory
允许我们使用自定义类别名称创建ILogger
。我们不会在这里探讨这个问题。
下面是更为常用的模式,包括注入ILogger<T>
接口并在使用前将其存储在ILogger
字段中,如下所示:
public class Service : IService
{
private readonly ILogger _logger;
public Service(ILogger<Service> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public void Execute()
{
_logger.LogInformation("Service.Execute()");
}
}
在前面的代码中,Service
类中有私有的ILogger _logger
字段。我们注入一个存储在该字段中的ILogger<Service> logger
,然后在Execute
方法中使用该成员将信息级消息写入日志。
为了测试这一点,我加载了一个我创建的小库,它提供了额外的日志提供程序用于测试:
namespace Logging
{
public class BaseAbstractions
{
[Fact]
public void Should_log_the_Service_Execute_line()
{
// Arrange
var lines = new List<string>();
var args = new string[0];
var host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(loggingBuilder =>
{
loggingBuilder.ClearProviders();
loggingBuilder.AddAssertableLogger(lines);
})
.ConfigureServices(services =>
{
services.AddSingleton<IService, Service>();
})
.Build();
var service = host.Services. GetRequiredService<IService>();
在测试的Arrange
阶段,我们创建了一些变量,配置了IHost
,得到了一个我们想要测试的IService
实例(代码在测试用例之后)。
在突出显示的代码中,我们使用ClearProviders
方法删除了所有提供程序,然后从加载的库中使用AddAssertableLogger
扩展方法添加新的提供程序。如果我们愿意,我们可以添加一个新的提供者,但我想向您展示如何删除现有的提供者,以便我们可以从头开始。这是你将来可能需要的东西。
笔记
我加载的库在 NuGet 上可用,名为ForEvolve.Testing.Logging
,但这与理解日志抽象无关。
通常,IHost
是在Program
类中构建的,可以在Program
类中定制。也可以在这里加载您选择的第三方库。既然我们已经讨论了这一点,让我们继续测试用例:
// Act
service.Execute();
// Assert
Assert.Collection(lines,
line => Assert.Equal("Service.Execute()", line)
);
}
在Act
阶段,我们调用我们服务的Execute
方法。这个方法应该记录一行(请参阅下面的代码)。然后,我们断言该行写入了lines
列表中(这就是AssertableLogger
所做的;也就是说,它写入了List<string>
。接下来,我们有其他构建块:
public interface IService
{
void Execute();
}
public class Service : IService
{
private readonly ILogger _logger;
public Service(ILogger<Service> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public void Execute()
{
_logger.LogInformation("Service.Execute()");
}
}
}
}
Service
类是ILogger<Service>
的简单使用者,并使用该ILogger
。您可以对任何要添加日志记录支持的类执行相同的操作。通过该类的名称更改Service
。
总而言之,这个测试用例允许我们在 ASP.NET Core 5 中实现最常用的日志模式,并添加一个自定义提供程序,以确保我们记录了正确的信息。由于并非所有日志项都是相同的,我们接下来将研究日志级别。
日志级别
我们使用LogInformation
方法记录信息消息,但也有其他级别,如下表所示:
优化
在我领导的一个项目中,我们对使用 ASP.NETCore 记录简单和复杂消息的多种方法进行了基准测试,因为我们希望建立明确的指导原则。当消息被记录时,我们无法得出一个公平的结论(差异太大),但当消息没有记录时,我们观察到一个常数。基于这一结论,我建议使用以下构造来记录Trace
和Debug
消息,而不是插值、string.Format
或其他方式。以下是不写入日志项的最快方法:
_logger.LogTrace("Some: {variable}", variable);
// Or
_logger.LogTrace("Some: {0}", variable);
当日志级别被禁用时,例如生产中的Trace
,您只需支付方法调用的费用,因为没有对您的日志条目进行处理。另一方面,如果我们使用插值,处理就完成了,因此一个参数被传递给Log[Level]
方法,导致每个日志条目的处理能力成本更高。
日志记录提供程序
为了让您了解可能的内置日志提供商,以下是官方文档中的列表(请参阅本章末尾的进一步阅读部分):
- 安慰
- 调试
- 事件源
- 事件日志(仅限 Windows)
- 应用照明
以下是第三方日志提供商的列表,也来自官方文档:
- 埃尔玛·伊奥
- 盖尔夫
- JSNLog
- KissLog.net
- Log4Net
- 伐木工人
- NLog
- 哨兵
- 塞里洛格
- 堆垛机
现在,如果您需要其中任何一个,或者您最喜欢的日志库是前面列表的一部分,那么您知道可以使用它。如果不是,可能它支持 ASP.NET Core 5,但在我查阅它时,它不是文档的一部分。
配置日志记录
与大多数 ASP.NET Core 5 一样,我们可以配置日志记录。调用Host.CreateDefaultBuilder(args)
时创建的默认主机为我们做了很多事情。正如我们前面看到的,它注册了许多配置提供程序,并且还加载了配置的Logging
部分。默认情况下,appsettings.json
文件中存在该节。与所有配置一样,它是累积的,因此我们可以在另一个配置中重新定义它的一部分。如果不使用默认生成器,则必须自己配置日志记录;请注意所涉及的额外工作。
我不想在这方面花费太多的页面,但很高兴知道您可以自定义您正在记录的内容的最低级别。您还可以使用转换文件(如appsettings.Development.json
)根据环境自定义这些级别。例如,您可以在appsettings.json
中定义默认值,然后在appsettings.Development.json
中更新一些用于开发的设置,在appsettings.Production.json
中更改生产设置,然后在appsettings.Staging.json
中更改暂存设置,在appsettings.Testing.json
中添加一些测试设置。
在我们继续之前,让我们看一下默认设置:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}
我们可以为每个类别定义默认级别(使用Logging:LogLevel:Default
)和自定义级别(例如Logging:LogLevel:Microsoft
)。然后,我们可以使用配置或代码按提供程序筛选要记录的内容。在配置中,我们可以将控制台提供程序的默认级别更改为Trace
,如下所示:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
},
"Console": {
"LogLevel": {
"Default": "Trace"
}
}
}
}
我们保留了相同的默认值,但在Logging:Console
部分(见突出显示的代码)添加了一个默认值LogLevel
。我们可以在这里定义更多的设置,但这超出了本书的范围。
然后,我们可以使用AddFilter
扩展方法中的一种,如下实验测试代码所示:
[Fact]
public void Should_filter_logs_by_provider()
{
// Arrange
var lines = new List<string>();
var args = new string[0];
var host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(loggingBuilder =>
{
loggingBuilder.ClearProviders();
loggingBuilder.AddConsole();
loggingBuilder.AddAssertableLogger(lines);
loggingBuilder.AddxUnitTestOutput(_output);
loggingBuilder.AddFilter<XunitTestOutputLoggerProvider>(
level => level >= LogLevel.Warning
);
})
.ConfigureServices(services =>
{
services.AddSingleton<IService, Service>();
})
.Build();
var service = host.Services.GetRequiredService<IService>();
// Act
service.Execute();
// Assert
Assert.Collection(lines,
line => Assert.Equal("[info] Service.Execute()", line),
line => Assert.Equal("[warning] Service.Execute()", line)
);
}
public class Service : IService
{
//...
public void Execute()
{
_logger.LogInformation("[info] Service.Execute()");
_logger.LogWarning("[warning] Service.Execute()");
}
}
在这里,我们添加了三个提供者:控制台和两个测试提供者——一个记录到列表,另一个记录到 xUnit 输出。然后,我们告诉系统从XunitTestOutputLoggerProvider
中过滤掉所有不属于Warning
的内容(参见突出显示的代码);其他供应商不受影响。
您现在有两个选项:
- 密码
- 配置
所有这些应该足以让你开始。
结论
日志记录非常重要,ASP.NET Core 为我们提供了各种独立于第三方库进行日志记录的方法,同时允许我们使用我们最喜欢的日志记录框架。我们可以自定义日志的编写和分类方式。我们可以使用零个或多个日志提供程序。我们还可以创建自定义日志记录提供程序。最后,我们可以使用配置或代码来过滤日志等等。
您必须记住的是最有用的日志记录模式:
- 注入一个
ILogger<T>
,其中T
是记录器被注入的类的类型。T
成为类别。 - 将该记录器的引用保存到
private readonly ILogger
字段中。 - 在方法中使用该记录器以使用适当的日志级别记录消息。
总结
.NETCore 添加了许多功能,例如配置和日志记录,这些功能现在是.NET5 的一部分。与旧的.NET Framework API 相比,新的 API 更好,提供了很多价值。大多数样板代码都不见了,几乎所有东西都是基于选择加入的。
选项允许我们从多个源加载和组合配置,同时通过简单的 C#对象在系统中轻松使用这些配置。它消除了web.config
之前配置的麻烦,使其易于使用。创建自定义web.config
节不需要更复杂的样板代码;只需在appsettings.json
中添加一个 JSON 对象,告诉系统要加载哪个部分,应该是什么类型,瞧——您有自己的强类型选项!同样的简单性也适用于消费设置:注入所需的接口或类本身并使用它。有了这些,你就可以开始跑步了;不再有静态ConfigurationManager
或其他难以测试的结构。
伐木也是一个很好的补充;它允许我们标准化日志记录机制,使我们的系统更易于长期维护。例如,如果您想要使用新的第三方库,甚至是定制库,您可以将提供者加载到您的Program
中,整个系统将进行调整并开始使用它,而无需进行任何进一步的更改。这就是精心设计的抽象应该带给系统的东西。
本章结束本书以 ASP.NET Core 5 为中心的第二部分。在接下来的三章中,我们将探讨设计模式,以设计灵活和健壮的组件。
问题
让我们来看看几个练习题:
IOptionsMonitor<TOptions>
的寿命是多少?IOptionsSnapshot<TOptions>
的寿命是多少?IOptionsFactory<TOptions>
的寿命是多少?- 我们可以同时将日志条目写入控制台和文件吗?
- 我们应该在生产环境中记录跟踪和调试级别的日志条目,这是真的吗?
进一步阅读
以下是我们在本章学到的一些链接:
- 【官方文档】登录.NET Core 和 ASP.NET Core:https://net5.link/MUVG
- 【公文】ASP.NET Core 选项模式:https://net5.link/RTGc
版权属于:月萌API www.moonapi.com,转载请注明出处