- PVSM.RU - https://www.pvsm.ru -
Сериализация и десериализация — типичные операции, к которым современный разработчик относится как к тривиальным. Мы общаемся с базами данных, формируем HTTP-запросы, получаем данные через REST API, и часто даже не задумываемся как это работает. Сегодня я предлагаю написать свой сериализатор и десериализатор для JSON, чтобы узнать, что там «под капотом».
Как и в прошлый раз [1], я замечу: мы напишем примитивный сериализатор, можно сказать, велосипед. Если вам нужно готовое решение — используйте Json.NET [2]. Эти ребята выпустили замечательный продукт, который хорошо настраивается, много умеет и уже решает проблемы, которые возникают при работе с JSON. Использовать своё собственное решение действительно здорово, но только если вам нужна максимальная производительность, специальная кастомизация, либо вы любите велосипеды так, как люблю их я.
Сервис конвертации из JSON в объектное представление состоит как минимум из двух подсистем. Deserializer — это подсистема, которая превращает валидный JSON [3] (текст) в объектное представление внутри нашей программы. Десериализация включает в себя токенизацию, то есть разбор JSON на логические элементы. Serializer — это подсистема, которая выполняет обратную задачу: превращает объектное представление данных в JSON.
Потребитель чаще всего видит следующий интерфейс. Я специально его упростил, чтобы выделить основные методы, которые чаще всего используются.
public interface IJsonConverter
{
T Deserialize<T>(string json);
string Serialize(object source);
}
«Под капотом» десериализация включает токенизацию (разбор JSON-текста) и построение неких примитивов, по которым впоследствии легче осуществлять создание объектного представления. Для целей обучения мы пропустим построение промежуточных примитивов (например, JObject, JProperty из Json.NET) и будем сразу писать данные в объект. Это минус, так как уменьшает возможности настройки, но создать целую библиотеку в рамках одной статьи невозможно.
Напомню, что процесс токенизации или лексического анализа [4] — это разбор текста c целью получения иного, более строго представления содержащихся в нем данных. Обычно подобное представление называется токенами или лексемами. Для целей разбора JSON мы должны выделить свойства, их значения, символы начала и конца структур — то есть токены, которые в коде могут быть представлены как JsonToken.
JsonToken [5] это структура, которая содержит в себе значение (текст), а также тип токена. JSON — строгая нотация, поэтому все типы токенов можно свести к следующему enum [6]. Конечно, было бы здорово добавить в токен его координаты во входящих данных (строка и колонка), но отладка выходит за рамки вело-имплементации, а значит, этих данных 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 [7] (из файла или сети), который мы последовательно вычитываем.
Я подготовил код JsonTokenizer, с которым можно ознакомиться тут [8]. Идея прежняя — токенизатор последовательно идёт по строке, пытаясь определить, к чему относится символ или их последовательность. Если получилось понять, то создаем токен и передаем управление потребителю. Если ещё не понятно — читаем дальше.
Чаще всего запрос на преобразование данных из JSON есть вызов generic-метода Deserialize, где TOut — тип данных, с которым нужно сопоставить JSON-токены. А там, где есть Type [9]: самое время применить Reflection [10] и ExpressionTrees [11]. Основы работы с ExpressionTrees, а также почему скомпилированные выражения лучше, чем «голый» Reflection, я описал в предыдущей статье про то, как сделать свой AutoMapper [1]. Если вы ничего не знаете про Expression.Labmda.Compile() — рекомендую прочитать. Мне кажется, на примере маппера получилось достаточно понятно.
Итак, план создания десериализатора объекта основывается на знании, что мы можем в любой момент получить типы свойств из типа TOut, то есть коллекцию PropertyInfo [12]. При этом, типы свойств ограничены нотацией JSON: числа, строки, массивы и объекты. Даже если мы не забудем про null — это не так много, как может показаться на первый взгляд. И если для каждого примитивного типа мы будем вынуждены создавать отдельный десериализатор, то для массивов и объектов можно сделать generic-классы. Если немного подумать, все сериализаторы-десериализаторы (или конвертеры) можно свести к следующему интерфейсу:
public interface IJsonConverter<T>
{
T Deserialize(JsonTokenizer tokenizer);
void Serialize(T value, StringBuilder builder);
}
Код строго типизированного конвертера примитивных типов максимально прост: мы извлекаем текущий JsonToken из токенизатора и превращаем его в значение путем парсинга. Например, float.Parse(currentToken.Value). Взгляните на BoolConverter [13] или FloatConverter [14] — ничего сложного. Далее, если будет нужен десериализатор для bool? или float?, его также можно будет добавить.
Код generic-класса [15] для конвертации массива из JSON тоже сравнительно прост. Он параметризируется типом элемента, который мы можем извлечь Type.GetElementType() [16]. Определить, что тип — это массив, также просто: Type.IsArray [17]. Десериализация массива сводится к тому, чтобы говорить 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 [18]. Он будет отвечать за все операции сериализации и десериализации JSON и доступен потребителям как сервис.
Напомню, что получить все свойства типа T можно вот так: typeof(T).GetProperties(). Для каждого свойства можно извлечь PropertyInfo.PropertyType [19], что даст нам возможность создать типизированный IJsonConverter для сериализации и десериализации данных конкретного типа. Если тип свойства это массив, то инстанциируем ArrayConverter или находим подходящий среди уже существующих. Если тип свойства — примитивный тип, то в конструкторе JConverter для них уже созданы десериализаторы (конвертеры).
Получившийся код можно посмотреть в generic-классе ObjectConverter [20]. В его конструкторе создается активатор, из специально подготовленного словаря извлекаются свойства и для каждого из них создается метод десериализации — 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 [21], которая хранит ссылку на инстанс десериализатора для значения свойства. Это не совсем та константа, которую мы пишем в «обычном C#», так как она может хранить reference type. Далее из типа десериализатора извлекается метод Deserialize, возвращающий значение нужного типа, ну а затем производится её вызов — Expression.Call [22]. Таким образом, у нас получается метод, который точно знает, куда и что писать. Остаётся положить его в словарь и вызывать тогда, когда из токенизатора «придёт» токен типа Property с нужным именем. Ещё одним плюсом является то, что всё это работает очень быстро.
Велосипеды, как было замечено в самом начале, имеет смысл писать в нескольких случаях: если это попытка понять, как работает технология, либо нужно достигнуть каких-то специальных результатов. Например, скорости. Вы можете убедиться, что десериализатор действительно десериализует с помощью подготовленных тестов [23] (я использую AutoFixture [24], чтобы получать тестовые данные). Кстати, вы наверное заметили, что я написал ещё и сериализацию объектов. Но так как статья получилась достаточно большой, я её описывать не буду, а просто дам бенчмарки. Да, так же, как и с предыдущей статьей, я написал бенчмарки используя библиотеку BenchmarkDotNet [25].
Конечно, скорость десериализации я сравнивал [26] с 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 |
Сравнение скорости и аллокации при сериализации данных [27] дала ещё более интересные результаты. Оказывается, вело-сериализатор аллоцировал почти в пять раз меньше и работал почти в три раза быстрее. Если бы меня сильно (действительно сильно) заботила скорость, это было бы явным успехом.
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 |
Да, при замерах скорости я не использовал советы по увеличению производительности [28], которые размещены на сайте Json.NET. Я производил замеры «из коробки», то есть по наиболее часто используемому сценарию: JsonConvert.DeserializeObject. Возможно, существуют иные способы улучшения производительности, но я о них не знаю.
Несмотря на достаточно высокую скорость работы сериализации и десериализации, я бы не рекомендовал отказываться от Json.NET в пользу собственного решения. Выигрыш в скорости исчисляется в миллисекундах, а они запросто «тонут» в задержках сети, диска или коде, который иерархически расположен выше того места, где применяется сериализация. Поддерживать подобные собственные решения — ад, куда могут быть допущены только разработчики, хорошо разбирающиеся в предмете.
Область применения подобных велосипедов — приложения, которые полностью спроектированы с прицелом на высокую производительность, либо pet-проекты, где вы разбираетесь с тем, как работает та или иная технология. Надеюсь, я немного помог вам во всем этом.
Автор: teoadal
Источник [29]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/c-2/327776
Ссылки в тексте:
[1] в прошлый раз: https://habr.com/ru/post/463961
[2] Json.NET: https://www.newtonsoft.com/json
[3] JSON: https://ru.wikipedia.org/wiki/JSON
[4] лексического анализа: https://ru.wikipedia.org/wiki/%D0%9B%D0%B5%D0%BA%D1%81%D0%B8%D1%87%D0%B5%D1%81%D0%BA%D0%B8%D0%B9_%D0%B0%D0%BD%D0%B0%D0%BB%D0%B8%D0%B7
[5] JsonToken: https://github.com/teoadal/veloimplementations/blob/master/src/Velo/Serialization/Tokenization/JsonToken.cs
[6] следующему enum: https://github.com/teoadal/veloimplementations/blob/master/src/Velo/Serialization/Tokenization/JsonTokenType.cs
[7] Stream: https://docs.microsoft.com/ru-ru/dotnet/api/system.io.stream?view=netcore-2.0
[8] ознакомиться тут: https://github.com/teoadal/veloimplementations/blob/master/src/Velo/Serialization/Tokenization/JsonTokenizer.cs
[9] Type: https://docs.microsoft.com/ru-ru/dotnet/api/system.type?view=netcore-2.0
[10] Reflection: https://docs.microsoft.com/ru-ru/dotnet/csharp/programming-guide/concepts/reflection
[11] ExpressionTrees: https://docs.microsoft.com/ru-ru/dotnet/csharp/programming-guide/concepts/expression-trees/
[12] PropertyInfo: https://docs.microsoft.com/ru-ru/dotnet/api/system.reflection.propertyinfo?view=netcore-2.0
[13] BoolConverter: https://github.com/teoadal/veloimplementations/blob/master/src/Velo/Serialization/Converters/BoolConverter.cs
[14] FloatConverter: https://github.com/teoadal/veloimplementations/blob/master/src/Velo/Serialization/Converters/FloatConverter.cs
[15] Код generic-класса: https://github.com/teoadal/veloimplementations/blob/master/src/Velo/Serialization/Converters/ArrayConverter.cs
[16] Type.GetElementType(): https://docs.microsoft.com/ru-ru/dotnet/api/system.type.getelementtype?view=netcore-2.0
[17] Type.IsArray: https://docs.microsoft.com/ru-ru/dotnet/api/system.type.isarray?view=netcore-2.0
[18] JConverter: https://github.com/teoadal/veloimplementations/blob/master/src/Velo/Serialization/JConverter.cs
[19] PropertyInfo.PropertyType: https://docs.microsoft.com/ru-ru/dotnet/api/system.reflection.propertyinfo.propertytype?view=netcore-2.0
[20] ObjectConverter: https://github.com/teoadal/veloimplementations/blob/master/src/Velo/Serialization/Converters/ObjectConverter.cs
[21] Expression.Constant: https://docs.microsoft.com/ru-ru/dotnet/api/system.linq.expressions.expression.constant?view=netcore-2.0
[22] Expression.Call: https://docs.microsoft.com/ru-ru/dotnet/api/system.linq.expressions.expression.call?view=netcore-2.0
[23] с помощью подготовленных тестов: https://github.com/teoadal/veloimplementations/blob/master/src/Velo.Tests/DeserializationTests.cs
[24] AutoFixture: https://github.com/AutoFixture/AutoFixture
[25] BenchmarkDotNet: https://github.com/dotnet/BenchmarkDotNet
[26] сравнивал: https://github.com/teoadal/veloimplementations/blob/master/src/Velo.Benchmark/DeserializationBenchmark.cs
[27] при сериализации данных: https://github.com/teoadal/veloimplementations/blob/master/src/Velo.Benchmark/SerializationBenchmark.cs
[28] советы по увеличению производительности: https://www.newtonsoft.com/json/help/html/performance.htm
[29] Источник: https://habr.com/ru/post/464525/?utm_campaign=464525&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.