Avoiding all DI antipatterns for types requiring asynchronous initialization

梦想的初衷 提交于 2019-11-28 18:15:25

The problem you have, and the application you're building, is a-typical. It’s a-typical for two reasons:

  1. you need (or rather want) asynchronous start-up initialization, and
  2. Your application framework (azure functions) supports asynchronous start-up initialization (or rather, there seems to be little framework surrounding it). This makes your situation a bit different from a typical scenario, which might make it a bit harder to discuss common patterns.

However, even in your case the solution is rather simple and elegant:

Extract initialization out of the classes that hold it, and move it into the Composition Root. At that point you can create and initialize those classes before registering them in the container and feed those initialized classes into the container as part of registrations.

This works well in your particular case, because you want to do some (one-time) start-up initialization. Start-up initialization is typically done before you configure the container (or sometimes after if it requires a fully composed object graph). In most cases I’ve seen, initialization can be done before, as can be done effectively in your case.

As I said, your case is a bit peculiar, compared to the norm. The norm is:

  • Start-up initialization is synchronous. Frameworks (like ASP.NET Core) typically do not support asynchronous initialization in the start-up phase
  • Initialization often needs to be done per-request and just-in-time rather than per-application and ahead-of-time. Often components that need initialization have a short lifetime, which means we typically initialize such instance on first use (in other words: just-in-time).

There is usually no real benefit of doing start-up initialization asynchronously. There is no practical performance benefit because, at start-up time, there will only be a single thread running anyway (although we might parallelize this, that obviously doesn’t require async). Also note that although some application types might deadlock on doing synch-over-async, in the Composition Root we know exactly which application type we are using and whether or not this will be a problem or not. A Composition Root is always application-specific. In other words, when we have initialization in the Composition Root of a non-deadlocking application (e.g. ASP.NET Core, Azure Functions, etc), there is typically no benefit of doing start-up initialization asynchronously.

Because in the Composition Root we know whether or not sync-over-async is a problem or not, we could even decide to do the initialization on first use and synchronously. Because the amount of initialization is finite (compared to per-request initialization) there is no practical performance impact on doing it on a background thread with synchronous blocking if we wish. All we have to do is define a Proxy class in our Composition Root that makes sure that initialization is done on first use. This is pretty much the idea that Mark Seemann proposed as answer.

I was not familiar at all with Azure Functions, so this is actually the first application type (except Console apps of course) that I know of that actually supports async initialization. In most framework types, there is no way for users to do this start-up initialization asynchronously at all. When we’re inside an Application_Start event in an ASP.NET application or in the Startup class of an ASP.NET Core application, for instance, there is no async. Everything has to be synchronous.

On top of that, application frameworks don’t allow us to build their framework root components asynchronously. So even if DI Containers would support the concept of doing asynchronous resolves, this wouldn’t work because of the ‘lack’ of support of application frameworks. Take ASP.NET Core’s IControllerActivator for instance. Its Create(ControllerContext) method allows us to compose a Controller instance, but the return type of the Create method is object, not Task<object>. In other words, even if DI Containers would provide us with a ResolveAsync method, it would still cause blocking because ResolveAsync calls would be wrapped behind synchronous framework abstractions.

In the majority of cases, you’ll see that initialization is done per-instance or at runtime. A SqlConnection, for instance, is typically opened per request, so each request needs to open its own connection. When we want to open the connection ‘just in time’, this inevitably results in application interfaces that are asynchronous. But be careful here:

If we create an implementation that is synchronous, we should only make its abstraction synchronous in case we are sure that there will never be another implementation (or proxy, decorator, interceptor, etc.) that is asynchronous. If we invalidly make the abstraction synchronous (i.e. have methods and properties that do not expose Task<T>), we might very well have a Leaky Abstraction at hand. This might force us to make sweeping changes throughout the application when we get an asynchronous implementation later on.

In other words, with the introduction of async we have to take even more care of the design of our application abstractions. This holds for your case as well. Even though you might only require start-up initialization now, are you sure that for the abstractions you defined (and AzureConnections as well) will never need just-in-time async initialization? In case the synchronous behavior of AzureConnections is an implementation detail, you will have to make it async right away.

Another example of this is your INugetRepository. Its members are synchronous, but that is clearly a Leaky Abstraction, because the reason it is synchronous is because its implementation is synchronous. Its implementation, however, is synchronous because it makes use of a legacy NuGet NuGet package that only has a synchronous API. It’s pretty clear that INugetRepository should be completely async, even though its implementation is synchronous.

In an application that applies async, most application abstractions will have mostly async members. When this is the case, it would be a no-brainer to make this kind of just-in-time initialization logic async as well; everything is already async.

To summarize:

  • In case you need start-up initialization: do it before or after configuring the container. This makes composing object graphs itself fast, reliable, and verifiable.
  • Doing initialization before configuring the container prevents Temporal Coupling, but might mean you will have to move initialization out of the classes that require it (which is actually a good thing).
  • Async start-up initialization is impossible in most application types. In the other application types it is typically unnecessary.
  • In case you require per-request or just-in-time initialization, there is no way around having asynchronous interfaces.
  • Be careful with synchronous interfaces if you’re building an asynchronous application, you might be leaking implementation details.

While I'm fairly sure the following isn't what you're looking for, can you explain why it doesn't address your question?

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

In order to keep the design clear, I only implemented one of the cloud properties, but the two others could be done in a similar fashion.

The AzureConnections constructor will not block, even if it takes significant time to initialise the various cloud objects.

It will, on the other hand, start the work, and since .NET tasks behave like promises, the first time you try to access the value (using Result) it's going to return the value produced by InitializeStorageAccount.

I get the strong impression that this isn't what you want, but since I don't understand what problem you're trying to solve, I thought I'd leave this answer so at least we'd have something to discuss.

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!