Bootstrap

基于 WebRTC 的1 对 1 通话实战(二)信令服务器实现

一、信令协议封装

本篇文章我们主要讲解上图中Signal Server(信令服务器)的实现。在讲解信令服务器的具体实现前我们先来了解下信令协议该如何设计。如果对上图不理解的请先阅读

信令服务器中的信令协议使用JSON格式来封装(JSON作为一种轻量级的数据交换格式,易于人们阅读与书写)。现在我们来对上图中蓝青色背景部分相关的信令做json格式封装。如下

join:加入房间

var jsonMsg = {
'cmd': 'join',
'roomId': roomId,
'uid': localUserId,
};

resp-­join:当加入房间后发现房间已经有人则返回此人的uid,只有自己时不返回

jsonMsg = {
'cmd': 'resp‐join',
'remoteUid': remoteUid
};

leave:离开房间,服务器收到leave信令则检查同一房间是否有其他人,如果有则通知他有人离开了

var jsonMsg = {
'cmd': 'leave',
'roomId': roomId,
'uid': localUserId,
};

new-­peer:服务器通知客户端有新人加入,当客户端收到new-­peer消息则发起连接请求

var jsonMsg = {
'cmd': 'new‐peer',
'remoteUid': uid
};

peer-­leave:服务器通知客户端有人离开

var jsonMsg = {
'cmd': 'peer‐leave',
'remoteUid': uid
};

offer:转发offer sdp

var jsonMsg = {
'cmd': 'offer',
'roomId': roomId,
'uid': localUserId,
'remoteUid':remoteUserId,
'msg': JSON.stringify(sessionDescription)
};

answer:转发answer sdp

var jsonMsg = {
'cmd': 'answer',
'roomId': roomId,
'uid': localUserId,
'remoteUid':remoteUserId,
'msg': JSON.stringify(sessionDescription)
};

candidate:转发candidate sdp

var jsonMsg = {
'cmd': 'candidate',
'roomId': roomId,
'uid': localUserId,
'remoteUid':remoteUserId,
'msg': JSON.stringify(candidateJson)
};

信令协议封装完成后我们就可以参考上边封装的协议一步步实现信令服务器。

二、信令服务器实现

信令服务器使用nodejs-websocket来实现。

首先创建signal_server.js文件,并在文件开头引入nodejs-websocket包

var ws = require("nodejs-websocket")

然后我们将信令中cmd字段值定义为常量,方便后续使用

const SIGNAL_TYPE_JOIN = "join";// 主动加入房间
const SIGNAL_TYPE_RESP_JOIN = "resp-join";// 告知加入者房间中是谁
const SIGNAL_TYPE_LEAVE = "leave";// 主动离开房间
const SIGNAL_TYPE_NEW_PEER = "new-peer";// 有人加入房间,通知已经在房间的人
const SIGNAL_TYPE_PEER_LEAVE = "peer-leave";// 有人离开房间,通知已经在房间的人
const SIGNAL_TYPE_OFFER = "offer";// 发送offer给对端peer(发起方)
const SIGNAL_TYPE_ANSWER = "answer";//发送answer给对端peer(接收方)
const SIGNAL_TYPE_CANDIDATE = "candidate";// 发送candidate给对端peer

信令服务器中的房间管理使用Map,封装房间管理类如下(主要是为了方便房间创建、获取、删除等操作)

var RTCMap = function () {
    this._entrys = new Array();

    this.put = function (key, value) {
        if (key == null || key == undefined) {
            return;
        }
        var index = this._getIndex(key);
        if (index == -1) {
            var entry = new Object();
            entry.key = key;
            entry.value = value;
            this._entrys[this._entrys.length] = entry;
        } else {
            this._entrys[index].value = value;
        }
    };
    this.get = function (key) {
        var index = this._getIndex(key);
        return (index != -1) ? this._entrys[index].value : null;
    };
    this.remove = function (key) {
        var index = this._getIndex(key);
        if (index != -1) {
            this._entrys.splice(index, 1);
        }
    };
    this.clear = function () {
        this._entrys.length = 0;
    };
    this.contains = function (key) {
        var index = this._getIndex(key);
        return (index != -1) ? true : false;
    };
    this.size = function () {
        return this._entrys.length;
    };
    this.getEntrys = function () {
        return this._entrys;
    };
    this._getIndex = function (key) {
        if (key == null || key == undefined) {
            return -1;
        }
        var _length = this._entrys.length;
        for (var i = 0; i < _length; i++) {
            var entry = this._entrys[i];
            if (entry == null || entry == undefined) {
                continue;
            }
            if (entry.key === key) {
                return i;
            }
        }
        return -1;
    };
}

我们还需要封装一个客户端方法,来作为上边房间管理类map的值

function Client(uid, conn, roomId) {
    this.uid = uid;     // 用户uid
    this.conn = conn;   // uid对应的websocket连接
    this.roomId = roomId;// 用户所在房间id
}

接下来创建一个websocket服务(监听端口根据自己需求与客户端保持一致即可),在服务中监听客户端信令消息并处理。

var server = ws.createServer(function(conn){
    console.log("a new connection was created ~ ")
    conn.client = null; // client
 
    conn.on("text", function(str) {
     
        var jsonMsg = JSON.parse(str);
        switch (jsonMsg.cmd) {
            case SIGNAL_TYPE_JOIN:
                conn.client = handleJoin(jsonMsg, conn);
            break;
            case SIGNAL_TYPE_LEAVE:
                handleLeave(jsonMsg);
                break;
            case SIGNAL_TYPE_OFFER:
                handleOffer(jsonMsg);
                break;   
            case SIGNAL_TYPE_ANSWER:
                handleAnswer(jsonMsg);
                break; 
            case SIGNAL_TYPE_CANDIDATE:
                handleCandidate(jsonMsg);
            break;      
        }
    });

    conn.on("close", function(code, reason) {
        console.info("server close , code: " + code + ", reason: " + reason);
        if(conn.client != null) {
            // force all clients to exit the room
            handleForceLeave(conn.client);
        }
    });

    conn.on("error", function(err) {
        console.info("server error: " + err);
    });
}).listen(8099);

服务close时我们需要强制退出房间中所有客户端:handleForceLeave(conn.client),如下

function handleForceLeave(client) {
    var roomId = client.roomId;
    var uid = client.uid;

    // 查找rooId
    var roomMap = roomTableMap.get(roomId);
    if (roomMap == null) {
        console.warn("handleForceLeave can't find the roomId: " + roomId);
        return;
    }

    // uid是否在房间
    if (!roomMap.contains(uid)) {
        console.info("uid: " + uid +" have leave roomId: " + roomId);
        return;
    }

    // 客户端没有正常离开,执行强制离开
    console.info("uid: " + uid + " force leave room: " + roomId);

    roomMap.remove(uid); // 删除发送者
    if(roomMap.size() >= 1) {
        var clients = roomMap.getEntrys();
        for(var i in clients) {
            var jsonMsg = {
                'cmd': SIGNAL_TYPE_PEER_LEAVE,
                'remoteUid': uid // 离开方uid
            };
            var msg = JSON.stringify(jsonMsg);
            var remoteUid = clients[i].key;
            var remoteClient = roomMap.get(remoteUid);
            if(remoteClient) {
                console.info("notify peer: " + remoteClient.uid + ", uid: " + uid + " leave");
                remoteClient.conn.sendText(msg);
            }
        }
    }
}

接下来我们介绍服务器监听到客户端消息text如何处理,如下

SIGNAL_TYPE_JOIN://加入房间

//server监听到有人加入房间时调用
function handleJoin(message, conn) {
    var roomId = message.roomId;
    var uid = message.uid;

    console.info("uid: " + uid + " try to join the room " + roomId);

    var roomMap = roomTableMap.get(roomId);
    if (roomMap == null) {//没有房间时创建房间
        roomMap = new  RTCMap();
        roomTableMap.put(roomId, roomMap);
    }

    if(roomMap.size() >= 2) {
        console.error("roomId:" + roomId + " the room is full");
        // 有需要可以自己加个信令来通知客户端房间已满
        return null;
    }

    var client = new Client(uid, conn, roomId);
    roomMap.put(uid, client);// 将新加入的客户端加入到房间管理Map中

    if(roomMap.size() > 1) {// 当房间里面已经有人时要通知刚加进来的人与房间中的人
        var clients = roomMap.getEntrys();
        for(var i in clients) {
            var remoteUid = clients[i].key;
            if (remoteUid != uid) {
                var jsonMsg = {
                    'cmd': SIGNAL_TYPE_NEW_PEER,
                    'remoteUid': uid
                };
                var msg = JSON.stringify(jsonMsg);
                var remoteClient =roomMap.get(remoteUid);
                console.info("new-peer: " + msg);
                //通知已经在房间的人
                remoteClient.conn.sendText(msg);

                jsonMsg = {
                    'cmd':SIGNAL_TYPE_RESP_JOIN,
                    'remoteUid': remoteUid
                };
                msg = JSON.stringify(jsonMsg);
                console.info("resp-join: " + msg);
                //通知加入房间的人
                conn.sendText(msg);
            }
        }
    }
    return client;
}

以上方法会返回一个client客户端与服务端建立连接。

SIGNAL_TYPE_LEAVE://离开房间

//server监听到有人离开房间时调用
function handleLeave(message) {
    var roomId = message.roomId;
    var uid = message.uid;

    var roomMap = roomTableMap.get(roomId);
    if (roomMap == null) {
        console.error("handleLeave can't find then roomId: " + roomId);
        return;
    }
    if (!roomMap.contains(uid)) {
        console.info("uid: " + uid +" have leave roomId: " + roomId);
        return;
    }
    
    console.info("uid: " + uid + " leave room: " + roomId);
    roomMap.remove(uid);  // 移除发送者
    if(roomMap.size() >= 1) {
        var clients = roomMap.getEntrys();
        for(var i in clients) {
            var jsonMsg = {
                'cmd': SIGNAL_TYPE_PEER_LEAVE,
                'remoteUid': uid // 离开方uid
            };
            var msg = JSON.stringify(jsonMsg);
            var remoteUid = clients[i].key;
            var remoteClient = roomMap.get(remoteUid);
            if(remoteClient) {
                console.info("notify peer: " + remoteClient.uid + ", uid: " + uid + " leave");
                remoteClient.conn.sendText(msg);
            }
        }
    }
}

SIGNAL_TYPE_OFFER://发送offer给对端peer

function handleOffer(message) {
    var roomId = message.roomId;
    var uid = message.uid;
    var remoteUid = message.remoteUid;

    console.info("handleOffer uid: " + uid + " transfer  offer  to remoteUid: " + remoteUid);

    var roomMap = roomTableMap.get(roomId);
    if (roomMap == null) {
        console.error("handleOffer can't find the roomId: " + roomId);
        return;
    }

    if(roomMap.get(uid) == null) {
        console.error("handleOffer can't find the uid: " + uid);
        return;
    }

    var remoteClient = roomMap.get(remoteUid);
    if(remoteClient) {
        var msg = JSON.stringify(message);
        remoteClient.conn.sendText(msg);
    } else {
        console.error("can't find the remoteUid: " + remoteUid);
    }
}

SIGNAL_TYPE_ANSWER://发送answer给对端peer

function handleAnswer(message) {
    var roomId = message.roomId;
    var uid = message.uid;
    var remoteUid = message.remoteUid;  

    console.info("handleAnswer uid: " + uid + " transfer answer  to remoteUid: " + remoteUid);

    var roomMap = roomTableMap.get(roomId);
    if (roomMap == null) {
        console.error("handleAnswer can't find the roomId: " + roomId);
        return;
    }

    if(roomMap.get(uid) == null) {
        console.error("handleAnswer can't find the uid: " + uid);
        return;
    }

    var remoteClient = roomMap.get(remoteUid);
    if(remoteClient) {
        var msg = JSON.stringify(message);
        remoteClient.conn.sendText(msg);
    } else {
        console.error("can't find the remoteUid: " + remoteUid);
    }
}

SIGNAL_TYPE_CANDIDATE://发送candidate给对端peer

function handleCandidate(message) {
    var roomId = message.roomId;
    var uid = message.uid; 
    var remoteUid = message.remoteUid;

    console.info("handleCandidate uid: " + uid + "transfer candidate to remoteUid: " + remoteUid);

    var roomMap = roomTableMap.get(roomId);
    if (roomMap == null) {
        console.error("handleCandidate can't find the roomId: " + roomId);
        return;
    }

    if(roomMap.get(uid) == null) {
        console.error("handleCandidate can't find the uid: " + uid);
        return;
    }

    var remoteClient = roomMap.get(remoteUid);
    if(remoteClient) {
        var msg = JSON.stringify(message);
        remoteClient.conn.sendText(msg);
    } else {
        console.error("can't find remoteUid: " + remoteUid);
    }
}

三、总结

笔者对 WebRTC 信令服务器的信令协议封装与信令服务器实现做了较为详尽的介绍,本文讲解的信令服务器主要是用websocket来实现客户端各种行为的处理,目的是为了让读者能对信令服务器有一个更好的理解,其中的一些技术细节还需要读者自己做进一步的拓展学习。笔者后续还会有基于 WebRTC 的 1 对 1 通话实战(三)web 端实现、基于 WebRTC 的 1 对 1 通话实战(四)Android 端实现、基于 WebRTC 的 1 对 1 通话实战(五)iOS 端实现系列文章,欢迎关注了解。如果本篇文章对你有帮助,欢迎点个赞哈~