如何初始化通过依赖注入注册且相互依赖的异步服务?

后端开发 2026-07-08

在ASP.NET Core中对服务进行尽早初始化,我是这样做的:

public sealed class ServiceA   // interfaces omitted
{
    // makes service usable
    public async Task EnsureIndexesAsync(CancellationToken cancellationToken) {...}

    // should be called after EnsureIndexesAsync
    public void use() {...}
}

public sealed class ServiceB 
{
    // dependency without clear timing requrements, will call a.use() eventually
    public ServiceB(ServiceA a) {...}
    public async Task InitializeAsync(CancellationToken cancellationToken) {...}
}

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ServiceA>();
builder.Services.AddSingleton<ServiceB>();
var app = builder.Build();
var a = app.Services.GetRequiredService<ServiceA>();
var b = app.Services.GetRequiredService<ServiceB>();
// brittle! Pray the order is correct.
await a.EnsureIndexesAsync(startupCancellation);
await b.InitializeAsync(startupCancellation);

服务的依赖图很庞大,初始化顺序可能会变化,服务可能需要尽早获得它们的依赖。如果服务是同步的,我会在构造函数中初始化它们,遵循「始终有效」原则。

但构造函数不能是异步的。如何避免访问未初始化的异步服务?我是不是应该在构造函数中等待?

我熟悉DI容器中的工厂提供者,但从未处理过异步依赖图。

解决方案

首先,修复API

如何避免访问未初始化的异步服务?

EnsureIndexesAsync() 成为 internal(或 private)的实现细节。

你的问题来自设计/需求之间的矛盾:

  • 你希望将其暴露为 public,并要求别人来调用它
  • 你又希望掌控它被如何以及何时被调用

作为一个 public 方法,它不仅引入你所遇到的时间耦合,而且还是一个泄漏的抽象。通过将其设为 internalServiceA 拥有对该方法的调用。

正如评论中所建议的,从 use() 调用它。看起来可能是这样的:

public interface IServiceA
{
    Task UseAsync();
}

internal sealed class ServiceA : IServiceA
{
    private Task? _initialization;

    internal Task EnsureIndexesAsync(CancellationToken cancellationToken)
    {
        _initialization ??= Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);

        return _initialization;
    }

    public async Task UseAsync()
    {
        await EnsureIndexesAsync(CancellationToken.None);

        // do other stuff here that relies on initialisation
    }
}

其次,接受TPL的限制

为了调用 EnsureIndexesAsync()use() 也必须成为一个 async 方法 UseAsync()。这个过程理想情况下应该沿整个调用链向上推进,参见 async all the way

但构造函数不能是 async

没错。这意味着你既不能在构造函数中调用 EnsureIndexesAsync(),也不能调用 UseAsync()。真糟!

但仔细想想,你也不应该这样做。构造函数不应包含繁重的逻辑,更不用说要在一个 async 方法中实现的如此繁琐。

结论是 UseAsync() 只能从其他 async 方法中调用。一个 ServiceB 可能是这样的:

public interface IServiceB
{
    Task DoAsync();
}

internal sealed class ServiceB : IServiceB
{
    private Task? _initialization;
    private readonly IServiceA _serviceA;

    public ServiceB(IServiceA serviceA)
    {
        _serviceA = serviceA ?? throw new ArgumentNullException(nameof(serviceA));
    }

    internal Task InitializeAsync(CancellationToken cancellationToken)
    {
        _initialization ??= Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);

        return _initialization;
    }

    public async Task DoAsync()
    {
        await InitializeAsync(CancellationToken.None);

        // do other stuff here that relies on initialisation

        await _serviceA.UseAsync();
    }
}

一个用法示例可能是这样的:

var collection = new ServiceCollection();
collection.AddSingleton<IServiceA, ServiceA>();
collection.AddSingleton<IServiceB, ServiceB>();
var provider = collection.BuildServiceProvider();

await provider.GetRequiredService<IServiceB>().DoAsync();

虽然它保证了每次初始化都会发生,但它是在对每个具有初始化前提条件的方法的第一次调用时执行(直接或间接地)。对 DoAsync() 的调用大约需要5 秒。

第三,添加用于预加载的API

从上到下思考,你可能希望把上面的代码改成下面这样的:

var collection = new ServiceCollection();
collection.AddSingleton<IServiceA, ServiceA>();
collection.AddSingleton<IServiceB, ServiceB>();
var provider = collection.BuildServiceProvider();

await Task.WhenAll(provider.GetRequiredService<IEnumerable<IInitialize>>()
    .Select(service => service.InitializeAsync(startupCancellation)));

await provider.GetRequiredService<IServiceB>().DoAsync();

创建一个接口 IInitialize

public interface IInitialize
{
    Task InitializeAsync(CancellationToken cancellationToken);
}

由你实现

internal sealed class ServiceA : IServiceA, IInitialize
{
    // [...]

    Task IInitialize.InitializeAsync(CancellationToken cancellationToken)
    {
        return EnsureIndexesAsync(cancellationToken);
    }
}
internal sealed class ServiceB : IServiceB, IInitialize
{
    // [...]

    Task IInitialize.InitializeAsync(CancellationToken cancellationToken)
    {
        return InitializeAsync(cancellationToken);
    }
}

并修改注册:不幸地,你使用的是众所周知、功能单一的 Microsoft.Extensions.DependencyInjection 容器,它不太方便地支持将一个类注册到多个接口。我的权宜之计是注册实现并转发,详见Andrew Lock的描述:链接

var collection = new ServiceCollection();
collection.AddSingleton<ServiceA>();
collection.AddSingleton<IServiceA>(x => x.GetRequiredService<ServiceA>());
collection.AddSingleton<IInitialize>(x => x.GetRequiredService<ServiceB>());
collection.AddSingleton<ServiceB>();
collection.AddSingleton<IServiceB>(x => x.GetRequiredService<ServiceB>());
collection.AddSingleton<IInitialize>(x => x.GetRequiredService<ServiceA>());
var provider = collection.BuildServiceProvider();

await Task.WhenAll(provider.GetRequiredService<IEnumerable<IInitialize>>()
    .Select(service => service.InitializeAsync(startupCancellation)));

await provider.GetRequiredService<IServiceB>().DoAsync();

看看其他现有的容器,它们在棘手场景中提供了更多实用特性。

如果你不喜欢实现了多个接口的服务,你可以编写一个适配器。


以下是所有代码以及一个有点儿俗气的测试,使用 DateTime.Now。请欣赏。

using Microsoft.Extensions.DependencyInjection;

namespace AsyncInitialization;

public class Test
{
    [Fact]
    public async Task First_call_is_shorter_than_initialization()
    {
        CancellationToken startupCancellation = new();

        var collection = new ServiceCollection();
        collection.AddSingleton<ServiceA>();
        collection.AddSingleton<IServiceA>(x => x.GetRequiredService<ServiceA>());
        collection.AddSingleton<IInitialize>(x => x.GetRequiredService<ServiceB>());
        collection.AddSingleton<ServiceB>();
        collection.AddSingleton<IServiceB>(x => x.GetRequiredService<ServiceB>());
        collection.AddSingleton<IInitialize>(x => x.GetRequiredService<ServiceA>());
        var provider = collection.BuildServiceProvider();

        await Task.WhenAll(provider.GetRequiredService<IEnumerable<IInitialize>>()
            .Select(service => service.InitializeAsync(startupCancellation)));

        var start = DateTime.Now;
        await provider.GetRequiredService<IServiceB>().DoAsync();
        var duration = DateTime.Now - start;
        Assert.True(duration < TimeSpan.FromSeconds(1));
    }
}

public interface IInitialize
{
    Task InitializeAsync(CancellationToken cancellationToken);
}

public interface IServiceA
{
    Task UseAsync();
}

internal sealed class ServiceA : IServiceA, IInitialize
{
    private Task? _initialization;

    internal Task EnsureIndexesAsync(CancellationToken cancellationToken)
    {
        _initialization ??= Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);

        return _initialization;
    }

    public async Task UseAsync()
    {
        await EnsureIndexesAsync(CancellationToken.None);

        // do other stuff here that relies on initialisation
    }

    Task IInitialize.InitializeAsync(CancellationToken cancellationToken)
    {
        return EnsureIndexesAsync(cancellationToken);
    }
}

public interface IServiceB
{
    Task DoAsync();
}

internal sealed class ServiceB : IServiceB, IInitialize
{
    private Task? _initialization;
    private readonly IServiceA _serviceA;

    public ServiceB(IServiceA serviceA)
    {
        _serviceA = serviceA ?? throw new ArgumentNullException(nameof(serviceA));
    }

    internal Task InitializeAsync(CancellationToken cancellationToken)
    {
        _initialization ??= Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);

        return _initialization;
    }

    public async Task DoAsync()
    {
        await InitializeAsync(CancellationToken.None);

        // do other stuff here that relies on initialisation

        await _serviceA.UseAsync();
    }

    Task IInitialize.InitializeAsync(CancellationToken cancellationToken)
    {
        return InitializeAsync(cancellationToken);
    }
}

第四,与MongoDB友好相处

将返回类型包装成一个 Task 的通用做法在这里也同样适用

public interface IMongoDbContext
{
    Task<IMongoCollection<Board>> Boards { get; }

    // [...]
}

但正如你所注意的,它会导致相当丑陋的代码,需要 async 再次“解包”Task

private readonly Task<IMongoCollection<Board>> _boards;

// [...]

var boards = await _boards; // duuh
await boards.InsertOneAsync(board, cancellationToken: cancellationToken);

幸运地,你有一个中心入口点:IMongoDbContext。 与其把大范围的代码改动应用到所有调用点,不如通过使用装饰器用面向切面的方式来处理这个问题。思路是把额外的 await 降到 IMongoDbContext 里,这样各个调用点就不需要自己处理。你想要的大概是这样的:

public interface IMongoDbContext
{
    IAsyncMongoCollection<Board> Boards { get; }

    // [...]
}

不幸地,IMongoCollection 包含所有同步和异步方法。似乎没有纯粹的 async 接口可用。因此自己实现一个:

public interface IAsyncMongoCollection<TDocument>
{
    /// <summary>
    /// Inserts a single document.
    /// </summary>
    /// <param name="document">The document.</param>
    /// <param name="options">The options.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    /// <returns>
    /// The result of the insert operation.
    /// </returns>
    Task InsertOneAsync(TDocument document, InsertOneOptions options = null, CancellationToken cancellationToken = default(CancellationToken));

    // duplicate ALL async methods from IMongoCollection<TDocument> here or at least all that you plan to use in your app
}

包装器将包含确保初始化完成的逻辑。虽然这仍然是大量样板代码,但至少实现是集中化的。你也许可以通过IL weaving(中间语言织入)或代码生成器来编写得更易维护。

internal sealed class InitializationEnsuringAsyncMongoCollectionWrapper<TDocument> : IAsyncMongoCollection<TDocument>
{
    private readonly IMongoCollection<TDocument> _collection;
    private readonly Task _init;

    public InitializationEnsuringAsyncMongoCollectionWrapper(IMongoCollection<TDocument> collection, Task init)
    {
        _collection = collection ?? throw new ArgumentNullException(nameof(collection));
        _init = init ?? throw new ArgumentNullException(nameof(init));
    }

    public Task InsertOneAsync(TDocument document, InsertOneOptions options = null, CancellationToken cancellationToken = default)
    {
        return _init.ContinueWith((_) => _collection.InsertOneAsync(document, options, cancellationToken));
    }

    // [...]
}

在上下文中应用该包装器。

public sealed class MongoDbContext : IMongoDbContext, IHostedService
{
    private const string StrokeEventsCollectionName = "StrokeEvents";

    private readonly IMongoDatabase _database;
    public IAsyncMongoCollection<Board> Boards { get; }
    // [...]
    private readonly Task _init;

    public MongoDbContext(IMongoClient client, IConfiguration configuration, IHostApplicationLifetime cancellationToken)
    {
        var databaseName = configuration["MongoDB:DatabaseName"]
            ?? throw new InvalidOperationException("MongoDB database name is not configured.");

        _database = client.GetDatabase(databaseName);
        // Indexes should be created before access
        _init = Init(cancellationToken.ApplicationStopping);

        Boards = new InitializationEnsuringAsyncMongoCollectionWrapper(
            _database.GetCollection<Board>("Boards"),
            _init);

        // [...]
    }

    // [...]
}

那么你的调用点代码在没有初始 await 的情况下应该也没问题

private readonly Task<IMongoCollection<Board>> _boards;

// [...]

// var boards = await _boards; // gone now
await _boards.InsertOneAsync(board, cancellationToken: cancellationToken);
站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章