Go 反射:从 map 到结构体

2026年10月20日 · 1222 字 · 6 分钟

以 map[string]any 转结构体为例,梳理 Go 反射中的类型与值、指针解引用、字段赋值,以及嵌套结构体和指针字段的递归处理。

假设我们已经把一份配置整理成了 map[string]any:

data := map[string]any{
	"Mode": "debug",
	"Http": map[string]any{
		"Host": "localhost",
		"Port": 8080,
	},
}

程序使用的配置结构体是这样的:

type HttpConfig struct {
	Host string
	Port int
}

type Config struct {
	Mode string
	Http HttpConfig
}

我们希望写一个函数,把 map 中的值填进去:

var conf Config
ConvertMapToStruct(data, &conf)

调用之后,conf.Mode 是 "debug",conf.Http.Port 是 8080。

如果只需要处理 Config,手动赋值就够了:

conf.Mode = data["Mode"].(string)

但如果同一个函数还要处理其他结构体,参数就不能写死为 *Config:

func ConvertMapToStruct(data map[string]any, obj any)

这时,函数内部无法直接写 obj.Mode。编译器只知道 obj 是 any,不知道调用者会传来什么结构体,也无法通过一个运行时才拿到的字符串直接访问字段。

反射可以在程序运行时取得具体类型的信息,找到对应字段,再按类型规则赋值。

any 里面还保留着具体类型

先看一个接口变量:

var obj any = &Config{}

obj 的静态类型是 any,但接口值仍保留着动态类型和动态值。这里的动态类型是 *Config,动态值是指向某个 Config 对象的指针。any 是 interface{} 的别名。

反射会利用这些运行时信息,以及编译器保留的类型元数据。

reflect 包提供了两个常用入口:

t := reflect.TypeOf(obj)
v := reflect.ValueOf(obj)

t 是 reflect.Type,用来查看类型信息,比如字段名、字段类型和 tag。v 是 reflect.Value,用来读取具体值,也可以在满足条件时修改值。

传入的是指针,Elem 取得它指向的值

调用转换函数时,传进去的是 &conf:

var conf Config
ConvertMapToStruct(data, &conf)

进入函数后,obj 的静态类型是 any,里面装着的具体值却是一个 *Config 指针。因此,函数开头需要先解引用:

v := reflect.ValueOf(obj) // 表示 *Config 指针
v = v.Elem()             // 表示这个指针指向的原结构体 conf
t := v.Type()            // 此时取得的类型是 Config

调用 Elem() 后,v 自身仍是 reflect.Value。它表示的内容从指针变成了指针指向的结构体,obj 本身没有改变。

这里的 Elem() 类似普通 Go 代码中的 *p。指针和它指向的结构体是两种不同的值,NumField()、Field(i) 要求当前反射值表示结构体,不能直接在指针上调用。

用一个整数对照会更清楚:

x := 10

p := reflect.ValueOf(&x) // Kind 是 reflect.Ptr
v := p.Elem()           // Kind 是 reflect.Int,表示原来的 x

v.SetInt(20)
fmt.Println(x) // 20

与它对应的普通赋值是:

p := &x
*p = 20

ValueOf 不会自动取得参数的地址。传入 &x,得到的反射值就表示指针;传入 10,得到的反射值就表示整数。是否需要 Elem(),取决于当前表示的值和接下来要做的操作。

为什么直接传结构体值不能修改原变量

试着把结构体值直接交给反射:

var conf Config

v := reflect.ValueOf(conf)
fmt.Println(v.CanSet()) // false

CanSet() 表示这个反射值能否被修改。这里得到的值无法回写原变量 conf,所以返回 false。如果继续调用下面的代码,就会 panic:

v.FieldByName("Mode").SetString("release")

传入指针,再取得它指向的值:

p := reflect.ValueOf(&conf)

fmt.Println(p.CanSet())        // false
fmt.Println(p.Elem().CanSet()) // true

p 表示传进来的指针值,本身不可设置。调用 p.Elem() 会取得它所指向的原结构体,这个反射值可以修改。在这里,Elem() 做的是指针解引用。

因此,修改 conf.Mode 的写法是:

p.Elem().FieldByName("Mode").SetString("release")

这段代码依次取得原结构体、找到 Mode 字段,再把字符串写进去。

Go 的参数传递仍然是值传递。传指针时,复制的是指针值,复制后的指针依然指向同一个 conf。下一层函数通过它找到原对象,修改才会生效。

v 自身的类型,与 v 表示的类型

下面几个表达式查询的对象不同:

v := reflect.ValueOf(obj).Elem()

fmt.Printf("%T\n", v)          // reflect.Value
fmt.Println(reflect.TypeOf(v)) // reflect.Value
fmt.Println(v.Type())          // main.Config
fmt.Println(reflect.TypeOf(obj)) // *main.Config

reflect.TypeOf(v) 查询 v 这个 Go 变量自身的类型;v.Type() 查询它表示的内容的类型。这里 v 表示一个 Config 结构体,所以 v.Type() 得到 Config。

reflect.TypeOf(obj) 则检查原来的入参。入参依然装着 *Config 指针,得到的也依然是指针类型。

Type.Elem 和 Value.Elem 取得的东西不同

对于同一个指针:

reflect.TypeOf(&conf).Elem()  // 取得指向的类型:Config
reflect.ValueOf(&conf).Elem() // 取得指向的值:原来的 conf

Type.Elem() 在这里用来查询类型;Value.Elem() 用来沿着指针访问实际的值。它们都不会替一个 nil 指针创建对象。

本文讨论的是指针场景。reflect.Value.Elem() 也可以用于接口值,但不能对普通结构体、整数或字符串值调用。它只解开一层;调用本文的转换函数时,应传入非 nil 的结构体指针。

t 和 v 要保持在同一层

如果把入参记为 b,下面这段代码只给值解了引用:

t := reflect.TypeOf(b)         // *Config
v := reflect.ValueOf(b).Elem() // Config 结构体

此时 v.NumField() 可以执行,t.Field(i) 却会报 Field of non-struct type *main.Config,因为 t 仍然描述指针。v.Elem() 不会顺带改变另一个变量 t。

可以从解引用后的值取得类型:

v := reflect.ValueOf(b).Elem()
t := v.Type()

也可以让类型和值分别解引用:

t := reflect.TypeOf(b).Elem()
v := reflect.ValueOf(b).Elem()

在入参为非 nil 结构体指针的前提下,这两种写法都正确。后面的练习会使用第二种写法。

t.Field(i) 与 v.Field(i) 分别取得什么

“字段描述”指的是结构体定义里的一行声明。例如 Mode string 告诉我们:字段名叫 Mode,类型是 string。这些信息属于类型定义,不代表某个对象中存着的 "debug" 或 "release"。

先创建一个对象,再分别读取字段描述和字段值:

conf := Config{Mode: "debug"}
v := reflect.ValueOf(&conf).Elem()
t := v.Type()

tf := t.Field(0) // 字段描述,类型为 reflect.StructField
f := v.Field(0)  // 表示当前对象 conf.Mode 的 reflect.Value

fmt.Println(tf.Name)       // Mode
fmt.Println(tf.Type)       // string
fmt.Println(f.Interface()) // debug

f.SetString("release")
fmt.Println(conf.Mode) // release

这里的下标 0 表示结构体的第一个字段。在本文的 Config 中,它就是 Mode。t.Field(0) 查看的是 Mode string 这条声明的信息,v.Field(0) 则访问当前这个对象的 conf.Mode。

表达式 返回的内容
t.Field(0) reflect.StructField,描述 Mode 这个字段
tf.Name 字段名 "Mode"
tf.Type 字段的类型 string,它是一个 reflect.Type
tf.Tag 字段上的 tag,本文没有设置
v.Field(0) 当前对象中 conf.Mode 的反射值,类型是 reflect.Value

t.Field(i) 返回的是 reflect.StructField,字段类型保存在它的 Type 属性中。v.Field(i) 返回的仍是 reflect.Value,可以通过 Interface() 读取其中表示的值,也可以在允许修改时通过 Set 等方法写回原字段。

当 conf.Mode 从 "debug" 改成 "release" 时,tf.Name 仍是 "Mode",tf.Type 仍是 string,改变的是当前对象里的值。

tf 和 f 都是自定义变量名,不是 Go 关键字,也不是反射库提供的函数。后面的练习分别把它们命名为 tt 和 vv,用途相同。

字段名怎样用来查找 map

反射不会自动把字段与 map 关联起来。转换函数自己规定:取得字段名,再用这个名字查询 map。

继续以上面的 Mode 字段为例,把查询和赋值拆开写:

data := map[string]any{"Mode": "debug"}

key := tf.Name       // 字符串 "Mode"
val, ok := data[key] // 相当于 data["Mode"],得到 "debug"、true
if ok {
	f.Set(reflect.ValueOf(val)) // 把查询结果写进 conf.Mode
}

所以 t.Field(i) 本身不是 map 的 key。i 是结构体字段的下标,tf.Name 才是我们拿来当 key 的字符串。

如果 map 只提供 "mode",字段仍叫 Mode,tf.Name 不会随之改变,查询也会失败,ok 为 false。是否按字段名、tag 或其他规则匹配,需要转换函数自己决定。

循环可以使用 v.NumField()

可以把循环条件写成下面这样:

for i := 0; i < v.NumField(); i++ {
	tf := t.Field(i)
	f := v.Field(i)
	fmt.Printf("字段名=%s,类型=%v,值=%v\n", tf.Name, tf.Type, f.Interface())
}

只要 t 是 v.Type(),且 v 表示结构体,t.NumField() 和 v.NumField() 得到的数量就相同。对于本文的 Config,结果是 2:只统计这一层的 Mode 和 Http,不会把 Http 内部的 Host、Port 展开计算。

switch tf.Type.Kind() 也可以

Type 描述具体类型,Kind() 表示类型所属的类别:

t := reflect.TypeOf(Config{})
fmt.Println(t)        // main.Config
fmt.Println(t.Kind()) // struct

Config 和 HttpConfig 的具体类型不同,但 Kind() 都是 reflect.Struct。在当前循环中,tf.Type.Kind() 与 f.Kind() 得到的类别相同,所以可以写:

switch tf.Type.Kind() {
case reflect.Struct:
	// 处理嵌套结构体
case reflect.Ptr:
	// 处理指针字段
default:
	// 处理普通字段
}

注意,tf 是 reflect.StructField,它本身没有 Kind() 方法,需要通过 tf.Type.Kind() 查询。

写出转换函数

下面是一份完整的教学代码。它只处理普通字段和嵌套结构体,并假设:传入的是非 nil 的结构体指针,字段已导出,输入值的类型与字段匹配。

package main

import (
	"fmt"
	"reflect"
)

type HttpConfig struct {
	Host string
	Port int
}

type Config struct {
	Mode string
	Http HttpConfig
}

func ConvertMapToStruct(data map[string]any, obj any) {
	// 取得指针指向的结构体
	v := reflect.ValueOf(obj).Elem()
	t := v.Type()

	for i := 0; i < t.NumField(); i++ {
		tf := t.Field(i) // 字段信息
		f := v.Field(i)  // 字段的具体值

		val, ok := data[tf.Name]
		if !ok {
			continue
		}

		switch f.Kind() {
		case reflect.Struct:
			// 嵌套结构体:取得原字段的地址,递归填充
			ConvertMapToStruct(
				val.(map[string]any),
				f.Addr().Interface(),
			)

		default:
			// 普通字段:直接赋值
			f.Set(reflect.ValueOf(val))
		}
	}
}

func main() {
	data := map[string]any{
		"Mode": "debug",
		"Http": map[string]any{
			"Host": "localhost",
			"Port": 8080,
		},
	}

	var conf Config
	ConvertMapToStruct(data, &conf)
	fmt.Printf("%+v\n", conf)
}

保存为 main.go,运行:

go run main.go

输出:

{Mode:debug Http:{Host:localhost Port:8080}}

函数取得原结构体后,用字段名从 map 中查找数据。处理 Mode 时,tf.Name 是 "Mode",f 对应原来的 conf.Mode,val 则是字符串 "debug"。赋值发生在这一行:

f.Set(reflect.ValueOf(val))

完成了与 conf.Mode = "debug" 相同的赋值。Set 接收的参数也是 reflect.Value,所以先用 ValueOf 包装 map 中取出的值。

为什么赋值时不用 ValueOf(val).Elem()

入口处的 obj 与这里的 val 虽然都是 any,里面装着的具体值却不同:

位置 具体值 ValueOf 得到的 Kind 下一步要做的事
函数入口的 obj &conf,类型为 *Config reflect.Ptr 通过 Elem() 找到结构体,再访问字段
Mode 对应的 val "debug",类型为 string reflect.String 直接赋给 string 字段
Port 对应的 val 8080,类型为 int reflect.Int 直接赋给 int 字段

Elem() 不负责把值从 reflect.Value 中取出来。它在这里做的是指针解引用;对字符串或整数调用它会 panic。

是否需要解引用,还要看目标字段需要什么类型。假设 f 表示一个 int 字段,而来源是一个 *int 指针,可以先取得指针指向的值:

port := 8080
f.Set(reflect.ValueOf(&port).Elem()) // 来源解引用后为 int,与目标字段匹配

如果目标字段本来就是 *int,来源也为 *int,就应保留指针,直接 Set(reflect.ValueOf(&port))。Set 要求来源类型可赋值给目标类型,不能把 Elem() 当作每次赋值前的固定步骤。

遇到 Http 时,它的 Kind() 是 reflect.Struct。函数取得这个字段的地址,再把内层 map 传下去,继续处理 Host 和 Port。

嵌套结构体里的 Interface 和 Addr

递归时,我们通过下面的表达式传入嵌套字段:

f.Addr().Interface()

此时 f 表示原来的 conf.Http。

f.Addr() 取得这个字段的地址,相当于普通代码里的 &conf.Http,返回结果仍是一个 reflect.Value。

Interface() 再把其中表示的指针取出来,返回一个 any。这个接口值的动态类型是 *HttpConfig,可以交给下一层 ConvertMapToStruct。

两步的区别也可以直接打印出来:

fmt.Printf("%T\n", f.Addr())             // reflect.Value
fmt.Printf("%T\n", f.Addr().Interface()) // *main.HttpConfig

如果递归时只传 f.Addr(),下一层入参里面装着的就是一个 reflect.Value 对象。再调用 reflect.ValueOf(obj),取得的是表示这个 reflect.Value 结构体的反射值,随后调用 Elem() 就会 panic。因此,在本文约定接收普通结构体指针的函数中,不能省略 Interface()。

下一层调用 ValueOf(obj).Elem() 时,就能沿着这个指针找到原来的 conf.Http。

如果省掉 Addr(),直接这样递归:

ConvertMapToStruct(val.(map[string]any), f.Interface())

传进去的就是结构体值,而不是字段的地址。在当前这份实现中,下一层对结构体值调用 Elem() 就会 panic。

即使把函数入口改为先判断是不是指针,仅在指针上调用 Elem(),问题也没有解决。重新通过 ValueOf 取得的结构体值依然不可设置,后面的 Set 仍会失败:

f.CanSet()                            // true
reflect.ValueOf(f.Interface()).CanSet() // false

Interface() 没有保留这个结构体字段可回写原对象的访问方式。把字段地址传下去,才能继续修改同一个对象:

reflect.ValueOf(f.Addr().Interface()).Elem().CanSet() // true

Interface() 返回的是 any。它保留动态类型,但不会让接收变量的静态类型自动变成 HttpConfig 或 *HttpConfig。如果普通代码需要按具体类型使用它,仍然需要类型断言。

新建对象再赋回,也能解决

本文参考的视频使用了另一种结构体递归写法:

fobj := reflect.New(tf.Type).Interface()
ConvertMapToStruct(val.(map[string]any), fobj)
f.Set(reflect.ValueOf(fobj).Elem())

reflect.New(tf.Type) 根据字段类型创建一个新对象。假设字段类型是 HttpConfig,创建结果表示的就是一个 *HttpConfig。

递归函数先填充新对象,再通过最后一行把它的值赋回原字段。在本文的例子中,原字段已经可以取地址,用 f.Addr().Interface() 就能直接填充它。

如果字段本身就是指针

如果把 Http 字段的类型改为 *HttpConfig,当字段非 nil 时,f.Interface() 取出来的已经是指针,可以直接交给下一层函数。这里不用再调用 Addr(),否则取得的会是指向该指针字段的 **HttpConfig。

它的 Kind() 是 reflect.Ptr,不会进入 reflect.Struct 分支。字段指向的类型则可以通过 tf.Type.Elem() 查询,在这里得到 HttpConfig,其 Kind() 才是 reflect.Struct。

上面的精简实现没有加入指针字段分支。如果要支持这种指向结构体的字段,可以在 switch 中补上下面的分支。这里仍假设对应的输入是 map[string]any,并且目标字段可设置:

case reflect.Ptr:
	if f.IsNil() {
		f.Set(reflect.New(f.Type().Elem()))
	}
	ConvertMapToStruct(val.(map[string]any), f.Interface())

f.Type().Elem() 取得 HttpConfig 类型,reflect.New 再创建一个指向新对象的 *HttpConfig。目标字段也需要 *HttpConfig,所以这一行 Set 不需要再调用 Elem()。

这与函数入口处不同:初始化字段时,需要把指针放进指针字段;递归函数进入后,要访问指针指向的结构体,才调用 ValueOf(obj).Elem()。这个分支只用于当前讨论的结构体指针,不能直接当作所有指针类型的通用处理。

再练一次:填充带有 *Log 字段的配置

下面是另一份独立程序,可以单独保存为 practice.go 运行。它在前面精简实现的基础上,加上结构体指针字段的初始化,保留 v.NumField() 和 switch tt.Type.Kind() 的写法。不要把两份程序的 main 函数放在同一个文件中。

package main

import (
	"fmt"
	"reflect"
)

type Conf struct {
	Port int
	Host string
	Log  *Log
}

type Log struct {
	Path string
}

func mapToC(a map[string]any, b any) {
	t := reflect.TypeOf(b).Elem()
	v := reflect.ValueOf(b).Elem()

	for i := 0; i < v.NumField(); i++ {
		tt := t.Field(i)
		vv := v.Field(i)

		val, ok := a[tt.Name]
		if !ok {
			continue
		}

		switch tt.Type.Kind() {
		case reflect.Struct:
			mapToC(val.(map[string]any), vv.Addr().Interface())

		case reflect.Ptr:
			if vv.IsNil() {
				vv.Set(reflect.New(tt.Type.Elem()))
			}
			mapToC(val.(map[string]any), vv.Interface())

		default:
			vv.Set(reflect.ValueOf(val))
		}
	}
}

func main() {
	a := map[string]any{
		"Port": 8080,
		"Host": "127.0.0.1",
		"Log": map[string]any{
			"Path": "/log.log",
		},
	}

	c := &Conf{}
	mapToC(a, c)
	fmt.Println(c.Port, c.Host, c.Log.Path)
}

运行 go run practice.go,输出:

8080 127.0.0.1 /log.log

这份练习第一轮的 i 为 0:tt 描述的是 Port int,tt.Name 为 "Port",vv 表示当前对象的 c.Port。a[tt.Name] 用名字查到 8080,再由 vv.Set 把这个整数写进字段。

处理到 Log 时,tt.Type 是 *Log,tt.Type.Kind() 是 reflect.Ptr,所以会进入指针分支。初始的 c.Log 为 nil,需要先创建对象:tt.Type.Elem() 得到 Log 类型,reflect.New 创建一个 *Log,再由 vv.Set 把它放进字段。

递归时,vv.Interface() 返回的接口值装着 *Log。下一层函数取得的 t 描述 Log 类型,v 表示原来的 Log 对象,随后就能给 Path 赋值。

如果改成只打印 fmt.Println(*c),本例中的 Log 会显示为指针地址。可以单独打印 c.Log.Path 检查内容,不要把地址输出当作填充失败。

对照报错检查代码

写法或输入 在本文示例中的后果 调整方式
t := reflect.TypeOf(b),但 v 已经调用 Elem() t.Field(i) 报指针不是结构体 对 t 也调用 Elem(),或使用 v.Type()
只有 reflect.Struct 和 default 分支 *Log 进入 default,map 不能直接赋给 *Log 增加结构体指针分支,必要时先初始化
给递归函数传 vv.Addr() 入参装着 reflect.Value,下一层 Elem() 作用在了结构体上 传 vv.Addr().Interface()
忽略 map 查询的 ok 缺少字段时可能拿到 nil,后续赋值或断言会 panic 没有对应键时跳过该字段

这份练习仍假设指针字段指向结构体、嵌套输入为 map,而且普通字段类型匹配。Port 对应的 8080 是 int;若实际输入为 "8080",需要先用 strconv.Atoi 等方式解析并处理错误。Elem() 不会把字符串转成整数。

从教学代码到实际使用

这份函数省略了输入校验,不能直接当作通用配置解析器使用。

首先,修改字段要满足可设置的条件。可寻址不等于一定可设置:未导出字段即使可以取地址,也不能通过普通的反射赋值操作修改。实际代码应检查 CanSet()。

类型也必须匹配。Set 要求来源值的类型能够赋给目标类型,不会自动把 float64(8080) 转成 int。使用标准库把 JSON 解码到 map[string]any 时,数字默认是 float64,因此不能直接套用本文的 Port 赋值。示例中的 8080 是在 Go 代码里定义的 int。

入口和递归参数同样需要检查。传入 nil、非指针、指向非结构体的指针,或者让 Http 对应的值不是 map[string]any,都可能触发 panic。实际使用时应返回明确的错误。

当前函数按字段名精确匹配,所以 "Mode" 能找到 Mode,"mode" 不行。要支持 json:"mode" 这样的名称,需要从字段描述信息中读取 tag,再决定使用哪个键。

这个例子与配置绑定、JSON 序列化、ORM 中常见的反射用法有相似之处:库接收业务代码传来的值,通过类型信息了解结构,再处理具体字段。对于只有一种确定结构的业务逻辑,直接赋值通常更容易阅读;需要让同一段代码适配多种结构体时,再考虑使用反射。

示例来源

本文的配置转换例子参考了老周聊 golang 的视频:京东 Go 一面:反射原理以及反射的应用场景。文中使用了精简后的结构,并将嵌套结构体的处理写成直接取地址递归。