流畅接口

2005年12月20日

几个月前,我参加了与 Eric Evans 的一个研讨会,他谈到了我们决定命名为流畅接口的一种特定接口风格。这并不是一种常见的风格,但我们认为它应该更广为人知。也许最好的描述方式是举个例子。

最简单的例子可能来自 Eric 的 timeAndMoney 库。以通常的方式创建时间间隔,我们可能会看到类似这样的代码

TimePoint fiveOClock, sixOClock;
...
TimeInterval meetingTime = new TimeInterval(fiveOClock, sixOClock);

timeAndMoney 库用户会以这种方式使用它

   TimeInterval meetingTime = fiveOClock.until(sixOClock);

我将继续使用常见的例子,即为客户开具订单。订单包含行项目,其中包含数量和产品。行项目可以是可跳过的,这意味着我宁愿在不包含此行项目的情况下进行交付,也不愿延迟整个订单。我可以为整个订单设置紧急状态。

我看到这种构建方式最常见的方式是这样的

    private void makeNormal(Customer customer) {
        Order o1 = new Order();
        customer.addOrder(o1);
        OrderLine line1 = new OrderLine(6, Product.find("TAL"));
        o1.addLine(line1);
        OrderLine line2 = new OrderLine(5, Product.find("HPK"));
        o1.addLine(line2);
        OrderLine line3 = new OrderLine(3, Product.find("LGV"));
        o1.addLine(line3);
        line2.setSkippable(true);
        o1.setRush(true);
    }

本质上,我们创建了各种对象并将它们连接在一起。如果我们无法在构造函数中设置所有内容,那么我们需要创建临时变量来帮助我们完成连接 - 这在将项目添加到集合中时尤其如此。

以下是使用流畅风格完成的相同组装

   private void makeFluent(Customer customer) {
        customer.newOrder()
                .with(6, "TAL")
                .with(5, "HPK").skippable()
                .with(3, "LGV")
                .priorityRush();
    }

可能关于这种风格最重要的是,其意图是沿着内部 领域特定语言 的路线做一些事情。事实上,这就是我们选择用“流畅”来描述它的原因,在很多方面,这两个术语是同义词。API 主要设计为可读且流畅。这种流畅性的代价是更多的努力,无论是在思考方面还是在 API 本身构建方面。构造函数、setter 和添加方法的简单 API 编写起来容易得多。想出一个好的流畅 API 需要认真思考。

事实上,这个小例子中的一个问题是我只是在卡尔加里的一家咖啡馆里边吃早餐边把它敲出来的。好的流畅 API 需要一段时间才能构建。如果你想看一个更经过深思熟虑的流畅 API 的例子,可以看看 JMock。JMock,就像任何模拟库一样,都需要创建复杂的行为规范。在过去几年中,已经构建了许多模拟库,JMock 包含一个非常好的流畅 API,它非常流畅。以下是一个示例期望

mock.expects(once()).method("m").with( or(stringContains("hello"),
                                          stringContains("howdy")) );

我在 Steve FreemanNat PriceJAOO2005 上发表了一个关于 JMock API 演变的精彩演讲,他们后来在 OOPSLA 论文 中写了它。

到目前为止,我们主要看到了用于创建对象配置的流畅 API,通常涉及值对象。我不确定这是否是决定性特征,尽管我怀疑它们在声明性上下文中出现时存在一些特殊之处。对我们来说,流畅性的关键测试是领域特定语言的质量。API 的使用越具有语言般的流畅性,它就越流畅。

构建这样的流畅 API 会导致一些不寻常的 API 习惯。最明显的一个是返回值的 setter。(在订单示例中,with 将订单行添加到订单中并返回订单。)在花括号世界中,常见的约定是修改方法是 void,我喜欢这一点,因为它遵循了 命令查询分离 的原则。这种约定确实妨碍了流畅接口,因此我倾向于在这种情况下暂停这种约定。

你应该根据需要继续流畅操作来选择返回类型。JMock 非常重视根据接下来可能需要的内容来移动其类型。这种风格的一个好处是,方法完成(智能感知)有助于告诉你接下来要输入什么 - 就像 IDE 中的向导一样。总的来说,我发现动态语言更适合 DSL,因为它们往往具有更简洁的语法。但是,使用方法完成对于静态语言来说是一个优势。

流畅接口中方法的一个问题是,它们本身没有多大意义。查看方法浏览器或逐个方法的文档,对 with 并没有什么意义。事实上,我认为它本身就是一个命名不当的方法,根本无法很好地传达其意图。只有在流畅操作的上下文中,它才会显示出它的优势。解决这个问题的一种方法可能是使用仅在此上下文中使用的构建器对象。

Eric 提到的一个问题是,到目前为止,他使用过,也看到过,流畅接口主要用于值对象的配置。值对象没有领域意义上的标识,因此你可以轻松地创建和丢弃它们。因此,流畅性依赖于从旧值创建新值。从这个意义上说,订单示例并不典型,因为它是在 Evans 分类 中的实体。

我还没有看到很多流畅接口,所以我得出结论,我们对它们的优缺点知之甚少。因此,任何使用它们的劝告都只能是初步的 - 然而,我认为它们非常适合进行更多实验。

来自 Piers Cawley 的文章对本文进行了很好的后续。

更新(2008年6月23日)。自从我写这篇文章以来,这个术语被广泛使用,这让我感到非常欣慰。我在 我一直在编写的书 中完善了我对流畅接口和内部 DSL 的想法。我还注意到一个常见的误解 - 许多人似乎将流畅接口等同于方法链。当然,链式调用是流畅接口中常用的技术,但真正的流畅性远不止于此。

我在上面展示的 JMock 示例使用了方法链,但也使用了嵌套函数和对象作用域