乐者为王

Do one thing, and do it well.

ANTLR 4权威参考读书笔记(25)

这次我们要做的是通过监听器实现CSV文件的加载器,用于建立一个二维列表数据结构。

加载CSV数据

我们的目标是构建一个监听器去加载CSV数据到一个映射列表数据结构中,这是任何数据格式阅读器或配置文件阅读器都会做的事。我们会收集每行的字段并放到一个映射中,构成头名-值组合。以下是示例文件t.csv的内容:

1
2
3
4
Details,Month,Amount
Mid Bonus,June,"$2,000"
,January,"""zippo"""
Total Bonuses,"","$5,000"

我们想要看到如下的映射列表被打印出:

1
2
3
[{Details=Mid Bonus, Month=June, Amount="$2,000"},
 {Details=, Month=January, Amount="""zippo"""},
 {Details=Total Bonuses, Month="", Amount="$5,000"}]

为了在监听器中得到精确的方法,我们给CSV语法中field规则的每个选项打上标签:

1
2
3
4
5
6
7
8
9
10
11
12
13
grammar CSV;

file : hdr row+ ;
hdr : row ;
row : field (',' field)* '\r'? '\n' ;
field
    : TEXT    # text
    | STRING  # string
    |         # empty
    ;

TEXT : ~[,\n\r"]+ ;
STRING : '"' ('""'|~'"')* '"' ;     // quote-quote is an escaped quote

我们可以从定义我们需要的数据结构开始监听器的实现。首先,我们需要的数据结构是称为rows的映射列表。我们也需要在头行中找到的列名列表header。为处理数据行,我们需要把字段值读到一个临时列表currentRowFieldValues中,然后把列名映射到那些值上。以下是监听器LoadCSV.java的实现代码:

1
2
3
4
5
6
7
8
public static class Loader extends CSVBaseListener {
    public static final String EMPTY = "";
    /** Load a list of row maps that map field name to value */
    List<Map<String,String>> rows = new ArrayList<Map<String, String>>();
    /** List of column names */
    List<String> header;
    /** Build up a list of fields in current row */
    List<String> currentRowFieldValues;

下面的3个规则方法通过计算适当的字符串处理字段值,并把它添加到currentRowFieldValues中。

1
2
3
4
5
6
7
8
9
public void exitString(CSVParser.StringContext ctx) {
    currentRowFieldValues.add(ctx.STRING().getText());
}
public void exitText(CSVParser.TextContext ctx) {
    currentRowFieldValues.add(ctx.TEXT().getText());
}
public void exitEmpty(CSVParser.EmptyContext ctx) {
    currentRowFieldValues.add(EMPTY);
}

在我们能处理数据行之前,我们需要从第一行取得列名列表。头行在语法上仅仅是另外的行,但我们在对待它时要不同于常规的数据行,那意味着我们需要检查上下文。暂时让我们假设在exitRow()执行后,currentRowFieldValues包含列名列表。要填充header,我们只需要捕获第一行的字段值。

1
2
3
4
public void exitHdr(CSVParser.HdrContext ctx) {
    header = new ArrayList<String>();
    header.addAll(currentRowFieldValues);
}

谈到行时,我们需要两个操作:一个是当我们开始一行时,另一个是当我们结束一行时。当我们开始一行时,我们需要分配或清除currentRowFieldValues,准备获取一组新的数据。

1
2
3
public void enterRow(CSVParser.RowContext ctx) {
    currentRowFieldValues = new ArrayList<String>();
}

在行结束的时候,我们必须考虑上下文。如果我们仅仅加载头行,那我们不能改变rows字段,因为列名不是数据。在exitRow()中,我们可以通过查看在语法分析树中的父节点的getRuleIndex()值(或者询问父节点是否是HdrContext类型)测试上下文。如果当前行是数据行,我们将通过同时遍历header中的列名和currentRowFieldValues中的值获取的内容创建映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void exitRow(CSVParser.RowContext ctx) {
    // If this is the header row, do nothing
    // if ( ctx.parent instanceof CSVParser.HdrContext ) return; OR:
    if ( ctx.getParent().getRuleIndex() == CSVParser.RULE_hdr ) {
        return;
    }
    // It's a data row
    Map<String, String> m = new LinkedHashMap<String, String>();
    int i = 0;
    for (String v : currentRowFieldValues) {
        m.put(header.get(i), v);
        i++;
    }
    rows.add(m);
}

到这里,加载CSV数据到数据结构中的任务就算已经完成。在使用ParseTreeWalker遍历树后,我们就可以紧接着打印出rows字段:

1
2
3
4
ParseTreeWalker walker = new ParseTreeWalker();
Loader loader = new Loader();
walker.walk(loader, tree);
System.out.println(loader.rows);

以下是构建和测试序列:

1
2
3
antlr CSV.g
compile *.java
run LoadCSV t.csv

下面显示的是输出结果:

1
2
[{Details=Mid Bonus, Month=June, Amount="$2,000"}, {Details=, Month=January,
Amount="""zippo"""}, {Details=Total Bonuses, Month="", Amount="$5,000"}]

Comments