引言
Wind.js 是很有特点的一个 JavaScript 异步编程类库(其前身为 Jscex),最近作者不但发布了其眼中的里程碑版(v0.6.5),还在“我们的开源项目”系列活动和“阿里技术嘉年华”上连续露脸,获得广泛关注。Wind.js 的作者赵劼就在本站担任编辑,InfoQ 自然抓住机会对老赵做了正式的书面采访。采访篇幅较长,将分上下集播出。老赵将在上篇中借机倾述他对于库设计的思考和心得。
Q:能否先介绍一下你自己?
大家好,我是赵劼,在网上一般叫做老赵或者赵姐夫,经常沉迷于代码世界里,关注技术发展或是程序员的成长等话题,而不太愿意接触如今比较“流行”的产品设计,项目管理,产业分析等等,连所谓的“大规模应用架构设计”都不太关心——甚至还略有抵触,所以我可以说是个纯码农。之前呆过外企,呆过民企,创过业,也呆过国内的互联网企业,现在则是在做外资银行的内部系统,可以说是一块没有接触过的领域,痛并快乐着。
在工作之余,我也会十分热衷于在业余时间编写自己的开源? 作品,其中 Wind.js 便是迄今为止我最为投入(没有之一)的项目。当然技术之外,我也喜欢烧烧菜,弹弹琴——别看我一副十足的码农范,但我也是从小学琴,标准琴童出身呢。
Q:是什么促使你开发 Wind.js 这个异步编程类库的呢?
应该说是需求与兴趣的共同驱使吧。
首先,随着 Web 平台地位的提升,霸占着浏览器的 JavaScript 语言也成为了世界上最流行的语言之一,甚至通过 Node.js 进入了服务器编程领域。JavaScript 的一个重要特性便是“不能阻塞”,这里的“不能”是指“不应该”而不是“无法”的意思(只要提供阻塞的 API)。JavaScript 是一门单线程语言,因此一旦有某个 API 阻塞了当前线程,就相当于阻塞了整个程序,所以“异步”在 JavaScript 编程中占有很重要的地位。异步编程对程序执行效果的好处这里就不多谈了,但是异步编程对于开发者来说十分麻烦,它会将程序逻辑拆分地支离破碎,语义完全丢失。因此,许多程序员都在打造一些异步编程模型已经相关的 API 来简化异步编程工作,例如 Promise 模型。而我开发 Wind.js,其实也是出于这个目的,只不过使用的方式有些特别,效果更好。
其次,我本身一直是个编程语言爱好者,对这方面的关注经常不被人理解,被鄙视也不是一次两次了。Wind.js 主要受到了 F#的启发,F#是微软为.NET 平台创建的一门函数式编程语言,以 OCaml 为基础,并加入了一些前沿的编程语言研究成果。其中很重要的一个特性便是“计算表达式”,它对于异步编程的支持令人眼前一亮。Wind.js 的前身是 Jscex,即 JavaScript Computation EXpressions 的缩写,它是 F#中“计算表达式”特性在JavaScript 编程语言上的体现,因此理论上说它不仅仅只有“异步编程”,但简化异步编程的确是它最重要的一部分功能,没有之一。
这么看来,我为JavaScript 本身引入其他语言的异步编程特性也是顺理成章的事情吧。
Q:正如你所说的那样,在简化 JavaScript 异步编程这个事情上其实已经有很多不同的方案(例如 Promise 等异步模型),那么你觉得这些现成的方案都有哪些不足之处,Wind.js 是否有“重新发明轮子”的意味在里面呢?
作为一个异步流程控制类库,Wind.js 的唯一目的便是“改善编程体验”,这跟其他大大小小异步流程控制方案有着相同的目标——但是,改善的“程度”以及改善的“方式”便是 Wind.js 与它们最大的区别。
在传统的异步流程控制类库中,人们普遍使用构建各类 API 的方式来辅助异步逻辑编写。例如著名的 Step 类库为了解决异步操作顺序执行的问题,便引入了一个Step
方法:
Step( function readFile() { fs.readFile(filename, this); }, function capitalize(err, text) { if (err) throw err; return text.toUpperCase(); }, function writeBack(err, newText) { if (err) throw err; fs.writeFile(filename, newText, this); }, function complete(err) { if (err) throw err; console.log("complete"); } );
在 Step 类库里,假如要将几个系统串联,则可以依次将多个函数顺序提供给Step
方法,每个函数内部使用自身的this
作为回调,或直接返回一个结果。那么这里就有一个问题,例如在一个面向对象开发的系统中,它们对this
有着严格的依赖关系,而 Step 则强制使用this
保存回调函数,那又该如何协调两者之间的矛盾?当然,对于一个有经验的 JavaScript 开发人员来说,解决这点自然不是问题,但始终需要“绕”,即便不影响功能,也会影响编程风格(模式)——由于类库的需要而不得不引入的编程风格(模式)。
串行如此,那么并行呢?Step 又引入了parallel
函数来实现这方面需求,换句话说,每增加一类需求,我们则往往需要增加一个甚至一套 API。Step 提供了“串行”、“并行”、以及“分组”三类执行方式,而功能更为丰富的 Async 类库则提供了十几种异步流程控制或是数据处理的辅助方法。
这就产生了矛盾:更强大的类库,则往往意味着更多的 API,以及更多的学习时间,这样开发者才能根据自身需求选择最合适的方法。那么,多少 API 才能够满足广大人民群众的需求呢?这其实很难界定,根据 80/20 法则,80% 的用户只需要 20% 的功能,但其实不同的用户群体需要的那 20% 可能各不相同,这几乎意味着一个类库为了满足各种多变的需求,则需要提供 100% 的功能,让不同的用户各取自身所需的 20% 功能。而事实上,由于人们总是能够不断地挖掘出新的需求,因此我们时不时需要设计并实现新的 API。同时,为了保证现有代码的兼容性,旧的 API 也必须保留下来,于是类库的规模往往会不断扩大。
此外,API 的粒度也是一个课题。粒度越大的 API 往往功能越强,可以通过少量的调用完成大量工作,但粒度大往往意味着难以复用。越细粒度的 API 灵活度往往越高,可以通过有限的 API 组合出足够的灵活性,但组合是需要付出“表现力”作为成本的。JavaScript 在表现力方面有一些硬伤,即所谓“语法噪音”,例如定义函数所用的 function
关键字和大括号,函数返回值必须使用的return
关键字等等,其实都限制 JavaScript 在某些场景下的表达能力,例如在 CoffeeScript 中的匿名函数(x) -> x + 1
,使用 JavaScript 就必须写为function (x) { return x + 1; }
。我们的确可以在 JavaScript 中设计出一套足够灵活,可以组合出各种流程的异步 API,我也做过这方面的尝试,但最终会发现,这样设计出来的 API 始终无法清晰地表达出流程的真正意图。
总之,对于传统类库来说,在如何界定类库的“边界”,如何确定 API 的粒度等方面,都需要做出大量的权衡。所谓“权衡”便是通过牺牲一部分的利益来换取更大的收获,但可能在满足 80% 的需求的时候,剩余 20% 的用户只能无奈地离开了。
Wind.js 的确是个“轮子”,但绝对不是“重新发明”的轮子。我不会掩饰对 Wind.js 的“自夸”:Wind.js 在 JavaScript 异步编程领域绝对是一个创新,可谓前无来者。有朋友就评价说“在看到 Wind.js 之前,真以为这是不可能实现的”,因为 Wind.js 事实上是用类库的形式“修补”了 JavaScript 语言,也正是这个原因,才能让 JavaScript 异步编程体验获得质的飞跃。
Q:你一直强调 Wind.js 完全就是 JavaScript 的编程,包括“编程体验”,为什么特别纠结这一点?你为 Wind.js 选择了比较特别的实现方式,也跟这个思路有关吗?这种实现方式的优势在哪里?
我们现在想要解决的是“流程控制”问题,那么说起在 JavaScript 中进行流程控制,有什么能比 JavaScript 语言本身更为现成的解决方案呢?在我看来,流程控制并非是一个理应使用类库来解决的问题,它应该是语言的职责。只不过在某些时候,我们无法使用语言来表达逻辑或是控制流程,于是退而求其次地使用“类库”来解决这方面的问题。
这方面我可以举一个例子,且看这段简单的代码:
for (var i = -5; i < 5; i++) { if (i < 0) { console.log(i + " 是负数 "); } else { console.log(i + " 是正整数 "); } }
但其实,只要我们提供了合适的_for
及_if
函数(附 1),我们完全可以不用for
及if
关键字来实现相同的功能:
_for (-5, 5, function (i) { _if ( // 条件 i < 0, // 满足条件的分支 function () { console.log(i + " 是负数 "); }, // else 分支 function () { console.log(i + " 是正整数 "); }); });
其实只要是有一定函数式编程能力(主要是匿名函数)的语言,都可以在一定程度上通过这种形式的类库实现流程控制,只不过因为语言特性不同,各有美丑而已。不得不说,JavaScript 虽然有着还算不错的函数式编程特性,但由于之前提到的硬伤,几乎无法优雅地完成这项任务。
事实上,上面演示的_for
方法的灵活程度与for
语句还有很大差距,例如,您不妨尝试下如何使用类库实现如下的for
循环:
var iter = getIterator(); for (var i = 0; iter.current < i++; iter.moveNext()) { ... }
这会直接导致我们的_for
函数必须接受更多的匿名函数,“享受”更多 JavaScript 语言的硬伤。
我想说的是,即便我们“有能力”用类库实现类似的效果,但真会有人去这么做吗?使用 JavaScript 语言本身提供的特性进行流程控制,对于 JavaScript 开发人员来说,是一件天经地义,顺理成章的事情。JavaScript 语言已经提供了开发人员控制流程所需的全部关键字,而且开发人员已经无数次证明了这些关键字是多么的灵活与优雅。假如,我们这里先说“假如”——假如我们可以使用传统的 JavaScript 语言编写异步代码,使用 JavaScript 来控制流程,表达逻辑,那么我们就可以避免学习大量新的 API,避免遭受 JavaScript 中的语法噪音,我们只需编写简单而熟悉的 JavaScript 代码即可。
在软件开发行业有一句很著名的话: Every abstraction is leaky ,换句话说,任何一种抽象都是有缺陷的。事实上,各种异步编程模型都是种抽象,它们是为了实现一些常用的异步编程模式而设计出来的一套有针对性的 API。但是,在实际使用过程中我们可能遇到千变万化的问题,一旦遇到模型没有“正面应对”的场景,或是触及这种模型的限制(如 Step 的this
回调),开发人员往往就只能使用一些相对较为丑陋的方式来“回避问题”。Wind.js 也是一种抽象,因此也不可避免的出现 leaky,但 Wind.js 的设计思路便是将这种抽象构建为 JavaScript 本身。换句话说,Wind.js 的能力在很大程度上是受限于 JavaScript 语言的表达能力,而传统异步编程类库则是受限于自身设计的 API 的表达能力。哪种做法可以应对更大范围的场景,我相信应该已经不言而喻了。
所以 Wind.js 选择的是这么一种方式:开发人员使用最普通不过的 JavaScript 来表达一段逻辑,只不过通过某种方式来指定某一个环节是“异步操作”,代码执行流程会在这里“暂停”,等待该异步操作结束,然后再继续执行后续代码。如果这个操作失败,则相当于抛出了一个异常,会被catch
语句捕获。一段类似的代码可以是这样的:
try { var content = $await(read(src, content)); console.log(" 内容读取成功 "); $await(write(target, content)); console.log(" 内容读取成功 "); } catch (ex) { $await(submitError(ex)); console.log(" 错误提交成功 "); }
在这段代码里,我们用$await(<task>)
标记一个异步操作,代码流程会在此处“等待”异步操作完成,在catch
分支中也一样。我想,任何一个普通的 JavaScript 程序员都能顺利理解这段代码的含义。至于性能,自然必须和使用传统回调风格写出来的程序完全一致,这里的“等待”并不是“阻塞”,而会空出执行线程,直至操作完成。而且,假如系统本身没有提供阻塞的 API,我们甚至没有“阻塞”代码的方法(当然,本就不该阻塞)。
当然,单纯依靠 JavaScript 语言本身显然无法获得这样的能力,JavaScript 的执行流程不会随便地暂停,因此我们需要对代码进行自动改写。这便是 Wind.js 的工作原理,值得注意的是“自动”二字,换句话说开发者编写代码的时候完全不需要额外的“改写”步骤,这个功能在 Wind.js 中叫做“ JIT(Just in Time)编译”,也就是在程序的执行过程中动态生成并执行新的代码。
Wind.js 自动改写的最小单位是“函数”,传统 JavaScript 函数是这样定义的:
var func = function () { // 函数体 };
而 Wind.js 函数是这样定义的:
var windFunc = eval(Wind.compile("async", function () { // Wind.js 函数体 } ));
可见,在 Wind.js 在使用形式上和一个普通类库毫无二致,这便保证了它与 JavaScript 有着相同的编程体验。传统如 CoffeeScript 或是 Dart 等以 JavaScript 作为目标语言的“新语言”,都必须引入一个额外的编译步骤。我反复强调,使用 Wind.js 进行开发与普通的 JavaScript 编程几乎没有任何区别。至于外围的“架子代码”,我们完全可以不关心其特别含义,作为代码片段加入编辑工具,敲一个快捷键便能直接输出。
在 Wind.js 函数中,我们就可以使用唯一的语言扩展:“绑定”操作,这可以简单理解为那个异步的$await
操作。可以看到,这个特殊操作使用了 JavaScript 的“函数调用”语义,同样符合 JavaScript 的编程实践。有些朋友建议把$await
改成关键字形式,而不是函数调用。可惜,这么做首先会被 JavaScript 运行环境拒之门外,导致无法实现 JIT 编译器,其次也有可能会破坏一些相关的事物,例如开发工具里的代码着色,甚至某些高级 IDE 中的语法提示,因为语法提示功能需要先对代码语义进行完整的分析。因此,保证 Wind.js 与 JavaScript 绝对一致,其实也是在保证 Wind.js 能够完美融入整个 JavaScript 生态环境。
不仅是语法设计上如此,Wind.js 自带的类库设计也充分借助了 JavaScript 的语义实现。例如 Wind.js 的异步任务模型支持取消操作,要将一个任务置为“取消”状态,只需抛出一个未经捕获的CanceledError
错误即可,例如:
var t1 = eval(Wind.compile("async", function () { $await(t2()); })); var t2 = eval(Wind.compile("async", function () { $await(t3()); })); var t3 = eval(Wind.compile("async", function () { throw new CanceledError(); }));
根据 JavaScript 的语义,一个未捕获的异常会顺着调用链不断抛出,导致一系列的异步任务都会进入“取消”状态。这是一个统一而简单的取消模型。
Q:Wind.js 原来的版本 API 极其简单,可以说只有一个 $await。发展到现在,Wind.js 增加了一些扩展,比如针对 Node.js、WinRT 的扩展,还有编程模型方面的扩展,像 seq、agent 这些。这些扩展是否说明 Wind.js 的应用场景超出你原来的预想?Wind.js 的 surface API 和语法有没有因为这些扩展而变得复杂呢?
说实话,Wind.js 目前还处于“叫好不叫座”的推广期,并没有收到太多额外的用户需求,所有的组件与都是由我设计并实现的,所以当然都在我预想之内啦!
其实你说的这些概念都不是同一个层面上的东西,它们可以分两大类,一是用于不同目的的“构造器”:
- 异步:即
async
构造器,用于简化异步开发,基于 Wind.js 自带的异步任务模型。 - 迭代:即
seq
构造器,用于生成一个延迟计算的迭代器。 - Promise :即
promise
构造器,用于支持基于 Promise/A 模型的异步开发。
“构造器”和“编译器”是 Wind.js 的两大支柱。Wind.js 编译器在转化一个普通的 JavaScript 函数的时候永远使用相同的模式,但配合不同的“构造器”便能得到不同的效果。例如seq
构造器可以用来创建一个无限长的迭代器:
var infinite = eval(Wind.compile("seq", function (start) { while (true) { $yield(start++); } })); var fib = eval(Wind.compile("seq", function () { $yield(0); $yield(1); var a = 0, current = 1; while (true) { var b = a; a = current; current = a + b; $yield(current); } }));
不同的构造器中的“绑定”操作不同,其含义也不一样。例如在seq
构造器中的绑定操作为$yield
,表示输出迭代器里的一个对象,并将控制权交由外部,直到下次调用moveNext
方法时才继续执行。因此,虽然上述两个方法都是会输出“无限长”的序列,但什么时候取元素,取多少元素完全由使用者控制,即所谓“延迟计算”,“按需计算”。
两个迭代器还能够组合使用,例如:
fib() // 无限长的菲波纳契数列 .filter(function (n) { return n % 2 == 0; }) // 取其中所有的偶数 .skip(2) // 跳开前 2 个数 .take(5) // 取 5 个数 .zip(infinite(1)) // 和从 1 开始的无限长序列组合 .map(function (a) { return a[1] + ": " + a[0]; }) // 将每个组合拼接成字符串 .each(print); // 并打印出来
即便是无穷序列,但由于我们使用了take(5)
操作,最终也只会打印出五项,而不会成为死循环。这类“组合”操作便是“增强类库”的功劳。面向异步编程的async
构造器也有相关的增强类库,它扩展了async
构造器所使用的异步任务模型,例如并发相关的辅助函数。此外还有一些常用的异步操作(如封装了setTimeout
的sleep
方法),并简化了与其他一些常见异步编程模型的协作。你所提到的 Node.js 以及 agent 扩展都是类似的“类库”,前者自不必说,后者则是一个 Erlang 中 Actor 模型的尝试实现,基于 Wind.js 的异步编程支持。
不过你提到的 WinRT 扩展就不同了,它其实便是promise
构造器,是为了直接支持使用 Promise/A 异步模型的开发环境。Promise/A 现在是 CommonJS 的草案之一,提出了一种 Promise 模型的设计及 API 表现。虽说它离“标准”还有很长一段距离,但其实很多类库都已经实现了这个规范了,例如著名的 jQuery , node-promise ,还有用来编写 Win8 中 Metro 应用(即 WinRT)的 HTML5 开发平台。当然严格来说,它们都是基于 Promise/A 规范的一套“扩展实现”,但既然有了共有的子集,那事情就已经好办多了。
之前在一个 QQ 群上某同学建议我提供一个类似于fromStandard
(用于将标准的回调异步方法适配为 Wind.js 异步模型)一样的fromPromise
辅助方法。这当然没问题,其实很简单,接下来也会做,但 Wind.js 考虑得更多,例如:
- 为什么需要
fromPromise
辅助方法?因为用户使用了 Promise 异步模型,而希望 Wind.js 提供更好的辅助环境。 - 为什么对方不使用 Wind.js 自带的异步任务模型?因为用户可能已经有部分代码采用了 Promise 模型。
- 为什么它要使用 Promise 这种已经较为成熟且复杂的异步模型?因为用户可能已经有了一个围绕着 Promise 模型开发的应用程序,甚至是一个已经拥有大量辅助方法支持的应用开发框架(例如 WinRT)。
因此,这种情况下如何还需要再结合 Wind.js 的异步任务模型,则需要来回转换,显得略为冗余——不如就让 Wind.js 对 Promise/A 异步模型直接提供支持吧!换句话说,要让$await
操作接受一个 Promise 对象而不是一个 Wind.js 异步任务对象,同时一个使用promise
构造器生成的 Wind.js 异步方法也会直接返回一个 Promise/A 对象。于是我们在 WinRT 中便可以编写这样的代码:
WinJS.Namespace.define("MyApp", { showPhoto: eval(Wind.compile("promise", function () { var dlg = new MessageDialog("Do you want to open a file?"); dlg.commands.push(new UICommand("Yes", null, "Yes")); dlg.commands.push(new UICommand("No", null, "No")); // 显示对话框,并等待用户选择 Yes 或 No var result = $await(dlg.showAsync()); if (result.id == "Yes") { var picker = new FileOpenPicker(); picker.viewMode = PickerViewMode.thumbnail; picker.suggestedStartLocation = PickerLocationId.picturesLibrary; picker.fileTypeFilter.push(".jpg"); // 显示文件选择器,并等待用户选择文件 var file = $await(picker.pickSingleFileAsync()); if (file != null) { $("#myImg").src = URL.createObjectURL(file); } } })) });
此时我们可以一视同仁地对待 showPhoto 方法以及 WinRT 中标准的异步操作,甚至可以把它传入 WinRT 的系统方法配合使用,因为它完全符合 WinRT 标准。值得一提地是,创建promise
构造器只需花费 30 余行代码(包括空行及函数定义)。从这个例子中可以看出,在需要的情况下,我们完全可以轻松地让 Wind.js 融入任何异步模型。
一开始我就提到,Wind.js 其实是受到 F#计算表达式的启发而诞生的 JavaScript 类库,这些所谓的“变化”和“扩展”实际上都没有逃离计算表达式及其基础理论 Monad 的范畴。Wind.js 从诞生开始便一直差不多是现在这样的使用方式,只是其内部实现在不断完善而已。不过我目前的精力完全放在了异步编程上,因此如seq
构造器或是 agent 扩展都还处于孵化阶段,虽然它们几乎是我还在验证 Wind.js 可行性的阶段时开发的。
附 1
我们可以这样实现_for
及_if
函数:
var _for = function (begin, end, body) { for (var i = begin; i < end; i++) { body(i); } }; var _if = function (condition, bodyPart, elsePart) { if (condition) { bodyPart(); } else { elsePart(); } };
参考
Wind.js 相关:
项目:
Promise 模型:
其他:
评论