小试牛刀:Go 反射帮我把 Excel 转成 Struct

image

背景

起因于最近的一项工作:我们会定义一些关键指标来衡量当前系统的健康状态,然后配置对应的报警规则来进行监控报警。但是当前的报警规则会产生大量的误报,需要进行优化。我所负责的是将一些和用户行为指标相关的报警规则拆封从日间和夜间两套规则(因为在夜间用户的使用量减少,报警的阈值是可以调高的)。

这实际上就是一个体力活儿,把原来的报警规则再复制一份,然后改一下阈值。但我算了一个,原来大概有100多个报警规则,这还是一个不小的力气活儿啊!万幸的是,我们的报警平台是支持通过 json 文件的方式导入规则的,我可以使用PythonGo写一个简单的程序(最开始是用 Python 写的,但想提高一下 Go 的熟练度,又用 Go 写了一版):使用代码生成出可被报警平台解析的 json 文件。

为了保持规则的可维护性,我决定把规则的核心参数(比如:指标参数、时间、阈值等)放在在线 Excel 进行保存,编辑完后,下载到本地,通过一个简单的程序生成 json 文件,导入到报警平台。

解析 Excel 文件是通过github.com/xuri/excelize/v2这个库在做的,但它只能把每行解析成string类型的切片,还需要我去一个一个转成我定义的结构体对应字段的类型,然后再去赋值。我想到:如果能像json.Unmarshal一样,可以自动进行类型转换,并且复值给结构体中的对应字段,那就好了!

json.Unmarshal 是怎么做到的?

type Stu struct {
   Name string `json:"name"`
   Age int32   `json:"age"`
}

func main() {
   data := `{"name": "Zioyi", "age": 1}`
   s1 := Stu{}
   _ = json.Unmarshal([]byte(data), &s1)
   fmt.Printf("%+v\n", s1)
}


$ > go run main.go
{Name:Zioyi Age:1}

为什么它可以把Zioyi赋值给Name字段,把1赋值给age字段?

可以注意到,在定义结构体 Stu 时,在每个字段的类型后面,有一段被反引号包含的内容:json:"name"、 json:"Age"。这实际上 Go 的一个特性:结构体标签,通过它将被结构体字段和 json 数据中的 key 进行了绑定,使得再调用 json.Unmarshal 时,可以把 json 数据中的 value 准确的赋值给结构体字段。
image.png

那如何在运行时,取到结构体标签的呢?实际上是接助了 Go 的反射能力。

反射

众所周知,Go 一门强类型语言,这在保证程序运行安全的同时,也为程序编码增加了也许不便。而反射机制,便提供了一种能力:在编译时不知道类型的情况下,可更新变量、在运行时查看值、调用方法以及直接对它们的布局进行操作。

Go 将反射negligible都封装在了reflect包,包内有两对非常重要的函数和类型,两个函数分别是:

  • reflect.TypeOf函数接收任意的 interface{} 参数,并且把接口的动态类型以refelct.Type的形式返回
  • reflect.VauleOf函数接收任意的 interface{} 参数,并且把接口的动态值以refelct.Type的形式返回

两个类型是reflect.Typereflect.Value,它们与函数是一一对应的关系:

image.png

reflect.Type是一个接口,通过调用reflect.TypeOf函数可以后去任意变量的类型。这个接口绑定了很多有用的方法:MethodByName可以获取当前类型对应方法的引用、Implements 可以判断当前类型是否实现了某个接口、Field可以根据下标取到结构体字段的应用等。

type Type interface {
    Align() int
    FieldAlign() int
    Method(int) Method
    MethodByName(string) (Method, bool)
    NumMethod() int
    Field(i int) StructField
    FieldByIndex(index []int) StructField
    ...
    Implements(u Type) bool
    ...
}

reflect.Value是一个结构体

type Value struct {
   typ *rtype
   ptr unsafe.Pointer
   flag
}

但它没有可导出的字段,需要通过方法来访问,有两个比较重要的方法:

  • func (v Value) Elem() Value {} 返回制作指向的具体数据
  • func (v Value) SetT(x T) {} 可以实现更新变量

三大法则

  1. interface{}变量反射出反射对象

    reflect.TypeOf的入参类型是interface{},所以当我们调用时,会把原来的强类型对象变成interface{}类型的拷贝(Go 中函数传参是值传递)传到函数内部。所以说,使用reflect.TypeOfreflect.ValueOf能够获取 Go 语言中的变量对应的反射对象。一旦获取了反射对象,我们就能得到跟当前类型相关数据和操作,并可以使用这些运行时获取的结构执行方法。

  2. 从反射对象对象可以获取interface{} 变量

    reflect.Value.Interfac方法可以帮助我们将一个反射对象(reflect.Value)变回interface{}对象。也就是说,我们通reflec包可以实现反射对象interface{}对象之间的自由切换:
    image.png

  3. 要修改反射对象,其值必须可设置

    这一点很重要,如果我们想更新一个reflect.Value,那必须是可以被更新的。含义是,如果当我们调用reflect.ValueOf直接传入了变量,由于 Go 语言的函数调用都是传值的,所以我们得到的反射对象跟最开始的变量没有任何关系,那么直接修改反射对象无法改变原始变量。所以我们应该传入的是原来变量的地址,然后通过refect.Value.Elem取到指针指向的变量再去修改。

    func badCase() {
        i := 1
        v := reflect.ValueOf(i)
        v.SetInt(10)  // panic: reflect: reflect.flag.mustBeAssignable using unaddressable value
        fmt.Println(i)
    }
    
     func goodCase() {
        i := 1
        v := reflect.ValueOf(&i)
        v.Elem().SetInt(10)
        fmt.Println(i)  // 10
    }
    

有用的方法

  1. 获取到结构体标签
type Stu struct {
   Name string `json:"name"`
   Age int32   `json:"age"`
}

上面提到过,reflect.Type接口中有一个方法Field,他可以通过下标返回结构体中的第X个字段(类型为FieldStruct

func main () {
    u := reflect.TypeOf(Stu{})
    f := u.Field(0)
    fmt.Printf("%+v\n", f)
    v, ok := f.Tag.Lookup("json")
    fmt.Printf("tag value is %s, ok is %t\n", v, ok)
}

$ > go run main.go
{Name:Name PkgPath: Type:string Tag:json:"name" Offset:0 Index:[0] Anonymous:false}
tag value is name, ok is true

根据打印出的内容可以看到,StructField结构体中的Tag字段就保存了标签信息,并且非常人性化地提供了Lookup方法找到我们想要 tag 值。

而且,reflect.Type接口中有一个方法NumField可以获取到结构体的字段总数,这样我们就可以结合起来去遍历了:

func main () {
   u := reflect.TypeOf(Stu{})
   num := u.NumField()
   fmt.Printf("Struct Str total field count:%+v\n", num)

   for i := 0; i < num; i++ {
      f := u.Field(i)
      v, _ := f.Tag.Lookup("json")
      fmt.Printf("field %s, tag value is %s\n", f.Name, v)
   }
}

$ > go run main.go 
Struct Str total field count:2
field Name, tag value is name
field Age, tag value is age
  1. 给结构体中字段赋值

正常地结构体字段赋值,我们都是通过字面量的方式去做:

s := Stu{}
s.Name = "Zioyi"

reflect.Value.Set方法提供给了我们去更新反射对象的能力,我们可以这样做:

func main () {
   s1 := Stu{}
   u := reflect.ValueOf(&s1)           // 获取反射对象
   fv := u.Elem().FieldByName("Name")  // 通过字段名获取字段的反射对象
   fv.SetString("Zioyi")               // 等价于 s1.Name = "Zioyi"
   fv = u.Elem().Field(1)              // 通过字段下标获取字段的反射对象
   fv.SetInt(1)                        // 等价于 s1.Age = 1
   fmt.Printf("%+v\n", s1)
}

$ > go run main.go
{Name:Zioyi Age:1}

Excel to Struct

通过上面的介绍,我们已经掌握了reflect的基本用法,我们已经可以是想一个xslx版的Unmarshal了。

  1. 构建结构体标签与字段的映射

我们就把xlsx作为标签的 key

type Stu struct {
   Name string `xlsx:"name"`
   Age int32   `xlsx:"age"`
}

然后通过上面提到的Field方法来提取结构体Stu的字段和标签

func initTag2FieldIdx(v interface{}, tagKey string) map[string]int {
   u := reflect.TypeOf(v)
   numField := u.NumField()
   tag2fieldIndex := map[string]int{}
   for i := 0; i < numField; i++ {
      f := u.Field(i)
      tagValue, ok := f.Tag.Lookup(tagKey)
      if ok {
         tag2fieldIndex[tagValue] = i
      } else {
         continue
      }
   }
   return tag2fieldIndex
}

func main () {
   initTag2FieldIdx := initTag2FieldIdx(Stu{}, "xlsx")
   fmt.Printf("%+v\n", initTag2FieldIdx)
}

$ > go run main.go
map[age:1 name:0]
  1. 读取 xslx 文件内容
    image.png
func getRows() [][]string {
   file, err := excelize.OpenFile("stu.xlsx")
   if err != nil {
      panic(err)
   }
   defer file.Close()

   rows, err := file.GetRows("Stu", excelize.Options{})
   if err != nil {
      panic(err)
   }

   return rows
}

func main () {
   rows := getRows()
   for _, row := range rows {
      fmt.Printf("%+v\n", row)
   }
}

$ > go run main.go
[name age]
[Zioyi 1]
[Bob 12]
  1. 将 xlsx 文件内容转成 Stu 结构体

我们默认 xlxs 的第一行描述了每列对应 Stu 的字段,我们可以通过上面的提到的Vaule.Set方法进行赋值

func rowsToStus (rows [][] string, tag2fieldIndex map[string]int) []*Stu {
   var data []*Stu
   // 默认第一行对应tag
   head := rows[0]
   for _, row := range rows[1:] {
      stu := &Stu{}
      rv := reflect.ValueOf(stu).Elem()
      for i := 0; i < len(row); i++ {
         colCell := row[i]
         // 通过 tag 取到结构体字段下标
         fieldIndex, ok := tag2fieldIndex[head[i]]
         if !ok {
            continue
         }

         colCell = strings.Trim(colCell, " ")
         // 通过字段下标找到字段放射对象
         v := rv.Field(fieldIndex)
         // 根据字段的类型,选择适合的赋值方法
         switch v.Kind() {
         case reflect.String:
            value := colCell
            v.SetString(value)
         case reflect.Int64, reflect.Int32:
            value, err := strconv.Atoi(colCell)
            if err != nil {
               panic(err)
            }
            v.SetInt(int64(value))
         case reflect.Float64:
            value, err := strconv.ParseFloat(colCell, 64)
            if err != nil {
               panic(err)
            }
            v.SetFloat(value)
         }
      }

      data = append(data, stu)
   }
   return data
}

func main() {
   initTag2FieldIdx := initTag2FieldIdx(Stu{}, "xlsx")
   rows := getRows()
   stus := rowsToStus(rows, initTag2FieldIdx)
   for _, s := range stus {
      fmt.Printf("%+v\n",s)
   }
}

$ > go run main.go 
&{Name:Zioyi Age:1}
&{Name:Bob Age:12}

到这里,我们就完成了xslx版本的Unmarshal操作。

Tags: