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.oncelisteningerrorイベントを設定し、処理部分で「もう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.codeEADDRINUSEでした。これは、 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扱い。
    }
  });
}