编译技术在前端的实践(二)—— Antlr 及其应用

avatar
FE @字节跳动

序言

在上一讲的分享《编译技术在前端的实践(一)——编译原理基础》中,我们介绍了编译原理的最基本概念,并结合表达式计算器的例子介绍了手工编写递归向下解析器的方法。

但在前端的生产实践中,很少需要费时费力地去实现手工解析器。通常情况下,在前端,最常见的两个需要使用到编译技术的场景包括:

  1. 前端工程化或业务需求中,需要对编程语言的源码进行定制化的分析。
  2. 业务需求中,需要对字符串输入源进行指定语法规则的语义解析。

第 1 种场景很好理解。比如 arco-design 的组件的文档,就是从 tsx 源码文件里,直接分析提取的 interface 的结构和注释并自动生成的 API 文档。这一类的场景,由于需要分析的是现成的编程语言,基本上都能找到语言对应的解析工具。比如 tsx 就用 tsc 工具就能分析并获取 tsx 源码对应的 AST 结构,而 js 可以用 acorn ,等等。有了 AST 结构后,业务代码就能自行遍历标准的树型结构来进行需求驱动的处理。

第2种场景,举个例子来讲,比如需要对一堆来源不同的单行字符串日志进行分析并转成 json 结构。所谓单行字符串日志,可以用 nginx 日志来理解,我们需要从单行字符串中提取属性信息(比如时间、请求URL等)。但此处的日志可能源自不同系统,每一行日志的格式并不统一。这种 case 下,使用正则表达式来分析会显得有些力不从心,完全去手写分析逻辑又太累且很难应对需求变更。

再比如更好理解的例子是,某大数据分析的产品,自主设计了一个类似 SQL 但比 SQL 更简单亲民的查询语言,在前端需要实现该查询语言编辑器的语法高亮和智能提示的功能。

一般而言,对于第2种场景,首先考虑的是使用“[词法语法分析器]自动生成工具”。也就是,我们用一个工具,来自动生成解析器。经典的自动生成工具包括历史悠久的 lex, yyac 以及后续的衍生工具,但这些工具都是传统的 c/c++ 语言领域的工具,前端领域基本无法使用,有兴趣的同学可以在 The LEX & YACC Page 上查看详情。而前端可以使用的,正是本次分享的主角,Antlr 工具。

简介

Twitter搜索使用 Antlr 进行语法分析,每天处理超过20亿次查询;Hadoop生态系统中的 Hive 、Pig、 数据仓库 和分析系统所使用的语言都用到了Antlr;Lex Machina将 Antlr 用于分析法律文本;Oracle公司在 SQL 开发者 IDE 和迁移工具中使用了 Antlr;Hibernate对象-关系映射框架( ORM )使用 Antlr 来处理HQL语言。 ”——ANTLR 4权威指南 (豆瓣)

ANTLR(全名:ANother Tool for Language Recognition)是用 Java 语言编写的功能强大的语法分析器自动生成工具,由旧金山大学的 Terence Parr 博士等人于 1989 年推出第一代,迭代到现在是第四代,因此一般称之为 Antlr4。该工具本身是 java 语言的工具,但产出的语法分析器可以是包括 js 和 ts 语言在内的主流编程语言,因此基本上可以认为 Antlr4 是当前使用最广泛的一款语法分析器自动生成工具。

Antlr4 接收 g4 文法作为输入,输出为符合该文法约束的对应目标语言的解析器源代码。更准确地讲,是输出解析器的框架代码,该框架代码在运行时可以自动解析输入文本并生成我们在上一讲提到的抽象语法树(AST),但业务项目仍然需要在该框架代码上补充完善业务需求的逻辑。

在序言中我们有提到 ts 等编程语言一般都有现成的解析器可供使用。但你仍然可以使用 Antlr4 来构建编程语言的编译器,因为 Antlr4 本身足够强大足以支撑哪怕是高级编程语言的编译器的编写。Antlr4 官方提供了主流语言的 G4 文法规则可供我们学习和使用,比如 javascript 的 G4 文法,你可以试着和我们在上一讲提到EMAC官方文法规范的进行对比学习。注意理解后者是官方规范,前者可以理解成对官方规范的 G4 语言实现(是的,G4 本身也算是一个程序语言,用来描述文法的语言)。

接下来,我们结合还是上一讲的表达式计算器的例子,来进一步学习 Antlr 的概念和使用。首先我们需要搭建好 Antlr4 的环境。

安装

Java 环境

Antlr4 本身是一个 Java 工具,因此首先要安装 Java 环境。对于 mac 系统而言,直接在 java.com/en/download… 下载安装即可。安装好后,在 Terminal 里面,执行java --version 测试是否安装成功。

IDE 插件

为了更好地编辑 "g4" 文件,可以安装 IDE 插件来支持包括代码高亮和智能提示。以 vscode 为例,在市场中搜索antlr 关键字,找到插件后安装即可。

image.png

该插件除了提供 g4 文法规格的高亮、智能提示、自动格式化的基础功能外,还提供了对文法规则可视化以及文法规则调试等高级功能。

Webstorm 下同样有功能类似的插件 ANTLR v4 - IntelliJ IDEs Plugin,有兴趣的同学可自主探索和使用。

npm 安装

安装好 Java 环境和 IDE 插件后,就需要在需要使用到 antlr 的项目中添加相关依赖。 这里我们以 typescript 语言项目为例,需要依次安装:

npm i antlr4ts

npm i -D antlr4ts-cli

其中,antlr4ts 是 Antlr 的 typescript runtime library,主要包括面向对象设计下的各种基类和工具函数等。这个库本身也全部是 ts/js 代码,类似 react, lodash 等,最终会被打包到业务产品的构建产物中。

antlr4ts-cli 是 cli 工具,或者说是 g4 语言本身的编译器,可以将 g4 文法规则源代码,编译成文法解析器源代码。这个工具通过 js 代码简单调用了 antlr.jar 工具,因此使用 antlr4ts-cli 依赖系统中已经安装好了 java 环境。

为了方便大家体验,在安装好 java 环境和 ide 插件后,可以直接从 gitlab 下载本文接下来的示例项目。

git clone https://github.com/cloudfun-team/learn-antlr4

cd learn-antlr4

npm install

## 对于迫不及待的同学,可以继续运行下面的命令体验下。

npm run tsc # 编译 ts 源码,会生成到 dist 目录

node dist/calculator-visitor # 启动 demo,输入比如 3*(1+1) 然后回车

使用

g4 文法

g4 文法是 antlr 支持的用于定义语法规则的文法,可以简单理解成我们在上一讲中提到的“上下文无关文法”。g4 文法支持用自然易懂的规则去表达,不需要文法的设计者去考虑上一讲中提到的“左递归”的规避或“LL(k)”的设计。比如我们还是以上一讲中提到的表达式计算器,其 g4 文法源代码如下:

image.png

(以上使用了截图而不是飞书代码块是为了更好地通过代码高亮展示,同学们可以直接去前面提到的 learn-antlr4 这个代码仓库里去查看该文件)

上图的代码中,我们标记了 1、2、3、4,对应了 g4 文法的 4 个基础部分。分别是:

  1. 词法定义(Lexer Tokens)

上一讲中,我们知道编译器需要首先进行词法分析生成 token 流。而 g4 文法里也对应需要先定义词法规则。这个规则跟正则表达式的定义很像,此处不再赘述。对于我们的表达式计算器而言,词法主要就是关注数字(NUMBER)和加减乘除的操作,而空白符是不关心的直接忽略。

  1. 文法规则(Grammar Rules)

第 11 到18 行定义了完整的文法规则。start 定义了文法的入口;expression 定义了具体的计算表达式,其中使用了上一讲中提到的“递归”概念。

表达式(expression)可以是一个直接的数字(13行),或者两个数字加减乘除(15-17行);两个表达式又可以通过加减乘除来组合成表达式(15-17行),或者通过括号来包裹表达式组合(14行)。整体上通过这种递归的方式,严谨又完备地定义和约束了计算器表达式的语法规则。

  1. 规则名称(Rule Name)

在简介章节已经简单提到,antlr 会将 g4 定义的规则生成一个解析器的框架代码,这个框架代码能自动解析并生成 AST 数据结构,但也仅此而已。业务层拿到 AST 后,仍然需要遍历分析 AST 来处理业务逻辑和实现业务需求。

但即便是 AST,当一个文法规则的规模逐渐庞大的时候(可以想象下用 g4 直接来定义 javascript 语言),其 AST 也是晦涩复杂的。因此,Antrl 生成的框架代码,提供了两种更舒服的方式去处理 AST 和实现业务逻辑,而规则名称,则是业务代码处理 AST 的入口名称。我们会在后文具体介绍。

需要稍微注意的是规则名称是在文法行尾使用 # 打头定义,需要注意不是 bash 语言下的注释。

  1. 上下文标记(Context Label)

可以为文法规则分配上下文标记,该标记可以用于在业务代码处理 AST 时,更明确地区分当前上下文下的规则。

以17行为例,业务代码在处理AdditionOrSubtraction这行规则时,显然要先处理+-号两侧的的表达式(expression),拿到左右表达式的值,再进行加法或减法。由于左右的规则都是递归的expression这个名称,因此我们为左右的expression规则分别通过 left=right= 的方式,为左边的 expression 分配 left 标记,右边的 expression 分配 right 标记。如此一来,业务代码在处理AdditionOrSubtraction 这行规则时,就能在当前上下文中明确识别出左右两个 expression。我们在后文会进一步具体介绍。

生成解析器

Antlr4 提供了两种不同的解析器生成模式,Visitor 和 Listener 模式。我们先不讨论两者的区别,而是直接以最常见的 Visitor 模式来看下生成的代码。

Visitor模式

在刚才 clone 下来的示例项目下,执行 npx antlr4ts -visitor calculator-visitor/antlr/calc.g4,会在 calc.g4 文件同目录下生成以下文件:

image.png

其中的 ts 文件,就是根据 g4 自动生成的解析器框架代码。从文件名可以大概看出各模块的职责,calcLexer.ts 是词法分析器,作用是生成tokens流;calcParser.ts 是语法分析器,用于生成 AST。而 calcVisitor.ts 是用于遍历 AST 的访问器的interface,业务代码通过实现该 interface 来实现具体的业务需求逻辑。

我们这里的业务需求是实现对表达式的计算。在index.ts文件中,我们有如下代码:

image.png 从代码可以看出,业务代码需要实现的逻辑是 g4 文法中对应的规则的处理。在 calcVisitor.ts 这个接口中,定义了一系列的 visit 为前缀的函数,这些函数正是对应了 g4 文法中的每一个规则名称(Rule Name) 。比如 visitNumber 对应 calc.g4 中的第 13 行定义的 #Number 规则,visitPower 对应 15 行的 #Power 规则。

这些访问函数,都接受一个上下文参数。对不同的规则,其上下文参数会有差异,最主要的差异就是会有不同的上下文标记(Context Label) 可以使用。比如 #Parentheses 规则(calc.g4中14行),我们定义了 expression 规则在递归时的标记为 inner,在业务代码中,可以通过 ctx._inner 来拿到递归的规则(index.ts 中第 29 行)。拿到 ctx._inner 后就可以使用 super.visit 这个遍历函数来递归深度遍历表达式。

super.visit 这个函数,正是遍历 AST 的核心函数。这个函数内部,会根据 visit 的具体的规则,进入具体的 visitXXX 函数,在具体的 visitXXX 函数中,又由业务代码控制递归调用 super.visit 函数来实现深度优先遍历。同时,具体的 visitXXX 函数可以根据业务需求的场景,返回数据,返回的数据也会通过super.visit 层层向上返回。在我们这个例子里,visit 函数返回的是表达式的计算值,对 25 行的 visitNumber 来说它访问的是叶子节点也就是具体的数字,直接使用 Number 函数将字符串转数字返回即可。而比如 34 行的 visitAdditionOrSubtraction 函数,我们先遍历访问树的左节点ctx._left,得到了左边表达式的值。然后访问右节点,得到右边表达式。最后根据ctx._operator具体是加号还是减号,来将左值和右值进行相加或相减,最后将计算的结果向上返回。

实现好遍历处理逻辑CaculatorVisitor后,就可以拿这个解析器来具体处理实际的业务输入。

image.png

从上面代码可以看出,Antlr 框架生成的解析器,也正如我们上一讲提到的编译器的通用的流程,执行【源文本】->【词法分析token流】->【语法分析 AST】->【遍历 AST 执行业务逻辑】这样一个标准化的编译过程。

Listener 模式

上文提到的 Visitor 模式下,业务逻辑CaculatorVisitor通过super.visit这个访问函数,完全控制对 AST 的遍历。如果在 visitXXX 函数中不书写对子节点的 visit,就不会访问子节点。

与之对应的是 Antlr 提供的 Listener 模式。这种模式下,对 AST 的深度优先遍历是自动的,不需要也不受业务代码控制。业务代码可以监听这个遍历过程在每一个 AST 节点上的进入和退出事件。

这种模式由于是“旁听”的方式,并不能控制遍历过程在节点的返回值,或者说监听器方法是没有返回值的。因此需要一种额外的数据结构(通常比如是Map或者Stack)来存储中间结果。

learn-antlr4 这个仓库中也提供了 Listener 模式的表达式计算器的 demo,有兴趣的同学可以对比两种模式的 demo 学习和感受两个模式的差异。在生产上,一般还是以 Vistor 模式的使用居多。

业务示例

为了进一步说明 Antlr 在前端可以有实际使用场景,而不只是 demo,此处介绍下如下项目:

jsx-loader

2018 年时搞的一个小工具,当时 tsx 还没有现在这么流行,项目还是 jsx 为主流。esbuild 还没出现,babel 慢的一批。索性就用 antlr4 写了个 webpack 的 jsx-loader 练习和自用。

github.com/YuhangGe/js…

jinge-material

一个麻雀虽小但也算有模有样的 UI 组件库,其底层使用的是笔者兴趣和学习驱动研发的一个 mvvm 框架,该框架使用了 antlr 对模板进行分析。举一个例子,模板中引用组件,其中之一的方式是在注释中书写 import 语句,例如:

<!--
import { ComponentA } from './a';
import { ComponentB } from '~root/component';
-->

<div><ComponentA /><ComponentB /></div>

这个地方就涉及到扩展了标准的 html/xml 语法,因此采用了 Antlr 搭配 acorn 来处理自定义规则的语法。

Jinge Material

github.com/jinge-desig…

HQL:HanSight Query Language

将 ace-editor 和 Antlr 进行配合,可以把类SQL的大数据查询分析语言的编辑器的代码高亮和智能提示。在实际的前端业务产品中,Antlr 也是完全可以发挥光芒的。