Skip to content

第31讲:OAL语言,原来定义创造一门新语言如此轻松(上)

在前文介绍 Metrics 实现以及对应的 DIspatcher 实现的时候,会发现有一部分实现类位于 generated-analysis 模块的 target/generated-sources 目录中,这些类的开头都会有下图所示的注释:

通过这行注释我们知道,这些类是通过 OAL(Observability Analysis Language)生成的。OAL 是 SkyWalking 后端自定义的一种脚本,在 SkyWalking 编译阶段会通过 Antlr4 解析 OAL 脚本,并与 freemarker 配合使用,生成上述 Metrics 实现类以及对应的 Dispatcher 实现类。生成的结果如下图所示:

在生成上述代码的过程主要涉及的模块有:generate-tool、generate-tool-grammar、generated-analysis 三个模块。本课时将详细介绍生成上述代码过程中使用到的基础知识以及这三个模块的核心实现。

Antlr4 基础入门

Antlr4(Another Tool for Language Recognition)是一款强大的语法生成器工具,它可以根据输入的字节流自动生成语法树,作为一款开源语法分析器,可用于读取、处理、执行和翻译结构化的文本或二进制文件。基本上是当前 Java 语言中使用最为广泛的语法生成器工具,下面简单列举了 Antlr4 出现的场景:

  • 在很多大数据系统中都使用 Antlr4 ,例如,Hadoop 生态系统中的 Hive、Spark 数据仓库和分析系统所使用的语言,都用到了 Antlr4;

  • Hibernate 对象-关系映射框架(ORM)使用 Antlr 来处理 HQL 语言;

  • Oracle 公司在 SQL 开发者 IDE 和迁移工具中使用了 Antlr4;

  • NetBeans 使用 Antlr4 来解析 C++。

本课时将通过 Antlr4 实现一个简单的计算器功能,在开始之前,我们需要先了解 Antlr4 的一些基础知识。

词法分析器(Lexer)

我们的编程语言通常由一系列关键字以及一套严格定义的语法结构组成。编译的目的是将程序员日常使用的高级编程语言翻译成物理机或是虚拟机可以执行的二进制指令。词法分析器的工作是读取、解析程序员写出来的代码文件,这些文件基本都是文本文件。词法分析器通过读取代码文件中的字节流,就可以将其翻译成一个一个连续的、编程语言预先定义好的 Token 。一个 Token 可以是关键字、标识符、符号(symbols)和操作符等等,下面的语法分析器将通过这些 Token 构造抽象语法树(Abstract Syntax Tree,AST)。

语法分析器(Parser)

在分析读取到的字符流时,词法分析器(Lexer)并不关心所生成的单个 Token 的语法意义及其与上下文之间的关系。语法分析器(Parser)将收到的所有 Token 组织起来,并转换成为目标语言语法定义所允许的序列。

无论是 Lexer 还是 Parser 都是一种识别器,Lexer 是字符序列识别器,而 Parser 是 Token 序列识别器。它们在本质上是类似的东西,而只是在分工上有所不同而已。例如以一条赋值语句

sp = 100;

为例来看它在词法分析器(Lexer)以及语法分析器(Parser)中的处理流程,如下图所示:

Antlr4 允许我们定义识别字符流的词法规则以及用于解释 Token 流的语法分析规则。Antlr4 将会根据用户提供的语法(grammer)文件自动生成相应的词法/语法分析器。用户可以利用它们将输入的文本进行编译,并转换成其他形式,比如前面提到的抽象的语法树(AST)。

下面开始进入我们的"计算器"示例,为了告诉 Antlr4 计算器的词法分析规则和语法分析规则,我们需要定义语法(grammar)文件(".g4"后缀文件),这就使用到了 Antlr4 的元语言。下面结合计算器示例的 grammar 文件 ------ Calculator.g4,介绍一下 Antlr4 元语言的基本内容:

java
grammar Calculator; 

expr : '(' expr ')'
     | expr ('*'|'/') expr
     | expr ('+'|'-') expr
     | FLOAT
     ;
line : expr EOF ;
WS : [ \t\n\r]+ -> skip;
FLOAT : DIGIT+ '.' DIGIT* EXPONET?
      | '.' DIGIT+ EXPONET?
      | DIGIT+ EXPONET?
      ;

fragment DIGIT : '0'..'9' ;
fragment EXPONENT : ('e'|'E') ('+'|'-')? DIGIT+ ;

第 1 行:定义了 grammar 的名字,这个名字必须要与文件名相同。

第 3~8 行:expr 和 line 就是我们定义的"Calculator"语言的语法规则。Antlr4 约定词法规则名字以大写字母开头,例如这里的 FLOAT。语法规则名字以小写字母开头,例如这里的 expr、line。 这里的规则定义都是以分号(;)结束。Antlr4 的语法是基于 C 的, 也有很多像正则表达式的地方。

expr 由 4 个备选分支,不同的备选分支由"|"分割,expr 规则的含义分别是:

  • 第一个备选分支表示 expr 语句可以由另一个 expr 加上左右两个括号构成。

  • 第二个备选分支表示 expr 语句可以是 expr * expr 或是 expr / expr 格式。

  • 第三个备选分支表示 xpr 语句可以是 expr + expr 或是 expr - expr 格式。

  • 第四个备选分支表示 expr 语句可以是 FLOAT。

多个备选分支的前后顺序还决定了歧义的问题,例如这里的 expr 规则在处理 1 + 2 * 3 这个表达式的时候,因为 expr * expr 的分支在前,生产的语法树如下图所示:

这也就满足了四则运算中,括号优先级高于乘除,乘除优先级高于加减的要求。

line 规则比较简单,可以匹配一个 expr 语句与结束符。

第 9~13 行是词法定义。其中,第 9 行的 WS 定义了空白字符,后面的 skip 是一个特殊的标记,表示空白字符会被忽略。

第 10~13 行的 FLOAT 是定义的浮点数,这里使用的"+""*"等符号是正则表达式中的含义,而不是四则运算中的加号和乘号。

第 15~16 行 fragment 定义了两个词法定义中使用到的公共部分,DIGIT 表示的是整数,EXPONENT 表示的是科学计数法。fragment 类似于一种内联函数(或别名),只是为了简化词法规则的定义以及可读性,并不会被识别为 Token。

了解了 Antlr4 基本的语法以及 .g4 文件的编写方式之后,我们将其添加到 maven 项目的 src/main/antlr4 目录下,如下图所示:

接下来要在 pom.xml 文件中添加 Antlr4 依赖 jar 包以及相应版本的 maven 插件,如下所示,这里与 SkyWalking 6.2 版本使用 antlr4.jar 版本相同:

js
<dependencies>
    <dependency>
        <groupId>org.antlr</groupId>
        <artifactId>antlr4</artifactId>
        <version>4.7.1</version>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.antlr</groupId>
            <artifactId>antlr4-maven-plugin</artifactId>
            <version>4.7.1</version <!-- 与jar包版本相同-->
            <executions>
                <execution>
                    <id>antlr</id>
                    <goals>
                        <goal>antlr4</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

执行 mvn package 命令即可在项目的 target/generated-sources 目录下看到 Antlr4 为我们生成 Lexer、Parser 类。为了让 IDEA 能够发现这些生成的 Java 类,我们需要将 target/generated-sources/antlr4 目录标记为 Generated Source Root,如下图所示:

接下来要做的就是使用这些生成的 Lexer 类以及 Parser 类实现解析输入的字节流。Antlr4 提供了 Visitor 和 Listener 两种模式,通过这两种模式可以很轻松地把 Parser 的结果做各种处理。Antlr4 默认会生成 Listener 模式的相关代码(如果需要生成 Visitor 模式的相关代码,需要调整 maven 插件的配置),这里与 SkyWalking 保持一致,使用 Listener 模式。

相信你已经对 Listener 模式都比较熟悉了,简单来说就是在代码中预先定义一系列的事件,然后开发人员可以编写这些事件的 Listener。在程序运行的过程中,如果某些事件被触发了,则程序会根据事件类型调用相应的 Listener 进行处理。

Antlr4 中的定义的 ParseTreeListener 接口就是我们要实现的 Listener 接口,在计算机示例中生成的代码中,CalculatorListener 接口继承了 ParseTreeListener 接口并对其进行了扩展,CalculatorBaseListener 是 CalculatorListener 接口的空实现,具体方法如下:

我们这里提供一个简单的 CalculatorListener 实现类 ------ PrintListener,它继承了 CalculatorBaseListener 并覆盖它全部的方法,所有方法实现只会输出当前涉及的节点信息,例如:

java
public class PrintListener extends com.xxx.CalculatorBaseListener {
    @Override
    public void enterLine(com.xxx.CalculatorParser.LineContext ctx) {
        System.out.println("enterLine:" + ctx.getText());
    }
    ... ... // 省略其他方法的实现
}

接下来,通过 CalculatorBaseListener 的输出内容来帮助我们明确各个方法的回调时机,同时可以了解 Antlr4 API 的基本使用方式,main() 方法的相关实现如下:

java
public class Main {
    public static void main(String[] args) throws Exception {
        // 这里处理的是"1+2"这一行计算器语言,读取得到字节流
        ANTLRInputStream input = new ANTLRInputStream("1+2");
        // 创建CalculatorLexer,词法分析器(Lexer)识别字节流得到 Token流
        CalculatorLexer lexer = new CalculatorLexer(input);
        CommonTokenStream tokens = new CommonTokenStream(lexer);
        // 创建CalculatorParser,语法分析器(Parser)识别 Token流得到 AST
        CalculatorParser parser = new CalculatorParser(tokens);
        ParseTree tree = parser.line();
        // 遍历 AST中各个节点回调 PrintListener中相应的方法
        ParseTreeWalker walker = new ParseTreeWalker();
        walker.walk(new PrintListener(), tree);
        // 将整个 AST转换成字符串输出
        System.out.println(tree.toStringTree(parser));
    }
}

最后来看具体的输出内容:

dart
enterEveryRule:1+2<EOF>
enterLine:1+2<EOF>   # 开始遍历 line: 1+2
enterEveryRule:1+2
enterExpr:1+2   # 开始遍历 expr: 1+2
enterEveryRule:1
enterExpr:1  # 开始遍历 expr: 1
visitTerminal:1
exitExpr:1   # 结束遍历 expr: 1
exitEveryRule:1
visitTerminal:+
enterEveryRule:2
enterExpr:2   # 开始遍历 expr: 2
visitTerminal:2
exitExpr:2    # 结束遍历 expr: 2
exitEveryRule:2
exitExpr:1+2  # 结束遍历 expr: 1+2
exitEveryRule:1+2 
visitTerminal:<EOF>
exitEveryRule:1+2<EOF>
(line (expr (expr 1) + (expr 2)) <EOF>)

下图是 IDEA 中 Antlr4 插件展示的 AST,如果你感兴趣可以去尝试一下:

在后面分析 SkyWalking OAL 语言的时候,还会再次看到 Antlr4 Listener 模式的实践,这里就不再继续深入了。

FreeMarker 基础入门

SkyWalking OAL 语言在生成 Java 代码时除了使用到 Antlr4,还会使用到 FreeMarker 模板引擎,即一种基于模板要改变的数据用来生成文本的通用工具。

FreeMarker 使用专门的模板语言 ------ FreeMarker Template Language 来编写模板文件(后缀为".ftl")。在模板文件中,我们只需要专注于如何展现数据,而在模板之外的逻辑则需要专注于要展示哪些数据,如下图所示,这种模式也被称为 MVC 模式。

下面将通过一个简单的宠物店示例帮助你快速入门 FreeMarker 的基础使用,首先我们定义一个 Animal 抽象类以及多个实现类,如下图所示:

接下来,准备 test.ftl 模板文件。FreeMarker Template Language 的语法与 JSP 中的 EL 表达式非常类似,具体实现如下:

js
<html>
<body>
<h1>
    <!-- 使用 ${...}展示一个变量 -->
    Welcome, ${user}
    <!-- 使用 <#if><#else>标签实现条件分支 -->
    <#if user == "freemarker-user"> 
    our leader<#else>
    out user</#if>!
</h1>
<p>We have these animals:
<table border=1>
    <tr>
        <td>Animal Name</td>
        <td>Price</td>
        <td>Size</td>
    </tr>
  <!-- 使用 <#list ... as>标签实现对 List集合的遍历 -->
  <#list animals as animal>
  <tr>
      <td>${animal.name}</td>
      <td>${animal.price}</td>
      <td>${animal.size}</td>
  </tr>
  </#list>
</table>
</body>
</html>

最后写一个 main() 方法,其中首先会初始化 FreeMarker 、加载模板文件、创建 Animal 集合,最后将数据写入到文件中:

java
public static void main(String[] args) throws Exception {
    //1.初始化并配置Configuration对象
    Configuration configuration =
               new Configuration(Configuration.getVersion());
    //2.设置模板文件所在的目录
    configuration.setClassForTemplateLoading(Main.class, "/template");
    //3.设置字符集
    configuration.setDefaultEncoding("utf-8");
    //4.加载模板文件
    Template template = configuration.getTemplate("test.ftl");
    //5.创建数据模型
    Map<String, Object> result = createData();
    //6.创建Writer对象
    FileWriter writer = new FileWriter(
            new File("/Users/xxx/Documents/log/test.html"));
    //7.输出数据模型到文件中
    template.process(result, writer);
    //8.关闭Writer对象
    writer.close();
}

最后生产的 test.html 文件如下图所示:

到此为止,SkyWalking 生成代码涉及的基础知识就介绍完了,下一课时将开始介绍 generate-tool、generate-tool-grammar、generated-analysis 三个模块的具体实现。

深入 OAL

在 generate-tool-grammar 模块中,OAL(Observability Analysis Language)语法的定义分为了 OALLexer.g4 和 OALParser.g4 两个文件,其中 OALLexer.g4 定义了 OAL 的词法规则,OALParser.g4 定义了 OAL 的语法规则。

在 generated-analysis 模块中,official_analysis.oal 是 SkyWalking 默认提供的 OAL 文件,其中就是用 generate-tool-grammar 模块中定义的 OAL 语法编写而成的。在 official_analysis.oal 中我们可以看到很多熟悉的 Metrics 指标,例如:

js
instance_jvm_old_gc_time = 
   from(ServiceInstanceJVMGC.time)
      .filter(phrase == GCPhrase.OLD).longAvg();
service_cpm = from(Service.*).cpm();
service_p99 = from(Service.latency).p99(10);
service_relation_server_cpm = 
    from(ServiceRelation.*)
       .filter(detectPoint == DetectPoint.SERVER).cpm();

这四条语句是比较典型的 OAL 语句,下面将以 instance_jvm_old_gc_time 这条语句为线索简单介绍 OAL 语法的定义, official_analysis.oal 文件中其他 OAL 语句基本雷同,不再赘述。

这里先用 IDEA Antlr4 插件预览一下该语句解析出来的抽象语法树(AST),如下图所示:

我们从 root 规则开始一步步分析,instance_jvm_old_gc_time 这条语句匹配了哪些语法规则,如下图所示:

整个 instance_jvm_old_gc_time 语句匹配了 aggregationStatement 规则,其中 variable 规则定义了 OAL 语言中变量名称的结构,等号左边的 instance_jvm_old_gc_time 变量名称会匹配到该规则,而整个等号右边的内容会匹配到 metricStatement 规则(除了结尾的分号)。

metricStatement 规则如下图所示,开头的 from 关键字、source、sourceAttribute 三部分比较简单,你可以直接在 OALParser.g4 文件中找到相应的定义。后面的 filterStatement 表达式开头是 filter 关键字,紧接着的括号中是一个 ==、>、<、>= 或 <= 表达式。最后的聚合函数则是我们在其他编程语言中常见的函数调用格式(可以包含参数),例如这里的 longAvg() 以及前文看到的 p99(10)。

看完对 Antlr4 示例以及 metricStatement 规则的分析,相信你已经可以独立分析 OAL 语言剩余的语法规则,filterStatement 以及 aggregateFunction 两个语法规则就不再展开分析了。

SkyWalking 源码分析指北第 27 课时------OAL语言(上),到此结束。