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

CSV 文件解析器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.83/5 (36投票s)

2016年9月12日

CPOL

13分钟阅读

viewsIcon

113302

downloadIcon

3358

解析 CSV 文件。

引言

---------------------------------------------------------------
注意! - 我在这里添加了本文的一个改进版本 - CSV/Excel 文件解析器 - 重新审视[^]。请将本文视为已过时。
---------------------------------------------------------------

直言不讳地说,我真的不想为另一个 CSV 解析器敲打一整篇文章。说到底,那些正在寻找解决此处所述问题的解决方案的人很可能根本不会阅读文本。他们只会下载代码,尝试使用它,如果我运气好,他们会花时间使用调试器(利用他们超强的调试技能)自行找出问题,然后再在下方发帖,要求我为他们的特定应用程序修复代码。

因此,我不会详细介绍代码的工作原理,而是更侧重于它做了什么,以及在它没有做其他需要做的事情时,为什么会以这种方式做。会有很少的代码片段,绝对没有图片,并且我会尽可能多地表现出明显的冷漠,同时仍传达所需的信息。

背景

我生活在一个非常奇特的编程世界。我最持久的项目涉及从近五十个不同的数据源导入数据,这些数据源主要包括 Excel XLSX 文件、一个提供 XML 格式原始数据的网站以及一些实际的数据库查询。Excel 电子表格来自各种来源,包括可以在网上将结果以该电子表格文件形式返回的数据库查询,其余的则由人工手动生成。

 

垃圾进

你可能会认为人工生成的文件会带来最多的怪癖,因为人类是有缺陷的。令人头昏脑胀的枯燥数据输入、实质上不足的薪资报酬以及事实是政府雇员在工作,所有这些因素共同作用,形成了一个完美风暴,导致无法集中任何注意力细节,从而在表格数据中产生“细微差别”。然而,数据库的提取也同样充满错误(有趣的是),尤其是当数据库的数据输入端未能完全捕捉到人类可能引入的所有潜在错误时。

垃圾出

导入这些电子表格的主要方法是一个名为 EPPlus 的库。虽然它总体上是一个不错的库,但它也有一些弱点。让我写这篇文章代码的那个弱点是,出于某种原因(至今未被任何人发现),某些 .XLSX 文件无法使用该库加载。这种“细微差别”迫使我使用 Excel 将所需的表格另存为 CSV 文件,然后我又不得不编写更多代码来实现该功能。这就是你在这里的原因。

假设

与我大多数的文章一样,本文也不是关于理论,也不是关于微软似乎认为我们希望在 .Net 中看到的最新小玩意。简单来说,这是真实世界的代码,在一个实际项目中生机勃勃。随着它的使用,它会得到更彻底的测试,随着问题的出现,它们会迅速得到修复。本文提供的代码似乎在今天相当好用。明天将是“可能不行”的新炼狱,因为我想不到所有可能抛给它的事情。我尽量避免了大多数明显的问题,但就像与编程相关的一切一样,当你认为你的代码是万无一失的时候,世界就会发明一个更厉害的“傻瓜”,而你最终会执行我称之为“条件反射式编程”的操作。

本文假定你是一名中等水平的开发人员,但你想要一些代码来解决一个你宁愿不花太多时间自己去解决的火灾。我没有做任何过于花哨或优雅的事情,因为“花哨而优雅”的代码往往更难理解和维护。代码有大量的注释,因此应该有充分的关于它如何工作的解释。

提供了一个非常简短的示例文件(包含一个标题行和两行数据),用于测试该类。为了确保该类满足您的特定需求,请使用包含的示例项目来确定 CSVParser 的适用性,并在将其用于您自己的项目之前进行任何您认为必要的更改。

代码

再说一遍,本文与其说是关于它是如何工作的,不如说是关于它做了什么以及为什么这么做。请记住,大多数时候,“为什么”的答案是我是我遇到的最懒惰的红脖子。而且我老了。真的老了。我根本不在乎它是否适合所有人的需求(尤其是居住在美国以外的任何人),只要它适合我的需求。正如我所说,这段代码是我所需要的,而你只是我慷慨的代码共享视野的受益者。

到目前为止,我可能惹恼了不少人,但这并不困扰我,因为如果我没有惹恼他们,我就没有延续我在 CP 上的声誉,从而辜负了我无数粉丝(好吧,也许有一两个人会失望,“无数”是一个主观的词)。

它是什么

CSVParser 类是一个抽象类,用于解析逗号分隔值的文件(或流)。作为抽象类,它必须由程序员开发的类继承,该类至少必须实现抽象方法。CSVParser 类中的大多数方法都是虚拟的,允许程序员通过新的或补充的处理来重写它们的功能。

它的功能

代码接收一个逗号分隔的文本文件(或流),并将每一行解析为离散的字段。

配置属性

  • public HasHeaderRow - 指示文件的第一行是标题行。我处理的所有 CSV 文件都有标题行,这使我的工作变得容易得多。如果您的 CSV 文件没有标题行,则在列标识过程中会引入中等风险,因为第一行数据可能格式错误。默认值为 true
     
  • public ExactDateTimeFormat - 指示在解析日期时间字段时使用的 DateTime 格式。默认值为 'M/d/yyyy'
     
  • public RemoveCurrencySymbols - 为了正确地转换表示货币的值,我们必须剥离货币符号(如果存在)。默认值为 true
     
  • public CurrencySymbol - 这是您希望在 RemoveCurrencySymboltrue 时剥离的货币符号。如果此属性为 null 或为空,则类使用当前区域性来确定您要剥离的货币符号。
     
  • public ThrowFindExceptions - 在一行解析后,调用方法使用(重载的) FindValue() 方法来处理结果字段。如果此标志为 true,则在找不到要查找的字段或无法将其转换为适当类型时,将抛出异常。
     

内部使用的属性

  • protected DataStream - 传递给 Parse 方法的流或通过加载指定文件创建的流。
     
  • protected Columns - 当流被解析时,此字典(string, int)将由第一行填充,无论是行标题还是实际数据。如果 HasHeaderRow 为 true,则此字典中存储的键将是解析行中包含的文本。Excel 会自动用方括号括起标题行字段,但这些方括号会被解析器剥离。如果 HasHeaderRow 为 false,则会自动为列名分配格式为“ColN”的名称,其中“N”是列的索引号。在这两种情况下,KeyValuePair 的 value 都是列的数值索引。
     
  • protected CurrentData - 这是解析行时发现的字段的字符串数组。
     
  • protected CurrentLine - 这是当前正在解析的行。我实现了此属性,以便我可以将该行添加到此类支持的自定义异常之一。
     
  • protected IsMalformed - 指示当前行 格式错误
     
  • public InvalidLines - 指示无法纠正的无效行索引列表。
     
  • public TotalLinesProcessed - 指示已处理的总行数。此数字不包括空行或标题行。
     

总体而言,解析

构造函数不接受任何参数,允许程序员使用自动属性按需设置配置属性。我个人更喜欢这种方式,而不是设置一系列看似无穷无尽的重载构造函数,每个构造函数都有需要提供的参数范围。我仍然使用带参数的构造函数,但仅在绝对必要或方便时使用。构造函数仅用于初始化各种 List 属性。在派生类中,除非您更喜欢在那里设置配置属性而不是使用自动属性,否则您实际上不需要做任何事情。

要开始解析过程,请使用所需的​​文件名或流对象调用 Parse 方法。逐行解析,使用以下方法

protected virtual string[] ReadFields(string text, bool removeQuotes=true)
{
    //assume we have a proper line of text
    this.IsMalformed = false;
    // split the string on commas (because this is a CSV file, after all)
    string[] parts = text.Trim().Split(',');
 
    // create a container for our results
    List<string> newParts = new List<string>();
    // set some initial values
    bool inQuotes = false;
    string currentPart = string.Empty;
 
    // iterate the parts array
    for (int i = 0; i < parts.Length; i++) 
    {
        // get the part at the current index
        string part = parts[i];
        // if we're in a quoted string and the current part starts with a single double 
        // quote AND currentPart isn't empty, assume the currentPart is complete, add it to 
        // the newParts list, and reset for the new part
        if (inQuotes && part.StartsWithSingleDoubleQuote()==true && !string.IsNullOrEmpty(currentPart))
        {
            currentPart = string.Concat(currentPart, "\"");
            newParts.Add(currentPart);
            currentPart = string.Empty;
            inQuotes = false;
        }
        // see if we're in a quoted string
        inQuotes = (inQuotes || (!inQuotes && part.StartsWithSingleDoubleQuote() == true));
        // if so, add the part to the current currentPart
        if (inQuotes)
        {
            currentPart = (string.IsNullOrEmpty(currentPart))? part : string.Format("{0},{1}", currentPart, part);
        }
        // otherwise, simply set the currentPart to the part
        else
        {
            currentPart = part;
        }
        // see if we're still in a quoted string
        inQuotes = (inQuotes && currentPart.EndsWithSingleDoubleQuote()==false);
        // if not
        if (!inQuotes)
        {
            // remove the quote characters
            currentPart = (removeQuotes) ? currentPart.Trim('\"') : currentPart;
            // put the currentPart into our container
            newParts.Add(currentPart);
            // reset the currentPart
            currentPart = string.Empty;
        }
    }
    this.IsMalformed = (inQuotes || (this.Columns.Count > 0 && newParts.Count != this.Columns.Count));
    return newParts.ToArray();
}
    

最初,我使用 VisualBasic.FileIOTextFieldParser 对象来处理此问题,但我鄙视所有与 VB 相关的东西,并且添加对 VB 程序集的引用在许多层面上都感觉不对(更不用说我可能会产生使用 goto 语句的非正常欲望的恐惧)。既然我已经处理了 VB 对象提供的所有其他(我需要的)东西,我认为自己编写一个 TextFieldParser.ReadFields 方法的版本会更“开发人员化”。

完成行的解析后,将调用抽象方法 ProcessFields(bool isMalFormed)。在派生类中,您可以这样做:

protected override void ProcessFields(bool isMalformed)
{
    if (this.CurrentData != null && !isMalformed)
    {
        // TO-DO: Your stuff
        try
        {
            int      col1 = this.FindValue("col1", -1);
            string   col2 = this.FindValue("col2", "ERROR");
            string   col3 = this.FindValue("col3", "ERROR");
            double   col4 = this.FindValue("col4", -1d);
            DateTime col5 = this.FindValue("col5", new DateTime(0));
        }
        catch (FindValueException fvex)
        {
            //TO-DO: react to an exception thrown because the value found could not be cast 
            //		 to the expected type.
        }
    }
}
    

在这里,您可以检索字段值并对其进行处理。最有可能的是,处理方法是创建一个适当的应用程序特定对象的实例,并将其属性设置为字段值。

请记住,您可以选择在字段无法解析为预期类型(由接收变量/属性指示)时抛出异常。但是,设置一个指示错误的默认值有时会更有用,尤其是在调试时。我的对象中有一个方法,它根据属性的内容执行有效性检查。我在示例项目中没有使用它,但我将其包含在本文中,因为它可以对他人有用。

public bool IsValid 
{
    get 
    {
        bool           valid = false;
        PropertyInfo[] infos = this.GetType().GetProperties();
        foreach(PropertyInfo info in infos)
        {
            if (info.Name != "IsValid")
            {
                object property   = info.GetValue(this, null);
                string propString = string.Format("{0}",property);
                valid             = (!propString.IsInExact("-1,-1.0,ERROR"));

                if (!valid)
                {
                    break;
                }
            }
        }
        // we don't need to check the dates specificially because if they weren't 
        // valid, this object would not have been created
        return valid;
    }
}
	

它使用反射,但这真的无法避免,如果您想在任何地方使用它,例如从基类或类似的东西。我还包含了项目中的 IsInExact() 扩展方法(此 IsValid 属性利用了它)。您可能会注意到它不验证 DateTime,因为我的代码不会创建包含此属性的对象,除非日期有效,但即使对于一个技能普通的程序员来说,包含它也并不困难。

一旦流/文件被解析,CSVParser 类就会调用抽象的 Finished() 方法。这为您提供了机会来处理您已保留的已解析数据。在调试时,它也可能有所帮助,允许您检查有效、无效和已更正行的行索引列表。

查找值

一旦开始解析,并且基类调用 ProcessFields 方法,您就可以逐个字段检索数据。为此,请使用 FindValue() 方法。FindValue() 已为四种最常见的类型(stringintdoubleDateTime)进行了重载。FindValue 接受所需的列名,以及一个默认值,如果查找指定列时出现问题,或无法将找到的数据转换为所需类型,则将其分配。

查找指定列涉及 IsLike 扩展方法,该方法的功能类似于 SQL 的 LIKE 函数。您可以使用任何适当的 SQL 通配符字符作为列名,以在已发现的列字典中查找它。因此,给定列名“Really Long Column Name”,您可以使用类似“really long col%”、“%long column%”、“%column name”或“Really Long Column Name”来在字典中查找该列。它仅仅是一种允许输入更少字符的工具。当然,必须小心确保 FindValue 中指定的列名足够限定以找到所需的列。与 SQL LIKE 函数一样,字符串匹配不区分大小写,无论是否使用通配符。

它不做什么

  • 不支持除逗号以外的任何分隔符(但可以轻松修改)
     
  • 除了解析数据行外,它不执行任何其他操作。没有直接支持用于填充外部(程序员定义的模型)对象。

用法

首先,您实例化您的派生解析器对象

CSVFileParser parser = new CSVFileParser();
parser.Parse("sample1.csv");

在您的派生解析器对象内部,重写两个抽象方法

public class CSVFileParser : CSVParser
{
    public CSVFileParser() : base()
    {
    }

    protected override void ProcessFields(bool isMalformed)
    {
        if (this.CurrentData != null && !isMalformed)
        {
            try
            {
	            // TO-DO: Your stuff (involves calling FindValue for each field 
                //        you want to retrieve from the current line)
            }
            catch (FindValueException fvex)
            {
                //TO-DO: react to an exception thrown because the value found could not be cast 
                //       to the expected type.
            }
        }
    }

    protected override void Finished()
    {
        //TO-DO: Celebration that could include dancing naked around a fire, pounding your 
        //       chest and singing songs of a successful parsing event.

        // At this point you can examine the properties that invalid lines, and the total lines processed 
		// (excluding the header row and blank lines).
    }
}

当然,您可以重写基类中的几乎任何方法来修改解析器的行为。

定义

  • 格式错误的数据 - 基本上只有一种情况(我认为)会表明数据格式错误,那就是一个应该被双引号包围但缺少一个引号字符的字段。当 Excel 从工作表中创建 CSV 文件时,它会自动用双引号括起一个字段,根据我的观察,来自 Excel 的 CSV 文件不应该存在格式错误的情况。这让我推测,格式错误的字段只能由其他软件生成,或者当有人手动篡改了相关文件时生成。
     

免责声明和买者注意

显然,对于这个问题还有其他解决方案,它们要么更简洁,要么更实质性,我将把寻找这些替代方案的谷歌搜索任务留给您。请不要在评论区浪费空间告知我这些替代方案。我根本不在乎。我写这段代码是因为它符合我的需求。我分享它是因为它可能符合别人的需求。

文章历史

  • 2016 年 12 月 15 日 - 修正了一些拼写错误,并进一步表达了对懒惰且阅读理解能力差的程序员的鄙视。
     
  • 2016 年 9 月 19 日 - 糟糕。我在 program.cs 中放错了示例文件名。已上传 zip 文件的新版本。
     
  • 2016 年 9 月 18 日 - 我在 ReadFields 方法中发现了一个 bug,该 bug 有时会导致“格式错误”行,即使该行实际上没有格式错误。之后,我集中尝试了几次自动纠正格式错误的字段,但我得出的结论是,a) 格式错误的文件是创建它的程序的错误,或者 b) 是一个篡改它的人。自动纠正格式错误的文件是不可能的,因为无法确定可能发生多少错误,以及在哪里准确地进行适当的更正。为了完全透明,我包含了旧的 CSVParser 源文件,其中包含了几次尝试解决此问题。我的深思熟虑的观点是,您实际上需要一个应用程序,允许您在视觉上检查错误行并手动修复它们,然后允许您使用 CSVParser 重新处理文件。

    我还从文章中删除了所有关于自动纠正的引用(但将旧代码保留在 zip 文件中,以便您能够见证随之而来的悲剧性失败)。

    最后,我在类中添加了一些本地化功能,用于剥离货币符号。如果您不指定要剥离的货币符号,代码将使用当前区域性信息来确定适当的货币符号。
     
  • 2016 年 9 月 12 日 - 首次发布。
     
© . All rights reserved.