
В рамках разработки продукта 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>(); ... }
При подключении его к контейнеру нашего сервиса мы может спокойно работать с отдельно настроенным объектом настроек, никак не пересекаясь с настройками самого приложения.
Однако, наш объект в контейнере ничего не знает про нашу сущность в домене и как работать с БД.

Описываем нового провайдера конфигурации
Напомним, что наша задача — хранить настройки в БД. А для этого нужно описать нового провайдера конфигурации 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 в БД в соответствии с описанной логикой.
В БД же наши настройки выглядят так:

Пример чтения настроек из БД
Для чтения настроек 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, описанного в нашем провайдере.
А что далее?
А дальше мы планируем добавлять всевозможные настройки в приложении в наше общее хранилище.
Мы надеемся, что наше решение вам будет полезно и вы поделитесь с нами вашими вопросами и замечаниями.
