1. 简介
CSV 是一种极其通用的数据格式:它可压缩、人类可读,并且被主流电子表格软件(如 MS Excel、Google Sheets、LibreOffice)广泛支持。更重要的是,CSV 文件可以轻松拆分或合并,非常适合并行处理和自动化数据采集。
那么 Kotlin 在处理 CSV 上表现如何?
得益于 Kotlin 对函数式编程的天然支持,编写批量处理任务变得非常简洁高效。本文将探讨仅使用 Kotlin 标准库的方法,也会介绍几个能显著提升开发效率的第三方库,特别是在流式处理和大批量场景下。
2. 使用纯 Kotlin 读写 CSV
CSV 看似简单,但一旦数据源不可控——比如字段包含逗号、引号或前后空格——手写解析逻辑就会迅速变得复杂。✅ 虽然完全可以用纯 Kotlin 实现一个简易解析器,但使用成熟库能帮你规避大量边界情况。
先看一个简单的 CSV 示例:
"Year", "Score", "Title"
1968, 86, "Greetings"
1970, 17, "Bloody Mama"
1970, 73, "Hi, Mom!"
该文件记录了罗伯特·德尼罗参演电影的年份、评分和片名。我们希望将其解析为如下数据类:
data class Movie(
val year: Year,
val score: Int,
val title: String,
)
假设条件如下:
- 第一行为 header
- 每行有三个字段
- 前两个字段为数字
只需处理行尾空行和多余空格即可:
fun readCsv(inputStream: InputStream): List<Movie> {
val reader = inputStream.bufferedReader()
val header = reader.readLine()
return reader.lineSequence()
.filter { it.isNotBlank() }
.map {
val (year, rating, title) = it.split(',', ignoreCase = false, limit = 3)
Movie(Year.of(year.trim().toInt()), rating.trim().toInt(), title.trim().removeSurrounding("\""))
}.toList()
}
val movies = readCsv(File("movies.csv").inputStream())
⚠️ 踩坑提醒:如果 title
字段出现在前面,而片名中又含有逗号(如 “New York, New York”),这种基于 split(",")
的方案就直接失效了。所以它只适用于结构高度规整的文件。
相比之下,写入 CSV 要简单得多,无需依赖任何外部库:
fun OutputStream.writeCsv(movies: List<Movie>) {
val writer = bufferedWriter()
writer.write(""""Year", "Score", "Title"""")
writer.newLine()
movies.forEach {
writer.write("${it.year}, ${it.score}, \"${it.title}\"")
writer.newLine()
}
writer.flush()
}
FileOutputStream("output.csv").apply { writeCsv(movies) }
当然,这个写法是高度定制化的,仅适用于 Movie
类。不过反过来看,大多数 CSV 库也需要你做类似的“模型到泛型”的适配工作。
再来看一个更复杂的例子:
"Index", "Item", "Cost", "Tax", "Total"
1, "Fruit of the Loom Girl's Socks", 7.97, 0.60, 8.57
2, "Banana Boat Sunscreen, 8 oz", 6.68, 0.50, 7.18
这个文件为了可读性加入了大量空格和制表符。❌ 对我们的纯 Kotlin 解析器来说是个挑战:不仅字段前后有空白,第二列内容本身还包含逗号。这种情况下,仅靠语言原生能力很难稳定解析。
3. kotlin-csv 库
确实存在纯 Kotlin 编写的 CSV 库,比如 kotlin-csv。但它尚未成为事实标准。更重要的是,kotlin-csv 对非严格格式的支持较弱,以上述含税商品数据为例,会出现三个问题:
- 默认配置下,无法正确处理 header 中逗号后的空格(如
"Index", "Item"
) - 设置
escapeChar = '\\'
后虽能运行,但每行 key 会残留空格和引号 - 遇到商品名中包含逗号的情况(如 Banana Boat...)时,会错误地将一行解析成六列
但如果数据符合严格 CSV 规范,使用 kotlin-csv 非常方便:
fun readStrictCsv(inputStream: InputStream): List<TaxableGood> = csvReader().open(inputStream) {
readAllWithHeaderAsSequence().map {
TaxableGood(
it["Index"]!!.trim().toInt(),
it["Item"]!!.trim(),
BigDecimal(it["Cost"]),
BigDecimal(it["Tax"]),
BigDecimal(it["Total"])
)
}.toList()
}
✅ 亮点:通过 Sequence
实现了流式处理,避免一次性加载整个文件到内存,在处理大文件时至关重要。
4. Apache CSV 库
当 kotlin-csv 不够用时,我们可以转向 JVM 生态的经典选择——Apache Commons CSV。
读取操作非常直观:
fun readCsv(inputStream: InputStream): List<TaxableGood> =
CSVFormat.Builder.create(CSVFormat.DEFAULT).apply {
setIgnoreSurroundingSpaces(true)
}.build().parse(inputStream.reader())
.drop(1) // 跳过 header
.map {
TaxableGood(
index = it[0].toInt(),
item = it[1],
cost = BigDecimal(it[2]),
tax = BigDecimal(it[3]),
total = BigDecimal(it[4])
)
}.toList()
Apache CSV 提供多种预设格式:
DEFAULT
:默认格式RFC4180
:更严格,不允许空行EXCEL
:兼容 Excel 导出格式TDF
:用于.tsv
制表符分隔文件
此外还支持添加文件级注释、设置注释标记符等高级功能。
忽略周围空白后,字段可直接按数组索引访问,非常方便。
写入更加简单:
fun Writer.writeCsv(goods: List<TaxableGood>) {
CSVFormat.DEFAULT.print(this).apply {
printRecord("Index", "Item", "Cost", "Tax", "Total")
goods.forEach { (index, item, cost, tax, total) ->
printRecord(index, item, cost, tax, total)
}
}
}
⚠️ 注意:CSV 本身不定义“美观排版”,因此无法还原原始数据中的对齐制表符。
5. FasterXML Jackson CSV 库
Jackson 并非最轻量的选择——它需要初始化 CsvMapper
实例(建议缓存复用),配置略显繁琐。但它的优势在于类型安全和与 JVM 生态的良好集成。
要解析上述含税商品文件,需先定义 mapper 和 schema:
val csvMapper = CsvMapper().apply {
enable(CsvParser.Feature.TRIM_SPACES)
enable(CsvParser.Feature.SKIP_EMPTY_LINES)
}
val schema = CsvSchema.builder()
.addNumberColumn("Index")
.addColumn("Item")
.addColumn("Cost")
.addColumn("Tax")
.addColumn("Total")
.build()
通过启用 TRIM_SPACES
和 SKIP_EMPTY_LINES
,我们让 Jackson 容忍数据周围的空白和末尾空行。
接着,需要在数据类上使用 @JsonProperty
注解进行字段映射,因为列名与属性名不完全一致:
data class TaxableGood(
@field:JsonProperty("Index") val index: Int,
@field:JsonProperty("Item") val item: String?,
@field:JsonProperty("Cost") val cost: BigDecimal?,
@field:JsonProperty("Tax") val tax: BigDecimal?,
@field:JsonProperty("Total") val total: BigDecimal?
) {
constructor() : this(0, "", BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO)
}
⚠️ 必须提供无参构造函数,否则 Jackson 无法实例化对象。可通过手动添加,或使用 Kotlin 的 no-arg 编译插件 自动生成。
完成配置后,读取数据非常简洁:
fun readCsv(inputStream: InputStream): List<TaxableGood> =
csvMapper.readerFor(TaxableGood::class.java)
.with(schema.withSkipFirstDataRow(true))
.readValues<TaxableGood>(inputStream)
.readAll()
写入也同样清晰:
fun OutputStream.writeCsv(goods: List<TaxableGood>) {
csvMapper.writer().with(schema.withHeader()).writeValues(this).writeAll(goods)
}
✅ 最大优势:直接将 CSV 行映射为类型安全的数据类实例,后续业务逻辑处理极为方便。
6. 总结
本文对比了多种 Kotlin 中处理 CSV 的方式:
方案 | 优点 | 缺点 | 推荐场景 |
---|---|---|---|
✅ 纯 Kotlin | 无依赖、轻量 | 难以处理复杂格式 | 数据结构固定且简单 |
✅ kotlin-csv | 纯 Kotlin 实现 | 对非标准格式支持差 | 严格遵循 CSV 标准的小文件 |
✅ Apache CSV | 稳定、灵活、JVM 经典 | 无直接对象映射 | 需要精细控制格式的大批量处理 |
✅ Jackson CSV | 类型安全、自动映射 | 启动开销大、需配置 schema | 强类型需求、与 Spring 等框架集成 |
📌 最终建议:
- 若追求稳定性与灵活性,优先考虑 Apache CSV
- 若项目已使用 Jackson 且强调类型安全,推荐 Jackson CSV
- 仅在数据结构极简且不愿引入依赖时,才考虑纯 Kotlin 手写方案
文中所有代码示例均可在 GitHub 获取:https://github.com/Baeldung/kotlin-tutorials/tree/master/kotlin-libraries-2