如何優雅的實現訊息通訊?

阿寶哥發表於2020-08-27

一、背景

作為一名 Web 開發者,在日常工作中,經常都會遇到訊息通訊的場景。比如實現元件間通訊、實現外掛間通訊、實現不同的系統間通訊。那麼針對這些場景,我們應該怎麼實現訊息通訊呢?本文阿寶哥將帶大家一起來學習如何優雅的實現訊息通訊。

好的,接下來我們馬上步入正題,這裡阿寶哥以一個文章訂閱的例子來拉開本文的序幕。小秦與小王是阿寶哥的兩個好朋友,他們在阿寶哥的 “全棧修仙之路” 部落格中發現了 TS 專題文章,剛好他們近期也打算系統地學習 TS,所以他們就開啟了 TS 的學習之旅。

時間就這樣過了半個月,小秦和小王都陸續找到了阿寶哥,說 “全棧修仙之路” 部落格上的 TS 文章都差不多學完了,他們有空的時候都會到 “全棧修仙之路” 部落格上檢視是否有新發的 TS 文章。他們覺得這樣挺麻煩的,看能不能在阿寶哥發完新的 TS 文章之後,主動通知他們。

好友提的建議,阿寶哥怎能拒絕呢?所以阿寶哥分別跟他們說:“我會給部落格加個訂閱的功能,功能釋出後,你填寫一下郵箱地址。以後釋出新的 TS 文章,系統會及時給你發郵件”。此時新的流程如下圖所示:

在阿寶哥的一頓 “操作” 之後,部落格的訂閱功能上線了,阿寶哥第一時間通知了小秦與小王,讓他們填寫各自的郵箱。之後,每當阿寶哥釋出新的 TS 文章,他們就會收到新的郵件通知了。

阿寶哥是個技術宅,對新的技術也很感興趣。在遇到 Deno 之後,阿寶哥燃起了學習 Deno 的熱情,同時也開啟了新的 Deno 專題。在寫了幾篇 Deno 專題文章之後,兩個讀者小池和小郭分別聯絡到我,說他們看到了阿寶哥的 Deno 文章,想跟阿寶哥一起學習 Deno。

在瞭解他們的情況之後,阿寶哥突然想到了之前小秦與小王提的建議。因此,又是一頓 “操作” 之後,阿寶哥為了部落格增加了專題訂閱功能。該功能上線之後,阿寶哥及時聯絡了小池和小郭,邀請他們訂閱 Deno 專題。之後小池和小郭也成為了阿寶哥部落格的訂閱者。現在的流程變成這樣:

這個例子看起來很簡單,但它背後卻與一些設計思想和設計模式相關聯。因此,接下來阿寶哥將分析以上三個場景與軟體開發中一些設計思想和設計模式的關聯性。

二、場景與模式

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....

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)。應用邏輯被分割為獨立的外掛模組和核心繫統,提供了可擴充套件性、靈活性、功能隔離和自定義處理邏輯的特性。

<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 這個事件派發器來監聽播放器的 playdestroy 事件。當 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釋出訂閱模式

五、參考資源

六、推薦閱讀

相關文章