GoでJSONをパースするときに考えることになるアレこれ

  • 62
    Like
  • 0
    Comment
More than 1 year has passed since last update.

GoでJSONをパースするときに、あれこれ考えるのだけど
きちんとやっていくと、どうしてもうまく実装できなくなって困っているという話。
先に書いておきますが、結論はないです。

JSONをパースするときの問題

そもそも、JSONを簡単に取り扱う必要がなぜあるのか、について。
標準では、JSONをパースする場合 json.Unmarshalを使ったりjson.NewDecoderを使ったりする。

例えば、User構造体に

type User struct {
  Name string
  Age  uint16
}

func main() {
  var jsonStr = `{
    "Name": "Ken", "Age": 24
  }`

  var u User
  json.Unmarshal([]byte(jsonStr), &u)
  fmt.Println(u) // {Ken 24}
}

上の場合、JSONの値にプロパティがないと何も値が入らない。
ところが、Goでは初期値として型の0値が入る。
なので、上のUser構造体の場合、Ageが設定されないままのため
値が0として取り扱われしまう。

これだと、本当に0歳なのか設定ミスなのかわからなくなってしまう。

func main() {
  var jsonStr = `{
    "Name": "Bob"
  }`

  var u User
  json.Unmarshal([]byte(jsonStr), &u)
  fmt.Println(u) // {Ken 0}
}

これを解決するために、元となる構造体のフィールドをポインタ型にするとnilチェックができるようになる。

type User struct {
  Name *string
  Age  *uint16
}

func main() {
  var jsonStr = `{
    "Name": "Ken"
  }`

  var u User
  json.Unmarshal([]byte(jsonStr), &u)
  fmt.Println(u) // {0x2081ec2a0 <nil>}
}

ただこの場合、このJSONの結果がポインタ型だと意識して使わないといけなくなるので
使い勝手が悪くなる。なので、JSONを取り扱いやすいライブラリが欲しくなる。

よくあるJSONパッケージ

JSONを簡単に扱いたい場合に、以下のようなパッケージがある

jason

https://github.com/antonholmquist/jason

import "github.com/antonholmquist/jason"

func main() {
  var jsonStr = `{
    "Name": "Ken", "Age": 32
  }`

  var u User
  var err error

  v, err := jason.NewObjectFromBytes([]byte(jsonStr))
  if err != nil {
    panic(err)
  }

  u.Name, err = v.GetString("Name")
  if err != nil {
    panic(err)
  }

  var age float64
  age, err = v.GetNumber("Age")
  if err != nil {
    panic(err) // ここで設定を検知する
  }
  u.Age = uint16(age)

  fmt.Println(u) // {Ken, 32}
}

go-simplejson

https://github.com/bitly/go-simplejson

import (
  simpleJson "github.com/bitly/go-simplejson"
)

func main() {
  var jsonStr = `{"Name": "Ken", "Age": 32}`

  js, err := simpleJson.NewJson([]byte(jsonStr))
  if err != nil {
    panic(err)
  }

  var u User

  u.Name, err = js.Get("Name").String()
  if err != nil {
    panic(err)
  }

  var age uint64
  age, err = js.Get("Age").Uint64()
  if err != nil {
    panic(err) // ここで設定を検知する
  }
  u.Age = uint16(age)

  fmt.Println(u) // {Ken, 32}
}

go-scan

https://github.com/mattn/go-scan

import (
  scan "github.com/mattn/go-scan"
)

func main() {
  var jsonStr = `{"Name": "Ken", "Age": 32}`
  var u User

  r := strings.NewReader(jsonStr)
  if err := scan.ScanJSON(r, "/Name", &u.Name); err != nil {
    panic(err)
  }

  r = strings.NewReader(jsonStr)
  if err := scan.ScanJSON(r, "/Age", &u.Age); err != nil {
    panic(err)
  }

  fmt.Println(u) // {Ken, 32}
}

ちなみに、ScanTree()のほうが使いやすいと思う。

func main() {
  var jsonStr = `{"Name": "Ken", "Age": 32}`
  var u User

  var i interface{}
  if err := json.Unmarshal([]byte(jsonStr), &i); err != nil {
    panic(err)
  }

  if err := scan.ScanTree(i, "/Name", &u.Name); err != nil {
    panic(err)
  }

  if err := scan.ScanTree(i, "/Age", &u.Age); err != nil {
    panic(err)
  }

  fmt.Println(u) // {Ken, 32}
}

ベンチマーク

上3つのライブラリのベンチマークの結果はこんな感じ。

% go test -bench .
testing: warning: no tests to run
PASS
BenchmarkJason    100000         20752 ns/op
BenchmarkSimpleJSON   200000          9710 ns/op
BenchmarkGoScan   100000         18262 ns/op

テストコード

型にUnmarshalを実装する

型にjson.Unmarshalerインタフェースを実装することで、
JSONをデコードしたときに、そちらを呼び出すことができる。

package main

import (
  "encoding/json"
  "fmt"

  scan "github.com/mattn/go-scan"
)

type User struct {
  Name string
  Age  uint16
}

func (u *User) UnmarshalJSON(data []byte) error {
  var i interface{}

  if err := json.Unmarshal(data, &i); err != nil {
    return err
  }
  if err := scan.ScanTree(i, "/Name", &u.Name); err != nil {
    return err
  }
  if err := scan.ScanTree(i, "/Age", &u.Age); err != nil {
    return err
  }
  return nil
}

func main() {
  var jsonStr = `{"Name": "Ken", "Age": 32}`
  var u User
  err := json.Unmarshal([]byte(jsonStr), &u)
  if err != nil {
    panic(err)
  }
  fmt.Printf("Name: %s Age: %d\n", u.Name, u.Age) // name: Ken Age: 32
}

https://gist.github.com/hiroosak/af65464863cda7dc494a

ネストされたJSONの場合

ここから上手いやり方がわからない話。
たとえば、下のようなネストされたデータを持ちたい場合。
そして Article.TitleArticle.Content を必須にしたい場合。

{
    "Name": "Ken",
    "Age": 32,
    "Articles": [
      {"Title": "Title A", "Content": "content"},
      {"Title": "Title B", "Content": "content"}
    ]
}

構造体もArticleを入れる。

type User struct {
  Name     string
  Age      uint16
  Articles []Article
}

type Article struct {
  Title   string
  Content string
}

前の段落で書いたUnmarshalerインターフェースを実装しなければ、
構造体への展開はよろしくやってくれる。

func main() {
  var jsonStr = `{
    "Name": "Ken",
    "Age": 32,
    "Articles": [
      {"Title": "Title A", "Content": "content"},
      {"Title": "Title B", "Content": "content"}
    ]
  }`
  var u User

  err := json.Unmarshal([]byte(jsonStr), &u)
  if err != nil {
    panic(err)
  }

  fmt.Println(u) // {Ken 32 [{Title A content} {Title B content}]}
}

ところが、上のUnmarshalerインターフェースを実装すると、これができなくなる。
なので、func (u *User) UnmarshalJSON(data []byte) error側に直接記載する必要が出てくる。

func (u *User) UnmarshalJSON(data []byte) error {
  js, err := simpleJson.NewJson(data)
  if err != nil {
    return err
  }

  u.Name, err = js.Get("Name").String()
  if err != nil {
    return err
  }

  var age uint64
  age, err = js.Get("Age").Uint64()
  if err != nil {
    return err
  }
  u.Age = uint16(age)

  // Article
  var bytes []byte
  bytes, err = js.Get("Articles").Encode()
  if err != nil {
    return err
  }

  var as []Article
  if err := json.Unmarshal(bytes, &as); err != nil {
    return err
  }
  u.Articles = as

  return nil
}

ArticleUserのようにUnmarshalJSONを実装する

func (a *Article) UnmarshalJSON(data []byte) error {
  js, err := simpleJson.NewJson(data)
  if err != nil {
    return err
  }

  a.Title, err = js.Get("Title").String()
  if err != nil {
    return err
  }

  a.Content, err = js.Get("Content").String()
  if err != nil {
    return err
  }
  return nil
}

https://gist.github.com/hiroosak/2cc2ab4a8da6cc7d78c4

バリデーションとパースを分ける

そうなるともうバリデーションとパースを完全に分けたほうがいいんじゃなかろうかってなる。
なので、json-schemaを用意して、gojsonschemaを使って最初にバリデーションをかけてみる

func main() {
    var jsonStr = `{
        "Name": "Ken",
        "Age": 32,
        "Articles": [
            {"Title": "Title A", "Content": "content A"},
            {"Title": "Title B", "Content": "content B"}
        ]
    }`

    schema := `{
        "type": "object",
        "properties": {
            "Name": {
                "type": "string"
            },
            "Age": {
                "type": "integer"
            },
            "Articles": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "Title": {
                            "type": "string"
                        },
                        "Content": {
                            "type": "string"
                        }
                    },
                    "additionalProperties": false,
                    "required": ["Title", "Content"]
                }
            }
        },
        "additionalProperties": false,
        "required": ["Name", "Age", "Articles"]
    }`

    var j map[string]interface{}
    err := json.Unmarshal([]byte(schema), &j)
    if err != nil {
        panic(err)
    }

    schemaDocument, err := gojsonschema.NewJsonSchemaDocument(j)
    if err != nil {
        panic(err)
    }

    var jsonBody interface{}
    err = json.Unmarshal([]byte(jsonStr), &jsonBody)
    if err != nil {
        panic(err)
    }

    result := schemaDocument.Validate(jsonBody)
    if result.Valid() == false {
        panic("err")
    }

    var u User
    err = json.Unmarshal([]byte(jsonStr), &u)
    if err != nil {
        panic(err)
    }

    fmt.Println(u) // {Ken 32 [{Title A content} {Title B content}]}
}

https://gist.github.com/hiroosak/c70066ed92b0c847f499

これで、Articlesの中身もjson-schemaで指定した通りにチェックしてくれる。

まとめ

いろいろとグダグダ書いてみたけれど
まとめるとこんな感じ。

  • json.Unmarshalで簡単にJSONをパースできる
  • ゼロ値が意図的に設定されたものなのかを確認したい場合
    • ポインタ型で受け取ってnilチェックをする
      • ポインタ把握が若干ツラい
    • map[string]interface{}で受け取ってチェックをする (上では記載してない)
      • 型キャストがたくさんできてツラい
    • ライブラリを使って値をチェックする
    • 処理を書く場所は?
      • 毎回Unmarshalする前に書く
      • 値を入れたい構造体Unmarshalerインタフェースを実装する
  • ネストされたJSONをパースしたい場合
    • 構造体をネスト構造にしてjson.Unmarshal
    • ゼロ値のチェックが必要な場合は?
      • Unmarshalerインターフェースを実装して、そこに手動で書く
      • 組み合わせがあったときにつらい
    • バリデーションとパースを分けて考える
      • バリデーション
        • jason go-simplejson go-scanとか
        • json-schemaを用意してgojsonschemaを使って、パースをする

状況に応じてバリエーションがあってなかなか
コレだという感じがない。
なんかもっと良い方法があるのではないかとモヤモヤ思う今日この頃です。