本文可以看作 这篇文章 的延伸

    要实现一个简单的 DSL 解释器,通常可以简化为下面的过程

    1. graph LR
    2. 词解析\--\> 语法解析
    3. 语法解析 \--\> 解释执行

    每个过程也有很多种做法

    • 词解析是将一个语句解析成 token 的过程,这个过程一般比较简单,可以使用 lex, flex 之类的工具,也可以完全手写

    • 语法解析时将 token 组合解析成语法树的过程,对于比较复杂的 dsl 设计,这个过程可能比较复杂,可以借助如 yacc 这样的工具,但是为了追求效率,也可以完全手写(promql 就是手写,如果是手写,没有太大必要把词解析和语法解析两者分割得太清楚)

    • 执行我们只看即时执行的情况,一般来说可以对上一步的语法树直接执行,也可以做进一步编译,以虚拟机(类似 lua、python 等)的方式执行。先编译的好处是可以做一些优化,执行效率会更高。

    • goyaccc 版本的 yacc 工具 翻译而来

    • 能解释 LALR(1) 语法 (look head one token and decide what action to take).

    • 可以嵌入 go 代码

    • 内部使用状态机实现,很高效

    goyacc 内部有两个重要的 interface, 其中 yyLexer 需要使用者自己实现提供,yacc 会生成 yyParser 的实现,其使用 yyLexer 做解释操作。解释的过程和和解释前后都可以嵌入自己的代码逻辑,完成一个程序或者单纯生成一个自定义的语法树结构.

    1. type yyLexer interface {
    2. Lex(lval \*yySymType) int
    3. Error(e string)
    4. }
    5. type yyParser interface {
    6. Parse(yyLex) int
    7. Lookahead() int
    8. }

    golang 1.8 版本之前 yacc 直接再带与 go tool 无需自行安装。

    鉴于使用的频率太少,遂在 golang 1.8 版本后 移除默认安装,即之后版本需手动安装(仍然为官方包)。

    1. go get \-u github.com/golang/tools/tree/master/cmd/goyacc

    |

    参数

    |

    说明

    | |
    | |

    |

    -l

    |

    显示 line 指令

    |
    |

    -o string

    |

    指定输出解析器的文件名称 (默认 y.go)

    |
    |

    -p string

    |

    指定解析器输出接口的前缀

    |
    |

    -v string

    |

    生成解析过程表 (默认 y.output)

    |

    先看一个简单的例子, 这个例子来自 go 官方

    1. %union {
    2. num \*big.Rat
    3. }
    4. %type <num\> expr expr1 expr2 expr3
    5. %token '+' '-' '\*' '/' '(' ')'
    6. %token <num\> NUM
    7. %%
    8. top:
    9. expr
    10. {
    11. if $1.IsInt() {
    12. fmt.Println($1.Num().String())
    13. } else {
    14. fmt.Println($1.String())
    15. }
    16. }
    17. expr:
    18. expr1
    19. | '+' expr
    20. {
    21. $$ \= $2
    22. }
    23. | '-' expr
    24. {
    25. $$ \= $2.Neg($2)
    26. }
    27. expr1:
    28. expr2
    29. | expr1 '+' expr2
    30. {
    31. $$ \= $1.Add($1, $3)
    32. }
    33. | expr1 '-' expr2
    34. {
    35. $$ \= $1.Sub($1, $3)
    36. }
    37. expr2:
    38. expr3
    39. | expr2 '\*' expr3
    40. {
    41. $$ \= $1.Mul($1, $3)
    42. }
    43. | expr2 '/' expr3
    44. {
    45. $$ \= $1.Quo($1, $3)
    46. }
    47. expr3:
    48. NUM
    49. | '(' expr ')'
    50. {
    51. $$ \= $2
    52. }
    53. %%

    可以看一下这个 y 文件的构成, 时如下的结构

    1. {%
    2. 嵌入代码: go 代码
    3. %}
    4. 文法定义: %union %type %token %left %right %start 等组成的定义
    5. %%
    6. 文法规则: 非终结符 终结符 组成的匹配 + 动作规则
    7. %%
    8. 嵌入代码 (这部分为可选,比如可以 lexer 或者 main 可以写在这里或者单独用文件写

    文法定义简单说明如下

    |

    描述符

    |

    说明

    | |
    | |

    |

    %union

    |

    用来定义一个类型并映射 golang 的一个数据类型

    |
    |

    %struct

    |

    同 %union 建议使用 %union

    |
    |

    %token

    |

    定义终结符,表示不能再拆了的字符 是一个 union 中定义的类型, 可无类型

    |
    |

    %type

    |

    定义非终结符

    |
    |

    %start

    |

    定义从哪个终结符开始解析 默认规则段中的第一个终结符

    |
    |

    %left

    |

    定义规则结合性质 左优先

    |
    |

    %right

    |

    定义规则结合性质 右优先

    |
    |

    %nonasso

    |

    定义规则结合性质 不结合

    |
    |

    %perc term

    |

    定义优先级与 term 一致

    |

    • 其中最常用的时 union/token/type, union 用来表示, token, type 的类型,也就是说 token, type 可能是 union 中的一个类型,并且这个结构会出现在生成的 symType 里面,会由 lexer 传给 parser. 在下面的文法规则的动作里面,匹配后的变量 $1, $2 等等,都可以当成定义好的类型.
      %union {
      num big.Rat
      }
      %type expr expr1 expr2 expr3
      %token ‘+’ ‘-‘ ‘
      ‘ ‘/‘ ‘(‘ ‘)’
      %token NUM

    • 文法规则表示匹配了符号之后,怎么完成动作. 一般写作下面的形式, 规则描述 可以是 非终结符、终结符, 可用 | 分割多个规则. 动作描述可以没有,写成 {} 或者不写, 动作描述由 golang 表示,一般会取动作描述中的元素作为参数使用 $1, $2 这样的形式表示第一个,第二个符号,符号的类型在 union 中已经定义。$$ 则表示当前整个符号对应的结构
      非终结符:
      规则描述1
      {
      动作描述2
      }
      | 规则描述2
      {
      动作描述2
      }
      top:
      expr
      {
      if $1.IsInt() {
      fmt.Println($1.Num().String())
      } else {
      fmt.Println($1.String())
      }
      }
      expr:
      expr1
      | ‘+’ expr
      {
      $$ = $2
      }
      | ‘-‘ expr
      {
      $$ = $2.Neg($2)
      }
      expr1:
      expr2
      | expr1 ‘+’ expr2
      {
      $$ = $1.Add($1, $3)
      }
      | expr1 ‘-‘ expr2
      {
      $$ = $1.Sub($1, $3)
      }
      expr2:
      expr3
      | expr2 ‘*’ expr3
      {
      $$ = $1.Mul($1, $3)
      }
      | expr2 ‘/‘ expr3
      {
      $$ = $1.Quo($1, $3)
      }
      expr3:
      NUM
      | ‘(‘ expr ‘)’
      {
      $$ = $2
      }

    这个例子时上面的示例的完整版本,来自 go 官方, 本质是实现了一个大数的计算器,支持 "+", "-", "*", "/", "(", ")", 值得注意的是 expr 定义了几种,里面蕴含了优先级关系.

    本文将原始的代码分成 .y, lexer, main 多个文件,并且做了一定的简化,使得代码可读性更高,可以参考这里

    例子来自这里, 写一个编译器识别 json 格式的字符串,【这个解释器做得比较简单,并无完全符合 json 标准】

    这个例子的关键在于写出 json 的 表达式树, 简化如下

    1. %{
    2. package jsonparser
    3. type pair struct {
    4. key string
    5. val interface{}
    6. }
    7. %}
    8. %union{
    9. obj map\[string\]interface{}
    10. list \[\]interface{}
    11. pair pair
    12. val interface{}
    13. }
    14. %token <val\> String Number Literal
    15. %type <obj\> object members
    16. %type <pair\> pair
    17. %type <val\> array
    18. %type <list\> elements
    19. %type <val\> value
    20. %%
    21. object: '{' members '}'
    22. members:
    23. | pair
    24. | members ',' pair
    25. pair: String ':' value
    26. array: '\[' elements '\]'
    27. elements:
    28. | value
    29. | elements ',' value
    30. value:
    31. String
    32. | Number
    33. | Literal
    34. | object
    35. | array

    brainfuck 语言的解释器, 这个语言比较简单,支持如下几种操作:

    1. \# \> Move the pointer to the right
    2. # < Move the pointer to the left
    3. # + Increment the memory cell under the pointer
    4. # \- Decrement the memory cell under the pointer
    5. # . Output the character signified by the cell at the pointer
    6. # , Input a character and store it in the cell at the pointer
    7. # \[ Jump past the matching \] if the cell under the pointer is 0
    8. # \] Jump back to the matching \[ if the cell under the pointer is nonzero

    代码在这里

    • 我们除了 lexer.go parser.y 之外还写了一个 env.go, 这是执行使用的结构体,目的是优化和执行代码。

    • 这里我们用了几种优化策略:

      • 优化重复的操作符比如 +++ 可以优化成 (+, 3)
      • 对循环进行优化,比如 [+] 或者 [-], 表示持续加 1 直到变为 0 可以优化成 (setzero)[>] 或者 [<], 表示向左 / 右移动指针直到指针下面的值为 0,可以优化成 (moveptr)[-<+>] 或者 [->+<] 表示向指针下 / 上 n 个位置移动当前指针下的值,可以优化成 (movedata, n)

    用 sql 的方式查询 github api,这个例子目前实现得比较简单,只支持 user repo api,并且只支持 select a from repo count x page y 这样的语句,不过大致的思路可以体现出来了. 代码在这里, 运行的时候需要先填入 github access token. 运行效果如下.

    1. ./githubsql
    2. s\> select \* from repo count 10 page 0
    3. +\--\--\--\--\--\--\--\--\--\-+\--\--\--\--\--\--\--\--\-+\--\--\--\--\--\--+\--\--\--\--\--\--\--\--\--\--\--\--\--\--\-+
    4. | NAME | OWNER LOGIN | LANGUAGE | TOPICS |
    5. +\--\--\--\--\--\--\--\--\--\-+\--\--\--\--\--\--\--\--\-+\--\--\--\--\--\--+\--\--\--\--\--\--\--\--\--\--\--\--\--\--\-+
    6. | kubebox | arlert | | |
    7. | kubepipe | arlert | Go | |
    8. | malcolm | arlert | Go | |
    9. | malcolm\-ui | arlert | TypeScript | |
    10. | ymir | arlert | Go | |
    11. | fengming | cargogogo | Go | \["container","image","p2p"\] |
    12. | Dockerfiles | goodow | | |
    13. | bird | kirk\-enterprise | C | |
    14. | calico | kirk\-enterprise | Python | |
    15. | calico\-bgp\-daemon | kirk\-enterprise | Go | |
    16. +\--\--\--\--\--\--\--\--\--\-+\--\--\--\--\--\--\--\--\-+\--\--\--\--\--\--+\--\--\--\--\--\--\--\--\--\--\--\--\--\--\-+

    原创声明,本文系作者授权云 + 社区发表,未经许可,不得转载。

    如有侵权,请联系 yunjia_community@tencent.com 删除。
    https://cloud.tencent.com/developer/article/1744609