Golang 操作 CSV 及其开源库分享

前言

最近遇到了一个需求,需要写代码操作 CSV 文件,就想着写一套 CSV 工具,以后再遇到类似的需求可以重复利用。 写着写着没想到一个小小的 CSV 文件操作中也有几个“小坑”,这里做一点小总结分享给大家。 相关源码我上传到了我的 Github: https://github.com/kaolengmian7/go-kit ,使用方法很简单,看 readme 即可,大家感兴趣可以拉下来代码跑一跑单元测试玩。

Golang 封装 CSV 操作

go-kit 库中提供了 4 个基础函数:

// 根据坐标返回数据,rowNum == 0, column == 1 则返回坐标为(0,1)的数据
func (c *CSV) GetByCoordinate(rowNum int, columnNum int) (res string, err error) 

// 在某一行末尾追加数据
func (c *CSV) AddToRow(rowNum int, data string) (err error)

// 读取 CSV 文件
func (c *CSV) Read(filePath string) (err error)

// 导出 CSV 文件到当前目录
func (c *CSV) Export(fileName string) (err error)

坑1:使用CSV.Read()函数无法读取非矩阵型的CSV文件

什么叫非矩阵型的CSV?就是在某一列,有些行有数据,有些行为 nil。 我们用表格举例子就很好懂:可以看到第 3 列,第 2、3 行的值为 nil,读取这种 CSV 文件会返回一个空数组

列1列2列3
xxxxxx
xxxxnil
xxxxnil
xxxxxx

所以强烈建议:在赋值时,某行如果没有值,填充一个空字符串。以防止其他程序读取到此 CSV 文件时得到一个空数组,产生诡异的 Bug。 在我 go-kit 库的单元测试(TestExport函数)中可以看到:我使用的测试用例为

// 正确测试用例
csv.data = [][]string{{"1", "2", "3", "test"}, {"4", "5", "6", ""}, {"7", "8", "9", ""}}
// 反例
csv.data = [][]string{{"1", "2", "3", "test"}, {"4", "5", "6"}, {"7", "8", "9"}}

坑2:UTF-8 的 BOM 头

什么是 BOM?

BOM = Byte Order Mark 是 Unicode 规范中推荐的标记字节顺序的方法。比如说对于 UTF-16,如果接收者收到的 BOM 是FEFF,表明这个字节流是大端序的;如果收到FFFE,就表明这个字节流是小端序的。

关于什么是大端序和小端序,可以看我的这篇文章的第 6 小节: 斯坦福CS144计算机网络笔记一

UTF-8 不需要 BOM 来表明字节顺序,但可以用 BOM 来表明“我是 UTF-8 编码”。BOM 的 UTF-8 编码是EF BB BF。所以如果接收者收到以EF BB BF开头的字节流,就知道这是 UTF-8 编码了。

BOM 如何影响了我的代码?

正常情况下,Go 读取带有 BOM 头的 CSV 文件后,打印输出是不会出现异常的(不可见),比如:

func TestXXX(t *testing.T) {
	csv := New()
	err = csv.Read("./test.csv")
	log.Printf("csvData:%+v", csv.data)
}
------------------------- console -------------------------
2022/08/25 20:38:09 csvData:[[1 2 3 test] [4 5 6 ] [7 8 9 ]]

但是在我的单元测试中,使用assert.ElementsMatch()函数验证 input 与 output 时,就出现了问题:可以看到我代码的第 15 行,必须要在第一个数据那里加上\ufeff才能通过测试。

func TestExport(t *testing.T) {
  csv := New()
  csv.data = [][]string{{"1", "2", "3", "test"}, {"4", "5", "6", ""}, {"7", "8", "9", ""}}
  err := csv.Export("test_export")
  assert.Nil(t, err)
  
  fp := fmt.Sprintf("%s-%s.csv", "test_export", time.Now().Format("2006-01-02"))
  log.Println(fp)
  // 验证文件存在
  _, err = os.Stat(fp)
  assert.Nil(t, err)
  // 验证内容一致
  expectCsv := New()
  err = expectCsv.Read(fp)
  expect := [][]string{{"\ufeff1", "2", "3", "test"}, {"4", "5", "6", ""}, {"7", "8", "9", ""}} // 因为写入了 UTF-8 BOM 所以要加上 \ufeff
  log.Printf("expectCSVData:%+v", expectCsv.data)
  assert.ElementsMatch(t, expect, expectCsv.data)
  // 删除测试文件
  err = os.Remove(fp)
  if err != nil {
    panic(fmt.Sprintf("csv_util2 TestExport failed! os.Remove(%s) failed!", fp))
  }
}