イントロダクション
P2P通信を利用したチャットアプリを作るきっかけ
私自身のReactNativeの勉強です!
今までネイティブアプリ開発からFlutterという経歴でReactNativeは全くやってこなかったんですが、業務でReactNative製のアプリを担当することになったので、もしかしたらそのアプリに、今回の内容が何か役立つかもな。というところからP2Pのチャットアプリを作ってみることにしました。
P2P通信とは何か?
今回はReactNativeでのP2Pのチャットアプリをメインにしているので、P2P通信とは?という点については以下を参照頂けると助かります。
実装する前に
まずは今回の構成です。ローカルネットワーク内のP2Pアプリ同士で通信するのであれば、IPアドレスやホスト名を解決するのはそこまで難しくありませんが、インターネット越しで通信する場合は、様々なネットワーク機器を越えて通信する必要があります。このため今回はsignaling serverで相手のネットワーク情報を特定し、stun serverでP2Pの通信をブリッジする構成にしました。
serverが思いっきり出てきているのでP2P感がかなりないですが、一旦気にせず、それぞれをざっくり説明すると以下の通りです。
-
stun server
デバイスが自身のパブリックIPアドレスとポート番号を特定するために使用されます。これにより、NATやファイアウォールを越えてP2P接続を確立することが可能になります。 -
signaling server
P2P通信を確立するために必要な接続情報をデバイス間で交換する役割を担います。具体的には、オファーとアンサーのメッセージを転送し、デバイスが互いに接続設定を知ることが可能です。
※ 参考:オファーアンサーモデルでSDPをやり取りする -
P2P
データをリアルタイムで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の環境を作ります。
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から削除します。
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');
以下のコマンドで起動します。
node index.js
P2Pアプリ
今回のアプリ
UIがシンプルなのでわかりづらいかもしれませんが、それぞれの端末でSend Messageした内容が表示されています。(画面中央の緑と青の文字が送受信したメッセージになります。)
使い方としては
- 一つ目のテキストボックスは自身の接続名です。好きな名前を入力しRegisterボタンをクリックして自身の接続名をsignaling serverに登録してください。
- 二つ目のテキストボックスは通信を受け入れる相手の接続名です。もう片方の端末で入力した一つ目のテキストボックスの接続名を入力し、Create Offerボタンをクリックして通信相手を受け入れてください。
- 通信が確立後、3つ目のテキストボックスにメッセージを入力してSEND MESSAGEボタンをクリックすることでテキストメッセージの交換を行うことができます。
シーケンス図で書くと以下のような形になります。
では、実装に進みます。
ReactNativeプロジェクト作成
ReactNativeの環境がセットアップできていない場合、以下を参考にセットアップしてください。
まずはPJ作成。
npx react-native@latest init p2p
一旦何も修正せずに起動するか確認しましょう。
cd ios
pod install
cd ..
npm start
npm run ios
次に必要なライブラリをインストールします。
npm install react-native-webrtc
実装
テキストメッセージを送るだけのサンプルですが、全部ソースを載せるとちょっと冗長になってしまうので、ここにはポイントだけ記載します。実際に動くものはGithubの方を見てください。また、今回はsignaling serverはlocalhost(iOSシミュレーターであれば、127.0.0.1、Androidエミュレーターであれば10.0.2.2)で通信していますが、signaling serverをHerokuやGGlitch等で構築することもできると思います。
offer, answer, candiate(WebSocketの接続部分)のハンドリングとメッセージの送信部分
- オファー(offer): 接続を開始する側が生成する接続情報。
- アンサー(answer): 接続を受け入れる側が生成する接続情報。
- ICE候補(ICE Candidates): ネットワーク経路を特定するための情報。
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に送るだけ。
const register = () => {
sendMessage({type: 'register'});
};
Registerボタンクリックでsignaling server側のログに以下の内容が出力され、登録されたことが分かります。
{ type: 'register', id: '123' }
オファーの作成と送信
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があるのが分かりますね。
{
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を体験することを目的としたので、それ以外の部分についてはかなり雑で、実際にサービスで利用する場合、参考のリンク先にもある通りWebRTC SFU(Selective Forwarding Unit)が必要になってきたりすると思いますし、Firestoreを使うことにもなるのかなと思います。
参考
以下参考にさせて頂きました。大変ありがとうございました!