小試牛刀:Go 反射幫我把 Excel 轉成 Struct

2022-07-31 15:00:47

背景

起因於最近的一項工作:我們會定義一些關鍵指標來衡量當前系統的健康狀態,然後設定對應的報警規則來進行監控報警。但是當前的報警規則會產生大量的誤報,需要進行優化。我所負責的是將一些和使用者行為指標相關的報警規則拆封從日間和夜間兩套規則(因為在夜間使用者的使用量減少,報警的閾值是可以調高的)。

這實際上就是一個體力活兒,把原來的報警規則再複製一份,然後改一下閾值。但我算了一個,原來大概有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 準確的賦值給結構體欄位。

那如何在執行時,取到結構體標籤的呢?實際上是接助了 Go 的反射能力。

反射

眾所周知,Go 一門強型別語言,這在保證程式執行安全的同時,也為程式編碼增加了也許不便。而反射機制,便提供了一種能力:在編譯時不知道型別的情況下,可更新變數、在執行時檢視值、呼叫方法以及直接對它們的佈局進行操作。

Go 將反射negligible都封裝在了reflect包,包內有兩對非常重要的函數和型別,兩個函數分別是:

  • reflect.TypeOf函數接收任意的 interface{} 引數,並且把介面的動態型別以refelct.Type的形式返回
  • reflect.VauleOf函數接收任意的 interface{} 引數,並且把介面的動態值以refelct.Type的形式返回

兩個型別是reflect.Typereflect.Value,它們與函數是一一對應的關係:

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{}物件之間的自由切換:

  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 檔案內容
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操作。