Как стать автором
Обновить

Нельзя просто так взять и распарсить этот JSON на JavaScript

Время прочтения 7 мин
Просмотры 18K
JavaScript *Совершенный код *Криптовалюты

JSON является одним из очень простых, но в то же время эффективных языков для хранения и передачи данных. Он настолько популярен, что, пожалуй, может считаться самым совместимым форматом представления данных в мире. Вероятно, что все современные языки программирования умеют работать с JSON практически из коробки.

Одновременно с этим, JavaScript является одним из наиболее популярных языков программирования и применяется практически везде, начиная от веб-браузеров, заканчивая серверами и инструментами разработки. Также, нужно понимать, что JSON появился напрямую из JavaScript и эти два языка просто созданы друг для друга.

Но что же может пойти не так, спросите Вы? Просто попробуйте распарсить следующий JSON-документ:

{ "foo": 123456789123456789123 }

Давайте попробуем самый простой способ:

const object = JSON.parse('{ "foo": 123456789123456789123 }');

console.log(object.foo);

// Outputs: 123456789123456800000

Что же здесь произошло? Очевидно результат выполнения: 123456789123456800000 отличается от оригинального значения из JSON-документа: 123456789123456789123

Дело в том, что указанное число слишком велико, чтобы быть представленным стандартными типами данных, которые можно найти в современных языках программирования. Обычно, самым большим типом данных, который часто поддерживается языками программирования, является unsigned int 64, т. е. целое 64-битное число без знака (что соответствует размерам регистра современного 64-битного процессора), тогда как наше число требует 67 битов для корректного представления. Это и приводит к потере точности при попытке записать число в память компьютера. И стоит понимать, что это происходит не только с JavaScript, но и со всеми языками программирования, которые имеют ограничение на размер хранимого числа.

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

Приведем пример, как наш JSON-документ можно распарсить, например, на Go с использованием специального типа:

package main

import (
 "encoding/json"
 "fmt"
 "math/big"
)

func main() {

 var object struct {
  Foo big.Int `json:"foo"`
 }
 
 b := []byte(`{ "foo": 123456789123456789123 }`)

 if err := json.Unmarshal(b, &object); err != nil {
  panic(err)
 }
 
 fmt.Println(object.Foo.String())
 
 // Outputs: 123456789123456789123
 
}

Благодаря использованию типа big.Int вышеприведенный код отлично распарсит наше число без потери точности. Как правило, любой другой язык программирования также позволяет добиться аналогичного результата похожим образом.

Попробуем повторить это на JavaScript?

Обратим внимание на параметры JSON.parse. Вторым аргументом метода является специальная callback-функция называемая "reviver" (восстановитель). По-сути это функция десериализации, которая вызывается для каждого значения (value) из распарсиваемого JSON-документа и позволяет менять значение на усмотрение пользователя. Также, в JavaScript есть специальный тип BigInt, позволяющий работать с большими целыми числами.

Супер! Давайте теперь объединим это всё вместе:

const object = JSON.parse(
  '{ "foo": 123456789123456789123 }',
  (key, value) => (key === 'foo') ? BigInt(value) : value
);

console.log(object.foo);

// Outputs: 123456789123456794624n

В реализации выше мы проверяем, что ключ соответствует значению: "foo", и конвертируем значение из JSON-документа в BigInt.

Но подождите! Полученный результат: 123456789123456794624n снова отличается от ожидаемого нами: 123456789123456789123. В чем дело на этот раз?

Немного копнув глубже, оказывается, что в далеком 2009-ом году разработчики стандарта EcmaScript (JavaScript) проделали не самую хорошую работу. Дело в том, что значение (value), которое передается в пользовательскую функцию (reviver) уже является предварительно десериализованным. Фактически, это приводит к выполнению примерно следующего кода:

(key === 'foo') ? BigInt(123456789123456800000) : 123456789123456800000

Другими словами, как бы Вы того не хотели, не существует способа корректно распарсить это число используя нативный JSON-парсер на JavaScript.

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

Hidden text

Извиняюсь, что перебил!

Недавно я запустил свой Telegram-канал по разработке на JavaScript, там я планирую делиться опытом, новостями и наработанными практиками из мира JS. Заходите не стесняйтесь :)

Но есть и хорошие новости!

Комитет TC39 (группа экспертов отвечающих за разработку современного стандарта EcmaScript) рассматривает предложение об улучшении языка (JSON.parse source text access proposal), которое добавляет возможность доступа к исходному тексту JSON-значения в reviver-функцию. Данный стандарт находится уже на третьей стадии (Stage 3) и даже реализован в движке V8 (на котором работает Chromium и Node.js). Достаточно включить флаг: harmony_json_parse_with_source.

Реализация этого функционала в V8 появилась относительно недавно: 10-го Декабря 2022 года, однако само предложение толком не двигается вперед уже более двух лет и, кажется, что мейнтейнеры не особо торопятся его финализировать. Конечно, такая вещь как изменение стандарта требует времени. Думаю пройдет еще не один год, прежде чем данный функционал будет полностью реализован и внедрен во все JS-движки.

Почему это важно?

Нынче я работаю с технологиями Web 3.0 и в этой сфере очень часто можно встретить не просто большие, но огромные числа. Возьмем к примеру Ethereum. Один эфир (Ether) в этой системе равен 1,000,000,000,000,000,000 WEI (1E18), согласитесь квинтиллион — это довольно большое число. В системах, которые работают с финансами принято хранить денежные значения как целое число условных "центов" (WEI в данном примере), а затем просто округлять такие значения при отображении пользователю. Это позволяет добиться максимальной точности при всех вычислениях.

В то же время формат JSON используется, наверное, в 99% различных API и нам нужен способ передавать такие большие числа между системами. Многие API представляют большие числа не как числа, а как строки, содержащие числа. Отчасти это решает проблему на стороне JS, но это достаточно коряво и нарушает семантику стандарта JSON. Должен быть способ лучше!

Способ лучше

Приняв все вышеизложенное, я решил разработать собственный JSON-парсер на JavaScript, который бы учел все имеющиеся проблемы с парсингом JSON на JS в одном готовом решении и мне удалось оформить это как полноценный полифил. Теперь не нужно ждать внедрения стандарта несколько лет перед его использованием. Возможно это даже поможет с внедрением самого стандарта.

Вот пример как можно использовать новый стандарт совместно с полифилом для нашего случая:

import '@ton.js/json-parse-polyfill';

const object = JSON.parse(
  '{ "foo": 123456789123456789123 }',
  (key, value, context) => (
    (key === 'foo' ? BigInt(context.source) : value)
  )
);

console.log(object.foo);

// Outputs: 123456789123456789123n

Как видите достаточно только импортировать полифил и немного поменять reviver-функцию. Наконец-то оно работает!

В завершение

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

Кстати, кроме доступа к исходному тексту JSON-значения из документа, полифил также добавляет доступ к пути из ключей соответствующему каждому конкретному значению, это позволяет существенно упростить парсинг документов и избежать повторного обхода всех значений объекта. В дополнение, парсер имеет фикс безопасности, который применяется, в частности, в Fastify.

Ну, а если Вам нужно еще больше гибкости и удобства при парсинге JSON-документов, то обратите внимание на мою дополнительную библиотеку, которая позволяет матчить значения по их путям используя некое подобие глобов.

Ознакомиться с документацией и примерами всех библиотек можно в репозитории на GitHub. Также не стесняйтесь делиться собственным мнением и задавать вопросы в комментариях.

Удачного парсинга! Теперь ни одно подлое число от Вас не сбежит!

Понравилась статья?

Если статья показалась Вам полезной, пожалуйста, поставьте звезду на GitHub.

Также, приглашаю на мой новый канал по JavaScript-разработке в Telegram. Весь новый контент я выкладываю там.

Вопросы

  1. Какие есть недостатки при использовании этого полифила?

Сам полифил не очень большой, всего 8 КБ несжатого кода. Однако, нужно понимать, что он будет работать медленнее, чем нативная реализация. В 25 раз медленнее, если быть точным. Но, данный парсер позволяет пропускать через себя 1 МБ вложенных JSON-данных за ~40 миллисекунд. Этого должно быть вполне достаточно для большинства приложений.

Также, полифил использует кастомный парсер только в том случае, когда Вы применяете функционал из нового стандарта (аргумент context в reviver-функции), во всех остальных случаях используется обычная нативная реализация.

  1. Написать свой парсер не такая простая задача, он точно работает корректно?

Вся прелесть JSON в том, что этот формат имеет очень простую спецификацию. Также, парсер хорошо покрыт unit-тестами и протестирован на большом количестве реальных сложных документов. Код парсера написан на предельно строгом TypeScript, а сгенерированные бандлы проверяются перед каждым релизом как вручную, так и автоматически на предмет безопасности. Также, библиотека имеет ноль зависимостей и включает дополнительные фичи повышающие безопасность. Таким образом, библиотека может быть использована даже в проектах с достаточно высокими требованиями к безопасности.

Подробнее про безопасность в npm и выбор зависимостей Вы можете почитать в моем цикле статей на Хабре.

  1. Как лучше представлять большие числа в JSON-документах?

Стандарт JSON не накладывает каких-либо ограничений на размер чисел, однако, авторы некоторых API часто представляют большие числа как строки в своих JSON-документах:

{ "foo": "123456789123456789123" }

Данный подход позволяет JS парсить большие числа используя нативную реализацию.

Однако, я абсолютно убежден, что правильным решением является использование семантически-корректного типа для представления больших чисел в JSON — number. Это не вина стандарта JSON и других платформ в том, что разработчики EcmaScript оказались столь недальновидными. Ровно по этой причине комитет TC39 работает над внедрением нового стандарта, а я разработал описанные выше библиотеки.

P/S: в комментариях поделились интересным кейсом — если Ваш API имеет прослойки между сервером и клиентом, то представление данных стоит внимательно проверять, бывают случаи, когда какой-нибудь API Gateway может "портить" числа в документах.

Теги:
Хабы:
Всего голосов 75: ↑70 и ↓5 +65
Комментарии 60
Комментарии Комментарии 60

Публикации

Истории

Работа