小試牛刀: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: