WebRTC学习(二)

2020-04-03  本文已影响0人  强某某

信令服务器

没有信令服务器,各个WebRTC之间是没办法通信的。
传递媒体数据有两个信息,必须经过信令服务器进行交换

  1. 媒体信息

通过SDP来表示,如编解码器是什么?是否支持音频视频?编码方式是什么?等
这些信息是通过SDP协议描述出来,通过信令服务器中转的

  1. 网络信息

两个WebRTC客户端会尽可能选择P2P进行连接,那么进行连接前是如何发现对方的?就是通过信令服务器。
首先将你所有网络相关信息传到信令服务器,服务器帮你交换到对端,对端拿到你的信息后,
若在同一局域网内,直接通过P2P传输;若不在,首先进行P2P穿越,看是否能打通,打通则传输,打不通则中转等。

  1. 具体业务

还有一点也需要信令服务器进行传输,比如加入房间,离开房间,禁言等功能

使用socket.io的原因

  1. socke.io是 WebRTC超集,它本身就有WebSocket功能

在传输时,一般有两种协议 TCP和 UDP
底层协议使用 UDP主要用于流媒体传输(音频视频)还有文本,文字聊天等,但 UDP是不可靠传输,是可以丢包的,当然音频视频是可以丢包的,丢失一帧只会卡顿下,还可以继续工作。
但信令服务器不能丢失数据,所有的包必须保证到达,否则断开连接,所以信令服务器一般使用TCP可靠性传输。
websocket底层使用的就是 TCP协议, socket.io 使用的也是TCP

  1. socket.io本身就有房间的概念,不用自己再写一个ROOM服务器

在websocket官方中,是有三个服务器的,ROOM服务器(提供用户进出房间服务)、信令服务器、流媒体(中转)服务器
选用socket.io 即不用单独写ROOM服务器,这里ROOM和信令是同一个服务器

  1. socket.io 跨平台、跨终端、跨语言

socket.io是一个基于Nodejs的库,在现有的Node Server上增加个socket.io即可

在任何终端都可以引入socket.io客户端的库,通过客户端的库就可以连接到 Nodejs中 socket.io服务器上
这样就可以建立连接,然后就可以创建,加入房间,这样房间内的人就可以通信了

多个 socke.io可以串行通信。

Socket.IO API

发送消息

  1. 给本次连接发送消息

socket.emit()

  1. 给某个房间内所有人发送消息

io.in(room).emit() /io.sockets.in(room).emit()

  1. 除了本连接外,给某个房间内所有人发消息

socket.to(room).emit()

  1. 除了本连接外,给所有人发消息

socket.broadcast.emit()

处理消息

  1. 发送action命令
S:socket.emit('action');
C: socket.on('action',function(){})
  1. 发送一个action命令,还有data数据
S:socket.emit('action',data);
C: socket.on('action',function(data){})
  1. 发送一个action命令,还有两个数据
S:socket.emit('action',arg1,arg2);
C: socket.on('action',function(arg1,arg2){})
  1. 发送一个action命令,再emit方法中包含回调函数
S:socket.emit('action',data,function(arg1,arg2){});
C: socket.on('action',function(data,fn){fn(a,b)})

基于socket.io实现聊天室

<html>
    <head>
        <title>Chat Room</title>
        <link rel="stylesheet" href="./css/main.css"></link>
    </head>
    <body>
        <table align="center">
            <tr>
                <td>
                    <label>UserName: </label>
                    <input type=text id="username"></input>
                </td>
            </tr>
            <tr>
                <td>
                    <label>room: </label>
                    <input type=text id="room"></input>
                    <button id="connect">Conect</button>
                    <button id="leave" disabled>Leave</button>
                </td>
            </tr>
            <tr>
                <td>
                    <label>Content: </label><br>
                    <textarea disabled style="line-height: 1.5;" id="output" rows="10" cols="100"></textarea>
                </td>
            </tr>
            <tr>
                <td>
                    <label>Input: </label><br>
                    <textarea disabled id="input" rows="3" cols="100"></textarea>
                </td>
            </tr>
            <tr>
                <td>
                    <button id="send">Send</button>
                </td>
            </tr>
        </table>

        <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.0.3/socket.io.js"></script>
        <script src="./js/client.js"></script>
    </body>

</html>
//client.js
'use strict'

//
var userName = document.querySelector('input#username');
var inputRoom = document.querySelector('input#room');
var btnConnect = document.querySelector('button#connect');
var btnLeave = document.querySelector('button#leave');
var outputArea = document.querySelector('textarea#output');
var inputArea = document.querySelector('textarea#input');
var btnSend = document.querySelector('button#send');

var socket;
var room;

btnConnect.onclick = () => {

    //connect
    socket = io.connect('https://zengqiang.mynatapp.cc/');

    //recieve message
    socket.on('joined', (room, id) => {
        btnConnect.disabled = true;
        btnLeave.disabled = false;
        inputArea.disabled = false;
        btnSend.disabled = false;
    });

    socket.on('leaved', (room, id) => {
        btnConnect.disabled = false;
        btnLeave.disabled = true;
        inputArea.disabled = true;
        btnSend.disabled = true;

        socket.disconnect();
    });

    socket.on('message', (room, data) => {
        outputArea.scrollTop = outputArea.scrollHeight;//窗口总是显示最后的内容
        outputArea.value = outputArea.value + data + '\r';
    });

    socket.on('disconnect', (socket) => {
        btnConnect.disabled = false;
        btnLeave.disabled = true;
        inputArea.disabled = true;
        btnSend.disabled = true;
    });

    //send message
    room = inputRoom.value;
    socket.emit('join', room);
}

btnSend.onclick = () => {
    var data = inputArea.value;
    data = userName.value + ':' + data;
    socket.emit('message', room, data);
    inputArea.value = '';
}

btnLeave.onclick = () => {
    room = inputRoom.value;
    socket.emit('leave', room);
}

inputArea.onkeypress = (event) => {
    //event = event || window.event;
    if (event.keyCode == 13) { //回车发送消息
        var data = inputArea.value;
        data = userName.value + ':' + data;
        socket.emit('message', room, data);
        inputArea.value = '';
        event.preventDefault();//阻止默认行为
    }
}
//server.js
const http = require('http');
const https = require('https');
const fs = require('fs');

const express = require('express');
const socketIO = require('socket.io');
const serveIndex = require('serve-index')

const app = express();
app.use(serveIndex('./public'));
app.use(express.static('./public'));

const http_server = http.createServer(app);
const io = socketIO.listen(http_server);
//每个socket就是一个连接,一个客户端
io.sockets.on('connection', socket => {
    socket.on('message', (room, data) => {
        socket.to(room).emit('message', room, data)//房间内所有人,除自己外
    });
    //'join'时自定义事件
    socket.on('join', room => {
        socket.join(room);
        //如果第一次的时候,因为有上一句代码,其实也已经添加进了rooms的列表里面
        const myRoom = io.sockets.adapter.rooms[room];
        const users = Object.keys(myRoom.sockets).length;//房间里面的人数
        io.in(room).emit('joined', room, socket.id);//房间里面所有人
        // socket.to(room).emit('joined',room,socket.id);
        //socket.emit('join',room,socket.id);
        // socket.broadcast.emit('joined',room,socket.id)
    });
    socket.on('leave', room => {
        const myRoom = io.sockets.adapter.rooms[room];
        const users = Object.keys(myRoom.sockets).length;
        socket.leave(room);
        io.in(room).emit('leaved', room, socket.id);
    });
})
http_server.listen(8080);

WebRTC传输基本知识

当在专用网内部的一些主机本来已经分配到了本地IP地址(即仅在本专用网内使用的专用地址),但现在又想和因特网上的主机通信(并不需要加密)时,可使用NAT方法。

这种方法需要在专用网连接到因特网的路由器上安装NAT软件。装有NAT软件的路由器叫做NAT路由器,它至少有一个有效的外部全球IP地址。
这样,所有使用本地地址的主机在和外界通信时,都要在NAT路由器上将其本地地址转换成全球IP地址,才能和因特网连接。

其实就是内网服务器共用一个IP但是映射成不同的端口(这是其中一个方式)


1.jpg

直接P2P通信

它允许位于NAT(或多重NAT)后的客户端找出自己的公网地址,查出自己位于哪种类型的NAT之后以及NAT为某一个本地端口所绑定的Internet端端口。这些信息被用来在两个同时处于NAT路由器之后的主机之间创建UDP通信

转发

TURN协议允许NAT或者防火墙后面的对象可以通过TCP或者UDP接收到数据。这在使用了对称式的NAT(或者防火墙)的网络中尤其具有实用价值。

TURN方式解决NAT问题的思路与STUN相似,是基于私网接入用户通过某种机制预先得到其私有地址对应在公网的地址(STUN方式得到的地址为出口NAT上的地址,TURN方式得到地址为TURNServer上的地址),然后在报文负载中所描述的地址信息直接填写该公网地址的方式,实际应用原理也是一样的

TURN的局限性在于所有报文都必须经过TURNServer转发,增大了包的延迟和丢包的可能性

把上面三者打包在一起,然后选择最优解(到底选择p2p还是TURN),首先p2p,然后turn服务进行中转

NAT种类

NAT穿越原理

需要内网主机先发起请求,经过防火墙等转换然后生成公网地址(在NAT服务上打个洞,形成外网地址),只要知道外网地址,其他服务可以随意请求,安全性低

内网主机先发射请求,也是NAT服务打洞,但是是在防火墙上形成映射表(内网主机地址,外网地址,所请求服务的地址),这样可以避免其他服务能随意请求内网主机,安全性更高

内网主机先发射请求,也是NAT服务打洞,但是是在防火墙上形成映射表(内网主机ip地址和端口,外网ip地址和端口,所请求服务的ip地址和端口),相比上一个安全性更高

上面三种是在防火墙上面形成固定的ip地址和端口,虽然可能不通但是是都能找到的;对称型NAT针对请求的不同外部服务产生对应的不同的公网ip地址和端口,例如:同一个内网服务主机请求百度和请求腾讯,就会在防火墙上面形成两个不同的IP和端口都是公网的。而且只能专用IP端口和特定外网服务打通,其他通不了。一般情况下,这种方式在国内几乎无法成功穿透

穿越步骤

C指的是Client

NAT穿越组合

全锥型 全锥型  √
全锥型 受限锥型  √
全锥型 端口受限锥型  √
全锥型 对称型  √
受限锥型 受限锥型  √
受限锥型 端口受限锥型  √
受限锥型 对称型  √
端口受限锥型 端口受限锥型 √
端口受限锥型 对称型 × 无法打通
对称型 对称型 × 无法打通

STUN介绍

RFC STUN规范

基于UDP穿透,但是国内路由器厂商对路由器限制大甚至不能使用,所以成功率很低

Simple Traversal of UDP Through NAT

是上面一种完善方案
Session Traversal Utilities for NAT

STUN协议

  0                   1                   2                   3
       0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      |0 0|     STUN Message Type     |         Message Length        |
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      |                         Magic Cookie                          |
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      |                                                               |
      |                     Transaction ID (96 bits)                  |
      |                                                               |
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

将原来的128bit的TransactionID分割为2部分,一部分是magic cookie,一部分是id。
The magic cookie field MUST contain the fixed value 0x2112A442 in network byte order
magic cookie是一个常量

STUN Message Type

C0C1

STUN消息类型

类型    含义
0x0001  绑定消息
0x0101  绑定响应
0x0111  绑定错误
0x0002  私密请求
0x0102  私密响应
0x0112  私密错误

大小端模式

Transaction ID

STUN Message Body

RFC3489定义的属性

0x0001  MAPPED-ADDRESS  获取客户端映射地址
0x0002  RESPONSE-ADDRESS  获取对于MAPPED-ADDRESS的响应应该由哪发送
0x0003  CHANGE-REQUEST  请求服务端使用不同的IP和端口发送请响
0x0004  SOURCE-ADDRESS  指定服务端的IP地址和端口
0x0005  CHANGED-ADDRESS 它是CHANGE-REQUEST请求的响应
0x0006  USERNAME  用于安全验证
0x0007  PASSWORD  用于安全验证
0x0008  MESSAGE-INTEGRITY  消息完整性验证
0x0009  ERROR-CODE  错误码
0x000a  UNKNOWN-ATTRIBUTES  未知属性
0x000b  REFLECTED-FROM  拒约

TURN介绍(中继)

TURN使用的传输协议

TURN client  to TURN server     TURN server to peer
UDP                             UDP
TCP                             UDP
TLS over TCP                    UDP

TURN 发送数据的机制

因为流媒体数据量很大,每个都带消息头则对带宽压力很大,所以还有第二种方式

ICE

ICE是一种标准穿透协议,利用STUN和TURN服务器来帮助端点建立连接。WebRTC当通过信令server交换完sdp, candidate后,之后依靠ICE框架在2端之间建立一个通道。

ICE的过程主要分为5步:

3.png

https://www.imweb.io/topic/5a4a6cb2a192c3b460fce37f
https://segmentfault.com/a/1190000011403597

ICE Candidate(候选者)

Candidate类型

什么是SDP

SDP 完全是一种会话描述格式 ― 它不属于传输协议 ― 它只使用不同的适当的传输协议,包括会话通知协议(SAP)、会话初始协议(SIP)、实时流协议(RTSP)、MIME 扩展协议的电子邮件以及超文本传输协议(HTTP)。SDP协议是也是基于文本的协议,这样就能保证协议的可扩展性比较强,这样就使其具有广泛的应用范围。SDP 不支持会话内容或媒体编码的协商,所以在流媒体中只用来描述媒体信息。

媒体协商

pc=new RTCPeerConnection([configuration]);

方法分类

4.png

协商状态变化

5.png

媒体协商方法

aPromise=myPeerConnection.createOffer([options]);

aPromise=myPeerConnection.createAnswer([options]);

aPromise=myPeerConnection.setLOcalDescription(sessionDescription);

aPromise=myPeerConnection.setRemoteDescription(sessionDescription);

Track方法

rtpSender=myPC.addTrack(track,stream...);

//Parameters
参数   说明
track  添加到RTCPeerConnection中的媒体轨
stream   指定track所在的stream
myPC.remoteTrack(rtpSender);

重要事件

Demo(本机点对点通信)

RTCPeerConnection的作用是在浏览器之间建立数据的“点对点”(peer to peer)通信.

使用WebRTC的编解码器和协议做了大量的工作,方便了开发者,使实时通信成为可能,甚至在不可靠的网络,

比如这些如果在voip体系下开发工作量将非常大,而用webRTC的js开发者则不用考虑这些,举几个例子:

不同客户端之间的音频/视频传递,是不用通过服务器的。但是,两个客户端之间建立信令联系,需要通过服务器。这个和XMPP的Jingle会话很类似。

通信内容的元数据:打开/关闭对话(session)的命令、媒体文件的元数据(编码格式、媒体类型和带宽)等。
网络通信的元数据:IP地址、NAT网络地址翻译和防火墙等

WebRTC协议没有规定与服务器的信令通信方式,因此可以采用各种方式,比如WebSocket。通过服务器,两个客户端按照Session Description Protocol(SDP协议)交换双方的元数据。

本地和远端通讯的过程有些像电话,比如张三正在试着打电话给李四,详细机制:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>

<body>
    <video id="localvideo" autoplay playsinline></video>
    <video id="remotevideo" autoplay playsinline></video>
    <button id="start">start</button>
    <button id="call">call</button>
    <button id="hangup">Hang Up</button>
</body>

</html>
<script>
    let localStream;
    let pc1,pc2;
    const localvideo = document.querySelector('video#localvideo');
    const remotevideo = document.querySelector('video#remotevideo');

    const btnStart = document.querySelector('button#start');
    const btnCall = document.querySelector('button#call');
    const btnhangup = document.querySelector('button#hangup');
    btnStart.onclick = () => {
        if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
            console.log('不支持');
            return
        } else {
            const constraints = {
                video: true,
                audio: false
            }
            navigator.mediaDevices.getUserMedia(constraints)
                .then(getMediaStream)
                .catch(handleError);
        }
    }
    btnCall.onclick = () => {
        pc1=new RTCPeerConnection();
        pc2=new RTCPeerConnection();

        pc1.onicecandidate=e=>{
            pc2.addIceCandidate(e.candidate);
        }
        pc2.onicecandidate=e=>{
            pc1.addIceCandidate(e.candidate);
        }
        pc2.ontrack=getRemoteStream;
        localStream.getTracks().forEach(track => {
            //本地采集的视频流添加到pc1的轨道中
            pc1.addTrack(track,localStream);
        });
        let offerOptions={
            //此时不采集音频
            offerToRecieveAudio:0,
            offerToRecieveVideo:1
        };
        pc1.createOffer(offerOptions).
        then(getOffer)
        .catch(handleError);
    }
    btnhangup.onclick = () => {
        pc1.close();
        pc2.close();
        pc1=null;
        pc2=null;
    }

    function getOffer(desc) {
        pc1.setLocalDescription(desc);

        pc2.setRemoteDescription(desc);
        pc2.createAnswer().then(getAnswer)
        .catch(handleError);
    }

    function getAnswer(desc) {
        pc2.setLocalDescription(desc);
        pc1.setRemoteDescription(desc);
    }
    function getMediaStream(stream) {
        localvideo.srcObject = stream;
        localStream = stream;
    }

    function handleError(err) {
        console.log(err);
    }

    function getRemoteStream(e) {
        remotevideo.srcObject=e.streams[0];
    }
</script>

说明:上面例子只是本地显示,所以很多回调里面出现pc1和pc2同时存在,实际项目中,应该是例如进入pc1的回调之后发送网络通信,然后在另外一边接收的回调里面写逻辑

参考博客

上一篇 下一篇

猜你喜欢

热点阅读