Golang进阶: 反射的使用,将结构体转换为map

2024 年 3 月 1 日 星期五(已编辑)
13
AI 生成的摘要
此内容由 AI 生成
本文介绍了在Golang中通过结构体标签(tag)实现类似Java和.Net注解功能的方法,重点讲解了反射结构获取、解析反射结构以及将结构体转换为map的实践。文章详细说明了如何使用reflect包操作结构体字段和标签,并提供了具体的代码示例和标注规范。
这篇文章上次修改于 2025 年 6 月 25 日 星期三,可能部分内容已经不适用,如有疑问可询问作者。

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 结构中,有一个名称为 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 包就是一个常见的使用示范,它关于标注的文档可以参阅 Gorm: Declaring Models

我们可以参考常见的使用实践如 xorm/gorm/json/yaml/xml 等总结出它们的常用规范:

  1. 标注内不同配置之间用英文半角逗号或分号隔开
  2. 使用 key:"value" 这种格式来标明需要有值的配置
  3. 尽量使用英文、数字、连接线和下划线来完成配置

基于此,我们就可以来实现一个配置解析器,下面我们来简单地实现一个 json 的标注配置解析器,它的格式是 json:"key,omitempty" ,其中,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 output:
    // 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  
       }  
    }  
}

通过以上案例,希望读者能够解 Golang 反射的大多数使用场景和具体实现!

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...