尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java CSV处理实战:Apache Commons CSV核心用法与踩坑指南

Java CSV处理实战:Apache Commons CSV核心用法与踩坑指南 1. 为什么是Apache Commons CSV做Java后端的人早晚都会遇到CSV处理的需求。导报表、导账单、做数据迁移、接第三方系统的批量接口CSV几乎是无处不在的通用交换格式。很多人的第一反应是CSV不就是用逗号把字段拼起来吗我直接用String.split(,)或者自己拼一个StringBuilder不就完事了这种想法我也曾经有过直到真正面对真实业务数据时才明白CSV的“简单”只是表面现象坑全藏在那些看似不起眼的边界情况里。比如字段内容里恰好有逗号怎么办用户填的备注信息里带了换行怎么办Excel导出的CSV里字段被双引号包裹解析时要不要去掉文件里有空行和注释行怎么跳过还有那个绕不开的经典问题为什么用Java生成的CSV用Excel打开之后中文全是乱码这些问题自己一个个去处理不是不行但每做一个项目就要重写一遍而且几乎每次都会漏掉一两个边界条件。Apache Commons CSV的出现就是把这些通用逻辑收敛成一个标准库它把“CSV格式”本身抽象成CSVFormat对象把解析和输出统一收敛到CSVParser和CSVPrinter两个核心类里。你不需要再关心字段怎么转义、引号怎么处理、换行用什么符号只要告诉它“我要用哪种CSV规范”就行。这篇文章主要面向需要做数据导入导出、报表生成、批量任务处理的Java开发者也适合刚接触CSV处理、想知道怎么写才不至于踩坑的新手。我会从格式设计原理讲起再分别给出生成和解析的完整实操方案最后把我这几年实际工作中踩过的坑和排查思路一起整理出来。2. 核心API与格式模型2.1 CSVFormat抓住格式就抓住了一切Apache Commons CSV最核心的设计思想是把“CSV的方言”封装成一个配置对象也就是CSVFormat。不同的系统、不同的工具对CSV的理解并不完全一致有的用逗号做分隔符有的用制表符有的要求所有字段用双引号包起来有的只在字段包含特殊字符时才加引号有的换行符是\r\n有的是\n。如果你把这些差异散落在代码各处维护起来就是一场灾难。而CSVFormat把这些问题一次集中解决。实际编程中我们会通过CSVFormat.Builder来构建自定义格式也可以通过库内置的静态实例直接使用。构造函数的方式在新版本中已经不太推荐比如CSVFormat.DEFAULT.withDelimiter(;)这种链式调用虽然还能用但官方更推荐Builder方式CSVFormat format CSVFormat.Builder.create(CSVFormat.DEFAULT) .setDelimiter(;) .setQuote() .setRecordSeparator(\r\n) .setHeader(name, age, remark) .setSkipHeaderRecord(true) .build();这个配置对象里比较重要的几个参数按我的经验排个序delimiter字段分隔符默认是英文逗号但欧洲一些系统习惯用分号TDF格式用制表符。quote引号字符默认双引号。当字段内容里出现分隔符、引号或换行符时库会自动用引号把它包起来。escape转义字符。默认情况下列内包含引号时用双引号本身来转义也就是表示一个这是RFC4180的标准。也可以单独设置一个反斜杠转义具体看对端系统的要求。recordSeparator记录分隔符也就是“换行符”常见的有\n和\r\n。header表头定义可以用字符串数组、枚举或者解析时用第一行作为表头。skipHeaderRecord解析时是否跳过第一条记录配合header使用。ignoreEmptyLines是否忽略空行默认true。ignoreSurroundingSpaces是否忽略字段前后的空白字符默认false。nullString把某个字符串值映射为null常用于数据库中空值导出后回读的场景。commentMarker注释行的标识符比如#遇到以它开头的行解析时直接跳过。quoteMode什么时候给字段加引号具体见后面的生成章节。刚开始接触会觉得参数多但记住一个原则就好先选一个和你目标格式最接近的内置格式再按需调整不要从零开始搭一个格式。2.2 内置格式怎么选DEFAULT、EXCEL、RFC4180还是TDFCSVFormat内置了多个常见的格式实例用哪个取决于你的对端是谁。我整理了对比表方便你快速决策内置格式分隔符引号规则换行符适用场景CSVFormat.DEFAULT逗号最小值转义\r\n通用场景Java之间互相传递CSVFormat.EXCEL逗号最小值转义\r\n要交给Excel打开的文件CSVFormat.RFC4180逗号最小值转义\r\n符合RFC4180规范的标准CSVCSVFormat.TDF制表符最小值转义\r\nTab分隔的文本常用于日志、分析场景CSVFormat.MYSQL逗号全字段引号\nMySQL的LOAD DATA INFILECSVFormat.POSTGRESQL_CSV逗号全字段引号\nPostgreSQL的COPY命令导入CSVFormat.ORACLE逗号全字段引号\r\nOracle SQL*Loader这里要特别说一句DEFAULT和EXCEL在源码实现上其实很接近但EXCEL格式的解析宽容度更好一些遇到一些不合规的数据时不容易直接抛异常更适合解析从各种乱七八糟来源拿来的Excel导出文件。2.3 CSVParser、CSVRecord、CSVPrinter三个主角理解了CSVFormat剩下的三个类就很好上手了。CSVParser负责把Reader或File转换成可迭代的记录流。注意它创建后持有底层Reader用完必须关闭最稳妥的方式是写在try-with-resources里。CSVRecord表示一行记录。它有两种取值方式可以按序号record.get(0)也可以按表头名record.get(name)。isSet(header)可以判断某列是否存在isMapped(header)可以判断表头映射是否生效。CSVPrinter负责把数据一行一行写出去。它的print方法会把字段自动做转义和引号处理println会结束当前行并输出换行符printRecords可以直接接收一个集合或者另一个CSVParser实现快速复制。三个类配合CSVFormat基本能覆盖CSV处理的所有常规需求。下面两章我会分别用生成和解析两个场景把你实际写代码时会遇到的处理细节全部展开。3. 生成CSV文件的实操3.1 最小可运行示例从输出到文件先看一个最基础的生成示例。假设我要导出一份用户列表字段有姓名、年龄和备注。用Commons CSV就是下面这几行Path path Paths.get(users.csv); try (BufferedWriter writer Files.newBufferedWriter(path, Charset.forName(UTF-8)); CSVPrinter printer new CSVPrinter(writer, CSVFormat.DEFAULT)) { printer.printRecord(张三, 28, 喜欢爬山); printer.printRecord(李四, 32, 备注里有,逗号也有\引号\); printer.printRecord(王五, 25, 备注里\n有换行); }生成的users.csv内容大概是这样的张三,28,喜欢爬山 李四,32,备注里有,逗号也有引号 王五,25,备注里 有换行注意看第二行和第三行只要字段里出现了逗号、引号或换行CSVPrinter会自动给整个字段加上双引号字段内部的引号用两个双引号转义。这就是你手写String.join(,, list)做不到的事情。如果你硬拼字符串第二行会被Excel拆成4列第三行直接变成两行数据整个导入流程就崩了。提示输出时记得指定字符集。Files.newBufferedWriter默认使用UTF-8但如果你用new FileWriter它会依赖操作系统默认字符集在Windows上很容易出现编码问题。建议始终显式指定Charset.forName(UTF-8)。3.2 带表头与自定义分隔符实际业务里表头基本都会有。用withHeader指定表头后printRecord时不需要再手动把表头写进去库会在第一次输出时自动写入。代码可以这样写CSVFormat format CSVFormat.DEFAULT.builder() .setHeader(name, age, remark) .build(); try (BufferedWriter writer Files.newBufferedWriter(path, UTF_8); CSVPrinter printer new CSVPrinter(writer, format)) { printer.printRecord(张三, 28, 喜欢爬山); }这种情况下不需要setSkipHeaderRecord那是解析时用的选项对写入没有影响。再举一个自定义分隔符的场景。有时候对端系统要求用分号做分隔符因为它的数据内容里本身包含大量逗号。这种需求一行代码就能搞定CSVFormat format CSVFormat.DEFAULT.builder() .setDelimiter(;) .setHeader(name, age, remark) .build();生成出来的内容就是张三;28;喜欢爬山。用CSVFormat的好处就在这里业务代码完全不变只要换一个格式对象整个文件的方言就变了。3.3 转义规则与QuoteMode什么时候给字段加引号quoteMode决定了库在多频繁的情况下给字段加引号。默认是QuoteMode.MINIMAL也就是只在字段包含特殊字符时才加引号最省空间也最接近RFC4180规范。但有些场景需要特殊处理QuoteMode.ALL所有字段都加双引号。MySQL导入、一些老系统对接时比较常见因为它们的解析器实现简单看到引号就知道字段边界。QuoteMode.NON_NUMERIC非数字字段都加引号。注意这里的“数字”判断很粗暴所有字段都当作非数字处理的话它会给每个字段都加引号实际效果和ALL类似。它更适合解析场景。QuoteMode.ALL_NON_NULL非null字段都加引号null字段输出为空字符串不加引号。我建议默认情况下用MINIMAL除非对端有明确要求否则不要盲目改成ALL因为同样的数据用ALL生成的体积会大不少而且很多解析器对“被引号包裹的字段里包含换行”的处理并不统一。还有一点容易忽略如果字段本身是nullprintRecord会输出一个空字符串不会输出字符串null。如果你的业务里null和空字符串有区别导出时最好先把null转成明确的占位符或者用setNullString(\\N)这类配置否则到对端那里会丢失语义。3.4 中文乱码与Excel兼容写法这是老生常谈但每次项目里遇到CSV导出总会有人再踩一遍。Java生成的UTF-8编码CSV文件直接用Excel打开时中文经常显示成乱码。原因在于Excel默认用ANSI编码去解读文件而Java默认输出UTF-8。明明文件本身没问题但用户感知就是乱码这就很冤枉。解决方式有两个。第一个最稳妥在文件开头写入UTF-8的BOM字节顺序标记也就是三个字节0xEF 0xBB 0xBF。Excel读取到BOM后会识别为UTF-8编码中文就能正常显示。用Java实现Path path Paths.get(users_with_bom.csv); try (OutputStream out Files.newOutputStream(path); Writer writer new BufferedWriter(new OutputStreamWriter(out, UTF_8))) { // 写入UTF-8 BOM out.write(0xEF); out.write(0xBB); out.write(0xBF); try (CSVPrinter printer new CSVPrinter(writer, CSVFormat.DEFAULT)) { printer.printRecord(姓名, 年龄); printer.printRecord(张三, 28); } }注意顺序要先拿到原始的OutputStream写入BOM字节再包上Writer。如果你直接用Files.newBufferedWriterBOM就没有地方插进去了。这种方式生成的CSVExcel双击打开完全正常。第二种方式是直接输出GBK编码try (BufferedWriter writer Files.newBufferedWriter(path, Charset.forName(GBK)); CSVPrinter printer new CSVPrinter(writer, CSVFormat.DEFAULT)) { ... }这种方式也能解决Excel中文乱码但代价是文件不再通用如果你还指望用其他工具、脚本去处理这个CSV建议还是用UTF-8加BOM的方案兼容性更好。注意不是所有场景都需要BOM。如果你生成的CSV是给后端程序自动解析的BOM反而可能成为解析器的负担有些解析器会把第一个表头字段解析成\uFEFFname这种带前缀的奇怪值。所以我的经验是给人看加BOM给程序用干净UTF-8。3.5 追加写入与大批量导出业务上还有一类常见需求分批导出多次往同一个文件里追加数据。实现不复杂关键是不要重复写表头。CSVFormat format CSVFormat.DEFAULT.builder() .setHeader(name, age) .build(); try (BufferedWriter writer Files.newBufferedWriter(path, UTF_8, StandardOpenOption.APPEND); CSVPrinter printer new CSVPrinter(writer, format)) { // 第一次创建文件时写表头之后追加时不能再写 if (Files.size(path) 0) { // 通过printRecord写一次表头或者绕过CSVPrinter直接手动写 } printer.printRecord(张三, 28); }有一个细节要提醒你CSVPrinter的printRecord不会自动判断“当前文件是不是空的”所以追加场景下最简单的做法是不要用setHeader配置表头而是在第一次创建文件时手动把表头字符串写进去后续追加只写数据行。否则你没法控制表头是否重复。大批量导出时除了用追加写还要注意不要一次性把所有数据都加载到内存里再写。正确姿势是从数据库分页查询每查出一批就printRecord一批写完一批后flush一次。这样无论是100万行还是1000万行内存占用都能保持稳定。4. 解析CSV文件的实操4.1 基础解析流程Reader、Parser、Record三步走解析的核心代码比生成还要简洁。通过CSVFormat创建CSVParser然后遍历CSVRecord即可Path path Paths.get(users.csv); try (Reader reader Files.newBufferedReader(path, UTF_8); CSVParser parser new CSVParser(reader, CSVFormat.DEFAULT)) { for (CSVRecord record : parser) { String name record.get(0); String age record.get(1); String remark record.get(2); System.out.println(name - age - remark); } }这里有个很实用的能力CSVParser实现了IterableCSVRecord所以可以直接用增强for循环遍历不需要任何游标或者计数器。同时它也是一个Closeable配合try-with-resources可以在循环结束后自动关闭底层Reader。养成这个习惯比手动管理资源安全得多。按序号取值虽然简单但可读性差而且一旦表头顺序变了代码就要跟着改。实际项目中我更推荐用表头名来取字段下节展开。4.2 使用Header映射列名让代码不再依赖列顺序如果CSV文件带有表头最优雅的解析方式是用第一行作为header。实现有两个方式一是CSVFormat.DEFAULT.builder().setHeader(...).setSkipHeaderRecord(true).build()在构建格式时手动声明表头二是直接使用setFirstRecordAsHeader(true)让库把第一条记录当作表头CSVFormat format CSVFormat.DEFAULT.builder() .setFirstRecordAsHeader(true) .build(); try (Reader reader Files.newBufferedReader(path, UTF_8); CSVParser parser new CSVParser(reader, format)) { for (CSVRecord record : parser) { String name record.get(name); String age record.get(age); String remark record.get(remark); // 业务处理... } }这种方式的好处是表头顺序变了代码完全不用动只要列名没变就能正确映射。而且你还可以用record.isSet(email)判断某一列是否存在针对可选列做兼容处理。也有一个坑需要提前了解如果表头里有重复列名setFirstRecordAsHeader在取值时会出现不确定性通常后一个会覆盖前一个或者抛出异常。所以拿到的文件先检查一下表头是否重复遇到重复列名最简单办法是解析前对表头做统一处理或者退回到按序号取值。此外CSVRecord的toMap()方法可以把记录转成MapString, String配合流式处理很方便ListMapString, String data new ArrayList(); for (CSVRecord record : parser) { data.add(record.toMap()); }注意toMap也是基于header映射的没有header时会抛出异常用之前先确认格式配置。4.3 大文件流式读取不要随便getRecords新手经常犯的一个错误是用parser.getRecords()一次性把所有记录读进内存。这个方法内部会把整个文件解析完返回一个ListCSVRecord。文件只有几百KB时没什么感觉但当你面对几GB的日志或账单文件时一次性加载直接OutOfMemoryError。正确的做法是用流式遍历让记录一条一条从Reader里读取。之前展示的增强for循环就是流式处理的典型写法。除此之外你还可以通过parser.stream()结合Stream API做更灵活的过滤和统计try (Reader reader Files.newBufferedReader(path, UTF_8); CSVParser parser new CSVParser(reader, CSVFormat.DEFAULT.builder() .setFirstRecordAsHeader(true) .build())) { long totalAge parser.stream() .mapToInt(r - Integer.parseInt(r.get(age))) .sum(); System.out.println(总年龄: totalAge); }这里有一个性能关键点要解释清楚CSVParser的遍历是按需读取的底层Reader缓冲区默认大约是8KB每解析一行只处理一行不解析的内容不会驻留内存。你唯一要保证的是不要在循环里把每条CSVRecord都塞进一个List里积攒起来那和getRecords就没有区别了。还有一个可以提升吞吐的小技巧如果CSV文件每一行记录是相互独立的并且你只需要部分字段解析后在循环里立刻做筛选和输出不要全部攒到内存里再统一处理。数据量大的时候这种“边读边处理”的模型能让内存占用稳定在几十MB级别。4.4 容错处理空值、空行、注释行和两边的空白真实拿到的CSV远没有测试数据那么干净最常见的几种脏数据情况空行文件中间偶尔有一个空行默认配置下CSVFormat.DEFAULT的ignoreEmptyLines是true会自动跳过。注释行有些导出工具会在文件开头加一行注释比如# export time: 2024-01-01。这种行直接解析会把注释当成一条记录。可以设置setCommentMarker(#)让解析器自动跳过注释行。字段前后空白比如name, age这样的文件age前面带了一个空格。默认情况下空格会被保留解析出来的age字符串就是 28如果你用Integer.parseInt会直接炸。设置setIgnoreSurroundingSpaces(true)可以自动去掉字段两边的空白。空字符串与null的区分如果文件里用\N或NULL字符串表示数据库空值可以用setNullString(\\N)解析时遇到这个字符串record.get会直接返回null省去手动判断。综合配置的完整写法CSVFormat format CSVFormat.DEFAULT.builder() .setFirstRecordAsHeader(true) .setIgnoreEmptyLines(true) .setIgnoreSurroundingSpaces(true) .setCommentMarker(#) .setNullString(\\N) .build();这一套配置下来解析多数“半野生”的CSV文件都能平稳跑过。4.5 后端接口接收文件解析再补充一个实战场景。Spring Boot的接口里接收上传的CSV文件前端传的是MultipartFile怎么用Commons CSV解析核心逻辑不变但要把MultipartFile的InputStream转换成Reader注意字符集PostMapping(/import) public String importCsv(RequestParam(file) MultipartFile file) throws IOException { CSVFormat format CSVFormat.DEFAULT.builder() .setFirstRecordAsHeader(true) .build(); try (Reader reader new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8); CSVParser parser new CSVParser(reader, format)) { for (CSVRecord record : parser) { String name record.get(name); Integer age Integer.valueOf(record.get(age)); // 逐行入库或者做校验 } } return success; }上传文件的字符集判断是一个很容易被忽略的问题。如果前端上传的CSV是Excel导出的它可能是ANSIWindows下通常是GBK编码。稳妥的办法是读取文件的前几个字节判断BOMPushbackInputStream pbis new PushbackInputStream(file.getInputStream(), 3); byte[] bom new byte[3]; int n pbis.read(bom); String charset UTF-8; if (n 3 (bom[0] 0xFF) 0xEF (bom[1] 0xFF) 0xBB (bom[2] 0xFF) 0xBF) { charset UTF-8; pbis.unread(bom, 3, 0); // BOM不处理直接消费掉 } else { pbis.unread(bom, 0, n); charset GBK; } Reader reader new InputStreamReader(pbis, charset);5. 常见问题与排错实录5.1 Excel打开乱码怎么办这是CSV生成端出现频率最高的问题。如果你已经按3.4节加了UTF-8 BOMExcel里通常就正常了。如果你的场景是生成后给用户下载而不是给程序解析我建议默认就加BOM成本极低。如果已经加BOM还是乱码检查三个地方代码里写文件时用的是不是OutputStreamWriter并且指定了UTF-8如果是new FileWriter它可能走了平台默认编码。字符串本身是不是被污染了。有时候乱码不是写入环节而是数据源就已经乱码比如数据库连接串没配置characterEncodingutf-8。用十六进制查看文件开头的前三个字节不是EF BB BF就说明BOM没写进去。5.2 内容里的逗号和引号导致列错位我见过不少手写解析CSV的代码遇到字段带逗号就错位。用了Commons CSV之后这个问题基本不会再出现因为写入时自动加引号解析时自动处理引号。但有一个变种问题需要注意如果对端系统生成CSV时没有遵循引号规范比如字段里有逗号但它没加引号这种非标准文件任何解析库都没法完美处理。你能做的就是增强解析的容错性比如用CSVFormat.RFC4180或EXCEL格式解析它们对待引号的处理更加宽容。如果你发现某些行解析出来的字段数比其他行多多半是文件里有字段包含了分隔符但没有被引号包裹。这时先检查对端系统导出CSV的配置让它在每个字段外加引号或者用QuoteMode.ALL重新生成一遍。5.3 列数不一致或缺少列导致的异常用header模式解析时如果某一行记录的实际列数少于header定义的列数调用record.get(headerName)会抛出IllegalArgumentException。规避方法是在取值前用isSet判断if (record.isSet(email)) { String email record.get(email); } else { // 这一行没有email列做降级处理 }如果某个文件经常出现列数不齐更彻底的方案是在格式层设置setAllowMissingColumnNames(true)让缺失列在映射时返回空值而不是抛异常。不过这个配置更偏向于解析宽容度建议只在确定对端数据不可控时才使用。补充一个容易忽略的小点CSVRecord的size()方法返回当前记录的字段数values()返回所有字段数组。当你不确定当前记录的宽度时先打印size()看实际数据调试效率会高很多。5.4 解析性能优化一次百万行数据的实测体会我在一个账单对账项目里处理过单文件230万行的CSV字段大约15列。最初用getRecords()跑了一次内存直接涨到接近1.5GBGC频繁用户体验极差。改成流式遍历之后内存稳定在约120MB处理时间约7秒。差异就是这么明显。当时的优化手段汇总用Files.newBufferedReader代替new FileReader并指定缓冲区大小例如Files.newBufferedReader(path, UTF_8, 1 * 1024 * 1024)减少IO次数。全程使用流式遍历不让CSVRecord积压。尽量避免在循环里做字符串拼接改用StringBuilder分批输出。如果只是简单统计优先用parser.stream()配合map/filter/reduce代码更简洁也能发挥Stream的惰性求值优势。注意CSVParser本身不保证线程安全。多个线程想并行解析同一个文件建议拆分成多个分段文件或者使用多个Parser分别处理。我没有找到官方支持的并行解析方案实践中也是按文件分片来做并行。5.5 表头重复、大小写和前后空格的问题表头重复会引起取值错乱前面已经说了。大小写方面record.get(Name)和record.get(name)是不同的key解析时严格区分大小写。如果对端系统表头大小写不稳定有个简单办法是解析前统一转换CSVFormat format CSVFormat.DEFAULT.builder() .setFirstRecordAsHeader(true) .build(); try (CSVParser parser new CSVParser(reader, format)) { MapString, Integer headerMap parser.getHeaderMap(); // 如果需要大小写不敏感可以把headerMap的key统一小写后再建映射 }还有表头字段偶尔带前后空格比如 name 取值时按name取不到。配合setIgnoreSurroundingSpaces(true)可以从源头解决实在不行也可以把record.toMap()的key做一次trim。5.6 关于CSV锁定和Excel占用文件的问题最后分享一个Windows环境下的经验。你用Java生成CSV文件后如果在Excel里打开着再执行生成程序覆盖这个文件大概率会碰到FileNotFoundException或FileSystemException提示文件被另一个进程占用。这不是Commons CSV的问题而是Windows文件锁机制。遇到这种报错先从代码层面确认writer和printer都已经正确关闭再检查是否有Excel进程还开着文件。生产环境定时任务导出时建议先写到一个临时文件再通过Files.move原子替换目标文件这样能避免半文件状态也减少文件占用的冲突概率。如果这类操作频繁建议在输出路径上加上时间戳或批次号让每次导出生成独立文件从根上规避覆盖冲突。5.7 结合工具类封装我的最终实践用Commons CSV几年后我把常用功能封装成了一个简单的工具类统一管理字符集和格式配置。核心方法就两个一个生成一个解析public final class CsvUtils { private static final Charset CHARSET StandardCharsets.UTF_8; public static void write(Path path, String[] headers, IterableString[] rows) throws IOException { CSVFormat format CSVFormat.DEFAULT.builder() .setHeader(headers) .build(); try (BufferedWriter writer Files.newBufferedWriter(path, CHARSET); CSVPrinter printer new CSVPrinter(writer, format)) { for (String[] row : rows) { printer.printRecord((Object[]) row); } } } public static ListMapString, String read(Path path) throws IOException { CSVFormat format CSVFormat.DEFAULT.builder() .setFirstRecordAsHeader(true) .setIgnoreSurroundingSpaces(true) .build(); try (Reader reader Files.newBufferedReader(path, CHARSET); CSVParser parser new CSVParser(reader, format)) { ListMapString, String result new ArrayList(); for (CSVRecord record : parser) { result.add(record.toMap()); } return result; } } }这个封装足以应付大部分日常需求真正遇到特殊格式再针对性地补充配置项。有一点我觉得很有必要提醒CSV处理看似简单但它是一种跨系统交换格式细节决定成败把格式二字拆分出来看就是分隔符、引号、换行、编码这四个维度。把这四个维度都搞清楚你在CSV处理上基本就不再会遇到“惊喜”了。我个人在这些年的项目里凡是提前用Commons CSV这类成熟库而不是自己造轮子的后面维护都省了很多心这个库值得放进你的工具箱里。
返回列表