golang导出excel| 青训营笔记

1,347 阅读8分钟

需求:导出excel

看到需求之后就找了找golang操作excel文件的库,选了一个看起来比较靠谱的 github.com/360EntSecGr…

一开始处理Excel的流程大概是:

获取需要保存到.xlsx文件的结构体切片-->遍历切片-->通过SetCellValue()给每一个单元格赋值-->SaveAs()储存-->返回文件路径。

func CreateFailExcel(fail []FailUser,name string)(string,error) {
    f := excelize.NewFile()
​
    for i,v:= range fail{
        f.SetCellValue("Sheet1",ID2index("A",i),v.StuNum)
        f.SetCellValue("Sheet1",ID2index("B",i),v.Name)
        f.SetCellValue("Sheet1",ID2index("C",i),v.Phone)
        f.SetCellValue("Sheet1",ID2index("D",i),v.Reason)
    }
    if err := f.SaveAs(filepath  + "fail" + name + ".xlsx"); err != nil {
        fmt.println(err.Error())
        return "", err
    }
    return filepath + name + ".xlsx" ,nil
}
​

这样写肉眼可见的不靠谱:

  1. 这次需要处理的结构体暂时只有4个字段,假如有n个字段的结构体需要处理,难道要写n行SetCellValue吗。

  2. 这样写 没 有 表 头,如果想要表头的话还需要在一开始给每这张表的A1-D1字段添加特定的值(比如f.SetCellValue("Sheet1","A1",“学号”)),又多了n行,(并且在设置表格值的之后,y轴要+1)

        f := excelize.NewFile()
        f.SetCellValue("Sheet1",ID2index("A",1),"学号")
        f.SetCellValue("Sheet1",ID2index("B",1),"姓名")
        f.SetCellValue("Sheet1",ID2index("C",1),"手机号")
        f.SetCellValue("Sheet1",ID2index("D",1),"失败原因")
    

于是便开始了第一轮封装:

Round-1

我们都知道封装可以隐藏细节,对这里而言,每一个SetCellValue都是可以被省略的细节,我们可以抽象出一个表类,这张表应该有表头,表名,表的内容

type ExcelHelper struct {
    Object interface{}      //传入的结构体模板
    TableName string        //表名
    TableHeader []string    //表头
    File *excelize.File     //进行操作的file
}

作为一张excel表格,名字和应该是在一开始就定好了的,因此需要一个InitTable函数用作初始化,内容是在后续添加的,因此ExcelHelper应该需要Insert方法,插入完成后需要储存,因此还要StoreFile方法。

确定了InitTable,Insert,StoreFile作为主要流程后,我们可以加一点点细节:

//初版的goxcel//写着玩还没加注释
package util
​
import (
    "github.com/360EntSecGroup-Skylar/excelize"
    "reflect"
    "strconv"
)
​
type ExcelHelper struct {
    Object interface{}
    TableName string
    TableHeader []string
    File *excelize.File
}
​
func InitTable(tableName string, v interface{})*ExcelHelper  {
    var e ExcelHelper
    e.TableName = tableName
    e.Object = v
    e.analyzeTableHeader().insertHeader()
    return &e
}
​
func (e *ExcelHelper) analyzeTableHeader() *ExcelHelper {
    obj := reflect.ValueOf(e.Object)
    if obj.Kind() == reflect.Ptr {
        obj = obj.Elem()
    }
    typ := reflect.TypeOf(e.Object)
    if typ.Kind() == reflect.Ptr {
        typ = typ.Elem()
    }
    Num := obj.NumField()
​
    for i := 0; i < Num; i++ {
        tag := typ.Field(i).Tag.Get("helper")
        e.TableHeader = append(e.TableHeader,tag)
    }
​
    return e
}
​
func (e *ExcelHelper) analyzeTableValue(v interface{})(field []string)   {
    obj := reflect.ValueOf(v)
    if obj.Kind() == reflect.Ptr {
        obj = obj.Elem()
    }
    typ := reflect.TypeOf(v)
    if typ.Kind() == reflect.Ptr {
        typ = typ.Elem()
    }
    Num := obj.NumField()
​
    for i := 0; i < Num; i++ {
        v := obj.Field(i)
        field = append(field,v.String())
    }
    return field
}
​
func (e *ExcelHelper) insertHeader()*ExcelHelper  {
    f := excelize.NewFile()
    for i,v := range e.TableHeader{
        f.SetCellValue("Sheet1",id2index(i,1),v)
    }
    e.File = f
    return e
}
​
func (e *ExcelHelper) Insert(index int,v interface{})*ExcelHelper  {
    value := e.analyzeTableValue(v)
    for i,j := range value{
        e.File.SetCellValue("Sheet1",id2index(i,index+2),j)
    }
    return e
}
​
func (e *ExcelHelper) StoreFile(filepath string)(string, error) {
    finalFile := filepath + e.TableName + ".xlsx"
    if err := e.File.SaveAs(finalFile); err != nil {
        println(err.Error())
        return "", err
    }
    return finalFile,nil
}
​
func index2Chara(i int)string  {
    if i >= 24{
        return "nil"
    }
    return string(rune(65+i))
}
​
func id2index(charaID int,i int)string  {
    s := strconv.Itoa(i)
    return index2Chara(charaID)+s
}

在初始化表部分,我们可以将

    f := excelize.NewFile()
    f.SetCellValue("Sheet1",ID2index("A",1),"学号")
    f.SetCellValue("Sheet1",ID2index("B",1),"姓名")
    f.SetCellValue("Sheet1",ID2index("C",1),"手机号")
    f.SetCellValue("Sheet1",ID2index("D",1),"失败原因")

替换为

    //name--文件名,FailUser{}希望储存的结构体模板
    table := InitTable(name, FailUser{})
  • analyzeTableHeader中,使用反射获取tag,取tag为表头

    type FailUser struct {
        Name        string `helper:"姓名"`
        StuNum      string `helper:"学号"`
        Phone       string `helper:"手机号"`
        Reason      string `helper:"失败原因"`
    }
    

在插入值部分,我们可以将原本的对每一个单元格进行插入,替换为(直观的)插入一个结构体,orm

//before
for i,v:= range fail{
    f.SetCellValue("Sheet1",ID2index("A",i+2),v.StuNum)
    f.SetCellValue("Sheet1",ID2index("B",i+2),v.Name)
    f.SetCellValue("Sheet1",ID2index("C",i+2),v.Phone)
    f.SetCellValue("Sheet1",ID2index("D",i+2),v.Reason)
}
//after
for i, i2 := range fail {
    table.Insert(i,i2)
}
  • Insert通过遍历时结构体切片的角标确定应该存在的位置,
  • analyzeTableValue,通过反射,获取到结构体每一个字段的value,组合字段为一个string切片,最后遍历这个切片存入每一个单元格

最后的储存还是照旧,因为核心的结构体ExcelHelper里包含了File(这个是github.com/360EntSecGr…里的file,大部分操作都是基于这个file进行的)

err := table.StoreFile("")
if err!=nil{
    fmt.Println("error in store file")
}
  • 事实上,table.StoreFile()就是对File.SaveAs()的简单封装,使其更符合自然语言的逻辑

在原始版本的goxcel中,使用InitTable()对核心的结构体ExcelHelper进行初始化,后续的操作都依托于File *excelize.File进行

type ExcelHelper struct {
    Object interface{}      //传入的结构体模板
    TableName string        //表名
    TableHeader []string    //表头
    File *excelize.File     //进行操作的file
}

最终实现导出结构体切片为.xlsx文件:

func CreateFailExcel(fail []FailUser,name string)(string,error) {
    table := InitTable("学生",Student{})
    for i, i2 := range students {
        table.Insert(i,i2)
    }
    f,err := table.StoreFile("")
    if err!=nil{
        fmt.Println("error in store file:",err)
        return "",err
    }
    return f,nil
}

对比原来的:

func CreateFailExcel(fail []FailUser,name string)(string,error) {
    f := excelize.NewFile()
    f.SetCellValue("Sheet1",ID2index("A",1),"学号")
    f.SetCellValue("Sheet1",ID2index("B",1),"姓名")
    f.SetCellValue("Sheet1",ID2index("C",1),"手机号")
    f.SetCellValue("Sheet1",ID2index("D",1),"失败原因")
    for i,v:= range fail{
        f.SetCellValue("Sheet1",ID2index("A",i+2),v.StuNum)
        f.SetCellValue("Sheet1",ID2index("B",i+2),v.Name)
        f.SetCellValue("Sheet1",ID2index("C",i+2),v.Phone)
        f.SetCellValue("Sheet1",ID2index("D",i+2),v.Reason)
    }
    if err := f.SaveAs(filepath  + "fail" + name + ".xlsx"); err != nil {
        fmt.println(err.Error())
        return "", err
    }
    return filepath + name + ".xlsx" ,nil
}

优点:

  • 提高了复用性,不管是什么结构体,都可以直接Insert在内部通过analyzeTableValue获取字段信息,而不用在外面每一个字段都去SetCellValue
  • 简化了流程:通过analyzeTableHeader获取表头,代价仅仅只是添加tag
  • 遍历时在函数内部处理应该插入的位置,不用在外面ID2index("A",i+2)

不足:

  • 流程还是比较繁琐

    为什么繁琐

    • 对反射的运用还比较初级,尝试过直接插入结构体切片,但在转换空接口为结构体切片时,遇到了很多问题,比如:

      天真的我以为这样可以直接传入结构体切片,然后遍历v对每个结构体取值

      func InterfaceTesting(v []interface{})  {
          
      }
      

      如果是这样,确实可以正确地将结构体切片传入,但是怎么转换空接口为未知结构体切片呢?

      func InterfaceTesting(v interface{})  {
          
      }
      

      然后直接插入结构体切片这一想法就暂时咕了

    • 国内反射的教程博客又少,中文文档也一言难尽,最后只能让Inset出现在外部(其实在外部也有好处,不过这里只是技术不够的妥协做法,仅处理单个结构体的值)

Round-2

大概摸鱼了一个月,折腾了半节课的反射,然后在stackoverflow找到了一个Type converting slices of interfaces的问题,里面解释了为什么下面的函数是无法使用的

func foo([]interface{}) { /* do something */ }
​
func main() {
    var a []string = []string{"hello", "world"}
    foo(a)
}

In Go, there is a general rule that syntax should not hide complex/costly operations. Converting a string to an interface{} is done in O(1) time. Converting a []string to an interface{} is also done in O(1) time since a slice is still one value. However, converting a []string to an []interface{} is O(n) time because each element of the slice must be converted to an interface{}.

The one exception to this rule is converting strings. When converting a string to and from a []byte or a []rune, Go does O(n) work even though conversions are "syntax".

There is no standard library function that will do this conversion for you. You could make one with reflect, but it would be slower than the three line option.

大概说的是

在Go中,有一个通用规则,即语法不应隐藏复杂/昂贵(开销大)的操作。将“string”转换为“interface{}”只需O(1)次。将一个“[]字符串”转换为“interface{}”也是在O(1)时间内完成的,因为切片仍然是一个值。但是,将[]string转换为[]interface{}是O(n)时间,因为切片的每个元素都必须转换为“interface{}”。

然后我又去找了下go interface to slice,结果一下就出来了:

Well I used reflect.ValueOf and then if it is a slice you can call Len() and Index() on the value to get the len of the slice and element at an index. I don't think you will be able to use the range operate to do this.

之前一直在使用的,反射包中的field()只能用在结构体上,对于数组或切片,应该使用Index(),很简单的问题结果拖了快一个月才解决。。然后就顺利写出了现在版本的goxcel:

代码见childifish/goxcel: 幼儿园小朋友也能看懂的golang struct-->.xslx小工具 (github.com)

最终实现导出结构体切片为.xlsx文件:

func CreateFailExcel(fail []FailUser,name string)(string,error) {
    err := goxcel.ExcelStructs(fail,name,filepath).Error()
    return name + filepath + ".xlsx",err
}

或者直接

func CreateFailExcel(fail []FailUser)error {
    retuen goxcel.ExcelStructsLite(fail).Error()
}

ExcelStructsLite中,name和filepath是可选参数,如果没有name输入,会生成传入结构体切片中的结构体名(通过反射获取)+当前时间戳作为.xlsx的名字。

func ExcelStructsLite(v interface{},para ...string)*ExcelHelper {...}

如果有多次分开插入数据的需求,也可以用ExcelStructsNotStore,这里不做展示

其他类似

  • 定时删除的DeleteTimer(这个已经写了,testing.....)

  • 表头表明嵌套结构,有多层嵌套的结构以此类推

    eg:

    学生(横排的这四格合并为一格)
    学号姓名手机号失败原因
    2019233333打工人12334565756未绑定x帮手
  • 读取表到结构体切片中 (rom关系对象映射) 使用方法尽量在向gorm靠拢

都还在完善中

goxcel地址:github.com/childifish/…

现在到v1.0.3版本了

FIN+总结

  • 感觉写完了会很像一个极简 (简陋) 版的orm框架

  • 反射很好用

  • 应该仔细看文档(在知道应该使用.Index之后,发现文档里一直都有。。)

  • 善用英文搜索

    之前搜golang 空接口 结构体切片 golang 反射 切片,反射,切片,结构体两两组合都找不到靠谱的教程,少数看起来不太一样的博客,分析的都不是未知类型的切片,一度以为不能这样用

  • 关于反射踩的坑和使用,大概写在下一次分享会吧(从暑假作业的json框架一直到现在,已经被反射折腾出斯德哥尔摩综合症了)