如何優(yōu)雅的實現(xiàn)消息通信?
本文轉(zhuǎn)載自微信公眾號「全棧修仙之路」,作者semlinker。轉(zhuǎn)載本文請聯(lián)系全棧修仙之路公眾號。
一、背景
作為一名 Web 開發(fā)者,在日常工作中,經(jīng)常都會遇到消息通信的場景。比如實現(xiàn)組件間通信、實現(xiàn)插件間通信、實現(xiàn)不同的系統(tǒng)間通信。那么針對這些場景,我們應(yīng)該怎么實現(xiàn)消息通信呢?本文阿寶哥將帶大家一起來學習如何優(yōu)雅的實現(xiàn)消息通信。
時間就這樣過了半個月,小秦和小王都陸續(xù)找到了阿寶哥,說 “全棧修仙之路” 博客上的 TS 文章都差不多學完了,他們有空的時候都會到 “全棧修仙之路” 博客上查看是否有新發(fā)的 TS 文章。他們覺得這樣挺麻煩的,看能不能在阿寶哥發(fā)完新的 TS 文章之后,主動通知他們。
好友提的建議,阿寶哥怎能拒絕呢?所以阿寶哥分別跟他們說:“我會給博客加個訂閱的功能,功能發(fā)布后,你填寫一下郵箱地址。以后發(fā)布新的 TS 文章,系統(tǒng)會及時給你發(fā)郵件”。此時新的流程如下圖所示:
在阿寶哥的一頓 “操作” 之后,博客的訂閱功能上線了,阿寶哥第一時間通知了小秦與小王,讓他們填寫各自的郵箱。之后,每當阿寶哥發(fā)布新的 TS 文章,他們就會收到新的郵件通知了。
阿寶哥是個技術(shù)宅,對新的技術(shù)也很感興趣。在遇到 Deno 之后,阿寶哥燃起了學習 Deno 的熱情,同時也開啟了新的 Deno 專題。在寫了幾篇 Deno 專題文章之后,兩個讀者小池和小郭分別聯(lián)系到我,說他們看到了阿寶哥的 Deno 文章,想跟阿寶哥一起學習 Deno。
在了解他們的情況之后,阿寶哥突然想到了之前小秦與小王提的建議。因此,又是一頓 “操作” 之后,阿寶哥為了博客增加了專題訂閱功能。該功能上線之后,阿寶哥及時聯(lián)系了小池和小郭,邀請他們訂閱 Deno 專題。之后小池和小郭也成為了阿寶哥博客的訂閱者?,F(xiàn)在的流程變成這樣:
這個例子看起來很簡單,但它背后卻與一些設(shè)計思想和設(shè)計模式相關(guān)聯(lián)。因此,接下來阿寶哥將分析以上三個場景與軟件開發(fā)中一些設(shè)計思想和設(shè)計模式的關(guān)聯(lián)性。
二、場景與模式
2.1 消息輪詢模式
在第一個場景中,小秦和小王為了能查看阿寶哥新發(fā)的 TS 文章,他們需要不斷地訪問 “全棧修仙之路” 博客:
這個場景跟軟件開發(fā)過程中的輪詢模式類似。早期,很多網(wǎng)站為了實現(xiàn)推送技術(shù),所用的技術(shù)都是輪詢。輪詢是指由瀏覽器每隔一段時間向服務(wù)器發(fā)出 HTTP 請求,然后服務(wù)器返回最新的數(shù)據(jù)給客戶端。常見的輪詢方式分為輪詢與長輪詢,它們的區(qū)別如下圖所示:
這種傳統(tǒng)的模式帶來很明顯的缺點,即瀏覽器需要不斷的向服務(wù)器發(fā)出請求,然而 HTTP 請求與響應(yīng)可能會包含較長的頭部,其中真正有效的數(shù)據(jù)可能只是很小的一部分,所以這樣會消耗很多帶寬資源。為了解決上述問題 HTML5 定義了 WebSocket 協(xié)議,能更好的節(jié)省服務(wù)器資源和帶寬,并且能夠更實時地進行通訊。
WebSocket 是一種網(wǎng)絡(luò)傳輸協(xié)議,可在單個 TCP 連接上進行全雙工通信,位于 OSI 模型的應(yīng)用層。WebSocket 協(xié)議在 2011 年由 IETF 標準化為 RFC 6455,后由 RFC 7936 補充規(guī)范。
既然已經(jīng)提到了 OSI(Open System Interconnection Model)模型,這里阿寶哥來分享一張很生動、很形象描述 OSI 模型的示意圖:
(圖片來源:https://www.networkingsphere.com/2019/07/what-is-osi-model.html)
WebSocket 使得客戶端和服務(wù)器之間的數(shù)據(jù)交換變得更加簡單,允許服務(wù)端主動向客戶端推送數(shù)據(jù)。在 WebSocket API 中,瀏覽器和服務(wù)器只需要完成一次握手,兩者之間就可以創(chuàng)建持久性的連接,并進行雙向數(shù)據(jù)傳輸。
介紹完輪詢和 WebSocket 的相關(guān)內(nèi)容之后,接下來我們來看一下 XHR Polling 與 WebSocket 之間的區(qū)別:
對于 XHR Polling 與 WebSocket 來說,它們分別對應(yīng)了消息通信的兩種模式,即 Pull(拉)模式與 Push(推)模式:
場景一我們就介紹到這里,對輪詢和 WebSocket 感興趣的小伙伴可以閱讀阿寶哥寫的“你不知道的 WebSocket” 這一篇文章。下面我們來繼續(xù)分析第二個場景。
2.2 觀察者模式
在第二個場景中,為了讓小秦和小王能及時收到阿寶哥新發(fā)布的 TS 文章,阿寶哥給博客增加了訂閱功能。這里假設(shè)阿寶哥博客一開始只發(fā)布 TS 專題的文章。
針對這個場景,我們可以考慮使用設(shè)計模式中觀察者模式來實現(xiàn)上述功能。觀察者模式,它定義了一種一對多的關(guān)系,讓多個觀察者對象同時監(jiān)聽某一個主題對象,這個主題對象的狀態(tài)發(fā)生變化時就會通知所有的觀察者對象,使得它們能夠自動更新自己。
在觀察者模式中有兩個主要角色:Subject(主題)和 Observer(觀察者)。
在第二個場景中,Subject(主題)就是阿寶哥的 TS 專題文章,而觀察者就是小秦和小王。由于觀察者模式支持簡單的廣播通信,當消息更新時,會自動通知所有的觀察者。因此對于第二個場景,我們可以考慮使用觀察者設(shè)計模式來實現(xiàn)上述的功能。接下來,我們來繼續(xù)分析第三個場景。
2.3 發(fā)布訂閱模式
在第三個場景中,為了讓小池和小郭能及時收到阿寶哥新發(fā)布的 Deno 文章,阿寶哥給博客增加了專題訂閱功能。即支持為阿寶哥博客的訂閱者分別推送新發(fā)布的 TS 或 Deno 文章。
針對這個場景,我們可以考慮使用發(fā)布訂閱模式來實現(xiàn)上述功能。在軟件架構(gòu)中,發(fā)布 — 訂閱是一種消息范式,消息的發(fā)送者(稱為發(fā)布者)不會將消息直接發(fā)送給特定的接收者(稱為訂閱者)。而是將發(fā)布的消息分為不同的類別,然后分別發(fā)送給不同的訂閱者。同樣的,訂閱者可以表達對一個或多個類別的興趣,只接收感興趣的消息,無需了解哪些發(fā)布者存在。
在發(fā)布訂閱模式中有三個主要角色:Publisher(發(fā)布者)、 Channels(通道)和 Subscriber(訂閱者)。
在第三個場景中,Publisher(發(fā)布者)是阿寶哥,Channels(通道)中 Topic A 和 Topic B 分別對應(yīng)于 TS 專題和 Deno 專題,而 Subscriber(訂閱者)就是小秦、小王、小池和小郭。好的,了解完發(fā)布訂閱模式,下面我們來介紹一下它的一些應(yīng)用場景。
三、發(fā)布訂閱模式的應(yīng)用
3.1 前端框架中模塊/頁面間消息通信
在一些主流的前端框架中,內(nèi)部也會提供用于模塊間或頁面間通信的組件。比如在 Vue 框架中,我們可以通過 new Vue() 來創(chuàng)建 EventBus 組件。而在 Ionic 3 中我們可以使用 ionic-angular 模塊中的 Events 組件來實現(xiàn)模塊間或頁面間的消息通信。下面我們來分別介紹在 Vue 和 Ionic 中如何實現(xiàn)模塊/頁面間的消息通信。
3.1.1 Vue 使用 EventBus 進行消息通信
在 Vue 中我們可以通過創(chuàng)建 EventBus 來實現(xiàn)組件間或模塊間的消息通信,使用方式很簡單。在下圖中包含兩個 Vue 組件:Greet 和 Alert 組件。Alert 組件用于顯示消息,而 Greet 組件中包含一個按鈕,即下圖中 ”顯示問候消息“ 的按鈕。當用戶點擊按鈕時,Greet 組件會通過 EventBus 把消息傳遞給 Alert 組件,該組件接收到消息后,會調(diào)用 alert 方法把收到的消息顯示出來。
以上示例對應(yīng)的代碼如下:
main.js
- Vue.prototype.$bus = new Vue();
Alert.vue
- <script>
- export default {
- name: "alert",
- created() {
- // 監(jiān)聽alert:message事件
- this.$bus.$on("alert:message", msg => {
- this.showMessage(msg);
- });
- },
- methods: {
- showMessage(msg) {
- alert(msg);
- },
- },
- beforeDestroy: function() {
- // 組件銷毀時,移除alert:message事件監(jiān)聽
- 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>
3.1.2 Ionic 使用 Events 組件進行消息通信
在 Ionic 3 項目中,要實現(xiàn)頁面間消息通信很簡單。我們只要通過構(gòu)造注入的方式注入 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);
- });
- }
介紹完發(fā)布訂閱模式在 Vue 和 Ionic 框架中的應(yīng)用之后,接下來阿寶哥將介紹該模式在微內(nèi)核架構(gòu)中是如何實現(xiàn)插件通信的。
3.2 微內(nèi)核架構(gòu)中插件通信
微內(nèi)核架構(gòu)(Microkernel Architecture),有時也被稱為插件化架構(gòu)(Plug-in Architecture),是一種面向功能進行拆分的可擴展性架構(gòu),通常用于實現(xiàn)基于產(chǎn)品的應(yīng)用。微內(nèi)核架構(gòu)模式允許你將其他應(yīng)用程序功能作為插件添加到核心應(yīng)用程序,從而提供可擴展性以及功能分離和隔離。
微內(nèi)核架構(gòu)模式包括兩種類型的架構(gòu)組件:核心系統(tǒng)(Core System)和插件模塊(Plug-in modules)。應(yīng)用邏輯被分割為獨立的插件模塊和核心系統(tǒng),提供了可擴展性、靈活性、功能隔離和自定義處理邏輯的特性。
對于微內(nèi)核的核心系統(tǒng)設(shè)計來說,它涉及三個關(guān)鍵技術(shù):插件管理、插件連接和插件通信,這里我們重點來分析一下插件通信。
插件通信是指插件間的通信。雖然設(shè)計的時候插件間是完全解耦的,但實際業(yè)務(wù)運行過程中,必然會出現(xiàn)某個業(yè)務(wù)流程需要多個插件協(xié)作,這就要求兩個插件間進行通信;由于插件之間沒有直接聯(lián)系,通信必須通過核心系統(tǒng),因此核心系統(tǒng)需要提供插件通信機制。
這種情況和計算機類似,計算機的 CPU、硬盤、內(nèi)存、網(wǎng)卡是獨立設(shè)計的配置,但計算機運行過程中,CPU 和內(nèi)存、內(nèi)存和硬盤肯定是有通信的,計算機通過主板上的總線提供了這些組件之間的通信功能。
下面阿寶哥將以基于微內(nèi)核架構(gòu)設(shè)計的西瓜播放器為例,介紹它的內(nèi)部是如何提供插件通信機制。在西瓜播放器內(nèi)部,定義了一個 Player 類來創(chuàng)建播放器實例:
- let player = new Player({
- id: 'mse',
- url: '//abc.com/**/*.mp4'
- });
Player 類繼承于 Proxy 類,而在 Proxy 類內(nèi)部會通過構(gòu)造繼承的方式繼承 EventEmitter 事件派發(fā)器:
- import EventEmitter from 'event-emitter'
- class Proxy {
- constructor (options) {
- this._hasStart = false;
- // 省略大部分代碼
- EventEmitter(this);
- }
- }
所以我們創(chuàng)建的西瓜播放器也是一個事件派發(fā)器,利用它就可以實現(xiàn)插件的通信。為了讓大家能夠更好地理解具體的通信流程,我們以內(nèi)置的 poster 插件為例,來看一下它內(nèi)部如何使用事件派發(fā)器。
poster 插件用于在播放器播放音視頻前顯示海報圖,該插件的使用方式如下:
- new Player({
- el:document.querySelector('#mse'),
- url: 'video_url',
- poster: '//abc.com/**/*.png' // 默認值""
- });
poster 插件的對應(yīng)源碼如下:
- 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)
- }
- // 監(jiān)聽播放事件,播放時隱藏封面圖
- function playFunc () {
- poster.style.display = 'none'
- }
- player.on('play', playFunc)
- // 監(jiān)聽銷毀事件,執(zhí)行清理操作
- function destroyFunc () {
- player.off('play', playFunc)
- player.off('destroy', destroyFunc)
- }
- player.once('destroy', destroyFunc)
- }
- Player.install('poster', poster)
(https://github.com/bytedance/xgplayer/blob/master/packages/xgplayer/src/control/poster.js)
通過觀察源碼可知,在注冊 poster 插件時,會把播放器實例注入到插件中。之后,在插件內(nèi)部會使用 player 這個事件派發(fā)器來監(jiān)聽播放器的 play 和 destroy 事件。當 poster 插件監(jiān)聽到播放器的 play 事件之后,就會隱藏海報圖。而當 poster 插件監(jiān)聽到播放器的 destroy 事件時,就會執(zhí)行清理操作,比如移除已綁定的事件。
看到這里我們就已經(jīng)很清楚了,西瓜播放器內(nèi)部使用 EventEmitter 來提供插件通信機制,每個插件都會注入 player 這個全局的事件派發(fā)器,通過它就可以輕松地實現(xiàn)插件間通信了。
提到 EventEmitter,相信很多小伙伴對它并不會陌生。在 Node.js 中有一個名為 events 的內(nèi)置模塊,通過它我們可以方便地實現(xiàn)一個自定義的事件派發(fā)器,比如:
- const EventEmitter = require('events');
- class MyEmitter extends EventEmitter {}
- const myEmitter = new MyEmitter();
- myEmitter.on('event', () => {
- console.log('大家好,我是阿寶哥!');
- });
- myEmitter.emit('event');
3.3 基于 Redis 實現(xiàn)不同系統(tǒng)間通信
在前面我們介紹了發(fā)布訂閱模式在單個系統(tǒng)中的應(yīng)用。其實,在日常開發(fā)過程中,我們也會遇到不同系統(tǒng)間通信的問題。接下來阿寶哥將介紹如何利用 Redis 提供的發(fā)布與訂閱功能實現(xiàn)系統(tǒng)間的通信,不過在介紹具體應(yīng)用前,我們得先熟悉一下 Redis 提供的發(fā)布與訂閱功能。
3.3.1 Redis 發(fā)布與訂閱功能
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 的發(fā)布功能。
Redis 發(fā)布功能
通過 Redis 的 publish 命令,我們可以為指定的通道發(fā)布消息,其語法為:PUBLISH channel message。
- ➜ ~ redis-cli
- 127.0.0.1:6379> publish ts "pub/sub design mode"
- (integer) 1
當成功發(fā)布消息之后,訂閱該通道的客戶端就會收到消息,對應(yīng)的控制臺就會輸出如下信息:
- 1) "message"
- 2) "ts"
- 3) "pub/sub design mode"
了解完 Redis 的發(fā)布與訂閱功能,接下來阿寶哥將介紹如何利用 Redis 提供的發(fā)布與訂閱功能實現(xiàn)不同系統(tǒng)間的通信。
3.3.2 實現(xiàn)不同系統(tǒng)間的通信
這里我們使用 Node.js 的 Express 框架和 redis 模塊來快速搭建不同的 Web 應(yīng)用,首先創(chuàng)建一個新的 Web 項目并安裝一下相關(guān)的依賴:
- $ npm init --yes
- $ npm install express redis
接著創(chuàng)建一個發(fā)布者應(yīng)用:
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實戰(zhàn)之發(fā)布訂閱模式",
- };
- publisher.publish("ts", JSON.stringify(article));
- res.send("阿寶哥寫了一篇TS文章");
- });
- app.listen(3005, () => {
- console.log(`server is listening on PORT 3005`);
- });
然后分別創(chuàng)建兩個訂閱者應(yīng)用:
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");
- });
接著分別啟動上面的三個應(yīng)用,當所有應(yīng)用都成功啟動之后,在瀏覽器中訪問 http://localhost:3005/ 地址,此時上面的兩個訂閱者應(yīng)用對應(yīng)的終端會分別輸出以下信息:
subscriber-1.js
- server is listening to port 3006
- 小王收到了阿寶哥的TS文章: {"id":"666","name":"TypeScript實戰(zhàn)之發(fā)布訂閱模式"}
subscriber-2.js
- server is listening to port 3007
- 小秦收到了阿寶哥的TS文章: {"id":"666","name":"TypeScript實戰(zhàn)之發(fā)布訂閱模式"}
以上示例對應(yīng)的通信流程如下圖所示:
到這里發(fā)布訂閱模式的應(yīng)用場景,已經(jīng)介紹完了。最后,阿寶哥來介紹一下如何使用 TS 實現(xiàn)一個支持發(fā)布與訂閱功能的 EventEmitter 組件。
四、發(fā)布訂閱模式實戰(zhàn)
4.1 定義 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;
- }
- // 為指定的主題發(fā)布消息
- 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;
- }
- });
- }
- }
4.2 使用示例
- const eventEmitter = new EventEmitter();
- eventEmitter.subscribe("ts", (msg) => console.log(`收到訂閱的消息:${msg}`) );
- eventEmitter.publish("ts", "TypeScript發(fā)布訂閱模式");
- eventEmitter.unsubscribe("ts");
- eventEmitter.publish("ts", "TypeScript發(fā)布訂閱模式");
以上代碼成功運行之后,控制臺會輸出以下信息:
- 收到訂閱的消息:TypeScript發(fā)布訂閱模式
收到訂閱的消息:TypeScript發(fā)布訂閱模式
五、參考資源
維基百科 - 發(fā)布/訂閱
Ionic 3 - Events
implementing-redis-pub-sub-in-node-js-application