八、选项和日志模式

在本章中,我们将介绍特定于.NET 的模式,如选项模式和日志记录。我们将探索这些抽象,同时将其保持在我们使用它们的水平,而不是掌握它们的每个方面。阅读本章后,您应该知道如何利用.NET 5 选项和设置基础架构,以及如何编写应用日志。我们还将简要探讨如何定制这些系统。

本章将介绍以下主题:

  • 选项模式概述
  • 熟悉.NET 日志抽象

让我们开始吧!

期权模式概述

在 ASP.NET 5 中,我们可以使用预定义的机制来提高应用设置的使用率。这些允许我们将配置划分为多个较小的对象,在启动流程的多个阶段对它们进行配置,验证它们,甚至以最小的工作量观察运行时的更改。

选项模式的目标是在运行时使用设置,允许在不更改代码的情况下更改应用。设置可以是简单的stringbool、数据库连接字符串或包含整个子系统配置的复杂对象。

本节探讨 ASP.NET Core 提供的不同工具,用于管理、注入配置和选项,并将其加载到我们的程序中。我们将处理不同的场景,从普通场景到更高级场景。

开始

ASP.NET Core 5 中的选项模式允许我们无缝地从多个源加载设置。我们可以在创建IHostBuilder时自定义这些源,甚至可以使用通过调用Host.CreateDefaultBuilder(args)设置的默认源。按顺序排列的默认源如下:

  1. appsettings.json
  2. appsettings.{Environment}.json
  3. 用户机密;这些仅在环境为Development时加载。
  4. 环境变量。
  5. 命令行参数。

顺序也非常重要,因为最后一个要加载的值会覆盖任何以前的值。例如,您可以在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>。我们还将定义多个其他场景的共同点。让我们一步一步地回顾一下我们将要做的事情:

  1. 为我们的服务创建一个名为IMyNameService的接口。
  2. 创建选项类。
  3. 针对该接口编写一些单元测试代码。我们将为每个实现重用这些测试。
  4. 为我们的第一个实现编写代码。
  5. 对它运行我们的测试。

我们的界面非常简单,如下所示:

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 的实践者,让我们看看作为初始代码使用者的单元测试。我们的简单说明是当someConditiontrue时,GetName返回Option1Name的值,而当someConditionfalse时,GetName返回Option2Name的值。

笔记

Option1NameOption2Name是两个包含不相关值的常数(但不同的值,以便我们可以比较它们的输出)。

让我们从单元测试开始:

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 one MyDoubleNameOptions. 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 as appsettings.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"
  }
}

这里的数据结构与我们之前定义的类相同;也就是说,MyOptionsMyDoubleNameOptions。这是因为我们将要使用提供的选项实用程序将这些设置加载(反序列化)到我们的类中。

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>

首先,让我们为我们的小节目奠定基础:

  1. 第一个构建块是我们要配置的选项类:

    cs public class ConfigureMeOptions {     public string Title { get; set; }     public IEnumerable<string> Lines { get; set; } }

  2. 现在,让我们定义我们的应用设置(appsettings.json):

    cs {   "configureMe": {     "title": "Configure Me!",     "lines": [       "This comes from appsettings!"     ]   } }

  3. 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的内容。

  4. 在运行程序之前,我们需要告诉 ASP.NET 这些设置:

    cs services.Configure<ConfigureMeOptions>(_configuration.GetSection("configureMe"));

  5. 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文件中配置的值非常相似。唯一的例外是套管;我们没有配置序列化程序,因此它使用了我们属性的大小写(帕斯卡大小写上驼峰大小写)。

  6. To take care of this, we can pass an instance of JsonSerializerOptions to the Serialize 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注册的依赖项是有序的,因此通过交换Configure1ConfigureMeOptionsConfigure2ConfigureMeOptions的注册,我们将得到以下输出:

{
  "currentValue": {
    "title": "Configure Me!",
    "lines": [
      "This comes from appsettings!",
      "Added line 2!",
      "Added line 1!"
    ]
  }
}

很好,对吗?

更多配置

配置选项还有其他方式;例如:

  • 我们可以多次调用Configure扩展方法。
  • ConfigureAll允许我们在任何给定TOptions的所有选项上运行配置。这主要用于配置命名选项,但未命名选项只是与默认名称关联的命名选项,因此在我们的示例中,这也适用。
  • PostConfigurePostConfigureAll分别是ConfigureConfigureAll的等价物,但它们在初始化过程结束时运行。

通过向合成根添加以下内容,

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类公开了一些成员来帮助我们,例如SuccessSkip字段,以及两个Fail()方法。

ValidateOptionsResult.Success表示成功。ValidateOptionsResult.Skip表示验证器没有验证选项,很可能是因为它只验证某些命名的选项,而不是给定的选项。对于失败,我们可以通过调用ValidateOptionsResult.Fail(message)ValidateOptionsResult.Fail(messages)以单个消息或消息集合失败。

下一步是在 IoC 容器中提供验证器。在我们的例子中,我们可以使用一个简单的services.AddSingleton<IValidateOptions<Options>, OptionsValidator>()调用来实现这一点,但我们也可以扫描一个或多个程序集以“自动”注册所有验证器

然后,与数据注释一样,当我们第一次使用选项时,将针对该实例执行验证。

直接注入选项

关于.NET 选项模式,我唯一的负面观点是我们需要将代码绑定到框架的接口,这意味着我们需要注入IOptionsMonitor<Options>而不是Options。我更愿意直接注入Options,从组合根控制其生存期,而不是让类本身控制其依赖项。我有点反控制狂,我知道。

碰巧我们可以用一个小技巧很容易地绕过这个问题。在这里,我们需要做两件事:

  1. 设置选项模式,如本章前面所示。
  2. 创建一个依赖项绑定,告诉容器如何直接注入我们想要的 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或多个目标的组合。

在创建日志条目时,我们还必须考虑该日志条目的级别。在某种程度上,该级别表示我们要记录的消息的类型重要级别。它还可以用来过滤那些日志。TraceErrorDebug是日志条目级别的示例。这些级别在Microsoft.Extensions.Logging.LogLevel枚举中定义。

日志条目的另一个重要方面是它的结构。您可以记录单个字符串。团队中的每个人都可以用自己的方式记录单个字符串。但是当有人搜索信息时会发生什么呢?混乱接踵而至!有一种压力,就是找不到那个人要找的东西,还有那个人对原木结构的不满。解决这个问题的一种方法是使用结构化日志记录。它简单而复杂;您需要创建一个每个日志条目都遵循的结构。这种结构可能或多或少复杂。它可以序列化为 JSON。重要的是日志条目是结构化的。我们这里不讨论这个主题,但如果您必须决定日志策略,我建议您首先深入研究结构化日志。如果你是一个团队的一员,那么其他人可能已经这样做了。如果不是这样的话,你可以随时提出来。持续改进是生活的一个关键方面。

我们可以写一整本关于日志记录、最佳日志记录实践、结构化日志记录和分布式日志记录的书,但本章的目的是学习如何使用.NET 日志记录抽象。

写日志

首先,日志记录系统是基于提供商的,这意味着您必须注册一个或多个ILoggerProvider,如果您希望您的日志条目进入某个地方。默认情况下,调用Host.CreateDefaultBuilder(args)时,注册控制台调试事件源事件日志(仅限 Windows)提供程序,但此列表可以修改。如果需要,可以添加和删除提供程序。CreateDefaultBuilder还注册在应用中使用日志记录所需的依赖项。

在查看代码之前,让我们先了解如何创建日志条目,这是日志记录背后的目标。要创建条目,我们可以使用以下接口之一:ILoggerILogger<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 记录简单和复杂消息的多种方法进行了基准测试,因为我们希望建立明确的指导原则。当消息被记录时,我们无法得出一个公平的结论(差异太大),但当消息没有记录时,我们观察到一个常数。基于这一结论,我建议使用以下构造来记录TraceDebug消息,而不是插值、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 为我们提供了各种独立于第三方库进行日志记录的方法,同时允许我们使用我们最喜欢的日志记录框架。我们可以自定义日志的编写和分类方式。我们可以使用零个或多个日志提供程序。我们还可以创建自定义日志记录提供程序。最后,我们可以使用配置或代码来过滤日志等等。

您必须记住的是最有用的日志记录模式:

  1. 注入一个ILogger<T>,其中T是记录器被注入的类的类型。T成为类别。
  2. 将该记录器的引用保存到private readonly ILogger字段中。
  3. 在方法中使用该记录器以使用适当的日志级别记录消息。

总结

.NETCore 添加了许多功能,例如配置和日志记录,这些功能现在是.NET5 的一部分。与旧的.NET Framework API 相比,新的 API 更好,提供了很多价值。大多数样板代码都不见了,几乎所有东西都是基于选择加入的。

选项允许我们从多个源加载和组合配置,同时通过简单的 C#对象在系统中轻松使用这些配置。它消除了web.config之前配置的麻烦,使其易于使用。创建自定义web.config节不需要更复杂的样板代码;只需在appsettings.json中添加一个 JSON 对象,告诉系统要加载哪个部分,应该是什么类型,瞧——您有自己的强类型选项!同样的简单性也适用于消费设置:注入所需的接口或类本身并使用它。有了这些,你就可以开始跑步了;不再有静态ConfigurationManager或其他难以测试的结构。

伐木也是一个很好的补充;它允许我们标准化日志记录机制,使我们的系统更易于长期维护。例如,如果您想要使用新的第三方库,甚至是定制库,您可以将提供者加载到您的Program中,整个系统将进行调整并开始使用它,而无需进行任何进一步的更改。这就是精心设计的抽象应该带给系统的东西。

本章结束本书以 ASP.NET Core 5 为中心的第二部分。在接下来的三章中,我们将探讨设计模式,以设计灵活和健壮的组件。

问题

让我们来看看几个练习题:

  1. IOptionsMonitor<TOptions>的寿命是多少?
  2. IOptionsSnapshot<TOptions>的寿命是多少?
  3. IOptionsFactory<TOptions>的寿命是多少?
  4. 我们可以同时将日志条目写入控制台和文件吗?
  5. 我们应该在生产环境中记录跟踪和调试级别的日志条目,这是真的吗?

进一步阅读

以下是我们在本章学到的一些链接: