Перейти к содержанию

Расширения для обработки конфигурационных файлов

Обработка конфигурационных файлов app.json выполняется по алгоритму, описанному в разделе Файлы app.json. Результирующая конфигурация доступна через объект IConfigurationManager из DI-контейнера.

Платформа предоставляет механизмы для расширения этого алгоритма, выполняя регистрации в DI-контейнерах Microsoft (с использованием классов WebRegistrator для регистрации в объекте IServiceCollection) и Unity (с использованием классов Registrator для регистрации в объекте IUnityContainer).

Ниже представлена таблица, определяющая, для каких из компонентов возможна регистрация расширений для модификации обработки конфигурационных файлов.

Компонент
DI-контейнер
web IServiceCollection
chronos IUnityContainer
tadmin IUnityContainer
Tessa Client IUnityContainer
Tessa Admin IUnityContainer

Note

Хотя для сервиса web объект IConfigurationManager использует зависимости из DI-контейнера IServiceCollection для построения конфигурации, в контейнере IUnityContainer регистрируется тот же экземпляр объекта IConfigurationManager, что и в объекте IServiceCollection.

Поэтому, независимо от того, в каком из контейнеров регистрируется объект-зависимость, использующий настройки из конфигурационных файлов, в нём достаточно получить объект IConfigurationManager через конструктор, в котором будет конфигурация, построенная посредством регистраций в IServiceCollection.

Следующие компоненты не имеют средств расширения в DI-контейнере, но в них возможно использовать сборки .NET (файлы .dll) для указания квалифицированных имён классов вида FullNamespace.UriExampleLoaderClass, UriExampleLibrary с расширяемой логикой (например, по ключу .loader.type в директиве include):

  • jinni;
  • monitor;
  • SchemeEditor.

Note

Сервис webbi не является .NET-сервисом, поэтому расширения для загрузки его конфигурационных файлов не предусмотрены.

Однако, можно использовать возможность загрузки уже полностью сформированного конфигурационного файла из стандартного потока ввода при помощи аргумента -cstdin. В этом случае можно использовать команду PrintJson консольной утилиты tadmin для формирования файла, который затем можно передать в webbi. При этом все загрузчики будут выполнены.

Пример такого запуска:

../tools/tadmin PrintJson ../webbi/app.json -q|./webbi -cstdin

Ниже рассмотрены примеры по расширению алгоритма обработки конфигурационных файлов.

Tip

Для того, чтобы вывести результирующий объект конфигурации после его загрузки со всеми расширениями, воспользуйтесь консольной командой tadmin PrintJson.

Убедитесь, что в папке extensions расположены актуальные версии сборок с расширениями Tessa.Extensions.*.dll.

Трансформация значений json-объектов посредством IConfigurationStorageTransformer

Посредством объектов, реализующих интерфейс IConfigurationStorageTransformer, возможно заменить любой json-объект, в зависимости от его содержимого, на объект того же или другого типа: атомарный (число, строка), массив или json-объект.

Это позволяет “расширить” содержимое хеш-таблицы, добавив, изменив или удалив любые свойства. Или, например, заменить json-объект на строку с паролем, который может храниться в зашифрованном виде.

Далее показан пример, заменяющий json-объект со свойством ".uri" на json-объект, загруженный по указанному в этом свойстве адресу.

Так, если есть файл app.json следующего содержимого:

{ "Settings": { "SimpleValue": 42, "RemoteValue": { ".uri": "https://my.configuration.server/values/my-value" } } }

То в результате обработки свойство SimpleValue останется без изменений, а свойство RemoteValue будет заменено на json-объект, загруженный через GET-запрос по указанному адресу https://my.configuration.server/values/my-value.

{ "Settings": { "SimpleValue": 42, "RemoteValue": { "Login": "admin", "Password": "1234" } } }

Чтобы такая логика по замене выполнялась для всех компонентов, поддерживающих расширения через DI-контейнеры, добавьте класс с этой логикой в проекте Tessa.Extensions.Shared. Создайте в нём папку Configuration и добавьте файл UriConfigurationStorageTransformer.cs:

using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tessa.Platform; using Tessa.Platform.Configuration; using Tessa.Platform.Storage; using Tessa.Platform.Web; using Unity;

namespace Tessa.Extensions.Shared.Configuration { [Order(100)] public class UriConfigurationStorageTransformer( [OptionalDependency] IHttpClientFactory? httpClientFactory = null) : ConfigurationStorageTransformerBase { protected IHttpClientFactory? HttpClientFactory { get; } = httpClientFactory;

protected override async ValueTask<object?> TransformCoreAsync( Dictionary<string, object?> obj, IConfigurationBuilderContext context, CancellationToken cancellationToken = default) => obj.TryGet<object>(".uri") is string uri && (this.HttpClientFactory ?? context.ServiceProvider?.TryResolve<IHttpClientFactory>()) is { } httpClientFactory && context.JsonSerializer is { } jsonSerializer ? await LoadValueAsync(uri, httpClientFactory, jsonSerializer, context, cancellationToken).ConfigureAwait(false) : obj;

private static async ValueTask<object?> LoadValueAsync( string requestUri, IHttpClientFactory httpClientFactory, IConfigurationJsonSerializer jsonSerializer, IConfigurationBuilderContext context, CancellationToken cancellationToken) { string text; using (var httpClient = httpClientFactory.CreateHttpClient()) { text = await httpClient.GetStringAsync(requestUri, cancellationToken).ConfigureAwait(false); }

return await jsonSerializer.DeserializeAsync(text, context, cancellationToken).ConfigureAwait(false); } } }

Класс имеет атрибут [Order], определяющий порядок выполнения относительно других реализаций IConfigurationStorageTransformer, выполняющих замену. Стандартные платформенные реализации имеют порядковые номера 1..99, чтобы выполнить логику перед ними - укажите отрицательное значение, а чтобы выполнить её после них - значение 100 и больше.

Также класс получает зависимость IHttpClientFactory из DI-контейнера для выполнения HTTP-запросов по сети. Если зависимость отсутствует, то объект не выполняет действий, чтобы не привести к ошибке чтения конфигурации для контейнеров, которые не имеют зарегистрированный объект IHttpClientFactory.

Tip

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

Она указана как опциональная, чтобы показать, как получать другие зависимости, которые могут быть не зарегистрированы в каких-то контейнерах (например, IDbScope).

Important

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

Использование этой зависимости с большой вероятностью приведёт к зависанию из-за рекурсии в асинхронном коде.

  1. Метод TransformCoreAsync получает исходный json-объект в параметре obj.
  2. Метод будет вызван для всех json-объектов любых уровней вложенности (в примере также для Settings), поэтому он возвращает исходный объект obj, если в нём отсутствует ключ ".uri".
  3. Иначе свойства obj отбрасываются, выполняется загрузка json-объекта сетевым HTTP GET-запросом httpClient.GetStringAsync, и результат десериализуется в виде Dictionary<string, object?> посредством вызова IConfigurationJsonSerializer.DeserializeAsync.

Для использования в компонентах, задействующих контейнер IUnityContainer, добавьте рядом класс регистратора Registrator.cs:

using Tessa.Platform.Configuration; using Unity;

namespace Tessa.Extensions.Shared.Configuration { [Registrator(Tag = RegistratorTag.GroupForConfiguration)] public sealed class Registrator : RegistratorBase { public override void RegisterUnity() { this.UnityContainer .RegisterSingleton<IConfigurationStorageTransformer, UriConfigurationStorageTransformer>( nameof(UriConfigurationStorageTransformer)); } } }

В атрибуте регистратора указан тег RegistratorTag.GroupForConfiguration, чтобы регистрация выполнялась во всех видах контейнеров, поддерживающих конфигурацию.

Important

Всегда указывайте тег RegistratorTag.GroupForConfiguration при регистрации зависимостей, расширяющих конфигурацию.

Класс UriConfigurationStorageTransformer регистрируется по интерфейсу IConfigurationStorageTransformer, и он должен иметь уникальное имя - используем строку с именем класса "UriConfigurationStorageTransformer".

Для использования класса UriConfigurationStorageTransformer в контейнере IServiceCollection (сервисе web) в проект Tessa.Extensions.Server.Web добавьте папку Configuration, и в неё добавьте класс WebRegistrator.cs:

using Microsoft.Extensions.DependencyInjection; using Tessa.Extensions.Shared.Configuration; using Tessa.Platform.Configuration; using Tessa.Web.Registrations;

namespace Tessa.Extensions.Server.Web.Configuration { [WebRegistrator] public sealed class WebRegistrator : WebRegistratorBase { public override void RegisterServices() { this.Services .AddSingleton<IConfigurationStorageTransformer, UriConfigurationStorageTransformer>(); } } }

Класс UriConfigurationStorageTransformer регистрируется по интерфейсу IConfigurationStorageTransformer, при этом ключ указывать не надо.

Загрузчик IConfigurationItemSourceLoader для переопределения включаемых файлов

Директива .include позволяет включать содержимое других файлов app.json в текущую загружаемую конфигурацию.

Для того, чтобы вместо включения файла по указанному пути определить, каким образом загружается json-объект включаемого конфигурационного файла, используется загрузчик.

В примере рассматривается класс загрузчика UriConfigurationLoader, получающий в параметрах HTTP-адрес, по которому запрашивается содержимое включаемого файла.


Первый способ указания загрузчика - по строке с ключом, по которому он зарегистрирован в DI-контейнере для интерфейса IConfigurationItemSourceLoader. Ключ указывается в свойстве .loader для объекта директивы.

Для следующего файла app.json:

{ ".include": [ "app-*.json", { ".loader": "uri", "uri": "https://my.configuration.server/files/app1.json" } ] }

  1. Сначала будут включены конфигурационные файлы по маске app-*.json в папке текущего файла app.json.
  2. Затем выполняется загрузчик, зарегистрированный в DI-контейнере по ключу "uri". Он получает параметры с HTTP-адресом в свойстве uri и выполняет GET-запрос по указанному адресу для получения json-объекта, объединяемого с текущей конфигурацией.

Например, если в папке отсутствуют другие файлы app-*.json и по адресу https://my.configuration.server/files/app1.json возвращается такой json-объект:

{ "Settings": { "Login": "admin", "Password": "1234" } }

Тогда этот же объект будет содержимым конфигурации после обработки файла app.json.


Второй способ указания загрузчика - по квалифицированному имени типа (полное имя типа и имя сборки через запятую), указанное в свойстве ".loader.type".

Класс этого типа должен реализовывать интерфейс IConfigurationItemSourceLoader и иметь конструктор по умолчанию.

{ ".include": [ "app-*.json", { ".loader.type": "Tessa.Extensions.Shared.Configuration.UriConfigurationLoader, Tessa.Extensions.Shared", "uri": "https://my.configuration.server/files/app2.json" } ] }

Tip

Такой способ позволяет расширить загрузку в компонентах без наличия механизма расширений в DI-контейнерах (сервисы jinni, monitor).

Класс загрузчика должен иметь конструктор по умолчанию, а зависимости из DI-контейнера могут быть получены через объект context.ServiceProvider.


В проекте Tessa.Extensions.Shared создайте папку Configuration, а в ней - файл UriConfigurationLoader.cs:

using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tessa.Platform.Configuration; using Tessa.Platform.Storage; using Tessa.Platform.Web;

namespace Tessa.Extensions.Shared.Configuration { public class UriConfigurationLoader : IConfigurationItemSourceLoader { public const string Key = "uri"; // константа для регистраций в DI

public ValueTask<IConfigurationBuilderItemSource?> GetItemSourceAsync( Dictionary<string, object?> parameters, IConfigurationBuilderContext context, CancellationToken cancellationToken = default) => new(parameters.TryGet<object>("uri") is string uri && context.ServiceProvider?.TryResolve<IHttpClientFactory>() is { } httpClientFactory ? new SingleConfigurationBuilderItemSource( new UriConfigurationBuilderItem(uri, httpClientFactory)) : null); } }

Класс не получает зависимости из DI-контейнера через конструктор, т.е. имеет конструктор по умолчанию. Причём зависимости запрашиваются из DI-контейнера через объект context.ServiceProvider. Это необходимо для демонстрации загрузки с указанием типа (через свойство ".loader.type").

Tip

Если вы используете загрузчик только в компонентах, поддерживающих расширения DI-контейнера, то оптимальным будет получение зависимостей через конструктор, по аналогии с классом UriConfigurationStorageTransformer, описанным выше.

  • Метод GetItemSourceAsync вызывается для каждого json-объекта в директиве .include, который соответствует этому классу загрузчика по свойству ".loader" или ".loader.type".
  • Задача метода - вернуть один или несколько объектов, которые отложенно загрузят json-объект включаемого файла, когда до них дойдёт очередь обработки.
  • Если метод возвращает null, то дополнительного включения конфигурационных файлов не выполняется.
  • Если указано свойство uri и в DI-контейнере присутствует зависимость IHttpClientFactory, то возвращается объект UriConfigurationBuilderItem, выполняющий фактическую загрузку json-объекта по адресу из uri.

Создайте рядом файл UriConfigurationBuilderItem.cs с классом, загружающим Dictionary<string, object?> для включения в конфигурацию:

using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tessa.Platform.Configuration; using Tessa.Platform.Web;

namespace Tessa.Extensions.Shared.Configuration { public class UriConfigurationBuilderItem( string requestUri, IHttpClientFactory httpClientFactory) : ConfigurationBuilderItemBase { public string RequestUri { get; } = NotEmptyOrThrow(requestUri);

public IHttpClientFactory HttpClientFactory { get; } = NotNullOrThrow(httpClientFactory);

public override string FilePathForError => this.RequestUri;

public override string ToString() => this.RequestUri;

protected override bool EqualsCore(IConfigurationBuilderItem other) => other is UriConfigurationBuilderItem otherItem && string.Equals(this.RequestUri, otherItem.RequestUri, StringComparison.OrdinalIgnoreCase);

public override int GetHashCode() => this.RequestUri.GetHashCode(StringComparison.OrdinalIgnoreCase);

protected override async ValueTask<Dictionary<string, object?>?> LoadStorageCoreAsync( IConfigurationBuilderContext context, CancellationToken cancellationToken = default) { if (context.JsonSerializer is not { } jsonSerializer) { return null; }

string text; using (var httpClient = this.HttpClientFactory.CreateHttpClient()) { text = await httpClient.GetStringAsync(this.RequestUri, cancellationToken).ConfigureAwait(false); }

return await jsonSerializer.DeserializeAsync(text, context, cancellationToken).ConfigureAwait(false); } } }

  1. Свойство RequestUri содержит HTTP-адрес для загрузки.
  2. Свойство HttpClientFactory - фабрика создания объектов HttpClient, используемых для выполнения HTTP-запросов (фабрика передана из класса загрузчика).
  3. Свойство FilePathForError - имя текущего объекта, используется для логирования ошибок загрузки (если метод LoadStorageCoreAsync выбросит исключение).
    • Свойство может вернуть null, тогда в логе не будет указано имя проблемной записи в массиве директивы .include. Поэтому рекомендуется указать любую строку, которая поможет в отладке возможных проблем.
    • В дополнение к логированию, значение свойства может быть выведено на консоль при использовании в утилите tadmin или на странице /check веб-сервисов. Если ошибка произошла на стороне веб-сервиса и страница /check отключена, то имя не будет возвращено на клиент (показано пользователю).
  4. Определение метода ToString() опционально, может быть полезно в отладке.
  5. Определения методов EqualsCore и GetHashCode позволяют указать, что конфигурационный файл не будет загружен и обработан дважды для одного и того же адреса RequestUri без учёта регистра (если один адрес упомянут несколько раз в директиве .include в одном и том же или в разных конфигурационных файлах). Если эти методы не переопределены, то повторная загрузка и обработка допустимы.
  6. Метод LoadStorageCoreAsync содержит логику загрузки - выполнение GET-запроса по адресу this.RequestUri, а затем его десериализация в хеш-таблицу Dictionary<string, object?> вызовом IConfigurationJsonSerializer.DeserializeAsync.
    • Возврат null аналогичен отмене загрузки, т.е. объединение с конфигурации пропускается и происходит переход к следующей записи в массиве директивы .include.

Для регистрации в контейнере IUnityContainer по ключу "uri" создайте рядом файл Registrator.cs:

using Tessa.Platform.Configuration; using Unity;

namespace Tessa.Extensions.Shared.Configuration { [Registrator(Tag = RegistratorTag.GroupForConfiguration)] public sealed class Registrator : RegistratorBase { public override void RegisterUnity() { this.UnityContainer .RegisterSingleton<IConfigurationItemSourceLoader, UriConfigurationLoader>( UriConfigurationLoader.Key); } } }

Класс UriConfigurationLoader регистрируется по интерфейсу IConfigurationItemSourceLoader и ключу "uri", который задаётся в свойстве ".loader".

Для использования класса UriConfigurationStorageTransformer в контейнере IServiceCollection (сервисе web) в проект Tessa.Extensions.Server.Web добавьте папку Configuration, и в неё добавьте класс WebRegistrator.cs:

using Microsoft.Extensions.DependencyInjection; using Tessa.Extensions.Shared.Configuration; using Tessa.Platform.Configuration; using Tessa.Web.Registrations;

namespace Tessa.Extensions.Server.Web.Configuration { [WebRegistrator] public sealed class WebRegistrator : WebRegistratorBase { public override void RegisterServices() { this.Services .AddKeyedSingleton<IConfigurationItemSourceLoader, UriConfigurationLoader>( UriConfigurationLoader.Key); } } }

Класс UriConfigurationLoader регистрируется по интерфейсу IConfigurationItemSourceLoader, при этом передаётся строка с ключом "uri".

Note

Для использования посредством указания типа в свойстве ".loader.type" не требуется выполнять регистрацию в DI-контейнерах.


Для кастомизированных загрузчиков, выполняющих обращение к внешней системе, рекомендуется разделять загрузку по умолчанию, производимую при запуске приложения для определения базовых свойств (таких как PlatformDependencies и ProbingPath), и загрузку в Dependency Injection контейнере, связанную с полезными функциями приложения (включая строки подключения к базам данных, Redis и др.). В противном случае загрузка выполнится дважды, причём результаты первой загрузки будут отброшены.

Для этого поместите директиву .include внутрь директивы .if, выполняемой, когда объявлен служебный символ di:

{ ".include": [ "app-*.json" ], ".if": [ "di", { ".include": { ".loader.type": "Tessa.Extensions.Shared.Configuration.UriConfigurationLoader, Tessa.Extensions.Shared", "uri": "https://my.configuration.server/files/app2.json" } } ] }

Загрузчик с методом Invoke для переопределения включаемых файлов

Посредством свойства ".loader.type" возможно указать класс, который не реализует интерфейс IConfigurationItemSourceLoader.

Возможны следующие варианты указания типа:

  1. "ExampleNamespace.Loaders.ExampleConfigurationLoader, ExampleLoadersLib" - квалифицированное имя типа ExampleConfigurationLoader, указанное для сборки ExampleLoadersLib, которая может быть загружена по имени сборки (на которую ссылается приложение).
  2. [ "ExampleNamespace.Loaders.ExampleConfigurationLoader", "ExampleLoadersLib" ] - имя типа с пространством имён и имя сборки, аналогично п.1.
  3. [ "ExampleNamespace.Loaders.ExampleConfigurationLoader", "extensions/Tessa.Extensions.Shared.dll" ] - имя типа с пространством имён и имя файла со сборкой. Если указан относительный путь, то он рассчитывается от папки с конфигурационными файлами, которая определяется переменной окружения TESSA_CONFIG_ROOT (по умолчанию соответствует папке с приложением). При этом приложение может не ссылаться на сборку с этим именем.

В этом случае, при наличии в нём метода Invoke, класс будет инстанциирован конструктором по умолчанию, а метод будет вызван. Если указанный тип не может быть загружен, то добавляется ошибка конфигурации.

Tip

Использование такого загрузчика позволяет создать библиотеку, содержащую логику загрузки, которая не ссылается на Tessa.dll, т.е. не использует никаких зависимых от платформы типов.

Метод Invoke получает следующие параметры:

  1. Первый параметр типа Dictionary<string, object?> - это содержимое json-объекта в директиве .include, включая свойство ".loader.type".
  2. Второй параметр типа Dictionary<string, object?> - это целиком содержание конфигурации на момент обработки (но без директивы .include), аналогично context.Storage.
  3. Третий параметр типа Dictionary<string, object?> - это значение context.Info, через который различные регистрации могут передавать дополнительные настройки.
    • Стандартные платформенные обработчики не используют context.Info. В собственных обработчиках вы можете передавать через этот объект любые значения, в т.ч. зависимости.
  4. Параметр типа CancellationToken получает токен отмены операции.
  5. Все прочие параметры при их наличии получает значение null. Если они не допускают null (например, типы-значения), то вызов метода приведёт к ошибке.

Important

Если какие-либо из перечисленных параметров отсутствуют, то они не передаются. Например, если метод имеет единственный параметр Dictionary<string, object?>, то параметры из п.2 и п.3 не передаются.

Метод Invoke возвращает одно из следующих значений:

  1. Dictionary<string, object?>? - результирующий json-объект для объединения с текущим конфигурационным файлом, или null, если обработка не требуется. Метод вызывается синхронно.
  2. ValueTask<Dictionary<string, object?>?> - асинхронная задача, возвращающая json-объект для объединения с текущим конфигурационным файлом, или null, если обработка не требуется. Метод вызывается асинхронно.
  3. Task<Dictionary<string, object?>?> - асинхронная задача, аналогичная ValueTask в п.2.
  4. Все прочие возвращаемые значения игнорируются, т.е. аналогичны возврату null. Метод вызывается синхронно.

Пример такого класса:

using System.Collections.Generic; using System.IO; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json;

namespace ExampleNamespace.Loaders { public sealed class ExampleConfigurationLoader { public async ValueTask<Dictionary<string, object?>?> Invoke( Dictionary<string, object?> parameters, Dictionary<string, object?> storage, Dictionary<string, object?> info, CancellationToken cancellationToken) { if (parameters.TryGetValue("uri", out var value) && value is string uri) { // зависимости необходимо создать вручную, DI недоступен string text; using (var httpClient = new HttpClient()) { text = await httpClient.GetStringAsync(uri, cancellationToken).ConfigureAwait(false); }

if (!string.IsNullOrEmpty(text)) { // в примере не ссылаемся на Tessa.dll, // поэтому напрямую используем библиотеку Newtonsoft.Json var serializer = new JsonSerializer(); var reader = new JsonTextReader(new StringReader(text)); await using var _ = reader.ConfigureAwait(false); var settings = serializer.Deserialize<Dictionary<string, object?>>(reader); return new Dictionary<string, object?> { { "Settings", settings } }; } }

return null; } } }

Пример упрощённого класса, который получает только параметры, т.е. значение json-объекта в директиве .include, и синхронно возвращает хеш-таблицу.

using System.Collections.Generic; using System.IO; using System.Net.Http; using Newtonsoft.Json;

namespace ExampleNamespace.Loaders { public sealed class ExampleConfigurationLoader { public Dictionary<string, object?>? Invoke(Dictionary<string, object?> parameters) { if (parameters.TryGetValue("uri", out var value) && value is string uri) { string text; using (var httpClient = new HttpClient()) { // для сетевых запросов нежелательно выполнять синхронную загрузку

// если бы метод изначально выполнял только синхронные действия, // то его имело бы смысл сделать синхронным

// здесь, для примера, мы асинхронный вызов преобразуем в синхронный

text = httpClient.GetStringAsync(uri).GetAwaiter().GetResult(); }

if (!string.IsNullOrEmpty(text)) { var serializer = new JsonSerializer(); using var reader = new JsonTextReader(new StringReader(text)); var settings = serializer.Deserialize<Dictionary<string, object?>>(reader); return new Dictionary<string, object?> { { "Settings", settings } }; } }

return null; } } }

Если класс расположен в сборке ExampleLoadersLib.dll, которая размещена в папке приложения, то его возможно использовать в файле app.json следующим образом:

{ ".include": [ { ".loader.type": [ "ExampleNamespace.Loaders.ExampleConfigurationLoader", "ExampleLoadersLib.dll" ], "uri": "https://my.configuration.server/files/app1.json" } ] }

Расширение директивы .include посредством IConfigurationIncludeHandler

Обработку, выполняемую для директивы .include, возможно полностью заменить, зарегистрировав объект, реализующий интерфейс IConfigurationIncludeHandler, если приведённые выше варианты с загрузчиками недостаточны для решения задачи.

В примере рассмотрена замена строк, содержащих ://, на загрузку содержимого по HTTP-адресу, а также аналогичная загрузка для объектов, содержащих свойства с адресом ".uri":

{ ".include": [ "app-*.json", "https://my.configuration.server/files/app1.json", { ".uri": "https://my.configuration.server/files/app2.json" } ] }

Здесь сначала будут включены файлы app-*.json в папке приложения (посредством стандартного алгоритма), затем загружено содержимое по HTTP-адресу https://my.configuration.server/files/app1.json, и после этого - содержимое по HTTP-адресу https://my.configuration.server/files/app2.json.

В проекте Tessa.Extensions.Shared создайте папку Configuration, в которую добавьте файл UriConfigurationIncludeHandler.cs:

using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tessa.Platform; using Tessa.Platform.Configuration; using Tessa.Platform.Storage; using Tessa.Platform.Web; using Unity;

namespace Tessa.Extensions.Shared.Configuration { [Order(-1)] public class UriConfigurationIncludeHandler( [OptionalDependency] IHttpClientFactory? httpClientFactory = null) : ConfigurationIncludeHandlerBase { protected IHttpClientFactory? HttpClientFactory { get; } = httpClientFactory;

protected override ValueTask<IConfigurationBuilderItemSource?> GetItemSourceCoreAsync( object value, IConfigurationBuilderContext context, CancellationToken cancellationToken = default) { IConfigurationBuilderItemSource? result = null; if ((this.HttpClientFactory ?? context.ServiceProvider?.TryResolve<IHttpClientFactory>()) is { } httpClientFactory) { switch (value) { case string uri when uri.Contains("://", StringComparison.Ordinal): result = new SingleConfigurationBuilderItemSource( new UriConfigurationBuilderItem(uri, httpClientFactory)); break;

case Dictionary<string, object?> storage: if (storage.TryGet<object>(".uri") is string uri2) { result = new SingleConfigurationBuilderItemSource( new UriConfigurationBuilderItem(uri2, httpClientFactory)); }

break; } }

return new(result); } } }

Атрибут [Order] указывает порядок выполнения обработчиков.

Выполнение обработчика завершается, как только первый из них вернёт отличный от null результат. Поэтому, чтобы выполнить логику перед стандартными обработчиками, указан порядок -1.

Также класс получает зависимость IHttpClientFactory из DI-контейнера, являющаяся фабрикой создания объектов HttpClient, используемых для выполнения HTTP-запросов по сети. Если зависимость отсутствует, то объект не выполняет действий, чтобы не привести к ошибке чтения конфигурации для контейнеров, которые не имеют зарегистрированный объект IHttpClientFactory.

Important

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

Использование этой зависимости с большой вероятностью приведёт к зависанию из-за рекурсии в асинхронном коде.

  1. Метод GetItemSourceCoreAsync в параметре value получает очередное значение из массива директивы .include.
    • Это может быть либо строка, либо хеш-таблица Dictionary<string, object?>. Значение null и прочие значения никогда не передаются в любой обработчик.
  2. Если метод получил строку, содержащую подстроку ://, то возвращается объект UriConfigurationBuilderItem, выполняющий загрузку по указанной ссылке.
  3. Если метод получил хеш-таблицу с строковым ключом ".uri", то для значения этого ключа возвращается объект UriConfigurationBuilderItem.
  4. В противном случае метод возвращает null, что выполняет последующие обработчики IConfigurationIncludeHandler.

Содержимое класса UriConfigurationBuilderItem описано выше, также добавьте его в папку.

Для регистрации в контейнере IUnityContainer создайте рядом файл Registrator.cs:

using Tessa.Platform.Configuration; using Unity;

namespace Tessa.Extensions.Shared.Configuration { [Registrator(Tag = RegistratorTag.GroupForConfiguration)] public sealed class Registrator : RegistratorBase { public override void RegisterUnity() { this.UnityContainer .RegisterSingleton<IConfigurationIncludeHandler, UriConfigurationIncludeHandler>( nameof(UriConfigurationIncludeHandler)); } } }

Класс UriConfigurationIncludeHandler регистрируется по интерфейсу IConfigurationIncludeHandler, и он должен иметь уникальное имя - используем строку с именем класса "UriConfigurationIncludeHandler".

Для использования класса UriConfigurationIncludeHandler в контейнере IServiceCollection (сервисе web) в проект Tessa.Extensions.Server.Web добавьте папку Configuration, и в неё добавьте класс WebRegistrator.cs:

using Microsoft.Extensions.DependencyInjection; using Tessa.Extensions.Shared.Configuration; using Tessa.Platform.Configuration; using Tessa.Web.Registrations;

namespace Tessa.Extensions.Server.Web.Configuration { [WebRegistrator] public sealed class WebRegistrator : WebRegistratorBase { public override void RegisterServices() { this.Services .AddSingleton<IConfigurationIncludeHandler, UriConfigurationIncludeHandler>(); } } }

Класс UriConfigurationIncludeHandler регистрируется по интерфейсу IConfigurationIncludeHandler, при этом ключ указывать не надо.

Финализация после загрузки конфигурации посредством IConfigurationContextFinalizer

При обработке конфигурации различными объектами, такими как IConfigurationStorageTransformer и IConfigurationIncludeHandler, может потребоваться создать объект, требующий освобождения после использования, такой как объект подключения к Redis, и затем не освобождать его сразу, а положить в контекст построения конфигурации по некоторому ключу в свойство context.Info, чтобы его можно было использовать повторно (без открытия ещё одного подключения).

Финализацию таких объектов возможно произвести в объекте IConfigurationContextFinalizer, метод FinalizeAsync которого вызывается при освобождении контекста IConfigurationBuilderContext: после построения конфигурации или в случае невосстановимой ошибки при построении конфигурации, но перед тем, как другие служебные объекты из свойств IConfigurationBuilderContext будут освобождены. Так, в методе FinalizeAsync возможно получить объекты открытых подключений из context.Info и закрыть их.

Tip

Если метод FinalizeAsync в объекте IConfigurationContextFinalizer выбрасывает необработанное исключение, то оно логируется, но не предотвращает выполнение других объектов финализации.

Далее показан пример, заменяющий json-объект со свойством ".redis", где задана строка подключения к Redis, и свойством "key", где указан ключ в Redis, на строковое значение, полученное из Redis по этому ключу.

Так, если есть файл app.json следующего содержимого:

{ "Settings": { "SimpleValue": 42, "RemoteValue1": { ".redis": "localhost", "key": "key1" }, "RemoteValue2": { ".redis": "localhost", "key": "key2" } } }

То в результате обработки свойство SimpleValue останется без изменений, а свойства RemoteValue1 и RemoteValue2 будут заменены на значения из Redis, строка подключения к которому "localhost", и в свойствах "key1" и "key2" которого расположены заменяемые значения.

{ "Settings": { "SimpleValue": 42, "RemoteValue1": "value1", "RemoteValue2": "value2" } }

Поскольку оба свойства возвращаются из одного и того же сервиса Redis, который доступен по строке подключения "localhost", то более оптимальным является подход, где при замене первого свойства "RemoteValue1" подключение к Redis открывается, при замене второго свойства "RemoteValue2" оно повторно используется. Тогда подключение должно быть закрыто при завершении загрузки конфигурации, когда были обработаны все встреченные объекты со свойством ".redis". Именно для этого будет задействован объект IConfigurationContextFinalizer.

Чтобы такая логика по замене выполнялась для всех компонентов, поддерживающих расширения через DI-контейнеры, добавьте классы с этой логикой в проекте Tessa.Extensions.Shared. Создайте в нём папку Configuration и добавьте файл RedisConfigurationStorageTransformer.cs:

using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tessa.Platform; using Tessa.Platform.Configuration; using Tessa.Platform.Redis; using Tessa.Platform.Storage;

namespace Tessa.Extensions.Shared.Configuration { [Order(100)] public class RedisConfigurationStorageTransformer : ConfigurationStorageTransformerBase { // ключ в context.Info public const string Key = $"{nameof(RedisConfigurationStorageTransformer)}.Connections";

protected override async ValueTask<object?> TransformCoreAsync( Dictionary<string, object?> obj, IConfigurationBuilderContext context, CancellationToken cancellationToken = default) { // проверяем, что есть строка подключения по ключу ".redis", // и есть строка с ключом Redis по ключу "key" if (obj.TryGet<object>(".redis") is not string { Length: > 0 } redisConnectionString || obj.TryGet<object>("key") is not string { Length: > 0 } redisKey) { return obj; }

// получаем кэш объектов подключений (ключ = строка подключения) var connections = context.Info.TryGet<Dictionary<string, RedisConnectionProvider>>(Key); if (connections is null) { connections = new(); context.Info[ConnectionsKey] = connections; }

// если в кэше для строки подключения нет объекта, то создаём его if (!connections.TryGetValue(redisConnectionString, out var provider)) { provider = new RedisConnectionProvider( _ => new(redisConnectionString), context.ServiceProvider?.TryResolve<IRedisConnectionStringCleaner>());

connections[redisConnectionString] = provider; }

// открываем подключение к Redis, если оно ещё не открыто для объекта provider var connection = await provider.GetOpenedConnectionAsync( false, cancellationToken).ConfigureAwait(false);

// получаем значение из Redis по ключу redisKey return await connection.GetDatabase().StringGetAsync(redisKey).ConfigureAwait(false);

// если метод создал объект RedisConnectionProvider, то подключение к Redis открыто, // и объекть содержится в context.Info } } }

Подробнее создание объектов ConfigurationStorageTransformer описано выше.

Далее в ту же папку добавьте файл RedisConfigurationContextFinalizer.cs, который будет содержать логику по освобождению объектов RedisConnectionProvider, которые могут держать открытые подключения к серверам Redis.

using System; using System.Collections.Generic; using System.Threading.Tasks; using Tessa.Platform; using Tessa.Platform.Configuration; using Tessa.Platform.Redis; using Tessa.Platform.Storage;

namespace Tessa.Extensions.Shared.Configuration { [Order(100)] public class RedisConfigurationContextFinalizer : ConfigurationContextFinalizerBase { protected override async ValueTask FinalizeCoreAsync(IConfigurationBuilderContext context) { var connections = context.Info.TryGet<Dictionary<string, RedisConnectionProvider>>( RedisConfigurationStorageTransformer.ConnectionsKey);

if (connections is null) { return; }

foreach (var (_, provider) in connections) { try { await provider.DisposeAsync().ConfigureAwait(false); } catch (OperationCanceledException) { // игнорируем } catch (Exception ex) { // реализация RedisConnectionProvider не выбрасывает исключение, // которое может произойти при закрытии соединения с Redis, // но другой освобождаемый объект мог бы выбросить await context.ReportExceptionAsync(ex).ConfigureAwait(false); } }

context.Info.Remove(RedisConfigurationStorageTransformer.Key); } } }

Для использования в компонентах, задействующих контейнер IUnityContainer, добавьте рядом класс регистратора Registrator.cs:

using Tessa.Platform.Configuration; using Unity;

namespace Tessa.Extensions.Shared.Configuration { [Registrator(Tag = RegistratorTag.GroupForConfiguration)] public sealed class Registrator : RegistratorBase { public override void RegisterUnity() { this.UnityContainer .RegisterSingleton<IConfigurationStorageTransformer, RedisConfigurationStorageTransformer>( nameof(RedisConfigurationStorageTransformer)) .RegisterSingleton<IConfigurationContextFinalizer, RedisConfigurationContextFinalizer>( nameof(RedisConfigurationContextFinalizer)); } } }

  • Класс RedisConfigurationStorageTransformer регистрируется по интерфейсу IConfigurationStorageTransformer, и он должен иметь уникальное имя - используем строку с именем класса "RedisConfigurationStorageTransformer".
  • Класс RedisConfigurationContextFinalizer регистрируется по интерфейсу IConfigurationContextFinalizer, и он должен иметь уникальное имя - используем строку с именем класса "RedisConfigurationContextFinalizer".

Для использования классов RedisConfigurationStorageTransformer и RedisConfigurationContextFinalizer в контейнере IServiceCollection (сервисе web) в проект Tessa.Extensions.Server.Web добавьте папку Configuration, и в неё добавьте класс WebRegistrator.cs:

using Microsoft.Extensions.DependencyInjection; using Tessa.Extensions.Shared.Configuration; using Tessa.Platform.Configuration; using Tessa.Web.Registrations;

namespace Tessa.Extensions.Server.Web.Configuration { [WebRegistrator] public sealed class WebRegistrator : WebRegistratorBase { public override void RegisterServices() { this.Services .AddSingleton<IConfigurationStorageTransformer, RedisConfigurationStorageTransformer>() .AddSingleton<IConfigurationContextFinalizer, RedisConfigurationContextFinalizer>(); } } }

  • Класс RedisConfigurationStorageTransformer регистрируется по интерфейсу IConfigurationStorageTransformer, при этом ключ указывать не надо.
  • Класс RedisConfigurationContextFinalizer регистрируется по интерфейсу IConfigurationContextFinalizer, при этом ключ указывать не надо.
Back to top