DSL编程技术的介绍
2016-10-30 / modified at 2022-04-04 / 2.8k words / 10 mins
️This article has been over 2 years since the last update.

本文先介绍了元编程的概念,接着举了几个DSL的例子,最后总结了DSL开发的前景。

DSL的介绍

在讨论DSL之前,首先讨论一下元数据。

定义

元数据指对数据信息的描述,比如Latex对排版的描述,Mac中的plist,gradle对Android项目的描述,Java中的注解,甚至通信领域中通过ASN.1对LTE报文的描述,都可以称作“元数据”。这种描述可以看作为一种抽象语法,即Abstract Syntax。如果这种抽象语法的描述只为特定领域设计,就被称作领域专属语言(DSL, Domain Specific Language)。

DSL的分类举例
内部DSLRuby, Lisp, Java(Annotation), Groovy, kotlin
外部DSLXML, SQL, JSON, Plist, Regex, Markdown, SCSS, React, Excel, Jenkinsfile

再更深入的理解上,它本质上是皮尔斯符号学理论的展开,在符号理论中,对符号有符号形体(represesentamen)、符号对象(object)和符号解释三种层级。

DSL的设计需要满足这三种层级,甚至需要结合高语境进行理解,设计DSL并不是用英语来模拟/简化代码,而是有极高门槛的。

例子

领域专属语言与通用语言是相对的,它一般用于解决特定的专业问题,适合不太懂编程但是又有简化工作需求的专业人员(比如金融、通信、IC),以减少编码工作量。

通过DSL将业务与开发分离

在Java中,甚至不仅在JavaEE中,在Android中也使用范围非常广泛。主要有外部描述与自描述两种方法

  1. 使用xml, json, yaml等标记语言实现对数据的描述,比如通过XML描述如何依赖注入的Spring框架,通过XML实现免硬编码的AOP等,通过yaml发布/订阅微服务,这些一般用于企业项目,配置繁琐但是的确能够处理复杂的业务需求。Android中通过xml描述界面布局等,其实你自定义控件时的app:xx属性就是不经意间新建的元数据。
  2. 使用groovy, 注解等实现对数据的描述,比如ORM框架,SpringBoot框架,SOA框架,通过注解描述信息,并最终通过动态代理将注解拼接为实际请求。或者在React中使用jsx代替生成createElement

举例

下文将具体叙述如何通过DSL将业务与实现分离出来的。

芯片开发

随着芯片面积不断提高,在数字电路中不可能通过手动编写电路图来设计了,而是使用了一种叫做“Verilog”的语言来“约束”硬件的IO逻辑,EDA工具(比如Synopsis)对描述进行编译综合、COT等步骤后、才能生成最终立体的网表(电路),通过这种电路描述语言,将前端数字逻辑设计与后端物理实现分离,提高了项目流水线效率。

此处综合约束、编译、仿真等基础算法,以及全套硬件大部分上是A国垄断,当然也有开源的Yosys

硬件开发

在IC业务层开发中,一般先设计电路,配置IO口,再进行C的高级语言移植。在这个过程中,电子工程师精通电路设计而不懂C语言,如果在使用元数据以前,可能是这样的:比较小的团队可能需要一个同时精通C与电子领域业务的专家来支撑项目,工资高、新人培养难度大不说,极有可能产生单点故障;大的团队可能会将项目文档化,先让电子领域专家写设计原型,然后交给SE写文档,最后让开发照着文档填接口,这样最后的结果就是延长了开发周期,需求易变质,开会次数多。而这些文档、沟通其实是可以用计算机中的电路设计图代劳的。

而使用元数据后,专家可以通过鼠标在软件平台中拖拽来设计芯片、电路等物理数据。软件平台的引擎对设计图进行解析,并能够生成准确的电子元件的C语言头文件。最后C语言开发者通过调用头文件这类SDK进行软件层的开发,进而对硬件进行控制。这样就实现了设计与开发分离,让传统行业(电子通信、电气、汽车等)与IT技术的合作成为可能,并节约了无谓的重复手工劳动时间。

1
2
3
4
5
6
7
8
9
领域专业知识 + 电路设计图(元数据,可能用xml进行描述)
|
|(元数据处理引擎处理)
|
硬件平台的C语言SDK代码桩
|
|(软件开发者,可以找便宜外包,不需要理解物理实现细节)
|
基于SDK的上层开发

上述主要讲的是EDA/CAD工具,目前仍然是国外垄断地位。

JCommand: 通过注解定义Usage

在编写终端程序时,当输入外部参数为空时,一般要输出Help 或者Usage信息,JCommand就是用于处理用户输入数据的Parser,通过定义注解元数据

1
2
3
4
5
6
7
8
9
10
static class Arguments {
@Parameter(names = { "-h", "--help" }, description = "Help message", help = true)
boolean help;

@Parameter(names = { "--conf-file" }, description = "Configuration file")
public String confFile;

@Parameter(names = { "-t", "--num-topics" }, description = "Number of topics")
public int numDestinations = 1;
}

当你运行程序时,比如输入下列参数,JCommand就会自动为confFile对象映射并赋值。

1
./app -t 20 --conf-file /usr/etc/app.cfg

通过上述注解,简化了繁琐的入参命令Parse流程,代码看起来也更简洁。

XML: 通过自然语言编写

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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()

最后将生成

1
2
3
4
5
6
7
8
9
<html>
<head>
<title>Hello</title>
<script src='https://xxxx.com/vue.js' />
</head>
<body>
<p>Excited</p>
</body>
</html>

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

什么时候使用DSL

看了上面的DSL介绍,各位可能激动不已,跃跃欲试了,如果你正在看某个技术汇报胶片,甚至已经激动地打算全员推广DSL了,其实你忘记了DSL的风险,它只是看起来很美好

  • DSL只是前端,有方言问题,如果扩大DSL的规模,方言(dialect)过多,导致调试更加难理解,因此最终DSL的规模最终将被限制到脚本级。
  • DSL与ECO:Berkeley开发了基于Scala的Chisel语言来开发RISC-V处理器,用于提高对硬件的抽象描述,然而它生成的Verilog代码可读性非常差,而且本身Scala的语法糖过多,导致Verilog硬件工程师的学习曲线陡峭。再比如,编译器的词法分析原本可以用JFlex读取某个模版生成状态机,但是大部分实际实现仍然是手写的,因为手写性能更强,而非一堆代码。想必你也不想在生成的这坨代码上进行修改吧,这个术语叫做ECO。
  • DSL可以替换设计模式中的样板代码,甚至可以自动化生成测试用例(比如Dagger生成MockServer与TestCase,通过Swagger生成Feign接口)
  • DSL可以被静态工具类(Utils.java)代替。通用的业务可以被封装为工具类,只是代码更加冗长而已,而DSL只是加上了一层语法糖(比如Groovy的useCategory)。
  • DSL不能解决动态部署/定制类业务。在企业软件开发中,很多人都喜欢动态可配置,甚至用XML创建了<if>等语法(想想ant脚本有多难用吧),也就是直接用XML写AST,这样最终结果痛苦不堪。这类DSL还不如写个Excel宏呢。

DSL不是银弹

各位可能在DSL的书籍中看到通过DSL降低代码量的例子,但是根据香农公式,在总信息量不变的情况下,代码行数越短,它的“潜规则”信息量就越多,而这些一旦出现问题,很难定位。

  • 为了掌握DSL里的Context(语境,参数与潜规则),用户需要翻阅/查询大量使用文档,而普通人的直觉只能进行线性因果思考
  • DSL一般会使用high-level synthesis,,编译生成的“字节码”的过程是黑盒的,不但对内部工作不明朗,如果报错的话,不但堆栈行数无法与源码对应上,而且无法“断点”或者“日志”,这种代码安全感的缺乏,使用者除了使用类似JS中的map文件实现源码映射外,没有什么好办法。
  • DSL对设计者要求极高。DSL不但需要自己设计Interceptor(最简单的Groovy的MethodInvkoing写起来也比较麻烦),还要文档齐全,支撑充分,甚至要开源以帮助使用者定位。最终使用者可能并不买账,而是直接把你引以为豪的DSL再包装一遍。

总结

元驱动开发是高效分工的产物,通过DSL编写一个小范围内使用的语言,可以降低成本,提高程序可维护性。但是DSL一旦没玩好,反而是一个大坑。结合我做过的DSL项目,我的建议如下:

  1. 没有必要强行上,先用Java、环境变量等传统方法实现特例
  2. 将通用的无状态代码下层为祖传Util/FAAS,有状态的代码分解为微服务应用
  3. 复用JVM语言,比如Groovy设计Clousre对工具类进行注入,或者对ScriptEngine进行bind设计DSL。
  4. 对DSL侧进行详细文档(可搜索性),甚至可以开源,而基线工具库不必开源
  5. 工具类型的DSL,建议使用YAML+强Schema校验,而不要自己造轮子

参考与推荐

下面列举了一些书籍与工具

  1. 松本行弘推荐的代码生成书籍-《Code Generation in Action》
  2. 松本行弘对编程的杂谈-《松本行弘的程序世界》
  3. 知名度很高的自我修炼实践-《程序员修炼之道:从小工到专家》
  4. Intellij开源的元编程工具 - https://www.jetbrains.com/mps/
  5. 王垠对编辑器与IDE的一些思考 - http://www.yinwang.org/blog-cn/2013/04/20/editor-ide
  6. 王垠对DSL的看法 - http://www.yinwang.org/blog-cn/2017/05/25/dsl
  7. 使用Ruby进行元编程的例子 - two simple ruby dsl examples introduction
  8. 更广义的提高效率 - 正确地做事(善用自动化)