Избегание всех антипаттернов DI для типов, требующих асинхронной инициализации

У меня есть тип Connections это требует асинхронной инициализации. Экземпляр этого типа используется несколькими другими типами (например, Storage), каждая из которых также требует асинхронной инициализации (статической, а не для каждого экземпляра, и эти инициализации также зависят от Connections). Наконец, мои логические типы (например, Logic) потребляет эти экземпляры хранилища. В настоящее время используется простой инжектор.

Я пробовал несколько разных решений, но всегда присутствует антипаттерн.


Явная инициализация (временная связь)

Решение, которое я сейчас использую, имеет антипаттерн Temporal Coupling:

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

Я инкапсулировал временную связь в метод, так что это не так плохо, как могло бы быть. Но, тем не менее, это антипаттерн и не так легко обслуживаемый, как хотелось бы.


Абстрактная фабрика (Sync-Over-Async)

Распространенным предлагаемым решением является шаблон Abstract Factory. Однако в этом случае мы имеем дело с асинхронной инициализацией. Таким образом, я мог бы использовать Abstract Factory, заставив инициализацию выполняться синхронно, но тогда он принимает антипаттерн "синхронизация по асинхронности". Мне действительно не нравится подход sync-over-async, потому что у меня есть несколько хранилищ, и в моем текущем коде они все инициализируются одновременно; поскольку это облачное приложение, изменение его на последовательную синхронность увеличит время запуска, а параллельная синхронная также не идеальна из-за потребления ресурсов.


Асинхронная абстрактная фабрика (неправильное использование абстрактной фабрики)

Я также могу использовать Abstract Factory с асинхронными фабричными методами. Однако с этим подходом есть одна серьезная проблема. Как комментирует Марк Симан, "любой DI-контейнер, достойный своей соли, сможет автоматически подключить экземпляр [factory] для вас, если вы зарегистрируете его правильно". К сожалению, это совершенно не соответствует действительности для асинхронных фабрик: в AFAIK нет 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, потому что она нарушает несколько важных основных правил, когда дело доходит до внедрения зависимости". Однако, игра строго "по основным правилам", очевидно, заставляет другие антипаттерны, которые кажутся намного хуже.

Вопрос: есть ли решение для асинхронной инициализации, позволяющее избежать всех антипаттернов?


Обновление: полная подпись для AzureConnections (упоминается выше как Connections):

public sealed class AzureConnections
{
  public AzureConnections();

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

  public async Task InitializeAsync();
}

2 ответа

Решение

Проблема, которая у вас есть, и приложение, которое вы создаете, типичны. Это типично по двум причинам:

  1. Вы нуждаетесь (или, скорее, хотите) в инициализации асинхронного запуска, и
  2. Платформа вашего приложения (функции Azure) поддерживает асинхронную инициализацию при запуске (или, скорее, ее окружение, кажется, мало). Это немного отличает вашу ситуацию от обычного сценария, что может усложнить обсуждение общих шаблонов.

Однако даже в вашем случае решение достаточно простое и элегантное:

Извлеките инициализацию из классов, которые ее содержат, и переместите инициализацию в корень композиции. На этом этапе вы можете создать и инициализировать эти классы, прежде чем зарегистрировать их в контейнере, и передать эти инициализированные классы в контейнер как часть регистрации.

Это хорошо работает в вашем конкретном случае, потому что вы хотите выполнить некоторую (единовременную) инициализацию при запуске. Начальная инициализация обычно выполняется перед настройкой контейнера, а иногда и после, если для этого требуется полностью составленный граф объектов. В большинстве случаев, которые я видел, инициализация может быть выполнена раньше, как это может быть эффективно сделано в вашем случае.

Как я уже сказал, ваш случай немного необычен по сравнению с нормой. Норма:

  • Инициализация при запуске является синхронной. Фреймворки (например, ASP.NET Core) обычно не поддерживают асинхронную инициализацию на этапе запуска
  • Инициализацию часто нужно выполнять по запросу, точно вовремя, а не по приложению, заблаговременно. Часто компоненты, которые нуждаются в инициализации, имеют короткое время жизни, что означает, что мы обычно инициализируем такой экземпляр при первом использовании (другими словами: точно в срок).

Как правило, нет реальной выгоды от асинхронной инициализации при запуске. Практического выигрыша в производительности нет, потому что во время запуска в любом случае будет работать только один поток (хотя мы можем распараллелить его, но для этого, очевидно, не требуется асинхронность). Также обратите внимание, что хотя некоторые типы приложений могут блокироваться при выполнении синхронизации по асинхронности, в корне композиции мы точно знаем , какой тип приложения мы используем, и будет ли это проблемой или нет. Корень композиции зависит от приложения. Другими словами, когда у нас есть инициализация в нашем корне композиции, обычно нет никакой выгоды делать асинхронную инициализацию запуска.

Поскольку в корне композиции мы знаем, является ли проблема синхронизации по асинхронности или нет, мы могли бы даже решить выполнить инициализацию при первом использовании и синхронно. Поскольку объем инициализации конечен (по сравнению с инициализацией для каждого запроса), практическое влияние на производительность при выполнении этого в фоновом потоке с синхронной блокировкой не будет, если мы захотим. Все, что нам нужно сделать, - это определить прокси-класс в нашем корне композиции, который гарантирует, что инициализация выполняется при первом использовании. Это в значительной степени идея, которую Марк Симанн предложил в качестве ответа.

Я совсем не был знаком с функциями Azure, так что это фактически первый тип приложения (за исключением, конечно, консольных приложений), который, как мне известно, поддерживает асинхронную инициализацию. В большинстве типов фреймворков пользователи вообще не могут выполнять эту инициализацию при запуске асинхронно. Когда мы внутри Application_Start Например, в событии ASP.NET или в классе запуска приложения ASP.NET Core отсутствует асинхронность. Все должно быть синхронно.

Кроме того, фреймворки приложений не позволяют асинхронно создавать их корневые компоненты. Даже если DI Containers будет поддерживать концепцию выполнения асинхронных разрешений, это не сработает из-за "отсутствия" поддержки каркасов приложений. Возьмите ядро ​​ASP.NET IControllerActivator например. это Create(ControllerContext) Метод позволяет нам создать экземпляр контроллера, но тип возвращаемого значения object не Task<object>, Другими словами, даже если DI Containers предоставит нам ResolveAsync метод, он по-прежнему вызывает блокировку, потому что ResolveAsync вызовы будут заключены в синхронные абстракции фреймворка.

В большинстве случаев вы увидите, что инициализация выполняется для каждого экземпляра или во время выполнения. Например, SqlConnections обычно открываются для каждого запроса, поэтому каждый запрос должен открывать свое собственное соединение. Когда мы хотим открыть соединение "вовремя", это неизбежно приводит к асинхронным интерфейсам приложений. Но будьте осторожны здесь:

Если мы создаем реализацию, которая является синхронной, мы должны делать ее абстракцию синхронной только в том случае, если мы уверены, что никогда не будет другой реализации (или прокси, декоратора, перехватчика и т. Д.), Которая будет асинхронной. Если мы неправильно сделаем абстракцию синхронной (то есть имеем методы и свойства, которые не раскрывают Task<T> ), у нас вполне может быть Leaky Abstraction в наших руках. Это может привести к тому, что мы сделаем радикальные изменения во всем приложении, когда позже мы получим асинхронную реализацию.

Другими словами, с введением асинхронности мы должны еще больше заботиться о дизайне абстракций нашего приложения. Это относится и к вашему делу. Даже если вам может потребоваться только начальная инициализация сейчас, вы уверены, что для определенных вами абстракций (и AzureConnections также), вам никогда не понадобится синхронизация по времени? В случае синхронного поведения AzureConnections это деталь реализации, вам нужно будет сделать ее асинхронной сразу.

Другим примером этого является ваш INugetRepository. Его члены синхронны, но это явно Leaky Abstraction, потому что причина его синхронности в том, что его реализация синхронна. Однако его реализация является синхронной, поскольку она использует устаревший пакет NuGet NuGet, который имеет только синхронный API. Довольно ясно, что INugetRepository должен быть полностью асинхронным, хотя его реализация является синхронной.

В приложении, которое применяет асинхронность, большинство абстракций приложения будут иметь в основном асинхронные члены. Когда это так, было бы не сложно сделать этот тип логики инициализации точно в срок асинхронным; все уже асинхронно.

Подвести итоги:

  • Если вам нужна инициализация при запуске: сделайте это до или после настройки контейнера или после. Это делает составление графов объектов быстрым, надежным и проверяемым.
  • Выполнение инициализации перед настройкой контейнера предотвращает временную связь, но может означать, что вам придется удалить инициализацию из классов, которые в ней нуждаются (что я на самом деле считаю хорошей вещью).
  • В большинстве типов приложений асинхронная инициализация запуска невозможна, в других типах она обычно не требуется.
  • В случае, если вам требуется инициализация по запросу или только по времени, невозможно обойтись без асинхронных интерфейсов.
  • Будьте осторожны с синхронными интерфейсами, если вы создаете асинхронное приложение, возможно, вы пропускаете детали реализации.

Хотя я уверен, что следующее не то, что вы ищете, вы можете объяснить, почему это не отвечает на ваш вопрос?

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,

У меня сложилось сильное впечатление, что это не то, что вы хотите, но, поскольку я не понимаю, какую проблему вы пытаетесь решить, я решил оставить этот ответ, чтобы, по крайней мере, у нас было что обсудить.

Похоже, вы пытаетесь сделать то же, что и я, с моим синглтон-классом прокси.

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