API设计:用流畅接口构造内部DSL
感谢@weidagang (Todd)向酷壳投递本文。
程序设计语言的抽象机制包含了两个最基本的方面:一是语言关注的基本元素/语义;另一个是从基本元素/语义到复合元素/语义的构造规则。在C、C++、Java、C#、Python等通用语言中,语言的基本元素/语义往往离问题域较远,通过API库的形式进行层层抽象是降低问题难度最常用的方法。比如,在C语言中最常见的方式是提供函数库来封装复杂逻辑,方便外部调用。
不过普通的API设计方法存在一种天然的陷阱,那就是不管怎样封装,大过程虽然比小过程抽象层次更高,但本质上还是过程,受到过程语义的制约。也就是说,通过基本元素/语义构造更高级抽象元素/语义的时候,语言的构造规则很大程度上限制了抽象的维度,我们很难跳出这个维度去,甚至可能根本意识不到这个限制。而SQL、HTML、CSS、make等DSL(领域特定语言)的抽象维度是为特定领域量身定做的,从这些抽象角度看问题往往最为简单,所以DSL在解决其特定领域的问题时比通用程序设计语言更加方便。通常,SQL等非通用语言被称为外部DSL(External DSL);在通用语言中,我们其实也可以在一定程度上突破语言构造规则的抽象维度限制,定义内部DSL(Internal DSL)。
本文将介绍一种被称为流畅接口(Fluent Interface)的内部DSL设计方法。Wikipedia上Fluent Interface的定义是:
A fluent interface (as first coined by Eric Evans and Martin Fowler) is an implementation of an object oriented API that aims to provide for more readable code. A fluent interface is normally implemented by using method chaining to relay the instruction context of a subsequent call (but a fluent interface entails more than just method chaining).
下面将分4个部分来逐步说明流畅接口在构造内部DSL中的典型应用。
目录
1. 基本语义抽象
如果要输出0..4这5个数,我们一般会首先想到类似这样的代码:
//Java for (int i = 0; i < 5; ++i) { system.out.println(i); }
而Ruby虽然也支持类似的for循环,但最简单的是下面这样的实现:
//Ruby 5.times {|i| puts i}
Ruby中一切皆对象,5是Fixnum类的实例,times是Fixnum的一个方法,它接受一个block参数。相比for循环实现,Ruby的times方式更简洁,可读性更强,但熟悉OOP的朋友可能会有疑问,times是否应该作为整型类的方法呢?在OOP中,方法调用通常代表了向对象发送消息,改变或查询对象的状态,times方法显然不是对整型对象状态的查询和修改。如果你是Ruby的设计者,你会把times方法放入Fixnum类吗?如果答案是否定的,那么Ruby的这种设计本质上代表了什么呢?实际上,这里的times虽然只是一个普通的类方法,但它的目的却与普通意义上的类方法不同,它的语义实际上类似于for循环这样的语言基本语义,可以被视为一种自定义的基本语义。times的语义从一定程度上跳出了类方法的框框,向问题域迈进了一步!
另一个例子来自Eric Evans的“用两个时间点构造一个时间段对象”,普通设计:
//Java TimePoint fiveOClock, sixOClock; TimeInterval meetingTime = new TimeInterval(fiveOClock, sixOClock);
另一种Evans的设计是这样:
//Java TimeInterval meetingTime = fiveOClock.until(sixOClock);
按传统OO设计,until方法本不应出现在TimePoint类中,这里TimePoint类的until方法同样代表了一种自定义的基本语义,使得表达时间域的问题更加自然。
虽然上面的两个简单例子和普通设计相比看不出太大的优势,但它却为我们理解流畅接口打下了基础。重要的是应该体会到它们从一定程度上跳出了语言基本抽象机制的束缚,我们不应该再用类职责划分、迪米特法则(Law of Demeter)等OO设计原则来看待它们。
2. 管道抽象
在Shell中,我们可以通过管道将一系列的小命令组合在一起实现复杂的功能。管道中流动的是单一类型的文本流,计算过程就是从输入流到输出流的变换过程,每个命令是对文本流的一次变换作用,通过管道将作用叠加起来。在Shell中,很多时候我们只需要一句话就能完成log统计这样的中小规模问题。和其他抽象机制相比,管道的优美在于无嵌套。比如下面这段C程序,由于嵌套层次较深,不容易一下子理解清楚:
//C min(max(min(max(a,b),c),d),e)
而用管道来表达同样的功能则清晰得多:
#!/bin/bash max a b | min c | max d | min e
我们很容易理解这段程序表达的意思是:先求a, b的最大值;再把结果和c取最小值;再把结果和d求最大值;再把结果和e求最小值。
jQuery的链式调用设计也具有管道的风格,方法链上流动的是同一类型的jQuery对象,每一步方法调用是对对象的一次作用,整个方法链将各个方法的作用叠加起来。
//Javascript $('li').filter(':event').css('background-color', 'red');
3. 层次结构抽象
除了管道这种“线性”结构外,流畅接口还可用于构造层次结构抽象。比如,用Javascript动态创建创建下面的HTML片段:
<div id="’product_123’" class="’product’"> <img src="’preview_123.jpg’" alt="" /> <ul> <li>Name: iPad2 32G</li> <li>Price: 3600</li> </ul> </div>
若采用Javascript的DOM API:
//Javascript var div = document.createElement('div'); div.setAttribute(‘id’, ‘product_123’); div.setAttribute(‘class’, ‘product’); var img = document.createElement('img'); img.setAttribute(‘src’, ‘preview_123.jpg’); div.appendChild(img); var ul = document.createElement('ul'); var li1 = document.createElement('li'); var txt1 = document.createTextNode("Name: iPad2 32G"); li1.appendChild(txt1); … div.appendChild(ul);
而下面流畅接口API则要有表现力得多:
//Javascript var obj = $.div({id:’product_123’, class:’product’}) .img({src:’preview_123.jpg’}) .ul() .li().text(‘Name: iPad2 32G’)._li() .li().text(‘Price: 3600’)._li() ._ul() ._div();
4. 异步抽象
//Javascript
$.begin()
.async(newTask(‘task1’), ‘task1’)
.async(newTask(‘task2’), ‘task2’)
.async(newTask(‘task3’), ‘task3’)
.when()
.each_done(function(name, result) {
console.log(name + ‘: ‘ + result);})
.all_done(function(){ console.log(‘good, all completed’); })
.timeout(function(){
console.log(‘timeout!!’);
$.begin()
.async(newTask(‘task4’), ‘task4’)
.when()
.each_done(function(name, result) {
console.log(name + ‘: ‘ + result); })
.end();}
, 3000)
.end();[/javascript]
上面介绍了用流畅接口构造的4种典型抽象,出此之外还有很多其他的抽象和应用场合,比如:不少单元测试框架就通过流畅接口定义了单元测试的DSL。虽然上面的例子以Javascript等动态语言居多,但其实流畅接口所依赖的语法基础并不苛刻,即使在Java这样的静态语言中,同样可以轻松地使用。流畅接口不同于传统的API设计,理解和使用流畅接口关键是要突破语言抽象机制带来的定势思维,根据问题域选取适当的抽象维度,利用语言的基本语法构造领域特定的语义和语法。
参考
- Wikipedia: Fluent Interface
- Martin Fowler: Fluent Interface
- jQuery is DSL
- An Approach to Internal Domain-Specific Languages in Java
(转载本站文章请注明作者和出处 酷 壳 – CoolShell ,请勿用于任何商业用途)
《API设计:用流畅接口构造内部DSL》的相关评论
嗯,的確,基本上,簡潔、優雅、彈性才是不變的設計原則。有經驗的程式設計師不會侷限在特定的方法裡,有時他們會”感覺”到雜亂與不適,那就是該採用不同作法的時候了。這或許也是程式設計比較接近工藝的一面。 :)
在OOP中,方法调用通常代表了向对象发送消息,改变或查询对象的状态,times方法显然不是对整型对象状态的查询和修改。
我认为这一点不尽然, 比如工厂模式中, 工厂对象的一些成员方法是用来产生对象而非访问工厂自身的状态, 对这里 times 方法我的理解类似, 它产生了一个抽象的迭代过程.
当然死板地用 OO 的方式来理解真是一件无趣的事情.
忍不住水一下,超级喜欢这篇,但是我也只能说大赞!了
比较有创意
哇,原来DSL是这么优美。我想知道文章中提到的jquery的那些div()还有后面的begin,when,end等方法是哪个版本的?还是作者自己用来做为例举而创造出来的?
@gray
自己写的,不是jQuery的。很简单,你也可以写一个。
@tcdona
有道理,不过一般的设计者可能都不会想到把times放到Fixnum类中去。
好文,赞一个。
nice article!
点醒了最近的编码中的疑惑,自己设计的api实在太丑陋了,尤其是涉及到时间、异步问题的时候,简直没法看了。
比如最近遇到这样一个需求,有n个按钮B0、B1。。。Bn,绑定n个事件:T0、T1。。。Tn。在点击按钮Bm后,要延迟一秒再触发事件Tm。如果在这一秒钟之内,也就是Tm触发前,用户又点击了按钮Bk,那么取消Tm,转而准备在一秒钟后启动Tk。实现出来不难,但是实现起来很不自然(我用java)。有兴趣的同学可以研究一下。
很赞,特别是异步抽象那一段,感觉非常好。歪个楼啊 :-) 另外关于Time方法和FixNum类的问题,我觉得是不是说明了OO中的两个方面:方法除了作为对象行为的抽象,是否还作为消息的抽象? 不过后一种抽象有没有可能引发一定的耦合?因为一般来说,对象之间的消息传递往往被用来组织业务逻辑。欢迎讨论!
有意思,尤其是异步抽象那段,让我想起erlang里的actor模式。
我越来越崇拜 todd了
kevin
赞!最后的异步抽象,貌似现在用得比较多的是Deferred模式,jQuery现在也已经实现了这种接口
@luobo25
如果你用Windows API就可以用一个简单的WaitableTimer搞定了,set它,然后不需要了可以cancel然后再set一个,每次set都把一个能代表按钮的id作为参数并加上一个函数指针作为事件,直接解决了您的问题。java我不清楚。
@卡卡西.R
现在object.method的“点”已经不是那个意思了,已经发展成一种目标为“纯粹想把object放在method前面”的解决方法了……
忍不住马上用java实现了异步API:D
C# 的扩展方法就是为Fluent Interface而生的
我觉得这个很火星的呀,C++标准库中的流(iostream)不就是流畅接口么,那玩意儿15年前就有人用了吧
而某些第三方库,例如2001年就有的 Boost Spirit 更是整个库都用的这种 API,比如你可以用如下代码解析一个四则运算,看上去就像 EBNF 一样,而且这样做还几乎没有抽象惩罚:
expression = term >> *(‘+’ >> term | ‘-‘ >> term);
term = factor >> *(‘*’ >> factor | ‘/’ >> factor);
factor = uint_
| ‘(‘ >> expression >> ‘)’
| ‘-‘ >> factor
| ‘+’ >> factor;
好文。看了这篇后恍然大悟。
原来我自己一直在用的单元测试框架Mockito实际上也是定义了一个DSL..
严重同意。使用扩展方法即可保证类设计上OO,又可以按需要提供使用上的“流畅”
在上家公司就是用internal DSL定义了metadata框架,用来定义,设计config文件,然后通过codegen来生成对应的java/xml/xsd文件的。
@sarstime
确实定义configuration的时候更自然,但是也同样提高了复杂度和维护的成本。
DSL感觉就是一种思维方式
比如java里面的groovy
把很多常规的流程化操作 复杂的使用方式逻辑 一些模式用的东西抽象出来 可以使用简洁的方式
设计这类的API,不是Java这类强类型语言的强项.
若用ruby,groovy等估计就好多了,动态语言写代码确实愉快多了.
@Colin Zhang
动态语言的确很方便。
不过,DSL并没有限制静态语言的发挥,像作者参考的这篇文章http://www.infoq.com/articles/internal-dsls-java。还有,Hibernate的 criteria query 也很好地体现了 Fluent Interface。