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

別解

別解も色々ある。


  1. 登録したのは2021/12/20。。

  2. できることだけに限定したほうが生産性は高い。それこそjsonは二級市民的な感じなのでprotobufだけで済ますとかのほうが楽だったりするかもしれない(もちろん、基本的な構造を扱うことは問題なくできる)。

  3. そもそもserialize/deserializeを期待するstructにinterfaceを含むのが良くないという話がある。それはそう。

  4. 設定できるようにするissueは一応acceptedにはなっている(generics関連で調整が入りそうではある)。 https://github.com/golang/go/issues/5901

  5. この方法が別の用途で使われている例 [Go] JSONを構造体にマッピングしつつ生データを保存するUnmarshalJSONの実装方法 - My External Storage

  6. 細かな注意点としては、元のフィールドと上書き用のフィールドについて、「タグなし、タグなし」、「タグなし、タグあり」、「タグあり、タグあり」は上手く覆い隠せるが、「タグあり、タグなし」の場合には覆い隠せないという挙動をするようだった。これにハマって期待通りに動かず手こずった。本来的には全部タグありにするからハマらないだろうけれど。

  7. ただし、定義はopenではない。例えばこのLinkを実装する型を新たに独自で定義したときにこれに対応する事ができない。