Сериализация и десериализация — типичные операции, к которым современный разработчик относится как к тривиальным. Мы общаемся с базами данных, формируем 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-проекты, где вы разбираетесь с тем, как работает та или иная технология. Надеюсь, я немного помог вам во всем этом.