这是WebRTC系列文章的第三篇。这次我们来实现一个可以一对一视频通话和有文字聊天功能的项目。
如果你对WebSocket、ICE、SDP、这些知识还不是很了解的话,推荐你先看下文章末尾的几篇推荐文章。
在此特别感谢 前端李老师的帮助
环境准备
桌面游览器 | Chrome 80.0.3987.163(正式版本) (64 位) |
手机游览器 | Chrome 80.0.3987.162 |
桌面游览器 | Microsoft Edge 版本 80.0.361.111 |
JDK | 1.8 以上 |
springboot | 2.1.6 |
Gradle | 4.8 |
一台CentOS7 云服务器 | 要有公网IP |
想要在游览器中打开视频连接你还需要时用HTTPS。
这部分可以参考Tomcat8.5配置https和SpringBoot配置https
使用PKCS12
Windows10 记得开启游览器访问 相机的权限
码代码
首先是信令服务器
这里我们使用springboot来开发
引入依赖
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// https://mvnrepository.com/artifact/com.alibaba/fastjson
compile group: 'com.alibaba', name: 'fastjson', version: '1.2.68'
}
信令服务器
/**
* WebSocket业务类
* 此类用来作为RTC的信令服务器
*/
@Service
@ServerEndpoint("/websocketRTC")
public class WebSocketRTC {
private static Vector<Session> sessions = new Vector<>();
private static Vector<JSONObject> sessionProduce = new Vector<>();
private static TreeMap<String,Session> sessionTreeMap = new TreeMap<>();
private static int loginNumber = 0;
private Session session ;
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(WebSocketRTC.class);
/**
* 响应一个客户端WebSocket连接
* @param session
* @throws IOException
*/
@OnOpen
public void onopenproc(Session session) throws IOException {
System.out.println("hava a client connected");
this.session = session;
JSONObject open = new JSONObject();
open.put("status", "success");
sendMessageToClient(open.toJSONString(), session);
}
/**
* 响应一个客户端的连接关闭
* @param session
*/
@OnClose
public void oncloseproc(Session session){
System.out.println("had a client is disconneted");
// sessionTreeMap.remove(data);
}
/**
* 对于客户端消息的处理和响应
* @param message 客户端发送的消息
* @param session 客户端的WebSocket会话对象
* @throws IOException
*/
@OnMessage
public void onmessageproc(String message , Session session) throws IOException {
/**
* 信令服务器与客户端之间的消息传递采用JSON
*/
if(message!=null) {
JSONObject msgJSON = JSON.parseObject(message);
/**
* 消息中的type字段表示此次消息的类型
* 服务器根据消息的type针对性的处理
*/
switch (msgJSON.getString("type")) {
case "login" :{
/**
* 处理客户端登录
*/
log.info("session : "+session + "is login .. "+new Date());
log.info("user login in as "+msgJSON.getString("name"));
if (sessionTreeMap.containsKey(msgJSON.getString("name"))) {
JSONObject login = new JSONObject();
login.put("type", "login");
login.put("success", false);
sendMessageToClient(login.toJSONString() , session);
}else {
sessionTreeMap.put(msgJSON.getString("name"), session);
JSONObject login = new JSONObject();
login.put("type", "login");
login.put("success", true);
login.put("myName", msgJSON.getString("name"));
sendMessageToClient(login.toJSONString() , session);
}
}break;
case "offer": {
/**
* 处理offer消息
* offer是一个peer to peer 连接中的 第一步
* 这个是响应通话发起者的消息
* 这里主要是找到 通话发起者要通话的对方的会话
*/
// onOffer(data.offer, data.name);\
log.info("Sending offer to " + msgJSON.getString("name")+" from "+msgJSON.getString("myName"));
Session conn = sessionTreeMap.get(msgJSON.getString("name"));
if (conn != null) {
JSONObject offer = new JSONObject();
offer.put("type", "offer");
offer.put("offer", msgJSON.getString("offer"));
offer.put("name", msgJSON.getString("name"));
sendMessageToClient(offer.toJSONString(), conn);
/**
* 保存会话状态
*/
JSONObject offerAnswer = new JSONObject();
offerAnswer.put("offerName", msgJSON.getString("myName"));
offerAnswer.put("answerName", msgJSON.getString("name"));
JSONObject sessionTemp = new JSONObject();
sessionTemp.put("session", offerAnswer);
sessionTemp.put("type", "offer");
sessionProduce.add(sessionTemp);
}
}
break;
case "answer": {
/**
* 响应answer消息
* answer是 被通话客户端 对 发起通话者的回复
*/
log.info("answer ..." + sessionProduce.size());
for (int i = 0; i < sessionProduce.size(); i++) {
log.info(sessionProduce.get(i).toJSONString());
}
if (true) {
Session conn = null;
/**
* 保存会话状态
* 查询谁是应该接受Anser消息的人
*/
for (int ii = 0; ii < sessionProduce.size(); ii++) {
JSONObject i = sessionProduce.get(ii);
JSONObject sessionJson = i.getJSONObject("session");
log.info(msgJSON.toJSONString());
log.info(sessionJson.toJSONString());
log.info("myName is " + msgJSON.getString("myName") + " , answer to name " + sessionJson.getString("answerName"));
if (/*i.getString("offerName").equals(msgJSON.getString("name")) && */sessionJson.getString("answerName").equals(msgJSON.getString("myName"))) {
conn = sessionTreeMap.get(sessionJson.getString("offerName"));
log.info("Sending answer to " + sessionJson.getString("offerName") + " from " + msgJSON.getString("myName"));
sessionProduce.remove(ii);
}
}
JSONObject answer = new JSONObject();
answer.put("type", "answer");
answer.put("answer", msgJSON.getString("answer"));
sendMessageToClient(answer.toJSONString(),conn);
}
}
break;
case "candidate": {
/**
* 这个是对候选连接的处理
* 这个消息处理在一次通话中可能发生多次
*/
log.info("Sending candidate to "+msgJSON.getString("name"));
Session conn = sessionTreeMap.get(msgJSON.getString("name"));
if (conn != null) {
JSONObject candidate = new JSONObject();
candidate.put("type", "candidate");
candidate.put("candidate", msgJSON.getString("candidate"));
sendMessageToClient(candidate.toJSONString(),conn );
}
}
break;
case "leave":{
/**
* 此消息是处理结束通话的事件
*/
log.info("Disconnectiong user from " + msgJSON.getString(" name"));
Session conn = sessionTreeMap.get(msgJSON.getString("name"));
if (conn != null) {
JSONObject leave = new JSONObject();
leave.put("type", "leave");
sendMessageToClient(leave.toJSONString(),conn);
}
}
break;
default:
JSONObject defaultMsg = new JSONObject();
defaultMsg.put("type", "error");
defaultMsg.put("message", "Unreconfized command : "+ msgJSON.getString("type") );
sendMessageToClient(defaultMsg.toJSONString(),session);
break;
}
System.out.println(message);
}
}
/**
* 发送消息
* @param msg
* @throws IOException
*/
public void sendMessage(String msg) throws IOException {
if(this.session!=null)
this.session.getBasicRemote().sendText("hello everyone!");
this.session.getBasicRemote().sendText(msg);
}
public void sendMessageForAllClient(String msg){
if(!sessions.isEmpty()){
sessions.forEach(i->{
try {
if(i.isOpen()) {
i.getBasicRemote().sendText(msg+" : "+new Date().toString());
}
} catch (IOException e) {
e.printStackTrace();
}
});
}
}
/**
* 向指定客户端发送消息
* @param msg
* @param session
* @throws IOException
*/
public void sendMessageToClient(String msg , Session session) throws IOException {
if(session.isOpen())
session.getBasicRemote().sendText(msg);
}
}
网页
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>WebRTC 系列文章 一对一视频通话和文字聊天</title>
<style>
body {
background-color: #3D6DF2;
margin-top: 15px;
font-family: sans-serif;
color: white;
}
video {
background: black;
border: 1px solid gray;
}
.page {
position: relative;
display: block;
margin: 0 auto;
width: 500px;
height: 500px;
}
#yours {
width: 150px;
height: 150px;
position: absolute;
top: 15px;
right: 15px;
}
#theirs {
width: 500px;
height: 500px;
}
#received {
display: block;
width: 480px;
height: 100px;
background: white;
padding: 10px;
margin-top: 10px;
color: black;
overflow: scroll;
}
</style>
</head>
<body>
<div id="login-page" class="page">
<h2>Login As</h2>
<input type="text" id="username" />
<button id="login">Login</button>
</div>
<div id="call-page" class="page">
<video id="yours" muted="muted" autoplay ></video>
<video id="theirs" muted="muted" autoplay></video>
<input type="text" id="their-username" />
<button id="call">Call</button>
<button id="hang-up">Hang Up</button>
<input type="text" id="message"></input>
<button id="send">Send</button>
<div id="received"></div>
</div>
<!--<script src="client.js"></script>-->
<script src="part3.js"></script>
</body>
</html>
javascrpit
// 核心的javascript
// 声明 变量 : 记录自己的登录名 , 对方的登录名
var name,
connectedUser;
var myName;
// 建立WebSocket连接 信令服务器
var connection = new WebSocket("wss://119.3.239.168:9443/websocketRTC");
// var connection = new WebSocket("wss://localhost:9443/websocketRTC");
// 自己的RTCPeerConnection
// RTC 最重要的对象
var yourConnection;
// 打开连接事件响应
connection.onopen = function () {
console.log("Connected");
};
// Handle all messages through this callback
connection.onmessage = function (message) {
console.log("Got message", message.data);
var data = JSON.parse(message.data);
switch (data.type) {
case "login":
onLogin(data.success);
break;
case "offer":
onOffer(data.offer, data.name);
break;
case "answer":
onAnswer(data.answer);
break;
case "candidate":
onCandidate(data.candidate);
break;
case "leave":
onLeave();
break;
default:
console.log("default message");
console.log(data);
break;
}
};
connection.onerror = function (err) {
console.log("Got error", err);
};
// 发送消息的方法 向信令服务器
// Alias for sending messages in JSON format
function send(message) {
if (connectedUser) {
message.name = connectedUser;
message.myName = name;
}
connection.send(JSON.stringify(message));
};
// 绑定HTML上的一些标签
var loginPage = document.querySelector('#login-page'),
usernameInput = document.querySelector('#username'),
loginButton = document.querySelector('#login'),
callPage = document.querySelector('#call-page'),
theirUsernameInput = document.querySelector('#their-username'),
callButton = document.querySelector('#call'),
hangUpButton = document.querySelector('#hang-up'),
messageInput = document.querySelector('#message'),
sendButton = document.querySelector('#send'),
received = document.querySelector('#received');
callPage.style.display = "none";
// 登录按钮click事件响应
// Login when the user clicks the button
// 记录登录名,向信令服务器发送登录信息
loginButton.addEventListener("click", function (event) {
name = usernameInput.value;
myName = usernameInput.value;
if (name.length > 0) {
send({
type: "login",
name: name
});
}
});
// 响应信令服务器反馈的登录信息
function onLogin(success) {
if (success === false) {
alert("Login unsuccessful, please try a different name.");
} else {
loginPage.style.display = "none";
callPage.style.display = "block";
// Get the plumbing ready for a call
// 准备开始一个连接
startConnection();
}
};
var yourVideo = document.querySelector('#yours'),
theirVideo = document.querySelector('#theirs'),
// yourConnection, connectedUser, stream, dataChannel;
connectedUser, stream, dataChannel;
// 打开自己的摄像头
// 准备开始一次peer to peer 连接
function startConnection() {
// 想要获取一个最接近 1280x720 的相机分辨率
var constraints = {audio: false, video: {width: 320, height: 480}};
navigator.mediaDevices.getUserMedia(constraints)
.then(function (mediaStream) {
// var video = document.querySelector('video');
yourVideo.srcObject = mediaStream;
if (hasRTCPeerConnection()) {
console.log("setupPeerConnection .. ")
setupPeerConnection(mediaStream);
} else {
alert("Sorry, your browser does not support WebRTC.");
}
yourVideo.onloadedmetadata = function (e) {
yourVideo.play();
};
})
.catch(function (err) {
console.log(err.name + " -- : " + err.message);
});
}
// 创建RTCPeerConnection对象 ,绑定ICE服务器,绑定多媒体数据流
function setupPeerConnection(stream) {
if (yourConnection == null) {
var configuration = {
// "iceServers": [{ "url": "stun:127.0.0.1:9876" }]
"iceServers": [{"url": "stun:119.3.239.168:3478"}, {
"url": "turn:119.3.239.168:3478",
"username": "codeboy",
"credential": "helloworld"
}]
};
yourConnection = new RTCPeerConnection(configuration, {optional: [{RtpDataChannels: true}]});
}
if (yourConnection == null) {
console.log("yourConneion is null");
} else {
console.log("yourConnection is a object")
}
console.log("========================= setupPeerConnection stream ====================================")
// console.log(stream);
// Setup stream listening
yourConnection.addStream(stream);
yourConnection.onaddstream = function (e) {
console.log(e);
// theirVideo.src = window.URL.createObjectURL(e.stream);
theirVideo.srcObject = e.stream;
theirVideo.play();
};
// Setup ice handling
yourConnection.onicecandidate = function (event) {
if (event.candidate) {
send({
type: "candidate",
candidate: event.candidate
});
}
};
// 打开数据通道 (这个是用于 文字交流用)
openDataChannel();
}
function openDataChannel() {
var dataChannelOptions = {
reliable: true
};
dataChannel = yourConnection.createDataChannel("myLabel", dataChannelOptions);
dataChannel.onerror = function (error) {
console.log("Data Channel Error:", error);
};
dataChannel.onmessage = function (event) {
console.log("Got Data Channel Message:", event.data);
received.innerHTML += event.data + "<br />";
received.scrollTop = received.scrollHeight;
};
dataChannel.onopen = function () {
dataChannel.send(name + " has connected.");
};
dataChannel.onclose = function () {
console.log("The Data Channel is Closed");
};
}
// Bind our text input and received area
sendButton.addEventListener("click", function (event) {
var val = messageInput.value;
received.innerHTML += val + "<br />";
received.scrollTop = received.scrollHeight;
dataChannel.send(val);
});
/* function hasUserMedia() {
navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia;
return !!navigator.getUserMedia;
}*/
function hasRTCPeerConnection() {
window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection;
window.RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || window.mozRTCSessionDescription;
window.RTCIceCandidate = window.RTCIceCandidate || window.webkitRTCIceCandidate || window.mozRTCIceCandidate;
return !!window.RTCPeerConnection;
}
callButton.addEventListener("click", function () {
var theirUsername = theirUsernameInput.value;
console.log("call " + theirUsername)
if (theirUsername.length > 0) {
startPeerConnection(theirUsername);
}
});
// 开始peer to peer 连接
function startPeerConnection(user) {
connectedUser = user;
// yourConnection
// Begin the offer
// 发送通话请求 1
yourConnection.createOffer(function (offer) {
console.log(" yourConnection.createOffer");
send({
type: "offer",
offer: offer
});
console.log(" yourConnection.setLocalDescription(offer);");
yourConnection.setLocalDescription(offer);
}, function (error) {
alert("An error has occurred.");
});
};
// 接受通话者 响应 通话请求 2
function onOffer(offer, name) {
connectedUser = name;
console.log("============================================================");
console.log("=============== onOffer (===================");
console.log("connector user name is "+connectedUser);
console.log("============================================================");
var offerJson = JSON.parse(offer);
var sdp = offerJson.sdp;
// 设置对方的会话描述
try {
console.log(" yourConnection.setRemoteDescription ");
yourConnection.setRemoteDescription(new window.RTCSessionDescription(offerJson), function () {
console.log("success");
}
,
function () {
console.log("fail")
});
} catch (e) {
alert(e)
}
// 向通话请求者 发送回复消息 3
yourConnection.createAnswer(function (answer) {
yourConnection.setLocalDescription(answer);
console.log(" yourConnection.createAnswer ");
send({
type: "answer",
answer: answer
});
}, function (error) {
alert("An error has occurred");
});
console.log("onOffer is success");
};
// 通话请求者 处理 回复 4
function onAnswer(answer) {
if (yourConnection == null) {
alert("yourconnection is null in onAnswer");
}
console.log("============================================================");
console.log("================ OnAnswer ============================");
console.log("============================================================");
console.log(answer);
if (answer != null) {
console.log(typeof answer);
}
var answerJson = JSON.parse(answer);
console.log(answerJson);
try {
// 设置本次会话的描述
yourConnection.setRemoteDescription(new RTCSessionDescription(answerJson));
} catch (e) {
alert(e);
}
console.log("onAnswer is success");
};
// 对ICE候选连接的事情响应
function onCandidate(candidate) {
console.log("============================================================");
console.log("================ OnCandidate ============================");
console.log("============================================================");
console.log(candidate);
if (candidate != null) {
console.log(typeof candidate);
}
var iceCandidate;
// try {
var candidateJson = JSON.parse(candidate);
console.log(candidateJson);
iceCandidate = new RTCIceCandidate(candidateJson);
// }catch(e){
// console.log("exception is ")
// console.log(e);
// }
if (yourConnection == null) {
alert("yourconnection is null in onCandidate");
}
// yourConnection.addIceCandidate(new RTCIceCandidate(candidate));
yourConnection.addIceCandidate(iceCandidate);
};
hangUpButton.addEventListener("click", function () {
send({
type: "leave"
});
onLeave();
});
function onLeave() {
connectedUser = null;
theirVideo.src = null;
yourConnection.close();
yourConnection.onicecandidate = null;
yourConnection.onaddstream = null;
setupPeerConnection(stream);
};
测试结果
将https的key文件放到服务器中
将bootJar包放到服务器中运行
java -jar demo-0.0.42-SNAPSHOT.jar --spring.profiles.active=dev
推荐阅读