HOWTO · Csharp

C# 读取 CSV 文件并将其值存储到数组中

本文介绍如何读取 CSV 文件并将其值存储在 C# 中的数组中的不同方法。它介绍了 OpenRead(),ReadLine()和 TextFieldParser 之类的方法

本页内容

CSV 文件是一种以逗号分隔的文件,用于以有组织的方式存储数据。它通常以表格形式存储数据。大多数企业组织都将数据存储在 CSV 文件中。

在 C# 中,我们可以对 CSV 文件执行各种操作。我们可以读取、写入和修改 CSV 文件。本文重点介绍读取 CSV 文件并将其值存储到数组中的方法:使用 StreamReader 逐行读取、使用 File.ReadAllLines() 一次加载全部行,以及使用 TextFieldParser 解析字段。

前三个示例使用同一个示例 Data.csv 文件:

Name,Age
Alice,30
Bob,25

使用 StreamReader 读取 CSV 文件

StreamReader 类将文件作为文本流读取。File.OpenRead() 打开文件,ReadLine() 一次返回一行,直到到达 EndOfStream。将每一行按逗号拆分即可得到各个值,示例将这些值按列存储到一个列表中。

使用 OpenRead() 和 ReadLine() 方法的正确语法如下:

// OpenRead() Syntax
File.OpenRead(@"FilePath");
// ReadLine() Syntax
StreamReaderObject.ReadLine();

示例代码:

using System.IO;
using System.Collections.Generic;
using System;

class ReadingCSV {
  static void Main(string[] args) {
    var reader = new StreamReader(File.OpenRead(@"D:\New folder\Data.csv"));
    List<string> listA = new List<string>();
    List<string> listB = new List<string>();
    while (!reader.EndOfStream) {
      var line = reader.ReadLine();
      var values = line.Split(',');

      listA.Add(values[0]);
      listB.Add(values[1]);
    }
    foreach (var column1 in listA) {
      Console.WriteLine(column1);
    }
    foreach (var column2 in listB) {
      Console.WriteLine(column2);
    }
  }
}

输出:

Name
Alice
Bob
Age
30
25

按逗号拆分适用于字段本身不包含逗号的情况。像 "Smith, John" 这样带引号的字段会在错误的位置被拆开;对于这类文件,请使用 TextFieldParser。

使用 File.ReadAllLines 一次读取全部行

当文件小到可以装入内存时,File.ReadAllLines() 是得到数组的最短路径:它读取整个文件,并将每一行作为 string[] 的一个元素返回。下面的示例拆分每一行,将每个数据行存储为自己的 string[],并用 ToArray() 将行列表转换为交错数组,因此 data[i] 就是完整的第 i 行。循环从索引 1 开始以跳过标题行,跳过空白行,并在索引前检查字段数量,因此过短或为空的行不会让它崩溃。

示例代码:

using System;
using System.Collections.Generic;
using System.IO;

class ReadingAllLines {
  static void Main() {
    string[] lines = File.ReadAllLines("Data.csv");
    List<string[]> rows = new List<string[]>();
    // Start at index 1 to skip the header row.
    for (int i = 1; i < lines.Length; i++) {
      if (string.IsNullOrWhiteSpace(lines[i])) {
        continue;
      }
      string[] values = lines[i].Split(',');
      if (values.Length < 2) {
        continue;
      }
      rows.Add(values);
    }
    string[][] data = rows.ToArray();
    foreach (string[] row in data) {
      Console.WriteLine(row[0] + ": " + row[1]);
    }
  }
}

输出:

Alice: 30
Bob: 25

取舍在于内存与简单性。ReadAllLines() 在处理第一行之前就加载整个文件,并返回一个可以立即索引、计数和重读的数组。StreamReader 循环一次只保留一行,因此对于只需处理一次就丢弃的超大文件,它仍然是更安全的选择。对于会多次查看的小文件,一次全部加载更简单,而且足够快。

使用 TextFieldParser 解析 CSV 字段

TextFieldParser 类定义在 Microsoft.VisualBasic.FileIO 命名空间中。它逐个字段地解析带分隔符的文件,并能理解带引号的字段。使用 SetDelimiters() 设置分隔符,然后调用 ReadFields() 将当前行的字段作为字符串数组获取;解析器在每次调用时移动到下一行。在 .NET Framework 项目中,请在运行下面的程序之前添加对 Microsoft.VisualBasic 的引用;在现代 .NET 中,该命名空间是共享框架的一部分。

使用此解析器的正确语法如下:

TextFieldParser ParserName = new TextFieldParser(PathString);

示例代码:

using System;
using System.Collections.Generic;
using Microsoft.VisualBasic.FileIO;

class ReadingCSV {
  public static void Main() {
    var path = @"D:\New folder\Data.csv";
    List<string> listA = new List<string>();
    List<string> listB = new List<string>();
    using (TextFieldParser csvReader = new TextFieldParser(path)) {
      csvReader.CommentTokens = new string[] { "#" };
      csvReader.SetDelimiters(new string[] { "," });
      csvReader.HasFieldsEnclosedInQuotes = true;

      // Skip the row with the column names
      csvReader.ReadLine();

      while (!csvReader.EndOfData) {
        // Read current line fields, pointer moves to the next line.
        string[] fields = csvReader.ReadFields();
        listA.Add(fields[0]);
        listB.Add(fields[1]);
      }
    }
    foreach (var column1 in listA) {
      Console.WriteLine(column1);
    }
    foreach (var column2 in listB) {
      Console.WriteLine(column2);
    }
  }
}

输出:

Alice
Bob
30
25

这一次,标题行通过最初的 ReadLine() 被跳过,并且由解析器而不是手动拆分来决定每个字段在哪里结束,因此字段内部带引号的逗号会保持完整。

处理带引号的字段和转义引号

真实的导出文件会给字段加引号。包含分隔符的字段会被双引号包围,而带引号字段内部的双引号会被写成两个。下面的示例 People.csv 文件同时使用了这两条规则:

Name,Note
"Smith, John","Likes ""quoted"" text"
Jones,Plain note

Split(',') 对这些规则一无所知:它会在名字内部的逗号处,把第一个数据行切成 3 段。TextFieldParser 则会应用这些规则:它去掉包围字段的引号,并把每一对重复引号合并成一个,因此字段会原封不动地按预期返回。这个程序先输出 Split(',') 的段数,然后输出解析后的字段。

示例代码:

using System;
using Microsoft.VisualBasic.FileIO;

class ReadingQuotedCsv {
  public static void Main() {
    string rawLine = "\"Smith, John\",\"Likes \"\"quoted\"\" text\"";
    Console.WriteLine("Split(',') pieces: " + rawLine.Split(',').Length);
    using (TextFieldParser parser = new TextFieldParser("People.csv")) {
      parser.SetDelimiters(new string[] { "," });
      parser.HasFieldsEnclosedInQuotes = true;

      // Skip the row with the column names
      parser.ReadLine();

      while (!parser.EndOfData) {
        string[] fields = parser.ReadFields();
        Console.WriteLine(fields[0] + " | " + fields[1]);
      }
    }
  }
}

输出:

Split(',') pieces: 3
Smith, John | Likes "quoted" text
Jones | Plain note

如果手动拆分得到的字段数比标题行的列数还多,原因通常就是带引号的字段。对这类文件应改用 TextFieldParser,而不是事后试图拼凑这些片段。

读取 CSV 文件时的常见错误

  • values[1] 处的 IndexOutOfRangeException:末尾的空白行或缺少某列的行会被拆成比代码预期更少的字段。应跳过纯空白行,并在索引前检查字段数量,就像 ReadAllLines() 示例所做的那样。

  • FileNotFoundException 或 DirectoryNotFoundException:运行程序的机器上不存在该路径。像 D:\New folder\Data.csv 这样写死的绝对路径只在作者的电脑上有效;应优先使用相对路径,或用 Path.Combine() 构建路径,并在调试时用 File.Exists() 检查。

  • 标题行被当成数据出现:第一个 StreamReader 示例把 Name 和 Age 与真实数据行一起存储。当文件带有标题时,应像其他示例那样,在循环之前跳过第一行。

  • 文件一直被锁定:从未关闭的 StreamReader 会一直持有其句柄,直到垃圾回收器运行。应将读取器声明在 using 块中,这样即使异常中断了循环,文件也会在循环结束时立即关闭。