PHP7とjson_decodeとjson_encodeの困った仕様 – 数値型データの問題

(更新日: 2015/05/15)

PHP7からint/float/arrayの基本的データ型のタイプヒントが導入されます。これには困る問題があるのですが、その問題を更に複雑にするjson_decode関数のデータ型変換問題があります。

JSONデータの数値型データ※が特定の型に変換される問題はPHPのjson_decode関数に限った問題ではなく、JSONを利用する処理系を作る開発者すべてが注意すべき問題です。

※正確には数値型データと書くより「数値型リテラル」と記述するべきですが、「数値型データ」とします。

大きな数値さえ使わなければ気にしなくても問題にならないのですが、json_decodeはJSONの整数/浮動小数点形式データをPHPのint型、float型に自動変換します。

 

JSON RFCの内容

JSONを定義するRFC 7195によると

This specification allows implementations to set limits on the range and precision of numbers accepted. Since software that implements IEEE 754-2008 binary64 (double precision) numbers [IEEE754] is generally available and widely used, good interoperability can be achieved by implementations that expect no more precision or range than these provide, in the sense that implementations will approximate JSON numbers within the expected precision.

浮動小数点型は、IEEE754の倍精度浮動小数点の範囲で使わないと、相互運用性に問題が生じる可能性があるとしています。整数も同様に

numbers that are integers and are in the range [-(2**53)+1, (2**53)-1] are interoperable in the sense that implementations will agree exactly on their numeric values.

-(2**53)+ から (2**53)-1 の範囲、つまりIEEE754の仮数(exponent)が整数として正確に表現できる範囲でないと、相互運用性に問題が生じる可能性があるとしています。

 

RFC 7195のコメントとPHPの仕様

RFC 7195は数値データがネイティブデータ型に変換されるとにより、相互運用性に問題を生じるかもしれないと指摘しています。PHPの動作が正にこれにあたりますが、これはPHPに限った問題ではありません。

PHPに限らないのですが、ここではPHPの話をしているのでPHPコードとその動作を紹介します。

http://3v4l.org/4ceSC#v535

結果

int型、float型に変換されています。値がfloatの範囲内であれば注意していなくても困ることはありません。32 bit環境で値がint型の範囲を超える場合、自動的にfloat型に変換されるので符号付き53 bit整数の範囲なら正確に変換されます。Windows環境では64 bit環境でもPHPのint型は符号付き32 bit整数です。PHP7からはWindowsも64 bit環境ならintは符号付き64 bit整数になります。

次の例はLinux 64 bit環境ですが、整数オーバーフローする値を設定するとfloat型に変換されることが確認できます。

http://3v4l.org/AfQt3

結果

12345678999999999999がfloat(1.2345679E+19)になり、float型に変換されています。

 

データ型の自動変換が問題の原因に

データベースの整数IDなどは、普通は符号付き64ビット整数であることが多いです。32bit環境では簡単に困った状況になります。そこでjson_decode()にはJSON_BIGINT_AS_STRINGという、オーバーフローする整数型を文字列として返すオプションがあります。

http://3v4l.org/EeqYU

結果

string型として返され、値の正確性は失われていません。

これがなぜPHP7で導入されるタイプヒントで問題になるかと言うと、JSONデータを配列に変換した場合、

「整数はint、floatまたはstringの何れかになる」

ことにあります。JSONデータを検証する場合、3つのデータ型になる可能性があることに注意が必要です。PHP7で採用された方のタイプヒントRFCでは、declare(strict_types=1)を宣言すると型のミスマッチで致命的エラーになります。declare(strict_types=1)が無い場合、自動的に指定した型へのキャストを行います。採用されなかった方のタイプヒントRFCでは、問題を隠してしまう動作にはなりません。

json_decode関数が折角floatにデータ型変換していても、intタイプヒントを使ってしまうと32 bit環境では符号付き32 bit整数になってしまいます。例えば、32bit環境でも動作テストを行い、json_decode関数でデコード処理したあとに大きな整数も正しくデコードされているので、intにしても大丈夫なはず、と勘違いしてしまうユーザーも居ると思います。

declare(strict_types=1)宣言の有無に関係なく、32 bit環境を考慮しないコードの場合、32 bit環境では整数の情報が比較的簡単に失われることになります。例えば、32 bitのIoCデバイスがPHPを利用していると、開発者が予期しないバグを作ってしまう、と予想されます。JSON整数データは「整数はint、floatまたはstringの何れかになる」json_decodeの仕様はPHP7のタイプヒント問題を複雑にします。

他のjson_decode関数の困った問題に、表現範囲が広い浮動小数点データを文字列として渡すオプションがないことです。PHP7用には議論されているので、少なくともJSON_BIGFLOAT_AS_STRINGのようなオプションが追加されると思います。

 

PostgreSQLのJSONサポート

PostgreSQLはJSON型とJSONB型の二種類をサポートしています。JSON型はデータを文字列として保存するので、どのように大きな数値データでも正確に保存します。JSONB型はバイナリのデータに変換し保存しますが、数値データはNUMERIC型を利用しており、通常ではあり得ないくらい大きな範囲(小数点前までは131072桁、小数点以降は16383桁)のデータを正確に保存します。

PostgreSQLマニュアルではJSONB型の制限が明確に記載されています。(PHPのマニュアルは明確ではありません。。)

 

PHPはどうすべきだったのか?

PHPのPostgreSQLモジュールをメンテナーを担当している者としては、「なぜデフォルトで全て文字列として扱わなかったのか?」と疑問に思わざるを得ません。データベースのAPIは基本的に、全てテキストでデータをやり取りしています。WebのAPIは基本的にテキストであり、特定のデータ型を利用するという概念はほとんどありません。Web用言語であるPHPが「デフォルトで全て文字列として扱わない」理由はあまりありません。

値の範囲を想定できないデータを正確に取り扱うには「データを文字列として扱う」しかありません。RFC 7195が言及している通り、データ型変換をしてしまうと相互運用性に欠ける仕様になってしまいます。

元々のJSON RFCであるRFC 4627では数値データは

2.4. Numbers

The representation of numbers is similar to that used in most programming languages. A number contains an integer component that may be prefixed with an optional minus sign, which may be followed by a fraction part and/or an exponent part.

Octal and hex forms are not allowed. Leading zeros are not allowed.

A fraction part is a decimal point followed by one or more digits.

An exponent part begins with the letter E in upper or lowercase, which may be followed by a plus or minus sign. The E and optional sign are followed by one or more digits.

Numeric values that cannot be represented as sequences of digits (such as Infinity and NaN) are not permitted.

と定義されており、特定のデータ型(PHPが利用する符号付き32/64bit整数、IEEE754倍精度浮動小数点)の概念はありません。データが持つべき範囲に言及しておらず、”INF”などになってしまうfloat型に変換するのは明白なRFC違反になります。

更におかしな挙動はゼロ除算です。

数値のばずが論理型になってしまいます。IEEE 754の場合、それぞれINFとNaNになるべきです。(これはPHP7での修正が議論されています)

 

開発者はどうすべきか?

整数IDなどでオーバーフローが懸念されるデータの場合、JSONの数値型(整数、浮動小数点)には使わない対策が有効です。

http://3v4l.org/muJEi

結果

APIでJSONを利用する場合、 数値は”文字列”として渡すことをAPI仕様とすれば、意図しないIDオーバーフローなどで悩まされることがなくなります。

IDオーバーフローなどはAPIを提供している側で発生するとは限りません。APIを利用している側で発生する可能性があります。APIを提供する側は、APIを利用する側のコード/ライブラリ/システムを制御できないので、相互運用性を最大限にするには「数値型を利用せず、文字列として渡す」方法が選択可能な対処策になります。

JSON数値データを提供するAPIを利用する場合、json_decode()にはJSON_BIGINT_AS_STRINGを利用すると良いです。しかし、これだけでは十分ではありません。JSON数値データを返す必要があり json_encodeでエンコードする場合、PHP変数のデータ型はint/floatでなければなりません。これは簡単に解決できません。。

 

まとめ

前回のブログで「データ型は単純に変換してはならない」と書きました。PHPのjson_decode実装の失敗は「データ型は単純に変換してはならない」の良い教訓だと思います。

PHPの開発者はC言語を良く知っており、データベースやWeb APIの特徴も良く知っているハズなので、このようなAPI設計ミス※をするとは思いもよりませんでした。 データ型をあまり意識しないスクリプト系言語に慣れているユーザーが、PHP7でどのようなミスをしてしまうのか、今から心配でなりません。。。

※ JSONモジュールのJSON_parser.c/hは https://github.com/udp/json-parser が元になっています。このコードがCのint、doubleに変換してしまっている仕様をそのまま使ってしまったことがこの問題の原因です。この仕様の場合、受け取ったJSONデータを加工してJSONデータを返す場合に便利であることも理由でしょう。PHPはデータ型が弱く、コンテクストに応じて適当にデータ型を自動変換していたので、この仕様を「設計ミス」とするのは言い過ぎかも知れません。

ライブラリによってJSON数値データの性質をあまり考えないで特定のデータ型に変換されてしまう問題は、PHPに限った問題ではありません。PHPだけにクレームを付けても問題は解決しません。json.orgで紹介されているライブラリにはPHPと同様(PHPが使っていたjson-parser、新しいPHPが使っているjson-c含む)の問題があります。データ型の範囲を超える値の場合、オーバーフローせずエラーとなるような実装もあります。文字列として数値を扱うことにより、問題を回避できるシステムはPHP以外にも多くあります。

  • JSONで数値データの相互運用性を保証するために、数値データも文字列データにする

このようなJSONデータ構造/API設計の場合、言語によっては一手間必要となる場合もありますが大した問題ではありません。データ型が厳格な言語でもデータベースとの相性が良くなるのでメリットは大きいです。

JSONで数値データを利用する場合、JSONデータを受け取った処理系は

  • 概ね符号付き53 bit整数の範囲までしか利用できない。※
  • 浮動小数点の場合、IEEE754 倍精度までしか利用できない。

と仮定して利用する必要があります。

※多くの処理系では符号付き64bit整数の範囲まで利用できますが、符号付き32bit整数の範囲までしか利用できない、と仮定する方が安全です。(加算などの演算を行うと、32bit整数になる、と想定する)PHPの場合、環境に関わらず、符号付き53bit整数まで正確に取り扱うことができます。

JSONライブラリ開発/JSONデータ構造設計/JSON API設計を行う場合、

  • 数値型(整数、浮動小数点)データがサポートする範囲を明確に文書化する

よう注意しなければなりません。利用者はマニュアルでライブラリのAPI仕様が明確でない場合、ソースコードを確認すると良いでしょう。OSSでないならサポートセンターに仕様を問い合わせましょう。

途中で簡単に触れましたがjson_encode関数にも同様の問題※があります。これは機会があれば詳しく紹介します。

※ int型、float型でないと正しくエンコードされない問題。PHPに限らず、JSON数値データの正しい取り扱いは一筋縄ではありません。そもそもWebアプリが利用するAPIはテキストばかりで、セキュリティ対策として入力値バリデーションが必須です。特殊なデータ型を持つSQLiteを利用するアプリも多いでしょう。PHP以外の言語でも、JSON数値データは全て文字列にしてしまった方が簡単かつ統一して取り扱えるので便利かも知れません。現在ではデータ型が強い言語でも、変数のデータ型を気にせず出力できるシステムが多いです。このため、全て文字列データにして入力バリデーションした方がセキュリティも向上します。

参考:

Comments

comments

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です