最近键盘坏了,恰好看到掘金有声网的技术征文,想整个键盘。因而就开始从零开始学习webrtc, 一开始看文档就是个素质三连。这么难啊,这咋整啊,这谁顶的住啊。因而就开始全网找资料,很幸运的在掘金上找到了江三疯大佬的webrtc系列,以及WebRTC实时通讯系列教程,或者英文原版的Real time communication with WebRTC,有兴趣的同窗也能够去看下,很是棒。既然有这么棒的文章为啥还要再写篇文章呢,那固然是分(zheng)享(ge)经(jian)验(pan)啦。鉴于本身耗时将近三周的学习加项目,项目写着写着就破千行了(枯惹),虽然中途有事情耽误了一段时间,可是也是花费了我极其大的精力,踩了无数的坑,这里我会尽量从最基础开始用简答易懂的方式,带领你们完成一个较完整你画我猜。文章可能会很长,能够慢慢看。有些知识点不须要那么详细,为了让你思路更清晰会省略介绍,有兴趣的能够本身去看。javascript
Github地址:你画我猜css
欢迎Star!html
WebRTC (Web Real-Time Communication)是一个能够用在视频聊天,音频聊天或P2P文件分享等Web App中的 API。html5
全名叫web的实时通讯,从官方文档能够看出来他能够用来视频聊天,音频聊天,端对端(p2p),数据传输,文件分享的一个api。如今的直播用的就是这个技术java
webrtc下有三个重要的api,正好对应三个功能。git
首先咱们先实现一个简单的获取视频和音频而且显示在网页上es6
javasrcipt
// 获取本地的视频和音频流,{ audio: true, video: true }都是true这两个都获取
let localStream
navigator.mediaDevices.getUserMedia({ audio: true, video: true })
.then((stream) => {localStream = stream})
//找到video标签,用一个video来接受流,而且显示
let video = document.querySelector("#video")
// 使用srcObject给video添加流
video.srcObject = localStream
html
<video id="video" autoplay style="width:600; height:400;"></video>
复制代码
由于咱们这里只须要得到数据流,这里就不具体的解释api,咱们能够去看官方文档MDN。 从这里能够看咱们只须要一个简单的api就能得到到本地的视频和音频流,咱们最后确定是须要将这个流发送到其余的客户端的,如何发送流呢,咱们经过RTCPeerConnection来进行链接以及流的传输。github
navigator.getUserMedia 目前是仍是支持的。可是在官方文档中已经不推荐使用,应该使用navigator.MediaDevices上的getUserMedia(),可是该api目前不是全部浏览器都支持,有兼容性问题 web
为了不兼容性问题,咱们能够用如下代码来进行兼容性适配canvas
//浏览器不支持navigator.mediaDevices
if (navigator.mediaDevices == undefined) {
navigator.mediaDevices = {}
navigator.mediaDevices.getUserMedia = function (constraints) {
//得到旧版的getUserMedia
let getUserMedia = navigator.webkitGetUserMedia || navigator.mozGetUserMedia
//浏览器就不支持getUserMedia这个api,则返回个错误
if (!getUserMedia) {
return Promise.reject(new Error('getUserMedia is can not use in the browser'))
}
// getUserMedia是异步的,因此用Promise,将返回一个绑定在navigator上的getUserMedia
return new Promise((resolve, reject) => {
getUserMedia.call(navigator, constraints, resolve, reject)
})
}
}
复制代码
这是实现端对端(既不经过服务器进行数据交换)链接的最重要的api,这也是最难理解的一部分。
端对端的链接第一次是须要借助服务器来链接的,须要服务器来进行中转,当第一次链接上后就不须要再经过服务器了。这里咱们使用socket.io,以及一点点koa,这个咱们后面再讲。也有其余方式咱们这里不讲有兴趣的能够看江三疯大佬的文章。总之第一次是须要服务器来实现两端的链接。
接下来是具体的交换过程
建立RTCPeerConnection的实例
let PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection
let peer = new PeerConnection(iceServers)
复制代码
这里有个参数iceServers,参数中存在两个属性,分别是stun和turn。是用于NAT穿透的,具体能够看WebRTC in the real world: STUN TURN and signaling
{
iceServers: [
{ url: "stun:stun.l.google.com:19302"}, // 谷歌的公共服务
{
url: "turn:***",
username: ***, // 用户名
credential: *** // 密码
}
]
}
复制代码
NAT
先说下咱们为何要用NAT穿透技术才能实现p2p的链接。
NAT全称(Network Address Translation,网络地址转换),是用于网络的地址交换,这会致使咱们得不到设备真实的ip地址
因为外网用的是IPV4的地址码,致使地址码的数量不够,因而就将会使用路由之类的NAT设备将外网的ip地址以及端口号都修改并使用IPV6的地址,使得多个内网能够该外网。这样增长了网络链接数量,可是却使得咱们没法从内网直接找到对方的内网,因此咱们须要进行NAT穿透,来实现端对端的链接。
NAT穿透的大体步骤是如A,B两端,A段向B端发送一条信息,这条信息是会被NAT设备给丢弃,可是会在NAT上留下一个洞,下次信息就能够经过这个洞来传输,同理B也这一发送一条信息,来打通本身的NAT设备。具体实现使用STUN和TURN来进行NAT穿透,该过程是经过STUN Server来进行NAT穿透,若是没法穿透则须要使用TURN Server来进行中转,具体是如何穿透的能够看ICE协议下NAT穿越的实现(STUN&TURN),另外咱们能够搭建本身的STUN 和 TURN,本身动手搭建 WebRTC TURN&STUN 服务器
- STUN(Simple Traversal of User Datagram Protocol through Network Address Translators (NATs),NAT的UDP简单穿越)是一种网络协议
- TURN的全称为Traversal Using Relay NAT,TURN协议容许NAT或者防火墙后面的对象能够经过TCP或者UDP接收到数据
P2P
如今咱们已经了解了NAT穿透,如今让咱们用PeerConnection来实现p2p链接。上文中咱们已经建立了PeerConnection的实例,咱们称他为localPeer,remotePeer。如今咱们来交换本地和远程的sdp数据描述,先上代码。
localPeer.createOffer()
.then(offer => localPeer.setLocalDescription(offer))
.then(() => remotePeer.setRemoteDescription(localPeer.localDescription))
.then(() => remotePeer.createAnswer())
.then(answer => remotePeer.setLocalDescription(answer))
.then(() => localPeer.setRemoteDescription(remotePeer.localDescription))
复制代码
实现交换本地和远程的sdp数据描述和咱们以前的NAT穿透的步骤很像。
createOffer()
api来建立一个offer类型的sdp,并使用setLocalDescription()
将其添加到localDescription
,这里咱们只是在本地创建p2p,不须要服务器,来第一次链接localDescription
,并使用setRemoteDescription
将其添加到本身的RemoteDescription
createAnswer()
建立一个answer类型的sdp,并将其添加到本身的LocalDescription
localDescription
添加为本身的remoteDescription
到这里两端的sdp数据交换就已经完成,也就表明了本地的p2p已经链接好了,可是咱们这里是在同一个界面建立了两个端,是没法真正的p2p,若是要使用网络的p2p咱们就须要使用ice实现网络的对等链接,而且还须要socket.io来创建第一次数据传输
SDP
SDP(Session Description Protocol,会话描述协议) 它不属于传输协议, 可是可使用多种的传输协议,包括会话通知协议(SAP)、会话初始协议(SIP)、实时流协议(RTSP)、MIME 扩展协议的电子邮件以及超文本传输协议(HTTP)。
这是一个具体的sdp,是本地媒体元数据,详情能够去看P2P通讯标准协议(三)之ICE
v=0
o=- 1877521640243013583 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1 2
a=msid-semantic: WMS
m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126
复制代码
让咱们再看下offer
能够看到offer是一个offer类型的sdp,answer也是同理
ICE
ICE的全称为Interactive Connectivity Establishment,即交互式链接创建。ICE是一个用于在offer/answer模式下的NAT传输协议,主要用于UDP下多媒体会话的创建,使用了STUN协议以及TURN 协议
若是咱们须要实现网络的p2p就须要进行两端的ice协议链接。这里咱们须要用到
RTCPeerConnection.onicecandidate()
api用于监视本地ice网络的变化,若是有了就将其使用socket.io发送出去,RTCPeerConnection.addIceCandidate()
用于将收到的ice添加到本地的RTCPeerConnection实例中。传输stream流 当创建好了p2p后咱们可使用RTCPeerConnection实例中的
onaddstream()在接送端answer的setRemoteDescription执行完成后会当即执行,也就是说咱们不能在p2p建立完成后在使用addstream来添加流。
addstream()和onaddstream()已经在官方文档中不推荐使用,咱们最好使用更新的addTrack()和onaddTrack(),有兴趣能够看MDN
RTCDataChannel用于p2p中的数据通道,咱们使用的是RTCPeerConnection中的createDataChannel()
来建立一个TCDataChannel实例。这里咱们假设建立了一个实例叫channel,这里咱们须要的api有
//发送数据hello
channel.send(JSON.stringify('hello'))
// 监听channel的状态
peer.ondatachannel = (event) => {
var channel = event.channel
channel.binaryType = 'arraybuffer'
channel.onopen = (event) => { // 链接成功
console.log('channel onopen')
}
channel.onclose = function(event) { // 链接关闭
console.log('channel onclose')
}
channel.onmessage = (event) => { // 收到消息
let data = JSON.parse(event.data)
console.log('channel onmessage', data)
}
}
复制代码
到这里咱们的webrtc基础已经写完了,咱们虽然webrtc是一个不须要服务器的p2p,可是咱们第一次链接是须要服务器来帮咱们找到响应的端的,从而将offer,answer,ice等信息进行交互,创建p2p链接。接下来咱们就使用koa和socket.io做为服务器来进行首次的链接,以及一些业务逻辑交互。
koa
koa是一个为一个HTTP服务的中间件框架,极其的轻量级,几乎没有集成,不少功能须要咱们安装插件才能使用。而且使用的是es6的语法,使用的是async来实现异步。
咱们须要建立一个server.js来部署服务器。
import Koa from 'koa'
import { join } from 'path'
import Static from 'koa-static'
import Socket from 'socket.io'
// 建立一个socket.io
const io = new Socket({
options : {
pingTimeout: 10000,
pingInterval: 5000
}
})
// 建立koa
const app = new Koa()
// socket注入app
io.attach(app)
// 添加指定静态web文件的Static路径
// Static(root, opts) 这里将public做为根路径
app.use(Static(
// join 拼接路径
// __dirname返回被执行文件夹的绝对路径
join( __dirname, './public')
))
// 服务器端口号,这里两个listen外面的是socket.io的,后面一个是koa的listen,须要将socket监听koa的端口,否则会报错
io.listen(app.listen(3000, () => {
console.log('server start at port: ' + 3000)
}))
复制代码
socket.io
咱们先来介绍下WebSocket网络协议,他是不一样于http协议的一种,具体能够看websocket
socket.io是服务器使用的是WebSocket网络协议,是HTML5新增的一种通讯协议,其特色是服务端能够主动向客户端推送信息,客户端也能够主动向服务端发送信息,是真正的双向平等对话,属于服务器推送技术的一种。
这样咱们就能够经过两端的主动发送打服务器,以及服务器主动发送到双端,来实现交互。 咱们须要使用socket.io的api
首先客户端和服务器端相互链接。因为服务器端设置了端口号为3000,咱们的html页端的socket服务器
// html
// 引入
<script src="https://cdn.bootcss.com/socket.io/2.2.0/socket.io.js"></script>
// 链接3000端口
var socket = io('ws://localhost:3000/')
// server.js
// 监听链接
// io是服务器端的, socket是客户端的
io.on('connection', socket => {
...
})
// 监听关闭
io.on('disconnect', socket => {})
复制代码
咱们经过socket的来实现webrtc的第一次链接
// A 向 B 的p2p
// html
// A
// user 是全局变量,存在sessionStorage中, 建立时候获取
var user = window.sessionStorage.user || ''
// 发给服务器改socket的名称
socket.emit('createUser', 'A')
// 兼容性
let PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection
var peer = new PeerConnection()
// 建立A端的offer
peer.createOffer()
.then(offer => {
// 设置A端的本地描述
peer.setLocalDescription(offer, () => {
// socket发送offer和房间
socket.emit('offer', {offer: offer, user: 'B'})
})
})
// 监听本地的ice变化,有则发送个B
peer.onicecandidate = (event) => {
if (event.candidate) {

// B
// user 是全局变量,存在sessionStorage中, 建立时候获取
var user = window.sessionStorage.user || ''
// 发给服务器改socket的名称
socket.emit('createUser', 'A')
let PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection
var peer = new PeerConnection()
// 接受服务器端发过来的offer辨识的数据
socket.on('offer', date => {
// 设置B端的远程offer 描述
peer.setRemoteDescription(data.offer, () => {
// 建立B的Answer
peer.createAnswer()
.then(answer => {
// 设置B端的本地描述
peer.setLocalDescription(answer, () => {
socket.emit('answer', {answer: answer, user: 'A'})
})
})
})
})
socket.on('ice', data => {
// 设置B ICE
peer.addIceCandidate(data.candidate);
})
socket.emit('createUser', 'B')
// server.js
// 用于接受客户端的用户名对应的服务器
const sockets = {}
// 保存user
const users = {}
io.on('connection', data => {
// 建立帐户
socket.on('createUser', data => {
let user = new User(data)
users[data] = user
sockets[data] = socket
})
socket.on('offer', data => {
// 经过B的socket的id只发送给B
socket.to(sockets[data.user].id).emit('offer', data)
})
socket.on('answer', data => {
// 经过B的socket的id只发送给A
socket.to(sockets[data.user].id).emit('answer', data)
})
socket.on('ice', data => {
// ice发送给B
socket.to(sockets[data.user].id).emit('ice', data)
})
})
复制代码
以上就是经过socket.io来实现p2p的第一次链接。和咱们在webrtc基础的过程是同样的,只是经过了server.js来进行中转。在以后的业务逻辑中咱们须要对多种不一样的服务器群进行广播,这里咱们来扩展下socket的广播的种类。
到这里关于socket.io的咱们一些api的使用和使用socket.io来实现p2p咱们已经了解了,接下来咱们将下关于canvas实现一个画板
cnavas是html5中的画板,咱们能够用它来实如今html上的绘画功能,这里咱们的画板也是用这个作的。 实现画板咱们用一个类来进行封装,须要实现如下的功能
因此咱们能够写出咱们的canvas的绘制类
// 建立绘图类
class Draw {
constructor(canvas, callBack) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')
this.width = this.canvas.width
this.height = this.canvas.height
this.color = color
this.weight = weight
this.isMove = false
this.option = ''
// 保存每次鼠标按下并抬起的所绘制的图片,用于撤回,前进
this.imgData = []
// 记录当前帧
this.index = 0
// 如今的坐标
this.now = [0, 0]
// 移动前的坐标
this.last = [0, 0]
this.bindMousemove = this.onmousemove.bind(this)
this.callBack = callBack || function() {}
}
// 初始化
init() { }
// 监听鼠标按下
onmousedown(event) { }
// 监听鼠标移动
onmousemove(event) { }
// 监听鼠标抬起
onmouseup() { }
//绘制线条
line(last, now, weight, color) { }
// 橡皮
eraser(last, now, weight) { }
// 回退
back() { }
// 前进
go() { }
// 清除
clear() { }
// 收集每一帧的图片
getImage() { }
// 绘制当前帧的图片
putImage() { }
// 设置尺寸
setWeight(weight) { }
// 设置颜色
setColor(color) { }
// 全部的操做的合集
options(option, data) { }
}
复制代码
咱们来具体实现下这些方法
操做合集
options(option, data) {
switch (option) {
case 'pen': {
this.line(...data)
this.callBack('pen', data)
break
}
case 'eraser': {
this.eraser(...data)
this.callBack('eraser', data)
break
}
case 'getImage': {
this.callBack('getImage')
this.getImage()
break
}
case 'go': {
this.callBack('go')
this.go()
break
}
case 'back': {
this.callBack('back')
this.back()
break
}
case 'clear': {
this.callBack('clear')
this.clear()
break
}
case 'setWeight': {
this.callBack('setWeight', data)
this.setWeight(data)
break
}
case 'setColor': {
this.callBack('setColor', data)
this.setColor(data)
break
}
}
}
复制代码
这里咱们将全部操做的调用都放在一个方法中,这样有利于代码的重构,可是这样作最主要的目的是为了,当咱们将每一个操做的回调函数写在option方法中而不写在具体操做的方法中,这样能够避免当咱们使用回调函数把参数传递出去的后,接收端使用该方法更新了本身的canvas后又会调用回调致使两端的无限回调。
画笔和橡皮
咱们实现画笔的思路是当鼠标按下时,咱们监听鼠标的移动,鼠标以移动就将鼠标的位置参数传递给options函数,options函数经过this.option来识别是画笔仍是橡皮,调用响应的函数。当鼠标抬起时,结束移动事件的监听,并将当前帧进行保存,而且调用callback函数将保存针的信息传递出去。
onmousedown(event) {
this.last = [event.offsetX, event.offsetY]
this.canvas.addEventListener('mousemove', this.bindMousemove)
}
onmousemove(event) {
this.isMove = true
this.now = [event.offsetX, event.offsetY]
let data = [
this.last,
this.now,
this.weight,
this.color
]
this.options(this.option, data)
}
onmouseup() {
this.canvas.removeEventListener('mousemove', this.bindMousemove)
if (this.isMove) {
this.isMove = false
this.options('getImage')
}
}
line(last, now, weight, color) {
this.ctx.beginPath()
this.ctx.lineCap = 'round'
this.ctx.lineJoin = 'round'
this.ctx.lineWidth = weight
this.ctx.strokeStyle = color
this.ctx.moveTo(last[0], last[1])
this.ctx.lineTo(now[0], now[1])
this.ctx.closePath()
this.ctx.stroke()
this.last = now
}
eraser(last, now, weight) {
this.ctx.save()
this.ctx.beginPath()
// console.log(now[0] , now[1])
this.ctx.arc(now[0], now[1], weight, 0, 2 * Math.PI)
this.ctx.closePath()
this.ctx.clip()
this.ctx.clearRect(0, 0, this.width, this.height)
this.ctx.fillStyle = '#fff'
this.ctx.fillRect(0, 0, this.width, this.height)
this.ctx.restore()
}
复制代码
画笔的具体实现
橡皮的具体实现
更多细节能够看canvas绘制形状
前进和回退
前进和回退的是每当鼠标抬起时咱们算一针,经过canvas的
清除,设置参数
到这里咱们的canvas用到的技术已经介绍完毕
视频模式有好几种,具体能够去在视频模式,不一样的模式处理不一样的状况,不过咱们这里使用的是p2p多对多的链接。由于是p2p,因此要实现多对多,那就能够变成每一个的一对一。就是经过每一个端都进行p2p链接。这里咱们须要注意添加的顺序问题。这里咱们是当有人进入房间时,进入的人和房间每个进行p2p,已经进入的就只和进入的进行p2p。这样就能够所有都是p2p
// nat链接方法
function createPeers(data) {
if (user !== data.joinUser) {
let conn = [data.joinUser, user].join('-')
if (!peers[conn]) {
initPeer(conn)
}
} else if (data.joinUser === user) {
if (data.roomusers.length > 1) {
data.roomusers.forEach(roomuser => {
if (roomuser.name !== user) {
let conn = [data.joinUser, roomuser.name].join('-')
if (!peers[conn]) {
// initPeer和以前差很少,就多了将新建的Peer和channel加入数组
initPeer(conn)
}
}
})
}
}
}
复制代码
咱们在每一个客户端都使用了一个数组来进行存储。经过加入的和现有的user进行标示,来标示不一样的p2p。
每一个p2p的具体实现
和以前单个的相同,只是咱们会经过for循环来遍历数组,将每一个房间内的人都会去发送offer
// 新建对每一个已经在房间的offer
if (data.joinUser === user) {
for (let conn in peers) {
// conn标示
createoffer(conn, peers[conn])
}
}
function createoffer(conn, peer) {
peer.createOffer({
offerToReceiveAudio: 1,
offerToReceiveVideo: 1
})
.then(offer => {
peer.setLocalDescription(offer, () => {
console.log('setLocalDescription-offer', peer.localDescription)
socket.emit('offer', {room: room, conn: conn, user: conn.split('-')[0], toUser: conn.split('-')[1], sdp: offer})
})
})
}
复制代码
而在使用socket.io进行第一个链接的时候,须要经过conn标示来进行对应的传输,咱们将conn进行拆分,user是发送者,touser是接受者。
// 转发offer
socket.on('offer', data => {
// 经过toUser发送个其对应的socket
socket.to(sockets[data.toUser].id).emit('offer', data)
})
复制代码
// 接收端收到offer
socket.on('offer', (data) => {
console.log('setRemoteDescription-offer-sdp', data.conn, data.sdp)
var peer = peers[data.conn]
peer.setRemoteDescription(data.sdp, () => {
peer.createAnswer()
.then(answer => {
peer.setLocalDescription(answer, () => {
console.log('setLocalDescription-answer', data.conn, answer)
// 此时将发送者和接受者互换,发送answer
socket.emit('answer', {room: room, user: data.toUser, toUser: data.user, conn: data.conn, sdp: answer})
})
})
})
})
复制代码
// 转发answer
socket.on('answer', data => {
socket.to(sockets[data.toUser].id).emit('answer', data)
})
复制代码
// 请求端收到answer
socket.on('answer', (data) => {
// 呼叫端设置远程 answer 描述
var peer = peers[data.conn]
peer.setRemoteDescription(data.sdp, () => {
console.log('setRemoteDescription-answer-sdp', data.conn, data.sdp)
})
})
复制代码
加上ice
// 监听ICE候选信息 若是收集到,就发送给对方
peer.onicecandidate = (event) => {
if (event.candidate) {
socket.emit('ice', {room: room, conn: conn, user: conn.split('-')[0], toUser: conn.split('-')[1], candidate: event.candidate})
}
}
// 转发iceCandidate
socket.on('ice', data => {
socket.to(sockets[data.toUser].id).emit('ice', data)
})
// 收到Ice
socket.on('ice', (data) => {
console.log('onice', data.conn, data.candidate)
var peer = peers[data.conn]
console.log('------------------------peer',peer)
peer.addIceCandidate(data.candidate); // 设置远程 ICE
})
复制代码
到这里咱们的p2p就结束了
动态画板效果
这里咱们有三种方法:
前面说过canva类中有个回调函数,当咱们进行操做的时候,就会调用回调函数,将参数传递到类外面的sendOther()方法
peer.ondatachannel = (event) => {
var channel = event.channel
channel.binaryType = 'arraybuffer'
channel.onopen = (event) => { // 链接成功
console.log('channel onopen')
}
channel.onclose = function(event) { // 链接关闭
console.log('channel onclose')
}
channel.onmessage = (event) => { // 收到消息
let obj = JSON.parse(event.data)
let option = obj.option
let data = obj.data
// console.log('onmessage----------', data, option, event)
if (option === 'text') {
msgList.push(data)
updateMsgList(data)
} else {
switch (option) {
case 'pen': {
draw.line(...data)
break
}
case 'eraser': {
draw.eraser(...data)
break
}
case 'getImage': {
draw.getImage()
break
}
case 'back': {
draw.back()
break
}
case 'go': {
draw.go()
break
}
case 'clear': {
draw.clear()
break
}
case 'setWeight': {
draw.setWeight(...data)
break
}
case 'setColor': {
draw.setColor(...data)
break
}
}
}
// console.log('channel onmessage', e.data);
}
}
复制代码
经过此次的项目仍是有不少收获的,首先是webrtc领域,若是不是此次项目可能我都不会接触这个领域,也增强了个人canvas和业务逻辑的能力。用原生js写业务是真滴麻烦。 因为这段时间在写小程序,这个项目有些地方仍是没有完善的,有些业务逻辑还没写完,不过核心功能已经写完了,没有太大影响。