乐者为王

Do one thing, and do it well.

协议分析器的威力

英文原文:http://arstechnica.com/information-technology/2016/09/the-power-of-protocol-analyzers/

问题发生在错综复杂的网络世界里。但要在一时激动之下确定一种新型问题的确切原因变得有些冒险。在这种情况下,当Google-fu耗尽的时候甚至其他能干的工程师也可能会被迫去依赖试错法。

幸运的是,有个秘密武器等待乐意的工程师去部署——协议分析器。该工具允许你明确地确定几乎任何错误的根源,给你提供在底层协议上自我学习的能力。现在唯一的问题是,许多工程师因为(毫无根据的)恐惧而完全回避它。

什么是协议分析器?

协议分析器,或者数据包嗅探器,是一个用于拦截通信量,存储它们,并以一个已解码的、人类可读的状态呈现它们的工具。现代协议分析器比如Wireshark甚至可以靠自己发现基本的问题,然后使用捕获的数据执行统计分析。

不理会特性,数据包嗅探器都以基本相同的方式工作。它们把自己插入到网络堆栈中,把所有通信量复制到一个缓冲区或文件。大部分还会将网络驱动置于“混杂模式”,该模式从根本上说允许这些工具取回所有进入网络堆栈的通信量,而不是只采集前往系统本身的通信量。

协议分析仪如何帮助

在很多情况下,解决一个困难的网络问题的最难部分是找到和理解问题的根源。这种困难的部分源于这样的事实,你对大多数问题使用的工具不是正确的对底层问题的工具。

如果你是一个系统管理员,很有可能你经常用于数据采集的工具是某种日志和错误消息。通常,这些都是解释工具。这些实体试图把原始数据总结为对非开发者或非工程师有意义的东西。因为解释工具是从应用层的视角提供问题的汇总数据,它们往往不能帮助你解决底层的问题。

例如,一条事件日志消息可以告诉你应用程序无法连接到服务器。它甚至可以告诉你根本原因是超时。但这条消息不大可能告诉你超时是由一个黑洞路由器丢弃一个大帧引起。它不能,因为事件日志消息服务不知道错误为何发生。为了使工具知道那个,它需要预测(不解释)这个非常问题,在MTU稳步减少的情况下发送数据包,直到一个通过。如果一个事件日志消息服务早就被编写好要做那件事,从一开始你就不会有这个问题。

当使用错误的工具时,你可能会在某处花上几个小时甚至几周的时间,直到你侥幸得到解决方案。然而,通过使用协议分析器和历久弥新的ping命令,你可以非常容易地在大约5分钟内诊断这个问题。就像早在高中时我的汽车技术辅导员就告诉我的,它全都是关于对任务使用恰当的工具。

除了确定错误,协议分析器提供为数不多的方法之一去证实问题的根源。以前我在微软的时候,棘手问题在团队间来回穿梭是很常见的,因为每个组误解由解释工具提供的数据。首先,问题可能被发送到Exchange团队,接着它可能被穿梭到Active Directory团队,然后最后到Networking团队。

通常,这是因为在其它团队的能力范围之内一个问题好像是合理的。然而,烫手山芋的游戏往往停止在Networking团队。为什么?因为Networking团队的头号工具是证实问题根源的救世主。

网络,像所有的计算,其核心是完全合乎逻辑的。一旦你了解它在幕后是如何工作的,你就有能力在底层确定问题,不论问题是多么独特。作为协议分析的一个伟大副作用,你也将学到很多关于网络的知识,它们将帮助你解决各种各样的网络问题(即使那些不需要协议分析)。

Wireshark基础

现在,有各种各样的协议分析器可供选择,从免费的和相当功能的微软消息分析器到特性极其丰富但十分昂贵的Savvius Omnipeek。多年来我已经使用过大量的分析器,但我最喜欢的用于常规故障排除的协议分析器是Wireshark。它是免费的,开源的,多平台的具有很多特性的分析器。有个充满活力的社区站在它背后,而且Wireshark也相当容易习惯。这让它成为一个很好的开始的地方。

你可以从 https://www.wireshark.org/ 下载用于你操作系统的Wireshark。安装它没有什么特别的,但如果你是安装在Windows上,确保也安装了捆绑的WinPCAP驱动程序。这允许Wireshark实际上捕获数据包(没有它,你只能观看存档的数据包)。

Wireshark通常将你的NIC置于混杂模式。正常情况下,你的NIC只会保留前往你的MAC或者广播MAC(FF-FF-FF-FF-FF-FF)的帧。启用混杂模式后,不管怎样,你的NIC保留所有它听到的帧。

从理论上讲,这意味着你应该接收所有的在你Ethernet段上的数据包。不过,实际上如今几乎所有的Ethernet网络都是交换网络。如果你想接收所有的通信量,必须多做些工作。

一旦你已经安装了Wireshark,使用它是相当简单的。把它打开,你将看到如下显示的屏幕:

wireshark-network-analyzer

这个屏幕给你展示选择一个在它上面捕获数据包的NIC的选项和输入一个用于只捕获一部分入站数据包的过滤器的选项。如果你选择一个NIC然后点击在文件菜单下面的小鱼翅图标,Wireshark将立即开始捕获数据包。

随着数据包被捕获,Wireshark在主界面中实时地显示它们。当你准备停止时,你只需点击在鱼翅图标旁边的小红方块。

wireshark-main-interface

数据包列表部分显示在这个点捕获的每件事物,按它们被捕获的顺序排序(默认)。(你可以通过点击要作为排序依据的标题任意地排序这些数据包。)

数据包细节部分显示Wireshark解码的在数据包中的每个报头。基本上,Wireshark有几乎今天在用的每个协议的解码器,并且为了显示分成字段的数据,解码器工具自动应用到每个数据包。

举例来说,如下,我已经为一个典型的HTTP数据包增加了Ethernet II报头。

wireshark-http-header

你可以清楚地看到Wireshark已经析出了Destination和Source的MAC地址,以及Type字段,它是0x0800。Type字段指出下个报头应该是一个IPv4报头,Wireshark很方便告诉你这个。

这个解码特性使你不必自己计算字节和解码它(不过,如果你愿意,你仍然还可以在原始字节部分做)。如果对原始字节部分有兴趣:Wireshark同时为所有数据提供ASCII转换,它有时提供令人惊讶的数据。在下图,你可以在ASCII视图里的这个数据包中清楚地看到HTTP请求发送的细节。

wireshark-ascii-view

Wireshark同时也提供一些非常实用的分析和统计特性,包括测量响应时间和往返时间的能力。但到目前为止,最有用的特性是过滤功能。

在数据包列表直接的上方是一个文本框,那里你可以输入显示过滤器。默认不使用过滤器,意味着显示被捕获的所有数据包。然而,你经常最后得到信息过载,而过滤掉噪音是数据包分析的一个非常重要的部分。

Wireshark中的过滤器按照一门简单的语言结合协议字段、比较运算符和逻辑运算符以便过滤掉不匹配条件的数据包。例如,过滤器http将只显示HTTP通信量,而过滤器ip.addr == 192.168.1.10将只显示源或目标的IP地址是192.168.1.10的数据包。

当你是第一次开始的时候,过滤可能会有点令人生畏,但通常在Wireshark中学习过滤器的最简单的方法是使用内建的表达式工具。可以通过点击过滤器文本框右边的Expression按钮访问它。

这个工具允许你寻遍Wireshark本身支持的所有协议,挑选你想过滤的字段而不需要知道过滤器动词或语法。只需选择协议,填写呈现的字段,然后过滤器将会为你而建。这里,我使用表达式工具构建一个仅查找Ethernet广播的过滤器。

wireshark-filter-expression

注意工具如何在底部的绿框中显示最终的过滤器语法。通过注意这个字段,你将最终变得熟悉你的最常用的过滤器。

然而,你不能用表达式工具做的一件事是把过滤器串起来。因为那个原因,你需要学习一些逻辑运算符。Wireshark的基本逻辑运算符是and(&&)、or(||)和not(!)。

and被用于结合过滤器,只有满足所有条件的数据包会被显示。例如,过滤器http && ip.addr == 192.168.1.10将只显示在第7层报头中的HTTP协议和在IP报头中的IP地址192.168.1.10两者都包括的数据包。

or被用于查找两者中的任何一个过滤器,因此满足你输入的任何条件的数据包都会被显示。举例来说,过滤器http || ip.addr == 192.168.1.10将显示在第7层报头中的HTTP协议或在IP报头中的IP地址192.168.1.10的数据包。

not被用于从结果中过滤掉一些东西。例如,过滤器ip.addr == 192.168.1.10 ! http将显示有在IP报头中的IP地址192.168.1.10但没有在第7层报头中的HTTP协议的数据包。

关于基本的Wireshark功能最后要注意的事是,除保存你的原始捕获外,你也有多种多样的选项导出捕获。

首先,你导出当前选择的数据包、所有数据包、你标记的数据包或者一段范围的数据包。在这些选项的每一个中,你可以选择导出所有被捕获的数据包或只是被显示的数据包(考虑当前应用的过滤器)。这些选项让你非常具体地知道你想导出哪些数据包。

wireshark-export-packets

此外,你可以把数据包导出成几乎任何常用的格式。在Wireshark中用于文档和电子邮件转出的最好的特性之一是以纯文本或CSV格式导出解剖数据包(完整的数据包解码)的能力。要做到这个,只需从文件菜单里选择“Export Packet Dissections”。

理解你所看到的

尽管所有这些功能都很好,底线是如果你不明白在每个报头中字段的目的它们都是无用的。幸运的是,除了少量专有协议,你遇到的几乎每个协议的规格说明都是免费在线的。

例如:Ethernet,你可以直接到IEEE下载标准;802.3标准可以在 http://standards.ieee.org/about/get/802/802.3.html 获得。它是免费的直接来自权威人士。如果你在查找802.3 Ethernet帧格式,你将发现真的只有3个感兴趣的字段:目标MAC地址、源MAC地址和类型/长度字段。下图中在Wireshark解剖体左边的是来自IEEE 802.3规格说明Section 1中Part 3.1.1的Figure 3-1:

wireshark-packet-format

如果你想知道preamble和SFD发生了什么,它们在帧从NIC到Wireshark向上传递给栈之前被移除。同样地,你通常不会在末尾看到FCS,因为它在向上传递帧之前被剥去。

在第2层上面,所有TPCTCP/IP协议由IETF管理和由RFCs(请求评论)定义。所有这些RFCs可以在站点 https://www.rfc-editor.org/ 上即刻地免费地获得。虽然它们有点简洁(并且因为这个原因有时难以理解),它们总是正确的,具体问题的澄清可以使用Google快速搜索获得。

举例来说,通常混淆新手的事情之一是大量TCP重置,或者在TCP报头中数据包有打开的RST标记。浏览RFC 793(TCP),你可能会得到RST总是用信号告知一些坏事情的印象。几乎所有的35个左右提到的RST与某种错误条件有关联。

然而,使用关键词“tcp rst from client”的Google快速搜索将让你得到大量的关于这个现象的很好的讨论。也许最好的是来自Wireshark论坛,在那里他们解释说这很平常,因为客户端应用仅仅被编码去重置链接而不是优雅地关闭它。在这种情况下,服务端已经发送一个FIN。作为回复一个FIN/ACK并等待最终的ACK的替换,客户端只需通过发送一个RST并中止会话来优化过程。在下面的示例中这可以清楚地被看到。

wireshark-pcap-example

除了规格说明和Google,另一个学习协议通常如何运转的良好来源是示例跟踪的资料库。这些示例跟踪允许你去查看相当典型的既常见又晦涩的协议操作,以及一些十分罕见的平常可能不会碰到的错误。

一个很好的起点是Wireshark Wiki上的样板捕获:https://wiki.wireshark.org/SampleCaptures 。在这里有大量非常有用的捕获让你去下载以及用过滤和其它Wireshark特性做实验,包括像广播风暴、病毒和攻击套装这样有意思的错误。如果这些还不够,在这个页面上还有一些其它资源的链接去协助你。但是毫无疑问地,变得擅长协议分析的最快方式是仔细观看大量的捕获并试图理解被使用的协议。

如何得到好的捕获

如果不能捕获正确的数据,世界上所有的领悟都无济于事。在最基本的层面,目标是只捕获涉及你试图解决的问题的数据包,有效减少你跟踪里的噪音。

为了做到这点,你可以使用一个捕获过滤器去从捕获中排除那些匹配过滤器外的所有数据。如果你确切地知道你在寻找什么这会工作的很好,但往往这种方法会导致你不能觉察一些重要的事情。大多数时候你只有一个问题是什么的粗略想法,或者你忘了一些潜在的找到错误的关键的过程。如果这种情况发生,使用捕获过滤器就没那么幸运,而且你不能返回和没重设置它就不过滤捕获。

例如,在诊断一个网站的性能问题时,你可能决定使用一个捕获过滤器集从Web服务器自身取得捕获,以便只捕获在其与客户端系统和后台SQL服务器之间往返的数据。然而,这个问题实际上可能仅仅是Web服务器使用的身份验证服务器过载,等待身份验证才是引起整个性能问题的原因。使用你选择的捕获过滤器你将永远不会发现这个问题。

这是我倾向于捕获所有数据并且使用显示过滤器去减少跟踪里的噪音的原因。这不是说捕获过滤器完全不必要。捕获过滤器的一个常见用途是当你有一个非常繁忙的你正在捕获的千兆或万兆连接的时候,捕获过滤器变得有用仅仅是因为大量的数据。不过,你始终需要牢记过滤器的限制。

得到一个好的捕获的第二部分是正确识别你需要捕获的系统。举例来说,在前面关于Web服务器性能问题的例子中,我可能首先会从Web服务器和Web客户端两者取得同时发生的捕获。这样你可以看到两边的正常预期行为的任何偏差,这有助于你将问题隔离到服务器或客户端。

一旦查明延迟是一个与身份验证有关的服务端问题,然后我会从Web服务器和身份验证服务器两者取得其它的跟踪。这样,我可以看到是本地到身份验证服务器的问题还是等待像DNS这样其它服务的问题或者Global Catalog是实际上的罪魁祸首。

得到一个好的捕获的第三步是在成功和失败条件中都使用捕获。举例来说,如果你有一个间歇性的Web服务器性能问题,设法在站点正常和不正常工作时都得到跟踪。这能给你一个好的和坏的比较跟踪,可以使用它去隔离问题。

最后,当处理一个间歇性的问题时,你会发现很难得到一个失败捕获。在这种情况下,Wireshark有一个很重要的特性被称为环形缓冲区,它允许你持续地捕获。

通常,特别在一个繁忙的网络上,持续的捕获将冒填满磁盘的风险。但有了环形缓冲区,Wireshark会写入文件直到它达到指定大小或者经过一段时间,然后它会切换到一个新文件。一旦指定数量的文件已经被写入,程序删除最旧的文件。例如,看看下面我已经定义的设置:

wireshark-capture-interfaces

这个配置告诉Wireshark不管文件大小每10分钟创建一个新文件,并且确保程序保留总计3个文件,根据需要删除最旧的。这确保从错误被通知的时间起我有30分钟去停止捕获。这是一个非常有用的技术用于捕获极其间歇性的问题。

这些是你需要的以便用Wireshark开始故障排除的所有基本技术。使用这些技术和资源,你会发现你经常能用比几乎任何其它技术更短的时间找到和验证网络问题的原因。快乐的故障排除。

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

今天准备做的是把JSON文本文件转换成XML文本文件。

把JSON转换成XML

许多Web服务返回的是JSON数据,但是我们可能会遇到一种情况,需要把JSON数据送给那些只接受XML数据的代码。这就需要我们构建一个JSON到XML的转换器。我们的目标是读入像下面这样的JSON数据:

1
2
3
4
5
6
7
{
"description" : "An imaginary server config file",
"logs" : {"level":"verbose", "dir":"/var/log"},
"host" : "antlr.org",
"admin": ["parrt", "tombu"],
"aliases": []
}

放出等价的XML数据,就像下面这样的:

1
2
3
4
5
6
7
8
9
10
11
<description>An imaginary server config file</description>
<logs>
  <level>verbose</level>
  <dir>/var/log</dir>
</logs>
<host>antlr.org</host>
<admin>
  <element>parrt</element>
  <element>tombu</element>
</admin>
<aliases></aliases>

正如我们对CSV做的那样,让我们给JSON语法中的一些选项打上标签,以便让ANTLR生成更精确的监听器方法。

1
2
3
4
5
6
7
8
object
    : '{' pair (',' pair)* '}'    # AnObject
    | '{' '}'                     # EmptyObject
    ;
array
    : '[' value (',' value)* ']'  # ArrayOfValues
    | '[' ']'                     # EmptyArray
    ;

我们将对规则value做同样的事,但是稍有不同。除3个选项外的其它所有选项只需要返回被匹配的值的文本,所以我们可以为其它所有选项使用相同的标签,使语法分析树遍历器为那些选项触发相同的监听器方法。

1
2
3
4
5
6
7
8
9
value
    : STRING    # String
    | NUMBER    # Atom
    | object    # ObjectValue
    | array     # ArrayValue
    | 'true'    # Atom
    | 'false'   # Atom
    | 'null'    # Atom
    ;

为构建这样的转换器,明智的做法是让每个规则返回被它匹配的输入短语的XML等价物。为追踪部分结构,我们使用字段xml和两个帮助方法来注解语法分析树。

1
2
3
4
public static class XMLEmitter extends JSONBaseListener {
    ParseTreeProperty<String> xml = new ParseTreeProperty<String>();
    String getXML(ParseTree ctx) { return xml.get(ctx); }
    void setXML(ParseTree ctx, String s) { xml.put(ctx, s); }

我们把每棵子树转换后的字符串挂载到该子树的根节点。在语法分析树更高节点上工作的方法可以捕获这些值以便计算更大的字符串。然后挂载在根节点上的字符串完成计算。

让我们从最简单的转换开始。value的Atom选项返回匹配记号的文本。

1
2
3
public void exitAtom(JSONParser.AtomContext ctx) {
    setXML(ctx, ctx.getText());
}

字符串基本上是相同的,只是我们必须去除双引号。

1
2
3
public void exitString(JSONParser.StringContext ctx) {
    setXML(ctx, stripQuotes(ctx.getText()));
}

如果value()规则方法找到一个对象或数组,它可以把组合元素的部分转换拷贝到它自己的语法分析树节点。以下代码是找到对象时的处理:

1
2
3
4
public void exitObjectValue(JSONParser.ObjectValueContext ctx) {
    // analogous to String value() {return object();}
    setXML(ctx, getXML(ctx.object()));
}

一旦我们可以转换所有的值,我们需要担心名-值对以及把它们转换成标签和文本。生成的XML的标签名字来源于STRING ':' value选项中的STRING。在左右尖括号之间的文本来源于挂载在value子节点上的文本。

1
2
3
4
5
6
public void exitPair(JSONParser.PairContext ctx) {
    String tag = stripQuotes(ctx.STRING().getText());
    JSONParser.ValueContext vctx = ctx.value();
    String x = String.format("<%s>%s</%s>\n", tag, getXML(vctx), tag);
    setXML(ctx, x);
}

JSON对象由名-值对组成。因此,对于被选项中标记为AnObject的object找到的每个对,我们把计算后的结果追加在语法分析树。

1
2
3
4
5
6
7
8
9
10
11
public void exitAnObject(JSONParser.AnObjectContext ctx) {
    StringBuilder buf = new StringBuilder();
    buf.append("\n");
    for (JSONParser.PairContext pctx : ctx.pair()) {
        buf.append(getXML(pctx));
    }
    setXML(ctx, buf.toString());
}
public void exitEmptyObject(JSONParser.EmptyObjectContext ctx) {
    setXML(ctx, "");
}

处理数组遵循相似的模式,只是简单地连接来自子节点的结果列表,然后把它们包裹在<element>标签中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void exitArrayOfValues(JSONParser.ArrayOfValuesContext ctx) {
    StringBuilder buf = new StringBuilder();
    buf.append("\n");
    for (JSONParser.ValueContext vctx : ctx.value()) {
        buf.append("<element>"); // conjure up element for valid XML
        buf.append(getXML(vctx));
        buf.append("</element>");
        buf.append("\n");
    }
    setXML(ctx, buf.toString());
}
public void exitEmptyArray(JSONParser.EmptyArrayContext ctx) {
    setXML(ctx, "");
}

最后,我们需要使用从一个对象或数组收集来的全部转换注解语法分析树的根节点。

1
2
3
json: object
    | array
    ;

我们可以在监听器里用一个集合运算做到这点。

1
2
3
public void exitJson(JSONParser.JsonContext ctx) {
    setXML(ctx, getXML(ctx.getChild(0)));
}

以下是构建和测试序列:

1
2
3
antlr JSON.g
compile *.java
run JSON2XML t.json

下面的是部分的输出结果:

1
2
3
4
<description>An imaginary server config file</description>
<logs>
<level>verbose</level>
...

有些转换不总是像JSON到XML那样直白的。但是,这个例子向我们表明如何通过拼凑部分翻译短语处理句子转换问题。

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"}]

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

在本节中,我们准备讲讲有时候事件方法需要传递部分结果或其它信息的问题。

在事件方法间共享信息

无论收集信息还是计算值,传递参数和返回值都是比使用字段和全局变量更方便良好的编程实践。问题是ANTLR自动生成的监听器方法的签名不需要特定应用的返回值或参数,ANTLR也自动生成访问者方法而不需要特定应用的参数。

接下来,我们将探讨让事件方法无需修改事件方法签名就能传递数据的机制。我们将构建同样的简单计算器的3个不同实现,基于前面章节的LExpr表达式语法。第一个实现使用访问者方法返回值,第二个定义了一个在事件方法间共享的字段,第三个则注解语法分析树节点以便储存感兴趣的值。

使用访问者遍历语法分析树

构建基于访问者的计算器,最简单的方法是让和规则expr相关的事件方法返回子表达式的值。例如,visitAdd()将返回两个子表达式相加的值,visitInt()将返回整型的值。传统的访问者不指定visit方法的返回值。当我们为特定应用需求实现一个类时添加返回类型是容易的,扩展LExprBaseVisitor并提供Integer作为类型参数。访问者代码看起来如下所示:

1
2
3
4
5
6
7
8
9
10
11
public static class EvalVisitor extends LExprBaseVisitor<Integer> {
    public Integer visitMult(LExprParser.MultContext ctx) {
        return visit(ctx.e(0)) * visit(ctx.e(1));
    }
    public Integer visitAdd(LExprParser.AddContext ctx) {
        return visit(ctx.e(0)) + visit(ctx.e(1));
    }
    public Integer visitInt(LExprParser.IntContext ctx) {
        return Integer.valueOf(ctx.INT().getText());
    }
}

EvalVisitor从ANTLR生成的AbstractParseTreeVisitor类继承通用的visit()方法,我们的访问者使用它去准确地触发子树访问。

注意,EvalVisitor没有针对规则s的访问者方法。在LExprBaseVisitor中的visitS()的默认实现调用预定义的方法ParseTreeVisitor.visitChildren(). visitChildren()返回从最后的子节点访问返回的值。在这里,visitS()返回访问它唯一的子节点(节点e)时返回的表达式的值。我们可以使用这种默认的行为。

在测试文件TestLEvalVisitor.java中,我们有常用代码去启动LExprParser和打印语法分析树,然后我们需要编码去启动EvalVisitor和打印出当访问树时计算出的表达式的值。

1
2
3
EvalVisitor evalVisitor = new EvalVisitor();
int result = evalVisitor.visit(tree);
System.out.println("visitor result = " + result);

要构建计算器,需要告诉ANTLR使用-visitor参数去生成访问者。(如果我们不再需要生成监听器,可以使用-no-listener参数)以下是完整的构建和测试序列:

1
2
3
antlr -visitor LExpr.g
compile *.java
run TestLEvalVisitor

接着输入以下内容:

1
2
1+2*3
EOF

你就会看到如下结果:

1
2
(s (e (e 1) + (e (e 2) * (e 3))))
visitor result = 7

如果我们需要特定应用的返回值,访问者工作的相当好,因为我们使用了内建的Java返回值机制。如果我们不希望显式地调用访问者方法去访问子节点,我们可以切换到监听器机制,不幸的是,这意味着我们要放弃使用Java方法返回值的整洁。

使用栈模拟返回值

ANTLR生成的监听器事件方法没有返回值。为了给在语法分析树更高节点上执行的监听器方法返回值,我们可以把部分的值存储在监听器的一个字段中。我们会想到用栈来存储值,方法就是把计算一个子表达式的结果推送到栈中,在语法分析树上用于子表达式的方法则把运算元从栈中弹出。以下是完整的Evaluator计算器监听器(代码在TestLEvaluator.java文件中):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static class Evaluator extends LExprBaseListener {
    Stack<Integer> stack = new Stack<Integer>();
    public void exitMult(LExprParser.MultContext ctx) {
        int right = stack.pop();
        int left = stack.pop();
        stack.push(left * right);
    }
    public void exitAdd(LExprParser.AddContext ctx) {
        int right = stack.pop();
        int left = stack.pop();
        stack.push(left + right);
    }
    public void exitInt(LExprParser.IntContext ctx) {
        stack.push(Integer.valueOf(ctx.INT().getText()));
    }
}

要测试上面的这段代码,我们可以创建和使用在代码TestLEvaluator中的ParseTreeWalker,以下是完整的构建和测试序列:

1
2
3
antlr LExpr.g
compile *.java
run TestLEvaluator

接着输入以下内容:

1
2
1+2*3
EOF

你就会看到如下结果:

1
2
(s (e (e 1) + (e (e 2) * (e 3))))
stack result = 7

使用栈字段有点别扭但工作得很好。我们必须确保事件方法以正确的顺序压入和弹出跨越监听器事件的值。带有返回值的访问者没有栈的这种笨拙但却需要手工访问树的节点。第三种实现是通过把部分值隐藏在树节点中来捕获它们。

注解语法分析树

作为使用临时存储在事件方法间共享数据的替代,我们可以把这些值存储在语法分析树本身中。使用树注解方法时我们可以带有监听器或访问者,但在这里我们使用监听器来阐明如何使用它。让我们首先看一下用部分结果注解的1+2*3的LExpr语法分析树。

lexpr-parse-tree

每个子表达式对应一个子树根(和对应一个e规则调用)。从e节点发出的水平线指向的数字是我们想要返回的部分结果。

让我们看看节点注解策略将如何工作在来自LExpr语法的规则e上。

1
2
3
4
e : e MULT e    # Mult
  | e ADD e     # Add
  | INT         # Int
  ;

e选项的监听器方法每个都会存储一个结果在相对应的e语法分析树节点中。任何随后的在语法分析树更高节点上的add或multiply事件将通过查看存储在它们对应的子节点中的值来抓取子表达式的值。

现在,让我们假设每个语法分析树节点(每个规则上下文对象)都有一个字段value,那么exitAdd()看起来将是这样;

1
2
3
4
public void exitAdd(LExprParser.AddContext ctx) {
    // e(0).value is the subexpression value of the first e in the alternative
    ctx.value = ctx.e(0).value + ctx.e(1).value;    // e '+' e # Add
}

这看起来相当合理,但不幸的是,在Java中我们不能扩展类ExprContext去动态地添加字段。为了让语法分析树注解生效,我们需要一种方法去注解各式各样的节点而不需要手工修改由ANTLR生成的关联节点类。

注解语法分析树节点最简单的方式是使用与节点任意值相关联的一个Map。因此,ANTLR提供了一个简单的帮助类ParseTreeProperty。让我们在文件TestLEvaluatorWithProps.java中构建称作EvaluatorWithProps的另一个计算器版本,它使用ParseTreeProperty关联了LExpr语法分析树节点和部分结果。以下是在监听器开始处的适当的定义:

1
2
3
public static class EvaluatorWithProps extends LExprBaseListener {
    /** maps nodes to integers with Map<ParseTree,Integer> */
    ParseTreeProperty<Integer> values = new ParseTreeProperty<Integer>();

注意:如果你想使用自己的Map类型字段代替ParseTreeProperty,确保它继承自IdentityHashMap,而不是通常的HashMap。我们需要去注解特殊的节点,进行同一性测试而不是equals()。两个e节点可能是equals(),但在内存中不是同一个物理节点。

为注解一个节点,我们使用values.put(node, value)。为得到和一个节点有关联的值,我们使用values.get(node)。这很好,但是让我们创建一些有直白名字的帮助方法以便让代码更容易阅读。

1
2
public void setValue(ParseTree node, int value) { values.put(node, value); }
public int getValue(ParseTree node) { return values.get(node); }

让我们从最简单的表达式选项Int开始监听器方法。我们想使用它匹配的INT记号的整型值去注解它的语法分析树e节点。

1
2
3
4
public void exitInt(LExprParser.IntContext ctx) {
    String intText = ctx.INT().getText();    // INT    # Int
    setValue(ctx, Integer.valueOf(intText));
}

对于加法树,我们得到两个子表达式子节点的值(运算元)和带有和的注释的子树跟。

1
2
3
4
5
public void exitAdd(LExprParser.AddContext ctx) {
    int left = getValue(ctx.e(0));    // e '+' e    # Add
    int right = getValue(ctx.e(1));
    setValue(ctx, left + right);
}

方法exitMult()是相同的,只是运算的时候用multiply代替了add。

我们的测试代码从分析规则s开始。因此我们必须确保语法分析树根有e子树的值。为把值从e节点冒泡到根s节点,我们实现了exitS()。

1
2
3
4
/** Need to pass e's value out of rule s : e ; */
public void exitS(LExprParser.SContext ctx) {
    setValue(ctx, getValue(ctx.e()));    // like: int s() { return e(); }
}

以下是如何启动监听器以及打印出来自语法分析树根节点的表达式的值:

1
2
3
4
ParseTreeWalker walker = new ParseTreeWalker();
EvaluatorWithProps evalProp = new EvaluatorWithProps();
walker.walk(evalProp, tree);
System.out.println("properties result = " + evalProp.getValue(tree));

以下是构建和测试序列:

1
2
3
antlr LExpr.g
compile *.java
run TestLEvaluatorWithProps

接着输入以下内容:

1
2
1+2*3
EOF

你就会看到如下结果:

1
2
(s (e (e 1) + (e (e 2) * (e 3))))
stack result = 7

现在我们已经看到了相同计算器的3个实现,并且我们也已经准备好把我们的知识用于构建真实案例。因为每个方法都有它的优势和劣势,下面就让我们来比较下不同的技术。

比较信息共享方法

为得到可复用和可重定目标的语法,我们需要让它们完全清除用户定义的动作。这意味着要把所有特定应用的代码放到语法外的某些监听器和访问者中。监听器和访问者操作语法分析树,ANTLR自动生成合适的树遍历接口和默认实现。因为事件方法签名是固定的和不特定于应用的,所以事件方法可以共享信息的方式有3种:

  • 本地Java调用栈:访问者返回用户定义类型的一个值。如果访问者需要传递参数,它也必须使用下面两种技术的一种。
  • 基于栈:一个栈字段模仿参数和返回值,像Java调用栈那样。
  • 注解者:一个Map字段使用有用的值注解节点。

所有这3种方法是和语法本身完全解耦的,并且很好地封装在专门的对象中。除此之外,它们也都有各自的优点和缺点。我们可以根据问题的需要和个人的喜好决定采取哪种方法。你甚至可以在同一个应用中使用多种方法。

访问者方法很好懂,因为它们直接调用其它访问者方法去获取部分结果,并且能像其它任何方法那样返回值。这也是它们的缺点,访问者方法必须显式地访问它们的子节点。而监听器就不需要。因为访问者有个通用的接口,所以它不能定义参数。访问者必须使用其它解决方案的一种去传递参数给它在子节点上调用的访问者方法。访问者的空间效率很好,因为它在任何时间仅需保留少数的部分结果。在树遍历后没有部分结果保留。当访问者方法可以返回值时,每个值必须是同种类型,不想其它的解决方案。

基于栈的解决方案可以模仿参数和返回带有一个栈的值,但在手动管理栈时有个断开的机会。这可能会发生,因为监听器方法不能直接调用彼此。作为程序员,我们必须确定推入栈中的在将来事件方法调用能适当地弹出。栈可以传递多个值和多个返回值。基于栈的解决方案也是空间有效的,因为它不会把任何东西固定到树上。在树遍历后所有的部分结果存储消失。

注解者通常可以作为默认解决方案采用,因为它允许你任意地提供信息给事件方法操作语法分析树中上上下下的节点。你也可以传递多个值,它们可以是任意类型。在许多情况下注解胜于使用带有短暂值的栈。在各种方法的数据传递准备间很少有断开的机会。比起在编程语言中说返回值,使用setValue(ctx, value)注解树不太直观,但是更通用。超过其它两种的这种方法的唯一缺点是在树遍历期间部分结果是保留的,因此它有较大的内存占用。

从另一方面来说,在某些应用中能够注解树正是我们需要的。应用需要在树上通过多遍,第一遍是很方便在树上计算和储存数据的。当语法分析树遍历器重新遍历树的时候第二遍然后就很容易访问数据。总的来说,树注解非常灵活,有一个可接受的内存负担。

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

默认情况下,ANTLR为每个规则生成一个单一的事件类型,而不管语法分析器匹配了哪个选项。这很不方便,因为监听器和访问者方法必须确定哪个选项被语法分析器匹配。在本节中,我们将看到如何得到更细粒度的事件。

为规则选项贴标签以得到精确的事件方法

为阐明事件粒度问题,让我们为以下的表达式语法构建一个带有监听器的简单计算器:

1
2
3
4
5
6
grammar Expr;
s : e ;
e : e op=MULT e    // MULT is '*'
  | e op=ADD e     // ADD is '+'
  | INT
  ;

按照上面的语法,规则e会产生一个相当无用的监听器,因为规则e的所有选项导致树遍历器触发相同的enterE()和exitE()方法。

1
2
3
public interface ExprListener extends ParseTreeListener {
    void enterE(ExprParser.EContext ctx);
    void exitE(ExprParser.EContext ctx);

监听器方法必须使用op记号标签和ctx的方法进行测试以查看语法分析器为每个e子树匹配了哪个选项。

1
2
3
4
5
6
7
8
9
10
11
12
13
public void exitE(ExprParser.EContext ctx) {
    if (ctx.getChildCount() == 3) {    // operations have 3 children
        int left = values.get(ctx.e(0));
        int right = values.get(ctx.e(1));
        if (ctx.op.getType() == ExprParser.MULT) {
            values.put(ctx, left * right);
        } else {
            values.put(ctx, left + right);
        }
    } else {
        values.put(ctx, values.get(ctx.getChild(0)));    // an INT
    }
}

在exitE()中引用的MULT字段是在ExprParser中由ANTLR自动生成的:

1
2
public class ExprParser extends Parser {
    public static final int MULT=1, ADD=2, INT=3, WS=4;

如果我们查看在类ExprParser中的类EContext,我们可以看到ANTLR把来自3个选项的所有元素都塞进了相同的上下文对象。

1
2
3
4
5
public static class EContext extends ParserRuleContext {
    public Token op;                     // derived from label op
    public List<EContext> e() { ... }    // get all e subtrees
    public EContext e(int i) { ... }     // get ith e subtree
    public TerminalNode INT() { ... }    // get INT node if alt 3 of e

为得到更精确的监听器事件,ANTLR让我们使用#运算符给任何规则最外层的选项打标签。让我们从Expr派生语法LExpr,并给e的选项打上标签,以下是修改后的e规则:

1
2
3
4
e : e MULT e  # Mult
  | e ADD e   # Add
  | INT       # Int
  ;

现在,ANTLR为e的每个选项生成了单独的监听器方法,因此,我们不再需要op记号标签。对于选项标签X,ANTLR生成方法enterX()和exitX()。

1
2
3
4
5
6
7
8
9
public interface LExprListener extends ParseTreeListener {
    void enterMult(LExprParser.MultContext ctx);
    void exitMult(LExprParser.MultContext ctx);
    void enterAdd(LExprParser.AddContext ctx);
    void exitAdd(LExprParser.AddContext ctx);
    void enterInt(LExprParser.IntContext ctx);
    void exitInt(LExprParser.IntContext ctx);
    ...
}

注意,ANTLR也为选项生成特定的以标签命名的上下文对象(EContext的子类)。专门的上下文对象的getter方法只限于应用在那些相关的选项。例如,IntContext只有一个INT()方法,我们可以在enterInt()中调用ctx.INT(),但在enterAdd()中就不能。

监听器和访问者是极好的。我们只需要通过充实事件方法就可以得到可复用和可重定目标的语法以及封装的语言应用。ANTLR甚至会为我们自动生成骨架代码。

如何创建有效的图标

英文原文:http://www.awwwards.com/how-to-create-effective-icons.html

我可能就是你所说的图标爱好者。我喜欢图标,而且我更加喜欢制作它们!作为一个艺术家,我的背景在很大程度上是绘画——我喜欢绘画,并且我已经画了一辈子(甚至远远早于我知道什么是图形设计)。我想,这是我理解创建图标的一个关键。绘画教你看——然后把你所看到的转化成纸上的线条和图形——而这正是如何创建有效的图标。

几何图形

因此,对初学者而言,基本上任何东西都可以用这四种图形组合而成:

effective-icons-1

当我想把某事物转换成一个图标时,我观察它然后尽可能地将其拆分为最简单的图形。例如,水滴可以用一个三角形和一个圆形组成。

effective-icons-2

心形图标可以由两个圆形和一个三角形构成。

effective-icons-3

我每次都是在Adobe Illustrator中创建这些图形。使用矢量图形可以让我控制线条的粗细,以及图形和其锚点的相互作用。Illustrator也可以让我自由地把线条转换成图形,反之亦然。这一切也许看起来十分基础,但它是我用于创建最复杂图标的同样的方法。下面是我最近在做的一个略微更加复杂些的《权利法案》图标的示例,在这里我应用了同样的原则。

effective-icons-4

界面图标

我最近有机会为一款超赞的iPhone应用Parker Planner制作一组图标。我很喜欢做这个项目,这个项目其中最重要的一个方面是创建一组易懂的、私有的、实用的和美观的用户界面图标,可以帮助用户浏览操作这款略微复杂的计划应用。

effective-icons-5

让我们选取这些图标的其中之一分解看看我如何创建它。例如,垃圾桶图标是由三个圆角矩形和三条线构成。

1、选择圆角矩形工具。

effective-icons-6

2、拖动出一个图形。

effective-icons-7

3、调整笔划宽度直到你满意。

effective-icons-8

我通常选择在整组图标中使用一到两种笔划宽度。

effective-icons-9

这使它们看起来更一致和感觉更有整体性。

4、用另一个圆角矩形创建盖子。

effective-icons-10

5、再一个圆角矩形创建盖子的把手。

effective-icons-11

6、擦除圆角矩形的下半部分。

effective-icons-12

7、现在,通过添加三条竖线到桶身上给桶添加条纹。

effective-icons-13

8、然后你就获得了它!一个垃圾桶图标……如果你喜欢,你可以用颜色或线条宽度做进一步调整。

effective-icons-14

我在创建图标时经常使用的一些其它真正有用的工具是Pathfinder,我使用它来剪切、连接和挖空图形。

effective-icons-15

Stroke/Fill工具,它帮助你将图形在填满和笔划间切换。

effective-icons-16

以及我非常喜欢的工具Stroke Panel,它帮助你将拐角和线的末端从直角转换到圆角。

effective-icons-17

当我完成一组图标,我通常将它们全体紧挨着排成一排,看看是否有哪个看起来很奇怪或不到位。然后我会做任何必要的修改。

effective-icons-18

最后,我总是在应用中测试它们以确保它们感觉正确和功能良好。

effective-icons-19

最终,我想说创建优秀图标的方法不仅仅是学习Illustrator技巧,尽管它们也是必需的。最好的做法是练习把你周围看到的事物分解成简单图形。你在这点上越是变得更好,你越是能够成为更高超的图标设计师!加油!

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

基于监听器的方法是极好的,因为所有树遍历和方法触发被自动完成。尽管有时候自动树遍历也是一个缺点,因为我们不能控制遍历本身。例如,我们可能想遍历一段C程序的语法分析树,通过跳过函数体子树忽略函数中的一切。监听器事件方法也不能使用方法返回值去传递数据。当我们需要控制遍历或返回带有事件方法返回值的值时,我们使用访问者模式。现在,让我们构建一个基于访问者版本的属性文件加载器去比较这两种方法。

使用访问者实现应用

使用访问者代替监听器,我们只需要让ANTLR生成访问者接口和实现接口,然后创建一段测试代码在语法分析树上调用visit(),根本不需要触及到语法。

在命令行使用-visitor参数时,ANTLR生成接口PropertyFileVisitor和类PropertyFileBaseVisitor,后者有如下的默认实现:

1
2
3
4
5
6
7
public class PropertyFileBaseVisitor<T> extends AbstractParseTreeVisitor<T>
                                        implements PropertyFileVisitor<T> {
    @Override
    public T visitFile(PropertyFileParser.FileContext ctx) { ... }
    @Override
    public T visitProp(PropertyFileParser.PropContext ctx) { ... }
}

我们可以从监听器的exitProp()中拷贝映射功能,然后把它粘贴到与规则prop相关的访问者方法中。

1
2
3
4
5
6
7
8
9
10
public class TestPropertyFileVisitor {
    public static class PropertyFileVisitor extends PropertyFileBaseVisitor<Void> {
        Map<String,String> props = new OrderedHashMap<String, String>();
        public Void visitProp(PropertyFileParser.PropContext ctx) {
            String id = ctx.ID().getText();    // prop : ID '=' STRING '\n' ;
            String value = ctx.STRING().getText();
            props.put(id, value);
            return null;    // Java says must return something even when Void
        }
    }

这里是访问者接口和类之间的继承关系:

propertyfile-visitor-hierarchy

访问者在子节点上通过显式地调用接口ParseTreeVisitor的visit()方法遍历语法分析树。那些方法在AbstractParseTreeVisitor中实现。在这里,为prop调用创建的节点没有子树,因此visitProp()不需要调用visit()。

在监听器和访问者的测试代码(例如TestPropertyFileVisitor)之间最大的不同是访问者的测试代码不需要ParseTreeWalker,它只需要让访问者去访问由语法分析器创建的树。

1
2
3
PropertyFileVisitor loader = new PropertyFileVisitor();
loader.visit(tree);
System.out.println(loader.props);    // print results

以下是构建和测试序列:

1
2
3
antlr -visitor PropertyFile.g  # create visitor as well this time
compile *.java
run TestPropertyFileVisitor t.properties

这里是输出的内容:

1
{user="parrt", machine="maniac"}

我们可以使用监听器和访问者构建几乎任何我们想要的。一旦我们处于Java空间,就不再需要学习更多的ANTLR知识。我们只要知道语法、语法分析树、监听器和访问者事件方法之间的关系。除此之外,就是代码。在对识别中的输入短语的回答中,我们可以生成输出、收集信息、以某种方式验证短语,或者执行计算。

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

前文的语法仍然存在问题,因为它限制我们只能使用Java生成语法分析器。为使语法可复用和语言无关,我们需要完全避免嵌入动作。接下来就将展示如何用监听器做到这点。

使用语法分析树监听器实现应用

在构建语言应用时要避免应用和语法纠缠在一起,关键是让语法分析器生成语法分析树,然后遍历该树去触发特定的应用代码。我们可以使用我们最喜欢的技术遍历树,也可以使用ANTLR生成的树遍历机制中的一个。在本节中,我们将使用ANTLR内建的ParseTreeWalker构建一个基于监听器版本的属性文件应用。

让我们从属性文件语法的原始版本开始:

1
2
file : prop+ ;
prop : ID '=' STRING '\n' ;

下面是属性示例文件t.properties的内容:

1
2
user="parrt"
machine="maniac"

通过上述语法,ANTLR生成PropertyFileParser,该语法分析器会自动构建如下图所示的语法分析树:

propertyfile-parse-tree

有了语法分析树,我们就可以使用ParseTreeWalker去访问所有的节点,触发进入和退出方法。

现在来看一下ANTLR通过语法PropertyFile生成的监听器接口PropertyFileListener,当ANTLR的ParseTreeWalker发现和完成节点时,它会为每个规则子树分别触发进入和退出方法。因为在语法PropertyFile中只有两条语法规则,所以在接口中有4个方法:

1
2
3
4
5
6
public interface PropertyFileListener extends ParseTreeListener {
    void enterFile(PropertyFileParser.FileContext ctx);
    void exitFile(PropertyFileParser.FileContext ctx);
    void enterProp(PropertyFileParser.PropContext ctx);
    void exitProp(PropertyFileParser.PropContext ctx);
}

FileContext和PropContext对象是针对每条语法规则的语法分析树节点的实现,它们包含一些有用的方法。

为方便起见,ANTLR也会生成带有默认实现的类PropertyFileBaseListener,这些默认实现模仿在前文语法中@member区域我们手写的空白方法。

1
2
3
4
5
6
7
public class PropertyFileBaseVisitor<T> extends AbstractParseTreeVisitor<T>
                                        implements PropertyFileVisitor<T> {
    @Override
    public T visitFile(PropertyFileParser.FileContext ctx) { }
    @Override
    public T visitProp(PropertyFileParser.PropContext ctx) { }
}

默认实现让我们只需要覆盖和实现那些我们关心的方法。例如,以下是属性文件加载器的一个重新实现,像前面那样它只有一个方法,但使用监听器机制:

1
2
3
4
5
6
7
8
public static class PropertyFileLoader extends PropertyFileBaseListener {
    Map<String,String> props = new OrderedHashMap<String, String>();
    public void exitProp(PropertyFileParser.PropContext ctx) {
        String id = ctx.ID().getText();    // prop : ID '=' STRING '\n' ;
        String value = ctx.STRING().getText();
        props.put(id, value);
    }
}

主要的不同是这个版本扩展了基类监听器,而不是语法分析器,监听器方法在语法分析器完成后被触发。

这里面有很多的接口和类,让我们看一下在这些关键元素间的继承关系。

propertyfile-listener-hierarchy

处于ANTLR运行库中的接口ParseTreeListener要求每个监听器对事件visitTerminal()、enterEveryRule()和exitEveryRule()作出反应,如果有语法错误的还要加上visitErrorNode()。ANTLR从语法文件PropertyFile生成接口PropertyFileListener,并且为类PropertyFileBaseListener的所有方法生成默认实现。我们仅需要构建PropertyFileLoader,它继承了PropertyFileBaseListener中的所有空方法。

方法exitProp()可以访问与规则prop相关的规则上下文对象PropContext,该上下文对象持有在规则prop中提到的每个元素(ID和STRING)的对应方法。因为这些元素是语法中的记号引用,所以方法返回语法分析树节点TerminalNode。我们既可以通过getText()直接访问记号的文本,也可以通过getSymbol()首先获取Token。

现在让我们创建测试文件TestPropertyFile.java遍历树,倾听来自PropertyFileLoader的声音:

1
2
3
4
5
6
// create a standard ANTLR parse tree walker
ParseTreeWalker walker = new ParseTreeWalker();
// create listener then feed to walker
PropertyFileLoader loader = new PropertyFileLoader();
walker.walk(loader, tree);    // walk parse tree
System.out.println(loader.props);    // print results

然后就是编译和生成代码,运行测试程序去处理输入文件:

1
2
3
antlr PropertyFile.g
compile *.java
run TestPropertyFile t.properties

这里是输出的内容:

1
{user="parrt", machine="maniac"}

测试程序成功地把文件中的属性赋值重组成内存中的映射数据结构。

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

单独的语法不是很有用,因为相关的语法分析器只能告诉我们某个输入句子是否遵守语言规格。为构建语言应用,我们需要语法分析器在看到特定的输入句子、短语或者记号时触发特定的动作。这些短语-动作对的集合表示语言应用或者至少是在语法和一个更大的周边应用之间的接口。

我们可以使用语法分析树监听器和访问者构建语言应用。监听器是一个响应规则进入和退出事件的对象,这些短语识别事件在语法分析树遍历器发现和完成节点时触发。为了支持应用必须控制树是如何被遍历的这种情况,ANTLR生成的语法分析树也支持著名的树访问者模式。

监听器和访问者之间最大的不同是,监听器方法不对显式地调用方法去遍历它们的子树负责,而访问者必须显式地触发访问子节点以保持树的遍历继续。通过这些显式的访问子树的调用,访问者控制遍历的顺序以及多少树被访问。为方便起见,我们使用术语“事件方法”来指代监听器回调或者访问者方法。

为了确切地知道ANTLR为我们构建了什么样的树遍历设施以及为什么,让我们首先看看监听器机制的起源以及如何使用监听器和访问者把特定应用的代码和语法分离。

从嵌入动作演化到监听器

监听器和访问者机制将语法从应用代码中解耦,提供了一些令人信服的好处。这种解耦很好地封装了应用程序,而不是把它切割成碎片分散到语法中的各个地方。没有嵌入动作,我们可以在不同的应用中复用相同的语法,甚至不需要重新编译生成的语法分析器。如果没有嵌入动作,ANTLR还可以用相同的语法生成不同编程语言的语法分析器。集成语法缺陷修复或更新也更容易,因为我们不必担心嵌入动作导致的合并冲突。

接下来,我们将探讨从带有嵌入动作的语法到完全解耦的语法和应用的演化。以下含有用«...»描述的嵌入动作的属性文件语法读取属性文件,每行一个属性赋值。像«start file»这样的动作只是Java代码适当的替代。

1
2
3
4
5
grammar PropertyFile;
file : {«start file»} prop+ {«finish file»} ;
prop : ID '=' STRING '\n' {«process property»} ;
ID   : [a-z]+ ;
STRING : '"' .*? '"' ;

这样的紧密耦合把语法束缚到一个特定的应用上。一个更好的方法是创建由ANTLR生成的语法分析器PropertyFileParser的子类,然后把嵌入动作转换成方法。重构仅留下不重要的方法调用在语法中触发新创建方法的动作,然后,通过子类化语法分析器,我们可以实现任意数量的不同应用而无需修改语法。这样的重构看起来像:

1
2
3
4
5
6
7
8
9
10
11
grammar PropertyFile;
@members {
    void startFile() { }    // blank implementations
    void finishFile() { }
    void defineProperty(Token name, Token value) { }
}

file : {startFile();} prop+ {finishFile();} ;
prop : ID '=' STRING '\n' {defineProperty($ID, $STRING)} ;
ID   : [a-z]+ ;
STRING : '"' .*? '"' ;

解耦可以让语法被不同的应用复用,但语法因为方法调用的关系仍然被绑定在Java上。

为演示已重构语法的复用性,让我们构建两个不同的应用。首先从遇到属性后只是打印它们的那个开始。这个过程只是去扩展由ANTLR生成的语法分析器类和覆盖一个或多个由语法触发的方法。

1
2
3
4
5
class PropertyFilePrinter extends PropertyFileParser {
    void defineProperty(Token name, Token value) {
        System.out.println(name.getText() + "=" + value.getText());
    }
}

由于ANTLR生成的PropertyFileParser类中的默认实现,我们不需要覆盖startFile()或finishFile()。

为了启动这个应用,我们需要创建一个特定的PropertyFilePrinter语法分析器子类的实例,而不是常规的PropertyFileParser的。

1
2
3
4
PropertyFileLexer lexer = new PropertyFileLexer(input);
CommonTokenStream tokens = new CommonTokenStream(lexer);
PropertyFilePrinter parser = new PropertyFilePrinter(tokens);
parser.file();    // launch our special version of the parser

作为第二个应用,我们把属性加载到一个映射中而不是打印它们。我们需要做的就是创建一个新的子类并把不同的功能放到defineProperty()中。

1
2
3
4
5
6
class PropertyFileLoader extends PropertyFileParser {
    Map<String,String> props = new OrderedHashMap<String, String>();
    void defineProperty(Token name, Token value) {
        props.put(name.getText(), value.getText());
    }
}

在语法分析器执行完毕后,字段props将会包含名-值对。

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

  • component 构件

因为词法规则可以使用递归,所以词法解析器在技术上和语法解析器一样强大。那意味着我们甚至可以在词法分析器中匹配语法结构。或者,在另一个极端,我们可以把字符当作记号,使用语法分析器去把语法结构应用到字符流(这种被称为无扫描语法分析器)。这导致什么在词法分析器中匹配和什么在语法分析器中匹配的界线在哪里并不是很明显。幸运的是,有几条经验法则可以让我们做出判断:

  • 在词法分析器中匹配和丢弃任何语法分析器根本不需要见到的东西。例如,在词法分析器中识别和扔掉像注释和空格这些东西。否则,语法分析器必须经常查看是否有注释或空格在记号间。
  • 在词法分析器中匹配诸如标志符、关键字、字符串和数字这样的常用记号。语法分析器比词法分析器有更多的开销,因此,我们不必让语法分析器承受把数字放在一起识别成整数的负担。
  • 把那些语法分析器不需要去辨别的词法结构合并成一个单独的记号类型。例如,如果我们的应用把整数和浮点数当作同一事物对待,然后把它们合并成记号类型NUMBER,那么就没必要向语法分析器发送单独的记号类型。
  • 合并能被语法分析器视为一个单独实体的任何东西。例如,如果语法分析器不在乎XML标签里的内容,词法分析器可以把尖括号中的任何东西合并成一个单独的被称为TAG的记号类型。
  • 如果语法分析器需要先拆开一小块文本后才能去处理它,那么词法分析器应该传递独立的构件作为记号给语法分析器。例如,如果语法分析器需要处理一个IP地址的元素,词法分析器应该发送IP构件(整数和点)的独立的记号。

想象下现在需要处理Web服务器上的日志文件,每一行表示一条记录。让我们假设每条记录都有一个请求IP地址、HTTP协议命令和结果代码。这里是一个日志条目的示例:

1
192.168.209.85 "GET /download/foo.html HTTP/1.0" 200

如果想要统计文件中有多少行,那么我们可以忽略掉任何东西除了换行字符的序列:

1
2
3
file  : NL+ ;               // 匹配换行符(NL)序列的语法规则
STUFF : ~'\n'+ -> skip ;    // 匹配和丢弃除'\n'外的任何东西
NL    : '\n' ;              // 返回NL给语法分析器或调用代码

词法分析器不必识别太多的结构,语法分析器会匹配换行记号的序列。

接下来,我们需要从日志文件中收集一系列的IP地址。这意味着我们需要一条规则去识别IP地址的词法结构。并且我们也可以提供其它记录元素的词法规则:

1
2
3
4
5
IP    : INT '.' INT '.' INT '.' INT ;    // 192.168.209.85
INT   : [0-9]+ ;                         // 匹配IP八位组或者HTTP结果代码
STRING: '"' .*? '"' ;                    // 匹配HTTP协议命令
NL    : '\n' ;                           // 匹配日志文件记录终结符
WS    : ' ' -> skip ;                    // 忽略空格

拥有一套完整的记号后,我们可以让语法规则匹配日志文件中的记录:

1
2
file : row+ ;                // 匹配日志文件中行的语法规则
row  : IP STRING INT NL ;    // 匹配日志文件记录

更进一步,我们需要把文本IP地址转换成32位的数字。使用便利的库函数split('.'),我们可以把IP地址切割成字符串传递给语法分析器让它去处理。但是,更好的做法是让词法分析器匹配IP地址的词法结构,然后把匹配出的构件作为记号传递给语法分析器。

1
2
3
4
5
6
7
file  : row+ ;                           // 匹配日志文件中行的语法规则
row   : ip STRING INT NL ;               // 匹配日志文件记录
ip    : INT '.' INT '.' INT '.' INT ;    // 在语法分析器中匹配IP地址
INT   : [0-9]+ ;                         // 匹配IP八位组或者HTTP结果代码
STRING: '"' .*? '"' ;                    // 匹配HTTP协议命令
NL    : '\n' ;                           // 匹配日志文件记录终结符
WS    : ' ' -> skip ;                    // 忽略空格

把词法规则IP切换成语法规则ip显示了我们可以多么轻易地移动这条分界线。

如果要求处理HTTP协议命令字符串的内容,我们可以遵循相同的思考过程。如果不需要检查字符串的部分,那么词法分析器可以把整个字符串作为一个单独的记号传递给语法分析器。如果我们需要抽出各种不同的部分,最好就是让词法分析器去识别那些部分后再把这些匹配出的构件传递给语法分析器。