如何初始化通过依赖注入注册且相互依赖的异步服务?
在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 方法,它不仅引入你所遇到的时间耦合,而且还是一个泄漏的抽象。通过将其设为 internal,ServiceA 拥有对该方法的调用。
正如评论中所建议的,从 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);