CSV/Excel 文件解析器 - 重访
一个代码演进以适应新需求的例子
引言
首先,我承认——我在这里并没有开创什么新天地。我之前写过一篇关于解析 CSV 文件的文章——CSV 文件解析器[^]——虽然它提供了足够的代码,但我认为可以给使用者带来一些便利。我选择将其作为一个新文章来呈现,是因为 a) 它很好地展示了在有机会重访代码时代码是如何演变的,以及 b) 它提出了我认为比原文(或原文们)显著改进的方法。
这段代码实际上是我最近发布的一系列文章的提取内容,这些文章允许你创建 SQL Server Express 的可计划代理——SQLXAgent - SQL Express 的任务 - 第1部分(共6部分)[^]。那篇文章包含了这里的大部分代码,但移除了数据库支持,因为本文的目标是从 CSV(或 Excel)文件中获取数据到内存中的集合。之后的数据处理(理所当然地)留给实现此代码的程序员,我在文章末尾提供了一个如何实现此功能的示例。
我曾想过直接引用之前的 CSVParser 文章,但考虑到如果我是一名读者,我会因为需要点击一个链接来阅读可能不适用于本文的内容而感到恼火,所以请原谅关于 CSV 解析代码的任何重复叙述,请记住,我是为了孩子们做的。
本文没有图片、精美的图表或废话。如果你在找这类东西,去附近的报刊亭买一本《Cosmopolitan》吧。
背景
我(仍然)生活在一个非常奇特的编程世界里。最初开发这段代码的项目后来被放弃了,但它却是这段代码存在的原因。那个项目涉及导入近五打(DOZEN)不同数据源的数据,主要由 Excel XLSX 文件、至少一个 CSV 文件、一个提供 XML 格式原始数据的网站,以及几个实际的数据库查询组成。Excel 电子表格来自于在可以返回该格式电子表格文件的网站上的数据库查询的混合,其余则是人工生成的。
输入垃圾
你会认为,人类生成的文件会带来最多的怪癖,因为人是会有缺陷的。令人厌烦的数据录入、微薄的薪水补偿,以及政府雇员执行这项工作的事实,共同构成了一个完美的风暴,使得他们无法集中注意力,从而导致表格数据出现“细微差别”。然而,数据库拉取也同样可能充满错误(有趣的是),尤其是当数据库的数据录入端没有完全捕捉到人类可能引入的所有潜在错误时,或者当负责数据提取的人决定重命名、移动、删除某个列,或者添加一个新列时。当然,他们没有感到迫切需要告知其数据消费者有关格式或内容的变化,这就导致了一个奇妙的惊喜世界,最好通过 SQL Server 作业失败来观察,或者就像这个已弃用的项目一样,代码有时会发生灾难性的失败,因为我没有预料到最近一次的恶意人类愚蠢行为。
输出垃圾
导入这些电子表格的主要方法是使用一个名为 EPPlus
的库。虽然它总体上是一个足够好的库,但它也有一些弱点。其中一个促使我编写了原始文章中介绍的代码的问题是,出于某种原因(至今无人发现),一些 .XLSX 文件根本无法使用该库加载。这种“细微差别”迫使我使用 Excel 将受影响的表格另存为 CSV 文件,这又促使我编写更多代码来实现这一功能。
我后来改用了似乎更强大的 Excel 读取器,尽管它使导入 CSV 文件的需求不再必要,但并未完全消除我的众多热切粉丝们使用 CSV 解析代码的需求。
假设
与我大多数文章一样,这篇文章不是关于理论,也不是关于微软认为我们想在 .NET 中看到的最新小玩意或装饰品。简单来说,这是在实际项目中运行的真实代码。随着它的使用,它会得到更彻底的测试,并且当出现问题时,它们会被及时修复。这里呈现的代码似乎是“今天”可以正常工作的。明天将是一个全新的“可能不行”的炼狱,因为我想不到所有可能遇到的情况。我尽量避免大多数明显的问题,但就像编程中的一切一样,当你认为你的代码是防傻瓜的,世界就会发明一个更傻的傻瓜,然后你就不得不进行我称之为“条件反射式编程”的工作。
这篇文章假设你是一名中等水平的开发者,但同时也希望有一些代码可以帮助你处理一个你宁愿自己不花太多时间去处理的紧急情况。我没有做任何过于花哨或优雅的事情,因为“花哨和优雅”的代码通常更难理解和维护。代码有详细的注释,因此应该有充分的解释说明它是如何工作的。如果我在这里提到了你不理解的内容,你有许多即时可用的搜索引擎。我的建议——使用它们。
示例文件、适用性以及注意事项
示例代码包含一个简短的 XLSX 文件,以及一个同样简短的 CSV 文件。CSV 文件包含一些故意无效的行,以便代码能够得到合理的测试。
为了确保该类满足您的特定需求,请使用包含的示例项目来确定代码的适用性,并在将其放入您自己的项目之前进行任何您认为必要的更改。
在浏览代码时,请记住,编码风格在几乎所有程序员之间都不同。不要纠结于格式,或者为什么我以某种方式做某事。请记住,您拥有源代码,因此您可以自由地根据自己的喜好修改代码。同时也要记住,您可能会遇到一两个错误。没有人能写出完美的代码,我也可能没有采取足够的措施来防止程序员滥用代码。如果您发现类似情况,请像一名程序员一样解决它。
代码
这个库从 Excel 或 CSV 文件加载数据,并以 DataTable
的形式提供给程序员,以便在他们的应用程序中进行进一步处理。每列的数据类型可以根据列本身的内容自动确定,也可以由程序员手动指定。
NuGet 包 - ExcelDataReader
当我为上述 SQLXAgent 项目开发这段代码时,我发现了一个名为 ExcelDataReader
的 NuGet 包,它非常方便地加载 Excel 文件并将加载的数据以 DataTable
对象的形式返回。我真的感到震惊,这个包没有再进一步尝试确定加载数据的合适数据类型,而本文中的很多代码都致力于解决加载数据的这一方面。
这个包可以加载 Excel 和 CSV 文件,但我只用它来加载 Excel 文件,并更喜欢使用自己的代码来加载 CSV 文件。原因是 ExcelDataReader
不给我足够的控制权来处理 CSV 文件解析过程中发生的事情。例如,如果遇到格式错误行的形式的问题,ExcelDataReader
会抛出一个异常,你将无法从文件中获取任何数据。我希望尽可能多地返回数据,同时仍然报告哪些行格式错误。我还想控制何时以及如何确定列数据类型。
总体架构
代码由一个基类导入器、一个继承基类的特定源导入器类以及几个支持类组成。还包含一些我多年来开发的各种 .NET 类的扩展方法,其中许多不一定被导入器代码使用,但其中一些对你们来说在其他情况下可能很有价值。我将这些方法留给你们通过智能感知(愿原力与你同在)来发现。
ImportBase 类
这个类无疑是大部分工作发生的地方。正如你所料,它包含 CsvImportBase
和 ExcelImportBase
类都常用的方法和属性。这个类也是抽象的,这意味着你不能直接实例化它。
你可能会注意到,几乎所有的方法都是虚方法。这意味着如果你对事情应该如何工作有不同的看法,你可以重写这些方法并实现你自己的疯狂版本,而不会破坏原始方法。
导入器配置属性
string FileName
- 表示正在导入的文件的完整路径ImportFileType FileType
- 表示正在导入的文件类型(Excel 或 CSV),基于哪个派生的基类导入器正在使用。bool CanImport
- 一个指示文件是否可以导入的标志。该标志由派生的基类导入器根据给定的导入器执行的健全性检查来设置。
导入器控制属性
为了使导入尽可能可配置(但仍是自动化的),有许多属性可以在你的派生类中设置。
ItemHints ColumnHints
- 表示一个包含项的集合,指示给定导入列的数据类型。请参考专门讨论此集合的部分。bool AllFieldsAreStrings (false)
- 一个指示所有导入的列都包含字符串的标志。bool DeleteEmptyRows (true)
- 一个指示在导入数据中遇到的空行不应包含在生成的DataTable
对象中的标志。bool FirstRowDeterminesType (false)
- 一个指示仅使用数据的第一行来确定ColumnHints
集合中列数据类型的标志。bool FirstRowIsHeader (true)
- 一个指示文件第一行包含列标题名称的标志。如果你为某个文件将其设置为 false,则列名将以“Column_n”的格式分配,其中“n”表示行中列的序数位置。string ImportedTableName (string.Empty)
- 分配给DataTable
的表名。如果在调用Import
方法时此字符串为空/空,则文件名(如果是 Excel 文件,则还包括工作表名)将用作表名。
导入器输出属性
DataTable ImportedData
- 此属性是导入数据的结果。Import
方法退出后,假设在导入过程中没有抛出异常,此对象将返回所有有效的行,并且已经设置了列名和适当的列数据类型。int RemovedRowCount
- 此值表示在导入过程中删除的空行的数量。
抽象方法 - Import()
这是类中唯一的抽象方法,因此必须在继承链的某个地方重写。在我们的例子中,它被 CSVImportBase
和 ExcelImportBase
类重写,因为这两种文件类型的导入略有不同。将在本文稍后单独讨论每个类的版本。
方法 - Init
Init
方法由派生类调用,用于对文件名进行健全性检查,并为控制和输出属性设置默认值。
protected virtual void Init()
{
DebugMsgs.Add(this, DebugLevel.Full, "ImportBase");
this.DoSanityChecks();
this.ImportedTableName = string.Empty;
this.FirstRowIsHeader = true;
this.ColumnHints = null;
this.DeleteEmptyRows = true;
this.FirstRowDeterminesType = false;
this.AllFieldsAreStrings = false;
}
方法 - DoSanityChecks
此方法确保我们有 a) 有效的文件名,以及 b) 文件存在。我认识到空/ null 文件名将为 File.Exists()
方法返回 false,但我将两种可能的异常分开,因为它们是截然不同的问题。
protected virtual void DoSanityChecks()
{
this.CanImport = false;
if (string.IsNullOrEmpty(this.FileName))
{
throw new ParserAgentException(ParserExceptionEnum.InvalidFileName);
}
else
{
if (!File.Exists(this.FileName))
{
throw new ParserAgentException(ParserExceptionEnum.ImportFileNotFound,
new FileNotFoundException(this.FileName));
}
}
this.CanImport = true;
}
方法 - ProcessData
此方法由派生类重写的 Import
方法调用,并负责根据注释的要求整理导入的数据。它调用的每个方法都可以在派生类中被重写,以提供额外的和/或替代的功能。
protected virtual void ProcessData()
{
// remove empty rows (if configured to do so)
this.RemoveEmptyRows();
// remove linefeeds, spaces, and non-alphanumeric characters from
// column names
this.NormalizeNames();
// Create column hints list if necessary. If you want to use your own,
// instantiate it in your derived class BEFORE calling the Import()
// method.
this.CreateColumnHints();
// Correct the datatypes in the ImportedData object based on the
// ColumnHints content.
this.SetColumnTypes();
}
方法 - RemoveEmptyRows
如果 DeleteEmptyRows
属性为 true
,此方法将从数据表中删除所有列值都为 null 或空字符串的空行。否则,不执行任何处理。
protected virtual void RemoveEmptyRows()
{
this.RemovedRowCount = 0;
if (this.DeleteEmptyRows)
{
for (int i = this.ImportedData.Rows.Count-1; i >= 0; i--)
{
DataRow row = this.ImportedData.Rows[i];
if (row.ItemArray.All(x=>x is DBNull || string.IsNullOrEmpty(x.ToString())))
{
this.ImportedData.Rows.Remove(row);
this.RemovedRowCount++;
}
}
}
}
方法 - NormalizeNames
如果 FirstRowIsHeader
属性为 true
,此方法会将每个列名中的所有空格和非字母数字字符替换为下划线。这是从 SQLXAgent
文章中保留下来的,目的是避免 SQL 表中出现奇怪的列名(我个人讨厌在查询中使用方括号)。此外,表名(除非程序员指定了一个静态的)被设置为文件的名称。
protected virtual void NormalizeNames()
{
// If the first row is the header, pull the names from the first row, and
// delete the row.
if (this.FirstRowIsHeader)
{
// first row
DataRow headerRow = this.ImportedData.Rows[0];
// process each column
for (int i = 0; i < headerRow.ItemArray.Count(); i++)
{
// to ease typing
DataColumn excelColumn = this.ImportedData.Columns[i];
string rowColumn = (string)headerRow.ItemArray[i];
// Set the column name from the first row of the data
// if the column in the row is null/empty
excelColumn.ColumnName = (string.IsNullOrEmpty(rowColumn)
// keep the name we already have
? excelColumn.ColumnName
// otherwise set the excel column to whatever
// the row says it should be
: rowColumn
// trim leading/trailing spaces, and
// replace linefeeds and embedded spaces
// with underscores
).Trim().ReplaceNonAlphaNumeric('_');
}
// Delete the header row - i do this here because we've already satisfied
// the FirstRowIsHeader condition, and there's really no point in checking
// again, just to delete the header row.
this.ImportedData.Rows.RemoveAt(0);
}
// set the table name based on the name of the file (and if Excel file, the
// name of the sheet)
this.ImportedData.TableName = this.BuildTableName();
}
方法 - BuildTableName
此方法根据正在导入的文件名构建表名。同样,空格和非字母数字字符被替换为下划线。
protected virtual string BuildTableName()
{
// We use either the specified ImportedTableName, or we build the table name
// based on the filename and (if this is an excel file) sheet name. I try to
// avoid nested ternary conditions, but sometimes, it just makes sense to use
// them. In these situations, code formatting goes a long way toward assisting
// a programmer who is not familiar with the code base.
string newTableName = (string.IsNullOrEmpty(this.ImportedTableName))
? string.Concat("Imported_", Path.GetFileName(this.FileName).Trim())
: this.ImportedTableName;
return newTableName.ReplaceNonAlphaNumeric('_');
}
方法 - CreateColumnHints
此方法根据各种控制属性的指示创建列提示。简而言之,列提示的创建如下所示。为了简洁起见,下面列出的所有方法都包含在同一个代码块中。
- 如果
AllFieldsAreStrings
属性为true
,此方法将调用CreateAllStringsColumnHints
方法,该方法简单地将所有列的数据类型都设置为string
。由于不执行值检查,这是导入器中最快的列提示创建方法。它也很有可能在处理大多数导入数据方面毫无用处。如果AllFieldsAreStrings
属性为false
,并且... - 如果
FirstRowDeterminesType
属性为true
,此方法将调用CreateColumnHintFromFirstCompleteRow
方法,该方法仅检查数据第一行中的值来确定列类型。这是第二快的列提示创建方法,因为它只处理数据第一行中的每一列来确定列类型。如果FirstRowDeterminesType
属性为false
,并且... - 如果程序员尚未手动创建
ColumnHints
集合,则会评估每一行中的每一列,以确定给定列的最合适数据类型。数据类型的确定方法将在描述ColumnHints
集合的部分中进行讨论。
protected virtual void CreateColumnHints()
{
if (this.AllFieldsAreStrings)
{
this.CreateAllStringsColumnHints();
}
else if (this.FirstRowDeterminesType)
{
this.CreateColumnHintFromFirstCompleteRow();
}
else
{
// if the programmer hasn't already specified a hints list
if (this.ColumnHints == null || this.ColumnHints.Count == 0)
{
// instantiate
this.ColumnHints = new ItemHints();
// for each column in the Columns collection
for (int i = 0; i < this.ImportedData.Columns.Count; i++)
{
// get the column
DataColumn col = this.ImportedData.Columns[i];
// if the name isn't null/empty (theoretically impossible, but we
// check simply because it's a good idea)
if (!string.IsNullOrEmpty(col.ColumnName))
{
// create a new hint item
HintItem hint = new HintItem()
{ Name = col.ColumnName, ColNumb = col.Ordinal, ItemType = null };
// iterate each row
foreach (DataRow row in this.ImportedData.Rows)
{
// try to determine the best data type based on all of the
// possible values
hint.DetermineType(row[col], this.FileType == ImportFileType.CSV);
// if we determine at any point that the column should be a
// string, we can quit because a string type is our ultimate
// fallback data type.
if (hint.ItemType.Name.IsLike("String"))
{
break;
}
}
// add the hint to our list
this.ColumnHints.Add(hint);
}
}
}
}
}
public virtual void CreateAllStringsColumnHints()
{
// instantiate a new collection of hints
this.ColumnHints = new ItemHints();
// iterate all of the columns
foreach (DataColumn col in this.ImportedData.Columns)
{
// if the name isn't null/empty (theoretically impossible, but we
// check simply because it's a good idea)
if (!string.IsNullOrEmpty(col.ColumnName))
{
// create a new hint item and add it to the column hints collection
HintItem hint = new HintItem()
{ Name = col.ColumnName, ColNumb = col.Ordinal, ItemType = typeof(string) };
this.ColumnHints.Add(hint);
}
}
}
protected virtual void CreateColumnHintFromFirstCompleteRow()
{
// instantiate a new collection of hints
this.ColumnHints = new ItemHints();
// get the first row
DataRow row = this.ImportedData.Rows[0];
// iterate all of the columns
foreach (DataColumn col in this.ImportedData.Columns)
{
// if the name isn't null/empty (theoretically impossible, but we
// check simply because it's a good idea)
if (!string.IsNullOrEmpty(col.ColumnName))
{
// create a new hint item and add it to the column hints collection
HintItem hint = new HintItem()
{ Name = col.ColumnName, ColNumb=col.Ordinal, ItemType = null };
hint.DetermineType(row[col], this.FileType == ImportFileType.CSV);
this.ColumnHints.Add(hint);
}
}
// if a column in the first row is empty, that column will be determined
// to be a string column.
}
方法 - SetColumnTypes
此方法使用 ColumnHints
集合为导入的数据重置列数据类型。我在这里学到了一点:一旦 DataTable
对象有了行,就无法更改列数据类型。如果要更改数据类型,必须克隆数据表对象(只复制模式到新数据表中),设置列数据类型,然后将旧数据表的行逐一导入到新数据表中。
protected virtual void SetColumnTypes()
{
// if we have column hints
if (this.ColumnHints != null)
{
// you can't change the datatype of a column once there are rows in the
// datatable, so clone the datatable (brings over schema, but not data)
using (DataTable cloned = this.ImportedData.Clone())
{
// set the column types
for (int i = 0; i < cloned.Columns.Count; i++)
{
cloned.Columns[i].DataType = ColumnHints[i].ItemType;
}
for (int i = 0; i < this.ImportedData.Rows.Count; i++)
{
DataRow row = this.ImportedData.Rows[i];
cloned.ImportRow(row);
}
// set the imported data to the cloned data table
this.ImportedData = cloned;
}
}
else
{
using (DataTable cloned = this.ImportedData.Clone())
{
// set the column types
for (int i = 0; i < cloned.Columns.Count; i++)
{
cloned.Columns[i].DataType = typeof(object);
}
for (int i = 0; i < this.ImportedData.Rows.Count; i++)
{
DataRow row = this.ImportedData.Rows[i];
cloned.ImportRow(row);
}
// set the imported data to the cloned data table
this.ImportedData = cloned;
}
}
}
方法 - GetColumnValue(一个重载)
这些方法检索指定行/列中包含的值。为了避免方法产生无数个重载,我选择了使用泛型类型,以便可以对值执行适当的转换。同样,为了简洁起见,我将两个重载都包含在同一个代码块中。
/// <summary>
/// Gets the row/column value by column name. The column index is found, and this
/// method then calls the method overload.
/// </summary>
/// <param name="row">The row index in the datatable</param>
/// <param name="columnName">The name of the column in the datab table</param>
/// <param name="defaultValue">The value to return if the value is null</param>
/// <returns>An object representing the value at the specified row index/column name</returns>
public T GetColumnValue<t>(int row, string colName, T defaultValue)
{
// sanity checks first
if (row < 0 || row >= this.ImportedData.Rows.Count)
{
throw new ParserAgentException(ParserExceptionEnum.RowIndexOutOfRange,
new ArgumentOutOfRangeException(
string.Format("Index {0} is out of range
(max row index is {1})",
row, this.ImportedData.Rows.Count-1)));
}
if (string.IsNullOrEmpty(colName))
{
throw new ArgumentNullException("Column name parameter cannot be null or empty.");
}
if (!this.ImportedData.Columns.Contains(colName))
{
throw new ParserAgentException(ParserExceptionEnum.ColumnDoesNotExist,
new ArgumentException(
string.Format("Specified column name '{0}'
not found in data table",
colName)));
}
if (this.ColumnHints.FindByColumnName(colName) == null)
{
throw new ParserAgentException(ParserExceptionEnum.ColumnDoesNotExist,
new ArgumentException(
string.Format("Specified column name '{0}'
not found in column hints",
colName)));
}
// if we get here, we're guaranteed a valid column index
int column = -1;
for (int i = 0; i < this.ImportedData.Columns.Count; i++)
{
if (ImportedData.Columns[i].ColumnName.ToUpper() == colName.ToUpper())
{
column = i;
}
}
object value = this.ImportedData.Rows[row].ItemArray[column];
T result = (value != null) ? (T)value : defaultValue;
return result;
}
/// <summary>
/// Gets the row/column value by index.
/// </summary>
/// <param name="row">The row index in the datatable</param>
/// <param name="column">The column index in the datatable</param>
/// <param name="defaultValue">The value to return if the value is null</param>
/// <returns>An object representing the value at the specified row/column index</returns>
public T GetColumnValue<t>(int row, int column, T defaultValue)
{
// sanity checks first
if (row < 0 || row >= this.ImportedData.Rows.Count)
{
throw new ParserAgentException(ParserExceptionEnum.RowIndexOutOfRange,
new ArgumentOutOfRangeException
(string.Format("Index {0} is out of range
(max row index is {1})",
row, this.ImportedData.Rows.Count-1)));
}
if (column < 0 || column >= this.ImportedData.Columns.Count)
{
throw new ParserAgentException(ParserExceptionEnum.ColumnIndexOutOfRange,
new ArgumentOutOfRangeException(
string.Format("Column index {0} is out of range
(max column index is {1})",
column, this.ImportedData.Columns.Count-1)));
}
if (column >= this.ColumnHints.Count)
{
throw new ParserAgentException(ParserExceptionEnum.ColumnIndexOutOfRange,
new ArgumentException(
string.Format("Column index {0} is out of range
in column hints collection (max column index is {1})",
column, this.ColumnHints.Count-1)));
}
T result;
object value = this.ImportedData.Rows[row].ItemArray[column];
result = (value != null) ? (T)value : defaultValue;
return result;
}
ColumnHints
ColumnHints
集合包含导入数据中找到的每一列的一个 HintItem
对象。HintItem
包含 Name
、DataType
和 ColNumb
属性,但它们并不是类中最有趣的部分。真正的魔力发生在 DetermineType
方法中。
类型确定基本上是按复杂性递减的顺序处理的,首先尝试将值解析为 DateTime
,然后是各种数字和布尔类型,当所有其他都失败时,类型被确定为 string
。有各种方法可以用来覆盖数据类型确定的行为,所有这些方法都已经讨论过。
public void DetermineType(object value, bool fromCSV=false)
{
// if the type isn't already a string
if (this.ItemType == null || !this.ItemType.Name.IsLike("String"))
{
Type theType = this.ItemType;
// we have to check if its from a CSV import because everything imported
// from a CSV defaults to string.
if (value is string && !fromCSV)
{
theType = typeof(String);
}
else
{
string valueStr = value.ToString();
DateTime parsedDateTime;
Int32 parsedInt32;
double parsedDouble;
bool parsedBool;
// We try to parse the value using all standard datetime formats
// (custom formats are not supported unless you want to modify the
// DateFormatStrings object.).
if (DateTime.TryParseExact(valueStr, DateFormatStrings.Formats,
CultureInfo.CurrentUICulture, DateTimeStyles.None, out parsedDateTime))
{
theType = typeof(DateTime);
}
else if (Int32.TryParse(valueStr, out parsedInt32))
{
// we only want to change the type if it's not already a double
if (theType == null || !theType.Name.IsLike("Double"))
{
theType = typeof(Int32);
}
}
else if (double.TryParse(valueStr, out parsedDouble))
{
theType = typeof(Double);
}
else if (bool.TryParse(valueStr, out parsedBool))
{
theType = typeof(Boolean);
}
else
{
theType = typeof(String);
}
}
this.ItemType = theType;
}
}
CSV 导入器
此类负责导入 CSV 文件。与 ImportBase
类一样,它是抽象的,并且必须由您编写的类继承。幸运的是,该类的默认设置使得您可以轻松地做到这一点(请参阅下面的使用导入器部分)。
控件属性
bool FailOnMalformedLine (false)
- 如果此属性为true
,则在遇到格式错误的行时,导入将停止并抛出异常。string CurrencySymbol
- 表示本地化的货币符号。默认情况下,代码会根据操作系统设置来设置此值。但是,程序员可以根据需要手动设置。string[] CurrentData
- 表示最后解析行的列数据数组。bool RemoveCurrencySymbols (true)
- 如果此属性为true
,则在确定列数据类型之前,将从数据中删除货币符号。
注意:如果您希望您的货币数据被视为数字数据,则必须将此标志保留为true
。否则,整个列将被视为string
。bool RemoveQuotes (true)
- 如果此属性为true
,则将从string
字段中删除包围的引号字符。
处理属性
string CurrentLine
- 表示当前正在解析的文本行。bool IsMalformed
- 指示当前行是否被确定为格式错误。
输出属性
List<invalidline> InvalidLines
- 在处理 CSV 文件时遇到的无效行的集合。int TotalLinesProcessed
- 指示在 CSV 文件中处理的行数。此数字包括无效行。
方法 - Init
此方法初始化 CSV 导入器对象。首先,它调用基类版本的方法,然后继续为基类属性和自己的属性设置适当的值。
protected override void Init()
{
// call the base class Init method
base.Init();
// set the file type
this.FileType = ImportFileType.CSV;
// unlike the excel importer, we have some additional properties we need to set
this.RemoveCurrencySymbols = true;
this.CurrencySymbol = CultureInfo.CurrentUICulture.NumberFormat.CurrencySymbol;
this.CurrentLine = string.Empty;
this.CurrentData = null;
this.IsMalformed = false;
this.InvalidLines = new List<invalidline>();
this.ImportedData = new DataTable();
this.TotalLinesProcessed = 0;
this.FailOnMalformedLine = false;
this.RemoveQuotes = true;
}
方法 - Import
这是必须最终重写的抽象方法,它协调导入过程。在这种情况下,我们调用一个方法来导入和解析数据,然后调用基类的 ProcessData
方法。
public override void Import()
{
if (this.CanImport)
{
this.Parse();
base.ProcessData();
}
}
方法 - Parse
此方法逐行打开文件,解析数据,如果没有遇到问题,则将解析后的数据添加到数据表中。如果在一行中遇到问题,则将行号、行文本以及确定其无效的原因存储在 InvalidLines
集合中。
protected virtual void Parse()
{
// Yes, I'm aware that the ExcelDataReader can import CSV files, but I wanted
// more control over the process so I could report on invalid lines, etc.
this.InvalidLines.Clear();
try
{
using (StreamReader dataStream = new StreamReader(this.FileName))
{
this.InvalidLines.Clear();
// sheet is not going to be null by the time we get here
string line = string.Empty;
int lineCounter = -1;
while ((line = dataStream.ReadLine()) != null)
{
lineCounter++;
// ignore blank lines
if (string.IsNullOrEmpty(line))
{
continue;
}
// read the fields in the string. If the line is malformed, this method
// will detect that condition and set the IsMalformed property in this
// class.
this.CurrentData = this.ReadFields(lineCounter, line);
// initialize the data table (if necessary)
this.InitDataTable(this.CurrentData);
Debug.WriteLine(line);
// if the string is malformed, add its index to the InvalidLines list
if (this.IsMalformed)
{
if (this.FailOnMalformedLine)
{
throw new ParserAgentException(ParserExceptionEnum.CsvMalformedLine,
new InvalidOperationException(
string.Format("Line n is malformed.",
lineCounter)));
}
}
else
{
// we add all rows to the datatable,
// even the header row (if the file has one).
this.AddToDataTable(this.CurrentData);
}
this.TotalLinesProcessed++;
} // while ((line = this.DataStream.ReadLine()) != null)
} // using (StreamReader dataStream = new StreamReader(this.FileName))
}
catch (Exception ex)
{
throw new Exception("Error parsing file.", ex);
}
}
方法 - ReadFields
Parse
方法为 CSV 文件中的每一行调用此方法,并解析代表数据行的提供的文本行。不尝试修复无效行。如果一行被确定为无效(例如缺少闭合引号,或找到的列数不等于预期的列数),则将错误行添加到 InvalidLines
集合中。
protected virtual string[] ReadFields(int lineCounter, string text)
{
//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 = (this.RemoveQuotes) ? currentPart.Trim('\"') : currentPart;
// put the currentPart into our container
newParts.Add(currentPart);
// reset the currentPart
currentPart = string.Empty;
}
}
// determine if the line is somehow invalid, and if it is, save it
this.IsMalformed = (inQuotes || this.ImportedData.Columns.Count > 0 &&
newParts.Count != this.ImportedData.Columns.Count);
if (this.IsMalformed)
{
string reason = (inQuotes) ? "Missing end-quote" : "Missing at least one column";
this.InvalidLines.Add(new InvalidLine()
{
LineNumber = lineCounter,
LineText = text,
Reason = reason
});
}
return newParts.ToArray();
}
方法 - InitDataTable
此方法为处理的每一行调用,但仅在数据表中尚未定义任何列时才初始化数据表。它只是添加列。
protected virtual void InitDataTable(string[] parts)
{
if (this.ImportedData == null)
{
this.ImportedData = new DataTable();
}
// if the columns have not yet been defined, we need to create them.
if (this.ImportedData.Columns.Count == 0)
{
for (int i = 0; i < parts.Length; i++)
{
// If the first row contains headers, use the contents of the parts
// array to specify the names. Otherwise, set the column names to
// "Column_n" where "n" is the current index into the parts array.
this.ImportedData.Columns.Add((this.FirstRowIsHeader) ?
parts[i] : string.Format("Column_{0}", i + 1), typeof(object));
}
}
}
方法 - AddToDataTable
此方法将解析后的数据添加到数据表中。到这里为止,不应需要进行验证。
protected virtual void AddToDataTable(string[] parts)
{
// Create a new row. New rows contain n items in the ItemArray property. The
// number of items is determined by the number of columns the datatable has.
DataRow row = this.ImportedData.NewRow();
// now add our parts to the row
for (int i = 0; i < parts.Length; i++)
{
string part = parts[i];
if (!string.IsNullOrEmpty(this.CurrencySymbol))
{
part = (part.StartsWith(this.CurrencySymbol) ||
part.EndsWith(this.CurrencySymbol)) ?
part.Replace(this.CurrencySymbol, "").Replace(",", "") : part;
}
part = part.Replace("\"", "").Trim();
row[i] = part;
}
// add the new row to the datatable.
this.ImportedData.Rows.Add(row);
// Yes, we are adding the header row (if there is a header row), but before
// we determine data types, we'll be deleting it, so no harm no foul.
}
Excel 导入器
由于我们将所有繁重的工作交给 ExcelDataReader
库来完成,因此此类的工作量非常少。
配置属性
string SheetName
- 表示要导入的工作表的名称。
构造函数
由于我们必须在文件中指定一个 worksheet
,因此此构造函数有一个额外的行来处理它。
public ExcelImportBase(string filename, string sheetName) : base(filename)
{
this.SheetName = sheetName;
// initialize the properties that control how the importer functions
this.Init();
}
方法 - Init
与 CSV 导入器一样,“本地”Init
方法调用基类版本的方法,并设置文件类型。
protected override void Init()
{
base.Init();
this.FileType = ImportFileType.EXCEL;
}
方法 - DoSanityChecks
Excel 导入器需要额外的健全性检查来验证工作表名称是否有效。首先,它调用基类版本的方法,然后对工作表名称执行健全性检查。
protected override void DoSanityChecks()
{
base.DoSanityChecks();
if (this.CanImport)
{
if (this.FileType == ImportFileType.EXCEL)
{
if (string.IsNullOrEmpty(this.SheetName))
{
this.CanImport = false;
throw new ParserAgentException(ParserExceptionEnum.InvalidSheetName);
}
}
}
}
方法 - Import
同样,与 CSV 导入器一样,重写的抽象 Import
方法加载文件,然后调用基类的 ProcessData
方法。
public override void Import()
{
if (this.CanImport)
{
// load the worksheet from the file
this.ImportedData = this.ReadSheet(this.FileName, this.SheetName);
// process the data
base.ProcessData();
}
}
方法 - BuildTableName
由于我们要将工作表名称作为表名的一部分,因此我们重写了 BuildTableName
,它调用基类版本的方法,然后如果 ImportedTableName
是 null
/空 string
,则将工作表名称附加到当前表名。
protected override string BuildTableName()
{
string newTableName = base.BuildTableName();
newTableName = (!string.IsNullOrEmpty(this.ImportedTableName))
? this.ImportedTableName
: string.Concat(newTableName, "_", this.SheetName.Trim());
return newTableName.ReplaceNonAlphaNumeric('_');
}
方法 - ReadSheet
此方法使 ExcelDataReader
库将指定的文件/工作表加载到数据集中,然后使用该数据集创建结果数据表。由于该库完成了所有繁重的工作,我们所要做的就是接受它。令人惊讶的是,该库在正确识别列的数据类型方面几乎不(或根本不)做任何努力,所以在此方法返回后我们仍然调用 ProcessData
。
protected virtual DataTable ReadSheet(string filename, string sheetName)
{
DataSet ds = null;
DataTable data = null;
try
{
using (System.IO.FileStream stream =
File.Open(filename, FileMode.Open, FileAccess.Read))
{
// all of the heavy lifting is performed by the ExcelDataReader library.
using (var reader = ExcelDataReader.ExcelReaderFactory.CreateReader(stream))
{
do
{
if (reader.Name.IsLike(sheetName))
{
ds = reader.AsDataSet();
}
} while (reader.NextResult());
}
if (ds != null)
{
data = ds.Tables[0];
}
}
}
catch (Exception ex)
{
throw new ParserAgentException(ParserExceptionEnum.ExcelDataReaderError, ex);
}
return data;
}
使用导入器
您将对编写代码以使这些类正常工作所需的代码量感到震惊。首先,您创建您的 Importer 类。如果您使用默认设置,您只需要一个带有构造函数的类,假设您只执行标准导入而无需更改基类中的任何默认属性值。
public class CSVLoader : CsvImportBase
{
public CSVLoader(string filename) : base(filename)
{
}
}
然后,您编写代码来使用它。本文随附的示例应用程序要广泛得多(并且注释更详细)。下面的代码块用于说明加载 CSV 文件所需的工作量有多小。
class Program
{
private static string AppPath { get; set; }
static void Main(string[] args)
{
string codeBase = Assembly.GetExecutingAssembly().CodeBase;
UriBuilder uri = new UriBuilder(codeBase);
string path = Uri.UnescapeDataString(uri.Path).ToLower().Replace("/", @"\");
AppPath = System.IO.Path.GetDirectoryName(path);
ImportCSV();
}
private static void ImportCSV()
{
string filename = System.IO.Path.Combine(AppPath, @"testdata\sample1.csv");
CSVLoader loader = null;
try
{
loader = new CSVLoader(filename);
loader.Import();
// this is where you add code to do something meaningful with the data
}
catch (Exception ex)
{
if (ex != null) { }
}
}
}
从加载器中提取数据
最终,您很可能会编写代码来以某种方式提取数据到对象中以供进一步操作/使用,因此我包含了一个示例来说明如何做到这一点。
CSVItem 类
首先,我们需要一个对象来保存项目的数据。为了达到示例(嘿,如果你这群疯子可以用“大象”代替“他妈的”,我就可以自己造词)的目的,这个对象只是定义了保存数据的必要属性。
public class CSVItem
{
public int ID { get; set; }
public string Comment { get; set; }
public DateTime The_Date { get; set; }
public double FloatValue { get; set; }
public int IntValue { get; set; }
public string IPAddress { get; set; }
public double Currency { get; set; }
}
接下来,我定义了一个继承 List>CSVItem>
的集合。在这个类中,有两个对 List.Add
方法的重写。
public class CSVItems : List<CSVItem>
{
/// <summary>
/// Add the entire data table collection to this collection,
/// using DataTableReader extension methods.
/// </summary>
/// <param name="data">The datatable that represents the imported data</param>
public void Add(DataTable data)
{
try
{
// I already have a set of extension methods to extract column data from a
// DataTableReader object (these methods do all the casting and sets default
// values), so it's a simple matter create a DataTableReader from the
// DataTable to utilize these existing methods.
using (DataTableReader reader = data.CreateDataReader())
{
if (reader.HasRows)
{
int ORD_ID = reader.GetOrdinal("ID");
int ORD_COMM = reader.GetOrdinal("Comment");
int ORD_DATE = reader.GetOrdinal("The_Date");
int ORD_FLOATV = reader.GetOrdinal("FloatValue");
int ORD_INTV = reader.GetOrdinal("IntValue");
int ORD_IP = reader.GetOrdinal("IPAddress");
int ORD_CURR = reader.GetOrdinal("Currency");
while (reader.Read())
{
this.Add(new CSVItem()
{
ID = reader.GetInt32OrDefault (ORD_ID, 0),
Comment = reader.GetStringOrDefault (ORD_COMM,
"No Comment specified"),
The_Date = reader.GetDateTimeOrDefault(ORD_DATE,
new DateTime(0)),
FloatValue = reader.GetDoubleOrDefault (ORD_FLOATV, 0d),
IntValue = reader.GetInt32OrDefault (ORD_INTV, 0),
IPAddress = reader.GetStringOrDefault (ORD_IP, "1.0.0.0"),
Currency = reader.GetDoubleOrDefault (ORD_CURR, 0d),
});
}
}
}
}
catch (Exception ex)
{
throw new Exception("Exception encountered while storing data
into a list of objects", ex);
}
}
/// <summary>
/// Add the entire data table collection to this collection, using the importer object's
/// GetColumnValue method.
/// </summary>
/// <param name="loader">The importer that contains the imported data</param>
public void Add(CsvImportBase loader)
{
try
{
// The loader has a method called GetColumnValue which requires less code than
// the extension methods described above. Most of you will probably prefer to
// use those methods.
for (int row = 0; row < loader.ImportedData.Rows.Count; row++)
{
this.Add(new CSVItem()
{
ID = loader.GetColumnValue(row, "ID", 0),
Comment = loader.GetColumnValue(row, "Comment",
"No Comment specified"),
The_Date = loader.GetColumnValue(row, "The_Date", new DateTime(0)),
FloatValue = loader.GetColumnValue(row, "FloatValue", 0d),
IntValue = loader.GetColumnValue(row, "IntValue", 0),
IPAddress = loader.GetColumnValue(row, "IPAddress", "1.0.0.0"),
Currency = loader.GetColumnValue(row, "Currency", 0d),
});
}
}
catch (Exception ex)
{
throw new Exception("Exception encountered while storing data
into a list of objects", ex);
}
}
}
结束语
我认为,恰当使用面向对象原则应该尽可能地抽象出实现细节,同时允许尽可能合理的扩展性。这段代码的最终目标是尽可能简单地加载 CSV/Excel 文件。我认为这段代码实现了这些目标。
如果您不需要 Excel 导入代码,那么从项目中删除它很简单。我建议先使用 Nuget 包管理器控制台卸载 ExcelDataReader
包,然后让智能感知之类的东西失灵。此时,只需向下滚动到 Visual Studio 错误列表窗口,删除所有带有红色下划线的代码。这应该只需要几分钟。
历史
- 2018年4月7日 - 初次发布