java 方法内部方法
介绍
特定领域语言(DSL)通常被描述为针对特定类型问题的计算机语言,并且不计划解决其领域之外的问题。 DSL已经被正式研究了很多年。 然而,直到最近,内部DSL只是作为编程人员的偶然事件而被程序员编写的,他们只是试图以最易读和简洁的方式解决其问题。 最近,随着Ruby和其他动态语言的出现,程序员之间对DSL的兴趣日益增长。 这些结构松散的语言为DSL提供了一种方法,该方法允许最小化语法,因此可以最直接地表示特定语言。 但是,使用这种方法无疑会放弃编译器以及使用最强大的现代IDE(例如Eclipse)的能力。 作者已经成功地折衷了这两种方法,并认为以DSL方向以Java之类的结构化语言来进行API设计是很有可能且有帮助的。 本文介绍了如何使用Java语言编写领域特定的语言,并提出了构建它们的一些模式。
Java是否适合创建内部特定于域的语言?
在检查Java语言作为创建DSL的工具之前,我们需要介绍“内部DSL”的概念。 使用应用程序的主要语言创建内部DSL,而无需创建(和维护)自定义编译器和解释器。 马丁·福勒(Martin Fowler)撰写了大量有关各种类型的DSL(内部和外部DSL)的文章,以及每种类型的一些很好的例子。 但是,以Java之类的语言创建DSL时,他只能顺便解决。
同样重要的是要注意,很难区分DSL和API。 对于内部DSL,它们本质上是相同的。 考虑DSL时,我们利用主机语言来创建范围有限的可读API。 “内部DSL”或多或少是API的一个奇特名称,该API是根据可读性并着眼于特定域的特定问题而创建的。
任何内部DSL都限于其基本语言的语法和结构。 对于Java,必须使用花括号,括号和分号,并且缺少闭包和元编程,可能会导致DSL比使用动态语言创建的DSL更冗长。
从好的方面来说,通过使用Java语言,我们可以利用功能强大且成熟的IDE(例如Eclipse和IntelliJ IDEA),借助“自动完成”,自动重构和调试等功能,可以更轻松地创建,使用和维护DSL。 。 此外,Java 5中的新语言功能(泛型,可变参数和静态导入)可以帮助我们创建比以前版本更紧凑的API。
通常,用Java编写的DSL不会导致业务用户可以从头开始创建的语言。 这将导致这是一个商业用户相当的可读性 ,以及作为非常直观的读取和写入从编程的角度来看的语言。 与外部DSL或用动态语言编写的DSL相比,它的优势在于编译器可以在执行过程中强制正确性,并在Ruby或Pearl会愉快地接受无意义的输入并在运行时失败的情况下标记不当使用。 这大大降低了测试的冗长性,并可以大大提高应用程序质量。 但是,使用编译器以这种方式提高质量是一门艺术,并且当前,许多程序员都为满足编译器而不是使用编译器来构建使用语法来强制语义的语言而苦恼。
使用Java创建DSL有优点和缺点。 最后,您的业务需求和工作环境将决定它是否是您的正确选择。
Java作为内部DSL的平台
动态构建SQL是一个很好的例子,其中构建与SQL领域相适应的“ DSL”具有明显的优势。
使用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
版本需要进行大量修改才能切换为使用绑定变量。 如果引用不正确或将整数参数传递给date列进行比较,则后者将无法编译。 短语“ nvl(foo, 'X') != 'X'
“是在Oracle SQL中发现的一种特定形式。 对于非Oracle SQL程序员或不熟悉SQL的人来说,它几乎是不可读的。 例如,SQL Server中的惯用法就是“ (foo is null or foo != 'X')
。 通过用更易于理解和类似于语言的“ isNotNullOr(rejectedValue)
”替换此短语,增强了可读性,并且保护了系统免遭以后需要更改实现以利用另一数据库供应商提供的功能的保护。
用Java创建内部DSL
创建DSL的最佳方法是,首先对所需的API进行原型设计,然后在给定基本语言约束的情况下进行实现。 DSL的实施将涉及不断测试,以确保我们朝着正确的方向前进。 测试驱动开发(TDD)提倡这种“原型和测试”方法。
当使用Java创建DSL时,我们可能希望通过流畅的界面创建DSL。 流畅的界面提供了我们要建模的领域问题的紧凑且易于阅读的表示形式。 流利的接口是使用方法链实现的。 重要的是要注意,方法链接本身不足以创建DSL。 Java的StringBuilder
是一个很好的例子,该方法的“ append
”总是返回同一StringBuilder
的实例。 这是一个例子:
StringBuilder b = new StringBuilder();
b.append("Hello. My name is ")
.append(name)
.append(" and my age is ")
.append(age);
本示例不解决任何特定于域的域。
除了方法链接之外,静态工厂方法和导入对创建紧凑但可读的DSL很有帮助。 我们将在以下各节中详细介绍这些技术。
1.方法链接
有两种使用方法链创建DSL的方法,两者都与链中方法的返回值有关。 我们的选择是返回this
或返回中间对象,具体取决于我们要执行的操作。
1.1返回this
我们通常回到this
时链方法的调用可以是:
- 可选的
- 以任何顺序调用
- 叫了无数次
我们发现此方法有两个用例:
- 相关对象行为的链接
- 对象的简单构造/配置
1.1.1链接相关对象的行为
很多时候,我们只想链接对象的方法,以通过模拟向同一对象分配“多个消息”(或多个方法调用)来减少代码中不必要的文本。 以下代码清单显示了用于测试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测试。
1.1.2对象的简单构造/配置
这种情况与前一种情况相似,不同之处在于,我们创建了一个“构建器”来使用流利的接口创建和/或配置对象,而不仅仅是链接对象的相关方法。
以下示例说明了使用设置器创建的“梦想中的汽车”:
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
很容易并且代码易于阅读,但是我们可以使用汽车制造商来创建更紧凑的代码:
// 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
}
使用构建器,我们可以重写DreamCar
创建,如下所示:
DreamCar car = car().brand("Tesla")
.color(RED)
.fuelEfficient()
.build();
使用流畅的界面再次减少了代码中的噪音,从而使代码更具可读性。 必须注意的是,返回this
,可以随时随地调用链中的任何方法。 在我们的示例中,我们可以根据需要多次调用color
方法,并且每次调用都将覆盖前一次调用设置的值,这在应用程序上下文中可能是有效的。
另一个重要的观察结果是,没有编译器检查来强制执行必需的字段值。 如果违反了任何对象创建和/或配置规则(例如,缺少必填字段),则可能的解决方案是在运行时引发异常。 通过从链中的方法返回中间对象,可以实现规则验证。
1.2返回中间对象
从流利的接口中的方法返回中间对象比返回this
对象具有一些优势:
- 我们可以使用编译器强制执行业务规则(例如,必填字段)
- 通过限制链中下一个元素的可用选项,我们可以通过特定路径引导我们的流利界面用户
- 使API创建者可以更好地控制用户可以(或必须)调用哪些方法,以及API用户可以调用方法的顺序和次数。
以下示例说明了使用构造函数参数创建的休假:
Vacation vacation = new Vacation("10/09/2007", "10/17/2007",
"Paris", "Hilton",
"United", "UA-6886");
这种方法的好处是,它迫使我们的用户指定所有必需的参数。 不幸的是,参数太多,它们无法传达其目的。 “巴黎”和“希尔顿”是指目的地城市和酒店吗? 还是他们指的是我们同伴的名字? :)
第二种方法是使用设置器来记录每个参数:
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().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
and BookableItem
( Hotel
和Flight
)以及“ Airline
。 该航空公司,在这种情况下,充当工厂的Flight
物体; Location
充当Hotel
物品等的工厂。我们期望的预订语法暗含了这些对象中的每一个,但几乎可以肯定,它们还会在系统中具有许多其他重要的行为。 中间对象的使用允许我们引入编译器检查的用户可以做什么和不能做什么的约束。 例如,如果API的用户尝试预订有开始日期但没有结束日期的假期,则该代码将根本无法编译。 如前所述,我们可以构建一种使用语法来强制语义的语言。
在前面的示例中,我们还介绍了静态工厂方法的用法。 静态工厂方法与静态导入一起使用时,可以帮助我们创建更紧凑的流畅接口。 例如,如果没有静态导入,则前面的示例将需要这样编码:
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");
上面的示例不像使用静态导入的示例那样可读。 在下一节中,我们将详细介绍静态工厂方法和导入。
这是Java中DSL的第二个示例。 这次,我们正在简化Java反射的用法:
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");
使用方法链接时,我们需要谨慎。 这很容易过度使用,从而导致许多呼叫“残骸”链接在一条线路中。 这可能会导致许多问题,包括在出现异常时大大降低堆栈跟踪中的可读性和模糊性。
2.静态工厂方法和导入
静态工厂方法和导入可以使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不支持命名参数。 从好的方面来说,我们可以使用静态工厂方法和静态导入来模拟它们,从而得到如下结果:
dialog.table("results").selectCell(row(6).column(8));
我们可以通过更改方法的签名开始,方法是将所有参数替换为一个包含它们的对象。 在我们的示例中,我们可以将selectCell(int, int)
的签名更改为:
selectCell(TableCell);
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
以强制用户使用factory方法:
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
包括静态导入。 为了刷新我们的记忆,对selectCell
调用如下所示:
dialog.table("results").selectCell(row(6).column(8));
我们的示例显示,只需做一些额外的工作,我们就可以克服宿主语言的某些限制。 如前所述,这只是使用静态工厂方法和导入来提高代码可读性的多种方式之一。 以下代码清单显示了使用静态工厂方法和以不同方式导入的另一种解决表索引问题的方法:
/**
* @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 IDE,它们可以使DSL的创建,使用和维护变得更加容易。
用Java创建DSL还需要API设计人员做更多的工作。 有更多的代码和更多的文档可以创建和维护。 结果可能是有益的。 使用我们的API的用户将看到其代码库中的改进。 他们的代码将更紧凑,更易于维护,从而可以简化他们的生活。
根据我们要完成的工作,有许多种用Java创建DSL的方法。 尽管没有“一刀切”的方法,但我们发现将方法链与静态工厂方法和导入结合使用可以产生干净,紧凑的API,并且易于编写和阅读。
总而言之,使用Java创建DSL时有优缺点。 开发人员应根据我们项目的需求来决定是否是正确的选择。
附带说明一下, Java 7可能包含新的语言功能(例如闭包),可以帮助我们创建较少冗长的DSL。 有关拟议功能的完整列表,请访问Alex Miller的博客 。
java 方法内部方法