Skip to content

详细介绍一下go中反射的原理(可以适当进行类比)

什么是反射

一句话概括:反射是程序在运行时检查和操作自身类型信息的能力

通常写代码时,所有的类型信息在编译期就确定了——你知道 argsBinArgs 类型、reply*float64。但 RPC 框架不知道用户会注册什么类型的服务、什么类型的参数,这些信息只有在运行时才能获取。反射就是解决这个"编译时不知道,运行时才知道"问题的工具。


类比理解:反射就像"身份证系统"

可以把 Go 的类型系统类比为一个人:

一个人(变量)
  ├── 身份证(Type):记录了你是什么类型、有什么字段、有什么方法
  └── 本人(Value):你的实际数据、实际的值

正常情况:
  你认识你的朋友(编译期知道类型),直接叫名字说话

反射情况:
  来了个陌生人(interface{}),你不认识
  → 先查身份证(reflect.TypeOf):哦,你是 Calculator 类型
  → 再看本人(reflect.ValueOf):你有 Add、Subtract、Multiply 三个方法
  → 通过身份证上的信息调用方法(reflect.Method.Func.Call)

Go 反射的三个核心概念

1. interface{} —— 反射的入口

Go 中所有反射操作都从 interface{} 开始。当一个具体类型被赋值给 interface{} 时,Go 运行时会在接口值内部存储两样东西:

interface{} 的内部结构:
┌─────────────────────────────────┐
│ type pointer  → 指向类型描述表    │  ← 这个值是什么类型?
│ data pointer  → 指向实际数据      │  ← 这个值的数据在哪?
└─────────────────────────────────┘
go
var x interface{} = Calculator{}

// x 内部:
//   type pointer → Calculator 的类型信息(字段、方法、大小...)
//   data pointer → Calculator{} 实例的内存地址

反射就是拆开 interface{} 的内部结构,分别取出类型信息(reflect.Type)和数据信息(reflect.Value)。

2. reflect.Type —— "这是什么类型"

go
t := reflect.TypeOf(rcvr)    // rcvr 是 *Calculator

t.Name()        // "" (指针类型没有名字)
t.Elem().Name() // "Calculator"
t.NumMethod()   // 3 (Add、Subtract、Multiply)
t.Method(0)     // reflect.Method{Name:"Add", Type:..., Func:...}
t.In(1)         // 第 1 个入参的类型(BinArgs)
t.Out(0)        // 第 0 个出参的类型(error)

reflect.Type 是只读的类型描述信息,它告诉你:

  • 这个类型叫什么名字
  • 有哪些字段(结构体)
  • 有哪些方法
  • 方法的参数和返回值分别是什么类型

3. reflect.Value —— "这个值是什么"

go
v := reflect.ValueOf(rcvr)   // rcvr 是 &Calculator{}

v.Type()              // *Calculator
v.Kind()              // reflect.Ptr
v.Elem()              // Calculator{} 的 Value(解引用指针)
v.Method(0).Call(...) // 调用第 0 个方法

reflect.Value 持有实际的数据,你可以通过它:

  • 读取和修改值
  • 调用方法
  • 创建新的实例

反射在 miniRPC 中的具体应用

miniRPC 的 service.go 是反射的集中应用场景。下面按执行顺序逐一分析。

第 1 步:创建服务(newService)

go
func newService(rcvr interface{}) *service {
    s := new(service)
    s.rcvr = reflect.ValueOf(rcvr)                    // ① 获取 Value
    s.name = reflect.Indirect(s.rcvr).Type().Name()   // ② 获取类型名
    s.typ = reflect.TypeOf(rcvr)                      // ③ 获取 Type
    if !ast.IsExported(s.name) {
        log.Fatalf("rpc server: %s is not a valid service name", s.name)
    }
    s.registerMethods()
    return s
}

server.Register(new(Calculator)) 为例,逐行拆解:

rcvr = &Calculator{}(interface{} 类型)

① reflect.ValueOf(rcvr)
   → s.rcvr = Value{type: *Calculator, data: 指向 Calculator 实例}
   作用:保存服务实例,后续方法调用时作为 receiver

② reflect.Indirect(s.rcvr).Type().Name()
   → reflect.Indirect:如果是指针就解引用,得到 Calculator 的 Value
   → .Type():得到 Calculator 的 Type
   → .Name():得到 "Calculator"
   → s.name = "Calculator"
   作用:提取服务名,用于 "Calculator.Add" 中的 "Calculator" 部分

③ reflect.TypeOf(rcvr)
   → s.typ = *Calculator 的 Type
   作用:后续遍历 *Calculator 上的方法

为什么 ② 要用 reflect.Indirect

因为 rcvr 可能是 &Calculator{}(指针)或 Calculator{}(值)。reflect.Indirect 统一处理:如果是指针就解引用拿到底层值,如果本身就是值则直接返回。这样无论用户传 new(Calculator) 还是 Calculator{},都能正确获取类型名 "Calculator" 而不是空字符串(指针类型的 Name() 返回 "")。

为什么 ③ 用 TypeOf 而不也用 Indirect

因为方法是定义在 *Calculator 上的(func (c *Calculator) Add(...)),要通过 *Calculator 这个类型去遍历方法,如果 IndirectCalculator,就找不到指针接收者的方法了。

第 2 步:发现方法(registerMethods)

go
func (s *service) registerMethods() {
    s.method = make(map[string]*methodType)
    for i := 0; i < s.typ.NumMethod(); i++ {        // 遍历所有方法
        method := s.typ.Method(i)                    // 获取第 i 个方法
        mType := method.Type                         // 方法的类型信息

        if mType.NumIn() != 3 || mType.NumOut() != 1 {   // 校验参数数量
            continue
        }
        if mType.Out(0) != reflect.TypeOf((*error)(nil)).Elem() {  // 校验返回值是 error
            continue
        }
        argType, replyType := mType.In(1), mType.In(2)   // 获取参数类型
        if !isExportedOrBuiltinType(argType) || !isExportedOrBuiltinType(replyType) {
            continue
        }
        s.method[method.Name] = &methodType{
            method:    method,
            ArgType:   argType,
            ReplyType: replyType,
        }
    }
}

*Calculator 为例,NumMethod() 返回 3,遍历过程:

方法 0:Add
  method.Type = func(*Calculator, BinArgs, *float64) error
  ├── NumIn() = 3    ✓(receiver=*Calculator, args=BinArgs, reply=*float64)
  ├── NumOut() = 1   ✓
  ├── Out(0) = error ✓
  ├── In(1) = BinArgs    → isExported("BinArgs") = true ✓
  └── In(2) = *float64   → PkgPath() = "" (内建类型) ✓
  → 注册:s.method["Add"] = &methodType{...}

方法 1:Multiply   → 同样通过校验 → 注册
方法 2:Subtract   → 同样通过校验 → 注册

为什么 NumIn() == 3 而不是 2?

通过反射获取方法时,Go 会把 receiver 也算作第一个参数。所以 func (c *Calculator) Add(args BinArgs, reply *float64) error 在反射层面的参数列表是 [*Calculator, BinArgs, *float64],共 3 个。

reflect.TypeOf((*error)(nil)).Elem() 是什么?

这是获取 error 接口类型的惯用写法。不能直接 reflect.TypeOf(error(nil)),因为 error(nil) 是一个 nil 接口值,TypeOf 会返回 nil。所以先创建一个指向 error 的指针类型 (*error)(nil),再 .Elem() 取出 error 接口类型本身。

第 3 步:创建参数实例(newArgv / newReplyv)

当服务端收到请求时,需要创建对应类型的参数实例来接收反序列化的数据。但编译时不知道参数类型是什么(可能是 BinArgs、可能是 string),所以必须用反射创建。

go
func (m *methodType) newArgv() reflect.Value {
    if m.ArgType.Kind() == reflect.Ptr {
        return reflect.New(m.ArgType.Elem())     // 指针参数:New(T) 返回 *T
    }
    return reflect.New(m.ArgType).Elem()         // 值参数:New(T).Elem() 返回 T
}

类比普通代码:

go
// 如果编译期知道类型,直接写:
args := BinArgs{}
args := &BinArgs{}

// 但框架编译期不知道类型,只有运行时的 reflect.Type,所以用反射:
argv := reflect.New(m.ArgType).Elem()   // 等价于 BinArgs{}
argv := reflect.New(m.ArgType.Elem())   // 等价于 &BinArgs{}

为什么要区分指针和值?

用户定义的方法签名可能是 func (c *T) Method(args Args, ...)func (c *T) Method(args *Args, ...)。如果参数类型是 *Args(指针),reflect.New(m.ArgType.Elem()) 创建 *Args;如果是 Args(值),reflect.New(m.ArgType).Elem() 创建 Args。这样传给 ReadBody 的类型才和用户定义的方法签名一致。

newReplyv 为什么要特殊处理 map 和 slice?

go
func (m *methodType) newReplyv() reflect.Value {
    replyv := reflect.New(m.ReplyType.Elem())
    switch m.ReplyType.Elem().Kind() {
    case reflect.Map:
        replyv.Elem().Set(reflect.MakeMap(m.ReplyType.Elem()))
    case reflect.Slice:
        replyv.Elem().Set(reflect.MakeSlice(m.ReplyType.Elem(), 0, 0))
    }
    return replyv
}

因为 reflect.New 只分配内存并零初始化。对于 *map[string]int,零初始化后内部的 map 是 nil,往 nil map 里写数据会 panic。同理 nil slice 虽然不会 panic 但 gob.Decode 可能出问题。所以需要用 MakeMap/MakeSlice 创建一个真正初始化过的实例。

等价于普通代码中的差异:

go
var m map[string]int   // nil map,不能用
m = make(map[string]int) // 初始化后可用

// reflect 版本:
replyv := reflect.New(mapType)                               // 此时 *replyv 是 nil map
replyv.Elem().Set(reflect.MakeMap(mapType))                  // 相当于 make(map[...])

第 4 步:反射调用方法(call)

go
func (s *service) call(m *methodType, argv, replyv reflect.Value) error {
    atomic.AddUint64(&m.numCalls, 1)
    f := m.method.Func
    returnValues := f.Call([]reflect.Value{s.rcvr, argv, replyv})
    if errInter := returnValues[0].Interface(); errInter != nil {
        return errInter.(error)
    }
    return nil
}

以调用 Calculator.Add(BinArgs{10, 20}, &reply) 为例:

f = reflect.Method.Func
  → 代表 func(*Calculator, BinArgs, *float64) error 这个函数

f.Call([]reflect.Value{s.rcvr, argv, replyv})
  → s.rcvr  = Value{*Calculator实例}     ← receiver
  → argv    = Value{BinArgs{10, 20}}     ← 第一个参数
  → replyv  = Value{*float64(指向0)}     ← 第二个参数(输出)

等价于普通代码:
  err := calculator.Add(BinArgs{10, 20}, &reply)

调用完成后:
  → replyv 指向的 float64 已被方法修改为 30
  → returnValues[0] = Value{nil}(无错误)

returnValues[0].Interface() 是什么?

reflect.Value.Interface() 方法将反射值转回 interface{},这样就可以用普通的类型断言 .(error) 来取出错误值。这是从"反射世界"回到"普通世界"的桥梁。


反射调用的完整链路

把所有步骤串起来,当客户端调用 client.Call(ctx, "Calculator.Add", BinArgs{10, 20}, &reply) 时,服务端发生了什么:

                        编译期知道的               运行时才知道的
                            │                         │
readRequest():              │                         │
  ReadHeader → h.ServiceMethod = "Calculator.Add"     │
  findService("Calculator.Add")                       │
    → svc = serviceMap["Calculator"]                  │ ← 反射注册时存入
    → mtype = svc.method["Add"]                       │ ← 反射发现时存入

  mtype.newArgv()                                     │
    → reflect.New(BinArgs类型).Elem()                  │ ← 运行时创建 BinArgs 实例
  mtype.newReplyv()                                   │
    → reflect.New(float64类型)                         │ ← 运行时创建 *float64 实例

  cc.ReadBody(argvi)                                  │
    → 将网络字节流反序列化到 BinArgs 实例中               │ ← 运行时填充数据

handleRequest():                                      │
  svc.call(mtype, argv, replyv)                       │
    → m.method.Func.Call(...)                         │ ← 运行时反射调用 Add 方法
    → replyv 被修改为 30                               │

  sendResponse(header, replyv.Interface())            │
    → replyv.Interface() 取出 float64 值 30            │ ← 从反射世界回到普通世界
    → cc.Write(header, 30)                            │
    → 发送响应给客户端                                  │

反射的性能代价

反射不是免费的午餐。和直接调用相比,反射调用有额外开销:

维度直接调用反射调用
类型检查编译期完成,运行时零开销运行时检查,有开销
方法调用编译器直接生成调用指令通过 Func.Call 间接调用,需要构造参数切片
内存分配编译器优化,可能栈上分配reflect.New 在堆上分配,[]reflect.Value 参数切片也需分配
性能差距基准约慢 10~100 倍(取决于操作)

但在 RPC 场景下,反射的开销通常可以忽略,原因:

  1. 网络 IO 是主要瓶颈:一次网络往返至少几十微秒到几毫秒,反射调用只有几百纳秒,占比极小。
  2. 注册只做一次registerMethods 中的类型遍历和方法发现只在服务启动时执行一次,不影响运行时性能。
  3. 热路径有限:每次请求中真正涉及反射的只有 newArgv/newReplyv(创建参数)和 Func.Call(调用方法),其他操作(序列化、网络读写)都不涉及反射。

为什么 RPC 框架必须用反射

根本原因是框架代码和用户代码在不同时间编写

框架作者编写 service.go 时:
  不知道用户会定义什么服务(Calculator? Greeter? 任何东西)
  不知道方法参数是什么类型(BinArgs? string? 自定义结构体?)
  不知道返回值是什么类型(*int? *string? *map[string]int?)

用户编写业务代码时:
  server.Register(new(Calculator))   ← 此刻框架才知道有 Calculator 这个服务
  client.Call("Calculator.Add", ...)  ← 此刻才知道要调用 Add 方法

没有反射,框架就必须为每种可能的类型写一套处理代码,这显然不可能。反射让框架能够用通用的代码处理任意类型,这就是它存在的意义。

对比其他 RPC 框架的解决方案:

框架处理方式是否用反射
miniRPC / net/rpc运行时反射发现方法
gRPC编译时生成代码(protoc)否(代码生成替代反射)
Thrift编译时生成代码(thrift compiler)

gRPC 和 Thrift 通过代码生成避免了反射——它们在编译前用工具生成了类型相关的序列化/调用代码,所以运行时不需要反射。代价是开发流程更重(需要写 .proto/.thrift 文件、运行代码生成器)。miniRPC 选择反射是为了保持零配置、零代码生成的简洁体验。