事件驱动的CoffeeScript怎样将你从依赖的地狱中解救出来(点击查看原文)


作者:Trevor Burnham 翻译:Dinsy(weibo@talentm)

Trevor向我们展示了怎样使Node应用像其他实时环境的代码一样简洁地被组织

单元测试是件痛苦的事情

经过充分测试的代码是非常棒的.这是我告诉大家的.又为什么不是呢?这种情况常常并不是那样,我只是不想在我的项目中实践它.部分原因是因为多数功能很难做到单独测试除非你只是在写一个计算器简单应用,你的应用常常包含很多单元块,同时他们又依赖于另一些单元块:比如当它被子弹击中的时候,游戏中的怪兽调用声音和图像组件;当收到回复的时候,你的bug追踪单元唤出email和rss;并且对于每个在典型Node.js应用中的文件,你可以这样使用他们:

db = require 'db'
 logger = require 'logger'
 markup = require 'markup'
 ...

Node的模块性是天使也是魔鬼.像rails这样的框架使使每一块代码都能轻松被其它任何地方复用,Node强制性地让你使你的依赖变得显式.当你只有少数活动件,那真的很棒,但是有数以百计的的时候,那简直是梦魇.并且,当拥有如此所得必须依赖.如何做到分离测试?幸运的是,有种方式简化你的应用架构,而不需要只是简单地使全部组件都成为全局性的:我们并不使单元间通过函数调用来进行通信,相反我们对它们进行事件监听.

事件监听式的传输

Node.js被说成是事件驱动型,因为它是单线程的.相反,每个时间段脚本有完成的任务.Node检查是否有事件进入队列(通过setTimeout或者是readFile之类的函数)然后运行队列中的某个函数.这个过程常常会更高效,而且bug存在的可能性更低,相比其它一些语言通过线程使多个代码段同时运行,node.js中的"事件"一词还有其它一层意思,它来自Node的EventEmitter API(事件监听接口)(这里是完整的文档).一个事件发起器(EventEmitter)可以发起任何类型的事件(通过字符串来识别)之后,所有匹配的监听者(回调函数)会被瞬间触发.(如果你对jQuery或者Backbone.js熟悉的话,将"emit"看作是EventEmitter 是一个类,在javascript中等同于一个捆绑在prototype(原型)上的函数.这意味着你恶意创建EventEmitter 实例:

{EventEmitter} = require 'events'
 emitter = new EventEmitter
 emitter.on 'foo', -> console.log 'bar'
 emitter.emit 'foo' # bar

也许你可以定制自己的EventEmitter子类 :

{EventEmitter} = require 'events'
 class Rooster extends EventEmitter
 constructor: ->
 @on 'wake', -> console.log 'COCKADOODLEDOO!'
(foghorn = new Rooster).emit 'wake' # COCKADOODLEDOO!

或者你可以结合EventEmitter的原型,将他的函数添加已经存在的对象中:

sprinkler = waterSupply: 2
 sprinkler[k] = func for k, func of EventEmitter.prototype
 sprinkler.on 'fire', ->
 if @waterSupply > 0
 @waterSupply--; console.log 'fire extinguished'
 else
 console.log 'good luck...'

 sprinkler.emit 'fire' # fire extinguished
 sprinkler.emit 'fire' # fire extinguished
 sprinkler.emit 'fire' # good luck...

请注意,emit听起来就像是某个对象在自身被emit调用,这个方法是公开的可以从其他任何地方进行访问.关键点在于---一切你可以通过函数调用实现的东西都可以用时间监听代替.(好吧,时间不能返回值,但是这是Javascript;使用回调!)你可以在时间名之后传递参数:

cave = new EventEmitter
 cave.on 'echo', -> console.log x for x in arguments
 cave.emit 'echo', 'Hello?' # Hello?

但是使事件比常规函数更加强大的是:你可以利用栈对同一个事件进行多重监听.监听者在事件到来的时候按照定义的顺序触发:

attached in:
     codeMonkey = new EventEmitter
     codeMonkey.on 'wake', -> console.log 'get up, get coffee'
     codeMonkey.on 'wake', -> console.log 'go to job'
     codeMonkey.on 'wake', -> console.log 'have meeting'
     codeMonkey.emit 'wake' # get up, get coffee; go to job; have meeting

反向依赖

那么事件驱动代码如何将我们从依赖的地狱拯救出来呢?让我们回到文首的怪兽例子. 我们想让他做出一系列的事情,当被击中.下面的代码就是简单的实现:

class Monster
     constructor: -> @hp = 1000
     hit: (damage) ->
     @hp -= damage
     if @hp > 0
     audio.play 'monster_hit'
     video.show 'monster_hit'
     player.score += 5
     else
     audio.play 'monster_die'
     video.show 'monster_die'
     player.score += 100
     world.remove this

但是看看这里我们需要的依赖:声音,视频,播放器,场景...这将导致两个问题:一个从每个类似怪兽的模块获取那些实例的引用,这导致了代码的重复.另一则是,怎样测试怪兽被击中的整个过程.在我们运行测试套件的时候,我们的确不想一系列的噪声出现.通常的实现是依赖注入,通过提供一些途径让怪兽获取声音和视频实例的副本,这些副本拥有同的特性和真实的实例而没有副作用.另一种解决方法是充分利用javascript的动态特性操纵像声音和视屏这些对象的原型,将他们补偿到进测试模式.第三种针对特殊情况,在某些条件下使用,像测试模式中的排除操作.当然,没有一种方案有助于解决明确列出所有依赖代码重复问题.假使,换种思路,我们使用时间去分离和其他相关的内部逻辑代码.

   class Monster extends EventEmitter
     constructor: -> @hp = 1000
     hit: (damage) ->
     @hp -= damage
     if @hp > 0
     @emit 'hit'
     else
     @emit 'die'

现在,我们所需要做的所有事情就是在某处进行监听.可以说场景对象旨在生成大量怪兽,并且当事件来临,新的怪兽自身为参数进行事件注册.接下来在是声音对象中,我们添加:

world.on 'new_monster', (monster) =>
  monster.on 'hit', => @play 'monster_hit'
  monster.on 'die', => @play 'monster_die'

视频对象看起来类似.播放器就像这样:

 world.on 'new_monster', (monster) =>
 monster.on 'hit', => @score += 5
 monster.on 'die', => @score += 100

在众多的方法中,这种组织代码的方式更加符合逻辑.它允许怪兽类保持更加纯净的属性.为了去测试,我们所需做的就是创建不监听任何新怪兽的事件.并且看一下我们的依赖关系图变得多么漂亮.

enter image description here

完全无依赖性的猜想

许多的Node开发者会告诉你将一些东西绑定到global而不是用exports,但那是不推荐的.将自己的代码打包成库,或者创建自己的工具集,这才是对的方式.但是如果你开发一个相对独立的应用,按你的方式去做就好了,定义一些全局变量.尤其是你正在使用当前区块内的场景,试着将这些加到你的主程序文件:

 global.world = world

这一行不仅仅意味着千百行的 world = require 'world',而且他使得场景依赖温和而轻松地被替换.在应用测试的头部,你可以加上这些代码:

 global.world = {
 eventListeners: {}
 emitCounts: {}

 on: (event, func) -> (@eventListeners[event] ?= []).push func
 emit: (event) -> @emitCounts[event] ?= 0; @emitCounts[event]++

现在所有与场景相关的的监听都不会被触发,你可以轻松地在对的事件监听者数量和触发次数进行断言测试.同时,你可以制定更轻的策略,简单地增加额外的监听者并且选择性地使用EventEmitter::removeListener去停止那些持续的怪物尖叫.在单元测试的过程中.你可以充分利用once方法进行断言,once方法就像是on的一次性模式:

 nasa = new EventEmitter
 nasa.once 'land_on_moon', -> console.log 'Wow!'
 nasa.emit 'land_on_moon' # Wow!
 nasa.emit 'land_on_moon' # (silence)

结论:尽可能地使用模块

尽管node在构建复杂对象方面有良好的口碑,我更愿意说是,想实时环境语言一样清晰地组织代码是可能的.事件范式提供一种优雅的连接对象的途径,提供了最大灵活性和最小的引用.它测试友好,并且鼓励应用代码更多地根据功能块而不是重要性顺序进行代码划分.它是独一无二的.Javascript已经成为了世界上最重要的语言,我作为其中一员,欢迎我们事件驱动的领主.

本文参加 Translate Geeks to Chinese 翻译活动