适用于.NET的CSV序列化器
用于序列化和反序列化CSV文件的通用解决方案。
引言
在你需要的时候,怎么找不到通用的CSV序列化器呢?是不是没有?
本文构建了一个通用的CSV序列化解决方案。
设计
我们期望这样的序列化器能够提供以下能力
- 将指定类型的列表序列化为平面CSV文件。
- 从相同(或构造方式相似)的文件中重建对象。
- 模仿.NET XML序列化器或Binary Formatter在流对象写入和读取方面的用法。
- 使用
CsvIgnore
特性指定要从目标类型中忽略的属性。 - 允许用户指定一个分隔符字符,而不是默认的逗号。
关注点
以下是可能在初期并不明显,代表风险的顾虑
- 分隔符字符可能出现在正在序列化的属性值中。这将导致生成的行看起来比预期的列数更多。
- 同样,换行符可能出现在属性的值中。这将导致生成的行跨越多行。
这两个问题都会导致行(甚至整个CSV文件)不可读,因此必须加以处理。
我们用同一种方法解决这两个问题:将有问题的字符序列替换为用户指定的字符串。在序列化时,每个出现问题字符的地方都被替换为一个虚拟字符,然后在反序列化时,原始字符替换虚拟字符,原始字符串得以恢复。
用户需要知道,在所有这些情况下,替换字符串必须不可能出现在要序列化的常规数据中。否则,反序列化会将替换字符串的有效出现替换为分隔符或换行符字符。
CSV序列化
考虑到CSV是一种平面文件结构,我们不必考虑深度对象序列化。因此,关联将呈现为单个文本值。
CSV结构也意味着要序列化的数据应该是对象列表,因此序列化方法应接收目标类型的IList
。反序列化方法将返回这样的列表。
类
根据设计要求1、2、3和5,以及到目前为止的讨论,我们可以定义该类的主要需求。
该类将
- 公开一个分隔符字符(默认为逗号)。
- 公开一个替换字符串。
- 通过泛型参数
T
引用要序列化/反序列化的目标类型。 - 构建并维护一个
PropertyInfo
列表,用于要序列化/反序列化的属性。 - 公开一个接受
Stream
对象和数据IList
的序列化方法。 - 公开一个接受
Stream
对象并返回数据IList
的反序列化方法。
现在我们可以将这个粗略的轮廓定义如下:
public class CsvSerializer<T> where T : class, new()
{
public char Separator { get; set; }
public string Replacement { get; set; }
private List<PropertyInfo> _properties;
public void Serialize(Stream stream, IList<T> data) { }
public IList<T> Deserialize(Stream stream) { }
.
.
.
}
PropertyInfo列表
该类执行的第一步将是组装一个要渲染到CSV的属性列表(以List
of PropertyInfo
的形式)。这一步将在构造时执行。
此属性列表用于三个目的:
- 创建CSV头。
- 在序列化时通过反射获取实例值。
- 在反序列化时通过反射设置实例值。
public CsvSerializer()
{
var type = typeof(T);
var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance
| BindingFlags.GetProperty | BindingFlags.SetProperty);
_properties = (from a in properties
where a.GetCustomAttribute<CsvIgnoreAttribute>() == null
orderby a.Name
select a).ToList();
}
CsvIgnoreAttribute
与XmlSerializer
一样,我们希望通过在属性上添加CsvIgnore
特性来指定要从序列化中排除的属性。
CsvIgnore
特性的定义非常简单,如下所示:
public class CsvIgnoreAttribute : Attribute { }
CSV头
文件的头行将是属性名称的逗号分隔字符串。属性列表已按名称排序,因此我们可以确信列的顺序将在渲染每一行时与值的顺序匹配。
我们创建一个私有的GetHeader
方法,该方法返回头行作为字符串。
private string GetHeader()
{
var columns = Properties.Select(a => a.Name).ToArray();
var header = string.Join(Separator.ToString(), columns);
return header;
}
序列化方法
在创建CSV头行后,序列化器会迭代数据项的可枚举列表,并为每个项检索PropertyInfo
列表中所有属性的值(如上所述)。这些值被添加到字符串数组中,最后用分隔符字符连接。
序列化方法本身接受一个流对象和类型为T
的对象列表IList
。
在遍历列表并生成每一行后,它使用流写入器将最终的CSV文本写入流对象。
public void Serialize(Stream stream, IList<T> data)
{
var sb = new StringBuilder();
var values = new List<string>();
sb.AppendLine(GetHeader());
var row = 1;
foreach (var item in data)
{
values.Clear();
foreach (var p in _properties)
{
var raw = p.GetValue(item);
var value = raw == null ?
"" :
raw.ToString().Replace(Separator.ToString(), Replacement);
values.Add(value);
}
sb.AppendLine(string.Join(Separator.ToString(), values.ToArray()));
}
using (var sw = new StreamWriter(stream))
{
sw.Write(sb.ToString().Trim());
}
}
反序列化方法
反序列化方法接受一个代表CSV文本文件的流对象。
我们将第一行读作CSV头行。头通过分隔符字符分割,然后存储在列数组中,并在以后索引以按名称引用属性。
剩余的文本按换行符分割并存储在“行”中。每一行然后使用分隔符字符进行分割。结果部分随后被反序列化为一个新的T
类型实例的值。这些值的顺序将与列数组的顺序相同,这样我们就可以通过索引检索列名,并使用列名从PropertyInfo
列表中检索相应的PropertyInfo
。
然后,可以使用属性的.NETTypeConverter
将每个字符串部分转换为正确的值,并使用PropertyInfo
的SetValue
方法存储到目标对象的新实例中。
public IList<T> Deserialize(Stream stream)
{
string[] columns;
string[] rows;
try
{
using (var sr = new StreamReader(stream))
{
columns = sr.ReadLine().Split(Separator);
rows = sr.ReadToEnd().Split(new string[] { Environment.NewLine }, StringSplitOptions.None);
}
}
catch (Exception ex)
{
throw new InvalidCsvFormatException(
"The CSV File is Invalid. See Inner Exception for more inoformation.", ex);
}
var data = new List<T>();
for (int row = 0; row < rows.Length; row++)
{
var line = rows[row];
if (string.IsNullOrWhiteSpace(line))
{
throw new InvalidCsvFormatException(string.Format(
@"Error: Empty line at line number: {0}", row));
}
var parts = line.Split(Separator);
var datum = new T();
for (int i = 0; i < parts.Length; i++)
{
var value = parts[i];
var column = columns[i];
value = value.Replace(Replacement, Separator.ToString());
var p = _properties.First(a => a.Name == column);
var converter = TypeDescriptor.GetConverter(p.PropertyType);
var convertedvalue = converter.ConvertFrom(value);
p.SetValue(datum, convertedvalue);
}
data.Add(datum);
}
return data;
}
进一步的考虑和增强
在此,我们讨论一些可用于自定义特定情况下的序列化的有用设置。
语法和功能选项
分隔符
CSV分隔符字符
默认:逗号替换
如果分隔符字符出现在字段值中,则使用替换字符串。
默认:(char)255
NewLineReplacement
如果字段值中出现换行符,则使用替换字符串。
默认:(char)254
UseLineNumbers
可以在CSV中插入一个额外的列用于行号。
默认:true
UseEofLiteral
可以使用EOF字面量来标记文件结束。
默认:false
IgnoreEmptyLines
如果不是true,则在反序列化过程中遇到空行时会抛出异常。
默认:true
IgnoreReferenceTypesExceptString
排除除字符串以外的引用类型属性。
(默认:true
)。RowNumberColumnTitle
如果UseLineNumbers
为true
,则行号列的标题。
默认:“RowNumber
”-
UseTextQualifier
将双引号括起所有值。默认为false。
Separator
和Replacement
选项已通过公开Separator
和Replacement
属性来考虑,因此使用者可以使用任何其他字符作为分隔符并设置替换字符串。
重要的是,消费者要了解正在序列化的类型的取值范围,以便指定一个在数据范围内不会出现的替换字符串,否则在正常数据中出现的替换字符串的有效实例在反序列化时会被错误地转换为分隔符字符。
RowNumberColumnTitle
将与UseLineNumbers
选项配合使用,并在CSV文件中引入一个额外的第一列,其中包含行号。
发现CsvIgnore
一直应用于引用类型,使得引用类型在大多数CSV序列化场景中意义不大。此语句不适用于String
类型。因此,包含此属性似乎是合理的,可以自动排除除String
以外的所有引用类型。
在促使创建此类的要求中,规定CSV文件的最后一行应包含字面量字符串“EOF”。这不是CSV的常见做法,因此引入了一个选项来允许这种可能性。实现只是在序列化期间向CSV文本添加一行。并允许在反序列化期间存在EOF行的可能性。
IgnoreEmptyLines
选项允许反序列化器忽略空行或损坏的文本行。否则将抛出InvalidCsvFormatException
。
用法
与XmlSerializer
一样,要求目标类型公开一个默认的无参数构造函数。
然后,用法是一个两阶段的过程。首先,创建目标类型的CsvSerializer
实例。然后,用有效的流对象调用序列化或反序列化方法。
序列化方法当然需要要序列化的项目列表。
var data = GetPeople();
using (var stream = new FileStream("persons.csv", FileMode.Create, FileAccess.Write))
{
var cs = new CsvSerializer<Person>();
cs.Serialize(stream, data);
}
而反序列化方法将返回目标类型的对象列表。
IList<Person> data = null;
using (var stream = new FileStream("persons.csv", FileMode.Open, FileAccess.Read))
{
var cs = new CsvSerializer<Person>();
data = cs.Deserialize(stream);
}
注意
尽管此处提供的代码旨在提供通用解决方案,但它是由特定用例的标准促成的。
请测试并修改代码以适应此处未考虑到的其他特定情况。
进一步工作
以下是一些可能需要进一步工作和扩展的想法列表。
- 性能:该解决方案可能未针对速度进行充分优化,因此需要进一步工作来解决任何性能问题。
ColumnAttribute
:引入一个Column
属性,以便通过属性名以外的名称来命名列。IgnoreList
:与其用CsvIgnore
特性装饰属性,不如引用一个要忽略的属性名称列表。- 使用字符数组指定一个分隔符字符列表,用于反序列化,其中流可能包含不同的分隔符。
更新
- 添加
UseTextQualifier
属性,由用户Gogowater提供。