实时对讲 webrtc
WebRTC stands for Web Real Time Communication.
WebRTC代表Web实时通信 。
It allows to create a direct data communication between browsers.
它允许在浏览器之间创建直接的数据通信。
You can use it to
你可以用它来
- stream audio 流音频
- stream video 流视频
- share files 分享文件
- video chat 视讯通讯
- create a peer-to-peer data sharing service 创建对等数据共享服务
- create multiplayer games 创建多人游戏
and more.
和更多。
It’s an effort to make real-time communication applications easy to create, leveraging Web technologies, so that no 3rd party plugin or external technology is needed beside your Web browser.
为了使利用Web技术的实时通信应用程序易于创建是一项努力,因此您的Web浏览器旁边不需要第三方插件或外部技术。
There should be no need for a plugin to perform RTC in the future, but all should instead rely on a standard technology - WebRTC.
将来无需再有插件来执行RTC,但所有人都应该依赖于标准技术-WebRTC。
It is supported by all the modern browsers (with partial support from Edge which does not support RTCDataChannel
- see later):
所有现代浏览器都支持它(Edge的部分支持不支持RTCDataChannel
稍后请参见):
WebRTC implements the following APIs:
WebRTC实现以下API:
MediaStream
gets access to data streams from the user’s end, like the camera and the microphoneMediaStream
可以从用户端访问摄像头和麦克风等数据流RTCPeerConnection
handles communication of audio and video streaming between peersRTCPeerConnection
处理对等点之间的音频和视频流通信RTCDataChannel
: handles communication of other kinds of data (arbitrary data)RTCDataChannel
:处理其他类型的数据(任意数据)的通信
With video and audio communication you’ll use MediaStream
and RTCPeerConnection
.
通过视频和音频通信,您将使用MediaStream
和RTCPeerConnection
。
Other kind of application, like gaming, file sharing and others rely on RTCDataChannel
.
其他类型的应用程序,例如游戏,文件共享等,都依赖RTCDataChannel
。
In this article I’ll create an example using WebRTC to connect two remote webcams, using a Websockets server using Node.js.
在本文中,我将创建一个示例,该示例使用WebRTC通过使用Node.js的Websockets服务器连接两个远程摄像头。
Tip: in your projects you’ll likely use a library that abstracts away many of those details. This tutorial aims to explain the WebRTC technology, so you know what is going on under the hood.
提示:在您的项目中,您可能会使用一个抽象许多细节的库。 本教程旨在说明WebRTC技术,因此您可以了解幕后情况。
媒体流 (MediaStream)
This API lets you access the camera and microphone stream using JavaScript.
通过此API,您可以使用JavaScript访问摄像头和麦克风流。
Here is a simple example that asks you to access the video camera and plays the video in the page:
这是一个简单的示例,要求您访问摄像机并在页面中播放视频:
See the Pen WebRTC MediaStream simple example by Flavio Copes (@flaviocopes) on CodePen.
见笔的WebRTC MediaStream简单的例子,由弗拉维奥·科佩斯( @flaviocopes上) CodePen 。
We add a button to get access to the camera, then we add a video
element, with the autoplay
attribute.
我们添加一个按钮以访问摄像机,然后添加一个具有autoplay
属性的video
元素。
We also add the WebRTC Adapter which helps for cross-browser compatibility:
我们还添加了WebRTC适配器 ,该适配器有助于跨浏览器兼容性:
<button id="get-access">Get access to camera</button>
<video autoplay></video>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
The JS listens for a click on the button, then calls navigator.mediaDevices.getUserMedia()
asking for the video.
JS监听按钮的单击,然后调用navigator.mediaDevices.getUserMedia()
询问视频。
See the getUserMedia() tutorial
Then we access the name of the camera used by calling stream.getVideoTracks()
on the result of the call to getUserMedia()
.
然后,我们访问摄像机的调用使用的名称stream.getVideoTracks()
的调用的结果getUserMedia()
The stream is set to be the source object for the video
tag, so that playback can happen:
流被设置为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)
}
})
The arguments of getUserMedia() can specify additional requirements for the video stream:
getUserMedia()的参数可以指定视频流的其他要求:
navigator.mediaDevices.getUserMedia({
video: {
mandatory: { minAspectRatio: 1.333, maxAspectRatio: 1.334 },
optional: [
{ minFrameRate: 60 },
{ maxWidth: 640 },
{ maxHeigth: 480 }
]
}
}, successCallback, errorCallback);
To get an audio stream you would ask for the audio media object too, and call stream.getAudioTracks()
instead of stream.getVideoTracks()
.
要获取音频流,您还需要音频媒体对象,然后调用stream.getAudioTracks()
而不是stream.getVideoTracks()
。
After 3 seconds of playback we stop the video streaming by calling track.stop()
.
播放3秒后,我们通过调用track.stop()
停止视频流。
发信号 (Signaling)
Signaling is not part of the WebRTC protocol but it’s an essential part for real time communication.
信令不是WebRTC协议的一部分,而是实时通信的重要组成部分。
Via signaling, devices communicate between each other and agree on the communication initialization, sharing information such as IP addresses and ports, resolutions and more.
通过信令,设备之间可以相互通信并就通信初始化达成协议,共享IP地址和端口,分辨率等信息。
You are free to choose any kind of communication mechanism, including:
您可以自由选择任何一种通信机制,包括:
- Websockets 网络套接字
- Channel Messaging API 频道讯息API
- XHR XHR
We implement it using Websockets.
我们使用Websockets实现它。
Install ws
using npm:
使用npm安装ws
:
npm init
npm install ws
We start with a simple Websockets server skeleton:
我们从一个简单的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
})
})
We first add a ‘username’ box to our frontend, so the user can pick a username before connecting to the server.
我们首先在前端添加一个“用户名”框,以便用户在连接服务器之前可以选择一个用户名。
<div id="login">
<label for="username">Login</label>
<input id="username" placeholder="Login" required="" autofocus="">
<button id="login">Login</button>
</div>
In the client JavaScript we initialize the Websocket to the server:
在客户端JavaScript中,我们将Websocket初始化为服务器:
const ws = new WebSocket('ws://localhost:8080')
ws.onopen = () => {
console.log('Connected to the signaling server')
}
ws.onerror = err => {
console.error(err)
}
When the user enters the username and clicks the login button we get the username value and we check it, then we send this information to the server:
当用户输入用户名并单击登录按钮时,我们将获得用户名值并进行检查,然后将此信息发送到服务器:
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
is a wrapper function for sending a JSON-encoded message to the Websocket server. We use a type
parameter to separate different kind of messages we’ll send:
sendMessage
是用于将JSON编码的消息发送到Websocket服务器的包装函数。 我们使用type
参数来分隔将发送的不同类型的消息:
const sendMessage = message => {
ws.send(JSON.stringify(message))
}
Server side, we decode the JSON message and we detect the message type
服务器端,我们解码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
}
})
We must add the user to a list of connected users, stored in an associative array users
:
我们必须将用户添加到已连接用户的列表中,并存储在关联数组users
:
const users = {}
If there is another user already with this same username, we send an error to the client, otherwise we add the user to the array, storing the Websocket connection:
如果已经有另一个具有相同用户名的用户,我们将错误发送给客户端,否则我们将该用户添加到数组中,并存储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
Client-side, when this happens we handle the message and we call the getUserMedia()
function:
在客户端,发生这种情况时,我们将处理消息,并调用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)
}
)
Inside the success callback, which gets the local stream object, we first hide the #login
div and we can show a new div that hosts the video
elements:
在获取本地流对象的成功回调内部,我们首先隐藏#login
div,然后可以显示一个托管video
元素的新div:
<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'
so that we can start streaming it on the video#local
element in the page:
这样我们就可以在页面的video#local
元素上开始流式传输:
document.querySelector('video#local').src = window.URL.createObjectURL(localStream)
RTCPeerConnection (RTCPeerConnection)
Now we must configure an RTCPeerConnection.
现在,我们必须配置一个RTCPeerConnection。
There are a few alien terms you’ll find now. ICE stands for Internet Connectivity Establishment, and STUN stands for Session Traversal of User Datagram Protocol [UDP] Through Network Address Translators [NATs])
您现在会发现一些外来术语。 ICE代表Internet连接建立 , STUN代表通过网络地址转换器[NAT]的用户数据报协议[UDP]的会话遍历)
In practice, we must have a way to get 2 computers located in local networks (like your home) to talk to each other. Since most users are behind a NAT router, computers cannot accept incoming connections out of the box.
实际上,我们必须有一种方法可以让位于本地网络(例如您的家庭)中的两台计算机相互通信。 由于大多数用户都在NAT路由器后面,因此计算机无法立即接受传入的连接。
There is a lot of code that’s just needed so we can have 2 endpoints to connect to each other, before the connection takes place.
只是需要很多代码,因此在建立连接之前,我们可以有2个端点相互连接。
The peer connection must be initiated using a STUN server and that server will send back our ICE candidate to communicate with another peer.
对等连接必须使用STUN服务器启动,并且该服务器将发回我们的ICE候选者与另一个对等进行通信。
This is basically what the code below does:
这基本上是以下代码的作用:
//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
})
}
}
We configure an ICE server using the Google public STUN server (which works fine for testing purposes, but you’ll most likely need to configure your own for production use).
我们使用Google公共STUN服务器(可以很好地用于测试目的配置ICE服务器),但是您很可能需要配置自己的ICE服务器以用于生产用途。
Then we add the local stream to that connection using addStream()
, and we pass 2 callback handlers for the RTCPeerConnection.onaddstream
and RTCPeerConnection.onicecandidate
events.
然后,使用addStream()
将本地流添加到该连接,并为RTCPeerConnection.onaddstream
和RTCPeerConnection.onicecandidate
事件传递2个回调处理程序。
RTCPeerConnection.onaddstream
is called when we have a remote audio/video stream coming in, and we assign it to the remote video
element to stream.
当我们有一个远程音频/视频流进入时,将调用RTCPeerConnection.onaddstream
,并将其分配给远程video
元素以进行流传输。
For data, the event would be called RTCPeerConnection.ondatachannel
and instead of using the addStream()
method you would have used createDataChannel()
.
对于数据,该事件将称为RTCPeerConnection.ondatachannel
,而不是使用addStream()
方法,而是使用createDataChannel()
。
RTCPeerConnection.onicecandidate
is called when we receive an ICE candidate, and we send it to our server.
当我们收到ICE候选者时,将调用RTCPeerConnection.onicecandidate
,并将其发送到我们的服务器。
Before this happens we must attempt to connect to a peer.
在这种情况发生之前,我们必须尝试连接到同级。
In this simple example we must know the username of the other person we want to connect to, and they must already be “logged in”.
在这个简单的示例中,我们必须知道我们要连接的其他人的用户名,并且他们必须已经“登录”。
One of the 2 users must enter the username in the box and click the “Call” button.
2个用户之一必须在框中输入用户名,然后单击“呼叫”按钮。
<div>
<input id="username-to-call" placeholder="Username to call" />
<button id="call">Call</button>
<button id="close-call">Close call</button>
</div>
In the client JavaScript we listen for the click event on this button and we get the username value.
在客户端JavaScript中,我们监听该按钮上的click事件,并获得用户名值。
If the username is valid we store it in the otherUsername
variable we’ll use later, and we create an offer.
如果用户名有效,我们将其存储在otherUsername
变量中,我们将在以后使用,并创建一个offer 。
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)
}
)
})
Once we get the offer by calling RTCPeerConnection.createOffer()
, we pass it to our server and we call RTCPeerConnection.setLocalDescription()
to configure the connection.
通过调用RTCPeerConnection.createOffer()
获得报价后,将其传递到服务器,然后调用RTCPeerConnection.setLocalDescription()
来配置连接。
On the server side, we process the offer and we send it to the user that we want to connect to, passed as data.otherUsername
:
在服务器端,我们处理商品并将其发送给我们要连接的用户,并以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
The client receives this offer as a Websocket message, and we call the handleOffer
method:
客户收到此报价作为Websocket消息,我们调用handleOffer
方法:
ws.onmessage = msg => {
//...
switch (data.type) {
//...
case 'offer':
handleOffer(data.offer, data.username)
break
}
}
This method accepts the offer and the username, and we first call RTCPeerConnection.setRemoteDescription()
to specify the properties of the remote end of the connection, then RTCPeerConnection.createAnswer()
to create the answer to the offer.
此方法接受要约和用户名,我们首先调用RTCPeerConnection.setRemoteDescription()
来指定连接远程端的属性,然后RTCPeerConnection.createAnswer()
来创建要约的答案。
Once the answer is created, we use it to set the properties of the local end of the connection and we post it to our server, using the sendMessage
function.
创建答案后,我们将使用它来设置连接本地端的属性,然后使用sendMessage
函数将其发布到服务器。
The RTCSessionDescription
object describes the connection capabilities and must be initialized before the RTC can happen. We must set both the description of the local end of the connection (setLocalDescription
), and the description of the other end of the connection (setRemoteDescription
).
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)
}
)
}
On the server side we handle the answer
event:
在服务器端,我们处理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
We check if the username we want to talk with exists, then we set it as the otherUsername
of the Websocket connection. We send the answer back to that user.
我们检查要与之交谈的用户名是否存在,然后将其设置为Websocket连接的otherUsername
。 我们将答案发送回该用户。
On the client side that user will get the answer
message that triggers the handleAnswer()
method, which calls RTCPeerConnection.setRemoteDescription()
to synchronize the properties of the remote end of the connection:
在客户端,用户将收到触发handleAnswer()
方法的answer
消息,该方法调用RTCPeerConnection.setRemoteDescription()
来同步连接远程端的属性:
ws.onmessage = msg => {
//...
switch (data.type) {
//...
case 'answer':
handleAnswer(data.answer)
break
}
}
const handleAnswer = answer => {
connection.setRemoteDescription(new RTCSessionDescription(answer))
}
Now that the session descriptions have been synchronized, the two peers start to determine how to establish the connection between them, using the ICE protocol. This is the key part that works around the NAT routers limitations.
现在,会话描述已经同步,两个对等方开始确定如何使用ICE协议在它们之间建立连接。 这是解决NAT路由器限制的关键部分。
RTCPeerConnection
produces an ICE candidate and calls its onicecandidate
callback function. In the callback we send the ICE candidate to the other end of connection, using our sendMessage()
function:
RTCPeerConnection
生成一个ICE候选者并调用其onicecandidate
回调函数。 在回调中,我们使用sendMessage()
函数将ICE候选对象发送到连接的另一端:
connection.onicecandidate = event => {
if (event.candidate) {
sendMessage({
type: 'candidate',
candidate: event.candidate
})
}
}
On the server side we handle the candidate
event by sending it to the other peer:
在服务器端,我们通过将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
The other peer receives it on the client:
另一个对等方在客户端上收到它:
ws.onmessage = msg => {
//...
switch (data.type) {
//...
case 'candidate':
handleCandidate(data.candidate)
break
}
}
const handleCandidate = candidate => {
connection.addIceCandidate(new RTCIceCandidate(candidate))
}
We call RTCPeerConnection.addIceCandidate()
to add the candidate locally.
我们调用RTCPeerConnection.addIceCandidate()
在本地添加候选对象。
At this point the ICE exchange steps and session description are complete, negotiation is done and WebRTC can connect the two remote peers, using the connection mechanism that was automatically agreed upon.
至此,ICE交换步骤和会话描述完成,协商完成,WebRTC可以使用自动约定的连接机制连接两个远程对等方。
We now have 2 computers directly communicating to each other exchanging their webcam streams!
现在,我们有2台计算机,彼此直接通信,交换网络摄像头流!
断开连接 (Closing the connection)
The connection can be closed programmatically. We have a button “Close call” that we can click once the connection has been made:
可以通过编程方式关闭连接。 建立连接后,我们可以单击“关闭呼叫”按钮:
<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
}
On the client side we remove the remote streaming and we close the RTCPeerConnection
connection, setting the callback for its events to null.
在客户端,我们删除远程流,并关闭RTCPeerConnection
连接,将其事件的回调设置为null。
We send the close
message to the server, which in turn sends it to the remote peer:
我们将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
so in the client side we can call the handleClose()
function:
因此,在客户端,我们可以调用handleClose()
函数:
ws.onmessage = msg => {
//...
switch (data.type) {
//...
case 'close':
handleClose()
break
}
}
The complete example is available on this Gist:
完整的示例可在以下要点中找到:
实时对讲 webrtc