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, описанного в нашем провайдере.

А что далее?


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

Мы надеемся, что наше решение вам будет полезно и вы поделитесь с нами вашими вопросами и замечаниями.

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


  1. alexs0ff
    31.10.2019 11:53
    +1

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

    return await Task.Run(() => ldapSettingsDto, cancellationToken);

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


    1. dmib85 Автор
      31.10.2019 22:27

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


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


  1. Ascar
    31.10.2019 12:43

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