DSL 设计参考

前言 (Intro)

向想要自己设计 DSL 的新手,分享一点我的经验总结。很多概念想法代入了个人见解,不具备权威性,请辩证看待。
本文不介绍 DSL 适合的场景,不解释为什么要用 DSL。以后我会另写一篇文章进行说明的。
本文主要介绍某种风格的 DSL 设计参考:基于 YAML 等通用数据结构设计 DSL。

语言结构

总结了四种结构:

  1. 通用数据结构。例如基于 YAML 的 docker-compose.yml、Ansible playbook,基于 XML 的 HTML。
  2. 自定义语法结构。例如 CSS,Dockerfile,Nginx.conf,GraphQL,SQL。
  3. 语义化的文字段落。例如文学编程 (Literate programming)。
  4. 通过编程语言内部结构实现的 DSL (Embedded DSL)。例如 Ruby,Lisp。

通用数据结构

我推荐方案 1。你可以基于 JSON/YAML/TOML 来设计 DSL,因为这些数据结构在大部分编程语言都有其解析库。社区的支持度很高,有很多编辑器,可视化工具等。基于社区通用的数据结构可以减少很多工作量。

真正需要用户填的是数据,而结构化的格式可以通过别的途径生成,比如专门做一个编辑器,自动补全提示;或者做模板脚手架,通过命令行来生成骨架。

本文主要讲的是基于该数据结构的 DSL 设计。

自定义语法结构

对于方案 2,大部分场景我是不推荐的,因为你至少得写一套对应的词法分析器 (Scanner)、语法解析器 (Parser)、解释器 (Interpreter),如果要跨语言,n 个语言要写 n 份解析器和执行器。

可能是这样的结构:

上图来自这个网站

当前方案 1 也是要写这些,不过省去了词法和语法分析,直接做语义分析。

语义化的文字段落

方案 3 会产生很多冗余字段,书写和阅读都很低效。可能只适合某些特别的场景。故不赘述。

通过编程语言内部结构实现的 DSL

参考这篇文章
这类 DSL 只能存在于特定的语言里,不具备跨语言的能力。

内部 DSL vs 外部 DSL

Martin Fowler 将 DSL 分为 内部 (internal) DSL 和 外部 (external) DSL,出自这本书,有兴趣可以读一下。网上也有很多好文章,请自行查阅。
语言结构」章节列出的四种形式 DSL,已经包括了这两种情况。

思想认知

在设计 DSL 语法之前,先铺垫一下我对 DSL 的认知。

程序与人

设计 DSL 的本质是设计人与程序如何交互。

我认为 DSL 与人与程序应该是这样的关系:
用户书写 DSL (YAML),Parser 把 YAML 转成程序友好的 JSON 格式配置,然后存数据库或文件系统,Interpreter 再去解析 JSON 配置做出相应的计算。
用户通过 DSL 间接控制程序的行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
                                  ┌───────────┐
│ Parser │
│ Program │
└───────────┘



┌──────────┐ │ ┌──────────┐
┌─────────┐ Write │ │ │ │ │ ┌────────────────┐
│ Human │─────────▶│ YAML │───translate───▶│ JSON │─────│ Interpreter │
└─────────┘ │ │ │ │ │ Program │
◎ └──────────┘ └──────────┘ └────────────────┘
│ │ │ ◎
│ │ │ │
└─────────────────────┘ └───────────────────┘
human-friendly syntax program-friendly syntax

YAML 是面向人类的配置,JSON 是面向机器的配置。YAML 和 JSON 在自身语法层面并不能带来这种差异,只是我特意对 DSL 设计与解析进行这样的概念定义。

为什么要加入 JSON 作为中间表示层?如果去掉 JSON,就会变成下图这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
                                  ┌───────────┐
│ Parser │
│ Program │
└───────────┘



┌──────────┐ │ ┌────────────────┐
┌─────────┐ Write │ │ │ │ Interpreter │
│ Human │─────────▶│ YAML │───translate───▶│ Program │
└─────────┘ │ │ └────────────────┘
◎ └──┬────┬──┘ ◎
│ │ │ │
└──────────────────┘ └────────────────────────────┘
human-friendly syntax program-unfriendly syntax

设计 DSL 会产生问题。因为语法设计者很可能会在 DSL 层面进行语法优化(如果不优化,用户会吐槽 DSL 好难用),比如加入很多语法糖,这种优化对人类是友好的,但对程序分析不友好。
Interpreter 不应该去兼容来自人的五花八门的需求,这会让 Interpreter 程序写很多冗余代码,它应该去解析固定不变的数据结构。

比如用户书写这样的 YAML,

1
abc: foo

Parser 解析得到这样的 JSON,

1
{"rule": "abc", "access": "foo"}

我说这个例子中 YAML 和 JSON 内容是一样的,因为它们描述了同样的逻辑,只是表达形式不一样。
同样的 abc: foo,根据 parser 规则不同也可能产生这样的 JSON 配置。

1
{"rule": "abc", "access": "foo", "any": "thing", "attached": [1,2,3]}

YAML 可以很精简,很抽象,JSON 必须是具体的,字段结构分明的。JSON 中间配置根据某些规则解释出来,这看具体需求。Parser 的解析过程可能会比较耗时,可能会有额外的开销,这就更有必要加入中间层来做缓存。

权衡

你在设计 DSL 时需要做很多权衡,大致是这些条件:人的书写效率、DSL 的学习成本、程序的开发维护效率、程序的解析 (parse) 效率、程序的解释 (interpret) 效率、DSL 可扩展性。

代码、配置、DSL

这一段是题外话。
我称呼 YAML DSL 和中间层 JSON 都是配置。是因为我认为它们应该跟代码有区别。配置是静态的,而代码是动态的。
我认为 DSL 绝不应该和 GPPL 用同样的编程范式来解决问题。DSL 应该是快速解决某个问题域的途径,而最快解决问题的不是编码,而是写配置来描述程序的起始状态,以及它的目标状态。
DSL 可以是配置 (configuration) 的进化,一种具备特定逻辑关系的配置数据。

我的理论模型是这样的,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
            ╔═════════════════════════════╗
║ Program ║
║ ║
║ ┌───────────────┐ ║
Data Input ║ │ │ ║ Data Output
────────────╬─────▶│ Code Module │──────╬────────────▶
║ │ │ ║
║ └───────▲───────┘ ║
║ │ ║
║ operate ║
╚══════════════╬══════════════╝

┌─────────────────────────────┐
│ Configuration │
└─────────────────────────────┘

解释起来文字量不少所以不解释了。我正在(这个正在拖了有半年了吧)写另一篇文章,关于数据流、业务逻辑、代码逻辑。敬请期待。

建立逻辑

确定使用什么样的语言结构,剩下的就是如何描述你的业务逻辑。

设计 DSL 的过程,是自顶向下的设计,而不是自底向上的。你不需要知道底层具体是怎么实现的。你需要关注的是逻辑的完备性。
你的业务逻辑的基本单元是什么,单元之间如何建立逻辑关系。DSL 要描述的是逻辑状态,而不是逻辑过程。DSL 要描述的是 WHAT,而不是 HOW。

只要逻辑能够描述完整,剩下的就是语言形式上的修改罢了。

语法技巧

DSL 针对不同问题产生不同的设计,因此没有通用的设计结构。于是这里只列一些你可能用得着的小技巧。

多版本

这是从 Docker 的设计上学到的。

凡是设计 DSL,最好加上一个 version 字段,其值用字符串存储,形如 version: '1.0.0'。表示当前 DSL 使用的语法版本。

当你实现 Parser 和 Interpreter 时,根据 version 的值,选择不同的解析引擎。
有了版本的概念,你就可以对 DSL 语法进行版本管理,升级。不必担心设计 DSL 考虑不周导致不兼容的修改,只要遵循 Semver 增加大版本号就行了。DSL 的设计因此具备迭代改进的能力。

version 的字符串不一定是 Semver 的概念,它还可以是 Tag 的概念,非常灵活好用。

冗余度、自由度、可扩展性

例 1

1
2
3
4
- rule: abc
weight: 12
- rule: def
weight: 34

例 2

1
2
- abc: 12
- def: 34

例 1 可扩展性强,未来有新功能只要新增字段即可。但是冗余度高,数组每个元素都必须写 rule 和 weight 字段。语义化高,容易理解。
例 2 可扩展性弱,不能再新增字段。但是冗余少,用户填的字符占比高,即书写效率高。但是没有语义化,一般人看不懂。

如果你把数据放到 key 里,就会限定的很死。难以扩充语法。适度增加一些冗余字段,就能增强可扩展性,但是用户就会重复写很多冗余的 key。
冗余度与可扩展性的置换,是设计 DSL 数据结构的一种技巧。

数据结构

array 与 map。需要保证顺序就用 array,其余一律用 map。map 优先。

嵌套

逻辑上的嵌套不要太多,1~3 层嵌套足够了。
普通的配置数据如果原本是嵌套的,也尽量平铺到一层,这样容易书写。
另外,越平铺越方便程序解析。

语法糖

比如说,通配符 * 是个语法糖,用来匹配多个条件。语法糖是一种优化手段,而不是解决问题的途径。没有语法糖你依然可以用 DSL 解决问题域。

比如说别名 (alias),某些关键词很长,我们可以给用户设计一个较短的别名。

比如说复用,YAML 有「锚点」这样的语法糖,它能在语法层面上解决重复字段的问题。

表达式

表达式是一种过程式的描述,它与 DSL 的设计理念相悖。但由于书写灵活度的权衡,你很可能会需要包含表达式的功能,即借用某个 GPPL 语言的表达式,以一行或一段字符串的形式写到 DSL 中。

理论上总能设计出一种结构来代替表达式的,但是书写起来必然会很累赘,比如保底方案可以是手写抽象语法树 (AST)。但这没有实现的价值,因此我不讨论这种结构。

命名空间

构造命名空间有很大的好处。

比如你可以用名称来唯一定位配置资源,在 DSL 里没有实际 id 的存在,当你需要建立配置项间的联系,就需要有个名字来索引。
构建命名空间,可以在逻辑上来定位,来避免重复,来复用资源。
命名跟逻辑相关,跟物理资源不耦合,这对数据迁移的场景非常有用。

DSL 的载体是内容,而不是文件

内容是 DSL 的载体,文件是内容的载体。
建议不要把 DSL 跟文件系统绑定,比如某个 DSL 文件应该遵守什么命名规则,某个 DSL 文件应该放在哪个目录下。这都是不好的,不必要的耦合。
你应该使用命名空间来划分 DSL 里的内容。

校验程序

用户书写 DSL 的时候可能会写错。在解析 DSL 过程中,需要给用户错误反馈,友好的提示错误语法的位置。

参考 (Bibliographies)

引用 (References)