Excelize 是 Go 语言编写的用于操作 Office Excel 文档基础库,功能非常强大,性能也强劲。因为老婆是学会计的,曾经还想着带她学 VBA(才起步就夭折),后来再去整 Python,自己花了一些时间学了部分 Numpy 和 Pandas 然后还买了《Excel+Python 飞速搞定数据分析与处理》和《代替 VBA! 用 Python 轻松实现 Excel 编程》想着一起学习,结果自己没有学完,也没有带动她,后面就书架上吃灰了。借助这次学习 Go 的机会,再次尝试,不再想带她,而是初步掌握一些自动化处理电子表格的技能,后面看能不能带给她一些帮助。

Excelize 的内容非常多,简单跟着官方文档的示例操作了部分后,在想还是找点练习做做,因为根据官方 Demo 敲代码,自己没有多少独立思考,脱离后就全忘记了,实践出真知,在实践中积累经验。

本次练习的数据来源自 Github 上一个仓库 leeli73/ExcelOnlineExaminationSystem: ExcelOnlineExaminationSystem-基于Excel VBA和Go语言的自动化考试系统 (github.com) 中的机试考试题目第一题。

初始时的数据截图:

Pasted image 20240419105451.png

初始化项目

在 Github 上新建一个仓库,然后克隆到本地,使用 go mod init github.com/username/repo 初始化项目,然后下载 Excelize 库 go get github.com/xuri/excelize,将所要操作的 Excel 文件下载下来删除掉操作要求完成样稿截图部分内容,另存为 source.xlsx

接下来创建一个 main.go 文件,写入官方打开文件的样板代码:

package main

import (
    "log"

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

func main() {
    f, err := excelize.OpenFile("source.xlsx")
    if err != nil {
        log.Fatal(err)
        return
    }
    defer func() {
        if err := f.Close(); err != nil {
            log.Fatal(err)
        }
    }()

    // 另存为别的文件,方便查看
    if err :=f.SaveAs("result.xlsx"); err != nil {
        log.Fatal(err)
        return
    }
}

在 Excelize 中 OpenFile()NewFile() 两个函数有一个 Options 选项,我们可以指定密码、日期数字格式等配置,具体可以在官方文档中的【工作簿】小节找到具体选项的描述信息。

接下来我们一步一步实现【操作要求】

实现:任务一,二

任务描述:

  • (1)合并及居中(A1:F1)单元格区域,该标题文字的字体设为“黑体、18号、白色、下划线”。
  • (2)设置标题行的底色为浅绿色(颜色与样稿相近即可)。

合并单元格可以通过 f.MergeCell() 函数指定起始和结束的单元格来完成,设置单元格的样式可以通过 f.SetCellStyle() 函数来完成。

电子表格默认的工作表名为 "Sheet1",后面代码中会多次用到,因此我们将其命名为常量 sheet,然后在为单元格设置样式时,我们需要初始一个 f.NewStyle() 这个函数返回一个 int 整数,需要传递给 f.SetCellStyle() 函数,因此我们也提供了常量 titleStyle 来传递,同时考虑到我们需要使用 if 语句将 error 判断写在一行,因此需要提前声明。具体的样式可以通过 Style 结构体中的 Font , Alignment , Fill 字段来设置字体,文字样式和背景色填充。

var (
    sheetName  = "Sheet1" // 工作表名称
    titleColor = "92d050" // 标题颜色
    titleStyle int
)

func main() {
    ...

    // 合并单元格 A1:F1
    if err := f.MergeCell(sheetName, "A1", "F1"); err != nil {
        log.Fatal(err)
        return
    }

    // 创建标题样式
    if titleStyle, err = f.NewStyle(&excelize.Style{
        Font: &excelize.Font{
            Family:    "黑体",     // 字体
            Size:      18,       // 字号
            Color:     "FFFFFF", // 字体颜色
            Underline: "single", // 下划线,可选值为 none, single,double
        },
        // 水平居中
        Alignment: &excelize.Alignment{Horizontal: "center"},
        // 填充背景色, Pattern 其它值参考文档:样式/图案填充部分
        Fill: excelize.Fill{Type: "pattern", Color: []string{titleColor}, Pattern: 1},
    }); err != nil {
        log.Fatal(err)
        return
    }

    // 设置标题行样式
    if err := f.SetCellStyle(sheetName, "A1", "F1", titleStyle); err != nil {
        log.Fatal(err)
        return
    }

    ...
}

使用 go run main.go 运行结果:

Pasted image 20240418202622.png

和“完成样稿”截图对比,我们发现标题文字的下划线和边框距离不够,视觉上有点重叠了,因此我们通过下面的代码来把行高适当加高一点:

var rowHeight float64
if rowHeight, err = f.GetRowHeight(sheetName, 1); err != nil {
    log.Fatal(err)
    return
}

newHeight := rowHeight + float64(12)
if err := f.SetRowHeight(sheetName, 1, newHeight); err != nil {
    log.Fatal(err)
    return
}

这下就明显看着舒适一些了:

企业微信截图_17134431273477.png

实现:任务三

描述:(3)删除第11行(“王楠”下面的那条空行)。

由于在原文件中我们并没有看到有什么空行,因此我们修改 source.xlsx 文件,自己去添加一个空行在数据 王楠 下面。

删除行代码很简单只需要调用 f.RemoveRow() 指定行数就可以了:

// 删除行
if err := f.RemoveRow(sheetName, 11); err != nil {
    log.Fatal(err)
    return
}

实现:任务四

描述:(4)设置A1:F17单元格的垂直对齐方式为“居中”。

前面我们对标题设置过水平居中,因此这里我们声明一个 cellVACStyle 变量来设置 A1:F17 垂直居中样式:

var (
    titleStyle, nameColStyle int
)

// 创建单元格垂直居中样式
if cellVACStyle, err = f.NewStyle(&excelize.Style{
    // 垂直居中
    Alignment: &excelize.Alignment{Vertical: "center"},
}); err != nil {
    log.Fatal(err)
    return
}

// 垂直居中所有单元格
if err = f.SetCellStyle(sheetName, "A1", "F17", cellVACStyle); err != nil {
    log.Fatal(err)
    return
}

执行结果:

企业微信截图_17134945728000.png

上面的结果是不是有点出乎意外?原来设置好的标题也没有了,不要急,接下来我们一一来分析解决。

首先,我们发现如果按照操作要求【设置 A1: F17 单元格垂直对齐】,在 f.SetCellStyle() 函数中第三个参数中传入 F17,结果是不正确的,因为数据有 18 行,因此题目有点问题,我们直接改成传入 F18 就对了。

其次,原本的数据 name 列和标题行是水平居中对齐的,现在全部变成了左对齐。由于我们代码是按照操作要求的步骤自上而下写的,这里是在设置 cellVACStyle 时把原先的样式给覆盖了,我们可以可以把设置垂直居中的代码移动到设置标题代码前面,同时我们也把删除空白行的代码也一起移动。当然我们也可以使用 f.GetCellStyle() 来获取原来单元格的样式,进行合并,这样需要遍历整个 A1:F18 区域,反而写复杂,并不适合。

再次执行后:

Pasted image 20240419112146.png

这次标题样式和对齐没有什么问题了,数字向右对齐是满足完成样稿要求的,至于垂直居中,我们可以拖动改变行的高度来验证,但是【姓名】一列以及第二行列标题的对齐,不满足水平居中的要求,我们增加一个 cellHACStyle 来处理:

var (
    titleStyle, cellVACStyle, cellHACStyle int
)

// 创建水平居中单元格样式
if cellHACStyle, err = f.NewStyle(&excelize.Style{
    // 水平居中
    Alignment: &excelize.Alignment{Horizontal: "center"},
}); err != nil {
    log.Fatal(err)
    return
}

// 水平居中【姓名】列
if err = f.SetCellStyle(sheetName, "A2", "A18", cellHACStyle); err != nil {
    log.Fatal(err)
    return
}

// 水平居中表第二行
if err = f.SetCellStyle(sheetName, "A2", "F2", cellHACStyle); err != nil {
    log.Fatal(err)
    return
}

现阶段执行结果:

Pasted image 20240419114717.png

看着似乎没有什么问题,但是当我们调整行高后会发现 A 列以及第二行的垂直居中丢失了。搞了半天重复覆盖陷入死循环了,然后我们去查看了一下 f.SetCellStyle() 函数的描述信息:

SetCellStyle provides a function to add style attribute for cells by given worksheet name, range reference and style ID. This function is concurrency safe. Note that diagonalDown and diagonalUp type border should be use same color in the same range. SetCellStyle will overwrite the existing styles for the cell, it won't append or merge style with existing styles.

所以我们所能做的就是调整策略,代码操作不同于在 Excel 中直接操作表格,我们可以灵活转变思路调整实现方式:

  1. 单独设置标题样式
  2. 单独设置 A1 列和第二行的垂直居中对齐
  3. 单独设置 B3:E18 垂直居中
  4. F 列单独设置垂直居中,水平向左对齐

调整后的代码如下:

package main

import (
    "log"

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

var (
    titleStyle, cellCenterStyle, CellVACStyle,
    cellVACAndVALStyle int
    sheetName  = "Sheet1" // 工作表名称
    titleColor = "92d050" // 标题颜色
)

func main() {
    f, err := excelize.OpenFile("source.xlsx")
    if err != nil {
        log.Fatal(err)
        return
    }
    defer func() {
        if err := f.Close(); err != nil {
            log.Fatal(err)
        }
    }()

    // 删除第11行
    if err := f.RemoveRow(sheetName, 11); err != nil {
        log.Fatal(err)
        return
    }

    // 合并单元格 A1:F1
    if err := f.MergeCell(sheetName, "A1", "F1"); err != nil {
        log.Fatal(err)
        return
    }

    // 创建标题样式
    if titleStyle, err = f.NewStyle(&excelize.Style{
        Font: &excelize.Font{
            Family:    "黑体",     // 字体
            Size:      18,       // 字号
            Color:     "FFFFFF", // 字体颜色
            Underline: "single", // 下划线,可选值为 none, single,double
        },
        // 水平居中
        Alignment: &excelize.Alignment{Horizontal: "center"},
        // 填充背景色, Pattern 其它值参考文档:样式/图案填充部分
        Fill: excelize.Fill{Type: "pattern", Color: []string{titleColor}, Pattern: 1},
    }); err != nil {
        log.Fatal(err)
        return
    }

    // 创建居中样式
    if cellCenterStyle, err = f.NewStyle(&excelize.Style{
        // 水平居中
        Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
    }); err != nil {
        log.Fatal(err)
        return
    }

    // 创建垂直居中样式
    if CellVACStyle, err = f.NewStyle(&excelize.Style{
        // 水平居中
        Alignment: &excelize.Alignment{Vertical: "center"},
    }); err != nil {
        log.Fatal(err)
        return
    }

    // 创建垂直居中,水平向左对齐样式
    if cellVACAndVALStyle, err = f.NewStyle(&excelize.Style{
        // 水平居中
        Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center"},
    }); err != nil {
        log.Fatal(err)
        return
    }

    // 设置标题行样式
    if err := f.SetCellStyle(sheetName, "A1", "F1", titleStyle); err != nil {
        log.Fatal(err)
        return
    }

    var rowHeight float64
    if rowHeight, err = f.GetRowHeight(sheetName, 1); err != nil {
        log.Fatal(err)
        return
    }

    // 调整行高
    newHeight := rowHeight + float64(12)
    if err := f.SetRowHeight(sheetName, 1, newHeight); err != nil {
        log.Fatal(err)
        return
    }

    // 居中【姓名】列
    if err = f.SetCellStyle(sheetName, "A2", "A18", cellCenterStyle); err != nil {
        log.Fatal(err)
        return
    }

    // 居中第二行
    if err = f.SetCellStyle(sheetName, "A2", "F2", cellCenterStyle); err != nil {
        log.Fatal(err)
        return
    }

    // 设置 B3:E18 样式
    if err = f.SetCellStyle(sheetName, "B3", "E18", CellVACStyle); err != nil {
        log.Fatal(err)
        return
    }

    // 设置 B3:E18 样式
    if err = f.SetCellStyle(sheetName, "F3", "F18", cellVACAndVALStyle); err != nil {
        log.Fatal(err)
        return
    }

    // 另存为别的文件,方便查看
    if err := f.SaveAs("result.xlsx"); err != nil {
        log.Fatal(err)
        return
    }
}

实现效果:

Pasted image 20240419121755.png

一切都 Ok,接下来我们进行成绩计算和是否需要补考判断。

实现:任务四、五

任务描述:

  • 用公式(总成绩=平时成绩0.3+期中成绩0.3+期未成绩*0.4)计算出所有学生的“总成绩”。
  • (6)用IF函数判断是否补考,即某学生的“总成绩<60”则在“补考否”列自动填上“补考”文字。

在 Excelize 中我们有 2 种方式来遍历数据,一种是使用 f.GetRows() 函数,它返回一个二维数组,我们可以直接使用 for 去遍历,另外一种是使用 f.Rows() 函数,称之为行迭代器,它以流方式读取遍历单元格,性能上会好一点。

使用 f.GetRows() 遍历

遍历一个二维数组还相对比较容易的,需要注意的是我们在遍历时需要跳过标题行,列名所在的行,这里使用切片 rows[2:] 来跳过前 2 行数据,然后每一行数据遍历时我们使用 row[1:4] 来截取有效的成绩列。因为从每个单元格获取的数据都字符串,所以我们需要使用 strconv.ParseFloat() 函数将其转换成 32 位浮点数,然后根据操作要求中的计算公式来计算总成绩以及判断是否及格,并写相应的单元格中。在调用 f.SetCellValue() 函数时,我们通过当前遍历索引值加 3 来跳过标题和列名这二行,然后通过字符串拼接得到具体的单元格编号,我们也可以通过官方提供的辅助函数 excelize.JoinCellName("E", index) 来实现,但需要额外处理其返回的 error 信息。

var (
    markMsg = "补考" // 标记信息
)

rows, err := f.GetRows(sheetName)

if err != nil {
    log.Fatal(err)
    return
}

for index, row := range rows[2:] {
    var totalScore float64 = 0.0
    for i, val := range row[1:4] {
        score, err := strconv.ParseFloat(val, 32)

        if err != nil {
            log.Fatal(err)
            continue
        }

        if i < len(row[1:4])-1 {
            totalScore += float64(0.3) * score
        } else {
            totalScore += float64(0.4) * score
        }

    }

    // 写入【总成绩】列
    if err := f.SetCellValue(sheetName, "E"+strconv.Itoa(index+3), totalScore); err != nil {
        log.Fatal(err)
        continue
    }

    if totalScore < 60 {
        // 写入【补考否】列
        if err := f.SetCellValue(sheetName, "F"+strconv.Itoa(index+3), markMsg); err != nil {
            log.Fatal(err)
            continue
        }
    }

    totalScore = 0.0
}

使用 f.Rows() 遍历

这种方式虽然高效,但是在当前这个案例中我们需要自己手动添加一个 index 变量来作为遍历的索引值,其它的思路是一致,需要注意因为是流式遍历,所以别忘记了关闭流。

rows, err := f.Rows(sheetName)

if err != nil {
    log.Fatal(err)
    return
}
defer rows.Close()

var index int = 0
for rows.Next() {
    index++

    if index < 3 {
        continue
    }

    row, err := rows.Columns()

    if err != nil {
        log.Fatal(err)
        continue
    }

    fmt.Println(row, rows.GetRowOpts())
    var totalScore float64 = 0.0
    for i, val := range row[1:4] {
        score, err := strconv.ParseFloat(val, 32)

        if err != nil {
            log.Fatal(err)
            continue
        }

        if i < len(row[1:4])-1 {
            totalScore += float64(0.3) * score
        } else {
            totalScore += float64(0.4) * score
        }
    }

    // 写入【总成绩】列
    if err := f.SetCellValue(sheetName, "E"+strconv.Itoa(index), totalScore); err != nil {
        log.Fatal(err)
        continue
    }

    if totalScore < 60 {
        // 写入【补考否】列
        if err := f.SetCellValue(sheetName, "F"+strconv.Itoa(index), markMsg); err != nil {
            log.Fatal(err)
            continue
        }
    }

    totalScore = 0.0
}

最终的结果:

企业微信截图_1713512495555.png

总结

初步实践下来发现还是有很坑需要自己去摸索和实践的,实际项目中应该不会傻傻地这样子去处理样式,多数组场景应该是数据的计算和表的合并等操作。

文章代码参见:xjxl520303/excelize-practise: Excelize 实践系列文章源码 (github.com)


jenemy
1.7k 声望744 粉丝

从事前端多年,技术依然很渣的IT程序员。