ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Apache Commons CSV实战:从手工拼接翻车到编码与分隔符避坑

Apache Commons CSV实战:从手工拼接翻车到编码与分隔符避坑 刚写Java那几年我做CSV导出都用最朴素的办法拿一条StringBuilder字段之间拼逗号行尾拼\r\n。直到有一次线上工单导出功能翻车——用户备注字段里既有英文逗号又有双引号导出的数据从那一列开始全线错位一个工单的备注被劈成好几列第二天一大早就被运营同事找上门。那次以后我才认真研究Apache Commons CSV这个工具类才发现CSV生成与解析的水比想象中深直接把逗号拼字符串的做法在真实数据面前根本扛不住。这篇东西算是我在实际项目里用Commons CSV做导入导出、以及踩过各种坑之后的一篇总结适合刚开始处理CSV的Java开发也适合那些已经在用Commons CSV但偶尔被中文乱码、Excel兼容性折腾到怀疑人生的朋友。1. 为什么我弃用手写split一条备注里的双引号引发的线上事故1.1 手工拼CSV时必然遇到的边界问题很多人第一次接触CSV会下意识觉得这格式太简单了不就是“逗号分隔的纯文本”嘛。可一旦进入真实业务数据你会发现下面这几种情况一个比一个致命。字段里带英文逗号比如商品名叫“华为,小米”手工split出来的结果会从逗号中间劈开。字段里带双引号比如“他说好的”导出的文本不仅少个引号整个文本语义都变了。字段里有换行比如用户填的收货地址中间夹了一个换行生成的文件直接多出一行后续所有行偏移。字段首尾有空格程序里看不出问题Excel打开后要么展示时多了空格要么被某些工具自动trim掉数据对不上。当时我那个工单导出用的就是StringBuilder.append(field).append(,)正好撞上用户备注里的英文双引号和逗号。线上数据不是测试数据用户输入什么都有那一刻我才意识到CSV看着是个“玩具格式”实际上边界条件一点都不少。那时候再去Google发现这个问题早在2002年就有过详细讨论最终形成了RFC 4180规定了“字段分隔符、引号包场、重复双引号转义、CRLF换行”等一系列细节。而Apache Commons CSV就是把这些细节做得最省心的Java实现之一。1.2 Commons CSV解决的本质问题把格式细节封装成“合同”在没引入Commons CSV之前每个开发者对CSV的理解都不同有人用逗号分隔有人用Tab有人只处理单行有人遇到引号就转义一次。实际上格式的不统一带来的问题比编码问题更隐蔽。Commons CSV把格式定义抽象成一个CSVFormat对象这个对象就是一份“格式合同”它规定了分隔符是什么、引号是什么、换行符是什么、是否需要忽略空行、是否将首行当作表头、是否有Null字符串表示法。写入时CSVPrinter按合同把字段转成合规文本读取时CSVParser按同一份合同把文本还原成字段数组。开发者只要在代码里知道“我的数据长什么样”不用再关心“这个数据里有逗号怎么办、有引号怎么办、有换行怎么办”这些都交给库处理。这个抽象对团队协作尤其重要两个人只要约定用同一个CSVFormat生成的CSV就能被对方正确解析不再出现“我这边导出的你那边读不了”的口水仗。1.3 引入依赖与整体API印象Maven项目里加依赖非常简单截止到本文写作时最新稳定版是1.11.0dependency groupIdorg.apache.commons/groupId artifactIdcommons-csv/artifactId version1.11.0/version /dependency整个库的核心API就三个CSVFormat负责定义格式CSVPrinter负责写CSVParser配合CSVRecord负责读。三者都不依赖Spring、不依赖Servlet容器就是个普普通通的Java库在任何Java 8及以上的项目里都能直接用。用一句话总结定位它不解决“数据从哪来、到哪去”的问题只专注解决“一段数据如何安全地变成CSV文本、以及CSV文本如何安全地还原成数据”这个问题。2. 生成CSV的落地写法从CSVFormat到CSVPrinter2.1 一个能直接抄的订单导出示例先给一个完整的生成示例。假设我要把一批订单导出成CSV列是“订单号、商品、数量、单价、备注”最标准的写法是这样Path path Paths.get(orders.csv); try (BufferedWriter writer Files.newBufferedWriter(path, StandardCharsets.UTF_8); CSVPrinter printer new CSVPrinter(writer, CSVFormat.DEFAULT.builder() .setHeader(订单号, 商品, 数量, 单价, 备注) .get())) { printer.printRecord(A1001, 智能手表, 2, 1299.00, 顺丰加急); printer.printRecord(A1002, 机械键盘, 1, 499.00, 开发用需要手托); printer.printRecord(A1003, 显示器, 1, 1899.00, 他说\要原箱\); }这段代码执行完orders.csv内容长这样订单号,商品,数量,单价,备注 A1001,智能手表,2,1299.00,顺丰加急 A1002,机械键盘,1,499.00,开发用需要手托 A1003,显示器,1,1899.00,他说要原箱注意第二行和第三行的备注字段字段值里带了中文逗号与英文双引号Commons CSV会自动用英文双引号把整个字段包起来字段内部的双引号则写成两个连续双引号。这个规则完全符合RFC 4180Excel也认。你不用在业务代码里写任何转义逻辑这是对比手工拼字符串最直观的优势。如果你还在用老版本Commons CSV或者网上搜到的是旧写法CSVFormat.DEFAULT.builder()的老前辈是CSVFormat.DEFAULT.withHeader(...)两者结果一样只是1.10.0之后官方推荐builder方式链式可读性更强。老项目升级时withHeader依然可用不会立刻废掉。2.2 表头、printRecord与print的差别CSV格式本身没有“表头行”这个特殊概念所谓表头就是第一行记录恰好是字段名。因此Commons CSV里setHeader做的事只是在第一次写入记录时自动把表头打出来省得你自己手动print一次。用printRecord(Object... values)是最常见的方式它把传入的字段按顺序输出并在行尾自动追加记录分隔符。需要注意它与print(Object... values)的区别print只输出字段不追加换行适用于你想自己控制行尾、或者一条记录跨多行输出的场景正常业务里99%都用printRecord就够了。还有一个我实际用过的点CSVPrinter不是线程安全的。如果你的导出逻辑里多个线程同时往同一个printer里写记录会互相穿插出现行内容错乱。多线程导出时要么每个线程自己维护一个printer最后合并文件要么在调用层加锁。别指望CSVPrinter内部会帮你做并发控制。2.3 自动转义特殊字符不需要你操心很多人见到上面那个输出案例第一反应是“原来引号和逗号会被自动包起来”但不知道背后的判断条件。Commons CSV的默认逻辑是字段值里只要出现了分隔符逗号、引号、换行符中的任意一个就会用引号包住整个字段字段内部的引号再翻倍。这个判断是按单个字段做的所以你完全不用事先扫描整行数据。写一个实验代码更直观printer.printRecord(华为,小米, 他说\好的\, 第一行\n第二行);输出会是华为,小米,他说好的,第一行 第二行第一列因为含逗号被引号包住第二列因为含双引号被引号包住且内部引号翻倍第三列因为含换行被引号包住读取端还原时第三列会取回那个真实的换行符。这就是“生成”这一侧最核心的机制理解了它你再也不会写出“用StringBuilder拼逗号”的代码。写大文件时还有一个容易被忽略的性能点CSVPrinter内部虽然有缓冲但如果你在循环里每次都调用flushIO次数会暴增几百万行数据能慢到人烦躁。正确做法是全部写完再flush一次或者直接用try-with-resources让close()负责清理。说到底CSV写入的性能瓶颈通常不在Commons CSV本身而在你是否让Writer频繁flush。3. 解析CSV的落地写法CSVParser、流式与列名映射3.1 解析的基本姿势与列名访问读取是生成的反过程核心类是CSVParser和CSVRecord。拿到了带表头的CSV最省心的解析方式是这样Path path Paths.get(orders.csv); try (BufferedReader reader Files.newBufferedReader(path, StandardCharsets.UTF_8); CSVParser parser new CSVParser(reader, CSVFormat.DEFAULT.builder() .setFirstRecordAsHeader(true) .get())) { for (CSVRecord record : parser) { String orderNo record.get(订单号); String product record.get(商品); String remark record.get(备注); System.out.println(orderNo - product - remark); } }.setFirstRecordAsHeader(true)的作用是把第一行文本当作列名表之后每一条CSVRecord都可以直接用record.get(列名)取值。这样做有几点好处代码可读性好、不怕列顺序变化、后期加列也不会破坏已有解析逻辑。如果没有表头或者你想完全按列号操作也可以退回到record.get(0)、record.get(1)这样的方式。但我不建议在列数多、列顺序不确定的场景里这么写太脆了上游调整一次列顺序你的解析逻辑就全乱了。3.2 什么时候用getRecords什么时候用迭代器CSVParser提供了两种获取数据的方式parser.getRecords()和直接for循环遍历parser。两者差别很大。getRecords()会一次性把所有行解析成ListCSVRecord返回。文件只有几千行时没感觉可一旦到了几十万行、上百万行内存里同时堆着上百万个字符串数组GC压力会非常大经常把老年代占满。而for (CSVRecord record : parser)这种方式走的是迭代器解析一条、消费一条内存里最多只保留当前记录和少量的缓冲数据非常适合批量入库、批量转换这类场景。打个比方getRecords()像看电影前一次性把全年爆米花全买回家堆满客厅而迭代器像边看电影边随手拿一颗吃看完客厅还是空的。我实际处理过一份约80万行的业务明细CSV用getRecords()直接导致堆内存飙到600多兆改成for循环迭代后稳定在100多兆以内处理时间也没增加。所以只要不是必须随机访问多条记录的情况都优先用迭代器。3.3 无表头、缺字段、空行的现实处理真实世界里的CSV远比教程里的干净样例脏。我列几个最常见的考验。第一空行怎么处理。默认的CSVFormat.DEFAULT解析时会把空行也当作一条记录只是字段数量为0。如果你不想要这个行为在定义format时加上.setIgnoreEmptyLines(true)解析器会自动跳过空行。第二字段缺失。如果某一行比表头少了一列用record.get(列名)不会抛异常而是返回null。官方推荐用record.isSet(列名)先判断该列是否存在再做业务处理避免NPE。第三整行转成Map。record.toMap()可以把当前记录转成MapString, String在需要把CSV行映射成Java对象时很方便。但注意列名重复时后出现的列值会覆盖先出现的设计表头时要保证列名唯一。第四带BOM的文件。如果CSV是用Windows记事本或Excel保存的UTF-8 with BOMsetFirstRecordAsHeader(true)解析时第一个表头字段名里会附着一个看不见的\uFEFF字符这时候record.get(订单号)会找不到列因为实际列名变成了“\uFEFF订单号”。处理办法有两个读取时去掉BOM或者解析后把列名的\uFEFF替换掉。这个点我会在第4部分展开讲。4. 三个高频深坑的完整排查链路中文乱码、Excel分隔符水土不服、split被击穿4.1 中文乱码Excel打开满屏“锟斤拷”的定位过程中文乱码是CSV相关提问里出现频率最高的问题甚至没有之一。我复盘一次完整排查过程。现象Java程序用UTF-8写出CSV用IntelliJ IDEA打开完全正常用Excel双击打开后中文全是乱码。第一步先确认文件本身的编码。用文本编辑器打开CSV如果中文正常说明文件编码确实是UTF-8问题出在“Excel默认按什么编码打开无BOM的CSV”上。第二步了解Excel的行为。Windows版Excel打开CSV文件时默认按系统本地ANSI编码来解码在简体中文系统上就是GBK。用UTF-8编码且没有BOM的文件Excel把它当GBK读自然出现乱码。第三步决定方案。输出GBK编码国内Windows上正常但一旦文件流转到macOS、Linux或者繁体中文环境又会出现新的乱码输出UTF-8无BOM跨平台最好但Excel直开有问题输出UTF-8 with BOMExcel能正确识别为UTF-8Java程序也能正常读只是许多老Unix工具会把BOM当成字段内容。我这边实际给的方案是“导出给普通业务人员用Excel打开的话写UTF-8 BOM导出给程序做数据交换的话写标准UTF-8不带BOM”。加BOM的写法很简单try (BufferedWriter writer Files.newBufferedWriter(path, StandardCharsets.UTF_8)) { writer.write(\uFEFF); try (CSVPrinter printer new CSVPrinter(writer, CSVFormat.DEFAULT.builder() .setHeader(订单号, 商品) .get())) { printer.printRecord(A1001, 智能手表); } }反过来如果程序要读这种带BOM的CSV解析前要处理掉BOM。最简单的方法是用Apache Commons IO的BOMInputStream包一层或者在解析后判断第一个表头字段是否以\uFEFF开头是就去掉。这个排查链路的关键经验是不要一看到中文乱码就怀疑代码写错了先去看文件的字节、再看打开它的软件默认行为。编码的问题用文本编辑器十六进制查看器基本能定位80%。4.2 Excel保存的CSV全挤在一列分隔符水土不服另一个高频坑出现在“接收别人给的CSV”时。有一次合作方发来一个CSV我用Commons CSV按默认格式解析每行record.size()永远是1所有字段全挤在同一列里。排查第一步先用文本编辑器打开文件发现分隔符是分号;不是逗号,。第二步确认这不是文件损坏而是合作方用了欧洲语言环境下的Excel。第三步查证发现Excel导出CSV时分隔符并不总是逗号它跟随系统的“列表分隔符”设置部分地区的Excel默认用分号。解决办法就一行解析时把分隔符改成文件实际使用的字符而不是想当然地用逗号。CSVFormat format CSVFormat.DEFAULT.builder() .setDelimiter(;) .get(); try (CSVParser parser new CSVParser(reader, format)) { // ... }我后来养成了一个习惯在写解析代码之前先拿文本编辑器打开CSV文件看第一行确定分隔符、换行符、是否有BOM再做解析。这一步看起来笨却省掉很多逻辑调试时间。顺便提一句我见过有人把“分隔符是分号”当成“文件坏了”直接让合作方重新导出结果对方换个环境导出的文件到了自己手里又变成另一种分隔符。与其让对方反复导出不如自己解析时兼容多种分隔符或者在交接文档里明确标注列分隔符。4.3 字段值自带换行/引号为什么split会被击穿这个坑其实就是我开篇提到的事故。如果你用split(,)去解析一个合规的CSV文件遇到类似下面这种内容A1001,华为,小米,他说好的,第一行 第二行split(,)会把“小米”前面的逗号当成字段分隔符把他说和好的拆得面目全非把字段内的换行当成行结束。因为CSV的字段是被引号包裹的引号内部的逗号和换行都是字段内容而不是结构符。Commons CSV的解析器是按RFC 4180的状态机逻辑处理的遇到引号就进入“引号内”状态引号内的逗号、换行一律当作普通字符直到遇到匹配的结束引号才回到字段分隔状态。所以如果你的系统里有人还在用正则或者split(,)处理CSV请尽快替换掉。这属于“表面上能用实际随时爆炸”的代码测试数据碰巧没有引号和换行时一切正常一旦来了真实用户输入就完了。5. 进阶配置与选型自定义CSVFormat、与其他CSV库的取舍5.1 定制自己的CSVFormatCommons CSV的CSVFormat可以通过builder任意调整格式我用过一个比较完整的定制示例专门处理管道分隔、忽略空行、兼容NULL字符串和前后空格的文件CSVFormat format CSVFormat.DEFAULT.builder() .setDelimiter(|) .setQuote() .setEscape(\\) .setIgnoreEmptyLines(true) .setIgnoreSurroundingSpaces(true) .setNullString(NULL) .setRecordSeparator(\n) .get();逐项解释一下各参数的实际用途。setDelimiter(|)适合生成给人看的报表因为管道符出现在普通文本里的概率比逗号低很多setQuote()指定包裹字段的引号字符setEscape(\\)设置转义字符这在使用MySQL导出数据时比较常见setIgnoreEmptyLines(true)跳过空行setIgnoreSurroundingSpaces(true)在解析时去除字段前后的空格但默认行为是保留的因为空格可能是有意义的内容setNullString(NULL)表示写入null字段时输出字符串NULL解析时碰到NULL再转回null适合与数据库导出的CSV对接。5.2 预置格式与常见参数一览Commons CSV自带了好几种预定义格式我用了很长一段时间才发现这些内置格式的价值。简单整理成表格预置格式分隔符说明CSVFormat.DEFAULT逗号最常用符合RFC 4180自动加引号转义CSVFormat.EXCEL逗号与Excel兼容性较好的配置空行处理略有差异CSVFormat.RFC4180逗号严格按RFC 4180标准CSVFormat.TDFTab制表符分隔适合日志、TSV场景CSVFormat.MYSQL逗号兼容MySQL导出文本格式CSVFormat.ORACLE逗号兼容Oracle SQL*Loader导出格式CSVFormat.POSTGRESQL_CSV逗号兼容PostgreSQL COPY格式大多数情况下你只需要CSVFormat.DEFAULT或者基于DEFAULT做少量定制。内置格式的价值在于对接数据库工具导出的文件时不用自己盲猜转义规则。5.3 该不该换库Commons CSV、OpenCSV与uniVocity的取舍写到这里可能有人会问Java生态里不只Commons CSV一个CSV库还有OpenCSV、uniVocity为什么选它我实际对比下来的结论是方面Apache Commons CSVOpenCSVuniVocity依赖体积轻量无额外依赖中依赖commons-lang等较重Bean映射不支持需手动转支持CsvBindByName等注解支持解析性能中上中等高适合超大文件API风格简洁、贴近底层功能多但稍显繁琐配置丰富但上手成本高适用场景大多数日常导入导出需要快速把CSV映射成JavaBean几千万级数据、复杂格式容错如果只是“把数据写进CSV、把CSV读回数据”Commons CSV是成本最低的选择如果项目里遍布“CSV行直接映射成实体类”的需求OpenCSV能省去你写大量手工setter的代码如果数据量大到百万行以上、格式错误又多uniVocity的容错能力和性能会体现出来。就我个人习惯来说现在凡是在Java项目里遇到CSV读写我第一反应永远是Commons CSV。理由很简单它足够小、足够稳、没有侵入性核心逻辑一眼能看懂。等到确实出现了“需要Bean映射”或者“千万级数据”的需求再评估是否引入更重的库不迟而不是一开始就把所有工具都堆进项目里。最后再分享两个小习惯一是把一个项目的CSVFormat定义成常量避免散落各处出现不同配置二是写完导出功能后一定要用文本编辑器打开生成文件看一眼前几行确认分隔符、引号和编码符合预期这个习惯帮我拦下过好几次即将上线的低级错误。
返回列表