В рамках разработки продукта 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, описанного в нашем провайдере.
А что далее?
А дальше мы планируем добавлять всевозможные настройки в приложении в наше общее хранилище.
Мы надеемся, что наше решение вам будет полезно и вы поделитесь с нами вашими вопросами и замечаниями.
alexs0ff
Не плохо было бы предложить исходники тестовых примеров, чтобы можно было запустить у себя локально и более детально изучить использование подобного метода.
Offtop:
А в чем смысл запускать дочернюю таску, там фактически IO bound операций нет же? Task.FromResult и все?
dmib85 Автор
Я старался описать подход так, чтобы можно было в "один клик" создать приложение asp net core mvc, добавить туда вышеуказанный код и получит результат. Код взят с небольшой обфускацией из реального проекта, который защищен коммерческой тайной. Если у вас появятся сложности в реализации этого решения, тогда опишите конкретный кейс и мы вам постараемся дать совет как его решить.
По поводу Task. Это больше шаблонный подход в написании результатов запросов в проекте. Смысла конечно нет в этом запуске. Можно обойтись и FromResult