WebRTC是指网络实时通信。
它允许在浏览器之间创建一个直接的数据通信。
你可以用它来
- 流媒体音频
- 流媒体视频
- 分享文件
- 视频聊天
- 创建一个点对点的数据共享服务
- 创建多人游戏
以及更多。
这是一项努力,使实时通信应用易于创建,利用网络技术,因此,在你的网络浏览器旁边不需要第三方插件或外部技术。
未来应该不需要插件来执行RTC,而应该全部依靠一种标准技术--WebRTC。
所有的现代浏览器都支持它(Edge部分支持,它不支持RTCDataChannel ,见下文)。

WebRTC实现了以下的API。
- **
MediaStream**从用户端获取数据流,如摄像头和麦克风 - **
RTCPeerConnection**处理对等体之间的音频和视频流的通信 - **
RTCDataChannel**处理其他类型的数据(任意数据)的通信
对于视频和音频通信,你将使用MediaStream 和RTCPeerConnection 。
其他类型的应用,如游戏、文件共享等,则依靠RTCDataChannel 。
在这篇文章中,我将创建一个使用WebRTC连接两个远程网络摄像头的例子,使用Node.js的Websockets服务器。
提示:在你的项目中,你可能会使用一个抽象了许多细节的库。本教程旨在解释WebRTC技术,让你知道引擎盖下发生了什么。
这个API让你可以使用JavaScript访问摄像头和麦克风流。
这里有一个简单的例子,要求你访问摄像机并在页面中播放视频。
请看Flavio Copes(@flaviocopes)在CodePen上的PenWebRTC MediaStream简单例子。
我们添加一个按钮来访问摄像机,然后我们添加一个video ,属性为autoplay 。
我们还添加了WebRTC适配器,这有助于实现跨浏览器的兼容性。
<button id="get-access">Get access to camera</button>
<video autoplay></video>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
JS监听按钮的点击,然后调用navigator.mediaDevices.getUserMedia() ,要求提供视频。
然后我们通过调用stream.getVideoTracks() ,在调用getUserMedia() 的结果中访问所使用的摄像机的名称。
流被设置为video 标签的源对象,这样就可以进行播放。
document
.querySelector('#get-access')
.addEventListener('click', async function init(e) {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true
})
document.querySelector('video').srcObject = stream
document.querySelector('#get-access').setAttribute('hidden', true)
setTimeout(() => {
track.stop()
}, 3 * 1000)
} catch (error) {
alert(`${error.name}`)
console.error(error)
}
})
getUserMedia()的参数可以指定对视频流的额外要求。
navigator.mediaDevices.getUserMedia(
{
video: {
mandatory: { minAspectRatio: 1.333, maxAspectRatio: 1.334 },
optional: [{ minFrameRate: 60 }, { maxWidth: 640 }, { maxHeigth: 480 }]
}
},
successCallback,
errorCallback
)
要获得音频流,你也会要求获得音频媒体对象,并调用stream.getAudioTracks() ,而不是stream.getVideoTracks() 。
在播放3秒后,我们通过调用track.stop() ,停止视频流。
信令
信令不是WebRTC协议的一部分,但它是实时通信的一个重要部分。
通过信令,设备之间进行通信,并就通信初始化达成一致,共享信息,如IP地址和端口、分辨率等等。
你可以自由选择任何一种通信机制,包括。
我们使用Websockets来实现它。
使用npm安装ws 。
我们从一个简单的Websockets服务器骨架开始。
const WebSocket = require('ws')
const wss = new WebSocket.Server({ port: 8080 })
wss.on('connection', (ws) => {
console.log('User connected')
ws.on('message', (message) => {
console.log(`Received message => ${message}`)
})
ws.on('close', () => {
//handle closing
})
})
我们首先在前台添加一个 "用户名 "框,这样用户就可以在连接到服务器之前选择一个用户名。
<div id="login">
<label for="username">Login</label>
<input id="username" placeholder="Login" required="" autofocus="" />
<button id="login">Login</button>
</div>
在客户端的JavaScript中,我们初始化Websocket到服务器。
const ws = new WebSocket('ws://localhost:8080')
ws.onopen = () => {
console.log('Connected to the signaling server')
}
ws.onerror = (err) => {
console.error(err)
}
当用户输入用户名并点击登录按钮时,我们得到用户名的值并对其进行检查,然后我们将这些信息发送给服务器。
document.querySelector('button#login').addEventListener('click', (event) => {
username = document.querySelector('input#username').value
if (username.length < 0) {
alert('Please enter a username 🙂')
return
}
sendMessage({
type: 'login',
username: username
})
})
sendMessage 是一个封装函数,用于向Websocket服务器发送JSON编码的信息。我们使用一个 参数来区分我们要发送的不同类型的信息。type
const sendMessage = (message) => {
ws.send(JSON.stringify(message))
}
在服务器端,我们对JSON消息进行解码,并检测消息的类型。
ws.on('message', (message) => {
let data = null
try {
data = JSON.parse(message)
} catch (error) {
console.error('Invalid JSON', error)
data = {}
}
switch (data.type) {
case 'login':
console.log('User logged', data.username)
break
}
})
我们必须将用户添加到连接的用户列表中,存储在一个关联数组users 。
如果已经有另一个用户拥有这个相同的用户名,我们就向客户端发送一个错误,否则我们就将用户添加到数组中,存储Websocket连接。
//...
case 'login':
console.log('User logged', data.username)
if (users[data.username]) {
sendTo(ws, { type: 'login', success: false })
} else {
users[data.username] = ws
ws.username = data.username
sendTo(ws, { type: 'login', success: true })
}
break
在客户端,当这种情况发生时,我们处理消息,并调用getUserMedia() 函数。
ws.onmessage = (msg) => {
console.log('Got message', msg.data)
const data = JSON.parse(msg.data)
switch (data.type) {
case 'login':
handleLogin(data.success)
break
}
}
//handleLogin...
navigator.mediaDevices.getUserMedia(
{ video: true, audio: true },
(localStream) => {
//...
},
(error) => {
console.error(error)
}
)
在获得本地流对象的成功回调中,我们首先隐藏#login div,我们可以显示一个新的div,承载video 的元素。
<div id="call">
<video id="local" autoplay></video>
<video id="remote" autoplay></video>
</div>
document.querySelector('div#login').style.display = 'none'
document.querySelector('div#call').style.display = 'block'
这样我们就可以在页面中的video#local 元素上开始流媒体。
document.querySelector('video#local').src = window.URL.createObjectURL(
localStream
)
RTCPeerConnection
现在我们必须配置一个RTCPeerConnection。
现在你会发现有几个外来的术语。ICE代表Internet Connectivity Establishment,STUN代表Session Traversal of User Datagram Protocol [UDP] Through Network Address Translators [NATs])
在实践中,我们必须有一种方法让位于本地网络(如你的家)的2台计算机相互交谈。由于大多数用户都在NAT路由器后面,所以计算机不能接受开箱即来的连接。
在连接发生之前,有很多代码是需要的,所以我们可以让2个端点互相连接。
对方连接必须使用STUN服务器发起,该服务器将发回我们的ICE候选者,以便与另一个对方进行通信。
这基本上就是下面的代码所做的。
//using Google public stun server
const configuration = {
iceServers: [{ url: 'stun:stun2.1.google.com:19302' }]
}
connection = new RTCPeerConnection(configuration)
connection.addStream(localStream)
connection.onaddstream = (event) => {
document.querySelector('video#remote').srcObject = event.stream
}
connection.onicecandidate = (event) => {
if (event.candidate) {
sendMessage({
type: 'candidate',
candidate: event.candidate
})
}
}
我们使用谷歌的公共STUN服务器配置一个ICE服务器(这对测试来说很好用,但你很可能需要为生产使用配置你自己的)。
然后我们使用addStream() ,将本地流添加到该连接中,并为RTCPeerConnection.onaddstream 和RTCPeerConnection.onicecandidate 事件传递两个回调处理程序。
RTCPeerConnection.onaddstream 当我们有一个远程音频/视频流进来的时候,就会被调用,我们把它分配给远程的 元素来进行传输。video
对于数据,该事件将被称为RTCPeerConnection.ondatachannel ,而不是使用addStream() 方法,你将使用createDataChannel() 。
RTCPeerConnection.onicecandidate 当我们收到一个候选的ICE时,就会调用这个事件,并将其发送到我们的服务器。
在这之前,我们必须尝试连接到一个对等体。
在这个简单的例子中,我们必须知道我们想要连接的另一个人的用户名,而且他们必须已经 "登录"。
两个用户中的一个必须在方框中输入用户名,然后点击 "呼叫 "按钮。
<div>
<input id="username-to-call" placeholder="Username to call" />
<button id="call">Call</button>
<button id="close-call">Close call</button>
</div>
在客户端的JavaScript中,我们监听这个按钮的点击事件,并获得用户名的值。
如果用户名是有效的,我们就把它储存在我们稍后要使用的otherUsername 变量中,然后我们创建一个报价。
let otherUsername
document.querySelector('button#call').addEventListener('click', () => {
const callToUsername = document.querySelector('input#username-to-call').value
if (callToUsername.length === 0) {
alert('Enter a username 😉')
return
}
otherUsername = callToUsername
// create an offer
connection.createOffer(
(offer) => {
sendMessage({
type: 'offer',
offer: offer
})
connection.setLocalDescription(offer)
},
(error) => {
alert('Error when creating an offer')
console.error(error)
}
)
})
一旦我们通过调用RTCPeerConnection.createOffer() ,得到了报价,我们就把它传递给我们的服务器,然后我们调用RTCPeerConnection.setLocalDescription() ,配置连接。
在服务器端,我们处理该提议,并将其发送给我们想要连接的用户,以data.otherUsername 。
case 'offer':
console.log('Sending offer to: ', data.otherUsername)
if (users[data.otherUsername] != null) {
ws.otherUsername = data.otherUsername
sendTo(users[data.otherUsername], {
type: 'offer',
offer: data.offer,
username: ws.username
})
}
break
客户端以Websocket消息的形式收到这个提议,我们调用handleOffer 方法。
ws.onmessage = (msg) => {
//...
switch (data.type) {
//...
case 'offer':
handleOffer(data.offer, data.username)
break
}
}
这个方法接受要约和用户名,我们首先调用RTCPeerConnection.setRemoteDescription() ,指定连接的远程端属性,然后调用RTCPeerConnection.createAnswer() ,创建要约的答案。
一旦答案被创建,我们就用它来设置连接的本地端属性,并使用sendMessage 函数将其发布到我们的服务器。
RTCSessionDescription 对象描述了连接能力,在RTC发生之前必须被初始化。我们必须设置连接的本地端描述(setLocalDescription )和连接的另一端描述(setRemoteDescription )。
const handleOffer = (offer, username) => {
otherUsername = username
connection.setRemoteDescription(new RTCSessionDescription(offer))
connection.createAnswer(
(answer) => {
connection.setLocalDescription(answer)
sendMessage({
type: 'answer',
answer: answer
})
},
(error) => {
alert('Error when creating an answer')
console.error(error)
}
)
}
在服务器端,我们处理answer 事件。
case 'answer':
console.log('Sending answer to: ', data.otherUsername)
if (users[data.otherUsername] != null) {
ws.otherUsername = data.otherUsername
sendTo(users[data.otherUsername], {
type: 'answer',
answer: data.answer
})
}
break
我们检查我们想要交谈的用户名是否存在,然后我们将其设置为Websocket连接的otherUsername 。我们将答案发回给该用户。
在客户端,该用户将收到触发handleAnswer() 方法的answer 消息,该方法调用RTCPeerConnection.setRemoteDescription() ,以同步连接的远程端属性。
ws.onmessage = (msg) => {
//...
switch (data.type) {
//...
case 'answer':
handleAnswer(data.answer)
break
}
}
const handleAnswer = (answer) => {
connection.setRemoteDescription(new RTCSessionDescription(answer))
}
现在会话描述已经同步,两个对等体开始确定如何使用ICE协议在它们之间建立连接。这是绕过NAT路由器限制的关键部分。
RTCPeerConnection 在这个过程中,我们会产生一个ICE候选者并调用其 回调函数。在回调中,我们使用我们的 函数,将ICE候选者发送到连接的另一端。onicecandidate sendMessage()
connection.onicecandidate = (event) => {
if (event.candidate) {
sendMessage({
type: 'candidate',
candidate: event.candidate
})
}
}
在服务器端,我们处理candidate 事件,将其发送给其他对等体。
//...
case 'candidate':
console.log('Sending candidate to:', data.otherUsername)
if (users[data.otherUsername] != null) {
sendTo(users[data.otherUsername], {
type: 'candidate',
candidate: data.candidate
})
}
break
另一个对等体在客户端收到它。
ws.onmessage = (msg) => {
//...
switch (data.type) {
//...
case 'candidate':
handleCandidate(data.candidate)
break
}
}
const handleCandidate = (candidate) => {
connection.addIceCandidate(new RTCIceCandidate(candidate))
}
我们调用RTCPeerConnection.addIceCandidate() ,在本地添加候选人。
在这一点上,ICE交换步骤和会话描述已经完成,协商已经完成,WebRTC可以使用自动商定的连接机制,连接两个远程对等体。
我们现在有两台计算机直接互相通信,交换他们的网络摄像头流
关闭连接
连接可以通过编程方式关闭。我们有一个 "关闭呼叫 "的按钮,一旦连接完成,我们可以点击它。
<button id="close-call">Close call</button>
document.querySelector('button#close-call').addEventListener('click', () => {
sendMessage({
type: 'close'
})
handleClose()
})
const handleClose = () => {
otherUsername = null
document.querySelector('video#remote').src = null
connection.close()
connection.onicecandidate = null
connection.onaddstream = null
}
在客户端,我们删除远程流,我们关闭RTCPeerConnection 连接,将其事件的回调设置为null。
我们将close 消息发送给服务器,服务器再将其发送给远程对等体。
case 'close':
console.log('Disconnecting from', data.otherUsername)
users[data.otherUsername].otherUsername = null
if (users[data.otherUsername] != null) {
sendTo(users[data.otherUsername], { type: 'close' })
}
break
所以在客户端我们可以调用handleClose() 函数。
ws.onmessage = (msg) => {
//...
switch (data.type) {
//...
case 'close':
handleClose()
break
}
}
完整的例子可以在这个Gist上找到。