banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

利好 SharedWorker によるクロスページシングルトン WebSocket の実現

以前の記事では、サイトのリアルタイム人数はどのように実現されているのか?について詳しく紹介しましたが、その中で、SharedWorker を使用してページ間で Socket インスタンスを共有し、ページをまたいでシングルトン WebSocket を実現する方法についての謎を残しました。

動機#

この問題を探求する背景は、偶然に Zhihu で WebSocket に関する質問を見たことから始まりました。その中で、WebSocket 接続がサーバーリソースを過度に消費するという回答がありました。ページを何度も開くと、各ページが WebSocket 接続を確立し、確かにサーバーへの負荷が無意識に増加してしまいます。この問題については以前から考えていましたが、個人サイトでは大量のユーザーが同時にオンラインになることはないため、深く研究することはありませんでした。私たちは Socket.IO のソリューションを使用して WebSocket 接続を確立していますが、Socket.IO 自体は WebSocket のラッパーであるため、オーバーヘッドの観点からは Socket.IO はネイティブ WebSocket よりも少しオーバーヘッドが多くなります。Socket.IO 接続がサーバー側に与えるメモリオーバーヘッドについては、ドキュメントに言及されています:Memory Usage | Socket.IO

このパフォーマンスの最適化は痛くも痒くもなく、何の向上もありませんが、既然この問題があるのなら、今回はこの問題を解決してみようと思います。

解決策を見つける#

私たちが解決すべき問題は明確です。つまり、ブラウザが私たちのサイトのページを 2 つ以上開いたとき(複数のタブ)、同じ Socket インスタンスを再利用することです。

キーワードを検索することで、私たちは API であるSharedWorkerを知りました。SharedWorker は、複数のブラウジングコンテキスト(例えば、複数のウィンドウ、タブ、または iframe)間で共有される Worker です。SharedWorker にはグローバルスコープがあり、複数のブラウジングコンテキストで使用できるため、私たちの目標を実現できます。

私たちは、元々Socket インスタンスのコードを SharedWorker に置き、その後、ページ内で Worker の Socket インスタンスと通信すれば良いのです。以前の SocketClient は Socket を抽象化していたため、今回のリファクタリングではあまり多くのコードを変更する必要はなく、通信層を実装するだけで済みます。

SharedWorker の基本的な使用法#

まず、SharedWorker がどのように使用されるかを見てみましょう。

let i = 0

const ports = []

onconnect = (e) => {
  const port = e.ports[0];

  ports.push(port)

  port.addEventListener("message", (e) => {
    const workerResult = `Result: ${e.data[0] * e.data[1]}`;
    port.postMessage(workerResult);
  });
  port.start();
};
setInterval(() => {
  i++;
  ports.forEach(port => port.postMessage("Message from worker, time: " + i))
}, 1000);
const worker = new SharedWorker('/shared-worker.js')
worker.port.onmessage = (e) => {
  console.log('Message received from worker', e.data)
}
worker.port.start()

上記の例では、SharedWorker を作成し、ページ内で Worker と通信しています。Worker 内では、onconnectイベントをリッスンし、Worker が接続されると、ポートのメッセージイベントをリッスンし、ページ内でもポートのメッセージイベントをリッスンして、Worker からのメッセージをページ内でさらに処理します。

SharedWorker は Worker と同様に、メッセージは MessageChannel を介して送信されます。そして、worker.portは本質的に MessageChannel のラッパーです。

現在、この基本的な例を実行して、効果を見てみましょう。

https://codesandbox.io/p/sandbox/ecstatic-jerry-6xsgvs

2 つのタブを同時に開くと、両方のタブが Worker から送信されたメッセージを受け取ることができます。

image

chrome://inspect/#workersを通じて、Worker の情報を見ることができます。複数のタブを開くと、SharedWorker は複数のタブ間で共有されます。すべてのページが閉じられると、Worker は破棄されます。

Next.js で SharedWorker を使用する#

上記の例では、Worker の実装を public ディレクトリに置きましたが、この方法はエンジニアリングプロジェクトには適していません。なぜなら、エンジニアリングプロジェクトでは public ディレクトリ内のファイルを直接参照すべきではなく、import の方法で参照すべきだからです。次に、Worker の実装で外部ライブラリを参照する必要があるかもしれませんし、TypeScript で Worker の実装を書く必要があるかもしれません。これらの要件に対して、直接裸書きの js の方法では実現できません。

これに対処するために、Next.js の webpack 設定を少し変更して、Worker のインポートをサポートする必要があります。

まず、worker-loaderをインストールします:

npm i -D worker-loader

next.config.jsを変更します:

/** @type {import('next').NextConfig} */

let nextConfig = {
  webpack: (config, { webpack }) => {
    config.module.rules.unshift({
      test: /\.worker\.ts$/,
      loader: 'worker-loader',
      options: {
        publicPath: '/_next/',
        worker: {
          type: 'SharedWorker',
          // https://v4.webpack.js.org/loaders/worker-loader/#worker
          options: {
            name: 'ws-worker',
          },
        },
      },
    })

    return config
  },
}

export default nextConfig

ここでは、worker-loaderを使用し、ファイル拡張子.worker.tsSharedWorker実装として設定しました。これにより、Next.js で SharedWorker を使用できるようになります。

const worker = new SharedWorker("/worker.js"); // ![code --]
import worker from './worker.worker' // ![code ++]

SharedWorker Socket の実装#

現在、Worker 内で Socket のロジックを実装します。

おおよその流れを整理すると:

  1. 最初に Worker を作成する際(つまり最初のページが Worker を作成する際)、Socket 接続の設定を渡します。(Worker 内では環境変数やメインスレッドの変数を取得できないため)
  2. Socket インスタンスを作成し、Socket 接続の状態とメッセージイベントをリッスンし、MessageChannel を介してメインスレッド(ページ)にメッセージを送信します。
  3. 新しいページが開かれたとき、または Socket 接続が完了した後、Socket に関連する情報をメインスレッドに渡します。これらのデータはメインプロセスに保存され、他のコンポーネントで消費される必要があります。
  4. メインプロセス内で、Worker との通信を実装し、Socket インスタンスを操作します。例えば、emitメソッドなどです。
Mermaid Loading...

具体的なコード実装は以下の通りです:

Note

以下の例では、TypeScript を使用して Worker を作成し、Worker 内で外部ライブラリを import して直接使用できるようにしています。Worker Scope の型をより良くサポートするために、トップに/// <reference lib="webworker" />を追加して関連する型サポートを増やしています。

import { io } from 'socket.io-client'
import type { Socket } from 'socket.io-client'

/// <reference lib="webworker" />

let ws: Socket | null = null

function setupIo(config: { url: string }) {
  if (ws) return
  // socket.ioを使用
  console.log('Connecting to io, url: ', config.url)

  ws = io(config.url, {
    timeout: 10000,
    reconnectionDelay: 3000,
    autoConnect: false,
    reconnectionAttempts: 3,
    transports: ['websocket'],
  })
  if (!ws) return

  ws.on('disconnect', () => {
    boardcast({
      type: 'disconnect',
    })
  })

  /**
   * @param {any} payload
   */
  ws.on('message', (payload) => {
    console.log('ws', payload)

    boardcast({
      type: 'message',
      payload,
    })
  })

  ws.on('connect', () => {
    console.log('Connected to ws.io server from SharedWorker')

    if (waitingEmitQueue.length > 0) {
      waitingEmitQueue.forEach((payload) => {
        if (!ws) return
        ws.emit('message', payload)
      })
      waitingEmitQueue.length = 0
    }
    boardcast({
      type: 'connect',
      // @ts-expect-error
      payload: ws.id,
    })
  })

  ws.open()
  boardcast({
    type: 'sid',
    payload: ws.id,
  })
}

const ports = [] as MessagePort[]

self.addEventListener('connect', (ev: any) => {
  const event = ev as MessageEvent

  const port = event.ports[0]

  ports.push(port)

  port.onmessage = (event) => {
    const { type, payload } = event.data
    console.log('get message from main', event.data)

    switch (type) {
      case 'config':
        setupIo(payload)
        break
      case 'emit':
        if (ws) {
          if (ws.connected) ws.emit('message', payload)
          else waitingEmitQueue.push(payload)
        }
        break
      case 'reconnect':
        if (ws) ws.open()
        break
      case 'init':
        port.postMessage({ type: 'ping' })

        if (ws) {
          if (ws.connected) port.postMessage({ type: 'connect' })
          port.postMessage({ type: 'sid', payload: ws.id })
        }
        break
      default:
        console.log('Unknown message type:', type)
    }
  }

  port.start()
})

function boardcast(payload: any) {
  console.log('[ws] boardcast', payload)
  ports.forEach((port) => {
    port.postMessage(payload)
  })
}

const waitingEmitQueue: any[] = []

Worker が完成したので、次はメインスレッドと Worker の通信コードを書く必要があります。

まず、流れを見てみましょう:

Mermaid Loading...

具体的なコード実装は以下の通りです:

Important

以下の例では、constructor 内で非同期に SharedWorker を初期化するためにロードしているのは、サーバーサイドで Worker が存在しない場合にエラーを回避するためです。

interface WorkerSocket {
  sid: string
}

class SocketWorker {
  private socket: WorkerSocket | null = null

  worker: SharedWorker | null = null

  constructor() {
    if (isServerSide) return
    // @ts-expect-error
    import('./io.worker').then(({ default: SharedWorker }) => {
      if (isServerSide) return
      const worker = new SharedWorker()

      this.prepare(worker)
      this.worker = worker
    })
  }

  async getSid() {
    return this.socket?.sid
  }

  private setSid(sid: string) {
    this.socket = {
      ...this.socket,
      sid,
    }
  }
  bindMessageHandler = (worker: SharedWorker) => {
    worker.port.onmessage = (event: MessageEvent) => {
      const { data } = event
      const { type, payload } = data

      switch (type) {
        case 'ping': {
          worker?.port.postMessage({
            type: 'pong',
          })
          console.log('[ws worker] pong')
          break
        }
        case 'connect': {
          window.dispatchEvent(new SocketConnectedEvent())
          setSocketIsConnect(true)

          const sid = payload
          this.setSid(sid)
          break
        }
        case 'disconnect': {
          window.dispatchEvent(new SocketDisconnectedEvent())
          setSocketIsConnect(false)
          break
        }
        case 'sid': {
          const sid = payload
          this.setSid(sid)
          break
        }
        case 'message': {
          const typedPayload = payload as string | Record<'type' | 'data', any>
          if (typeof typedPayload !== 'string') {
            return this.handleEvent(
              typedPayload.type,
              camelcaseKeys(typedPayload.data),
            )
          }
          const { data, type } = JSON.parse(typedPayload) as {
            data: any
            type: EventTypes
          }
          this.handleEvent(type, camelcaseKeys(data))
        }
      }
    }
  }

  prepare(worker: SharedWorker) {
    const gatewayUrlWithoutTrailingSlash = GATEWAY_URL.replace(/\/$/, '')
    this.bindMessageHandler(worker)
    worker.port.postMessage({
      type: 'config',

      payload: {
        url: `${gatewayUrlWithoutTrailingSlash}/web`,
      },
    })

    worker.port.start()

    worker.port.postMessage({
      type: 'init',
    })
  }
  handleEvent(type: EventTypes, data: any) {
    // ビジネスイベントを処理
  }

  emit(event: SocketEmitEnum, payload: any) {
    this.worker?.port.postMessage({
      type: 'emit',
      payload: { type: event, payload },
    })
  }

  reconnect() {
    this.worker?.port.postMessage({
      type: 'reconnect',
    })
  }

  static shared = new SocketWorker()
}

export const socketWorker = SocketWorker.shared
export type TSocketClient = SocketWorker

これで大功告成です。SocketWorkerは基本的に元のSocketClientから抽象化されたもので、基本的に同じメソッドを実装しているため、ビジネスでの使用には大きな変化はなく、移行プロセスも非常にスムーズです。

そういえば、今回のリファクタリングはShiro/550abdにありますので、参考にしてください。

完成した実装は以下にあります:

https://github.com/Innei/Shiro/blob/c399372f7cc1bff55f842ff68342ffb0071b5ae6/src/socket/io.worker.ts

この記事はMix Spaceによって xLog に同期更新されました。元のリンクはhttps://innei.in/posts/tech/using-sharedworker-singleton-websocket-in-nextjsです。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。