日記マン

動画広告プロダクトしてます。Go, Kubernetesが好きです。

error型の階層情報を失わずにjsonシリアライズ・デシリアライズする

Goの error 型を json シリアライズ・デシリアライズ したいときのシリアライズ化条件と階層情報の失わないように拡張する話。

たとえばこのような 正常系のデータを格納する Data メンバと、
異常系の理由を格納する Error メンバを保持した Result 構造体を定義しても、

type Result struct {
    Data interface{} `json:"data,omitempty"`
    Error error `json:"err,omitempty"`
}

素直に jsonエンコードしてしまうと err フィールドには何も入らない {} 結果になる。
(エンコード・デコード可能な公開メンバがみつからないため。)

foo := User{Name: "Foo"}
res := &Result{
    Data: foo,
    Error: nil,
}
json.Marshal(res)
// => {"data": {"name": "foo"}}

res := &Result{
    Error: errors.New("no such model"),
}
json.Marshal(res)
// => {"err": {}}

そこで、 構造体では error 型として保持し、 jsonシリアライズ・デシリアライズ 時に
文字列の string 型に変換するように json.(Marshaler, Unmarshaler) をダックタイピングする。

参考: hawksnowlog: json: cannot unmarshal object into Go struct field Profile.error of type error

type Result struct {
    Data   interface{} `json:"data,omitempty"`
    Err    error       `json:"-"`
    ErrMsg string      `json:"err,omitempty"`
}

// MarshalJSON implements json.Marshaler.
func (r Result) MarshalJSON() ([]byte, error) {
    type Alias Result
    r2 := &Alias{
        Data: r.Data,
    }
    if r.Err != nil {
        r2.ErrMsg = r.Err.Error()
    }
    return json.Marshal(r2)
}

// UnmarshalJSON implements json.Unmarshaler.
func (r *Result) UnmarshalJSON(b []byte) error {
    type Alias Result
    r2 := &Alias{Data: r.Data}
    if err := json.Unmarshal(b, r2); err != nil {
        return err
    }
    if r2.ErrMsg != "" {
        r.Err = errors.New(r2.ErrMsg)
    }
    return nil
}

しかし、上記の方法では、以下のように : %w によるラップ後のエラーの値を、
jsonシリアライズ時にはエラーの構造的な情報が失われてしまうため、
errors.Is メソッドで等価判定できない。

errNoSuchModel := errors.New("no such model")
res := &Result{
    Error: fmt.Errorf("could not get from repository: %w", errNoSuchModel),
}
b, err := json.Marshal(res)

res := &Result{}
json.Unmarshal(b, res) // res.Error `could not get from repository: no such model`
errors.Is(res.Error, errNoSuchModel) // false

そこで、独自の型定義を用意する。

type ResultError struct {
    msg string
}

func newResultError(msg string) error {
    return &ResultError{msg: msg}
}

func (e ResultError) Error() string {
    return e.msg
}

func (e ResultError) Unwrap() error {
    parts := strings.SplitN(e.Error(), ": ", 2)
    if len(parts) != 2 {
        return nil
    }
    return newResultError(parts[1])
}

func (e ResultError) Is(target error) bool {
    return e.Error() == target.Error()
}

Unwrap() error を実装している型であれば、 errors.Is メソッド内部でアンラップを行ってくれる。
これで以下のように修正する。

type Result struct {
    Data   interface{} `json:"data,omitempty"`
    Err    error       `json:"-"`
    ErrMsg string      `json:"err,omitempty"`
}

// MarshalJSON implements json.Marshaler.
func (r Result) MarshalJSON() ([]byte, error) {
    type Alias Result
    r2 := &Alias{
        Data: r.Data,
    }
    if r.Err != nil {
        r2.ErrMsg = r.Err.Error()
    }
    return json.Marshal(r2)
}

// UnmarshalJSON implements json.Unmarshaler.
func (r *Result) UnmarshalJSON(b []byte) error {
    type Alias Result
    r2 := &Alias{Data: r.Data}
    if err := json.Unmarshal(b, r2); err != nil {
        return err
    }
    if r2.ErrMsg != "" {
        r.Err = newResultError(r2.ErrMsg)
    }
    return nil
}