Сериализация и десериализация — типичные операции, к которым современный разработчик относится как к тривиальным. Мы общаемся с базами данных, формируем 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 |
Velo | 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 |
Velo | 20.66 ms | 0.0484 ms | 0.0429 ms | 0.38 | 5.93 MB |
Да, при замерах скорости я не использовал советы по увеличению производительности, которые размещены на сайте Json.NET. Я производил замеры «из коробки», то есть по наиболее часто используемому сценарию: JsonConvert.DeserializeObject. Возможно, существуют иные способы улучшения производительности, но я о них не знаю.
Выводы
Несмотря на достаточно высокую скорость работы сериализации и десериализации, я бы не рекомендовал отказываться от Json.NET в пользу собственного решения. Выигрыш в скорости исчисляется в миллисекундах, а они запросто «тонут» в задержках сети, диска или коде, который иерархически расположен выше того места, где применяется сериализация. Поддерживать подобные собственные решения — ад, куда могут быть допущены только разработчики, хорошо разбирающиеся в предмете.
Область применения подобных велосипедов — приложения, которые полностью спроектированы с прицелом на высокую производительность, либо pet-проекты, где вы разбираетесь с тем, как работает та или иная технология. Надеюсь, я немного помог вам во всем этом.
Комментарии (17)
SemenPV
21.08.2019 15:50Одна из интересных особенностей — то что вы можете сериализировать не только данные, но и налету генерировать JS код из кода C#, т.е. некоторые свойства могут быть вычисляемые.
Пример можно посмотреть в проекте knockoutmvc, как это делается в коде пример .
Например есть свойство в C#
Из него был сгенерирован JSpublic string FullName { get { return FirstName + " " + LastName; } }
data.FullName = ko.computed(function() { try { return ((this.FirstName() + ' ') + this.LastName()) } catch(e) { return null; }; }, data);
PrinceKorwin
21.08.2019 16:15Я извиняюсь, но при виде try {… } catch (e) { return null; } у меня жутко дергается глаз.
SemenPV
21.08.2019 16:22у меня жутко дергается глаз.
Код сгенерирован, так что можно на него вообще никогда не смотреть.
Ну и так как я не специалист в JS и не могу оценить истинную причину проблем вашего глаза, то было бы неплохо прояснить. Это эстетическая, идеалогическая или религиозная причина?PrinceKorwin
21.08.2019 16:55Если где-то в коде перехватывается любой тип ошибки, не логируется и происходит подмена результирующих данный (здесь возврат NULL) — это путь к долгим отладкам и не тривиальным ошибкам.
SemenPV
21.08.2019 17:44В общем случае да, особенно если в JS вызывается ассинхронный метод, то try/catch его даже не поймает. Но существуют вполне валидные случаи когда это ок.
Dansoid
21.08.2019 18:47Неплохо было бы сравнить и Майкрософтовскую версию, только тут придется играться с .NET Core 3.0 preview.
www.nuget.org/packages/System.Text.Jsonteoadal Автор
22.08.2019 08:25Да, я думал об этом и даже набросал небольшие тесты в отдельной ветке. Тот, который System.Text.Json.JsonSerializer работает очень хорошо. Максимальную производительность, судя по документации, должна обеспечивать работа с Utf8JsonReader — но до её тестирования пока руки не дошли.
Тестирование производилось на следующей конфигурации (так как мы имеем дело с preview, то привожу её полностью):
BenchmarkDotNet=v0.11.5, OS=Windows 10.0.17134.81 (1803/April2018Update/Redstone4)
Intel Core i5-7400 CPU 3.00GHz (Kaby Lake), 1 CPU, 4 logical and 4 physical cores
.NET Core SDK=3.0.100-preview8-013656
[Host]: .NET Core 3.0.0-preview8-28405-07 (CoreCLR 4.700.19.37902, CoreFX 4.700.19.40503), 64bit RyuJIT
Core: .NET Core 3.0.0-preview8-28405-07 (CoreCLR 4.700.19.37902, CoreFX 4.700.19.40503), 64bit RyuJIT
Job=Core Runtime=Core
Десериализация (.NET Core 3.0 preview):
Newtonsoft: 1 (allocation 34.87 MB)
VNext: 0.76 (allocation 9.3 MB)
Velo: 0.39 (allocation 11.37 MB).
Сериализация:
Newtonsoft: 1 (allocation 24.85 MB)
VNext: 0.82 (allocation 16.59 MB)
Velo: 0.37 (allocation 5.91 MB).
Думаю, что можно уже готовиться переходить на System.Text.Json.JsonSerializer. Из коробки он работает значительно лучше Newtonsoft: аллокация при десериализации очень низкая — как минимум GC будет благодарен.
Lex20
22.08.2019 06:19Оптимизируйте решение, токены вообще не нужно в памяти хранить
teoadal Автор
22.08.2019 08:34+1Возможно, вы укажете кусок кода, который вас смутил?
Я нигде не храню токены. JsonTokenizer специально построен как IEnumerator — токены считываются из исходной строки сразу и передаются потребителю.Lex20
25.08.2019 07:26var tokens = new List();
В статье есть.
Извиняюсь, прочитал просто до этого момента и дальше не стал читать.
mshak
22.08.2019 08:36Мне еще [де]сериализатор от facebook нравится — github.com/facebook-csharp-sdk/simple-json тоже достаточно простой.
Исходник одним файлом, можно затащить к себе (MIT лицензия).teoadal Автор
22.08.2019 10:05Я, наверно, вас расстрою. Бегло посмотрев код я заподозрил, что данный [де]сериализатор будет очень много аллоцировать. Собственно, так оно и оказалось. Я обновил бенчмарки сериализации и десериализации.
Десериализация:
Newtonsoft: 1 (аллокация 35.47 MB)
SimpleJson: 1.81 (аллокация 667.02 MB)
Velo: 0.43 (аллокация 12.36 MB)
Сериализация:
Newtonsoft: 1 (аллокация 25.44 MB)
SimpleJson: 1.15 (аллокация 72 MB)
Velo: 0.39 (аллокация 5.93 MB)
Я скопировал файл SimpleJson и добавил его в проект. Ничего не менял.
При такой аллокации его использовать просто опасно. В бенчмарке всего 10 000 элементов, которые де/сериализуются. Тратить на такую простую операцию 600 мегабайт — очень сомнительное удовольствие.
AgentFire
А теперь сравните с fastJSON, пожалуйста.
teoadal Автор
Я установил nuget-пакет отсюда и обновил код бенчмарков: deserialization и serialization. Так как я не знаю обычных способов использования fastJSON, то я пошёл по пути наименьшего сопротивления: в библиотеке есть статический класс JSON с двумя методами — ToObject и ToJson. Я использовал именно их. Возможно, вы подскажете какие-то более оптимальные способы.
Результаты тестирования для десериализации (таблицу вставить почему-то не получилось):
Newtonsoft: 1.00 (аллокация 35.47 MB)
FastJson: 0.97 (аллокация 48.81 MB)
JDeserializer: 0.42 (аллокация 12.36 MB)
Результаты тестирования для сериализации:
Newtonsoft: 1.00 (аллокация 25.44 MB)
FastJson: 1.21 (аллокация 54.8 MB)
JSerializer: 0.38 (аллокация 5.93 MB)
AgentFire
странно, у меня fastJSON работает где то в 20 раз быстрее Newtonsoft (на десериализацию). но спасибо за тесты.
teoadal Автор
Хм. Конечно, было бы здорово сделать бенчмарк и дать ссылку на него код, но если вам не удобно, то хоть примерный код набросайте — как вы это делаете?
Ещё у меня есть проблема с примерами. Я вот бегло посмотрел «Using the code» на странице проекта, и там написано:
Но в static-классе JSON нет свойства Instance. Возможно, у вас какой-то другой код? Может быть, вы дадите ссылку на nuget? Как я уже писал, я взял вот отсюда (версия 2.2.4). Если перейти с nuget на страницу проекта — то это именно та, которую вы дали.
AgentFire
я просто использую
JSON.ToObject<T>(data)
, версия 2.2.4, пакет вроде тот же.