为什么入门阶段也值得理解一点反射
很多 Go 初学者第一次看到反射,并不是因为主动想学它,而是因为结构体字段后面那一串标签:
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
}
你可能会好奇:encoding/json 为什么知道要把 Email 变成 email?Web 框架为什么能自动把请求体绑定到结构体?校验库为什么能通过 validate:"required" 判断字段必填?这些能力背后都和反射有关。
反射允许程序在运行时观察类型和值。它很强,但也容易让代码变复杂。普通业务逻辑里,你应该优先写明确的结构体、函数和方法,而不是动不动就用反射。但理解反射的基本机制,会让你看标准库和框架时更踏实,也能帮助你判断什么时候该用工具,什么时候自己写几行明确代码更好。
这篇文章不追求把 reflect 包讲全,而是通过两个小例子建立直觉:读取结构体标签,以及写一个非常简单的必填字段校验器。
JSON 标签不是注释
先看一个结构体:
type Article struct {
ID int64 `json:"id"`
Title string `json:"title"`
Content string `json:"content,omitempty"`
}
编码:
article := Article{
ID: 1,
Title: "Go 反射入门",
}
data, err := json.Marshal(article)
if err != nil {
return err
}
fmt.Println(string(data))
输出类似:
{"id":1,"title":"Go 反射入门"}
Content 没有输出,因为它是空字符串,并且标签里有 omitempty。这些标签不是普通注释,运行时可以被反射读取。encoding/json 正是通过反射遍历结构体字段,读取 json 标签,再决定字段名和编码策略。
有一个重要前提:字段必须导出,也就是首字母大写。下面这个字段不会被 JSON 包正常编码:
type Article struct {
title string `json:"title"`
}
标签不能绕过 Go 的可见性规则。小写字段是包内私有,标准库外部包不能直接访问它。
reflect.Type 和 reflect.Value
反射里最常见的两个类型是 reflect.Type 和 reflect.Value。前者描述“类型是什么”,后者描述“值是什么”。
article := Article{ID: 1, Title: "Go"}
t := reflect.TypeOf(article)
v := reflect.ValueOf(article)
fmt.Println(t.Name()) // Article
fmt.Println(t.NumField()) // 3
fmt.Println(v.Field(0)) // 1
遍历字段:
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
value := v.Field(i)
fmt.Println(field.Name, field.Tag.Get("json"), value)
}
field.Tag.Get("json") 会读取 json 标签。输出可能是:
ID id 1
Title title Go
Content content,omitempty
这就是很多库读取结构体标签的基本方式。它们拿到字段名、字段类型、标签内容和值,再根据自己的规则处理。
处理指针输入
真实函数里,调用方可能传结构体,也可能传结构体指针。反射时要先处理指针:
func inspect(input interface{}) error {
v := reflect.ValueOf(input)
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return fmt.Errorf("input is nil")
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return fmt.Errorf("input must be struct")
}
t := v.Type()
for i := 0; i < v.NumField(); i++ {
field := t.Field(i)
fmt.Println(field.Name)
}
return nil
}
Kind 表示值的类别,比如结构体、指针、字符串、整数、切片。Elem 可以取得指针指向的值。反射代码里经常要先判断类别,再做下一步,否则很容易 panic。
这也是反射代码比普通代码难写的原因:很多错误从编译期推迟到了运行期。你需要自己做更多检查。
写一个简单 required 校验器
定义请求结构体:
type CreateUserRequest struct {
Email string `json:"email" validate:"required"`
Password string `json:"password" validate:"required"`
Name string `json:"name"`
}
实现一个只支持字符串必填的校验器:
func ValidateRequired(input interface{}) error {
v := reflect.ValueOf(input)
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return fmt.Errorf("input is nil")
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return fmt.Errorf("input must be struct")
}
t := v.Type()
for i := 0; i < v.NumField(); i++ {
field := t.Field(i)
if field.Tag.Get("validate") != "required" {
continue
}
value := v.Field(i)
if value.Kind() != reflect.String {
continue
}
if strings.TrimSpace(value.String()) == "" {
name := field.Tag.Get("json")
if name == "" {
name = field.Name
}
name = strings.Split(name, ",")[0]
return fmt.Errorf("%s is required", name)
}
}
return nil
}
使用:
req := CreateUserRequest{
Email: "xiaolin@example.com",
}
if err := ValidateRequired(req); err != nil {
fmt.Println(err) // password is required
}
这个例子很小,但已经展示了框架校验的大致思路:遍历字段,读取标签,根据字段类型和值执行规则。真正的校验库会支持更多类型、嵌套结构体、切片、错误集合和自定义规则。
什么时候不该用反射
如果你只校验一个明确类型,直接写方法通常更好:
func (r CreateUserRequest) Validate() error {
if strings.TrimSpace(r.Email) == "" {
return fmt.Errorf("email is required")
}
if strings.TrimSpace(r.Password) == "" {
return fmt.Errorf("password is required")
}
return nil
}
这段代码比反射版本更容易读,也能被编译器检查。反射适合“处理任意结构体”的通用工具,不适合普通业务里为了少写两行判断而引入复杂度。
一个实用判断是:如果你的函数参数类型已经明确,就先不用反射;如果你的函数必须接收许多未知结构体,并根据标签统一处理,反射才可能合适。
小结
反射让 Go 程序能在运行时观察类型、字段、标签和值。encoding/json、Web 框架、配置库、校验库和 ORM 都大量使用这套机制。理解 reflect.Type、reflect.Value、Kind、Elem 和结构体标签后,你就能看懂很多“自动绑定”背后的基本原理。
但反射不是日常业务代码的首选。它更难读,运行期错误更多,性能也通常不如直接代码。入门阶段学反射的目的,不是以后到处写反射,而是知道工具为什么能工作,以及什么时候应该保持简单。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。