WebRTC 简单p2p实例演示
在去年(具体时间不太记得了),就有一个音视频功能的需求。当然思路最先是使用已经有的现成的解决方案。例如各类云厂商提供的相关功能,或者开源方案。(实际这也是个彩蛋😁)。 在我个人看来如果直接使用第三方服务难免太无聊了。加上其实如果从应用层面来说,单纯使用WebRTC并不困难。所以可以先展示一个demo,再来研究源码。或者其中诸如sctp, rtp, rtcp等一大堆相关协议。(别说WebRTC真就是一套API以及整合的一大堆协议) 具体什么是WebRTC就不说了,这个过程蛮无聊的,还是直接写个demo来的实在一点。下面是一个简单的端对端连接的例子的实现。

这里我开了两个firefox浏览器实例,并且使用了两个摄像头,两个浏览器实例分别使用两个摄像头作为本地设备。为了区别本地和远端,我在视频右上角对其进行了标记。
小解释
先不考虑拥塞控制,码率控制等问题。(说实话单纯个demo还不至于,要自定义乃至商用,那源码分析是肯定的,这个我之后有时间再来【目前我也没看】)从整体来说,在两个peer之间通过一个服务器来进行媒体协商以及网络协商。在媒体协商中使用sdp协议来协商媒体相关诸如:协议,会话,媒体描述,连接信息,地址类型等。而在网络协商的过程中,为了让二者尽可能的能够穿越nat(大部分机子都在nat后),则使用ice机制以及stun协议或者说支持stun的服务器来使二者了解对方的网络情况(这里不是拥塞情况)。在以上不能直连的情况下可以使用turn服务来对数据进行中转。(当然这是不希望的情况)。
在主流浏览器内,webrtc的支持已经很到位了,并且都封装了一大波api(这也是为什么前面说WebRTC也可以指代一套API)。并且对于sdp的交互webrtc并没有做出规定,这个就很随意了。目前最常见的是使用websocket来传递sdp以及网络协商时的icecandidate数据(这个名词就不解释了具体可以看: rfc , 本篇文章只是单纯从简单实践出发,概念不多解释。)。
整体流程如下

所以第一步要做的是交换sdp,也就是媒体协商。(因为对于只是调用接口来说PeerConnection已经是完全体了,只是可能很多WebRTC概念未来会被修改)具体sdp的内容很多,但是不难,所以这里就不说了,只要看了rfc就知道了。
Peer端
在Peer端,因为已经有了浏览器提供完善的API了。因此创建一个PeerConnection只要一句new PeerConnection()。下面是整个页面的整个目录结构以及html部分。
.
├── css
│ └── style.css
├── images
│ ├── batman.jpeg
│ └── remote.png
├── index.html
├── js
│ ├── adapter.js
│ └── client.js
└── websockett
├── go.mod
├── go.sum
├── handler
│ └── websocket.go
└── main.go
整体代码量很少,因为只是一个简单可用的demo,我把go语言实现的服务端也直接放在了这个下面,待会涉及到的时候聊一下。
<!DOCTYPE html>
<html>
<head>
<title>WebRTC Start</title>
<link rel="stylesheet" href="./css/style.css">
</head>
<body>
<div id="players">
<div id="local-stream" class="avstream">
<div class="avtag local">LOCAL</div>
<video autoplay playinline id="local-player" poster="images/batman.jpeg"></video>
<div class="player-btns">
<button id="local-start" class="cbtn btn-start">开始</button>
<button id="local-call" class="cbtn btn-call">呼叫</button>
<button id="local-hangup" class="cbtn btn-hangup">挂断</button>
</div>
</div>
<div id="remote-stream" class="avstream">
<div class="avtag remote">REMOTE</div>
<video autoplay playinline id="remote-player" poster="images/batman.jpeg"></video>
<div class="player-btns">
<button id="remote-start" class="cbtn btn-start" disabled="true">开始</button>
<button id="remote-call" class="cbtn btn-call" disabled="true">呼叫</button>
<button id="remote-hangup" class="cbtn btn-hangup" disabled="true">挂断</button>
</div>
</div>
</div>
<!-- adapter.js 用于适配不同浏览器(适配不同浏览器getUserMedia相同功能的方法名字不同) -->
<script src="./js/adapter.js"></script>
<script src="./js/client.js"></script>
</body>
</html>
这里就是两个视频标签以及几个按钮,然后通过script引入client.js和adapter.js两个js文件。其中client.js是peer端的逻辑代码而adapter.js是用于不同浏览器之间api兼容适配用的。
css过于简单就不说明什么了。在最后的附录里面再说吧。
JavaScript
直接来看js代码。第一步要做的就是获取到这些按钮,其实获取本地框里面的按钮就行,反正我也没打算给远端实现具体功能。
变量定义
'use strict';
// buttons
// 本地按钮们
let localStartBtn = document.querySelector('button#local-start');
let localCallBtn = document.querySelector('button#local-call');
let localHangupBtn = document.querySelector('button#local-hangup');
// 远端按钮们😄
let remoteStartBtn = document.querySelector('button#remote-start');
let remoteCallBtn = document.querySelector('button#remote-call');
let remoteHangupBtn = document.querySelector('button#remote-hangup');
获取到对应的按钮元素之后便于给他们设置对应的监听事件。比如开始按钮的onclick,用于将本地视频流设置到视频标签的srcObject属性上,让其可以显示本地视频。(所以其实调用不同api就行,获得不同流,流内有不同的轨,以此也可以共享桌面等等。)
再来定义一些变量,本地的steam以及peerConnection。因为我使用了两个摄像头,所以需要定义一个摄像头数组,以及当前使用的摄像头id用于区别两个不同的摄像头。
// streams and connections
let localStream = null;
let localPeerConnection = null;
// 信令服务器Websocket
let socket = null;
// 本地摄像头id
let cameraId = null;
// 所有摄像头
let cameras = [];
设备获取 & Channel定义
ok,变量定义完成了。先来获取设备。因为在本地,我使用两个摄像头。所以使用一个camera数组存放相关信息,以便于两个浏览器分别使用两个摄像头。
// 获取所有摄像头
navigator.mediaDevices.enumerateDevices().then(devices => {
devices.forEach(device => {
console.log(device.kind + ": " + device.label +
" id = " + device.deviceId + " groupId = " + device.groupId);
if (device.kind === 'videoinput') {
cameras.push(device);
}
});
});
通过 cameras 这个通道来共享信息,也就是cameraId。
// 用于通知其他浏览器实例
const channel = new BroadcastChannel('cameras');
channel.onmessage = function(event) {
console.log(event.data);
cameraId = event.data;
}
不同浏览器实例之间的通信也给了很多好玩的应用实现的可能。😃
TURN & STUN
stun和turn服务器有很多实现,为了节省时间,就直接使用coturn这个开源turn服务器,它既是stun也是turn服务。所以只要部署好之后,在Peer端设置好地址即可。我的主要重点还是放在信令服务器和Peer端。
// 先直接写入turn和stun服务器的配置
let turnAndStunConfig = {
iceServers: [
// {
// urls: 'stun:stun.l.google.com:19302'
// },
{
urls: 'turn:your_server_ip:your_server_port',
username: 'your_name', // 没有的话就不填
credential: 'your_credential', // 没有的话就不填
}
]
}
绑定本地事件
本地start
在开始双方通信之前,需要做一些准备工作,这里放到start函数下来完成,并且将本地start按钮的onclick设置为start。
function start() {
// 判断浏览器是否支持
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
alert('getUserMedia is not supported!');
} else {
if (cameras.length === 0) {
alert('no camera');
return;
}
// 因为这里我自己只有两个摄像头,所以直接写死了
if (!cameraId) {
cameraId = cameras[0].deviceId;
channel.postMessage(cameras[cameras.length > 1 ? 1 : 0].deviceId);
}
// 设置为true时,则为获取对应的流
// 此时为只获取视频流
let constraints = {
audio: false,
// audio: {
// // 降噪处理
// noiseSuppression: true,
// // 回音消除
// echoCancellation: true
// },
video: {
// 摄像头id
deviceId: cameraId,
// 视频宽度
width: 640,
// 视频高度
height: 480,
// 视频帧率
frameRate: 30,
// 摄像头方向(user 表示为前置摄像头)
facingMode: 'user'
},
};
navigator.mediaDevices.getUserMedia(constraints)
.then(gotLocalMediaStream)
.catch(handleError)
initializeSocket();
initializeRemoteStream();
}
}
localStartBtn.onclick = start;
首先判断是否可以获取用户的设备,如果连设备都获取不到就不用进行了。浏览器也会对其进行限制,如果证书不可信等也无法获取设备。
之后使用channel判断cameraId是否已经使用(本地),并且通过channel的postMessage将信息发送到channel,另一方接收并设置cameraId,还是说一下,因为我在本地使用两个摄像头才这么做。
通过constraints对象设置音视频相关,比如视频宽高,帧率,方向等。
// 设置为true时,则为获取对应的流
// 此时为只获取视频流
let constraints = {
audio: false,
// audio: {
// // 降噪处理
// noiseSuppression: true,
// // 回音消除
// echoCancellation: true
// },
video: {
// 摄像头id
deviceId: cameraId,
// 视频宽度
width: 640,
// 视频高度
height: 480,
// 视频帧率
frameRate: 30,
// 摄像头方向(user 表示为前置摄像头)
facingMode: 'user'
},
};
好嘞!一般的音视频通话在连接建立前都会先把本地视频显示到当前设备上。这样用户体验也会更好而不是一个黑屏一直等。所以这里设置本地媒体流。将获取到的媒体流放到对应video标签的srcObject上。
function gotLocalMediaStream(stream) {
localStream = stream;
localVideoPlayer.srcObject = stream;
// 所以这里触发对方的ontrack事件
localStream.getTracks().forEach(track => localPeerConnection.addTrack(track, localStream));
localStartBtn.disabled = true;
}
遍历流内所有的轨,并且与localPeerConnection对象进行关联。这样会触发对方的ontrack事件。ontrack事件可以通过将流设置到remote视频内的video标签的srcObject上。所以对于我们来说,对方在addTrack时,我们也能获得对方的流。所以需要设置localPeerConnection的ontrack回调。
function gotRemoteMediaStream(event) {
console.log('got remote stream')
// 因为只有视频一股流,直接取0
remoteVideoPlayer.srcObject = event.streams[0];
}
localPeerConnection.ontrack = gotRemoteMediaStream;
点击start之后,除了设置本地相关参数,和设置相关回调之外最主要的是通过信令进行媒体和网络协商。
function initializeSocket() {
// 连接信令服务器
socket = new WebSocket('wss://your_server:your_port');
socket.onopen = function() {
alert('socket open');
}
if (!localPeerConnection) {
localPeerConnection = new RTCPeerConnection(turnAndStunConfig);
console.log(localPeerConnection);
}
// 设置socket监听
socket.onmessage = function(event) {
console.log('socket message');
let data = JSON.parse(event.data)
let jsData = JSON.parse(data.content)
// 得到远端offer sdp
if (jsData.type === 'offer') {
// TODO: 注意state状态变化
localPeerConnection.setRemoteDescription(jsData);
localPeerConnection.createAnswer(offerOptions)
.then(desc => {
// 设置本地sdp
localPeerConnection.setLocalDescription(desc);
console.log(desc);
// 发送本地sdp
socket.send(JSON.stringify(desc));
})
.catch(handleOfferError);
} else if (jsData.type === 'answer') {
console.log("set remote ", jsData);
localPeerConnection.setRemoteDescription(jsData);
} else if (jsData.type === 'candidate') {
let candidate = new RTCIceCandidate({
sdpMLineIndex: jsData.label,
candidate: jsData.candidate
});
localPeerConnection.addIceCandidate(candidate);
// console.log("set candidate ", jsData);
}
}
socket.onclose = function(event) {
if (event.wasClean) {
alert(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
} else {
alert('[close] Connection died');
}
}
}
首先通过websocket连接信令服务器(这里加了tls,如果是本地可以不用)。之后根据turn服务器的配置创建本地的peerConnection。这样基本上所有需要的对象都有了。下面就是对相关socket事件的函数注册,便于peer双方通过信令服务器传递sdp和candidate。
当收到消息的时候,如果是远端offer,则设置调用setRemoteDescription。因为如果是offer类型,表示对方已经设置了本地offer,而我们自己还没有,所以需要创建sdp的answer,并且将设置本地sdp之后发送给对方自己的answer。这个时候对方收到answer,就会设置远端sdp。这样双方就有了对方的媒体信息。
但是要注意媒体协商时候状态的变化,下面是一个相关状态转化图

如果是candidate类型的数据,则表示对方的网络候选对象,直接设置到本地connection的icecandidate内即可。
localPeerConnection.addIceCandidate(candidate)
注册远端流相关回调
function initializeRemoteStream() {
// 对方添加流时触发
localPeerConnection.ontrack = gotRemoteMediaStream;
// 获得自己的ice candidate时触发,将candidate发送给对方
localPeerConnection.onicecandidate = handleIceCandidate;
}
ontrack之前说过,在对方将媒体流关联对应的connection之后,peer方会被触发该方法。gotRemoteMediaStream比较简单,只是设置对应标签的属性。
而onicecandidate是在获得自己相关ice信息时触发,应用开发者不用关心细节,在设置了turn和stun服务器之后,这个过程是webrtc相关组件自动收集。在收集到了之后调用onicecandidate函数。
function handleIceCandidate(event) {
console.log("icecandidate event: ", event);
if (event.candidate) {
// 发送candidate给对方
socket.send(JSON.stringify({
type: 'candidate',
label: event.candidate.sdpMLineIndex,
id: event.candidate.sdpMid,
candidate: event.candidate.candidate
}));
} else {
console.log("End of candidates.");
}
}
在收到candidate信息之后,通过之前和信令服务器建立的连接发送给对方。对方在收到之后,设置对应的candidate即可。这样双方网络协商也就完成了。
call
call方法就是一个实现创建本地offer并设置之后发送的功能。
function call() {
// 发送sdp offer
// 创建RTCPeerConnection对象
if (!localStream) {
alert('please start local stream first');
return;
}
if (!localPeerConnection) {
localPeerConnection = new RTCPeerConnection(turnAndStunConfig);
console.log(localPeerConnection);
}
// 设置本地sdp
localPeerConnection.createOffer(offerOptions)
.then(getOffer)
.catch(handleOfferError);
// 通过stun服务器获取ice candidate
// 设置本地candidate,并通过信令服务器发送给对方
// 接收设置远端candidate(这个时候,双方已经可以进行通信了)
}
function getOffer(desc) {
localPeerConnection.setLocalDescription(desc);
// console.log(desc);
socket.send(JSON.stringify(desc));
}
function handleOfferError(err) {
console.log(err);
}
最后关闭按钮需要做的事情就是对应停止相应的轨,关闭连接(没有关闭信令的连接),并且设置video标签的srcObject为空,以及重新启用按钮。
localHangupBtn.onclick = function() {
if (localPeerConnection) {
localPeerConnection.close();
localPeerConnection = null;
}
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localStream = null;
}
localVideoPlayer.srcObject = null;
localStartBtn.disabled = false;
};
Server端
Server端是非常简单的。只是使用websocket交换两个peer端的相关数据即可。并且限制连接到房间内的peer个数。可以使用js相关的socket.io库,这里内置了“房间的概念”。但是不是很懂js,也写不习惯。所以还是用Go语言。
Golang
main
package main
import (
"flag"
"websockett/handler"
)
var (
addr = flag.String("addr", "localhost", "ws service address")
port = flag.Int("port", 8089, "ws service port")
isLocal = flag.Bool("local", true, "is local")
cert = flag.String("cert", "", "cert file")
key = flag.String("key", "", "key file")
)
func main() {
flag.Parse()
handler.Run(*addr, *port, *isLocal, *cert, *key)
}
用flag来设置命令行的参数,这样方便编译之后设置不同环境。下面是主要逻辑,非常简单了。我这首先定义了一个结构体用于序列化数据。并且定一个Server结构体,里面包含一个向所有连接广播数据的channel。
type Server struct {
Addr string
Port int
// maybe not good in Server struct
broadcast chan Message
}
type Message struct {
Id int64 `json:"id"`
From int64 `json:"from"`
To string `json:"to"`
Content string `json:"content"`
Type string `json:"type"`
}
定义几个变量,相关作用在注释内。
var atomicId atomic.Int64
// 用于复用http端口,升级协议到websocket
var upgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
// 跨域允许
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// 并发不安全
// 设置所有的连接过来的客户端,和对应id
var Clients = make(map[*websocket.Conn]int64)
// 已经连接的客户端
var clientCount = 0
handler
设置对应handler
func handler(w http.ResponseWriter, r *http.Request) {
// Upgrade the HTTP connection to a WebSocket connection
// and return the new connection
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
fmt.Println(err)
return
}
// 只允许两个人连接
if len(Clients) == 2 {
conn.WriteMessage(websocket.TextMessage, []byte("room is full"))
conn.Close()
return
}
// 设置Id
Id := atomicId.Add(1)
Clients[conn] = Id
// 设置客户端连接个数,不是原子操作,小心,其实不所很有所谓,这个没啥用
clientCount++
fmt.Println("client count: ", clientCount)
// conn is now a WebSocket connection
// Use conn to read and write messages.
// 日常每个连接一个goroutine
go simpleHandler(conn, Id)
}
func simpleHandler(conn *websocket.Conn, Id int64) {
defer func() {
conn.Close()
delete(Clients, conn)
clientCount--
fmt.Println("client count: ", clientCount)
}()
for {
// Read message from browser
// 从浏览器发送了相关数据之后,直接通过server的广播通道发送出去
messageType, p, err := conn.ReadMessage()
fmt.Println(messageType)
ms := Message{
Id: Id,
From: Id,
To: "",
Content: string(p),
Type: "text",
}
if err != nil {
fmt.Println(err)
break
}
// Write message back to broadcast
server.broadcast <- ms
}
}
这里很多情况没有判断,只是单纯的限制clients的大小为2,毕竟这只是一个demo。不想写那么多。 http挂一个handler
func InitialRouter() {
http.HandleFunc("/", handler)
}
Broadcast
一个Broadcast函数不断从服务器的广播channel中读取消息,发送给另一个peer端。
func Broadcast() {
for {
select {
case message := <-server.broadcast:
// 不给自己发
for client, id := range Clients {
if message.From == id {
continue
}
// fmt.Println(ms)
b, err1 := json.Marshal(message)
if err1 != nil {
fmt.Println(err1)
continue
}
err := client.WriteMessage(websocket.TextMessage, b)
if err != nil {
client.Close()
delete(Clients, client)
}
}
}
}
}
Run
最后Run起来,初始化路由,将Broadcast运行起来,并且根据参数情况判断要不要用tls。最后等待SIGINT或SIGTERM的信号来结束服务端运行。
func Run(addr string, port int, isLocal bool, cert string, key string) {
InitialRouter()
go Broadcast()
if isLocal {
go func() {
if err := http.ListenAndServe(fmt.Sprintf("%s:%d", addr, port), nil); err != nil {
panic(err)
}
}()
} else {
certFile := cert
keyFile := key
if err := http.ListenAndServeTLS(fmt.Sprintf("%s:%d", addr, port), certFile, keyFile, nil); err != nil {
panic(err)
}
}
fmt.Printf("Server is running on %s:%d\n", addr, port)
quit := make(chan os.Signal)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
fmt.Println("Server shutdown")
}
Go语言部分没啥东西,更多的概念体现其实是在peer端,也就是js代码部分。但是这些只是皮毛。但却是一个开始吧。
总结
这个demo大致展示了webRTC相关概念以及浏览器内的API调用,当然还有相当多的细节这里没有讨论,一部分细节是因为篇幅,一部分是因为我也还没掌握哈哈哈哈哈(但是不代表我看的资料少喔),还有比如datachannel,google拥塞控制,码率控制等等。当然不要看名字WebRTC,但其实在桌面端以及移动端都有相应的api实现,或者称为WebRTC Native。
好了,该走了。快考研了。但是我记得,至少,有突破口。
附录:(前端大佬轻喷css)
html {
font-size: 62.5%;
}
#players {
display: grid;
grid-template-columns: 3fr 7fr 2fr 7fr 3fr;
grid-template-rows: 2fr 7fr 3fr;
}
#local-stream {
grid-column: 2;
}
#remote-stream {
grid-column: 4;
}
.avstream {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
border-radius: 30px;
box-shadow: 10px 10px 10px 0 rgba(0, 0, 0, 0.2);
grid-row: 2;
}
.avstream video {
width: 100%;
height: 80%;
object-fit: cover;
border-top-right-radius: 30px;
border-top-left-radius: 30px;
}
.avtag {
position: absolute;
top: 3rem;
right: 3rem;
font-size: 1.2vw;
z-index: 3;
}
.avtag.local {
color: #00f000;
border: 2px groove #00ff00;
}
.avtag.remote {
color: #f00000;
border: 2px groove #f00000;
}
.player-btns {
width: 100%;
height: 20%;
display: flex;
justify-content: space-evenly;
padding: 2rem 0 1rem 0;
}
.cbtn {
width: 5rem;
height: 5rem;
border-radius: 50%;
border: none;
background-color: #1e90ff;
color: #fff;
box-shadow: 5px 5px 5px 0 rgba(0, 0, 0, 0.2);
cursor: pointer;
}
.cbtn:hover {
animation: bounce-hover 0.5s ease-in-out;
box-shadow: 7px 7px 5px 0 rgba(0, 0, 0, 0.2);
background-color: #00bbf3;
}
/*bounc when click*/
.cbtn:active {
animation: bounce-animation 0.5s ease-in-out;
background-color: #00bbf3;
}
.cbtn:disabled {
background-color: #ccc;
cursor: not-allowed;
}
/* TODO: 这两个动画还需要修改 */
@keyframes bounce-hover {
0% {
transform: scale(1);
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1);
}
}
@keyframes bounce-animation {
0%,
20%,
53% {
transform: none;
animation-timing-function: cubic-bezier(0.33, 1, 0.68, 1);
}
40%,
43% {
transform: translate(0px, -30px) scale(1, 1.1);
animation-timing-function: cubic-bezier(0.64, 0, 0.78, 0);
}
70% {
transform: translate(0px, -15px) scale(1, 1.05);
animation-timing-function: cubic-bezier(0.64, 0, 0.78, 0);
}
80% {
transform: translate(0px, 0px) scale(1, 0.95);
animation-timing-function: cubic-bezier(0.33, 1, 0.68, 1);
}
90% {
transform: translate(0, -4px) scale(1, 1.02);
}
100% {
transform: none;
}
}
评论 (...)
加载中...