一杆到底:DSL 领域特定语言

2025-05-03 09:23:07

一、DSL了解

1、DSL介绍

DSL(Domain Specific Language)是针对某一领域,具有受限表达性的一种计算机程序设计语言。 常用于聚焦指定的领域或问题,这就要求 DSL 具备强大的表现力,同时在使用起来要简单。说到DSL,大家也会自然的想到通用语言(如Java、C等)。

为什么没有一种语言同时 兼具『简洁』和『业务表达』能力呢?

从信息论本质上来讨论这个问题,每个语言的程序都可以抽象为一个字符串,每个字符串由有限数量的合法字符组成,它在运行时会实现某个功能,因而可以看作是一种需求的信源编码。每种需求可以映射到一个或多个正确的程序,但一个程序肯定只对应到一种需求,因而程序包含的信息熵不低于需求的信息熵。而程序中不仅仅需要描述需求的信息,还需要包含 可读性、辩识度,如果是静态语言还需要 静态检查等额外信息。 这里也可以看出来,为什么DSL是特定领域的语言了。

2、DSL分类

最常见的分类方法是按照DSL的实现途径来分类。马丁·福勒曾将DSL分为内部和外部两大类,他的分类法得到了绝大多数业界人士的认可和沿袭。内部与外部之分取决于DSL是否将一种现存语言作为宿主语言,在其上构建自身的实现。

2.1、内部DSL

也称内嵌式DSL。因为它们的实现嵌入到宿主语言中,与之合为一体。内部DSL将一种现有编程语言作为宿主语言,基于其设施建立专门面向特定领域的各种语义。例如:Kotlin DSL、Groovy DSL等;

2.2、外部DSL

也称独立DSL。因为它们是从零开始建立起来的独立语言,而不基于任何现有宿主语言的设施建立。外部DSL是从零开发的DSL,在词法分析、解析技术、解释、编译、代码生成等方面拥有独立的设施。开发外部DSL近似于从零开始实现一种拥有独特语法和语义的全新语言。构建工具make 、语法分析器生成工具YACC、词法分析工具LEX等都是常见的外部DSL。例如:正则表达式、XML、SQL、JSON、 Markdown等;

3、DSL示例

3.1、内部DSL

HTML: 通过自然语言编写

在Groovy中,通过DSL可以用易读的写法生成XML

import groovy.xml.MarkupBuilder

def s = new StringWriter()

def xml = new MarkupBuilder(s)

xml.html{

head{

title("Hello")

script(ahref:'https://xxxx.com/vue.js')

}

body{

p("Excited")

}

}

println s.toString()

最后将生成

Excited

这里相对于Java这样的动态语言,最为不同的就是xml.html这个并不存在的方法居然可以通过编译并运行,它内部重写了invokeMethod方法,并进行闭包遍历,少写了许多POJO对象,效率更高。

3.2、外部DSL

以plantUML为例,外部DSL不受限于宿主语言的语法,对用户很友好,尤其是对于不懂宿主语言语法的用户。但外部DSL的自定义语法需要有配套的语法分析器。常见的语法分析器有:YACC、ANTLR等。

4、DSL & DDD(领域驱动)

DDD和DSL的融合有三点:

面向领域;

模型的组装方式;

分层架构演进;

DSL 可以看作是在领域模型之上的一层外壳,可以显著增强领域模型的能力。

它的价值主要有两个,一是提升了开发人员的生产力,二是增进了开发人员与领域专家的沟通。外部 DSL 就是对领域模型的一种组装方式。

5、DSL不是银弹

前开篇也提到了,在信息量不变的情况下,代码行数越短,它的“潜规则”信息量就越多,那么如何排查?如何定位?如何扩展?成为一个好的DSL需要考量的点。好的DSL难点在于:

DSL只是一种声明式的编程语言,无法承载大量业务。

DSL语句与编译生成的“字节码”的过程是黑盒的,不但对内部工作不明朗,如果报错的话,不但堆栈行数无法与源码对应上,而且无法“断点”或者“日志”。

DSL对设计者要求高,需要会一个领域有通透的理解,设计时要克制『增加各种特性』,DSL还要文档齐全,支撑充分,甚至要开源以帮助使用者定位。

二、有哪些工具

上节中提到,DSL分为内部和外部。由于外部DSL需要自己编写分析器,所以笔者使用 内部DSL实现。从之前收集的大量资料中,调研到 有两种比如轻量实现DSL的方式。

第一种:使用Groovy语言的元编程特性,天然支持DSL的下定义,而且兼容Java调用,生成的class更容易被JVM优化,执行性能上不会有太多损失。

第二种:使用Jetbrains MPS,开发基于java base的内部DSL。支持快速修复、智能提示、语法检查等。

https://www.jetbrains.com/zh-cn/mps/

下面我们将以:第一种 Groovy为基础语言,开发 内部DSL。

三、Groovy实战DSL

1、 实现原理

(1)闭包

官方定义是“Groovy中的闭包是一个开放,匿名的代码块,可以接受参数,返回值并分配给变量”

简而言之,他说一个匿名的代码块,可以接受参数,有返回值。在DSL中,一个DSL脚本就是一个闭包。

比如:

//执行一句话

{ printf 'Hello World' }

//闭包有默认参数it,且不用申明

{ println it }

//闭包有默认参数it,申明了也无所谓

{ it -> println it }

// name是自定义的参数名

{ name -> println name }

//多个参数的闭包

{ String x, int y ->

println "hey ${x} the value is ${y}"

}

每定义的闭包是一个Closure对象,我们可以把一个闭包赋值给一个变量,然后调用变量执行

//闭包赋值

def closure = {

printf("hello")

}

//调用

closure()

(2)括号语法

当调用的方法需要参数时,Groovy 不要求使用括号,若有多个参数,那么参数之间依然使用逗号分隔;如果不需要参数,那么方法的调用必须显示的使用括号。

def add(number) { 1 + number }

//DSL调用

def res = add 1

println res

也支持级联调用方式,举例来说,a b c d 实际上就等同于 a(b).c(d)

//定义

total = 0

def a(number) {

total += number

return this

}

def b(number) {

total *= number

return this

}

//dsl

a 2 b 3

println total

(3)无参方法调用

我们结合 Groovy 中对属性的访问就是对 getXXX 的访问,将无参数的方法名改成 getXXX 的形式,即可实现“调用无参数的方法不需要括号”的语法!比如:

def getTotal() { println "Total" }

//DSL调用

total

(4)MOP

MOP:元对象协议。由 Groovy 语言中的一种协议。该协议的出现为元编程提供了优雅的解决方案。而 MOP 机制的核心就是 MetaClass。

有点类似于 Java 中的反射,但是在使用上却比 Java 中的反射简单的多。

常用的方法有:

invokeMethod()

setProperty()

hasProperty()

methodMissing()

以下是一个methodMissing的例子:

detailInfo = [:]

def methodMissing(String name, args) {

detailInfo[name] = args

}

def introduce(closure) {

closure.delegate = this

closure()

detailInfo.each {

key, value ->

println "My $key is $value"

}

}

introduce {

name "zx"

age 18

}

(5)定义和脚本分离

@BaseScript 需要在注释在自定义的脚本类型变量上,来指定当前脚本属于哪个Delegate,从而执行相应的脚本命令,也使IDE有自动提示的功能:

脚本定义

abstract class DslDelegate extends Script {

def setName(String name){

println name

}

}

脚本:

import dsl.groovy.SetNameDelegate

import groovy.transform.BaseScript

@BaseScript DslDelegate _

setName("name")

(6)闭包委托

使用以上介绍的方法,只能在脚本里执行单个命令,如果想在脚本里执行复杂的嵌套关系,比如Gradle中的dependencies,就需要@DelegatesTo支持了,@DelegatesTo执行了脚本里定义的闭包用那个类来解析。

上面提到一个DSL脚本就是一个闭包,这里的DelegatesTo其实定义的是闭包里面的二级闭包的格式,当然如果你乐意,可以无限嵌套定义。

//定义二级闭包格式

class Conf{

String name

int age

Conf name(String name) {

this.name = name

return this

}

Conf age(int age) {

this.age = age

return this

}

}

//定义一级闭包格式,即脚本的格式

String user(@DelegatesTo(Conf.class) Closure closure) {

Conf conf = new Conf()

DefaultGroovyMethods.with(conf, closure)

println "my name is ${conf.name} my age is ${conf.age}"

}

//dsl脚本

user{

name "tom"

age 12

}

(7)Java加载并执行脚本

脚本可以在IDE里直接执行,大多数情况下DSL脚本都是以文本的形式存在数据库或配置中,这时候就需要先加载脚本再执行,加载脚本可以通过以下方式:

CompilerConfiguration compilerConfiguration = new CompilerConfiguration();

compilerConfiguration.setScriptBaseClass(DslDelegate.class.getName());

GroovyShell shell = new GroovyShell(GroovyScriptRunner.class.getClassLoader());

Script script = shell.parse(file);

给脚本传参数,并得到返回结果:

Binding binding = new Binding();

binding.setProperty("key", anyValue);

Object res = InvokerHelper.createScript(script.getClass(), binding).run()

2、Groovy DSL示例

(1)需求

假设我们要做一个备忘录生成器。备忘录有to、from、body 三个字段,它也可以包含如 Summary、Important等动态字段,最后它可以以 xml、html、text三种格式输出。

Groovy中DLS实现后的效果,如下:

MemoDsl.make {

to 'Nirav Assar'

from 'Barack Obama'

body 'How are things? We are doing well. Take care'

idea 'The economy is key'

request 'Please vote for me'

xml

}

输出结果如下(DSL的最后一行 决定输出格式,可以xml、html、text):

Nirav Assar

Barack Obama

How are things? We are doing well. Take care

The economy is key Please vote for me

(2)实现

定义接收类

MemoDsl类中make静态方法,会创建一个MemoDsl实例,并委托给闭包。后续to、from方法,将调用到MemoDsl实例上,在调用to()方法后,文本将保存在实例中,以便稍后使用。

class MemoDsl {

String toText

String fromText

String body

def sections = []

// mark方法需要接受一个闭包,并委托closure方法到memoDsl,所以DSL方法才能生效

def static make(closure) {

MemoDsl memoDsl = new MemoDsl()

// 任务调用到闭包的方法,都将委托给memoDsl实例

closure.delegate = memoDsl

closure()

}

// 将参数保存到变量中,以便稍后使用

def to(String toText) {

this.toText = toText

}

def from(String fromText) {

this.fromText = fromText

}

def body(String bodyText) {

this.body = bodyText

}

}

处理动态属性

当闭包包含了MemoDsl类不存在的方法时,groovy会将方法标识为缺失方法。它会通过groovy的元对象协议,调用到MemoDsl的methodMissing接口上。这也是我们能正确处理idea、request字段的原因。

MemoDsl.make {

to 'Nirav Assar'

from 'Barack Obama'

body 'How are things? We are doing well. Take care'

idea 'The economy is key'

request 'Please vote for me'

xml

}

处理缺失属性的方法如下:

// 当遇到缺失属性时,groovy通过元对象协议,调用methodMissing方法

def methodMissing(String methodName, args) {

def section = new Section(title: methodName, body: args[0])

sections << section

}

处理输出格式

最后,DSL输出各种格式呢?闭包中的最后一行指定了所需的输出。当闭包包含一个没有参数的字符串(如“xml”)时,groovy会假定这是一个“getter”方法。因此,我们需要实现“getXml()”来捕获委托执行:

// 指定xml、html、text时,默认会调用get...方法

def getXml() {

doXml(this)

}

// 使用MarkupBuilder输出xml

private static doXml(MemoDsl memoDsl) {

def writer = new StringWriter()

def xml = new MarkupBuilder(writer)

xml.memo() {

to(memoDsl.toText)

from(memoDsl.fromText)

body(memoDsl.body)

// 循环创建 动态xml节点

for (s in memoDsl.sections) {

"$s.title"(s.body)

}

}

println writer

}

text和html的输出也类似。

完整代码

pom.xml添加依赖:

org.codehaus.groovy

groovy-all

3.0.10

pom

junit

junit

4.13.2

test

SimpleDslTest.groovy文件:

import groovy.test.GroovyTestCase

import org.junit.Test

class SimpleDslTest extends GroovyTestCase {

@Test

void testDslUsage_outputXml() {

MemoDsl.make {

to 'Nirav Assar'

from 'Barack Obama'

body 'How are things? We are doing well. Take care'

idea 'The economy is key'

request 'Please vote for me'

xml

}

}

@Test

void testDslUsage_outputHtml() {

MemoDsl.make {

to 'Nirav Assar'

from 'Barack Obama'

body 'How are things? We are doing well. Take care'

idea 'The economy is key'

request 'Please vote for me'

html

}

}

@Test

void testDslUsage_outputText() {

MemoDsl.make {

to 'Nirav Assar'

from 'Barack Obama'

body 'How are things? We are doing well. Take care'

idea 'The economy is key'

request 'Please vote for me'

text

}

}

}

import groovy.xml.MarkupBuilder

// 简单DSL示例

class MemoDsl {

String toText

String fromText

String body

def sections = []

// mark方法需要接受一个闭包,并委托closure方法到memoDsl,所以DSL方法才能生效

def static make(@DelegatesTo(MemoDsl.class) Closure closure) {

MemoDsl memoDsl = new MemoDsl()

// 任务调用到闭包的方法,都将委托给memoDsl实例

closure.delegate = memoDsl

closure()

}

// 将参数保存到变量中,以便稍后使用

def to(String toText) {

this.toText = toText

}

def from(String fromText) {

this.fromText = fromText

}

def body(String bodyText) {

this.body = bodyText

}

// 当遇到缺失属性时,groovy通过元对象协议,调用methodMissing方法

def methodMissing(String methodName, args) {

def section = new Section(title: methodName, body: args[0])

sections << section

}

// 指定xml、html、text时,默认会调用get...方法

def getXml() {

doXml(this)

}

def getHtml() {

doHtml(this)

}

def getText() {

doText(this)

}

// 使用MarkupBuilder输出xml

private static doXml(MemoDsl memoDsl) {

def writer = new StringWriter()

def xml = new MarkupBuilder(writer)

xml.memo() {

to(memoDsl.toText)

from(memoDsl.fromText)

body(memoDsl.body)

// 循环创建 动态xml节点

for (s in memoDsl.sections) {

"$s.title"(s.body)

}

}

println writer

}

// 使用MarkupBuilder输出html

private static doHtml(MemoDsl memoDsl) {

def writer = new StringWriter()

def xml = new MarkupBuilder(writer)

xml.html() {

head {

title('Memo')

}

body {

h1('Memo')

h3("To: ${memoDsl.toText}")

h3("From: ${memoDsl.fromText}")

p(memoDsl.body)

// 循环创建节点,并将 内容转换为大写 + 加粗

for (s in memoDsl.sections) {

p {

b(s.title.toUpperCase())

}

p(s.body)

}

}

}

println writer

}

// 使用字符串模板输出text格式

private static doText(MemoDsl memoDsl) {

String template = "Memo\nTo: ${memoDsl.toText}\nFrom: ${memoDsl.fromText}\n${memoDsl.body}\n"

def sectionStrings = ''

for (s in memoDsl.sections) {

sectionStrings += s.title.toUpperCase() + '\n' + s.body + '\n'

}

template += sectionStrings

println template

}

}

class Section {

String title

String body

}

-------------点个赞吧!!当你读到这里,代表这篇文章对你是有价值的,欢迎拍砖。-------------

参考资料

领域驱动设计 (DDD) 总结:https://cloud.tencent.com/developer/article/1662032

使用Groovy构建DSL:https://www.cnblogs.com/lesofn/p/14480455.html

开发复杂的外部 DSL:https://www.infoq.cn/article/external-dsl-vaughn-vernon

DSL编程技术的介绍: https://juejin.cn/post/6844903506659262478

松本行弘推荐的代码生成书籍-《Code Generation in Action》

松本行弘对编程的杂谈-《松本行弘的程序世界》

知名度很高的自我修炼实践:《程序员修炼之道:从小工到专家》

Intellij开源的元编程工具: https://www.jetbrains.com/mps/

王垠对编辑器与IDE的一些思考 : http://www.yinwang.org/blog-cn/2013/04/20/editor-ide

王垠对DSL的看法: http://www.yinwang.org/blog-cn/2017/05/25/dsl

使用Ruby进行元编程的例子:https://shvets.github.io/blog/2013/11/16/two_simple_ruby_dsl_examples.html

为什么没有一种令绝大部分程序员满意的编程语言?https://www.zhihu.com/question/305131584/answer/566237106

DDD:DSL(领域专用语言):http://apframework.com/2019/12/21/ddd-dsl/

领域专用语言实战 (图灵程序设计丛书):https://book.douban.com/subject/25741352/

当DDD遇上DSL:https://www.itdks.com/Act/apply?id=3188

DDD:架构思想的旧瓶新酒:https://www.infoq.cn/article/K6AfHfMlx6IZqKwmpXcu

Groovy DSL A Simple Example:https://www.javacodegeeks.com/2012/08/groovy-dsl-simple-example.html