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

自己动手?- 一个简单的 CSV 解析器示例

starIconstarIconstarIconstarIconstarIcon

5.00/5 (12投票s)

2020 年 1 月 13 日

CPOL

8分钟阅读

viewsIcon

15387

downloadIcon

229

C# 和 F# 中的实现。

目录

引言

我曾与一位同事就使用第三方库还是自己动手进行实现进行过讨论,特别是关于解析逗号分隔值(CSV)数据,或者更普遍地,任何由已知分隔符分隔的文本数据。

这个主题已经被讨论烂了,而且毫无疑问,在世界上各种语言中都有许多解析 CSV 文件的库。我的同事向我推荐了这篇文章:.NET Core 中的 CSV 解析,这是一个关于某些边缘情况的有用示例(在此我忽略了它),当然不是所有情况。这篇文章引用了几个项目,CsvHelperTinyCsvParser,并且很容易在 Code Project 上找到几个 CSV 解析器。

最后一个链接非常全面地介绍了 Tomas Takac 评估过的解析器,并有一个很棒的解析 CSV 数据的有限状态机图。更有趣的是(至少对我而言),他引用了 RFC-4180,该文档规定了逗号分隔值(CSV)文件的通用格式和 MIME 类型。非常酷。

话虽如此,在我自己研究了一些解析器并有一些非常基本的需求(确实没有边缘情况)之后,关于是自己动手还是使用库的问题再次出现。一些讨论点是

  • 如果自己写的代码有 bug 怎么办?
  • 如果需要扩展怎么办?
  • 如果文件规范发生变化怎么办?

当然,我的回答有偏见,但它们可能仍然有价值。

如果自己写的代码有 bug 怎么办?

好吧,考虑到实际的解析器只有 80 行代码(不包括扩展方法和辅助函数),出现 bug 的几率有多大?而且还有单元测试,实际上是代码量的 3 倍!我第一个想法总是关于复杂性的问题:要解决的问题有多复杂,以及市面上解决这个问题的第三方库有多复杂?代码行数越少,bug 越少,需要编写的单元测试也越少。并不能保证第三方库没有 bug 或实现了体面的单元测试。诚然,对于非边缘情况来说,这一切都有些无关紧要。不过,看看一些库的代码,其实现代码有数千行,我真的真的无法证明将它添加到我的代码库中是合理的——npm installnuget install 等等操作非常简单,以至于它们阻碍了对你即将要做的事情进行任何“思考”。这确实是一种危险的状况,特别是当软件包有隐藏的依赖项时,你突然安装了十几个或一百多个额外的、易于损坏的依赖项。

如果需要扩展怎么办?

假设实现将过程分解为小的步骤,就可以轻松地通过虚方法来实现可扩展性。当然还有其他机制。然而,我认为这个问题是一个似是而非论点,因为它提出了一个未来“万一”的场景,鉴于需求范围,这是不现实的。当然,如果需求表明需要处理各种边缘情况,那么是的,让我们选择第三方库。但我不同意“边缘情况会在其他程序生成的[_]数据文件中突然出现,并以某种方式破坏解析器”的论点。

如果文件规范发生变化怎么办?

这绝对是可能的,但实际上不是解析器的问题,而是解析数据的类以及属性到字段的映射问题。所以在我看来,这不是一个有效的论点。

开始实现!

说了这么多,我还是放手做了一个概念验证。它实际上是一个很好的例子,可以看看编程语言已经发展到什么程度,因为这段代码基本上只是一组 map、reduce 和 filter 操作。我花了大约 30 分钟写完这段代码(相比之下,我写 F# 示例花了 5 个多小时,因为 F# 并不是我特别擅长的!)

数据集

我决定使用前面引用的关于 .NET Core CSV 解析的文章中的简化版数据。

string data =
@"Make,Model,Type,Year,Cost,Comment
Toyota,Corolla,Car,1990,2000.99,A Comment";

在这里你会注意到我假设有标题行。

所属类

至少,我希望能够重命名映射到 CSV 字段的属性。

public enum AutomobileType
    {
        None,
        Car,
        Truck,
        Motorbike
    }

public class Automobile : IPopulatedFromCsv
{
    public string Make { get; set; }
    public string Model { get; set; }
    public AutomobileType Type { get; set; }
    public int Year { get; set; }

    [ColumnMap(Header = "Cost")]
    public decimal Price { get; set; }

    public string Comment { get; set; }
}

请注意 ColumnMap 属性。

public class ColumnMapAttribute : Attribute
{
    public string Header { get; set; }
}

为了好玩,我要求任何将被 CSV 解析器填充的类都实现 IPopulatedFromCsv,作为一个对用户有用的提示。

代码

这是核心解析器。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;

using Clifton.Core.Utils;

namespace SimpleCsvParser
{
    public class CsvParser
    {
        public virtual List<T> Parse<T>(string data, string delimiter) 
               where T : IPopulatedFromCsv, new()
        {
            List<T> items = new List<T>();
            var lines = GetLines(data);
            var headerFields = GetFieldsFromHeader(lines.First(), delimiter);
            var propertyList = MapHeaderToProperties<T>(headerFields);
            lines.Skip(1).ForEach(l => items.Add(Populate<T>(l, delimiter, propertyList)));

            return items;
        }

        // Get all non-blank lines
        protected virtual IEnumerable<string> GetLines(string data)
        {
            var lines = data.Split(new char[] { '\r', '\n' }).Where
                                (l => !String.IsNullOrWhiteSpace(l));

            return lines;
        }

        protected virtual T Create<T>() where T : IPopulatedFromCsv, new()
        {
            return new T();
        }

        protected virtual string[] GetFieldsFromHeader(string headerLine, string delimiter)
        {
            // The || allows blank headers if at least one header or delimiter exists.
            var fields = headerLine.Split(delimiter)
               .Select(f => f.Trim())
               .Where(f => 
                   !string.IsNullOrWhiteSpace(f) || 
                   !String.IsNullOrEmpty(headerLine)).ToArray();

            return fields;
        }

        protected virtual List<PropertyInfo> MapHeaderToProperties<T>(string[] headerFields)
        {
            var map = new List<PropertyInfo>();
            Type t = typeof(T);

            // Include null properties so these are skipped when parsing the data lines.
            headerFields
                .Select(f =>
                    (
                        f,
                        t.GetProperty(f, 
                           BindingFlags.Instance | 
                           BindingFlags.Public | 
                           BindingFlags.IgnoreCase | 
                           BindingFlags.FlattenHierarchy) ?? AliasedProperty(t, f)
                    )
                )
                .ForEach(fp => map.Add(fp.Item2));

            return map;
        }

        protected virtual PropertyInfo AliasedProperty(Type t, string fieldName)
        {
            var pi = t.GetProperties()
                .Where(p => p.GetCustomAttribute<ColumnMapAttribute>()
                ?.Header
                ?.CaseInsensitiveEquals(fieldName) 
                ?? false);
            Assert.That<CsvParserDuplicateAliasException>(pi.Count() <= 1,
                $"{fieldName} is aliased more than once.");

            return pi.FirstOrDefault();
        }

        protected virtual T Populate<T>(
            string line, 
            string delimiter, 
            List<PropertyInfo> props) where T : IPopulatedFromCsv, new()
        {
            T t = Create<T>();
            var fieldValues = line.Split(delimiter);
            // Unmapped fields will have a null property, and we also skip empty fields, 
            // and trim the field value before doing the type conversion.
            props.ForEach(fieldValues, 
               (p, v) => p != null && !String.IsNullOrWhiteSpace(v), 
               (p, v) => p.SetValue(t, Converter.Convert(v.Trim(), p.PropertyType)));

            return t;
        }
    }
}

使用了大量的 LINQ、null 传播和 null 合并运算符来将字段映射到属性,以及将属性名称通过 ColumnMap 属性覆盖,并处理没有属性映射到特定字段的情况。

另外请注意,所有方法,包括 Create,都是虚方法,以便可以重写以实现自定义行为。

别名属性方法

这段代码并非完全显而易见。它的作用是:

  • 获取我们要填充的类的所有 public 实例属性。
  • 尝试获取自定义属性。
  • 使用 null 传播,获取别名标题。
  • 再次使用 null 传播,将别名标题与 CSV 标题进行比较。
  • 使用 null 合并,返回比较结果或返回 falsewhere 条件。
  • 断言该别名有 0 或 1 个属性。
  • 最后,使用 FirstOrDefault,因为我们现在知道枚举中只有 0 或 1 个项目,并且我们希望在所有这些之后,如果没有属性别名对应于 CSV 标题字段值,则返回 null 作为占位符。

填充方法

这段代码使用了一个扩展方法 ForEach

  • 它同步迭代 *两个集合*,并且……
  • 仅当满足条件时才执行操作。

因此,代码

props.ForEach(fieldValues, 
   (p, v) => p != null && !String.IsNullOrWhiteSpace(v), 
   (p, v) => p.SetValue(t, Converter.Convert(v.Trim(), p.PropertyType)));

执行以下操作:

  • 两个集合是 propsfieldValues
  • 属性必须存在(记住,我们使用 null 作为占位符)。
  • 值不是 null 或空白。
  • 当满足该条件时,我们使用反射来设置属性值,使用 Converter 辅助函数。

使用解析器

class Program
    {
        static void Main(string[] args)
        {
            string data =
@"Make,Model,Type,Year,Cost,Comment
Toyota,Corolla,Car,1990,2000.99,A Comment";

            CsvParser parser = new CsvParser();
            var recs = parser.Parse<Automobile>(data, ",");
            var result = JsonConvert.SerializeObject(recs);
            Console.WriteLine(result);
        }
    }

输出:

[{"Make":"Toyota","Model":"Corolla","Type":1,"Year":1990,"Price":2000.99,"Comment":"A Comment"}]

JsonConvert 是我使用一个庞大的第三方库的 **反例**,仅仅是为了写出数组实例值!因为我太懒了!看到没?我验证了我自己关于在有更简单的方法时倾向于使用第三方库的危险性的论点!

支持代码

所以,这里有一堆支持代码。我们有扩展方法。

public static class ExtensionMethods
{
    public static void ForEach<T>(this IEnumerable<T> collection, Action<T> action)
    {
        foreach (var item in collection)
        {
            action(item);
        }
    }

    // Double iteration -- both collections are iterated over 
    // and are assumed to be of equal length
    public static void ForEach<T, U>(
        this IEnumerable<T> collection1, 
        IList<U> collection2, 
        Func<T, U, 
        bool> where, 
        Action<T, U> action)
    {
        int n = 0;

        foreach (var item in collection1)
        {
            U v2 = collection2[n++];

            if (where(item, v2))
            {
                action(item, v2);
            }
        }
    }

    public static string[] Split(this string str, string splitter)
    {
        return str.Split(new[] { splitter }, StringSplitOptions.None);
    }

    public static bool CaseInsensitiveEquals(this string a, string b)
    {
        return String.Equals(a, b, StringComparison.OrdinalIgnoreCase);
    }
}

我们有一个简单的 Assert static 类。

public static class Assert
{
    public static void That<T>(bool condition, string msg) where T : Exception, new()
    {
        if (!condition)
        {
            var ex = Activator.CreateInstance(typeof(T), new object[] { msg }) as T;
            throw ex;
        }
    }
}

还有这个我很久以前写的 Converter 类。

public class Converter
{
    public static object Convert(object src, Type destType)
    {
        object ret = src;

        if ((src != null) && (src != DBNull.Value))
        {
            Type srcType = src.GetType();

            if ((srcType.FullName == "System.Object") || 
                            (destType.FullName == "System.Object"))
            {
                ret = src;
            }
            else
            {
                if (srcType != destType)
                {
                    TypeConverter tcSrc = TypeDescriptor.GetConverter(srcType);
                    TypeConverter tcDest = TypeDescriptor.GetConverter(destType);

                    if (tcSrc.CanConvertTo(destType))
                    {
                        ret = tcSrc.ConvertTo(src, destType);
                    }
                    else if (tcDest.CanConvertFrom(srcType))
                    {
                        if (srcType.FullName == "System.String")
                        {
                            ret = tcDest.ConvertFromInvariantString((string)src);
                        }
                        else
                        {
                            ret = tcDest.ConvertFrom(src);
                        }
                    }
                    else
                    {
                        // If the target type is a base class of the source type, 
                        // then we don't need to do any conversion.
                        if (destType.IsAssignableFrom(srcType))
                        {
                            ret = src;
                        }
                        else
                        {
                            // If no conversion exists, throw an exception.
                            throw new ConverterException("Can't convert from " + 
                               src.GetType().FullName + 
                               " to " + 
                               destType.FullName);
                        }
                    }
                }
            }
        }
        else if (src == DBNull.Value)
        {
            if (destType.FullName == "System.String")
            {
                // convert DBNull.Value to null for strings.
                ret = null;
            }
        }

        return ret;
    }
}

真的,最后一次提交 是 2015 年 11 月 26 日!它太老了,以至于内联 string 解析 $"{}" 不存在(或者我不知道,哈哈)。

单元测试

然后我想,好吧,我们来写一些单元测试,因为这段代码实际上是可以进行单元测试的。

public class NoAliases : IPopulatedFromCsv
{
    public string A { get; set; }
    public string B { get; set; }
    public string C { get; set; }
}

public class WithAlias : IPopulatedFromCsv
{
    public string A { get; set; }
    public string B { get; set; }

    [ColumnMap(Header = "C")]
    public string TheCField { get; set; }
}

public class WithDuplicateAlias : IPopulatedFromCsv
{
    public string A { get; set; }
    public string B { get; set; }

    [ColumnMap(Header = "C")]
    public string TheCField { get; set; }

    [ColumnMap(Header = "C")]
    public string Oops { get; set; }
}

public class NoMatchingField : IPopulatedFromCsv
{
    public string A { get; set; }
    public string B { get; set; }
    public string C { get; set; }

    public string D { get; set; }
}

public class NoMatchingProperty : IPopulatedFromCsv
{
    public string A { get; set; }
    public string C { get; set; }
}

public class Disordered : IPopulatedFromCsv
{
    public string C { get; set; }
    public string B { get; set; }
    public string A { get; set; }
}

// public class NoMatching

/// <summary>
/// By inheriting from CsvParser, we have access to the protected methods.
/// </summary>
[TestClass]
public class CsvParserTests : CsvParser
{
    [TestMethod]
    public void BlankAndWhitespaceLinesAreSkippedTest()
    {
        GetLines("").Count().Should().Be(0);
        GetLines(" ").Count().Should().Be(0);
        GetLines("\r").Count().Should().Be(0);
        GetLines("\n").Count().Should().Be(0);
        GetLines("\r\n").Count().Should().Be(0);
    }

    [TestMethod]
    public void CRLFBothCreateNewLineTest()
    {
        GetLines("a\rb").Count().Should().Be(2);
        GetLines("a\nb").Count().Should().Be(2);
        GetLines("a\r\nb").Count().Should().Be(2);
    }

    [TestMethod]
    public void HeaderSplitByDelimiterTest()
    {
        GetFieldsFromHeader("a,b,c", ",").Count().Should().Be(3);
    }

    [TestMethod]
    public void SingleHeaderTest()
    {
        GetFieldsFromHeader("a", ",").Count().Should().Be(1);
    }

    [TestMethod]
    public void NoHeaderTest()
    {
        GetFieldsFromHeader("", ",").Count().Should().Be(0);
    }

    [TestMethod]
    public void EmptyHeaderFieldIsAllowedTest()
    {
        GetFieldsFromHeader("a,,c", ",").Count().Should().Be(3);
    }

    [TestMethod]
    public void BlankHeaderFieldsAreAllowedTest()
    {
        GetFieldsFromHeader(",,", ",").Count().Should().Be(3);
    }

    [TestMethod]
    public void HeaderIsTrimmedTest()
    {
        GetFieldsFromHeader("a ", ",")[0].Should().Be("a");
        GetFieldsFromHeader(" a", ",")[0].Should().Be("a");
    }

    [TestMethod]
    public void DirectMappingTest()
    {
        MapHeaderToProperties<NoAliases>
                 (new string[] { "A", "B", "C" }).Count().Should().Be(3);
    }

    [TestMethod]
    public void AliasTest()
    {
        MapHeaderToProperties<WithAlias>
                 (new string[] { "A", "B", "C" }).Count().Should().Be(3);
    }

    [TestMethod]
    public void CaseInsensitiveTest()
    {
        MapHeaderToProperties<NoAliases>
                 (new string[] { "a", "b", "c" }).Count().Should().Be(3);
    }

    [TestMethod]
    public void AdditionalPropertyTest()
    {
        MapHeaderToProperties<NoMatchingField>
                 (new string[] { "A", "B", "C" }).Count().Should().Be(3);
    }

    [TestMethod]
    public void MissingPropertyTest()
    {
        MapHeaderToProperties<NoMatchingProperty>
                 (new string[] { "A", "B", "C" }).Count().Should().Be(3);
    }

    [TestMethod]
    public void FieldNotMappedTest()
    {
        var props = MapHeaderToProperties<NoMatchingProperty>(new string[] { "A", "B", "C" });
        props.Count().Should().Be(3);
        props[0].Should().NotBeNull();
        props[1].Should().BeNull();
        props[2].Should().NotBeNull();
    }

    [TestMethod]
    public void CaseInsensitiveAliasTest()
    {
        AliasedProperty(typeof(WithAlias), "c").Should().NotBeNull();
    }

    [TestMethod]
    public void PropertyNotFoundTest()
    {
        AliasedProperty(typeof(NoAliases), "D").Should().BeNull();
    }

    [TestMethod]
    public void DuplicateAliasTest()
    {
        this.Invoking((_) => AliasedProperty(typeof(WithDuplicateAlias), "C")).Should()
            .Throw<CsvParserDuplicateAliasException>()
            .WithMessage("C is aliased more than once.");
    }

    [TestMethod]
    public void PopulateFieldsTest()
    {
        var props = MapHeaderToProperties<NoAliases>(new string[] { "a", "b", "c" });
        var t = Populate<NoAliases>("1,2,3", ",", props);
        t.A.Should().Be("1");
        t.B.Should().Be("2");
        t.C.Should().Be("3");
    }

    [TestMethod]
    public void PopulatedFieldsAreTrimmedTest()
    {
        var props = MapHeaderToProperties<NoAliases>(new string[] { "a", "b", "c" });
        var t = Populate<NoAliases>("1 , 2, 3 ", ",", props);
        t.A.Should().Be("1");
        t.B.Should().Be("2");
        t.C.Should().Be("3");
    }

    [TestMethod]
    public void PopulateFieldNotMappedTest()
    {
        var props = MapHeaderToProperties<NoMatchingProperty>(new string[] { "a", "b", "c" });
        var t = Populate<NoMatchingProperty>("1,2,3", ",", props);
        t.A.Should().Be("1");
        t.C.Should().Be("3");
    }

    [TestMethod]
    public void OrderedTest()
    {
        var map = MapHeaderToProperties<NoAliases>(new string[] { "A", "B", "C" });
        map[0].Name.Should().Be("A");
        map[1].Name.Should().Be("B");
        map[2].Name.Should().Be("C");
    }

    [TestMethod]
    public void DisorderedTest()
    {
        // Field mapping should be independent of the order of the fields.
        var map = MapHeaderToProperties<Disordered>(new string[] { "A", "B", "C" });
        map[0].Name.Should().Be("A");
        map[1].Name.Should().Be("B");
        map[2].Name.Should().Be("C");
    }
}

万岁!

无泛型类的 F# 实现

然后我决定折磨自己,用 F# 重写这个,并在过程中学习一些东西。我之所以想尝试这个,是因为我非常喜欢 F# 中的前向管道 |> 操作符,它允许我使用一个函数的输出作为另一个函数的输入来链接函数。我还“犯了个错误”,没有实现一个 CsvParser *类*——这导致我发现泛型只适用于*成员*,而不是函数,但我发现了一个非常棒的解决方法。所以,这是我疯狂的 F# 实现。

open System
open System.Reflection
open System.ComponentModel

type AutomobileType = 
    None = 0 
    | Car = 1 
    | Truck = 2 
    | Motorbike = 3

// Regarding AllowNullLiteral: https://sergeytihon.com/2013/04/10/f-null-trick/
[<AllowNullLiteral>]
type ColumnMapAttribute(headerName) =
    inherit System.Attribute()
    let mutable header : string = headerName
    member x.Header with get() = header

type Automobile() =
    let mutable make : string = null
    let mutable model : string = null
    let mutable year : int = 0
    let mutable price : decimal = 0M
    let mutable comment : string = null
    let mutable atype : AutomobileType = AutomobileType.None

    member x.Make with get() = make and set(v) = make <- v
    member x.Model with get() = model and set(v) = model <- v
    member x.Type with get() = atype and set(v) = atype <- v
    member x.Year with get() = year and set(v) = year <- v
    member x.Comment with get() = comment and set(v) = comment <- v

    [<ColumnMap("Cost")>]
    member x.Price with get() = price and set(v) = price <- v

module String = 
    let notNullOrWhiteSpace = not << System.String.IsNullOrWhiteSpace

// I would never have been able to figure this out.
// https://stackoverflow.com/a/32345373/2276361
type TypeParameter<'a> = TP

[<EntryPoint>]
let main _ = 
    let mapHeaderToProperties(_ : TypeParameter<'a>) fields = 
        let otype = typeof<'a>
        fields |> 
            Array.map (fun f -> 
                match otype.GetProperty(f, 
                    BindingFlags.Instance ||| 
                    BindingFlags.Public ||| 
                    BindingFlags.IgnoreCase ||| 
                    BindingFlags.FlattenHierarchy) with
                | null -> match otype.GetProperties() |> Array.filter 
                     (fun p -> 
                              match p.GetCustomAttribute<ColumnMapAttribute>() with 
                              | null -> false
                                   // head::tail requires a list, not an array!
                              | a -> a.Header = f) |> Array.toList with                               
                          | head::_ -> head
                          | [] -> null
                | a -> a
            )

    let convert(v, destType) = 
        let srcType = v.GetType();

        let tcSrc = TypeDescriptor.GetConverter(srcType)
        let tcDest = TypeDescriptor.GetConverter(destType)

        if tcSrc.CanConvertTo(destType) then
            tcSrc.ConvertTo(v, destType)
        else 
            if tcDest.CanConvertFrom(srcType) then
                if srcType.FullName = "System.String" then
                    tcDest.ConvertFromInvariantString(v)
                else
                    tcDest.ConvertFrom(v);
             else
                if destType.IsAssignableFrom(srcType) then
                    v :> obj
                else
                    null

    let populate(_ : TypeParameter<'a>) 
        (line:string) (delimiter:char) (props: PropertyInfo[]) =
        let t = Activator.CreateInstance<'a>()
        let vals = line.Split(delimiter)
        
        for i in 0..vals.Length-1 do
            match (props.[i], vals.[i]) with
            | (p, _) when p = null -> ()
            | (_, v) when String.isNullOrWhiteSpace v -> ()
            | (p, v) -> p.SetValue(t, convert(v.Trim(), p.PropertyType))

        t 

    let data ="Make,Model,Type,Year,Cost,Comment\r\nToyota,Corolla,Car,1990,2000.99,A Comment";
    let delimiter = ',';
    let lines = data.Split [|'\r'; '\n'|] |> Array.filter 
                        (fun l -> String.notNullOrWhiteSpace l)
    let headerLine = lines.[0]
    let fields = headerLine.Split(delimiter) |> Array.map (fun l -> l.Trim()) |> 
        Array.filter (fun l -> String.notNullOrWhiteSpace l || 
                               String.notNullOrWhiteSpace headerLine)
    let props = mapHeaderToProperties(TP:TypeParameter<Automobile>) fields
    let recs = lines |> Seq.skip 1 |> Seq.map (fun l ->
               populate(TP:TypeParameter<Automobile>) l delimiter props) |> Seq.toList
    
    printf "fields: %A\r\n" fields
    printfn "lines: %A\r\n" lines
    printfn "props: %A\r\n" props
    recs |> Seq.iter 
    (fun r -> printfn "%A %A %A %A %A %A\r\n" r.Make r.Model r.Year r.Type r.Price r.Comment)

    0

输出是:

fields: [|"Make"; "Model"; "Type"; "Year"; "Cost"; "Comment"|]
lines: [|"Make,Model,Type,Year,Cost,Comment";
  "Toyota,Corolla,Car,1990,2000.99,A Comment"|]

props: [|System.String Make; System.String Model; AutomobileType Type; Int32 Year;
  System.Decimal Price; System.String Comment|]

"Toyota" "Corolla" 1990 Car 2000.99M "A Comment"

坚持不使用 FirstOrDefault 是一件很艰难的事。

// Using FirstOrDefault:
fields |> 
    Array.map (fun f -> 
        match otype.GetProperty(f, 
            BindingFlags.Instance ||| 
            BindingFlags.Public ||| 
            BindingFlags.IgnoreCase ||| 
            BindingFlags.FlattenHierarchy) with
        | null -> otype.GetProperties() |> 
                    Array.filter (fun p -> match p.GetCustomAttribute<ColumnMapAttribute>() with
                                           | null -> false
                                           | a -> a.Header = f)
                |> Enumerable.FirstOrDefault
        | a -> a
    )

但我坚持了下来!我学到了一些东西:

  • match 很棘手,我花了一段时间才弄清楚为什么一个数组 [] 不起作用。我 F# 无知的一面再次暴露出来。
  • F# 不喜欢可空类型,而 C# 中充满了可空类型,所以我了解了 [<AllowNullLiteral>] 属性及其用法!
  • 这个 type TypeParameter<'a> = TP 是一个非常棘手的代码片段,用于伪装函数为“泛型”!我对此不居功——请参见代码注释。

带有 SimpleCsvParser<'a> 的 F# 实现

所以我决定将实现重构为一个真正的泛型类——这很容易做到。

type SimpleCsvParser<'a>() =
    member __.mapHeaderToProperties fields = 
        let otype = typeof<'a>
        fields |> 
            Array.map (fun f -> 
                match otype.GetProperty(f, 
                    BindingFlags.Instance ||| 
                    BindingFlags.Public ||| 
                    BindingFlags.IgnoreCase ||| 
                    BindingFlags.FlattenHierarchy) with
                | null -> match otype.GetProperties() |> Array.filter (fun p -> 
                              match p.GetCustomAttribute<ColumnMapAttribute>() with 
                              | null -> false
                              | a -> a.Header = f) |> Array.toList with
                          | head::_ -> head
                          | [] -> null
                | a -> a
            )

    member __.populate (line:string) (delimiter:char) (props: PropertyInfo[]) =
        let t = Activator.CreateInstance<'a>()
        let vals = line.Split(delimiter)
        
        for i in 0..vals.Length-1 do
            match (props.[i], vals.[i]) with
            | (p, _) when p = null -> ()
            | (_, v) when String.isNullOrWhiteSpace v -> ()
            | (p, v) -> p.SetValue(t, convert(v.Trim(), p.PropertyType))

        t 

    member this.parse (data:string) (delimiter:char) = 
        let lines = data.Split [|'\r'; '\n'|] |> Array.filter 
                           (fun l -> String.notNullOrWhiteSpace l)
        let headerLine = lines.[0]
        let fields = headerLine.Split(delimiter) |> Array.map 
                           (fun l -> l.Trim()) |> Array.filter 
                     (fun l -> String.notNullOrWhiteSpace l || 
                     String.notNullOrWhiteSpace headerLine)
        let props = this.mapHeaderToProperties fields
        lines |> Seq.skip 1 |> Seq.map 
             (fun l -> this.populate l delimiter props) |> Seq.toList

请注意,convert 作为一个通用方法,并未在类中实现。另外请注意,不需要那个奇怪的 type TypeParameter<'a> = TP 类型定义,因为我们这样使用这个类:

    let parser = new SimpleCsvParser<Automobile>();
    let recs2 = parser.parse data delimiter
    recs2 |> Seq.iter 
    (fun r -> printfn "%A %A %A %A %A %A\r\n" r.Make r.Model r.Year r.Type r.Price r.Comment)

结论

正如我所提到的,C# 代码(不包括单元测试)花了 30 分钟编写。F# 代码花了大约 5 个小时。这是一次很棒的学习经历!

但更重要的是,仍然是那个未解决的问题:你是想自己动手还是使用第三方库。我的观点是,如果需求简单且不太可能改变,在转向第三方库之前,至少应该考虑自己动手,特别是如果库具有以下一个或多个特性:

  • 代码库规模庞大,因为它试图处理各种各样的情况。
  • 对其他库有许多依赖项。
  • 没有良好的测试覆盖率。
  • 没有良好的文档。
  • 不可扩展。
  • 评估库比自己编写代码花费的时间更长!

历史

  • 2020 年 1 月 13 日:初始版本
© . All rights reserved.