29
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【即試せる!】Webでビデオ通話してみたくない?Nest.js × WebRTCで作ってみた

Posted at

はじめに

最近、「zoomみたいなビデオチャットアプリって、どうやって動いているんだろう?」ってふと思ったので、実際に作ってみました!

この記事ではベースはNest.jsとして、WebRTCを使った超簡易的なWebビデオチャットの作り方を、できるだけ丁寧に・分かりやすくご紹介していますので良かったら見てみてください!!

実際の動作イメージとソースコードはgithubにあげているので、もしよかったらご参照ください!!

↓↓↓ ★スターガホシイナ★


他にもいろいろな記事を投稿しているので、もしよかったら見てみてください!

何はともあれ、動かしてみよう!(5分でできます)

ローカルにおいて、以下手順で立ち上げていただければ、動くと思います!

①ソースコードをクローン

git clone https://github.com/rtkjm22/WebRTC_video_chat_app.git

②クローンしたディレクトリへ移動

cd webrtc-nest-videochat

③パッケージインストール

npm install

④プロジェクトの立ち上げ

npm run start

⑤ビデオチャットにアクセス

http://localhost:3000

⑥別ブラウザ(同じブラウザならシークレットモードも可)でもアクセス

http://localhost:3000

これで画面上では、2名分(自分自身と相手)のビデオチャットが始まっているかと思います!!

たぶん。。。

そもそも、WebRTCってなに?

WebRTC (Web Real-Time Communication、ウェブリアルタイムコミュニケーション) は、ウェブアプリケーションやウェブサイトにて、仲介を必要とせずにブラウザー間で直接、任意のデータの交換や、キャプチャした音声/映像ストリームの送受信を可能にする技術です。 WebRTC に関する一連の標準規格は、ユーザーがプラグインやサードパーティ製ソフトウェアをインストールすることなく、ピアーツーピアーにて、データ共有や遠隔会議を実現することを可能にします。
https://developer.mozilla.org/ja/docs/Web/API/WebRTC_API

なに言ってるかわからん。。。

簡単に言うと、WebRTC(Web Real-Time Communication)は、ブラウザ間で音声だったり、映像のやり取りをリアルタイムに行うための技術です。
サーバーを経由せず、クライアント同士で直接通信することができます(P2P通信)。

ただし、P2P通信を行うには「どう接続するか?」を決める必要があります。

ここで登場するのが STUNサーバー です。

STUNサーバーとは?

STUN(Session Traversal Utilities for NAT)サーバーは、自分自身のグローバルIPアドレスとポート番号を取得する役割を担っています。
これにより、プライベートなネットワーク環境からインターネットを経由して直接外部ネットワークに通信でき(いわゆる、NAT越え)、WebRTCで通信するための接続情報(ICE候補)を交換できるようになります。

わかりやすい記事があったため、ぜひこちらもご覧ください!
(とても参考になりました、感謝です!!)
↓↓↓

ソースコード解説

ここからの解説は以下リポジトリのソースコードを基に、重要な箇所をかいつまんで解説していきたいと思います!

サーバーサイドから見ていこう!

src/app.module.ts

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService, SignalingGateway], // ← WebSocketゲートウェイをプロバイダーとして登録!
})
export class AppModule {}

ここでは SignalingGatewayproviders に追加することで、WebSocket通信のエントリーポイントとして有効にしています。

WebSocket はリアルタイムで通信するためのプロトコルです。
複数人でGoogleスプレッドシートを使っている時に、相手の操作をリアルタイムで見ることができる「アレ」です。


src/signaling.gateway.ts

@WebSocketGateway({ cors: true })
export class SignalingGateway {
  @WebSocketServer()
  server: Server

  @SubscribeMessage('signal')
  handleSignal(@MessageBody() data: { from: string; to: string; signal: any }) {
    this.server.to(data.to).emit('signal', data)
  }

  @SubscribeMessage('join')
  handleJoin(@MessageBody() userId: string) {
    this.server.emit('user-joined', userId)
  }
}

SignalingGatewayクラスはWebRTC通信の心臓部となります。
クライアント同士が直接やり取りを始めるまでの司令塔のようなイメージです。

主にサブスクライブイベントとして、2つ定義しています。

signal イベント

@SubscribeMessage('signal')
handleSignal(@MessageBody() data: { from: string; to: string; signal: any }) {
  this.server.to(data.to).emit('signal', data)
}

WebRTCの通信情報(ICE)を相手に送る処理をしています。

とあるメッセージを、

  • 誰が(from)
  • 誰に(to)
  • どんな情報(signal)

として中継しているイメージです。

フロント側からはこんな感じでリクエストします。

socket.emit('signal', {
  from: 送り主のユーザーID,
  to: 送り先のユーザーID,
  signal: { sdp: '...' },
})

join イベント

@SubscribeMessage('join')
handleJoin(@MessageBody() userId: string) {
  this.server.emit('user-joined', userId)
}

こちらでは、新しいユーザーがビデオチャットルームに入ってきたとき、すでに参加しているユーザー全員にuser-joinedイベントを送ります。
これによって、クライアント側は「新しい人が来たな!」と察知し、WebRTC接続を開始する準備をするわけです。

以上がバックエンド処理の説明になります。

@WebSocketGateway は、Nest.js側が用意しているデコレーターになります。
詳しくは以下リンクをご参考ください。

フロントエンドのコードも見てみよう!

public/index.html

<video id="localVideo" autoplay muted></video>
<video id="remoteVideo" autoplay></video>
<script src="/socket.io/socket.io.js"></script>
<script src="client.js"></script>

クライアント側で実行されるHTMLファイルになります。
localVideoremoteVideが、それぞれビデオ画面になります。

今回は簡易版として、Socket.IOは事前にCDNで読み込むようにしました。


public/client.js

const socket = io('http://localhost:3000')
const localVideo = document.getElementById('localVideo')
const remoteVideo = document.getElementById('remoteVideo')
const constraints = { video: true, audio: true }
const peerConnections = {}
const configuration = {
  iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
}

navigator.mediaDevices
  .getUserMedia(constraints)
  .then((stream) => {
    localVideo.srcObject = stream

    socket.on('user-joined', (userId) => {
      if (!peerConnections[userId]) {
        const peerConnection = new RTCPeerConnection(configuration)
        peerConnections[userId] = peerConnection

        stream.getTracks().forEach((track) => peerConnection.addTrack(track, stream))

        peerConnection.onicecandidate = (event) => {
          if (event.candidate) {
            socket.emit('signal', {
              from: socket.id,
              to: userId,
              signal: { candidate: event.candidate },
            })
          }
        }

        peerConnection.ontrack = (event) => {
          remoteVideo.srcObject = event.streams[0]
        }

        peerConnection
          .createOffer()
          .then((offer) => peerConnection.setLocalDescription(offer))
          .then(() => {
            socket.emit('signal', {
              from: socket.id,
              to: userId,
              signal: { sdp: peerConnection.localDescription },
            })
          })
      }
    })

    socket.on('signal', ({ from, signal }) => {
      if (!peerConnections[from]) {
        const peerConnection = new RTCPeerConnection(configuration)
        peerConnections[from] = peerConnection

        stream.getTracks().forEach((track) => peerConnection.addTrack(track, stream))

        peerConnection.ontrack = (event) => {
          remoteVideo.srcObject = event.streams[0]
        }

        peerConnection.onicecandidate = (event) => {
          if (event.candidate) {
            socket.emit('signal', {
              from: socket.id,
              to: from,
              signal: { candidate: event.candidate },
            })
          }
        }
      }

      const peerConnection = peerConnections[from]

      if (signal.sdp) {
        peerConnection
          .setRemoteDescription(new RTCSessionDescription(signal.sdp))
          .then(() => {
            if (peerConnection.remoteDescription.type === 'offer') {
              peerConnection
                .createAnswer()
                .then((answer) => peerConnection.setLocalDescription(answer))
                .then(() => {
                  socket.emit('signal', {
                    from: socket.id,
                    to: from,
                    signal: { sdp: peerConnection.localDescription },
                  })
                })
            }
          })
      } else if (signal.candidate) {
        peerConnection.addIceCandidate(new RTCIceCandidate(signal.candidate))
      }
    })

    socket.emit('join', socket.id)
  })
  .catch((error) => {
    console.error('Error accessing media devices.', error)
  })

いよいよ、WebRTC通信の処理内容になります!

① もろもろの設定

const socket = io('http://localhost:3000')
const localVideo = document.getElementById('localVideo')
const remoteVideo = document.getElementById('remoteVideo')
const constraints = { video: true, audio: true }
const peerConnections = {}
const configuration = {
  iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
}

各種設定を行っています。
これらは、後ほど使用していきます。

  • socket : Socket.IOを使用してバックエンドと通信
  • localVideo, remoteVideo : ビデオ領域を取得
  • constraints : WebRTC通信で使用する情報として、映像と音声ストリームを設定
  • peerConnections : 通信相手ごとの接続情報を格納
  • configuration : STUNサーバーの設定

STUNサーバーについてはGoogle提供のものを使用しています。
ありがたや。。。

② 自分自身の映像・音声メディアをセット

navigator.mediaDevices
  .getUserMedia(constraints)
  .then((stream) => {
    localVideo.srcObject = stream

ブラウザ側で映像・音声ストリームにアクセスできるようにしています。
streamにはローカル(自分自身)の映像・音声情報が格納されているため、localVideoにいれることで画面上では自分自身が映るようになります。

(このあたりは調べ方が悪かったのか、情報が少なくて、まじで意味わからんかった。。)

③ 新規ユーザーが参加した時

socket.on('user-joined', (userId) => {

新しくユーザーがビデオチャットに参加すると、その通知が既に参加している他のユーザーのブラウザに届きます。

このとき、以降の④〜⑦の処理が実行され、新しく入ってきた相手と通信を始める準備が行われます。

④ WebRTC通信の初期化 & 通信前に映像・音声ストリームを準備

const peerConnection = new RTCPeerConnection(configuration)
peerConnections[userId] = peerConnection

stream
  .getTracks()
  .forEach((track) => peerConnection.addTrack(track, stream))

相手との通信用に、WebRTC通信の接続情報 RTCPeerConnection を初期化しています。
そのうえで、addTrack を使って、自分の映像・音声ストリームをWebRTC通信の接続情報に追加しています。

peerConnections は各ユーザー毎の通信情報がユーザーIDをキーとして格納されています。
そのため、参加人数分のWebRTC通信の接続情報 RTCPeerConnectionpeerConnections に入ります。

これにより、通信が確立されたときに、相手に自分の映像・音声を送る準備が整います。

addTrack はあくまで前準備にすぎないので、まだWebRTC通信は起きていません!!

⑤ 通信経路を見つけて、通信相手に映像・音声ストリームを送信

peerConnection.onicecandidate = (event) => {
  if (event.candidate) {
    socket.emit('signal', {
      from: socket.id,
      to: userId,
      signal: { candidate: event.candidate },
    })
  }
}

通信経路(ICE候補)を見つけたら、WebSocket通信を用いて、サーバー経由で相手に 自分自身の情報(ICE情報) を送信しています。

なお、onicecandidate は通信経路を見つけたら発火するイベントハンドラーとなります。

event.candidate には接続候補の生データが格納されており、WebRTC側でいい感じに最もつながりやすいルートを選んでくれるらしいです。

最適なルートを選ぶのは WebRTC ですが、実際に接続情報を届けるのは Socket.IO になります。

⑥ 通信相手の映像・音声ストリームを受け取って、画面に表示

peerConnection.ontrack = (event) => {
  remoteVideo.srcObject = event.streams[0]
}

④で紹介した addTrack により、通信相手が自身の映像・音声ストリームを RTCPeerConnection に登録すると、その映像・音声ストリームがネットワークを通じてこちらに届いたタイミングで、ontrack イベントが発火します。

これをビデオ領域 remoteVideo に描画することで、実際に相手の顔が自分自身のブラウザから見られるようになるのです!

⑦ 通話のはじまり!SDPオファーの作成と送信

peerConnection
  .createOffer()
  .then((offer) => peerConnection.setLocalDescription(offer))
  .then(() => {
    socket.emit('signal', {
      from: socket.id,
      to: userId,
      signal: { sdp: peerConnection.localDescription },
    })
  })

createOffer によって、相手に対して「通話を始めたいよ!」という旨のオファーを作成しています。
内部では、setLocalDescription で自分自身の状態として保存し、実際にサーバー経由でSDPを送信しています。

「ねぇねぇ、この条件(SDP)でビデオ通信しない?」と相手に送っているわけです。

WebRTCによってP2P通信を行うためには、相手といろいろな取り決めを行わなくてはいけません。
自分の映像・音声情報や通信ルール、タイムゾーンなどですね。
そこで、こちらからの条件をテキスト形式でまとめたものが SDP (Session Description Protocol) になります。

以下のようなフォーマットのテキストが送られます。
↓↓↓

v=0
o=- 123456789 2 IN IP4 127.0.0.1
s=-
t=0 0
m=audio 49170 RTP/AVP 0
c=IN IP4 192.0.2.1
a=rtpmap:0 PCMU/8000

⑧ 通信相手から送られてきたシグナルを受信

socket.on('signal', ({ from, signal }) => {

ここからのブロックでは、通信相手から送られてきたシグナル(SDPやICE候補など)を受け取って、オファーの返事をしたりします。

⑨ 対象ユーザーとの接続が確立していない場合は、④〜⑥を実行

if (!peerConnections[userId]) {
  const peerConnection = new RTCPeerConnection(configuration)
  peerConnections[from] = peerConnection

  stream
  .getTracks()
  .forEach((track) => peerConnection.addTrack(track, stream))

  peerConnection.ontrack = (event) => {
    remoteVideo.srcObject = event.streams[0]
  }

  peerConnection.onicecandidate = (event) => {
    if (event.candidate) {
      socket.emit('signal', {
        from: socket.id,
        to: from,
        signal: { candidate: event.candidate },
      })
    }
  }
}

何らかの原因で user-joined イベントが受信できなかった場合、つまり peerConnections に該当のユーザーIDに紐づく接続情報がなかった場合に実行されます。

上記④〜⑥の内容と全く同じになります。

  • ④WebRTC通信の初期化 & 通信前に映像・音声ストリームを準備
  • ⑤通信経路を見つけて、通信相手に映像・音声ストリームを送信
  • ⑥通信相手の映像・音声ストリームを受け取って、画面に表示

⑩ 相手からのオファーを受信したら、返事を送信

if (signal.sdp) {
  peerConnection
    .setRemoteDescription(new RTCSessionDescription(signal.sdp))
    .then(() => {
      if (peerConnection.remoteDescription.type === 'offer') {
        peerConnection
          .createAnswer()
          .then((answer) => peerConnection.setLocalDescription(answer))
          .then(() => {
            socket.emit('signal', {
              from: socket.id,
              to: from,
              signal: { sdp: peerConnection.localDescription },
            })
          })
      }
    })

⑦によって新規ユーザー向けに「通信しようよ!」という旨のメッセージが送信されていたかと思います。
こちらでは、その「いいよ!私はこんな条件で通信できるよ!」という旨の返事を作成して返送しています。

setRemoteDescription は相手からオファーされたSDP情報を基に、自分自身のWebRTC接続に適用する関数になります。
「いいよ!私はこんな条件で通信できるよ!」の「いいよ!」の部分になります。

あとは createAnswer によって、お返事を作成しています。
「いいよ!私はこんな条件で通信できるよ!」の「私はこんな条件で通信できるよ!」の部分になります。

peerConnection.setLocalDescription(answer) によって、自分自身のICE候補を格納して、あとは signal イベントに流しているイメージですね。

⑪ ICE候補を受信時、相手からの接続情報を登録

} else if (signal.candidate) {
  peerConnection
    .addIceCandidate(new RTCIceCandidate(signal.candidate))
    .catch((err) => {
      console.error('Error adding ICE candidate:', err)
    })
}

相手から送られてきたICE候補は、RTCPeerConnection に登録され、接続経路の候補となります。

接続経路の候補となります。

候補としているのは、まだこの段階では「この経路でつながるかも?」って状態なので、一旦登録だけしているためです。

⑫ 自分がビデオチャットに参加したことを宣言

socket.emit('join', socket.id)

自分自身がビデオチャットに参加した時に、サーバー側に「参加したよ!みんなに伝えて!」と宣言しています。


改めて通信の流れをざっくりと

説明が長くなってしまったので、改めて通信の流れをざっくりとまとめてみました。

  1. ⑫によって、新規ユーザーがビデオチャットに入ったことを宣言
    • 🆕 新規ユーザー:「参加したよ!みんなに伝えて〜!」
  2. サーバー側で、既存ユーザー全員に新規ユーザーが入ったことを通知
    • 🤖 サーバー:「👨‍💻 既存ユーザーさん、新しいユーザーが参加したよ!」
  3. ④〜⑦ によって、既存のユーザーが新規ユーザーに対して SDP(オファー)を送信
    • 👨‍💻 既存ユーザー:「よーし!私の接続条件(SDP)を用意して、新しい人に送るよ〜!」
  4. サーバーが、そのSDPオファーを新規ユーザーに中継
    • 🤖 サーバー:「🆕 新規ユーザーさん、オファー届いたよ〜!」
  5. ⑩ によって、新規ユーザーが SDP(アンサー)を作成して返答
    • 🆕 新規ユーザー:「わたしも接続条件OKだよ!アンサー送るね〜!」
  6. サーバー側で、アンサーを既存のユーザーに中継
    • 🤖 サーバー:「👨‍💻 既存ユーザーさん、お返事きたよ〜!」
  7. ⑪ によって、ICE候補が交換され、WebRTCの接続が確立!
    • 👨‍💻 既存ユーザー:「やった〜!接続できた!映像も音声も届いてるよ〜!」

まとめ

ふと「ビデオチャットってどうやって動いているんだろう?」と思い調べてみたところ、想像以上に奥深い世界が広がっていて、とても面白かったです。
実際に学んでみると、STUN/TURN、ICE、SDPといった初耳の概念が次々と出てきて、思った以上に低レイヤーな通信の仕組みが関わっていることに驚かされました。

今後、特に新規開発ではZoomライクなシステムを構築する際には、LiveKit や mediasoup などのライブラリを使うのが一般的で、VanillaJSで低レイヤーの処理を書く機会は多くないかもしれません。
ですが、WebRTCの仕組みを一から学ぶことで、裏側で何が起きているのかを理解でき、とても良い経験になりました!

あとは、

  • 大人数での通話対応
  • 映像や音声のON/OFF切り替え
  • チャット機能追加
  • 機械学習を使って背景を隠す

など、いろいろ拡張できそうでワクワクしますね!

もし実装する機会がありましたら、参考にしてもらえると嬉しいです!


最後まで読んでくださりありがとうございます。

もし記事が参考になったら、「いいね」してもらえるとすごく励みになります!
また、内容に誤りや気になる点があれば、遠慮なくご指摘していただけると嬉しいです!

他にもいろいろな記事を投稿しているので、もしよかったら見てみてください!

ではでは!

参考

WebRTCを理解するにあたって、以下の資料を参考にさせていただきました。
先人たちの知見に感謝です!

29
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
29
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?