Runner in the High

技術のことをかくこころみ

GolangでUnmarshallerインターフェースを実装した構造体をフィールドの型として使うと便利

例えばこんな構造体が定義されているとして

type User struct {
    Name   string `json:"name"`
    Status int    `json:"status"`
}

この構造体をJSONからUnmarshalしてマッピングする際に、以下のような要件があるとする

  • statusの取りうる範囲は1,2だけ、かつそれぞれACTIVE, INACTIVEという定数にマッピングしたい。
    • 1,2以外の数値が来たらエラーにしたい。
  • statusフィールドがないときもエラーにしたい。

User構造体を受け取ってバリデーションをする関数を作ってもよいが、以下のようにStatus型を新しく作り、それにUnmarshallerインターフェースを実装させるとグッとスマートにできる。

type User struct {
    Name   string `json:"name"`
    Status Status `json:"status"` // int型からStatus型に変更
}

Unmarshallerインターフェースを実装したStatus型

Unmarshallerインターフェースは以下のような定義になっている

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

JSONのUnmarshalをする際、マッピング対象のフィールドにUnmarshallerインターフェースを実装したものがあると、そのフィールドへのマッピングの際に自動的に実装されたUnmarshalJSONメソッドが呼ばれるようになる。

つまり、Status型にこのUnmarshallerインターフェースを実装することで、User型がUnmarshalされる際に自動でStatus型に実装された任意のマッピング処理およびバリデーション(!)行えるようになるというわけだ。

Unmarshallerインターフェースを用いて要件を実装したStatus型は以下になる。

type Status int

const (
    ACTIVE Status = iota + 1
    INACTIVE
)

// Unmashaller
func (status *Status) UnmarshalJSON(data []byte) error {
    var value int

    if err := json.Unmarshal(data, &value); err != nil {
        return fmt.Errorf("Failed decoding status: %s", err.Error())
    }

    switch value {
    case 1:
        *status = ACTIVE
    case 2:
        *status = INACTIVE
    default:
        return fmt.Errorf("Invalid status: %d", value)
    }

    return nil
}

statusの取りうる範囲をバリデーションできているか見てみる

上の実装を終えた上で、Status型をフィールドに持つUser型をJSONからマッピングしてみるとこうなる

func main() {
    src := `{ "name": "Jonathan", "status": 3 }`

    var user User
 
    if err := json.Unmarshal([]byte(src), &user); err != nil {
        panic(err) // panic: Invalid status: 3
    }

    // ...
}

statusに3が与えられているのはUnmarshal時にマッピングエラーになる! すばらしい

statusフィールドが存在しない場合にどうするか

実はこれだけではまだ、以下のようにstatus型が抜け落ちているJSONマッピングに対するエラー検知ができていない

{ "name": "Jonathan" }

上のJSONマッピングするとどういう挙動になるかというと以下のようになる。

func main() {
    src := `{ "name": "Jonathan" }`

    var user User
 
    if err := json.Unmarshal([]byte(src), &user); err != nil {
        panic(err)
    }

    fmt.Printf("%#v\n", user) // main.User{Name:"Jonathan", Status:0}
}

つまりint型の初期値である0が代入される。

自分の場合は以下の IsEmpty のようなレシーバメソッドを生やして、仮にUnmarshalが成功したとしても、必ず直後にIsEmptyを事前条件チェックとして呼ぶようにしているが、若干微妙ではある。

type Status int
 

const (
    ACTIVE Status = iota + 1
    INACTIVE
)

func (status Status) IsEmpty() bool {
    return status == 0
}

// ...

func main() {
    src := `{ "name": "Jonathan" }`

    var user User
 
    if err := json.Unmarshal([]byte(src), &user); err != nil {
        panic(err)
    }
 
    if user.Status.IsEmpty() {
        panic("status is empty")
    }

    fmt.Printf("%#v\n", user)
}

本当はUnmarshalJSONメソッドの中でどうにかしたいが、Unmarshal対象のフィールドがない場合には当たり前だが呼ばれない。ここはちょっと苦しい。