哇——是個危險的題目,對嗎?我們對於什麼是本質的理解當然會隨著我們對要解決問題的理解而變化。因此我不會說謊——一年前我所理解的本質很不幸並不完整,因為我確信我將要寫的已經快伴隨我有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);
一些需要注意的事情是:
* 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 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”主題的消息。一個表明了事件,另一個表明了命令。做這些決定的時候要仔細思考。命令並不壞,但在你需要請求/響應或命令之前,你會為事件所能描述的數量而吃驚的。