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,再看那些框架"凭空"把结构体和前端参数、数据库字段对上,就不神秘了。
