My External Storage

Nov 23, 2018 - 4 minute read - Comments - go

[Go] JSON内の数字や文字列が混じった配列を構造体にUnmarshalする

以下のようなJSONデータはGoではパースしずらい。 理由は、配列に複数の型が含まれていてGoの配列としては[]interface{}にするしかないことと、名前(key)が設定されていないのでマッピングしにくいからだ。 これを無理やりUnmarshalしたときのメモ。

{
  "totals": [
    1,
    7,
    3,
    "42.85714",
    4
  ]
}

TL;DR

  • Goは構造体のUnmarshalJSONを独自実装すると、Unmarshal時の挙動も独自定義できる
  • JSON配列はUnmarshalすると[]interface{}となるので一つ一つ型キャストすれば値が取れる

最初にコードだけ書いておくと以下になる。

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

package main

import (
	"encoding/json"
	"errors"
	"testing"
)

// TotalsArray defined for totals array
type TotalsArray struct {
	Files         int
	CoverageRatio string
}

// UnmarshalJSON 独自定義したUnmarshal方法でUnmarshal時の動きを変える
func (ta *TotalsArray) UnmarshalJSON(data []byte) error {
	// まず引数のdataとして渡ってきたJSONから`[]interface{}`を取得する
	var row []interface{}
	err := json.Unmarshal(data, &row)
	if err != nil {
		return err
	}

	// 決め打ちで型キャストして取得した情報で構造体の初期化を行なっていく。
	f, ok := row[0].(float64)
	if !ok {
		return errors.New("failed type cast f")
	}
	ta.Files = int(f)

	c, ok := row[3].(string)
	if !ok {
		return errors.New("failed type cast c")
	}
	ta.CoverageRatio = c

	// 他のフィールドは省略

	return nil
}

func TestTotalsArray_UnmarshalJSON(t *testing.T) {
	// 複数型が混じった配列を含むJSON配列。Keyがないので個別にUnmarshalできない
	jsondata := `
{
  "totals": [
    1,
    7,
    3,
    "42.85714",
    4
  ]
}`
	// 期待するUnmarshal後の構造体の状態
	want := TotalsArray{
		Files:         1,
		CoverageRatio: "42.85714",
	}

	// "totals" key部分をTotalsArrayとして解釈する構造体
	var got struct {
		Totals TotalsArray `json:"totals"`
	}

	json.Unmarshal([]byte(jsondata), &got) // {Files:1, CoverageRatio:"42.85714"}

	if got.Totals != want {
		t.Fatalf("want:\n%+v\nbut, got:\n%#v", want, got.Totals)
	}
}

JSON配列をうまくUnmarshalできない

Goで3rdパーティのAPIクライアントを書こうとしたら、以下のようなtotals配列を含んだJSONデータを出力されていた。

{
  "totals": [
    1,
    7,
    3,
    "42.85714",
    4
  ]
}

GoでJSONデータをUnmarshalするときは通常は構造体のフィールドにタグをつけてマッピングすることでUnmarshalしていく。

type User struct {
	Name      string `json:"name"`
	Email     string `json:"email"`
}

が、今回totalsはオブジェクトではなく配列なのでKeyが設定されていない。また、intと文字列が混じる配列のため、単純な[]intでも受けられない。 そのため別手段で強引に構造体にUnmarshalすることにした。

構造体にUnmarshalJSON([]byte) errorを定義する

まず、最終的にtotals配列のデータを格納する構造体を定義する(一部省略)。

// TotalsArray defines totals responses.
type TotalsArray struct {
	Files         int
	CoverageRatio string
}

(配列にkeyはないが、腕力で何番目のデータが何を意味するのかは解析済…)

Goは構造体がUnmarshalerインターフェースを実装していればそれを使ってUnmarshalを行なう。

https://golang.org/pkg/encoding/json/#Unmarshaler

type Unmarshaler interface {
        UnmarshalJSON([]byte) error
}

なので、TotalsArrayUnmarshalJSON([]byte) errorを実装する。

[]interface{}で受けて強引にパースしていく

UnmarshalJSON([]byte) error内で配列をパースしていく。 totalsは複数型のデータを持っているため、単純な[]intのような配列では受けることができない。 そのため、一度[]interface{}で受けて、中身を型キャストして構造体に当てはめていく。interface{}オブジェクトとしてUnmarshalすると、JSONデータは以下のように解釈される。

bool, for JSON booleans
float64, for JSON numbers
string, for JSON strings
[]interface{}, for JSON arrays
map[string]interface{}, for JSON objects
nil for JSON null

今回は[]interface{}の何番目がどんなデータかわかっているので、以下のようにタイプキャストして構造体のフィールドに取得した情報を代入していく。

// UnmarshalJSON はエラーハンドリングを省略した簡易コード
func (ta *TotalsArray) UnmarshalJSON(data []byte) error {
	var arr interface{}
	// 引数として"totals"のデータが渡ってくる
	json.Unmarshal(data, &arr)

	// 0番目は数値(float64)であると決め打ち
	f, _ := arr[0].(float64)
	ta.Files = int(f)

  // 3番目は文字列(string)であると決め打ち
	c, _ := arr[3].(string)
	ta.CoverageRatio = c

	return nil
}

あとはこのTotalsArrayを使ってJSONをUnmarshalすればよい。

var got struct {
				Totals TotalsArray `json:"totals"`
			}

json.Unmarshal(data, &got)

終わりに

自分でAPIを設計するときはこんなJSONを出力するのは辞めよう。利用者がその値の意味を理解できない可能性があるし、配列内の順序が変わっていたとしても利用者は気づくことができないのでAPI間でバグが混入する可能性が高い。

参考

関連記事