NetCore 开发实战(1)——必备知识


01 | 简介

为什么要学习 .NET Core

  • 微软大力支持推动 .Net 技术生态发展
  • 跨平台:更多的开发环境和部署环境选择,尤其是对 Docker 和 Kubernetes 的良好支持,快速构建微服务并部署到云基础设施中,实现高可用,可伸缩的系统架构搭建,提高代码重用程度
  • 开源:.NET 技术栈的开放性和包容性,同时也意味着自主性,可以自由使用,再分发 .NET Core 源码
  • 在桌面开发、移动客户端开发、物联网、AI等领域都有非常好的支持,所以可以快速构建适应不同场景的系统

学习 .NET Core 的难点有哪些

.NET Core 的类库、框架、组件使用起来非常自然简单,因此入门非常容易

但是如何用最好的方式使用它来解决工作中的各类问题

如何确保我们设计的系统具备健壮性、可扩展性

如何让团队借助 .NET Core 高效的协作,则是需要大量的实战和经验积累的

比如,如何确保我们的应用适应不同的部署环境

如何设计业务代码,确保其不会随着系统的复杂度的提升而丧失可维护性

服务化又是如何在多团队中保障支付效率的

如何使用 .NET Core 技术解决服务化带来的事务一致性问题

要回答上述问题,就需要你对 .NET Core 的深层原理

以及在实际生产中的最佳实践有进一步的学习和了解

这样你才能认清技术架构和团队协作的关系

并具备保障系统架构的可持续演进的能力

主要预期

  • 掌握 .NET Core 重要组件的设计原理和最佳实践
  • 掌握 Kubernetes 下 .NET Core 微服务应用的设计和实现方案
  • 掌握工程设计原则在 .NET Core 技术栈中的实践

02 | 内容综述

主要目标

  • 掌握 .NET Core 微服务架构的最佳实践
  • 成长为一个具备良好架构设计能力的架构师

主要内容

  • 第一部分 .NET Core 的必备知识
  • 第二部分 .NET Core 微服务实战
  • 第三部分 将微服务应用部署到 Kubernetes 中

第一部分 .NET Core 的必备知识

  • 依赖注入
  • 配置管理
  • 日志框架
  • 关键中间件

这些都是构建良好架构的必要知识

第二部分 .NET Core 微服务实战

面向期望掌握复杂系统架构设计能力的开发者

通过一步步构建一个微服务架构展开

涉及领域驱动设计、远程调用、熔断限流、网关、身份认证、安全等微服务架构的各个方面

第三部分 将微服务应用部署到 Kubernetes 中

偏向运维侧的需求,现在 DevOps 协作模式非常流行,部署和维护不再是单个运维单个角色的职责,开发和架构师都需要掌握这部分技能

通过一个在 Kubernetes 中部署和维护的案例,了解技术机构对团队 DevOps 能力的影响

通过这部分内容,理解如何保障系统的可用性、可检测性、故障隔离能力和可维护性

03 | .NET Core的现状、未来以及环境搭建

.NET Core的现状


.NET Core 的应用场景:桌面端、Web端、云端、移动端、游戏、IOT 和 AI

云端指的是 .NET Core 与云原生 Kubernetes 的完美融合

游戏,比如最流行的王者荣耀,就是用 Unity 3D 做的,基于 .NET 的 C# 语言和 Mono

AI 指的是 ML.NET 和 Azure .NET

.NET Core的未来

.NET Core 的版本历史主要版本

  • 2018年5月 .NET Core 2.1 (LTS)
  • 2019年12月 .NET Core 3.1 (LTS)
  • 2020年11月 .NET 5.0
  • 2021年11月 .NET 6.0 (LTS)
  • 2022年11月 .NTE 7.0
  • 2023年11月 .NET 8.0 (LTS)

LTS:3年官方支持期

.NET Core 开发工具介绍

  • Visual Studio (Community, Professional, Enterprise)
  • Visual Studio for Mac
  • Visual Studio Code

环境搭建

开发工具下载链接:https://visualstudio.microsoft.com/zh-hans/
社区版是针对个人开发者授权免费下载使用

工作负载:勾选 ASP.NET 和 Web 开发

单个组件:可以选择一些自定义选项

04 | Startup:掌握ASP.NET Core的启动过程

新建一个 ASP.NET Core Web 应用程序

选择 API

public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseStartup<Startup>();
            });
}

在 Program.cs 的 Main 函数中

CreateHostBuilder 方法返回了一个 IHostBuilder

它是应用程序启动的核心接口

IHostBuilder 接口有六个方法:

主要关注以下三个:

  • ConfigureAppConfiguration
  • ConfigureHostConfiguration
  • ConfigureServices

接下来,我们添加一些代码演示整个应用程序的启动过程:

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration(builder =>
        {
            Console.WriteLine("ConfigureAppConfiguration");
        })
        .ConfigureServices(service =>
        {
            Console.WriteLine("ConfigureServices");
        })
        .ConfigureHostConfiguration(builder =>
        {
            Console.WriteLine("ConfigureHostConfiguration");
        })
        .ConfigureWebHostDefaults(webBuilder =>
        {
            Console.WriteLine("ConfigureWebHostDefaults");
            webBuilder.UseStartup<Startup>();
        });

接着,在 Startup 的三个方法中添加一些代码

public Startup(IConfiguration configuration)
{
    Console.WriteLine("Startup");
    ...

public void ConfigureServices(IServiceCollection services)
{
    Console.WriteLine("ConfigureServices");
    ...

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    Console.WriteLine("Configure");
    ...

启动程序查看输出:

ConfigureWebHostDefaults
ConfigureHostConfiguration
ConfigureAppConfiguration
ConfigureServices
Startup
Startup.ConfigureServices
Startup.Configure

调整一下委托的注册顺序

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureWebHostDefaults(webBuilder =>
        {
            Console.WriteLine("ConfigureWebHostDefaults");
            webBuilder.UseStartup<Startup>();
        })
        .ConfigureServices(service =>
        {
            Console.WriteLine("ConfigureServices");
        })
        .ConfigureAppConfiguration(builder =>
        {
            Console.WriteLine("ConfigureAppConfiguration");
        })
        .ConfigureHostConfiguration(builder =>
        {
            Console.WriteLine("ConfigureHostConfiguration");
        })
        ;

会得到不同的结果

ConfigureWebHostDefaults
ConfigureHostConfiguration
ConfigureAppConfiguration
Startup
Startup.ConfigureServices
ConfigureServices
Startup.Configure

本质上,如果查看源码会发现,委托注册进去之后,实际上是按照一定的顺序来执行的:

  1. ConfigureWebHostDefaults

这个阶段注册了应用程序必要的几个组件,比如配置的组件、容器组件

  1. ConfigureHostConfiguration

用于配置应用程序启动时必要的配置,比如应用程序启动时所需要监听的端口,URL 地址

在这个过程可以嵌入一些自己配置的内容,注入到配置的框架中

  1. ConfigureAppConfiguration

用于嵌入自己的配置文件,供应用程序读取,这些配置将会在后续的应用程序执行过程中间每个组件读取

  1. ConfigureServices, ConfigureLogging, Startup, Startup.ConfigureServices

用于往容器里注入应用的组件

  1. Startup.Configure

用于注入中间件,处理 HttpContext 整个请求过程

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    Console.WriteLine("Startup.Configure");
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseHttpsRedirection();

    app.UseRouting();

    app.UseAuthorization();

    app.UseFileServer();

    app.UseWebSockets();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

在整个启动的过程中,Startup 这个类不是必要的,只是让代码结构更加合理

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureWebHostDefaults(webBuilder =>
        {
            Console.WriteLine("ConfigureWebHostDefaults");
            //webBuilder.UseStartup<Startup>();

            webBuilder.ConfigureServices(services =>
            {
                Console.WriteLine("webBuilder.ConfigureServices");
                services.AddControllers();
            });

            webBuilder.Configure(app =>
            {
                Console.WriteLine("webBuilder.Configure");

                app.UseHttpsRedirection();

                app.UseRouting();

                app.UseAuthorization();

                app.UseFileServer();

                app.UseWebSockets();

                app.UseEndpoints(endpoints =>
                {
                    endpoints.MapControllers();
                });
            });
        })
        .ConfigureServices(service =>
        {
            Console.WriteLine("ConfigureServices");
        })
        .ConfigureAppConfiguration(builder =>
        {
            Console.WriteLine("ConfigureAppConfiguration");
        })
        .ConfigureHostConfiguration(builder =>
        {
            Console.WriteLine("ConfigureHostConfiguration");
        })
        ;
}

启动程序查看输出:

ConfigureWebHostDefaults
ConfigureHostConfiguration
ConfigureAppConfiguration
webBuilder.ConfigureServices
ConfigureServices
webBuilder.Configure

服务注册一般放在 Startup 的 ConfigureServices,一般是services.AddXXX

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();
    services.AddAuthentication();
    services.AddAuthorization();
    Console.WriteLine("Startup.ConfigureServices");
    services.AddControllers();
}

中间件的注册一般放在 Startup 的 Configure

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    Console.WriteLine("Startup.Configure");
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseHttpsRedirection();

    app.UseRouting();

    app.UseAuthorization();

    app.UseFileServer();

    app.UseWebSockets();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

05 | 依赖注入:良好架构的起点

为什么要使用依赖注入框架

  • 借助依赖注入框架,我们可以轻松管理类之间的依赖,帮助我们在构建应用时遵循设计原则,确保代码的可维护性和可扩展性
  • ASP.NET Core 的整个架构中,依赖注入框架提供了对象创建和生命周期管理的核心能力,各个组件相互协作,也是由依赖注入框架的能力来实现的

组件包

  • Microsoft.Extensions.DependencyInjection.Abstractions
  • Microsoft.Extensions.DependencyInjection

依赖注入的核心是以上两个组件包,一个是抽象包,一个是具体的实现

这里用到了一个经典的设计模式,接口实现分离模式

组件只需要依赖抽象接口,而不需要依赖具体实现,当使用的时候注入它的具体实现即可

这样做的好处是可以在使用时决定具体的实现,也就意味着未来可以做任意的扩展,替换依赖注入框架的具体实现

默认情况下,使用 .NET Core 提供的内置依赖注入框架,也可以使用第三方的依赖注入框架来替换默认实现

核心类型

  • IServiceCollection:服务的注册
  • ServiceDescriptor:每一个服务注册时的信息
  • IServiceProvider:具体的容器,由 ServiceCollection Build 产生
  • IServiceScope:一个容器的子容器的生命周期

生命周期

  • 单例 Singleton:在整个根容器的生命周期内,都是单例,不管是子容器还是根容器,与作用域的区别是:一个是全局的,一个是范围的单例
  • 作用域 Scoped:在 Scope 的生存周期内,也就是容器的生存周期内,或者子容器的生存周期内,如果我的容器释放掉,我的对象也会释放掉
  • 瞬时(暂时)Transient:每一次从容器里面获取对象时,都可以得到一个全新的对象

新建一个 ASP.NET Core Web 应用程序 DependencyInjectionDemo,选择API

添加一个 Services 文件夹,新建三个服务代表三个生命周期的服务

namespace DependencyInjectionDemo.Services
{
    public interface IMyScopedService { }
    public class MyScopedService : IMyScopedService
    {
    }
}
namespace DependencyInjectionDemo.Services
{
    public interface IMySingletonService { }
    public class MySingletonService : IMySingletonService
    {
    }
}
namespace DependencyInjectionDemo.Services
{
    public interface IMyTransientService { }
    public class MyTransientService : IMyTransientService
    {
    }
}

在 Startup 中注册服务

public void ConfigureServices(IServiceCollection services)
{
    #region 注册服务不同生命周期的服务

    // 将单例的服务注册为单例的模式
    services.AddSingleton<IMySingletonService, MySingletonService>();

    // Scoped 的服务注册为 Scoped 的生命周期
    services.AddScoped<IMyScopedService, MyScopedService>();

    // 瞬时的服务注册为瞬时的生命周期
    services.AddTransient<IMyTransientService, MyTransientService>();

    #endregion

    services.AddControllers();
}

在 Controller 里面获取我们的服务

// FromServices 标注的作用是从容器里面获取我们的对象
// 每个对象获取两遍,用于对比每个生命周期获取的对象是什么样子的
// HashCode 代表对象的唯一性
[HttpGet]
public int GetService(
    [FromServices]IMySingletonService singleton1,
    [FromServices]IMySingletonService singleton2,
    [FromServices]IMyTransientService transient1,
    [FromServices]IMyTransientService transient2,
    [FromServices]IMyScopedService scoped1,
    [FromServices]IMyScopedService scoped2)
{
    Console.WriteLine($"singleton1:{singleton1.GetHashCode()}");
    Console.WriteLine($"singleton2:{singleton2.GetHashCode()}");
    Console.WriteLine($"transient1:{transient1.GetHashCode()}");
    Console.WriteLine($"transient2:{transient2.GetHashCode()}");
    Console.WriteLine($"scoped1:{scoped1.GetHashCode()}");
    Console.WriteLine($"scoped2:{scoped2.GetHashCode()}");
    Console.WriteLine($"========请求结束========");
    return 1;
}

启动程序,刷新浏览器再次访问接口,输出如下:

单例模式两次的 HashCode 没有变化

两个瞬时服务两次的 HashCode 完全不同,意味着瞬时服务每次请求都会得到一个新对象

范围服务每个请求内是相同的,不同的请求之间得到的对象实例是不同的

其他服务注册方式

除了使用泛型的方式注册服务之外,还有其他的方式

添加一个 OrderService

public interface IOrderService
{

}

public class OrderService : IOrderService
{

}

public class OrderServiceEx : IOrderService
{

}

在 Startup 中注册服务

public void ConfigureServices(IServiceCollection services)
{
    #region 注册服务不同生命周期的服务

    // 将单例的服务注册为单例的模式
    services.AddSingleton<IMySingletonService, MySingletonService>();

    // Scoped 的服务注册为 Scoped 的生命周期
    services.AddScoped<IMyScopedService, MyScopedService>();

    // 瞬时的服务注册为瞬时的生命周期
    services.AddTransient<IMyTransientService, MyTransientService>();

    #endregion

    #region 花式注册

    services.AddSingleton<IOrderService>(new OrderService());  //直接注入实例

    //// 通过工厂模式
    //services.AddSingleton<IOrderService>(serviceProvider =>
    //{
    //    return new OrderServiceEx();
    //});

    //services.AddScoped<IOrderService>(serviceProvider =>
    //{
    //    // 可以使用 IServiceProvider 入参,也就意味着可以从容器里面获取多个对象,进行组装,得到最终的实现实例
    //    // 也就是可以把工厂类设计的比较复杂,比如说实现类依赖了容器里面的另外一个类,或者用另一个类来包装原有的实现
    //    //serviceProvider.GetService<>()

    //    return new OrderServiceEx();
    //});

    #endregion

    #region 尝试注册(如果服务已经注册过,则不再注册)

    services.TryAddSingleton<IOrderService, OrderServiceEx>();

    #endregion

    services.AddControllers();
}

在服务端 WeatherForecastController 定义另外一个接口

// IEnumerable<IOrderService>:获取曾经注册过的所有 IOrderService
public int GetServiceList([FromServices]IEnumerable<IOrderService> services)
{
    foreach (var item in services)
    {
        Console.WriteLine($"获取到服务实例:{item.ToString()}:{item.GetHashCode()}");
    }
    return 1;
}

调整一下程序的启动页面,Properties 下的 launchSetting.json 的这一行代码

"launchUrl": "weatherforecast/getservicelist",

启动程序,输出如下:

获取到服务实例:DependencyInjectionDemo.Services.OrderService:25560520

只有一个实例,说明 TryAddSingleton 没有生效

接着,注册两个服务

services.AddSingleton<IOrderService>(new OrderService());
services.AddSingleton<IOrderService, OrderServiceEx>();

启动程序,输出如下:

获取到服务实例:DependencyInjectionDemo.Services.OrderService:16991442
获取到服务实例:DependencyInjectionDemo.Services.OrderServiceEx:25560520

结果获取到了两个实例

接下来,了解一下 TryAddEnumerable 与 TryAddSingleton 的区别

#region 尝试注册(如果服务已经注册过,则不再注册)

services.TryAddSingleton<IOrderService, OrderServiceEx>();// 接口类型重复,则不注册

services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrderService, OrderService>());// 相同类型的接口,实现类相同,则不注册

#endregion

注册服务

services.AddSingleton<IOrderService>(new OrderService());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrderService, OrderService>());

启动程序,输出如下:

获取到服务实例:DependencyInjectionDemo.Services.OrderService:53046438

因为已经注册过 OrderService,所以第二句代码不生效

以不同的实现注册服务

services.AddSingleton<IOrderService>(new OrderService());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrderService, OrderServiceEx>());

启动程序,输出如下:

获取到服务实例:DependencyInjectionDemo.Services.OrderService:24219861
获取到服务实例:DependencyInjectionDemo.Services.OrderServiceEx:38855053

这样就可以获取到两个服务实例

刷新浏览器,再执行一遍

获取到服务实例:DependencyInjectionDemo.Services.OrderService:24219861
获取到服务实例:DependencyInjectionDemo.Services.OrderServiceEx:38855053
获取到服务实例:DependencyInjectionDemo.Services.OrderService:24219861
获取到服务实例:DependencyInjectionDemo.Services.OrderServiceEx:38855053

因为注册的是单例,所以两次请求获取到的实例都是相同的

服务的替换和删除

注册完毕之后,想替换某些组件的某些部分时,可以使用 ReplaceRemoveAll

services.AddSingleton<IOrderService>(new OrderService());
services.Replace(ServiceDescriptor.Singleton<IOrderService, OrderServiceEx>());// 替换掉注册的第一个实现

启动程序,输出如下:

获取到服务实例:DependencyInjectionDemo.Services.OrderServiceEx:25560520

从结果看出,注册的 OrderService 被替换为 OrderServiceEx

下面介绍 RemoveAll

services.AddSingleton<IOrderService>(new OrderService());
services.AddSingleton<IOrderService, OrderServiceEx>();
services.RemoveAll<IOrderService>();// 移除所有 IOrderService 的注册

这种情况下程序会报错,因为所有 IOrderService 的注册被移除

Unable to resolve service for type 'DependencyInjectionDemo.Services.IOrderService'

注册泛型模板

当需要注册一组泛型实现的时候

实际上注册的时候并不知道泛型类的具体类型入参

依赖注入框架为我们提供了泛型模板的注册方式

通过一行代码来注册所有此泛型的具体实现

定义一个泛型接口

namespace DependencyInjectionDemo.Services
{
    public interface IGenericService<T>
    {

    }
    public class GenericService<T> : IGenericService<T>
    {
        public T Data { get; private set; }
        public GenericService(T data)
        {
            this.Data = data;
        }
    }
}

泛型模板注册方法

services.AddSingleton(typeof(IGenericService<>), typeof(GenericService<>));

它的生命周期与之前的注册方式是一致的

不过它无法通过泛型 API 注册

需要注册两个 service 的 type

第一个入参是服务的类型

第二个入参是服务实现的类型

接下来,看看如何在 controller 中使用

// 在构造函数中添加两个入参,IOrderService 和 IGenericService
// 通过断点调试查看 genericService 的类型可得知,泛型的具体实现可以用容器里面的任意类型来替代
public WeatherForecastController(ILogger<WeatherForecastController> logger, IOrderService orderService, IGenericService<IOrderService> genericService)
{
    _orderService = orderService;
    _logger = logger;
}

在 controller 中有两种依赖注入的实例的获取方式:

  • 通过 controller 构造函数注入
  • 通过 [FromServices] 注入

当定义一个 controller 的时候

它的服务是大部分接口都需要使用的情况下

推荐的做法是用构造函数注入的方式

如果这个服务仅仅在某一个接口使用的情况下

推荐使用 [FromServices] 注入

06 | 作用域与对象释放行为

作用域主要由IServiceScope这个接口来承载

对于实现 IDisposable 类的实例的对象,容器会负责对其生命周期进行管理,使用完毕之后,他会释放这些对象。

实现 IDisposable 接口类型的释放:

  • 1.容器只会负责由其创建的对象,如果这个对象是自己创建出来并放到容器里的,容器不负责释放这个对象
  • 2.在容器和子容器释放时,容器才会去释放这些对象,也就是说容器的生命周期与其创建的对象的生命周期是有对应关系的

两点建议:

  • 1.在根容器,最好不要创建实现了 IDisposable 瞬时服务
  • 2.避免手动创建实现了 IDisposable 对象,然后塞到容器里面,应该尽可能地使用容器来管理我们对象的创建和释放

先看一下服务

namespace DependencyInjectionScopeAndDisposableDemo.Services
{

    public interface IOrderService
    {

    }

    public class DisposableOrderService : IOrderService, IDisposable
    {
        public void Dispose()
        {
            Console.WriteLine($"DisposableOrderService Disposed:{this.GetHashCode()}");
        }
    }
}

首先定义 IOrderService

接着定义 IOrderService 的实现 DisposableOrderService,并*实现了 IDisposable *这个接口

在释放的时候打印释放信息,并输出对象的 HashCode

接着是服务注册(Startup)

services.AddTransient<IOrderService,DisposableOrderService>();

这里先注册一个瞬时服务,将 IOrderService 注册进去

然后看一下控制器(WeatherForecastController)

[HttpGet]
public int Get([FromServices] IOrderService orderService,
    [FromServices] IOrderService orderService2)
{
    return 1;
}

这里 FromServices 获取了两次 IOrderService

这里不需要写任何代码对它进行操作,因为整个生命周期是由容器去管理的

启动程序,输出如下:

DisposableOrderService Disposed:10579059
DisposableOrderService Disposed:47945396

可以看出,执行完毕之后,DisposableOrderService 会被释放掉,并且两个对象都会被释放掉

两个对象的 HashCode 不同

瞬时服务在每一次获取的时候都会获得一个新的对象

接着,添加一行代码表示服务

[HttpGet]
public int Get([FromServices] IOrderService orderService,
    [FromServices] IOrderService orderService2)
{
    Console.WriteLine("接口请求处理结束");
    return 1;
}

输出一下,表示我们的接口已经访问完毕,看一下释放时机在哪里

启动程序,输出如下:

接口请求处理结束
DisposableOrderService Disposed:35023218
DisposableOrderService Disposed:13943705

由此看出,接口请求处理结束后,才释放对象

接下来看一下 Scoped 模式

服务注册

services.AddScoped<IOrderService>(p => new DisposableOrderService());

控制器

[HttpGet]
public int Get([FromServices] IOrderService orderService,
    [FromServices] IOrderService orderService2)
{
    Console.WriteLine("=======1==========");
    // HttpContext.RequestServices
    // 是当前请求的一个根容器
    // 应用程序根容器的一个子容器
    // 每个请求会创建一个容器
    using (IServiceScope scope = HttpContext.RequestServices.CreateScope())
    {
        // 在这个子容器下面再创建一个子容器来获取服务
        var service = scope.ServiceProvider.GetService<IOrderService>();
    }
    Console.WriteLine("=======2==========");

    Console.WriteLine("接口请求处理结束");

    return 1;
}

启动程序,输出如下:

=======1==========
DisposableOrderService Disposed:31307802
=======2==========
接口请求处理结束
DisposableOrderService Disposed:31614998

每次请求会获得两个释放,意味着每创建一个 Scoped 的作用域,每个作用域内可以是单例的

接下来,把服务切换为单例模式,通过工厂的方式

services.AddSingleton<IOrderService>(p => new DisposableOrderService());

启动程序,输出如下:

=======1==========
=======2==========
接口请求处理结束

可以看到代码实际上不会被释放

如果切换为瞬时模式,通过工厂的方式

services.AddTransient<IOrderService>(p => new DisposableOrderService());

启动程序,输出如下:

=======1==========
DisposableOrderService Disposed:12021664
DisposableOrderService Disposed:32106157
=======2==========
接口请求处理结束
DisposableOrderService Disposed:3165221
DisposableOrderService Disposed:13048313

这里可以看到,获取四个服务并且释放掉

接下来把服务调整为自己创建,并注册进去

var service = new DisposableOrderService();
services.AddSingleton<IOrderService>(service);

同样我们也不会得到释放的输出

也就是说,通过这种方式注册,容器不会管理对象的生命周期

如何识别这个区别呢?

在控制器中注入 IHostApplicationLifetime 接口

这个接口的作用是用来管理整个应用程序的生命周期

它有一个方法 StopApplication

也就是说它可以把整个应用程序关掉

接着,通过手工关掉的方式看一下应用程序关闭时会不会把单例对象释放掉

[HttpGet]
public int Get([FromServices] IOrderService orderService,
    [FromServices] IOrderService orderService2,
    [FromServices]IHostApplicationLifetime hostApplicationLifetime,
    [FromQuery]bool stop = false)
{
    Console.WriteLine("=======1==========");
    // HttpContext.RequestServices
    // 是当前请求的一个根容器
    // 应用程序根容器的一个子容器
    // 每个请求会创建一个容器
    using (IServiceScope scope = HttpContext.RequestServices.CreateScope())
    {
        // 在这个子容器下面再创建一个子容器来获取服务
        var service = scope.ServiceProvider.GetService<IOrderService>();
        var service2 = scope.ServiceProvider.GetService<IOrderService>();
    }
    Console.WriteLine("=======2==========");

    if (stop)
    {
        hostApplicationLifetime.StopApplication();
    }

    Console.WriteLine("接口请求处理结束");

    return 1;
}

首先用自己创建对象的方式

var service = new DisposableOrderService();
services.AddSingleton<IOrderService>(service);

启动程序

输入 ?stop=true

https://localhost:5001/weatherforecast?stop=true

输出如下:

...
DependencyInjectionScopeAndDisposableDemo.exe (进程 16884)已退出,代码为 0。
要在调试停止时自动关闭控制台,请启用“工具”->“选项”->“调试”->“调试停止时自动关闭控制台”。
按任意键关闭此窗口. . .

如果单例由容器来管理,切换回普通注册方式

services.AddSingleton<IOrderService, DisposableOrderService>();

启动程序

输入 ?stop=true

https://localhost:5001/weatherforecast?stop=true

输出如下:

Application is shutting down...
接口请求处理结束
DisposableOrderService Disposed:23399238

对象释放,应用程序退出

这里说明单例的服务都是注册在根容器里面

根容器的释放意味着需要在整个应用程序退出时释放

这个时候它会释放自己所管理的所有的 IDisposable 的对象

这里面有一个非常需要注意的坑:

假如把服务注册成瞬时

services.AddTransient<IOrderService, DisposableOrderService>();

然后又在根容器里面去获取这个对象

var s = app.ApplicationServices.GetService<IOrderService>();

这意味着在根容器去持续的创建 IOrderService,但是由于根容器只会在应用程序整个退出时回收,也就意味着这些对象会一直积累在应用程序内

调整控制器,不获取 IOrderService

[HttpGet]
public int Get(
    [FromServices]IHostApplicationLifetime hostApplicationLifetime,
    [FromQuery]bool stop = false)
{

    if (stop)
    {
        hostApplicationLifetime.StopApplication();
    }

    return 1;
}

仅仅在根容器获取一次

var s = app.ApplicationServices.GetService<IOrderService>();

这样运行起来,每次请求(点击刷新)的话,整个输出是不会有内容的,因为我们没有在子容器里面去获取对象

但实际上当我们退出的时候,会发现确实有一个实例被释放掉了

DisposableOrderService Disposed:7511460

也就是说,实现了 IDisposable 接口的服务,如果时注册瞬时的,又在根容器去做操作,它会一直保持到应用程序退出的时候,才能够被回收掉

07 | 用Autofac增强容器能力:引入面向切面编程(AOP)的能力

这一节讲解使用第三方框架来扩展依赖注入容器

什么情况下需要我们引入第三方容器组件?

大部分情况下,默认的容器组件足够使用

当需要一些非常特殊的场景如下:

  1. 基于名称的注入:需要把一个服务按照名称来区分它的不同实现的时候

  2. 属性注入:直接把服务注册到某一个类的属性里面去,而不需要定义构造函数,比如之前的 FromService 和 构造函数入参

  3. 子容器:可以理解为之前讲过的 Scope,但实际上还可以用第三方的框架实现一些特殊的子容器

  4. 基于动态代理的 AOP:需要在服务中注入额外的行为的时候,可以用动态代理的能力

.NET Core 的依赖注入框架,它的核心扩展点是 IserviceProviderFactory

第三方的依赖注入容器都是用了这个类来作为扩展点,把自己注入到整个框架里来

也就是说在使用这些依赖注入框架的时候,不需要关注说谁家的特性,谁家的接口是什么样子,只需要关注官方核心的定义就可以了,不需要直接依赖这些框架

服务

namespace DependencyInjectionAutofacDemo.Services
{
    public interface IMyService
    {
        void ShowCode();
    }
    public class MyService : IMyService
    {
        public void ShowCode()
        {
            Console.WriteLine($"MyService.ShowCode:{GetHashCode()}");
        }
    }

    public class MyServiceV2 : IMyService
    {
        /// <summary>
        /// 用于演示属性注入的方式
        /// </summary>
        public MyNameService NameService { get; set; }

        public void ShowCode()
        {
            // 默认情况下,NameService 为空,如果注入成功,则不为空
            Console.WriteLine($"MyServiceV2.ShowCode:{GetHashCode()},NameService是否为空:{NameService == null}");
        }
    }

    public class MyNameService
    { 

    }
}

接下来看一下如何集成 Autofac

使用 Autofac 是因为它是 .NET 社区里面最老牌的容器框架之一

它有两个包:

  • Autofac.Extensions.DependencyInjection
  • Autofac.Extras.DynamicProxy

引入这两个包,就可以使用它来达到之前说的四种能力

引入这两个包后,需要在 Program 中添加一行代码

.UseServiceProviderFactory(new AutofacServiceProviderFactory())

UseServiceProviderFactory 是用于注册第三方容器的入口

还有一个改动在 Startup 中,我们需要添加一个 ConfigureContainer 方法,它的入参是 Autofac 的 ContainerBuilder

public void ConfigureContainer(ContainerBuilder builder)

现在有两个 ConfigureServices,一个是默认的,一个是 ConfigureContainer

服务注册进默认的容器之后,实际上会被 Autofac 接替,然后执行 ConfigureContainer

Autofac 的注册方式与之前的注册方式不同,先注册具体的实现,然后再告诉它想把它标记为哪个服务的类型,与之前的写法相反

builder.RegisterType<MyService>().As<IMyService>();

接下来是命名注册,当需要把一个服务注册多次,并且用不同命名作为区分的时候,可以用这种方式,入参是一个服务名

builder.RegisterType<MyServiceV2>().Named<IMyService>("service2");

如何使用它呢?

public ILifetimeScope AutofacContainer { get; private set; }

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // 注册根容器
    this.AutofacContainer = app.ApplicationServices.GetAutofacRoot();

    // Autofac 容器获取实例的方式是一组 Resolve 方法
    var service = this.AutofacContainer.ResolveNamed<IMyService>("service2");
    service.ShowCode();

    ...

启动程序,输出如下:

MyServiceV2.ShowCode:61566768,NameService是否为空:True

如何获取没有命名的服务呢?

// 获取没有命名的服务,把 namd 去掉即可
var servicenamed = this.AutofacContainer.Resolve<IMyService>();
servicenamed.ShowCode();

// Autofac 容器获取实例的方式是一组 Resolve 方法
var service = this.AutofacContainer.ResolveNamed<IMyService>("service2");
service.ShowCode();

启动程序,输出如下:

MyService.ShowCode:61566768
MyServiceV2.ShowCode:44407631,NameService是否为空:True

接下来,讲解属性注入

builder.RegisterType();
// 只需要在注册方法加上 PropertiesAutowired 即可
builder.RegisterType().As().PropertiesAutowired();

从服务里面获取它并且 ShowCode

var servicenamed = this.AutofacContainer.Resolve<IMyService>();
servicenamed.ShowCode();

启动程序,输出如下:

MyServiceV2.ShowCode:11318800,NameService是否为空:False

不为空,注册成功

接下来,演示 AOP 场景,它指的是在不期望改变原有类的情况下,在方法执行时嵌入一些逻辑,使得可以在方法执行的切面上任意插入逻辑

namespace DependencyInjectionAutofacDemo.Services
{
    /// 
    /// IInterceptor 是 Autofac 的面向切面的最重要的一个接口,它可以把逻辑注入到方法的切面里面去
    /// 
    public class MyInterceptor : IInterceptor
    {
        public void Intercept(IInvocation invocation)
        {
            // 方法执行前
            Console.WriteLine($"Intercept before,Method:{invocation.Method.Name}");
            // 具体方法的执行,如果这句话不执行,相当于把切面的方法拦截掉,让具体类的方法不执行
            invocation.Proceed();
            // 方法执行后,也就是说可以在任意的方法执行后,插入执行逻辑,并且决定原有的方法是否执行
            Console.WriteLine($"Intercept after,Method:{invocation.Method.Name}");
        }
    }
}

如何启动切面?

// 把拦截器注册到容器里面
builder.RegisterType<MyInterceptor>();
// 注册 MyServiceV2,并且允许它属性注册 (PropertiesAutowired)
// 开启拦截器需要使用 InterceptedBy 方法,并且注册类型 MyInterceptor
// 最后还要执行一个开关 EnableInterfaceInterceptors 允许接口拦截器
builder.RegisterType<MyServiceV2>().As<IMyService>().PropertiesAutowired().InterceptedBy(typeof(MyInterceptor)).EnableInterfaceInterceptors();

拦截器分两种类型,一种是接口拦截器,一种是类拦截器

常用的是接口拦截器,当服务类型是接口的时候,就需要使用这种方式

如果没有基于接口设计类,而是实现类的时候,就需要用类拦截器

类拦截器需要把方法设计为虚方法,这样子允许类重载的情况下,才可以拦截到具体的方法

启动程序,输出如下:

Intercept before,Method:ShowCode
MyServiceV2.ShowCode:31780825,NameService是否为空:True
Intercept after,Method:ShowCode

接下来看一下子容器的用法

// Autofac 具备给子容器进行命名的特性,可以把以服务注入到子容器中,并且是特定命名的子容器,这就意味着在其他的子容器是获取不到这个对象的
builder.RegisterType<MyNameService>().InstancePerMatchingLifetimeScope("myscope");

创建一个 myscope 的子容器

using (var myscope = AutofacContainer.BeginLifetimeScope("myscope"))
{
    var service0 = myscope.Resolve<MyNameService>();
    using (var scope = myscope.BeginLifetimeScope())
    {
        var service1 = scope.Resolve<MyNameService>();
        var service2 = scope.Resolve<MyNameService>();
        Console.WriteLine($"service1=service2:{service1 == service2}");
        Console.WriteLine($"service1=service0:{service1 == service0}");
    }
}

启动程序,输出如下:

service1=service2:True
service1=service0:True

这意味着在 myscope 子容器下面,不管再创建任何子容器的生命周期,得到的都是同一个对象

这样子的好处是当不期望这个对象在根容器创建时,又希望它在某一定的范围内时单例模式的情况下,可以使用这种方式

08 | 配置框架:让服务无缝适应各种环境

配置是应用程序发布到各种环境的必备能力,这一节开始详细讲解 ASP.NET Core 的配置框架

配置框架的核心包有两个,一个抽象包,一个实现包

  • Microsoft.Extensions.Configuration.Abstractions
  • Microsoft.Extensions.Configuration

这与依赖注入框架一样,也是使用了接口实现分离的设计模式

配置框架以 Key-value 字符串键值对的方式抽象了配置

同时还支持从各种不同的数据源读取配置,比如从命令行读取,从环境变量读取,从文件中读取

配置框架的核心接口有四个

  • IConfiguration
  • IConfigurationRoot
  • IConfigurationSection
  • IConfigurationBuilder

配置框架有一个核心的扩展点,就是注入自己的配置源,也就是说可以指定任意的配置的数据来源,注入到配置框架里面

  • IConfigurationSource
  • IConfigurationProvider

接下来通过一个基本的控制台应用程序从头到尾演示一个配置的构建和使用。

首先引入上面提到的两个包

  • Microsoft.Extensions.Configuration.Abstractions
  • Microsoft.Extensions.Configuration

接着是构建和使用

namespace ConfigurationDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            // ConfigurationBuilder 是用来构建配置的核心,所有设置都在 builder 中完成
            IConfigurationBuilder builder = new ConfigurationBuilder();
            // 注入一个内存的配置数据源(注入一个字典集合作为配置数据源)
            builder.AddInMemoryCollection(new Dictionary<string, string>()
            {
                { "key1","value1" },
                { "key2","value2" },
            });
            // Build 方法用来把所有的配置构建出来,并且获得一个 configurationRoot,表示配置的根
            // 也就是说读取配置的动作都需要从 IConfigurationRoot 这个对象读取的
            IConfigurationRoot configurationRoot = builder.Build();
            Console.WriteLine(configurationRoot["key1"]);
            Console.WriteLine(configurationRoot["key2"]);
        }
    }
}

启动程序,输出如下:

value1
value2

IConfigurationSection

namespace ConfigurationDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            // ConfigurationBuilder 是用来构建配置的核心,所有设置都在 builder 中完成
            IConfigurationBuilder builder = new ConfigurationBuilder();
            // 注入一个内存的配置数据源(注入一个字典集合作为配置数据源)
            builder.AddInMemoryCollection(new Dictionary<string, string>()
            {
                { "key1","value1" },
                { "key2","value2" },
                { "section1:key4","value4" },
                { "section2:key5","value5" },
                { "section2:key6","value6" },
            });
            // Build 方法用来把所有的配置构建出来,并且获得一个 configurationRoot,表示配置的根
            // 也就是说读取配置的动作都需要从 IConfigurationRoot 这个对象读取的
            IConfigurationRoot configurationRoot = builder.Build();
            //IConfiguration config = configurationRoot;
            Console.WriteLine(configurationRoot["key1"]);
            Console.WriteLine(configurationRoot["key2"]);

            // section 的作用是指当配置不仅仅是简单的 Key value 的时候,比如说需要给配置分组,就可以使用 section 来定义
            // section 每一节是用冒号来作为节的分隔符的
            IConfigurationSection section = configurationRoot.GetSection("section1");
            Console.WriteLine($"key4:{section["key4"]}");
            Console.WriteLine($"key5:{section["key5"]}");
        }
    }
}

启动程序,输出如下:

value1
value2
key4:value4
key5:

section1 的 key5 没有值

打印一下 section2 的 key5

IConfigurationSection section2 = configurationRoot.GetSection("section2");
Console.WriteLine($"key5_v2:{section2["key5"]}");

启动程序,输出如下:

key5_v2:value5

多级嵌套

{ "section2:section3:key7","value7" }

打印输出

var section3 = section2.GetSection("section3");
Console.WriteLine($"key7:{section3["key7"]}");

启动程序,输出如下:

key7:value7

这样做的好处是:一方面避免一个服务重复注册,也可以控制一个服务需要注册不同的实现

09 | 命令行配置提供程序:最简单快捷的配置注入方法

这一节讲解如何使用命令行参数来作为配置数据源

命令行配置(提供程序的)支持三种格式的命令

  1. 无前缀的 key=value 模式
  2. 双中横线模式 –key=value 或 –key value
  3. 正横杠模式 /key=value 或 /key value

备注:等号分隔符和空格分隔符不能混用

命令替换模式:为命令参数提供别名

  1. 必须以单横杠(-)或双横杠(–)开头
  2. 映射字典不能包含重复 Key

首先引入三个包

  • Microsoft.Extensions.Configuration.Abstractions
  • Microsoft.Extensions.Configuration
  • Microsoft.Extensions.Configuration.CommandLine

主程序

namespace ConfigurationCommandLineDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            var builder = new ConfigurationBuilder();

            // 把入参传递给命令行参提供程序
            builder.AddCommandLine(args);

            var configurationRoot = builder.Build();

            Console.WriteLine($"CommandLineKey1:{configurationRoot["CommandLineKey1"]}");
            Console.WriteLine($"CommandLineKey2:{configurationRoot["CommandLineKey2"]}");
            Console.ReadKey();
        }
    }
}

项目右键属性,设置调试模式启动时的命令参数

CommandLineKey1=value1 --CommandLineKey2=value2 /CommandLineKey3=value3 --k1=k3

也可以通过文件编辑,launchSettings.json

{
  "profiles": {
    "ConfigurationCommandLineDemo": {
      "commandName": "Project",
      "commandLineArgs": "CommandLineKey1=value1 --CommandLineKey2=value2 /CommandLineKey3=value3 --k1=k3"
    }
  }
}

启动程序,输出如下:

CommandLineKey1:value1
CommandLineKey2:value2

接着是命令替换

namespace ConfigurationCommandLineDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            var builder = new ConfigurationBuilder();

            //// 把入参传递给命令行参提供程序
            //builder.AddCommandLine(args);

            #region 命令替换
            var mapper = new Dictionary<string, string> { { "-k1", "CommandLineKey1" } };
            builder.AddCommandLine(args, mapper);
            #endregion

            var configurationRoot = builder.Build();

            Console.WriteLine($"CommandLineKey1:{configurationRoot["CommandLineKey1"]}");
            Console.WriteLine($"CommandLineKey2:{configurationRoot["CommandLineKey2"]}");
            Console.ReadKey();
        }
    }
}

将双横杠 –k1=k3 改为 单横杠 -k1=k3

{
  "profiles": {
    "ConfigurationCommandLineDemo": {
      "commandName": "Project",
      "commandLineArgs": "CommandLineKey1=value1 --CommandLineKey2=value2 /CommandLineKey3=value3 -k1=k3"
    }
  }
}

启动程序,输出如下:

CommandLineKey1:k3
CommandLineKey2:value2

可以看出,-k1 替换了 CommandLineKey1 里面的值

这个场景是用来做什么的?

实际上可以看一下 .NET 自己的命令行工具

打开控制台,输入 dotnet –help

sdk-options:
  -d|--diagnostics  启用诊断输出。
  -h|--help         显示命令行帮助。
  --info            显示 .NET Core 信息。
  --list-runtimes   显示安装的运行时。
  --list-sdks       显示安装的 SDK。
  --version         显示使用中的 .NET Core SDK 版本。

这里可以看到 options 支持双横杠长命名和单横杠的短命名

实际上最典型的场景就是给应用的命令行参数提供了一个短命名快捷命名的方式,比如说 -h 就可以替换 –help

10 | 环境变量配置提供程序:容器环境下配置注入的最佳途径

环境变量的配置提供程序主要适应场景:

  1. 在 Docker 中运行时
  2. 在 Kubernetes 中运行时
  3. 需要设置 ASP.NET Core 的一些内置特殊配置时

环境变量和命令行这两个提供程序在早期是没有容器化的,当时一个操作系统会跑多个应用程序,应用程序注入配置的方式一般都是通过文件或者是命令行的方式来注入的,环境变量当时用的比较少

现在在容器化的环境下,有了 Docker 的隔离能力,就意味着每一个应用程序都相当于跑在一个小型的操作系统下面一样,所以说这个时候 Docker 提供的环境隔离能力让我们可以使用环境变量来配置应用程序,在 Docker 和 Kubernetes 中,会大量使用环境变量,而不是使用命令行来配置基础配置

环境变量的配置有如下特点:

  1. 对于配置的分层键,支持使用双下横线 “__” 代替 “:”
  2. 支持根据前缀加载

在某些操作系统,比如说 Linux 下面,冒号作为环境变量的 Key 值是不行的,所以说这里支持用双下划线来代替冒号,也就是说当遇到双下划线的环境变量时,可以认为这是一个分层键

环境变量提供程序还支持根据环境变量的前缀来加载

接下来是代码演示:

首先引入三个包

  • Microsoft.Extensions.Configuration.Abstractions
  • Microsoft.Extensions.Configuration
  • Microsoft.Extensions.Configuration.EnvironmentVariables

然后看一下环境变量如何在调试环境下注入

右键项目,属性,调试,环境变量

同样的在 Properties 下的 launchSettings.json 可以看到配置

{
  "profiles": {
    "ConfigurationEnvironmentVariablesDemo": {
      "commandName": "Project",
      "environmentVariables": {
        "KEY1": "value1",
        "KEY2": "value2",
        "SECTION1__KEY3": "value3",
        "SECTION1__SECTION2__KEY4": "value4",
        "XIAO_KEY1": "xiao key1"
      }
    }
  }
}

主程序

namespace ConfigurationEnvironmentVariablesDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            var builder = new ConfigurationBuilder();
            builder.AddEnvironmentVariables();

            var configurationRoot = builder.Build();
            Console.WriteLine($"key1:{configurationRoot["key1"]}");
        }
    }
}

启动程序,输出如下:

key1:value1

分层键

// "SECTION1__KEY3": "value3"
// 我们定义了一个分层键 SECTION1,用双下划线隔开,这个 section 下面有一个 KEY3 的 Key
var section = configurationRoot.GetSection("SECTION1");
Console.WriteLine($"KEY3:{section["KEY3"]}");

启动程序,输出如下:

KEY3:value3

多级分层键

// "SECTION1__SECTION2__KEY4": "value4"
var section2 = configurationRoot.GetSection("SECTION1:SECTION2");
Console.WriteLine($"KEY4:{section2["KEY4"]}");

启动程序,输出如下:

KEY4:value4

前缀过滤:是指在注入环境变量的时候,指定一个前缀,意味着只注入指定前缀的环境变量,而不是把整个操作系统的所有环境变量注入进去

// "XIAO_KEY1": "xiao key1"
// build 之后把读取到的环境变量的前缀去掉
builder.AddEnvironmentVariables("XIAO_");
var configurationRoot = builder.Build();
Console.WriteLine($"KEY1:{configurationRoot["KEY1"]}");
// "KEY2": "value2"
// 在注入的时候,凡是没有 XIAO_ 开头的 Key 都没有注入进来,仅注册进来需要的一个环境变量值
// 适合当需要加载特定的值,去掉系统其他值的干扰项的场景使用
Console.WriteLine($"KEY2:{configurationRoot["KEY2"]}");

启动程序,输出如下:

KEY1:xiao key1
KEY2:value2

11 | 文件配置提供程序:自由选择配置的格式

文件配置提供程序

  • Microsoft.Extensions.Configuration.Ini
  • Microsoft.Extensions.Configuration.Json
  • Microsoft.Extensions.Configuration.NewtonsoftJson
  • Microsoft.Extensions.Configuration.Xml
  • Microsoft.Extensions.Configuration.UserSecrets

这些都是读取不同文件的格式,或者从不同的位置来读取文件

文件提供程序支持

  • 文件是否可选
  • 监视文件的变更

下面通过代码来了解这些特性

引用以下四个包:

  • Microsoft.Extensions.Configuration
  • Microsoft.Extensions.Configuration.Abstractions
  • Microsoft.Extensions.Configuration.Ini
  • Microsoft.Extensions.Configuration.Json

读取 appsettings.json

{
  "Key1": "Value1",
  "Key2": "Value2"
}

主程序

var builder = new ConfigurationBuilder();
builder.AddJsonFile("appsettings.json");
var configurationRoot = builder.Build();

Console.WriteLine($"Key1:{configurationRoot["Key1"]}");
Console.WriteLine($"Key2:{configurationRoot["Key2"]}");
Console.WriteLine($"Key3:{configurationRoot["Key3"]}");
Console.ReadKey();

启动程序,输出如下:

Key1:Value1
Key2:Value2
Key3:

Key3 不存在,所以他的值是空的

文件是否可选是它的第二个参数 optional,默认情况下是 false

builder.AddJsonFile("appsettings.json", optional:false);

这意味当文件不存在的时候它会报错

它的另一个参数是 reloadOnChange, 默认情况下是 true

builder.AddJsonFile("appsettings.json", optional:false, reloadOnChange:true);

这意味着每次文件变更,它会去读取新文件

接下来看一下 appsettings.ini

Key3=Value3 in ini

主程序

var builder = new ConfigurationBuilder();
builder.AddJsonFile("appsettings.json", optional:false, reloadOnChange:true);
builder.AddIniFile("appsettings.ini");
var configurationRoot = builder.Build();

Console.WriteLine($"Key1:{configurationRoot["Key1"]}");
Console.WriteLine($"Key2:{configurationRoot["Key2"]}");
Console.WriteLine($"Key3:{configurationRoot["Key3"]}");
Console.ReadKey();

启动程序,输出如下:

Key1:Value1
Key2:Value2
Key3:Value3 in ini

这里可以看到新添加的配置已经生效

builder 中添加配置源是有顺序关系的,后添加的配置会覆盖先添加的配置

12 | 配置变更监听:配置热更新能力的核心

这一节讲解如何使用代码来监视配置变化并做出一些动作

当我们需要追踪配置发生的变化,可以在变化发生时执行一些特定的操作

配置主要提供了一个 GetReloadToken 方法,这就是跟踪配置的关键方法

接着使用上一节的代码

var builder = new ConfigurationBuilder();
builder.AddJsonFile("appsettings.json", optional:true, reloadOnChange:true);
var configurationRoot = builder.Build();

IChangeToken token = configurationRoot.GetReloadToken();

IChangeToken 有两个属性和一个方法

public interface IChangeToken
{
    bool HasChanged { get; }

    bool ActiveChangeCallbacks { get; }

    IDisposable RegisterChangeCallback(Action<object> callback, object state);
}

接着注册 Callback

token.RegisterChangeCallback(state =>
{
    Console.WriteLine($"Key1:{configurationRoot["Key1"]}");
    Console.WriteLine($"Key2:{configurationRoot["Key2"]}");
    Console.WriteLine($"Key3:{configurationRoot["Key3"]}");
}, configurationRoot);

启动程序,修改配置文件,触发 Callback

多次修改配置文件没有效果?

因为 IChangeToken 这个对象只能使用一次,也就是说捕获到变更并且执行代码之后,需要再重新获取一个新的 IChangeToken,再次注册

token.RegisterChangeCallback(state =>
{
    Console.WriteLine($"Key1:{configurationRoot["Key1"]}");
    Console.WriteLine($"Key2:{configurationRoot["Key2"]}");
    Console.WriteLine($"Key3:{configurationRoot["Key3"]}");

    token = configurationRoot.GetReloadToken();
    token.RegisterChangeCallback(state2 =>
    {
        Console.WriteLine();
    }, configurationRoot);
}, configurationRoot);

这将变成一个无限循环的过程,微软实际上提供了一个比较方便使用的快捷的扩展方法,这个方法可以帮助我们轻松地处理这件事,也就意味着每次触发完成以后可以重新绑定

ChangeToken.OnChange(() => configurationRoot.GetReloadToken(), () =>
{
    Console.WriteLine($"Key1:{configurationRoot["Key1"]}");
    Console.WriteLine($"Key2:{configurationRoot["Key2"]}");
    Console.WriteLine($"Key3:{configurationRoot["Key3"]}");
});

第一个参数是获取 IChangeToken 的方法

第二个参数是处理变更的注入方法

启动程序,修改配置文件,多次触发 Callback

13 | 配置绑定:使用强类型对象承载配置数据

要点:

  1. 支持将配置值绑定到已有对象

  2. 支持将配置值绑定到私有属性上

继续使用上一节代码

首先定义一个类作为接收配置的实例

class Config
{
    public string Key1 { get; set; }
    public bool Key5 { get; set; }
    public int Key6 { get;  set; }
}

接着看一下配置文件,appsettings.json

{
  "Key1": "Value1",
  "Key2": "Value2",
  "Key5": true,
  "Key6": 0
}

新增一个引用包

  • Microsoft.Extensions.Configuration.Binder

这个包的作用就是让我们能够很方便的把配置绑定到强类型上面去

主程序

var builder = new ConfigurationBuilder();
builder.AddJsonFile("appsettings.json", optional:true, reloadOnChange:true);
var configurationRoot = builder.Build();

var config = new Config()
{
    Key1 = "config key1",
    Key5 = false,
    Key6 = 100
};

configurationRoot.Bind(config);

Console.WriteLine($"Key1:{config.Key1}");
Console.WriteLine($"Key5:{config.Key5}");
Console.WriteLine($"Key6:{config.Key6}");

启动程序,输出如下:

Key1:Value1
Key5:True
Key6:0

可以看出,绑定的字段都是从配置中读出来的

实际上通常意义来讲,配置文件不会这么简单,一般都是有嵌套格式

{
  "Key2": "Value2",
  "Key6": 0,
  "OrderService": {
    "Key1": "order key1",
    "Key5": true,
    "Key6": 200
  }
}

在这种情形下,需要把 section 绑定给 config 对象

configurationRoot.GetSection("OrderService").Bind(config);

这样就可以对不同的配置进行分组,并且分别绑定,避免配置混在一起

启动程序,输出如下:

Key1:order key1
Key5:True
Key6:200

也就是说可以从任意的节来读取配置,并且绑定到类型上面

这里定义的所有类型,所有的字段都是 public,但有一些场景下面可能是 private,对于私有的字段,默认情况下,是不会去绑定的,也不允许赋默认值,可以在定义时设置

class Config
{
    public string Key1 { get; set; }
    public bool Key5 { get; set; }
    public int Key6 { get; private set; } = 100;
}

主程序

var config = new Config()
{
    Key1 = "config key1",
    Key5 = false
};

configurationRoot.GetSection("OrderService").Bind(config);

启动程序,输出如下:

Key1:order key1
Key5:True
Key6:100

可以看到 Key6 的值是100,没有发生变化,而配置中的值是200

要让私有变量生效,实际上 Bind 还有另外一个参数

configurationRoot.GetSection("OrderService").Bind(config,
                binderOptions => { binderOptions.BindNonPublicProperties = true; });

启动程序,输出如下:

Key1:order key1
Key5:True
Key6:200

这样一来,私有字段也都可以从配置里面赋值了

14 | 自定义配置数据源:低成本实现定制化配置方案

这一节讲解如何定义自己的数据源,来扩展配置框架

扩展步骤

  1. 实现 IConfigurationSource
  2. 实现 IConfigurationProvider
  3. 实现 AddXXX 扩展方法,用来作为注入的快捷方式

首先定义一个 MyConfigurationSource

namespace ConfigurationCustom
{
    class MyConfigurationSource : IConfigurationSource
    {
        public IConfigurationProvider Build(IConfigurationBuilder builder)
        {
            return new MyConfigurationProvider();
        }
    }
}

接着是 MyConfigurationProvider

namespace ConfigurationCustom
{
    // ConfigurationProvider 集成自 IConfigurationProvider
    class MyConfigurationProvider : ConfigurationProvider
    {

        Timer timer;

        public MyConfigurationProvider() : base()
        {
            // 用一个线程模拟配置发生变化,每三秒钟执行一次,告诉我们要重新加载配置
            timer = new Timer();
            timer.Elapsed += Timer_Elapsed;
            timer.Interval = 3000;
            timer.Start();
        }

        private void Timer_Elapsed(object sender, ElapsedEventArgs e) => Load(true);

        public override void Load() => Load(false);

        /// <summary>
        /// 加载数据
        /// </summary>
        /// <param name="reload">是否重新加载数据</param>
        void Load(bool reload)
        {
            // Data 表示 Key-value 数据,这是由 ConfigurationProvider 提供的一个数据承载的集合
            // 我们把最新的时间填充进去
            Data["lastTime"] = DateTime.Now.ToString();
            if (reload)
            {
                base.OnReload();
            }
        }
    }
}

实际上到此扩展就已经完成了,可以通过 builder.AddXXX 这个方法来把 source 注入进来

namespace ConfigurationCustom
{
    class Program
    {
        static void Main(string[] args)
        {
            var builder = new ConfigurationBuilder();
            builder.Add(new MyConfigurationSource());

            var configRoot = builder.Build();
            Console.WriteLine($"lastTime:{configRoot["lastTime"]}");

            Console.ReadKey();
        }
    }
}

启动程序,输出如下:

lastTime:2020/3/1 22:39:36

这里可以看到,输出最新的时间

但是如果这样去分发配置源的包的话,需要把 MyConfigurationSource
定义为 public,否则使用方式没办法引用到这个类

那么就可以通过扩展方法的方式来保障不需要暴露 ConfigSource

定义一个扩展方法 AddMyConfiguration

namespace Microsoft.Extensions.Configuration
{
    public static class MyConfigurationBuilderExtensions
    {
        public static IConfigurationBuilder AddMyConfiguration(this IConfigurationBuilder builder)
        {
            builder.Add(new MyConfigurationSource());
            return builder;
        }
    }
}

首先把扩展方法的命名空间放在 config 的命名空间,而不是自己的命名空间,这样方便在引用的时候直接使用而无需加载具体的命名空间

另外一个可以把 Provider 定义为 internal 的,默认是 internal,如果说分发到第三方的话,internal 的类是不能被引用的,这样就意味着只需要暴露一个扩展方法,而不需要暴露具体的配置源的实现

class MyConfigurationProvider : ConfigurationProvider

如何使用呢,其实很简单

只需要在 builder.Add 的时候使用 builder.AddMyConfiguration 就可以了,这样达到的效果是一样的

namespace ConfigurationCustom
{
    class Program
    {
        static void Main(string[] args)
        {
            var builder = new ConfigurationBuilder();
            //builder.Add(new MyConfigurationSource());
            builder.AddMyConfiguration();

            var configRoot = builder.Build();
            Console.WriteLine($"lastTime:{configRoot["lastTime"]}");

            Console.ReadKey();
        }
    }
}

启动程序,输出如下:

lastTime:2020/3/1 22:55:11

在定义扩展的时候,都推荐这样去做,把具体实现都定义为私有的,然后通过扩展方法的方式暴露出去

刚才实际上还定义了一个 timer 来模拟配置的变更,这里可以监听一下它的变更,看是否生效

上一节讲到 ChangeToken 的方式,这里还是用 ChangeToken 的 OnChange 方法

namespace ConfigurationCustom
{
    class Program
    {
        static void Main(string[] args)
        {
            var builder = new ConfigurationBuilder();
            builder.AddMyConfiguration();

            var configRoot = builder.Build();

            ChangeToken.OnChange(() => configRoot.GetReloadToken(), () =>
            {
                Console.WriteLine($"lastTime:{configRoot["lastTime"]}");
            });

            Console.WriteLine("开始了");
            Console.ReadKey();
        }
    }
}

启动程序,输出如下:

开始了
lastTime:2020/3/1 22:59:25
lastTime:2020/3/1 22:59:28
lastTime:2020/3/1 22:59:31

每个三秒钟输出一次,这说明我们定义的配置变更的通知已经生效了

MyConfigurationProvider 中我们只是通过赋值一个 DateTime 来模拟配置源

实际上可以从远程来说,比如阿波罗的配置中心,Kazoo,这些地方远程的读取配置,结合着命令行和环境变量配置,就可以完成配置中心的远程方案,意味着可以版本化的管理配置

这样子在 Docker 容器环境下面,Kubernetes 环境下面,就可以有完善的配置管理解决方案

15 | 选项框架:服务组件集成配置的最佳实践

这一节讲解如何使用选项框架来处理服务和配置的关系

选项框架的特性:

  1. 支持单例模式读取配置
  2. 支持快照
  3. 支持配置变更通知
  4. 支持运行时动态修改选项值

在设计系统的时候需要遵循两个原则:

  1. 接口分离原则(ISP),我们的类不应该依赖它不使用的配置
  2. 关注点分离(SoC),不同组件、服务、类之间的配置不应相互依赖或耦合

建议:

  1. 为我们的服务设计 XXXOptions
  2. 使用 IOptions、IOptionsSnapshot、IOptionsMonitor作为服务构造函数的参数

这样会让我们更快的实现服务配置的各种能力

在定义服务的时候,一般先定义服务接口

namespace OptionsDemo.Services
{
    public interface IOrderService
    {
        int ShowMaxOrderCount();
    }

    public class OrderService : IOrderService
    {
        OrderServiceOptions _options;

        public OrderService(OrderServiceOptions options)
        {
            _options = options;
        }

        public int ShowMaxOrderCount()
        {
            return _options.MaxOrderCount;
        }
    }

    // 代表从配置中读取的值
    public class OrderServiceOptions
    {
        public int MaxOrderCount { get; set; } = 100;
    }
}

接着是服务注册

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<OrderServiceOptions>();
    services.AddSingleton<IOrderService, OrderService>();
}

接着是控制器的定义

[HttpGet]
public int Get([FromServices]IOrderService orderService)
{
    Console.WriteLine($"orderService.ShowMaxOrderCount:{orderService.ShowMaxOrderCount()}");
    return 1;
}

启动程序,输出如下:

orderService.ShowMaxOrderCount:100

如果说我们需要把这个值跟配置绑定,怎么做呢?

首先需要引入 Options 框架

ASP.NET Core 实际上已经默认帮我们把框架引入进来了

命名空间是:Microsoft.Extensions.Options

我们需要修改一下服务的入参

public class OrderService : IOrderService
{
    //OrderServiceOptions _options;
    IOptions<OrderServiceOptions> _options;

    //public OrderService(OrderServiceOptions options)
    public OrderService(IOptions<OrderServiceOptions> options)
    {
        _options = options;
    }

    public int ShowMaxOrderCount()
    {
        //return _options.MaxOrderCount;
        return _options.Value.MaxOrderCount;
    }
}

注册的时候使用 config 方法,从配置文件读取

public void ConfigureServices(IServiceCollection services)
{
    //services.AddSingleton<OrderServiceOptions>();
    services.Configure<OrderServiceOptions>(Configuration.GetSection("OrderService"));
    services.AddSingleton<IOrderService, OrderService>();
}

配置文件

{
  "OrderService": {
    "MaxOrderCount": 200
  }
}

启动程序,输出如下:

orderService.ShowMaxOrderCount:200

可以看到,输出的值为200,说明配置与选项已经完成绑定

服务只依赖了 OrderServiceOptions,并没有依赖配置框架,也就是说服务只关心配置的值是什么,它并不关心配置的值从哪里来,解除了配置与服务之间的依赖

另外可以为所有的服务分别设计它们的 Options,这样服务之间的选项配置也都不会互相依赖

16 | 选项数据热更新:让服务感知配置的变化

选项框架还有两个关键类型:

  1. IOptionsMonitor
  2. IOptionsSnapshot

场景:

  1. 范围作用域类型使用 IOptinsSnapshot
  2. 单例服务使用 IOptionsMonitor

通过代码更新选项:

IPostConfigureOptions

延续上一节的代码,但是做一些特殊处理,之前注册 Order 服务用的是单例模式,这里改为 Scoped 模式

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<OrderServiceOptions>(Configuration.GetSection("OrderService"));
    //services.AddSingleton<IOrderService, OrderService>();
    services.AddScoped<IOrderService, OrderService>();
    services.AddControllers();
}

service 还是使用 IOptions

public class OrderService : IOrderService
{
    IOptions<OrderServiceOptions> _options;

    public OrderService(IOptions<OrderServiceOptions> options)
    {
        _options = options;
    }

    public int ShowMaxOrderCount()
    {
        return _options.Value.MaxOrderCount;
    }
}

启动程序,输出如下:

orderService.ShowMaxOrderCount:200

修改配置

{
    "OrderService": {
    "MaxOrderCount": 2000
  }
}

启动程序,输出如下:

orderService.ShowMaxOrderCount:200

输出值还是200

那么如何能够读到新的配置呢?

只需要把 IOptions 换成 IOptionsSnapshot 即可

IOptionsSnapshot<OrderServiceOptions> _options;

public OrderService(IOptionsSnapshot<OrderServiceOptions> options)
{
    ...
}

这是因为我们的服务注册的是 Scoped 模式,并且使用 Snapshot 来读取配置,每次请求都会重新计算并读取配置

那如果我们的服务是单例的时候怎么办呢?

把服务注册改为单例模式

services.AddSingleton<IOrderService, OrderService>();

这里需要使用另一个接口,把 Snapshot 改为 Monitor

IOptionsMonitor<OrderServiceOptions> _options;

public OrderService(IOptionsMonitor<OrderServiceOptions> options)
{
    ...
}

Monitor 与 Snapshot 的定义略微有些不同,它获取值是需要用 CurrentValue 字段

public int ShowMaxOrderCount()
{
    return _options.CurrentValue.MaxOrderCount;
}

启动程序,修改配置文件,刷新浏览器,可以看到输出了修改后的数据,也就是说单例对象同时也能读取到最新的配置

如果说我想知道配置的值发生变化并且通知到我的 Options 怎么做呢?

首先看一下 Monitor 的定义

namespace Microsoft.Extensions.Options
{
  public interface IOptionsMonitor<out TOptions>
  {

    TOptions CurrentValue { get; }

    TOptions Get(string name);

    IDisposable OnChange(Action<TOptions, string> listener);
  }
}

它有一个 OnChange 方法,也就是说可以监听它的变更

public OrderService(IOptionsMonitor<OrderServiceOptions> options)
{
    _options = options;

    _options.OnChange(option =>
    {
        Console.WriteLine($"配置更新了,最新的值是:{_options.CurrentValue.MaxOrderCount}");
    });
}

启动程序,修改配置,可以看到输出配置变化,也就是说可以在单例模式下监听到 Options 的变化

通常情况下,在设计服务的时候,会在 ConfigureServices 添加配置注入、服务注入,但是当配置多起来的时候,注入代码就会非常多

那么如何使代码结构更加良好?

实际上可以把服务注册的代码放在静态扩展方法里,使得 ConfigureServices 更加简洁

namespace Microsoft.Extensions.DependencyInjection
{
    public static class OrderServiceExtensions
    {
        public static IServiceCollection AddOrderService(this IServiceCollection services,IConfiguration configuration)
        {

            services.Configure<OrderServiceOptions>(configuration);
            services.AddSingleton<IOrderService, OrderService>();
            return services;
        }
    }
}

这样在 Startup 的注册就变得更为简单了

services.AddOrderService(Configuration.GetSection("OrderService"));

我们在设计系统的时候会涉及大量的 service,所以我们可以把 service 的注册提炼在扩展方法里,不同的模块用不同的扩展方法隔开,使模块之间更加清晰,代码的结构也更加的清晰

那么实际上我们在设计服务的时候,还有一些特殊的述求,比如说把配置读取出来之后,还需要在内存里面进行一些特殊的处理,我们就可以使用动态配置的方式

动态配置的方式是在我们的 Configure 的代码之后,调用 PostConfigure 的方法,这里需要配置 OrderServiceOptions

{
    public static class OrderServiceExtensions
    {
        public static IServiceCollection AddOrderService(this IServiceCollection services,IConfiguration configuration)
        {

            services.Configure<OrderServiceOptions>(configuration);

            services.PostConfigure<OrderServiceOptions>(options =>
            {
                options.MaxOrderCount += 20;
            });

            services.AddSingleton<IOrderService, OrderService>();
            return services;
        }
    }
}

启动程序,可以看到输出动态增加了20

17 | 为选项数据添加验证:避免错误配置的应用接收用户流量

三种验证方法

  1. 直接注册验证函数
  2. 实现 IValidateOptions
  3. 使用 Microsoft.Extensions.Options.DataAnnotations

延用上一节代码

需要添加验证的时候不能用 Configure,而用 AddOptions 方法

//services.Configure<OrderServiceOptions>(configuration);

services.AddOptions<OrderServiceOptions>().Configure(options =>
{
    configuration.Bind(options);
}).Validate(options =>
{
    return options.MaxOrderCount <= 100;
}, "MaxOrderCount 不能大于100");

配置中的值是200,所以运行之后报错,提示 “MaxOrderCount 不能大于100”

接着使用属性的方式,切换成属性注入

services.AddOptions<OrderServiceOptions>().Configure(options =>
{
    configuration.Bind(options);
}).ValidateDataAnnotations();

还需要修改 OrderServiceOptions,定义它的验证属性

public class OrderServiceOptions
{
  [Range(30, 100)]
  public int MaxOrderCount { get; set; } = 100;
}

配置中的值是200,所以运行之后报错,提示 “MaxOrderCount 的值必须在30到100之间”

接着是第三种方式,实现接口的方式

首先是定义验证类

public class OrderServiceValidateOptions : IValidateOptions
{
    public ValidateOptionsResult Validate(string name, OrderServiceOptions options)
    {
        if (options.MaxOrderCount > 100)
        {
            return ValidateOptionsResult.Fail("MaxOrderCount 不能大于100");
        }
        else
        {
            return ValidateOptionsResult.Success;
        }
    }
}

要使用这个类,需要注入进去

services.AddOptions<OrderServiceOptions>().Configure(options =>
{
    configuration.Bind(options);
}).Services.AddSingleton<IValidateOptions<OrderServiceOptions>>(new OrderServiceValidateOptions( ));

配置中的值是200,所以运行之后报错,提示 “MaxOrderCount 不能大于100”

总结一下,通过添加选项的验证,可以在配置错误的情况下阻止应用程序启动,这样就可以避免用户流量达到错误的节点上。

18 | 日志框架:聊聊记日志的最佳姿势

日志框架必要的包:

  1. Microsoft.Extensions.Logging
  2. Microsoft.Extensions.Logging.Console
  3. Microsoft.Extensions.Logging.Debug
  4. Microsoft.Extensions.Logging.TraceSource

代码通过一个控制台程序,展示从读取配置到整个日志的记录器的构造和日志记录的过程
首先从文件读取配置

IConfigurationBuilder configBuilder = new ConfigurationBuilder();
configBuilder.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
var config = configBuilder.Build();

接着构造容器,注入对象

IServiceCollection serviceCollection = new ServiceCollection();// 构造容器
// 用工厂模式将配置对象注册到容器管理
// 注入的时候使用了一个委托,意味着容器可以帮我们管理这个对象的生命周期
serviceCollection.AddSingleton<IConfiguration>(p => config);
// 如果将实例直接注入,容器不会帮我们管理
//serviceCollection.AddSingleton<IConfiguration>(config);

// AddLogging 往容器里面注册了几个关键对象:
// ILoggerFactory,泛型模板 typeof (ILogger<>),Logger 的过滤配置 IConfigureOptions<LoggerFilterOptions>
// 最后一行,configure((ILoggingBuilder) new LoggingBuilder(services)); 就是整个注入我们的委托
serviceCollection.AddLogging(builder =>
{
    builder.AddConfiguration(config.GetSection("Logging"));// 注册 Logging 配置的 Section
    builder.AddConsole();// 先使用一个 Console 的日志输出提供程序
});

AddLogging 源码

public static IServiceCollection AddLogging(
      this IServiceCollection services,
      Action<ILoggingBuilder> configure)
{
  if (services == null)
    throw new ArgumentNullException(nameof (services));
  services.AddOptions();
  services.TryAdd(ServiceDescriptor.Singleton<ILoggerFactory, LoggerFactory>());
  services.TryAdd(ServiceDescriptor.Singleton(typeof (ILogger<>), typeof (Logger<>)));
  services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<LoggerFilterOptions>>((IConfigureOptions<LoggerFilterOptions>) new DefaultLoggerLevelConfigureOptions(LogLevel.Information)));
  configure((ILoggingBuilder) new LoggingBuilder(services));
  return services;
}

配置文件,appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Debug",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    },
    "Console": {
      "LogLevel": {
        "Default": "Information",
        "Program": "Trace",
        "alogger": "Trace",
        "LoggingSimpleDemo.OrderService": "None"
      }
    }
  }
}

Logging 里面定义了 Log 的级别,Key 代表 Log 的名称,Value 代表 Logger 的级别

Console 是指针对 Console 的输出提供程序配置的日志级别

下面看一下日志级别的定义,按照严重程度从低到高

namespace Microsoft.Extensions.Logging
{
  public enum LogLevel
  {
    Trace,
    Debug,
    Information,
    Warning,
    Error,
    Critical,
    None,
  }
}

也就是说我们可以指定日志输出的最低级别

接着 BuildServiceProvider,从容器里面获取 ILoggerFactory

IServiceProvider service = serviceCollection.BuildServiceProvider();
ILoggerFactory loggerFactory = service.GetService<ILoggerFactory>();

ILoggerFactory 的定义

namespace Microsoft.Extensions.Logging
{
  public interface ILoggerFactory : IDisposable
  {
    // 输入的名称是 Logger 的名称,输出的结果是一个 ILogger 的对象,代表日志记录器
    ILogger CreateLogger(string categoryName);

    // 这个方法通常不会用到它,因为通常情况下注册容器提供程序会在 AddLogging 委托里面去注册,而不会用 AddProvider 方法
    void AddProvider(ILoggerProvider provider);
  }
}

获取到 ILoggerFactory 之后就可以创建日志记录器

ILogger alogger = loggerFactory.CreateLogger("alogger");

alogger.LogDebug(2001, "aiya");
alogger.LogInformation("hello");

var ex = new Exception("出错了");
alogger.LogError(ex, "出错了");

因为配置文件中 alogger 的级别是 Trace

"alogger": "Trace",

所以这三行都会被打印出来

启动程序,输出如下:

dbug: alogger[2001]
      aiya
info: alogger[0]
      hello
fail: alogger[0]
      出错了
System.Exception: 出错了

方括号的内容是 EventID,也就是针对每一个记录的位置事件,可以为它分配一个事件 ID,代码中在 LogDebug 的时候定义了一个事件 ID 是2001

假如说把 alogger 的日志级别调整成 Information

"alogger": "Information",

那么 Debug 级别的信息没有输出的

info: alogger[0]
      hello
fail: alogger[0]
      出错了
System.Exception: 出错了

除了使用 CreateLogger 指定 logger 的名称,实际上还可以借助容器来构造 logger,通常情况下我们会定义自己的类

namespace LoggingSimpleDemo
{
    public class OrderService
    {
        ILogger<OrderService> _logger;

        public OrderService(ILogger<OrderService> logger)
        {
            _logger = logger;
        }

        public void Show()
        {
            _logger.LogInformation("Show Time{time}", DateTime.Now);
        }
    }
}

接着,将 OrderService 注入到容器中

serviceCollection.AddTransient<OrderService>();
IServiceProvider service = serviceCollection.BuildServiceProvider();
var order = service.GetService<OrderService>();
order.Show();

日志级别设置为 Trace

"LoggingSimpleDemo.OrderService": "Trace"

启动程序,输出如下:

info: LoggingSimpleDemo.OrderService[0]
      Show Time03/06/2020 23:41:38

这样做的意义是什么呢?

通常情况下并不会用 ILoggerFactory 来构造日志记录器,而是用强类型的这种依赖注入的方式来去管理我们的日志,也就是说用构造函数将泛型的 ILogger 注入进来的方式

这样的方式有个好处就是我们不需要去为 logger 定义名字,它会默认将我们类型的名称作为记录器的名字,命名空间加上类名 LoggingSimpleDemo.OrderService ,那也就是可以在配置文件里面设置日志级别

"LoggingSimpleDemo.OrderService": "None"

这样子就没有输出

这里面有一个小技巧,需要大家特别注意,就是当我们在记录日志的时候,尽量使用模板的方式

_logger.LogInformation("Show Time{time}", DateTime.Now);

以下两种方式效果相同,但是字符串拼接的时机不同

_logger.LogInformation("Show Time{time}", DateTime.Now);
_logger.LogInformation($"Show Time{DateTime.Now}");

第一行代码是在我们决定要输出的时候,也就是在 LogInformation 内部 console 要输出的时候才做拼接的动作

第二行代码是指我们在字符串拼接好以后,输入给了 LogInformation

如果我们把日志级别关掉

"LoggingSimpleDemo.OrderService": "None"

两行代码都不会有输出,但是第一行代码字符串拼接的动作不会执行,第二行代码已经执行了,第一行代码节省了运行资源

另外一个就是,在记录日志的时候,不要把敏感信息记录到日志中,记录日志的目的是为了调试或者定位问题

总结一下

  1. 日志级别定义

日志级别会从严重程度的低到高定义,可以决定输出的最低级别

  1. 日志对象获取

可以通过 ILoggerFactory 的方式获取日志对象,对它指定一个名字,也可以通过 ILogger 泛型的模式,从容器中获取日志对象,最推荐的就是强类型的泛型模式

  1. 日志过滤的配置逻辑

可以针对 logger 的名称来进行任意的配置,日志的开关以及日志的级别

  1. 日志记录的方法

LogInformation,LogDebug,还有一些小技巧,使用模板的方式记录日志,而不是提前拼接字符串输入给日志系统

  1. 避免记录敏感信息,如密码、密钥,规避安全风险

19 | 日志作用域:解决不同请求之间的日志干扰

开始之前先看一下上一节的代码

// 配置的框架
var configBuilder = new ConfigurationBuilder();
configBuilder.AddCommandLine(args);
configBuilder.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);

var config = configBuilder.Build();
IServiceCollection serviceCollection = new ServiceCollection();
serviceCollection.AddSingleton<IConfiguration>(p => config);

// 日志的框架
serviceCollection.AddLogging(builder =>
{
    builder.AddConfiguration(config.GetSection("Logging"));// 注册 Logging 配置的 Section
    builder.AddConsole();// 先使用一个 Console 的日志输出提供程序
    builder.AddDebug();
});

我们可以观察到配置的框架和日志的框架,它们的设计模式是很相似的

区别就是:

配置的框架是从不同的数据源读取数据并且供给我们结构化的数据可以读取

日志框架是用统一的记录方式,让我们可以把日志记录到不同的地方去,输出到不同的地方去

接下来演示一下关于日志的作用域的部分

日志作用域几个常用场景:

  1. 一个事务包含多条操作时:比如说在一个事务里面去操作的时候,会需要记录多条日志,需要把多条日志串联在一起,而不是记录成一行
  2. 复杂流程的日志关联时:比如说工作流流程里面去进入这个日志
  3. 调用链追踪与请求处理过程对应时:如果在调用链追踪过程中记录了多条日志,希望把日志串联在一起的时候,作用域就发挥了作用

主程序

namespace LoggingScopeDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            var configBuilder = new ConfigurationBuilder();
            configBuilder.AddCommandLine(args);
            configBuilder.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
            var config = configBuilder.Build();
            IServiceCollection serviceCollection = new ServiceCollection();
            serviceCollection.AddSingleton(p => config); //用工厂模式将配置对象注册到容器管理
            serviceCollection.AddLogging(builder =>
            {
                builder.AddConfiguration(config.GetSection("Logging"));
                builder.AddConsole();
                builder.AddDebug();
            });

            IServiceProvider service = serviceCollection.BuildServiceProvider();

            var logger = service.GetService<ILogger<Program>>();

            // 相当于记录了一条上下文串联的日志
            using (logger.BeginScope("ScopeId:{scopeId}", Guid.NewGuid()))
            {
                logger.LogInformation("这是Info");
                logger.LogError("这是Error");
                logger.LogTrace("这是Trace");
            }
        }
    }
}

配置文件

{
  "Logging": {
    "LogLevel": {
      "Default": "Debug",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    },
    "Console": {
      "IncludeScopes": false,
      "LogLevel": {
        "Default": "Information",
        "LoggingScopeDemo.Program": "Trace",
        "alogger": "Trace"
      }
    }
  }
}

启动程序,输出如下:

info: LoggingScopeDemo.Program[0]
      这是Info
fail: LoggingScopeDemo.Program[0]
      这是Error
trce: LoggingScopeDemo.Program[0]
      这是Trace

和之前一样的输出,接着修改配置文件

"IncludeScopes": true,

启动程序,输出如下:

info: LoggingScopeDemo.Program[0]
      => ScopeId:b8ef7682-6c6d-4f74-83c8-b9fd4613c623
      这是Info
fail: LoggingScopeDemo.Program[0]
      => ScopeId:b8ef7682-6c6d-4f74-83c8-b9fd4613c623
      这是Error
trce: LoggingScopeDemo.Program[0]
      => ScopeId:b8ef7682-6c6d-4f74-83c8-b9fd4613c623
      这是Trace

可以看到,日志里面有 scope,并且三条日志都包含了相同的 ScopeId,这个是由我们决定 Scope 的内容是什么,一般推荐使用一个唯一标识,比如 HTTP 请求的 id,或者是 session 的 id,或者是事务的 id

接着修改为循环

// 只要输入不是 Esc 就循环执行
while (Console.ReadKey().Key != ConsoleKey.Escape)
{
    // 相当于记录了一条上下文串联的日志
    using (logger.BeginScope("ScopeId:{scopeId}", Guid.NewGuid()))
    {
        logger.LogInformation("这是Info");
        logger.LogError("这是Error");
        logger.LogTrace("这是Trace");
    }
    Console.WriteLine("============分割线=============");
}
Console.ReadKey();

启动程序,输出如下:

info: LoggingScopeDemo.Program[0]
      => ScopeId:cc25dd86-d3fe-41e8-b607-61912c65bde7
      这是Info
============分割线=============
fail: LoggingScopeDemo.Program[0]
      => ScopeId:cc25dd86-d3fe-41e8-b607-61912c65bde7
      这是Error
trce: LoggingScopeDemo.Program[0]
      => ScopeId:cc25dd86-d3fe-41e8-b607-61912c65bde7
      这是Trace

这里可以看到分割线有点错乱,这是因为 Console 的提供程序实际上内部是用异步的方式在记录,那也就是这里遇到并发的问题

调整一下代码,让主线程休息一下

System.Threading.Thread.Sleep(100);
Console.WriteLine("============分割线=============");

这样子启动之后顺序就正确了

在程序启动的情况下,修改 Debug 目录下的配置文件

"IncludeScopes": false,

修改保存后在控制台输入回车,可以看到配置生效了,意味着可以使用配置热更新能力来动态修改配置的输出,调整配置输出的级别

比如将

"LoggingScopeDemo.Program": "Trace",

修改为

"LoggingScopeDemo.Program": "Error",

修改保存后在控制台输入回车,只会输出 Error 级别

这是在控制台里面的效果,接下来看一下在一个 ASP.NET Core Web 应用下面的日志是什么样子。

这是一个默认的工程,仅仅在应用程序里面加了两行代码

[HttpGet]
public async Task<IEnumerable<WeatherForecast>> Get()
{
    _logger.LogInformation("开始Get了");

    _logger.LogInformation("Get睡醒了");

    var rng = new Random();
    return Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
        Date = DateTime.Now.AddDays(index),
        TemperatureC = rng.Next(-20, 55),
        Summary = Summaries[rng.Next(Summaries.Length)]
    })
    .ToArray();
}

日志级别

"Console": {
      "IncludeScopes": true
    }

启动程序,输出如下:

info: LoggingDemo.Controllers.WeatherForecastController[0]
      => RequestPath:/weatherforecast RequestId:0HLU2MTQ99HO2:00000001, SpanId:|7bb9cb12-4a0fe499cae27707., TraceId:7bb9cb12-4a0fe499cae27707, ParentId: => LoggingDemo.Controllers.WeatherForecastController.Get (LoggingDemo)
      开始Get了
info: LoggingDemo.Controllers.WeatherForecastController[0]
      => RequestPath:/weatherforecast RequestId:0HLU2MTQ99HO2:00000001, SpanId:|7bb9cb12-4a0fe499cae27707., TraceId:7bb9cb12-4a0fe499cae27707, ParentId: => LoggingDemo.Controllers.WeatherForecastController.Get (LoggingDemo)
      Get睡醒了

可以看到,记录的 开始Get了 以及 Get睡醒了,都包含了 RequestPath,RequestId,SpanId,TraceId 这些信息,这些信息是当前请求的上下文

也就意味着可以在记录日志的时候,用请求上下文把日志串联起来,多个请求的日志可以区分开来,无论记录了多条还是单条

也就意味着可以在事务处理的过程中,复杂的流程的过程中,或者调用链的处理过程中,当然还有其他的场景任意的需要将多条日志串联起来的场景,都可以用作用域来实现这个能力。

20 | 结构化日志组件Serilog:记录对查询分析友好的日志

之前讲解的日志框架,记录的日志都是文本,而且是非结构化的,这样一串串文本实际上不利于我们去做分析

结构化的日志它的好处就显而易见,它可以让我们更易于去检索,更易于与现有的分析系统进行结合

结构化日志的主要场景:

  1. 实现日志告警
  2. 实现上下文的关联:可以在日志系统里面对一段业务逻辑输出的日志进行分析
  3. 实现与追踪系统集成:在调用链的系统里面看到有问题的情况下,可以追踪到调用链过程中间的所有的日志信息

这里创建的依然是一个默认的 ASP.NET Core 的工程

引用包:Serilog.AspNetCore

这个包实际上依赖了 Serilog 很多的内置的包

比如核心的 Serilog (2.8.0)

配置 Serilog.Settings.Configuration (3.1.0)

Console 的输出 Serilog.Sinks.Console (3.1.1)

Debug 的输出 Serilog.Sinks.Debug (1.0.1)

File 的输出 Serilog.Sinks.File (4.0.0)

我们在 Program 这里提前读取一下配置,然后传递给 Serilog 的初始化过程,这里我们把 Main 函数进行了稍微的改造,以让 Serilog 可以接替整个默认的日志记录框架

namespace LoggingSerilogDemo
{
    public class Program
    {
        // 读取配置
        public static IConfiguration Configuration { get; } = new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
            .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json", optional: true)
            .AddEnvironmentVariables()
            .Build();

        public static int Main(string[] args)
        {
            // 将配置传递给 Serilog 的初始化过程
            Log.Logger = new LoggerConfiguration().ReadFrom.Configuration(Configuration)
            .MinimumLevel.Debug()
            .Enrich.FromLogContext()
            .WriteTo.Console(new RenderedCompactJsonFormatter())
            .WriteTo.File(formatter: new CompactJsonFormatter(), "logs\\myapp.txt", rollingInterval: RollingInterval.Day)
            .CreateLogger();
            try
            {
                Log.Information("Starting web host");
                CreateHostBuilder(args).Build().Run();
                return 0;
            }
            catch (Exception ex)
            {
                Log.Fatal(ex, "Host terminated unexpectedly");
                return 1;
            }
            finally
            {
                Log.CloseAndFlush();
            }
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                })
                .UseSerilog(dispose: true);// dispose 设置为 true,它就会在退出时帮我们释放我们的日志对象
    }
}

启动程序,输出如下:

{"@t":"2020-03-08T15:47:40.2569100Z","@m":"Starting web host","@i":"4872fd06"}
{"@t":"2020-03-08T15:47:44.1978171Z","@m":"Get 随机创建数据","@i":"6936e72c","SourceContext":"LoggingSerilogDemo.Controllers.WeatherForecastController","ActionId":"8d8ebb60-2211-4acb-bc91-a079be45a689","ActionName":"LoggingSerilogDemo.Controllers.WeatherForecastController.Get (LoggingSerilogDemo)","RequestId":"0HLU3F052RUUN:00000001","RequestPath":"/weatherforecast","SpanId":"|99917a4d-4ccf47636d09b066.","TraceId":"99917a4d-4ccf47636d09b066","ParentId":""}

可以看到每一行都是一个 json,也就是将日志输出为 json 格式,这就意味着可以在整个日志系统里面以 json 的格式去检索数据,比如 SourceContext 就是 Logger 的 name

它还记录了请求上下文,并且输出了 RequestId,SpanId,TraceId,ParentId

RequestId 与 SpanId 的作用就是与追踪系统可以结合

我们记录的日志的方式实际上是与之前是一样的,Controller 里面还是注入了 ILogger,依然使用 ILogger 来记录日志

namespace LoggingSerilogDemo.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {
        private static readonly string[] Summaries = new[]
        {
            "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
        };

        private readonly ILogger<WeatherForecastController> _logger;

        public WeatherForecastController(ILogger<WeatherForecastController> logger)
        {
            _logger = logger;
        }

        [HttpGet]
        public IEnumerable<WeatherForecast> Get()
        {

            _logger.LogInformation("Get 随机创建数据");
            var rng = new Random();
            return Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateTime.Now.AddDays(index),
                TemperatureC = rng.Next(-20, 55),
                Summary = Summaries[rng.Next(Summaries.Length)]
            })
            .ToArray();

        }
    }
}

也就是说可以通过简单的配置和几行代码的设置就可以替换官方提供的日志框架,让我们具备记录结构化日志的能力

我们刚才看到日志输出到 Console,同时输出到文件,可以看到 logs 目录已经产生了一个 myapp20200308.txt 文件

{"@t":"2020-03-08T15:47:40.2569100Z","@mt":"Starting web host"}
{"@t":"2020-03-08T15:47:44.1978171Z","@mt":"Get 随机创建数据","SourceContext":"LoggingSerilogDemo.Controllers.WeatherForecastController","ActionId":"8d8ebb60-2211-4acb-bc91-a079be45a689","ActionName":"LoggingSerilogDemo.Controllers.WeatherForecastController.Get (LoggingSerilogDemo)","RequestId":"0HLU3F052RUUN:00000001","RequestPath":"/weatherforecast","SpanId":"|99917a4d-4ccf47636d09b066.","TraceId":"99917a4d-4ccf47636d09b066","ParentId":""}

这个文件可以看到每一行是一条日志,每一条日志都是一个 json 对象,包括刚才我们记录的 Get 随机创建数据,已经输出出来了

我们可以调整日志级别,打开配置文件

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Error",
        "System": "Information"
      }
    }
  },
  "AllowedHosts": "*"
}

Serilog 需要单独配置,它与之前的配置方式略有不同,它需要配置最小的日志输出级别,默认是 Information

Override 是重载上面 Logging 定义的日志级别

设置 Microsoft 为 Error 之后会把 Microsoft 默认的日志输出级别过滤掉

也意味着整个的配置和输出的方式与之前是级别类似的,我们可以把日志输出到 Console,也可以把日志输出到文件,当然实际上 Serilog 还提供了很多的这种输出的提供程序,还可以与 EFK,ELK 这种日志的套件进行集成,把日志输出到分析系统里面

21 | 中间件:掌控请求处理过程的关键

这一节主要解释如何通过中间件来管理请求处理过程

中间件工作原理

next 表示后面有一个委托,每一层每一层套下去可以在任意的中间件来决定在后面的中间件之前执行什么,或者说在所有中间件执行完之后执行什么

整个中间件的处理过程实际上有两个核心对象:

IApplicationBuilder

RequestDelegate:处理整个请求的委托

IApplicationBuilder

namespace Microsoft.AspNetCore.Builder
{
  public interface IApplicationBuilder
  {
    IServiceProvider ApplicationServices { get; set; }

    IDictionary<string, object> Properties { get; }

    IFeatureCollection ServerFeatures { get; }

    // 最终它会 Build 返回一个委托
    // 这个委托就是把所有的中间件串起来之后,合并成一个委托方法
    // 这个方法的入参可以看下方委托的定义
    RequestDelegate Build();

    IApplicationBuilder New();

    // 它可以让我们去注册我们的中间件,把委托注册进去,每一个委托的入参也是一个委托
    // 这也就意味着可以把这些委托注册成一个链,就像上面的图显示的那样
    IApplicationBuilder Use(Func<RequestDelegate, RequestDelegate> middleware);
  }
}

委托的定义

namespace Microsoft.AspNetCore.Http
{
  // 委托的入参是 HttpContext,所有的注册中间件的委托实际上都是对 HttpContext 的处理
  public delegate Task RequestDelegate(HttpContext context);
}

接着让我们看一下应用程序里面是怎么让它工作的?

之前讲过 Configure 方法是用来注册中间件的

app.UseMyMiddleware();

app.UseHttpsRedirection();

app.UseRouting();

app.UseAuthorization();

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllers();
});

根据刚才流程图表示的话,实际上中间件的执行顺序是跟注册顺序有关系的,最早注册的中间件它的权力是最大的,它可以越早的发生作用

中间件的注册实际上不仅仅是有上面展示的已有内置的中间件,实际上还可以用注册委托的方法来注册我们的逻辑

app.Use(async (context, next) =>
{
    await context.Response.WriteAsync("Hello");
});

因为这个中间件注册最早,而且不对后续的 next 做任何操作,所以启动之后无论输入什么都会输出 Hello

如果需要后续的中间件执行,那就意味着需要调用 next,可以在中间件执行之后再次 Hello 一次

app.Use(async (context, next) =>
{
    await context.Response.WriteAsync("Hello");
    await next();
    await context.Response.WriteAsync("Hello2");
});

启动程序报错:

System.InvalidOperationException: Headers are read-only, response has already started.

意味着一旦应用程序已经对 Response 输出内容,我们就不能对 header 进行操作了,但是可以在 Response 后续继续写出信息

app.Use(async (context, next) =>
{
    //await context.Response.WriteAsync("Hello");
    await next();
    await context.Response.WriteAsync("Hello2");
});

实际上除了 Use 这种方式的话,还有 Map 的方式

app.Map("/abc", abcBuilder =>
{
    abcBuilder.Use(async (context, next) =>
    {
        //await context.Response.WriteAsync("Hello");
        await next();
        await context.Response.WriteAsync("Hello2");
    });
});

启动程序不会直接看到 Hello 输出,如果把地址改为 localhost:5001/abc,我们的输出就会变成 Hello2

也就是说当我们需要对特定的路径进行指定中间件的时候可以这样做

如果在 Map 的时候逻辑复杂一点,不仅仅判断它的 URL 地址,而且要做特殊的判断的话,可以这么做把判断逻辑变成一个委托

我们要判断当我们的请求地址包含 abc 的时候,输出 new abc

app.MapWhen(context =>
{
    return context.Request.Query.Keys.Contains("abc");
}, builder =>
{
    builder.Run(async context =>
    {
        await context.Response.WriteAsync("new abc");
    });
});

启动程序,没有任何输出

当我们在默认启动地址后面输入 ?abc=1 的时候,可以看到输出了 new abc

这里用到了一个 Run 的方法,上一节用到的是 Use 方法

app.Map("/abc", abcBuilder =>
{
    abcBuilder.Use(async (context, next) =>
    {
        //await context.Response.WriteAsync("Hello");
        await next();
        await context.Response.WriteAsync("Hello2");
    });
});

Run 和 Use 的区别是什么呢?

Use 是指我们可以像注册一个完整的中间件一样,将 next 注入进来,我们可以去决定是否执行后续的中间件

Run 的含义就表示我们这里就是中间件执行的末端,也就不在执行后面的中间件了,在这里将返回请求

那我们如何像 UseRouting UseEndpoints 一样来设计我们自己的中间件呢?

这里定义好了一个 MyMiddleware

namespace MiddlewareDemo.Middlewares
{
    class MyMiddleware
    {
        RequestDelegate _next;
        ILogger _logger;
        public MyMiddleware(RequestDelegate next, ILogger<MyMiddleware> logger)
        {
            _next = next;
            _logger = logger;
        }

        public async Task InvokeAsync(HttpContext context)
        {
            using (_logger.BeginScope("TraceIdentifier:{TraceIdentifier}", context.TraceIdentifier))
            {
                _logger.LogDebug("开始执行");

                await _next(context);

                _logger.LogDebug("执行结束");
            }
        }
    }
}

定义中间件是用了一个约定的方式,中间件的类包含一个方法 Invoke 或者 InvokeAsync 这样一个方法,它的返回是一个 Task,入参是一个 HttpContext,实际上可以理解成与中间件的委托是一样的,只要我们的类包含这样一个方法,就可以把它作为一个中间件注册进去,并被框架识别到

这里还定义了一个 MyBuilderExtensions

namespace Microsoft.AspNetCore.Builder
{
    public static class MyBuilderExtensions
    {
        public static IApplicationBuilder UseMyMiddleware(this IApplicationBuilder app)
        {
            return app.UseMiddleware<MyMiddleware>();
        }
    }
}

把我们的中间件注册进去,这个方法就是 UseMyMiddleware

通过这样的定义,我们就可以使用自己的中间件

app.UseMyMiddleware();

启动程序,输出如下:

控制台输出

dbug: MiddlewareDemo.Middlewares.MyMiddleware[0]
      => RequestPath:/weatherforecast RequestId:0HLU50UEM3M9F:00000001, SpanId:|77f92fe8-4a6d800968327989., TraceId:77f92fe8-4a6d800968327989, ParentId: => TraceIdentifier:0HLU50UEM3M9F:00000001
      开始执行
dbug: MiddlewareDemo.Middlewares.MyMiddleware[0]
      => RequestPath:/weatherforecast RequestId:0HLU50UEM3M9F:00000001, SpanId:|77f92fe8-4a6d800968327989., TraceId:77f92fe8-4a6d800968327989, ParentId: => TraceIdentifier:0HLU50UEM3M9F:00000001
      执行结束

网页控制器输出

[{"date":"2020-03-11T23:30:55.3411696+08:00","temperatureC":20,"temperatureF":67,"summary":"Warm"},{"date":"2020-03-12T23:30:55.3417863+08:00","temperatureC":52,"temperatureF":125,"summary":"Bracing"},{"date":"2020-03-13T23:30:55.3417916+08:00","temperatureC":-3,"temperatureF":27,"summary":"Mild"},{"date":"2020-03-14T23:30:55.341792+08:00","temperatureC":35,"temperatureF":94,"summary":"Balmy"},{"date":"2020-03-15T23:30:55.3417923+08:00","temperatureC":37,"temperatureF":98,"summary":"Sweltering"}]Hello2

如果要实现一个断路器,就是不执行后续逻辑,注释掉一行

_logger.LogDebug("开始执行");

//await _next(context);

_logger.LogDebug("执行结束");

启动程序,页面不会输出任何内容,只会在控制台打印出中间件的执行过程,后续的控制器不会执行

这样就实现了一个断路器,也就意味着可以使用自己的中间件做请求的控制,而且时非常灵活的控制

在使用中间件的过程中,需要非常注意的是注册中间件的顺序,这些顺序就决定了中间件执行的时机,某些中间件会是断路器的作用,某些中间件会做一些请求内容的处理

还有一个比较关键的要点是指应用程序一旦开始向 Response write 的时候,后续的中间件就不能再去操作它的 header,这一点是需要注意的

可以通过 Context.Response.HasStarted 来判断是否已经开始向响应的 body 输出内容,一旦输出了内容,就不要再操作 header

22 | 异常处理中间件:区分真异常与逻辑异常

这一节我们来探讨一下错误处理的最佳实践

系统里面异常处理,ASP.NET Core 提供了四种方式

  1. 异常处理页
  2. 异常处理匿名委托方法
  3. IExceptionFilter
  4. ExceptionFilterAttribute

Startup 的 Configure 方法

if (env.IsDevelopment())
{
    // 开发环境下的异常处理页
    app.UseDeveloperExceptionPage();
}

控制器抛出异常

throw new Exception("报个错");

启动程序,可以看到一个错误页

这个错误页会输出我们当前请求的详细信息和错误的详细信息,这种页面是不适合给用户看到的,所以这样的错误页在生产环境是需要关闭的

以下是正常处理错误页的方式:

// 第一种方式就是定义错误页的方式
app.UseExceptionHandler("/error");

定义一个接口 IKnownException

namespace ExceptionDemo.Exceptions
{
    public interface IKnownException
    {
        public string Message { get; }

        public int ErrorCode { get; }

        public object[] ErrorData { get; }
    }
}

默认实现 KnownException

namespace ExceptionDemo.Exceptions
{
    public class KnownException : IKnownException
    {
        public string Message { get; private set; }

        public int ErrorCode { get; private set; }

        public object[] ErrorData { get; private set; }

        public readonly static IKnownException Unknown = new KnownException { Message = "未知错误", ErrorCode = 9999 };

        public static IKnownException FromKnownException(IKnownException exception)
        {
            return new KnownException { Message = exception.Message, ErrorCode = exception.ErrorCode, ErrorData = exception.ErrorData };
        }
    }
}

为什么需要定义这样一个类型呢?

因为通常情况下我们系统里面的异常和我们业务逻辑的异常是不同的,业务逻辑上面的判断异常,比如说输入的参数,订单的状态不符合条件,当前账户余额不足,这样子的信息我们有两种处理方式:

一种处理方式就是对不同的逻辑输出不同的业务对象

还有一种方式就是对于异常的这种业务逻辑,输出一个异常,用异常来承载逻辑的特殊分支,这个时候就需要识别出来哪些是业务的异常,哪些是不确定的未知的异常,比如说网络的请求出现了异常,MySql 的连接闪断了,Redis 的连接出现了异常

接着通过定义一个错误页来承载错误信息,比如我们的 ErrorController,它只有一个页面,它的作用就是输出错误信息

namespace ExceptionDemo.Controllers
{
    [AllowAnonymous]
    public class ErrorController : Controller
    {
        [Route("/error")]
        public IActionResult Index()
        {
            // 获取当前上下文里面报出的异常信息
            var exceptionHandlerPathFeature = HttpContext.Features.Get<IExceptionHandlerPathFeature>();

            var ex = exceptionHandlerPathFeature?.Error;

            // 特殊处理,尝试转换为 IKnownException
            var knownException = ex as IKnownException;
            // 对于未知异常,我们并不应该把错误异常完整地输出给客户端,而是应该定义一个特殊的信息 Unknown 传递给用户
            // Unknown 其实也是一个 IKnownException 的实现,它的 Message = "未知错误", ErrorCode = 9999
            // 也就是说我们在控制器 throw new Exception("报个错"); 就会看到错误信息
            if (knownException == null)
            {
                var logger = HttpContext.RequestServices.GetService<ILogger<MyExceptionFilterAttribute>>();
                // 我们看到的信息是未知错误,但是在我们的日志系统里面,我们还是记录的原有的异常信息
                logger.LogError(ex, ex.Message);
                knownException = KnownException.Unknown;
            }
            else// 当识别到异常是已知的业务异常时,输出已知的异常,包括异常消息,错误状态码和错误信息,就是在 IKnownException 中的定义
            {
                knownException = KnownException.FromKnownException(knownException);
            }
            return View(knownException);
        }
    }
}

View

@model ExceptionDemo.Exceptions.IKnownException
@{
    ViewData["Title"] = "Index";
}

<h1>错误信息</h1>

<div>Message:<label>@Model.Message</label></div>
<div>ErrorCode<label>@Model.ErrorCode</label></div>

启动程序之后可以看到自定义的错误页已经成功渲染出来了

这就是第一种处理错误的方式

接下来介绍使用代理方法的方式,也就是说把 ErrorController 整段逻辑直接定义在注册的地方,使用一个匿名委托来处理,这里的逻辑与之前的逻辑是相同的

app.UseExceptionHandler(errApp =>
{
    errApp.Run(async context =>
    {
        // 在 Features 里面获取异常
        var exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>();
        // 识别异常是否为 IKnownException
        IKnownException knownException = exceptionHandlerPathFeature.Error as IKnownException;
        if (knownException == null)
        {
            // 如果不是则记录并且把错误的响应码响应成 Http 500
            var logger = context.RequestServices.GetService<ILogger<MyExceptionFilterAttribute>>();
            logger.LogError(exceptionHandlerPathFeature.Error, exceptionHandlerPathFeature.Error.Message);
            knownException = KnownException.Unknown;
            context.Response.StatusCode = StatusCodes.Status500InternalServerError;
        }
        else
        {
            // 如果捕获到的是一个业务逻辑的异常,Http 响应码应该给是 200
            knownException = KnownException.FromKnownException(knownException);
            context.Response.StatusCode = StatusCodes.Status200OK;
        }
        // 然后再把响应信息通过 json 的方式输出出去
        var jsonOptions = context.RequestServices.GetService<IOptions<JsonOptions>>();
        context.Response.ContentType = "application/json; charset=utf-8";
        await context.Response.WriteAsync(System.Text.Json.JsonSerializer.Serialize(knownException, jsonOptions.Value.JsonSerializerOptions));
    });
});

为什么对于未知的异常要输出 Http 500,而对于业务逻辑的异常,建议输出 Http 200?

因为监控系统实际上会对 Http 的响应码进行识别,当监控系统识别到 Http 响应是 500 的比例比较高的情况下,会认为系统的可用性有问题,这个时候告警系统就会发出警告

对于已知的业务逻辑的这种正常的识别的话,用正常的 Http 200 来处理是一个正常的行为,这样就可以让监控系统更好的工作,正确的识别出系统的一些未知的错误信息,错误的告警,让告警系统更加的灵敏,也避免了业务逻辑的异常干扰告警系统

接下来看一下第三种,通过异常过滤器的方式

这种方式实际上是作用在 MVC 的整个框架的体系下面的,它并不是在中间件的最早期发生作用的,它是在 MVC 的整个生命周期里面发生作用,也就是说它只能工作在 MVC Web API 的请求周期里面

首先自定义一个 MyExceptionFilter

namespace ExceptionDemo.Exceptions
{
    public class MyExceptionFilter : IExceptionFilter
    {
        public void OnException(ExceptionContext context)
        {
            IKnownException knownException = context.Exception as IKnownException;
            if (knownException == null)
            {
                var logger = context.HttpContext.RequestServices.GetService<ILogger<MyExceptionFilterAttribute>>();
                logger.LogError(context.Exception, context.Exception.Message);
                knownException = KnownException.Unknown;
                context.HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
            }
            else
            {
                knownException = KnownException.FromKnownException(knownException);
                context.HttpContext.Response.StatusCode = StatusCodes.Status200OK;
            }
            context.Result = new JsonResult(knownException)
            {
                ContentType = "application/json; charset=utf-8"
            };
        }
    }
}

处理逻辑与之前的相同

接着注册 Filters

services.AddMvc(mvcOptions =>
{
    mvcOptions.Filters.Add<MyExceptionFilter>();
}).AddJsonOptions(jsonoptions =>
{
    jsonoptions.JsonSerializerOptions.Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping;
});

启动程序,输出如下:

{"message":"未知错误","errorCode":9999,"errorData":null}

输出与之前的一致,因为这是在 Controller 里面输出了错误

如果在 MVC 的中间件之前输出错误的话,它是没办法处理的

这个场景一般情况下是指需要对 Controller 进行特殊的异常处理,而对于中间件整体来讲的话,又要用另一种特殊的逻辑来处理的时候,可以用 ExceptionFilter 的方式处理

这种方式还可以通过 Attribute 的方式

自定义一个 MyExceptionFilterAttribute

namespace ExceptionDemo.Exceptions
{
    public class MyExceptionFilterAttribute : ExceptionFilterAttribute
    {
        public override void OnException(ExceptionContext context)
        {
            IKnownException knownException = context.Exception as IKnownException;
            if (knownException == null)
            {
                var logger = context.HttpContext.RequestServices.GetService<ILogger<MyExceptionFilterAttribute>>();
                logger.LogError(context.Exception, context.Exception.Message);
                knownException = KnownException.Unknown;
                context.HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
            }
            else
            {
                knownException = KnownException.FromKnownException(knownException);
                context.HttpContext.Response.StatusCode = StatusCodes.Status200OK;
            }
            context.Result = new JsonResult(knownException)
            {
                ContentType = "application/json; charset=utf-8"
            };
        }
    }
}

在 Controller 上面标注 MyExceptionFilter

[MyExceptionFilter]
public class WeatherForecastController : ControllerBase

启动运行之后效果相同

这两种方式的效果是对等的,区别在于说可以更细粒度的对异常处理进行控制,可以指定部分的 Controller 或者 Exception,来决定我们的异常处理,也可以在全局注册 ExceptionFilter

当然因为 ExceptionFilterAttribute 也实现了 IExceptionFilter,所以它也可以注册到全局,也可以把它当作全局异常处理的过滤器来使用,Controller 上面也就不需要标记了

注册 Filters

services.AddMvc(mvcOptions =>
{
    //mvcOptions.Filters.Add<MyExceptionFilter>();
    mvcOptions.Filters.Add<MyExceptionFilterAttribute>();
}).AddJsonOptions(jsonoptions =>
{
    jsonoptions.JsonSerializerOptions.Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping;
});

在 Controller 上面取消标注 MyExceptionFilter

//[MyExceptionFilter]
public class WeatherForecastController : ControllerBase

启动程序,输出结果一致

这个场景对于我们定义一些 API,然后对 API 进行定义我们的异常处理的约定是很有帮助的

总结一下

  1. 首先我们需要定义特定的异常类或者接口,我们可以定义抽象类,也可以用接口的方式,例子中是通过接口的方式表示业务逻辑的异常

  2. 对于业务逻辑的异常,实际上需要定义全局的错误码

  3. 对于未知的异常,应该输出特定的输出信息和错误码,然后记录完整的日志,我们不应该把系统内部的一些比如说异常堆栈这些信息输出给用户

  4. 对于已知的业务逻辑的异常,用 Http 200 的方式,对于未知的异常,用 Http 500 的方式,这样可以让监控系统更好的工作

  5. 另外一个建议就是尽量记录所有的异常的详细信息,以供后续对日志进行分析,也供监控系统做一些特定的监控警告

23 | 静态文件中间件:前后端分离开发合并部署骚操作

我们先来看一下静态文件中间件有哪些能力

  1. 支持指定相对路径

  2. 支持目录的浏览

  3. 支持设置默认文档

  4. 支持多目录映射

首先使用静态文件中间件

// 通过这一行代码就可以访问到静态配置文件
app.UseStaticFiles();

这样就可以将 wwwroot 目录映射出来,这是一个默认的配置,也就是说,当我们需要使用中间件静态文件输出的时候,首选就是应该把静态文件放在 wwwroot 下面

我们在这个目录下面放了几个文件:index.html,app.js,a 目录下面也有一个 index.html 和一个 a.js,这两个 index.html 的内容是不一样的

a/index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>/a/index</title>
    <script src="a.js"></script>
</head>
<body>
    <h1>这是/a/index</h1>
</body>
</html>

index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>静态首页</title>
    <script src="app.js"></script>
</head>
<body>
    <h1>这是静态首页</h1>
</body>
</html>

启动程序,由于我们没有指定相对路径,所以说我们的根目录是/,就代表访问到了 wwwroot,输入 index.html,可以看到 javaScript 执行

https://localhost:5001/index.html

如果把地址换一下,会得到另一个页面

https://localhost:5001/a/index.html

如果默认情况下都是访问 index.html,怎么做呢?

app.UseDefaultFiles();

这个方法还有一个重载

namespace Microsoft.AspNetCore.Builder
{
  public static class DefaultFilesExtensions
  {
    public static IApplicationBuilder UseDefaultFiles(
      this IApplicationBuilder app);

    public static IApplicationBuilder UseDefaultFiles(
      this IApplicationBuilder app,
      DefaultFilesOptions options);

    public static IApplicationBuilder UseDefaultFiles(
      this IApplicationBuilder app,
      string requestPath);
  }
}

DefaultFilesOptions

namespace Microsoft.AspNetCore.Builder
{
  public class DefaultFilesOptions : SharedOptionsBase
  {
    public DefaultFilesOptions();

    public DefaultFilesOptions(SharedOptions sharedOptions);

    public IList<string> DefaultFileNames { get; set; }
  }
}

可以设置 DefaultFileNames,默认 index.html 是在里面的,所以这里可以不输入任何参数

启动程序,访问根目录的时候,应该输出首页的 index

https://localhost:5001/

访问 a 目录会输出 a 的 index

还有一种场景就是我们需要浏览我们的目录

在 ConfigureServices 注册 AddDirectoryBrowser

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddDirectoryBrowser();
}

然后在 Configure 里面启用

app.UseDirectoryBrowser();

启动程序,访问根目录

可以看到浏览器上面显示了目录的文件,当我们点击其中的一个文件的时候,实际上是访问这个文件,我们还可以浏览它的子目录

这是我们在使用 wwwroot 的情况下,实际上我们还可以使用其他的目录,把其他的目录也注册进来

我们在应用程序的 file 目录下面另外添加了一个 page.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>page</title>
</head>
<body>
    <h1>page</h1>
</body>
</html>

我们也期望可以访问到这个文件,我们就可以这样去做

app.UseStaticFiles();

app.UseStaticFiles(new StaticFileOptions
{
    // 注入我们的物理文件提供程序,把我们的当前目录加 file,就是 file 目录,赋值给我们的提供程序
    // 这样子的效果就是我们的 wwwroot 会优先去寻找我们的文件,如果没有的话就会执行下一个中间件
    // 然后在这个中间件里面再找我们的文件是否存在,如果没有的话,它会去执行后面的路由和 MVC 的 Web API 的 Controller
    FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "file"))
});

因为这里我们入参并没有设置相对路径,也就是说我们根目录对应的也是 file 这个目录,我们这里可以输出 page.html

https://localhost:5001/page.html

我们的 page.html 就可以访问到了

还有一种情况是我们希望把我们的静态目录映射为某一个特定的 URL 地址目录下面,我们可以这样去做

app.UseStaticFiles();

app.UseStaticFiles(new StaticFileOptions
{
    // 我们希望把我们的静态目录映射为某一个特定的 URL 地址目录下面
    RequestPath = "/files",
    // 注入我们的物理文件提供程序,把我们的当前目录加 file,就是 file 目录,赋值给我们的提供程序
    // 这样子的效果就是我们的 wwwroot 会优先去寻找我们的文件,如果没有的话就会执行下一个中间件
    // 然后在这个中间件里面再找我们的文件是否存在,如果没有的话,它会去执行后面的路由和 MVC 的 Web API 的 Controller
    FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "file"))
});

访问以下路径就可以看到我们的静态文件页面

https://localhost:5001/files/page.html

也就是说我们可以把任意的文件目录映射为任意的 URL 地址

这里还有一个比较特殊的用法

一般情况下,我们前后端分离的架构,前端会编译成一个 index.html 文件和若干个 CSS 文件和 JavaScript 和图片文件

CSS 文件和 JavaScript 和图片文件一般会部署在 CDN 服务器上,这个 index 文件就需要我们建立一个宿主来 host 它

并且前端的一般路由的话,我们现在都会用 HTML5 的 History 的路由模式

这个时候前端就会对后端有一个特殊的诉求,除了 API 的请求以外,其他的请求的响应都应该是 index.html 这个静态文件

要达到这个目的,我们可以借助我们的中间件的执行原理来实现

首先假设我们的 index.html 就是我们前端编译好的静态文件,我们放置在 wwwroot 下面,前端编译的任何文件都放在 wwwroot 下面

然后我们再做一件事件就是 UseStaticFiles,我们把目录访问整个去掉

//services.AddDirectoryBrowser();

首先映射静态文件

app.UseStaticFiles();

静态文件映射出来之后实际上还有一个诉求,就是当我们访问其他特殊的页面地址的时候,比如说 /order/get 这样子的页面的时候,也应该响应我们的静态文件

这个时候我们可以把这样一段逻辑加入进来

// 判断我们当前的请求是否满足条件
app.MapWhen(context =>
{
    // 如果我们的请求不是以 API 开头的请求
    return !context.Request.Path.Value.StartsWith("/api");
}, appBuilder =>
{
    // 如果满足条件,我就走我下面这一段中间件的逻辑
    var option = new RewriteOptions();
    // 重写为 /index.html
    option.AddRewrite(".*", "/index.html", true);
    appBuilder.UseRewriter(option);

    // 重写完之后再使用我们的静态文件中间件
    appBuilder.UseStaticFiles();
});

这样子可以达到一个效果就是我们访问任意的非 API 目录的时候,我们都可以得到 index.html

启动程序

https://localhost:5001/api/weatherforecast

可以正常访问

API 的请求我们都是让它通过的,不是 API 的时候才会拦截

这个时候如果访问

https://localhost:5001/order

会发现获得的是静态文件

如果说静态文件是存在的,这个时候实际上会响应原有的静态文件,比如说访问

https://localhost:5001/a/index.html

这样子就可以发现我们能让静态文件的目录正常工作,并且能将其他的我们需要的地址都重定向到 index.html

当然这里还有另外一种写法,就是不用 UseRewriter 的方式,而是用 Run 的方式,也是就用断路器的方式

// 判断我们当前的请求是否满足条件
app.MapWhen(context =>
{
    // 如果我们的请求不是以 API 开头的请求
    return !context.Request.Path.Value.StartsWith("/api");
}, appBuilder =>
{
    //// 如果满足条件,我就走我下面这一段中间件的逻辑
    //var option = new RewriteOptions();
    //// 重写为 /index.html
    //option.AddRewrite(".*", "/index.html", true);
    //appBuilder.UseRewriter(option);

    //// 重写完之后再使用我们的静态文件中间件
    //appBuilder.UseStaticFiles();

    appBuilder.Run(async c =>
    {
        // 读取静态文件,并且输出给我们的 Response
        var file = env.WebRootFileProvider.GetFileInfo("index.html");
        c.Response.ContentType = "text/html";
        using (var fileStream = new FileStream(file.PhysicalPath, FileMode.Open, FileAccess.Read))
        {
            await StreamCopyOperation.CopyToAsync(fileStream, c.Response.Body, null, BufferSize, c.RequestAborted);
        }
    });
});

这种写法有一个缺点就是,没办法像静态文件中间件那样,输出正确的 Http 请求头

对比一下两种方式的输出的请求头的不同

启动程序,访问

https://localhost:5001/order

打开调试工具,可以看到对 order 的我们的响应头就只有 4 个

其他的静态文件,响应头会多出来 etag,data,last-modified

这些的话就是我们关于 HTTP 缓存可以用到的头,所以说我们还是推荐使用上面这种方式,静态中间件的方式,而不是自己输出文件的方式

24 | 文件提供程序:让你可以将文件放在任何地方

文件提供程序核心类型:

  1. IFileProvider

  2. IFileInfo

  3. IDirectoryContents

IFileProvider 是访问各种各样文件提供程序的接口

通过这样子抽象的定义,让我们与具体的抽象文件的读取的代码进行了隔离

这样的好处是我们可以从不同的地方去读取文件,不仅仅是我们的物理文件,也可以是嵌入式文件,甚至可以说是云端上面的其他 API 提供的文件

内置的提供程序有三种:

(1)PhysicalFileProvider:物理文件的提供程序

(2)EmbeddedFileProvider:嵌入式的提供程序

(3)CompositeFileProvider:组合文件的提供程序

组合文件的提供程序是指当我们有多种文件数据来源的时候,可以将这些源合并为一个目录一样,让我们像在使用同一个目录一样使用我们的文件系统

首先我们可以看一下 IFileProvider 的定义

namespace Microsoft.Extensions.FileProviders
{
  public interface IFileProvider
  {
    // 输入是一个相对的路径
    IFileInfo GetFileInfo(string subpath);

    // 获取指定目录下的目录信息
    IDirectoryContents GetDirectoryContents(string subpath);

    IChangeToken Watch(string filter);
  }
}

IDirectoryContents

namespace Microsoft.Extensions.FileProviders
{
  public interface IDirectoryContents : IEnumerable<IFileInfo>, IEnumerable
  {
    bool Exists { get; }
  }
}

这个接口实际上就是 IFileInfo 的一个集合,还有一个属性是否存在,表示当前目录是否存在,如果存在的话,我们可以从它内部枚举到我们的所有文件

IFileInfo

namespace Microsoft.Extensions.FileProviders
{
  public interface IFileInfo
  {
    bool Exists { get; }

    long Length { get; }

    string PhysicalPath { get; }

    string Name { get; }

    DateTimeOffset LastModified { get; }

    bool IsDirectory { get; }

    Stream CreateReadStream();
  }
}

IFileInfo 有几个属性:是否存在,文件长度,物理地址,文件名,最后修改时间,是否是一个目录(有可能获取到的文件并不是一个真实的文件,它可能是一个目录,那也就是用 IFileInfo 来代替的),读取文件流

接下来通过代码看一下

// 定义一个物理文件的提供程序,把我们当前应用程序的根目录映射出来
IFileProvider provider1 = new PhysicalFileProvider(AppDomain.CurrentDomain.BaseDirectory);

// 获取到这个目录下面的所有内容
var contents = provider1.GetDirectoryContents("/");

foreach (var item in contents)
{
    // 打印文件名
    Console.WriteLine(item.Name);
}

启动程序可以看到控制台输出了编译目录下面的文件

FileProviderDemo.deps.json
FileProviderDemo.dll
FileProviderDemo.exe
FileProviderDemo.pdb
FileProviderDemo.runtimeconfig.dev.json
FileProviderDemo.runtimeconfig.json
Microsoft.Extensions.FileProviders.Abstractions.dll
Microsoft.Extensions.FileProviders.Composite.dll
Microsoft.Extensions.FileProviders.Embedded.dll
Microsoft.Extensions.FileProviders.Physical.dll
Microsoft.Extensions.FileSystemGlobbing.dll
Microsoft.Extensions.Primitives.dll

如果我们要读文件流的话,可以通过 CreateReadStream

foreach (var item in contents)
{
    // 读取文件流
    var stream = item.CreateReadStream();
    // 打印文件名
    Console.WriteLine(item.Name);
}

接下来看一下嵌入式的提供程序,它是指编译时把文件嵌入到程序集内部,就像源文件一样,但是与通常的资源文件不同的是,我们可以像读取目录一样读取我们的文件

IFileProvider provider2 = new EmbeddedFileProvider(typeof(Program).Assembly);

这里我们创建了一个 emb.html

<!DOCTYPE html>

<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="utf-8" />
    <title></title>
</head>
<body>

</body>
</html>

然后把它的属性设置为嵌入的资源,而不是内容

这样的设置的话,我们可以看一下对工程文件有什么影响

编辑项目可以看到我们把这个文件定义为嵌入式资源

  <ItemGroup>
    <EmbeddedResource Include="emb.html" />
  </ItemGroup>

再次读取这个文件

IFileProvider provider2 = new EmbeddedFileProvider(typeof(Program).Assembly);
var html = provider2.GetFileInfo("emb.html");

断点调试查看文件信息

可以看到 html 这个文件是否存在,是否目录,最后修改时间,长度,名字,物理路径

这就是可以通过嵌入式的文件提供程序来读取编译时构建到程序集里面的资源

最后一个就是组合文件提供程序,它的作用就是将各种提供程序组合成一个目录,让我们可以访问它

// 传入前面的两种文件提供程序到组合提供程序里面,它可以传入多个文件提供程序
IFileProvider provider = new CompositeFileProvider(provider1, provider2);

var contents = provider.GetDirectoryContents("/");

foreach (var item in contents)
{
    Console.WriteLine(item.Name);
}

启动程序可以看到,不仅输出了程序集,编译构建出来的文件,同时还输出资源文件 emb.html

FileProviderDemo.deps.json
FileProviderDemo.dll
FileProviderDemo.exe
FileProviderDemo.pdb
FileProviderDemo.runtimeconfig.dev.json
FileProviderDemo.runtimeconfig.json
Microsoft.Extensions.FileProviders.Abstractions.dll
Microsoft.Extensions.FileProviders.Composite.dll
Microsoft.Extensions.FileProviders.Embedded.dll
Microsoft.Extensions.FileProviders.Physical.dll
Microsoft.Extensions.FileSystemGlobbing.dll
Microsoft.Extensions.Primitives.dll
emb.html

这就说明可以像在访问同一个目录一样,访问不同的文件提供程序目录,这就意味着实际上是可以通过实现简单的 IFileProvider 和 IFileInfo 就可以实现自己的文件提供程序

这些文件提供程序举一个场景比如说可以通过 OSS 的这种远程存储的方式将文件读取出来并且提供给应用程序,但是应用程序并不需要做特殊的配置,只需要把 OSS 提供的程序注入到系统里面,只需要按照 IFileProvider 提供的接口来读取文件,就可以做到像在读取本地文件一样,也就是说可以借助这套框架读取任意位置的文件

25 | 路由与终结点:如何规划好你的Web API

路由系统在 ASP.NET MVC 框架里面就已经存在了,在 ASP.NET Core 框架里面进行了改进

路由系统的核心作用是指 URL 和 应用程序 Controller 的对应关系的一种映射

这个映射关系实际上有两种作用:

  1. 把 URL 映射到对应的 Controller 对应的 action 上面去

  2. 根据 Controller 和 action 的名字来生产 URL

.NET Core 提供了两种路由注册的方式:

  1. 路由模板的方式

  2. RouteAttribute 方式

这两种方式分别适用于的场景是不一样的

路由模板的方式是之前传统的方式,可以用来作为 MVC 的页面 Web 配置

现在用的比较多的前后端分离的架构,定义 Web API 的时候使用 RouteAttribute 方式去做

在定义路由,注册路由的过程中间,有一个重要的特性就是路由约束,是指路由如何匹配

有以下简单的几种约束:

  1. 类型约束
  2. 范围约束
  3. 正则表达式
  4. 是否必选
  5. 自定义 IRouteConstraint

另外路由系统提供了两个关键的类,用来反向根据路由的信息生产 URL 地址

  1. LinkGenerator
  2. IUrlHelper

IUrlHelper 与 MVC 框架里面的 MVCHelper 很像

而 LinkGenerator 是全新提供的一个链接生成的对象,可以从容器里面,在任意的位置都可以获取到这个对象,然后根据需要生成 URL 地址

为了方便演示,这里先注册了一组 Swagger 的代码,将 Web API 通过 Swagger 的可视化界面输出出来

引入 Swagger 对应 ASP.NET Core 的包

Swashbuckle.AspNetCore

将代码文档 XML 文档注入给 Swagger

services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });
    var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
    var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
    c.IncludeXmlComments(xmlPath);
});

在中间件里面注册 Swagger

app.UseSwagger();
app.UseSwaggerUI(c =>
{
    c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1");
});

这样子就可以在界面上看到 Swagger 的界面,并且浏览我们定义的 API

接着是路由的定义 OrderController

namespace RoutingDemo.Controllers
{
    [Route("api/[controller]/[action]")]// RouteAttribute 的方式
    [ApiController]
    public class OrderController : ControllerBase
    {
        /// <summary>
        /// 
        /// </summary>
        /// <param name="id">必须可以转为long</param>
        /// <returns></returns>
        [HttpGet("{id:MyRouteConstraint}")]// 这里使用了自定义的约束
        public bool OrderExist(object id)
        {
            return true;
        }

        /// <summary>
        /// 
        /// </summary>
        /// <param name="id">最大20</param>
        /// <returns></returns>
        [HttpGet("{id:max(20)}")]// 这里使用了 Max 的约束
        public bool Max(long id)
        {
            return true;
        }

        /// <summary>
        /// 
        /// </summary>
        /// <param name="ss">必填</param>
        /// <returns></returns>
        [HttpGet("{name:required}")]// 必填约束
        public bool Reque(string name)
        {
            return true;
        }

        /// <summary>
        /// 
        /// </summary>
        /// <param name="number">以三个数字开始</param>
        /// <returns></returns>
        [HttpGet("{number:regex(^\\d{{3}}$)}")]// 正则表达式约束
        public bool Number(string number)
        {
            return true;
        }
    }
}

上面用到了自定义约束 MyRouteConstraint

namespace RoutingDemo.Constraints
{
    public class MyRouteConstraint : IRouteConstraint
    {
        public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection)
        {
            if (RouteDirection.IncomingRequest == routeDirection)
            {
                var v = values[routeKey];
                if (long.TryParse(v.ToString(), out var value))
                {
                    return true;
                }
            }
            return false;
        }
    }
}

注册 MyRouteConstraint

services.AddRouting(options =>
{
    options.ConstraintMap.Add("MyRouteConstraint", typeof(MyRouteConstraint));
});

让它生效之前,需要在中间件注册的位置注入 UseEndpoints,然后对 UseEndpoints 使用 MapControllers

app.UseEndpoints(endpoints =>
{
    // 使用 RouteAttribute
    endpoints.MapControllers();
});

通过这样子的方式把 OrderController 的路由注入进来

启动程序,可以看到一共有五个接口

第一个接口是我们实现的自定义约束,点击 try it out 后输入参数

第二个接口约束最大为20

输入5,执行

可以看到响应码是 200

输入25,执行

可以看到响应码是 404,也就说路由匹配失败了

第三个接口因为参数是必须的,所以没办法输入空值,有一个前端的验证

第四个接口以三个数字开始,输入 234,符合正则表达式,响应码 200

自定义约束实现了路由约束接口,它只有一个 Match 方法,这个方法传入了 Http 当前的 httpContext,route,routeKey

这个 routeKey 就是我们要验证的 key 值

后面两个参数 RouteValueDictionary 就是当前可以获取到的这个 routeKey 对应的传入的值是什么值,这样就可以验证我们传入的信息

routeDirection 这个枚举的作用是当前验证是用来验证 URL 请求进来,验证是否路由匹配,还是用来生成 URL,是进还是出的这样一个定义,在不同的场景下面可能响应的逻辑是不一样的

下面的逻辑是如果路由是进来的,也就是通过 URL 配置 action 的情况,就做一个判断,根据 routeKey 取到当前输入的这个值,然后判断它是否可以转成 long,这个其实模拟了类型验证,比如说 long 型验证的方式

namespace RoutingDemo.Constraints
{
    public class MyRouteConstraint : IRouteConstraint
    {
        public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection)
        {
            if (RouteDirection.IncomingRequest == routeDirection)
            {
                var v = values[routeKey];
                if (long.TryParse(v.ToString(), out var value))
                {
                    return true;
                }
            }
            return false;
        }
    }
}

RouteDirection

namespace Microsoft.AspNetCore.Routing
{
    public enum RouteDirection
    {
        IncomingRequest = 0,
        UrlGeneration = 1
    }
}

接下来看一下约束是如何注入到我们系统里生效的

可以给我们的约束起一个名字 isLong,这个名字就是用来 Attribute 上面标识约束的

services.AddRouting(options =>
{
    //options.ConstraintMap.Add("MyRouteConstraint", typeof(MyRouteConstraint));
    options.ConstraintMap.Add("isLong", typeof(MyRouteConstraint));
});

OrderController 里面也修改为 isLong

/// <summary>
/// 
/// </summary>
/// <param name="id">必须可以转为long</param>
/// <returns></returns>
//[HttpGet("{id:MyRouteConstraint}")]// 这里使用了自定义的约束
[HttpGet("{id:isLong}")]
//public bool OrderExist(object id)
public bool OrderExist([FromRoute] string id)
{
    return true;
}

启动程序,输入34,返回响应码200,输入abc,返回响应码404,也就是自定义约束生效了

接下来讲一下链接生成的过程

/// <summary>
/// 
/// </summary>
/// <param name="id">最大20</param>
/// <param name="linkGenerator"></param>
/// <returns></returns>
[HttpGet("{id:max(20)}")]// 这里使用了 Max 的约束
//public bool Max(long id)
public bool Max([FromRoute]long id, [FromServices]LinkGenerator linkGenerator)
{
    // 这两行就是分别获取完整 Uri 和 path 的代码
    // 它还有不同的重载,可以根据需要传入不同的路由的值
    var path = linkGenerator.GetPathByAction(HttpContext,
        action: "Reque",
        controller: "Order",
        values: new { name = "abc" });// 因为下面对 name 有一个必填的约束,所以这里需要传值

    var uri = linkGenerator.GetUriByAction(HttpContext,
        action: "Reque",
        controller: "Order",
        values: new { name = "abc" });
    return true;
}

/// <summary>
/// 
/// </summary>
/// <param name="ss">必填</param>
/// <returns></returns>
[HttpGet("{name:required}")]// 必填约束
public bool Reque(string name)
{
    return true;
}

启动程序,端点调试,输入1,点击执行,可以看到

path 的值为

/api/Order/Reque/abc

uri 的值为

https://localhost:5001/api/Order/Reque/abc

在定义 Controller 的时候,实际上还会做一些接口废弃的过程,通过 [Obsolete]

/// <summary>
/// 
/// </summary>
/// <param name="ss">必填</param>
/// <returns></returns>
[HttpGet("{name:required}")]// 必填约束
[Obsolete]
public bool Reque(string name)
{
    return true;
}

我们不必直接删除我们的接口,它还可以正常工作,但是我们可以把它标记为已废弃,在 Swagger 上面会有体现

可以看到这个接口已经被标记为废弃的,但是它的调用还是可以工作的

总结一下

  1. Restful 不是必须的,只要约束好 Http 方法以及 URL 地址,还有 Http 响应码,响应的 Json 格式,这些约定只要适合团队的协作习惯就可以了,也就是说需要定义好 API 的表达契约

  2. 建议是把 API 都约束在特定的目录下面,与其他功能性页面进行隔离,比如说 /api /api 加版本号这样子的方式

  3. 在废弃 API 的过程中间,应该是间隔版本的方式废弃,也就是说先将即将废弃的 API 标记为已废弃,但是它还是可以工作,间隔几个版本之后将代码删除掉


文章作者: Chaoqiang
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Chaoqiang !
评论
 上一篇
NetCore 开发实战(2)——微服务实战 NetCore 开发实战(2)——微服务实战
26 | 工程结构概览:定义应用分层及依赖关系从这一节开始进入微服务实战部分 这一节主要探讨工程的结构和应用的分层 在应用的分层这里定义了四个层次: 领域模型层 基础设施层 应用层 共享层 可以通过代码来看一下 共享层一共建立三
下一篇 
DotNet-Advanced-Series-3-3-NetCoreSourceCode DotNet-Advanced-Series-3-3-NetCoreSourceCode
编译的目的debug版本的运行时,调试源码,一行不差。 .NET Core 源码编译https://github.com/dotnet 这是一个合并过后的仓库,包括四个项目: coreclr installer libraries mo
  目录