【AI Shift Advent Calendar 2021】Go言語でJSONのNumberを扱う際の落とし穴

こんにちは、サーバーサイドエンジニアインターンの木村です。

AI Shiftではアプリケーションのサーバーサイドの開発にGo言語を使用しています。今回はGo言語でJSONを扱う際の、ちょっとした落とし穴について触れていこうと思います!

Go言語のencoding/jsonパッケージの基本

まずはencoding/jsonの基本からです。(すでに知ってる方は適宜読み飛ばしてください。)

Go言語では、jsonを扱うためのライブラリが、encoding/jsonパッケージとして標準で提供されています。

例えば、あるjsonファイル(sample.json)をGoで扱う例として、下記のようなコードが考えられます

{
    "name" : "hoge",
    "age": 11
}
package main

import (
	"encoding/json"
	"fmt"
	"os"
	"reflect"
)

type Person struct {
	Name string `json:"name"`
	Age  int    `json:"age"`
}

func main() {
	// JSON読み込み
	f, err := os.Open("./sample.json")
	if err != nil {
		// errorハンドリング
	}
	// JSONデコード
	dec := json.NewDecoder(f)
	var person Person
	err = dec.Decode(&person)
	if err != nil {
		// errorハンドリング
	}

	// デコードしたデータを表示
	fmt.Printf("name: %s, age: %d\n", person.Name, person.Age)
	fmt.Printf("  Type of Name: %+v\n", reflect.TypeOf(person.Name))
	fmt.Printf("  Type of Age : %+v\n", reflect.TypeOf(person.Age))
}

Decodeされたjsonの各要素の型も表示してみました。
実行結果は下記のようになります。

> 
name: hoge, age: 11
  Type of Name: string
  Type of Age : int

上記での要は、json.Unmarshal(bytes, &person); の部分です。ここでjsonのbyteコードをGoの変数(ここではperson)に変換してます。

この変換の際に、jsonのどのフィールドをGoの値に対応させるかというのを、structタグで指定することができます。具体的に上の例だと、Person型の各フィールド定義にこのstructタグによる指定を行なっていて、

  • jsonの「name」フィールド -> Person型のNameフィールド
  • jsonの「age」フィールド -> Person型のAgeフィールド

といったマッピングを宣言しています。

このように、構造体定義時に対応するjsonのフィールドをstructタグとして記述することで、encoding/jsonパッケージのjson.Unmarshal等のAPIがよしなにjsonの値をGoの値に変換してくれます。

JSON中のNumber型をInterface{}として受け取ると

ここから本題です。

先ほどの例ではJSONを変換するGoの構造体を事前に準備しており、コンパイルの時点で入力のjsonがどのような構造か事前に予測ができている状況を想定していた例でした。
しかし実際には、実行時になるまでどういう値が入ってくるか分からない、といったケースもあるでしょう。

そのようなケースを考慮して、encoding/jsonではデコード先の型としてinterface{}もサポートしてます。

具体的に、先ほどの例を少し変更して下の例を考えてみます。

func main() {
  // 略
	// JSONデコード
	dec := json.NewDecoder(f)
	var person map[string]interface{}
	if err := dec.Decode(&person); err != nil {
  // 略
}

先ほどとの変更点は、変数 person の型です。
Person型ではなくmap[string]interface{}型に変更してみました。

ここで再度実行してみます。

> 
name: hoge, age: 11
  Type of Name: string
  Type of Age : float64

ageの型に注目してください。先ほどint型であったのが、Decode先の変数の型をinterface{}にすることで、ageの型がfloat64になってしまいました。

どうやらJSON中の数値をinterface{}で受け取ると、Go側ではfloat64として扱われるようです。

なにが起こっているか

結論、これはencoding/jsonの仕様の仕様のようです。
encoding/jsonのdocsを見ると、json値のinterface{}への当てはめに関して下記のような記述があります。

To unmarshal JSON into an interface value, Unmarshal stores one of these in the interface value:
bool, for JSON booleans 
float64, for JSON numbers
string, for JSON strings
[]interface{}, for JSON arrays
map[string]interface{}, for JSON objects
nil for JSON null

上記のように、json中のNumberはfloat64として扱われるとあります。
これは仕様を知らないと、ハマるポイントかもしれません!... (実際に筆者はこれでハマりました)
恐らくJavascriptの数値表現(IEEE754 倍精度浮動小数点)に合わせるために、このような実装になっていると考えられます。

実際にこの挙動は、開発にどのように影響するでしょうか。


基本的には、interface{}型をアサートなしで具象型に当て嵌めようすると、

incrementAge()はint型を受け取る関数として定義.この場合静的チェックでErrorになる

のようにコンパイラが「interface{}別の型として扱うな!」と警告してくれるので、すぐに致命的なErrorとなりうるケースは少ないかもしれません。

ただ、「この場合は注意した方がいい」と思われるケースが何点かあるので、そちらをピックアップしていこうと思います。

注意するケース1: 実行時アサート

Goはinterface{}型の実行時アサートをサポートしてます。
これは、実行時にinterface{}型を具象型に変換してくれる便利機能なのですが、使用に際しては注意が必要です。

上の例で警告が出た、incrementAge()の例を再度見てみます。
先ほどはincrementAge()の引数がint型でない、という点で怒られていたので、今度は実行時アサートを下記のように使用してみました。

これでErrorはひとまず消えました!
コンパイラは、実行時にp["age"]にはint型が入ると信じてコンパイルを通してくれます。

しかし、このコードは実際の実行時には上で見たようにp["age"]にはfloat64型の値が入ります。これを実行すると、

> 
panic: interface conversion: interface {} is float64, not int

goroutine 1 [running]:
main.main()
        /Users/me/golang/tmp/main.go:34 +0x565
exit status 2

実行時Errorになってしまいました!

これを解決するためには、一旦実行時アサートでfloat64として値を取り出し、その後intへキャストする方法が考えられます。

また、より一般的には型switchを使用して、各型に応じて処理を切り替えることでも防止することができます。

型switchを用いた例

注意するケース2: オーバーフローしそうな大きな値をintに変換して扱う時

これはjsonライブラリというより、キャスト時の注意という感じではありますが、jsonの数値をintとして扱おうとしていて、かつjsonに大きい整数値が入ると想定される場合は注意が必要です。

具体的に、入力のjsonファイルを下記のように変更してみます。

{
    "name" : "hoge",
    "age": 9223372036854775807
}

ageを9223372036854775807という値にしてみました。
これはint64で桁溢れが発生しない最大値(1<<63 - 1)です。

これでincrementAge()の中に下記のようなログ、

を仕込んで、aの値をみてみます。

name: hoge, age: 9.223372036854776e+18
  Type of Name: string
  Type of Age : float64
  Incremented age: -9223372036854775807

桁溢れが発生していて、期待するのとは別の値になってしまっていますね...。

このような事態を防ぐためにも、実装時の段階で、jsonの数値として入りうる値を考慮しながらキャストする型を決める必要があるでしょう。

まとめ

Go言語でjsonを扱うライブラリencoding/jsonが、jsonの数値をinterface{}型に割り当てる際の挙動を見ていきました。

実際にこの挙動で開発時に困る場面はあまりないかもしれませんが、もし関連のErrorに遭遇した際の助けとなれば幸いです!

参考文献


補足: encoding/jsonのどこでfloat64としてデコードしているか

encoding/jsonの標準ライブラリの中身を追って、実際にfloat64に変換されてる部分を少し覗いてみましょう。(Go 1.15時点でのソースコードになります)

jsonのデコード処理を行う公開関数(Decode()やUnmarshal())は、まずここのdecodeState.unmarshal()関数を通ります。引数で渡してるinteface{}型のvは、jsonの値が埋め込まれるGoの値です。ここをエントリにして読んでみます。

decodeState.unmarshal()関数

上記関数内で、decodeState.value()関数が呼ばれます。

ここで、今読んでるjsonの値が、リテラル値か配列かObjectかで場合分けしています。
ここでは簡単のためリテラル値のケース(scanBeginLiteral)内のliteralStore()関数を読み進めてみます。

上記はliteralStore()関数の一部抜粋です。
jsonのvalueの先頭部分を読み、値により処理を切り分けています。「case 't', 'f' 」の部分がboolのtrue, falseを読もうとしてる部分などは、特に分かり易いと思います。

このswitch文においてjsonの数値を扱ってるのはdefault caseの部分です。

literalStore()関数内で数値を扱ってるdefaultcaseの箇所

上記がその部分ですが、さらにその内部でDecodeする数値型によって分岐するswitch文があります。
float64に変換されるのはinterfaceを渡した場合なので、
「case reflect.Interface:」を見てみます。

reflect.Interfaceのcase


このcaseの中にconvertNumber()という関数を呼んでる部分がありますが、ここのstrconv.ParseFloat()の部分でstringからfloat64へ変換を行なってるようです。

float64への変換を行なってるconvertNumber関数

なお、オプションでinterface{}にfloat64ではなくNumber(encoding/json内の独自string型)として値を受け取る方法もあるみたいで、そちらを使用した場合は上記ParseFloat()の上にあるd.useNumberの判定がtrueになり、Number()が返るようです。このオプションをONにするには、公開関数のUseNumber()メソッドを使用します。

PICK UP

TAG