Go结构体Tag的用法和反射读取

Go 结构体字段后面反引号里的 struct tag 是键值对元信息,编译器不解释,由库和反射读取。最常用 json tag 做重命名、-忽略、omitempty 空值不输出,一个字段还能并存 json/bson/form 多种 tag。自己用 reflect.TypeOf 和 field.Tag.Get 能读 tag,这就是 gin、validator 校验的底层套路。

写 Go 的时候,结构体字段后面那串反引号括起来的东西,一开始我以为是注释,看着可有可无。

type User struct {
    Name string `json:"name"`
}
​

后来才知道,这玩意儿叫 struct tag,是 Go 里一个相当重要的机制。gin 绑参数、JSON 序列化、各种校验库,背后全靠它。

这篇把 struct tag 是什么、怎么用、怎么自己通过反射读它、甚至自定义一套 tag,一路讲到底。

从一个常见痛点说起

先看个场景,这是我理解 tag 价值的起点。

Go 有个硬规则:结构体字段首字母大写才能被外部包访问(导出)。而要被 json 包序列化、要被其他包复用,字段就必须大写。

于是问题来了,字段大写,序列化出来的 JSON key 也跟着大写:

type User struct {
    Name string
    Age  int
}

func main() {
    u := User{"米虫", 18}
    b, _ := json.Marshal(u)
    fmt.Println(string(b))
}
​

输出:

{"Name":"米虫","Age":18}
​

这 key 是大写开头的。跟前端对接、尤其是传给 Java 那边,人家习惯小写开头的 name,你甩个大写的 Name 过去,对方开发当场想打人。

字段又不能改成小写(一改就不导出了,json 包都读不到)。这个死结,就是 struct tag 来解的。

struct tag 长什么样

tag 是跟在字段后面、用反引号 ` 包起来的一段字符串。

它有固定的格式约定:键值对,键和值用冒号加引号,多个键值对之间用空格隔开。

type User struct {
    //       键    值        键     值
    Name string `json:"name" bson:"user_name"`
}
​
  • 一个反引号串里可以放多个 tag(上面就有 json 和 bson 两个)。
  • 每个 tag 是 键:"值" 的形式。
  • 多个用空格分隔,别用逗号(逗号是值内部用的,下面会讲)。

tag 本身只是个字符串,Go 编译器不解释它的含义。真正去读它、按它办事的,是具体的库(json 包、gin 等)和反射。

json tag 常用玩法

最常打交道的就是 json tag,几个高频用法说一下。

重命名字段,解决上面那个大写问题:

type User struct {
    Name string `json:"name"`     // 序列化成小写 name
    Age  int    `json:"ageYear"`  // 甚至可以叫完全不同的名字
}
// 输出:{"name":"米虫","ageYear":18}
​

用 - 忽略字段,不参与序列化:

type User struct {
    Name     string `json:"name"`
    Password string `json:"-"`   // 密码字段永远不输出到 JSON,安全
}
​

小坑:如果想让字段名真的叫 -,得写成 json:"-,"(后面加个逗号),否则单个 - 会被当成"忽略"。

用 omitempty 空值不输出:

type User struct {
    Name string `json:"name"`
    Nick string `json:"nick,omitempty"`  // Nick 为空字符串时,这个 key 不出现
}
​

注意 omitempty 跟在值后面用逗号接——这就是前面说"逗号是值内部用的"。name,omitempty 里,name 是字段名,omitempty 是选项。

多个 tag 并存

一个字段可以同时挂多种 tag,各管各的库。

type User struct {
    // json 给 HTTP 接口用,bson 给 MongoDB 用,form 给 gin 表单绑定用
    Name string `json:"name" bson:"name" form:"name"`
    Age  int    `json:"age"  bson:"age"  binding:"required,gte=0"`
}
​
  • json:encoding/json 序列化时用。
  • bson:MongoDB 驱动序列化时用。
  • form / binding:gin 框架绑定前端参数、做校验时用。

同一个结构体,不同的库各取自己认识的 tag,互不干扰。这就是 tag 灵活的地方。

自己用反射读 tag

库能读 tag,是因为用了反射。我们自己也能读,搞懂这个才算真正理解 tag 的运作。

import "reflect"

type User struct {
    Name string `json:"name" label:"用户名"`
    Age  int    `json:"age"  label:"年龄"`
}

func main() {
    u := User{}
    // 拿到类型信息(注意是 TypeOf,不是 ValueOf)
    t := reflect.TypeOf(u)
    // 遍历每个字段
    for i := 0; i < t.NumField(); i++ {
        field := t.Field(i)
        // 按 key 取出对应的 tag 值
        jsonTag := field.Tag.Get("json")
        labelTag := field.Tag.Get("label")
        fmt.Printf("字段=%s  json=%s  label=%s\n", field.Name, jsonTag, labelTag)
    }
}
​

输出:

字段=Name  json=name  label=用户名
字段=Age   json=age   label=年龄
​

关键就两个 API:

  • reflect.TypeOf(x) 拿到类型。
  • field.Tag.Get("键名") 按键取 tag 值。

还有个 field.Tag.Lookup("键名"),比 Get 多返回一个 bool,能区分"tag 不存在"和"tag 存在但值为空",更严谨。

自定义一套 tag

理解了反射读 tag,就能自己造轮子了。举个实用例子:根据自定义的 label tag 做个简单的"字段中文名"映射,用于校验报错提示。

// 自定义 required tag:标记哪些字段必填,报错时用 label 的中文名
type Form struct {
    Username string `label:"用户名" required:"true"`
    Email    string `label:"邮箱"   required:"false"`
}

// 一个极简校验器:必填字段为空就返回带中文名的错误
func validate(f Form) error {
    t := reflect.TypeOf(f)
    v := reflect.ValueOf(f)
    for i := 0; i < t.NumField(); i++ {
        field := t.Field(i)
        // 只校验标了 required:"true" 的字段
        if field.Tag.Get("required") != "true" {
            continue
        }
        // 当前字段的实际值为空字符串,就报错
        if v.Field(i).String() == "" {
            label := field.Tag.Get("label") // 取中文名拼报错
            return fmt.Errorf("%s不能为空", label)
        }
    }
    return nil
}

func main() {
    err := validate(Form{Username: "", Email: "[email protected]"})
    fmt.Println(err) // 输出:用户名不能为空
}
​

这就是各种校验库(像 validator)的最朴素原型:用 tag 声明规则,用反射读规则并执行。你平时写的 binding:"required",底层干的就是类似的事。

一点提醒

tag 虽好,用的时候有两个地方容易翻车:

  • 格式必须规范:json:"name" 里的引号、冒号一个都不能少写错。写成 json:name(值没引号)这种,反射 Get 可能取不到正确值,而且编译器不会报错,排查起来很坑。
  • 反射有性能开销:读 tag 走反射,比直接访问字段慢。热点路径里别反复反射同一个结构体,可以把解析结果缓存起来。好在 json 包这些成熟库内部都做了缓存,自己造轮子才要操心。

小结

struct tag 是挂在字段后面的键值对元信息,编译器不管它,由具体的库和反射来读取使用。

最常用的是 json tag:重命名、- 忽略、omitempty 空值不输出;一个字段还能并存 json/bson/form 等多种 tag,各库各取所需。

自己读 tag 靠反射:reflect.TypeOf 拿类型,field.Tag.Get 取值,顺着这个就能造出自定义 tag 和配套的校验逻辑,这正是 gin、validator 这些框架的底层套路。

用的时候盯紧格式别写错,热点路径注意反射开销。理解了 tag,再看那些框架"凭空"把结构体和前端参数、数据库字段对上,就不神秘了。

更多推荐

章节目录