使用 Groovy 读取和写入 CSV 文件

作者:Paul King
发布时间:2022-07-25 02:26PM


简介

在这篇文章中,我们将介绍如何使用 Groovy 读取和写入 CSV 文件。

CSV 文件不就只是文本文件吗?

对于简单的用例,我们可以像对待其他文本文件一样对待 CSV 文件。假设我们有以下数据,我们希望将其写入 CSV 文件。

def data = [
        ['place', 'firstname', 'lastname', 'team'],
        ['1', 'Lorena', 'Wiebes', 'Team DSM'],
        ['2', 'Marianne', 'Vos', 'Team Jumbo Visma'],
        ['3', 'Lotte', 'Kopecky', 'Team SD Worx']
]

Groovy 使用与 Java 相似的 FilePath 对象。我们将在这里使用 File 对象,并且为了我们的目的,我们将只使用一个临时文件,因为我们只需要读取它并将其与我们的数据进行比较。以下是如何创建临时文件

def file = File.createTempFile('FemmesStage1Podium', '.csv')

写入我们的 CSV(在这个简单的例子中)就像用逗号连接数据,用行分隔符连接行一样简单

file.text = data*.join(',').join(System.lineSeparator())

我们一次性写入了整个文件内容,但也可以选择一次写入一行、一个字符或一个字节。

读取数据同样简单。我们读取行,并以逗号为分隔符进行分割

assert file.readLines()*.split(',') == data

一般来说,我们可能想进一步处理数据。Groovy 也提供了不错的选择。假设我们有以下现有的 CSV 文件

HommesOverall

我们可以读取文件,并使用以下代码选择我们感兴趣的各种列

def file = new File('HommesStageWinners.csv')
def rows = file.readLines().tail()*.split(',')
int total = rows.size()
Set names = rows.collect { it[1] + ' ' + it[2] }
Set teams = rows*.getAt(3)
Set countries = rows*.getAt(4)
String result = "Across $total stages, ${names.size()} riders from " +
        "${teams.size()} teams and ${countries.size()} countries won stages."
assert result == 'Across 21 stages, 15 riders from 10 teams and 9 countries won stages.'

这里,tail() 方法跳过了标题行。第 0 列包含阶段号,我们忽略它。第 1 列包含名字,第 2 列包含姓氏,第 3 列包含车队,第 4 列包含车手的国籍。我们将全名、车队和国籍存储在集合中,以消除重复项。然后,我们使用这些集合的大小创建一个总体结果消息。

虽然在这个简单的例子中,编码相当简单,但并不建议以这种方式手动处理 CSV 文件。CSV 的细节很快就会变得混乱。如果值本身包含逗号或换行符怎么办?也许我们可以用双引号包围它们,但如果值包含双引号怎么办?等等。因此,建议使用 CSV 库。

我们将简要介绍三个库,但首先,让我们通过查看多个获胜者来总结环法自行车赛的一些亮点。以下代码总结了我们的 CSV 数据

def byValueDesc = { -it.value }
def bySize = { k, v -> [k, v.size()] }
def isMultiple = { it.value > 1 }
def multipleWins = { Closure select -> rows
    .groupBy(select)
    .collectEntries(bySize)
    .findAll(isMultiple)
    .sort(byValueDesc)
    .entrySet()
    .join(', ')
}
println 'Multiple wins by country:\n' + multipleWins{ it[4] }
println 'Multiple wins by rider:\n' + multipleWins{ it[1] + ' ' + it[2] }
println 'Multiple wins by team:\n' + multipleWins{ it[3] }

这个总结与 CSV 文件本身无关,但为了纪念环法自行车赛中精彩的骑行而总结!以下是输出结果

MultipleWins

好了,现在让我们看看我们的三个 CSV 库。

Commons CSV

Apache Commons CSV 库使写入和解析 CSV 文件变得更容易。以下是使用 CSVPrinter 类写入我们的 CSV 的代码

file.withWriter { w ->
    new CSVPrinter(w, CSVFormat.DEFAULT).printRecords(data)
}

以下是使用 RFC4180 解析器工厂单例读取它的代码

file.withReader { r ->
    assert RFC4180.parse(r).records*.toList() == data
}

还有其他单例工厂用于制表符分隔值和其他常见格式,以及构建器,允许您设置各种选项,如转义字符、引号选项、是否使用枚举定义标题名称,以及是否忽略空行或空值。

对于我们更复杂的例子,我们需要做一些额外的工作。我们将使用构建器告诉解析器跳过标题行。我们也可以选择使用之前用过的 tail() 技巧,但我们决定使用解析器功能。代码如下

file.withReader { r ->
    def rows = RFC4180.builder()
            .setHeader()
            .setSkipHeaderRecord(true)
            .build()
            .parse(r)
            .records
    assert rows.size() == 21
    assert rows.collect { it.firstname + ' ' + it.lastname }.toSet().size() == 15
    assert rows*.team.toSet().size() == 10
    assert rows*.country.toSet().size() == 9
}

您可以看到,我们在处理过程中使用了列名称而不是列号。使用列名称是使用 CSV 库的另一个优点;手动执行这方面的工作会相当繁琐。另外请注意,为了简便起见,我们没有像之前的例子那样创建完整的 result 消息。相反,我们只是检查了之前计算的所有相关集合的大小。

OpenCSV

OpenCSV 库在需要时会处理混乱的 CSV 细节,但在简单的情况下不会造成阻碍。对于我们的第一个例子,CSVReaderCSVWriter 类将很适合。以下是与之前相同的代码,用于写入我们的 CSV 文件

file.withWriter { w ->
    new CSVWriter(w).writeAll(data.collect{ it as String[] })
}

以下是读取数据的代码

file.withReader { r ->
    assert new CSVReader(r).readAll() == data
}

如果我们查看生成的 文件,它比之前的 文件要更复杂一些,因为所有数据都用双引号括起来

FemmesPodiumStage1

如果我们想进行更复杂的处理,CSVReaderHeaderAware 类将识别初始标题行及其列名称。以下是我们更复杂的示例,它进一步处理了一些数据

file.withReader { r ->
    def rows = []
    def reader = new CSVReaderHeaderAware(r)
    while ((next = reader.readMap())) rows << next
    assert rows.size() == 21
    assert rows.collect { it.firstname + ' ' + it.lastname }.toSet().size() == 15
    assert rows*.team.toSet().size() == 10
    assert rows*.country.toSet().size() == 9
}

您可以看到,我们在处理过程中再次使用了列名称而不是列号。为了简便起见,我们遵循与 Commons CSV 示例相同的风格,只是检查了之前计算的所有相关集合的大小。

OpenCSV 还支持将 CSV 文件转换为 JavaBean 实例。首先,我们定义目标类(或对现有的域类进行注释)

class Cyclist {
    @CsvBindByName(column = 'firstname')
    String first
    @CsvBindByName(column = 'lastname')
    String last
    @CsvBindByName
    String team
    @CsvBindByName
    String country
}

对于两列,我们指示 CSV 文件中的列名称与我们的类属性不匹配。注释属性可以处理这种情况。

然后,我们可以使用以下代码将我们的 CSV 文件转换为域对象列表

file.withReader { r ->
    List<Cyclist> rows = new CsvToBeanBuilder(r).withType(Cyclist).build().parse()
    assert rows.size() == 21
    assert rows.collect { it.first + ' ' + it.last }.toSet().size() == 15
    assert rows*.team.toSet().size() == 10
    assert rows*.country.toSet().size() == 9
}

OpenCSV 有很多我们没有展示的选项。在写入文件时,您可以指定分隔符和引号字符;在读取 CSV 时,您可以指定列位置、类型并验证数据。

Jackson Databind CSV

Jackson Databind 库支持 CSV 格式(以及许多其他格式)。

从现有数据写入 CSV 文件很简单,如运行示例所示

file.withWriter { w ->
    new CsvMapper().writeValue(w, data)
}

这将数据写入我们的临时文件,就像我们在前面的示例中看到的那样。一个细微的差别是,默认情况下,只有包含空格的值才会用双引号括起来,但与其他库一样,有很多配置选项可以调整这些设置。

可以使用以下代码读取数据

def mapper = new CsvMapper().readerForListOf(String).with(CsvParser.Feature.WRAP_AS_ARRAY)
file.withReader { r ->
    assert mapper.readValues(r).readAll() == data
}

我们更复杂的示例也是以类似的方式完成的

def schema = CsvSchema.emptySchema().withHeader()
def mapper = new CsvMapper().readerForMapOf(String).with(schema)
file.withReader { r ->
    def rows = mapper.readValues(r).readAll()
    assert rows.size() == 21
    assert rows.collect { it.firstname + ' ' + it.lastname }.toSet().size() == 15
    assert rows*.team.toSet().size() == 10
    assert rows*.country.toSet().size() == 9
}

在这里,我们告诉库使用我们的标题行,并将每行数据存储在一个映射中。

Jackson Databind 还支持写入类,包括 JavaBean 以及记录。让我们创建一个记录来保存我们的自行车手信息

@JsonCreator
record Cyclist(
        @JsonProperty('stage') int stage,
        @JsonProperty('firstname') String first,
        @JsonProperty('lastname') String last,
        @JsonProperty('team') String team,
        @JsonProperty('country') String country) {
    String full() { "$first $last" }
}

请注意,我们再次可以指示记录组件名称与 CSV 文件中使用的名称不匹配的地方,我们只需在指定属性时提供替代名称即可。还有其他选项,例如指示某个字段是必需的,或提供其列位置,但我们在这个示例中不需要这些选项。我们还添加了一个 full() 辅助方法来返回自行车手的全名。

Groovy 将在支持的平台(JDK16+)上使用原生记录,或在更早的平台上使用模拟记录。

现在,我们可以编写记录反序列化的代码

def schema = CsvSchema.emptySchema().withHeader()
def mapper = new CsvMapper().readerFor(Cyclist).with(schema)
file.withReader { r ->
    List<Cyclist> records = mapper.readValues(r).readAll()
    assert records.size() == 21
    assert records*.full().toSet().size() == 15
    assert records*.team.toSet().size() == 10
    assert records*.country.toSet().size() == 9
}

结论

我们已经介绍了将 CSV 文件写入字符串、域类和记录以及从这些地方读取 CSV 文件的方法。我们已经介绍了如何手动处理简单情况,并介绍了 OpenCSV、Commons CSV 和 Jackson Databind CSV 库。

其他使用 Groovy 进行数据科学示例的代码
https://github.com/paulk-asert/groovy-data-science