Хабрахабр

Свой конвертер 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.

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

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

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 ему передается десериализатор элемента.

Reflection позволяет в realtime создавать generic-типы, а значит, мы можем использовать созданный тип в качестве аргумента Activator. Иногда возникают сложности с инстанциированием generic-имплементаций, поэтому я сразу расскажу как это сделать. Воспользуемся этим: 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 для них уже созданы десериализаторы (конвертеры).

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

NET), как наиболее распространенным и рекомендуемым решением для работы с JSON. Конечно, скорость десериализации я сравнивал с Newtonsoft (Json. Короче говоря, мне хотелось узнать, насколько сильно мой код будет проигрывать. Более того, прямо у них на сайте написано: 50% faster than DataContractJsonSerializer, and 250% faster than JavaScriptSerializer. Результаты меня удивили: обратите внимание, что аллокация данных меньше почти в три раза, а скорость десериализации выше примерно в два.

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

Выводы

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

Надеюсь, я немного помог вам во всем этом.
Область применения подобных велосипедов — приложения, которые полностью спроектированы с прицелом на высокую производительность, либо pet-проекты, где вы разбираетесь с тем, как работает та или иная технология.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть