Skip to content

WebRTC WebRTC的学习-AI整理版 ​

基于 WebRTC的学习 重新整理。

目标不是“把原文再说一遍”,而是把内容改成更适合学习、复习、回看的笔记结构。

这份笔记怎么读 ​

建议先看懂原理再动手写例子,按下面顺序阅读:

  1. WebRTC 是什么、能做什么
  2. WebRTC 的整体架构
  3. 通信原理:媒体协商(SDP)+ 网络协商(Candidate)+ 信令服务器
  4. 安装 coturn(STUN/TURN)穿透和转发服务器
  5. 跑通一对一通话案例(WebRTC + SpringBoot + WebSocket)
  6. 打包 App、公网部署

学习路线图 ​

阶段重点说明
入门概念与架构知道 WebRTC 能做什么、分几层
原理NAT、STUN、TURN、ICE理解点对点连接是怎么建立的
原理SDP 与信令服务器理解媒体信息和网络信息如何交换
环境coturn搭建 STUN/TURN 服务器并测试
实战一对一通话WebRTC + SpringBoot + WebSocket 完整案例
进阶公网部署有公网服务器后再实践

一、WebRTC 是什么 ​

WebRTC(Web Real-Time Communications)是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输,使用户在无需安装任何插件或第三方软件的情况下,创建点对点的数据分享和电话会议成为可能。

简单来说,就是可以实现例如微信电话的技术,在浏览器里就可以实现视频通话。

WebRTC 有许多不同的用例,从使用摄像头或麦克风的基本 Web 应用程序到更高级的视频通话应用程序和屏幕共享。它提供了视频会议的核心技术,包括音视频的采集、编解码、网络传输、展示等功能,并且支持跨平台(Linux、Windows、Mac、Android 等)。WebRTC 中文网内还有很多小示例。


二、WebRTC 的架构 ​

WebRTC架构图

组件说明
Your Web AppWeb 开发者基于浏览器提供的 Web API 开发的实时通信应用
Web API面向第三方开发者的 WebRTC 标准 API(JavaScript)
WebRTC Native C++ API本地 C++ API 层,使浏览器厂商容易实现标准的 Web API,抽象地对数字信号过程进行处理
Transport / Session传输/会话层,采用 libjingle 库的部分组件实现,无须使用 xmpp/jingle 协议
VoiceEngine音频引擎,一系列音频多媒体处理框架,是 Google 收购 GIPS 公司后开源的,VoIP 技术业界领先
VideoEngine视频处理引擎,从摄像头采集到网络传输再到视频显示的完整解决方案

引擎中的两个重点:

  • Opus(音频编码):支持 6 kbit/s 到 510 kbit/s 的恒定和可变比特率编码,帧大小 2.5 ms 到 60 ms,采样率 8 kHz 到 48 kHz(20 kHz 带宽,可覆盖人类听觉系统的整个听力范围),由 IETF RFC 6176 定义
  • NetEQ 模块:语音引擎的核心模块,一种动态抖动缓冲和错误隐藏算法,用于隐藏网络抖动和数据包丢失的负面影响,尽可能保持低延迟和最高语音质量
  • VP8(视频编码):WebRTC 视频引擎默认的编解码器,针对低延时设计,适合实时通信场景

三、WebRTC 的通信原理 ​

两个不同网络环境的(具备摄像头/麦克风的)浏览器要实现点对点的实时音视频对话,难点有三个:

  1. 了解对方的媒体格式、支持的最大分辨率和其他媒体信息?
  2. 了解彼此的网络,才有可能找到一条通信链路?
  3. 还没建立连接时,如何交换“媒体信息”和“网络信息”?

对应两组协商和一个交换通道:媒体协商(sdp)、网络协商(candidate)、信令服务器。

1. 媒体协商(SDP) ​

WebRTC媒体协商

彼此要了解对方支持的媒体格式,最简单的方法就是取它们的交集(如 H264)。有一种特殊的协议叫做 Session Description Protocol(SDP),可以用来描述这些信息。

媒体协商:参与视频通信的双方必须首先交换 SDP 信息,这样双方才能知根知底,交换 SDP 的过程就称为“媒体协商”。

2. 网络协商(Candidate) ​

理想的网络情况是每台电脑都有私有公网 IP,可以直接点对点连接。

理想网络情况

实际情况是电脑之间或大或小都在某个局域网中,需要 NAT(Network Address Translation,网络地址转换)。同一局域网内可以直接用内网网段,但一般都是在不同局域网中。

实际网络情况

在中国的网络环境下,据统计至少有一半的网络不能直接连接。根本原因:互联网发展早期绝大多数 IPv4 地址资源被国外占据,轮到中国等发展中国家时,大多数计算机没有公网 IP,只能通过路由器和交换机做 NAT 转换,且相当一部分 NAT 是对称的,基本无法直接打通。

NAT网关转换

所以要解决上述问题,需要用到 STUN 和 TURN。

2.1 STUN ​

STUN(Session Traversal Utilities for NAT,NAT 会话穿越应用程序)是一种网络协议,由 RFC 5389 定义。它允许位于 NAT(或多重 NAT)后的客户端找出自己的公网地址、查明自己位于哪种类型的 NAT 之后、以及 NAT 为某个本地端口所绑定的公网端口,这些信息被用来在两个同时处于 NAT 路由器之后的主机之间创建 UDP 通信。

我们可以搭建一个 STUN 服务器,它主要是给无法在公网环境下的视频通话设备分配公网 IP 用的,这样两台电脑就可以在公网 IP 中进行通话。

STUN

一句话说明 STUN 做的事情:告诉我你的公网 IP 地址 + 端口是什么?搭建 STUN 服务器很简单,媒体流传输按 P2P 方式进行。

但 STUN 并不是每次都能成功分配 IP 地址,且 P2P 传输用的是本地带宽,多人视频通话时质量好坏取决于使用者的本地带宽。这就需要 TURN 来解决。

2.2 TURN ​

TURN(Traversal Using Relays around NAT)是 STUN/RFC 5389 的一个拓展,主要添加了 Relay 功能。如果终端在 NAT 之后,特定情景下可能无法和其对等端(peer)直接通信,这时就需要公网的服务器作为一个中继,对来往的数据进行转发,这个转发协议就被定义为 TURN。

TURN

在 STUN 分配公网 IP 失败后,可以通过 TURN 服务器请求公网 IP 地址作为中继地址。这种方式带宽由服务器端承担,多人视频聊天时本地带宽压力较小,且根据 Google 的说明,TURN 协议可以使用在所有的环境中。

2.3 ICE 与总结 ​

  • STUN 和 TURN 是 WebRTC 中经常用到的 2 个协议,服务器用 coturn 开源项目搭建(集成了 STUN 和 TURN 的功能)
  • ICE 跟 STUN 和 TURN 不一样,它不是一种协议,而是一个框架(Framework),整合了 STUN 和 TURN
  • WebRTC 中描述网络信息的术语叫 candidate

一句话对照:媒体协商 sdp,网络协商 candidate。

3. 信令服务器(Signal Server) ​

两个客户端之间怎么交换 SDP 和 Candidate?需要一个“中间商”,也就是信令服务器(Signal Server),转发彼此的媒体信息和网络信息。

SignalServer

开发应用时,把彼此的 APP 连接到信令服务器(一般搭建在公网,或两端都可以访问到的局域网),借助它完成 SDP 媒体信息及 Candidate 网络信息的交换。

一个类比:写信给朋友,信是 sdp,地址是 candidate;邮局(邮递员)充当信令服务器,地址太大需要逐级分发(市邮局 -> 县邮局 -> 乡镇邮局 -> 邮递员)。NAT 相当于你主动根据邮局地址去拿,Relay 相当于让邮递员送上门。

交换 SDP 的过程:

交换SDP的过程

  1. Amy 通过 setLocalDescription 保存自己的 SDP 信息,然后通过 offer 方法发送给信令服务器
  2. 信令服务器将 Amy 的 SDP 转发给另一端的 Bob,Bob 先调用 setRemoteDescription 保存 Amy 的 SDP
  3. Bob 调用 setLocalDescription 保存他的 SDP,再使用 answer 方法通过信令服务器发给 Amy
  4. Amy 收到后调用 setRemoteDescription 保存,双方完成 SDP 交换、找到交集,能达成协议就可以建立 P2P 连接并开始通信

四、安装 coturn 穿透和转发服务器 ​

1. 安装依赖 ​

shell
# ubuntu
sudo apt-get install libssl-dev
sudo apt-get install libevent-dev

# centos
sudo yum install openssl-devel
sudo yum install libevent-devel

2. 下载源码编译安装 ​

shell
git clone https://github.com/coturn/coturn
cd coturn
./configure
make
sudo make install

执行 ./configure 时可能遇到的报错:

  • 需要安装 g++:sudo apt-get install g++

configure执行报错1

  • 缺少 pkg-config:sudo apt-get install pkg-config,之后再次执行 ./configure

configure执行报错2

configure 正常运行后,依次执行 make 和 sudo make install。

configure正常运行

make

3. 启动并测试 ​

shell
sudo nohup turnserver -L 0.0.0.0 -a -u lqf:123456 -v -f -r nort.gov &

用 Trickle ICE 测试。

测试 stun:

测试stun

注意:stun 不需要 username 和 password(截图里是多写了)。

测试 turn:

测试turn

注意:测试 turn 需要 username 和 password,且 IceTransports value 选择为 relay。测试时出现过 701 的返回码,查资料没有头绪,可能和当时用的是本地虚拟机(云服务器过期)有关,待后续再试。


五、案例实现:一对一通话 ​

1. 场景说明 ​

一对一通话场景

在一对一通话场景中,每个 Peer 均创建有一个 PeerConnection 对象,由一方主动发 Offer SDP,另一方应答 Answer SDP,最后双方交换 ICE Candidate 完成通话链路的建立。在中国的网络环境中,至少一半的网络无法直接穿透打通,这种情况只能借助 TURN 服务器中转。

2. 技术选型与整体流程 ​

  • 前端:html、css、js、WebRTC、websocket
  • 后端:springboot + websocket

WebRTC整合SpringBoot执行流程

3. 前端页面代码 ​

页面结构:远端全屏视频 + 本地小窗视频 + 好友账号输入框 + 通话/挂断按钮(样式部分见原文)。

html
<body>
    <div id="main">
        <video id="remoteVideo" playsinline autoplay></video>
        <video id="localVideo" playsinline autoplay muted></video>

        <div id="buttons">
            <span id="myName" style="color: red;"></span>
            <input id="toUser" placeholder="输入在线好友账号" /><br />
            <button id="call">视频通话</button>
            <button id="hangup">挂断</button>
        </div>
    </div>
</body>

核心 JS 逻辑:

html
<script type="text/javascript">
    // 生成一个随机的用户名
    let username = '' + Math.floor(Math.random() * (100 - 1) + 1);
    if (username) {
        document.getElementById("myName").innerText = username
    }
    let localVideo = document.getElementById('localVideo');
    let remoteVideo = document.getElementById('remoteVideo');
    let websocket = null;
    let peer = null;

    WebSocketInit();
    ButtonFunInit();

    /* WebSocket */
    function WebSocketInit() {
        if ('WebSocket' in window) {
            // 使用局域网IP的目的是打包成app时可以通过局域网访问
            url = "ws://192.168.8.57:8080/webrtc/" + username
            websocket = new WebSocket(url);
        } else {
            alert("当前浏览器不支持WebSocket!");
        }

        websocket.onerror = function (e) {
            console.log(e)
            alert("WebSocket连接发生错误!");
        };

        websocket.onclose = function () {
            console.error("WebSocket连接关闭");
        };

        websocket.onopen = function () {
            console.log("WebSocket连接成功");
        };

        websocket.onmessage = async function (event) {
            let { type, fromUser, msg, sdp, iceCandidate } = JSON.parse(event.data.replace(/\n/g, "\\n").replace(/\r/g, "\\r"));

            if (type === 'hangup') {
                console.log(msg);
                document.getElementById('hangup').click();
                return;
            }

            if (type === 'call_start') {
                // msg = 0 表示拒绝,1表示同意
                let msg = "0"
                if (confirm(fromUser + "发起视频通话,确定接听吗") == true) {
                    document.getElementById('toUser').value = fromUser;
                    WebRTCInit();
                    msg = "1"
                }

                websocket.send(JSON.stringify({
                    type: "call_back",
                    toUser: fromUser,
                    fromUser: username,
                    msg: msg
                }));
                return;
            }

            if (type === 'call_back') {
                if (msg === "1") {
                    console.log(document.getElementById('toUser').value + "同意视频通话");

                    // 创建本地视频并发送offer
                    let stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
                    localVideo.srcObject = stream;
                    stream.getTracks().forEach(track => {
                        peer.addTrack(track, stream);
                    });

                    let offer = await peer.createOffer();
                    await peer.setLocalDescription(offer);

                    let newOffer = offer.toJSON();
                    newOffer["fromUser"] = username;
                    newOffer["toUser"] = document.getElementById('toUser').value;
                    websocket.send(JSON.stringify(newOffer));
                } else if (msg === "0") {
                    alert(document.getElementById('toUser').value + "拒绝视频通话");
                    document.getElementById('hangup').click();
                } else {
                    alert(msg);
                    document.getElementById('hangup').click();
                }
                return;
            }

            if (type === 'offer') {
                let stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
                localVideo.srcObject = stream;
                stream.getTracks().forEach(track => {
                    peer.addTrack(track, stream);
                });

                await peer.setRemoteDescription(new RTCSessionDescription({ type, sdp }));
                let answer = await peer.createAnswer();
                let newAnswer = answer.toJSON();

                newAnswer["fromUser"] = username;
                newAnswer["toUser"] = document.getElementById('toUser').value;
                websocket.send(JSON.stringify(newAnswer));

                await peer.setLocalDescription(answer);
                return;
            }

            if (type === 'answer') {
                peer.setRemoteDescription(new RTCSessionDescription({ type, sdp }));
                return;
            }

            if (type === '_ice') {
                peer.addIceCandidate(iceCandidate);
                return;
            }
        }
    }

    /* WebRTC */
    function WebRTCInit() {
        // RTCPeerConnection 的配置,内网时不需要开启stun、turn
        const defaultConfiguration = {
            bundlePolicy: "max-bundle",
            rtcpMuxPolicy: "require",
            iceTransportPolicy: "all", // relay:如果使用了turn建议使用relay
            iceServers: [
                {
                    "urls": [
                        "turn:192.168.147.122:3478?transport=udp",
                        "turn:192.168.147.122:3478?transport=tcp"
                    ],
                    "username": 'lqf',
                    "credential": "123456"
                },
                {
                    "urls": [
                        "stun:192.168.147.122:3478"
                    ]
                }
            ]
        }
        var Rtc = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection;
        if (!Rtc) {
            alert("不支持WebRTC")
        }
        // peer = new RTCPeerConnection(defaultConfiguration);
        peer = new RTCPeerConnection();
        // ice
        peer.onicecandidate = function (e) {
            if (e.candidate) {
                websocket.send(JSON.stringify({
                    type: '_ice',
                    toUser: document.getElementById('toUser').value,
                    fromUser: username,
                    iceCandidate: e.candidate
                }));
            }
        };

        // track
        peer.ontrack = function (e) {
            if (e && e.streams) {
                remoteVideo.srcObject = e.streams[0];
            }
        };
    }

    /* 按钮事件 */
    function ButtonFunInit() {
        // 视频通话
        document.getElementById('call').onclick = function (e) {
            document.getElementById('toUser').style.visibility = 'hidden';

            let toUser = document.getElementById('toUser').value;
            if (!toUser) {
                alert("请先指定好友账号,再发起视频通话!");
                return;
            }

            if (peer == null) {
                WebRTCInit();
            }

            websocket.send(JSON.stringify({
                type: "call_start",
                fromUser: username,
                toUser: toUser,
            }));
        }

        // 挂断
        document.getElementById('hangup').onclick = function (e) {
            document.getElementById('toUser').style.visibility = 'unset';

            if (localVideo.srcObject) {
                const videoTracks = localVideo.srcObject.getVideoTracks();
                videoTracks.forEach(videoTrack => {
                    videoTrack.stop();
                    localVideo.srcObject.removeTrack(videoTrack);
                });
            }

            if (remoteVideo.srcObject) {
                const videoTracks = remoteVideo.srcObject.getVideoTracks();
                videoTracks.forEach(videoTrack => {
                    videoTrack.stop();
                    remoteVideo.srcObject.removeTrack(videoTrack);
                });

                // 挂断同时,通知对方
                websocket.send(JSON.stringify({
                    type: "hangup",
                    fromUser: username,
                    toUser: document.getElementById('toUser').value,
                }));
            }

            if (peer) {
                // 将peer的各类回调置空:ontrack、onremovetrack、onremovestream、
                // onicecandidate、oniceconnectionstatechange、onsignalingstatechange、
                // onicegatheringstatechange、onnegotiationneeded
                peer.close();
                peer = null;
            }

            localVideo.srcObject = null;
            remoteVideo.srcObject = null;
        }
    }
</script>

4. 后端代码 ​

核心依赖:spring-boot-starter-parent 2.5.3 + spring-boot-starter-websocket + lombok + hutool-all(Java 1.8)。

配置类:

java
@Configuration
// 扫描cn.hutool.extra.spring包下所有类并注册之
@ComponentScan(basePackages = {"cn.hutool.extra.spring"})
public class MyWebSocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

    @Bean
    public ObjectMapper mapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        return mapper;
    }
}

WebSocket 信令服务类(ConcurrentHashMap 保存在线会话,把收到的 offer/answer/ice 按接收人转发):

java
@Slf4j
@Component
@ServerEndpoint(value = "/webrtc/{username}")
@Import(cn.hutool.extra.spring.SpringUtil.class)
public class WebRtcWebSocketServer {

    /**
     * 连接集合
     */
    private static final Map<String, Session> sessionMap = new ConcurrentHashMap<>();

    @OnOpen
    public void onOpen(Session session, @PathParam("username") String username) {
        log.info("ws client 连接成功,username={}, session={}", username, session);
        sessionMap.put(username, session);
    }

    @OnClose
    public void onClose(Session session) {
        Set<Map.Entry<String, Session>> entries = sessionMap.entrySet();
        for (Map.Entry<String, Session> entry : entries) {
            if (entry.getValue() == session) {
                log.info("ws client 关闭成功,username={}, session={}", entry.getKey(), session);
                sessionMap.remove(entry.getKey());
                break;
            }
        }
    }

    @OnError
    public void onError(Session session, Throwable error) {
        log.error("ws 出现异常,", error);
    }

    /**
     * 服务器接收到客户端消息时调用的方法
     */
    @OnMessage
    public void onMessage(String message, Session session) {
        try {
            log.info("receive message:{}", message);
            ObjectMapper mapper = SpringUtil.getBean(ObjectMapper.class);
            HashMap hashMap = mapper.readValue(message, HashMap.class);

            String type = (String) hashMap.get("type");           // 消息类型
            String toUser = (String) hashMap.get("toUser");
            Session toUserSession = sessionMap.get(toUser);
            String fromUser = (String) hashMap.get("fromUser");
            String msg = (String) hashMap.get("msg");
            String sdp = (String) hashMap.get("sdp");
            Map iceCandidate = (Map) hashMap.get("iceCandidate");

            HashMap<String, Object> map = new HashMap<>();
            map.put("type", type);

            // 呼叫的用户不在线
            if (toUserSession == null) {
                map.put("type", "call_back");
                map.put("fromUser", "系统消息");
                map.put("msg", "Sorry,呼叫的用户不在线!");
                send(session, mapper.writeValueAsString(map));
                return;
            }

            if ("hangup".equals(type)) {          // 对方挂断
                map.put("fromUser", fromUser);
                map.put("msg", "对方挂断!");
            }
            if ("call_start".equals(type)) {      // 视频通话请求
                map.put("fromUser", fromUser);
                map.put("msg", "1");
            }
            if ("call_back".equals(type)) {       // 视频通话请求回应
                map.put("fromUser", toUser);
                map.put("msg", msg);
            }
            if ("offer".equals(type)) {           // offer
                map.put("fromUser", toUser);
                map.put("sdp", sdp);
            }
            if ("answer".equals(type)) {          // answer
                map.put("fromUser", toUser);
                map.put("sdp", sdp);
            }
            if ("_ice".equals(type)) {            // ice
                map.put("fromUser", toUser);
                map.put("iceCandidate", iceCandidate);
            }

            send(toUserSession, mapper.writeValueAsString(map));
        } catch (Exception e) {
            log.error("onMessage,异常:", e);
        }
    }

    /**
     * 封装一个send方法,发送消息到前端
     */
    private void send(Session session, String message) {
        try {
            log.info("send message:{}", message);
            session.getBasicRemote().sendText(message);
        } catch (Exception e) {
            log.error("message,待发送的数据:{},异常:", message, e);
        }
    }
}

注意:因为没有公网服务器,当时所有测试都只进行了内网测试。

5. 效果图 ​

浏览器视频效果图

电脑浏览器效果图

手机效果图

App 可以使用 HBuilder X 进行打包,打包时记得修改自己的 websocket 服务的 ip 和端口。


六、公网部署 ​

因为当时没有公网服务器,公网部署暂时没有实践,待有公网环境后再补充。


七、参考资料 ​

原文开头集中放了大量外链,整理成下表按需取用:

名称地址用途
WebRTC 中文网http://webrtc.p2hp.com/#google_vignette入门只看这个就够
WebRTC 中文社区https://webrtc.org.cn/中文社区
WebRTC 英文官网https://webrtc.org/最新文档
WebRTC 安全相关http://webrtc-security.github.io/安全知识
媒体设备入门http://webrtc.p2hp.com/getting-started/media-devices.html查看 WebRTC APIs
打开本地摄像头示例https://www.an.rustfisher.com/webrtc/web-samples/getUserMedia-open-camera/中文示例
WebRTC 代码案例博客https://www.bbsmax.com/A/B0zqLrWNdv/本篇案例代码来源
webrtc-p2p 源码https://github.com/shushushv/webrtc-p2p参考源码
WebRTC 原理简述https://www.jianshu.com/p/476f39de86ed原理博客
WebRTC 介绍及简单使用https://zhuanlan.zhihu.com/p/490239698入门介绍
coturn 开源地址https://github.com/coturn/coturnSTUN/TURN 服务器搭建
Trickle ICE 测试网站https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/测试 stun/turn
P2P 知识https://zhuanlan.zhihu.com/p/488135992P2P 基础
P2P 技术详解(一):NAT 详解https://www.cnblogs.com/mlgjb/p/8243646.htmNAT 原理、P2P 简介
P2P 技术详解(二):NAT 穿越(打洞)https://www.jianshu.com/p/9bfbcbee0abb打洞方案
P2P 技术详解(三):STUN、TURN、ICEhttps://www.jianshu.com/p/258e7d8be2ba穿透协议
详解 P2P 技术中的 NAT 穿透原理https://www.jianshu.com/p/f71707892eb2NAT 穿透原理
b 站视频教学https://www.bilibili.com/video/BV1D14y1W7qp?p=1&vd_source=d6cd8b3f892acbf22f02da2bfa7d95fe入门视频

八、一页总结 ​

  • WebRTC 让浏览器不装插件就能点对点传输音视频和任意数据
  • 建立连接要解决三件事:媒体信息(SDP)、网络信息(Candidate)、两者如何交换(信令服务器)
  • STUN 负责“告诉我公网 IP+端口”,TURN 负责“打不通时中继转发”,ICE 是整合两者的框架
  • 信令服务器用 WebSocket 实现,本篇用 SpringBoot + WebSocket 完成
  • 一对一流程:call_start -> call_back -> offer -> answer -> 交换 ICE Candidate -> P2P 通话