浅入浅出WebRTC-视频通话

1,683 阅读7分钟

WebRTC 概述

(部分摘自百度百科)

WebRTC,名称源自网页即时通信(英语:Web Real-Time Communication)的缩写, 是一个支持网页浏览器进行实时语音对话或视频对话的API。 它于2011年6月1日开源并在Google、Mozilla、Opera支持下被纳入万维网联盟的W3C推荐标准。

2021年1月26日,万维网联盟(W3C)和互联网工程任务组(IETF)宣布, Web实时支持多种服务的通信(WebRTC)现在已成为官方标准, 可将音频和视频通信带到Web上的任何位置。 www.w3.org/TR/webrtc/

2010年5月,Google以6820万美元收购VoIP软件开发商Global IP Solutions的GIPS引擎, 并改为名为“WebRTC”。WebRTC使用GIPS引擎,实现了基于网页的视频会议,并支持722,PCM,ILBC,ISAC等编码, 同时使用谷歌自家的VP8视频解码器;同时支持RTP/SRTP传输等。

2012年1月,谷歌已经把这款软件集成到Chrome浏览器中。 同时FreeSWITCH项目宣称支持iSAC audio codec。

QQ语音技术也来自Global IP Solutions公司

WebRTC提供了视频会议的核心技术,包括音视频的采集、编解码、网络传输、显示等功能, 并且还支持跨平台:windows,linux,mac,android。

WebRTC 名字带Web但不仅作用于Web平台,其核心代码使用C++编写, Web平台只是WebRTC的一种实现,其核心代码仍然是WebRTC的C++版本

WebRTC架构图(转载于网络)

img.png

从架构图中我们看到,WebRTC帮我实现了音频引擎视频引擎网络传输引擎 ,封装了上层API,让开发者能够基于浏览器(Chrome\FireFox...)轻易快捷开发出丰富的实时多媒体应用, 而无需下载安装任何插件,也无需关注多媒体的数字信号处理过程,只需编写简单的**Javascript**程序即可实现

WebRTC 原理

浏览器点对点P2P的信道(peer-to-peer),信道可发送任何数据并无需经过服务器。

WebRTC的实现是建立客户端之间的直接连接而无需服务器中转的,即P2P。所以要求彼此知道对方外网地址, 而计算机大多位于NAT之后,只有少数主机拥有外网地址。这就需要一种可以穿透NAT技术,比如:STUN、TRUN。

WebRTC使用默认STUN服务器获取当前主机的外网地址和端口,Chrome默认的是Google域名下的一个STUN服务器。

NAT穿越(打洞)

在处于使用NAT设备的私有TCP/IP网络中的主机之间建立连接时需使用NAT穿越。 NAT的行为是非标准化的,穿越技术大多使用公共服务器,使全球任何地方都能访问得到IP地址, 在RTCPeerConnection 中实用ICE框架来保证RTCPeerConnection实现NAT穿越。

ICE 综合性NAT穿越技术

ICE(Interactive Connectivity Establishment)综合性NAT穿越技术

ICE框架整合各种NAT穿越技术如STUN、TURN(Traversal Using Relay NAT,中继NAT实现的穿透), ICE先使用STUN尝试建立一个基于UDP的连接,失败后使用TCP, 先尝试HTTP,失败后尝试HTTPS, 如果还是失败,ICE就会使用中继的TURN服务器。

在Web平台做WebRTC

创建WebRTC的流程

  1. ClientA 创建Offer
  2. ClientB 绑定Offer
  3. ClientB 创建Answer
  4. ClientA 绑定Answer,建立P2P连接

浏览器端代码实现

  • MediaStream 获取本地音视频流
  • RTCPeerConnection 用于建立P2P连接,传输音视频流等,可理解为RTC对象
  • RTCDataChannel 用于传输自定义数据。

1.ClientA 创建WebRTC对象和RTC数据通道,并绑定一些事件

名词解释
SDP:会话描述协议 Session Description Protocol

// 创建WebRTC对象
const pc = new RTCPeerConnection();

// 创建WebRTC数据通道,可以传输自定义数据
const dc = pc.createDataChannel("channel");

// 数据通道开启
dc.onopen = () => console.log("Data Channel Opened");

// 数据通道收到数据的事件
dc.onmessage = (e) => console.log("Get Message:", e.data);

// SDP变化的事件
pc.onicegatheringstatechange = () => {
   if (pc.iceGatheringState === "complete") {  // 在SPD创建完成的时候打印他
      console.log(JSON.stringify(pc.localDescription));
   }
}

2.ClientA 创建Offer

使用pc.createOffer();来创建Offer,并且调用setLocalDescription将Offer绑定在本地SDP

执行完成后可以看到在控制台中打印了你的Offer

复制这端Offer,我们可以来写ClientB的代码了

/**
 * 创建Offer
 * @return {Promise<void>}
 */
async function createOffer() {
   const offer = await pc.createOffer();
   await pc.setLocalDescription(offer);
}

3.ClientB 绑定Offer

先编写ClientB的代码

使用类似的代码来创建ClientB

由于ClientA已经有数据通道了,所以ClientB不需要自己创建

const pc = new RTCPeerConnection();

let dc = null;

// 同样的监听SDP变化的事件,在SPD创建完成的时候打印他
pc.onicegatheringstatechange = () => {
   if (pc.iceGatheringState === "complete") {
      console.log(JSON.stringify(pc.localDescription));
   }
}

// 监听数据通道的事件,并添加上事件,保存一下这条数据通道
pc.ondatachannel = (e) => {
   console.log("data channel");
   dc = e.channel;
   dc.onmessage = (e) => console.log("get message", e.data);
   dc.onopen = async (e) => console.log("channel opened");
}

接下来,我们来绑定之前创建的Offer

async function setOffer() {
   await pc.setRemoteDescription(JSON.parse(需要绑定的offer));
   console.log("Offer Set");
}

调用这个setOffer去设置之前创建的Offer

其中setRemoteDescription表示设置另一个客户端的SDP

4.ClientB 创建Answer

修改ClientB代码,使用createAnswer来创建一个Answer

并且用setLocalDescription设置到本地SDP

我们在设置完Offer后立刻创建Answer

经过上面的过程,可以在控制台中看到打印的本地Answer,复制这个Answer,我们去ClientA绑定它

async function setOffer() {
   await pc.setRemoteDescription(JSON.parse(需要绑定的offer));
   console.log("Offer Set");
   await createAnswer();
}

async function createAnswer() {
   const answer = await pc.createAnswer();
   await pc.setLocalDescription(answer);
}

5.ClientA 绑定Answer,建立P2P连接

回到ClientA,通过下面的代码来绑定我们刚才创建的Answer

async function setAnswer() {
   await pc.setRemoteDescription(JSON.parse(需要绑定的Answer));
   console.log("Answer Set");
}

执行完后我们看到在ClientA中控制台打印了数据通道开启提示

在ClientB中打印了收到数据通道和数据通道开启提示

6.使用RTCDataChannel发送自定义数据

已经开启了数据通道,我们可以直接使用数据通道发送任何数据

dc.send("我是ClientA,你是谁?");

以上数据传输不依赖任何服务器,这就是点对点传输P2P(peer-to-peer)

7.开启本地摄像头

在两个客户端的Html中都写入两个video,并用id取出备用


<style>
    .video-div div {
        width: 50%;
    }

    .video-div div video {
        width: 100%;
        background-color: #6D7587;
    }

    .video-div {
        display: flex;
        align-items: center;
        justify-content: center;
        width: 100%;
    }
</style>
<div class="video-div">
    <div>
        <label>Ta的视频</label>
        <video id="video" autoplay="autoplay"></video>
    </div>
    <div>
        <label>你的视频</label>
        <video id="myVideo" autoplay="autoplay"></video>
    </div>
</div>

<script>
    const video = document.getElementById("video");
    const myVideo = document.getElementById("myVideo");
</script>

在两个客户端中使用navigator.mediaDevices.getUserMedia开启摄像头,并将流推送给video标签

async function openVideo() {
   const stream = await navigator.mediaDevices.getUserMedia({
      video: true, audio: true
   });
   myVideo.srcObject = stream;
}

执行项目代码可以看到右边的视频里有你帅气的脸

8.将音视频流推送给WebRTC,并接收对方传来的音视频流

修改openVideo函数,加入推流给WebRTC的代码

async function openVideo() {
   const stream = await navigator.mediaDevices.getUserMedia({
      video: true, audio: true
   });
   myVideo.srcObject = stream;
   stream.getTracks().forEach((track) => {
      pc.addTrack(track, stream);
   });
}

在创建本地SPD之前完成开启摄像头和推流操作

利用异步变成更方便的控制调用顺序

async function init() {
   await openVideo();
   await createOffer();
}

init();

然后给RTCPeerConnection监听ontrack事件,表示有音视频轨道被推送进来

将音视频流推给另一个video标签

pc.ontrack = (e) => {
   console.log("track");
   video.srcObject = e.streams[0];
}

加上了这些操作,你已经可以在两个video标签上都看到自己帅气的脸了, 其中一个视频来自另一个一个客户端的推送

更好的WebRTC架构

1.将ClientA和ClientB统一为一个客户端软件

市面上所有的音视频,会议产品,恐怕不存在呼叫者和被呼叫者是需要区分两个APP的

编写一个网页,让他即具备呼叫者功能,又具备被呼叫者功能

2.完成屏幕共享

const stream = await navigator.mediaDevices.getDisplayMedia({
   video: {
      cursor: "always"
   },
   audio: {
      echoCancellation: true,
      noiseSuppression: true,
      sampleRate: 44100
   }
});

3.引入另一台服务器

使用一台服务器来转发SDP,SDP是会根据网络状态变化的,而且SPD非常长,用户交换SDP是非常不方便的
如果你有自己的用户系统,可以以唯一的用户ID来做身份标识,
用户只需要呼叫某个身份ID如QQ号,而SDP则使用信令服务器进行转发,同时承担一些其他内容,
如:转发被呼用户是否接听电话,转发用户挂断等

4.做个牛客网在线面试?

做个牛客网在线面试?

贴上代码

以下代码已整合双客户端

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <title>WebRTC视频通话</title>

    <style>
        html, body {
            width: 100%;
            height: 100%;
            margin: 0;
            padding: 0;
        }

        .video-div div {
            width: 50%;
        }

        .video-div div video {
            width: 100%;
            background-color: #6D7587;
        }

        .video-div {
            display: flex;
            align-items: center;
            justify-content: center;
            width: 100%;
        }

        .input-div {
            width: 100%;
            margin-top: 50px;
            display: flex;
            align-items: center;
            justify-content: center;
            flex-direction: row;
        }

        .input-div > div {
            flex-direction: column;
        }

        .input-div div {
            margin-top: 30px;
            width: 100%;
            display: flex;
            align-items: center;
            justify-content: center;
        }

        #SDP {
            width: 50%;
            height: 300px;
        }
    </style>
</head>
<body>

<div class="video-div">
    <div>
        <label>Ta的视频</label>
        <video id="video" autoplay="autoplay"></video>
    </div>
    <div>
        <label>你的视频</label>
        <video id="myVideo" autoplay="autoplay"></video>
    </div>
</div>

<div class="input-div">
    <div>
        <!--创建一个Offer-->
        <button onclick="createOffer()">创建Offer</button>

        <!--设置offer的区域-->
        <div>
            <label for="offerInput">输入offer:</label>
            <input type="text" id="offerInput"/>
            <button onclick="setOffer()">设置offer</button>
        </div>

        <!--设置answer的区域-->
        <div>
            <label for="answerInput">输入answer:</label><input type="text"
                                                             id="answerInput"/>
            <button onclick="setAnswer()">设置answer</button>
        </div>
    </div>

    <!--本地的SDP-->
    <div>
        <label for="SDP">你的SDP:</label>
        <textarea id="SDP"></textarea>
    </div>
</div>
<script>

    const SDP = document.getElementById("SDP");
    const answerInput = document.getElementById("answerInput");
    const offerInput = document.getElementById("offerInput");
    const video = document.getElementById("video");
    const myVideo = document.getElementById("myVideo");

    /**
     * 打开摄像头并且添加Track到RTCPeerConnection
     * @return {Promise<void>}
     */
    async function openVideo() {
        const stream = await navigator.mediaDevices.getUserMedia({
            video: true, audio: true
        });
        myVideo.srcObject = stream;
        stream.getTracks().forEach((track) => {
            pc.addTrack(track, stream);
        });
    }

    // 创建 RTCPeerConnection
    const pc = new RTCPeerConnection();

    // 监听ontrack,表示有视频流或音频流被推送过来
    pc.ontrack = (e) => {
        console.log("ontrack");
        video.srcObject = e.streams[0];
    }

    // 这个事件表示本地SDP有变化
    pc.onicegatheringstatechange = () => {
        if (pc.iceGatheringState === "complete") {  // 如果SDP创建完成了就给他显示出来
            SDP.value = JSON.stringify(pc.localDescription);
        }
    }

    // 创建自定义数据信道
    let dc = null;
    dc = pc.createDataChannel("channel");

    // onmessage表示有消息接收到
    dc.onmessage = (e) => console.log("Get Message:", e.data);

    // 表示自定义数据信道开启
    dc.onopen = () => console.log("Data Channel Opened");

    // 这个事件表示有一条自定义数据信道传入,即PeerA创建了一条自定义数据信道,PeerB不需要自己创建
    pc.ondatachannel = (e) => {
        console.log("Data Channel");
        dc = e.channel;

        // 同样的给他监听一些事件
        dc.onmessage = (e) => console.log("Get Message", e.data);
        dc.onopen = () => console.log("Channel Opened");
    }

    openVideo();    // 打开视频添加音视频轨道到 RTCPeerConnection

    /**
     * 设置Answer
     * @return {Promise<void>}
     */
    async function setAnswer() {
        await pc.setRemoteDescription(JSON.parse(answerInput.value));
        console.log("Answer Set");
    }

    /**
     * 设置Offer
     * @return {Promise<void>}
     */
    async function setOffer() {
        await pc.setRemoteDescription(JSON.parse(offerInput.value));
        console.log("Offer Set");
        await createAnswer();
    }

    /**
     * 创建Answer
     * @return {Promise<void>}
     */
    async function createAnswer() {
        const answer = await pc.createAnswer();
        await pc.setLocalDescription(answer);
    }

    /**
     * 创建Offer
     * @return {Promise<void>}
     */
    async function createOffer() {
        const offer = await pc.createOffer();
        await pc.setLocalDescription(offer);
    }

</script>
</body>
</html>