Подтвердить что ты не робот

Избегайте всех антипаттернов 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);
  }
}

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


Аннотация Factory (Sync-Over-Async)

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


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

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

Итак, решение Abstract Asynchronous Factory потребовало бы, чтобы я использовал явные фабрики, по крайней мере 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);
}

Это вызывает несколько собственных проблем:

  • Все мои учетные записи Factory должны явно выгружать зависимости из контейнера и передавать их на CreateAsync. Таким образом, контейнер DI больше не работает, вы знаете, инъекции зависимостей.
  • Результаты этих вызовов Factory имеют срок службы, которые больше не управляются контейнером DI. Каждый Factory теперь отвечает за управление жизненным циклом, а не за контейнер DI. (С синхронным тестом Factory это не проблема, если Factory зарегистрирован соответствующим образом).
  • Любой метод, использующий эти зависимости, должен быть асинхронным - поскольку даже логические методы должны ждать завершения инициализации хранилища/соединений. Это не очень важно для меня в этом приложении, так как мои методы хранения все равно асинхронны, но это может быть проблемой в общем случае.

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

Другое, менее распространенное решение состоит в том, чтобы каждый член типа ожидал своей собственной инициализации:

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

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


Итак, на самом деле есть две перспективы дизайна 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();
}
4b9b3361

Ответ 1

У вас есть проблема, и приложение, которое вы создаете, является типичным. Его а-типичный по двум причинам:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Подводя итог:

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

Ответ 2

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

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 ведут себя как promises, при первом попытке получить доступ к значению (используя Result) он вернет значение, созданное InitializeStorageAccount.

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