Struct Tag 是 Go 语言中一个简洁但极其强大的特性。它允许你为结构体字段附加元数据,这些元数据可以被反射读取,广泛用于序列化、ORM、验证等场景。

基本语法

Struct Tag 是紧跟在字段类型后面的反引号字符串

type User struct {
    Name  string `json:"name"`
    Age   int    `json:"age,omitempty"`
    Email string `json:"email"`
}

语法规则

  1. 使用反引号 ` 包裹,不是双引号
  2. 键值对组成,格式为 key:"value"
  3. 多个 tag 用空格分隔
  4. 值必须用双引号包裹
type Product struct {
    ID    int    `json:"id" db:"product_id" validate:"required"`
    Name  string `json:"name" db:"product_name"`
}

核心应用场景

1. JSON 序列化

最常用的场景,控制结构体与 JSON 之间的映射:

type Person struct {
    // 指定 JSON 字段名
    FirstName string `json:"first_name"`
    
    // omitempty: 零值时省略
    MiddleName string `json:"middle_name,omitempty"`
    
    // omitzero: 使用 IsZero() 方法判断(Go 1.24+)
    DeletedAt time.Time `json:"deleted_at,omitempty"`  // 零值会被省略
    UpdatedAt time.Time `json:"updated_at,omitzero"`   // 通过 IsZero() 判断
    
    // 短横线表示忽略该字段
    Password string `json:"-"`
    
    // 空 tag 使用字段名
    Age int `json:""`
}

2. 数据库 ORM

GORM 等 ORM 库使用 tag 定义表结构:

type User struct {
    ID        uint      `gorm:"primaryKey"`
    Username  string    `gorm:"uniqueIndex;size:50"`
    Email     string    `gorm:"index;not null"`
    CreatedAt time.Time `gorm:"autoCreateTime"`
}

3. 数据验证

validator 库通过 tag 定义验证规则:

type RegisterRequest struct {
    Username string `validate:"required,min=3,max=20"`
    Email    string `validate:"required,email"`
    Age      int    `validate:"gte=0,lte=150"`
    Password string `validate:"required,min=8"`
}

4. XML 映射

type Config struct {
    Server   string `xml:"server,attr"`   // 作为属性
    Port     int    `xml:"port,attr"`
    Database string `xml:"database>name"` // 嵌套元素
}

反射读取 Tag

使用 reflect 包可以动态读取 tag:

func parseTags(t interface{}) {
    rt := reflect.TypeOf(t)
    
    for i := 0; i < rt.NumField(); i++ {
        field := rt.Field(i)
        
        // 获取 json tag
        jsonTag := field.Tag.Get("json")
        fmt.Printf("Field: %s, JSON Tag: %s\n", field.Name, jsonTag)
        
        // 获取所有 tag
        fmt.Printf("All Tags: %s\n", field.Tag)
    }
}

type Example struct {
    Name string `json:"name" db:"user_name"`
}

parseTags(Example{})
// 输出:
// Field: Name, JSON Tag: name
// All Tags: json:"name" db:"user_name"

Tag 查找方法

field.Tag.Get("json")        // 返回 "name",找不到返回 ""
field.Tag.Lookup("json")     // 返回 ("name", true),找不到返回 ("", false)

omitempty vs omitzero

Go 1.24 引入了 omitzero 选项,解决了 omitempty 的一个痛点。

区别

选项判断方式适用场景
omitempty与零值比较(== zero基本类型、字符串、指针
omitzero调用 IsZero() bool 方法time.Time、自定义类型

问题示例

omitemptytime.Time 的处理基于零值比较:

type Record struct {
    CreatedAt time.Time `json:"created_at,omitempty"`
}

r := Record{CreatedAt: time.Time{}} // 0001-01-01 00:00:00
data, _ := json.Marshal(r)
// 输出:{}
// time.Time{} 是零值,omitempty 会省略

但问题在于非零时间的判断:

type Event struct {
    EndedAt time.Time `json:"ended_at,omitempty"`
}

// 假设事件确实没有结束时间,但你想显式表示"未设置"
// time.Time 没有"未设置"状态,只有零值和非零值
// 如果你需要一个"可能不存在"的时间,应该用指针
type Event2 struct {
    EndedAt *time.Time `json:"ended_at,omitempty"` // 指针 nil 时省略
}

omitzero 解决方案

type Record struct {
    CreatedAt time.Time `json:"created_at,omitzero"`
}

r := Record{CreatedAt: time.Time{}}
data, _ := json.Marshal(r)
// 输出: {}
// omitzero 调用 time.Time.IsZero(),正确识别为零值

对于 time.Timeomitemptyomitzero 效果相同,因为 time.Time{} 既是零值,IsZero() 也返回 true。

omitzero 的真正用武之地是自定义类型:

type NullableInt struct {
    value int
    valid bool
}

func (n NullableInt) IsZero() bool {
    return !n.valid
}

type Data struct {
    Count NullableInt `json:"count,omitzero"` // 只有 valid=false 时才省略
}

自定义 IsZero

你也可以为自定义类型实现 IsZero

type Optional[T any] struct {
    Value T
    Valid bool
}

func (o Optional[T]) IsZero() bool {
    return !o.Valid
}

type User struct {
    Name  string          `json:"name"`
    Phone Optional[string] `json:"phone,omitzero"`
}

最佳实践

1. 命名规范

  • JSON tag 使用 snake_case
  • 保持与 API 文档一致
  • 避免使用缩写,除非非常通用
// 好的实践
type Good struct {
    UserID    int64  `json:"user_id"`
    CreatedAt string `json:"created_at"`
}

// 避免
type Bad struct {
    UserId    int64  `json:"userId"`    // 不一致
    CreatedAt string `json:"createdAt"` // 应该用 snake_case
}

2. 谨慎使用 omitempty

type Data struct {
    Count int `json:"count,omitempty"`
}

// 当 Count = 0 时,JSON 中不会有 count 字段
// 这可能导致 API 消费者困惑:是 0 还是没传?

3. 敏感字段处理

type User struct {
    // 永远不要序列化密码
    PasswordHash string `json:"-"`
    
    // 内部 ID 不暴露
    InternalID int `json:"-"`
    
    // 外部使用的 ID
    PublicID string `json:"id"`
}

4. 多 tag 管理

当 tag 过多时,保持一致的顺序:

type Model struct {
    // 推荐顺序: json -> db -> validate -> 其他
    Name string `json:"name" db:"name" validate:"required"`
}

其他常用 Tag

mapstructure(配置解析)

Viper 等配置库使用:

type Config struct {
    Database struct {
        Host string `mapstructure:"host"`
        Port int    `mapstructure:"port"`
    } `mapstructure:"database"`
    Debug bool `mapstructure:"debug"`
}

url(表单绑定)

gorilla/schema 等表单解析库使用:

type SearchParams struct {
    Query  string `url:"q"`
    Page   int    `url:"page"`
    Limit  int    `url:"limit"`
}

yaml(YAML 序列化)

type Config struct {
    Server struct {
        Host string `yaml:"host"`
        Port int    `yaml:"port"`
    } `yaml:"server"`
}

inline 内联嵌套

部分库支持将嵌套结构体字段提升到外层:

type Base struct {
    ID        int       `json:"id"`
    CreatedAt time.Time `json:"created_at"`
}

type User struct {
    Base  `json:",inline"`  // Base 的字段会平铺到 User
    Name  string            `json:"name"`
}
// 输出: {"id":1,"created_at":"...","name":"xxx"}

常见陷阱

1. 格式错误

// 错误:使用双引号包裹
type Wrong struct {
    Name string "json:\"name\""
}

// 错误:值没有用双引号
type Wrong2 struct {
    Name string `json:name`
}

2. 反射时的指针问题

func inspect(v interface{}) {
    // 如果传入指针,需要先解引用
    rt := reflect.TypeOf(v)
    if rt.Kind() == reflect.Ptr {
        rt = rt.Elem()
    }
    // ...
}

3. 未导出字段无法读取

type Secret struct {
    public  string `json:"public"`  // 可以读取
    private string `json:"private"` // 反射无法访问
}

4. 嵌入字段的 Tag

匿名嵌入字段的 tag 有特殊处理:

type Base struct {
    ID int
}

type User struct {
    Base   // 继承 Base 的字段
    Name string `json:"name"`
}

// 序列化后:{"ID":1,"name":"xxx"}
// 注意:Base 的 ID 字段不会继承 User 的 json 命名空间

5. string 选项的坑

string 选项只对特定类型有效,且反序列化时类型必须严格匹配:

type Data struct {
    Count int `json:"count,string"`
}

// 序列化: {"count":"100"}
// 反序列化:json 字符串 "100" 可以解析为 int
// 但 json 数字 100 会报错(必须是字符串形式)

自定义 Tag 解析

你可以实现自己的 tag 解析器:

// ParseTag 解析 "name,opt1,opt2" 格式的 tag
func ParseTag(tag string) (name string, opts []string) {
    parts := strings.Split(tag, ",")
    if len(parts) == 0 {
        return "", nil
    }
    return parts[0], parts[1:]
}

// 使用
tag := `json:"user_id,omitempty,string"`
name, opts := ParseTag(tag)
// name = "user_id"
// opts = ["omitempty", "string"]

总结

Struct Tag 体现了 Go 的实用主义哲学:

  • 极简语法:反引号字符串,5 分钟学会
  • 反射驱动:编译期元数据,运行时通过反射读取
  • 生态基石:JSON、ORM、验证库都依赖它

记住三点:

  1. 用反引号,不是双引号
  2. omitempty 比较零值,omitzero 调用 IsZero()
  3. 敏感字段用 json:"-" 忽略