My External Storage

Oct 25, 2021 - 5 minute read - Comments - go

[Go] JSONを構造体にマッピングしつつ生データを保存するUnmarshalJSONの実装方法

GoではJSONを扱うときでもしっかり型定義に当てはめて利用するのが一般的だ。
しかし、外部から受け取ったJSONデータは型に当てはめつつ併せて生データも保存しておきたいときがある。
Defind Typeをうまく使うとシンプルなUnmarshalJSON(data []byte)メソッドを定義できる。

type Event struct {
    ID   string          `json:"id"`
    Type string          `json:"type"`
    Payload Payload      `json:"pyload"`
    // 構造体にマッピングする前のJSONを保存しておきたい
    Raw  json.RawMessage `json:"-"`
}

TL;DR

  • 外部から受け取るJSONは構造が不意に変わることを想定したいときがある
  • UnmarshalJSON(data []byte)メソッドを使うと独自のJSONパースができる
  • 構造体にマッピングすると、構造体に定義されていないデータが欠落する
  • JSONの生データを扱うときはjson.RawMessageを使うとよい
  • Defind Typeで名前を変えると独自定義したUnmarshalJSON(data []byte)メソッドを呼ばなくなる

https://play.golang.org/p/n7qvO3vTiV0

type Event struct {
    ID   string          `json:"id"`
    Type string          `json:"type"`
    Payload Payload      `json:"pyload"`
    Raw  json.RawMessage `json:"-"`
}

func (e *Event) UnmarshalJSON(data []byte) error {
  type event Event
  var ee event
  err := json.Unmarshal(data, &ee)
  if err != nil {
    return err
  }
  *e = (Event)(ee)
  e.Raw = data
  return nil
}

GoでJSONを扱うとき

GoではJSONを使うときもしっかり型を意識して取り扱う。 受け取ったJSONデータはencoding/jsonパッケージを使ってデコードして構造体にマッピングする。

https://pkg.go.dev/encoding/json

UnmarshalJSON(data []byte)メソッドを使うと独自のJSONパースができる

encoding/jsonパッケージは通常構造体のフィールドに記載されたjsonタグを見て構造体にJSONのデータを当てはめていく。 しかし構造体に対してUnmarshalJSON(data []byte)メソッドを定義しておくと、独自ロジックのJSONデコードが行える。

JSONの生データを扱うときはjson.RawMessageを使うとよい

また、JSONの元データを扱うときはjson.RawMessage型を利用する。

外部から受け取るJSONは構造が不意に変わることを想定したいときがある

上記の仕組みを使ってJSONデータを使うとき、問題になることがある。 当然といえば当然なのだが、構造体に未定義のキーはJSONデータから構造体へマッピングするときに欠落する。 そのため、たとえば次のようなステップを踏んでいると、HTTPレスポンス中の未定義のキーのデータは喪失する。

  • HTTPレスポンスをJSONで受け取る
  • 構造体にデコードする
  • DBに保存する

しっかりとした仕様の元でJSONを扱っていれば未定義のキーの心配などしなくてもよいのだが、お行儀の悪いAPIだと未定義のデータも混じる可能性が発生しうる。 そのため構造体にマッピングしつつJSON生データの保存するロジックを書きたい時がある。

生データを保存する独自UnmarshalJSON(data []byte)メソッド

実装の完成形を先に書くと、このようなUnmarshalJSON(data []byte)メソッドを書けばよい。

type Event struct {
    // もろもろのJSON構造の定義

    // 構造体にマッピングする前のJSONを保存する場所
    Raw  json.RawMessage `json:"-"`
}

func (e *Event) UnmarshalJSON(data []byte) error {
  type event Event
  var ee event
  err := json.Unmarshal(data, &ee)
  if err != nil {
    return err
  }
  *e = (Event)(ee)
  e.Raw = data
  return nil
}

やっていることは単純でアンマーシャルしたあと元データをjson.RawMessageフィールドで参照しているだけだ。 ただ型を定義しなおしてからアンマーシャルしているのがポイントとなる。

type event Event
var ee event
err := json.Unmarshal(data, &ee)

Defind Typeで名前を変えると独自定義したUnmarshalJSON(data []byte)メソッドを呼ばなくなる

型を定義しなおす理由は2つある。

最初の理由は「型を定義しなおさないといけない理由」で、同じ型のままjson.Unmarshal関数を使うと(e *Event) UnmarshalJSON(data []byte)が再帰的に呼び出されるだけになってしまうからだ。 別定義したevent型には(e *event) UnmarshalJSON(data []byte)メソッドは定義されていない。そのため、通常のアンマーシャルが行われるだけになる。

もうひとつの理由は「型を定義しなおしたほうがよい理由」で、このように定義されたevent型はEvent型とまったく同じ構造体定義をもつ。 そのため、(Event)(ee)のようなキャストも必ず成功する。また、Event型の構造体定義に変更が入ったときはevent型の定義も当然更新される。 メンテフリーでEvent型の内容にevent型が追従するため、「いつの間にか壊れていた」ということも発生しない。

終わりに

今回のやりかたはstripe-goを参考にしている。

もっというと@vvakameさんのツイートで見かけたから把握していたという背景もある。

OSSのコードを読むのは大事。

参考

関連記事