将对象序列化为自定义字符串格式以用于输出文件的最佳实践

35

我正准备在特定业务类上实现 ToString() 的重写,以产生适合于写入输出文件的 Excel 友好格式,稍后将被拾起并处理。以下是数据应如何显示:

5555555 "LASTN SR, FIRSTN"  5555555555  13956 STREET RD     TOWNSVILLE  MI  48890   25.88   01-003-06-0934

对我来说,只需制作一个格式化字符串并重写 ToString() 并不是什么大问题,但这将改变我决定以这种方式序列化的任何对象的 ToString() 的行为,使得整个库中的ToString() 实现都不一致。

现在,我已经研究了IFormatProvider,实现它的类听起来是个好主意,但我还是有些困惑这个逻辑应该在哪里,并如何构建格式化程序类。

当你需要从对象中生成CSV、制表符分隔或其他非XML任意字符串时,你们通常怎么做?

8个回答

75

以下是一种使用反射机制将对象列表转换为CSV文件的通用方法:

public static string ToCsv<T>(string separator, IEnumerable<T> objectlist)
{
    Type t = typeof(T);
    FieldInfo[] fields = t.GetFields();

    string header = String.Join(separator, fields.Select(f => f.Name).ToArray());

    StringBuilder csvdata = new StringBuilder();
    csvdata.AppendLine(header);

    foreach (var o in objectlist) 
        csvdata.AppendLine(ToCsvFields(separator, fields, o));

    return csvdata.ToString();
}

public static string ToCsvFields(string separator, FieldInfo[] fields, object o)
{
    StringBuilder linie = new StringBuilder();

    foreach (var f in fields)
    {
        if (linie.Length > 0)
            linie.Append(separator);

        var x = f.GetValue(o);

        if (x != null)
            linie.Append(x.ToString());
    }

    return linie.ToString();
}

有许多变化可以进行,例如在ToCsv()中直接写入文件,或者用IEnumerable和yield语句替换StringBuilder。


2
当对象列表很大时,您的解决方案将会变得缓慢,并且可能会出现内存不足问题。 - PerlDev
1
不要将输出写入字符串构建器,可以让它输出到某种OutputStream中,就像XmlSerializer.Serialize(...)所做的那样。这将解决大多数内存不足的问题。 - Spoike
或者将其改为返回IEnumerable<string>,并通过yield return逐行返回...然后(除非你将结果存储在列表中),你可以对其进行枚举,例如写出结果,内存开销很小。在你基本的想法上做了这些修改,并添加了附加答案。 - iCollect.it Ltd
我不确定这个检查 if (x != null) 是否合适。如果你有一个带有字段 { a, b, c } 的对象,并且不同的实例中,abc 中的某些可能为 null,那么你将无法从中生成有效的 CSV 并恢复这些实例。更好的方法是将 null 值替换为 NULL 或空白条目。 - Ivaylo Slavov
1
警告:不要使用这段代码,因为它会输出一个损坏的 CSV 文件!CSV 文件中的字段是用分隔符(通常是逗号)分隔的,因此如果字段内容包含分隔符或换行符,则必须将其引用起来。这段代码没有这样做。详情请参考:https://tools.ietf.org/html/rfc4180 - Askaga
1
这对我没用,我正在将文件的最大和最小值转换为CSV文件。 - Nagendra Upwanshi

39
这里有一个简化版的Per Hejndorf的CSV想法(没有内存开销,它会逐行产生)。由于受欢迎的要求,它还通过使用Concat支持字段和简单属性。
更新于2017年5月18日
此示例从未旨在成为完整的解决方案,只是推进了Per Hejndorf发布的原始思路。要生成有效的CSV,您需要将文本中的任何文本分隔符字符替换为2个分隔符字符的序列,例如:.Replace("\"", "\"\"")
更新于2016年2月12日
今天在项目中再次使用我的代码后,我意识到我不应该从@Per Hejndorf的示例中认为什么都是理所当然的。假设默认分隔符为“,”(逗号),并使分隔符成为第二个可选参数更有意义。我自己的库版本还提供了第三个header参数,用于控制是否返回标题行,因为有时您只想要数据。
例如:
public static IEnumerable<string> ToCsv<T>(IEnumerable<T> objectlist, string separator = ",", bool header = true)
{
    FieldInfo[] fields = typeof(T).GetFields();
    PropertyInfo[] properties = typeof(T).GetProperties();
    if (header)
    {
        yield return String.Join(separator, fields.Select(f => f.Name).Concat(properties.Select(p=>p.Name)).ToArray());
    }
    foreach (var o in objectlist)
    {
        yield return string.Join(separator, fields.Select(f=>(f.GetValue(o) ?? "").ToString())
            .Concat(properties.Select(p=>(p.GetValue(o,null) ?? "").ToString())).ToArray());
    }
}

因此,对于逗号分隔的内容,您可以像这样使用它:

foreach (var line in ToCsv(objects))
{
    Console.WriteLine(line);
}

或者使用其他分隔符(例如TAB):

foreach (var line in ToCsv(objects, "\t"))
{
    Console.WriteLine(line);
}

实用示例

将列表写入逗号分隔的CSV文件

using (TextWriter tw = File.CreateText("C:\testoutput.csv"))
{
    foreach (var line in ToCsv(objects))
    {
        tw.WriteLine(line);
    }
}

或者将其写成以制表符分隔的格式。
using (TextWriter tw = File.CreateText("C:\testoutput.txt"))
{
    foreach (var line in ToCsv(objects, "\t"))
    {
        tw.WriteLine(line);
    }
}

如果您有复杂的字段/属性,您需要将它们从选择子句中过滤出来。

以下是早期版本和详细信息:

这是Per Hejndorf的CSV想法的简化版(没有内存开销,因为它依次产生每行),只有4行代码 :)

public static IEnumerable<string> ToCsv<T>(string separator, IEnumerable<T> objectlist)
{
    FieldInfo[] fields = typeof(T).GetFields();
    yield return String.Join(separator, fields.Select(f => f.Name).ToArray());
    foreach (var o in objectlist)
    {
        yield return string.Join(separator, fields.Select(f=>(f.GetValue(o) ?? "").ToString()).ToArray());
    }
}

您可以像这样迭代它:
foreach (var line in ToCsv(",", objects))
{
    Console.WriteLine(line);
}

在这里,objects是一个强类型对象列表。

这个变化包括公共字段和简单的公共属性:

public static IEnumerable<string> ToCsv<T>(string separator, IEnumerable<T> objectlist)
{
    FieldInfo[] fields = typeof(T).GetFields();
    PropertyInfo[] properties = typeof(T).GetProperties();
    yield return String.Join(separator, fields.Select(f => f.Name).Concat(properties.Select(p=>p.Name)).ToArray());
    foreach (var o in objectlist)
    {
        yield return string.Join(separator, fields.Select(f=>(f.GetValue(o) ?? "").ToString())
            .Concat(properties.Select(p=>(p.GetValue(o,null) ?? "").ToString())).ToArray());
    }
}

你还需要为所选的分隔符添加适当的转义。实际的CSV,即使用逗号分隔的文本文件应该对每个字段进行引用并使用反斜杠对斜杠、逗号和双引号进行转义。 - David
哎呀,你不需要转义逗号,因为它是引用字符串的一部分。 - David
@David:CSV字符转义包括用一对相同的引号分隔符(通常是双引号“”)替换引号分隔符。反斜杠转义不是CSV的一部分。我会更新答案以包括这个信息。 - iCollect.it Ltd
3
感谢您提供这个优雅的解决方案。对于其他使用者,您可能想要将Union替换为Concat,这样当字段相同时它们就不会被合并。 - indeed005
1
@KoryGill:更新回答:这是你想要的吗? - iCollect.it Ltd
显示剩余13条评论

8
作为经验法则,我建议仅将toString覆盖作为调试工具,如果是业务逻辑,应该是类/接口上的显式方法。
对于像这样的简单序列化,我建议有一个单独的类,它知道您的CSV输出库和业务对象,并执行序列化,而不是将序列化推入业务对象本身。
这样,您就会得到每个输出格式的类,它会产生您模型的视图。
对于更复杂的序列化,其中您正在尝试编写对象图以进行持久化,我会考虑将其放在业务类中 - 但仅当它能使代码更清晰时。

我同意,我只有在调试时才会覆盖ToString()。我也会创建一个单独的序列化类,但你也可以添加一个名为"CSVString"的属性,它可以即时构建字符串... - Joshua

2
到目前为止,我找到的解决方案问题在于它们不允许您导出属性的子集,而只能导出整个对象。 大多数情况下,当我们需要将数据导出到CSV中时,我们需要以精确的方式“定制”其格式,因此我创建了这个简单的扩展方法,通过传递类型为 Func<T,string>的参数数组来指定映射,从而使我能够做到这一点。
public static string ToCsv<T>(this IEnumerable<T> list, params Func<T, string>[] properties)
{
    var columns = properties.Select(func => list.Select(func).ToList()).ToList();

    var stringBuilder = new StringBuilder();

    var rowsCount = columns.First().Count;

    for (var i = 0; i < rowsCount; i++)
    {
        var rowCells = columns.Select(column => column[i]);

        stringBuilder.AppendLine(string.Join(",", rowCells));
    }

    return stringBuilder.ToString();
}

用法:

philosophers.ToCsv(x => x.LastName, x => x.FirstName)

生成结果:

Hayek,Friedrich
Rothbard,Murray
Brent,David

1
Gone Coding的回答非常有帮助。我对其进行了一些更改,以处理可能会破坏输出的文本错误。
 /******************************************************/
    public static IEnumerable<string> ToCsv<T>(IEnumerable<T> objectlist, string separator = ",", bool header = true)
    {
       FieldInfo[] fields = typeof(T).GetFields();
       PropertyInfo[] properties = typeof(T).GetProperties();
       string str1;
       string str2;

       if(header)
       {
          str1 = String.Join(separator, fields.Select(f => f.Name).Concat(properties.Select(p => p.Name)).ToArray());
          str1 = str1 + Environment.NewLine;
          yield return str1;
       }
       foreach(var o in objectlist)
       {
          //regex is to remove any misplaced returns or tabs that would
          //really mess up a csv conversion.
          str2 = string.Join(separator, fields.Select(f => (Regex.Replace(Convert.ToString(f.GetValue(o)), @"\t|\n|\r", "") ?? "").Trim())
             .Concat(properties.Select(p => (Regex.Replace(Convert.ToString(p.GetValue(o, null)), @"\t|\n|\r", "") ?? "").Trim())).ToArray());

          str2 = str2 + Environment.NewLine;
          yield return str2;
       }
    }

1

我遇到了一个问题,HiTech Magic的变量有两个属性具有相同的值,只有一个会被填充。这似乎已经解决了:

        public static IEnumerable<string> ToCsv<T>(string separator, IEnumerable<T> objectlist)
    {
        FieldInfo[] fields = typeof(T).GetFields();
        PropertyInfo[] properties = typeof(T).GetProperties();
        yield return String.Join(separator, fields.Select(f => f.Name).Union(properties.Select(p => p.Name)).ToArray());
        foreach (var o in objectlist)
        {
            yield return string.Join(separator, (properties.Select(p => (p.GetValue(o, null) ?? "").ToString())).ToArray());
        }
    }

2
这将打印出字段和属性的标题以及仅属性的值,即仅当没有字段时才有效。 - David
这个答案从原始功能中删除了一些内容,如果还有其他字段存在,则无法正常工作。解决方案是使用 Concat 而不是 Union(已经在我的答案中更新了此内容)。 - iCollect.it Ltd

1

ServiceStack.Text 是一个流行的 NuGet 包,支持 CSV 序列化。这是您所需的全部代码:

CsvSerializer.SerializeToCsv(foo)

如果您不想要标题,首先使用以下代码:

CsvConfig<Foo>.OmitHeaders = true;

0
Gone Coding的回答很好!我对Gone Coding的回答进行了一些修改,用双引号限定字段,并使属性迭代器忽略没有索引参数的任何属性,即在类中启用属性名称getter和setter的属性:
FieldInfo[] fields = typeof(T).GetFields();
PropertyInfo[] properties = typeof(T).GetProperties().Where(x => x.GetIndexParameters().Length == 0).ToArray();
yield return string.Join(separator, fields.Select(f => f.Name).Concat(properties.Select(p => p.Name)).ToArray());
foreach (var o in objectlist)
{
    yield return string.Join(separator, fields.Select(f => "\"" + (( f.GetValue(o) ?? "").ToString()) + "\"")
                    .Concat(properties.Select(p => ("\"" + (p.GetValue(o, null) ?? "").ToString()) + "\"")).ToArray());
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接