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

(Last Updated On: )

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

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

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

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

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

<?php
$json = <<< 'EOD'
{
    "int": 123456789,
    "float": 123456789.5678
}
EOD;

var_dump(json_decode($json));

結果

object(stdClass)#1 (2) {
  ["int"]=>
  int(123456789)
  ["float"]=>
  float(123456789.5678)
}

int型、float型に変換されています。値がfloatの範囲内であれば注意していなくても困ることはありません。

32 bit環境で値がint型の範囲を超える場合、自動的にfloat型に変換されるので符号付き53 bit整数の範囲なら正確に変換されます。

Windows環境では64 bit環境でもPHPのint型は符号付き32 bit整数です。PHP7からはWindowsも64 bit環境ならintは符号付き64 bit整数になります。

つまり、PHP7なら符号付き53 bit整数の範囲内だと確実に正しく整数を扱えます。

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

http://3v4l.org/AfQt3

<?php
$json = <<< 'EOD'
{
    "int": 12345678999999999999,
    "float": 12345678999999999999.5678
}
EOD;

var_dump(json_decode($json));

結果

object(stdClass)#1 (2) {
  ["int"]=>
  float(1.2345679E+19)
  ["float"]=>
  float(1.2345679E+19)
}

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

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

データベースの整数IDなどは、普通は符号付き64ビット整数であることが多いです。32bit環境では簡単に困った状況になります。

そこでjson_decode()にはJSON_BIGINT_AS_STRINGという、オーバーフローする整数型を文字列として返すオプションがあります。

http://3v4l.org/EeqYU

<?php
$json = <<< 'EOD'
{
    "int": 12345678999999999999,
    "float": 12345678999999999999.5678
}
EOD;

var_dump(json_decode($json, false, 512, JSON_BIGINT_AS_STRING));

結果

object(stdClass)#1 (2) {
  ["int"]=>
  string(20) "12345678999999999999"
  ["float"]=>
  float(1.2345679E+19)
}

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関数でデコード処理すると大きな整数も正しくデコード(でも実はfloatの53bit整数)されているので、intにしても大丈夫なはず、と勘違いしてしまうユーザーも居ると思います。

declare(strict_types=1)宣言の有無に関係なく、32 bit環境を考慮しないコードの場合、32 bit環境では整数の情報が比較的簡単に失われることになります。

例えば、32 bitのIoTデバイスがPHPを利用していると、開発者が予期しないバグを作ってしまう、と予想されます。JSON整数データは「整数はint、floatまたはstringの何れかになる」json_decodeの仕様はPHP7のタイプヒント問題を複雑にします。

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

PostgreSQLのJSONサポート

PostgreSQLはJSON型とJSONB型の二種類をサポートしています。JSON型はデータを文字列として保存するので、どのように大きな数値データでも正確に保存します。

JSONB型はバイナリのデータに変換し保存しますが、数値データはNUMERIC型を利用しており、通常ではあり得ないくらい大きな範囲(小数点前までは131072桁、小数点以降は16383桁)のデータを正確に保存します。

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

PHPのみでなく、多くのプラットフォームでPostgreSQLが正しく処理可能なJSONデータを壊さずに処理することが困難だと思われます。

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違反になります。

$ php -r "echo 9999999999999999999999999999999999999999999999**99999999999999999999;"

INF

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

$ php -r "var_dump(1/0);"
PHP Warning:  Division by zero in Command line code on line 1

Warning: Division by zero in Command line code on line 1
bool(false)

$ php -r "var_dump(0/0);"
PHP Warning: Division by zero in Command line code on line 1

Warning: Division by zero in Command line code on line 1
bool(false)

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

開発者はどうすべきか?

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

http://3v4l.org/muJEi

<?php
$json = <<< 'EOD'
{
    "int": "123456789999999999999999",
    "float": "12345678999999999999999.5678"
}
EOD;

var_dump(json_decode($json));

結果

object(stdClass)#1 (2) {
  ["int"]=>
  string(24) "123456789999999999999999"
  ["float"]=>
  string(28) "12345678999999999999999.5678"
}

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設計ミス1をするとは思いもよりませんでした。 データ型をあまり意識しないスクリプト系言語に慣れているユーザーが、PHP7でどのようなミスをしてしまうのか、今から心配でなりません。。。

ライブラリによって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数値データは全て文字列にしてしまった方が簡単かつ統一して取り扱えるので便利かも知れません。現在ではデータ型が強い言語でも、変数のデータ型を気にせず出力できるシステムが多いです。このため、全て文字列データにして入力バリデーションした方がセキュリティも向上します。

参考:


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

投稿者: yohgaki