使用 WebApplicationBuilder 支持 EF Core 迁移
这是该系列的第五篇文章:探索 .NET 6。
在本系列中,我一直专注于使用 WebApplication
和 WebApplicationBuilder
构建的新最小托管 API。它们为构建 Web 应用提供了更简单的模型,同时保持了与 .NET Core 3.x/5 的基于通用主机应用程序相同的整体功能。
然而,这种简化也存在挑战。早期版本中更复杂的启动代码(通常在 Program.cs 和 Startup 之间拆分)具有优势,因为它提供了众所周知的钩子,工具可以使用这些钩子来劫持应用程序启动进程。
这方面的一个典型示例是 EF Core 工具 (opens new window)。如果你曾经使用过 EF Core,你可能熟悉尝试更改启动代码时出现的问题。因此,当框架更改其默认启动代码时,您就知道它会导致问题!
# ASP.NET Core 3.x/5 中的 EF Core 工具
EF Core 包含用于生成迁移和针对数据库运行迁移的各种工具,但要执行此操作,它需要了解您的代码。从本质上讲,它需要能够运行应用程序的启动代码,以便使用您配置的所有配置和依赖关系注入服务。
在以前版本的 ASP.NET Core 中,EF Core 挂接到 Program
类中的 CreateWebHostBuilder
或 CreateHostBuilder
方法中。EF Core 工具将寻找这种"神奇"方法 (opens new window)来访问用于构建应用程序的 IWebHostBuilder
或 IHostBuilder
。
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
2
3
4
5
6
这总是感觉很笨拙;如果重命名了该方法或更改了其签名,则 EF Core 工具将中断。
通过使用这种众所周知的方法,EF Core 工具可以使用反射加载应用程序的程序集,执行该方法,获取返回的 IHostBuilder
,在其上调用 Build
以创建 IHost
,然后从 IHost.Services
属性中获取 IServiceProvider
!从此提供商处,EF Core 几乎可以内观有关应用程序的任何内容。非常漂亮,但非常依赖于特定的约定。
那么,当 ASP.NET Core 在 .NET 6 中用最少的托管 API 抛弃所有这些约定时,会发生什么呢?好吧,EF Core工具坏了 (opens new window)😄
# .NET 6 中如何支持 EF Core 工具
当然,这个问题已经解决了,这篇文章简要介绍了大卫·福勒是如何实现这一目标的 (opens new window)。方法是他在应用启动期间添加了新的诊断源事件!
如果您不熟悉 DiagnosticSource
和 DiagnosticListener
,我在这里有一篇关于它的介绍性文章 (opens new window),其中描述了它们在 .NET Core 中各种日志记录基元中的适用位置。
这篇文章现在已经超过4年了(它甚至包含对 project.json 的引用!),但它仍然是诊断源如何工作的良好参考。
DiagnosticSource
主要用作记录丰富数据的高性能方法,类似于 Windows 事件跟踪 (ETW),但完全在进程中。通过将 DiagnosticSource
事件作为主机生成过程的一部分发出,David 为 EF Core 工具提供了一种访问 IHostBuilder
和 IHost
对象的方法。在下一节中,我们将查看新事件,在后续部分中,我们将看到 EF Core 工具如何使用它们。
# 在主机生成过程中发出新事件
在本系列的过去两篇文章中,我展示了新的 WebApplicationBuilder
和 WebApplication
只是围绕 .NET Core 3.0 中引入的通用主机的包装。其他 DiagnosticSource
事件已添加到通用 HostBuilder
中,因此它们既适用于最小主机 WebApplication
,也适用于"传统"通用主机构造。
这意味着,如果在 .NET 6 中继续使用 Program.cs 和
Start
拆分,并重命名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;
}
}
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();
}
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();
}
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();
}
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
初始化新的诊断侦听器时,主机侦听器会检查
- 我们位于同一异步本地上下文中(以避免并发问题 (opens new window)),如果不是,则忽略侦听器。我们不会从此侦听器接收任何事件。
- 检查侦听器是否名为 "
Microsoft.Extensions.Hosting
",以便我们仅订阅来自HostingBuilder
的事件。
订阅托管事件侦听器后,当 HostBuilder
调用 diagnosticListener.Write()
时,我们将收到事件,正如我们在本文前面看到的那样。OnNext()
方法是使用 KeyValuePair
调用的,该方法包含事件的名称和写入的对象。这就是 HostisterListener
可以访问 IHostBuilder
和 IHost
的方式!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 { }
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
后进行救助。
感觉它在某种程度上滥用了诊断源,但它有效!
# 总结
在这篇文章中,我描述了EF Core工具如何与新的最小托管 WebApplicationBuilder
和WebApplication
一起工作。使用这些类型和顶级程序,以前用于为应用加载 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)」许可协议进行许可。
转载请注明出处