По легенде, Брендан Эйх написал первую версию Javascript за 10 дней 1995 года. Среди множества компромиссов, Javascript был разработан так, чтобы иметь только один числовой тип, с плавающей запятой IEEE 754. Извлеченный из этого причудливого языка, мы получаем JSON, формат сериализации, который является буквальной объектной нотацией Javascript. Сегодня JSON используется почти везде, почти на всех языках программирования и на всех вычислительных платформах.
Недавно меня укусила причуда JSON в неожиданном месте: в моем коде Elixir. Я хочу поделиться этим опытом, потому что я считаю его интересным, и чтобы помочь другим узнать об ограничениях в JSON. В частности, эта ситуация подчеркивает, почему вы должны быть осторожны при использовании JSON для передачи чисел, особенно денежных значений.
В нашем проекте мы храним данные в Postgres, используя Ecto, самую популярную оболочку базы данных Elixir. Чтобы сохранить некоторую презентационную информацию, мы активно используем jsonb
столбцы. Ecto упрощает работу с jsonb. Вот пример различных способов определения типов jsonb в схеме Ecto.
defmodule MyTable do use Ecto.Schema schema "my_table" do field(:name, :string) field(:meta, :map) # jsonb, no typing embeds_one :fields, Fields do # jsonb, with an embedded schema field(:foo, :integer) field(:bar, :float) end end end
Из-за новых требований к дизайну мне пришлось перенести вложенное поле jsonb
из integer
в float
. Попытаться это сделать довольно просто.
- Запросить все строки в таблице
- Приведите значение от
integer
кfloat
- Обновите строки в базе данных
Псевдокод обновления выглядит примерно так:
query = from( r in "my_table", where: fragment("?->>'number' ~ '^[0-9]+$'", r.meta), select: r ) query |> Repo.all() |> Enum.map(&cast_field_to_float/1) |> Repo.update_all()
На данный момент все отлично работает. Все строки обновляются, но происходит что-то непредвиденное. Большинство, но не все поля были преобразованы в float. Такие числа, как 5
, были правильно преобразованы в 5.0
, но 5000
осталось 5000
. Какого черта?
Давайте перейдем к REPL и разберемся с этим. Что произойдет, если мы жестко запрограммируем 5000.0?
iex> meta = put_in(meta.number, 5000.0) model |> Ecto.Changeset.cast(%{meta: meta}, [:meta]) |> Repo.update(force: true) iex> Repo.one(from(r in "my_table", select: r.meta, where: r.id == 1)) %{"number" => 5000}
На данный момент я думаю, что мы обнаружили ошибку в Ecto, Postgrex или, возможно, даже в самом Postgres. Похоже, что не будут применяться только числа, кратные 10. Жесткое кодирование 5001.0
, похоже, работает, так что, может быть, мы нашли крайний случай?
Давайте перейдем непосредственно к Postgres, чтобы посмотреть, сможем ли мы воспроизвести его.
outline_dev=# UPDATE my_table SET meta = '{"number": 5000.0}' where id = 1; outline_dev=# select meta from cards where id = 1; meta -------------------- {"number": 5000.0}
Прямое использование SQL дает мне поведение, которое я ищу, поэтому на данном этапе я склонен думать, что где-то в стране эликсиров есть ошибка. Поговорив немного с Михалом Мускала в Elixir Slack, мы обнаружили проблему.
Чтобы преобразовать карту Elixir в JSON, Ecto должен закодировать значение. Вот как выглядят несколько разных значений после того, как они были закодированы в JSON.
iex(6)> Jason.encode(%{number: 5.0}) {:ok, "{\"number\":5.0}"} iex(7)> Jason.encode(%{number: 5}) {:ok, "{\"number\":5}"} iex(8)> Jason.encode(%{number: 5000.0}) {:ok, "{\"number\":5.0e3}"}
Обратите внимание, как кодируется 5000.0
? Согласно RFC 8259, JSON RFC, 5.0e3 является допустимой кодировкой для 5000.0
. В RFC указано, как анализировать это значение, но не указано его хранилище.
Postgres принимает решение сохранить 5.0e3
как значение integer
для поля jsonb
, в то время как Elixir предпочитает хранить его как float
. Из-за этого несоответствия, когда мы пытаемся сохранить это значение как float
, Postgres возвращает его в integer
.
Итак, какое здесь решение? К сожалению, Postgres ведет себя согласно спецификации. Мы можем попробовать использовать Ecto fragments и jsonb_set
function, чтобы установить значение напрямую, но даже это имеет ту же проблему хранения, что и описанная выше. В качестве альтернативы мы можем сначала изменить число на другое, например 5000
— ›5001.0
-› 5000.0
. Это далеко не идеальное решение, но оно заставляет Постгреса изменить значение так, как мы хотим.
JSON кажется простым форматом, но у него есть странные крайние случаи, которые намного глубже, чем его поверхность. При использовании JSON для передачи или хранения данных имейте в виду, что разные языки и платформы могут выбрать реализацию JSON немного разными, но удивительными способами. Осведомленность об этих ограничениях поможет вам защититься от них и разработать более надежные и лучшие системы.