なまえは まだ ない

思いついたことをアウトプットします

GoでJSONを良い感じに使おうと思ってハマった話

この記事はフラーAdvent Calendar 2019の4日目の記事です。

この会社に入ってGo言語に触れたので、最近Goで少しハマったことを紹介します。

事の発端

とある案件で管理コンソールの監査ログを作ることになりました。 監査ログには管理コンソールのログインユーザー、行った操作、操作をした時刻、そして操作したデータオブジェクトが含まれます。データオブジェクトは操作によって異なり(例えばユーザーに配信するニュース、静的ページへのリンクなど)、これをひとつのテーブルで一括管理する必要がありました。次のようなログを出力できることがゴールです。

2019-09-01 12:00:05Z UserXXX がニュースを作成しました。ID:15
2019-09-01 13:04:30Z UserXXX がニュースを削除しました。ID:15
2019-09-02 08:31:22Z UserYYY が関連リンクを更新しました。Title:お問い合わせ

どう実装する?

MySQLでは5.7からJSONデータ型をサポートしており、次のようにJSONオブジェクトを一つのカラムとして定義できます。

CREATE TABLE `audit_log` (
    `id` BIGINT unsigned NOT NULL AUTO_INCREMENT,
    `action` VARCHAR(191) NOT NULL,
    `uid` VARCHAR(191) NOT NULL,
    `info` JSON NOT NULL,
    `created_at` DATETIME NOT NULL,
    PRIMARY KEY (`id`)
)

で、Goの構造体は簡単にJSONオブジェクトにシリアライズできるので、 次のようにシリアライズしてしまえばどんなデータオブジェクトでも同じテーブルに突っ込めると思ったわけです。

// DataFoo とある操作で扱うデータオブジェクト
type DataFoo struct {
    ID      int    `json:"id"`
    Message string `json:"message"`
}

// 操作に使ったデータをJSONにシリアライズ
data := DataFoo{
    ID:      1,
    Message: "test message",
}
jsonBytes, _ := json.Marshal(data)

// jsonBytes を info カラムに突っ込む

監査ログの一覧を取得するときはデータベースに保存したJSONオブジェクトをデシリアライズするわけですが、 先述の通りJSONオブジェクトは様々なフォーマットが入ることが予想されます。 さてどうするか?ひとつの方法として、デシリアライズ先の変数に map[string]interface{}を渡してあげると、 任意のJSONオブジェクトをマッピングすることができます。

var info map[string]interface{}
json.Unmarshal(jsonBytes, &info)

fmt.Printf("ID: %v\n", info["id"]) // print "ID: 1"
fmt.Printf("Message: %v\n", info["message"]) // print "Message: test message"

何が問題なの?

さて、ここまでの範囲では問題が無いのですが、この info["id"]の値をしっかり使おうとすると少し問題があります。 info["id"]はinterface{}型なので、これを数値として使おうと思ったらシリアライズ前のint型に型アサーションすれば良いと思いますよね?私は思いました。

id := info["id"].(int)

ですが、これを実行するとエラーになります。なぜか?理由は info["id"]の型を見てみれば明らかです。

fmt.Printf("%T", data["id"]) // print "float64"

なんということでしょう? 整数型の変数をシリアライズしたのに、デシリアライズしたら実数になってしまいました!

理由は単純明快で、JSONの仕様で数値型はひとつしかなく、整数と実数を区別することができません。 json.Marshal() で構造体をJSONオブジェクトにシリアライズした時点で、整数だろうが実数だろうが関係なく数値型(number)として変換してしまうわけですね。

ちなみに

JSONオブジェクトをデシリアライズするとき、明確に型が決まってる構造体を指定するとこんなことは起きません。 デシリアライズ先の構造体に型が設定されてるので当たり前ですね。

var info DataFoo
json.Unmarshal(jsonBytes, &info)

fmt.Printf("ID: %d\n", info.ID) // print "ID: 1"
fmt.Printf("Message: %s\n", info.Message) // print "Message: test message"

今回の例で言えば、ユーザーが行った操作から扱ったデータフォーマットが推定できるので、最初から対応する構造体でデシリアライズすれば済む話です。 が、実際にハマった事案はもう少し複雑で、そうもいかなかったのです。。

ちなみにちなみに

公式ブログをちゃんと読めば、この辺りの事情がちゃーんと書いてあります。初心者よ、まずは公式ドキュメント読もうな。

blog.golang.org

おわりに

というわけで、JSONを良い感じに使ってやろうと思ったら見事にハマった事案でした。 Goは奥が深いですね。面白い。