В этой статье я хотел бы рассказать опыт нашей команды по созданию универсального конвертера данных. На первый взгляд звучит очень просто, что тут сложного? Взять один тип данных привести к другому типу. А если данные это структура? Тоже не трудно, вы скажете, просто нужно выполнить меппинг полей. Да, просто. Но когда целевых структур несколько, все они сложные и требуется конвертация “на лету”, да еще и с обогащением данных, то как говорится “надо думать”.
Перед командой была поставлена задача:
Написать конвертер данных из одной структуры в несколько других целевых структур. Причем формат хранения данных источника и данных назначения могут быть абсолютно произвольными. Конвертация должна выполняться на основе правил с возможностью повторного использования и редактирования. В процессе конвертации некоторые данные нужно перекодировать, например перевести строку “#ff0000” в строку “red”.
Вдобавок, как известно, все интеграционные конвертации пользователь хочет уметь читать и редактировать, т.е. разрабатываемый функционал должен быть вынесен на UI с возможностью редактирования.
Итак, приступим. В теории входные и выходные форматы могут быть любого типа (csv, json и т.д.). Для наглядности выберем формат XML.


Пример XML источника — "конвертировать ИЗ":


<Car>
    <Color>#ff0000</Color>
    <Length>5296 cm<Length>
    <Width>1848 cm</Width>
    <Price>31000 USD</Price>
</Car>

Пример XML назначения — "конвертировать В":


<Vehicle>
    <Body>
        <Exterior>
            <Color>red</Color>
        </Exterior>
        <Size>
            <MeasureUnit>ft</measureUnit>
            <Length>17.3753</Length>
            <Width>6.0630</Width>
        </Size>
    </Body>
    <Msrp>
        <Currency>RUB</Currency>
        <Value>1600000</Value>
    </Msrp>
</Vehicle>

Как вы видим, на пути возникают пересчеты и конвертации, меняется не только расположение значений в структуре, но и их типы, есть расчетные значения, причем в данных источника нет всех необходимых данных для успешного пересчета в конечный формат (требуется обогащение). Перечислю несколько:


  1. Цвет автомобиля Car.Color в источнике отображаются как RBG код “#ff0000”, а в объекте назначения его нужно перекодировать в словесную интерпретацию “red” в тег Vehicle.Body.Exterior.Color;
  2. Длина автомобиля Car.Lenght нужно распарсить на несколько составляющих, величину измерения и единицу измерения и перевести в американские футы, получившееся значение положить в Vehicle.Size.Length;
  3. Цену автомобиля Car.Price нужно также распарсить на составляющие, пересчитать по курсу ЦБ в рублях на дату пересчета положить в Vehicle.Msrp.

Выбор контейнера для доступа к данным


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


Класс-контейнер C# источника:


public class Car
{
    public string Color;
    public string Length;
    public string Width;
    public string Price;
}

Класс-контейнер C# назначения:


public class Vehicle {
    public Body Body;
    public Msrp Msrp;
}
public class Body
{
    public Exterior Exterior;
    public Size Size;
}
public class Msrp
{
    public string Currency;
    public decimal Value;
}
public class Exterior
{
    public string Color;
}
public class Size
{
    public string MeasureUnit;
    public decimal Length;
    public decimal Width;
}

Загрузка данных источника в контейнер


В .Net Framework есть готовые компоненты выполняющие десериализацию данных XML, с помощью которых получим экземпляр класса автоматично заполненный данными источника.
Если файл более специфичного формата, то не составит труда написать заказную библиотеку загрузки данных.


Доступ к данным контейнеров


Первое, что нам нужно научиться, это иметь единый способ доступа к данным контейнеров с произвольными структурами. Т.е. нам нужен доступ к метаданным контейнера. Это решается через рефлексию .Net. Мы можем добраться до любого свойства или поля класса, а зная тип и расположение данных мы сможем их модифицировать.
Для прямого указания структурного элемента (узла) будем использовать аналогию XPath для XML. Например, чтобы указать в источнике нужный нам узел достаточно указать строчку “Car.Color”.


Правила конвертации данных контейнера-источника в контейнер-назначения


Итак, мы имеем два контейнера, оба имеют структурированную архитектуру. Теперь нам надо научиться конвертировать один в другой, из контейнера-источника в контейнер-назначение.
Как было указано в постановке задачи, конвертация должна выполняться на основе набора правил. Правила должны обладать универсальностью, чтобы их можно было использовать многократно.
В коде вырисовывается следующая схема взаимодействия (см. схему ниже): Данные сериализуются из XML в объект .Net (1-2), далее путем обращения к данным контейнера (2) происходит преобразование исходя из списка (3) правил в контейнер назначения (2-3-4). Причем правила имеют возможность обогащать данные (3-3’-3). После того как контейнер-назначение инициализирован, данные выгружаются в конечный формат (4-5).


Схема 1. Схема взаимодействия компонент внутри конвертора:


Схема взаимодействия компонент внутри конвертора

Теперь разработаем механизм конвертации с применением правил. С их помощью мы должны уметь описать любую конвертацию.
Писать новый язык правил, а потом реализовывать для него отдельный компилятор или интерпретатор — это явно лишнее. Мы решили воспользоваться обыкновенным кодом C#, который всегда можно скомпилировать и подключить к существующему функционалу. Были разработаны несколько интерфейсов и базовых классов C#.


Сам конвертер:


public interface IConverter
{
    T Convert<T>(Object source, IDictionary<string, ConversionRule> rules) where T : class, new();
...
}

, где список правил это IDictionary<string, ConversionRule>, в котором ключи string это пути к данным контейтера-назначения, например "Vehicle.Msrp"


И правило конвертации:


public abstract class ConversionRule
{
    public abstract object GetValue(object source);
...
}

Задача конвертера, произвести преобразование указанного объекта источника source в новый объект типа T в соответствии со списком правил.
Во время конвертации “источника” в “назначение”, конвертер выполняет следующие действия.


  • Создает экземпляр типа Т (тип контейнера-назначения)
  • Рекурсивно пробегает каждое поле и свойство целевого объекта типа T (контейнера-назначения), ищет для его соответствующее правила конвертации и выполняет инициализацию (присвоение значения вычисленные на основе правила). Если правила не найдено, но узел остается не заполненным.

На вход каждому правилу передается объект контейнер-источник source. Правило должно выполнить расчет и вернуть результирующее значение. Как видно на примере, в правилах конвертации нет строгой типизации, на вход может быть передан объект, на выходе мы тоже получаем объект.
Рассмотрим пример правила, которое: получает цену автомобиля Car.Price разбирает на составляющие, пересчитает по курсу ЦБ в рублях (на дату пересчета) и записывает значение в Vehicle.Msrp” целевого контейнера.


Ниже представлена таблица настройки правила конвертации:


Целевой узел в объекте назначения Правило конвертации (класс в сборке) Параметры для правила конвертации
Vehicle.Msrp ConvertStringPriceToMsrp TargetCurrency = “RUB”, SourcePath = “Car.Price”

Пример класса заказного правила конвертации:


public class ConvertStringPriceToMsrp: ConvertionRule
{
    public string TargetCurrency;
    public string SourcePath;
    public override object GetValue(object source)
   {
         var targetObject = new Msrp();
         targetObject.Currency = TargetCurrency;
         targetObject.Value = SplitAndCalc(GetFiled(source(), SourcePath, TargetCurrency);
         return targetObject;
   }
...
}

Перед запуском правила, выполняется его инициализация путем перебора его полей и свойств через рефлексию и заполнения одноименных значениями TargetCurrency, SourcePath из конфига (набора параметров для конкретного экземпляра правила).
Обрабатывая данное правило, объект ConvertStringPriceToMsrp берет значение поля в контейнере источнике Car.Price, разбивает строку на составляющие: цена и валюта, и создает результирующий объект Msrp, заполняя поля Msrp.Curreny = RUB и Msrp.Value=[цена в рублях].
Как видно из описания, правилу еще необходимо обратиться к внешнему источнику данных, чтобы получить текущий курс рубля к доллару. Т.е. правило конвертации может подключаться к любым внешним источникам данных и выполнять обогащение данных.


Выгрузка данных назначения из контейнера


Выгрузка данных из объекта назначения в XML выполняется так же, готовой библиотекой .Net Framework путем сериализации объекта. Компонента аккуратно сложит данные полей и свойств класса в XML структуру.


Если файл назначения также специфичен, то для этого необходимо написать адаптер, который наш контейнер-назначение сохранит в нужном формате.


Действующий прототип достоинства и проблемы


Для автоматической подгрузки сервисных библиотек-справочников (для обогащения данных, для многократно используемых справочников) мы внедрили IoC Autofac. Таким образом при конвертации большого количества однородных данных мы решили проблему лишней нагрузки на ввод-вывод и ускорили обработку.


Конвертация к объекту назначения происходит в один проход без лишних циклов.
Благодаря рекурсивности, есть возможность подстановки значения узла опционально “на выбор”. Данная опция весьма полезна для XML, когда структура одного тега зависит от другого (например от типа товара, заполняются разные теги — мы это активно используем при формировании XML в Amazon API).


В тоже время, вся работа с метаданными основана на рефлексии, и на горизонте есть потенциальная проблема скорости. Проблема проявит себя когда задержки расчетов рефлексии будут доминировать нам быстрыми вычислениями внутри правил конвертеров. В текущий момент такая проблема пока не заявила о себе. Но, если она и появится, то есть идея выполнять кэширование типов контейнера-назначения при пакетной обработке.


Все настройки правил мы вынесли на Web интерфейс, что бы пользователи могли оперативно менять настройки. Настройки конвертаций сначала хранили в XML, но для удобства редактирования решили перенести в базу данных.


При всех достоинствах и недостатках, у нас таки получился желаемый “Универсальный конвертер данных на платформе .Net Framework”. Сейчас он активно работает в модулях публикации товаров на торгове площадки Amazon, Wallmart и другие, именно там требуется постоянный меппинг, конвертация и обогащение данных.

Комментарии (14)


  1. msin
    22.01.2018 22:04

    Для быстрого доступа к метаданным есть хорошая библиотека:
    Fast access to .net fields/properties
    Здесь рефлексия используется один раз на один тип данных


    1. RKokorev Автор
      23.01.2018 08:36

      да, спасибо за линк. но это тоже своего рода кеширование


  1. Lure_of_Chaos
    23.01.2018 07:08

    Если же определиться с форматом данных, а именно xml, то тут наиболее естественным и удобным был бы xslt


    1. RKokorev Автор
      23.01.2018 09:13

      У нас система работает с множеством внешних API с различными форматами. Привязка к XML свяжет нам руки. И еще, в большинстве случаев у нас идет конвертация во внешние форматы прямо из модели данных (вычитанной из БД), которая является тоже контейнером. Т.е. шаги (1-2) исключаются


  1. zolt85
    23.01.2018 10:07

    Правильно ли я понимаю, что если структура документа источника, либо назначения поменяется, то придется править код? Если так, то не такой уж и универсальный конвертер получается… Вот если бы пользователь мог загрузить файл-источник, и затем смаппить его на файл-назначение, то универсальность была бы выше. Не пробовали решать такую задачу?


    1. RKokorev Автор
      23.01.2018 10:39

      Придется менять настройки правил. В примере статьи правило возращает заполненный Msrp объект. Это сделано для простого примера и не совсем универсальное правило. В реальном коде мы стараемся делать максимально универсальные правила, которые могут работать с любой структурой. Например можно создать три правила для каждого узла: Vehicle.Msrp, Vehicle.Msrp.Currency, Vehicle.Msrp.Value, тогда они будут возращать скалярные значения и эти правила можно применить многократно.


  1. Sinatr
    23.01.2018 12:35

    Как выглядит SplitAndCalc? Я не пойму что за оверинженеринг (и вообще зачем статья и xml).

    У вас есть 2 типа, входной, используемый для десериализации, экземпляр которого вы передаете в качестве параметра конвертора и выходной, тип, возвращаемый конвертором, конструируется конвертором из входного (создается экземпляр). Что за «правила», зачем «рефлексия» (сериализация/десериализация, я так понимаю не в счет, выполняется стандартными средствами), какую «проблему» такая громоздкая архитектору призвана решить?

    По идее вам достаточно создать по одному конвертору для каждой комбинации входных/выходных типов. Все параметры (например, «РУБ»/"$" для денег) можно либо передавать в качестве параметров конструктора, либа десериализуя экземпляры десериализаторов, в любом случае где-то должна быть структура содержащая все такие конверторы и некое правило (ключ словаря?) которым выбирается/загружается (lazy initialization?) нужный экземпляр.

    Все конверторы работают с конкретными типами (generics, (T, V)), никаких SplitAndCalc не нужно. Я чего-то не понимаю?

    targetObject.Currency = ‘TargetCurrency’;

    Шта? Это псевдокод?


    1. RKokorev Автор
      23.01.2018 13:21

      SplitAndCalc — делает следующее парсит «31000 USD» на 31000 и «USD», заходит во внешний источник данных (например БД) берет курс USD/RUB, пересчитывает стоимость в рубли и возращает значение.
      Правило это код, который для заданного пути возращает значение для заполнение узла.
      Да, справочники правил, и их применения должны где-то храниться.
      Про ‘TargetCurrency’ — это опечатка, исправил
      Мысль статьи в том, что бы создать параметризуемый конструктор конвертаций различных узлов, что бы пользователь мог не сильно вдаваясь в подробности реализации встроенных правил мог ими пользоваться и главное читать (не код). Ну и XML это просто пример формата, а так конвертер способен работать с любыми форматами.
      Рефлексию мы используем для быстрого достпа к данным объекта и сборки целевого экземпляра контейнера.
      Найду время допишу в статью еще одно правило, для общего понимания идеи.


  1. Mikluho
    23.01.2018 17:24

    Может я что не так понял в постановке задачи, но почему не захотели использовать AutoMapper (или аналог) в связке с (де)сериализаторами, которые вместе можно положить в DI-контейнер и получить и мощь, и простоту, и конфигурируемость?

    У меня была похожа задача — мы реализовывали работу с внешними клиентами по разным протоколам и не хотели писать конвертеры входных/выходных данных для каждого протокола/клиента (от клиента зависели некоторые правила преобразования и контроля данных). В итоге у нас получилась цепочка: входной формат — десериализация — конвертация во внутреннее представление — обработка — конвертация во внешнее представление — сериализация — выходной формат.
    Через контейнер настраивались и конвертаторы и сериализаторы. На каждом этапе (включая обработку) через тот же контейнер были доступны компоненты для доступа к данным…


    1. RKokorev Автор
      23.01.2018 23:53

      Если не ошибаюсь, AutoМapper это меппер типов, причем детерменированный (можно настроить только из кода).
      Например, нужно заполнить класс у которого член Product.Item c типом Object. Согласно требований задачи, в Item можно назначить экземпляр объекта один из типов Struct1 и Struct2, заполнение которых выполняется по разному — не уверен, что маппер с ним справится без костылей


      1. lair
        24.01.2018 00:23

        Например, нужно заполнить класс у которого член Product.Item c типом Object. Согласно требований задачи, в Item можно назначить экземпляр объекта один из типов Struct1 и Struct2, заполнение которых выполняется по разному — не уверен, что маппер с ним справится без костылей

        Да легко, кстати. Там и конвертеры есть, и условное заполнение.


      1. Mikluho
        24.01.2018 07:29

        Да, в автомаппере гибкости много.
        Да и у вас кода не мало. Может с автомапером кода в конфигурации станет чуть больше, но обвязки станет меньше, как мне кажется… да и дебажить и тестировать такой подход проще.
        Или есть принципиальная потребность хранить правила в виде текста вне кода программы?
        (тут, кстати, тоже есть варианты, как прикрутить конфиг из текста к автомапперу)


        1. RKokorev Автор
          24.01.2018 10:59

          Да, нам нужен конфиг регулируемый пользователем. Думаю если конфиг из текста туда получится затащить, то может и получится, надо пробовать. В любом случае это будет надстройка над automapper (сразу приходит мысль, а не сделать ли лайт аналогию с маппером с нуля)


          1. lair
            24.01.2018 11:30

            Думаю если конфиг из текста туда получится затащить, то может и получится, надо пробовать.

            Да нет проблемы, прочитали конфиг, вызвали набор методов на MappingConfiguration.


            сразу приходит мысль, а не сделать ли лайт аналогию с маппером с нуля

            А зачем? NIH?