Универсально храним настройки приложения через IConfiguration

    image

    В рамках разработки продукта Docs Security Suit мы столкнулись с задачей хранения множества разнотипных настроек приложения как в БД, так и в конфигах. Да и еще так, чтобы их можно было удобно читать-писать. Здесь нам поможет интерфейс IConfiguration, тем более, что он универсальный и удобный для использования, что позволит хранить всевозможные настройки в одном месте

    Определяем задачи


    В ASP.Net Core приложениях появилась возможность работать с настройками приложения через интерфейс IConfiguration. По работе с ним написано не мало статей. Эта статья расскажет об опыте использования IConfiguration для хранения настроек нашего приложения, таких как настройки подключение к LDAP серверу, к SMTP серверу и т.п. Цель — настроить существующий механизм работы с конфигурациями приложения на работу с БД. В этом материале вы не найдете описание стандартного подхода использования интерфейса.

    Архитектура приложения построена у нас по DDD в сцепке с CQRS. К тому же, мы знаем, что объект интерфейса IConfiguration хранит все настройки в виде пары “ключ-значение”. Поэтому, мы вначале описали некую сущность настроек на домене в таком виде:

    public class Settings: Entity
        {
            public string Key { get; private set; }
    
            public string Value { get; private set; }
    
            protected Settings() { }
    
            public Settings(string key, string value)
            {
                Key = key;
                SetValue(value);
            }
    
            public void SetValue(string value)
            {
                Value = value;
            }
        }
    

    В качестве ORM в проекте используется EF Core. А за миграции отвечает FluentMigrator.
    Добавляем новую сущность в наш контекст:

    public class MyContext : DbContext
        {
            public MyContext(DbContextOptions options) : base(options)
            {
            }
    
            public DbSet<Settings> Settings { get; set; }
    
    	…
        }
    

    Далее для нашей новой сущности нужно описать конфигурацию EF:

    internal class SettingsConfiguration : IEntityTypeConfiguration<Settings>
        {	
            public void Configure(EntityTypeBuilder<Settings> builder)
            {
                builder.ToTable("Settings");
            }
        }
    

    И написать миграцию для этой сущности:

    [Migration(2019020101)]
    public class AddSettings: AutoReversingMigration
        {
            public override void Up()
            {
                Create.Table("Settings")
                    .WithColumn(nameof(Settings.Id)).AsInt32().PrimaryKey().Identity()
                    .WithColumn(nameof(Settings.Key)).AsString().Unique()
                    .WithColumn(nameof(Settings.Value)).AsString();
            }
        }
    

    А где же тут упомянутый IConfiguration?

    Применяем интерфейс IConfigurationRoot


    В нашем проекте есть api приложение построенное на ASP.NET Core MVC. И по умолчанию, мы используем IConfiguration для стандартного хранения настроек приложения, например подключения к БД:

    public Startup(IConfiguration configuration)
            {
                Configuration = configuration;
            }
    
           public IConfiguration Configuration { get; }
           public void ConfigureServices(IServiceCollection services)
            {
                void OptionsAction(DbContextOptionsBuilder options) => options.UseSqlServer(Configuration.GetConnectionString("MyDatabase"));
    
                services.AddDbContext<MyContext>(OptionsAction);
    
    	...
            }
    

    Эти настройки хранятся по умолчанию в переменных среды окружения:

    public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
               .ConfigureAppConfiguration((hostingContext, config) =>
               {
                   config.AddEnvironmentVariables();
               })
    

    И по идеи, мы можем использовать этот объект для хранения задуманных настроек, но тогда они будут пересекаться с общими настройками самого приложения (как упоминалось выше — подключения к БД)

    Для того чтобы отделить подключенные объекты в DI, решили использовать дочерний интерфейс IConfigurationRoot:

    public void ConfigureServices(IServiceCollection services)
            {
                services.AddScoped<IConfigurationRoot>();
    	...
            }
    

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

    Однако, наш объект в контейнере ничего не знает про нашу сущность в домене и как работать с БД.

    image

    Описываем нового провайдера конфигурации


    Напомним, что наша задача — хранить настройки в БД. А для этого нужно описать нового провайдера конфигурации IConfigurationRoot, унаследованного от ConfigurationProvider. Для корректной работы нового провайдера, мы должны описать метод чтения из БД — Load() и метод записи в БД — Set():

    public class EFSettingsProvider : ConfigurationProvider
        {
            public EFSettingsProvider(MyContext myContext)
            {
                _myContext = myContext;
            }
    
            private MyContext _myContext;
    
            public override void Load()
            {
                Data = _myContext.Settings.ToDictionary(c => c.Key, c => c.Value);
            }
    
            public override void Set(string key, string value)
            {
                base.Set(key, value);
    
                var configValues = new Dictionary<string, string>
                {
                    { key, value }
                };
    
                var val = _myContext.Settings.FirstOrDefault(v => v.Key == key);
    
                if (val != null && val.Value.Any())
                    val.SetValue(value);
                else
                    _myContext.Settings.AddRange(configValues
                        .Select(kvp => new Settings(kvp.Key, kvp.Value))
                        .ToArray());
    
                _myContext.SaveChanges();
            }
        }
    

    Далее, необходимо описать новый source для нашей конфигурации, который реализует IConfigurationSource:

    public class EFSettingsSource : IConfigurationSource
        {
            private DssContext _dssContext;
    
            public EFSettingSource(MyContext myContext)
            {
                _myContext = myContext;
            }
    
            public IConfigurationProvider Build(IConfigurationBuilder builder)
            {
                return new EFSettingsProvider(_myContext);
            }
        }
    

    И для простоты, добавляем расширение к IConfigurationBuilder:

    public static IConfigurationBuilder AddEFConfiguration(
                this IConfigurationBuilder builder,
                MyContext myContext)
            {
                return builder.Add(new EFSettingSource(myContext));
            }
    

    Теперь, мы можем указать описанного нами провайдера в месте, где мы подключаем объект к DI:

    public void ConfigureServices(IServiceCollection services)
            {
                services.AddScoped<IConfigurationRoot>(provider =>
                {
                    var myContext = provider.GetService<MyContext>();
                    var configurationBuilder = new ConfigurationBuilder();
                    configurationBuilder.AddEFConfiguration(myContext);
                    return configurationBuilder.Build();
                });
    
    	...
            }
    

    Что же нам дали наши манипуляции с новым провайдером?

    Примеры использования IConfigurationRoot


    Для начала определим некую модель Dto, которая будет транслироваться клиенту нашего приложения, например для хранения настроек подключения к ldap:

    public class LdapSettingsDto
        {
            public int Id { get; set; }
    
            public string UserName { get; set; }
    
            public string Password { get; set; }
    
            public string Address { get; set; }
        }
    

    Из “коробки” IConfiguration хорошо умеет записывать и читать один экземпляр объекта. А для работы с коллекцией нужны небольшие доработки.

    Для хранения нескольких однотипных объектов, мы написали расширение для IConfigurationRoot:

    public static void SetDataFromObjectProperties(this IConfigurationRoot config, object obj, string indexProperty = "Id")
            {
                //Получаем тип объекта
                var type = obj.GetType();
                int id;
                try
                {
                    //Получаем индекс экземпляра
                    id = int.Parse(type.GetProperty(indexProperty).GetValue(obj).ToString());
                }
                catch (Exception ex)
                {
                    throw new Exception($"Ошибка чтения свойства {indexProperty} объекта {type.Name}", ex.InnerException);
                }
    
                //Если индекс равен 0, то пробуем найти максимальный индекс в коллекции и присвоить его свойству indexProperty
                if (id == 0)
                {
                    var maxId = config.GetSection(type.Name)
                        .GetChildren().SelectMany(x => x.GetChildren());
                    var mm = maxId
                        .Where(c => c.Key == indexProperty)
                        .Select(v => int.Parse(v.Value))
                        .DefaultIfEmpty()
                        .Max();
                    id = mm + 1;
    
                    try
                    {
                        type.GetProperty(indexProperty).SetValue(obj, id);
                    }
                    catch (Exception ex)
                    {
                        throw new Exception($"Ошибка записи свойства {indexProperty} объекта {type.Name}", ex.InnerException);
                    }
                }
    
                //Бежим по свойствам объекта и записываем их в коллекцию
                foreach (var field in type.GetProperties())
                {
                    var key = $"{type.Name}:{id.ToString()}:{field.Name}";
                    
                    if (!string.IsNullOrEmpty(field.GetValue(obj)?.ToString()))
                    {
                        config[key] = field.GetValue(obj).ToString();
                    }
                }
            }
    

    Таким образом, мы можем работать с несколькими экземплярами наших настроек.

    Пример записи настроек в БД


    Как было упомянуто выше, в нашем проекте используется подход CQRS. Для записи настроек опишем простую команду:

    public class AddLdapSettingsCommand : IRequest<ICommandResult>
        {
            public LdapSettingsDto LdapSettings { get; }
    
            public AddLdapSettingsCommand(LdapSettingsDto ldapSettings)
            {
                LdapSettings = ldapSettings;
            }
        }
    

    А затем и обработчик нашей команды:

    public class AddLdapSettingsCommandHandler : IRequestHandler<AddLdapSettingsCommand, ICommandResult>
        {
            private readonly IConfigurationRoot _settings;
    
            public AddLdapSettingsCommandHandler(IConfigurationRoot settings)
            {
                _settings = settings;
            }
    
            public async Task<ICommandResult> Handle(AddLdapSettingsCommand request, CancellationToken cancellationToken)
            {
                try
                {
                    _settings.SetDataFromObjectProperties(request.LdapSettings);
                }
                catch (Exception ex)
                {
                    return CommandResult.Exception(ex.Message, ex);
                }
    
                return  await Task.Run(() => CommandResult.Success, cancellationToken);
            }
        }
    

    В итоге мы одной строчкой можем записывать данные наших настроек ldap в БД в соответствии с описанной логикой.

    В БД же наши настройки выглядят так:

    image

    Пример чтения настроек из БД


    Для чтения настроек ldap мы напишем простой запрос:

    public class GetLdapSettingsByIdQuery : IRequest<LdapSettingsDto>
        {
            public int Id { get; }
    
            public GetLdapSettingsByIdQuery(int id)
            {
                Id = id;
            }
        }
    

    А затем и обработчик нашего запроса:

    public class GetLdapSettingsByIdQueryHandler : IRequestHandler<GetLdapSettingsByIdQuery, LdapSettingsDto>
        {
            private readonly IConfigurationRoot _settings;
    
            public GetLdapSettingsByIdQueryHandler(IConfigurationRoot settings)
            {
                _settings = settings;
            }
    
            public async Task<LdapSettingsDto> Handle(GetLdapSettingsByIdQuery request, CancellationToken cancellationToken)
            {
                var ldapSettings = new List<LdapSettingsDto>();
                _settings.Bind(nameof(LdapSettingsDto), ldapSettings);
                var ldapSettingsDto = ldapSettings.FirstOrDefault(ls => ls.Id == request.Id);
    
                return await Task.Run(() => ldapSettingsDto, cancellationToken);
            }
        }
    

    Как видим из примера, с помощью метода Bind, мы наполняем наш объект ldapSettings данными из БД — по названию LdapSettingsDto мы определяем ключ (секцию) по которому нужно получить данные и далее происходит вызов метода Load, описанного в нашем провайдере.

    А что далее?


    А дальше мы планируем добавлять всевозможные настройки в приложении в наше общее хранилище.

    Мы надеемся, что наше решение вам будет полезно и вы поделитесь с нами вашими вопросами и замечаниями.
    Cross Technologies
    Системный интегратор и разработчик ПО

    Похожие публикации

    Комментарии 3

      +1
      Не плохо было бы предложить исходники тестовых примеров, чтобы можно было запустить у себя локально и более детально изучить использование подобного метода.
      Offtop:
      return await Task.Run(() => ldapSettingsDto, cancellationToken);

      А в чем смысл запускать дочернюю таску, там фактически IO bound операций нет же? Task.FromResult и все?
        0

        Я старался описать подход так, чтобы можно было в "один клик" создать приложение asp net core mvc, добавить туда вышеуказанный код и получит результат. Код взят с небольшой обфускацией из реального проекта, который защищен коммерческой тайной. Если у вас появятся сложности в реализации этого решения, тогда опишите конкретный кейс и мы вам постараемся дать совет как его решить.


        По поводу Task. Это больше шаблонный подход в написании результатов запросов в проекте. Смысла конечно нет в этом запуске. Можно обойтись и FromResult

        0
        Есть же консул

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое