Go structタグを読み解く—omitempty・json・reflectの仕組み

Go structタグを読み解く—omitempty・json・reflectの仕組み | mohablog

json:",omitempty" を書いたのに Count: 0 がレスポンスから消えない。最初は記法を間違えたのかと何度も見直したのですが、結論から書くと、struct タグは reflect.StructTag で読み出すだけのただの文字列で、omitempty がどう効くかは encoding/json 側の判定ロジック次第でした。Go 1.24 で omitzero が追加されたり、Go 1.26.2 で reflect.Type.Fields() による range-over-func 反復が入ったりと、struct タグ周辺の仕様は地味に進化しています。

そのあたりの仕組みを reflect から逆引きで整理して、json・validate・gorm・binding といったよく使うタグを使い分けるときの判断材料を残しておきます。バージョンは Go 1.26.2github.com/go-playground/validator/v10 v10.30.2 で確認しています。

目次

struct タグは結局ただの文字列

公式ドキュメント (pkg.go.dev/reflect) の StructTag 節にこう書かれています。

By convention, tag strings are a concatenation of optionally space-separated key:”value” pairs.

つまり key:"value" をスペースで連結した規約に過ぎません。コンパイラはタグの中身を解釈しないので、書き間違えてもビルドは通ります。これが omitempty のスペル違いに気付かない原因にもなりがちですね。

タグの基本記法

シンプルなフィールドのタグはこうなります。

type User struct {
    ID    int64  `json:"id"`
    Name  string `json:"name,omitempty"`
    Email string `json:"-"`
}

ポイントは値をダブルクォートで囲むこと、キー名は:の直前まで、, 以降がオプションになる、の3点です。- は「このフィールドは出力しない」という特別な意味を持ちます。

複数タグを並べるときの落とし穴

Google のサジェストにも「golang struct tags multiple」が上がってくるくらい、複数タグの書き方で迷う人は多いです。区切りは半角スペース1つだけで、カンマやセミコロンではありません。

// NG: カンマで区切ってしまうとパース結果が壊れる
type Bad struct {
    Name string `json:"name",validate:"required"`
}

// OK: スペースで区切る
type Good struct {
    Name string `json:"name" validate:"required"`
}

NG の方は reflect.StructTag.Get("validate") が空文字を返してしまい、validator のチェックが素通りします。go vet をかけると struct field tag is not compatible with reflect.StructTag.Get という警告で気付けるので、CI に入れておくと安全です。

「文字列」だから自前パーサも書ける

規約に従うだけのフォーマットなので、フレームワーク独自のタグを後付けで定義できます。例えば自社で masking:"email" のような独自タグを足して PII マスキングに使う、といった用途は珍しくありません。

reflect.StructTag を読み出す4つのAPI

struct タグを読み出す側のAPIは、reflectパッケージに集約されています。よく使うのは次の4つです。

Get と Lookup は何が違うか

触ってみるまで違いに気付かなかった部分なのですが、Get はキーが存在しないときも空文字を返します。一方 Lookup(value, ok) を返してくれるので、「キーは存在するが値が空文字」と「キー自体がない」を区別できます。

type S struct {
    A string `json:"" custom:""`
    B string
}

f, _ := reflect.TypeOf(S{}).FieldByName("A")
fmt.Println("Get json   :", f.Tag.Get("json"))   // ""
v, ok := f.Tag.Lookup("json")
fmt.Println("Lookup json:", v, ok)               // "" true

f2, _ := reflect.TypeOf(S{}).FieldByName("B")
v2, ok2 := f2.Tag.Lookup("json")
fmt.Println("Lookup B   :", v2, ok2)             // "" false
Get json   : 
Lookup json: "" true
Lookup B   :  false

「タグが書かれているか」を判定したい場面では必ず Lookup を使う、と覚えておくと事故が減ります。

VisibleFields で埋め込みフィールドを平坦化する

埋め込みのフィールドを再帰的にたどる処理を自前で書くとそこそこ煩雑です。Go 1.17 以降は reflect.VisibleFields が用意されていて、構造体の埋め込みを含めた可視フィールドを一度に取れます。

type Base struct {
    CreatedAt int64 `json:"created_at"`
}
type Article struct {
    Base
    Title string `json:"title"`
}

for _, f := range reflect.VisibleFields(reflect.TypeOf(Article{})) {
    fmt.Println(f.Name, "->", f.Tag.Get("json"))
}
Base -> 
CreatedAt -> created_at
Title -> title

埋め込み元の Base 自身もリストに含まれるので、f.Anonymous でフィルタするのが定石です。

Go 1.26.2 で追加された Type.Fields() で range する

Go 1.23 から関数型イテレータが言語仕様に入った流れで、reflectにも range 対応のメソッドが追加されました。Go 1.26.0 以降では reflect.Type.Fields()reflect.Value.Fields() が使えます。

// Go 1.26.0+ : Type.Fields() は iter.Seq[StructField] を返す
for f := range reflect.TypeOf(Article{}).Fields() {
    if tag, ok := f.Tag.Lookup("json"); ok {
        fmt.Println(f.Name, tag)
    }
}
CreatedAt created_at
Title title

従来の for i := 0; i < t.NumField(); i++ のループに比べると、フィールド名のミスタイプを減らせるのと、ネスト時に VisibleFields と書き分けやすいのが利点です。書き換えは急がなくていいですが、新規コードでは Fields() を選ぶ理由が増えました。

encoding/json タグの5つのオプション

公式ドキュメント pkg.go.dev/encoding/jsonMarshal 節に、対応オプションの仕様が書かれています。整理するとこうなります。

記法意味
json:"name"キー名を指定
json:",omitempty"ゼロ相当なら出力しない(後述の制約あり)
json:",omitzero"Go 1.24+。zero value もしくは IsZero() が true なら出力しない
json:"-"このフィールドを完全に無視。json:"-," とすると名前 “-” として出力
json:",string"数値・bool を JSON 文字列として埋め込む

omitempty が効かないケース

これがハマりどころで、omitempty は内部的に isEmptyValue という関数で判定されますが、構造体フィールド・固定長配列・カスタム IsZero メソッドは対象外です。次のコードは思った通りに動きません。

type Resp struct {
    Profile  Profile  `json:"profile,omitempty"`  // ← 構造体は omitempty が効かない
    UpdateAt time.Time `json:"updated_at,omitempty"`// ← time.Time も対象外
}
type Profile struct{ Bio string `json:"bio"` }

b, _ := json.Marshal(Resp{})
fmt.Println(string(b))
{"profile":{"bio":""},"updated_at":"0001-01-01T00:00:00Z"}

空の Profiletime.Time のゼロ値も、両方そのまま出ています。

omitzero への置き換えで解消する

Go 1.24 で追加された omitzero は、型に IsZero() bool があれば優先的にそれを呼びます。time.TimeIsZero を持っているので、書き換えるだけでスッキリします。

type Resp struct {
    Profile  Profile   `json:"profile,omitzero"`
    UpdateAt time.Time `json:"updated_at,omitzero"`
}
{}

1.24 未満の環境では、ポインタ型に変えて *Profile にすることで nil 判定を効かせるのが定番ワークアラウンドでした。互換性を維持したいなら両方を併記する json:",omitempty,omitzero" という書き方もできます。

自前バリデータを reflect で書いてみる

「struct タグが文字列に過ぎない」ことを実感するには、自分で読み取って動かすのが早いです。requiredmin だけを認識する小さなバリデータを書いてみます。

required と min を読み取って検査する

package mvalidate

import (
    "fmt"
    "reflect"
    "strconv"
    "strings"
)

type FieldErr struct {
    Field string
    Rule  string
    Msg   string
}

func Validate(v any) []FieldErr {
    rv := reflect.ValueOf(v)
    if rv.Kind() == reflect.Pointer {
        rv = rv.Elem()
    }
    rt := rv.Type()
    var errs []FieldErr
    for i := 0; i < rt.NumField(); i++ {
        tag, ok := rt.Field(i).Tag.Lookup("validate")
        if !ok {
            continue
        }
        for _, rule := range strings.Split(tag, ",") {
            errs = append(errs, check(rt.Field(i).Name, rv.Field(i), rule)...)
        }
    }
    return errs
}

func check(name string, v reflect.Value, rule string) []FieldErr {
    switch {
    case rule == "required":
        if v.IsZero() {
            return []FieldErr{{name, rule, "is required"}}
        }
    case strings.HasPrefix(rule, "min="):
        n, _ := strconv.Atoi(strings.TrimPrefix(rule, "min="))
        if v.Kind() == reflect.String && len(v.String()) < n {
            return []FieldErr{{name, rule, fmt.Sprintf("len < %d", n)}}
        }
    }
    return nil
}

これに対して、よくある会員登録のリクエストを通します。

type SignupReq struct {
    Email    string `validate:"required"`
    Password string `validate:"required,min=8"`
}

for _, e := range mvalidate.Validate(SignupReq{Password: "abc"}) {
    fmt.Printf("%s: %s (%s)\n", e.Field, e.Msg, e.Rule)
}
Email: is required (required)
Password: len < 8 (min=8)

40 行ちょっとで動くものができてしまいます。本物の go-playground/validator はこれにクロスフィールド検査・正規表現・カスタム関数登録・i18n エラーメッセージなどが乗っているだけ、という見え方になりました。

nil ポインタや埋め込みのケアを忘れない

上のサンプルは説明用に最小化しています。本番投入するなら、ポインタ型のチェック (v.IsNil()) と、埋め込み構造体への再帰呼び出しは入れておきましょう。reflect.VisibleFields でフィールドを取れば再帰部分を自前で書かずに済みます。

validator・GORM・Ginのbindingタグの守備範囲

1つのフィールドに複数タグが乗ると、どれが何をしているか混乱しがちです。よく出るタグを比較するとこうなります。

タグ処理タイミング主な責務
jsonMarshal/Unmarshal時JSONキー名・出力可否
validate明示的に Validate を呼んだとき値の制約チェック (validator v10.30.2)
bindingGin の ShouldBind* 系で呼ばれたとき内部で validator に委譲。GinはバインドとValidateを同時に行う
gormマイグレーション・クエリ生成時カラム名・主キー・インデックス指定

validate と binding は実質同じもの

Gin の binding タグは、内部で go-playground/validator を呼び出してチェックしているので、書ける構文は validate タグと共通です。Echo や Fiber では validate タグ名で同じ validator を使うことが多く、フレームワーク間で動きを揃えたいなら、validator のインスタンスを差し替えてタグ名を統一するのが定石です。

// Gin で binding タグを validate に統一する例
import (
    "github.com/gin-gonic/gin/binding"
    "github.com/go-playground/validator/v10"
)

func init() {
    if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
        v.SetTagName("validate")
    }
}

GORM のタグは別物として捉える

GORM の gorm:"primaryKey;column:user_id" はセミコロン区切りで、json/validate と区切り文字が違います。これは GORM が独自にパースしているためで、reflect.StructTag.Get("gorm") で取り出した文字列を GORM 側で strings.Split(s, ";") しているだけです。json タグと同じノリでカンマ区切りすると サイレントに無視される ので、混在するファイルでは気をつけたいところです。

reflectのコストとフィールド情報のキャッシュ戦略

reflect は便利な反面、誤った使い方をすると JSON Marshal がボトルネックになることがあります。

reflect.TypeOf 自体は軽い

誤解されがちなのですが、reflect.TypeOf 単体は内部的にキャッシュされた型情報を返すだけなので、ほぼゼロコストです。重いのは NumField ループの中で毎回タグをパースしたり、FieldByName で線形探索したりする側です。

フィールド情報を sync.Map にキャッシュする

自前で reflect 処理を書くなら、型ごとにフィールド情報を一度だけ抽出して sync.Map に保存するのが効きます。encoding/json 自体も cachedTypeFields という関数で同じことをしています (実装は encoding/json/encode.go)。

type fieldInfo struct {
    Name string
    Idx  int
    Rule string
}

var cache sync.Map // map[reflect.Type][]fieldInfo

func fieldsOf(t reflect.Type) []fieldInfo {
    if v, ok := cache.Load(t); ok {
        return v.([]fieldInfo)
    }
    var fs []fieldInfo
    for i := 0; i < t.NumField(); i++ {
        f := t.Field(i)
        if rule, ok := f.Tag.Lookup("validate"); ok {
            fs = append(fs, fieldInfo{f.Name, i, rule})
        }
    }
    cache.Store(t, fs)
    return fs
}

素朴な実装でも、毎回タグパースしていた処理を 1 万回ループでベンチすると、手元の M2 Mac で 約 1.4ms → 約 90μs になりました (Go 1.26.2, go test -bench)。Web ハンドラのホットパスに reflect が入る場合は、こうしたキャッシュを必ず通す前提で設計したほうが安全です。

reflect を避ける選択肢も持っておく

本当に速度がシビアなら、go generate でタグ駆動のコード生成に倒すのも手です。easyjsonffjson といったツールはこの方向性で、ベンチマーク上は encoding/json の数倍速いことが報告されています。reflect は便利だが万能ではない、という前提で選択肢を把握しておくと困りません。

まとめ

struct タグまわりの要点をふり返ります。

  • struct タグは key:"value" をスペース区切りで並べただけの文字列。コンパイラはチェックしないので go vet を CI に入れる
  • タグの存在判定には StructTag.Get ではなく StructTag.Lookup を使う
  • 埋め込みを含めて反復するなら reflect.VisibleFields、Go 1.26.2 以降は Type.Fields() で range する書き方が選べる
  • omitempty は構造体や time.Time に効かない。Go 1.24 以降は omitzero に置き換えると素直に動く
  • validator の validate タグと Gin の binding タグは同じ仕組み。タグ名を SetTagName で揃えると見通しが良くなる
  • reflect を多用するならフィールド情報を sync.Map にキャッシュする。ホットパスでは encoding/json 内部の cachedTypeFields と同じ発想を真似する

関連記事として Goのエラーハンドリングで失敗しない方法—wrappingパターンと構造化ログの組み合わせGORMのN+1問題とマイグレーション設計—Goで踏みやすい罠と回避策 も、struct タグ周辺の設計を考える上で参考になるはずです。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次