博文記錄
教程分享 2022-12-24 02:03:54 137 0

WebRTC (Web Real-Time Communications) 是一个基于 web 的实时通信技术,是浏览器应用的 高级API 技术,使用 Javascript 实现,可以在两个浏览器之间建立端对端(P2P)通信,进而实现一些数据传输、视频流、音频的传输。

它可以实现:

  • 端对端通信、不经过服务器传输

  • 音频视频通话

  • 屏幕分享

  • 文件数据传输

webrtc API有很多,有很多的关键词:信令服务器、视频流、P2P连接、stun 服务器端口映射等等,不仅仅是连接还包括音视频处理。

这些突然的概念很难去理解,包括搜到的很多例子,也糅合了很多的概念和场景、一上来就各种视频流、信令服务器又通过 websocket 传输信令,很难去单一理解这件事。那我们现在先只需要实现一些基础的,比如用它实现两个浏览器建立一个连接,用于传输一些基础数据,类似一个 websocket 的双向通信一样。

在此基础上如果想要实现视频流通信,那么在已有的连接上扩展就可以了,那么按照这个思路进行理解学习。

NAT 穿透

关于 NAT 和 UDP 打洞你可能需要了解:

  1. P2P 传输是通过 stun 服务器进行映射打洞实现的端对端连接,作为浏览器的客户端只能连接公网服务器而不能连接处于另一个家庭之类局域网下的 nat 设备。

  2. 你从家里设备发出的连接到公网 IP会存在一个本地端口和远程端口,一般情况下是本地随机端口如 12345 连接到远程端口 443 实现 https 网页访问,服务器把网页数据传输给你的 12345 端口,你的浏览器才能展示页面,用完连接就会释放。

  3. 打洞即是 nat 映射,往往家庭设备没有公网 IP,而是内网IP 如 192.168.1.10,你若需要访问 1.1.1.1:443,路由器会带着你的请求一层层向上递交寻找,直到访问到 1.1.1.1。

    假设你内网 IP 之外只有一层 nat 设备,那你的网络访问顺序可能是: 192.168.1.10:1234 -> 192.168.1.1(113.113.113.113:23456) -> 1.1.1.1:443

    你的浏览器请求时,创建一个连接,本地端口为12345,到了路由器,路由器也需要为这个连接创建 nat 映射,否则服务器 1.1.1.1:443 是访问不到 192.168.1.10:1234 这个设备的,他只能访问到 113.113.113.113:23456 这个 IP 和端口,就是这种映射关系使得内网设备能够访问到外网,外网数据能够回传成功。如果你中间的 nat 设备过多, 这种映射关系会更多。

  4. 以上大致就是传统 TCP 的 NAT 技术,STUN 也是同理,只是建立在 UDP 上的一种协议,让这个所谓的端口映射保持的时间更长、且能够双向即时传输。真实场景的 UDP打洞会更复杂,也不一定能够成功。

简化流程

如果你不熟悉 NAT,那没关系,我们暂时就先不引入那么多概念。

连接到哪里?

a 和 b 需要通信,a 在本地作为"服务器"的方式监听了 12345 端口,b 此时只需要向 a:12345 发送数据就可以实现单向通信,但此时 b 并不知道 a 监听了 12345 端口,所以我们需要一个中间人来实现信息的传递。

现在有了一个中间人 c, 可以帮 a 把端口信息传递给 b,同时也可以把 b 在本地的端口信息传递给 a,这样 a 和 b 就可以建立连接双向通信了。

这个中间人是什么?

往往是公网服务器,a、b都可以同时访问到的,一般用 websocket 协议来传递双方的信息,这个过程就是 ICE 信令传递。

本质上这个过程用什么方式都可以,传递的信息只是一段文本信息,包含了 a 、b各自的本地接口描述,又称为 本地对象描述符(LocalDescription),你可以用websocket、可以用 HTTP、甚至可以用短信发给对方手动复制粘贴进去实现通信。

先简化一下连接流程:

a  创建连接和数据管道 -> a 发起会话 offer邀请 -> ICE SERVER  -> b接受会话邀请 -> b 创建应答 -> ICE SERVER -> a连接完成 -> 开始双向通信
  • 新增的 发起会话 offer,实际上就是“创建本地端口”,以及收集本地数据、网络对等信息的过程。

  • ICE SERVER 我们通过手工复制实现。

  • 数据管道是为了满足我们传输数据需要所创建的,这是可选的,也可以创建音频视频流,总之需要有一个东西否则建立的连接是无效的。

  • a 并不是会话呼叫方,你可以理解为会话邀请方,而 b 是 收到邀请函赴约的,所以我们理解为应答方。

这是一个详细的连接流程图:

API介绍

 

简单介绍下我们用到的几个基础 API

RTCPeerConnection

如果我们想要让 a、b 两个人或 a、b 两个浏览器建立连接,就需要通过这个 API 创建一个连接,这个每个端都需要创建一个连接。

createDataChannel

基于连接创建数据通道,可以传输文本、二进制数据等。

createOffer

创建 offer 邀请, offer 里包含了本地设备信息的一些描述。

setRemoteDescription

设置远程描述符,理解这个 API 很重要,对于任意一个连接的断点,其自身都是 local,其对端都是 remote。

RTCSessionDescription

用于把文本的描述符实例化为一个具体的描述符对象。

createAnswer

针对收到的 offer,创建对应的应答响应,其结果也是一个对象描述符,并将其设置到本地描述符使其连接。

 

实现代码

简单实现 a b 两个页面通信,可以用同一个或者不同的浏览器打开测试。手动复制实现 offer 和 answer 传递。

a.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>a page</title>
</head>
<body>
    <h2>a page</h2>
<script>
    function handleCreateDescriptionError(error) {
    console.log(`Unable to create an offer: ${error.toString()}`);
    }
    
    function handleSendChannelStatusChange(event){
        console.log('handleSendChannelStatusChange:', sendChannel.readyState, event)
    }
    
    localConnection = new RTCPeerConnection();

    sendChannel = localConnection.createDataChannel("sendChannel");

    sendChannel.onopen = handleSendChannelStatusChange;
    sendChannel.onclose = handleSendChannelStatusChange;

    sendChannel.onmessage = (event)=>{
        console.log('event.data',event.data)
    }


    localConnection.onicecandidate = (e) =>{
        console.log('a on ice candidate',e)
    }

    localConnection
    .createOffer()
    .then((offer) => localConnection.setLocalDescription(offer))
    .then(() =>
        {
            console.log('请去设置远程链接符号:', JSON.stringify(localConnection.localDescription.toJSON()))
        }
    )
    .catch(handleCreateDescriptionError);

    function setRemoteDescription(text){
        const res = localConnection.setRemoteDescription(text)
        console.log('set remote res:', res)
    }

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

b.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>b page</title>
</head>
<body>
    <h2>b page</h2>
<script>
    function handleCreateDescriptionError(error) {
    console.log(`Unable to create an offer: ${error.toString()}`);
    }
    
    function handleReceiveChannelStatusChange(event) {
        if (receiveChannel) {
            console.log(
            `Receive channel's status has changed to ${receiveChannel.readyState}`
            );
        }
    }
    localConnection = new RTCPeerConnection();

    localConnection.onicecandidate = (e) =>{
        console.log('a on ice candidate',e)
    }

    localConnection.ondatachannel = (event)=>{
        receiveChannel = event.channel
        receiveChannel.onmessage = (event)=>{
            console.log('event.data',event.data)
        }
        receiveChannel.onopen = handleReceiveChannelStatusChange;
        receiveChannel.onclose = handleReceiveChannelStatusChange;
    }

    function setRemoteDescription(text){
        const desc = JSON.parse(text)
        const res = localConnection.setRemoteDescription(new RTCSessionDescription(desc))
        console.log('set remote res:', res)
    }


    function createAnswer()
    {
        localConnection.createAnswer().then(answer =>{
            console.log('current b answer', answer)
            localConnection.setLocalDescription(answer)
        })
    }

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

测试连接

  1. 打开 a.html 手动执行 JSON.stringify(localConnection.localDescription.toJSON())获取本地描述符

  2. 打开 b.html 手动执行 setRemoteDescription('a的描述符')

  3. 在 b.html 执行 createAnswer()

  4. 在 b.html 获取 b 的本地描述符 JSON.stringify(localConnection.localDescription)

  5. 在 a.html 执行 setRemoteDescription('a的描述符')

  6. 双向通信开始,在 a.html 执行 sendChannel.send('123')

  7. 在 b.html 收到响应,event.data 123, 也可以通过 receiveChannel.send('456') 发送给 a

 

大致了解流程之后,仔细阅读交互逻辑去理解这个过程。

当你看完本文并实现了 demo 之后,就可以看官方文档详细了解每一个接口细节:https://webrtc.org/getting-started/data-channels

stun 配置

 

当你在本地测试好后,放到公网上,两个不同 nat 下的设备就会发现无法连接了。那是因为本地测试时不需要打洞,而跨网络需要打洞,那么此时就需要引入 stun 服务器来进行 NAT 打洞。

实现非常简单,只需要配置下 stun 服务器即可:

var configuration = {
        iceServers: [{
                urls: "stun:stun.miwifi.com",
            }
        ]
    };
const localConnection = new RTCPeerConnection(configuration);
  • 需要给每个端点创建连接时配置

  • 有很多开放的免费 stun 服务器,这里采用小米的。

信令传递

候选信令传输,一般情况下为连接创建完 offer,改变完本地描述符之后会受到该事件,然后把 e.candidate 传递给对端并添加到对端候选信令列表里。

传统做法:

localConnection.onicecandidate = (e) => {
       
        console.log('a on ice candidate', e.candidate) // 拿到信令信息
        // 通过信令服务器或者任何方式,包括手动复制粘贴传递
        localConnectionB.addIceCandidate(e.candidate) // 在对端连接如 b 的逻辑里新增候选信令
    }

由于我们省略了信令的数据传输,所以 offer 必须在信令候选人 complete 状态之后,再去获取本地描述符发送给对端。

function waitIceComplete(conn)
{
   return new Promise((resolve)=>{
        const callback = ev =>{
            if(ev.currentTarget.iceGatheringState == 'complete'){
                resolve()
            }
        }
        conn.addEventListener('icegatheringstatechange',callback)
   })
}

complete 之后的再获取本地描述符就已经包含了信令候选人的信息。

v=0
o=- 704537251720792184 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0
a=extmap-allow-mixed
a=msid-semantic: WMS
m=application 55286 UDP/DTLS/SCTP webrtc-datachannel
c=IN IP4 116.1.1.1
a=candidate:866165860 1 udp 2113937151 21e0e50c-ecd2-47e0-a621-e799b32546d4.local 55286 typ host generation 0 network-cost 999
a=candidate:2955795236 1 udp 1677729535 116.1.1.1(公网IP) 55286 typ srflx raddr 0.0.0.0 rport 0 generation 0 network-cost 999
a=ice-ufrag:QVLU
a=ice-pwd:KN74Iuh6PIXO+DHFmfPdTVy8
a=ice-options:trickle
a=fingerprint:sha-256 AA:AA:AA{指纹信息}AA:AA:AA
a=setup:active
a=mid:0
a=sctp-port:5000
a=max-message-size:262144

进阶实现

当你熟悉了基础的 webrtc API 之后,还可以做很多事情。

音视频通信

获取视频流 getUserMedia,这是非常简单的API,在 createOffer 前通过 getUserMedia 获取 stream

async getMedia(){
	return await navigator.mediaDevices.getUserMedia({
      audio: true, // 麦克风,根据需要获取
      video: true, // 摄像头,根据需要获取
    })
}

getMedia().then(stream => localConnection.addStream(stream)).then(() => localConnection.createOffer())

在接收端,获取流并显示视频:

localConnection.ontrack = (ev)=>{
        console.log('ontrack',ev)
        var targetDisplay = document.getElementById('target-display')
        targetDisplay.srcObject = ev.streams[0]
}

如果你没有摄像头,却申请了摄像头权限,那么这个API执行会报错。建议麦克风和摄像头分开申请。

另外,addStream 已经被 webrtc 标准标记弃用了。可以使用 addTrack 替代:

navigator.getUserMedia({ video: true, audio: true }, (stream) => {
  const pc = new RTCPeerConnection();
  stream.getTracks().forEach((track) => {
    pc.addTrack(track, stream);
  });
});

分享屏幕

和音视频一样,分享屏幕的内容也是非常简单的API getDisplayMedia

navigator.mediaDevices
    .getDisplayMedia()
    .then(stream => localConnection.addStream(stream))
    .then(()=>localConnection.createOffer())

传输文件

前面例子中我们进行传递最简单的文本信息,除此之外还可以用于传输文件等任何二进制数据。

var dc = pc.createDataChannel("sendChannel");

这个通道有传输数据的限制,不能超过 64k,否则就会出现莫名其妙的连接断开提示.

RTCDataChannel send arraybuffer close

所以必须将文件拆分后传输,接收时再进行合并组装。

ArrayBuffer 是 JS 里的二进制数据格式,我们需要把文件转换为 ArrayBuffer,并且通过 DataView 读取其中一部分二进制数据发送给对端。

然后,接收端需要按照文件大小接收对应 ArrayBuffer 数组,并将其合并成文件对象

  • DataView 可理解为数据窗口,从 ArrayBuffer 的数据地址读取一部分数据

  • 接收端的数据通道消息在 ondatachannel 事件里获取,而不是对端自己的 dc.onmessage

  • File 或 Blob 对象实例化参数允许接收多个字节数组


/**
 * 
 * @param file 
 * @returns 
 */
function fileToArrayBuffer(file) {
    return new Promise((resolve) => {
        var reader = new FileReader();
        reader.addEventListener('load', (event) => {
            resolve(event.target.result)
        });
        reader.readAsArrayBuffer(file);
    });

}

/**
 * 
 * @param dc 
 * @param file 
 */
async function transferFile(dc, file) {
    const fileArrayBuffer = await fileToArrayBuffer(file)

    // 预先通知对端将有一个 file.size 大小的文件到达
    dc.send(JSON.stringify({
        type: 'file',
        file: {
            name: file.name,
            size: file.size,
            type: file.type
        }
    }))

    const maxLength = 1024 * 64
    let size = 0 // 已经传输大小
    while (size < fileArrayBuffer.byteLength) {
        const length = fileArrayBuffer.byteLength - offset >= maxLength ? maxLength : fileArrayBuffer.byteLength - offset;
        const dv = new DataView(fileArrayBuffer, offset, length);

        console.log(`chunk offset:${offset} length: ${length}`, dv)
        offset += length
        size += length

        dc.send(dv)
    }
}

const pc = new RTCPeerConnection()
const dc = pc.createDataChannel('sendChannel')

// ... 省略连接过程

var input = document.createElement('input')
input.type = 'file'
input.onchange = async () => {
    const file = input.files[0]

    transferFile(dc, file)
}
input.click()



// 而对端的接收逻辑也非常简单

let receiveQueue = {}
let receiveFiles = []


const remotePc = new RTCPeerConnection()
remotePc.ondatachannel = event => {
    const receiveChannel = event.channel

    receiveChannel.onmessage = (event) => {
        if(event.data instanceof ArrayBuffer){
            if(receiveQueue['file']){

                receiveQueue['file']['bytes'].push(event.data)
                receiveQueue['file']['receivedByte'] += event.data.byteLength

                if(receiveQueue['file']['receivedByte'] == receiveQueue['file']['fullSize']){
                    const {name, bytes, type} = receiveQueue['file']
                    delete receiveQueue['file'] // 清除队列

                    const fileInstance = new File(bytes, name, {type: type})
                    receiveFiles.push(fileInstance)

                    const fileDownURL = URL.createObjectURL(fileInstance)
                    console.log(`Received a file, The download url is ${fileDownURL}`)
                    
                }
            }else{
                console.error(`unknown data`, event)
            }
        }else{
            const payload = JSON.parse(event.data)
            if(payload.type == 'file'){
                receiveQueue['file'] = {
                    name: payload.file.name,
                    size: payload.file.size,
                    type: payload.file.type,
                    receivedByte: 0,
                    bytes: []
                }
            }
        }
    }
}
// ... 省略远端连接过程

获取用户真实IP

也可以利用 stun 的打洞功能实现获取用户的公网IP,此方式大部分情况可以绕过用户的系统代理软件,以获取到真实IP。

如果你将 ICE 服务器配置一个谷歌一个小米的,如果对方代理有路由分流,你可以同时获取到两个IP,对应国内和海外IP。(示例代码只正则匹配了一个)

function waitIceComplete(conn) {
    return new Promise((resolve) => {
        const callback = ev => {
            if (ev.currentTarget.iceGatheringState == 'complete') {
                resolve()
            }
        }
        conn.addEventListener('icegatheringstatechange', callback)
    })
}
function matchIP(text) {
    const matches = text.match(/c=IN IP4 (\d+.\d+.\d+.\d+)/)
    return matches ? matches[1] : ''
}

function getPublicIP() {
    return new Promise(resolve => {
        var configuration = {
            iceServers: [{
                urls: "stun:stun.miwifi.com",
            }
            ]
        };
        const localConnection = new RTCPeerConnection(configuration);
        localConnection.createDataChannel('data')
        localConnection.createOffer().then(offer => localConnection.setLocalDescription(offer))
            .then(() => waitIceComplete(localConnection))
            .then(() => resolve(matchIP(localConnection.localDescription.sdp)))
    })

}

getPublicIP().then(ip => console.log('ip:'+ip))

实现的一个Demo

https://p2p.mjj.ee/

学习 webrtc 过程中实现的一个 demo,实现了多人聊天室内的常见功能。

  • 语音通话、视频流

  • 文件传送

  • 屏幕分享

一些奇怪的测试记录

在学习 webrtc 的过程中遇到了很多奇怪的现象,没有找到原因。

其一是:

同一份代码,使用 http://127.0.0.1:8000 访问可以获取到 ICE 信令信息,且可以连接成功。

而同本机的网卡 http://192.168.1.x:8000 或者部署到公网之类的地址进行访问,就无法连接。

两端均在本地测试,不存在网络不通等问题。

连接过程的状态是:

ice status: new
ice status: checking
ice status: disconnected
connect: failed

测试地址: https://sfc.vuejs.org/#eNqlV81u20YQfpUtUUAUaq/q5FRVMho4/UXQBHGAHkIDWi+HFB1yl9hdSggMXXppi/jQS5BLgZ5ydU49FP17Gsu59RU6S1IiRVKygArwz+7OfDPzzezM6tJ5kKZ0loEzdEaaqyg1RIPJ0mNPREkqlSGXREFAFiRQMiE9FO15whNcCm1IokMytudu7yuIY0m+kyr2P+j1PRFkgptICjJnkfmaw4lM0hgMuKgp+uTSE6T2UWhTCSJgTp6gnUiD6yrQMp5Bn4yPm+L2EwUkx6IRhy+ZmYKKRHhqmAEyHpMeL+31WrYqmzm+i852H1uX2meL9lZBBmdxfM74C2QEZlucXknLGKgP51noTp4rw8/ICUYCBWE6j4FPmQjBJ0aSIfnwEmaUZ0qBMM+YCsG0o15MMJBui5aquwH2pK0MQVAFiZzB5zOEfBRpAwKU20PUcIWaB1LE0TtY07OF7j0y0sF8dzIEZb7/fx1b1Na5meJnXdcGtHH7BUkzpkgsEegJgML8cwVowS7KWKyA5avYbEmsdWmx/zgI7AFFf4Ur7cLWUyWFV/SRXTyE4s6iP4VYv9Rh+qXgbn983Eji5orZu9m6oGszDUJWdRtLrNpciPhon2B1Vp61y5JMPvrm9PG3VBu7FwUvKwMItRlEv1XChYsVdTb0p/mqHvsOxHYMhjCh53kWWugF/Q/y82YZbsRfQgwnByXaXp63krbWvTstFdIOv3qFFF55DjfXV8sff7a9eEccpfwqkTV398lkTbwrle2gNkq4ncddeLsKeSOk99d/3/51baN/97rdE2uXujzzBF7v2sWuX83G9RkMVr1eiiAKM8VyjTERWRx3VVpTroGHHJ+CmoHSQ/K83cq2dOBMxd3y6xgdbTIxtL9oEs2jIKLY1z3nYC+V+zSmoZQh8olaw6NP7n98b6vuWcd+oymf1Sj/tIuklFsGcfQ/fXZiaa9moR3wFX/9hnbKqRRIIWfCj/x8guGc2/ZeWNXInCnh9hjBfKAudv9SGScBBrxaNYdBs4zQdueMsdOn8H1zyHT04pZTk/xFYL3qRLHPADSLxxVB1eRvjq4uojUI/wSxBMRIFWIVpf6QGVZuu1gHlZDndFKO/DDrUgEDloMdnNseyCGaQWU5V6ElRFtrU57m5ZHyO+WkSEBrFhZlYE1sKwX7MdNIU0gigz3TcFqq2iLINe+s6q6K2OS3TmRDtnzu1oMqnxijQdHx8P2NCwPY/DFDuCJkND06Xl799MXRveWvb29/ebV88/bmnzf//nl1+/3vNz/8sfzt1ft3r0cDlMqlzzNjsMY/43HEX4w9x75XPOfY/hkNikMUHA3WNpwDp3jyHyYspRdaCvxSkLPnlQfac4YrPj0HvwrYtedMjUn1cDDQAbdfJS40lSoc4H9UZcJECVDQyeG5knONjf0CUcqmkWMMcBM74CE+TX1QoHZhNkRbuBYWiVw4i/8ATmzzDg==

 

另外一种是:

var configuration = null
new RTCPeerConnection(configuration)

正常情况下,如果初始化 RTC 连接,ICE 配置若为空,则认为这是本地连接,只会获取到 xxxx.local 的对等信息。

当配置了 ICE服务器,才有可能获取到你的公网 IP 的对等信息。

但若复制代码到任意网站测试,不加 ICE 配置的情况,基本都无法获取到外网对等信息。

但复制到 https://webrtc.github.io/ 打开控制台执行,就会获取到完整有效外网信息。

以上测试结果很迷惑,无论Chrome/Edge都存在问题,当完全重启浏览器进程之后,异常又消失了。

 

相关代码

根据 webrtc 实现了一个项目,支持多人在房间内基于P2P模式进行语音视频通话、分享屏幕,传送文字和文件等功能。

https://github.com/ellermister/ishoni

media iamge
StudioEIM - 冒险者讲习所
0:00