Предыстория вопроса
Нашей компанией, среди прочего, разработаны несколько сервисов (точнее — 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, но это скорее плюс, чем минус.
