如何设计领域特定语言,实现终极业务抽象?

开发 后端
本文所写的皆是外部 DSL,即『不同于应用系统主要使用语言』的语言。创建外部 DSL 和创建一种通用目的的编程语言的过程是相似的,它可以是编译型或者解释型的。

[[404312]]

在过去的几年里,我一直从事于各种领域定义语言的设计,包含 unflow、guarding、datum、forming 等。在我刚入门这个领域的时候,我从《领域特定语言》、《编程语言实现模式》 等,一直研究到龙书等。我渐渐掌握了领域特定语言设计的一些技巧,也能快速(相对于过去)设计出一个领域特定语言。

所以,我在想我应该总结一下相关的套路。这样一来,也可以在未来验证现在的思路是否正确:

  1. 定义呈现模式。
  2. 提炼领域特定名词。
  3. 设计关联关系与语法。
  4. 实现语法解析。
  5. 演进语言的设计。

领域特定语言

领域特定语言(英語:domain-specific language、DSL)指的是专注于某个应用程序领域的计算机语言。

本文所写的皆是外部 DSL,即『不同于应用系统主要使用语言』的语言。创建外部 DSL 和创建一种通用目的的编程语言的过程是相似的,它可以是编译型或者解释型的。

通用目的编程语言的源代码和外部 DSL 的源代码之间的主要区别在于,经过编译的 DSL 通常不会直接产生可执行的程序(但是它确实可以)。大多数情况下,外部 DSL 可以转换为一种与核心应用程序的操作环境相兼容的资源,也可以转换为用于构建核心应用的通用目的编程语言。—— Vaughn Vernon

简单场景下的领域特定语言,只是将特定的源码转换为特定的数据结构。如 JSON 便是一种 DSL,在 Java 语言里,需要将它转换为对应的数据类。复杂场景下的领域特定语言,可以直接编译为可执行程序。

外部 DSL 的麻烦点在于:

  • 语法设计
  • 语法解析
  • IDE 支持

当然了,它的优点也很明显:

  1. 让不懂编程的业务专家(领域专家)快速编写核心逻辑。
  2. 领域逻辑与具体编程语言无关
  3. 平台无关。

更多的信息,建议去阅读《领域特定语言》一书。

定义呈现模式

领域特定语言嘛,从需求上就是对于业务呈现的简化。根据不同的呈现模式,去解析源码,得到我们所需要的数据结构。

呈现模式

如下是常见的的领域特定语言的使用模式 [wiki_dsl]:

  • 独立的工具,如 Makefile
  • 在编译时或实时转换为宿主语言
  • 嵌入式领域特定语言
  • ……

可以参见维基百科,我就不再去翻译了。

[wikidsl]: https://en.wikipedia.org/wiki/Domain-specificlanguage

定义数据结构

从通用语言的编译过程来看:

  1. 词法分析器,分析输入的字符流,得到符号流。
  2. 语法分析,分析符号流,得到语法树
  3. 语义分析,分析语法树,得到新的语法树
  4. 中间代码生成器,分析语法树,得到中间表示形式
  5. ……

步骤 1~4,对于通用语言和领域特定语言来说都是极为类似的。唯一拥有区别的是这个中间表示形式,对于领域特定语言来说,我们场景的原因,这里往往是我们所需要的数据结构。

当然了,从某种意义上来说,AST(抽象语法树)也是一种数据结构,只不过它是一种中间的数据结构。所以,有时候在设计的时候,我就偷懒直接输出中间表示了。

提炼领域特定名词

这个环节的过程,实现上和 DDD(领域驱动设计)里的提炼问题域以获取领域知识是颇为相似的。同样的这个过程中,通过与领域专家的协作,我们才能获得更好的领域特定语言。

从用例开始

用例,或译使用案例、用况,是软件工程或系统工程中对系统如何反应外界请求的描述,是一种通过用户的使用场景来获取需求的技术。

在进行领域驱动设计协作时,我们需要与领域专家理解用户在这个过程中,进行的一系列操作,以提炼我们所需要的统一语言。而其中的用例能描述达到目标所需的步骤,包含用户和系统之间的交互。

在创建领域特定语言的时候,这个过程对于我们来说,也是类似的:与领域专家一起协作,从用例开始提炼。它也可以直接由现有的代码中提炼而来。

从已有用例入手

对于已有系统来说,用例可以由:

与领域专家交流获取。与领域专家聊天,是我们获得用例的最好方式。记录用例,从而获得关键信息。

从现有的代码中提取。

在 ArchUnit 中提取架构规划上的设计便是:

  1. classes().that().resideInAPackage("..foo.."
  2. .should().onlyHaveDependentClassesThat().resideInAnyPackage("..source.one..", "..f 

对应的,我们在 Guarding 的设计是:

  1. class(resideIn "..foo..") dependent package(resideIn ["..source.one..""..foo.."

在 Guarding 中设计的是针对主流的编程语言,所以在语法上会尽量与编程语言无关。

提取关键字、值、属性

在获得了用例作为输入条件之后,我们就需要从中提取一些关键信息,如关键字、值、属性等等。

如下是我在设计 Guarding DSL 时,从 ArchUnit 提取的一小部分关键信息:

  1. package: 
  2.   dependOn 
  3.  
  4. class: 
  5.   implement 
  6.   annotation 
  7.   annotatedWith 
  8.  
  9. expression: 
  10.   and 
  11.   or 
  12.   not 
  13.   equals 
  14.   only 

接着,我们就可以依据这些信息,展开它们的关联设计,进而设计我们的语法。

设计关联关系与语法

在设计领域特定语言时,我们主要以实现领域中的用例作为目标:

  1. 使用 DSL 描述一个用例
  2. 先不考虑语法实现,实现大部分用例的 DSL 草稿版本
  3. 对齐不同用例 DSL 中的差异
  4. 考虑一些非常规的用例,添加额外的属性

名词关系与逻辑设计

领域特定语言,所针对的是特定领域。在特定的领域里,都会使用特定的词汇来描述相关之间的关系。这个关系,便是我们设计语法的一个关键。

如在 Java 语言里,使用: implement、 extends 来表示两个类之间的关系。而为了表示包之间的关系,则会有: dependent、 resideIn 等等的关系。

实现用例

实现用例并不是一个复杂的过程,只是要符合人类的思维习惯,并尽可能地简化设计。不过,觉得注意的是,我们应该留下一些证据来告诉未来的自己:我们当时是为什么考虑的。

在设计 DSL 时,我往往会创建一个 sample 文件,以记录过程中,对于不同的要素的思索。如我在设计 Guarding DSL 里,使用了一个 0.0.1.sample 文本文件,来描述早期版本的语法示例:

  1. # 正则表达式 
  2. package(match("^/app")) endsWith "Connection"
  3.  
  4. package("..home..")::name should not contains(matching("")); 
  5.  
  6. # 简化的比较 
  7. class::name.len should < 20; 

通过一些注释来让自己优化设计。

实现语法解析

这一部分的过程,和我们学习编译原理时基本是一致的。不过呢,在编写领域特定语言的时候,我们一般会使用解析器生成器,而不是手写解析器。

细节设计

设计领域特定语言的时候,在设计语法上的拘束不会像通用语言那么多。所以,自由设计的范围就大一点,有些内容也不一定需要像编程语言麻烦。诸如于:

  • 分隔符
  • 缩进的处理
  • 语法块的开始和结束
  • ……

PS:使用类似于编程语言的写法,对于写 DSL 的非编程人士来说可能会变成一种困扰。

解析器生成器

经典的 Lex & Yacc 是你可以考虑的范围,在不同的语言里也有一些相似的实现。

对于我来说,以下是我常用的一些解析器生成器。

  • Antlr。支持主流的语言
  • Peg.js。JavaScript
  • Pest。Rust
  • Lalrpop。Rust

我还是比较习惯用 Antlr,支持的语言较多。我与同事以及开源社区的小伙伴们,在下面的项目中都使用过 Antlr:

  • Coca = Golang + Antlr
  • Unflow = Rust + Antlr
  • Lemonj = JavaScript/TypeScript + Antlr
  • Chapi = Java/Kotlin + Antlr

从使用上它们之间的差距并不大,但是都需要学习成本。

演进语言的设计

最后,让我们来谈谈一些有意思的东西,虽说是演进吧,但是,和设计暂时没有太大的关系。

测试驱动开发

经我大量发现,TDD 是非常适合于编程语言的开发与设计。需求是未知的,易于发生变化的,还需要覆盖足够全的场景。

从实践的层面上来说,主要是有两种:

  1. 面向语法的测试。即,只让语法编译能通过,但是不报错。
  2. 面向功能的测试。即,验证某一部分的语法是正确的。
  3. 面向用例的测试。即,验证符合使用场景。

自动化语言迁移

原先这部分的标题是,向下兼容。但是,我一直觉得向下兼容不是一个好主意。所以呢,我就想了想把在其它领域的经验搬了过来,于是呢,内容就变成了自动化语言迁移。

在关于版本的迁移上,我觉得 Angular 语言上关于版本的自动化迁移,是值得我们去借鉴的。当然了,采用这种设计的成本非常高,我们需要有一个专门的团队,使用工具自动化分析旧的系统,并使用工具来自动修改旧的代码。

其它

文中相关 DSL 链接(欢迎加入 Inherd 一起编写 DSL):

Unflow: https://github.com/inherd/unflow

Guarding: https://github.com/inherd/guarding

Forming: https://github.com/inherd/forming

本文转载自微信公众号「phodal」,可以通过以下二维码关注。转载本文请联系phodal公众号。

 

责任编辑:武晓燕 来源: phodal
相关推荐

2020-03-04 11:20:22

DSL开发领域特定语言

2009-03-12 14:31:15

QCon

2022-06-17 11:04:47

语法分析器GoyaccAST

2014-09-26 10:00:25

驱动设计DDD领域

2014-09-11 15:05:40

驱动设计驱动开发

2024-08-19 14:06:00

2023-08-29 08:57:03

事务脚本架构模式业务场景

2024-01-22 15:36:54

大语言模型人工智能

2023-11-27 15:34:51

大语言模型PubMedBERT

2009-08-27 09:16:48

F#中DSL原型设计

2022-01-28 14:20:53

前端代码中断

2023-02-13 07:04:12

VBC#语言

2024-07-17 08:36:53

2010-01-25 09:17:01

Visual Stud

2018-05-21 07:08:18

行为驱动开发BDD编码

2009-11-13 19:54:42

2009-10-13 17:08:10

CLR VB.NET

2020-04-13 09:54:44

微服务子集存储

2016-12-29 16:25:32

字符串算法代码

2021-06-30 07:51:09

新项目领域建模
点赞
收藏

51CTO技术栈公众号