65.9K
CodeProject 正在变化。 阅读更多。
Home

CSV 到/从 DataTable

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (14投票s)

2017年7月13日

CPOL

14分钟阅读

viewsIcon

101627

downloadIcon

1108

本文介绍将 CSV 格式数据转换为 DataTable 或从 DataTable 转换为 CSV 格式的方法

1. 背景 目录

所谓的 CSV(逗号分隔值)格式是电子表格和数据库最常见的导入和导出格式。没有“CSV 标准”,因此该格式的操作定义由许多读取和写入它的应用程序决定。缺乏标准意味着不同应用程序产生和消耗的数据之间经常存在细微差异。这些差异可能会使处理来自多个来源的 CSV 文件变得令人烦恼。尽管如此,虽然分隔符和引用字符各不相同,但整体格式足够相似,可以编写一个单一的模块来高效地操作此类数据,从而将读取和写入数据的细节隐藏起来,方便程序员使用。

来自 Python 标准库 - CSV 文件读写 [^]

互联网上出现了许多声称能够处理 CSV 数据的文章。不幸的是,许多实现都存在错误!

例如,一个已发布的实现使用 String.Split [^] 方法来获取 CSV 数据记录中的字段。对于不包含嵌入字段分隔符字符的字段,此方法将有效。例如

    field 1,field 2,field 3<cr><lf>

但是,如果字段包含嵌入的字段分隔符字符,如

    field 1,"field, 2",field 3<cr><lf>

那么 String.Split 方法将返回比记录中实际存在的字段更多的字段。而且,如果文件中的 CSV 数据符合 RFC 4180 [^] 或 CSV 渲染 [^],则包含嵌入字段分隔符字符的字段将被引号括起来,从而导致更多的混淆。

2. CSV 格式 目录

CSV 文件有多种格式化方式。一种由互联网工程任务组 (IETF) RFC 4180 [^] 定义。另一种是微软在其 CSV 渲染 [^] 中定义的标准。在本文中,我将只探讨 IETF RFC 4180 和微软 CSV 渲染。下表总结了这两者。

下表的说明。

  • "双引号" 被替换为 "引号"。
  • "双引号" 被替换为 "引号"。
  • "换行符" 被替换为 "记录分隔符字符串"。
  • "字段分隔符字符串" 被替换为 "字段分隔符字符",因为其原始数据类型是字符而不是字符串。
  • "文本限定符字符串" 被替换为 "文本限定符字符",因为其原始数据类型是字符而不是字符串。
  • 方括号包围的字符序列应被视为一个整体。例如,"[<cr><lf>]" 应被读取为一个由回车符和换行符组成的单一实体。
  • 圆括号包围了字符的示例。例如,短语 "...and comma (,)..." 包含一个提供 "逗号" 示例的元素。
  • 表格单元格包含上述两个文档的部分内容。R 规则来自 IETF RFC;M 规则来自微软 CSV 渲染。
  • 在 R 和 M 规则之后,是 解析器(P 规则)发射器(E 规则) 使用的规则。在 P 和 E 规则中可以找到粗体短语。这些代表可以修改其值的公共属性。

 

IETF RFC 4180微软 CSV 渲染

R1 每条记录都位于单独的行上,由记录分隔符字符串 ([<cr><lf>]) 分隔。

R2 文件中的最后一条记录可能有或没有结尾记录分隔符字符串。

M1 记录分隔符字符串是回车符和换行符 ([<cr><lf>])

P1 在解析之前,会扫描 CSV 数据的第一条记录以确定使用的记录分隔符。

E1 记录分隔符字符串可以通过指定新的值来更改其默认值 [<cr><lf>] 为 Record_Delimiter。提供的值可以是以下之一:[<cr><lf>]、[<lf>]、[<cr>] 或 [<lf><cr>]。在所有情况下,记录分隔符字符串都会在每行末尾发出,包括文件中的最后一行。

R3 文件中的第一行可能是一行可选的标题行,其格式与普通记录行相同。此标题包含对应于文件中字段的名称,并且应包含与文件中其余记录相同的字段数。

M2 只有文件的第一行包含列标题,并且每行有相同数量的列。

P2 第一行是否被视为标题取决于 Has_Header 的值。如果设置为 true,则第一行将被视为标题;否则,第一行将被视为数据行。Has_Header 的默认值为 true

P3 如果 Strict_Rendering 设置为 true,则第一行的字段数将被视为所有行的必需字段数;否则,将不进行逐行字段数验证。Strict_Rendering 的默认值为 true

R4 在标题和每条记录中,可以有一个或多个字段,由逗号分隔。每行应在整个文件中包含相同数量的字段。空格被视为字段的一部分,不应被忽略。记录中的最后一个字段后面不能跟逗号。

M3 默认字段分隔符字符是逗号 (,)。您可以将默认字段分隔符更改为任何您想要的字符,包括 <制表符>。

P4 字段分隔符字符可以通过指定新值来更改其默认值逗号为 Field_Delimiter。提供的值不能与 Text_Qualifier 的值或 Record_Delimiter 中的任何字符重复。Field_Delimiter 的默认值为逗号 (,)。

E2 字段分隔符字符将在每个字段的末尾发出,最后一个字段除外。最后一个字段后面将是记录分隔符字符串。

R5 每个字段可以被一对引号 (") 包围,也可以不被包围。如果字段没有被引号包围,那么引号不能出现在字段内。

R6 包含记录分隔符字符串、引号或字段分隔符字符的字段应被引号包围。

R7 如果使用引号包围字段,则字段内的引号必须通过在其前面加另一个引号来转义。

M4 文本限定符字符的默认值为引号 (")。CSV 渲染器不会为所有文本字符串添加文本限定符字符。

M5 文本限定符字符应添加到包含字段分隔符字符、文本限定符字符或记录分隔符字符串中字符的字段周围。如果字段包含文本限定符字符,则字段内的文本限定符字符会重复。

P5 文本限定符字符可以通过指定新值来更改其默认值引号为 Text_Qualifier。文本限定符字符必须与字段分隔符字符以及记录分隔符字符串中的任何字符不同。Text_Qualifier 的默认值为引号 (")。

E3 当字段包含字段分隔符字符、记录分隔符字符串中的字符或文本限定符字符时,文本限定符字符将包围该字段发出。如果字段包含一个或多个文本限定符字符,则每个都会被重复。

需要注意的是,微软 CSV 渲染可以有两种模式:一种是为 Excel 优化,另一种是为需要严格符合 RFC 4180 CSV 规范的第三方应用程序优化。本文的大部分读者都熟悉 Excel 模式。

3. 实现转换 目录

最近,我面临一项任务,涉及处理位于一个或多个网站或本地计算机上的 CSV 文件。直接在某种应用程序中处理这些文件的内容并不吸引人。基本上,我不想重新创建 Microsoft Excel。

DataTable [^] 是一个相对简单而强大的数据结构,满足了我的需求。所需的一切就是能够将 CSV 数据转换为 DataTable,然后将 DataTable 转换回 CSV 数据的。为此,我需要一些方法。

在搜索网络时,我发现了一些执行转换的方法。但令我沮丧的是,它们要么过于简单,无法处理大多数 CSV 文件,要么过于复杂,以至于底层算法不那么容易理解。我想要的是简单、一眼就能看懂并且可以使用 Microsoft .Net Framework 3.5 SP1 实现的方法。

3.1. CSV 数据到 DataTable 目录

我选择将 CSV 数据转换为 DataTable 的方法是一个解析器,它最多需要一个前瞻符号。该解析器是一个词法分析器,它一次读取一个字符,然后采取适合该字符的操作。

需要两种版本的解析器:一种用于填充了 CSV 数据的缓冲区,另一种用于包含 CSV 数据的本地文件。

初读时,可能会觉得有两个入口点很奇怪。但要求将本地 CSV 数据文件的全部内容一次性读入应用程序空间是浪费的。本地文件可以逐个字符地访问。然而,由于 StreamReader 类 [^] 只能处理本地计算机上的文件,因此必须先将网络上的文件读入 CSV 缓冲区,然后传递给 CSV_buffer_to_data_table

仔细检查时,这两种解析器版本的效率相同,只是在检索要处理的下一个字符方面有所不同。

 

3.1.1. CSV 缓冲区到 DataTable 目录

要将填充了 CSV 数据的缓冲区转换为 DataTable,可以使用 StringReader 类 [^]。

    determine_buffer_line_ending ( ... )
    data_table = new DataTable ( )
    using ( StringReader sr = new StringReader ( 
                                  csv_buffer ) ) 
        {
        bool  advance = false;
        char  ch = STX;
        char  next_ch = ETX;

        while ( sr.Peek ( ) > 0 )
            {
            ch = ( char ) sr.Read ( );
            if ( sr.Peek ( ) < 0 )
                {
                next_ch = ETX;
                }
            else 
                {
                next_ch = ( char ) sr.Peek ( );
                }

            process_character ( ref state,
                                ref data_table,
                                    ending );

            if ( advance )
                {
                advance = false;
                sr.Read ( );
                }
            }
        }
    

3.1.2. CSV 文件到 DataTable 目录

要将填充了 CSV 数据的本地文件转换为 DataTable,可以使用 StreamReader 类。

    determine_file_line_ending ( ...  )
    data_table = new DataTable ( )
    using ( StreamReader sr = new StreamReader ( 
                                  path ) )
        {
        bool  advance = false;
        char  ch = STX;
        char  next_ch = ETX;

        while ( sr.Peek ( ) > 0 )
            {
            ch = ( char ) sr.Read ( );
            if ( sr.Peek ( ) < 0 )
                {
                next_ch = ETX;
                }
            else 
                {
                next_ch = ( char ) sr.Peek ( );
                }

            process_character ( ref state,
                                ref data_table,
                                    ending );

            if ( advance )
                {
                advance = false;
                sr.Read ( );
                }
            }
        }
    

3.1.3. process_character 方法 目录

上述两种方法都有一个共同的字符解析方法,名为 process_character。其签名如下

    void process_character ( ref ParseState state,
                             ref DataTable  data_table,
                                 Ending     ending )
    

ParseState 是一个本地类,用于记录解析的当前状态。 data-table 是 CSV 数据转换的目标。在实际解析 CSV 数据开始之前,必须确定 CSV 数据的行结尾,并将结果存储在 ending 中。

ParseState 的成员是

    public bool            advance;        // true to retrieve next ch
    public char            ch;             // character being examined
    public StringBuilder   field;          // current field
    public List < string > fields;         // fields parsed
    public bool            first_ch;       // true if first character
    public bool            first_row;      // true if first row
    public char            next_ch;        // next character (lookahead)
    public bool            quoted_field;   // true if in quoted field
    

有关 Ending 的声明,请参见下面的 枚举和常量

解析 CSV 数据实际上非常直接。从 CSV 数据中检索当前字符及其后面的字符(下一个字符)。这个下一个字符被称为前瞻标记。

将每个字符与每个终端标记(即 Field_DelimiterText_QualifierRecord_Delimiter 中的任何字符)进行比较。如果匹配,则进行相应的处理;如果不匹配,则将该字符附加到当前字段。当捕获完 CSV 数据行的所有字段后,将在数据表中创建一个新记录。

    set end of record to false
    set advance to false
    IF character is a CR OR character is a LF 
        IF character is in a quoted field
            append character to current field
        ELSE IF character is the CR
            IF ending is CR_ONLY
                set end of record to true
            ELSE IF ending is a CRLF
                IF next character is a LF
                    set advance to true
                    set end of record to true
                ENDIF
            ENDIF
        ELSE IF character is the LF
            IF ending is a LF_ONLY
                set end of record to true
            ELSE IF ending is a LFCR
                IF next character is a CR
                    set advance to true
                    set end of record to true
                ENDIF
            ENDIF
        ENDIF
        IF end of record is true
            set end of record to false
            create a new record in data_table
        ENDIF
    ELSE IF character is the Text_Qualifier
                                // ,"....."
                                //  ^               character
        IF first character
            set quoted field to true
                                // ,"...""...",
                                //      ^           character
        ELSE IF quoted field
                                // ,"...""...",
                                //       ^          next character
            IF next character is a Text_Qualifier
                append character to current field
                set advance to true 
                                // ,"...",
                                //       ^          next character
            ELSE IF next character is a Field_Delimiter
                set quoted field to false;
                                // ,"..."C
                                // ,"..."L
                                //       ^          next character
            ELSE IF next character is a Record_Delimiter
                quoted field = false;
            ENDIF
        ELSE
                                // ...,..."..,...
                                //        ^         character
                                // INVALID CSV FORMAT 
                                // emit the Text_Qualifier
            append character to current field
        ENDIF
        set first character to false
    ELSE IF character is the Field_Delimiter
                                // ,"..,...",
                                //     ^            character
        IF quoted field
            append character to current field
                                // ,........,
                                //          ^       character
        ELSE 
            IF current field length greater than 0
                append current field to current fields
            ELSE
                append null to current fields
            ENDIF
            set current field length to 0
            set first character to true
        ENDIF
    ELSE 
        append character to current field
        set first character to false
    ENDIF
    

这个辅助方法最初包含在 CSV_buffer_to_data_tablelocal_CSV_file_to_data_table 中。然而,随着其中一个或另一个方法的修改,人们意识到需要一个单一的解析方法。

需要注意的是,process_character 每次处理一个字符。它可能会“前瞻”到下一个字符。如果确定需要从 CSV 源(缓冲区或文件)检索新字符,则将 advance 设置为 true

3.2. DataTable 到 CSV 数据 目录

将 DataTable 转换为 CSV 数据要容易得多。该过程的伪代码如下

    IF Has_Header
        FOREACH column in DataTable columns
            emit a CSV field containing the ColumnName
        ENDFOREACH
        emit a Record_Delimiter
    ENDIF
    FOREACH DataRow in DataTable
        FOREACH column in DataRow
            emit a CSV field containing the column text
        ENDFOREACH
        emit a Record_Delimiter
    ENDFOREACH
    

唯一的复杂之处在于生成新的 CSV 字段。这个过程在 generate_csv_field 方法中。

    // **************************************** generate_csv_field

    /// <summary>
    /// given a cell from a DataTable, generate a CSV field
    /// </summary>
    /// <param name="field">
    /// contents of the DataTable cell that is to be converted
    /// </param>
    /// <param name="terminals">
    /// a hash of terminal characters, adjusted for the desired 
    /// line ending
    /// </param>
    /// <param name="sb">
    /// CSV buffer to which the field will be added
    /// </param>
    void generate_csv_field (     string            field,
                                  HashSet < char >  terminals,
                              ref StringBuilder     sb )
        {
        StringBuilder  emitted_field = new StringBuilder ( );

        if ( field != null )
            {
            bool  quoted_field = false;

            foreach ( char ch in field )
                {
                emitted_field.Append ( ch );
                if ( terminals.Contains ( ch ) )
                    {
                    quoted_field = true;
                    }
                if ( ch == Text_Qualifier )
                    {               // escape the Text_Qualifier
                    emitted_field.Append ( Text_Qualifier );
                    }
                }

            if ( quoted_field )
                {
                emitted_field.Append ( Text_Qualifier );
                emitted_field.Insert ( 0, Text_Qualifier );
                }
            sb.Append ( emitted_field );
            }
                                    // always end with the 
                                    // Field_Delimiter
        sb.Append ( Field_Delimiter );
        }
    

4. 使用 CSVToFromDataTable 方法 目录

在其当前实现中,CSVToFromDataTable 驻留在 Utilities 命名空间中。为了便于以下讨论,应声明以下 using 语句。

    using CSV = Utilities.CSVToFromDataTable;
    

4.1. 枚举和常量 目录

CSVToFromDataTable 公开了公共枚举 Ending

      public enum Ending 
          {
          NOT_SPECIFIED,    //            unexpected
          CRLF,             // <cr><lf> - standard
          LF_ONLY,          // <lf>     - Excel
          CR_ONLY,          // <cr>     - unusual
          LFCR              // <lf><cr> - unusual
          }
    

Endingdata_table_to_CSV_buffer 的参数,用于指定要使用的 Record_Delimiter。对于将 CSV 数据转换为 DataTable 的方法,Record_Delimiter 是在内部确定的。

每个枚举常量都可以使用如下引用访问

    CSV.Ending.<enumeration-constant-name>
    

CSVToFromDataTable 还公开了以下公共常量

    public const char   CR = ( char ) 0x0D;     // line ending
    public const char   ETX = ( char ) 0x03;    // end parse
    public const char   LF = ( char ) 0x0A;     // line ending
    public const char   NUL = ( char ) 0x00;    // empty entry
    public const char   STX = ( char ) 0x02;    // start parse
    

每个常量都可以使用如下引用访问

    CSV.<name>
    

4.2. 属性 目录

CSVToFromDataTable 公开了下表中描述的属性。

属性数据类型默认值描述
Field_Delimiterchar逗号 (,)用于分隔记录中一个字段与另一个字段的字段分隔符字符。可以将其更改为任何所需的字符。强烈建议选择的字符是打印字符,通常不在 CSV 数据中。
Has_Headerbooltrue指定解析或发出 CSV 数据时要执行的操作。如果为 true,则第一行被视为包含标题字段;否则,第一行被视为数据行。
Record_Delimiter字符串<cr><lf>指定终止每个 CSV 数据记录的字符串。可以提供的值仅限于 [<cr><lf>]、[<lf>]、[<cr>] 和 [<lf><cr>]。
Strict_Renderingbooltrue指定 CSV 数据中的所有记录都必须包含相同数量的字段。如果为 true,则字段数量根据第一条记录的字段数量确定。
Text_Qualifierchar引号 (")指定字段包含字段分隔符字符、文本限定符字符或记录分隔符字符串中的字符之一。如果字段包含任何文本限定符字符,则字段内的任何文本限定符字符都将重复。

4.3. 实例化 CSVToFromDataTable 目录

有两种入口点可以实例化 CSVToFromDataTable

    public CSVToFromDataTable ( )

and 

    public CSVToFromDataTable ( char    field_delimiter,
                                bool    has_header,
                                char    qualifier,
                                string  record_delimiter,
                                bool    strict_rendering )

    

第一个使 CSVToFromDataTable 使用属性的默认值;第二个允许调用者指定所有属性。如果使用第一个实例化,调用者可以像往常一样直接访问这些属性。

使用前面定义的 CSV,以及下面声明的局部变量,可以如下实例化 CSVToFromDataTable

    char    field_delimiter = ',';
    bool    has_header = true;
    char    qualifier = '"';
    string  record_delimiter = String.Format ( "{0}{1}", 
                                               CSV.CR, 
                                               CSV.LF );
    bool    strict_rendering = true;

    CSV     csv = new CSV ( field_delimiter,
                            has_header,
                            qualifier,
                            record_delimiter,
                            strict_rendering );
    

如上所述,有两个入口点用于将 CSV 数据转换为 DataTable。CSV_buffer_to_data_table 将包含 CSV 数据的缓冲区转换为 DataTable;另一个 local_CSV_file_to_data_table 读取包含 CSV 数据的本地文件,并将其内容转换为 DataTable。

4.4. CSV_buffer_to_data_table 目录

当 CSV 数据已检索到 CSV_buffer 中时,调用此方法。它最适用于转换从 FTP 网站检索到的 CSV 数据。转换非常简单

    CSV     csv = new CSV ( );         // use defaults
    string  csv_buffer = String.Empty;
    string  error_message = String.Empty;
    bool    successful = true;
    :
    :
    // fill csv_buffer with CSV data
    :
    :
    successful = csv.CSV_buffer_to_data_table (    csv_buffer,
                                               ref data_table,
                                               ref error_message );
    if ( successful )
        {
        :
        :
    

可以使用下载中包含的 Utilities 库中的 WebsiteIO 方法来用来自网站的数据填充 csv_buffer

4.5. local_CSV_file_to_data_table 目录

当 CSV 数据位于本地计算机文件中时,调用此方法。转换非常简单

    CSV     csv = new CSV ( );         // use defaults
    string  error_message = String.Empty;
    bool    successful = true;
    
    successful = csv.local_CSV_file_to_data_table (     
                                                   path,
                                               ref data_table,
                                               ref error_message );
    if ( successful )
        {
        :
        :
    

local_CSV_file_to_data_table 读取具有 path 完全限定路径的文件中包含的 CSV 数据。无需将数据读入 CSV 缓冲区。

5. 下载内容 目录

有多种下载。除了 TestCSV 可执行文件外,其余下载均为 Visual Studio 2008 项目或解决方案。

Utilities 项目Utilities 项目包含 CSVToFromDataTable 源代码以及其他可能实用的方法。所有编译单元都具有 Utilities 命名空间。
WebOpenFileDialog 项目WebOpenFileDialog 项目包含用于面向 Web 的打开文件对话框的源代码,该对话框在 WebOpenFileDialog [^] 中进行了描述。它包含在此处以支持 TestCSV 项目。
TestCSV 项目TestCSV 项目包含 CSVToFromDataTable 的演示。它允许用户测试位于本地计算机或 Web 上的 CSV 数据文件的转换。
TestCSV 可执行文件TestCSV 可执行文件是 CSVToFromDataTable 的独立演示。
TestCSV 解决方案TestCSV 解决方案将 TestCSV 项目、Utilities 项目和 WebOpenFileDialog 结合到一个 Visual Studio 2008 解决方案中。

6. 参考文献 目录

7. 开发环境 目录

CSVToFromDataTable 类在以下环境中开发

Microsoft Windows 7 Professional Service Pack 1
Microsoft Visual Studio 2008 Professional
Microsoft .Net Framework Version 3.5 SP1
Microsoft Visual C# 2008

8. 历史 目录

07/11/2017原文
07/17/2017修复了 HTML 格式问题
© . All rights reserved.