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 对非严格格式的支持较弱,以上述含税商品数据为例,会出现三个问题:

  1. 默认配置下,无法正确处理 header 中逗号后的空格(如 "Index", "Item"
  2. 设置 escapeChar = '\\' 后虽能运行,但每行 key 会残留空格和引号
  3. 遇到商品名中包含逗号的情况(如 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_SPACESSKIP_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


原始标题:Read and Write CSV Files With Kotlin