领域特定语言

978-7-115-56316-3
作者: 马丁·福勒(Martin Fowler)
译者: 徐昊郑晔熊节
编辑: 刘雅思

图书目录:

详情

《领域特定语言》是领域特定语言(Domain-Specific Language,DSL)领域的丰碑之作,由世界级软件开发大师马丁·福勒(Martin Fowler)历时多年写作而成。 全书共57章,分为6个部分,全面介绍了DSL概念、DSL常见主题、外部DSL主题、内部DSL主题、备选计算模型以及代码生成等内容,揭示了与编程语言无关的通用原则和模式,阐释了如何通过DSL有效提高开发人员的生产力以及增进与领域专家的有效沟通,能为开发人员选择和使用DSL提供有效的决策依据和指导方法。 本书适合想要了解各种DSL及其构造方式,理解其通用原则、模式和适用场景,以提高开发生产力和沟通能力的软件开发人员阅读。

图书摘要

版权信息

书名:领域特定语言

ISBN:978-7-115-56316-3

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。


著    [美] 马丁•福勒(Martin Fowler)

译    徐 昊 郑 晔 熊 节

审  校 姚琪琳 黄进军 钟 敬

责任编辑 刘雅思

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315


Authorized translation from the English language edition, entitled DOMAIN-SPECIFIC LANGUAGES, 1st Edition by FOWLER, MARTIN, published by Pearson Education, Inc, Copyright © 2011 Martin Fowler.

All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from Pearson Education, Inc.

CHINESE SIMPLIFIED language edition published by POSTS & TELECOM PRESS, Copyright © 2021.

本书中文简体字版由Pearson Education Inc授权人民邮电出版社独家出版。未经出版者书面许可,不得以任何方式复制或抄袭本书内容。

本书封面贴有Pearson Education(培生教育出版集团)激光防伪标签,无标签者不得销售。

版权所有,侵权必究。


《领域特定语言》是领域特定语言(Domain-Specific Language,DSL)领域的丰碑之作,由世界级软件开发大师马丁·福勒(Martin Fowler)历时多年写作而成。

全书共57 章,分为6 个部分,全面介绍了DSL 概念、DSL 常见主题、外部DSL 主题、内部DSL 主题、备选计算模型以及代码生成等内容,揭示了与编程语言无关的通用原则和模式,阐释了如何通过DSL 有效提高开发人员的生产力以及增进与领域专家的有效沟通,能为开发人员选择和使用DSL 提供有效的决策依据和指导方法。

本书适合想要了解各种DSL 及其构造方式,理解其通用原则、模式和适用场景,以提高开发生产力和沟通能力的软件开发人员阅读。


在我开始编程生涯之前,领域特定语言(Domain-Specific Language,DSL)就已经成了程序世界中的一员。随便找个UNIX或者Lisp老手问问,他一定会跟你滔滔不绝地谈起DSL是怎么成为他的镇宅之宝的,直到你被烦得痛不欲生为止。尽管如此,DSL却从未处于聚光灯下。大多数人是从别人那里学到DSL的,而且只是学到了有限的几种技术。

我写这本书就是为了改变这一现状。我的意图是介绍广泛的DSL技术,让你能够做出明智的决策:什么时候在工作中使用DSL,选择哪种DSL技术。

DSL 流行的原因很多,我只强调两点:一是提升开发人员的生产力;二是增进与领域专家的沟通。如果DSL选择得当,就可以使一段复杂的代码变得清晰易懂,从而提升处理这段代码的效率。同时,如果有一段通用的文字既可作为可执行软件,又可充当功能描述,让领域专家能理解他们的想法是如何在系统中得以体现的,那么开发者和领域专家间的沟通就会更加顺畅。增进沟通比提升生产率要难一些,收益面却更为广泛,因为这有助于打通软件开发中最狭窄的瓶颈——开发者和客户之间的沟通。

DSL的价值也不应被夸大。我常说,无论什么时候谈到DSL的优缺点,都可以考虑把“DSL”换成“库”。能够从DSL中获得的多数收益,也可以通过构建框架获得。实际上,大多数DSL只不过是框架或库上的一层薄薄的门面(facade)。因此,DSL的成本和收益往往会比人们预想的小,但也未曾得到过充分的认识。掌握良好的技术可以大大降低构建DSL的成本,希望本书可以帮你做到这一点。这层门面虽薄,但是实用,值得一试。

DSL 由来已久,但直到近些年,人们对它的兴趣才有了显著的提升。与之同时,我决定用几年的时间写这本书。为什么呢?虽然我不知道自己是否可以给这一现象提供一个权威的解释,但我可以分享一下自己的观点。

在千禧年到来的时候,编程语言世界中(至少在我的企业软件世界中)出现了一种势不可挡的标准化的观念。先是Java,它在几年的时间里风光无限。即使后来微软推出的C#挑战了Java的统治地位,这个新生者依然是一门与Java很相似的语言。新时代的软件开发被编译型的、静态的、面向对象的、语法格式与C类似的语言统治着。(甚至连Visual Basic都被弄得尽可能地看起来接近这些性质。)

但人们很快发现,并不是所有的事情都能在Java/C#的霸权下良好运作。有些重要的逻辑用这些语言不能很好地实现,于是XML配置文件兴起了。不久之后,程序员就开玩笑说,他们写的XML代码比Java/C#代码都多。这固然有一部分原因是想在运行时改变系统行为,但也体现了另一种用更容易定制的方式来表达系统行为的各个方面的想法。XML的语法虽然十分烦琐,但确实可以让你定义自己的词汇,而且提供了非常强大的层次结构。

不过后来人们实在忍受不了XML的烦琐了。人们抱怨尖括号刺伤了他们的双眼。他们希望既能够享受XML配置文件带来的好处,又不用承受XML的代价。

到了21世纪的头十年,Ruby on Rails横空出世。不管Rails这个实用平台在历史上会占据什么样的位置(我觉得Rails确实是一个优秀的平台),它都已经给人们对框架和库的认识造成了深远的影响。Ruby社区有一种很重要的做事方式:让一切显得更加连贯。换句话说,在调用库的时候,就像用一种专门的语言进行编程一样。这不禁让我们想起一门古老的编程语 言——Lisp。这种方式也让人看到了在Java/C#这片坚硬的土地上绽开的花朵:在这两门语言中,连贯接口(fluent interface)都变得流行起来,这大概要归功于JMock和Hamcrest创始人的持久影响。

回头看看这一切,我发现这里存在着知识壁垒。有的时候,使用定制的语法会更容易理解,实现也不难,人们却用了XML;有的时候,使用定制的语法会简单很多,人们却把Ruby用得十分扭曲;有的时候,本来在常用的语言中使用连贯接口就可以轻易达成的事情,人们却非要玩起语法分析器。

我假设出现这些问题的原因是知识壁垒的存在。熟练的程序员对DSL技术了解不够,无法对使用哪些DSL技术做出明智的判断。这就是我想要填补的空白。

2.2节将讲述更多为何使用DSL的细节。我觉得需要学习DSL(以及本书中提到的技术)的原因主要有两点。

第一点是提升程序员的开发效率。先看一下下面这段代码:

input =~ /\d{3}-\d{3}-\d{4}/

你会认出这是一个正则表达式,也许还知道它匹配的代码是什么。正则表达式常常被指太让人费解,但试想一下,如果只能使用普通控制代码,你会怎样编写这段模式匹配代码?这段代码与正则表达式相比,哪个更容易理解和修改?

DSL 擅长在程序中某些特定的部分发挥作用,让它们容易被理解,进而提高编写和维护的速度,并且减少bug。

DSL 的第二个优势就不仅限于程序员的范畴了。因为DSL往往短小易读,所以非程序员也能看懂这些驱动着其重要业务的代码。把这些实际的代码暴露在领域专家面前,程序员和客户之间就有了非常顺畅的沟通渠道。

谈起这类事情,人们常说DSL可以让你不再需要程序员了。我对此深表怀疑,毕竟人们也曾这样说过COBOL。不过有些语言确实是给那些不以程序员自居的人来用的,如CSS。对这一类语言来说,读比写要重要得多。如果领域专家可以阅读并且很大程度上理解核心业务代码,那么他就可以跟编写这段代码的程序员进行更加深入、细致的交流。

DSL的第二个优势的达成并非易事,但从其回报来看是很值得的。软件开发中最狭窄的瓶颈就是程序员和客户之间的交流,任何可以解决这一问题的技术都值得一试。

看到这本书这么厚,你可能会吓一跳吧?我自己发现写了这么多内容的时候也忍不住倒吸一口冷气。我对大部头的态度总是小心翼翼的,因为人们用来阅读的时间是有限的,一本厚书就意味着时间上的大量投入(这比书价值钱多了)。所以,在这种情况下我倾向于将本书拆分为“姊妹篇”两大板块。

本书两大板块的内容都足以单独成书。第一个板块是叙述性的概述,需要从头到尾阅读。我希望它可以大致描述出DSL的主要内容,让人有一个整体认识就可以,不用深入细节。我觉得这部分最好不要超过150页,这是比较合理的厚度。

第二个板块是参考资料,篇幅更大一些。这个板块的内容不需要逐页阅读(虽然也有人这么做),用得着的时候再仔细看就行。有些人喜欢先读完第一个板块,有了整体认识之后,再去看第二个板块里感兴趣的章节。有些人喜欢一边读第一个板块,一边找第二个板块里感兴趣的地方看。之所以采用这种划分方式,主要是因为我想让读者了解哪些地方可以跳过,哪些地方不能,这样读者就可以有选择地深入阅读了。

我已经尽力让参考资料板块独立成篇。这样,如果你想使用树构造(第24章),你就去读那个模式,即便对概述板块的记忆已经有些模糊了,也能知道怎么去做。这样一来,一旦你完全理解了概述板块,这本书就变成了参考手册,想查详细资料的话,翻阅一下就能找到。

本书之所以篇幅这么大,是因为我没能找到缩小篇幅的方法。本书的一个主要目的是探索DSL可用的各项技术的广度。讨论代码生成、Ruby元编程、语法分析器生成器(第23章)工具的书有很多,我想在这本书里涵盖所有这些技术,让你了解它们的异同。它们都在更广阔的舞台上发挥着各自的作用。我的目的是既要带你从宏观上进行了解,又要提供足够的细节,以帮你上手使用这些技术。

本书将全面介绍各种DSL及其构建方法。人们开始尝试使用DSL的时候,通常只会选择一种技术。而本书则会介绍多种不同的技术,从而让人可以根据情况做出最佳选择。书中还提供了很多DSL技术的实现细节和例子。当然,我无法写出所有的细节,但也足以帮助你做出早期的决策。

前几章讲述什么是DSL、DSL的用途以及DSL与框架和库相比的作用。实现DSL的章节可以帮你理解如何构建外部DSL和内部DSL。有关外部DSL的内容会介绍语法分析器的作用、语法分析器生成器(第23章)的用途以及用语法分析器解析外部DSL的各种方式。有关内部DSL的内容会展示如何以DSL的风格使用各种语言构造。虽然这无法告诉你怎样用好特定的语言,但是可以帮你理解这些技术在不同语言间的对应关系。

生成代码的章节列出了生成代码的各种策略,需要时可以看一下。第9章讲解语言工作台,简要介绍了一种新一代的工具。本书介绍的绝大部分技术已经存在很长时间了,而语言工作台则是一种面向未来的技术,虽然应当有美好的前景,但尚未得到足够的验证。

本书面向的主要读者是那些正在考虑构建DSL的专业软件开发者。我觉得这类读者应该至少具有若干年的工作经验,并认同软件设计的基本思想。

如果你深入研究过语言设计,那么本书中大概不会有什么你没有接触过的内容。我倒是希望我在书中整理并表述信息的方式对你有所帮助。虽然人们在语言设计方面做了大量的工作——尤其是在学术界,但进入专业编程领域的成果寥寥无几。

第一部分的前几章也适用于任何想要了解DSL的基本概念和使用价值的读者。通读这一部分可以对DSL所采用的不同实现技巧有概要性的了解。

本书和我写过的大部分书一样,独立于具体的编程语言。我最主要的目的是揭示那些可以用于你手头的任何编程语言的通用原则和模式。所以,不管你用的是哪种现代的面向对象语言,书里的思想都会为你提供帮助。

函数式语言可能会与本书中的一些思想相左。虽然我觉得很多内容依然适用,但我在函数式编程中的经验尚不足以让我判断这种编程范式到底会在多大程度上影响书中的建议。本书对过程式语言(即非面向对象语言,如C语言)的作用也很有限,因为我介绍的很多技术依赖于面向对象。

虽然我写的是通用原则,但为了把它们恰当地讲述出来,还是需要用一些例子来说明,这就需要用一门具体的编程语言来写。在选择用哪门语言来写例子的时候,我的首要标准是有多少人能读懂它。于是绝大多数例子是用Java或C#写的。这两门编程语言在业界被广泛使用,有很多相似之处:类C的语法、内存管理以及提供了各种便利的类库。但我的意思并不是它们就是写DSL的最佳选择(这里特别强调,因为我根本就不这么认为),而是它们最能够帮助读者理解我讲的通用概念。我尽力让二者出现的机会均等,只有在某种语言用起来更方便的时候,才会打破这一平衡。虽然内部DSL的良好运用常常要用到某些另类的语法特色,但我尽力避免使用需要太多语法知识才能理解的语言元素——这着实挺困难的。

还有一些思想是必须使用动态语言才能满足的,而不能用Java或C#实现。这种情况下我就会改用Ruby,因为这是我最熟悉的动态语言。Ruby非常适合编写DSL,这一点对我很有帮助。再强调一点,虽然我个人更熟悉某种语言,在选择时也考虑了个人偏好,但不要因此推断这些技术就不能用于其他语言了。我很喜欢Ruby,但如果你胆敢贬低Smalltalk,就会看到我对语言真正偏执的一面。

值得一提的是,许多其他语言也适合构建DSL,其中有些还是专门为了编写内部DSL而设计的。我之所以没有提到它们,是因为我对它们所知不多,没有足够的信心对它们进行评价。请不要认为我对它们有什么负面看法。

特别强调的是,要写一本独立于语言的DSL书,最大的困难是许多技术的应用恰恰要依赖于特定语言的特性。为了达到广泛的通用性,我做了很多权衡,但你必须意识到,这些权衡可能会被具体的语言环境彻底改变。

在写这样一本书的过程中,最让人沮丧的莫过于意识到必须停笔的那一刻。我为本书投入了数年的时间,我相信书中有很多内容值得阅读。但我也知道我留了很多坑没填。我本来是想填的,可这需要大量的时间。我的信念是,宁可出版一本不完美的书,也不要为了书的完美再拖上几年——即便这种完美真的可能出现。下面简要介绍一下我已经看到但没有时间填的坑。

我在前面曾提到过一点——函数式语言。实际上,在基于ML或Haskell的现代函数式语言中,构建DSL有悠久的历史。而我在书中基本没有提到这部分内容。一个有趣的问题是:假如我熟悉函数式语言及其DSL的用法,本书的内容结构可能会发生多大的改变?

或许本书中最令人沮丧的是没有对诊断和错误处理进行充分的讨论。我记得上大学的时候学过,在写编译器的过程中诊断部分是何等艰难。所以我意识到忽略这一点其实掩盖了一个相当重要的主题。

我个人最喜欢第7章讲述的备选计算模型。这里有太多的东西可以写,只可惜时不我与。最后我只好决定少写一些——希望书中的内容依然可以激发你探索更多模型的兴趣。

尽管叙述部分的结构比较普通,但参考资料部分需要再多介绍一下。我把参考资料分成一系列主题,这些主题分散在不同的章中,以便保持类似的主题被放在一起。我的想法是每个主题都可以独立成篇,这样读者读完叙述部分以后,就可以深入了解某个特定的主题而无须涉及其他主题。若有例外,我会在对应主题的开头提到。

大部分主题以模式的形式呈现。模式的焦点是对一再重复出现的问题的通用解决方案。所以,如果有一个常见的问题“我该怎么设计语法分析器的结构呢?”,解决方案的两种可行模式是分隔符制导翻译(第17章)和语法制导翻译(第18章)。

近20年来,人们写了很多软件开发模式的书,不同作者的观点也不同。我的看法是,模式提供了一种出色的用于组织参考资料的方式,正如本书中所做的那样。叙述部分告诉你,如果想要解析文本,可以考虑上面两种模式,但模式本身提供了更多的信息以供选择和实施。

参考资料部分大多是以模式的结构来写的,但也有例外。对我而言,并不是所有的主题都是解决方案。对于有些主题,如嵌套运算符表达式(第29章),其焦点不是解决方案,并且该主题也不符合模式的结构,所以我没有采用模式风格的描述方式。还有一些情况很难称之为模式,如宏(第15章)和BNF(第19章),可是用模式结构来描述它们却很合适。总的来说,我的判断标准是,模式结构(尤其是把“运行机制”和“使用时机”分离开的形式)是否有助于描述相关概念。

多数作者在编写模式的时候用了一些标准模板。我也不例外,既用了一个标准模板,又跟别人用的有所区别。我采用的模板(或者说模式)的形式,是我在《企业应用架构模式》[Fowler PoEAA]中首次使用的,它的形式如下。

模板中最重要的元素大概要数名字了。我喜欢将模式作为参考资料部分的各个主题,其最主要的原因在于这样有助于创建一个强大的词汇表,从而方便展开讨论。虽然这个词汇表不一定能得到广泛应用,但至少可以让我的写作保持一致性,也可以在别人想要用这个模式的时候,为其提供一个上手的起点。

接下来的两个元素是意图概要。它们对模型进行简要的概括,还能起到提醒作用,如果你已“将模式纳入囊中”,但忘了名字,它们可以唤起你的记忆。意图是用一两句话总结模式,而概要是模式的一种可视化表示——有时候是一张图,有时候是代码示例,不管是什么形式,只要能够快速解释模式的本质就可以。如果采用图的形式,我有时候会用UML,不过要是有其他方式更容易表达意图的话,我也很乐意采用。

接下来就是稍长一些的摘要了。我一般会在相应位置给出一个例子,用来说明模式的用途。摘要由几段话组成,同样是为了让读者在深入细节之前先了解模式的全貌。

模式有两个主体部分:运行机制使用时机。这两部分没有固定顺序,如果你想了解是否该用某个模式,可能就只想读“使用时机”这部分。不过,一般来说,不了解运行机制的话,只看“使用时机”是没什么意义的。

最后一部分是例子。我尽力在“运行机制”一节把模式的工作原理讲清楚,但人们一般还是需要通过代码来理解。代码示例是有风险的,它们演示的只是模式的一种应用场景,而有些人却会以为模式只有这个用法,这是因为没有理解其背后的概念。你可以把一种模式用上千百遍,每次稍稍有些差异,可我没有足够的空间和精力写那么多代码。所以请记住,模式的含义远远不止你从代码示例中看到的这些。

所有的例子都设计得非常简单,只关注要讨论的模式本身。我用的例子都是相互独立的,目的是使每一个参考章节都独立成篇。一般来说,在实际应用模式的时候还会有其他一堆问题要处理,但在一个简单的例子中,起码可以让你有机会理解问题的核心。丰富的例子更贴近现实,可它们也会引入大量与当前模式无关的问题。于是我只会展示一些片段,你需要自己把它们组装起来,以满足特定的需求。

这也意味着我在代码中主要追求的是可理解性。我没有考虑性能和异常处理等因素,因为这些只会把你的注意力从模式的本质转移到别处。

我力图避免编写难以借鉴的代码,即便这更符合特定语言的惯用写法。这种折中在内部DSL上会显得有些笨拙,因为内部DSL经常要靠某些晦涩的语言技巧来强化语言的连贯性。

书中的很多模式会缺少上面讲的一两个部分,因为我觉得确实没有什么需要写进去的。有些模式没有例子,因为最合适的例子在其他模式里面用到了——在发生这种情况的时候,我会指出来。

我每次写书的时候,都有很多人提供了大量的帮助。虽然作者署的是我的名字,但许多朋友为提高本书的质量起了很大的作用。

首先要感谢的是我的同事Rebecca Parsons。我对DSL这个话题曾有很多顾虑,例如,它会涉及很多学术背景的知识,而那些是我所不熟悉的。Rebecca有深厚的语言理论背景,她在这方面给了我很多帮助。此外,她也是我们公司的首席技术探路人和战略家,因此她可以将学术背景和大量的实践经验合二为一。她本来有能力并且也愿意为本书付出更多心血,但ThoughtWorks在其他方面更需要她。我很高兴与她在DSL这个话题上聊了那么长时间。

作者总是希望(并且带着小小的恐惧)审校人可以通读全书,找出不计其数的大大小小的错误。我幸运地找到了Michael Hunger,他的审校工作做得极其出色。从这本书刚刚出现在我网站上的时候,他就开始不断地给我挑错,并给出改正的建议,这正是我需要的态度。他同时也推动我详细介绍了使用静态类型的技术,尤其是静态类型的符号表(第12章)。他给我提供了无数建议,足以再写两本书了。我希望有朝一日可以把这些想法写下来。

在过去的几年里,我和同事们,包括Rebecca Parsons和Neal Ford,写过很多这方面的文章。在这本书里,我把他们的一些成形的想法也借鉴了过来。

ThoughtWorks慷慨地给了我大量的时间来写这本书。我曾经用了很长的时间决定不再为某一家公司工作,但ThoughtWorks让我很愿意留下来,而且很高兴参与它的建设。

这本书还有很多正式的审校者,他们为本书提供了大量建议,并找出了很多错误。他们是:David Bock、David Ing、Gilad Bracha、Jeremy Miller、Aino Corry、Ravi Mohan、Sven Efftinge、Terance Parr、Eric Evans、Nat Pryce、Jay Fields、Chris Sells、Steve Freeman、Nathaniel Schutta、Brian Goetz、Craig Taverner、Steve Hayes、Dave Thomas、Clifford Heath、Glenn Vanderburg和Michael Hunger。

我还欠David Ing一个虽小但很重要的感谢,是他提出了“DSL集锦”这个名字。

成为一个系列书的编辑之后,我就有了些美妙的特权。例如,我拥有了一个很出色的作者团队,他们可以帮我出谋划策。其中我尤其要感谢Elliotte Rusty Harold,他提供了很多出色的建议。

很多ThoughtWorks的同事也成了我创意的源泉。非常感谢过去几年里允许我在各个项目中探索的每个人。我所看到的点子比能写下来的要多得多,能拥有这样一座丰富的宝藏,我感到无比愉悦。

有些人给本书的Safari在线图书初稿提供了很多建议,我在正式付梓之前也参考了他们的想法。这些人是:Pavel Bernhauser、Mocky、Roman Yakovenko、tdyer。

我还要谢谢本书出版商Pearson的工作人员。Greg Doench是本书的组稿编辑,他负责出版的整体流程。John Fuller是本书的执行编辑,他监管生产流程。

我粗略的文字经过Dmitry Kirsanov的斧正,才可称得上一部著作。Alina Kirsanova排定了本书的布局,并制作了索引。


本书由异步社区出品,社区(https://www.epubit.com/)为您提供相关资源和后续服务。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,点击“提交勘误”,输入勘误信息,点击“提交”按钮即可(见下图)。本书的作者和编辑会对您提交的勘误信息进行审核,确认并接受您的建议后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发送邮件给我们;有意出版图书的作者也可以到异步社区在线投稿(直接访问https://www.epubit.com/contribute即可)。

如果您来自学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发送邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近30年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、AI、测试、前端和网络技术等。

异步社区

微信服务号



落笔之初,我需要快速解释一下要写的内容,即什么是领域特定语言(Domain-Specific Language,DSL)。我喜欢先展示一个具体的例子,随后再下一个更抽象的定义。因此,本章会从一个示例开始来说明DSL可以采用的不同形式。在第2章中,我会试着给出一个广泛适用的、宽泛的定义。

在我的童年记忆里,电视上播放的那些低劣的冒险电影是模糊却持久的。通常,这些电影的场景会安排在某个古旧的城堡里,有着重要的密室或通道。为了找到它们,主角们需要拉动楼顶的烛托,然后轻轻敲打墙壁两次。

我们想象有这样一家公司,他们要根据这个想法构建一套安全系统。他们进入城堡之后,设置某种无线网络,并安装一些小型设备。如果发生了一些事情,这些设备就会发出四字符消息。例如,打开抽屉时,抽屉上附着的传感器就会发出D2OP消息。此外,还有一些小的控制设备,它们对四字符命令消息进行响应。例如,某个设备一收到D1UL消息,就可以打开一扇门上的锁。

所有这一切的核心是控制器软件,它会监听事件消息,弄清楚要做什么,然后发送命令消息。在那个.com不景气的年代,这个公司买到了一堆可以用Java控制的烤面包机,并用它们来做控制器。因此,只要客户买了古堡安全系统,公司就会进驻古堡,为其装上一大堆设备,还有一个烤面包机,里面安装了Java编写的控制程序。

就这个示例而言,我的关注点在于这个控制程序。每个客户都有各自的需求,但是,只要看到一些好的样本,我们就很容易看出常见的模式。为了打开密室,格兰特女士要关闭卧室的房门,打开抽屉,然后开一盏灯。而肖女士则先要打开水龙头,然后打开有机关的灯来开启两个密室中的一个。史密斯女士的密室则位于她办公室内一个上锁的壁橱里,她必须先关上门,把墙上的画摘下来,开关桌上的灯3次,打开文件柜最上面的抽屉。这时,壁橱就能打开了。但是,如果她在打开里面的密室前忘了关上桌上的灯,就会引发警报。

虽然这个例子有点天马行空,但它所要表达的意图很常见:我们有这样一系列系统,它们共享着大多数组件和行为,彼此间却存在一些较大的差异。在这个例子里,对所有客户来说,控制器发送和接收消息的方式是相同的,但是产生的事件和发送的命令的序列不尽相同。我们要好好安排一下这些东西,这样公司才能以最小的代价去安装一个全新的系统。因此,为控制器编写行为序列必须非常简单才行。

看了所有这些情况,人们的脑子里就会涌现出一种良好的处理方式:把控制器看作状态机。每个传感器都可以发送事件以改变控制器状态。当控制器进入某种状态时,可以在网络上发出一条命令消息。

此刻,我得承认,在刚开始写作本书时我的想法并不是这样的。状态机是一个很好的DSL的例子,因此,我先选了它。之所以选择古堡,是因为我厌倦了其他所有的状态机的例子。

这家神秘的公司拥有着成千上万满意的客户,但在这里,我们准备只关注其中的一位:格兰特女士,我最喜欢的客户。她的卧室里有个密室,通常会紧锁着,隐蔽得很好。要打开这个密室,她必须关上门,然后拉开柜子里的第二个抽屉并打开床边的灯(二者的操作顺序任意)。一旦完成这些操作,密室就会解锁并打开了。

我用一张状态图来表示这个序列(图1-1)。

图1-1 格兰特女士密室的状态图

你可能没接触过状态机,它们是一种常见的描述行为的方式——并非广泛适用,但对于描述类似于这样的情况再合适不过了。其基本的想法是,控制器可以处于不同的状态。当处于某个特定的状态时,某种事件会把控制器迁移到另一个状态,从而具有不同的状态迁移。因此,一系列的事件会让控制器在不同的状态之间迁移。在这个模型里,控制器进入某一状态时,会做出一些动作(如发送命令消息)。(其他类型的状态机可能会在不同的地方做出动作。)

基本上,这个控制器就是一个简单而传统的状态机,不过做了一些微调。客户的控制器要有一个明确的空闲(idle)状态,系统会有大部分的时间处于这种状态。即使系统正处于状态迁移的中间过程,某种特定的事件也可以让系统跳回到这个空闲状态,从而重置整个模型。在格兰特女士的这个例子里,开门就是这样一个重置事件。

引入重置事件,意味着这里描述的状态机并不完全适用于某种经典的状态机模型。状态机有几种非常有名的变体,该模型就是在其中一个变体的基础上,做了一些调整,增加了这种情况所独有的重置事件。

需要特别注意的是,严格说来,要表示格兰特女士的控制器并不一定非要使用重置事件。一种替代方案是,为每个状态添加一个状态迁移,只要由doorOpened触发,就会迁移到空闲状态。然而重置事件这个想法很有用,它简化了整个状态图。

如果团队认为状态机是对控制器工作原理的一个恰当的抽象,下一步就是要确保在软件中实现这一抽象。如果人们在考虑控制器行为的同时,也考虑了事件、状态和状态迁移,那么我们希望这些词汇也可以出现在软件代码里。从本质上来说,这就是领域驱动设计(Domain-Driven Design,DDD)中的通用语言(Ubiquitous Language)[Evans DDD]原则,也就是说,我们在领域人员(那些描述建筑安全该如何工作的人)和程序员之间构建了一种可共享的语言。

要用Java来处理这种事,最自然的方式就是使用状态机的领域模型(Domain Model)[Fowler PoEAA](图1-2)。

图1-2 状态机框架的类图

控制器通过接收事件消息和发送命令消息与设备通信。这些消息都是四字符码,它们可以通过通信通道进行发送。在控制器代码里,我想用符号名(symbolic name)来引用这些消息。我创建了事件(Event)类和命令(Command)类,它们都有代码(code)和名字(name)。我把它们放到单独的类里(有一个超类),因为它们在控制器代码里扮演了不同的角色。

class AbstractEvent...
  private String name, code;

  public AbstractEvent(String name, String code) {
    this.name = name;
    this.code = code;
  }
  public String getCode() { return code;}
  public String getName() { return name;}

public class Command extends AbstractEvent

public class Event extends AbstractEvent

状态(State)类记录了它所发送的命令,及其相应的状态迁移。

class State...
  private String name;
  private List<Command> actions = new ArrayList<Command>();
  private Map<String, Transition> transitions = new HashMap<String, Transition>();
class State...
  public void addTransition(Event event, State targetState) {
    assert null != targetState;
    transitions.put(event.getCode(), new Transition(this, event, targetState));
  }

class Transition...
  private final State source, target;
  private final Event trigger;

  public Transition(State source, Event trigger, State target) {
    this.source = source;
    this.target = target;
    this.trigger = trigger;
  }
  public State getSource() {return source;}
  public State getTarget() {return target;}
  public Event getTrigger() {return trigger;}
  public String getEventCode() {return trigger.getCode();}

状态机还保存了其起始(start)状态。

class StateMachine...
  private State start;

  public StateMachine(State start) {
    this.start = start;
  }
  public State getStart() {return Start;}

状态机里的其他任何状态均可从这个起始状态到达。

class StateMachine...
  public Collection<State> getStates() {
    List<State> result = new ArrayList<State>();
    collectStates(result, start);
    return result;
  }

  private void collectStates(Collection<State> result, State s) {
    if (result.contains(s)) return;
    result.add(s);
    for (State next : s.getAllTargets())
      collectStates(result, next);
  }

class State...
  Collection<State> getAllTargets() {
    List<State> result = new ArrayList<State>();
    for (Transition t : transitions.values()) result.add(t.getTarget());
    return result;
  }

为了处理重置事件(resetEvents),我在状态机上保存了重置事件的一个列表。

class StateMachine...
  private List<Event> resetEvents = new ArrayList<Event>();

  public void addResetEvents(Event... events) {
    for (Event e : events) resetEvents.add(e);
  }

像这样用一个单独结构处理重置事件并不是必需的,也可以简单地在状态机上声明一些额外的状态迁移来处理,像这样:

class StateMachine...
  private void addResetEvent_byAddingTransitions(Event e) {
    for (State s : getStates())
      if (!s.hasTransition(e.getCode())) s.addTransition(e, start);
  }

我倾向于在状态机上设置显式的重置事件,这样可以更好地表现意图。虽然,这样确实把状态机弄得有点儿复杂,但它也更加清晰地表现出通用状态机该如何工作,以及定义特定的状态机的意图。

处理完结构,再来看看行为。事实证明,这真的相当简单。控制器有一个handle方法,其参数为从设备接收到的事件编码。

class Controller...
  private State currentState;
  private StateMachine machine;

  public CommandChannel getCommandChannel() {
    return commandsChannel;
  }

  private CommandChannel commandsChannel;

  public void handle(String eventCode) {
    if (currentState.hasTransition(eventCode))
      transitionTo(currentState.targetState(eventCode));
    else if (machine.isResetEvent(eventCode))
      transitionTo(machine.getStart());
      //忽略未知事件
  }

  private void transitionTo(State target) {
    currentState = target;
    currentState.executeActions(commandsChannel);
  }

class State...
  public boolean hasTransition(String eventCode) {
    return transitions.containsKey(eventCode);
  }
  public State targetState(String eventCode) {
    return transitions.get(eventCode).getTarget();
  }
  public void executeActions(CommandChannel commandsChannel) {
    for (Command c : actions) commandsChannel.send(c.getCode());
  }

class StateMachine...
  public boolean isResetEvent(String eventCode) {
    return resetEventCodes().contains(eventCode);
  }

  private List<String> resetEventCodes() {
    List<String> result = new ArrayList<String>();
    for (Event e : resetEvents) result.add(e.getCode());
    return result;
  }

该方法会忽略未在状态上注册的事件。如果事件是可识别的,就会迁移到目标状态,并执行这个目标状态上定义的命令。

至此,我已经实现了状态机模型,我可以像下面这样为格兰特女士的控制器编程:

Event doorClosed = new Event("doorClosed", "D1CL");
Event drawerOpened = new Event("drawerOpened", "D2OP");
Event lightOn = new Event("lightOn", "L1ON");
Event doorOpened = new Event("doorOpened", "D1OP");
Event panelClosed = new Event("panelClosed", "PNCL");

Command unlockPanelCmd = new Command("unlockPanel", "PNUL");
Command lockPanelCmd = new Command("lockPanel", "PNLK");
Command lockDoorCmd = new Command("lockDoor", "D1LK");
Command unlockDoorCmd = new Command("unlockDoor", "D1UL");

State idle = new State("idle");
State activeState = new State("active");
State waitingForLightState = new State("waitingForLight");
State waitingForDrawerState = new State("waitingForDrawer");
State unlockedPanelState = new State("unlockedPanel");

StateMachine machine = new StateMachine(idle);

idle.addTransition(doorClosed, activeState);
idle.addAction(unlockDoorCmd);
idle.addAction(lockPanelCmd);

activeState.addTransition(drawerOpened, waitingForLightState);
activeState.addTransition(lightOn, waitingForDrawerState);

waitingForLightState.addTransition(lightOn, unlockedPanelState);

waitingForDrawerState.addTransition(drawerOpened, unlockedPanelState);

unlockedPanelState.addAction(unlockPanelCmd);
unlockedPanelState.addAction(lockDoorCmd);
unlockedPanelState.addTransition(panelClosed, idle);

machine.addResetEvents(doorOpened);

上面这段代码与之前的代码有很大的不同。之前的代码描述了如何构建状态机模型,而上面这段代码则关于如何为一个特定的控制器配置这个模型。我们常常会看到这样一种划分:一方面是程序库、框架或者组件实现的代码;另一方面是配置或组件的组装代码。从本质上来说,这就分开了公共代码和可变代码。我们用公共代码构建出一套组件,然后出于不同的目的进行配置(图1-3)。

图1-3 单个库多套配置

还有另外一种表示配置代码的方式:

<stateMachine start = "idle">
  <event name="doorClosed" code="D1CL"/>
  <event name="drawerOpened" code="D2OP"/>
  <event name="lightOn" code="L1ON"/>
  <event name="doorOpened" code="D1OP"/>
  <event name="panelClosed" code="PNCL"/>

  <command name="unlockPanel" code="PNUL"/>
  <command name="lockPanel" code="PNLK"/>
  <command name="lockDoor" code="D1LK"/>
  <command name="unlockDoor" code="D1UL"/>

  <state name="idle">
    <transition event="doorClosed" target="active"/>
    <action command="unlockDoor"/>
    <action command="lockPanel"/>
  </state>

  <state name="active">
    <transition event="drawerOpened" target="waitingForLight"/>
    <transition event="lightOn" target="waitingForDrawer"/>
  </state>

  <state name="waitingForLight">
    <transition event="lightOn" target="unlockedPanel"/>
  </state>

  <state name="waitingForDrawer">
    <transition event="drawerOpened" target="unlockedPanel"/>
  </state>

  <state name="unlockedPanel">
    <action command="unlockPanel"/>
    <action command="lockDoor"/>    
    <transition event="panelClosed" target="idle"/>
  </state>

  <resetEvent name = "doorOpened"/>
</stateMachine>

大多数读者应该更熟悉这种XML文件的表述风格。这种做法有几个好处。第一个明显的好处是,无须为每个要实现的控制器编译一个单独的Java程序,相反,只要把状态机组件和相应的语法分析器一起编译到一个公共的JAR里,然后在状态机启动时读取对应的XML文件。对控制器行为的任何修改都无须发布新的JAR。当然,我们需要为此付出一些代价,因为许多配置上的语法错误只能在运行时被检测出来,虽然有各种各样的XML Schema系统可以帮上点忙。我还是“广泛测试”(extensive testing)的超级拥护者,其在编译时检查就可以捕获大多数错误,以及类型检查无法发现的其他问题。有了这种测试,就不必那么担心运行时的错误检测了。

第二个好处在于文件本身的表达力。我们不必再去考虑通过变量进行连接的细节。相反,我们拥有了一种声明式的方式,以这种方式读文件会更加清晰。这里还有一些限制:在这个文件里只能表示配置——这种限制也是有益的,因为它会降低人们在编写组件的组装代码时犯错的概率。

你也许经常听别人提到声明式(declarative)编程。更常见的计算模型是命令式(imperative)模型,即用一系列的步骤指挥计算机。“声明式”是一个非常模糊的术语,通常用于非命令式模型。这里,我们朝着“声明式”迈进了一步:摆脱了变量传递,并用XML中的子元素来表示状态内的动作和状态迁移。

正是有了这些好处,如此之多的Java和C#中的框架采用XML作为配置文件。如今,有时我们会觉得自己更多在用XML编程,而不是用自己的主编程语言。

下面是配置代码的另一个版本:

events
  doorClosed  D1CL
  drawerOpened  D2OP
  lightOn     L1ON
  doorOpened  D1OP
  panelClosed PNCL
end

resetEvents
  doorOpened
end

commands
  unlockPanel PNUL
  lockPanel   PNLK
  lockDoor    D1LK
  unlockDoor  D1UL
end

state idle
  actions {unlockDoor lockPanel}
  doorClosed => active
end

state active
  drawerOpened => waitingForLight
  lightOn    => waitingForDrawer
end

state waitingForLight
  lightOn => unlockedPanel
end

state waitingForDrawer
  drawerOpened => unlockedPanel
end

state unlockedPanel
  actions {unlockPanel lockDoor}
  panelClosed => idle
end

这确实是代码,尽管不是用我们所熟悉的语法编写的。实际上,这是我专门为本例构建的自定义语法。相比于XML语法,我认为它更易写,而且最重要的是,更易读。它更简洁,省却了许多XML中的引用字符和噪声字符。或许,你的做法不尽相同,但重点在于,我们可以构造自己和团队所喜欢的语法。我们依然可以在运行时加载它(就像XML那样),但同时也可以不在运行时加载而在编译时读取(就像不用XML那样)。

这样的语言就是领域特定语言,它有着DSL的许多特征。首先,它只适用于非常有限的目的——除了配置这种特定的状态机,它什么都干不了。这样带来的结果就是,该DSL非常简单——没有用于控制结构或者其他东西的设施(facility)。它甚至不是图灵完备的。不能用这种语言编写整个应用程序,你所能做的只是描述应用中一个小的方面。因此,该DSL只有同其他语言配合起来才能完成整个工作。但该DSL的简单性也就意味着它易于编辑和处理。

简单性不仅对编写控制器软件的人而言意味着易于理解,而且对于开发人员之外的人可以将行为可视化。搭建系统的人能够查看这段代码,了解它是如何工作的,虽然他们并不理解控制器本身的核心Java代码。即使他们只读了DSL也可以指出错误,或者与Java开发人员进行有效的沟通。像这样的DSL可以作为领域专家和业务分析师之间的沟通工具,虽然构建起来存在着实际的困难,但能够在软件开发最困难的交流绝壑上架建一座桥梁,所以还是非常值得尝试的。

现在,回过头来看一下XML表示。它是一种DSL吗?我想说,它是。虽然它只不过是用XML的语法承载而已,但是它依旧是DSL。这个例子引出了一个设计问题:为DSL自定义语法和使用XML语法,哪种做法更好?XML语法更易于解析,因为人们对解析XML已经非常熟悉。(然而,与为自定义语法编写语法分析器相比,解析XML花了我几乎同样多的时间。)我要声明一点,自定义语法会易读得多,至少在这个例子里是这样的。尽管如此,这两种方式的核心部分是相当的。的确,我们可以认为,大多数XML配置文件本质上是DSL。

现在来看一下下面这段代码,它看上去像这个问题的DSL吗?

event :doorClosed, "D1CL"
event :drawerOpened,  "D2OP"
event :lightOn, "L1ON"
event :doorOpened,  "D1OP"
event :panelClosed, "PNCL"

command  :unlockPanel, "PNUL"
command  :lockPanel,   "PNLK"
command  :lockDoor,    "D1LK"
command  :unlockDoor,  "D1UL"

resetEvents :doorOpened

state :idle do
  actions :unlockDoor, :lockPanel
  transitions :doorClosed => :active
end

state :active do
  transitions :drawerOpened => :waitingForLight,
              :lightOn => :waitingForDrawer
end

state :waitingForLight do
  transitions :lightOn => :unlockedPanel
end

state :waitingForDrawer do
  transitions :drawerOpened => :unlockedPanel
end

state :unlockedPanel do
  actions :unlockPanel, :lockDoor
  transitions :panelClosed => :idle
end

同之前的自定义语言相比,它稍微有些噪声字符,但依旧相当清晰。与我有类似语言嗜好的读者可能看出来了,这是Ruby代码。在创建更可读的代码方面,Ruby提供了许多语法上的选项。因此,我可以把它弄得很像一门自定义语言。

Ruby开发人员会把这段代码当作一种DSL。我用到的是Ruby这方面能力的一个子集,表现的想法同使用XML和自定义语法是一样的。从本质上说,我是把DSL嵌入Ruby,用Ruby的子集作为我的语法。在一定程度上来说,这只是视角问题,我选择的视角是戴着DSL眼镜来看Ruby代码,但这是一个有着悠久历史的视角——Lisp程序员总是想着在Lisp里创建DSL。

在此,我要指出,有两种类型的DSL,我称之为外部DSL和内部DSL。外部DSL(external DSL)指,在主程序设计语言之外,用一种单独的语言表示的领域特定语言。这种语言可能使用的是自定义语法,或者遵循另一种表示形式的语法,如XML。内部DSL(internal DSL)指用通用型语言的语法表示的DSL。这是为了领域特定的专用目的而按照某种风格使用这种语言。

也许有人听说过一个术语——嵌入式DSL(embedded DSL),它是内部DSL的同义词。虽然这个术语得到了相当广泛的应用,但我还是会避免使用它。因为“嵌入式语言”(embedded language)指在应用程序中嵌入脚本语言,例如Excel里的VBA或Gimp里的Scheme。

回过头来考虑一下原来的Java配置代码。它是一种DSL吗?我想说,它不是。下面这段代码感觉像是同API缝合在一起的,而上面的Ruby代码则更有声明式语言的感觉。这是否意味着无法用Java实现内部DSL呢?我们来看下面这段代码:

public class BasicStateMachine extends StateMachineBuilder {

  Events doorClosed, drawerOpened, lightOn, panelClosed;
  Commands unlockPanel, lockPanel, lockDoor, unlockDoor;
  States idle, active, waitingForLight, waitingForDrawer, unlockedPanel;
  ResetEvents doorOpened;

  protected void defineStateMachine() {
    doorClosed. code("D1CL");
    drawerOpened. code("D2OP");
    lightOn.    code("L1ON");
    panelClosed.code("PNCL");

    doorOpened. code("D1OP");

    unlockPanel.code("PNUL");
    lockPanel.  code("PNLK");
    lockDoor.   code("D1LK");
    unlockDoor. code("D1UL");

    idle
      .actions(unlockDoor, lockPanel)
      .transition(doorClosed).to(active)
      ;

    active
      .transition(drawerOpened).to(waitingForLight)
      .transition(lightOn).to(waitingForDrawer)
      ;

    waitingForLight
      .transition(lightOn).to(unlockedPanel)
      ;

    waitingForDrawer
      .transition(drawerOpened).to(unlockedPanel)
      ;

    unlockedPanel
      .actions(unlockPanel, lockDoor)
      .transition(panelClosed).to(idle)
      ;
  }
}

这段代码在格式上有些奇怪,而且用到了一些不常见的编程约定,但它确实是有效的Java代码。对于这段代码,我愿意称之为DSL,虽然同Ruby DSL相比有些凌乱,但它还是有DSL所需的声明流。

什么让内部DSL不同于通常的API呢?这是一个很难回答的问题,后面我会在4.1节中花更多的时间来讨论。但总体来说,可以归结为一个相当模糊的概念——类语言流(language-like flow)。

内部DSL还有一种叫法,即连贯接口(fluent interface)。它强调内部DSL实际上只是某种特殊种类的API,只不过设计时考虑到了连贯性。鉴于这种差别,最好给非连贯API起一个名字——我用的术语是命令查询API(command-query API)。

在这个例子的开始,我谈到了构建一个状态机模型。这种模型的存在以及它同DSL的关系是至关重要的。在这个例子里,DSL的角色是组装状态机模型。因此,当解析自定义语法的版本时,会遇到:

events
  doorClosed D1CL

我会创建一个新的事件对象(new Event("doorClosed", "D1CL")),并保存在符号表(第12章)里。而doorClosed => active则表示一个状态迁移(使用addTransition)。该模型就是一个提供状态机行为的引擎。确实,这种设计的过人之处就是因为有了这个模型。DSL所做的一切就是提供一种可读的方式来组装这个模型,这就是与开始的命令查询API不同的地方。

从DSL的角度来看,我把这个模型称为语义模型(第11章)。谈及编程语言时,我们常常会提到语法(syntax)和语义(semantic)。语法描述了程序的合法表达式,在自定义语法的DSL里,一切都是由文法(grammar)来描述的。而程序的语义指它想表达的含义,即运行时所能做的事情。在这个例子里,模型定义了语义。如果你熟悉领域模型[Fowler PoEAA],这里就可以把语义模型理解为与它非常类似的东西(图1-4)。

(可以先读一下第11章,了解语义模型领域模型的差异,以及语义模型与抽象语法树的差异。)

我认为对一个设计良好的DSL而言,语义模型至关重要。在现实中,有些DSL用了语义模型,而有些没有。但是我强烈建议你应该“几乎”“总是”使用语义模型。(我发现对于“总是”这样的词,几乎不可能不加上限定词“几乎”。我几乎找不到一条广泛适用的规则。)

图1-4 解析DSL组装语义模型

我提倡语义模型,因为它清晰地分离了语言的语法分析和结果语义。我可以推理出状态机的工作机制,并改进和调试状态机,而无须顾及语言问题。通过命令查询接口,我们可以组装状态机模型并进行测试。状态机模型和DSL可以各自独立演进,即便还没想好如何通过语言表示,也可以为模型添加新特性。也许,最关键的要点在于可以独立测试模型,而与如何把玩语言无关。确实,上面所有DSL的例子都构建在相同语义模型的基础上,为模型创建出相同的对象配置。

在这个例子里,语义模型是一个对象模型。语义模型还可以有其他形式。它可以是一个纯粹的数据结构,所有的行为都在单独的函数里。我依然愿意称之为语义模型,因为在那些函数的上下文里,数据结构表现出了DSL脚本特定的含义。

从这个角度来看,DSL只是扮演着表达模型配置的某种机制的角色。使用这种方式的好处更多来自模型而非DSL。为客户配置一个新的状态机很容易,这是模型的属性,而非DSL。控制器可以在运行时改变,而无须编译,这是模型的特性,而非DSL。代码可以在多次安装控制器时复用,这是模型的属性,而非DSL。由此可见,DSL只是模型的一个薄薄的门面(facade)。

即使没有DSL,模型也能提供很多好处。因此,我们一直使用它。我们使用库和框架来避免重复工作。我们在自己的软件中建立模型,构建抽象来加快编程速度。一个良好的模型,无论是发布为库或框架,还是只为自己的代码服务,即使没有任何DSL也可以工作得很好。

不过,DSL可以增强模型的能力。正确的DSL使理解一个特定状态机的工作机制更容易。一些DSL甚至允许在运行时配置模型。因此,DSL是对某些模型有益的补充。

DSL带来的好处与状态机密切相关。状态机是一种特殊类型的模型,其组装有效地为系统扮演着程序的角色。如果要改变状态机的行为,就要调整模型中的对象及其相互关系。这种风格的模型通常称为适应性模型(第47章)。其结果是一个模糊了代码和数据之间差异的系统,因为要理解状态机的行为,不能只看代码,还要看对象实例连接在一起的方式。当然,在某种程度上事情总是这样,因为对于不同的数据,任何程序都会给出不同的结果,但这里有明显的区别,因为状态对象的出现可以在极大程度上改变系统的行为。

适应性模型可以非常强大,但经常也很难用,因为人们看不到任何定义特定行为的代码。DSL的价值在于,它提供了一种显式的方式来表示这样的代码,让人们能够感受到是在为状态机进行编程。

状态机的这一方面可以很好地适用于适应性模型,因为它是备选计算模型。常规的编程语言提供了一种为机器编程的标准的思考方式,这在多数情况下工作得很好。但是,有时我们需要一些不同的方式,如状态机(第51章)、产生式规则系统(第50章)或者依赖网络(第49章)。使用适应性模型是提供备选计算模型的一种很好的方式,而DSL是对这种模型编程进行简化的一种很好的方式。在本书后续部分,我会介绍一些备选计算模型(参见第7章),你可以了解它们是什么样子的以及如何实现它们。或许你曾听过有人把这种使用DSL的方式称为声明式编程。

在讨论这个例子时,我用的是这样一个过程:先构建模型,然后在此层次之上创建DSL以便对其进行操作。之所以用这种方式进行描述是因为我觉得这是一种简单的方式,有助于理解DSL是如何适用于软件开发的。虽然这种模型优先的情况很常见,但它并不是唯一的方式。在其他场景下,你可能会与领域专家交谈,假定他们可以理解状态机的方式,然后和他们一起创建出他们可以理解的DSL。在这种情况下,DSL和模型可以同步构建。

到目前为止,在我们的讨论中我通过处理DSL来组装语义模型(第11章),然后通过运行语义模型来提供我们希望控制器提供的行为。在编程语言圈子里,这种方式称为解释(interpretation)。在解释文本时,我们先解析文本,然后立即产生我们希望从程序中得到的结果。(在软件圈子里,解释是一个棘手的词,因为它承载了太多的含义,但在这里特指立即执行的这种形式。)

在编程语言世界中,与解释对应的是编译。编译(compilation)时会先解析程序文本并产生中间结果,然后单独处理中间结果来提供预期行为。在DSL的上下文里,编译方式通常指的是代码生成(code generation)。

用状态机的例子解释它们之间的差异有点儿困难,因此,我们换另外一个小例子。想象一下,有某种规则判定人们是否符合某种条件,如保险资格。例如,一条规则是“年龄在21至40岁之间”(age between 21 and 40)。这条规则可以是一个DSL,检验像我这样的候选人是否具备资格。

如果是解释,资格判定处理器会解析规则,在执行时或启动时加载语义模型。在检验某个候选人时,会对他运行语义模型以获得一个结果(图1-5)。

图1-5 在单个进程中解释器解析文本并产生结果

如果是编译,语法分析器会加载语义模型,把它当作资格判定处理器构建进程的一部分。在构建期间,DSL处理器会产生一些代码,这些代码经过编译、打包,纳入资格判定处理器里,也许是作为共享库。然后,运行这些中间代码,对候选人(candidate)进行评估(图1-6)。

图1-6 编译器解析文本并产生中间代码,然后打包到另一个进程中运行

这个例子中的状态机使用的是解释:在运行时解析配置代码,组装语义模型。但我们其实也可以生成一些代码,以免在烤面包机里出现语法分析器和模型代码。

代码生成通常很笨拙,因为它常常需要执行额外的编译步骤。要构建程序,首先需要编译状态框架和语法分析器,然后运行语法分析器,从而为格兰特女士的控制器生成源代码,再编译生成的代码。这会让构建进程变得复杂许多。

然而,代码生成的一个优势在于,生成代码和编写语法分析器可以用不同的编程语言。在这种情况下,如果生成代码用的是动态语言,如JavaScript或者JRuby,生成代码的第二个编译步骤就可以省略了。

如果DSL所用的语言平台缺乏支持DSL的工具,代码生成的作用就会凸显出来。例如,我们不得不在一些老式的烤面包机上运行这个安全系统,而它们又只能理解编译过的C代码,那么我们可以实现一个代码生成器,使用组装的语义模型作为输入,产生可以编译并运行在老式烤面包机上的C代码。在最近做的一些项目里,我们曾为MathCAD、SQL和COBOL生成代码。

许多DSL相关的作品会关注代码生成,更有甚者,会把代码生成当作主要目标。结果产生了很多赞美代码生成的文章和书籍。然而,在我看来,代码生成只是一种实现机制,且大多数情况下用不到。当然,也有很多情况下必须要用代码生成,但的确也有很多情况下确实不需要代码生成。

许多人用了代码生成就舍弃了语义模型,他们在解析输入文本之后,就直接产生已生成的代码。虽然对使用代码生成的DSL而言,这也是一种常见的方式,但我并不推荐这么做,除非是最简单的情况。使用语义模型可以将语法分析、执行语义和代码生成分离。这种分离会使整个活动变得简单许多。它也会让我们改变想法,例如,无须修改代码生成的例程就可以把内部DSL改成外部DSL。类似地,我们无须让语法分析器变得复杂,就可以很容易地产生多种输出。就同一种语义模型而言,我们既可以用解释模型,又可以选择代码生成。

因此,在本书的大部分内容里,我会假设存在一个语义模型,它是DSL工作的核心。

常见的代码生成风格有两种:一种是“第一遍”代码,这种代码被用作一个模板,但之后要手工修改;另一种确保除了调试期间所加的追踪信息,生成的代码绝对不会手工修改。我几乎总是倾向于使用后者,因为这样可以更自由地重新生成代码。对DSL而言,这一点尤其正确,因为我们希望DSL是它所定义逻辑的主要表示形式。这意味着,无论我们何时想要修改行为,必须能够很轻松地修改DSL。因此,我们必须确保,任何生成的代码都没有经过手工编辑,虽然它可以调用手写的代码,或者由手写的代码调用。

目前展示的两种风格的DSL(内部DSL和外部DSL)是思考DSL的传统方式。它们也许还没有得到广泛的理解和充分的运用,但是它们拥有很长的历史,也得到了适度的应用。因此,本书余下的部分就关注于使用那些成熟且容易得到的工具,让你初步掌握这两种DSL方式。

但是还有一类全新的工具已初露端倪,它们也许会极大程度地改变DSL的游戏规则——我称这类工具为语言工作台(language workbench)。语言工作台是一个环境,其设计初衷就是帮助人们构建新的DSL,以及有效运用这些DSL所需的高质量工具。

使用外部DSL的一大劣势在于,我们会被相对有限的工具所羁绊。在文本编辑器里设置语法高亮是大多数人所能达到的水平。虽然你可以争辩说DSL很简单,脚本很小巧,这就足够了,但还是有人希望拥有现代IDE所支持的成熟工具。语言工作台不但让定义语法分析器变得简单,而且为这门语言订制一个编辑环境也会变得简单。

所有这些都是有价值的,但是语言工作台真正有趣的方面在于,它们让DSL设计者可以超越传统的基于文本的源代码编辑而走向语言的不同形式。最显而易见的一个例子就是对图表语言的支持,这使我们可以通过状态迁移图直接设计密室状态机。

类似于这样的工具不仅可以定义图表语言,还可以从不同的视角来看DSL脚本。在图1-7中我们看到一幅图,图上不但显示了状态和事件的列表,还显示了一个可以输入事件编码的表格(如果看上去太乱的话可以删掉)。

图1-7 在MetaEdit语言工作台中设计密室状态机(来源:MetaCase)

许多工具会提供这种多面板的可视化编辑环境,但是自己打造一个这样的工具需要耗费很大的工作量。语言工作台要做的就是让这件事变得相当容易。确实,我第一次上手使用MetaEdit这个工具,就能很快弄出像图1-7这样的一个例子。它可以让我为状态机定义语义模型(第11章),定义图1-7那样的图形化和表格化的编辑器,然后根据语义模型编写代码生成器。

然而,虽然这样的工具看上去不错,但许多开发人员还是本能地怀疑这种玩具式的工具。有一些非常现实的原因使得用文本表示代码更有意义。所以,有些工具选择了这个方向,提供一种后IntelliJ风格的能力——为基于文本的语言提供类似于语法制导的编辑、自动补全等功能。

我对此的怀疑是:如果语言工作台真的流行开来,其所产生的语言会不同于我们常规理解的编程语言。这种工具的一大好处在于,它使非程序员也可以编程。对于这种想法,我常嗤之以鼻,因为这就是COBOL最初的意图。但我也必须承认,有一种编程环境极其成功,它给非程序员提供了一个编程工具,让这些不觉得自己是程序员的人也能编程,它就是电子表格。

许多人并不把电子表格当作编程环境,然而它可以被认为是目前为止最为成功的编程环境。作为一种编程环境,电子表格有一些有趣的特征,其中一个特征就是把工具紧密地集成到了编程环境之中。没有独立于工具的文本表示,也就无须语法分析器处理。工具和语言紧密地结合和设计在了一起。

另一个有趣的特征我称之为说明性编程(illustrative programming)。看一下电子表格,最显而易见的并不是可以进行所有计算的公式,而是构成样本计算的数字。这些数字说明了程序运行时所做的内容。在大多数编程语言里,程序是至关重要的,只有运行测试时,我们才会看到其输出。在电子表格里,输出是至关重要的,只有在点击某个单元格时,我们才会看到其程序。

说明性编程并不是一个获得广泛关注的概念,为了讨论它,我甚至不得不创造出这个词。对外行程序员而言,它可能十分重要,因为用它才能对电子表格进行操作。它也有劣势,例如,缺乏对程序结构的关注,这会导致大量的复制-粘贴编程,以及糟糕的程序结构。

语言工作台支持开发像这样的全新编程平台。因此,我认为它们所产生的DSL可能更接近于电子表格,而非我们通常理解的DSL(也就是本书要讨论的内容)。

我认为,语言工作台有着非凡的潜力。如果能够达成目标,它们会完全改变软件开发的面貌。然而,这种潜力虽然深远,但尚在较远的未来。语言工作台还处于起步期,新的方式会定期出现,旧的工具则势必将深刻演化。所以,在这里我不会进行过多的讨论,因为我觉得在本书预期的生命周期里,它们会有相当大的改变,但在结尾,确实有一章讨论它们,因为我觉得它们非常值得关注。

语言工作台的一大优势在于,它给了DSL更为多样的表示形式,特别是图形化表示。然而,即便是文本化的DSL也可以有图表化的表示。确实,我们在本章前面已经看到了。在图1-1中,你也许已经注意到了,它并不像我以往所画的那样整洁,其中的原因在于这并不是我画的,而是我根据格兰特女士的控制器的语义模型(第11章)自动生成的。状态机类不仅可以运行,还可以用DOT语言对自身进行展示。

DOT语言是Graphviz包的一部分。Graphviz包是一个开源工具,可以用来描述数学里的图结构(节点和边),然后自动绘制出来。只要告诉它什么是节点、什么是边、用什么形状以及其他一些提示,它就会算出如何对这个图进行布局。

对许多类型的DSL来说,使用Graphviz这样的工具非常有用,因为它提供了另一种表示形式。类似于DSL本身,这种可视化的表示形式可以让人更好地理解模型。可视化不同于对应的源码,因为其本身不可编辑,但另一方面,它可以做到可编辑形式无法做到的事情,例如呈现出图1-7这样的图。

可视化并不一定非要图形化。编写语法分析器时,我时常用简单的文本可视化来辅助调试。我见过有人用Excel生成可视化图表以帮助他们与领域专家交流。重点在于,一旦经过辛勤工作创建出语义模型,添加可视化就会非常容易。注意,可视化是根据模型产生的,而非DSL。因此,即便不用DSL组装模型,依然可以使用可视化。


看过第1章的示例之后,即使我还未给出领域特定语言(DSL)的一般定义,你对DSL也应该有了感性认识。(在第10章中会列举更多的例子。)现在我要开始给DSL下定义,并讨论它的好处和问题,在第3章介绍DSL的实现之前提供一些上下文。

“领域特定语言”是一个很有用的术语和概念,但是其边界很模糊。有些很明显是DSL,但有些可能会引起争论。这个术语已经使用了一段时间了,但就像软件行业中的很多事物一样,从来就没有一个非常明确的定义。对本书来说,我觉得给出一个定义还是非常有价值的。

领域特定语言(名词):一种专注于某特定领域并具有有限表达性的计算机编程语言。

这个定义中有4个关键元素。

注意,专注领域在上述因素中排在最后,它不过是有限表达性的结果。很多人按字面意思把DSL理解为一种特定领域的语言。但字面意思通常不正确,例如,我们并不把硬币叫作“亮片”,尽管它是一个铁片且比我们称之为亮片的铁片更亮。[1]

我把DSL分为3类:外部DSL、内部DSL和语言工作台。

多年来,这3种风格分别发展了自己的社区。你会发现,那些非常擅长使用内部DSL的人,对如何构建外部DSL一点都不了解。我认为这是一个问题,因为人们可能没有采用最适合的工具。我曾与一个团队讨论过,他们采用了非常巧妙的内部DSL处理技术解决了自定义语法问题,但我确信,采用外部DSL会简单得多。但是,他们不知道如何构建外部DSL,因此这种办法不会成为他们的备选方案之一。所以在本书中,把内部DSL和外部DSL讲清楚对我来说很重要,这样你就可以了解这些信息,并做出选择。(语言工作台的方式我会介绍得很简要,因为它还是全新的,还在继续演化。)

另一种看待DSL的方式是:把它看作一种处理抽象的方式。在软件开发中,我们建立抽象并处理它,而且经常在不同层次上。最常见的建立抽象的方式是实现一个库或框架。最常见的使用框架的方式是通过命令查询API调用。从这种角度来看,DSL是库的前端,提供对命令查询API的不同操作。在这种情况下,库就是DSL的语义模型(第11章),因此,DSL经常伴随着库出现。事实上,我认为语义模型应该是一个构建良好的DSL的必备附件。

当人们谈论DSL时,很容易觉得构建DSL很难。实际上,难的是构建模型,DSL只是模型基础上的层次。虽然构建一个良好的DSL需要一定的工作量,但比起构建底层模型所需的工作量还是要小多了。

我前面说过,DSL是一种边界模糊的概念。虽然我认为没有人会质疑正则表达式是一种DSL,但确实有很多情况存在争议。因此我觉得有必要在这里讨论其中的一些情况,这能让我们更好地理解什么是DSL。

每种风格的DSL都有自己的边界条件,因此我会分别讨论。在此之前有必要提醒的是,各种DSL的不同特征源于它们各自的语言性、有限表达性和专注领域。而且根据经验,专注领域并不是一个很好的边界条件,而按照语言性和有限表达性来划分边界是更常见的做法。

我们先来看看内部DSL。这里的边界问题,其实就是内部DSL与普通命令查询API之间的区别。从许多方面来说,内部DSL不过是一种特别的API(就像那句贝尔实验室名言“库设计就是语言设计”)。不过在我看来,它们之间的核心区别是语言性。Mike Roberts和我说过,命令查询API定义了抽象领域的词汇,而内部DSL则添加了文法。

列出类的所有方法是一种常见的给包含命令查询API的类编写文档的方式。这时每个方法自身都应该是有意义的。从这样的文档中,你得到了一组“单词”,每个单词基本上已足以表达自身的含义。而内部DSL的方法常常只有在更大的DSL表达式的上下文中才有意义。在前面以Java为例的内部DSL中,有一个名为to的方法,它指明了状态迁移的目标。这样的方法名在命令查询API中不是一个好名字,但在.transition(lightOn).to(unlockedPanel)这样的短语内部是适用的。

这样的结果是,内部DSL会给人一种组装各个完整句子的感觉,而不是组装一系列毫无关联的命令。这种特征正是这样的API被称为连贯接口的基础。

对内部DSL而言,有限表达性显然不是一项核心属性,因为内部DSL是一种通用型语言。在这种情况下,有限表达性来自其使用方式。在构建DSL表达式时,你限定自己只使用通用型语言特性的一个小子集。通常要避免使用条件判断、循环结构和变量。Piers Cawley把这种用法叫作宿主语言的洋泾浜用法(pidgin use)。

对外部DSL而言,它的边界就是它跟通用型语言之间的边界。语言可以既专注领域又是通用型语言。例如,R是一种统计学语言,也是一个平台,主要用于解决统计学问题,但依然具备一门通用型语言的所有表达性。因此,尽管它专注领域,我也不会称其为DSL。

正则表达式是一种更明显的DSL。专注领域(文本匹配)与其有限性紧密相关——正好使文本匹配更加简单。DSL的一个普遍特征是它不是图灵完备的。DSL通常会避免常见的命令控制结构(条件和循环),也没有变量,不能定义子例程。

说到这里,很多人可能对我的看法有不同意见。按照DSL的字面定义,像R这样的语言应该被归类为DSL。但是,我之所以如此强调DSL的有限表达性,是因为它使DSL和通用型语言之间的区分有了意义。有限表达性赋予了DSL不同的特征,不管是在使用的时候还是实现的时候。这就导致思考DSL时与通用型语言完全不同的方式。

如果这样的界线还不够模糊,让我们来看一下XSLT。XSLT的专注领域是XML文档转换,但它具备常规编程语言中的所有特性。这样一来,我认为,与XSLT是什么样的编程语言相比,更重要的是如何使用它。如果将XSLT用于转换XML,我愿意称其为DSL。但是,如果将XSLT用于求解“八皇后问题”,我愿意称其为一门通用型语言。语言的特殊用法可以将它自身置于DSL分界线的任何一侧。

外部DSL的另一条边界是其具有序列化的数据结构。配置文件中的属性赋值(如color`` = blue)列表是DSL吗?我认为此时的边界条件是语言性。一系列赋值表达式不够连贯,所以不符合标准。

类似的情况还出现在有很多配置文件的时候。如今许多环境通过不同类型的配置文件(通常为XML语法)来提供可编程性。在很多情况下,这种XML配置文件是DSL,但并非在所有情况下都是如此。有时,这些XML文件是由其他工具生成的,此时其目的只是用于序列化,而不是让人来使用。在这样的情况下,人并不期望使用它,所以我不会将其归类为DSL。当然,一种存储格式具备可读性肯定是有价值的,毕竟有利于调试。问题不在于评判其对于人是否是可读的,而在于其表示形式是否是人与系统交互的主要方式。

这种配置文件最大的问题在于,虽然它们不是为了让人手工编辑而设计的,但实际上手工编辑是家常便饭。于是这种XML文档就意外地成了DSL。

有了语言工作台,边界就在语言工作台与允许用户设计自己的数据结构和表单(如Microsoft Access)的应用程序之间。毕竟,你可以拿一个状态模型,用关系数据库结构来表示它(我还见过比这更糟糕的主意),然后就能创建表单来操纵模型。这里有两个问题:Access是一种语言工作台吗?在Access里定义的是一种DSL吗?

我从第二个问题开始讲解。既然我们正在为状态机构建一个特殊的应用程序,我们就有了专注领域和有限表达性,关键问题是语言性。如果我们只是把数据放进表单,并保存在表中,这感觉上不像一门语言。表可以是语言性的一种表达——像FIT(10.6节)和Excel都采用了表格的表示形式,同时又给人一种语言的感觉(我认为FIT是领域特定的,而Excel是通用的)。但是大部分应用程序不会追求这种连贯性,它们只创建表单和窗口,而不强调它们的互相关联。例如,Meta-Programming System Language Workbench的文本界面给人的感觉迥异于大部分基于表单的用户界面。同样地,很少有应用程序像MetaEdit那样允许通过图表的布局来定义事物的布置方式。

至于Access是不是一种语言工作台,我们最好回到它原始的设计意图上。Access并不是要设计成语言工作台,尽管可以那么用。就像Excel并不是要设计成数据库,但有很多人这么用一样。

从更广泛的意义上讲,一种人与人之间使用的纯粹的行话是不是DSL?一个常见的例子是人们在星巴克点咖啡时用的语言:“拿铁,超大杯、半咖、脱脂、不打泡、不要奶油。”这种语言看起来很适合,具备有限表达性、专注领域,还有自己的一套词汇和类似于文法的感觉,但它不在我的定义范围内,因为我只用“领域特定语言”来表示一种计算机语言。如果我们实现了一门计算机语言来表达在星巴克点咖啡,那么它显然是一种DSL。但我们在买咖啡提神的时候说出来的则是一种人类语言。这里,我用领域语言(domain language)来表示在特定领域使用的人类语言,而用“DSL”来表示计算机语言。

那么,这些关于DSL边界的讨论告诉了我们什么?我想至少有一件事情是明确的,即很少有清晰的边界。理性的人可能不认同我对DSL的定义。事实上,像语言性和有限表达性这样的衡量标准本身就很模糊,因此基于这些标准的结果也会模糊。而且,也并非所有人都会采用我设定的这些边界条件。

在上面的讨论中,我把很多东西排除在了DSL的定义之外,但这不代表我认为它们没有价值。定义的价值在于它有利于沟通,让不同的人在讨论问题时有一致的认识。对本书来说,定义可以让我们搞清楚我所描述的技术是否与之相关。我发现有了这样的定义之后,我能更有效地选择一些需要讨论的技术。

我在第1章“为格兰特女士的控制器编程”示例中使用的是独立DSL。我的意思是你可以看到一段这种DSL脚本(一般是一个文件),里面全是DSL。如果你只熟悉这种DSL,而不熟悉应用程序的宿主语言,也可以理解DSL在做什么。因为宿主语言要么不在脚本中(如外部DSL),要么被内部DSL所掩盖。

DSL的另一种使用方式是以片段的形式出现。在这种情况下,少量DSL被用在其宿主语言代码之中。你可以认为这些DSL用附加的特性增强了宿主语言。但这时如果不理解宿主语言,就不能明白这些DSL到底在做什么。

对外部DSL来说,片段DSL的一个很好的例子是正则表达式。在一个程序中不会有一个全是正则表达式的文件,但往往会有一些常规宿主代码中点缀着少量正则表达式的片段。片段DSL的另一个例子是SQL,经常能看到在大型程序上下文中使用的SQL语句。

内部DSL也有类似的使用片段的形式。单元测试是内部DSL开发成果显著的领域。尤其是,mock对象库中的预期文法就属于大型宿主代码上下文中的片段DSL。内部片段DSL的一个流行的语言特性是注解(Annotation)(第42章),它允许给宿主代码编程元素添加元数据,这使得注解非常适合片段DSL,但对独立DSL没什么用。

同一DSL也可以同时用在独立上下文和片段上下文中,SQL就是一个很好的例子。有些DSL被设计以片段形式使用,有些以独立形式使用,也有些则可以两者通吃。

到这里,我希望我们对什么是DSL已经有了一个很好的认识,接下来的问题是为何要考虑采用DSL。

DSL只是一个具有有限关注点的工具。它不像面向对象编程或敏捷方法论那样会引发软件开发领域的根本性改变。相反,它是在特定条件下有专门用途的工具。一个典型的项目可能在多个地方采用了多种DSL(事实上很多项目已经这么做了)。

在1.4节中,我一直说DSL只是库或框架所构成模型之上的一个薄层。这句话提醒我们,当你考虑DSL的好处或不足之处时,一定要分清它是来自DSL的底层模型,还是来自DSL本身。这一点很重要,因为人们经常会混淆这两者。

DSL本身有自己的价值。当你考虑采用它时,要仔细衡量它的哪些价值适合于当前的情况。

DSL的核心价值在于它提供了一种更加清晰的沟通系统中某一部分的意图的手段。如果格兰特女士的控制器的定义是以DSL形式给出的,那么要比通过模型的命令查询API形式给出的更容易理解。

这种清晰明了不光是审美上的需求。一般地,一段代码越容易看懂,就越容易发现错误,也就越容易进行修改。因此,我们鼓励变量名要起得有意义,文档要清楚,代码结构要规范。同样地,我们应该也鼓励采用DSL。

人们经常低估代码缺陷给生产率带来的影响。这些缺陷不仅降低软件的外部质量,还降低开发人员的效率(不得不花时间去调查原因和修复错误),并且给系统的行为埋下混乱的种子。DSL的表达能力有限,这让它很难写错,并且一旦出错就很容易发现。

很多时候,模型的存在本身就使生产率得到了很大的提升。它们通过把通用代码组织在一起消除了重复。最重要的是,模型提供了一种思考问题的抽象方式。通过这种方式可以更容易地描述问题,并使之更容易理解。在此基础上,DSL通过提供一种表达性更好的形式读取和操控抽象来进一步增强这种方式。而且,DSL对于人们学习这种抽象的API大有好处,因为它使人们的关注点转移到如何综合运用不同的API方法。

关于这种用法,我遇到的一个有趣的例子是用DSL对一些难用的第三方库进行包装。这些库本身提供的命令查询接口设计得很差,用DSL进行包装之后,接口的连贯性有了显著的提升。而且,DSL只需支持客户端真正需要的那些用法,这将大大降低客户端开发人员的学习成本。

我相信,软件项目最常见的失败原因来自项目中最难的部分,也就是开发团队与客户以及与软件用户之间的沟通。通过定义一种针对领域问题的清晰且精确的语言,DSL有助于改善这种沟通。

这项好处比简单提高生产率更加微妙。首先,很多DSL并不适用于领域沟通。例如,用于正则表达式或构建依赖的DSL实际上就不适用。只有一部分独立DSL适用于这种沟通手段。

当人们在这样的场景下谈起DSL时,经常会有人说“现在我们不需要程序员了,业务人员可以自己去确定业务规则”。我把这种论调叫作“COBOL谬论”,因为COBOL就曾被人们寄予这样的厚望。这种争论很常见,我再解释一遍也不会改善这种局面。

尽管存在“COBOL谬论”,但是我依然觉得DSL可以提高沟通效率。不是让领域专家自己去编写DSL,而是让他们可以读懂,进而理解系统做了什么。通过读懂DSL代码,领域专家可以指出系统实现上所犯的错误。而且,他们可以与真正编写业务规则的程序员更有效地交流,或许领域专家可以直接编写一些粗略的规则,然后交给程序员去细化成程序能用的DSL规则。

我并不是说领域专家永远不能自己编写DSL。我遇到过很多团队,他们成功地让领域专家用DSL编写重要的系统行为。但我仍然认为,以这种方式使用DSL的最大收益在于领域专家可以读懂。所以,当你想创建DSL时,从可读性开始,这样即使后续的目标达不到,也不会损失什么。

因为我将使用DSL的目的聚焦在让领域专家能够读懂,所以对于是否使用它一直存在争议。如果你希望领域专家理解一个语义模型(第11章)的内容,可以提供可视化的模型。这时考虑一下,只提供可视化是否比支持DSL更高效。通常可视化和DSL这二者都提供会更有用。

让领域专家参与构建DSL与让其参与构建模型类似。我经常发现,与领域专家一起构建模型能带来很大的好处。在双方共同构建通用语言(Ubiquitous Language)[Evans DDD]的过程中,软件开发人员与领域专家之间产生了更深入的沟通。DSL为这种沟通提供了另一种辅助手段。根据环境的不同,你可能会发现领域专家要么同时参与模型和DSL,要么只参与DSL。

实际上,有些人发现,试图用DSL描述领域,即使不实现DSL,也是非常有用的。它可以作为沟通的平台。

总而言之,让领域专家参与构建DSL比较难,但一旦完成回报很高。而且,即使领域专家最终无法参与,DSL依然可以提升开发人员的生产率,是非常值得投入的。

用XML来表达状态机的一个强有力的原因是,状态机的定义可以在运行时求值,而不是在编译时。希望代码在不同的环境中运行是我们使用DSL的常见动力,而使用XML配置文件的常见原因就是可以将逻辑从编译时切换到运行时。

除此之外,还有其他切换执行环境的方法也比较有用。我曾见过一个项目,需要遍历数据库来找到匹配某种条件的合同并对其进行标记。开发人员用Ruby编写了一个DSL来指定那些匹配条件,并用它组装成一种语义模型(第11章)。在Ruby中,把数据库中的所有合同都读入内存中再去运行查询逻辑会让系统运行速度变得很慢,但他们可以用这种语义模型来生成SQL,在数据库中执行。对开发人员来说,直接使用SQL编写规则很困难,更别提业务人员了。然而有了DSL,业务人员就能读懂(事实上,他们还能编写)那些合适的表达式了。

以这样的方式来使用DSL,可以弥补宿主语言的局限性,让我们用更令人舒服的DSL进行表达,然后为真正的执行环境生成代码。

模型的存在有助于这种执行环境的切换。一旦有了一个模型,既可以直接执行它,又可以由它生成代码。模型可以用DSL组装,也可以由表单风格的界面来组装。与使用表单相比,用DSL有几项好处:一是DSL比表单更擅长表示复杂的逻辑;二是我们还可以用同样的代码管理工具(如版本控制系统)来管理用DSL编写的业务规则。如果通过表单来创建这些业务规则,然后存储在数据库中,就没办法用版本控制系统了。

接下来我要说的是DSL的一个伪优点。我听说有人认为DSL的一个优势是它可以在不同的语言环境中执行相同的行为。例如,你可以用DSL编写业务规则,然后生成相应的Java或C#代码;或者用DSL描述验证逻辑,然后在服务器端的C#代码或在客户端的JavaScript代码中执行。但我认为这是一个伪优点。因为你完全可以使用模型来做到这一点,根本不需要DSL。当然,DSL能让那些规则表达得更易于理解,但那是另外一个问题了。

主流编程大多是命令式的计算模型。这就意味着,我们告诉计算机做什么,以及按什么样的顺序去做。控制流程是用条件句和循环体处理的,我们还使用变量。我们认为用这样的方式理所当然。命令式计算模型之所以这么流行,是因为它相对来说比较容易理解,也容易应用到许多常见问题上。然而,它并不总是最好的选择。

状态机是这方面的一个很好的例子。我们可以编写命令式代码和条件句来处理这种行为——它也可以被很好地结构化。但是,如果我们直接用状态机去思考,而不是过程和条件跳转,会带来更好的效果。另一个常见的例子是定义软件的构建方法。可以用命令式的逻辑,但人们后来发现用依赖网络(Dependency Network)(第49章)会更容易(例如,要运行测试必须先编译)。结果,人们设计出了专用于描述构建的语言(如Make和Ant),其中用任务间的依赖关系作为主要的结构化机制。

人们把这种非命令式的方法叫作声明式编程。之所以叫作声明式,是因为这种风格允许声明“应该发生什么”,而不是一堆描述“行为是如何发生的”的命令式语句。

要使用备选计算模型(alternative computational model),DSL不是必需的。备选计算模型的核心行为源于它所实现的语义模型(第11章),例如,前面讲的状态机。然而,DSL在这一场合非常有用,因为它让人们更容易操控组装语义模型的声明式程序。

前面讨论了这么多何时该采用DSL,接下来该讨论一下什么时候不该采用DSL了,或者至少是使用DSL时应注意哪些问题。

从根本上来说,不应该使用DSL的唯一原因就是在你的场景中使用DSL没有任何好处,或者是DSL的好处不及构建它所带来的成本。

有些场景虽然适合使用DSL,但是它们同样会带来一些问题。但总体来说,我认为当下的情况是这些问题言过其实了,一般是因为人们不太熟悉如何构建DSL,也不了解DSL在软件开发大环境下的定位。而且,很多常说的DSL问题源于人们对DSL和模型的混淆,这种混淆同样也扰乱了很多DSL的好处。

许多DSL问题只是与DSL的某种特定风格有关。而且要理解这些问题,你需要深入了解这种DSL是如何实现的。所以,我想把这些问题留到后面去讨论,在这里只讨论与本章有关的那些较宽泛的问题。

我把最常见的反对DSL的观点称为语言噪声问题:人们觉得学习一门新语言很难,所以使用多种语言肯定比使用一种要复杂。在项目中必须学习多种语言,这会让工作变得困难,对新人也并不友好。

当人们这么说时,他们通常有一些误解。首先是他们把学习DSL的成本与学习通用型语言的成本混淆了。DSL要比通用型语言容易得多,学习起来也更简单。

许多批评者知道这一点,但仍然反对使用DSL,因为即便DSL易学,他们也觉得一个项目中存在多种DSL会让项目变得难以理解。这里的误解在于,他们忘了一个项目中总会存在一些很难理解的复杂部分。即使没有DSL,代码库中也会有相应的抽象需要理解。通常来说,这样的抽象会被封装成库,以便于掌握。结果是,虽然不必学习多种DSL,但还是不得不学习多种库。

所以真正的问题在于,学习DSL和学习其底层模型中哪个更困难。我认为学习DSL要比理解模型容易得多。实际上,DSL的价值就在于让人们更容易地理解和使用模型,所以使用DSL应该能降低整体上的学习成本。

虽然DSL本身需要花费的成本比它的底层库小多了,但仍然有一部分编写代码和维护代码的成本。因此,就像我们编写任何代码一样,需要衡量其投入产出比。不是所有的库都值得投入精力去实现一层包装它的DSL。如果命令查询API已经够用,就不需要基于此再实现一套API了。尽管新实现一种DSL确实有用,但有时构建和维护DSL的成本会比从中获得的边际收益大得多。

DSL的维护成本是一项重要的考量因素。即使是一种简单的内部DSL,如果开发团队中的多数成员觉得难以理解,也会带来很大的问题。而外部DSL更是让很多人望而却步,语法分析器就会让很多程序员打退堂鼓。

增加DSL而带来成本的一个原因是,人们通常不熟悉如何构建一种DSL,为此需要去学习新的技术。不过,虽然不应忽略这些成本,但要明白的是,这些学习成本会在未来进行分摊,因为你以后还会用到DSL。

另外,请记住DSL的成本不包括构建模型的成本。任何软件中的复杂逻辑都需要某种机制来管理复杂性,如果复杂到需要用DSL来管理,那么它肯定复杂到能够从构建的模型中获益。DSL可以帮助我们思考模型,并降低模型的构建成本。

这导致的一个相关问题是:鼓励使用DSL会不会导致人们编写出很多糟糕的DSL?实际上这并不意外,就像很多库有着糟糕的命令查询API一样。问题是DSL会不会让事情变得更糟。好的DSL可以包装一个API设计得很差的库(可能的话我更愿意直接改库本身),让它更易于使用。而构建和维护糟糕的DSL就是在浪费资源,就像任何糟糕的代码一样。

集中营语言(Ghetto Language)问题与语言噪声问题正好相反。比方说,一家公司用一种自制语言编写了很多公司内部系统,这种语言的使用范围只是公司内部。这样一来,公司就很难招到适应其技术环境的员工,也很难跟得上外部技术环境的变化。

在分析这个问题时,我先澄清一点,根据我对DSL的定义,如果整个系统都是用某种语言写的,那么它就不是DSL,而是通用型语言。虽然你可以用很多与DSL有关的技术去构造一门通用型语言,但我强烈建议你不要这样做。构造并维护一门通用型语言是一项巨大的工程,很可能让你一辈子都耗费在这样的集中营里。所以,不要那么做。

我认为这样的集中营语言问题不是空穴来风,它隐含了一些现实存在的问题。第一个问题是,一种DSL总是存在着不经意间演化成一种通用型语言的风险。想象一下,你构建了一种DSL,开始使用它,然后逐渐为它添加新特性。今天添加了条件语句,明天又加上了循环体,然后——哎呀一不小心,你得到了一种图灵完备的语言。

要对抗这种滑向深渊的演化,唯一方法是牢牢地坚守底线。要确保你清楚该DSL所聚焦的问题。对任何可能超出该DSL目标的新特性都应持怀疑态度。如果你想解决更多问题,试试采用多种语言,并综合运用它们,而不是让一种DSL越来越臃肿。

同样的问题也会侵蚀框架。好的库都有一个清晰的目标。如果你的产品定价库包含了一种HTTP协议的实现,实际上就会面临同样的问题——没有分离关注点。

第二个问题是,总是自己构建而不从外部获取。这既适用于DSL,又适用于库。例如,你没理由构建自己的对象关系映射系统。我对软件开发的一个通用原则是,如果不是核心业务,就不要自己编写,而是从外部去获取。特别是,随着开源软件的兴起,通常你应该基于现有的开源软件进行扩展,而不是从头开发一个全新的软件。

DSL的有用之处在于它提供了一种抽象,我们可以用这种抽象来思考领域问题。这种抽象非常有价值,比起使用底层结构,DSL可以让人更简单地表达领域行为。

然而,任何抽象(无论是DSL还是模型)都会带来一个风险:使人的思路变得狭窄。一旦陷入狭隘的抽象,人们就会想方设法把所有东西都塞进这个抽象之中,而不会尝试其他思路。一般地,当你发现一种不适应抽象的事物时,你就会遇到这样的问题,而你会千方百计地让它适应抽象,而不是修改抽象让它能更容易地吸纳新的行为。这种狭隘的抽象往往发生在你觉得抽象大功告成之际——此时你很自然地不希望发生翻天覆地的变化。

不仅是DSL,任何抽象都会遇到这种狭隘性的问题。但DSL可能会让这个问题变得更严重。因为DSL提供了一种更舒适的方式来操作抽象,这使我们习惯之后就不愿意改变了。当你与领域专家一起使用DSL时这个问题可能更严重,他们习惯了一种抽象之后会比你更不愿意改变。

因此,就像对待任何抽象一样,你应该把DSL看作一种始终在演进的事物,它永远不会完结。

本书是关于领域特定语言的,但它同时也是关于语言处理技术的。这两者是有重叠的,因为在90%的情况下,开发团队使用语言处理技术是为了DSL。不过,这些技术也可以巧妙地用在其他方面,让我不能不提。

我曾在拜访一个ThoughtWorks项目团队时见到一个很好的例子。他们要与某种第三方系统通信,发送的消息内容是用COBOL copybook定义的。COBOL copybook是一种用来描述记录的数据结构格式。系统中有很多记录,所以我的同事Brian Egge决定构建一种COBOL copybook的语法分析器,来解析COBOL copybook语法的子集,生成Java类来对接这些记录。构建出语法分析器之后,项目组就可以轻松地处理很多COBOL copybook,而系统中的其他代码根本不需要了解COBOL的数据结构。一旦这种格式有变化,只需要重新生成一遍就可以了。在这里,很难说COBOL copybook是一种DSL,但我们可以用处理外部DSL的技术解决这个问题。

因此,虽然我是在DSL的上下文中讨论语言处理技术,但你也可以用它们来解决其他问题。掌握了语言处理的思想,就会发现它们的用途十分广泛。

在本书开头,我是这样引入DSL的:首先描述一个框架及其命令查询API,然后在该API之上定义一个DSL,从而使原来的API更加容易操作。之所以用这种方式是因为我觉得以这种方式理解DSL很容易,但这实际上并不是使用DSL的唯一方式。

另一种常见的方式是先定义DSL。在这种模式下,我们先从一些场景开始,将场景编写成一些你觉得容易看懂的DSL。如果这种语言是领域功能的一部分,最好和领域专家一起来编写——这是让DSL成为一种沟通媒介的良好开端。

有些人喜欢一开始就满足语法上的正确性。这意味着,对内部DSL来说,要确保其符合宿主语言的语法;对外部DSL来说,要确保其写法确实能被解析。也有些人则先从非正式的写法开始,第二遍再去通过DSL细化,从而满足语法上的合理性。

要用这种方式来实现状态机,你要和一些了解客户需求的人坐在一起。先想出一些控制器行为的例子,要么基于人们过去的需求,要么基于你对他们未来期望的理解。对每一个例子,尝试以DSL的形式写出来。随着场景的增多,你会调整DSL来支持新的功能。在练习结束后,你会得到一组合理的用例样本,以及对应的伪DSL描述。

如果用语言工作台定义DSL,要在工作台之外完成这一阶段,可以用普通的文本编辑器,可以用画图工具,当然也可以用纸和笔。

一旦获得了一组有代表性的伪DSL,就可以开始实现它们了。实现涉及用宿主语言设计状态机模型、模型的命令查询API、DSL的具体语法以及DSL与命令查询API之间的转换。实现方式有很多种。有些人喜欢一次做整套工作的一个切片:编写一点模型,增加一点操作模型的DSL,然后用测试把它们都调用起来进行验证。也有些人喜欢先构建并测试整个框架,再在其上构建DSL。还有人会先设计好DSL,然后构建库,再把二者匹配起来。作为一个增量开发的倡导者,我倾向于一点一点地实现端到端功能,因此会采用第一种做法。

所以我会从我看到的最简单的一个用例开始,用测试驱动开发的方式编写支持这个用例的库,然后编写DSL的部分,并把DSL与已构建的框架连接起来。这个过程中我会很乐于对之前得到的DSL进行调整,以便于实现。当然,我的这些调整会跟领域专家确认,以确保我们仍对这种沟通媒介有着共同的理解。就这样,完成了一个控制器之后,再挑下一个继续。在此过程中,我会先对框架和测试进行演进,然后对DSL进行演进。

虽然我的方式是这样,但不表示我觉得从模型开始开发的方式不好,实际上这往往是一个不错的选择。一般来说,从模型开始的方式经常发生在人们一开始没想使用或不确定是否需要DSL时。这种情况下,你会先构建一个可工作的框架,使用一段时间,然后觉得有必要增加一种DSL。对这个例子来说,你可能先构建了自己的状态机模型,并有了很多用户。过了一段时间,你发现添加新用户非常困难,于是决定尝试一下DSL。

接下来介绍两种从模型出发构建DSL的方式。第一种是“从语言开始生长”的方式,基本上把模型看作一个黑箱,在其之上慢慢地构建DSL。以前面的状态机为例,我们会先看看有哪些控制器,然后给每个控制器设计出伪DSL,再一个场景一个场景地实现DSL,大体上就像前面描述的那样。在这个过程中,你可能会给模型增加一些方法来帮助实现DSL,但模型通常不会有大的改变。

第二种是“从模型开始生长”的方式。在这里,你会先给模型增加一些使表达更连贯的方法,使其更容易配置和使用,然后逐渐把这些方法抽出来形成DSL。这种方式更适合用来构建内部DSL。你可以把这个过程看作对模型的一种深度重构以获得内部DSL。这种方式的一个有利的方面在于,它是逐步演进的,因此不会带来构建DSL的显著的成本。

当然,很多情况下,你甚至不知道已经有了一个框架。你可能构建了几个控制器,然后意识到有很多共同的功能。这时,我会先对现有系统进行重构,分离出模型代码和配置代码。这种分离非常重要。虽然在做这件事时我脑子里可能已经有一些DSL的雏形,但我会倾向于先完成分离,再去构建DSL。

写到这里,我觉得有必要先强调一件事(希望我的担心是多余的):一定要记得用版本控制系统来管理DSL脚本。DSL脚本是代码的一部分,所以就像任何别的代码一样,必须把它放进版本控制系统。DSL作为文本形式的一大好处就是适合版本管理,可以跟踪系统行为的变化。

审校本书的人经常问我,设计出优良的DSL有哪些技巧。毕竟,语言设计很复杂,我们不希望拙劣的语言越来越多。虽然我内心很希望能告诉别人一些好建议,但我承认,我对此也没有什么清晰的思路。

就像任何写作一样,DSL的整体目标就是向读者清晰地表明意图。作为作者,你希望你的目标读者(如程序员或者领域专家)能够尽可能快、尽可能清楚地理解DSL语句的意图。虽然关于如何做到这一点我没有过多可表达的,但我的确觉得在编程中时刻牢记这一点会非常有价值。

基本上我是一个迭代设计爱好者,在DSL编写上也不例外。设计DSL时,尽早从你的目标受众那里获取反馈。准备多种方案,看看人们对此有什么反应。设计一种好的语言总会经历无数的尝试和失败,不要怕走弯路,走的弯路越多,就越有可能找到正确的路。

在DSL及其语义模型(第11章)中,不要怕使用领域中的行话。如果DSL脚本的用户都熟悉这些行话,就可以在DSL中使用。虽然外人看着奇怪,但对领域内用户来说可以极大地提高沟通的效率。

另外,一定要记得利用一些通用约定。例如,如果每个人都熟悉Java或C#,就可以用“``//”来表示注释,或者用“``{”和“}”来表示层级结构。

我觉得需要特别引起注意的一点是:不要试图让DSL读起来像自然语言。历史上有很多通用型语言做过这方面的尝试,其中最有名的就是AppleScript。这里的问题在于,为了读起来像自然语言,会加入许多语法糖,这就导致对语义的理解变得极其复杂。记住,DSL是一种编程语言,那么用起来要像编程,这就需要比自然语言更简练、更精确。试图使编程语言看起来更像自然语言,会让你陷入错误的上下文中。当你与程序打交道时,一定要记得自己在编程语言环境中。

[1] 这里用了意译,因为中文没有办法表达硬币(coin)和光盘(compact disk)之间的关系。——译者注


相关图书

Rust游戏开发实战
Rust游戏开发实战
仓颉编程快速上手
仓颉编程快速上手
深入浅出Go语言编程从原理解析到实战进阶
深入浅出Go语言编程从原理解析到实战进阶
JUnit实战(第3版)
JUnit实战(第3版)
Go语言编程指南
Go语言编程指南
Scala速学版(第3版)
Scala速学版(第3版)

相关文章

相关课程