Golang实践录:利用反射reflect构建通用打印结构体接口

1,414 阅读4分钟

这是我参与11月更文挑战的第13天,活动详情查看:2021最后一次更文挑战

本文针对 Golang 的结构体字段的打印进行一些研究。其中涉及到一些反射的知识。实际上本文是基于前面积累的反射进行综合使用的一个示例,也在工作中使用着。

问题提出

总结一些实践情况,结构体字段值的输出还是比较常见的,至少笔者目前常用。比如输出某些数据表的数据(代码中会转换为结构体),对比不同版本数据表的数据,对比某些不同版本但格式相同的 json 文件,等。为了优化代码,减少开发维护工作量,需寻找一种高效的方法,打印结构体。初步需求如下:

  • 格式化,目前需迎合 markdown 表格的格式。
  • 接口可通用于数组、map等结构,原则上直接传递某个变量,即可自行输出格式化后的所需内容。
  • 输出方式多样化,如输出到终端或文件。

使用 markdown 是因为笔者需要将输出的数据表内容通过 vuepress 发布到内部 web 服务器上,以便随时查阅。

测试数据

本文使用的测试数据如下:

type TestObj struct {
    Name  string
    Value uint64
    Size  int32
    Guard float32
}
​
var objects []TestObj
​
    object1 := TestObj{
        Name:  "Jim | Kent",
        Value: 128,
        Size:  256,
        Guard: 56.4,
    }
    object2 := TestObj{
        Name:  "James1",
        Value: 128,
        Size:  259,
        Guard: 56.4,
    }
​
    objects = append(objects, object1)
    objects = append(objects, object2)
    
    var myMap map[string]TestObj
    myMap = make(map[string]TestObj)
    myMap["obj3"] = TestObj{"Jim Kent", 103, 201, 102.56}
    myMap["obj1"] = TestObj{"Kent", 101, 201, 102.56}
    myMap["obj2"] = TestObj{"Kent", 102, 201, 102.56}

效果

对于可识别渲染 markdown 的平台来说,输出的如下结果:

print by line - slice default  
total: 2  
​
| Name          | Value | Size | Guard |
| ------------- | ----- | ---- | ----- |
| Jim <br> Kent | 128   | 256  | 56.4  |
| James1        | 128   | 259  | 56.4  |

就能正常显示表格形式。如下:

print by line - slice default total: 2

NameValueSizeGuard
Jim Kent12825656.4
James112825956.4

简单版本

遍历结构体数据,并打印之:

    for a, b := range objects {
        fmt.Printf("%v %v\n", a, b)
        // fmt.Printf("%v %+v\n", a, b)
    }

如果需要格式化,需显式给出结构体字段和格式化形式。如下:

    for a, b := range objects {
        fmt.Printf("%d: %v | %v | %v | %v\n", a, b.Name, b.Value, b.Size, b.Guard)
    }

以上结果分别如下:

0 {Jim | Kent 128 256 56.4}
1 {James1 128 259 56.4}
​
0: Jim | Kent | 128 | 256 | 56.4
1: James1 | 128 | 259 | 56.4

由于此版本非吾所用,因此只具大致形式。

可以看到,前者简单,不用理会结构体内容,直接使用%v即可打印,如需要输出结构体字段名,则用%+v。但其形式固定的,类似{xx xx xx}这样。后者使用竖线|将各字段隔开,需一一写出字段(当然也可忽略部分字段)。

reflect版本

代码如下:

func checkSkipNames(a string, b []string) bool {
    for _, item := range b {
        if item == a {
            return true
        }
    }
    return false
}
​
// 结构体的字段名称
func GetStructName(myref reflect.Value, names []string) (buffer string) {
    // 注:有可能传递string数组,此时没有“标题”一说,返回
    if myref.Type().Name() == "string" {
        return
    }
    for i := 0; i < myref.NumField(); i++ {
        if ok := checkSkipNames(myref.Type().Field(i).Name, names); ok {
            continue
        }
        buffer += fmt.Sprintf("| %v ", myref.Type().Field(i).Name)
    }
    buffer += fmt.Sprintf("|\n")
    for i := 0; i < myref.NumField(); i++ {
        if ok := checkSkipNames(myref.Type().Field(i).Name, names); ok {
            continue
        }
        buffer += fmt.Sprintf("| --- ")
    }
    buffer += fmt.Sprintf("|\n")
    return
}
​
// 将 | 替换为 <br>
func replaceI(text string) (ret string) {
    // 下面2种方法都可以
    // reg := regexp.MustCompile(`|`)
    // ret = reg.ReplaceAllString(text, `${1}<br/>`)
    ret = strings.Replace(text, "|", "<br>", -1)
    // fmt.Printf("!!! %q\n", ret)
    return ret
}
​
// 结构体的值
func GetStructValue(myref reflect.Value, names []string) (buffer string) {
    // 注:有可能传递string数组,此时没有“字段”一说,返回原本的内容
    if myref.Type().Name() == "string" {
        return myref.Interface().(string)
    }
​
    for i := 0; i < myref.NumField(); i++ {
        if ok := checkSkipNames(myref.Type().Field(i).Name, names); ok {
            continue
        }
        // 判断是否包含|,有则替换,其必须是string类型,其它保持原有的
        t := myref.Field(i).Type().Name()
        if t == "string" {
            var str string = myref.Field(i).Interface().(string)
            str = replaceI(str)
            buffer += fmt.Sprintf("| %v ", str)
        } else {
            buffer += fmt.Sprintf("| %v ", myref.Field(i).Interface())
        }
    }
    buffer += fmt.Sprintf("|\n")
​
    return
}
​
func PrintStructTable(data interface{}, title string, skipNames ...string) {
    var w io.Writer
    w = os.Stdout // set to stdout
    buffer, num := PrintStructTable2Buffer(data, title, skipNames...)
    fmt.Fprintf(w, "total: %v\n", num)
    fmt.Fprintf(w, "%v\n", buffer)
}
​
/*
功能:指定结构体data,其可为slice map 单独结构体
     指定自定义标题,为空则使用结构体字段
     指定忽略的字段名称(即结构体字段的变量)
     按结构体定义的顺序列出,如自定义标题,则必须保证一致。
*/
func PrintStructTable2Buffer(data interface{}, title string, skipNames ...string) (buffer string, num int) {
    buffer = ""
​
    t := reflect.TypeOf(data)
    v := reflect.ValueOf(data)
​
    var skipNamess []string
    for _, item := range skipNames {
        skipNamess = append(skipNamess, item)
    }
​
    // 打印结构体字段标志
    innertitle := false
    printHead := false
    if len(title) == 0 {
        innertitle = true
    }
​
    // 不同类型的,其索引方式不同,故一一判断使用
    switch t.Kind() {
    case reflect.Slice, reflect.Array:
        num = v.Len()
        if innertitle {
            buffer += GetStructName(v.Index(0), skipNamess)
        } else {
            buffer += fmt.Sprintln(title)
        }
        for i := 0; i < v.Len(); i++ {
            buffer += GetStructValue(v.Index(i), skipNamess)
        }
    case reflect.Map:
        num = v.Len()
        iter := v.MapRange()
        for iter.Next() {
            if !printHead {
                if innertitle {
                    buffer += GetStructName(iter.Value(), skipNamess)
                } else {
                    buffer += fmt.Sprintln(title)
                }
                printHead = true
            }
            buffer += GetStructValue(iter.Value(), skipNamess)
        }
    default:
        num = 1 // 单独结构体不能用Len,单独赋值
        if !printHead {
            if innertitle {
                buffer += GetStructName(v, skipNamess)
            } else {
                buffer += fmt.Sprintln(title)
            }
            printHead = true
        }
        buffer += GetStructValue(v, skipNamess)
    }
​
    return
}

上述代码提供的对外接口为PrintStructTable2BufferPrintStructTable,因为默认格式为markdown表格形式,故加上Table。前者输出到缓冲区的(可继续写到文件中),后者直接输出终端。真正实现的接口为PrintStructTable2Buffer,其提供了自定义标题,和忽略的字段参数,如果不指定标题,必须将title置为空,因为最后的参数是可变参数,只能有一个,如不写,则输出所有字段。

至于内部实现,因为需要根据用户输入忽略某些字段,因此定义checkSkipNames检查参数,利用GetStructName获取结构体名称,GetStructValue获取结构体的值。不管获取字段还是值,均使用传递的interface{},不需额外传递结构体本身。 注意,由于默认使用竖线分隔,如果字段值本身有竖线,则使用<br>替换——即让该字段的值换行。

测试代码如下:

    // 数组,默认形式
    fmt.Println("print by line - slice default")
    buf, num := PrintStructTable2Buffer(objects, "")
    fmt.Println("total:", num)
    fmt.Println(buf)
​
    // 数组,自定义标题 
    fmt.Println("print by line - slice")
    buf, num = PrintStructTable2Buffer(objects, "| Name | Value | Size | Guard |\n| --- | --- | --- | ++++ |")
    fmt.Println("total:", num)
    fmt.Println(buf)
    // 单个对象 
    fmt.Println("print by line - single object")
    buf, num = PrintStructTable2Buffer(object1, "| Name | Value  | Guard |\n| +++ | +++ | +++ |", "Size")
    fmt.Println("total:", num)
    fmt.Println(buf)
    // map
    fmt.Println("print by line - map")
    buf, num = PrintStructTable2Buffer(myMap, "aaa")
    fmt.Println(buf)
    

测试结果如下:

print by line - slice default
total: 2
| Name | Value | Size | Guard |
| --- | --- | --- | --- |
| Jim <br> Kent | 128 | 256 | 56.4 |
| James1 | 128 | 259 | 56.4 |
​
print by line - slice
total: 2
| Name | Value | Size | Guard |
| --- | --- | --- | ++++ |
| Jim <br> Kent | 128 | 256 | 56.4 |
| James1 | 128 | 259 | 56.4 |
​
print by line - single object
total: 1
| Name | Value  | Guard |
| +++ | +++ | +++ |
| Jim <br> Kent | 128 | 56.4 |
​
print by line - map
aaa
| Jim Kent | 103 | 201 | 102.56 |
| Kent | 101 | 201 | 102.56 |
| Kent | 102 | 201 | 102.56 |
​

观察结果,可达到预期目的。