golang的反射使用,将结构体转换为map
在日常的编码中,在某些场景下,我们会想要通过读取结构体的某些标记来达成我们的目的,比如在模板解析时,将传入的变量按照其字段的名称解析到模板里;在处理 HTTP 请求时,根据请求体的字段判断字段是否必须以处理 400 阻断的效果。
在其余语言中,我们能够很轻松的找到这种场景的解决方案,比如 Java 和 .Net 的注解系统。那么在 golang 中,是否也能做到类似的实现效果呢?答案是 tag 标注,我们可以在一些标准库中找到它的使用身影,比如:
type request struct {
username string json:"username"
age int json:"age"
}
func main() {
req := &request{username: "sunist", age: 18}
bytes, _ := json.Marshal(req)
fmt.Println(string(bytes)) // {"username":"sunist","age":18}
}
在上面的示例中, encoding/json
包就通过了后面的 json:"username"
这一标注,获取到了这个字段在 json 序列化/反序列化时所使用的字段名称。
那么我们要如何使用,这个问题可以分为两个子问题:
我们要如何获取标注
我们获取到标注后能干什么
获取标注的原始值
在 reflect.StructField
结构中,有一个名称为 Tag
的 reflect.StructTag
类型的字段,可以通过这个字段的 Get()
方法来获取某个结构体字段中的标注的值:
package main_test
import (
"fmt"
"reflect"
"testing"
)
func TestGetStructFieldTag(t *testing.T) {
// 定义一个结构体,然后打上自定义标注
type myStruct struct {
Field1 string `custom_tag:"field1"`
Field2 string `custom_tag:"you can do what the f__ you want"`
}
s := myStruct{}
value := reflect.ValueOf(s)
// 获取结构体各个字段的标签
for i := 0; i < value.NumField(); i++ {
field := value.Type().Field(i)
tag := field.Tag.Get("custom_tag")
fmt.Println(tag)
}
}
输出结果如下:
=== RUN TestGetStructFieldTag
field1
you can do what the f__ you want
解析并处理标注
因为 golang 本身不对标注的格式做任何限制和要求,开发者可以随心所欲地进行标注,所以我们一般会通过文档来规范标注的使用,如 github.com/go-gorm/gorm
包就是一个常见的使用示范,它关于标注的文档可以参阅 https://gorm.io/docs/models.html 。
那么,我们可以参考常见的使用实践如 xorm/gorm/json/yaml/xml 等总结出它们的常用规范:
标注内不同配置之间用英文半角逗号隔开
使用
${key}:${value}
这种格式来标明需要有值的配置尽量使用英文、数字、连接线和下划线来完成配置
基于此,我们就可以来实现一个配置解析器,下面我们来简单地实现一个 json 的标注配置解析器,它的格式是 json:"key,(optional)omitempty"
,我们只需要:
使用逗号拆开字符串
遍历数组判断
omitempty
数组的另一个元素就是 key
为了程序健壮性,能拆更多的话使用第一个元素作为 key
代码如下:
package main_test
import (
"fmt"
"reflect"
"testing"
)
// jsonConfig 定义的json配置结构
type jsonConfig struct {
OmitEmpty bool
Alias string
}
// parse 将标注内容解析为配置结构
func parse(c string) jsonConfig {
config := jsonConfig{}
parts := strings.Split(c, ",")
for _, part := range parts {
if part == "omitempty" {
config.OmitEmpty = true
continue
}
// 第一个匹配到的内容作为 key
if config.Alias == "" {
config.Alias = part
}
}
return config
}
func TestParseJsonTag(t *testing.T) {
type myStruct struct {
Field1 string `json:"field_1"`
Field2 string `json:"field_2,omitempty"`
Field3 string `json:"field_3,omitempty,field_4"`
}
s := myStruct{}
value := reflect.ValueOf(s)
for i := 0; i < value.NumField(); i++ {
field := value.Type().Field(i)
tag := field.Tag.Get("json")
config := parse(tag)
fmt.Println(config)
}
}
输出结果如下:
=== RUN TestParseJsonTag
{false field_1}
{true field_2}
{true field_3}
然后,我们就要思考,获取到了这个标注的配置后,我们能做什么事情。
将结构体转换为 map 用于模板匹配
在某些场景下,比如打日志和进行一些模板匹配的场景,我们可能期望传入一个 map[string]string
或 map[string]any
这样的字段,但是我们的逻辑里持有的是一个结构体,我们这个时候就要手动将结构体转换为 map[string]xxx
传入,比如这样:
func Function(ctx context.Context) {
type myStruct struct {
Name string
Age int
}
logEntry := &myStruct{
Name: "sunist",
Age: 18,
}
mylog.Log(ctx, "this is a log message", mylog.FromMap(map[string]any{
"name": logEntry.Name,
"age": logEntry.Age,
}))
}
这样的场景少数情况下还好,但如果一个结构体有动辄十几个几十个字段,那么做这样的转换对我们将是巨大的身心负担,这种场景下我们就可以使用反射,来获取对应字段的转换配置,然后自动完成转换过程。
在 golang 中,我们使用 reflect.ValueOf()
来获取一个变量的反射值,然后对获取到的 reflect.Value
对象进行相关的操作,就可以获取到这个变量的更多信息了:
可以通过
(reflect.Value).Kind()
来获取这个变量的类型可以通过
(reflect.Value).IsZero()
来获取这个变量的值是否为空值如果变量类型是指针的话,即
(reflect.Value).Kind() == reflect.Ptr
,那么可以通过(reflect.Value).Elem()
来获取指针指向的变量,结果依然是一个reflect.Value
类型的变量如果变量类型是结构体的话,即
(reflect.Value).Kind() == reflect.Struct
,那么可以通过(reflect.Value).NumField()
来获取这个结构体的字段数量,并通过(reflect.Value).Field(i)
来遍历结构体的字段的reflect.Value
如果变量类型是结构体的话,我们可以通过上文所述的
(reflect.Value).Type()
来获取结构体的类型信息,如标注等,同样可以配合(reflect.Value).Type().Field(i)
遍历结构体字段的reflect.StructField
然后就让我们开始将结构体转换为 map 的实践吧,我们先放一个想要的效果:
type StructA struct {
Name string
Age int
}
type StructB struct {
Class int
Grade int
Monitor StructA
}
func Function() {
class := &StructB{
Class: 1,
Grade: 3,
Monitor: StructA{
Name: "sunist",
Age: 18,
},
}
mp := myConvert.Convert(class)
// mp: map[Class:1 Grade:3 Monitor.Age:18 Monitor.Name:sunist]
}
我们将嵌套结构体铺平为一级结构体,当然这是个简单的实现,我们就支持基础类型和结构体类型就行了。
同时,我们来约定一下标注配置,让铺平解析的时候能进行更多的功能与操作:
标注的标记键设置为
cc
即ConvertConfig
标注中有
-
表示这个字段无需转换标注中的
key:${key_name}
用于表示这个字段转换后的 key 名称标注中的
omitempty
用于表示这个字段在为零值时可以被忽略标注中的
default:${defalut_value}
用于表示这个字段在为零值时会被赋予的默认值
我们先实现这个标注的定义和解析过程:
const (
convertConfigTagName = "cc"
convertConfigKeyIgnore = "-"
convertConfigKeyTagName = "key:"
convertConfigOmitTagName = "omitempty"
convertConfigDefaultTagName = "default:"
)
type convertConfig struct {
ignore bool
key string
omitEmpty bool
defaultValue string
}
func getConvertConfig(tag string) *convertConfig {
// 没有配置的时候,或者指定不解析的时候,跳过对应解析
if tag == "" || tag == convertConfigKeyIgnore {
return &convertConfig{ignore: true}
}
config := &convertConfig{}
tags := strings.Split(tag, ",")
// 遍历拆分出来的所有tag
for _, tag := range tags {
switch {
// omitempty 标签
case tag == convertConfigOmitTagName:
config.omitEmpty = true
// key: 标签
case strings.HasPrefix(tag, convertConfigKeyTagName):
config.key = strings.TrimPrefix(tag, convertConfigKeyTagName)
// default: 标签
case strings.HasPrefix(tag, convertConfigDefaultTagName):
config.defaultValue = strings.TrimPrefix(tag, convertConfigDefaultTagName)
}
}
return config
}
然后我们就可以开始转换了:
const (
convertConfigTagName = "cc"
convertConfigKeyIgnore = "-"
convertConfigKeyTagName = "key:"
convertConfigOmitTagName = "omitempty"
convertConfigDefaultTagName = "default:"
)
// convertFieldValueToString 函数根据字段的类型将其值转换为字符串
func convertFieldValueToString(fieldValue reflect.Value) (string, bool) {
switch fieldValue.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(fieldValue.Int(), 10), true
case reflect.String:
return fieldValue.String(), true
case reflect.Bool:
return strconv.FormatBool(fieldValue.Bool()), true
case reflect.Float32, reflect.Float64:
return strconv.FormatFloat(fieldValue.Float(), 'f', -1, 64), true
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return strconv.FormatUint(fieldValue.Uint(), 10), true
default:
return "", false
}
}
// structToMap 将结构体转换为 map,收一个结构体的 reflect.Value
func structToMap(prefix string, value reflect.Value, m map[string]string) {
// 如果是指针类型,则获取其指向的值
for value.Kind() == reflect.Ptr {
value = value.Elem()
}
// 目前只处理结构体类型
if value.Kind() == reflect.Struct {
// 遍历结构体的所有字段
for i := 0; i < value.NumField(); i++ {
fieldValue := value.Field(i)
fieldType := value.Type().Field(i)
// 获取字段的转换配置
cc := getconvertConfig(fieldType.Tag.Get(convertConfigTagName))
// 如果字段被忽略,则跳过
if cc.ignore {
continue
}
// 如果未指定字段的名称,则使用字段名称
if cc.key == "" {
cc.key = fieldType.Name
}
// 铺平结构体字段
fieldName := cc.key
if prefix != "" {
fieldName = prefix + "." + fieldName
}
// 如果字段是指针类型,则获取其指向的值
for fieldValue.Kind() == reflect.Pointer {
fieldValue = fieldValue.Elem()
}
// 如果字段是结构体,则递归调用structToMap,将其子字段添加到map中
if fieldValue.Kind() == reflect.Struct {
structToMap(fieldName, fieldValue, m)
continue
}
// 根据字段的类型进行相应的处理,将字段值转换为字符串
convertedValue, converted := convertFieldValueToString(fieldValue)
// 对于不支持的类型,跳过
if !converted {
continue
}
// 处理omitempty标签
if cc.omitEmpty && fieldValue.IsZero() {
continue
}
// 如果转换后的值为空,并且有默认值,则使用默认值
if fieldValue.IsZero() && cc.defaultValue != "" {
convertedValue = cc.defaultValue
}
// 将字段名和值添加到map中
m[fieldName] = convertedValue
}
}
}
这篇博客所讲述的内容已在项目 github.com/alitoh-center/infrastructure 中使用,可以在 https://github.com/alioth-center/infrastructure/blob/main/utils/values/reflect.go 查看对应的应用