goでrequired/unrequiredに対応しつつpointerを使わないunmarshalJSONのエラーメッセージについて

昔以下の様な記事を書いていた。

リンク先の記事ではpointerを使わないstruct定義に対してUnmarshalJSONを自前で定義することでどうにかやろうという感じのものだった。ただしこのリンク先の記事の実装ではエラーメッセージに不備があった(後述する)。これを改善できるかもしれない方法を思いついたのでそのメモ。

なぜpointerが必要になってしまうのか?

その前に前提の確認。なぜpointerが必要になってしまうのかというとこれはzero値との兼ね合いのため。 以下のパッケージなどはゼロ値をunrequiredだと割り切った実装になっている。

一方でREST API用のvalidationなどを考えた場合にはこれではちょっと困る。具体的にはbooleanを含んだfieldのPATCH。PATCHは部分更新なのである対象を更新したいと思った時にその対象の持つフィールドの一部だけがリクエストのボディなどとして渡される。そしてこのときのフィールドの更新後の未来は以下3つの可能性が存在する(ここではあるbooleanフィールドxを対象に考える)。

  • あるフィールドXをtrueに更新
  • あるフィールドXをfalseに更新
  • あるフィールドXを 更新しない

最後が重要。現在がtrueだったらtrueに、現在がfalseだったらfalseにというように、現在の値を保つような更新のPATCHを投げたいことがある。実際通常は該当するフィールドを含めないPATCHの場合にはそのような動作を期待する。

このときbooleanのzero値はfalseではあるのだけれど。falseで更新されてほしくはない。逆にfalseで更新したいこともあるのでunrequired扱いになりエラーということは避けたい。

zero値とunrequiredを見分ける方法

zero値とunrequiredを区別して取り扱う方法は2つある。1つはpointerを使う方法で、もう1つは新しい型を定義する方法。後者はdatabase/sqlが行っている方法。

type NullBool struct {
    Bool bool
    Valid bool
}

type S struct {
    Status NullBool
}

例えばNullBoolの例ではValidがtrueの場合にはnullではない(unrequiredではない)。

一方、ポインタを使う方法もある。go-swaggerなどはこちがわの定義。

type S struct {
    Status *bool `json:"status"`
}

過去の記事の方法の問題

過去の記事ではUnmarshalJSONを自分で定義し。そのメソッドの中だけでpointerを使った表現にマッピングしてからvalidationを行うという方法で解決していた。

type Person struct {
    Name   string  `json:"name"` // required
    Age    int     `json:"age"`  // required
    Father *Person `json:"father"`
}

// UnmarshalJSON :
func (p *Person) UnmarshalJSON(b []byte) error {
    type personP struct {
        Name   *string  `json:"name"` // required
        Age    *int     `json:"age"`  // required
        Father **Person `json:"father"`
    }

    var ref personP
    if err := json.Unmarshal(b, &ref); err != nil {
        return err
    }
    if ref.Name == nil {
        return tracerr.Errorf("name is not found data=%s", b)
    }
    p.Name = *ref.Name
    if ref.Age == nil {
        return tracerr.Errorf("age is not found data=%s", b)
    }
    p.Age = *ref.Age

    if ref.Father != nil {
        p.Father = *ref.Father
    }
    return nil
}

これが一見良いと思われるもののネストした構造のエラーのときにあまりうれしくない。ネストした構造中でエラーがあった場合(先程の例ではfatherの中でエラーがあった場合)、本来ならば全体の構造を現してのエラーメッセージを表示したいところがエラー箇所部分のみの表示になってしまう。

具体的には以下の様なJSONを渡してのエラーは以下のようになってしまう。

"father/age" が足りないJSON

{"name": "foo", "age": 20, "father": {"name": "bar"}}

これをUnamarshalJSONしたときのメッセージ。

age is not found data={"name": "bar"}

father部分の不備なのにも関わらずfatherの情報が消えてしまっている。これは嬉しくない。

json.RawMessageを利用する

ここまでは概ね前回のおさらいみたいなもの。ここからが本題。

色々考えて見た所encoding/json.RawMessageを利用すればエラーメッセージを改善できそうなことに気づいた。ネスト部分をRawMessageの型にしてあげて遅延させてあげる。

すると以下のようなエラーメッセージのエラーが作れる。これならvalidation errorの表示としては悪くない。今回はfatherもエラーメッセージに含まれている。

father/age is not found data={"name": "bar"}

このときのコード。

// UnmarshalJSON :
func (p *Person) UnmarshalJSON(b []byte) error {
    type personP struct {
        Name   *string          `json:"name"` // required
        Age    *int             `json:"age"`  // required
        Father *json.RawMessage `json:"father"`
    }

    var ref personP
    if err := json.Unmarshal(b, &ref); err != nil {
        return err
    }

    if ref.Name == nil {
        return fmt.Errorf("name is not found data=%s", b)
    }
    p.Name = *ref.Name

    if ref.Age == nil {
        return fmt.Errorf("age is not found data=%s", b)
    }
    p.Age = *ref.Age

    if ref.Father != nil {
        if p.Father == nil {
            p.Father = &Person{}
        }
        if err := json.Unmarshal(*ref.Father, p.Father); err != nil {
            return fmt.Errorf("father/%s", err.Error())
        }
    }
    return nil
}

よりキレイなエラーの表示

あとはerrorをlistと見做したようなエラー表現やあるいはmapと見做したエラー表現にマッピングしてあげればネストしたエラー表現は作ることができる。

例えば以下の様な表示のエラーも作ることができる。

error:{"father":{"name":"required"},"mother":{"name":"required"}}

ということが分かったのでこのようなコードを出力するライブラリを作ってみても良いかもしれない。

追記

ただしまだ課題がある。

true/false/undefined的な3値に対して無視するという意味でのnilを許せない。pointer無しのstructにmappingできたとしても (requiredに対応できるがunrequiredに対応できない) 。