Перевод конфигурации сервисов с XML на YAML | OTUS
Запланируйте обучение с выгодой в Otus!
-15% на все курсы до 22.11 Забрать скидку! →
Выбрать курс

Перевод конфигурации сервисов с XML на YAML

C__HeadlineSEO_970x70-1801-f7b24e.png

Однажды в компании, где я работаю, было принято решение перейти на конфигурирование через модный YAML. Какие проблемы при этом перед нами встали, и как мы их решили — в этой статье.

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

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

  1. YamlDotNet github.com/aaubry/YamlDotNet. Из описания библиотеки (перевод): YamlDotNet — это .NET библиотека для YAML. YamlDotNet предоставляет низкоуровневые парсер и генератор YAML, а также высокоуровневую объектную модель, схожую с XmlDocument. Также сюда включена библиотека сериализации, которая позволяет читать и записывать объекты из/в YAML-потоков.
  2. NetEscapades.Configurationgithub.com/andrewlock/NetEscapades.Configuration. Это непосредственно провайдер конфигураций (в смысле Microsoft.Extensions.Configuration.IConfigurationSource, активно используемого в ASP.NET Core приложениях), который читает YAML-файлы, используя как раз, упомянутый выше YamlDotNet.

Подробнее о том, как использовать указанные библиотеки можно почитать вот тут: https://andrewlock.net/creating-a-custom-iconfigurationprovider-in-asp-net-core-to-parse-yaml/.

Переход к YAML

Сам переход мы осуществили в два этапа: сначала просто перешли от XML к YAML, но сохранив плоскую иерархию конфиг-файлов, а затем уже ввели секции в YAML-файлах. Эти этапы можно было, в принципе, объединить в один, и для простоты изложения я именно так и сделаю. Все описываемые далее действия применялись последовательно к каждому сервису.

Подготовка YML-файла

Сперва требуется подготовить сам YAML-файл. Назовем его именем проекта (полезно для будущих интеграционных тестов, которые должны уметь работать с разными серверами и различать их конфиги между собой), положим файлик прямо в корне проекта, рядом с AppSettings:

es2hjofltziwk8fbqj3f5faxte0_1-1801-929440.png

В самом 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));
    }
}

На этом все, окончание читайте в моей статье на Хабре: https://habr.com/ru/company/utex/blog/438362/. Там же полностью выложен и итоговый код, который сюда не поместился :-).

Не пропустите новые полезные статьи!

Спасибо за подписку!

Мы отправили вам письмо для подтверждения вашего email.
С уважением, OTUS!

Автор
0 комментариев
Для комментирования необходимо авторизоваться
Популярное
Сегодня тут пусто
Черная пятница в Otus! ⚡️
Скидка 15% на все курсы до 22.11 →