TypeScript+ElectronでUDPポートで受信待ち受け
タイトルのまんまです。ただし、サンプルコードではなく実際的な話です。
Electronでsocketを使う時は、main
プロセスでNode.jsの機能を使うことになります。私の場合、UDPを扱いたかったので、 dgram を使います。
私が必要だった機能は、次のようなものです。
- 特定のIPアドレス(NIC)とポートの組み合わせに対して、受信待ち受けを開始・終了できること
- IPアドレスとポートの組み合わせが既に使われている可能性があるため、その場合は利用者にエラーを通知できること
- 複数のポートが一括で開けること(※実際には、今は使っていません。つまり1ポートだけ)
この場合、受信待ち受けを開始するには、1つの組み合わせに対する受信待ち受けをPromise
にして、それを束ねてPromise.all
で実行し、then/catchで結果に基づく処理を行えばよさそうです。
実際に作ってみて、2点ほど「うーん」と思ったので、そこを先に書いておきます。
bindのエラー、bind後のエラーの識別
Node.dgramのエラーは、全てerror
イベントで捕捉、処理するという形式です。
しかし、いつ発生するか予測できない通常のエラーと、明らかな要因(IPアドレスとポートの組が使われている、など)があるbind時のエラーは、性質も対処も異なります。
このため、bind時はsocket.once
でlistening
とerror
イベントを設定し、処理部分で「もう1つのイベント」を解除して、通常のエラー対応をsocket.on
で設定することにしました。
雰囲気なコードはこんな感じです。実際は数珠繋ぎではなくクラスを分けますが。
const bindPromise1 = new Promise((resolve,reject) => { const onListenOnce = () => { this.socket.removeListener("error",onErrorOnce); resolve(); }; const onErrorOnce = (error:any) => { this.socket.removeListener("listening",onListenOnce); reject(error); }; socket.once("listening",onListenOnce); socket.once("error",onErrorOnce); socket.bind(port,address); }); const connectPromise1 = bindPromise1.then(() => { this.socket.on("message", (msg: Buffer, rinfo: dgram.AddressInfo) => { // bind後の受信処理 }); this.socket.on("error", (error:any) => { // bind後のエラー処理 }); }); // (中略) // 複数の接続をまとめて行うため、Promise.allで束ねる const bindAllPromise = Promise.all([connectPromise1, (略)]); // 実際の接続 bindAllPromise.then(() => { // 成功時処理 }).catch((error:Any) => { // 失敗時処理 });
エラー情報の型
エラー時、catchには引数が来ます。
実際にIPアドレスとポートの組が使われている状況で接続してみたところ、Errorオブジェクトらしきものが来ましたが、Error.code
はEADDRINUSE
でした。これは、 Node.jsのErrorドキュメント ではSystemError
の「Common System Errors」区分です。同じページ後半の「Node.js Error Codes」ではありませんでした。
この辺、実際のエラー(かNode.jsのソースコード)を見ないと型も値も判断が難しいという印象です。
私は、接続まわりではエラーは(禁断の)Any
扱いとしておき、それを最終的に受ける側(今回は利用者への通知もあるのでIPCでrenderer側に引き渡しています)で、code:string
プロパティのある自前クラスに割り当てています(型定義でNodeのErrorクラスが利用できなかったので……)。
実際に書いたコード(一部割愛)
まずは受信待ち受け開始の処理。こんな感じです。
// 通信処理をcomManagerというインスタンスに配置。 // connectメソッド // 引数:接続情報オブジェクトcfg(ConnectConfigクラス) // 戻り値:1つ以上の受信待ち受け開始を集約的に試みるPromise comManager.connect(cfg).then(() => { // 成功時処理 }).catch((err:any) => { // 失敗時処理 });
comManagerのconnectメソッドは、こんな感じ。Promise.allの戻り値はPromise<void[]>です。
connect(cfg: ConnectConfig) : Promise<void[]> { // cfg引数のチェック(中身はアプリケーション次第なので割愛) if (!this.isLegitConfig(cfg)) { return Promise.reject(new Error(`適当なエラーメッセージ`)); } // 2つのUDPを開く場合の例。何個でも考え方は同様。 const connectSocket1 = this.udp1.connect(cfg); const connectSocket2 = this.udp2.connect(cfg); return Promise.all([connectSocket1 connectSocket2]); }
udp1, udp2のconnectメソッドは、こんな感じです。
connect(cfg: ConnectConfig) : Promise<void> { // 接続済みの場合、いったん接続解除して再接続(アプリケーション特有の要件) return this.disconnect() .then(() => Udp1.makeBindPromise(cfg)) .then(() => { this.socket.on("message", (msg: Buffer, rinfo: dgram.AddressInfo) => { // bind成功後の受信時処理 }); this.socket.on("error", (error:any) => { // bind成功後のエラー時処理 }); }); this.isConnected = true; }
この中で使われているメソッドもつけます。
disconnect() : Promise<void> { if (!this.isConnected) { return Promise.resolve(); } return Udp1.makeClosePromise(this.socket).then(() => { this.isConnected = false; }); } // 以下のメソッドは汎用性を感じたのでstaticにしましたが、別にインスタンスメソッドでもいいと思います。 static makeBindPromise(cfg: ConnectConfig) : Promise<void> { return new Promise((resolve,reject) => { const onListenOnce = () => { socket.removeListener("error",onErrorOnce); resolve(); }; const onErrorOnce = (error:any) => { socket.removeListener("listening",onListenOnce); reject(error); }; socket.once("listening",onListenOnce); socket.once("error",onErrorOnce); const port = cfg.port; const address = cfg.address; socket.bind(port,address); }); } static makeClosePromise(socket: dgram.Socket) : Promise<void> { return new Promise((resolve,reject) => { try { socket.close(); resolve(); } catch(ex) { resolve(); // closeはされてるはずなので、resolve扱い。 } }); }