作者 Alex Ruiz and Jeff Bay 译者 沙晓兰 发布于 2008年3月12日 上午1时4分
领域特定语言(DSL)通常被定义为一种特别针对某类特殊问题的计算机语言,它不打算解决其领域外的问题。对于DSL的正式研究已经持续很多年,直到最近,在程序员试图采用最易读并且简炼的方法来解决他们的问题的时候,内部DSL意外地被写入程序中。近来,随着关于Ruby和其他一些动态语言的出现,程序员对DSL的兴趣越来越浓。这些结构松散的语言给DSL提供某种方法,使得DSL允许最少的语法以及对某种特殊语言最直接的表现。但是,放弃编译器和使用类似Eclipse这样最强大的现代集成开发环境无疑是该方式的一大缺点。然而,作者终于成功地找到了这两个方法的折衷解决方式,并且,他们将证明该折衷方法不但可能,而且对于使用Java这样的结构性语言从面向DSL的方式来设计API很有帮助。本文将描述怎样使用Java语言来编写领域特定语言,并将建议一些组建DSL语言时可采用的模式。
在我们审视Java语言是否可以作为创建DSL的工具之前,我们首先需要引进“内部DSL”这个概念。一个内部DSL在由应用软件的主编程语言创建,对定制编译器和解析器的创建(和维护)都没有任何要求。Martin Fowler曾编写过大量各种类型的DSL,无论是内部的还是外部的,每种类型他都编写过一些不错的例子。但使用像Java这样的语言来创建DSL,他却仅仅一笔带过。
另外还要着重提出的很重要的一点是,在DSL和API两者间其实很难区分。在内部DSL的例子中,他们本质上几乎是一样的。在联想到DSL这个词汇的时候,我们其实是在利用主编程语言在有限的范围内创建易读的API。“内部DSL”几乎是一个特定领域内针对特定问题而创建的极具可读性的API的代名词。
任何内部DSL都受它基础语言的文法结构的限制。比如在使用Java的情况下,大括弧,小括弧和分号的使用是必须的,并且缺少闭包和元编程有可能会导致DSL比使用动态语言创建来的更冗长。
但从光明的一面来看,通过使用Java,我们同时能利用强大且成熟的类似于Eclipse和IntelliJ IDEA的集成开发环境,由于这些集成开发环境“自动完成(auto-complete)”、自动重构和debug等特性,使得DSL的创建、使用和维护来的更加简单。另外,Java5中的一些新特性(比如generic、varargs 和static imports)可以帮助我们创建比以往任何版本任何语言都简洁的API。
一般来说,使用Java编写的DSL不会造就一门业务用户可以上手的语言,而会是一种业务用户也会觉得易读的语言,同时,从程序员的角度,它也会是一种阅读和编写都很直接的语言。和外部DSL或由动态语言编写的DSL相比有优势,那就是编译器可以增强纠错能力并标识不合适的使用,而Ruby或Pearl会“愉快接受”荒谬的input并在运行时失败。这可以大大减少冗长的测试,并极大地提高应用程序的质量。然而,以这样的方式利用编译器来提高质量是一门艺术,目前,很多程序员都在为尽力满足编译器而非利用它来创建一种使用语法来增强语义的语言。
利用Java来创建DSL有利有弊。最终,你的业务需求和你所工作的环境将决定这个选择正确与否。
动态构建SQL是一个很好的例子,其建造了一个DSL以适合SQL领域,获得了引人注意的优势。
传统的使用SQL的Java代码一般类似于:
String sql = "select id, name " +
"from customers c, order o " +
"where " +
"c.since >= sysdate - 30 and " +
"sum(o.total) > " + significantTotal + " and " +
"c.id = o.customer_id and " +
"nvl(c.status, 'DROPPED') != 'DROPPED'";
从作者最近工作的系统中摘录的另一个表达方式是:
Table c = CUSTOMER.alias();
Table o = ORDER.alias();
Clause recent = c.SINCE.laterThan(daysEarlier(30));
Clause hasSignificantOrders = o.TOTAT.sum().isAbove(significantTotal);
Clause ordersMatch = c.ID.matches(o.CUSTOMER_ID);
Clause activeCustomer = c.STATUS.isNotNullOr("DROPPED");
String sql = CUSTOMERS.where(recent.and(hasSignificantOrders)
.and(ordersMatch)
.and(activeCustomer)
.select(c.ID, c.NAME)
.sql();
这个DSL版本有几项优点。后者能够透明地适应转换到使用PreparedStatement的方法——用
String拼写SQL的
版本则需要大量的修改才能适应转换到使用捆绑变量的方法。如果引用不正确或者一个integer变量被传递到date column作比较的话,后者
版本根本无法通过编译。代码“nvl(foo, 'X') != 'X'
”是Oracle SQL中的一种特殊形式,这个句型对于非Oracle SQL程序员或不熟悉SQL的人来说很难读懂。例如在SQL Server方言中,该代码应该这样表达“(foo is null or foo != 'X')
”。但通过使用更易理解、更像人类语言的“isNotNullOr(rejectedValue)
”来替代这段代码的话,显然会更具阅读性,并且系统也能够受到保护,从而避免将来为了利用另一个数据库供应商的设施而不得不修改最初的代码实现。
创建DSL最好的方法是,首先将所需的API原型化,然后在基础语言的约束下将它实现。DSL的实现将会牵涉到连续不断的测试来肯定我们的开发确实瞄准了正确的方向。该“原型-测试”方法正是测试驱动开发模式(TDD-Test-Driven Development)所提倡的。
在使用Java来创建DSL的时候,我们可能想通过一个连贯接口(fluent interface)来创建DSL。连贯接口可以对我们所想要建模的领域问题提供一个简介但易读的表示。连贯接口的实现采用方法链接(method chaining)。但有一点很重要,方法链接本身不足以创建DSL。一个很好的例子是Java的StringBuilder
,它的方法“append”总是返回一个同样的StringBuilder
的实例。这里有一个例子:
StringBuilder b = new StringBuilder();
b.append("Hello. My name is ")
.append(name)
.append(" and my age is ")
.append(age);
该范例并不解决任何领域特定问题。
除了方法链接外,静态工厂方法(static factory method)和import对于创建简洁易读的DSL来说是不错的助手。在下面的章节中,我们将更详细地讲到这些技术。
使用方法链接来创建DSL有两种方式,这两种方式都涉及到链接中方法的返回值。我们的选择是返回this或者返回一个中间对象,这决定于我们试图要所达到的目的。
this
在可以以下列方式来调用链接中方法的时候,我们通常返回this
:
我们发现运用这个方法的两个用例:
很多次,我们只在企图减少代码中不必要的文本时,才通过模拟分派“多信息”(或多方法调用)给同一个对象而将对象的方法进行链接。下面的代码段显示的是一个用来测试Swing GUI的API。测试所证实的是,如果一个用户试图不输入她的密码而登录到系统中的话,系统将显示一条错误提示信息。
DialogFixture dialog = new DialogFixture(new LoginDialog());
dialog.show();
dialog.maximize();
TextComponentFixture usernameTextBox = dialog.textBox("username");
usernameTextBox.clear();
usernameTextBox.enter("leia.organa");
dialog.comboBox("role").select("REBEL");
OptionPaneFixture errorDialog = dialog.optionPane();
errorDialog.requireError();
errorDialog.requireMessage("Enter your password");
尽管代码很容易读懂,但却很冗长,需要很多键入。
下面列出的是在我们范例中所使用的TextComponentFixture
的两个方法:
public void clear() {
target.setText("");
}
public void enterText(String text) {
robot.enterText(target, text);
}
我们可以仅仅通过返回this
来简化我们的测试API,从而激活方法链接:
public TextComponentFixture clear() {
target.setText("");
return this;
}
public TextComponentFixture enterText(String text) {
robot.enterText(target, text);
return this;
}
在激活所有测试设施中的方法链接之后,我们的测试代码现在缩减到:
DialogFixture dialog = new DialogFixture(new LoginDialog());
dialog.show().maximize();
dialog.textBox("username").clear().enter("leia.organa");
dialog.comboBox("role").select("REBEL");
dialog.optionPane().requireError().requireMessage("Enter your password");
这个结果代码显然更加简洁易读。正如先前所提到的,方法链接本身并不意味着有了DSL。我们需要将解决领域特定问题的对象的所有相关行为相对应的方法链接起来。在我们的范例中,这个领域特定问题就是Swing GUI测试。
这个案例和上文的很相似,不同是,我们不再只将一个对象的相关方法链接起来,取而代之的是,我们会通过连贯接口创建一个“builder”来构建和/或配置对象。
下面这个例子采用了setter来创建“dream car”:
DreamCar car = new DreamCar();
car.setColor(RED);
car.setFuelEfficient(true);
car.setBrand("Tesla");
DreamCar
类的代码相当简单:
// package declaration and imports
public class DreamCar {
private Color color;
private String brand;
private boolean leatherSeats;
private boolean fuelEfficient;
private int passengerCount = 2;
// getters and setters for each field
}
尽管创建DreamCar
非常简单,并且代码也十分可读,但我们仍能够使用car builder来创造更简明的代码:
// package declaration and imports
public class DreamCarBuilder {
public static DreamCarBuilder car() {
return new DreamCarBuilder();
}
private final DreamCar car;
private DreamCarBuilder() {
car = new DreamCar();
}
public DreamCar build() { return car; }
public DreamCarBuilder brand(String brand) {
car.setBrand(brand);
return this;
}
public DreamCarBuilder fuelEfficient() {
car.setFuelEfficient(true);
return this;
}
// similar methods to set field values
}
通过builder,我们还能这样重新编写DreamCar
的创建过程:
DreamCar car = car().brand("Tesla")
.color(RED)
.fuelEfficient()
.build();
使用连贯接口,再一次减少了代码噪音,所带来的结果是更易读的代码。需要指出的很重要的一点是,在返回this
的时候,链中任何方法都可以在任何时候被调用,并且可以被调用任何次数。在我们的例子中,color
这个方法我们可想调用多少次就调用多少次,并且每次调用都会覆盖上一次调用所设置的值,这在应用程序的上下文中可能是合理的。
另一个重要的发现是,没有编译器检查来强制必需的属性值。一个可能的解决方案是,如果任何对象创建和/或配置规则没有得到满足的话(比如,一个必需属性被遗忘),在运行时抛出异常。通过从链中方法返回中间对象有可能达到规则校验的目的。
从连贯接口的方法中返回中间对象和返回this
的方式相比,有这样一些优点:
下面的例子表示的是通过带参数的构建函数来创建一个vacation对象的实例:
Vacation vacation = new Vacation("10/09/2007", "10/17/2007",
"Paris", "Hilton",
"United", "UA-6886");
这个方法的好处在于它可以迫使我们的用户申明所有必需的参数。不幸的是,这儿有太多的参数,而且没有表达出他们的目的。“Paris”和“Hilton”所指的分别是目的地的城市和酒店?还是我们同事的名字?:)
第二个方法是将setter方法对每个参数进行建档:
Vacation vacation = new Vacation();
vacation.setStart("10/09/2007");
vacation.setEnd("10/17/2007");
vacation.setCity("Paris");
vacation.setHotel("Hilton");
vacation.setAirline("United");
vacation.setFlight("UA-6886");
现在我们的代码更易读,但仍然很冗长。第三个方案则是创建一个连贯接口来构建vacation对象的实例,如同在前一章节提供的例子一样:
Vacation vacation = vacation().starting("10/09/2007")
.ending("10/17/2007")
.city("Paris")
.hotel("Hilton")
.airline("United")
.flight("UA-6886");
这个版本的简明和可读性又进了一步,但我们丢失了在第一个版本(使用构建函数的那个版本)中所拥有的关于遗忘属性的校验。换句话说,我们并没有使用编译器来校验可能存在的错误。这时,对这个方法我们所能做的最好的改进是,如果某个必需属性没有设置的话,在运行时抛出异常。
以下是第四个版本,连贯接口更完善的版本。这次,方法返回的是中间对象,而不是this:
Period vacation = from("10/09/2007").to("10/17/2007");
Booking booking = vacation.book(city("Paris").hotel("Hilton"));
booking.add(airline("united").flight("UA-6886");
这里,我们引进了Period
、Booking
、Location
、BookableItem
(Hotel
和Flight)
、以及 Airline
的概念。在这里的上下文中,airline作为Flight
对象的一个工厂;Location
是Hotel
的工厂,等等。我们所想要的booking的文法隐含了所有这些对象,几乎可以肯定的是,这些对象在系统中会有许多其他重要的行为。采用中间对象,使得我们可以对用户行为可否的限制进行编译器校验。例如,如果一个API的用户试图只通过提供一个开始日期而没有明确结束日期来预定假期的话,代码则不会被编译。正如我们之前提到,我们可以创建一种使用文法来增强语义的语言。
我们在上面的例子中还引入了静态工厂方法的应用。静态工厂方法在与静态import同时使用的时候,可以帮助我们创建更简洁的连贯接口。若没有静态import,上面的例子则需要这样的代码:
Period vacation = Period.from("10/09/2007").to("10/17/2007");
Booking booking = vacation.book(Location.city("Paris").hotel("Hilton"));
booking.add(Flight.airline("united").flight("UA-6886");
上面的例子不及采用了静态import的代码那么易读。在下面的章节中,我们将对静态工厂方法和import做更详细的讲解。
这是关于使用Java编写DSL的第二个例子。这次,我们将Java reflection的使用进行简化:
Person person = constructor().withParameterTypes(String.class)
.in(Person.class)
.newInstance("Yoda");
method("setName").withParameterTypes(String.class)
.in(person)
.invoke("Luke");
field("name").ofType(String.class)
.in(person)
.set("Anakin");
在使用方法链接的时候,我们必须倍加注意。方法链接很容易会被烂用,它会导致许多调用被一起链接在单一行中的“火车残骸”现象。这会引发很多问题,包括可读性的急剧下滑以及异常发生时栈轨迹(stack trace)的含义模糊。
静态工厂方法和imports可以使得API更加简洁易读。我们发现,静态工厂方法是在Java中模拟命名参数的一个非常方便的方法,是许多程序员希望开发语言中所能够包含的特性。比如,对于这样一段代码,它的目的在于通过模拟一个用户在一个JTable
中选择一行来测试GUI:
dialog.table("results").selectCell(6, 8); // row 6, column 8
没有注释“// row 6, column 8
”,这段代码想要实现的目的很容易被误解(或者说根本没有办法理解)。我们则需要花一些额外的时间来检查文档或者阅读更多行代码才能理解“6”和“8”分别代表什么。我们也可以将行和列的下标作为变量来声明,而非像上面这段代码那样使用常量:
int row = 6;
int column = 8;
dialog.table("results").selectCell(row, column);
我们已经改进了这段代码的可读性,但却付出了增加需要维护的代码的代价。为了将代码尽量简化,理想的解决方案是像这样编写代码:
dialog.table("results").selectCell(row: 6, column: 8);
不幸的是,我们不能这样做,因为Java不支持命名参数。好的一面的是,我们可以通过使用静态工厂方法和静态imports来模拟他们,从而可以得到这样的代码:
dialog.table("results").selectCell(row(6).column(8));
我们可以从改变方法的签名(signature)开始,通过包含所有参数的对象来替代所有这些参数。在我们的例子中,我们可以将方法selectCell(int, int)
修改为:
selectCell(TableCell);
TableCell
will contain the values for the row and column indices:
TableCell
将包含行和列的下标值:
public final class TableCell {
public final int row;
public final int column;
public TableCell(int row, int column) {
this.row = row;
this.column = column;
}
}
这时,我们只是将问题转移到了别处:TableCell
的构造函数仍然需要两个int
值。下一步则是将引入一个TableCell
的工厂,这个工厂将对初始版本中selectCell
的每个参数设置一个对应的方法。另外,为了迫使用户使用工厂,我们需要将TableCell
的构建函数修改为private
:
public final class TableCell {
public static class TableCellBuilder {
private final int row;
public TableCellBuilder(int row) {
this.row = row;
}
public TableCell column(int column) {
return new TableCell(row, column);
}
}
public final int row;
public final int column;
private TableCell(int row, int column) {
this.row = row;
this.column = column;
}
}
通过TableCellBuilder
工厂,我们可以创建对每个参数都有一个调用方法的TableCell
。工厂中的每个方法都表达了其参数的目的:
selectCell(new TableCellBuilder(6).column(8));
最后一步是引进静态工厂方法来替代TableCellBuilder
构造函数的使用,该构造函数没有表达出6代表的是什么。如我们在之前所实现的那样,我们需要将构造函数设置为private
来迫使用户使用工厂方法:
public final class TableCell {
public static class TableCellBuilder {
public static TableCellBuilder row(int row) {
return new TableCellBuilder(row);
}
private final int row;
private TableCellBuilder(int row) {
this.row = row;
}
private TableCell column(int column) {
return new TableCell(row, column);
}
}
public final int row;
public final int column;
private TableCell(int row, int column) {
this.row = row;
this.column = column;
}
}
现在我们只需要selectCell
的调用代码中增加内容,包含对TableCellBuilder
中row
方法的静态import。为了刷新一下我们的记忆,这是如何实现调用selectCell
的代码:
dialog.table("results").selectCell(row(6).column(8));
我们的例子说明,一点点额外的工作可以帮助我们克服主机编程语言中的一些限制。正如之前提到的,这只是我们通过使用静态工厂方法和imports来改善代码可读性的很多方法中的一个。下列代码段是以另一种不同的方法利用静态工厂方法和imports来解决相同的table坐标问题:
/**
* @author Mark Alexandre
*/
public final class TableCellIndex {
public static final class RowIndex {
final int row;
RowIndex(int row) {
this.row = row;
}
}
public static final class ColumnIndex {
final int column;
ColumnIndex(int column) {
this.column = column;
}
}
public final int row;
public final int column;
private TableCellIndex(RowIndex rowIndex, ColumnIndex columnIndex) {
this.row = rowIndex.row;
this.column = columnIndex.column;
}
public static TableCellIndex cellAt(RowIndex row, ColumnIndex column) {
return new TableCellIndex(row, column);
}
public static TableCellIndex cellAt(ColumnIndex column, RowIndex row) {
return new TableCellIndex(row, column);
}
public static RowIndex row(int index) {
return new RowIndex(index);
}
public static ColumnIndex column(int index) {
return new ColumnIndex(index);
}
}
这个方案的第二个版本比第一个版本更具灵活性,因为这个版本允许我们通过两种途径来声明行和列的坐标:
dialog.table("results").select(cellAt(row(6), column(8));
dialog.table("results").select(cellAt(column(3), row(5));
相比返回中间对象的的方式来说,返回this
的方式更加容易组织连贯接口的代码。前面的案例中,我们的最后结果是使用更少的类来封装连贯接口的逻辑,并且使得我们可以在组织非DSL代码的时候使用同样的规则或约定。
采用中间对象作为返回类型来组织连贯接口的代码更具技巧性,因为我们将连贯接口的逻辑遍布在一些小的类上。由于这些类结合在一起作为整体而形成我们的连贯接口,这使得将他们作为整体对待更为合理,我们可能不想将他们和DSL外的其他一些类混淆一起,那么我们有两个选择:
分解我们的系统所采用的方式取决于我们想要实现的文法的几个因素:DSL的目的,中间对象(如果有的话)的数量和大小(以代码的行数来计),以及DSL如何来与其它的代码库及其它的DSL相协调。
在组织代码一章节中提到,对方法返回this
的连贯接口建档比对返回中间对象的连贯接口建档来的简单的多,尤其是在使用Javadoc来建档的情况下。
Javadoc每次显示一个类的文档,这对于使用中间对象的DSL来说可能不是最好的方式:因为这样的DSL包含一组类,而不是单个的类。由于我们不能改变Javadoc显示我们的API文档的方式,我们发现在package.html文件中,加入一个使用连贯接口(包含所有相关类)、且对链中每个方法提供链接的例子,可以将Javadoc的限制的影响降到最低。
我们需要注意不要创建重复文档,因为那样会增加API创建者的维护代价。最好的方法是尽可能依赖于像可执行文档那样的测试。
Java适用于创建开发人员易读易写的、并且对于商业用户用样易读的内部领域特定语言。用Java创建的DSL可能比那些由动态语言创建的DSL来的冗长。但好的一面是,通过使用Java,我们可以利用编译器来增强DSL的语义。另外,我们依赖于成熟且强大的Java集成开发环境,从而使DSL的创建、使用和维护更加简单。
使用Java创建DSL需要API设计者做更多的工作,有更多的代码和文档需要创建和维护。但是,付出总有回报。使用我们API的用户在他们的代码库中会看到更多的优化。他们的代码将会更加简洁,更易于维护,这些将使得他们的生活更加轻松。
使用Java创建DSL有很多种不同的方式,这取决于我们试图达到的目的是什么。尽管没有什么通用的方法,我们还是发现结合方法链接和静态工厂方法与imports的方式可以得到干净、简洁、易读易写的API。
总而言之,在使用Java来创建DSL的时候有利有弊。这都由我们——开发人员根据项目需求去决定它是否是正确的选择。
另外一点题外话,Java 7可能会包含帮助我们创建不那么冗长的DSL的新语言特性(比如闭包)。如果想得到更多关于建议中所提特性的全面的列表,请访问Alex Miller的blog。
Alex Ruiz是Oracle开发工具组织中的一名软件工程师。Alex喜欢阅读任何关于Java、测试、OOP 和AOP的信息,他最大的爱好就是编程。在加入Oracle之前,Alex曾是ThoughtWorks的咨询顾问。Alex的blog为 http://www.jroller.com/page/alexRuiz。
Jeff Bay是纽约一家对冲基金的高级软件工程师。他曾多次建立高质量、迅速的XP团队工作于例如Onstar的计划注册系统、租赁软件、web服务器、建筑项目管理等各种系统。他对于消除重复和防止bug方面怀有极大的热情,以提高开发者的工作效率和减少在各种任务上所花费的时间。