React NativeでP2P通信を利用したチャットアプリを作る

# イントロダクション

## P2P通信を利用したチャットアプリを作るきっかけ
私自身のReactNativeの勉強です！
今までネイティブアプリ開発からFlutterという経歴でReactNativeは全くやってこなかったんですが、業務でReactNative製のアプリを担当することになったので、もしかしたらそのアプリに、今回の内容が何か役立つかもな。というところからP2Pのチャットアプリを作ってみることにしました。

## P2P通信とは何か？
今回はReactNativeでのP2Pのチャットアプリをメインにしているので、P2P通信とは？という点については以下を参照頂けると助かります。

https://udemy.benesse.co.jp/development/blockchain/p2p.html

https://jp.vcube.com/sdk/blog/webrtc-general-server-configuration-signaling-turn-sfu.html

# 実装する前に
まずは今回の構成です。ローカルネットワーク内のP2Pアプリ同士で通信するのであれば、IPアドレスやホスト名を解決するのはそこまで難しくありませんが、インターネット越しで通信する場合は、様々なネットワーク機器を越えて通信する必要があります。このため今回はsignaling serverで相手のネットワーク情報を特定し、stun serverでP2Pの通信をブリッジする構成にしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3774057/8ffa6b46-76f4-680d-30e4-39bc0fe7450c.png)

serverが思いっきり出てきているのでP2P感がかなりないですが、一旦気にせず、それぞれをざっくり説明すると以下の通りです。
1. **stun server**
デバイスが自身のパブリックIPアドレスとポート番号を特定するために使用されます。これにより、NATやファイアウォールを越えてP2P接続を確立することが可能になります。
2. **signaling server**
P2P通信を確立するために必要な接続情報をデバイス間で交換する役割を担います。具体的には、オファーとアンサーのメッセージを転送し、デバイスが互いに接続設定を知ることが可能です。
※ 参考：[オファーアンサーモデルでSDPをやり取りする](https://zenn.dev/yuki_uchida/books/c0946d19352af5/viewer/e26b4f#1.2.1-%E3%82%AA%E3%83%95%E3%82%A1%E3%83%BC%E3%82%A2%E3%83%B3%E3%82%B5%E3%83%BC%E3%83%A2%E3%83%87%E3%83%AB%E3%81%A7sdp%E3%82%92%E3%82%84%E3%82%8A%E5%8F%96%E3%82%8A%E3%81%99%E3%82%8B "オファーアンサーモデルでSDPをやり取りする")
3. **P2P**
データをリアルタイムでP2P通信します。

# 実装

とりあえず完成品を見たいんだぜ！という方は以下からどうぞ。

https://github.com/YojiroMotoba/react-native-p2p/

※今回stun serverはGoogleが公開しているサーバー（stun.l.google.com:19302）を使ってしまいます。
今回以外にも、以下のstun serverが公開されているようです。
stun1.l.google.com:19302
stun2.l.google.com:19302
stun3.l.google.com:19302
stun4.l.google.com:19302

## signaling server
まずはsignaling serverの環境を作ります。
```bash
mkdir signaling-server
cd signaling-server
npm init -y
npm install ws
```

前述の通り、signaling serverはP2Pアプリからの次の要求に応えることで、P2Pアプリ同士が通信を確立するために必要な接続情報をデバイス間で交換します。接続の初期段階でのみ使用され、データのやり取り自体には関与しません。また、今回はsignaling serverとP2PアプリはWebSocketを、端末同士の通信にはWebRTCを利用して通信しています。

- ネットワークへの接続要求(register)：P2Pアプリの接続情報をsignaling serverに登録します。
- 接続先デバイスの情報公開要求(offer, answer, candiate)：signaling serverに登録された接続情報から公開要求があったP2Pアプリのネットワーク情報を検索して返却します。
- ネットワークからの切断要求(close)：P2Pアプリの接続情報をsignaling serverから削除します。

```js
const WebSocket = require('ws');

const wss = new WebSocket.Server({port: 8080});

const clients = {};

wss.on('connection', ws => {
  ws.on('message', message => {
    const data = JSON.parse(message);
    console.log(data);
    if (data.type === 'register') {
      clients[data.id] = ws;
      ws.id = data.id;
    } else if (
      data.type === 'offer' ||
      data.type === 'answer' ||
      data.type === 'candidate'
    ) {
      const target = clients[data.target];
      if (target) {
        target.send(JSON.stringify(data));
      }
    }
  });

  ws.on('close', () => {
    console.log('close');
    console.log(ws);
    delete clients[ws.id];
  });
});

console.log('Signaling server is running on ws://localhost:8080');
```

以下のコマンドで起動します。
```bash
node index.js
```


## P2Pアプリ

### 今回のアプリ

UIがシンプルなのでわかりづらいかもしれませんが、それぞれの端末でSend Messageした内容が表示されています。（画面中央の緑と青の文字が送受信したメッセージになります。）
使い方としては
- 一つ目のテキストボックスは自身の接続名です。好きな名前を入力しRegisterボタンをクリックして自身の接続名をsignaling serverに登録してください。
- 二つ目のテキストボックスは通信を受け入れる相手の接続名です。もう片方の端末で入力した一つ目のテキストボックスの接続名を入力し、Create Offerボタンをクリックして通信相手を受け入れてください。
- 通信が確立後、３つ目のテキストボックスにメッセージを入力してSEND MESSAGEボタンをクリックすることでテキストメッセージの交換を行うことができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3774057/12c2dee3-ca56-cd56-a291-5200f7e66aa3.png)

シーケンス図で書くと以下のような形になります。
```mermaid
sequenceDiagram
    actor DeviceA
    actor DeviceB
    participant signaling server
    participant stun server

    DeviceA->>signaling server: DeviceAの登録要求

    alt Offer/Answer
    DeviceB->>signaling server: DeviceAへの接続オファー
    signaling server-->>DeviceA: DeviceBからの接続オファー

    DeviceA->>signaling server: DeviceBへの接続許可
    signaling server-->>DeviceB: DeviceAからの接続許可
    end

    alt 接続経路の確認
    DeviceA->>STUNServer: ネットワーク情報の確認
    STUNServer-->>DeviceA: 接続経路(Public IP and Port)
    DeviceB->>STUNServer: ネットワーク情報の確認
    STUNServer-->>DeviceB: 接続経路(Public IP and Port)
    end

    alt 接続経路候補の交換
    DeviceA->>signaling server: ICE Candidate(接続経路)
    signaling server-->>DeviceB: DeviceAの接続経路
    DeviceB->>signaling server: ICE Candidate(接続経路)
    signaling server-->>DeviceA: DeviceBの接続経路
    end

    alt メッセージの交換
    DeviceA->>DeviceB: Send Message
    DeviceB-->>DeviceA: Receive Message
    DeviceB->>DeviceA: Send Message
    DeviceA-->>DeviceB: Receive Message
    end
```

では、実装に進みます。

### ReactNativeプロジェクト作成
ReactNativeの環境がセットアップできていない場合、以下を参考にセットアップしてください。

https://reactnative.dev/docs/environment-setup

まずはPJ作成。
```bash
npx react-native@latest init p2p
```

一旦何も修正せずに起動するか確認しましょう。

```bash
cd ios
pod install
cd ..
npm start
npm run ios
```

次に必要なライブラリをインストールします。

```bash
npm install react-native-webrtc
```

### 実装

テキストメッセージを送るだけのサンプルですが、全部ソースを載せるとちょっと冗長になってしまうので、ここにはポイントだけ記載します。実際に動くものは[Github](https://github.com/YojiroMotoba/react-native-p2p/)の方を見てください。また、今回はsignaling serverはlocalhost（iOSシミュレーターであれば、127.0.0.1、Androidエミュレーターであれば10.0.2.2）で通信していますが、signaling serverを[Heroku](https://www.heroku.com/ "Heroku")やG[Glitch](https://glitch.com/ "Glitch")等で構築することもできると思います。

#### offer, answer, candiate(WebSocketの接続部分)のハンドリングとメッセージの送信部分
- オファー（offer）: 接続を開始する側が生成する接続情報。
- アンサー（answer）: 接続を受け入れる側が生成する接続情報。
- ICE候補（ICE Candidates）: ネットワーク経路を特定するための情報。
```tsx
const signalingServerUrl =
  Platform.OS === 'ios' ? 'ws://127.0.0.1:8080' : 'ws://10.0.2.2:8080';
const ws = new WebSocket(signalingServerUrl);
ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  switch (data.type) {
    case 'offer':
      handleOffer(data.offer);
      break;
    case 'answer':
      handleAnswer(data.answer);
      break;
    case 'candidate':
      handleCandidate(data.candidate);
      break;
  }
};

// オファーとアンサーのメッセージ送信部分。signaling serverへの登録等で利用する
const sendMessage = message => {
  ws.send(JSON.stringify({...message, id: myId}));
};
```

#### Registerボタンクリック時の処理(signaling serverに登録)
入力されたIDをsignaling serverに送るだけ。
```tsx
const register = () => {
  sendMessage({type: 'register'});
};
```
Registerボタンクリックでsignaling server側のログに以下の内容が出力され、登録されたことが分かります。
```json
{ type: 'register', id: '123' }
```

#### オファーの作成と送信
```tsx
const createOffer = async () => {
  setupPeerConnection();
  dataChannel.current = pc.current.createDataChannel('chat');
  setupDataChannel();
  const offer = await pc.current.createOffer();
  await pc.current.setLocalDescription(offer);
  sendMessage({type: 'offer', offer, target: targetId});
};

const setupPeerConnection = () => {
  pc.current = new RTCPeerConnection({
    iceServers: [
      {urls: 'stun:stun.l.google.com:19302'}, // stun server
    ],
  });

  pc.current.onicecandidate = event => {
    if (event.candidate) {
      sendMessage({
        type: 'candidate',
        candidate: event.candidate,
        target: targetId,
      });
    }
  };

  pc.current.ondatachannel = event => {
    dataChannel.current = event.channel;
    setupDataChannel();
  };

  pc.current.onconnectionstatechange = () => {
    setConnectionStatus(pc.current.connectionState);
  };
};

const setupDataChannel = () => {
  dataChannel.current.onmessage = event => {
    setChat(prevChat => [
      ...prevChat,
      {sender: 'remote', message: event.data},
    ]);
  };
  dataChannel.current.onopen = () => {
    setConnectionStatus('Connected');
  };
};
```
Create Offerボタンクリックでsignaling serverの方に以下のようなログが出ているかと思います。
（全量書くとすごい長くなってしまうので諸々省略しています）
ちゃんとoffer、candidate、answerがあるのが分かりますね。
```json
{
  type: 'offer',
  offer: {
    type: 'offer',
  },
  target: '123',
  id: ''
}
{
  type: 'candidate',
  candidate: {},
  target: '123',
  id: ''
}
{
  type: 'answer',
  answer: {
    type: 'answer'
  },
  target: '',
  id: ''
}
{
  type: 'candidate',
  candidate: {},
  target: '123',
  id: ''
}
```

これでメッセージがやりとりできます。セキュリティに関しては何も考慮してないので、signaling serverが分かってしまうと割と簡単に特定のチャットに参加できてしまうことが分かると思います。
　
# まとめ
アプリでチャットというと恐らくFirestoreでリアルタイムチャット！が多いかと思いますが、P2Pの方が構成がシンプルで実装もサクッとできてとてもいい感触でした。
ただ、今回はP2Pを体験することを目的としたので、それ以外の部分についてはかなり雑で、実際にサービスで利用する場合、[参考のリンク先](https://www.ois-yokohama.co.jp/szia/features/webrtc/ "参考のリンク先")にもある通りWebRTC SFU(Selective Forwarding Unit)が必要になってきたりすると思いますし、Firestoreを使うことにもなるのかなと思います。

# 参考

以下参考にさせて頂きました。大変ありがとうございました！

https://udemy.benesse.co.jp/development/blockchain/p2p.html

https://www.ois-yokohama.co.jp/szia/features/webrtc/

https://zenn.dev/yuki_uchida/books/c0946d19352af5

https://html5experts.jp/mganeko/20013/