うわー、それは危険な質問ですね。本質的なものについての私たちの理解は、解決すべき問題の理解によって確かに変わります。だから私は嘘をつくつもりはありません。私がこれから書こうとしている内容はもう半年近くも私の中にあると確信しているので、私が1年前に理解したものの本質は残念ながら不完全です。したがって、この記事は、JavaScript でクライアント側のメッセージング パターンをうまく使用するための重要なポイントのいくつかを私が発見したことを垣間見るものです。
1.) 調停者とオブザーバーの違いを理解する
ほとんどの人は、イベント/メッセージのメカニズムを説明するときに「パブリッシャー/サブスクライバー」(pub/sub) を使用することを好みますが、この用語は抽象化とうまく結びつかないと思います。もちろん、基本的には、何かが他の何かによって発行されたイベントをサブスクライブします。ただし、パブリッシャーとサブスクライバーがカプセル化されるレベルによっては、適切なパターンが鈍くなる可能性があります。それで、違いは何ですか?
観察者
観察者パターンには、1 人以上の観察者によって観察されるオブジェクトが含まれます。通常、このオブジェクトはすべてのオブザーバーのトレースを記録します。通常はリストを使用して、オブザーバーによって登録されたコールバック メソッドを保存します。オブザーバーは通知を受信するためにこのメソッドにサブスクライブします。 注: (ああ、冗談です。私は彼らをどれだけ愛しています) (翻訳者注: 観察してください、観察してください、注意してください)
var observer = { listen : function() { console.log("Yay for more cliché examples..."); } }; var elem = document.getElementById("cliche"); elem.addEventListener("click", observer.listen);
注意すべき点は次のとおりです:
* n は実際には無限ではありませんが、この議論の目的上、決して到達できない限界を指します
仲介者
Mediator パターンは、オブジェクトと観察者の間に「第三者」を導入し、両者を効果的に切り離し、両者の通信方法をカプセル化します。メディエーターの 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 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);
データがオプションである場合、それに関するテストは行われません。しかし、必要なコードは少なく、より拡張性が高く、オブジェクトがオブジェクトに渡されるときに実行されるようなテストを実行できるように、(メンバー名のおかげで) 一目瞭然である必要があります。サブスクライバ コールバック メソッドを 1 つずつ実行します。私は今でも「0-n パラメーター」を使用する必要がある場合にこれを使用していますが、もし私次第であれば、イベント センダーとメッセージ バスの両方で常にエンベロープ アプローチを使用することになります。 (これは私が偏見を持っていることを示しています。monologue と postal は同じエンベロープ データ構造を共有しており、monologue が使用しないチャネルは削除されています)
したがって、加入者にデータを送信するために使用される構造は「契約」の一部であることを認識する必要があります。ラッパーの方向では、(パラメーターを追加せずに) 追加のメタデータを使用してイベントを記述することができます。これにより、各イベントとサブスクライバーでメソッドの署名 (コントラクトの一部) の一貫性が保たれます。また、メッセージ構造を簡単にバージョン管理することもできます (または、必要に応じて他のエンベロープ レベルのメッセージを追加することもできます)。この方向に進む場合は、一貫したエンベロープ構造を使用するようにしてください。
6.) 「トポロジー」は思っているよりも重要であるというメッセージ
ここには特効薬はありません。ただし、トピックとチャネルに名前を付ける方法と、メッセージ ペイロードを構造化する方法については、慎重に考える必要があります。私は、次の 2 つの方法のいずれかでモデルをマッピングする傾向があります。単一のデータ チャネルでは、トピックの先頭にモデルの名前が付けられ、その後にその一意の ID が続き、その操作 ({modelType.id.operation}) によって処理されます。 、またはモデル自体のチャネルの場合、トピックは {id.operation} です。モデルがデータを要求したときに、この動作に自動的に応答することが常に行われています。ただし、バス上のすべての操作がリクエストであるわけではありません。簡単なイベントがアプリに投稿される場合があります。 (理想的には) イベントを説明するテーマに名前を付けますか?それとも、購読者の傾向と思われる行動を説明するためにトピックに名前を付けるという罠に陥ったことはありませんか?たとえば、「route.changed」または「show.customer.ui」トピックを含むメッセージ。 1 つはイベントを示し、もう 1 つは命令を示します。これらの決定を下すときは、慎重に検討してください。コマンドが悪いわけではありませんが、リクエスト/レスポンスやコマンドが必要になる前に、どれほど多くのイベントを記述できるかに驚くでしょう。