В этой главе мы собираемся создать клиентское приложение, которое позволяет двум пользователям на отдельных устройствах отправлять сообщения друг другу с помощью WebRTC. Наше приложение будет иметь две страницы. Один для входа в систему, а другой для отправки сообщений другому пользователю.
Две страницы будут тегами div . Большая часть ввода осуществляется через простые обработчики событий.
Сигнальный сервер
Чтобы создать соединение WebRTC, клиенты должны иметь возможность передавать сообщения без использования однорангового соединения WebRTC. Здесь мы будем использовать HTML5 WebSockets — двунаправленное сокетное соединение между двумя конечными точками — веб-сервером и веб-браузером. Теперь давайте начнем использовать библиотеку WebSocket. Создайте файл server.js и вставьте следующий код —
//require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({port: 9090}); //when a user connects to our sever wss.on('connection', function(connection) { console.log("user connected"); //when server gets a message from a connected user connection.on('message', function(message) { console.log("Got message from a user:", message); }); connection.send("Hello from server"); });
Первая строка требует библиотеки WebSocket, которую мы уже установили. Затем мы создаем сервер сокетов на порту 9090. Затем мы прослушиваем событие подключения . Этот код будет выполнен, когда пользователь устанавливает соединение WebSocket с сервером. Затем мы слушаем любые сообщения, отправленные пользователем. Наконец, мы отправляем ответ подключенному пользователю, говоря «Привет с сервера».
На нашем сервере сигнализации мы будем использовать имя пользователя на основе строки для каждого соединения, чтобы мы знали, куда отправлять сообщения. Давайте немного изменим наш обработчик соединения —
connection.on('message', function(message) { var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } });
Таким образом, мы принимаем только сообщения JSON. Далее нам нужно где-то хранить всех подключенных пользователей. Мы будем использовать простой объект Javascript для него. Изменить верх нашего файла —
//require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({port: 9090}); //all connected to the server users var users = {};
Мы собираемся добавить поле типа для каждого сообщения от клиента. Например, если пользователь хочет войти, он отправляет сообщение типа входа . Давайте определим это —
connection.on('message', function(message) { var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } //switching type of the user message switch (data.type) { //when a user tries to login case "login": console.log("User logged:", data.name); //if anyone is logged in with this username then refuse if(users[data.name]) { sendTo(connection, { type: "login", success: false }); } else { //save user connection on the server users[data.name] = connection; connection.name = data.name; sendTo(connection, { type: "login", success: true }); } break; default: sendTo(connection, { type: "error", message: "Command no found: " + data.type }); break; } });
Если пользователь отправляет сообщение с типом логина , мы —
- Проверьте, если кто-то уже вошел в систему с этим именем пользователя.
- Если это так, то сообщите пользователю, что он не вошел в систему.
- Если никто не использует это имя пользователя, мы добавляем имя пользователя в качестве ключа к объекту подключения.
- Если команда не распознана, мы отправляем ошибку.
Следующий код является вспомогательной функцией для отправки сообщений в соединение. Добавьте его в файл server.js —
function sendTo(connection, message) { connection.send(JSON.stringify(message)); }
Когда пользователь отключается, мы должны очистить его соединение. Мы можем удалить пользователя при возникновении события закрытия . Добавьте следующий код в обработчик соединения —
connection.on("close", function() { if(connection.name) { delete users[connection.name]; } });
После успешного входа в систему пользователь хочет позвонить другому. Он должен сделать предложение другому пользователю для достижения этого. Добавить обработчик предложения —
case "offer": //for ex. UserA wants to call UserB console.log("Sending offer to: ", data.name); //if UserB exists then send him offer details var conn = users[data.name]; if(conn != null){ //setting that UserA connected with UserB connection.otherName = data.name; sendTo(conn, { type: "offer", offer: data.offer, name: connection.name }); break;
Во-первых, мы получаем соединение с пользователем, которого пытаемся вызвать. Если он существует, мы отправляем ему детали предложения . Мы также добавляем otherName к объекту соединения . Это сделано для простоты поиска позже.
Ответ на ответ имеет аналогичную схему, которую мы использовали в обработчике предложений . Наш сервер просто проходит через все сообщения как ответ другому пользователю. Добавьте следующий код после обработчика предложения —
case "answer": console.log("Sending answer to: ", data.name); //for ex. UserB answers UserA var conn = users[data.name]; if(conn != null) { connection.otherName = data.name; sendTo(conn, { type: "answer", answer: data.answer }); } break;
Заключительная часть — обработка кандидата ICE между пользователями. Мы используем ту же технику, просто передавая сообщения между пользователями. Основное отличие состоит в том, что сообщения-кандидаты могут появляться несколько раз на пользователя в любом порядке. Добавьте обработчик кандидата —
case "candidate": console.log("Sending candidate to:",data.name); var conn = users[data.name]; if(conn != null) { sendTo(conn, { type: "candidate", candidate: data.candidate }); } break;
Чтобы позволить нашим пользователям отключаться от другого пользователя, мы должны реализовать функцию зависания. Он также скажет серверу удалить все пользовательские ссылки. Добавьте обработчик отпуска —
case "leave": console.log("Disconnecting from", data.name); var conn = users[data.name]; conn.otherName = null; //notify the other user so he can disconnect his peer connection if(conn != null) { sendTo(conn, { type: "leave" }); } break;
Это также отправит другому пользователю событие ухода, чтобы он мог соответствующим образом отключить одноранговое соединение. Также следует разобраться со случаем, когда пользователь сбрасывает соединение с сервера сигнализации. Давайте изменим наш близкий обработчик —
connection.on("close", function() { if(connection.name) { delete users[connection.name]; if(connection.otherName) { console.log("Disconnecting from ", connection.otherName); var conn = users[connection.otherName]; conn.otherName = null; if(conn != null) { sendTo(conn, { type: "leave" }); } } } });
Ниже приведен полный код нашего сервера сигнализации.
//require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({port: 9090}); //all connected to the server users var users = {}; //when a user connects to our sever wss.on('connection', function(connection) { console.log("User connected"); //when server gets a message from a connected user connection.on('message', function(message) { var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } //switching type of the user message switch (data.type) { //when a user tries to login case "login": console.log("User logged", data.name); //if anyone is logged in with this username then refuse if(users[data.name]) { sendTo(connection, { type: "login", success: false }); } else { //save user connection on the server users[data.name] = connection; connection.name = data.name; sendTo(connection, { type: "login", success: true }); } break; case "offer": //for ex. UserA wants to call UserB console.log("Sending offer to: ", data.name); //if UserB exists then send him offer details var conn = users[data.name]; if(conn != null) { //setting that UserA connected with UserB connection.otherName = data.name; sendTo(conn, { type: "offer", offer: data.offer, name: connection.name }); } break; case "answer": console.log("Sending answer to: ", data.name); //for ex. UserB answers UserA var conn = users[data.name]; if(conn != null) { connection.otherName = data.name; sendTo(conn, { type: "answer", answer: data.answer }); } break; case "candidate": console.log("Sending candidate to:",data.name); var conn = users[data.name]; if(conn != null) { sendTo(conn, { type: "candidate", candidate: data.candidate }); } break; case "leave": console.log("Disconnecting from", data.name); var conn = users[data.name]; conn.otherName = null; //notify the other user so he can disconnect his peer connection if(conn != null) { sendTo(conn, { type: "leave" }); } break; default: sendTo(connection, { type: "error", message: "Command not found: " + data.type }); break; } }); //when user exits, for example closes a browser window //this may help if we are still in "offer","answer" or "candidate" state connection.on("close", function() { if(connection.name) { delete users[connection.name]; if(connection.otherName) { console.log("Disconnecting from ", connection.otherName); var conn = users[connection.otherName]; conn.otherName = null; if(conn != null) { sendTo(conn, { type: "leave" }); } } } }); connection.send("Hello world"); }); function sendTo(connection, message) { connection.send(JSON.stringify(message)); }
Клиентское приложение
Один из способов проверить это приложение — открыть две вкладки браузера и попытаться отправить сообщение друг другу.
Прежде всего, нам нужно установить библиотеку начальной загрузки . Bootstrap — это интерфейс для разработки веб-приложений. Вы можете узнать больше на http://getbootstrap.com/. Создайте папку с именем, например, «textchat». Это будет наша корневая папка приложения. Внутри этой папки создайте файл package.json (он необходим для управления зависимостями npm) и добавьте следующее:
{ "name": "webrtc-textochat", "version": "0.1.0", "description": "webrtc-textchat", "author": "Author", "license": "BSD-2-Clause" }
Затем запустите npm install bootstrap . Это установит библиотеку начальной загрузки в папку textchat / node_modules .
Теперь нам нужно создать простую HTML-страницу. Создайте файл index.html в корневой папке со следующим кодом —
<html> <head> <title>WebRTC Text Demo</title> <link rel = "stylesheet" href = "node_modules/bootstrap/dist/css/bootstrap.min.css"/> </head> <style> body { background: #eee; padding: 5% 0; } </style> <body> <div id = "loginPage" class = "container text-center"> <div class = "row"> <div class = "col-md-4 col-md-offset-4"> <h2>WebRTC Text Demo. Please sign in</h2> <label for = "usernameInput" class = "sr-only">Login</label> <input type = "email" id = "usernameInput" class = "form-control formgroup" placeholder = "Login" required = "" autofocus = ""> <button id = "loginBtn" class = "btn btn-lg btn-primary btnblock"> Sign in</button> </div> </div> </div> <div id = "callPage" class = "call-page container"> <div class = "row"> <div class = "col-md-4 col-md-offset-4 text-center"> <div class = "panel panel-primary"> <div class = "panel-heading">Text chat</div> <div id = "chatarea" class = "panel-body text-left"></div> </div> </div> </div> <div class = "row text-center form-group"> <div class = "col-md-12"> <input id = "callToUsernameInput" type = "text" placeholder = "username to call" /> <button id = "callBtn" class = "btn-success btn">Call</button> <button id = "hangUpBtn" class = "btn-danger btn">Hang Up</button> </div> </div> <div class = "row text-center"> <div class = "col-md-12"> <input id = "msgInput" type = "text" placeholder = "message" /> <button id = "sendMsgBtn" class = "btn-success btn">Send</button> </div> </div> </div> <script src = "client.js"></script> </body> </html>
Эта страница должна быть вам знакома. Мы добавили файл начальной загрузки css. Мы также определили две страницы. Наконец, мы создали несколько текстовых полей и кнопок для получения информации от пользователя. На странице «чат» вы должны увидеть тег div с идентификатором «chatarea», где будут отображаться все наши сообщения. Обратите внимание, что мы добавили ссылку на файл client.js .
Теперь нам нужно установить соединение с нашим сервером сигнализации. Создайте файл client.js в корневой папке со следующим кодом —
//our username var name; var connectedUser; //connecting to our signaling server var conn = new WebSocket('ws://localhost:9090'); conn.onopen = function () { console.log("Connected to the signaling server"); }; //when we got a message from a signaling server conn.onmessage = function (msg) { console.log("Got message", msg.data); var data = JSON.parse(msg.data); switch(data.type) { case "login": handleLogin(data.success); break; //when somebody wants to call us case "offer": handleOffer(data.offer, data.name); break; case "answer": handleAnswer(data.answer); break; //when a remote peer sends an ice candidate to us case "candidate": handleCandidate(data.candidate); break; case "leave": handleLeave(); break; default: break; } }; conn.onerror = function (err) { console.log("Got error", err); }; //alias for sending JSON encoded messages function send(message) { //attach the other peer username to our messages if (connectedUser) { message.name = connectedUser; } conn.send(JSON.stringify(message)); };
Теперь запустите наш сервер сигнализации через сервер узлов . Затем внутри корневой папки запустите статическую команду и откройте страницу в браузере. Вы должны увидеть следующий вывод консоли —
Следующим шагом является реализация входа пользователя с уникальным именем пользователя. Мы просто отправляем имя пользователя на сервер, который затем сообщает нам, занято оно или нет. Добавьте следующий код в ваш файл client.js —
//****** //UI selectors block //****** var loginPage = document.querySelector('#loginPage'); var usernameInput = document.querySelector('#usernameInput'); var loginBtn = document.querySelector('#loginBtn'); var callPage = document.querySelector('#callPage'); var callToUsernameInput = document.querySelector('#callToUsernameInput'); var callBtn = document.querySelector('#callBtn'); var hangUpBtn = document.querySelector('#hangUpBtn'); callPage.style.display = "none"; // Login when the user clicks the button loginBtn.addEventListener("click", function (event) { name = usernameInput.value; if (name.length > 0) { send({ type: "login", name: name }); } }); function handleLogin(success) { if (success === false) { alert("Ooops...try a different username"); } else { loginPage.style.display = "none"; callPage.style.display = "block"; //********************** //Starting a peer connection //********************** } };
Во-первых, мы выбираем несколько ссылок на элементы на странице. Мы скрываем страницу вызова. Затем мы добавляем прослушиватель событий на кнопку входа. Когда пользователь щелкает по нему, мы отправляем его имя пользователя на сервер. Наконец, мы реализуем обратный вызов handleLogin. Если вход был успешным, мы показываем страницу вызова, устанавливаем одноранговое соединение и создаем канал данных.
Чтобы начать одноранговое соединение с каналом данных нам нужно —
- Создайте объект RTCPeerConnection
- Создайте канал данных внутри нашего объекта RTCPeerConnection
Добавьте следующий код в «блок селекторов пользовательского интерфейса» —
var msgInput = document.querySelector('#msgInput'); var sendMsgBtn = document.querySelector('#sendMsgBtn'); var chatArea = document.querySelector('#chatarea'); var yourConn; var dataChannel;
Изменить функцию handleLogin —
function handleLogin(success) { if (success === false) { alert("Ooops...try a different username"); } else { loginPage.style.display = "none"; callPage.style.display = "block"; //********************** //Starting a peer connection //********************** //using Google public stun server var configuration = { "iceServers": [{ "url": "stun:stun2.1.google.com:19302" }] }; yourConn = new webkitRTCPeerConnection(configuration, {optional: [{RtpDataChannels: true}]}); // Setup ice handling yourConn.onicecandidate = function (event) { if (event.candidate) { send({ type: "candidate", candidate: event.candidate }); } }; //creating data channel dataChannel = yourConn.createDataChannel("channel1", {reliable:true}); dataChannel.onerror = function (error) { console.log("Ooops...error:", error); }; //when we receive a message from the other peer, display it on the screen dataChannel.onmessage = function (event) { chatArea.innerHTML += connectedUser + ": " + event.data + "<br />"; }; dataChannel.onclose = function () { console.log("data channel is closed"); }; } };
Если вход был успешным, приложение создает объект RTCPeerConnection и настраивает обработчик onicecandidate, который отправляет все найденные icecandidates другому партнеру. Он также создает канал данных. Обратите внимание, что при создании объекта RTCPeerConnection второй аргумент в конструкторе необязательный: [{RtpDataChannels: true}] является обязательным, если вы используете Chrome или Opera. Следующим шагом является создание предложения для другого партнера. Как только пользователь получает предложение, он создает ответ и начинает торговать кандидатами ICE. Добавьте следующий код в файл client.js —
//initiating a call callBtn.addEventListener("click", function () { var callToUsername = callToUsernameInput.value; if (callToUsername.length > 0) { connectedUser = callToUsername; // create an offer yourConn.createOffer(function (offer) { send({ type: "offer", offer: offer }); yourConn.setLocalDescription(offer); }, function (error) { alert("Error when creating an offer"); }); } }); //when somebody sends us an offer function handleOffer(offer, name) { connectedUser = name; yourConn.setRemoteDescription(new RTCSessionDescription(offer)); //create an answer to an offer yourConn.createAnswer(function (answer) { yourConn.setLocalDescription(answer); send({ type: "answer", answer: answer }); }, function (error) { alert("Error when creating an answer"); }); }; //when we got an answer from a remote user function handleAnswer(answer) { yourConn.setRemoteDescription(new RTCSessionDescription(answer)); }; //when we got an ice candidate from a remote user function handleCandidate(candidate) { yourConn.addIceCandidate(new RTCIceCandidate(candidate)); };
Мы добавляем обработчик кликов к кнопке Call, которая инициирует предложение. Затем мы реализуем несколько обработчиков, ожидаемых обработчиком onmessage . Они будут обрабатываться асинхронно, пока оба пользователя не установят соединение.
Следующим шагом является реализация функции зависания. Это прекратит передачу данных и скажет другому пользователю закрыть канал данных. Добавьте следующий код —
//hang up hangUpBtn.addEventListener("click", function () { send({ type: "leave" }); handleLeave(); }); function handleLeave() { connectedUser = null; yourConn.close(); yourConn.onicecandidate = null; };
Когда пользователь нажимает кнопку Hang Up —
- Он отправит сообщение «оставить» другому пользователю.
- Он закроет RTCPeerConnection и канал данных.
Последний шаг — отправка сообщения другому узлу. Добавьте обработчик «click» к кнопке «send message» —
//when user clicks the "send message" button sendMsgBtn.addEventListener("click", function (event) { var val = msgInput.value; chatArea.innerHTML += name + ": " + val + "<br />"; //sending a message to a connected peer dataChannel.send(val); msgInput.value = ""; });
Теперь запустите код. Вы должны иметь возможность войти на сервер, используя две вкладки браузера. Затем вы можете установить одноранговое соединение с другим пользователем и отправить ему сообщение, а также закрыть канал данных, нажав кнопку «Отбой».
Ниже приведен весь файл client.js —