RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

Go言語でゼロ値の場合の項目を出し分けする方法とは?

はじめに

新卒1年目のTKDSです!
先日,Go言語でjsonで返すレスポンスを作る際,ゼロ値の場合の項目の出し分けを行いたい場面がありました.
そこで,encoding/jsonでゼロ値の場合の項目の出し分けを行う方法を調査しました.

行いたいこと

profileがゼロ値の場合,responseの一部を改変し,profileを含まず出力します.

{"id":1,"created_at":"2009-11-10T23:00:00Z","updated_at":"2009-11-10T23:00:00Z","profile":{"name":"TKD","age":"1000","email":"hogege@example.com"}}

profileがゼロ値の場合

{"id":1,"created_at":"2009-11-10T23:00:00Z","updated_at":"2009-11-10T23:00:00Z"}

改変前のサンプルコードはこちらです.

goの構造体として次のような形を持っています.

type user struct {
    ID        int64     `json:"id"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
    Profile   profile   `json:"profile"`
}

type profile struct {
    Name  string `json:"name"`
    Age   string `json:"age"`
    Email string `json:"email"`
}

user型の変数を定義する際,以下のコードのようにProfileを指定せずに変数の初期化を行うと,Profileフィールドは変数の型のゼロ値で初期化されます.stringの場合は””です.

user := user{
    ID:        1,
    CreatedAt: time.Now(),
    UpdatedAt: time.Now(),
}

サンプルコードを実行してみると,fmt.Println("Profile.Name is zero: ", user.Profile.Name == "")でtrueが出力されているのがわかります.

では,このコードを目的に合うように変更していきます.
ここでjsonを改変する方法は3つ考えられます.

1. 改変したいフィールドの型をany(interface{})にして,タグにomitemptyを指定する

通常,struct {}の型情報で宣言されているポインタ型でないフィールドには,何も代入しない場合ゼロ値がセットされます.
一方,タグのomitemptyはnilである場合にキーを無視して構造体をjsonに変換します.
そのため,このサンプルコードのProfileフィールドはjsonへの変換時,キーが無視されることなく,セットされたゼロ値を含むjsonレスポンスになってしまいます.

この処理はProfileにnilを代入することで防げます.
profileの値がnilになることで,omitemptyの条件に一致するようになり,jsonの項目にProfileが含まれなくなります.
しかし,Profile型の変数宣言ではnilを代入できません.

そこで,nilを代入できるようにProfileの型をany(interface{})にします.

type user struct {
    ID        int64     `json:"id"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
    Profile   any       `json:"profile_at,omitempty"`
}
{"id":1,"created_at":"2009-11-10T23:00:00Z","updated_at":"2009-11-10T23:00:00Z"}

出力結果からprofileの項目が消えていることが確認できます.

しかし,この方法には欠点があります.
any型にはなんでも代入できてしまうため,実装するときに変数の型について自分がなんの型を扱っているか気をつける必要があります.
これでは静的型付き言語であるgoを使うメリットが薄れてしまいます.

2. encoding/json/v2 のomitzeroを使う.

Goの実験的な実装であるencoding/json/v2を使う方法です.

profileを無視する機能を実現するのは構造体部分のタグです.

type user struct {
    ID        int64     `json:"id"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
    Profile   profile   `json:"profile,omitzero"`
}

omitzeroをつけることによって,ゼロ値の場合jsonに含まれなくなります.
この実装が実験的でなくなったら使いたい方法ですね.

3. MarshalJSON()メソッドを実装する.

json.Marshal()でjsonに変換する構造体にMarshalJSONメソッドを実装することで独自のタグに基づいてJSONを生成するロジックを記述できます. MarshalJSONはMarshaler interfaceに定義されているメソッドです. ついでに,なぜMarchalJSONを実装するのかencoding/jsonパッケージを見ながら調査してみました. json.Marshalの記述はsrc/encoding/json/encode.goにあります.

まずpackageのはじめの32行目あたりに,

// If an encountered value implements [Marshaler]
// and is not a nil pointer, Marshal calls [Marshaler.MarshalJSON]
// to produce JSON.

とあり,nilでなく,MarshalJSONを実装しているとMarshalJSONが呼び出されることが記述されています.

func Marshal(v any) ([]byte, error) {
    e := newEncodeState()
    defer encodeStatePool.Put(e)

    err := e.marshal(v, encOpts{escapeHTML: true})
    if err != nil {
        return nil, err
    }
    buf := append([]byte(nil), e.Bytes()...)

    return buf, nil
}

marshalを実行するメソッドを持つ構造体を返すのは,newEncodeStateです.

func newEncodeState() *encodeState {
    if v := encodeStatePool.Get(); v != nil {
        e := v.(*encodeState)
        e.Reset()
        if len(e.ptrSeen) > 0 {
            panic("ptrEncoder.encode should have emptied ptrSeen via defers")
        }
        e.ptrLevel = 0
        return e
    }
    return &encodeState{ptrSeen: make(map[any]struct{})}
}

encodeStateのreflectValueを見てみましょう.

func (e *encodeState) reflectValue(v reflect.Value, opts encOpts) {
    valueEncoder(v)(e, v, opts)
}

typeEncoderを呼び出した戻り値を返しています.

func valueEncoder(v reflect.Value) encoderFunc {
    if !v.IsValid() {
        return invalidValueEncoder
    }
    return typeEncoder(v.Type())
}

typeEncoderについてみてみましょう.
typeEncoderは長いので省略します.

省略
// Compute the real encoder and replace the indirect func with it.
f = newTypeEncoder(t, true)
wg.Done()
encoderCache.Store(t, f)
return f
}

typeEncoderは最終的にnewTypeEncoderの戻り値を返しています.
次はnewTypeEncoderについてみてみましょう.こちらも長いので省略します.
中身をみるとわかりますが,marshalerという単語が変数名やコメントに散見されます.
本丸に近づいていそうです. marshalerEncoderという名前が複数あります.
matshalerEncoderを返すためのif文があります.
これはポインタであるかどうか,marshalerTypeのinterfaceを実装しているかどうかなどをチェックしているようです.
前述したコード冒頭のMarshalJSONが機能する条件と合致しそうです.
addr, textなどの接頭辞がついてるmarshalerEncoderもありますが,シンプルなmarshalerEncoderを見てみましょう.

func marshalerEncoder(e *encodeState, v reflect.Value, opts encOpts) {
    if v.Kind() == reflect.Pointer && v.IsNil() {
        e.WriteString("null")
        return
    }
    m, ok := v.Interface().(Marshaler)
    if !ok {
        e.WriteString("null")
        return
    }
    b, err := m.MarshalJSON()
    if err == nil {
        e.Grow(len(b))
        out := e.AvailableBuffer()
        out, err = appendCompact(out, b, opts.escapeHTML)
        e.Buffer.Write(out)
    }
    if err != nil {
        e.error(&MarshalerError{v.Type(), err, "MarshalJSON"})
    }
}

メソッドの途中で,b, err := m.MarshalJSON()が呼ばれていることが確認できました. ここで,構造体に実装されたメソッドが実行され,独自のMarshalJSONが実行されます. なぜ,MarshalJSONを実装するとJSONのGoの構造体の間のマッピングが実行できるのか理解できました!

本題に戻り,MarshalJSONを実装してゼロ値のprofileを無視してみましょう.

実装したメソッドは次のようになっています.

func (u user) MarshalJSON() ([]byte, error) {
    fmt.Println("marchal")
    if u.Profile.Name == "" && u.Profile.Age == "" && u.Profile.Email == "" {

        return json.Marshal(&struct {
            ID        int64     `json:"id"`
            CreatedAt time.Time `json:"created_at"`
            UpdatedAt time.Time `json:"updated_at"`
        }{
            ID:        u.ID,
            CreatedAt: u.CreatedAt,
            UpdatedAt: u.UpdatedAt,
        })
    }

    return json.Marshal(u)
}

処理は単純で,ゼロ値だったらprofileを含まない構造体を返すだけです. 非常にシンプルにかけます. また,ゼロ値以外にも応用が効くので覚えておきたい方法です.

まとめ

最後まで見ていただきありがとうございました!
今回は, goのjsonパッケージについて調べました. 本筋から逸れましたが,パッケージの中身まで調べてみるとなぜinterfaceを満たすだけで任意の処理ができるのか学ぶことができました.
今後も気になった処理やパッケージがあったら調べてみたいと思います.

Copyright © RAKUS Co., Ltd. All rights reserved.