使用反射将结构体转换为map

使用反射将结构体转换为map

本篇博客探讨了如何在Go语言中使用反射将结构体转换为map,特别是利用结构体标签(tags)来实现。博客详细介绍了如何通过reflect包获取结构体字段的标签并解析它们,以及如何使用这些标签来定制化地将结构体字段映射到map的键值对。此外,作者还提供了如何处理嵌套结构体和设置默认值的高级技巧。最后,博客提供了在实际项目中的应用链接,供读者参考。

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 序列化/反序列化时所使用的字段名称。

那么我们要如何使用,这个问题可以分为两个子问题:

  1. 我们要如何获取标注

  2. 我们获取到标注后能干什么

获取标注的原始值

reflect.StructField 结构中,有一个名称为 Tagreflect.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 等总结出它们的常用规范:

  1. 标注内不同配置之间用英文半角逗号隔开

  2. 使用 ${key}:${value} 这种格式来标明需要有值的配置

  3. 尽量使用英文、数字、连接线和下划线来完成配置

基于此,我们就可以来实现一个配置解析器,下面我们来简单地实现一个 json 的标注配置解析器,它的格式是 json:"key,(optional)omitempty" ,我们只需要:

  1. 使用逗号拆开字符串

  2. 遍历数组判断 omitempty

  3. 数组的另一个元素就是 key

  4. 为了程序健壮性,能拆更多的话使用第一个元素作为 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]stringmap[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]  
}

我们将嵌套结构体铺平为一级结构体,当然这是个简单的实现,我们就支持基础类型和结构体类型就行了。

同时,我们来约定一下标注配置,让铺平解析的时候能进行更多的功能与操作:

  • 标注的标记键设置为 ccConvertConfig

  • 标注中有 - 表示这个字段无需转换

  • 标注中的 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 查看对应的应用

Comment