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





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

  1. AgentFire
    /#20532331

    А теперь сравните с fastJSON, пожалуйста.

    • teoadal
      /#20532993

      Я установил 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
        /#20533711

        странно, у меня fastJSON работает где то в 20 раз быстрее Newtonsoft (на десериализацию). но спасибо за тесты.

        • teoadal
          /#20533817

          Хм. Конечно, было бы здорово сделать бенчмарк и дать ссылку на него код, но если вам не удобно, то хоть примерный код набросайте — как вы это делаете?

          Ещё у меня есть проблема с примерами. Я вот бегло посмотрел «Using the code» на странице проекта, и там написано:

          var newobj = fastJSON.JSON.Instance.ToObject(jsonText);
          

          Но в static-классе JSON нет свойства Instance. Возможно, у вас какой-то другой код? Может быть, вы дадите ссылку на nuget? Как я уже писал, я взял вот отсюда (версия 2.2.4). Если перейти с nuget на страницу проекта — то это именно та, которую вы дали.

          • AgentFire
            /#20534365

            я просто использую JSON.ToObject<T>(data), версия 2.2.4, пакет вроде тот же.

  2. SemenPV
    /#20533041

    Одна из интересных особенностей — то что вы можете сериализировать не только данные, но и налету генерировать JS код из кода C#, т.е. некоторые свойства могут быть вычисляемые.

    Пример можно посмотреть в проекте knockoutmvc, как это делается в коде пример .

    Например есть свойство в C#

    public string FullName
        {
            get { return FirstName + " " + LastName; }
        }
    Из него был сгенерирован JS
    data.FullName = ko.computed(function() { 
        try 
        { 
            return ((this.FirstName() + ' ') + this.LastName())
        } catch(e) {
           return null; 
        };
    }, data);

    • PrinceKorwin
      /#20533169

      Я извиняюсь, но при виде try {… } catch (e) { return null; } у меня жутко дергается глаз.

      • SemenPV
        /#20533215

        у меня жутко дергается глаз.
        Код сгенерирован, так что можно на него вообще никогда не смотреть.

        Ну и так как я не специалист в JS и не могу оценить истинную причину проблем вашего глаза, то было бы неплохо прояснить. Это эстетическая, идеалогическая или религиозная причина?

        • PrinceKorwin
          /#20533461

          Если где-то в коде перехватывается любой тип ошибки, не логируется и происходит подмена результирующих данный (здесь возврат NULL) — это путь к долгим отладкам и не тривиальным ошибкам.

          • SemenPV
            /#20533805

            В общем случае да, особенно если в JS вызывается ассинхронный метод, то try/catch его даже не поймает. Но существуют вполне валидные случаи когда это ок.

  3. Dansoid
    /#20534203

    Неплохо было бы сравнить и Майкрософтовскую версию, только тут придется играться с .NET Core 3.0 preview.
    www.nuget.org/packages/System.Text.Json

    • teoadal
      /#20535783

      Да, я думал об этом и даже набросал небольшие тесты в отдельной ветке. Тот, который 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 будет благодарен.

  4. Lex20
    /#20535573

    Оптимизируйте решение, токены вообще не нужно в памяти хранить

    • teoadal
      /#20535811 / +1

      Возможно, вы укажете кусок кода, который вас смутил?

      Я нигде не храню токены. JsonTokenizer специально построен как IEnumerator — токены считываются из исходной строки сразу и передаются потребителю.

      • Lex20
        /#20547489

        var tokens = new List();
        В статье есть.

        Извиняюсь, прочитал просто до этого момента и дальше не стал читать.

  5. mshak
    /#20535819

    Мне еще [де]сериализатор от facebook нравится — github.com/facebook-csharp-sdk/simple-json тоже достаточно простой.
    Исходник одним файлом, можно затащить к себе (MIT лицензия).

    • teoadal
      /#20536183

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

      Десериализация:
      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 мегабайт — очень сомнительное удовольствие.