csv分词_花点时间,写了个CSV解析器

前段时间学习了下编译原理,凑巧的是,同事有解析 CSV 格式文件的需求,然后就花了点时间,写了个 CSV 文件解析器,这里分享出来。

本次主要内容有:

  1.  CSV 格式文件定义

  2. 描述 CSV 格式

  3. 接口定义

  4. 解析实现

  5. 单元测试

  1. CSV 格式文件定义

根据 RFC4184,将 CSV 格式定义如下:
  1.  每一条记录用换行符分割,换行符定义为 CRLF(\r\n)。例如:

aaa,bbb,ccc CRLFzzz,yyy,xxx CRLF

    2. 文件的最后一行可以有,也可以没有换行,如:

aaa,bbb,ccc CRLFzzz,yyy,xxx

    3. 文件可以选择性的在第一行指定一行和其他记录相同格式的标题行。这一行标题需要包含和其他记录相同的列,并且按照其他记录相同的顺序指定列的名称。有没有标题行应该可以通过参数指定。如:

field_name,field_name,field_name CRLFaaa,bbb,ccc CRLFzzz,yyy,xxx CRLF

    4. 对于标题行和其他的所有记录行,可能会有一个或多个用逗号(',')分隔的字段。在文件中,所有的记录都应该有相同的字段数量。空白字符应该也是字段的一部分,不应该被忽略。最后一个字段不可以添加逗号。如:

aaa,bbb,ccc

    5. 字段可以被双括号(")括起来,也可以不使用双括号括起来。如果一个字段没有被双括号括起来,那么双括号就不能出现在字段值中。如:

"aaa","bbb","ccc" CRLFzzz,yyy,xxx

    6. 包含换行(CRLF),双引号('"')和逗号(',')的字段,必须使用双引号括起来,如:

"aaa","b CRLFbb","ccc" CRLFzzz,yyy,xxx

    7. 如果双引号在字段值中出现,则需要使用另一个前导双引号对它进行转义,如:

"aaa","b""bb","ccc"

2. 描述 CSV 格式

在RFC4180中,已经对 CSV 格式使用了 ABNF 进行描述,这里不再重复。我们在这里使用有穷状态机进行描述如下:

52ac25f817eaa10927a7597ca6021a25.png

在这里,是读取一个字段的状态转换图,说明如下:

  1. 开始读取的时候,我们读取到任何不在 '"', ',', LRLF 和 EOF 中的字符,均将其存储,并继续读取下一个字符;

  2. 如果读取到了一个双引号,说明下面一个记录应该是一个字符串,所以我们转换到 readString 状态一直读取到一个双引号位置,说明下一个字符可能是一个转换字符,所以转换到 readEscape 状态,如果在 readEscape 状态读取到了双引号,说明我们读取到了一个需要被转义的双引号,则继续转回 readString 状态,如果读取到的字符不是双引号,则说明读取结束。

  3. 在 readRecord 字段,如果读取到了一个逗号,换行或者文件结束符,说明当前记录读取完成。

3. 接口定义

目前为止,我们只是需要实现一个字段一个字段读取文件的读取器即可,所以我们接口定义如下:
public CsvToken NextToken()

即,我们只需要定义一个读取下一个 Token 的方法即可。其中 CsvToken 的定义如下:

/// /// 从 CSV 文件读取到的一个分词。/// public class CsvToken{    ///     /// 使用给定的分词类型和分词值,创建并初始化一个    /// 对象实例。    ///     ///     /// 当前分词值的类型,参考 。    ///     /// 当前分词的值。    public CsvToken(CsvTokenType tokenType, string value)    {        this.TokenType = tokenType;        this.Value = value;    }    ///     /// 设置或者获取当前分词值的类型,参考    /// 。    ///     public CsvTokenType TokenType { get; set; }    ///     /// 设置或获取当前分词的值。    ///     public string Value { get; set; }}

对于 Token 类型,我们定义如下:

/// /// 从 CSV 文件中读取到的分词类型。/// public enum CsvTokenType : byte{    ///     /// 默认值,通常标志着还未读取。    ///     Unknow,    ///     /// 读取到一个字段。    ///     Record,    ///     /// 标志着读取到了一条记录的最后一个字段。    ///     EndRecord,    ///     /// 标志着读取到了文件的结尾。    ///     Eof,}

4. 解析实现

其实有了以上的分析,则实现已经不难了,只需要循环读取输入字符流就可以了,我们全类代码如下:
/// /// 将字符流转换为标记流,只支持向前读取。/// public partial class CsvTokenizer{    private readonly TextReader reader;    ///     /// 使用指定的字符流读取器,创建并初始化一个    /// 对象实例。    ///     /// 字符流读取器。    public CsvTokenizer(TextReader reader)    {        this.reader = reader            ?? throw new ArgumentNullException(nameof(reader));    }    ///     /// 从构造传入的字符输入流,读取下一个记录。    ///     /// 下一个记录信息。    public CsvToken NextToken()    {        int ch;        StringBuilder buff = new StringBuilder();        CsvTokenType tokenType = CsvTokenType.Unknow;        while ((ch = reader.Read()) != -1)        {            switch (ch)            {                case ',':                    tokenType = CsvTokenType.Record;                    goto ret;                case '"':                    if (buff.Length <= 0)                    {                        this.ReadString(buff);                        continue;                    }                    break;                case '\r':                    if (reader.Peek() == '\n')                    {                        // skip '\n'                        reader.Read();                        tokenType = CsvTokenType.EndRecord;                        goto ret;                    }                    break;                default:                    break;            }            buff.Append((char)ch);        }        if (ch == -1)        {            tokenType = CsvTokenType.Eof;        }    ret:        return new CsvToken(tokenType, buff.ToString());    }    private void ReadString(StringBuilder buff)    {        int ch;        while ((ch = reader.Read()) != -1)        {            switch (ch)            {                case '"':                    if (reader.Peek() == '"')                    {                        // skip next double-qoutes                        reader.Read();                        break;                    }                    return;                default:                    break;            }            buff.Append((char)ch);        }    }}
其中,类 CsvTokenizer 只有一个构造方法,传入一个 StringReader 对象,进行字符读取,其中 System.IO.StreamReader, System.IO.StringReader 均扩展了 System.IO.TextReader 抽象类,所以之类我们的构造方法选择使用 System.IO.TextReader 抽象类作为传入参数,以实现最大的灵活性。

另外需要说明的是,CsvTokenizer 类的主要任务是将传入的字符流转换为标记流(Token),所以这里没有进行错误校验,比如列的数量是否统一等。

5. 单元测试

到此为止,我们的 CSV 解析器,已经写完了。到最后,我们为其添加一些单元测试,以查看其工作结果,测试用例如下:

  1.  CsvTokenizer 构造方法参数为 null 时,应抛出 System.ArgumentNullException 异常;

  2.  CsvTokenizer 的 Dispose 方法调用之后,应该也将构造使用的 TextReader 对象也释放掉;

  3. 没有双引号,只有普通字符和逗号的 CSV 字符串解析;

  4. 字段没有使用双引号括起来,但是字段中间有双引号的字段,应该直接将双引号添加到字段值中。

  5. 使用双引号括起来的字段中如果出现了双引号,则应该将 "" 替换为 " 作为字段值的一部分;

  6. 当用双引号括起来的字段中包含回车换行时,应该将回车换行原样作为字段值的一部分;

  7. 当用双引号括起来的字段中出现逗号时,应将逗号作为字段值的一部分;

  8. 当字段中出现Unicode字符时,应能正常识别,这里使用 Emoji 进行测试;

测试代码如下:

[TestClass()]public class CsvTokenizerTests{    [TestMethod()]    public void CsvTokenizerTest_ConstructorArgumentNull()    {        ArgumentNullException ane =        Assert.ThrowsException(() =>        {            new CsvTokenizer(null);        });    }    [TestMethod]    public void CsvTokenizerTest_Disposed()    {        StringReader reader = new StringReader("");        CsvTokenizer tokenizer = new CsvTokenizer(reader);        tokenizer.Dispose();        Assert.ThrowsException<ObjectDisposedException>(() =>        {            reader.Read();        });        Assert.ThrowsException<ObjectDisposedException>(() =>        {            tokenizer.NextToken();        });    }    [TestMethod]    public void CsvTokenizerTest_OneLineNormal()    {        using CsvTokenizer tokenizer = new CsvTokenizer(            new StringReader(@"aaa,bbb,ccc"));        List<string> records = new List<string>();        CsvToken token;        while ((token = tokenizer.NextToken()).TokenType            != CsvTokenType.Eof)        {            records.Add(token.Value);        }        records.Add(token.Value);        Assert.AreEqual(3, records.Count);        Assert.AreEqual("aaa", records[0]);        Assert.AreEqual("bbb", records[1]);        Assert.AreEqual("ccc", records[2]);    }    [TestMethod]    public void CsvTokenizerTest_DoubleQouteOnRecord()    {        using CsvTokenizer tokenizer = new CsvTokenizer(            new StringReader("aaa\""));        CsvToken token = tokenizer.NextToken();        Assert.AreEqual(CsvTokenType.Eof, token.TokenType);        Assert.AreEqual("aaa\"", token.Value);    }    [TestMethod]    public void CsvTokenizerTest_DoubleQouteInDoubleQoutedValues()    {        using CsvTokenizer tokenizer = new CsvTokenizer(            new StringReader("aaa\",\"b\"\"\",ccc"));        List<string> records = new List<string>();        CsvToken token;        while ((token = tokenizer.NextToken()).TokenType            != CsvTokenType.Eof)        {            records.Add(token.Value);        }        records.Add(token.Value);        Assert.AreEqual(3, records.Count);        Assert.AreEqual("aaa\"", records[0]);        Assert.AreEqual("b\"", records[1]);        Assert.AreEqual("ccc", records[2]);    }    [TestMethod]    public void CsvTokenizerTest_LRLFInDoubleQoutedValues()    {        using CsvTokenizer tokenizer = new CsvTokenizer(            new StringReader("aaa\",\"b\r\n\"\"\",ccc"));        List<string> records = new List<string>();        CsvToken token;        while ((token = tokenizer.NextToken()).TokenType            != CsvTokenType.Eof)        {            records.Add(token.Value);        }        records.Add(token.Value);        Assert.AreEqual(3, records.Count);        Assert.AreEqual("aaa\"", records[0]);        Assert.AreEqual("b\r\n\"", records[1]);        Assert.AreEqual("ccc", records[2]);    }    [TestMethod]    public void CsvTokenizerTest_CommaInDoubleQoutedValues()    {        using CsvTokenizer tokenizer = new CsvTokenizer(            new StringReader("aaa\",\"b,\r\n\"\"\",ccc"));        List<string> records = new List<string>();        CsvToken token;        while ((token = tokenizer.NextToken()).TokenType            != CsvTokenType.Eof)        {            records.Add(token.Value);        }        records.Add(token.Value);        Assert.AreEqual(3, records.Count);        Assert.AreEqual("aaa\"", records[0]);        Assert.AreEqual("b,\r\n\"", records[1]);        Assert.AreEqual("ccc", records[2]);    }    [TestMethod]    public void CsvTokenizerTest_Emoji()    {        using CsvTokenizer tokenizer = new CsvTokenizer(            new StringReader("🍀aaa,b🍀bb,ccc🍀"));        List<string> records = new List<string>();        CsvToken token;        while ((token = tokenizer.NextToken()).TokenType            != CsvTokenType.Eof)        {            records.Add(token.Value);        }        records.Add(token.Value);        Assert.AreEqual(3, records.Count);        Assert.AreEqual("🍀aaa", records[0]);        Assert.AreEqual("b🍀bb", records[1]);        Assert.AreEqual("ccc🍀", records[2]);    }}

运行单元测试,结果如下:

f096153db0a9e54ef5f379ef41ca27d3.png

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值