GoでわがままなJSONシリアライズをしたい人がいました

このエントリはGo Advent Calendar 2020 のgo5 の代打エントリです。書いているのは12月25日ですが、12月24日分!

今回はGoにおけるデータをJSONに変換したり逆にJSONから読み出したりする際の様々なワークアラウンドについて書きます。

JSONを扱う

私は某所でGo言語に関する2Pの連載を書かせていただいているため、多少ネタのストックはあるのですが、できれば万が一の時のために取っておきたい!そんな時のことを考えてつい最近のアドベントカレンダーは手を出しそびれております。

そんなわけで上記の連載の中でもう少し推しておきたかった!でもページ数の関係で深堀りできなかった!というネタについて書かせてもらおうかと思います。

というわけで皆さんには WEB+DB PRESS vol.107を手に取っていただきたいのですが…

持ってない方のために簡単におさらいすると、この号の記事ではGoでJSONを扱うときのコツについて解説させていただきました。

JSON、特に場合によってちょっとだけデータの中身の型が変わるようなJSON(例:任意のキーがオブジェクトに追加されるような場合)をencoding/json を使ってGoの構造体にマッピングするするのはかなり難しい… というより面倒くさいと言えます。

例えば以下のようなGoの構造体があったとします。

type Person struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Age int `json:"age"`
}

これに対して、場合によって様々な情報が追加されることもある、というような場合を考えてみましょう。「Email」のように事前にある程度共通認識があるようなデータなら、以下のようにポインタを格納することによって任意フィールドとして対応できるかもしれません。

// { "first_name": "Daisuke", "last_name": "Maki", "age": 18,
// "email": "no-reply@okuttekuruna.net" }
type Person {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Age int `json:"age"`
Email *string `json:"email"`
}

しかし、本当にユーザーがなんでも追加してもいい、という場合もあります。例えばJWTの基本構造はRFCによって定義されていますが、それ以外のデータもいくらでも入れたトークンを作ってもいいのです。

全て可変である、という前提に立つので有れば、このデータは map[string]interface{} のようなものに格納するしかありませんが、以下のような構造体は正直型付き言語で扱うには大分… 辛いといえるでしょう。

type Person map[string]interface{}

これだと格納されているデータに触れるのに "first_name" 等のキー名を正しく書いた上でマップから読み出す必要がでてきます。でも人間はスペルミスをする動物ですので、コレだと必ず誰かが間違えてサーバーを落とすか、Githubに以下のコードでなぜか動かないんだ!というイッシューが毎週寄せられるでしょう。

var p Person
json.Unmarshal(data, &p)
p["first-name"] // "-"じゃなくて"_"
p["first_namae"] // 英語と日本語まぜんな

また、それぞれのキーから得られるデータの型も全て interface{} であるため、使うにはいちいち変換が必要となってしまいます。

中間データを使う

この状態をどうにかするには、元のテキストデータをなんらかの中間データ型に json.Unmarshal で読み込み、それを本来作りたかった Person 型にマッピングするしかありません。(もちろん、自力でJSONをパースする、という力業もありますが、それはここでは除外します)

まず思いつくのは 一度 map[string]interface{} にデータを読み込んでしまうことでしょう。これは原理的には一番シンプルですが、 map[string]interface{} から最終的な格納先に読み込む際に interface{} から型変換をした上での代入が必要となってしまいます。

以下ではエラー処理を割愛していますが、エラーとなりえるパターンを考えると頭が痛くなります。

type Person struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Age int `json:"age"`
Extras map[string]interface{} `json:"-"`
}
func (p *Person) UnmarshalJSON(data []byte) error {
var m map[string]interface{}
json.Unmarshal(data, &m)
p.FirstName = m["first_name"].(string)
p.LastName = m["last_name"].(string)
p.Age = int(m["age"].(float32)) // おっと、整数じゃない…
delete(m, "first_name")
delete(m, "last_name")
delete(m, "age")
// それ以外のデータはその他、というところにいれてしまう
// "email"もここに入る
p.Extras = m
return nil
}

これでも一通り期待の動きにはなります。が、今度は二つの問題が出てきます。

ひとつは前述したように Person をJSONから読み込む際に型変換の確認を全部自分でやる必要があること。期待していた型ではなかった場合の処理をひとつひとつ温かみのある職人の手によるエラー処理によって対処する必要があります。

また、上記のようにフィールドの値が stringint のようなシンプルなものではなく、別の構造体が欲しい場合はまたさらにややこしくなり、 うまいこと json.RawMessage を得た上でそのフィールドの値にのみ あらたに json.Unmarshal を適用するなど、どんどんと沼にはまっていきます。

もうひとつは Person からJSONに書き出す際に、読み込みの時と逆のロジックを全部書く必要があること(ちなみに Extras のところはどうあっても追加処理を書く必要があります)。

でもこれらはよくよく考えると、通常の場合なら json.Marshal ないし json.Unmarshal が勝手に処理してくれる部分のはずです。なんとかすることはできないでしょうか。

プロキシ構造体の活用

この流れですと当然 Person 型の UnmarshalJSON の中でうまいこと処理することを思いつくでしょうが、これは最終的に構造体への値の代入を json.Unmarshal に任せようとすると呼び出しがループしてしまうため、 Person 型だけでは完結しません。ぴんとこないかもしれませんが、やってみるとわかります。僕はすでにこれで何日も時間を溶かしました。

そこでプロキシのためだけの型を作ることを考えます。要はencoding/jsonで対処できる部分を任せるプロキシ用の型と、任意のフィールドをパースできるようにするという追加仕様を処理するコードとにわけ、Person型のUnmarshalJSONはそれらをつなぎ合わせるためだけのレイヤーとして実装します。

type personProxy struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Age int `json:"age"`
Email *string `json:"email"`
}
type Person struct {
firstName string
lastName string
age int
email *string
extras map[string]interface{}
}

このプロキシ型は本来Person型で定義したかった形で実装します。型が内包するフィールドは encoding/json に見えるようにエキスポートされている必要はありますが、型そのものはエキスポートされている必要はありません。これによってライブラリを書いている場合はユーザーにこのプロキシ型が見える必要はありません。

Person型のほうではこのプロキシを通して一回パースした上で、プロキシからPersonの実体のほうに値を代入するだけです。プロキシのほうがjson.Unmarshalを通っているので、再帰処理や構造体のフィールドとJSONのキーのマッピングをよい感じにしてくれます。例によってエラー処理は割愛しています。

func (p *Person) UnmarshalJSON(data []byte) error {
var pp personProxy
json.Unmarshal(data, &pp)
p.firstName = pp.FirstName
p.lastName = pp.LastName
p.age = pp.Age
p.email = pp.Email
...
}

Person型のフィールドがエキスポートされてないフィールドでもいい感じに処理できるので、「JSONに変換をしたい構造体は中身が全部ユーザーに見えてないといけない」ということもありません。上記では Person 型のフィールドは全部小文字にしてしまいました。

任意の値の処理

これでようやく任意のフィールドの処理まで戻ってくることができます。

ここは様々な方法が考えられますが、とりあえず仕様に最初から定義されていないフィールドは全部 map[string]interface{} に入れておく、という手法をとります。

ここで(残念な事に) json.Unmarshal をもう一回走らせて、今度は map[string]interface{} にデータを代入します。その上で、いらないフィールドは mapから削除し、残ったものをextrasに格納します。

func (p *Person) UnmarshalJSON(data []byte) error {
var pp personProxy
json.Unmarshal(data, &pp)
p.firstName = pp.FirstName
p.lastName = pp.LastName
p.age = pp.Age
p.email = pp.Email
var m map[string]interface{}
json.Unmarshal(data, &m)
delete(m, "first_name") // 残念ながらこれはやらないといけない
delete(m, "last_name")
delete(m, "age")
delete(m, "email")
p.extras = m
...
}

json.Unmarshal が2回走るところや、 deleteを延々と呼び出さないといけないところが非常に残念ですが、なんだかんだ json.Unmarshal を通す方がGoの標準的な動作になるので、この際パフォーマンスは一回忘れてましょう。

そしてこの方法を採ると、 Person 型をJSONにシリアライズするときも同じプロキシが使えるのが魅力です。この際も再帰処理などは全部 encoding/json がやってくれるので、ただ値を代入するだけです。

func (p *Person) MarshalJSON() ([]byte, error) {
var pp personProxy
pp.FirstName = p.firstName
pp.LastName = p.lastName
pp.Age = p.age
pp.Email = p.email
...
return json.Marshal(pp) // プロキシのほうをMarshal
}

型のエラーなどは代入の時点で気づけますし、あとは 普通に json.Marshal するときの動作と同じになるのがうれしいです。

最後に 任意フィールドをどうするか、ですが、ここもちょっと残念なハックをせざるを得ません。以下では一度得られたJSON文字列の最後の } をなかったことにして、その後に p.extras をシリアライズしたものをがッちゃんこしています。

func (p *Person) MarshalJSON() ([]byte, error) {
var pp personProxy
... (中略) ...
buf, _ := json.Marshal(pp) buf = buf[:len(buf)-1]
buf = append(buf, ',')
extrasBuf, _ := json.Marshal(p.extras)
extrasBuf = extrasBuf[1:] // 最初の '{'をなくす
buf = append(buf, extrasBuf) // これで {..., extras} になる
return buf, nil
}

ちょっと残念なコードですね!でもこれで最初やりたかったことは完全にできました。

JSON出力をそろえる

ちなみに最後のおまけとしては、このままだと encoding/json の出力と微妙に違う結果になります。実は encoding/json はキーの並び順をちゃんとソートしてくれているので、あとから追加した extras 分はソートがされていない状態になります…

例えば JSON文字列の結果からハッシュ値を求める場合などはこのソート順が違うとこまりますので、その辺りをちゃんとするなら、下手なことをせずにもう一回(!) json.Unmarshaljson.Marshal をしてしまうのがおすすめです

func (p *Person) MarshalJSON() ([]byte, error) {
... 略 ...
var m2 map[string]interface{}
json.Unmarshal(buf, &m2) // 全部読み込んで…
return json.Marshal(m2) // ソートされた状態で返す
}

まとめ

というわけで駆け足でしたが、GoにJSONデータをマッピングするのがどんな苦行か… ではなく。

ユーザーがどんなデータをJSONに突っ込んでくるか分からないが、可能な限り型付与した状態の構造体を定義したい、という要求があった際に私が使ったハックについての深堀りでした。

このあたりのコードはGoによるJWA/JWE/JWS/JWT/JWK実装である github.com/lestrrat-go/jwx ですごい使っています。

また、これを何回もやっていると手書きは辛いので、私は上記のようなコードはなんらかの定義から自動生成されるようにしています。そのときは使いませんでしたが、 reflect を使う事によってその辺りもさらに自動化できるかもしれません。

以上、Go Advent Calendar 2020 go5の代打エントリでした!さよなら2020年、世界的にひどい年だったよ!来年はよくなりますように、そしてみんなGoを書いてくれますように!

Go/perl hacker; author of peco; works @ Mercari; ex-mastermind of builderscon; Proud father of three boys;

Go/perl hacker; author of peco; works @ Mercari; ex-mastermind of builderscon; Proud father of three boys;