做爲一名 Web 開發者,在平常工做中,常常都會遇到消息通訊的場景。好比實現組件間通訊、實現插件間通訊、實現不一樣的系統間通訊。那麼針對這些場景,咱們應該怎麼實現消息通訊呢?本文阿寶哥將帶你們一塊兒來學習如何優雅的實現消息通訊。javascript
好的,接下來咱們立刻步入正題,這裏阿寶哥以一個文章訂閱的例子來拉開本文的序幕。小秦與小王是阿寶哥的兩個好朋友,他們在阿寶哥的 「全棧修仙之路」 博客中發現了 TS 專題文章,恰好他們近期也打算系統地學習 TS,因此他們就開啓了 TS 的學習之旅。html
時間就這樣過了半個月,小秦和小王都陸續找到了阿寶哥,說 「全棧修仙之路」 博客上的 TS 文章都差很少學完了,他們有空的時候都會到 「全棧修仙之路」 博客上查看是否有新發的 TS 文章。他們以爲這樣挺麻煩的,看能不能在阿寶哥發完新的 TS 文章以後,主動通知他們。前端
好友提的建議,阿寶哥怎能拒絕呢?因此阿寶哥分別跟他們說:「我會給博客加個訂閱的功能,功能發佈後,你填寫一下郵箱地址。之後發佈新的 TS 文章,系統會及時給你發郵件」。此時新的流程以下圖所示:vue
在阿寶哥的一頓 「操做」 以後,博客的訂閱功能上線了,阿寶哥第一時間通知了小秦與小王,讓他們填寫各自的郵箱。以後,每當阿寶哥發佈新的 TS 文章,他們就會收到新的郵件通知了。java
阿寶哥是個技術宅,對新的技術也很感興趣。在遇到 Deno 以後,阿寶哥燃起了學習 Deno 的熱情,同時也開啓了新的 Deno 專題。在寫了幾篇 Deno 專題文章以後,兩個讀者小池和小郭分別聯繫到我,說他們看到了阿寶哥的 Deno 文章,想跟阿寶哥一塊兒學習 Deno。node
在瞭解他們的狀況以後,阿寶哥忽然想到了以前小秦與小王提的建議。所以,又是一頓 「操做」 以後,阿寶哥爲了博客增長了專題訂閱功能。該功能上線以後,阿寶哥及時聯繫了小池和小郭,邀請他們訂閱 Deno 專題。以後小池和小郭也成爲了阿寶哥博客的訂閱者。如今的流程變成這樣:git
這個例子看起來很簡單,但它背後卻與一些設計思想和設計模式相關聯。所以,接下來阿寶哥將分析以上三個場景與軟件開發中一些設計思想和設計模式的關聯性。github
在第一個場景中,小秦和小王爲了能查看阿寶哥新發的 TS 文章,他們須要不斷地訪問 「全棧修仙之路」 博客:web
這個場景跟軟件開發過程當中的輪詢模式相似。早期,不少網站爲了實現推送技術,所用的技術都是輪詢。輪詢是指由瀏覽器每隔一段時間向服務器發出 HTTP 請求,而後服務器返回最新的數據給客戶端。常見的輪詢方式分爲輪詢與長輪詢,它們的區別以下圖所示:redis
這種傳統的模式帶來很明顯的缺點,即瀏覽器須要不斷的向服務器發出請求,然而 HTTP 請求與響應可能會包含較長的頭部,其中真正有效的數據可能只是很小的一部分,因此這樣會消耗不少帶寬資源。爲了解決上述問題 HTML5 定義了 WebSocket 協議,能更好的節省服務器資源和帶寬,而且可以更實時地進行通信。
WebSocket 是一種網絡傳輸協議,可在單個 TCP 鏈接上進行全雙工通訊,位於 OSI 模型的應用層。WebSocket 協議在 2011 年由 IETF 標準化爲 RFC 6455,後由 RFC 7936 補充規範。
既然已經提到了 OSI(Open System Interconnection Model)模型,這裏阿寶哥來分享一張很生動、很形象描述 OSI 模型的示意圖:
(圖片來源:https://www.networkingsphere....)
WebSocket 使得客戶端和服務器之間的數據交換變得更加簡單,容許服務端主動向客戶端推送數據。在 WebSocket API 中,瀏覽器和服務器只須要完成一次握手,二者之間就能夠建立持久性的鏈接,並進行雙向數據傳輸。
介紹完輪詢和 WebSocket 的相關內容以後,接下來咱們來看一下 XHR Polling 與 WebSocket 之間的區別:
對於 XHR Polling 與 WebSocket 來講,它們分別對應了消息通訊的兩種模式,即 Pull(拉)模式與 Push(推)模式:
場景一咱們就介紹到這裏,對輪詢和 WebSocket 感興趣的小夥伴能夠閱讀阿寶哥寫的 「你不知道的 WebSocket」 這一篇文章。下面咱們來繼續分析第二個場景。
在第二個場景中,爲了讓小秦和小王能及時收到阿寶哥新發布的 TS 文章,阿寶哥給博客增長了訂閱功能。這裏假設阿寶哥博客一開始只發布 TS 專題的文章。
針對這個場景,咱們能夠考慮使用設計模式中觀察者模式來實現上述功能。 觀察者模式,它定義了一種一對多的關係,讓多個觀察者對象同時監聽某一個主題對象,這個主題對象的狀態發生變化時就會通知全部的觀察者對象,使得它們可以自動更新本身。
在觀察者模式中有兩個主要角色:Subject(主題)和 Observer(觀察者)。
在第二個場景中,Subject(主題)就是阿寶哥的 TS 專題文章,而觀察者就是小秦和小王。因爲觀察者模式支持簡單的廣播通訊,當消息更新時,會自動通知全部的觀察者。所以對於第二個場景,咱們能夠考慮使用觀察者設計模式來實現上述的功能。接下來,咱們來繼續分析第三個場景。
在第三個場景中,爲了讓小池和小郭能及時收到阿寶哥新發布的 Deno 文章,阿寶哥給博客增長了專題訂閱功能。即支持爲阿寶哥博客的訂閱者分別推送新發布的 TS 或 Deno 文章。
針對這個場景,咱們能夠考慮使用發佈訂閱模式來實現上述功能。在軟件架構中,發佈 — 訂閱是一種消息範式,消息的發送者(稱爲發佈者)不會將消息直接發送給特定的接收者(稱爲訂閱者)。而是將發佈的消息分爲不一樣的類別,而後分別發送給不一樣的訂閱者。一樣的,訂閱者能夠表達對一個或多個類別的興趣,只接收感興趣的消息,無需瞭解哪些發佈者存在。
在發佈訂閱模式中有三個主要角色:Publisher(發佈者)、 Channels(通道)和 Subscriber(訂閱者)。
在第三個場景中,Publisher(發佈者)是阿寶哥,Channels(通道)中 Topic A 和 Topic B 分別對應於 TS 專題和 Deno 專題,而 Subscriber(訂閱者)就是小秦、小王、小池和小郭。好的,瞭解完發佈訂閱模式,下面咱們來介紹一下它的一些應用場景。
在一些主流的前端框架中,內部也會提供用於模塊間或頁面間通訊的組件。好比在 Vue 框架中,咱們能夠經過 new Vue()
來建立 EventBus 組件。而在 Ionic 3 中咱們可使用 ionic-angular
模塊中的 Events 組件來實現模塊間或頁面間的消息通訊。下面咱們來分別介紹在 Vue 和 Ionic 中如何實現模塊/頁面間的消息通訊。
在 Vue 中咱們能夠經過建立 EventBus 來實現組件間或模塊間的消息通訊,使用方式很簡單。在下圖中包含兩個 Vue 組件:Greet 和 Alert 組件。Alert 組件用於顯示消息,而 Greet 組件中包含一個按鈕,即下圖中 」顯示問候消息「 的按鈕。當用戶點擊按鈕時,Greet 組件會經過 EventBus 把消息傳遞給 Alert 組件,該組件接收到消息後,會調用 alert
方法把收到的消息顯示出來。
以上示例對應的代碼以下:
main.js
Vue.prototype.$bus = new Vue();
Alert.vue
<script> export default { name: "alert", created() { // 監聽alert:message事件 this.$bus.$on("alert:message", msg => { this.showMessage(msg); }); }, methods: { showMessage(msg) { alert(msg); }, }, beforeDestroy: function() { // 組件銷燬時,移除alert:message事件監聽 this.$bus.$off("alert:message"); } } </script>
Greet.vue
<template> <div> <button @click="greet(message)">顯示問候信息</button> </div> </template> <script> export default { name: "Greet", data() { return { message: "你們好,我是阿寶哥", }; }, methods: { greet(msg) { this.$bus.$emit("alert:message", msg); } } }; </script>
在 Ionic 3 項目中,要實現頁面間消息通訊很簡單。咱們只要經過構造注入的方式注入 ionic-angular
模塊中提供的 Events 組件便可。具體的使用示例以下所示:
import { Events } from 'ionic-angular'; // first page (publish an event when a user is created) constructor(public events: Events) {} createUser(user) { console.log('User created!') this.events.publish('user:created', user, Date.now()); } // second page (listen for the user created event after function is called) constructor(public events: Events) { events.subscribe('user:created', (user, time) => { // user and time are the same arguments passed in `events.publish(user, time)` console.log('Welcome', user, 'at', time); }); }
介紹完發佈訂閱模式在 Vue 和 Ionic 框架中的應用以後,接下來阿寶哥將介紹該模式在微內核架構中是如何實現插件通訊的。
微內核架構(Microkernel Architecture),有時也被稱爲插件化架構(Plug-in Architecture),是一種面向功能進行拆分的可擴展性架構,一般用於實現基於產品的應用。微內核架構模式容許你將其餘應用程序功能做爲插件添加到核心應用程序,從而提供可擴展性以及功能分離和隔離。
微內核架構模式包括兩種類型的架構組件:核心系統(Core System)和插件模塊(Plug-in modules)。應用邏輯被分割爲獨立的插件模塊和核心繫統,提供了可擴展性、靈活性、功能隔離和自定義處理邏輯的特性。
<img src="http://cdn.semlinker.com/microkernel-architecture-pattern.png" alt="" style="zoom:60%;" />
對於微內核的核心繫統設計來講,它涉及三個關鍵技術:插件管理、插件鏈接和插件通訊,這裏咱們重點來分析一下插件通訊。
插件通訊是指插件間的通訊。雖然設計的時候插件間是徹底解耦的,但實際業務運行過程當中,必然會出現某個業務流程須要多個插件協做,這就要求兩個插件間進行通訊;因爲插件之間沒有直接聯繫,通訊必須經過核心系統,所以核心系統須要提供插件通訊機制。
這種狀況和計算機相似,計算機的 CPU、硬盤、內存、網卡是獨立設計的配置,但計算機運行過程當中,CPU 和內存、內存和硬盤確定是有通訊的,計算機經過主板上的總線提供了這些組件之間的通訊功能。
下面阿寶哥將以基於微內核架構設計的西瓜播放器爲例,介紹它的內部是如何提供插件通訊機制。在西瓜播放器內部,定義了一個 Player
類來建立播放器實例:
let player = new Player({ id: 'mse', url: '//abc.com/**/*.mp4' });
Player
類繼承於 Proxy
類,而在 Proxy
類內部會經過構造繼承的方式繼承 EventEmitter
事件派發器:
import EventEmitter from 'event-emitter' class Proxy { constructor (options) { this._hasStart = false; // 省略大部分代碼 EventEmitter(this); } }
因此咱們建立的西瓜播放器也是一個事件派發器,利用它就能夠實現插件的通訊。爲了讓你們可以更好地理解具體的通訊流程,咱們之內置的 poster 插件爲例,來看一下它內部如何使用事件派發器。
poster 插件用於在播放器播放音視頻前顯示海報圖,該插件的使用方式以下:
new Player({ el:document.querySelector('#mse'), url: 'video_url', poster: '//abc.com/**/*.png' // 默認值"" });
poster 插件的對應源碼以下:
import Player from '../player' let poster = function () { let player = this; let util = Player.util let poster = util.createDom('xg-poster', '', {}, 'xgplayer-poster'); let root = player.root if (player.config.poster) { poster.style.backgroundImage = `url(${player.config.poster})` root.appendChild(poster) } // 監聽播放事件,播放時隱藏封面圖 function playFunc () { poster.style.display = 'none' } player.on('play', playFunc) // 監聽銷燬事件,執行清理操做 function destroyFunc () { player.off('play', playFunc) player.off('destroy', destroyFunc) } player.once('destroy', destroyFunc) } Player.install('poster', poster)
(https://github.com/bytedance/...
經過觀察源碼可知,在註冊 poster 插件時,會把播放器實例注入到插件中。以後,在插件內部會使用 player 這個事件派發器來監聽播放器的 play
和 destroy
事件。當 poster 插件監聽到播放器的 play
事件以後,就會隱藏海報圖。而當 poster 插件監聽到播放器的 destroy
事件時,就會執行清理操做,好比移除已綁定的事件。
看到這裏咱們就已經很清楚了,西瓜播放器內部使用 EventEmitter
來提供插件通訊機制,每一個插件都會注入 player 這個全局的事件派發器,經過它就能夠輕鬆地實現插件間通訊了。
提到 EventEmitter
,相信不少小夥伴對它並不會陌生。在 Node.js 中有一個名爲 events
的內置模塊,經過它咱們能夠方便地實現一個自定義的事件派發器,好比:
const EventEmitter = require('events'); class MyEmitter extends EventEmitter {} const myEmitter = new MyEmitter(); myEmitter.on('event', () => { console.log('你們好,我是阿寶哥!'); }); myEmitter.emit('event');
在前面咱們介紹了發佈訂閱模式在單個系統中的應用。其實,在平常開發過程當中,咱們也會遇到不一樣系統間通訊的問題。接下來阿寶哥將介紹如何利用 Redis 提供的發佈與訂閱功能實現系統間的通訊,不過在介紹具體應用前,咱們得先熟悉一下 Redis 提供的發佈與訂閱功能。
Redis 訂閱功能
經過 Redis 的 subscribe 命令,咱們能夠訂閱感興趣的通道,其語法爲:SUBSCRIBE channel [channel …]
。
➜ ~ redis-cli 127.0.0.1:6379> subscribe deno ts Reading messages... (press Ctrl-C to quit) 1) "subscribe" 2) "deno" 3) (integer) 1 1) "subscribe" 2) "ts" 3) (integer) 2
在上述命令中,咱們經過 subscribe
命令訂閱了 deno 和 ts 兩個通道。接下來咱們新開一個命令行窗口,來測試 Redis 的發佈功能。
Redis 發佈功能
經過 Redis 的 publish 命令,咱們能夠爲指定的通道發佈消息,其語法爲: PUBLISH channel message
。
➜ ~ redis-cli 127.0.0.1:6379> publish ts "pub/sub design mode" (integer) 1
當成功發佈消息以後,訂閱該通道的客戶端就會收到消息,對應的控制檯就會輸出以下信息:
1) "message" 2) "ts" 3) "pub/sub design mode"
瞭解完 Redis 的發佈與訂閱功能,接下來阿寶哥將介紹如何利用 Redis 提供的發佈與訂閱功能實現不一樣系統間的通訊。
這裏咱們使用 Node.js 的 Express 框架和 redis 模塊來快速搭建不一樣的 Web 應用,首先建立一個新的 Web 項目並安裝一下相關的依賴:
$ npm init --yes $ npm install express redis
接着建立一個發佈者應用:
publisher.js
const redis = require("redis"); const express = require("express"); const publisher = redis.createClient(); const app = express(); app.get("/", (req, res) => { const article = { id: "666", name: "TypeScript實戰之發佈訂閱模式", }; publisher.publish("ts", JSON.stringify(article)); res.send("阿寶哥寫了一篇TS文章"); }); app.listen(3005, () => { console.log(`server is listening on PORT 3005`); });
而後分別建立兩個訂閱者應用:
subscriber-1.js
const redis = require("redis"); const express = require("express"); const subscriber = redis.createClient(); const app = express(); subscriber.on("message", (channel, message) => { console.log("小王收到了阿寶哥的TS文章: " + message); }); subscriber.subscribe("ts"); app.get("/", (req, res) => { res.send("我是阿寶哥的粉絲,小王"); }); app.listen(3006, () => { console.log("server is listening to port 3006"); });
subscriber-2.js
const redis = require("redis"); const express = require("express"); const subscriber = redis.createClient(); // https://dev.to/ganeshmani/implementing-redis-pub-sub-in-node-js-application-12he const app = express(); subscriber.on("message", (channel, message) => { console.log("小秦收到了阿寶哥的TS文章: " + message); }); subscriber.subscribe("ts"); app.get("/", (req, res) => { res.send("我是阿寶哥的粉絲,小秦"); }); app.listen(3007, () => { console.log("server is listening to port 3007"); });
接着分別啓動上面的三個應用,當全部應用都成功啓動以後,在瀏覽器中訪問 http://localhost:3005/
地址,此時上面的兩個訂閱者應用對應的終端會分別輸出如下信息:
subscriber-1.js
server is listening to port 3006 小王收到了阿寶哥的TS文章: {"id":"666","name":"TypeScript實戰之發佈訂閱模式"}
subscriber-2.js
server is listening to port 3007 小秦收到了阿寶哥的TS文章: {"id":"666","name":"TypeScript實戰之發佈訂閱模式"}
以上示例對應的通訊流程以下圖所示:
到這裏發佈訂閱模式的應用場景,已經介紹完了。最後,阿寶哥來介紹一下如何使用 TS 實現一個支持發佈與訂閱功能的 EventEmitter 組件。
type EventHandler = (...args: any[]) => any; class EventEmitter { private c = new Map<string, EventHandler[]>(); // 訂閱指定的主題 subscribe(topic: string, ...handlers: EventHandler[]) { let topics = this.c.get(topic); if (!topics) { this.c.set(topic, topics = []); } topics.push(...handlers); } // 取消訂閱指定的主題 unsubscribe(topic: string, handler?: EventHandler): boolean { if (!handler) { return this.c.delete(topic); } const topics = this.c.get(topic); if (!topics) { return false; } const index = topics.indexOf(handler); if (index < 0) { return false; } topics.splice(index, 1); if (topics.length === 0) { this.c.delete(topic); } return true; } // 爲指定的主題發佈消息 publish(topic: string, ...args: any[]): any[] | null { const topics = this.c.get(topic); if (!topics) { return null; } return topics.map(handler => { try { return handler(...args); } catch (e) { console.error(e); return null; } }); } }
const eventEmitter = new EventEmitter(); eventEmitter.subscribe("ts", (msg) => console.log(`收到訂閱的消息:${msg}`) ); eventEmitter.publish("ts", "TypeScript發佈訂閱模式"); eventEmitter.unsubscribe("ts"); eventEmitter.publish("ts", "TypeScript發佈訂閱模式");
以上代碼成功運行以後,控制檯會輸出如下信息:
收到訂閱的消息:TypeScript發佈訂閱模式