goで直接reflectを使わずinterfaceを含んだstructをJSONでencode/decodeする方法
この記事はGo Advent Calendar 2021 4の19日目の記事です1。
goのencoding/jsonの機能はそこまで柔軟ではない。どこかで諦めたほうが良い事が多い2。 とはいえ、いろいろなことを柔軟にやってみる方法を検討してみるのは良いことなので検討してみる。
interfaceを含んだstructはdecodeできない
interfaceを含んだstructはencodeはできるがdecodeができない。
例えば、以下のようなstructを考えてみる。Stringerの方は String() string
を持つことを期待するinterface。
type S struct { Name string Stringer fmt.Stringer } type F struct{ Name string } func (f *F) String() string { return "F:" + f.Name }
encodeは、これを実装する実物の値を利用して出力するだけなので何もしていなくても動く。
s := S{Name: "Foo", Stringer: &F{Name: "Foo"}} fmt.Println(json.NewEncoder(os.Stdout).Encode(s)) // {"Name":"Foo","Stringer":{"Name":"Foo"}} // <nil>
一方、これをdecodeしようとしたときには上手く行かない。どのような型の値を期待するかわからないのでそれはそう。
code := `{"Name": "Foo", "Stringer": {"Name":"Foo"}}` var s S // json: cannot unmarshal object into Go struct field S.Stringer of type fmt.Stringer fmt.Println(json.NewDecoder(bytes.NewBufferString(code)).Decode(&s))
これをなんとかencode/decodeできる形にもっていけないか?というのが今回の主題3。
decodeできる構造を考えてみる
decodeできる構造を考えてみると以下2つが必要になる。
- どの型として扱うかどうかの情報 (type, discriminator)
- 型が保持するデータの情報 (data)
これは考えてみると、OpenAPIのoneOfの構造に近い。OpenAPIでは、どの型のschemaかを判別するためにdiscriminatorという識別子を利用する。同様にどの型として扱うかの情報が必要。 もちろん、データ自体の情報も必要。
例えば、goにsum typeは存在しないが、仮にあるとして、EmailLink,OauthLinkの2つの型の可能性のある型Linkを考えてみる。
// sum typeは存在しない // type Link EmailLink | OauthLink type EmailLink struct { EmailAddress string RegisteredAt time.Time } type OauthLink struct { ProfileID string Source string RegisteredAt time.Time } // 現実的には、interfaceとして扱うことになる type Link interface { Link() } func (link *EmailLink) Link() {} func (link *OauthLink) Link() {}
ここで、両者共に同一のフィールドである場合も考えられるので、型名も含めて同一性を考える必要がある。
というわけで、仮に以下のような出力を考えてみる。$Type
と $Data
でwrapしてみる。
{ "$Type": "EmailLink", "$Data": { "EmailAddress": "foo@example.net", "RegisteredAt": "2021-12-18T19:14:03.755686099+09:00" } }
とりあえず、このjsonをdecodeできれば、interfaceを含んだstructを扱えることになる。ここでgoはinterfaceにメソッドをもたせる事ができない。あるいはencoding/jsonはメソッド以外でencode,decodeの方法を変更することができない4。そういうこともあって、Linkやこれを実装した型のメソッドでdecode方法を指定することができない。
そんなわけで、そのinterfaceを子として持つstructを元に考えることにする。PrimaryとしてLinkを持つAccountというstruct。
type Account struct { Name string RegisteredAt time.Time Primary Link // Other []Link } // 後で消すかもだけど。Unmarshalを楽にするために type OneOfWrapper struct { Type string `json:"$Type"` Raw json.RawMessage `json:"$Data"` }
UnmarshalJSON()はかなりトリッキーな定義になる。structからデフォルトで導出される実装を利用するためにmethod setが空の定義をローカルで定義する5。フィールドのすべての型が同じものの場合は、キャストが可能なのでこれを利用する。また、元の型を埋め込みつつ隠したいフィールドを指定すると、そちらをdecode時に辿るようにできる6。
func (a *Account) UnmarshalJSON(b []byte) error { type Inner Account // memthod setを空にした型を定義 type T struct { Primary *OneOfWrapper // $Type, $Dataを扱う型でinterceptする *Inner } w := T{Inner: (*Inner)(a)} // Inner typeにcastして呼び出せばAccount自身のデータを設定できる if err := json.Unmarshal(b, &w); err != nil { return err } // Linkの$Typeを見て$Dataで埋める関数を呼ぶ if err := unmarshalJSONLink(w.Primary, &a.Primary); err != nil { return err } return nil }
unmarshalJSONLink()の実装は以下の様なものになる。このように定義すると、reflectを陽に使わずinterfaceに対応した型を実体化できる(decode)7。
func unmarshalJSONLink(data *OneOfWrapper, ref *Link) error { if data == nil { return nil } switch data.Type { case "EmailLink": var inner EmailLink *ref = &inner return json.Unmarshal(data.Raw, &inner) case "OauthLink": var inner OauthLink *ref = &inner return json.Unmarshal(data.Raw, &inner) default: return fmt.Errorf("unexpected interface=%q implementation, type=%q", "Link", data.Type) } }
例えば、以下のような形で読むことができる。
s := ` { "Name": "foo", "RegisteredAt": "2021-12-18T19:14:03.755686099+09:00", "Primary": { "$Type": "EmailLink", "$Data": { "EmailAddress": "foo@example.net", "RegisteredAt": "2021-12-18T19:14:03.755686099+09:00" } } } ` var foo Account json.NewDecoder(bytes.NewBufferString(s)).Decode(ob)
対応したencodeを作る
$Type
と$Data
でwrapしたJSONを出力するようなencodeは以下のような感じで定義できる。
自分自身のJSONを埋め込むために同様のローカルの型定義のトリックを使う(使わないと無限再帰してしまう)。
func (link EmailLink) MarshalJSON() ([]byte, error) { type Inner EmailLink type T struct { Type string `json:"$Type"` Data *Inner `json:"$Data"` } return json.Marshal(&T{Type: "EmailLink", Data: (*Inner)(&link)}) }
以下のようにJSONになる。
foo := Account{ Name: "foo", RegisteredAt: now, Primary: &EmailLink{EmailAddress: "foo@example.net", RegisteredAt: now}, } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") if err := enc.Encode(ob); err != nil { panic(err) } // { // "Name": "foo", // "RegisteredAt": "2021-12-18T19:14:03.755686099+09:00", // "Primary": { // "$Type": "EmailLink", // "$Data": { // "EmailAddress": "foo@example.net", // "RegisteredAt": "2021-12-18T19:14:03.755686099+09:00" // } // } // }
sliceも同様の感じで行えば対応できる。
まとめ
- interfaceをencoding/jsonでencode/decodeできるようにしたい
- reflectもなるべく使いたくない
- 頑張ればできるが、親側でもメソッドの定義が必要になる
gist
- https://gist.github.com/podhmo/18d31c579da6121f442c2d414835bf54
- フィールドを増やすことを許容すれば、埋め込みを使うことでallOfの一部にも対応できる https://gist.github.com/podhmo/464e983cbebc4c907fb49a3df598b92b
別解
別解も色々ある。
- grpcのoneOfで我慢する(jsonpbはoneOfに対応している)。
- reflectを素直に使う
- json.RawMessageと
json:"-"
を使って出し分ける JSON polymorphism in Go. Serializing and deserializing… | by Alex Kalyvitis | Medium
-
登録したのは2021/12/20。。↩
-
できることだけに限定したほうが生産性は高い。それこそjsonは二級市民的な感じなのでprotobufだけで済ますとかのほうが楽だったりするかもしれない(もちろん、基本的な構造を扱うことは問題なくできる)。↩
-
そもそもserialize/deserializeを期待するstructにinterfaceを含むのが良くないという話がある。それはそう。↩
-
設定できるようにするissueは一応acceptedにはなっている(generics関連で調整が入りそうではある)。 https://github.com/golang/go/issues/5901↩
-
この方法が別の用途で使われている例 [Go] JSONを構造体にマッピングしつつ生データを保存するUnmarshalJSONの実装方法 - My External Storage↩
-
細かな注意点としては、元のフィールドと上書き用のフィールドについて、「タグなし、タグなし」、「タグなし、タグあり」、「タグあり、タグあり」は上手く覆い隠せるが、「タグあり、タグなし」の場合には覆い隠せないという挙動をするようだった。これにハマって期待通りに動かず手こずった。本来的には全部タグありにするからハマらないだろうけれど。↩
-
ただし、定義はopenではない。例えばこのLinkを実装する型を新たに独自で定義したときにこれに対応する事ができない。↩