如何優雅的實現消息通信?
本文轉載自微信公眾號「全棧修仙之路」,作者semlinker。轉載本文請聯系全棧修仙之路公眾號。
一、背景
作為一名 Web 開發者,在日常工作中,經常都會遇到消息通信的場景。比如實現組件間通信、實現插件間通信、實現不同的系統間通信。那么針對這些場景,我們應該怎么實現消息通信呢?本文阿寶哥將帶大家一起來學習如何優雅的實現消息通信。
時間就這樣過了半個月,小秦和小王都陸續找到了阿寶哥,說 “全棧修仙之路” 博客上的 TS 文章都差不多學完了,他們有空的時候都會到 “全棧修仙之路” 博客上查看是否有新發的 TS 文章。他們覺得這樣挺麻煩的,看能不能在阿寶哥發完新的 TS 文章之后,主動通知他們。
好友提的建議,阿寶哥怎能拒絕呢?所以阿寶哥分別跟他們說:“我會給博客加個訂閱的功能,功能發布后,你填寫一下郵箱地址。以后發布新的 TS 文章,系統會及時給你發郵件”。此時新的流程如下圖所示:
在阿寶哥的一頓 “操作” 之后,博客的訂閱功能上線了,阿寶哥第一時間通知了小秦與小王,讓他們填寫各自的郵箱。之后,每當阿寶哥發布新的 TS 文章,他們就會收到新的郵件通知了。
阿寶哥是個技術宅,對新的技術也很感興趣。在遇到 Deno 之后,阿寶哥燃起了學習 Deno 的熱情,同時也開啟了新的 Deno 專題。在寫了幾篇 Deno 專題文章之后,兩個讀者小池和小郭分別聯系到我,說他們看到了阿寶哥的 Deno 文章,想跟阿寶哥一起學習 Deno。
在了解他們的情況之后,阿寶哥突然想到了之前小秦與小王提的建議。因此,又是一頓 “操作” 之后,阿寶哥為了博客增加了專題訂閱功能。該功能上線之后,阿寶哥及時聯系了小池和小郭,邀請他們訂閱 Deno 專題。之后小池和小郭也成為了阿寶哥博客的訂閱者?,F在的流程變成這樣:
這個例子看起來很簡單,但它背后卻與一些設計思想和設計模式相關聯。因此,接下來阿寶哥將分析以上三個場景與軟件開發中一些設計思想和設計模式的關聯性。
二、場景與模式
2.1 消息輪詢模式
在第一個場景中,小秦和小王為了能查看阿寶哥新發的 TS 文章,他們需要不斷地訪問 “全棧修仙之路” 博客:
這個場景跟軟件開發過程中的輪詢模式類似。早期,很多網站為了實現推送技術,所用的技術都是輪詢。輪詢是指由瀏覽器每隔一段時間向服務器發出 HTTP 請求,然后服務器返回最新的數據給客戶端。常見的輪詢方式分為輪詢與長輪詢,它們的區別如下圖所示:
這種傳統的模式帶來很明顯的缺點,即瀏覽器需要不斷的向服務器發出請求,然而 HTTP 請求與響應可能會包含較長的頭部,其中真正有效的數據可能只是很小的一部分,所以這樣會消耗很多帶寬資源。為了解決上述問題 HTML5 定義了 WebSocket 協議,能更好的節省服務器資源和帶寬,并且能夠更實時地進行通訊。
WebSocket 是一種網絡傳輸協議,可在單個 TCP 連接上進行全雙工通信,位于 OSI 模型的應用層。WebSocket 協議在 2011 年由 IETF 標準化為 RFC 6455,后由 RFC 7936 補充規范。
既然已經提到了 OSI(Open System Interconnection Model)模型,這里阿寶哥來分享一張很生動、很形象描述 OSI 模型的示意圖:
(圖片來源:https://www.networkingsphere.com/2019/07/what-is-osi-model.html)
WebSocket 使得客戶端和服務器之間的數據交換變得更加簡單,允許服務端主動向客戶端推送數據。在 WebSocket API 中,瀏覽器和服務器只需要完成一次握手,兩者之間就可以創建持久性的連接,并進行雙向數據傳輸。
介紹完輪詢和 WebSocket 的相關內容之后,接下來我們來看一下 XHR Polling 與 WebSocket 之間的區別:
對于 XHR Polling 與 WebSocket 來說,它們分別對應了消息通信的兩種模式,即 Pull(拉)模式與 Push(推)模式:
場景一我們就介紹到這里,對輪詢和 WebSocket 感興趣的小伙伴可以閱讀阿寶哥寫的“你不知道的 WebSocket” 這一篇文章。下面我們來繼續分析第二個場景。
2.2 觀察者模式
在第二個場景中,為了讓小秦和小王能及時收到阿寶哥新發布的 TS 文章,阿寶哥給博客增加了訂閱功能。這里假設阿寶哥博客一開始只發布 TS 專題的文章。
針對這個場景,我們可以考慮使用設計模式中觀察者模式來實現上述功能。觀察者模式,它定義了一種一對多的關系,讓多個觀察者對象同時監聽某一個主題對象,這個主題對象的狀態發生變化時就會通知所有的觀察者對象,使得它們能夠自動更新自己。
在觀察者模式中有兩個主要角色:Subject(主題)和 Observer(觀察者)。
在第二個場景中,Subject(主題)就是阿寶哥的 TS 專題文章,而觀察者就是小秦和小王。由于觀察者模式支持簡單的廣播通信,當消息更新時,會自動通知所有的觀察者。因此對于第二個場景,我們可以考慮使用觀察者設計模式來實現上述的功能。接下來,我們來繼續分析第三個場景。
2.3 發布訂閱模式
在第三個場景中,為了讓小池和小郭能及時收到阿寶哥新發布的 Deno 文章,阿寶哥給博客增加了專題訂閱功能。即支持為阿寶哥博客的訂閱者分別推送新發布的 TS 或 Deno 文章。
針對這個場景,我們可以考慮使用發布訂閱模式來實現上述功能。在軟件架構中,發布 — 訂閱是一種消息范式,消息的發送者(稱為發布者)不會將消息直接發送給特定的接收者(稱為訂閱者)。而是將發布的消息分為不同的類別,然后分別發送給不同的訂閱者。同樣的,訂閱者可以表達對一個或多個類別的興趣,只接收感興趣的消息,無需了解哪些發布者存在。
在發布訂閱模式中有三個主要角色:Publisher(發布者)、 Channels(通道)和 Subscriber(訂閱者)。
在第三個場景中,Publisher(發布者)是阿寶哥,Channels(通道)中 Topic A 和 Topic B 分別對應于 TS 專題和 Deno 專題,而 Subscriber(訂閱者)就是小秦、小王、小池和小郭。好的,了解完發布訂閱模式,下面我們來介紹一下它的一些應用場景。
三、發布訂閱模式的應用
3.1 前端框架中模塊/頁面間消息通信
在一些主流的前端框架中,內部也會提供用于模塊間或頁面間通信的組件。比如在 Vue 框架中,我們可以通過 new Vue() 來創建 EventBus 組件。而在 Ionic 3 中我們可以使用 ionic-angular 模塊中的 Events 組件來實現模塊間或頁面間的消息通信。下面我們來分別介紹在 Vue 和 Ionic 中如何實現模塊/頁面間的消息通信。
3.1.1 Vue 使用 EventBus 進行消息通信
在 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>
3.1.2 Ionic 使用 Events 組件進行消息通信
在 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 框架中的應用之后,接下來阿寶哥將介紹該模式在微內核架構中是如何實現插件通信的。
3.2 微內核架構中插件通信
微內核架構(Microkernel Architecture),有時也被稱為插件化架構(Plug-in Architecture),是一種面向功能進行拆分的可擴展性架構,通常用于實現基于產品的應用。微內核架構模式允許你將其他應用程序功能作為插件添加到核心應用程序,從而提供可擴展性以及功能分離和隔離。
微內核架構模式包括兩種類型的架構組件:核心系統(Core System)和插件模塊(Plug-in modules)。應用邏輯被分割為獨立的插件模塊和核心系統,提供了可擴展性、靈活性、功能隔離和自定義處理邏輯的特性。
對于微內核的核心系統設計來說,它涉及三個關鍵技術:插件管理、插件連接和插件通信,這里我們重點來分析一下插件通信。
插件通信是指插件間的通信。雖然設計的時候插件間是完全解耦的,但實際業務運行過程中,必然會出現某個業務流程需要多個插件協作,這就要求兩個插件間進行通信;由于插件之間沒有直接聯系,通信必須通過核心系統,因此核心系統需要提供插件通信機制。
這種情況和計算機類似,計算機的 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/xgplayer/blob/master/packages/xgplayer/src/control/poster.js)
通過觀察源碼可知,在注冊 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');
3.3 基于 Redis 實現不同系統間通信
在前面我們介紹了發布訂閱模式在單個系統中的應用。其實,在日常開發過程中,我們也會遇到不同系統間通信的問題。接下來阿寶哥將介紹如何利用 Redis 提供的發布與訂閱功能實現系統間的通信,不過在介紹具體應用前,我們得先熟悉一下 Redis 提供的發布與訂閱功能。
3.3.1 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 提供的發布與訂閱功能實現不同系統間的通信。
3.3.2 實現不同系統間的通信
這里我們使用 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 組件。
四、發布訂閱模式實戰
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;
- }
- // 為指定的主題發布消息
- 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發布訂閱模式");
- eventEmitter.unsubscribe("ts");
- eventEmitter.publish("ts", "TypeScript發布訂閱模式");
以上代碼成功運行之后,控制臺會輸出以下信息:
- 收到訂閱的消息:TypeScript發布訂閱模式
收到訂閱的消息:TypeScript發布訂閱模式
五、參考資源
維基百科 - 發布/訂閱
Ionic 3 - Events
implementing-redis-pub-sub-in-node-js-application