strict-event-emitter-typesを使ったSocket.IOの厳密な型定義
こんにちは、WFSでサーバサイドを担当している藤田です。
最近、ゲームサーバの負荷検証ツールをつくってるのですが、そこで、マスタースレーブ構成を作るためにTypeScript + Socket.IOを使ってみたら、思っていたことが簡単にできて感動したので、紹介できればと思い、このエントリを書いてみました。
対象の読者
TypeScriptを使っていて、Socket.IOなどのEventEmitter APIをもったモジュールを扱う方
strict-event-emitter-types
TypeScriptでSocket.IOを使う以上、イベントに型をつけたいとは思っていたのですが、自力で試行錯誤していると案外上手にできませんでした。
それで探してみると、strict-event-emitter-typesというパッケージが実現してくれていました。
https://github.com/bterlson/strict-event-emitter-types
READMEに載っているExampleを見ていただきたいのですが、
- 型宣言だけで実装しているので実行時のオーバーヘッドが無い
- 1つのインターフェースで宣言したイベント定義で、on、emit、once、addEventListenerなどのEventEmitter APIが型付きで定義される
- 複数のイベントペイロードに型が定義できる
- 未定義イベントがエラーになる
という、いままでTypeScriptで難しかったことが実現されています。
特に、複数イベントペイロードの型定義は、TypeScript 3.0から導入されたTuples in rest parameters and spread expressionsによって実現できるようになったものです。
試しに、Intellij Ideaで先ほどのExampleを編集してみたスクリーンショットが以下になります。
見やすいように少し変更してあります。
このスクリーンショットでも、エラーになって欲しいところがエラーになっていることや、イベントペイロードの型推論が効いていることがわかります。
使用方法
簡単に使い方を説明します。
定義したいイベント
あるEventEmitterオブジェクトに以下の様なイベントを定義したいとします。
1 2 3 4 5 6 |
ee.emit('multiple_payloads', 1, "alice", true); ee.on('multiple_payloads', (count: number, name: string, flag: boolean) => {}); ee.emit('no_payload'); ee.on('no_payload', () => {}); ee.emit('one_payload', 1); ee.on('one_payload', (count: number) => {}); |
イベントレコードを定義する
まず、以下の様にイベントレコードと呼ばれるインターフェースを定義します。
1 2 3 4 5 |
interface EventRecords { multiple_payloads: (count: number, name: string, flag: boolean) => void; no_payload: void; // () => void; one_payload: number; // (count: number) => void; } |
イベント名とイベントペイロードのマップです。
基本は、イベント名と関数型のマップで、複数のペイロードの型を定義できます。
上記ではmultiple_payloadsが該当します。
もし、ペイロードが無いイベントであれば、no_payloadの様に、単にvoidと書くことができます。
また、もし1つのペイロードしかないのであれば、one_payloadの様に、そのペイロードの型を書くことができます。
型付きEventEmitterを定義する
1 2 3 4 5 6 7 8 9 |
import StrictEventEmitter from 'strict-event-emitter-types'; // 目的のEventEmitter import { EventEmitter } from 'events'; // 型付きのEventEmitterを定義 type MyEventEmitterType = StrictEventEmitter<EventEmitter, EventRecords>; const ee: MyEventEmitterType = new EventEmitter(); |
StrictEventEmitterのジェネリクスの1つ目に自分が使いたいEventEmitterオブジェクトの型を、2つ目に先ほど定義したイベントレコードのインターフェースを渡します。
そして、作成したEventEmitterオブジェクトを格納した変数を型付けします。
ここで、MyEventEmitterは単なる型定義ですので、
1 |
const ee = new MyEventEmitterType(); |
のような使い方はしません。
これで、最初に想定したmultiple_payloads、no_payload、one_payloadのイベントを型付きで実装することができます。
EventEmitterを継承したクラスを実装する
よくある、EventEmitterを継承したクラスを実装する場合も簡単にできます。
1 2 3 4 5 |
class MyEventEmitter extends (EventEmitter as { new(): MyEventEmitterType }) { doEmit() { this.emit('multiple_payloads', 1, "alice", true); } } |
このように書くことで、EventEmitterを継承したクラスでも、型付きのイベントを実装することができます。
非対称イベント
さらに、strict-event-emitter-typesでは、送るイベントと受け取るイベントを別々に定義できます。
つまり、Socket.IOのように、サーバからクライアントへ送りたいイベントと、クライアントからサーバへ送りたいイベントが別々に実装される場合に対応できます。
以下に実装例を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
import * as SocketIOClient from 'socket.io-client'; import * as SocketIO from 'socket.io'; import StrictEventEmitter from 'strict-event-emitter-types'; // 共通イベント interface CommonEvents { common1: (foo: number) => void; } // サーバからクライアントへ interface EventsFromServer extends CommonEvents { sameName: (name: string, str: string) => void; server1: () => void; } // クライアントからサーバへ interface EventsFromClient extends CommonEvents { sameName: (name: string, num: number) => void; client1: (name: string) => void; } // サーバサイド // クライアントと通信するソケットの定義 type ClientSocket = StrictEventEmitter<SocketIOClient.Socket, EventsFromClient, EventsFromServer>; const io = SocketIO(); io.on('connection', (clientSocket: ClientSocket /* クライアントと通信するソケット */) => { // OK! // 継承した共通イベント clientSocket.emit('common1', 1); // 個別イベント clientSocket.on('common1', (foo: number) => {}); clientSocket.emit('sameName', 'name', '1'); clientSocket.on('sameName', (name: string, num: number) => {}); clientSocket.emit('server1'); clientSocket.on('client1', (name: string) => {}); // NG... // ClientSocketから送るイベント定義とはペイロードの型が異なる clientSocket.emit('sameName', 'name', 1); // ClientSocketが受け取るイベント定義とは異なる clientSocket.on('sameName', (name: string, str: string) => {}); // ClientSocket側では定義していない clientSocket.on('server1', () => {}); // ClientSocket側では定義していない clientSocket.emit('client1', 'name'); }); // クライアントサイド // サーバと通信するソケットの定義 type ServerSocket = StrictEventEmitter<SocketIO.Socket, EventsFromServer, EventsFromClient>; // サーバと通信するソケット const serverSocket: ServerSocket = new SocketIOClient.Socket(); // OK! // 継承した共通イベント aserverSocket.emit('common1', 1); // 個別イベント serverSocket.on('common1', (foo: number) => {}); serverSocket.emit('sameName', 'name', 1); serverSocket.on('sameName', (name: string, str: string) => {}); serverSocket.on('server1', () => {}); serverSocket.emit('client1', 'name'); // NG... // ServerSocketから送るイベント定義とはペイロードの型が異なる serverSocket.emit('sameName', 'name', '1'); // ServerSocketが受け取るイベント定義とは異なる serverSocket.on('sameName', (name: string, num: number) => {}); // ServerSocket側では定義していない serverSocket.emit('server1'); // // ServerSocket側では定義していない serverSocket.on('client1', (name: string) => {}); |
上記のように、サーバ側のイベントとクライアント側のイベントを別々に定義し、サーバ/クライアントそれぞれのソケット形のジェネリクスに反対にして渡してあげることで、サーバ側ソケットとクライアント側ソケットが矛盾なくonとemitを対応づけられるようになります。
こちらも、一部のスクリーンショットを載せます。
制限
Socket.IOは、@types/socket.ioと@types/socket.io-clientのTypeScript向け型定義がありますが、その中で定義してあるconnectedやdisconnectedなどといった組み込みイベントの定義が無くなるため、再定義してあげる必要がありました。
まとめ
今回は、EventEmitterに型をつけるstrict-event-emitter-typesの紹介と、それをSocket.IOで使った簡単な例を示しました。
以前に Socket.IO をがっつり使っていた頃は、実行時になって判明する初歩的なtypoやペイロードミスをずいぶんやっていましたが、このパッケージによって、IDEやコンパイラのサポートを十分に受けて実装できるようになりそうです。
とりあえずは、そもそもの発端である負荷検証ツールの開発で活用し、形になりましたらまたここで紹介させていただけたらと思います。
また、strict-event-emitter-typesの中身は、TypeScript 2.8で導入されたConditional Typesや、TypeScript 3.0で導入されたTuples in rest parameters and spread expressionsを組み合わせた複雑な型定義の塊です。とても面白いので、TypeScriptの最新機能に興味がある方はのぞいてみればいかがでしょう。
では、よいTypeScriptライフを。