详解JavaScript中的客户端消息框架设计原理

 哇——是个危险的题目,对吗?我们对于什么是本质的理解当然会随着我们对要解决问题的理解而变化。因此我不会说谎——一年前我所理解的本质很不幸并不完整,因为我确信我将要写的已经快伴随我有6个月之久。所以,这篇文章是我在发现JavaScript中成功的运用客户端消息模式的一些关键要点时的一个掠影。

1.) 理解中介者与观察者的区别 
 大多数人在描述任何事件/消息机制的时候喜欢套用“发布者/订阅者”(pub/sub)——但我认为这个术语不能很好的与抽象建立联系。当然,从根本上说,一些东西订阅了另一些东西发布的事件。但是发布者与订阅者在何等层次上封装在一起有可能使一个好的模式变得暗淡无光。那么,区别在什么地方呢?


观察者

观察者模式包括了被一个或多个观察者所观察的某个对象。典型的,该对象记录下所有观察者的痕迹,通常是用一个list来存储观察者注册的回调方法,这些是观察者为了接收通知而订阅的。 注意: (哦,双关语,我有多爱他们啊)(译者注:Observe 观察、注意)
 

var observer = {
 listen : function() {
  console.log("Yay for more cliché examples...");
 }
};
var elem = document.getElementById("cliche");
elem.addEventListener("click", observer.listen);

一些需要注意的事情是:

  •     我们必须获得对此对象的直接引用
  •     此对象必须保持一些内部的状态,保存观察者的回调痕迹
  •     有时侦听者不会利用由此对象返回的任何参数,理论上来说,有可能有 0-n*个参数 (更多是取决于以后会变得多有趣)

* n事实上不是无限的,但为了讨论的目的,它指我们永远也达不到的极限


中介者

中介者模式在一个对象与一个观察者之间引入了一个“第三方”——有效的将二者解耦而且将他们之间如何通信封装起来。一个中介者的API可能像“发布”、“订阅”、“取消订阅”一样简单,或者某个领域范围内的实现可能被提供用来隐藏这些方法于某些更有意义的语义之中。大多数我用过的服务器端的实现更倾向于领域范围而不是更简单,但是并没有对一个通用的中介者有任何规则限制!并不罕见,有种想法认为一个通用的中介者是一种信息经纪人。无论何种情形,结果都一样——特定对象与观察者之间不再互相直接知晓:
 

// It's fun to be naive!
var mediator = {
 _subs: {},
 // a real subscribe would at least check to make sure the
 // same callback instance wasn't registered 2x.
 // Sheesh, where did they find this guy?!
 subscribe: function(topic, callback) {
  this._subs[topic] = this._subs[topic] || [];
  this._subs[topic].push(callback);
 },
 // lolwut? No ability to pass function context? :-)
 publish : function(topic, data) {
  var subs = this._subs[topic] || [];
  subs.forEach(function(cb) {
   cb(data);
  });
 }
}
var FatherTime = function(med) { this.mediator = med; };
FatherTime.prototype.wakeyWakey = function() {
 this.mediator.publish("alarm.clock", {
  time: "06:00 AM",
  canSnooze: "heck-no-get-up-lazy-bum"
 });
}
var Developer = function(mediator) {
 this.mediator = mediator;
 this.mediator.subscribe("alarm.clock", this.pleaseGodNo);
};
Developer.prototype.pleaseGodNo = function(data) {
 alert("ZOMG, it's " + data.time + ". Please just make it stop.");
}
var fatherTime = new FatherTime(mediator);
var developer = new Developer(mediator);
fatherTime.wakeyWakey();

你可能会想,除了特别纯粹的中介者实现,特定对象不再负有保存订阅者列表的责任,而且“时光老人”(FatherTime)与“开发者”(Developer)实例永远没法真正互相知道。他们只是共享了一个信息——将如我们今后所见,这是一个很重要的合约。 “很好,Jim。这对我而言仍然是发布者/订阅者,那么重点呢?我选择某个方向真的会有区别吗?”哦,继续吧,亲爱的读者们,继续吧。

2.) 了解什么时候使用中介者和观察者

使用本地的观察者和中介者,即写在组件当中的,而中介者看起来又像远程的组件间通信。不管怎样。我对待这种情况的原则虽然是——tl;dr(too long; don't read)(太长,不读了)。但无论如何,反正串联在一起最好。

要我简捷地说真是麻烦,就像把几个月来的细致体验压缩到装不下140个字的沟里。现实中回答这个问题肯定不简洁。所以有一个长版本的解释:

    观察者除了关心数据映射之外还有必要引用别的项目吗?例如Backbone.View视图有各种理由直接引用它的模型。这是非常自然的关系,视图不仅要在模型改变时进行渲染,还需要调用模型的事件处理。如果段首的问题答案是”yes“,那观察者就是有意义的。
    如果观察者和观察对象的关系仅仅是依赖数据,那我愿意使用中介pub/sub方式。两个Backbone.View视图或模型之间的通信,用观察者是合适的。比如控制导航菜单的视图发出的信息,是面包屑(breadcrumb)挂件需要的(响应当前的层级)。挂件不需要引用导航视图,它只需要导航视图提供信息。更关键的,导航视图也许不是唯一的信息来源,别的视图可能也可以提供。此时,中介pub/sub模式是最理想的——而且自身扩展性良好。

看起来这样又好又全面,但是其实还有一个露点:如果我给对象定义一个本地事件,既想要观察者直接调用,又可以被订阅者间接访问到,怎么办?这就是我为什么说要串联在一起:你推送或者桥接本地事件到消息组去吧。需要些更多代码?很有可能——但是总比你把观察对象传递给所有观察者,一直紧耦合下去的情况好。然后,我们可以很好地继续以下两点...


3.) 选择性的“提交”本地事件到总线

最开始我几乎只用观察者模式来在JavaScript中触发事件。这是我们一次又一次遇到的模式,但更流行的客户端辅助库行为方式根本上来说是混合中介者的,给我们提供了就像它们是观察者模式的API。我最初写postal.js的时候,开始走进“为所有事物搭中介”的阶段。在我写的原型与构造函数中,分布各处的发布与订阅的调用并不罕见。当我从这个改变中自然的解耦受益时,非基础的代码开始似乎充满了相关于基础的部分。构造函数到处都要带上一个通道,订阅被当作新实例的一部分被创建,原型方法直接发布一个数值到总线(甚至本地的订阅者都不能直接的而必须监听总线以获得信息)。将这些明显关于总线的东西纳入app的这些部分,开始像是代码的味道。代码的“叙述”似乎总是被打断,如“噢,将这个向所有订阅者发布出去”,“等等!等等!监听这个通道那个事情。好,现在继续吧”。我的测试忽然开始需要依赖总线来做低层次的单元测试。而这感觉有点不对劲。

钟摆摆动的指向了中间,我认识到我应该保持一个“本地API”,并且在需要的时候通过一个中介者为应用扩展其可以触及的数据。 例如,我的backbone视图与模型,仍然用普通的Backbome.Events行为来给本地观察者发送事件(就是说,模型的事件被它相应的视图所观察)。当app的其它部分需要知道模型的变化时,我开始通过这些行将本地事件与总线桥接起来:
 

var SomeModel = Backbone.Model.extend({
 initialize: function() {
  this.on("change:superImportantField", function(model, value) {
   postal.publish({
    channel : "someChannel",
    topic : "omg.super.important.field.changed",
    data : {
    muyImportante: value,
    otherFoo: "otherBar"
    }
   });
  });
 }
});

重要的是要认识到,当有可能透明的推送事件到消息总线时,本地事件和消息必须被认为是分开的合约——至少概念上如此。换句话说,你要能够修改“内部的/本地的”事件而不破坏消息合约。这是要在脑海中记住的重要事实——否则你就是为紧耦合提供了一个新的途径,在一个方法上走反了!

所以理所当然,上述的模型是可以在没有消息总线的情况下被测试。而且如果我移去桥接在本地事件与总线之间的逻辑,我的视图与模型依然工作得毫无不畅。但是,这可是七行的例子(尽管格式化了)。 仅仅桥接四个事件就需要几乎三十行的代码。

噢,你怎样才能二者兼顾呢—— 在适合直接观察者时本地通知,同时使涉及事件可以扩展,以便你的对象不必给所有对象都发送一圈——不需要代码膨胀。通知怎样才能很少的代码又有更多的味道呢?

4.)在你的构架中隐藏样板

这并不是说上面的例子中的代码 —— 将事件接入总线 —— 的语法或概念是错误的(假设你接受本地和远程/桥接事件的概念)。然而,这是一个很好的体现在代码基础之上培养良好习惯的作用的例子。有时我们会听到类似“代码实在太多了”的抱怨(特别是当 LOC 作为代码质量的唯一判定者时)。 当这种情况下,我表示赞同。 它是一个可怕的样板。  下面是我在桥接 Backbone 对象的本地事件到 postal.js 时使用的模式:
 

// the logic to wire up publications and subscriptions
// exists in our custom MsgBackboneView constructor
var SomeView = MsgBackboneView.extend({
 
 className : "i-am-classy",
 
 // bridging local events triggered by this view
 publications: {
 // This is the more common 'shorthand' syntax
 // The key name is the name of the event. The
 // value is "channel topic" in postal. So this
 // means the bridgeTooFar event will get
 // published to postal on the "comm" channel
 // using a topic of "thats.far.enough". By default
 // the 1st argument passed to the event callback
 // will become the message payload.
 bridgeTooFar : "comm thats.far.enough",
 
 // However, the longhand approach works like this:
 // The key is still the event name that will be bridged.
 // The value is an object that provides a channel name,
 // a topic (which can be a string or a function returning
 // a string), and an optional data function that returns
 // the object that should be the message payload.
 bridgeBurned: {
  channel : "comm",
  topic : "match.lit",
  data : function() {
   return { id: this.get("id"), foo: 'bar' };
  }
 },
 
 // This is how we subscribe to the bus and invoke
 // local methods to handle incoming messages
 subscriptions: {
  // The key is the name of the method to invoke.
  // The value is the "channel topic" to subscribe to.
  // So this will subscribe to the "hotChannel" channel
  // with a topic binding of "start.burning.*", and any
  // message arriving gets routed to the "burnItWithFire"
  // method on the view.
  burnItWithFire : "hotChannel start.burning.*"
 },
 
 burnItWithFire: function(data, envelope) {
  // do stuff with message data and/or envelope
 }
 
 // other wire-up, etc.
});


显然你可以用几种不同的方式做这些——选择总线式的框架——这要比样板方式少很多无关内容,而且为Backbone开发人员所熟知。当你同时控制事件发送器和消息总线的实现时,桥接要更容易。这里有个将monologue.js发送器桥接到postal.js的例子: 
 

// using the 'monopost' add-on for monologue/postal:
// assuming we have a worker instance that has monologue
// methods on its prototype chain, etc. The keys are event
// topic bindings to match local events to, and if a match is
// found, it gets published to the channel specified in the
// value (using the same topic value)
worker.goPostal({
 "match.stuff.like.#" : "ThisChannelYo",
 "secret.sauce.*" : "SeeecretChannel",
 "another.*.topic" : "YayMoarChannelsChannel"
});

以不同的方式使用样板是令人愉快的好习惯。现在我可以分别独立的测试我的本地对象,桥接代码,甚至测试二者合一的生产&消费期待的消息过程等等。

同样重要的是要注意到,如果我需要在上述的场景访问普通的postal API,没有什么可以阻止我这么做。没有丢失灵活性这么就等于成功了


5.) 消息是合约——要明智的选择实现方式

有两种将数据传递给订阅者的方法——也许可以给他们贴上更“官方”的标签,我将如此描述他们:

  •     “0-n 参数”
  •     “封套” (或“单对象载荷“)

看看这些例子:
 

// 0-n args
this.trigger("someGuyBlogged", "Jim", "Cowart", "JavaScript");
// envelope style
this.emit("someGuyBlogged", {
 firstName: "Jim",
 lastName: "Cowart",
 category: "JavaScript"
});
/*
 In an emitter like monologue.js, the emit call above
 would actually publish an envelope that looked similar
 to this:
 {
  topic: "someGuyBlogged",
  timeStamp: "2013-02-05T04:54:59.209Z",
  data : {
   firstName: "Jim",
   lastName: "Cowart",
   category: "JavaScript"
  }
 }
*/

经过一段时间,我发现封套方式比0-n参数方式要少很多很多麻烦(与代码)。"0-n参数"途径的挑战主要在于两个原因(就我的经验而言):第一,很典型的是“当事件触发时,你还记得要传递哪一个参数吗?不记得?好,我想我会看看触发的源头”。不是一个真正意义上的好方法,对吗?但它可以打断代码的正常流程。你可以用一个调试工具,检测执行条件下的参数值并由此推断基于这些数值的”标签“,但哪个更简单呢——看到一个”1.21“的参数值,困惑于它的意义,或者检测一个对象并发现{千兆瓦:1.21}。第二个原因是由于伴随事件传送可选的数据,以及当方法签名变得更长带来的痛苦。


"说实话,Jim,你这是在搭车棚。"或许是的,但是一段时间以来我一直看到代码的基础在扩充与变形,简单的包含一两个参数的原始事件,在其间包含了可选的参数以后开始变得畸形:
 

// 最开始是这样的
this.trigger("someEvent", "a string!", 99);
// 有一天, 它变得包含了一切
this.trigger("someEvent", "string", 99, { sky: "blue" }, [1,2,3,4], true, 0);
// 可是等等——第4和第5个参数是可选的,因此也可能传的是:
this.trigger("someEvent", "string", 99, [1,2,3,4], true, 0);
// 噢,你还检查第5个参数的真/假吗?
// 哎呦!现在是早先的参数了……
this.trigger("someEvent", "string", 99, true, 0);

如果有任何数据是可选的,将没有围绕它的测试。但需要更少的代码,需要能更具扩展性,特别典型的是能自解释(感谢这些成员名字)以便能在逐一传送给订阅者回调方法时,对一个对象进行那种测试。我仍然在不得不用"0-n参数"的地方用它,但如果由我决定,将是一直用封套的方法——我的事件发送者和消息总线都是这样。(说明我存在偏见,monologue与postal共享同一个封套的数据结构,去掉了monologue不用的通道)

因此——得承认用来给订阅者传输数据的结构是”合约“的一个部分。在封套方式这个方向,你可以用额外的元数据描述事件(不需要增加额外的参数)——这保持了方法签名(这就是合约的一个部分)对每个事件和订阅者一致。你也能很容易的为一个信息结构编制版本(或在必要的时候增加其他封套层级的信息)。如果你沿着这个方向做的话,请确保用的是一致的封套结构。


6.) 消息”拓扑“比你想的还重要

这里没有银弹。但是你要对如何命名主题与通道,以及如何设计消息载荷的结构深思熟虑。我倾向于用两种方法之一映射我的模型:用一个单一的数据通道,主题的前缀采用模型的名字,后跟其唯一的id,然后通过它的操作({modelType.id.operation})处理,或者给模型的自身通道,主题就是{id.operation}。一个恒定的习惯是在模型请求数据的时候自动响应这个行为。但并不是所有总线上的操作都是请求。可能有简单的事件发布到app。你是否命名主题来描述事件(理想条件下)?或者你是否掉进了这样的陷阱,通过命名主题来描述某个订阅者可能的倾向行为?例如,包含“route.changed” 抑或 “show.customer.ui”主题的消息。一个表明了事件,另一个表明了命令。做这些决定的时候要仔细思考。命令并不坏,但在你需要请求/响应或命令之前,你会为事件所能描述的数量而吃惊的。