避免所有需要异步初始化的类型的 DI 反模式

Avoiding all DI antipatterns for types requiring asynchronous initialization

我有一个类型 Connections 需要异步初始化。这种类型的实例被其他几种类型(例如 Storage)使用,每个类型也需要异步初始化(静态的,而不是每个实例,并且这些初始化也依赖于 Connections)。最后,我的逻辑类型(例如 Logic)使用这些存储实例。目前正在使用 Simple Injector。

我尝试了几种不同的解决方案,但总有一种反模式存在。


显式初始化(​​时间耦合)

我当前使用的解决方案具有时间耦合反模式:

public sealed class Connections
{
  Task InitializeAsync();
}

public sealed class Storage : IStorage
{
  public Storage(Connections connections);
  public static Task InitializeAsync(Connections connections);
}

public sealed class Logic
{
  public Logic(IStorage storage);
}

public static class GlobalConfig
{
  public static async Task EnsureInitialized()
  {
    var connections = Container.GetInstance<Connections>();
    await connections.InitializeAsync();
    await Storage.InitializeAsync(connections);
  }
}

我已经将 Temporal Coupling 封装到一个方法中,所以它并没有想象中的那么糟糕。但是,它仍然是一种反模式,不像我想要的那样易于维护。


抽象工厂(异步同步)

一个常见的建议解决方案是抽象工厂模式。然而,在这种情况下,我们正在处理异步初始化。因此,我 可以 通过强制同步初始化到 运行 来使用抽象工厂,但这随后采用了异步同步反模式。我真的不喜欢异步同步方法,因为我有多个存储,并且在我当前的代码中它们都是同时初始化的;因为是云应用,改成串行同步会增加启动时间,并行同步也不理想,资源消耗。


异步抽象工厂(抽象工厂使用不当)

我还可以使用具有异步工厂方法的抽象工厂。但是,这种方法存在一个主要问题。正如 Mark Seeman 评论 here,“如果您正确注册,任何物有所值的 DI 容器都能够为您自动连接一个 [工厂] 实例。”不幸的是,这对于异步工厂来说是完全不正确的:AFAIK 没有 DI 容器支持这个。

因此,抽象异步工厂解决方案将要求我使用显式工厂,至少 Func<Task<T>>this ends up being everywhere(“我们个人认为允许默认注册 Func 委托是设计味道...如果您的系统中有许多依赖于 Func 的构造函数,请仔细查看您的依赖策略。"):

public sealed class Connections
{
  private Connections();
  public static Task<Connections> CreateAsync();
}

public sealed class Storage : IStorage
{
  // Use static Lazy internally for my own static initialization
  public static Task<Storage> CreateAsync(Func<Task<Connections>> connections);
}

public sealed class Logic
{
  public Logic(Func<Task<IStorage>> storage);
}

这会导致其自身的几个问题:

  1. 我所有的工厂注册都必须明确地从容器中提取依赖项并将它们传递给 CreateAsync。所以DI容器不再做,你知道的,依赖注入.
  2. 这些工厂调用的结果的生命周期不再由 DI 容器管理。每个工厂现在负责生命周期管理而不是 DI 容器。 (对于同步抽象工厂,如果工厂注册得当,这不是问题)。
  3. 任何实际使用这些依赖项的方法都需要是异步的——因为即使是逻辑方法也必须等待 storage/connections 初始化完成。这对我来说对这个应用程序来说不是什么大问题,因为我的存储方法无论如何都是异步的,但在一般情况下这可能是个问题。

自初始化(时间耦合)

另一种不太常见的解决方案是让类型的每个成员等待自己的初始化:

public sealed class Connections
{
  private Task InitializeAsync(); // Use Lazy internally

  // Used to be a property BobConnection
  public X GetBobConnectionAsync()
  {
    await InitializeAsync();
    return BobConnection;
  }
}

public sealed class Storage : IStorage
{
  public Storage(Connections connections);
  private static Task InitializeAsync(Connections connections); // Use Lazy internally
  public async Task<Y> IStorage.GetAsync()
  {
    await InitializeAsync(_connections);
    var connection = await _connections.GetBobConnectionAsync();
    return await connection.GetYAsync();
  }
}

public sealed class Logic
{
  public Logic(IStorage storage);
  public async Task<Y> GetAsync()
  {
    return await _storage.GetAsync();
  }
}

这里的问题是我们又回到了时间耦合,这次是在整个系统中传播。此外,此方法要求 all public 成员是异步方法。


所以,这里确实有两个不一致的 DI 设计观点:

问题是 - 特别是对于异步初始化 - 如果 DI 容器对“简单构造函数”方法采取强硬路线,那么它们只是强迫用户在其他地方进行自己的初始化,这会带来自己的反模式。例如,why Simple Injector won't consider asynchronous functions:“不,这样的功能对简单注入器或任何其他 DI 容器没有意义,因为它违反了一些重要的依赖注入基本规则。”然而,严格“遵守基本规则”显然会迫使其他看起来更糟糕的反模式。

问题:是否有避免所有反模式的异步初始化解决方案?


更新:AzureConnections 的完整签名(上文称为 Connections):

public sealed class AzureConnections
{
  public AzureConnections();

  public CloudStorageAccount CloudStorageAccount { get; }
  public CloudBlobClient CloudBlobClient { get; }
  public CloudTableClient CloudTableClient { get; }

  public async Task InitializeAsync();
}

虽然我很确定以下内容不是您要查找的内容,但您能解释一下为什么它没有解决您的问题吗?

public sealed class AzureConnections
{
    private readonly Task<CloudStorageAccount> storage;

    public AzureConnections()
    {
        this.storage = Task.Factory.StartNew(InitializeStorageAccount);
        // Repeat for other cloud 
    }

    private static CloudStorageAccount InitializeStorageAccount()
    {
        // Do any required initialization here...
        return new CloudStorageAccount( /* Constructor arguments... */ );
    }

    public CloudStorageAccount CloudStorageAccount
    {
        get { return this.storage.Result; }
    }
}

为了保持设计清晰,我只实现了一个云属性,但其他两个可以用类似的方式完成。

AzureConnections 构造函数不会阻塞,即使初始化各种云对象需要很长时间。

另一方面,它将开始工作,并且由于 .NET 任务的行为类似于承诺,因此您第一次尝试访问该值(使用 Result)时它将 return InitializeStorageAccount.

产生的值

我强烈认为这不是您想要的,但由于我不明白您要解决什么问题,所以我想我会留下这个答案,这样至少我们会有有事要讨论。

这是一个很长的答案。文末有总结。如果您赶时间,请向下滚动到摘要。

您遇到的问题 the application you're building 是非典型的。这是非典型的,原因有二:

  1. 您需要(或者说想要)异步启动初始化,并且
  2. 您的应用程序框架(azure 函数)支持异步启动初始化(或者更确切地说,围绕它的框架似乎很少)。

这使您的情况与典型情况有点不同,这可能会使讨论常见模式变得有点困难。

但是,即使在您的情况下,解决方案也相当简单和优雅:

从保存它的 class 中提取初始化,并将其移动到 Composition Root 中。那时你可以创建和初始化那些 classes before 在容器中注册它们并将这些初始化的 classes 作为注册的一部分提供给容器。

这在您的特定情况下效果很好,因为您想进行一些(一次性)启动初始化。启动初始化通常在您配置容器之前完成(或者有时在需要完全组合的对象图之后)。在我见过的大多数情况下,初始化都可以在之前完成,在你的情况下可以有效地完成。

正如我所说,与正常情况相比,你的情况有点特殊。标准是:

  • 启动初始化是同步的。框架(如 ASP.NET Core¹)通常不支持启动阶段的异步初始化。
  • 初始化通常需要按请求及时完成,而不是按应用程序提前完成。通常需要初始化的组件的生命周期很短,这意味着我们通常在首次使用时初始化此类实例(换句话说:即时)。

异步进行启动初始化通常没有真正的好处。没有实际的性能优势,因为在启动时,无论如何只有一个线程 运行(尽管我们可能会并行化它,但显然不需要异步)。另请注意,尽管某些应用程序类型可能会在执行异步同步时出现死锁,但在 Composition Root 中,我们 确切地 知道我们正在使用哪种应用程序类型,以及这是否会成为问题或不是。组合根 总是 特定于应用程序。换句话说,当我们在非死锁应用程序(例如 ASP.NET Core、Azure Functions 等)的组合根中进行初始化时,异步进行启动初始化通常没有任何好处,除了可能为了坚持建议的模式和做法。

因为您知道在您的组合根中同步异步是否是一个问题,您甚至可以决定在首次使用时同步进行初始化。因为初始化量是有限的(与每个请求的初始化相比),如果您愿意,在具有同步阻塞的后台线程上进行初始化不会对实际性能产生影响。您所要做的就是在 Composition Root 中定义一个 Proxy class 以确保在首次使用时完成初始化。这几乎就是 Mark Seemann 作为答案的想法。

我对 Azure Functions 一点都不熟悉,所以这实际上是我所知道的第一个实际支持异步初始化的应用程序类型(当然控制台应用程序除外)。在大多数框架类型中,用户根本无法异步进行这种启动初始化。例如,ASP.NET 应用程序或 ASP.NET 核心应用程序的 Startup class 中的 Application_Start 事件中的代码 运行,有没有异步。一切都必须是同步的。

最重要的是,应用程序框架不允许您异步构建它们的框架根组件。因此,即使 DI 容器支持执行异步解析的概念,这也行不通,因为“缺乏”应用程序框架的支持。以 ASP.NET Core 的 IControllerActivator 为例。它的Create(ControllerContext)方法允许你组合一个Controller实例,但是Create方法的return类型是object,而不是Task<object>。换句话说,即使 DI 容器为我们提供了 ResolveAsync 方法,它仍然会导致阻塞,因为 ResolveAsync 调用将被包装在同步框架抽象之后。

在大多数情况下,您会看到初始化是针对每个实例或在运行时完成的。例如,SqlConnection 通常是针对每个请求打开的,因此每个请求都需要打开自己的连接。当您想“及时”打开连接时,这不可避免地会导致应用程序接口异步。但这里要小心:

如果你创建一个同步的实现,你应该只在你确定永远不会是另一个实现(或代理、装饰器、拦截器等)即异步。如果您无效地使抽象同步(即具有不公开 Task<T> 的方法和属性),您很可能手头有一个 Leaky Abstraction。当您稍后获得异步实现时,这可能会迫使您对整个应用程序进行彻底的更改。

换句话说,随着 async 的引入,您必须更加注意应用程序抽象的设计。这也适用于您的具体情况。即使您现在可能只需要启动初始化,您确定您定义的抽象(以及 AzureConnections )永远不需要即时同步初始化吗?如果 AzureConnections 的同步行为是一个实现细节,您必须立即将其设为异步。

另一个例子是你的INugetRepository。它的成员是同步的,但这显然是一个 Leaky Abstraction,因为它同步的原因是因为它的实现是同步的。但是,它的实现是同步的,因为它使用了仅具有同步 API 的遗留 NuGet 包。很明显 INugetRepository 应该是完全异步的,即使它的实现是同步的,因为实现应该通过网络进行通信,这就是异步有意义的地方。

在应用异步的应用程序中,大多数应用程序抽象将主要包含异步成员。在这种情况下,使这种即时初始化逻辑也异步将是一个明智的选择;一切都已经异步了。

总结

  • 如果您需要启动初始化:在配置容器之前或之后进行。这使得组合对象图本身快速、可靠且可验证。
  • 在配置容器之前进行初始化会阻止 Temporal Coupling,但可能意味着您必须将初始化移出需要它的 classes(这实际上是一件好事)。
  • 在大多数应用程序类型中,异步启动初始化是不可能的。在其他应用程序类型中,通常不需要。
  • 如果您需要按请求或即时初始化,则没有办法绕过异步接口。
  • 如果您正在构建异步应用程序,请注意同步接口,您可能会泄露实现细节。

脚注

  1. ASP.NET Core 实际上 确实 允许异步启动初始化,但不能从 Startup class 内部进行。有几种方法可以实现这一点:要么实现并注册包含(或委托给)初始化的 hosted services,要么从程序 class 的 async Main 方法中触发异步初始化.

看起来你正在尝试用我的代理单身人士做我正在做的事情 class。

                services.AddSingleton<IWebProxy>((sp) => 
                {
                    //Notice the GetService outside the Task.  It was locking when it was inside
                    var data = sp.GetService<IData>();

                    return Task.Run(async () =>
                    {
                        try
                        {
                            var credentials = await data.GetProxyCredentialsAsync();
                            if (credentials != null)
                            {
                                return new WebHookProxy(credentials);
                            }
                            else
                            {
                                return (IWebProxy)null;
                            }
                        }
                        catch(Exception ex)
                        {
                            throw;
                        }
                    }).Result;  //Back to sync
                });