在Go里写接口或做数据序列化时,我们经常会在struct字段后面加上形如json:"name,omitempty"的tag,让encoding/json包知道字段如何映射。但如果你自己要写一个通用工具,比如根据struct生成数据库表结构、自动生成API文档、或者做字段级别的权限过滤,就必须手动用反射把这些tag读出来。这篇文章会从reflect的基础API讲起,逐步实现一个完整可用的json tag解析函数,并覆盖嵌套结构体、指针、切片等容易踩坑的场景。

一、reflect读取struct tag的基础原理
Go的反射能力由标准库reflect提供,核心入口是reflect.TypeOf()。对一个struct变量调用TypeOf后得到reflect.Type,它描述了类型的元信息。struct的每个字段可以通过Type.Field(i)获取,返回一个reflect.StructField结构,其中的Tag字段类型是reflect.StructTag,它本质上就是一段约定格式的字符串。
tag字符串的格式是空格分隔的多个key:"value"对,例如json:"user_name,omitempty" db:"user_name"。要取出json这一项,使用Tag.Get("json")即可,它返回key对应的value,如果key不存在则返回空字符串。看一个最简单的例子:
package main
import (
"fmt"
"reflect"
)
type User struct {
Name string `json:"name"`
Age int `json:"age,omitempty"`
Email string `json:"email"`
}
func main() {
t := reflect.TypeOf(User{})
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
fmt.Printf("字段名: %s, json tag: %s\n", field.Name, field.Tag.Get("json"))
}
}运行后输出每个字段名和它对应的json tag值。注意Age字段的tag是age,omitempty,逗号后面的部分是encoding/json包自己的选项约定,Get返回的是完整字符串,需要你自己按逗号拆分。
二、Tag.Get与Tag.Lookup的区别及tag解析细节
很多人只用Get,但遇到“tag不存在”和“tag存在但值为空”这两种情况时就会分不清。Get在两种情况下都返回空字符串,无法区分。而Tag.Lookup会返回两个值,第二个bool值明确告诉你key是否存在,这在写健壮的框架代码时非常关键。
func showDiff() {
type Example struct {
A string `json:""`
B string
}
t := reflect.TypeOf(Example{})
fA, okA := t.Field(0).Tag.Lookup("json")
fB, okB := t.Field(1).Tag.Lookup("json")
fmt.Println(fA, okA) // 输出: "" true
fmt.Println(fB, okB) // 输出: "" false
}拿到完整的json tag字符串后,一般要拆出字段名和选项。规则很简单:第一个逗号之前是json字段名(可能为空,为空时encoding/json会直接使用Go字段名),后面按逗号分隔的是选项列表,常见的有omitempty、string、-等。如果tag值是-,表示这个字段在JSON序列化时会被完全忽略。下面这段代码演示了规范的解析流程:
type JsonTagInfo struct {
Name string // json字段名
Options []string // 选项列表
Ignored bool // 是否被忽略
}
func parseJsonTag(tag string) JsonTagInfo {
info := JsonTagInfo{}
if tag == "-" {
info.Ignored = true
return info
}
parts := strings.Split(tag, ",")
info.Name = parts[0]
info.Options = parts[1:]
return info
}还有一个容易被忽视的细节:tag中value必须用双引号包裹,写成json:name这种形式时,Get会返回空字符串且不报错,因为不符合tag的解析语法。这类问题编译器不会提醒,只能靠review或单元测试发现。
三、处理嵌套struct、指针与切片的完整实现
真实业务里的struct很少是扁平的,往往包含嵌套结构体、指针字段、切片和map。要在这种结构上完整提取json信息,需要递归遍历,并且在每一步先做类型解包:指针用Elem()去掉一层,切片和数组用Elem()取元素类型。此外还要处理匿名嵌入字段,因为encoding/json对内嵌struct有字段提升的逻辑。
func collectJsonNames(t reflect.Type, prefix string, result map[string]string) {
// 去掉指针
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
tag := field.Tag.Get("json")
// 匿名嵌入字段:递归展开,字段名不作为前缀
if field.Anonymous {
collectJsonNames(field.Type, prefix, result)
continue
}
if tag == "-" {
continue
}
name := tag
if idx := strings.Index(tag, ","); idx >= 0 {
name = tag[:idx]
}
if name == "" {
name = field.Name
}
full := prefix + name
result[full] = field.Type.String()
// 嵌套struct或其指针、切片形式,继续递归
ft := field.Type
if ft.Kind() == reflect.Slice || ft.Kind() == reflect.Array {
ft = ft.Elem()
}
if ft.Kind() == reflect.Ptr {
ft = ft.Elem()
}
if ft.Kind() == reflect.Struct {
collectJsonNames(ft, full+".", result)
}
}
}使用时传入reflect.TypeOf(Order{})和一个空map,就能得到所有字段(包括嵌套层级)对应的json路径和Go类型。这个思路可以直接套用在根据struct生成数据库schema、自动校验请求参数等场景。
四、性能注意事项与使用建议
反射是有代价的,每次调用Tag.Get都要重新解析tag字符串。如果你的服务在热路径上频繁做反射,建议在初始化阶段把解析结果缓存下来:用一个sync.Map或者包级别的map,以类型为key缓存解析好的字段信息,运行时直接查表。
var tagCache sync.Map // reflect.Type -> []JsonTagInfo
func getCachedTags(t reflect.Type) []JsonTagInfo {
if v, ok := tagCache.Load(t); ok {
return v.([]JsonTagInfo)
}
infos := make([]JsonTagInfo, 0, t.NumField())
for i := 0; i < t.NumField(); i++ {
infos = append(infos, parseJsonTag(t.Field(i).Tag.Get("json")))
}
tagCache.Store(t, infos)
return infos
}另外两点建议:一是接口断言json.Marshaler,如果类型实现了MarshalJSON方法,字段级别的tag解析结果可能与实际输出不一致;二是对外暴露反射工具时做好panic防护,传入非struct类型时NumField会直接panic,务必先用Kind()判断。掌握这些细节后,用reflect读取json tag就不再是一件碰运气的事了。
Golang reflectstruct tagjson tag修改时间:2026-09-11 10:24:43