收藏不读系列:DSL 领域特定语言

原创
2022/03/12 20:34
阅读数 2.3K

一、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()

最后将生成

<html>
  <head>
    <title>Hello</title>
    <script ahref='https://xxxx.com/vue.js' />
  </head>
  <body>
    <p>Excited</p>
  </body>
</html>

这里相对于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<Conf> 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.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):

<memo>
     <to>Nirav Assar</to>
     <from>Barack Obama</from>
     <body>How are things? We are doing well. Take care</body>
     <idea>The economy is key</idea>
     <request>Please vote for me</request>
 </memo>

(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添加依赖:

<dependencies>
  <dependency>
    <groupId>org.codehaus.groovy</groupId>
    <artifactId>groovy-all</artifactId>
    <version>3.0.10</version>
    <type>pom</type>
  </dependency>
  <dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.13.2</version>
    <scope>test</scope>
  </dependency>
</dependencies>

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<MemoDsl> 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
}

 

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

 

参考资料

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
打赏
0 评论
4 收藏
0
分享
返回顶部
顶部