freee の API では BigDecimal をどう扱うべきなのか?

この記事は freee Developers Advent Calendar 2021 の最終日の記事です🎄

普段は freee会計 や freee人事労務 といった、freee におけるコアサービスの開発の面倒を見る、プロダクトコア開発本部の本部長をしています id:yo_waka です。

前回書いたときは Webpack でビルドしていましたが、昨日も Webpack でビルドしていました。

今回何を書こうかなーと悩んでいたのですが、最近 BigDecimal の扱いについて社内で議論する機会があったので共有してみます。

あらまし

  1. Rubyのバージョンアップをするぞ
  2. 依存ライブラリを新しいRubyバージョンに対応したものに上げていくぞ
  3. jbuilder 並びに JSON gem のバージョンを上げていくぞ <= 今日はここの話題です

freee では、Rails で API レスポンスとして JSON を返す際に Rails/jbuilder を使っているのですが、jbuilder のバージョンを上げると BigDecimal で扱っていた値の型が非互換になってしまう問題が分かりました。

これまで使っていたバージョンの jbuilder では、MultiJson という gem を通じて JSON の parse/dump 処理を様々な gem に委譲することができました。
freee会計 では委譲先の gem として、Oj を使って JSON を dump しています。
これが、jbuilder を最新のバージョン(正確にはv2.11.5以上)に上げると、MultiJson を使わずに Ruby にバンドルされる JSON gem を使うようになります。 このdiffがそうです。

以前の
# Encodes the current builder as JSON.
def target!
  ::MultiJson.dump(@attributes)
end

最新バージョンの
# Encodes the current builder as JSON.
def target!
  ::JSON.dump(@attributes)
end

ここで BigDecimal の扱いに差分が出ることが分かりました。
現在使っている Oj では、BigDecimal を JSON dump すると、float値が返る仕様になっています。
一方で JSON gem で JSON dump すると、String値が返るようになります。

hash = { qty: BigDecimal('100.00001') }

Oj.dump(hash)
=> "{\":qty\":100.00001}"

JSON.dump(hash)
=> "{\"qty\":\"100.00001\"}"

という感じで JSON dump 後の型が float から String に変わってしまう問題が起きます。 モバイルアプリのように、クライアント側で API レスポンスの型定義をして扱っている場合、クラッシュしてしまう可能性があります。

一旦休憩

ちょっと疲れてきたので一旦 JSON gem が BigDecimal をどう扱っているか、実装を見てみます。

JSON#dump では value の型チェックをしながら出力していくわけですが、各種型ごとに オープンクラスで as_json メソッドを拡張していて、出力時に呼び出されます。 BigDecimal の場合このように定義されていました(一部引用)。

https://github.com/flori/json/blob/09dd1d7fe35d8e094c22df6eac66331ff010ed66/lib/json/add/bigdecimal.rb#L7

class BigDecimal
  # Marshal the object to JSON.
  #
  # method used for JSON marshalling support.
  def as_json(*)
    {
      JSON.create_id => self.class.name,
      'b'            => _dump,
    }
  end
end

ここで注意なのが、Rails をフレームワークとして採用している場合 ActiveSupport を使うことになりますが、ActiveSupport 側でさらに as_json メソッドが拡張されます。

https://github.com/rails/rails/blob/18707ab17fa492eb25ad2e8f9818a320dc20b823/activesupport/lib/active_support/core_ext/object/json.rb#L118

class BigDecimal
  # A BigDecimal would be naturally represented as a JSON number. Most libraries,
  # however, parse non-integer JSON numbers directly as floats. Clients using
  # those libraries would get in general a wrong number and no way to recover
  # other than manually inspecting the string with the JSON code itself.
  #
  # That's why a JSON string is returned. The JSON literal is not numeric, but
  # if the other end knows by contract that the data is supposed to be a
  # BigDecimal, it still has the chance to post-process the string and get the
  # real value.
  def as_json(options = nil) # :nodoc:
    finite? ? to_s : nil
  end
end

これまで議論があったんだろうなと察する感じのコメントが書かれていて興味深いですね。 「ほとんどのライブラリがfloatとして返しているけど、それは間違ってるので文字列で返しますよ。その文字列を自分たちで処理して使ってね」と書かれています。

というわけで、 BigDecimal#to_s が最終的に呼ばれていることが分かりました。

BigDecimal#to_s の定義場所はここでした。

https://github.com/ruby/bigdecimal/blob/ac7daa5f15c4583b3cdf7db046e0dcad772360af/ext/bigdecimal/bigdecimal.c#L2508

ちゃんと指数部を変換してくれていますね。

API レスポンスでは BigDecimal な値をどう返すべきか

サーバサイドでは丸め誤差を防ぐ必要があるので、小数を含む数値を BigDecimal として扱うのは当然として、上記問題が起こる場合にクライアントにどう返すべきなのか。 挙動が変わることで起きるクライアントアプリのクラッシュのリスクを極力増やしたくないこと、上記実装を見ていって BigDecimal#to_s は信頼できる値が返ることが分かったので、クライアントには to_f した float 値を返せばいいんじゃないかと最初は思っていました。

qty = BigDecimal('100.00001')

qty.to_s
=> "100.00001"

qty.to_s.to_f
=> 100.00001

実際、会計や人事労務領域において、数値型、とりわけ小数を扱う機会はあまりないのです。 「金額」がほとんどで、一部業務で「個数」「年齢」「人数」を扱うくらい。あとは enum値でコードを持ったりしますが、いずれも整数です。 こと国内向けのサービスであれば、金額は円で、会計上は小数で扱うことがありません。 個数は小数を扱うケースがありますが、ほとんどのケースで小数点第二位までくらいです。

ということもあり、これまで小数の扱いについてちゃんと考える機会がなかったなと。 上のような議論を社内の Slack でしていて、こんな意見が出てきました。

BigDecimalのユースケース

確かに外貨を扱う場合はありえそう・・! 一気に自分の中で BigDecimal は String で返してクライアントで処理しなさい派に傾いてしまいましたw ActiveSupport に書かれていたコメントが腹落ちできてよかったです。

今後は、モバイルアプリのバージョンアップをお願いするコミュニケーションを取らせていただいて進捗した後に、BigDecimal を String で返す修正を入れて、ライブラリのアップデートを進めていく予定です。

ちなみに JavaScript の小数演算は、IEEE 754(浮動小数点数演算標準) の規格に沿っているため、そのまま扱ってしまうと誤差が出てしまいます。 また、一定の値を超えると正しく表示できなくなります。

0.1 + 0.2
> 0.30000000000000004

70368744177664.01
> 70368744177664.02

ブラウザで正確に扱いたい場合は bugnumber.js などの BigInt, BigDecimal を実装しているライブラリを使いましょう。

最後に

freeeでは毎日各チームで業務ドメインどうする議論が行われています。 それを繰り返していくだけで、ユースケースと実装の紐付けが強くなっていく実感が持てるので、そういうのいいなと思った方がいればぜひ話を聞きにきてみてください。

jobs.freee.co.jp

今年の freee Developers Advent Calendar 2021 はこれで終わりです。また来年もご期待ください。