通过 Goyacc 构建 Elasticsearch Querystring 解析器 - 领域特定语言语法分析实践

动手点关注 干货不迷路 

背景

领域特定语言(DSL),如 SQL、Elasticsearch Querystring 等,往往是为专门的目的设计的。在特定的任务中,DSL 通过在表达能力上做的妥协换取在某一领域内的高效。

在飞书套件日志系统的私有化研发过程中,为了符合研发同学查询日志的习惯,尝试使用 Elasticquery Querystring(下简称为 Querystring)作为过滤器的查询条件语句,由此需要可用的 Golang Querystring 解析器。由于目前开源界无法找到完善的实现,尝试使用 Goyacc 自行构建。

Yacc 是一个被普遍采用的编译器代码生成器,生成出的代码主要用于语法分析阶段,常常与作为词法分析器的 lex 匹配使用,使用 LALR 算法,将源代码构建为抽象语法树(AST)。Goyacc 是 yacc 的 Golang 版本。

本文尝试实现的 Querystring 解析器本质上是词法分析器和语法分析器的组合。语法分析器由 Goyacc 生成;为了方便实现符合 Querystring 习惯的反转义,词法分析器是自行实现的。Yacc 和它在各个语言上的实现,中文互联网上较少有具体的介绍。本文会尽量详细的描述 Goyacc 实践中可能需要注意的点。所述的 Querystring 解析器代码可在 https://github.com/bytedance/go-querystring-parser 此处查看。

框架

一套完整的应用 Goyacc 的 DSL 解析器包含以下部分:

  • 语法分析器(parser)

  • 词法分析器(lexer)

    • 较为简单的做法,是采用Scanner来进行构建;当存在特殊需求时,一般自行构建。

  • AST 节点的定义

  • 包装实体与工具函数

大致流程为,语法分析器调用词法分析器将源代码拆解为基本「Token(记号)」,根据语法规程将若干个「Token」组合成「Expr(表达式)」(Token 本身也被作为 Expr 处理)。「Expr」的结构构建在「AST」中,得到结果。

语法分析器

语法分析(syntactic analysis,或 parsing)是根据某种给定的形式文法对由记号序列构成的输入文本进行分析并确定其语法结构的一种过程。

语法分析器的作用是进行语法检查、并构建由输入的记号组成的数据结构(一般是语法分析树、抽象语法树等层次化的数据结构)。语法分析器通常使用一个独立的词法分析器从输入字符流中分离出一个个的「记号」,并将记号流作为其输入。实际开发中,语法分析器可以手工编写,也可以使用工具(半)自动生成。

在本例中,即为工具自动生成。使用巴科斯范式描述对应的语法定义,并使用 goyacc 生成 golang 代码,提供一个 LALR 语法分析器,并定义了供 lexer 返回的 token 定义。

安装 Goyacc

在安装了 golang 的环境中,执行:

1go get -u golang.org/x/tools/cmd/goyacc

如安装后无法正常运行,请检查 $GOPATH/bin 是否加入到了 $PATH 中。

Goyacc 语法定义文件

一个 yacc 语法定义文件,一般由以下若干部分构成。

头部目标语言代码

可参考「附录一:L1-L3」,使用 %{}% 将需要的目标语言原生代码段落包围起来。目标语言往往涉及到语言应用中的一些头部代码。在例子里,golang 所需的包名称定义等需要通过这一部分添加进来。其他的例子如常量的定义、结构体的定义等。

Union(集合)声明

可参考「附录一:L5-L9」,以 %union{} 格式定义,只可定义一次。「Union」这一概念包含了下述类型声明中的各个类型,及这些类型对应的目标语言类型(即 Golang 中的类型)。与 c 语言中 union 的概念类似,在目标代码中(生成为 yySymType),union 将被生成为一个结构体(struct),根据「类型声明」,将匹配出的值放到结构体的对应字段中,作为存放/传递值的媒介而存在。在例子中,s即为string,类型声明中凡是标记为 s 类型的「表达式」,都会被保存到s字段中。

Token(记号)声明

可参考「附录一:L11-L14」,以 %token 为行首的记号声明列表。对于 lexer 会直接分析出的 token,需要通过「Token 声明」来列出。Token 声明本身不需要关注顺序和组合,只需要单独列出需要由 lexer 输出的 token 类型即可。

Type(类型)声明

可参考「附录一:L11-L14」,以 %type <%TYPE%> 为行首的记号声明列表,%TYPE% 为本行所列举的「Expr」将会保存到的 union 中的字段/类型。

声明与语法定义规则间的分隔符

各个声明与语法规则定义间,以 %% 分隔(L23)。

语法规则定义

可参考「附录一:L25-L202」,语法规则使用巴科斯范式(Backus Normal Form,缩写为 BNF)定义。在 Goyacc(及它仿造的 yacc)中,以摘自「附录一:L53-L64」定义的一个 expr 为例,大致由以下部分构成:

1searchLogicPart:
 2searchLogicSimplePart {
 3    $$ = $1
 4}
 5|
 6searchLogicSimplePart tAND searchLogicPart {
 7    $$ = NewAndCondition($1, $3)
 8}
 9|
10searchLogicSimplePart tOR searchLogicPart {
11    $$ = NewOrCondition($1, $3)
12};
  • L1 被定义的 expr 的名称,本例中 expr 的名称为 searchLogicPart,在其他位置上将通过这个名称来引用这个 expr 及其格式定义。

  • L2-L4、L6-L8、L10-L12 expr 语法规则,以 L6-L8 部分为例,由两个部分构成:

  • L6 匹配格式,本语句由三个部分构成,分别是:

    • 一个 searchLogicSimplePart expr,在「附录一:L66」处定义,逻辑语句的单一元素,或 NOT 逻辑元素,或使用括号强制优先结合的若干元素集合。

    • 一个 tAND expr,实际上为 token,在 lexer 中分析并给出的类型,源码中为AND

    • 一个 searchLogicPart expr,即本段落中定义的语句。

    • L7 行为(action),即匹配完成后对匹配到的元素进行的行为,在匹配格式后以 {} 包围

    • 实际上是代码生成模板,以目标语言(Golang)完成

    • $$ 将生成为当前 expr 的 union 中对应的字段

    • 例如 L7 中的 $$,因 searchLogicPart 在 type 声明中,使用的 type 是 q(见「附录一:L20」),因此将生成为 yyVAL.q

    • 以此类推,$1$3 分别将生成为第一个匹配项和第三个匹配项对应的类型字段,也即第一和第三匹配项的值,根据类型定义,即 yyDollar[1].qyyDollar[3].q

    • L7 在目标代码中,将生成为 yyVAL.q = NewAndCondition(yyDollar[1].q, yyDollar[3].q)NewAndCondition 为另行定义的函数。也即使用匹配到的第一个项和第三个项,构成一个 AND 逻辑组合条件。

    • 行为可以由不定行代码生成模板构成。

  • L5、L9 使用 | 分隔 expr 可能的语法规则,也就是说,将匹配三种规则中的任意一种。

  • L12 一个 expr 语法规则定义完成后,使用 ; 作为结尾。需要额外注意的点:

  • 对语法的定义不应当有二义性,也不可以对不定个数的元素进行匹配。

  • 在有需要的场景下,可以直接操作 parser 函数上下文中含有的其他变量。

    • 如 AST 根的输出,「附录一:L27」语句。

Go yacc 的生成

使用命令 goyacc -o querystring.y.go querystring.y 生成目标语言代码,在 yacc 定义中使用的 golang 函数/结构体等,都应当预先定义在同一包/目标语言代码中。

目标语言代码的使用

生成出的代码,将包含一个主入口 yyParse 函数。主入口函数接收 yyLexer 即词法分析器实例这一参数。在实际使用中,可使用词法分析器实例将分析结果 AST 返回。

词法分析器

词法分析(lexical analysis)是计算机科学中将字符序列转换为记号(token)序列的过程。进行词法分析的程序或者函数叫作词法分析器(lexical analyzer,简称 lexer),也叫扫描器(scanner)。词法分析器一般以函数的形式存在,供语法分析器调用。

这里的记号是一个字串,是构成源代码的最小单位。在这个过程中,词法分析器还会对记号进行分类。

在 Goyacc 的实际使用中,词法分析器提供了三个特性:

  • 词法分析,即 Lex(lval *yySymType) (tokenType int) 函数,从源代码流中,读出下一个记号,将这一记号的值通过传入的 union 指针返回(即 lval),将这一记号的类型通过返回值返回(即 tokenType)。

  • 错误记录,即 Error(s string) 函数。

  • AST 的返回,由于 yyParse 没有提供其他有效的返回途径,这一实例还作为整体解析上下文来使用,带回解析完成的 AST。

一个较为简单的实现方式,是使用 Scanner 配合对字符的匹配,将值放置于传入的指针,并返回对应的类型。由于 Querystring 在转义/反转义上的特殊习惯,本例中对应的 lexer 是自行实现的。

实现一个词法分析器,大致需要以下部分:

  • 一个字符串流,记录了内容、目前读到的位置等信息

  • 当前状态的管理

AST 节点的定义

根据语法的定义,构建 AST 的过程中,需要根据语义分别使用不同的节点类型,以方便对 AST 的使用。这一过程中,需要对 AST 节点进行定义。

在 Querystring AST 中,定义了以下类型的节点,并提供了若干工具函数:

  • AndCondition,与逻辑条件

  • OrCondition,或逻辑条件

  • NotCondition,非逻辑条件

  • MatchCondition,匹配条件,根据字段类型决定为全等匹配或全文匹配

  • RegexpCondition,正则条件

  • WildcardCondition,通配符条件

  • NumberRangeCondition,数值 Range 条件,数值相关的各类条件都归一到此类

  • TimeRangeCondition,时间 Range 条件,时间相关的各类条件都归一到此类

某些开源库中,会在这一部分添加遍历相关的 Method,以方便使用者对 AST 进行遍历。

包装实体与工具函数

由于语法分析器提供的函数多为私有函数,也需要通过词法分析器对输入的源代码进行包裹,一般需要若干个工具函数。

本例中,主要提供了解析入口函数,主要功能为:输入一个 Querystring 格式的字符串,使用 lexer 实例进行包装,运行 parser,并返回过程中输出的错误。

AST 的使用

Querystring 作为查询条件,使用过程中,一般需要注意以下几个问题:

  • 查询条件的优化。用户输入的查询条件经常是无序的:有时相同的条件会多次出现在不同的位置,可以予以合并;对同一字段的过滤行为可以合并在一起,来降低 local cache 成本;有的条件可能是逻辑上无效的,可以剔除。

  • 类型推断。在数值类型中,根据 Elasticsearch 的官方实现,Querystring 中形似数字的值,可能作为整数、浮点数、字符串或时间处理。应当通过被过滤的数据的情况,对值的类型做二次推断。

附录一:Querystring goyacc 定义

也可通过 https://github.com/bytedance/go-querystring-parser/blob/main/querystring.y 查看

1%{
  2package querystring
  3%}
  4
  5%union {
  6    s   string
  7    n   int
  8    q   Condition
  9}
 10
 11%token tSTRING tPHRASE tNUMBER
 12%token tAND tOR tNOT tTO tPLUS tMINUS tCOLON
 13%token tLEFTBRACKET tRIGHTBRACKET tLEFTRANGE tRIGHTRANGE
 14%token tGREATER tLESS tEQUAL
 15
 16%type                 tSTRING
 17%type                 tPHRASE
 18%type                 tNUMBER
 19%type                 posOrNegNumber
20%type                 searchBase searchParts searchPart searchLogicPart searchLogicSimplePart
 21%type                 searchPrefix
 22
 23%%
 24
 25input:
 26searchParts {
 27    yylex.(*lexerWrapper).query = $1
 28};
 29
 30searchParts:
 31searchPart searchParts {
 32    $$ = NewAndCondition($1, $2)
 33}
 34|
 35searchPart {
 36    $$ = $1
 37};
 38
 39searchPart:
 40searchPrefix searchBase {
 41    switch($1) {
 42    case queryMustNot:
 43        $$ = NewNotCondition($2)
 44    default:
 45        $$ = $2
 46    }
 47}
 48|
 49searchLogicPart {
 50    $$ = $1
 51};
 52
 53searchLogicPart:
 54searchLogicSimplePart {
 55    $$ = $1
 56}
 57|
 58searchLogicSimplePart tAND searchLogicPart {
 59    $$ = NewAndCondition($1, $3)
 60}
 61|
 62searchLogicSimplePart tOR searchLogicPart {
 63    $$ = NewOrCondition($1, $3)
 64};
 65
 66searchLogicSimplePart:
 67searchBase {
 68    $$ = $1
 69}
 70|
 71tLEFTBRACKET searchLogicPart tRIGHTBRACKET {
 72    $$ = $2
 73}
 74|
 75tNOT searchLogicSimplePart {
 76    $$ = NewNotCondition($2)
 77};
 78
 79searchPrefix:
 80tPLUS {
 81    $$ = queryMust
 82}
 83|
 84tMINUS {
 85    $$ = queryMustNot
 86};
 87
 88searchBase:
 89tSTRING {
 90    $$ = newStringCondition($1)
 91}
 92|
 93tNUMBER {
 94    $$ = NewMatchCondition($1)
 95}
 96|
 97tPHRASE {
 98    phrase := $1
 99    q := NewMatchCondition(phrase)
100    $$ = q
101}
102|
103tSTRING tCOLON tSTRING {
104    q := newStringCondition($3)
105    q.SetField($1)
106    $$ = q
107}
108|
109tSTRING tCOLON posOrNegNumber {
110    val := $3
111    q := NewNumberRangeCondition(&val, &val, true, true)
112    q.SetField($1)
113    $$ = q
114}
115|
116tSTRING tCOLON tPHRASE {
117    q := NewMatchCondition($3)
118    q.SetField($1)
119    $$ = q
120}
121|
122tSTRING tCOLON tGREATER posOrNegNumber {
123    val := $4
124    q := NewNumberRangeCondition(&val, nil, false, false)
125    q.SetField($1)
126    $$ = q
127}
128|
129tSTRING tCOLON tGREATER tEQUAL posOrNegNumber {
130    val := $5
131    q := NewNumberRangeCondition(&val, nil, true, false)
132    q.SetField($1)
133    $$ = q
134}
135|
136tSTRING tCOLON tLESS posOrNegNumber {
137    val := $4
138    q := NewNumberRangeCondition(nil, &val, false, false)
139    q.SetField($1)
140    $$ = q
141}
142|
143tSTRING tCOLON tLESS tEQUAL posOrNegNumber {
144    val := $5
145    q := NewNumberRangeCondition(nil, &val, false, true)
146    q.SetField($1)
147    $$ = q
148}
149|
150tSTRING tCOLON tGREATER tPHRASE {
151    phrase := $4
152    q := NewTimeRangeCondition(&phrase, nil, false, false)
153    q.SetField($1)
154    $$ = q
155}
156|
157tSTRING tCOLON tGREATER tEQUAL tPHRASE {
158    phrase := $5
159    q := NewTimeRangeCondition(&phrase, nil, true, false)
160    q.SetField($1)
161    $$ = q
162}
163|
164tSTRING tCOLON tLESS tPHRASE {
165    phrase := $4
166    q := NewTimeRangeCondition(nil, &phrase, false, false)
167    q.SetField($1)
168    $$ = q
169}
170|
171tSTRING tCOLON tLESS tEQUAL tPHRASE {
172    phrase := $5
173    q := NewTimeRangeCondition(nil, &phrase, false, true)
174    q.SetField($1)
175    $$ = q
176}
177|
178tSTRING tCOLON tLEFTRANGE posOrNegNumber tTO posOrNegNumber tRIGHTRANGE {
179    min := $4
180    max := $6
181    q := NewNumberRangeCondition(&min, &max, true, true)
182    q.SetField($1)
183    $$ = q
184}
185|
186tSTRING tCOLON tLEFTRANGE tPHRASE tTO tPHRASE tRIGHTRANGE {
187    min := $4
188    max := $6
189    q := NewTimeRangeCondition(&min, &max, true, true)
190    q.SetField($1)
191    $$ = q
192};
193
194posOrNegNumber:
195tNUMBER {
196    $$ = $1
197}
198|
199tMINUS tNUMBER {
200    $$ = "-" + $2
201};

附录二:参考链接

  • https://godoc.org/golang.org/x/tools/cmd/goyacc

  • https://en.wikipedia.org/wiki/Backus%E2%80%93Naur_form

  • https://about.sourcegraph.com/go/gophercon-2018-how-to-write-a-parser-in-go/

  • https://github.com/xwb1989/sqlparser

  • https://www.lysator.liu.se/c/ANSI-C-grammar-y.html