Node.js 設計模式筆記 —— 消息中間件及其應用模式(發佈訂閱)

主要有兩類技術可以用來整合分佈式應用:一類是通過共享存儲作爲一箇中心化的協調者,跟蹤和保存所有需要共享的信息;另一類則是通過消息中間件,向系統中的所有節點散佈數據、事件和命令等。
消息存在於軟件系統的各個層級。我們通過互聯網交換消息完成通信;通過管道發送消息給其他進程;設備驅動通過消息與硬件進行交互等等。任何用於在組件和系統之間交換信息的離散或結構化數據都可以視爲消息。

消息系統基礎

對於消息系統,有以下四個基本要素需要考慮:

  • 通訊的方向。可以是單向的,也可以是“請求 - 響應”模式
  • 通訊的目的。同時決定了消息本身的內容
  • 消息的時效性。可以同步或者異步地發送與接收
  • 消息的投遞方式。可以直接投遞也可以通過某個中間件

單向 vs “請求 - 應答”模式

單向模式:消息從源頭推送到目的地。常見的應用比如郵件系統、將工作任務分派給一系列工作節點的系統。


“請求 - 響應”模式:一方發出的消息總能夠與對方發出的消息匹配。比如 web 服務的調用、向數據庫請求數據等。

包含多個響應節點的“請求 - 響應”模式:

消息類型

消息內容主要取決於通信的目的。通常有以下三種:

  • 命令消息
  • 事件消息
  • 文檔消息

命令消息用來令接收者觸發某個動作或者任務。藉助它可以實現遠程過程調用(RPC)系統,分佈式計算等。RESTful HTTP 請求就是簡單的命令消息的例子。
事件消息用來通知另一個組件發生了某些情況。事件在分佈式系統中是一種很重要的整合機制,用來確保系統的各個組件保持同樣的步調。
文檔消息基本上就是在組件之間傳輸數據。比如數據庫請求的結果。

異步隊列和流

同步通信類似於打電話。電話的雙方必須同時在線,連接到同一個通道,實時地交流信息。當我們需要打給另一個人時,通常就得搞一部新的手機或者掛掉當前正在進行的通話,撥打新的號碼。
異步通信類似於發短信。我們發送短信的時刻,並不需要接收方已經接入了網絡。我們可以一條接一條地發送多條短信給不同的人,以任意順序接收對方的回覆(如果有的話)。

另一個異步通信的重要特性就是,消息可以被臨時存儲在某個地方,再在之後的某個時間送達。當接收方非常忙碌無法處理新的消息,或者我們需要確保投遞的成功率時,這個特性就非常有用了。
消息隊列就是這樣一種在生產者和消費者之間存儲消息的中間組件。若消費者因爲某種原因崩潰、斷開連接等,消息會在隊列中累積,待消費者重新上線時立即進行分發。

另外一種類似的數據結構是 log。log 是一種只能追加的結構,它是持久的,其消息可以在到達時被讀取,也可以通過訪問其歷史記錄來獲取。在消息系統中,也常被叫做 stream
不同於隊列,在 stream 中,消息被消費後不會被移除,意味着 stream 在消息的獲取方面有着更高的自由度。隊列通常一次只暴露一條消息給消費者,而一個 stream 能夠被多個消費者共享(甚至是同一份消息)。

消息隊列:


流:


點對點 vs 消息中間件

“發佈 - 訂閱” 模式

就是一種分佈式的觀察者模式。

一個最小化的實時聊天應用

package.json:

{
    "type": "module",
    "dependencies": {
        "amqplib": "^0.10.3",
        "ioredis": "^5.2.4",
        "JSONStream": "^1.3.5",
        "level": "^8.0.0",
        "leveldown": "^6.1.1",
        "levelup": "^5.1.1",
        "monotonic-timestamp": "^0.0.9",
        "serve-handler": "^6.1.5",
        "superagent": "^8.0.6",
        "ws": "^8.11.0",
        "yargs": "^17.6.2",
        "zeromq": "^6.0.0-beta.16"
    }
}

index.js:

import ws, { WebSocketServer } from 'ws'
import { createServer } from 'http'
import staticHandler from 'serve-handler'

const server = createServer((req, res) => {
    return staticHandler(req, res, { public: 'www' })
})

const wss = new WebSocketServer({ server })
wss.on('connection', client => {
    console.log('Client connected')
    client.on('message', msg => {
        console.log(`Message: ${msg}`)
        broadcast(`${msg}`)
    })
})

function broadcast(msg) {
    for (const client of wss.clients) {
        if (client.readyState == ws.OPEN) {
            client.send(msg)
        }
    }
}

server.listen(process.argv[2] || 8000)
  • 首先創建一個 HTTP 服務,將所有請求轉發給一個特別的 handler(staticHandler),該 handler 負責 serve 所有的靜態文件
  • 創建一個 WebSocket 服務實例,綁定到 HTTP 服務。同時監聽來自 WebSocket 客戶端的連接請求,以及客戶端發送的消息
  • 當某個客戶端發送的新消息到達時,通過 broadcast() 函數將消息廣播給所有的客戶端

www/index.html:

<!DOCTYPE html>
<html>
  <body>
    Messages:
    <div id="messages"></div>
    <form id="msgForm">
      <input type="text" placeholder="Send a message" id="msgBox"/>
      <input type="submit" value="Send"/>

    </form>
    <script>
      const ws = new WebSocket(
          `ws://${window.document.location.host}`
      )
      ws.onmessage = function (message) {
          const msgDiv = document.createElement('div')
          msgDiv.innerHTML = message.data
          document.getElementById('messages').appendChild(msgDiv)
      }
      const form = document.getElementById('msgForm')
      form.addEventListener('submit', (event) => {
          event.preventDefault()
          const message = document.getElementById('msgBox').value
          ws.send(message)
          document.getElementById('msgBox').value = ''
      })
    </script>
  </body>
</html>

通過 node index.js 8002 命令運行應用,打開兩個瀏覽器頁面訪問 Web 服務,測試聊天效果:

但我們的應用是無法進行橫向擴展的。比如再啓動一個新的服務實例 node index.js 8003,此時連接到 8002 的客戶端無法與連接到 8003 的客戶端通信。可以自行測試。

使用 Redis 作爲消息中間件

架構圖如下所示。每個服務實例都會把從客戶端收到的消息發佈到消息中間件,同時也會通過中間件訂閱從其他服務實例發佈的消息。

  • 通過客戶端網頁發送的消息傳遞給對應的 chat server
  • chat server 把收到的消息發佈到 Redis
  • Redis 將收到的消息分發給所有的訂閱方(chat server)
  • chat server 將收到的消息再分發給所有連接的客戶端

index-redis.js:

import ws, { WebSocketServer } from 'ws'
import { createServer } from 'http'
import staticHandler from 'serve-handler'
import Redis from 'ioredis'

const redisSub = new Redis()
const redisPub = new Redis()

const server = createServer((req, res) => {
    return staticHandler(req, res, { public: 'www' })
})

const wss = new WebSocketServer({ server })
wss.on('connection', client => {
    console.log('Client connected')
    client.on('message', msg => {
        console.log(`Message: ${msg}`)
        redisPub.publish('chat_message', `${msg}`)
    })
})

redisSub.subscribe('chat_message')

redisSub.on('message', (channel, msg) => {
    for (const client of wss.clients) {
        if (client.readyState === ws.OPEN) {
            client.send(msg)
        }
    }
})

server.listen(process.argv[2] || 8000)

運行 node index-redis.js 8002node index-redis.js 8003 兩條命令啓動兩個服務實例,此時連接到不同服務器的客戶端相互之間也能夠進行通信。

點對點 Pub/Sub 模式

通過 ZeroMQ 創建兩種類型的 socket:PUBSUB。PUB socket 綁定到本地機器的某個端口,負責監聽來自其他機器上 SUB socket 的訂閱請求。當一條消息通過 PUB socket 發送時,該消息會被廣播到所有連接的 SUB socket。

index-zeromq.js:

import { createServer } from 'http'
import staticHandler from 'serve-handler'
import ws, { WebSocketServer } from 'ws'
import yargs from 'yargs'
import zmq from 'zeromq'

const server = createServer((req, res) => {
    return staticHandler(req, res, { public: 'www' })
})

let pubSocket
async function initializeSockets() {
    pubSocket = new zmq.Publisher()
    await pubSocket.bind(`tcp://127.0.0.1:${yargs(process.argv).argv.pub}`)

    const subSocket = new zmq.Subscriber()
    const subPorts = [].concat(yargs(process.argv).argv.sub)
    for (const port of subPorts) {
        console.log(`Subscribing to ${port}`)
        subSocket.connect(`tcp://127.0.0.1:${port}`)
    }

    subSocket.subscribe('chat')

    for await (const [msg] of subSocket) {
        console.log(`Message from another server: ${msg}`)
        broadcast(msg.toString().split(' ')[1])
    }
}

initializeSockets()

const wss = new WebSocketServer({ server })
wss.on('connection', client => {
    console.log('Client connected')
    client.on('message', msg => {
        console.log(`Message: ${msg}`)
        broadcast(`${msg}`)
        pubSocket.send(`chat ${msg}`)
    })
})

function broadcast(msg) {
    for (const client of wss.clients) {
        if (client.readyState === ws.OPEN) {
            client.send(msg)
        }
    }
}

server.listen(yargs(process.argv).argv.http || 8000)
  • 通過 yargs 模塊解析命令行參數
  • 通過 initializeSocket() 函數創建 Publisher,並綁定到由 --pub 命令行參數提供的端口上
  • 創建 Subscriber socket 並將其連接到其他應用實例的 Publisher socket。被連接的 Publisher 端口由 --sub 命令行參數提供。之後創建以 chat 爲過濾器的訂閱,即只接收以 chat 開頭的消息
  • 通過 for 循環監聽到達 Subscriber 的消息,去除消息中的 chat 前綴,通過 broadcast() 函數將處理後的消息廣播給所有連接的客戶端
  • 當有消息到達當前實例的 WebSocket 服務時,廣播此消息到所有客戶端,同時通過 Publisher 發佈該消息

運行服務測試效果:

node index-zeromq.js --http 8002 --pub 5000 --sub 5001 --sub 5002
node index-zeromq.js --http 8003 --pub 5001 --sub 5000 --sub 5002
node index-zeromq.js --http 8004 --pub 5002 --sub 5000 --sub 5001

通過隊列實現可靠的消息投遞

消息隊列是消息系統中的一種重要抽象。藉助消息隊列,通信中的發送方和接收方不必同時處於活躍的連接狀態。隊列系統會負責存儲未投遞的消息,直到目標處於能夠接收的狀態。

消息系統的投遞機制可以簡單概況爲以下 3 類:

  • 最多一次:fire-and-forget。消息不會被持久化,投遞狀態也不會被確認。意味着在接收者崩潰或者斷開連接時,消息有可能丟失
  • 最少一次:消息會確保至少被收到一次。但是重複收取同一條消息的情況有可能出現,比如接收者在收到消息後突然崩潰,沒有來得及告知發送者消息已經收到。
  • 只有一次:這是最可靠的投遞機制,保證消息只會被接收一次。但由於需要更復雜的確認機制,會犧牲一部分消息投遞的效率。

當消息投遞機制可以實現“最少一次”或者“只有一次”時,我們就有了 durable subscriber

AMQP

AMQP 是一個被很多消息系統支持的開放標準協議。除了定義一個通用的傳輸協議以外,他還提供了用於描述 routing、filtering、queuing、reliability 和 security 的模型。

  • Queue:用於存儲消息的數據結構。假如多個消費者綁定了同一個隊列,消息在它們之間是負載均衡的。隊列可以是以下任意一種類型:
    • Durable:當中間件重啓時隊列會自動重建。但這並不意味着其內容也會被保留。實際上只有標記爲持久化消息的內容纔會被保存到磁盤,並在重啓時恢復
    • Exclusive:隊列只綁定給唯一一個特定的訂閱者,當連接關閉時,隊列即被銷燬
    • Auto-delete:當最後一個訂閱者斷開連接時,隊列被刪除
  • Exchange:消息發佈的地方。Exchange 會將消息路由至一個或者多個 queue。路由規則取決於具體的實現:
    • Direct exchange:通過完整匹配一個 routing key 來對消息進行路由(如 chat.msg
    • Topic exchange:對 routing key 進行模糊匹配(如 chat.# 匹配所有以 chat 開頭的 key)
    • Fanout exchange:將消息廣播至所有連接的 queue,忽略提供的任何 routing key
  • Binding:Exchange 和 queue 之間的鏈接,定義了用於過濾消息的 routing key 或模式

上述所有組件由中間件進行維護,同時對外暴露用於創建和維護的 API。當連接到某個中間件時,客戶端會創建一個 channel 對象負責維護通信的狀態。

AMQP 和 RabbitMQ 實現 durable subscriber

chat 應用和消息歷史記錄服務的架構圖:

AMQP 和數據庫實現 history service

此模塊由兩部分組成:一個 HTTP 服務負責將聊天曆史記錄暴露給客戶端;一個 AMQP 消費者負責獲取聊天消息並將它們保存在本地數據庫中。

historySvc.js:

import { createServer } from 'http'
import levelup from 'levelup'
import leveldown from 'leveldown'
import timestamp from 'monotonic-timestamp'
import JSONStream from 'JSONStream'
import amqp from 'amqplib'


async function main() {
    const db = levelup(leveldown('./msgHistory'))

    const connection = await amqp.connect('amqp://localhost')
    const channel = await connection.createChannel()
    await channel.assertExchange('chat', 'fanout')
    const { queue } = channel.assertQueue('chat_history')
    await channel.bindQueue(queue, 'chat')
    channel.consume(queue, async msg => {
        const content = msg.content.toString()
        console.log(`Saving message: ${content}`)
        await db.put(timestamp(), content)
        channel.ack(msg)
    })

    createServer((req, res) => {
        res.writeHead(200)
        db.createValueStream()
            .pipe(JSONStream.stringify())
            .pipe(res)
    }).listen(8090)
}

main().catch(err => console.error(err))
  • 創建一個到 AMQP 中間件的連接
  • 設置一個名爲 chat 的 fanout 模式的 exchange。assertExchange() 函數會確保相應的 exchange 存在,否則就創建
  • 創建一個名爲 chat_history 的 queue,綁定給上一步中創建的 exchange
  • 開始監聽來自 queue 的消息,將收到的每一條消息保存至 LevelDB 數據庫,以時間戳作爲鍵。消息保存成功後由 channel.ack(msg) 進行確認。若確認動作未被中間件收到,則該條消息會保留在隊列中再次被處理

index-amqp.js

import { createServer } from 'http'
import staticHandler from 'serve-handler'
import ws, { WebSocketServer } from 'ws'
import amqp from 'amqplib'
import JSONStream from 'JSONStream'
import superagent from 'superagent'

const httpPort = process.argv[2] || 8000

async function main() {
    const connection = await amqp.connect('amqp://localhost')
    const channel = await connection.createChannel()
    await channel.assertExchange('chat', 'fanout')
    const { queue } = await channel.assertQueue(
        `chat_srv_${httpPort}`,
        { exclusive: true })
    await channel.bindQueue(queue, 'chat')
    channel.consume(queue, msg => {
        msg = msg.content.toString()
        console.log(`From queue: ${msg}`)
        broadcast(msg)
    }, { noAck: true })

    const server = createServer((req, res) => {
        return staticHandler(req, res, { public: 'www' })
    })

    const wss = new WebSocketServer({ server })
    wss.on('connection', client => {
        console.log('Client connected')

        client.on('message', msg => {
            console.log(`Message: ${msg}`)
            channel.publish('chat', '', Buffer.from(msg))
        })

        superagent
            .get('http://localhost:8090')
            .on('error', err => console.log(err))
            .pipe(JSONStream.parse('*'))
            .on('data', msg => {
                client.send(Buffer(msg).toString())
            })
    })

    function broadcast(msg) {
        for (const client of wss.clients) {
            if (client.readyState === ws.OPEN) {
                client.send(msg)
            }
        }
    }
    server.listen(httpPort)
}

main().catch(err => console.log(err))
  • 我們的聊天服務沒必要是 durable subscriber,fire-and-forget 機制就足夠了,因而有 { exclusive: true } 選項
  • 確認機制也是不需要的。{ noAck: true }
  • 發佈消息也很簡單,只需要指定目標 exchange(chat)和一個 routing key 即可,這裏我們使用的是 fanout exchange,不需要路由,routing key 爲空
  • 發佈到 exchange 的消息被轉發到所有綁定的 queue,再到達所有訂閱了 queue 的服務實例,每個實例再將消息發送到所有連接的客戶端
  • 通過 superagent 請求 history 微服務,將獲取到的所有歷史消息發送給剛連接的客戶端

運行服務測試效果:

node index-amqp.js 8002
node index-amqp.js 8003
node historySvc.js

通過 streams 實現可靠的消息投遞

在系統集成的範疇裏,stream(或 log)是一種有序的、只能追加的持久化的數據結構。Stream 概念裏的 message 更應該叫做 record,總是被添加到 stream 末尾,且不會在被消費之後自動刪除(不同於 queue)。這種特性令 stream 更像是一種數據倉庫而不是消息中間件。
Stream 的另一個重要特性在於,record 是被消費者從 stream 中“拉取”的,因而消費者可以按照自己的節奏處理 record。
Stream 可以用來實現可靠的消息投遞,一旦消費者崩潰,它可以在恢復後從中斷的地方繼續拉取消息。

Streams vs 消息隊列

Stream 明顯的應用場景在於處理順序的流數據,也支持批量處理或者根據之前的消息確定相關性,並可以跨多個節點分發數據。
Stream 和消息隊列都可以實現 Pub/Sub 模式,但消息隊列更適合複雜的系統集成任務,它可以提供更復雜的路由機制,允許我們爲不同的消息提供不同的優先級,而 Stream 中 record 的順序是一定的。

通過 Redis Streams 實現 chat 應用

index-stream.js:

import { createServer } from 'http'
import staticHandler from 'serve-handler'
import ws, { WebSocketServer } from 'ws'
import Redis from 'ioredis'


const redisClient = new Redis()
const redisClientXRead = new Redis()

const server = createServer((req, res) => {
    return staticHandler(req, res, { public: 'www' })
})

const wss = new WebSocketServer({ server })
wss.on('connection', async client => {
    console.log('Client connected')
    client.on('message', msg => {
        console.log(`Message: ${msg}`)
        redisClient.xadd('chat_stream', '*', 'message', msg)
    })

    const logs = await redisClient.xrange(
        'chat_stream', '-', '+')
    for (const [, [, message]] of logs) {
        client.send(message)
    }
})

function broadcast(msg) {
    for (const client of wss.clients) {
        if (client.readyState === ws.OPEN) {
            client.send(msg)
        }
    }
}


let lastRecordId = '$'

async function processStreamMessages() {
    while (true) {
        const [[, records]] = await redisClientXRead.xread(
            'BLOCK', '0', 'STREAMS', 'chat_stream', lastRecordId)
        for (const [recordId, [, message]] of records) {
            console.log(`Message from stream: ${message}`)
            broadcast(message)
            lastRecordId = recordId
        }
    }
}

processStreamMessages().catch(err => console.error(err))

server.listen(process.argv[2] || 8080)
  • xadd 負責在收到來自客戶端的消息時,向 stream 添加一條新的 record。它接收 3 個參數:
    • Stream 的名字,這裏是 chat_stream
    • record 的 ID。這裏傳入的是星號(*),令 Redis 爲我們生成一個 ID。ID 必須是單調遞增的,以保持 record 的順序,而 Redis 可以替我們處理這些
    • key-value 的列表。這裏只提供 value msg(從客戶端收到的消息)的 'message' key
  • 使用 xrange 檢索 stream 的過往記錄,以獲取聊天曆史。我們在每次有客戶端連接時就進行一次檢索。其中 - 表示最小的 ID 值,+ 表示最大的 ID 值,因而整個 xrange 會獲取當前 stream 中所有的消息
  • 最後一部分的邏輯是等待新的記錄被添加到 stream 中,從而每個應用實例都能讀取到更新的消息。這裏使用一個無線循環和 xread 命令:
    • 其中 BLOCK 表示在新消息到達前阻塞
    • 0 用來指定超時時間,超過這個時間則直接返回 null0 代表不超時
    • STREAMS 是一個關鍵字,告訴 Redis 我們接下來會指定想要讀取的 stream 的細節
    • chat_stream 是 stream 的名字
    • 最後我們提供 record ID(lastRecordId)作爲讀取新消息的節點。初始情況下是 $,表示當前 stream 中最大的 ID。當我們讀取第一條消息後,更新 lastRecordId 爲最近讀取到的消息的 ID

此外,解包消息的代碼 for (const [, [, message]] of logs) {...} 實際上等同於 for (const [recordId, [propertyId, message]] of logs) {...},由 xrange 命令查詢到的消息的格式如下:

[
  ["1588590110918-0", ["message", "This is a message"]],
  ["1588590130852-0", ["message", "This is another message"]]
]

參考資料

Node.js Design Patterns: Design and implement production-grade Node.js applications using proven patterns and techniques, 3rd Edition

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章