Линкор Эликсир: Джсон потопил мой поплавок

По легенде, Брендан Эйх написал первую версию 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. Попытаться это сделать довольно просто.

  1. Запросить все строки в таблице
  2. Приведите значение от integer к float
  3. Обновите строки в базе данных

Псевдокод обновления выглядит примерно так:

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. Какого черта?

См. также:  Javascript: литерал объекта против JSON

Давайте перейдем к 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. Это далеко не идеальное решение, но оно заставляет Постгреса изменить значение так, как мы хотим.

См. также:  Что я узнал, создав свое первое приложение SwiftUI

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

Понравилась статья? Поделиться с друзьями:
IT Шеф
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: