使用 Golang 創建和讀取 Excel 文件

轉自:

https://juejin.cn/post/7117578224854368263

摘要

本文提出一種使用 Golang 進行 Excel 文件創建和讀取的方案。首先對問題進行分析,引出方案的基本架構;然後分章節描述了 Excelize 基礎庫的基本用法,以及 Excel 數據在 Golang 中的表示和解析方式,並進一步提出了應對大規模數據寫入場景的優化方法;最後,指出了一些可能遇到的問題和對策。

引言

飛書是業界領先的下一代企業協作與管理平臺,集合了很多細分領域的優秀 ToB 產品。作者所在的部門,負責員工持股計劃(Employee Stock Ownership Plan,ESOP)相關係統的研發,主要的後端開發語言爲 Golang 。

系統管理員,需要定期對公司 ESOP 的參與人信息,以及股權激勵的授予、歸屬、取消等信息,以 Excel 表格的形式進行彙總,爲相關決策提供參考和依據;必要時,也需要通過修改 Excel 數據表,上傳系統,實現參與人、授予等信息的批量修改。

總而言之,隨着 Golang 的廣泛應用,如何使用 Golang 進行 Excel 數據表的讀取和創建,是一個值得探討的問題。本文將描述一套完整的 Excel 文件創建和讀取的方案,方案力圖做到靈活通用,和具體的業務數據類型無關,同時能夠兼顧大規模數據導出的效率。最後,分享了一些研發過程中遇到的問題,希望能夠避免讀者再次踩坑。

正文

架構

在開始具體深入地描述我們的方案之前,不妨先思考一下,實現這樣一個方案,需要解決的問題都有哪些?數據的處理大致應該是一個什麼樣的流程?下圖是 ESOP 系統中,涉及到 Excel 文件創建和讀取的部分業務場景。

文件對象

很自然地,我們需要引入一個 Excel 文件對象,此對象應該包含一個 Excel 工作簿的所有信息:有哪些工作表,每個工作表都有哪些列,每一行數據是什麼,單元格和文本的格式是什麼樣的,某一列是否包含枚舉值,等等。我們對文件的任何操作,無論是數據的增刪改,還是格式樣式的變更,亦或是文件的打開和保存,都應該將這個文件對象作爲切入點。

數據的表示

一個 Excel 工作表,可以很自然地和 Golang 結構體聯繫起來,工作表的每一列對應結構體中的一個字段。然而,只定義一個基本的結構體還不夠,至少還應該想辦法保存每個字段對應的 Excel 列名、數據驗證等等。

數據的解析

用 Golang 結構體表示了一個 Excel 工作表,自然還需要一種方法來解釋結構體中記錄的各種 Excel 數據信息,這樣,程序代碼才知道如何將結構體數據正確地寫入文件對象,以及反過來,如何讀取文件對象中的數據,來還原 Golang 結構體。數據表示和解析的整體思路,如下圖所示。

實際架構

幸運的是,上面幾個問題,我們都可以找到成熟且有力的工具來解決。方案基本的架構如下圖所示。

文件對象的創建和各種操作,我們通過 Excelize 基礎庫來實現,後面會對該基礎庫進行簡要介紹。

Excel 數據的表示,我們使用包含 tag 的 Golang 結構體實現,數據值以外的其他信息,用某種格式記錄在 tag 中。

Excel 數據的解析,我們利用 Golang 的反射機制實現。通過反射,我們可以解析出結構體每個字段的值以及 tag 中保存的其他有用信息。

Excelize 基礎庫

Excelize 是一個使用 Golang 編寫的,用於操作 Office Excel 文檔的基礎庫,支持 Golang 1.15 及以上版本。下面對其基本用法進行介紹,涉及到的各 API 的具體用法,可查看文章末尾給出的 Excelize Doc 鏈接。

文件

文件對象是本文大部分 Excel 文件操作的入口。使用 NewFile 函數,可以創建一個空白的文件對象。如果需要用已有的 Excel 文件數據創建文件對象,可以使用 OpenReader 或者 OpenFile 函數。結束文件操作之後,通常需要將文件保存在本地,或者將文件輸出爲字節數組,返回給前端供用戶下載,使用文件對象的 SaveAs 和 WriteToBuffer 方法,可以達到目的。

座標

在使用更多功能之前,必須搞清楚如何定位一個單元格或一個區域。

Excel 中使用形如 “A1” 這樣的座標來定位單元格。相應的,在 Excelize 基礎庫中,可以通過 CoordinatesToCellName 函數,將行號和列號這個二元組,轉換爲一個形如 “A1” 的座標。一些操作,需要通過兩個座標來確定生效區域,此時,兩個相同的座標表示對一個單元格生效,兩個不同的座標表示對一個矩形區域生效,分別指向區域左上角和右下角的單元格。

樣式

樣式包含字體、文字大小、粗細、位置、顏色等屬性。Excelize 中,樣式可以通過 NewStyle 方法創建,返回一個整數索引,唯一標識這個樣式。通過文件對象的 SetCellStyle 方法,指定座標和樣式索引,可以爲一個區域設置統一的樣式。

單元格操作

單元格的常用操作有設置高度和寬度、合併單元格、設置單元格數據等。

我們針對一行設置高度,針對一列設置寬度,分別使用文件對象中的 SetRowHeight 和 SetColWidth 方法進行。

在 “座標” 部分,我們講解了如何確定一個區域,合併單元格的操作,就是一個例子,我們可以使用文件對象中的 MergeCell 方法來完成。

一般情況下,數據的寫入操作,在單元格層面進行。使用文件對象中的 SetCellValue 方法,指定一個座標,可以將 Golang 中常用的數據類型(包括無符號整數、有符號整數、浮點數、字節切片、字符串、時間、布爾類型等等)的值寫入對應的單元格。

數據驗證

數據驗證功能,可以爲某一列數據定義枚舉值,用戶可以使用下拉列表,爲該列中某一行選擇要填入的值。

使用 NewDataValidation 函數,可以創建一個數據驗證對象,不妨命名爲 vd 。爲了指定 vd 的生效範圍,需要爲 vd 設置一個 Sqref 屬性,格式形如 “A1:A10”,表示此 vd 對第 1 列中第 1 行到第 10 行的數據生效。然後,使用 vd 的 SetDropList 方法,設置下拉列表的內容。最後,使用文件對象中的 AddDataValidation 方法,將此 vd 寫入文件。

數據的表示和解析

表示

根據 “架構” 部分的設想,我們可以定義一個帶有 tag 的結構體,來表示一個 Excel 工作表。

Golang 結構體的 tag ,是以鍵值對的形式表示的。爲了和其他用途的 tag 進行區分,我們將本方案的 tag 信息,用一個名爲 ex 的鍵來表示,而 ex 的值,仍然沿用鍵值對的形式,如下列代碼所示:

type PeopleInfo struct {
    PeopleNo          string `ex:"head:工號;type:string;required;color:#0070C0"`
    PeopleName        string `ex:"head:姓名;type:string;required"`
    BirthDate         string `ex:"head:出生日期;type:date;omitempty"`
    EmploymentStatus  string `ex:"head:在職狀態;type:string;select:在職,離職"`
}

我們可以爲 ex 設計下列屬性:

此外,我們還要定義一個結構體,保存 ex 的解析結果,結構體不妨命名爲 Setting :

type Setting struct {
    Head      string
    Type      string
    Select    []string
    Required  bool
    OmitEmpty bool
    Color     string
}

解析

使用 Golang 的反射機制,對類似於 PeopleInfo 這樣的結構體,我們可以抽取每個字段的 ex 值,進行字符串處理後,組裝成 Setting 對象。示例代碼如下:

import reflect

// 解析第idx個字段的ex
func ParseEx(idx int, data interface{}) *Setting {
    tp := reflect.ValueOf(data).Type().Elem().Elem() // 獲得結構體的反射Type
    field := tp.Field(idx)
    exStr := field.Tag.Get("ex") // 獲得tag中ex的值
    setting := &Setting{}
    // 下面可通過對exStr字符串進行切分,來組裝Setting對象,較爲簡單,省略
    ...
    return setting
}

func main() {
    ParseEx(0, []*PeopleInfo{{}})
}

由於反射機制較爲抽象,這裏不再贅述,對反射不熟悉的讀者,可以查看文章末尾給出的 Golang reflect 鏈接。

組裝了 Setting 之後,我們可以繼續通過反射,來獲取結構體中各字段的值,然後使用前面介紹過的一些 API ,將這些信息寫入 Excel 文件。

下面給出創建 Excel 文件的示例代碼,代碼對 omitempty 和 type 屬性進行了處理,並將部分數據寫入文件對象。其他 ex 屬性的處理,因篇幅有限,不再演示,讀者有興趣可以自己嘗試實現。

import reflect

import "github.com/xuri/excelize/v2"

// 寫入第1行數據的第idx個字段
func WriteFirstRow(ef *excelize.File, idx int, data interface{}) error {
    firstRow := reflect.ValueOf(data).Index(0).Elem() // 第1個數據的反射Value
    v := firstRow.Field(idx) // 第idx個字段的反射Value
    setting := ParseEx(idx, data) // 第idx個字段解析出來的ex信息
    
    // 處理omitempty
    if setting.OmitEmpty && v.IsZero() {
       return nil
    }
    
    var val interface{}
    // 處理type
    switch setting.Type {
    case "string":
        val = v.String()
    case ...
    }
    
    // Excel列號從1開始,所以列號是idx+1;行號從2開始,因爲第1行要顯示列名
    axis, err := excelize.CoordinatesToCellName(idx+1, 2)
    if err != nil {
        return err
    }
    
    // 將數據寫入默認工作表Sheet1中axis座標處的單元格
    return ef.SetCellValue("Sheet1", axis, val)
}

func main() {
    ef := excelize.NewFile()
    WriteFirstRow(ef, 0, []*PeopleInfo{{PeopleNo: "test"}})
    ef.SaveAs("people_info.xlsx")
}

上面給出的是創建 Excel 文件的示例。讀取 Excel 文件的過程是類似的,首先從二進制數據創建出文件對象,然後根據文件對象中的每一列數據,生成對應的結構體對象。示例代碼如下。

import reflect

import "github.com/xuri/excelize/v2"

// 讀取第1行數據的第idx列,假定idx從0開始,只有一個默認工作表Sheet1,數據從第2行開始
func ReadFirstRow(ef *excelize.File, idx int, holder interface{}) error {
    rows, err := ef.GetRows("Sheet1") // 所有行
    if err != nil {
       return err
    }
    row := rows[1]

    tp := reflect.TypeOf(holder).Elem().Elem().Elem() // 結構體的類型
    val := reflect.New(tp)                            // 創建一個新的結構體對象

    field := val.Elem().Field(idx) // 第idx個字段的反射Value
    cellValue := row[idx]          // 第idx個字段對應的Excel數據
    field.SetString(cellValue)     // 將Excel數據保存到結構體對象的對應字段中

    listV := reflect.ValueOf(holder)
    listV.Elem().Set(reflect.Append(listV.Elem(), val)) // 將結構體對象添加到holder中

    return nil
}

func main() {
    ef, _ := excelize.OpenFile("people_info.xlsx")
    holder := make([]*PeopleInfo, 0, 10)
    ReadFirstRow(ef, 0, &holder)
}

本節描述瞭如何使用 Golang 來表示和解析 Excel 數據,以及在此基礎上如何創建和讀取 Excel 文件。示例代碼中對 Excel 文件的寫入和讀取操作函數,使用 interface 類型的參數作爲數據提供方或接收方,和具體的業務數據類型無關,因此該方案具備通用性。

大規模數據的寫入

之前演示的 Excel 文件寫入方式,是在單元格層面進行的,在大規模數據寫入的場景下,耗時長,體驗差。Excelize 提供了一套流式寫入 API,以行爲單位寫入 Excel 數據,能夠顯著提高大規模數據的寫入效率。

使用流式 API 寫入 Excel 數據,首先需要使用文件對象的 NewStreamWriter 方法,創建一個流式寫入器。寫入一行數據時,需要構造一個切片,表示這一行數據,切片中每個元素表示一個單元格信息,包含單元格的值和樣式。

單元格元素,可以使用 Excelize 中提供的 Cell 數據類型來表示。之後,就可以通過流式寫入器的 SetRow 方法,將行數據寫入 Excel 文件。行的高度,可以在寫入時指定。示例代碼如下:

import reflect

import "github.com/xuri/excelize/v2"

// 寫入第1行數據
func StreamWriteFirstRow(sw *excelize.StreamWriter, data interface{}) error {
    firstRow := reflect.ValueOf(data).Index(0).Elem() // 第1個數據的反射Value
    v := firstRow.Field(0) // 首個字段的反射Value
    style := &excelize.Style{
        Alignment: &excelize.Alignment{
            Horizontal: "left",
            Vertical:   "center",
        },
    }
    styleID, err := sw.File.NewStyle(style) // 創建樣式
    if err != nil {
        return err
    }
    
    length := firstRow.NumField() // 結構體字段數量
    
    row := make([]interface{}, length) // 創建一個切片,表示一行數據
    row[0] = excelize.Cell{
       Value: v.String(),
       StyleID: styleID,
    } // 這裏只寫入首個字段
    
    // 每一行都是從列號1開始;行號從2開始,因爲假定第1行要顯示列名
    axis, err := excelize.CoordinatesToCellName(1, 2)
    if err != nil {
        return err
    }
    
    // 流式寫入行,並指定高度
    return sw.SetRow(axis, row, excelize.RowOpts{Height: 16})
}

func main() {
    ef := excelize.NewFile()
    sw, _ := ef.NewStreamWriter("Sheet1")
    StreamWriteFirstRow(sw, []*PeopleInfo{{PeopleNo: "test"}})
    sw.Flush()
    ef.SaveAs("stream_people_info.xlsx")
}

需要關注的問題

大量枚舉值的設置

在 “數據驗證” 部分,我們提到使用 SetDropList 方法可以設置下拉列表。然而,這樣設置的下拉列表是有侷限性的:每個枚舉值使用逗號拼接後得到的字符串,其總長度不得超過 255 個字符。

如果超過了這個限制,我們需要創建一個工作表(假設名稱爲 enum ),將枚舉值存儲在 enum 工作表的某一列中(假設存儲在 A 列,第 2 行到第 10 行),然後通過 vd 的 SetSqrefDropList 方法設置下拉列表,此方法通過一個形如 “enum!A2:A10” 的字符串來指定枚舉值的存儲位置,即 enum 工作表 A 列的第 2 行到第 10 行。

大工作表的讀取

讀取 Excel 文件時,我們會基於已有的物理文件來創建文件對象,如果其中有一個很大的工作表,那麼當我們將這個文件對象另存爲一個新的物理文件時,可能會發現文件變小了,且無法正常打開。

Excelize 庫通過一些參數,來限制打開和讀取工作薄時的內存使用。其中,WorksheetUnzipMemLimit 參數限制了 unzip 一個工作表時允許使用的最大內存,默認爲 16 MB 。當一個工作表大小超過這一默認值時,此工作表的數據會 unzip 到操作系統的臨時文件中。然而,當我們進行另存爲操作時,這些臨時文件的數據卻被 Excelize 的相關函數忽略了。

這可能是 Excelize 庫的一個 bug ,但是我們可以通過增大 WorksheetUnzipMemLimit 參數來規避。這一參數的值,可根據具體業務場景來設置,最大可以設置爲和 UnzipSizeLimit 參數相同,後者是打開整個工作簿時總的內存使用限制,默認爲 16 GB 。

流式寫入的注意事項

流式操作有自己的一套 API ,用於數據寫入、合併單元格、設置列寬等操作。流式 API 不能和普通的非流式 API 混用,否則可能無法正確寫入數據或設置格式。使用流式 API 設置列寬,需要在寫入數據之前進行。流式寫入完成之後,需要調用流式寫入器的 Flush 方法來結束寫入,否則保存文件時可能會丟失數據。

結語

本文對 Golang 中創建和讀取 Excel 文件所涉及的各方面問題,進行了總結歸納,並提出了一套完整的方案。此方案使用 Golang 結構體的 tag ,以及 Golang 反射機制,對 Excel 數據進行定義和解釋,實現了 Golang 結構體和 Excel 數據的雙向映射,同時使用成熟強大的 Excelize 基礎庫,對 Excel 文件進行創建、寫入、讀取等操作。

參考資料

Go 開發大全

參與維護一個非常全面的 Go 開源技術資源庫。日常分享 Go, 雲原生、k8s、Docker 和微服務方面的技術文章和行業動態。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/bec19NHTy5MNuhrBryJO3w