Как стать автором
Обновить
0
United Traders
Финтех

Как мы перевели конфигурирование наших сервисов с XML на YAML

Время на прочтение23 мин
Количество просмотров6.9K

Предыстория вопроса


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

Публикации

Изменить настройки темы

Информация

Сайт
unitedtraders.com
Дата регистрации
Численность
101–200 человек
Местоположение
Россия

Истории