Свой конвертер JSON или ещё немного про ExpressionTrees

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.


Сериализация и десериализация — типичные операции, к которым современный разработчик относится как к тривиальным. Мы общаемся с базами данных, формируем HTTP-запросы, получаем данные через REST API, и часто даже не задумываемся как это работает. Сегодня я предлагаю написать свой сериализатор и десериализатор для JSON, чтобы узнать, что там «под капотом».

Отказ от ответственности


Как и в прошлый раз, я замечу: мы напишем примитивный сериализатор, можно сказать, велосипед. Если вам нужно готовое решение — используйте Json.NET. Эти ребята выпустили замечательный продукт, который хорошо настраивается, много умеет и уже решает проблемы, которые возникают при работе с JSON. Использовать своё собственное решение действительно здорово, но только если вам нужна максимальная производительность, специальная кастомизация, либо вы любите велосипеды так, как люблю их я.

Предметная область


Сервис конвертации из JSON в объектное представление состоит как минимум из двух подсистем. Deserializer — это подсистема, которая превращает валидный JSON (текст) в объектное представление внутри нашей программы. Десериализация включает в себя токенизацию, то есть разбор JSON на логические элементы. Serializer — это подсистема, которая выполняет обратную задачу: превращает объектное представление данных в JSON.

Потребитель чаще всего видит следующий интерфейс. Я специально его упростил, чтобы выделить основные методы, которые чаще всего используются.

public interface IJsonConverter
{
    T Deserialize<T>(string json);
    string Serialize(object source);
}

«Под капотом» десериализация включает токенизацию (разбор JSON-текста) и построение неких примитивов, по которым впоследствии легче осуществлять создание объектного представления. Для целей обучения мы пропустим построение промежуточных примитивов (например, JObject, JProperty из Json.NET) и будем сразу писать данные в объект. Это минус, так как уменьшает возможности настройки, но создать целую библиотеку в рамках одной статьи невозможно.

Токенизация


Напомню, что процесс токенизации или лексического анализа — это разбор текста c целью получения иного, более строго представления содержащихся в нем данных. Обычно подобное представление называется токенами или лексемами. Для целей разбора JSON мы должны выделить свойства, их значения, символы начала и конца структур — то есть токены, которые в коде могут быть представлены как JsonToken.

JsonToken это структура, которая содержит в себе значение (текст), а также тип токена. JSON — строгая нотация, поэтому все типы токенов можно свести к следующему enum. Конечно, было бы здорово добавить в токен его координаты во входящих данных (строка и колонка), но отладка выходит за рамки вело-имплементации, а значит, этих данных JsonToken не содержит.

Итак, самый простой способ разбора текста на токены — последовательно считывать каждый символ и сопоставлять его с паттернами. Нам нужно понять, что значит тот или иной символ. Возможно, что с этого символа начинается ключевое слово (true, false, null), возможно, это начало строки (символ кавычки), а возможно этот символ сам по себе токен ([, ], {, }). Общая идея выглядит вот так:

var tokens = new List<JsonToken>();
for (int i = 0; i < json.Length; i++) {
    char ch = json[i];
    switch (ch) {
        case '[': 
            tokens.Add(new JsonToken(JsonTokenType.ArrayStart));
            break;
        case ']': 
            tokens.Add(new JsonToken(JsonTokenType.ArrayEnd));
            break;
        case '"':
            string stringValue = ReadString();
            tokens.Add(new JsonToken(JsonTokenType.String, stringValue);
            break;
        ...
    }
}

Глядя на код, кажется, что можно читать и сразу же что-то делать с прочитанными данными. Их не нужно хранить, их нужно сразу направить потребителю. Таким образом, напрашивается некий IEnumerator, который будет разбирать текст по кусочкам. Во-первых, это снизит аллокацию, так как нам не нужно хранить промежуточные результаты (массив токенов). Во-вторых, мы увеличим скорость работы — да, в нашем примере входные данные это строка, но в реальной ситуации на её месте будет Stream (из файла или сети), который мы последовательно вычитываем.

Я подготовил код JsonTokenizer, с которым можно ознакомиться тут. Идея прежняя — токенизатор последовательно идёт по строке, пытаясь определить, к чему относится символ или их последовательность. Если получилось понять, то создаем токен и передаем управление потребителю. Если ещё не понятно — читаем дальше.

Подготовка к десериализации объектов


Чаще всего запрос на преобразование данных из JSON есть вызов generic-метода Deserialize, где TOut — тип данных, с которым нужно сопоставить JSON-токены. А там, где есть Type: самое время применить Reflection и ExpressionTrees. Основы работы с ExpressionTrees, а также почему скомпилированные выражения лучше, чем «голый» Reflection, я описал в предыдущей статье про то, как сделать свой AutoMapper. Если вы ничего не знаете про Expression.Labmda.Compile() — рекомендую прочитать. Мне кажется, на примере маппера получилось достаточно понятно.

Итак, план создания десериализатора объекта основывается на знании, что мы можем в любой момент получить типы свойств из типа TOut, то есть коллекцию PropertyInfo. При этом, типы свойств ограничены нотацией JSON: числа, строки, массивы и объекты. Даже если мы не забудем про null — это не так много, как может показаться на первый взгляд. И если для каждого примитивного типа мы будем вынуждены создавать отдельный десериализатор, то для массивов и объектов можно сделать generic-классы. Если немного подумать, все сериализаторы-десериализаторы (или конвертеры) можно свести к следующему интерфейсу:

public interface IJsonConverter<T>
{
    T Deserialize(JsonTokenizer tokenizer);
    void Serialize(T value, StringBuilder builder);
}

Код строго типизированного конвертера примитивных типов максимально прост: мы извлекаем текущий JsonToken из токенизатора и превращаем его в значение путем парсинга. Например, float.Parse(currentToken.Value). Взгляните на BoolConverter или FloatConverter — ничего сложного. Далее, если будет нужен десериализатор для bool? или float?, его также можно будет добавить.

Десериализация массивов


Код generic-класса для конвертации массива из JSON тоже сравнительно прост. Он параметризируется типом элемента, который мы можем извлечь Type.GetElementType(). Определить, что тип — это массив, также просто: Type.IsArray. Десериализация массива сводится к тому, чтобы говорить tokenizer.MoveNext() до тех пор, пока не будет достигнут токен типа ArrayEnd. Десериализация элементов массива — это десериализация типа элемента массива, поэтому при создании ArrayConverter ему передается десериализатор элемента.

Иногда возникают сложности с инстанциированием generic-имплементаций, поэтому я сразу расскажу как это сделать. Reflection позволяет в realtime создавать generic-типы, а значит, мы можем использовать созданный тип в качестве аргумента Activator.CreateInstance. Воспользуемся этим:

Type elementType = arrayType.GetElementType();
Type converterType = typeof(ArrayConverter<>).MakeGenericType(elementType);

var converterInstance = Activator.CreateInstance(converterType, object[] args);

Завершая подготовку к созданию десериализатора объектов, можно положить весь инфраструктурный код, связанный с созданием и хранением десериализаторов, в фасад JConverter. Он будет отвечать за все операции сериализации и десериализации JSON и доступен потребителям как сервис.

Десериализация объектов


Напомню, что получить все свойства типа T можно вот так: typeof(T).GetProperties(). Для каждого свойства можно извлечь PropertyInfo.PropertyType, что даст нам возможность создать типизированный IJsonConverter для сериализации и десериализации данных конкретного типа. Если тип свойства это массив, то инстанциируем ArrayConverter или находим подходящий среди уже существующих. Если тип свойства — примитивный тип, то в конструкторе JConverter для них уже созданы десериализаторы (конвертеры).

Получившийся код можно посмотреть в generic-классе ObjectConverter. В его конструкторе создается активатор, из специально подготовленного словаря извлекаются свойства и для каждого из них создается метод десериализации — Action<TObject, JsonTokenizer>. Он нужен, во-первых, для того, чтобы сразу связать IJsonConverter с нужным свойством, а во-вторых, чтобы избежать boxing при извлечении и записи примитивных типов. Каждый метод десериализации знает, в какое свойство исходящего объекта будет произведена запись, десериализатор значения строго типизирован и возвращает значение именно в том виде, в котором нужно.

Связывание IJsonConverter со свойством производится следующим образом:

Type converterType = propertyValueConverter.GetType();
ConstantExpression  Expression.Constant(propertyValueConverter, converterType);

MethodInfo deserializeMethod = converterType.GetMethod("Deserialize");
var value = Expression.Call(converter, deserializeMethod, tokenizer);

Непосредственно в выражении создается константа Expression.Constant, которая хранит ссылку на инстанс десериализатора для значения свойства. Это не совсем та константа, которую мы пишем в «обычном C#», так как она может хранить reference type. Далее из типа десериализатора извлекается метод Deserialize, возвращающий значение нужного типа, ну а затем производится её вызов — Expression.Call. Таким образом, у нас получается метод, который точно знает, куда и что писать. Остаётся положить его в словарь и вызывать тогда, когда из токенизатора «придёт» токен типа Property с нужным именем. Ещё одним плюсом является то, что всё это работает очень быстро.

Насколько быстро?


Велосипеды, как было замечено в самом начале, имеет смысл писать в нескольких случаях: если это попытка понять, как работает технология, либо нужно достигнуть каких-то специальных результатов. Например, скорости. Вы можете убедиться, что десериализатор действительно десериализует с помощью подготовленных тестов (я использую AutoFixture, чтобы получать тестовые данные). Кстати, вы наверное заметили, что я написал ещё и сериализацию объектов. Но так как статья получилась достаточно большой, я её описывать не буду, а просто дам бенчмарки. Да, так же, как и с предыдущей статьей, я написал бенчмарки используя библиотеку BenchmarkDotNet.

Конечно, скорость десериализации я сравнивал с Newtonsoft (Json.NET), как наиболее распространенным и рекомендуемым решением для работы с JSON. Более того, прямо у них на сайте написано: 50% faster than DataContractJsonSerializer, and 250% faster than JavaScriptSerializer. Короче говоря, мне хотелось узнать, насколько сильно мой код будет проигрывать. Результаты меня удивили: обратите внимание, что аллокация данных меньше почти в три раза, а скорость десериализации выше примерно в два.
Method Mean Error StdDev Ratio Allocated
Newtonsoft 75.39 ms 0.3027 ms 0.2364 ms 1.00 35.47 MB
Deserializer 31.78 ms 0.1135 ms 0.1062 ms 0.42 12.36 MB

Сравнение скорости и аллокации при сериализации данных дала ещё более интересные результаты. Оказывается, вело-сериализатор аллоцировал почти в пять раз меньше и работал почти в три раза быстрее. Если бы меня сильно (действительно сильно) заботила скорость, это было бы явным успехом.
Method Mean Error StdDev Ratio Allocated
Newtonsoft 54.83 ms 0.5582 ms 0.5222 ms 1.00 25.44 MB
Serializer 20.66 ms 0.0484 ms 0.0429 ms 0.38 5.93 MB

Да, при замерах скорости я не использовал советы по увеличению производительности, которые размещены на сайте Json.NET. Я производил замеры «из коробки», то есть по наиболее часто используемому сценарию: JsonConvert.DeserializeObject. Возможно, существуют иные способы улучшения производительности, но я о них не знаю.

Выводы


Несмотря на достаточно высокую скорость работы сериализации и десериализации, я бы не рекомендовал отказываться от Json.NET в пользу собственного решения. Выигрыш в скорости исчисляется в миллисекундах, а они запросто «тонут» в задержках сети, диска или коде, который иерархически расположен выше того места, где применяется сериализация. Поддерживать подобные собственные решения — ад, куда могут быть допущены только разработчики, хорошо разбирающиеся в предмете.

Область применения подобных велосипедов — приложения, которые полностью спроектированы с прицелом на высокую производительность, либо pet-проекты, где вы разбираетесь с тем, как работает та или иная технология. Надеюсь, я немного помог вам во всем этом.
Источник: https://habr.com/ru/post/464525/


Интересные статьи

Интересные статьи

Цель данной статьи - объяснить логику работы радиуса в примерах, избавить от боязни и иллюзии сложности использования. Читать далее
Привет, меня зовут Александр, я тимлид команды разработки трекинговых решений компании Admitad. Почти всегда на собеседованиях мне задают вопросы, чем занимается команда, какие у нас про...
О популярной библиотеке SQLAlchemy для работы с разными СУБД из Python было написано довольно много статей. Предлагаю вашему вниманию обзор и сравнение запросов с использованием ORM и SQL...
В эту субботу пройдет онлайн-офлайн митап самарского PHP-чата: зрители трансляции смогут задавать докладчикам вопросы голосом и полноценно участвовать в викторине, а те, кто согласится но...
Мы очень рады объявить о начале бета-тестирования GitHub Sponsors ― нового способа финансовой поддержки разработчиков программного обеспечения с открытым исходным кодом, которое мы используем к...