使用 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 文件变得更容易。这是写入我们的 CSV 的代码,它使用了 CSVPrinter

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 库的另一个优点;手动完成这方面的工作将相当麻烦。另请注意,为简单起见,我们没有像前面的示例那样创建完整的*结果*消息。相反,我们只检查了我们之前计算的所有相关集合的大小。

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 文件写入字符串、域类和记录以及从它们读取。我们研究了手动处理简单情况,还研究了 OpenCSV、Commons CSV 和 Jackson Databind CSV 库。

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