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

44

我有一个类型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);
  }
}

我已经将时间耦合封装到一个方法中,所以情况不会像可能的那么糟糕。但是,它仍然是反模式,并且不像我想要的那样可维护。


抽象工厂(同步-异步)

一个常见的解决方案是抽象工厂模式。然而,在这种情况下,我们处理异步初始化。所以,我可以通过强制使初始化同步运行来使用抽象工厂模式,但这会采用同步覆盖异步的反模式。我真的不喜欢同步覆盖异步的方法,因为我有几个存储器,在我的当前代码中,它们都在并发初始化;由于这是云应用程序,将其更改为串行同步将增加启动时间,并行同步也不理想,因为会消耗资源。


异步抽象工厂(不当的抽象工厂用法)

我也可以使用具有异步工厂方法的抽象工厂。然而,这种方法有一个主要问题。正如Mark Seeman在这里评论的那样,“任何值得一试的DI容器都能够正确地为您自动连接一个[factory]实例”,但是很遗憾,对于异步工厂,这完全不成立。据我所知,没有DI容器支持此功能。

因此,抽象异步工厂解决方案需要我使用显式工厂,至少是Func<Task<T>>,并且这将无处不在(“我们个人认为,默认情况下允许注册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. 任何实际使用这些依赖关系的方法都需要是异步的-因为即使是逻辑方法也必须等待存储/连接初始化完成。对于我这个应用程序来说,这并不是什么大问题,因为我的存储方法都是异步的,但在一般情况下可能会有问题。

自初始化(时间耦合)

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

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();
  }
}

这里的问题在于我们回到了时间耦合,这次是遍布整个系统。此外,这种方法要求所有公共成员都是异步方法。


因此,实际上有两种 DI 设计观点在此相互冲突:

  • 使用者希望能够注入已经准备就绪的实例。
  • DI 容器强烈推崇简单的构造函数

问题在于 - 特别是对于异步初始化 - 如果 DI 容器在“简单构造函数”的方式上采取强硬立场,那么他们只是强迫用户在其他地方进行自己的初始化,这会带来自己的反模式。例如,为什么 Simple Injector 不考虑异步功能:“不,这个功能对于 Simple Injector 或任何其他 DI 容器都没有意义,因为它违反了关于依赖注入的一些重要基本规则。”然而,严格遵守“基本规则”显然会导致其他更糟糕的反模式。

问题是:是否有一种解决异步初始化的方法,可以避免所有反模式?


更新:上面提到的 Connections 的完整签名:

public sealed class AzureConnections
{
  public AzureConnections();

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

  public async Task InitializeAsync();
}

1
你能详细阐述“异步初始化”的动机吗?这是为了提高性能吗?如果是的话,虚拟代理模式听起来可能适用。我在我的书的第8.3.6节中稍微讨论了一下这个问题。 - Mark Seemann
2
我的想法是,重点过多关注了实现方面的问题。我会将AzureConnections视为第三方依赖项,并将其封装在一个抽象层后面,其实现将处理调用异步初始化(时间)代码的操作。 - Nkosi
1
@StephenCleary,如果您不使用DI容器(即,如果您使用Pure DI),那么您的问题是否有解决方案?它会是什么样子? - Yacoub Massad
1
@YacoubMassad:我在这里有一些示例。个人而言,我更喜欢静态异步工厂模式,因此根组合对象将像new Logic(await Storage.CreateAsync(await Connections.CreateAsync()));这样构建。当然,自己操作时会失去DI的“注入”部分,这会成为维护负担。 - Stephen Cleary
2
我有一个类型[...]需要异步初始化。这让我越来越觉得像是个 XY 问题。为什么它需要异步初始化?初始化对象不应该需要“工作”。对象初始化的角色是确保对象处于有效状态。这是面向对象设计的原则,可以追溯到80年代中期的Bertrand Meyer,甚至更早。 (很抱歉加入这个评论太晚了,但人们一直在指向这篇文章。) - Mark Seemann
显示剩余27条评论
3个回答

25

这是一个较长的答案。如果你急于了解,请直接跳到结论。

您面临的问题,以及您正在构建的应用程序,是不典型的。它有两个不典型之处:

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

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

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

将初始化从保存它的类中提取出来,并将其移动到组合根中。此时,您可以在注册它们之前创建和初始化这些类,并将这些已初始化的类作为注册的一部分馈送到容器中。

在您的特定情况下,这种方法非常有效,因为您想要进行一些(一次性的)启动初始化。启动初始化通常是在配置容器之前完成的(或者有时在之后完成,如果它需要完全组成的对象图)。在我看过的大多数情况下,初始化可以在之前完成,在您的情况下也可以有效地完成。

正如我所说,与常态相比,您的情况有些特殊。常态是:

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

在启动初始化时,异步化没有实际好处。没有实际的性能优势,因为在启动时,只会运行一个线程(虽然我们可能会并行执行,但显然不需要异步)。还要注意的是,尽管一些应用程序类型可能会死锁在同步上,但在组合根中,我们知道确切地我们正在使用哪种应用程序类型以及是否会出现问题。组合根始终是特定于应用程序的。换句话说,在非死锁应用程序的组合根中进行初始化(例如ASP.NET Core、Azure Functions等),通常没有必要在启动初始化期间异步处理,除了遵循建议的模式和实践之外。

因为您知道在组合根中,同步与异步的同步是否是一个问题,您甚至可以决定在第一次使用时同步进行初始化。由于初始化量是有限的(与每个请求的初始化相比),如果您愿意,在后台线程上进行同步阻塞也不会对性能产生实际影响。您需要做的就是在组合根中定义一个代理类,确保在第一次使用时完成初始化。这基本上就是Mark Seemann提出的建议

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

此外,应用程序框架不允许你异步构建它们的框架根组件。因此,即使 DI 容器支持进行异步解析的概念,由于应用程序框架的“缺乏”支持,这也不起作用。以 ASP.NET Core 的 IControllerActivator 为例。它的 Create(ControllerContext) 方法允许你组合一个 Controller 实例,但 Create 方法的返回类型是 object,而不是 Task。换句话说,即使 DI 容器提供了 ResolveAsync 方法,它仍会导致阻塞,因为 ResolveAsync 调用将被包装在同步框架抽象后面。
在大多数情况下,你会发现初始化是每个实例或运行时完成的。例如,SqlConnection 通常是每个请求打开一次,因此每个请求都需要打开自己的连接。当你想要“随时打开”连接时,这必然会导致应用程序接口是异步的。但是要小心:
如果你创建的实现是同步的,则只有在确定永远不会有另一个实现(或代理、装饰器、拦截器等)是异步的情况下,才应该使其抽象同步。如果你无效地使抽象同步(即具有不公开 Task 的方法和属性),你可能会遇到泄漏的抽象问题。这可能会在以后获得异步实现时强制你在整个应用程序中进行大量更改。
换句话说,引入async后,您必须更加注意应用程序抽象的设计。这也适用于您的特定情况。即使现在您只需要启动初始化,但您确定您定义的抽象(以及AzureConnections)永远不需要即时同步初始化吗?如果AzureConnections的同步行为是实现细节,则必须立即将其改为异步。
另一个例子是您的INugetRepository。它的成员是同步的,但这显然是一个漏洞抽象,因为它是同步实现的原因。然而,它的实现是同步的,因为它使用了一个仅具有同步API的遗留NuGet包。很明显,INugetRepository应该完全异步,即使其实现是同步的,因为预计实现将通过网络进行通信,这就是异步性有意义的地方。
在应用程序中应用异步的情况下,大多数应用程序抽象将具有大多数异步成员。当出现这种情况时,将此类即时初始化逻辑异步化将变得轻而易举;一切都已经是异步的。
总结
  • 如果需要启动初始化:在配置容器之前或之后进行。这使得对象图的组合本身快速、可靠和可验证。
  • 在配置容器之前进行初始化可以避免时间耦合,但可能意味着你必须将初始化移出需要它的类(这实际上是一件好事)。
  • 在大多数应用程序类型中,异步启动初始化是不可能的。在其他应用程序类型中,通常也是不必要的。
  • 如果需要每个请求或即时初始化,则无法避免使用异步接口。
  • 如果你正在构建一个异步应用程序,请小心同步接口,否则可能会泄露实现细节。

脚注

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

我发现你的脚注在抽象异步初始化方面特别有用:services.AddSingleton<MyService>().AddHostedService<MyService>(sp => sp.GetRequiredService<MyService>());然后我们的开发人员利用IHostedService.StartAsync作为处理所有异步服务初始化的标准方式。 - Eric Patrick

5

虽然我相当确定以下内容不是您要寻找的,但您能否解释一下为什么它不能回答您的问题?

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)时,它将返回由InitializeStorageAccount产生的值。
我强烈感觉这不是您想要的,但由于我不理解您要解决的问题,所以我想留下这个答案,这样我们至少有些东西可以讨论。

当我谈论“异步”时,我指的是真正的异步操作 - 即执行I/O,而不是使用后台线程。在我的情况下,CloudStorageAccount是同步的,没有问题,但考虑Blob容器:我想在其可供使用之前强制执行对CreateIfNotExistsAsync的单个调用。虽然我可以使用同步覆盖异步(即CreateIfNotExistsResult),但这将使我的所有初始化序列化 - 在云环境中可行但不理想。 - Stephen Cleary
@StephenCleary,我真的不明白你的意思,或者你试图解决的问题。当我查看OP中发布的AzureConnections类时,它有三个公共只读属性。根据框架设计指南,应该避免从属性getter中抛出异常。如果客户端在InitializeAsync完成之前尝试获取其中一个属性的值会发生什么? - Mark Seemann
它可以很好地获取客户端对象并使用它,即使服务属性尚未设置。但是,如果考虑Blob容器,则用户在调用UploadAsync之前必须调用CreateIfNotExistsAsync,否则上传将失败。 - Stephen Cleary

0

看起来你正在尝试使用我的代理单例类做的事情。

                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
                });

1
这是一个hack,可以让它“工作”,但是这段代码不再是异步的。调用.Result会使这段代码变成阻塞式的,而使用async/await则是非阻塞式的。 - tkit
1
当然这是一个hack...在services.AddSingleton/Transient/Scoped中没有异步方法...所以这是唯一的让它工作的方法。 - T Brown

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接