基于WebRTC实现浏览器端音视频聊天室

一. 前言

        WebRTC(Web Real-Time Communication)旨在将实时通信功能引入到浏览器,用户无需安装其他任何软件或插件即可在浏览器间进行实时通信功能。本文介绍基于 WebRTC 实现一对一音视频实时聊天室功能,浏览器通过 HTTP 请求 Web 服务器前端页面运行元素(HTML, CSS, JS),之后与信令服务器进行交互,信令服务提供房间管理,信令消息转发等功能,媒体数据通过 STUN/TURN 服务器中转。 

二. 功能展示

基于WebRTC实现浏览器端音视频聊天室_第1张图片

        如上图所示是一个 1v1 聊天室,用户加入房间后可以与房间内的其他用户进行文本聊天,也可以点击音视频通话按钮进行音视频通话。 

三. 架构说明

        如上是一对一音视频聊天室架构说明,左边是 Call 发起者流程,右边是 Call 被呼叫者流程。发起者首先进行音视频设备检测并采集音视频设备数据,然后创建与 STUN/TURN 服务器的 PeerConnection 连接(对等连接),并生成 offer SDP 发送给被呼叫者,被呼叫者收到发起者的 SDP 后也同样开始音视频设备检测并采集音视频数据,然后生成 answer SDP 发送给发起者。

四. 代码实现

1. 服务端实现

a. Web+Signal Server实现

        首先我们需要搭建一个 Web 服务器供浏览器拉取前端运行元素,本文选择 nodejs express 模块,它可以方便地搭建静态文件资源服务器,服务端只要将前端代码所在的路径暴露出去,浏览器通过 HTTP 请求即可。

        其次我们还需要搭建信令服务器 Signal Server,它维护房间状态,并提供消息转发等功能,我们可以 HTTP 接收用户请求并处理。

        如下展示了服务端(Web+Signal Server)部分代码,它通过 express 将 public 目录暴露出去,用户获取前端代码结合用户操作给信令发送请求消息,例如加入房间请求 join_req,退出房间请求 leave_req 以及一些需要信令服务端转发给其他在同房间用户的控制消息等。

// 注意:如下代码不完整,仅提供关键部分

var logger = log4js.getLogger();

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

var options = {
    key  : fs.readFileSync('./certificate/server.key'),
    cert : fs.readFileSync('./certificate/server.pem')
}

var httpsServer = https.createServer(options, app);
httpsServer.listen(443, '0.0.0.0');

var httpsIO = socketIO.listen(httpsServer);
httpsIO.sockets.on('connection', (socket) => {
    socket.on('join_req', (roomId, userName) => {
        socket.join(roomId);
        var room = httpsIO.sockets.adapter.rooms[roomId];
        var memberNumInRoom = Object.keys(room.sockets).length;
        if (memberNumInRoom > MAX_NUMBER_IN_ROOM) {
            socket.emit('member_full', roomId, socket.id);
            socket.leave(roomId);
            return;
        }
        fetchUserInRoom(socket, roomId);
        addUserToRoom(roomId, userName);
        logger.info(`${userName} join room: (${roomId}), current number in room is ${memberNumInRoom+1}`);
        socket.emit('join_res', roomId, socket.id);
        socket.to(roomId).emit('other_joined', roomId, socket.id, userName);
    });
    socket.on('leave_req', (roomId, userName) => {
        socket.leave(roomId);
        var room = httpsIO.sockets.adapter.rooms[roomId];
        if (!room) {
            return;
        }
        var memberNumInRoom = Object.keys(room.sockets).length;       
        delUserFromRoom(roomId, userName);
        logger.info(`${userName} leave room: (${roomId}), current number in room is ${memberNumInRoom-1}`);
        socket.emit('leave_res', roomId, socket.id);
        socket.to(roomId).emit('other_leaved', roomId, socket.id, userName);
    });
    socket.on('ctrl_message', (roomId, data) => {
        logger.debug('recv a ctrl message from ' + socket.id);
        socket.to(roomId).emit('ctrl_message', roomId, socket.id, data);
    });
    socket.on('start_call', (roomId, data) => {
        logger.debug("recv start_call from " + socket.id);
        socket.to(roomId).emit('start_call', roomId, socket.id, data);
    });
    socket.on('message', (roomId, data) => {
        logger.debug('recv a message from ' + socket.id);
        socket.to(roomId).emit('message', roomId, socket.id, data);
    });
});

b. 搭建STUN/TURN服务器

        如上架构图所示,我们还需要搭建 STUN/TURN 服务器,STUN 服务器主要提供通知客户端 NAT 映射后地址的功能,客户端知道自己 NAT 映射后地址后转发给对端,对端也会发送 NAT 映射后的地址给本端,双端尝试进行 P2P 通信,如果 P2P 连接不成功,数据需要经过 TURN 服务器中转,TURN 服务器一般部署在带公网 IP 的机器上,以保证不在同一网络环境的两个客户端 P2P 失败时也能通过 TURN 服务器中转数据。

        如果你对 STUN/TURN 工作原理不太熟悉,可以参考 STUN 的工作原理,TURN 的工作原理,可以使用 coturn 搭建 STUN/TURN 服务器,请参考这篇博客。

2. 客户端实现

a. 创建RTCPeerConnection

        RTCPeerConnection 是 WebRTC 实现点对点通信的组件,它提供了连接对等体并进行数据通信的方式。对等连接需要监听事件并在事件发生时执行回调,例如收到 icecandidate 事件时需要将本地候选者发送至对端,收到媒体轨事件等。

var ICE_CFG = {
    'iceServers': [{
        'url': 'stun:stun.l.google.com:19302'
    }, {
        'url': 'turn:xxx.xxx.xxx.xxx:3478',
        'username': 'xxx',
        'credential': 'xxx'
    }]
};
var pConn = new RTCPeerConnection(ICE_CFG);

        stun:stun.l.google.com:19302 是谷歌提供的 STUN 服务器地址,用于 P2P 穿越,当你完成 TURN 服务器搭建后,可以再指定 TURN 服务地址,这样就可以在 P2P 穿越失败时使用 TURN 服务进行中继转发。

b. 获取本地流并添加到PeerConnection

        WebRTC 提供了 getUserMedia API,用户可以方便地访问音视频设备获取流数据。

        基本用法:var promise = navigator.mediaDevices.getUserMedia(constraints);

        getUserMedia API 的使用可以参考这篇博客。

function GetLocalMediaStream(mediaStream) {
    localStream = mediaStream;
    localAv.srcObject = localStream;

    localStream.getTracks().forEach((track) => {
        myPconn.addTrack(track, localStream);
    });

    if (isInitiator == true) {
        var offerOptions = {
            offerToReceiveAudio: 1,
            offerToReceiveVideo: 1
        };
        myPconn.createOffer(offerOptions)
            .then(GetOfferDesc)
            .catch(HandleError);
    }
}

c. 媒体协商

        对于通话发起者 initiator,当把音视频轨添加到 RTCPeerConnection 后即可通过 createOffer 方法生成本地 SDP 信息并使用 setLocalDescription 设置到 RTCPeerConnection 中,然后将 SDP 信息通过信令发送给被呼叫者 buddy,buddy 收到 offer SDP 后使用 RTCPeerConnection setRemoteDescription 将该 SDP 设置到 remote sdp,再通过 RTCPeerConnection createAnswer 方法生成并设置本地 SDP 消息,最后通过信令发送给 initiator。

        关于 SDP 的介绍请参考这篇博客,关于 createOffer 的介绍请参考这篇博客。

if (isInitiator == true) {
    var offerOptions = {
        offerToReceiveAudio: 1,
        offerToReceiveVideo: 1
    };
    myPconn.createOffer(offerOptions)
        .then(GetOfferDesc)
        .catch(HandleError);
}

function GetOfferDesc(desc) {
    myPconn.setLocalDescription(desc);

    socket.emit('ctrl_message', roomId.value, desc);
}

function CreateAnswerDesc() {
    myPconn.createAnswer().then(GetAnswerDesc).catch(HandleError);
}

function GetAnswerDesc(desc) {
    myPconn.setLocalDescription(desc);

    socket.emit('ctrl_message', roomId.value, desc);
}
socket.on('ctrl_message', (roomId, socketId, data) => {
    if (data) {
        if (data.hasOwnProperty('type') && data.type === 'offer') {
            HandleOfferDesc(data);
            CreateAnswerDesc();
        } else if (data.hasOwnProperty('type') && data.type === 'answer') {
            HandleAnswerDesc(data);
        } else if (data.hasOwnProperty('type') && data.type === 'candidate') {
            HandleCandidate(data);
        } else {
            console.log('Unknow ctrl message, ' + data);
        }
    }
});

d. 交换candidate

        如下当创建 PeerConnection 时会监听 onicecandidate 回调函数,例如对于通话发起者,调用 setLocalDescription 后就会开启候选者收集,当收集完成后就会回调 onicecandidate 方法,然后将本地 candidate 信息发送给对端,对端收到本端的候选者后就可以开始创建连接并从众多连接中挑选可用的连接进行通话。

        关于 candidate 的详细说明请参考这篇博客,该博客介绍了 host candidate,srflx candidate,relay candidate 等。

        创建 PeerConnection 时还设置了 ontrack 回调函数,该函数是收到对端流数据后的处理,该 demo 是将流设置到

function CreatePeerConnection() {
    if (myPconn) {
        console.log('peer connection has already been created.');
        return;
    }
    myPconn = new RTCPeerConnection(ICE_CFG);

    myPconn.onicecandidate = (event) => {
        if (event.candidate) {
            socket.emit('ctrl_message', roomId.value, {
                type: 'candidate',
                label: event.candidate.sdpMLineIndex, 
                id: event.candidate.sdpMid, 
                candidate: event.candidate.candidate
            });
		}
	}
	myPconn.ontrack = GetRemoteMediaStream;
}
function HandleCandidate(data) {
    var candidate = new RTCIceCandidate({
        sdpMlineIndex: data.label,
        sdpMid: data.id,
        candidate: data.candidate
    });
    myPconn.addIceCandidate(candidate);
}
function GetRemoteMediaStream(event) {
    if(event && event.streams.length > 0) {
        remoteStream = event.streams[0];
        remoteAv.srcObject = remoteStream;
    }
}

你可能感兴趣的:(音视频,WebRTC,音视频,webrtc,前端,im,聊天室)