Предыстория вопроса
Нашей компанией, среди прочего, разработаны несколько сервисов (точнее — 12), работающих бэкендом наших систем. Каждый из сервисов представляет собой Windows-службу и выполняет свои специфические задачи.
Хочется все эти сервисы перенести под *nix-ОС. Для этого надо отказываться от обёртки в виде Windows-служб и переходить с .NET Framework на .NET Standard.
Последнее требование приводит к необходимости избавиться от некоторого Legacy-кода, который не поддерживается в .NET Standard, в т.ч. от поддержки конфигурирования наших серверов через XML, реализованного с использованием классов из System.Configuration. Заодно таким образом решается и давняя проблема, связанная с тем, что в XML-конфигах мы время от времени ошибались при изменении настроек (например, иногда не туда ставили закрывающий тэг или забывали его вовсе), а замечательная читалка XML-конфигов System.Xml.XmlDocument молча проглатывает такие конфиги, выдавая совсем непредсказуемый результат.
Было решено перейти на конфигурирование через модный YAML. Какие проблемы при этом перед нами встали, и как мы их решили — в этой статье.
Что имеем
Как мы читаем конфигурацию из XML
Читаем XML стандартным и для большинства других проектов способом.
В каждом сервисе есть файл настроек .NET-проектов, называется AppSettings.cs, содержит все требующиеся сервису настройки. Примерно так:
[System.Configuration.SettingsProvider(typeof(PortableSettingsProvider))]
internal sealed partial class AppSettings : IServerManagerConfigStorage,
IWebSettingsStorage,
IServerSettingsStorage,
IGraphiteAddressStorage,
IDatabaseConfigStorage,
IBlackListStorage,
IKeyCloackConfigFilePathProvider,
IPrometheusSettingsStorage,
IMetricsConfig
{
}
Подобная техника разделения настроек на интерфейсы позволяет удобно использовать их в дальнейшем через DI-контейнер.
Вся основная магия по хранению настроек на самом деле скрыта в PortableSettingsProvider (см. атрибут класса), а также в файле дизайнера AppSettings.Designer.cs:
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "14.0.0.0")]
internal sealed partial class AppSettings : global::System.Configuration.ApplicationSettingsBase {
private static AppSettings defaultInstance = ((AppSettings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new AppSettings())));
public static AppSettings Default {
get {
return defaultInstance;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("35016")]
public int ListenPort {
get {
return ((int)(this["ListenPort"]));
}
set {
this["ListenPort"] = value;
}
}
...
Как видно, «за кулисами» скрыты все те свойства, которые мы добавляем в конфигурацию сервера, когда редактируем ее через дизайнер настроек в Visual Studio.
Наш класс PortableSettingsProvider, упомянутый выше, занимается непосредственно чтением XML-файла, а прочитанный результат уже используется в SettingsProvider для записи настроек в свойства AppSettings.
Пример XML-конфига, который мы читаем (большая часть настроек скрыта из соображений безопасности):
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<configSections>
<sectionGroup name="userSettings" type="System.Configuration.UserSettingsGroup">
<section name="MetricServer.Properties.Settings" type="System.Configuration.ClientSettingsSection" />
</sectionGroup>
</configSections>
<userSettings>
<MetricServer.Properties.Settings>
<setting name="MCXSettings" serializeAs="String">
<value>Inactive, ChartLen: 1000, PrintLen: 50, UseProxy: False</value>
</setting>
<setting name="KickUnknownAfter" serializeAs="String">
<value>00:00:10</value>
</setting>
...
</MetricServer.Properties.Settings>
</userSettings>
</configuration>
Какие YAML-файлы хотелось бы читать
Примерно такие:
VirtualFeed:
MaxChartHistoryLength: 10
Port: 35016
UseThrottling: True
ThrottlingIntervalMs: 50000
UseHistoryBroadcast: True
CalendarName: "EmptyCalendar"
UsMarketFeed:
UseImbalances: True
Проблемы перехода
Во-первых, конфиги в XML — «плоские», а в YAML — нет (поддерживаются секции и подсекции). Это хорошо видно в приведенных выше примерах. При использовании XML мы решали проблему плоских настроек вводом собственных парсеров, которые умеют строки определенного вида преобразовывать в наши более сложные классы. Пример такой сложной строки:
<setting name="MCXSettings" serializeAs="String">
<value>Inactive, ChartLen: 1000, PrintLen: 50, UseProxy: False</value>
</setting>
Заниматься такими преобразованиями при работе с YAML совсем не хочется. Но при этом мы ограничены существующей «плоской» структурой класса AppSettings: все свойства настроек в нем свалены в одну кучу.
Во-вторых, конфиги наших серверов — это не статичный монолит, мы их время от времени меняем прямо по ходу работы сервера, т.е. эти изменения надо уметь отлавливать «на лету», в рантайме. Для этого в XML-реализации мы наследуем наш AppSettings от INotifyPropertyChanged (на самом деле от него унаследован каждый интерфейс, который реализует AppSettings) и подписываемся на события обновления свойств настроек. Работает такой подход от того, что базовый класс System.Configuration.ApplicationSettingsBase «из коробки» реализует INotifyPropertyChanged. Подобное поведение надо сохранить и после перехода на YAML.
В-третьих, конфигов по каждому серверу у нас, на самом деле, не один, а целых два: один с дефолтными настройками, другой — с переопределенными. Это требуется для того, чтобы в каждом из нескольких инстансов серверов одного типа, слушающих разные порты и имеющих немного отличающиеся настройки, не приходилось полностью копировать весь набор настроек.
И еще одна проблема — доступ к настройкам идет не только через интерфейсы, но и прямым обращением к AppSettings.Default. Напомню как он объявлен в закулисном AppSettings.Designer.cs:
private static AppSettings defaultInstance = ((AppSettings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new AppSettings())));
public static AppSettings Default {
get {
return defaultInstance;
}
}
С учетом изложенного требовалось придумать новый подход к хранению настроек в AppSettings.
Решение
Инструментарий
Непосредственно для чтения YAML решили использовать готовые библиотеки, доступные через NuGet:
- YamlDotNet — github.com/aaubry/YamlDotNet. Из описания библиотеки (перевод):
YamlDotNet — это .NET библиотека для YAML. YamlDotNet предоставляет низкоуровневые парсер и генератор YAML, а также высокоуровневую объектную модель, схожую с XmlDocument. Также сюда включена библиотека сериализации, которая позволяет читать и записывать объекты из/в YAML-потоков.
- NetEscapades.Configuration — github.com/andrewlock/NetEscapades.Configuration. Это непосредственно провайдер конфигураций (в смысле Microsoft.Extensions.Configuration.IConfigurationSource, активно используемого в ASP.NET Core приложениях), который читает YAML-файлы, используя как раз, упомянутый выше YamlDotNet.
Подробнее о том, как использовать указанные библиотеки можно почитать вот тут.
Переход к YAML
Сам переход мы осуществили в два этапа: сначала просто перешли от XML к YAML, но сохранив плоскую иерархию конфиг-файлов, а затем уже ввели секции в YAML-файлах. Эти этапы можно было, в принципе, объединить в один, и для простоты изложения я именно так и сделаю. Все описываемые далее действия применялись последовательно к каждому сервису.
Подготовка YML-файла
Сперва требуется подготовить сам YAML-файл. Назовем его именем проекта (полезно для будущих интеграционных тестов, которые должны уметь работать с разными серверами и различать их конфиги между собой), положим файлик прямо в корне проекта, рядом с AppSettings:
В самом YML-файле для начала сохраним «плоскую» структуру:
VirtualFeed: "MaxChartHistoryLength: 10, UseThrottling: True, ThrottlingIntervalMs: 50000, UseHistoryBroadcast: True, CalendarName: EmptyCalendar"
VirtualFeedPort: 35016
UsMarketFeedUseImbalances: True
Наполнение AppSettings свойствами настроек
Перенесем все свойства из AppSettings.Designer.cs в AppSettings.cs, попутно избавляясь от ставших лишними атрибутов дизайнера и самого кода в get/set-частях.
Было:
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("35016")]
public int VirtualFeedPort{
get {
return ((int)(this["VirtualFeedPort"]));
}
set {
this["VirtualFeedPort"] = value;
}
}
Стало:
public int VirtualFeedPort { get; set; }
Удалим полностью AppSettings.Designer.cs за ненадобностью. Теперь, кстати говоря, можно полностью избавиться от секции userSettings в файле app.config, если он есть в проекте — там хранятся те самые дефолтные настройки, которые мы прописываем через дизайнер настроек.
Идем дальше.
Контроль изменения настроек «на лету»
Так как нам надо уметь ловить обновления наших настроек в рантайме, то требуется реализовать INotifyPropertyChanged в нашем AppSettings. Базового System.Configuration.ApplicationSettingsBase больше нет, соответственно, рассчитывать на какую-то магию не приходится.
Можно реализовать «в лоб»: добавив имплементацию метода, выкидывающего нужное событие, и вызывая его в сеттере каждого свойства. Но это лишние строки кода, которые к тому же надо будет копировать по всем сервисам.
Поступим красивее — введем вспомогательный базовый класс AutoNotifier, который фактически делает то же самое, но «за кулисами», прямо как делал ранее System.Configuration.ApplicationSettingsBase:
/// <summary>
/// Implements <see cref="INotifyPropertyChanged"/> for classes with a lot of public properties (i.e. AppSettings).
/// This implementation is:
/// - fairly slow, so don't use it for classes where getting/setting of properties is often operation;
/// - not for properties described in inherited classes of 2nd level (bad idea: Inherit2 -> Inherit1 -> AutoNotifier; good idea: sealed Inherit -> AutoNotifier)
/// </summary>
public abstract class AutoNotifier : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private readonly ConcurrentDictionary<string, object> _wrappedValues = new ConcurrentDictionary<string, object>(); //just to avoid manual writing a lot of fields
protected T Get<T>([CallerMemberName] string propertyName = null)
{
return (T)_wrappedValues.GetValueOrDefault(propertyName, () => default(T));
}
protected void Set<T>(T value, [CallerMemberName] string propertyName = null)
{
// ReSharper disable once AssignNullToNotNullAttribute
_wrappedValues.AddOrUpdate(propertyName, value, (s, o) => value);
OnPropertyChanged(propertyName);
}
public object this[string propertyName]
{
get { return Get<object>(propertyName); }
set { Set(value, propertyName); }
}
protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
Здесь атрибут [CallerMemberName] позволяет автоматически получать название свойства вызывающего объекта, т.е. AppSettings.
Теперь мы можем занаследовать наш AppSettings от этого базового класса AutoNotifier, а далее каждое свойство несколько видоизменить:
public int VirtualFeedPort { get { return Get<int>(); } set { Set(value); } }
С таким подходом наши классы AppSettings, даже содержащие довольно много настроек, выглядят компактно, и при этом полноценно реализовывают INotifyPropertyChanged.
Да, я знаю, что можно было бы ввести чуть больше магии, используя, например, Castle.DynamicProxy.IInterceptor, перехватывая изменения необходимых свойств и рейзя там события. Но такое решение показалось мне слишком перегруженным.
Чтение настроек из YAML-файла
Следующим шагом добавим саму читалку YAML-конфигурации. Это происходит где-то поближе к старту сервиса. Скрывая излишние детали, не относящиеся к рассматриваемой теме, получится нечто подобное:
public static IServerConfigurationProvider LoadServerConfiguration(IReadOnlyDictionary<Type, string> allSections)
{
IConfigurationBuilder builder = new ConfigurationBuilder().SetBasePath(ConfigFiles.BasePath);
foreach (string configFile in configFiles)
{
string directory = Path.GetDirectoryName(configFile);
if (!string.IsNullOrEmpty(directory)) //can be empty if relative path is used
{
Directory.CreateDirectory(directory);
}
builder = builder.AddYamlFile(configFile, optional: true, reloadOnChange: true);
}
IConfigurationRoot config = builder.Build();
// load prepared files and merge them
return new ServerConfigurationProvider<TAppSettings>(config, allSections);
}
В представленном коде ConfigurationBuilder, наверное, особого интереса не представляет — вся работа с ним аналогична работе с конфигами в ASP.NET Core. Но интерес представляют следующие моменты. Во-первых, «из коробки» мы получили также возможность объединять настройки из нескольких файлов. Это обеспечивает требование иметь хотя бы два конфиг-файла на каждый сервер, о чем я упоминал выше. Во-вторых, весь прочитанный конфиг мы передаем в некий ServerConfigurationProvider. Зачем?
Секции в YAML-файле
Ответим на этот вопрос попозже, а сейчас вернемся к требованию хранения иерархически структурированных настроек в YML-файле.
В принципе, реализовать это достаточно просто. Сначала в самом YML-файле введем требующуюся нам структуру:
VirtualFeed:
MaxChartHistoryLength: 10
Port: 35016
UseThrottling: True
ThrottlingIntervalMs: 50000
UseHistoryBroadcast: True
CalendarName: "EmptyCalendar"
UsMarketFeed:
UseImbalances: True
А теперь пойдем в AppSettings и научим его разделять наши свойства по секциям. Как-то так:
public sealed class AppSettings : AutoNotifier,
IWebSettingsStorage,
IServerSettingsStorage,
IServerManagerAddressStorage,
IGlobalCredentialsStorage,
IGraphiteAddressStorage,
IDatabaseConfigStorage,
IBlackListStorage,
IKeyCloackConfigFilePathProvider,
IPrometheusSettingsStorage,
IHeartBeatConfig,
IConcurrentAcceptorProperties,
IMetricsConfig
{
public static IReadOnlyDictionary<Type, string> Sections { get; } = new Dictionary<Type, string>
{
{typeof(IDatabaseConfigStorage), "Database"},
{typeof(IWebSettingsStorage), "Web"},
{typeof(IServerSettingsStorage), "Server"},
{typeof(IConcurrentAcceptorProperties), "ConcurrentAcceptor"},
{typeof(IGraphiteAddressStorage), "Graphite"},
{typeof(IKeyCloackConfigFilePathProvider), "Keycloak"},
{typeof(IPrometheusSettingsStorage), "Prometheus"},
{typeof(IHeartBeatConfig), "Heartbeat"},
{typeof(IServerManagerAddressStorage), "ServerManager"},
{typeof(IGlobalCredentialsStorage), "GlobalCredentials"},
{typeof(IBlackListStorage), "Blacklist"},
{typeof(IMetricsConfig), "Metrics"}
};
...
Как видно, мы добавили прямо в AppSettings словарик, где ключами выступают типы интерфейсов, которые реализовывает класс AppSettings, а значениями — заголовки соответствующих секций. Теперь мы можем сопоставить иерархию в YML-файле с иерархией свойств в AppSettings (хотя и не глубже, чем один уровень вложенности, но в нашем случае этого было достаточно).
Почему мы делаем это прямо здесь — в AppSettings? Потому что таким образом мы не размазываем информацию о настройках по разным сущностям, а кроме того, это самое естественное место, т.к. в каждом сервисе и, соответственно, в каждом AppSettings, свои секции настроек.
Если не нужна иерархия в настройках?
В принципе странный кейс, но у нас такое было именно на первом этапе, когда просто переходили от XML к YAML, без использования преимуществ YAML.
В этом случае весь этот список секций можно не хранить, да и ServerConfigurationProvider будет значительно проще (рассматривается далее).
Но важный момент — если мы решим оставить плоскую иерархию, то мы как раз-таки сможем выполнить требование о сохранении возможности обращаться к настройкам через AppSettings.Default. Для этого добавим вот такой простой публичный конструктор в AppSettings:
public static AppSettings Default { get; }
public AppSettings()
{
Default = this;
}
Теперь мы везде можем продолжать обращаться к классу с настройками через AppSettings.Default (при условии, что настройки уже были ранее прочитаны через IConfigurationRoot в ServerConfigurationProvider и, соответственно, AppSettings был проинстанциирован).
Если же плоская иерархия недопустима, то, как ни крути, придется избавляться от AppSettings.Default везде по коду и работать с настройками только через интерфейсы (что в принципе хорошо). Почему так — станет ясно дальше.
ServerConfigurationProvider
Специальный класс ServerConfigurationProvider, упомянутый ранее, занимается той самой магией, которая позволяет полноценно работать с новым иерархическим YAML-конфигом при наличии лишь плоского AppSettings.
Если не терпится — вот он.
Полный код ServerConfigurationProvider
/// <summary>
/// Provides different configurations for current server
/// </summary>
public class ServerConfigurationProvider<TAppSettings> : IServerConfigurationProvider
where TAppSettings : new()
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly IConfigurationRoot _configuration;
private readonly IReadOnlyDictionary<Type, string> _sectionsByInterface;
private readonly IReadOnlyDictionary<string, Type> _interfacesBySections;
/// <summary>
/// Section name -> config
/// </summary>
private readonly ConcurrentDictionary<string, TAppSettings> _cachedSections;
public ServerConfigurationProvider(IConfigurationRoot configuration, IReadOnlyDictionary<Type, string> allSections)
{
_configuration = configuration;
_cachedSections = new ConcurrentDictionary<string, TAppSettings>();
_sectionsByInterface = allSections;
var interfacesBySections = new Dictionary<string, Type>();
foreach (KeyValuePair<Type, string> interfaceAndSection in _sectionsByInterface)
{
//section names must be unique
interfacesBySections.Add(interfaceAndSection.Value, interfaceAndSection.Key);
}
_interfacesBySections = interfacesBySections;
_configuration.GetReloadToken()?.RegisterChangeCallback(OnConfigurationFileChanged, null);
}
private void OnConfigurationFileChanged(object _)
{
UpdateCache();
}
private void UpdateCache()
{
foreach (string sectionName in _cachedSections.Keys)
{
Type sectionInterface = _interfacesBySections[sectionName];
TAppSettings newSection = ReadSection(sectionName, sectionInterface);
TAppSettings oldSection;
if (_cachedSections.TryGetValue(sectionName, out oldSection))
{
UpdateSection(oldSection, newSection);
}
}
}
private void UpdateSection(TAppSettings oldConfig, TAppSettings newConfig)
{
foreach (PropertyInfo propertyInfo in typeof(TAppSettings).GetProperties().Where(p => p.GetMethod != null && p.SetMethod != null))
{
propertyInfo.SetValue(newConfig, propertyInfo.GetValue(oldConfig));
}
}
public IEnumerable<Type> AllSections => _sectionsByInterface.Keys;
public TSettingsSectionInterface FindSection<TSettingsSectionInterface>() where TSettingsSectionInterface : class
{
return (TSettingsSectionInterface)FindSection(typeof(TSettingsSectionInterface));
}
[CanBeNull]
public object FindSection(Type sectionInterface)
{
string sectionName = FindSectionName(sectionInterface);
if (sectionName == null)
{
return null;
}
//we must return same instance of settings for same requested section (otherwise changing of settings will lead to inconsistent state)
return _cachedSections.GetOrAdd(sectionName, typeName => ReadSection(sectionName, sectionInterface));
}
private string FindSectionName(Type sectionInterface)
{
string sectionName;
if (!_sectionsByInterface.TryGetValue(sectionInterface, out sectionName))
{
Logger.Debug("This server doesn't contain settings for {0}", sectionInterface.FullName);
return null;
}
return sectionName;
}
private TAppSettings ReadSection(string sectionName, Type sectionInterface)
{
TAppSettings parsed;
try
{
IConfigurationSection section = _configuration.GetSection(sectionName);
CheckSection(section, sectionName, sectionInterface);
parsed = section.Get<TAppSettings>();
if (parsed == null)
{
//means that this section is empty or all its properties are empty
return new TAppSettings();
}
ReadArrays(parsed, section);
}
catch (Exception ex)
{
Logger.Fatal(ex, "Something wrong during reading section {0} in config", sectionName.SafeSurround());
throw;
}
return parsed;
}
/// <summary>
/// Manual reading of array properties in config
/// </summary>
private void ReadArrays(TAppSettings settings, IConfigurationSection section)
{
foreach (PropertyInfo propertyInfo in GetPublicProperties(typeof(TAppSettings), needSetters: true).Where(p => typeof(IEnumerable<string>).IsAssignableFrom(p.PropertyType)))
{
ClearDefaultArrayIfOverridenExists(section.Key, propertyInfo.Name);
IConfigurationSection enumerableProperty = section.GetSection(propertyInfo.Name);
propertyInfo.SetValue(settings, enumerableProperty.Get<IEnumerable<string>>());
}
}
/// <summary>
/// Clears array property from default config to use overriden one.
/// Standard implementation merges default and overriden array by indexes - this is not what we need
/// </summary>
private void ClearDefaultArrayIfOverridenExists(string sectionName, string propertyName)
{
List<IConfigurationProvider> providers = _configuration.Providers.ToList();
if (providers.Count == 0)
{
return;
}
string propertyTemplate = $"{sectionName}:{propertyName}:";
if (!providers[providers.Count - 1].TryGet($"{propertyTemplate}{0}", out _))
{
//we should use array from default config, because overriden config has no overriden array
return;
}
foreach (IConfigurationProvider provider in providers.Take(providers.Count - 1))
{
for (int i = 0; ; i++)
{
string propertyInnerName = $"{propertyTemplate}{i}";
if (!provider.TryGet(propertyInnerName, out _))
{
break;
}
provider.Set(propertyInnerName, null);
}
}
}
private void CheckSection(IConfigurationSection section, string sectionName, Type sectionInterface)
{
ICollection<PropertyInfo> properties = GetPublicProperties(sectionInterface, needSetters: false);
var configProperties = new HashSet<string>(section.GetChildren().Select(c => c.Key));
foreach (PropertyInfo propertyInfo in properties)
{
if (!configProperties.Remove(propertyInfo.Name))
{
if (propertyInfo.PropertyType != typeof(string) && typeof(IEnumerable).IsAssignableFrom(propertyInfo.PropertyType))
{
//no way to distinguish absent array and empty array :(
Logger.Debug("Property {0} has no valuable items in configs section {1}", propertyInfo.Name, sectionName.SafeSurround());
}
else
{
Logger.Fatal("Property {0} not found in configs section {1}", propertyInfo.Name, sectionName.SafeSurround());
}
}
}
if (configProperties.Any())
{
Logger.Fatal("Unexpected config properties {0} in configs section {1}", configProperties.SafeSurroundAndJoin(), sectionName.SafeSurround());
}
}
private static ICollection<PropertyInfo> GetPublicProperties(Type type, bool needSetters)
{
if (!type.IsInterface)
{
return type.GetProperties().Where(x => x.GetMethod != null && (!needSetters || x.SetMethod != null)).ToArray();
}
var propertyInfos = new List<PropertyInfo>();
var considered = new List<Type>();
var queue = new Queue<Type>();
considered.Add(type);
queue.Enqueue(type);
while (queue.Count > 0)
{
Type subType = queue.Dequeue();
foreach (Type subInterface in subType.GetInterfaces())
{
if (considered.Contains(subInterface))
{
continue;
}
considered.Add(subInterface);
queue.Enqueue(subInterface);
}
PropertyInfo[] typeProperties = subType.GetProperties(BindingFlags.FlattenHierarchy | BindingFlags.Public | BindingFlags.Instance);
IEnumerable<PropertyInfo> newPropertyInfos = typeProperties.Where(x => x.GetMethod != null && (!needSetters || x.SetMethod != null) && !propertyInfos.Contains(x));
propertyInfos.InsertRange(0, newPropertyInfos);
}
return propertyInfos;
}
}
ServerConfigurationProvider параметризирован по классу настроек AppSettings:
public class ServerConfigurationProvider<TAppSettings> : IServerConfigurationProvider
where TAppSettings : new()
Это, как нетрудно догадаться, позволяет применять его сразу во всех сервисах.
В конструктор передается сам прочитанный конфиг (IConfigurationRoot), а также упомянутый выше словарик секций (AppSettings.Sections). Там же происходит подписка на обновления файла (мы ведь хотим в случае изменения YML-файла сразу подтягивать эти изменения к нам в рантайм?):
_configuration.GetReloadToken()?.RegisterChangeCallback(OnConfigurationFileChanged, null);
...
private void OnConfigurationFileChanged(object _)
{
foreach (string sectionName in _cachedSections.Keys)
{
Type sectionInterface = _interfacesBySections[sectionName];
TAppSettings newSection = ReadSection(sectionName, sectionInterface);
TAppSettings oldSection;
if (_cachedSections.TryGetValue(sectionName, out oldSection))
{
UpdateSection(oldSection, newSection);
}
}
}
Как видно, тут мы в случае обновления YML-файла пробегаемся по всем известным нам секциям и читаем каждую. Затем, если секция уже была прочитана ранее в кэш (т.е. она где-то в коде уже запрашивалась каким-то классом), то переписываем старые значения в кэше новыми.
Казалось бы — зачем читать каждую секцию, почему бы не читать только те, которые в кэше (т.е. востребованные)? Потому что в чтении секции у нас реализована проверка на корректность конфигурации. И в случае некорректных настроек выкидываются соответствующие алерты, логируются проблемы. О проблемах в изменениях конфига лучше узнавать как можно скорее, от того читаем все секции сразу же.
Обновление старых значений в кэше новыми значениями достаточно тривиально:
private void UpdateSection(TAppSettings oldConfig, TAppSettings newConfig)
{
foreach (PropertyInfo propertyInfo in typeof(TAppSettings).GetProperties().Where(p => p.GetMethod != null && p.SetMethod != null))
{
propertyInfo.SetValue(newConfig, propertyInfo.GetValue(oldConfig));
}
}
А вот с чтением секций не всё так просто:
private TAppSettings ReadSection(string sectionName, Type sectionInterface)
{
TAppSettings parsed;
try
{
IConfigurationSection section = _configuration.GetSection(sectionName);
CheckSection(section, sectionName, sectionInterface);
parsed = section.Get<TAppSettings>();
if (parsed == null)
{
//means that this section is empty or all its properties are empty
return new TAppSettings();
}
ReadArrays(parsed, section);
}
catch (Exception ex)
{
Logger.Fatal(ex, "Something wrong during reading section {0} in config", sectionName.SafeSurround());
throw;
}
return parsed;
}
Тут мы, прежде всего, читаем саму секцию, используя стандартный IConfigurationRoot.GetSection. Затем как раз-таки проверяем корректность прочитанной секции.
Далее прочитанную секцию биндим к типу наших сеттингов: section.GetТут мы сталкиваемся с особенностью YAML-парсера — он не различает пустую секцию (без параметров, т.е. отсутствующую) от секции, в которой все параметры пустые.
Вот подобный кейс:
VirtualFeed:
Names: []
Тут в секции VirtualFeed есть параметр Names с пустым списком значений, но YAML-парсер, к сожалению, скажет, что секция VirtualFeed вообще полностью пустая. Печально.
Ну и напоследок в этом методе реализовано немного уличной магии для поддержки IEnumerable-свойств в настройках. Добиться нормального чтения списков «из коробки» у нас не получилось.
ReadArrays(parsed, section);
...
/// <summary>
/// Manual reading of array properties in config
/// </summary>
private void ReadArrays(TAppSettings settings, IConfigurationSection section)
{
foreach (PropertyInfo propertyInfo in GetPublicProperties(typeof(TAppSettings), needSetters: true).Where(p => typeof(IEnumerable<string>).IsAssignableFrom(p.PropertyType)))
{
ClearDefaultArrayIfOverridenExists(section.Key, propertyInfo.Name);
IConfigurationSection enumerableProperty = section.GetSection(propertyInfo.Name);
propertyInfo.SetValue(settings, enumerableProperty.Get<IEnumerable<string>>());
}
}
/// <summary>
/// Clears array property from default config to use overriden one.
/// Standard implementation merges default and overriden array by indexes - this is not what we need
/// </summary>
private void ClearDefaultArrayIfOverridenExists(string sectionName, string propertyName)
{
List<IConfigurationProvider> providers = _configuration.Providers.ToList();
if (providers.Count == 0)
{
return;
}
string propertyTemplate = $"{sectionName}:{propertyName}:";
if (!providers[providers.Count - 1].TryGet($"{propertyTemplate}{0}", out _))
{
//we should use array from default config, because overriden config has no overriden array
return;
}
foreach (IConfigurationProvider provider in providers.Take(providers.Count - 1))
{
for (int i = 0; ; i++)
{
string propertyInnerName = $"{propertyTemplate}{i}";
if (!provider.TryGet(propertyInnerName, out _))
{
break;
}
provider.Set(propertyInnerName, null);
}
}
}
Как видно, мы находим все свойства, тип которых унаследован от IEnumerable и присваиваем в них значения из фиктивной «секции», именованной также как и интересующая нас настройка. Но перед этим не забываем проверить: а есть ли переопределенное значение этого перечислимого свойства во втором конфиг-файле? Если есть — то только его и берем, а настройки, прочитанные из базового конфиг-файла, зачищаем. Если этого не делать, то оба свойства (из базового файла и из переопределенного) будут автоматически слиты в один массив на уровне IConfigurationSection, причем ключами для объединения послужат индексы массивов. Получится какая-то мешанина вместо нормального переопределенного значения.
Показанный метод ReadSection в итоге используется и в главном методе класса: FindSection.
[CanBeNull]
public object FindSection(Type sectionInterface)
{
string sectionName = FindSectionName(sectionInterface);
if (sectionName == null)
{
return null;
}
//we must return same instance of settings for same requested section (otherwise changing of settings will lead to inconsistent state)
return _cachedSections.GetOrAdd(sectionName, typeName => ReadSection(sectionName, sectionInterface));
}
В принципе, тут и становится ясно, почему при поддержке секций мы никак не можем поддерживать AppSettings.Default: каждое обращение к новой (ранее непрочитанной) секции настроек через FindSection на самом деле будет выдавать нам новый инстанс класса AppSettings, хоть и прикастенный к нужному интерфейсу, и, соответственно, если бы мы использовали AppSettings.Default, то он бы переопределялся при каждом чтении новой секции и содержал бы означенными лишь те настройки, которые относятся к последней прочитанной секции (остальные имели бы дефолтные значения — NULL и 0).
Проверка корректности настроек в секции реализована следующим образом:
private void CheckSection(IConfigurationSection section, string sectionName, Type sectionInterface)
{
ICollection<PropertyInfo> properties = GetPublicProperties(sectionInterface, needSetters: false);
var configProperties = new HashSet<string>(section.GetChildren().Select(c => c.Key));
foreach (PropertyInfo propertyInfo in properties)
{
if (!configProperties.Remove(propertyInfo.Name))
{
if (propertyInfo.PropertyType != typeof(string) && typeof(IEnumerable).IsAssignableFrom(propertyInfo.PropertyType))
{
//no way to distinguish absent array and empty array :(
Logger.Debug("Property {0} has no valuable items in configs section {1}", propertyInfo.Name, sectionName.SafeSurround());
}
else
{
Logger.Fatal("Property {0} not found in configs section {1}", propertyInfo.Name, sectionName.SafeSurround());
}
}
}
if (configProperties.Any())
{
Logger.Fatal("Unexpected config properties {0} in configs section {1}", configProperties.SafeSurroundAndJoin(), sectionName.SafeSurround());
}
}
Тут прежде всего извлекаются все публичные свойства интересующего нас интерфейса (читай — секции настроек). И по каждому из этих свойств ищется соответствие в прочитанных настройках: если соответствие не найдено, то логируется соответствующая проблема, ведь это означает, что в файле конфига не хватает какой-то настройки. В конце дополнительно проверяется, остались ли какие-либо из прочитанных настроек несопоставленными с интерфейсом. Если такие есть, то опять же логируется проблема, т.к. это означает, что в файле конфига обнаружены свойства, не описанные в интерфейсе, чего тоже не должно быть в нормальной ситуации.
Возникает вопрос — а откуда у нас требование, что в прочитанном файле все настройки должны соответствовать имеющимся в интерфейсе по принципу «один-к-одному»? Дело в том, что на самом деле, как упоминалось выше, на этот момент прочитан не один файл, а сразу два — один с дефолтными настройками, а другой с переопределенными, и оба они смержены. Соответственно, на самом деле мы смотрим настройки не из одного файла, а полные. И в этом случае, конечно же, их набор должен соответствовать ожидаемым настройкам один к одному.
Также обратите внимание в приведенных выше исходниках на метод GetPublicProperties, который, казалось бы, всего лишь возвращает все публичные свойства интерфейса. Но он не так прост, как могло бы быть, по той причине, что иногда у нас интерфейс, описывающий настройки сервера, наследуется от другого интерфейса, и, соответственно, есть необходимость просматривать всю иерархию интерфейсов с тем, чтобы найти все публичные свойства.
Получение настроек сервера
С учетом изложенного выше для получения настроек сервера везде по коду мы обращаемся к интерфейсу следующего вида:
/// <summary>
/// Provides different configurations for current server
/// </summary>
public interface IServerConfigurationProvider
{
TSettingsSectionInterface FindSection<TSettingsSectionInterface>() where TSettingsSectionInterface : class;
object FindSection(Type sectionInterface);
IEnumerable<Type> AllSections { get; }
}
Первый метод этого интерфейса — FindSection — позволяет обращаться к интересующей секции настроек. Как-то так:
IThreadPoolProperties threadPoolProperties = ConfigurationProvider.FindSection<IThreadPoolProperties>();
Зачем нужны второй и третий метод — объясню далее.
Регистрация интерфейсов настроек
У нас в проекте в качестве IoC-контейнера используется Castle Windsor. Именно он поставляет в том числе и интерфейсы настроек сервера. Соответственно, эти интерфейсы требуется в нем зарегистрировать.
С этой целью написан простой Extension-класс, позволяющий упростить эту процедуру, чтобы не писать регистрацию всего набора интерфейсов в каждом сервере:
public static class ServerConfigurationProviderExtensions
{
public static void RegisterAllConfigurationSections(this IWindsorContainer container, IServerConfigurationProvider configurationProvider)
{
Register(container, configurationProvider, configurationProvider.AllSections.ToArray());
}
public static void Register(this IWindsorContainer container, IServerConfigurationProvider configurationProvider, params Type[] configSections)
{
var registrations = new IRegistration[configSections.Length];
for (int i = 0; i < registrations.Length; i++)
{
Type configSection = configSections[i];
object section = configurationProvider.FindSection(configSection);
registrations[i] = Component.For(configSection).Instance(section).Named(configSection.FullName);
}
container.Register(registrations);
}
}
Первый метод позволяет зарегистрировать все секции настроек (для этого и нужно свойство AllSections в интерфейсе IServerConfigurationProvider).
А второй метод используется в первом, и он автоматически читает заданную секцию настроек с использованием нашего ServerConfigurationProvider, тем самым записывает ее сразу в кэш ServerConfigurationProvider и регистрирует в Windsor.
Именно здесь и используется второй, непараметризированный, метод FindSection из IServerConfigurationProvider.
Остаётся лишь позвать в коде регистрации контейнера Windsor наш Extension-метод:
container.RegisterAllConfigurationSections(configProvider);
Вывод
Что получилось
Представленным способом удалось достаточно безболезненно перевести все настройки наших серверов с XML на YAML, при этом произведя минимум изменений по существующему коду серверов.
YAML-конфигурации, в отличие от XML, получились более читаемыми за счет не только большей лаконичности, но и поддержки разбиения на секции.
Мы не изобретали собственных велосипедов для парсинга YAML, а использовали готовые решения. Тем не менее, для интеграции их в реалии нашего проекта потребовались некоторые ухищрения, описанные в этой статье. Надеюсь, они будут полезны и читателям.
Удалось сохранить возможность отлавливания изменений настроек в веб-мордах наших серверов «на лету». Более того, бонусом появилась возможность также налету отлавливать изменения в самом YAML-файле (ранее приходилось перезагружать сервер при любых изменений в конфиг-файлах).
Мы сохранили возможность мержа двух файлов конфигов — дефолтных и переопределенных настроек, причем сделали это с использованием сторонних решений «из коробки».
Что не очень получилось
Пришлось отказаться от имевшейся ранее возможности сохранения изменений, примененных из веб-морд наших серверов, в конфиг-файлы, т.к. поддержка такой функциональности потребовала бы больших телодвижений, а бизнес-задачи перед нами такой в общем-то не стояло.
Ну и также пришлось отказаться от обращений к настройкам через AppSettings.Default, но это скорее плюс, чем минус.