天天看點

【Web技術】702- 如何優雅的實作消息通信?

一、背景

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

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

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

【Web技術】702- 如何優雅的實作消息通信?

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

【Web技術】702- 如何優雅的實作消息通信?

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

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

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

【Web技術】702- 如何優雅的實作消息通信?

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

二、場景與模式

2.1 消息輪詢模式

在第一個場景中,小秦和小王為了能檢視阿寶哥新發的 TS 文章,他們需要不斷地通路 “全棧修仙之路” 部落格:

【Web技術】702- 如何優雅的實作消息通信?

這個場景跟軟體開發過程中的輪詢模式類似。早期,很多網站為了實作推送技術,所用的技術都是輪詢。輪詢是指由浏覽器每隔一段時間向伺服器發出 HTTP 請求,然後伺服器傳回最新的資料給用戶端。常見的輪詢方式分為輪詢與長輪詢,它們的差別如下圖所示:

【Web技術】702- 如何優雅的實作消息通信?

這種傳統的模式帶來很明顯的缺點,即浏覽器需要不斷的向伺服器送出請求,然而 HTTP 請求與響應可能會包含較長的頭部,其中真正有效的資料可能隻是很小的一部分,是以這樣會消耗很多帶寬資源。為了解決上述問題 HTML5 定義了 WebSocket 協定,能更好的節省伺服器資源和帶寬,并且能夠更實時地進行通訊。

WebSocket 是一種網絡傳輸協定,可在單個 TCP 連接配接上進行全雙工通信,位于 OSI 模型的應用層。WebSocket 協定在 2011 年由 IETF 标準化為 RFC 6455,後由 RFC 7936 補充規範。

既然已經提到了 OSI(Open System Interconnection Model)模型,這裡阿寶哥來分享一張很生動、很形象描述 OSI 模型的示意圖:

【Web技術】702- 如何優雅的實作消息通信?

(圖檔來源:https://www.networkingsphere.com/2019/07/what-is-osi-model.html)

WebSocket 使得用戶端和伺服器之間的資料交換變得更加簡單,允許服務端主動向用戶端推送資料。在 WebSocket API 中,浏覽器和伺服器隻需要完成一次握手,兩者之間就可以建立持久性的連接配接,并進行雙向資料傳輸。

介紹完輪詢和 WebSocket 的相關内容之後,接下來我們來看一下 XHR Polling 與 WebSocket 之間的差別:

【Web技術】702- 如何優雅的實作消息通信?

對于 XHR Polling 與 WebSocket 來說,它們分别對應了消息通信的兩種模式,即 Pull(拉)模式與 Push(推)模式:

【Web技術】702- 如何優雅的實作消息通信?

場景一我們就介紹到這裡,對輪詢和 WebSocket 感興趣的小夥伴可以閱讀阿寶哥寫的 “你不知道的 WebSocket” 這一篇文章。下面我們來繼續分析第二個場景。

2.2 觀察者模式

在第二個場景中,為了讓小秦和小王能及時收到阿寶哥新釋出的 TS 文章,阿寶哥給部落格增加了訂閱功能。這裡假設阿寶哥部落格一開始隻釋出 TS 專題的文章。

【Web技術】702- 如何優雅的實作消息通信?

針對這個場景,我們可以考慮使用設計模式中觀察者模式來實作上述功能。觀察者模式,它定義了一種一對多的關系,讓多個觀察者對象同時監聽某一個主題對象,這個主題對象的狀态發生變化時就會通知所有的觀察者對象,使得它們能夠自動更新自己。

在觀察者模式中有兩個主要角色:Subject(主題)和 Observer(觀察者)。

【Web技術】702- 如何優雅的實作消息通信?

在第二個場景中,Subject(主題)就是阿寶哥的 TS 專題文章,而觀察者就是小秦和小王。由于觀察者模式支援簡單的廣播通信,當消息更新時,會自動通知所有的觀察者。是以對于第二個場景,我們可以考慮使用觀察者設計模式來實作上述的功能。接下來,我們來繼續分析第三個場景。

2.3 釋出訂閱模式

在第三個場景中,為了讓小池和小郭能及時收到阿寶哥新釋出的 Deno 文章,阿寶哥給部落格增加了專題訂閱功能。即支援為阿寶哥部落格的訂閱者分别推送新釋出的 TS 或 Deno 文章。

【Web技術】702- 如何優雅的實作消息通信?

針對這個場景,我們可以考慮使用釋出訂閱模式來實作上述功能。在軟體架構中,釋出 — 訂閱是一種消息範式,消息的發送者(稱為釋出者)不會将消息直接發送給特定的接收者(稱為訂閱者)。而是将釋出的消息分為不同的類别,然後分别發送給不同的訂閱者。同樣的,訂閱者可以表達對一個或多個類别的興趣,隻接收感興趣的消息,無需了解哪些釋出者存在。

在釋出訂閱模式中有三個主要角色:Publisher(釋出者)、 Channels(通道)和 Subscriber(訂閱者)。

【Web技術】702- 如何優雅的實作消息通信?

在第三個場景中,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​

​ 方法把收到的消息顯示出來。

【Web技術】702- 如何優雅的實作消息通信?

以上示例對應的代碼如下:

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

【Web技術】702- 如何優雅的實作消息通信?

對于微核心的核心系統設計來說,它涉及三個關鍵技術:插件管理、插件連接配接和插件通信,這裡我們重點來分析一下插件通信。

插件通信是指插件間的通信。雖然設計的時候插件間是完全解耦的,但實際業務運作過程中,必然會出現某個業務流程需要多個插件協作,這就要求兩個插件間進行通信;由于插件之間沒有直接聯系,通信必須通過核心系統,是以核心系統需要提供插件通信機制。

這種情況和計算機類似,計算機的 CPU、硬碟、記憶體、網卡是獨立設計的配置,但計算機運作過程中,CPU 和記憶體、記憶體和硬碟肯定是有通信的,計算機通過主機闆上的總線提供了這些元件之間的通信功能。

【Web技術】702- 如何優雅的實作消息通信?

下面阿寶哥将以基于微核心架構設計的西瓜播放器為例,介紹它的内部是如何提供插件通信機制。在西瓜播放器内部,定義了一個 ​

​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 這個全局的事件派發器,通過它就可以輕松地實作插件間通信了。

【Web技術】702- 如何優雅的實作消息通信?

提到 ​

​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實戰之釋出訂閱模式"}      

以上示例對應的通信流程如下圖所示:

【Web技術】702- 如何優雅的實作消息通信?

到這裡釋出訂閱模式的應用場景,已經介紹完了。最後,阿寶哥來介紹一下如何使用 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釋出訂閱模式      

五、參考資源

  • 維基百科 - 釋出/訂閱
  • Ionic 3 - Events
  • implementing-redis-pub-sub-in-node-js-application

繼續閱讀