使用 WebApplicationBuilder 支持 EF Core 迁移

3/16/2022 .NET6

这是该系列的第五篇文章:探索 .NET 6

在本系列中,我一直专注于使用 WebApplicationWebApplicationBuilder 构建的新最小托管 API。它们为构建 Web 应用提供了更简单的模型,同时保持了与 .NET Core 3.x/5 的基于通用主机应用程序相同的整体功能。

然而,这种简化也存在挑战。早期版本中更复杂的启动代码(通常在 Program.csStartup 之间拆分)具有优势,因为它提供了众所周知的钩子,工具可以使用这些钩子来劫持应用程序启动进程。

这方面的一个典型示例是 EF Core 工具 (opens new window)。如果你曾经使用过 EF Core,你可能熟悉尝试更改启动代码时出现的问题。因此,当框架更改其默认启动代码时,您就知道它会导致问题!

# ASP.NET Core 3.x/5 中的 EF Core 工具

EF Core 包含用于生成迁移和针对数据库运行迁移的各种工具,但要执行此操作,它需要了解您的代码。从本质上讲,它需要能够运行应用程序的启动代码,以便使用您配置的所有配置和依赖关系注入服务。

在以前版本的 ASP.NET Core 中,EF Core 挂接到 Program 类中的 CreateWebHostBuilderCreateHostBuilder 方法中。EF Core 工具将寻找这种"神奇"方法 (opens new window)来访问用于构建应用程序的 IWebHostBuilderIHostBuilder

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureWebHostDefaults(webBuilder =>
        {
            webBuilder.UseStartup<Startup>();
        });
1
2
3
4
5
6

这总是感觉很笨拙;如果重命名了该方法或更改了其签名,则 EF Core 工具将中断。

通过使用这种众所周知的方法,EF Core 工具可以使用反射加载应用程序的程序集,执行该方法,获取返回的 IHostBuilder,在其上调用 Build 以创建 IHost,然后从 IHost.Services 属性中获取 IServiceProvider!从此提供商处,EF Core 几乎可以内观有关应用程序的任何内容。非常漂亮,但非常依赖于特定的约定。

The EF Core tools run your application using reflection to retrieve an IServiceProvider

那么,当 ASP.NET Core 在 .NET 6 中用最少的托管 API 抛弃所有这些约定时,会发生什么呢?好吧,EF Core工具坏了 (opens new window)😄

# .NET 6 中如何支持 EF Core 工具

当然,这个问题已经解决了,这篇文章简要介绍了大卫·福勒是如何实现这一目标的 (opens new window)。方法是他在应用启动期间添加了新的诊断源事件!

如果您不熟悉 DiagnosticSourceDiagnosticListener我在这里有一篇关于它的介绍性文章 (opens new window),其中描述了它们在 .NET Core 中各种日志记录基元中的适用位置。

这篇文章现在已经超过4年了(它甚至包含对 project.json 的引用!),但它仍然是诊断源如何工作的良好参考。

DiagnosticSource 主要用作记录丰富数据的高性能方法,类似于 Windows 事件跟踪 (ETW),但完全在进程中。通过将 DiagnosticSource 事件作为主机生成过程的一部分发出,David 为 EF Core 工具提供了一种访问 IHostBuilderIHost 对象的方法。在下一节中,我们将查看新事件,在后续部分中,我们将看到 EF Core 工具如何使用它们。

# 在主机生成过程中发出新事件

在本系列的过去两篇文章中,我展示了新的 WebApplicationBuilderWebApplication 只是围绕 .NET Core 3.0 中引入的通用主机的包装。其他 DiagnosticSource 事件已添加到通用 HostBuilder 中,因此它们既适用于最小主机 WebApplication,也适用于"传统"通用主机构造。

这意味着,如果在 .NET 6 中继续使用 Program.csStart 拆分,并重命名 CreateHostBuilder() 方法,则 EF Core 工具将不再中断!

下面的代码演示 HostBuilder.Build() 方法(从 .NET 6 RC1 开始 (opens new window),出于可读性考虑)。所做的更改相对简单:

  • Build() 方法的持续时间内将创建一个新的 DiagnosticListener
  • 如果有任何东西正在侦听 Microsoft.Extensions.Hosting.HostBuilding 事件,则 HostBuilder 会将自身作为参数传递给侦听器。
  • 构建主机完成后,在方法返回之前,HostBuilder 会检查是否有任何内容正在侦听 Microsoft.Extensions.Hosting.HostBuilt 事件。如果是,则新构建的主机将作为参数传递给侦听器。
public class HostBuilder : IHostBuilder
{
    public IHost Build()
    {
        using var diagnosticListener = new DiagnosticListener("Microsoft.Extensions.Hosting");
        const string hostBuildingEventName = "HostBuilding";
        const string hostBuiltEventName = "HostBuilt";

        if (diagnosticListener.IsEnabled() && diagnosticListener.IsEnabled(hostBuildingEventName))
        {
            diagnosticListener.Write(hostBuildingEventName, this);
        }

        // 正常构建过程
        BuildHostConfiguration();
        CreateHostingEnvironment();
        CreateHostBuilderContext();
        BuildAppConfiguration();
        CreateServiceProvider();

        var host = _appServices.GetRequiredService<IHost>();
        if (diagnosticListener.IsEnabled() && diagnosticListener.IsEnabled(hostBuiltEventName))
        {
            diagnosticListener.Write(hostBuiltEventName, host);
        }

        return host;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

添加这些 DiagnosticSource 事件在用户应用程序上不会有太大的实用性,因为在您自己的应用程序中,您显然已经可以访问 HostBuilder 和构建的 IHost。当你需要使用反射运行应用时,例如在 EF Core 的工具中,事件就会派上用场。

# HostFactoryResolver 中的更新

EF Core 工具有一个难办的工作。他们需要能够使用应用程序的配置(包括连接字符串、DI 服务配置和数据库提供程序配置),而无需实际运行应用程序。为了实现这一点,他们使用了一个名为HostFactoryResolver 的帮助器类 (opens new window)

HostFactoryResolver 位于源包中(即源代码被复制并包含在使用项目中),并提供帮助程序,用于在程序集中查找 ASP.NET Core 应用的一个已知入口点。

在 .NET 6 之前,HostFactoryResolver 会查找一个名为以下项之一的入口点:

  • BuildWebHost
  • CreateWebHostBuilder
  • CreateHostBuilder

如果它在程序集入口点类(即 Program)上找到具有所需名称的方法,该方法返回正确的类型,并采用 string[] 参数,则它将使用反射调用此方法以获取 IHostBuilder(或类似)实例。这就是 EF Core 工具能够使用应用配置的方式。

在 .NET 6 中,添加了使用新的诊断源事件的新解决机制。下面的代码显示了 ResolveHostFactory 方法,包括所有注释,因为它们非常有启发性:

// 此帮助程序封装了以下各项所需的所有复杂逻辑:
// 1. 在不同的线程中执行指定程序集的入口点。
// 2. 等待诊断源事件触发
// 3. 为调用方提供执行逻辑以改变 IHostBuilder 的机会
// 4. 解析应用程序的 IHost 实例
// 5. 允许调用方确定入口点是否已完成
public static Func<string[], object>? ResolveHostFactory(
    Assembly assembly, TimeSpan? waitTimeout = null, bool stopApplication = true,
    Action<object>? configureHostBuilder = null, Action<Exception?>? entrypointCompleted = null)
{
    if (assembly.EntryPoint is null)
    {
        return null;
    }

    try
    {
        // 尝试加载托管并检查版本,以确保事件甚至有机会触发(它们是在 .NET >= 6 中添加的)
        var hostingAssembly = Assembly.Load("Microsoft.Extensions.Hosting");
        if (hostingAssembly.GetName().Version is Version version && version.Major < 6)
        {
            return null;
        }

        // 我们使用的版本> = 6,因此事件可以触发。如果它们没有触发,那是因为应用程序没有使用托管APIs
    }
    catch
    {
        // 加载扩展程序集时出错,返回 null。
        return null;
    }

    return args => new HostingListener(
        args, 
        assembly.EntryPoint, 
        waitTimeout ?? s_defaultWaitTimeout, 
        stopApplication, 
        configureHostBuilder, 
        entrypointCompleted).CreateHost();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

此方法执行一些初始检查,以确保我们引用了 .NET 6+ 版本的 Microsoft.Extensions.Hosting 程序集,如果有的话,则创建 HostingListener 的实例,该实例是 HostFactoryResolver 中的嵌套类,大部分工作都发生在这里。

您可以在GitHub上看到 HostingListener 的完整源代码 (opens new window),但由于它有150行,我将讲解下面的重要部分。

大部分工作都在 CreateHost() 方法中,如下所示。此代码负责:

  • 创建 DiagnosticListener 订阅,方法是注册 HostingListener 本身以接收回调。我们稍后将对此进行介绍。
  • 创建新线程并启动入口点。这将启动在后台线程中运行的应用程序,并假定在某个时候您的应用程序将调用 HostBuilder.Build()
  • 等待3件事情之一发生:
    • 我们的诊断侦听器代码在完成所有设置后会抛出 StopTheHostException(我们很快就会介绍这个)
    • 应用程序中会引发其他一些异常
    • 应用程序启动时间太长,因此 CreateHost 超时。
  • 发生上述情况之一后,结果将传播给调用方,返回一个对象(这将是您的应用程序创建的IHost),或者将引发异常:
private readonly TaskCompletionSource<object> _hostTcs = new();
private readonly MethodInfo _entryPoint;
private static readonly AsyncLocal<HostingListener> _currentListener = new();
private readonly Action<Exception?>? _entrypointCompleted;

public object CreateHost()
{
    using var subscription = DiagnosticListener.AllListeners.Subscribe(this);

    // Kick off the entry point on a new thread so we don't block the current one
    // in case we need to timeout the execution
    var thread = new Thread(() =>
    {
        Exception? exception = null;

        try
        {
            // Set the async local to the instance of the HostingListener so we can filter events that
            // aren't scoped to this execution of the entry point.
            _currentListener.Value = this;

            var parameters = _entryPoint.GetParameters();
            if (parameters.Length == 0)
            {
                _entryPoint.Invoke(null, Array.Empty<object>());
            }
            else
            {
                _entryPoint.Invoke(null, new object[] { _args });
            }

            // Try to set an exception if the entry point returns gracefully, this will force
            // build to throw
            _hostTcs.TrySetException(new InvalidOperationException("Unable to build IHost"));
        }
        catch (TargetInvocationException tie) when (tie.InnerException is StopTheHostException)
        {
            // The host was stopped by our own logic
        }
        catch (TargetInvocationException tie)
        {
            exception = tie.InnerException ?? tie;

            // Another exception happened, propagate that to the caller
            _hostTcs.TrySetException(exception);
        }
        catch (Exception ex)
        {
            exception = ex;

            // Another exception happened, propagate that to the caller
            _hostTcs.TrySetException(ex);
        }
        finally
        {
            // Signal that the entry point is completed
            _entrypointCompleted?.Invoke(exception);
        }
    })
    {
        // Make sure this doesn't hang the process
        IsBackground = true
    };

    // Start the thread
    thread.Start();

    try
    {
        // Wait before throwing an exception
        if (!_hostTcs.Task.Wait(_waitTimeout))
        {
            throw new InvalidOperationException("Unable to build IHost");
        }
    }
    catch (AggregateException) when (_hostTcs.Task.IsCompleted)
    {
        // Lets this propagate out of the call to GetAwaiter().GetResult()
    }

    return _hostTcs.Task.GetAwaiter().GetResult();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82

仅凭此代码,尚不清楚 EF Core 工具如何能够与 IHostBuilder 生成过程挂钩,也不清楚它们如何访问 IHost。答案在于 HostingListener 收到的 DiagnosticSource 事件,这要归功于在前面的代码中调用 DiagnosticListener.AllListeners.Subscribe(this)

private sealed class HostingListener : IObserver<DiagnosticListener>, IObserver<KeyValuePair<string, object?>>
{
    private static readonly AsyncLocal<HostingListener> _currentListener = new();

    public void OnNext(DiagnosticListener value)
    {
        if (_currentListener.Value != this)
        {
            // Ignore events that aren't for this listener
            return;
        }

        if (value.Name == "Microsoft.Extensions.Hosting")
        {
            _disposable = value.Subscribe(this);
        }
    }

    public void OnCompleted()
    {
        _disposable?.Dispose();
    }
    // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

初始化新的诊断侦听器时,主机侦听器会检查

  1. 我们位于同一异步本地上下文中(以避免并发问题 (opens new window)),如果不是,则忽略侦听器。我们不会从此侦听器接收任何事件。
  2. 检查侦听器是否名为 "Microsoft.Extensions.Hosting",以便我们仅订阅来自 HostingBuilder 的事件。

订阅托管事件侦听器后,当 HostBuilder 调用 diagnosticListener.Write() 时,我们将收到事件,正如我们在本文前面看到的那样。OnNext() 方法是使用 KeyValuePair 调用的,该方法包含事件的名称和写入的对象。这就是 HostisterListener 可以访问 IHostBuilderIHost 的方式!IHostBuilder 是在完成构建之前自定义的,最终的 IHost 对象通过在 TaskCompletionSource 上设置结果而传递回 EF Core 工具

private readonly bool _stopApplication;
private readonly TaskCompletionSource<object> _hostTcs = new();
private readonly Action<object>? _configure;

public void OnNext(KeyValuePair<string, object?> value)
{
    if (_currentListener.Value != this)
    {
        // Ignore events that aren't for this listener
        return;
    }

    if (value.Key == "HostBuilding")
    {
        // run the action to customise the IHostBuilder passed in value.Value
        _configure?.Invoke(value.Value!);
    }

    if (value.Key == "HostBuilt")
    {
        // store the IHost passed in value.Value
        _hostTcs.TrySetResult(value.Value!);

        // The EF Core tools don't actually want to _run_ the application
        if (_stopApplication)
        {
            // Stop the host from running further
            throw new StopTheHostException();
        }
    }
}

private sealed class StopTheHostException : Exception { }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

为了防止应用程序正常运行(例如,在端口上侦听),侦听器会引发 StopTheHostException。当侦听器在应用程序中以内联方式运行时,这实际上会从正在运行的应用中解脱出来。HostingListener 捕获此异常,保留 IServiceProvider,并继续与以前版本的框架一样。

通过这些更改,EF Core 工具能够再次检索应用程序的 IServiceProvider 的实例,基本上运行您的 Program.Main 并在构建 IHost 后进行救助。

The EF Core tools run your Program.Main() and the diagnostic listener retrieves the IHost

感觉它在某种程度上滥用了诊断源,但它有效!

# 总结

在这篇文章中,我描述了EF Core工具如何与新的最小托管 WebApplicationBuilderWebApplication 一起工作。使用这些类型和顶级程序,以前用于为应用加载 IServiceProvider 的 EF Core 基于约定的方法将不再有效。

若要解决这些更改,已将新的诊断源事件添加到主机生成器。这些事件允许订阅者在构建 HostBuilder 之前访问它,以及在构建后立即访问 IHost 实例。

EF Core 工具使用这些事件来运行应用程序并检索 IHost 实例(以及关联的 IServiceProvider)。然后,它们会引发异常,以便你的应用实际上不会运行。




作者:Gerry Ge

出处:翻译至 Supporting EF Core migrations with WebApplicationBuilder (opens new window)

版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际 (opens new window)」许可协议进行许可。

转载请注明出处

Last Updated: 3/17/2022, 6:38:50 AM