演化架构和紧急设计: 连贯接口

构建内部 DSLs 来捕捉惯用域模式

本期的 演化架构和紧急设计 继续讨论在紧急设计中捕获惯用模式的方法。发现一个可重用模式时,您应当将其与其余代码分离,然后捕获它。特定领域语言(DSL)提供许多可准确捕获数据和功能的方法。这个月,Neal Ford 将展示构建内部 DSL 的三种方法,以捕获惯用域模式。

Neal Ford, 软件架构师, ThoughtWorks Inc.

Neal FordNeal Ford 是一家全球性 IT 咨询公司 ThoughtWorks 的软件架构师和 Meme Wrangler。他的工作还包括设计和开发应用程序、教材、杂志文章、课件和视频/DVD 演示,而且他是各种技术书籍的作者或编辑,包括最近的新书 The Productive Programmer。他主要的工作重心是设计和构建大型企业应用程序。他还是全球开发人员会议上的国际知名演说家。请访问他的 Web 站点



2010 年 9 月 07 日

系列上一期 介绍了如何使用特定领域语言(DSL)来捕获域惯用模式。本期将继续该主题,展示各种 DSL 构建方法。

在下一本书 Domain Specific Languages 中,Martin Fowler 将阐述两种 DSLs 之间的区别(参见 参考资料)。外部 DSLs 可构建一个新语法,构建时需要使用 lexx 和 yacc 或 Antlr 等工具。一个内部 DSL 在基本语言基础之上构建新的语言,并借用和样式化基本语言的语法。本期示例将以 Java™ 为基本语言构建内部 DSLs,在其语法之上构建新的小型语言。

关于本系列

系列 旨在从全新的视角来介绍经常讨论但是又难以理解的软件架构和设计概念。通过具体示例,Neal Ford 将帮助您在 演化架构紧急设计 的灵活实践中打下坚实的基础。通过将重要的架构和设计决定推迟到最后责任时刻,您可以防止不必要的复杂度降低软件项目的质量。

强调通过以下所有方法构建 DSLs 是隐式上下文 的概念所在。DSLs(特别是内部 DSLs)试图通过创建围绕相关元素的上下文包装器来消除繁杂的语法。该概念的一个典型示例是 XML 中的父元素和子元素,这些元素提供一个围绕相关项目的包装器。您会注意到,这些 DSL 方法中有许多使用语言语法技巧来达到同样的效果。

可读性是使用 DSL 的优势之一。如果您编写非开发人员可以读懂的代码,那么就缩短了您的团队与请求相关代码功能的人之间的反馈环路。Fowler 的书中确定的一个公共 DSL 模式叫作连贯接口,他将其定义为能够为一系列方法调用中转或维护指令上下文的行为。我会向您展示几种连贯接口,首先是方法链接

方法链接

方法链接使用方法的返回值来中转指令上下文,在这种情况下是进行第一个方法调用的对象实例。这听起来要复杂多了,因此我要通过一个示例来阐明该概念。

使用 DSLs 时,常常需要从目标语法开始向后运转,以弄清如何实现它。从终点开始行得通是因为可读性在 DSLs 中非常重要。我要使用的示例是一个跟踪日历条目的小型应用程序。该应用程序阐释了 DSL 的语法,如清单 1 所示:

清单 1. 日历 DSL 的目标语法
public class CalendarDemoChained {

    public static void main(String[] args) {
        new CalendarDemoChained();
    }

    public CalendarDemoChained() {
        Calendar fourPM = Calendar.getInstance();
        fourPM.set(Calendar.HOUR_OF_DAY, 16);
        Calendar fivePM = Calendar.getInstance();
        fivePM.set(Calendar.HOUR_OF_DAY, 17);

        AppointmentCalendarChained calendar =
                new AppointmentCalendarChained();
        calendar.add("dentist").
                from(fourPM).
                to(fivePM).
                at("123 main street");

        calendar.add("birthday party").at(fourPM);
        displayAppointments(calendar);
    } 

    private void displayAppointments(AppointmentCalendarChained calendar) {
        for (Appointment a : calendar.getAppointments())
            System.out.println(a.toString());
    }
}

在上面处理完 Java 日历必要的繁琐设置之后,您可以看到,在我向两个日历条目添加值之后,方法链接连贯接口开始运作。注意,我使用空格来分隔那部分单一代码行(从 Java 语法角度来看)。样式化基本语言以使 DSL 更加可读的行为在内部 DSLs 中是很常见的。

Appointment 类包含清单 2 中出现的大部分连贯接口方法:

清单 2. Appointment
public class Appointment {
    private String _name;
    private String _location;
    private Calendar _startTime;
    private Calendar _endTime;

    public Appointment(String name) {
        this._name = name;
    }

    public Appointment() {
    }

    public Appointment name(String name) {
        _name = name;
        return this;
    }
    public Appointment at(String location) {
        _location = location;
        return this;
    }

    public Appointment at(Calendar startTime) {
        _startTime = startTime;
        return this;
    }

    public Appointment from(Calendar startTime) {
        _startTime = startTime;
        return this;
    }

    public Appointment to(Calendar endTime) {
        _endTime = endTime;
        return this;
    }

    public String toString() {
        return "Appointment:"+ _name +
                ((_location != null && _location.length() > 0) ? 
                    ", location:" + _location : "") +
                ", Start time:" + _startTime.get(Calendar.HOUR_OF_DAY) +
                (_endTime != null? ", End time: " + 
                _endTime.get(Calendar.HOUR_OF_DAY) : "");
    }
}

如您所见,构建连贯接口很简单。对于每个赋值方法,您可以编写 setter 方法来返回主机对象(this)并用更加可读的名称替换 set 命名约定,这样就能从标准 JavaBean 语法中分离出来。本节开始部分的一般定义现在应该很清楚了。通过方法链接中转的上下文是 this,这意味着,您可以精确地进行一系列方法调用。

在 “利用可重用代码,第 2 部分” 一文中,我展示了火车车厢的一个 API 定义,如清单 3 所示:

清单 3. 火车车厢的 API
Car2 car = new CarImpl();
MarketingDescription desc = new MarketingDescriptionImpl();
desc.setType("Box");
desc.setSubType("Insulated");
desc.setAttribute("length", "50.5");
desc.setAttribute("ladder", "yes");
desc.setAttribute("lining type", "cork");
car.setDescription(desc);

由于有关内容和历史的一些监管规则,车厢的问题领域很复杂。在本例所属的项目上,我们有大量复杂的测试场景,需要几十行 set 调用,如 清单 3 中的那些调用。我们试图让业务分析师验证我们有着正确、神奇的属性组合,但是他们回绝了,因为他们将其看作是他们没有兴趣阅读的 Java 代码。该问题的最终影响是,开发人员需要口头翻译细节,这当然是易错且耗时的。

为了解决该问题,我们将 Car 类转换为一个连贯接口,因而 清单 3 中的代码变为清单 4 中的连贯接口:

清单 4. 火车车厢的连贯接口
Car car = Car.describedAs()
             .box()
             .length(50.5)
             .type(Type.INSULATED)
             .includes(Equipment.LADDER)
             .lining(Lining.CORK);

该代码清晰易懂,并移除了 Java API 版本上的大量繁杂代码,而这是业务分析师很乐意为我们验证的。

返回到日历示例中,最后实现的是 AppointmentCalendar 类,如清单 5 所示:

清单 5. AppointmentCalendar
public class AppointmentCalendarChained {
    private List<Appointment> appointments;

    public AppointmentCalendarChained() {
        appointments = new ArrayList<Appointment>();
    }

    public List<Appointment> getAppointments() {
        return appointments;
    }

    public Appointment add(String name) {
        Appointment appt = new Appointment(name);
        appointments.add(appt);

        return appt;                        
    }
}

add() 方法:

  1. 通过创建一个新的 Appointment 实例开始方法链接
  2. 将新实例添加到 appointments 列表中
  3. 最后返回新 appointment 实例,这意味着随后的方法调用是在新的 appointment 上进行的

运行应用程序时,您可以看到您配置的 appointments 的细节,如图 1 所示:

图 1. 日历应用程序运行结果
演示应用程序输出

到目前为止,方法链接看起来像一个清理过多繁琐语义的简单方法,特别是最具声明性的那些方法调用。这对于紧急设计中的惯用模式很有效,因为域模式通常都是声明性的。

注意,使用方法链接就必然会违反 JavaBeans 的语法规则,该规则规定赋值方法必须以 set 开始并返回 void。构建连贯接口就可以知道什么时候违反一些规则是行得通的。如果 JavaBeans 规范强制您编写混淆代码,那么它不会给您带来任何帮助。但是创建和使用连贯接口也不能排除对连贯接口和 JavaBeans 接口的同时支持。连贯接口方法可以转而调用标准 set 方法,从而允许您在框架坚持与 JavaBeans 类交互时仍然使用连贯接口。


解决终了问题

连贯接口固有的一个缺陷在某些情况下也称作终了问题。我会通过更改 清单 5 中的 AppointmentCalendar 类来说明这个问题。假设您希望做的不只是显示 appointments,比如将其放在一个数据库或其他一些持久性机制中。您要在哪里添加代码来保存已完成的 appointment?您可以尝试在返回 appointment 之前在 AppointmentCalendaradd() 方法中执行它。清单 6 显示了试图访问 appointment 并进行简单的打印操作的代码:

清单 6. 添加打印
public Appointment add(String name) {
    Appointment appt = new Appointment(name);
    appointments.add(appt);
    System.out.println(appt.toString());
    return appt;
}

运行 清单 6 中的代码时,会出现图 2 所示的不满结果:

图 2. 更改 AppointmentCalendar 之后的错误输出
更改 AppointmentCalendar 后的输出结果

显示的错误是一个 NullPointerException,它发生在 Appointment 类上的 toString() 方法中。尽管方法运行正确,它还是会报错,这就是终了问题的实质。

出现错误的原因在于,我试图在连贯接口 setter 方法被调用之前调用 appointment 实例上的 toString() 方法。方法中试图打印 appointment 的代码创建 appointment 实例并开始方法链接。我可以创建一个必须在链接中最后调用的 save()finished() 方法,但是我不愿给我的 DSL 用户强加一个容易忘记的规则。事实上,我不想在我的连贯接口中对方法强加任何规则语法。

真正的问题在于,我对于方法链接技术过于自信。方法链接对于简单数据对象的创建最有效,但是这里我同时为 Appointment 上和 AppointmentCalendar 中的 setter 方法启用方法链接。

我可以解决这个终了问题,方法就是完全通过 appointment 日历的 add() 方法的圆括号包装对 appointment 的创建,如清单 7 所示:

清单 7. 通过参数进行包装
AppointmentCalendar calendar = new AppointmentCalendar();
calendar.add(
        new Appointment("Dentist").
        at(fourPM));
calendar.add(
        new Appointment("Conference Call").
        from(fourPM).
        to(fivePM).
        at("555-123-4321"));
calendar.add(
            new Appointment("birthday party").
            from(fourPM).
            to(fivePM)).
        add(
            new Appointment("Doctor").
            at("123 Main St"));
calendar.add(
        new Appointment("No Fluff, Just Stuff").
        at(fourPM));
displayAppointments(calendar);

清单 7 中,add() 方法的圆括号封装对 Appointment 连贯接口的整个使用,允许 add() 方法处理它需要的任何附加行为(打印、持久性,等等)。事实上,我禁不住向 AppointmentCalendar 本身添加了一点连贯接口:您现在可以将 add() 方法链接起来,如 清单 7 所示,清单 8 是对它的实现:

清单 8. 参数包装 AppointmentCalendar
public class AppointmentCalendar {
    private List<Appointment> appointments;

    public AppointmentCalendar() {
        appointments = new ArrayList<Appointment>();
    }

    public AppointmentCalendar add(Appointment appt) {
        appointments.add(appt);
        return this;
    }

    public List<Appointment> getAppointments() {
        return appointments;
    }
}

终了问题在您组合连贯接口类的任何时候都有可能出现。它在本例中出现是因为我使用了 appointment 日历来启动方法链接,混合了构建和包装行为。通过延迟对 Appointment 类的构建和初始化,简化了对附加包装行为(比如持久性)的分离。


通过功能序列进行包装

目前为止,我展示了连贯接口 DSLs 三种上下文传递方法中的两种。第三种方法 — 功能序列 — 使用继承性和匿名内部类来创建上下文包装器。使用功能序列重新编写的日历应用程序如清单 9 所示:

清单 9. 通过功能序列进行包装
calendar.add(new Appointment() {{
    name("dentist");
    from(fourPM);
    to(fivePM);
    at("123 main street");
}});

calendar.add(new Appointment() {{
    name("birthday party");
    at(fourPM);
}});

清单 9 显示了我在 “利用可重用代码,第 2 部分” 中引入的一个模式,它以删除结构性重复为名义。这个语法看起来很古怪,古怪之处就在于这个双括弧 {{ 。第一组闭合括弧划定一个匿名内部类的构造,第二组划定匿名内部类的实例初始化语句块。(如果这听起来有点混淆,您可以参阅 “利用可重用代码,第 2 部分” 了解有关该 Java 习语的详细解释。)

这种连贯接口的主要优势在于其适应性。惟一需要以这种方式使用一个类的是一个默认构造函数(它允许您创建继承自类的一个匿名内部类)。这意味着,您可以向现有 Java APIs 轻松添加连贯接口方法,而无需更改任何当前调用语法。这可以让您逐渐 “连贯化” 现有 APIs。


结束语

DSLs 精确有效地捕捉惯用域模式。连贯接口提供一种简单的方式来更改编写代码的方式,以便您更容易看到 一直竭力识别的惯用模式。它们还强制您稍微换一个角度来看待代码:它不仅要有功能性,而且也要有很强的可读性,特别是当非开发人员需要使用它时。连贯接口移除语法中不需要的繁杂干扰,从而实现更可读的代码。对于声明性结构,您可以通过较小的努力更清晰地表达观念。

在下一期中,我将继续讨论 DSL 方法,将其作为一种在紧急设计中捕获惯用模式的机制。

参考资料

学习

获得产品和技术

  • Antlr:Antlr 是一种强大的语言设计工具,能让您为自定义语言创建新的解析器和词汇分析器。
  • 以最适合您的方式 评估 IBM 产品:下载产品试用版,在线试用产品,在云环境下试用产品, 或者在 SOA Sandbox 中花费几个小时来学习如何高效实现 Service Oriented Architecture。

讨论

  • 加入 My developerWorks 社区。查看开发人员推动的博客、论坛、组和 wikis,并与其他 developerWorks 用户交流。

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology
ArticleID=517050
ArticleTitle=演化架构和紧急设计: 连贯接口
publish-date=09072010