以前の記事では、サイトのリアルタイム人数はどのように実現されているのか?について詳しく紹介しましたが、その中で、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 から送信されたメッセージを受け取ることができます。
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.ts
をSharedWorker
実装として設定しました。これにより、Next.js で SharedWorker を使用できるようになります。
const worker = new SharedWorker("/worker.js"); // ![code --]
import worker from './worker.worker' // ![code ++]
SharedWorker Socket の実装#
現在、Worker 内で Socket のロジックを実装します。
おおよその流れを整理すると:
- 最初に Worker を作成する際(つまり最初のページが Worker を作成する際)、Socket 接続の設定を渡します。(Worker 内では環境変数やメインスレッドの変数を取得できないため)
- Socket インスタンスを作成し、Socket 接続の状態とメッセージイベントをリッスンし、MessageChannel を介してメインスレッド(ページ)にメッセージを送信します。
- 新しいページが開かれたとき、または Socket 接続が完了した後、Socket に関連する情報をメインスレッドに渡します。これらのデータはメインプロセスに保存され、他のコンポーネントで消費される必要があります。
- メインプロセス内で、Worker との通信を実装し、Socket インスタンスを操作します。例えば、
emit
メソッドなどです。
具体的なコード実装は以下の通りです:
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 の通信コードを書く必要があります。
まず、流れを見てみましょう:
具体的なコード実装は以下の通りです:
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です。