jjzjj

Spring Boot WebSocket + WebRTC 实现点对点视频通话功能Demo

JAVA·D·WangJing 2024-02-12 原文

一、创建 SpringBoot 项目

1.1、创建一个空项目:传送门

1.2、添加 websocket 引用

<!-- websocket -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

1.3、添加 WebSocketConfig 配置文件

package com.example.demo.conf;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

@Configuration
public class WebSocketConfig {
    /**
     * 注入一个ServerEndpointExporter,该Bean会自动注册使用@ServerEndpoint注解申明的websocket endpoint
     */
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

}

1.4、添加 WebSocketServer 核心代码

package com.example.demo.socket;

import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Enumeration;
import java.util.concurrent.ConcurrentHashMap;


@ServerEndpoint("/msgServer/{userId}")
@Component
@Scope("prototype")
public class WebSocketServer {

    /**
     * 静态变量,用来记录当前在线连接数。应该把它设计成线程安全的。
     */
    private static int onlineCount = 0;
    /**
     * concurrent包的线程安全Set,用来存放每个客户端对应的MyWebSocket对象。
     */
    private static ConcurrentHashMap<String, Session> webSocketMap = new ConcurrentHashMap<>();
    /**
     * 与某个客户端的连接会话,需要通过它来给客户端发送数据
     */
    private Session session;
    /**
     * 接收userId
     */
    private String userId = "";

    @OnOpen
    public void onOpen(Session session, @PathParam("userId") String userId) {
        this.session = session;
        this.userId = userId;
        /**
         * 连接被打开:向socket-map中添加session
         */
        webSocketMap.put(userId, session);
        System.out.println(userId + " - 连接建立成功...");
    }

    @OnMessage
    public void onMessage(String message, Session session) {
        try {
            this.sendMessage(message);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @OnError
    public void onError(Session session, Throwable error) {
        System.out.println("连接异常...");
        error.printStackTrace();
    }

    @OnClose
    public void onClose() {
        System.out.println("连接关闭");
    }

    /**
     * 实现服务器主动推送
     */
    public void sendMessage(String message) throws IOException {
        if (message.equals("心跳")) {
            this.session.getBasicRemote().sendText(message);
        }
        Enumeration<String> keys = webSocketMap.keys();
        while (keys.hasMoreElements()) {
            String key = keys.nextElement();
            if (key.equals(this.userId)) {
                System.err.println("my id " + key);
                continue;
            }
            if (webSocketMap.get(key) == null) {
                webSocketMap.remove(key);
                System.err.println(key + " : null");
                continue;
            }
            Session sessionValue = webSocketMap.get(key);
            if (sessionValue.isOpen()) {
                System.out.println("发消息给: " + key + " ,message: " + message);
                sessionValue.getBasicRemote().sendText(message);
            } else {
                System.err.println(key + ": not open");
                sessionValue.close();
                webSocketMap.remove(key);
            }
        }
    }

    /**
     * 发送自定义消息
     */
    public static void sendInfo(String message, @PathParam("userId") String userId) throws IOException {
        System.out.println("发送消息到:" + userId + ",内容:" + message);
        if (!StringUtils.isEmpty(userId) && webSocketMap.containsKey(userId)) {
            webSocketMap.get(userId).getBasicRemote().sendText(message);
            //webSocketServer.sendMessage(message);
        } else {
            System.out.println("用户" + userId + ",不在线!");
        }
    }

    public static synchronized int getOnlineCount() {
        return onlineCount;
    }

    public static synchronized void addOnlineCount() {
        WebSocketServer.onlineCount++;
    }

    public static synchronized void subOnlineCount() {
        WebSocketServer.onlineCount--;
    }
}

二、编写测试HTML

<!DOCTYPE html>
<html>

	<head>
		<title>RTC视频通话测试页面</title>
		<meta charset="UTF-8"> <!-- for HTML5 -->
		<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
		<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1" />
	</head>
	<body>
		<style type="text/css">
			body {
				background: #000;
			}

			button {
				height: 40px;
				line-height: 40px;
				width: auto;
				padding: 0 15px;
				background: #ccc;
				border: none;
				border-radius: 10px;
				margin-bottom: 10px;
				overflow: hidden;
			}

			.wrap {
				width: 100vw;
				height: 100vh;
				display: flex;
				justify-content: center;
				align-items: center;
			}

			.video-box {
				border-radius: 20px;
				background: pink;
				position: relative;
				width: 800px;
				height: 600px;
				overflow: hidden;
			}

			.remote-video {
				width: 800px;
				height: 600px;
				border: 1px solid black;
				overflow: hidden;
			}

			.local-video {
				width: 320px;
				height: 240px;
				position: absolute;
				right: 0;
				bottom: 0;
				border-radius: 20px 0 0 0;
				overflow: hidden;
			}

			video {
				width: 100%;
				height: 100%;
			}
		</style>
		<div class="wrap">
			<div>
				<div>
					<button type="button" onclick="startVideo();">开启本机摄像和音频</button>
					<button type="button" onclick="connect();">建立连接</button>
					<button type="button" onclick="hangUp();">挂断</button>
					<button type="button" onclick="refreshPage();">刷新页面</button>
				</div>
				<div class="video-box">
					<div class="local-video">
						<video id="local-video" autoplay style=""></video>
					</div>
					<div class="remote-video">
						<video id="remote-video" autoplay></video>
					</div>
				</div>
			</div>
		</div>

		<script>
			// ===================以下是socket=======================
			var user = Math.round(Math.random() * 1000) + ""
			var socketUrl = "ws://localhost:8080/msgServer/" + user;
			var socket = null
			var socketRead = false
			window.onload = function() {

				socket = new WebSocket(socketUrl)
				socket.onopen = function() {
					console.log("成功连接到服务器...")
					socketRead = true
				}
				socket.onclose = function(e) {
					console.log('与服务器连接关闭: ' + e.code)
					socketRead = false
				}

				socket.onmessage = function(res) {
					var evt = JSON.parse(res.data)
					console.log(evt)
					if (evt.type === 'offer') {
						console.log("接收到offer,设置offer,发送answer....")
						onOffer(evt);
					} else if (evt.type === 'answer' && peerStarted) {
						console.log('接收到answer,设置answer SDP');
						onAnswer(evt);
					} else if (evt.type === 'candidate' && peerStarted) {
						console.log('接收到ICE候选者..');
						onCandidate(evt);
					} else if (evt.type === 'bye' && peerStarted) {
						console.log("WebRTC通信断开");
						stop();
					}
				}
			}

			// ===================以上是socket=======================

			var localVideo = document.getElementById('local-video');
			var remoteVideo = document.getElementById('remote-video');
			var localStream = null;
			var peerConnection = null;
			var peerStarted = false;
			var mediaConstraints = {
				'mandatory': {
					'OfferToReceiveAudio': false,
					'OfferToReceiveVideo': true
				}
			};

			//----------------------交换信息 -----------------------

			function onOffer(evt) {
				console.log("接收到offer...")
				console.log(evt);
				setOffer(evt);
				sendAnswer(evt);
				peerStarted = true
			}

			function onAnswer(evt) {
				console.log("接收到Answer...")
				console.log(evt);
				setAnswer(evt);
			}

			function onCandidate(evt) {
				var candidate = new RTCIceCandidate({
					sdpMLineIndex: evt.sdpMLineIndex,
					sdpMid: evt.sdpMid,
					candidate: evt.candidate
				});
				console.log("接收到Candidate...")
				console.log(candidate);
				peerConnection.addIceCandidate(candidate);
			}

			function sendSDP(sdp) {
				var text = JSON.stringify(sdp);
				console.log('发送sdp.....')
				console.log(text); // "type":"offer"....
				// textForSendSDP.value = text;
				// 通过socket发送sdp
				socket.send(text)
			}

			function sendCandidate(candidate) {
				var text = JSON.stringify(candidate);
				console.log(text); // "type":"candidate","sdpMLineIndex":0,"sdpMid":"0","candidate":"....
				socket.send(text) // socket发送
			}

			//---------------------- 视频处理 -----------------------
			function startVideo() {
				navigator.webkitGetUserMedia({
						video: true,
						audio: true
					},
					function(stream) { //success
						localStream = stream;
						localVideo.srcObject = stream;
						//localVideo.src = window.URL.createObjectURL(stream);
						localVideo.play();
						localVideo.volume = 0;
					},
					function(error) { //error
						console.error('发生了一个错误: [错误代码:' + error.code + ']');
						return;
					});
			}

			function refreshPage() {
				location.reload();
			}

			//---------------------- 处理连接 -----------------------
			function prepareNewConnection() {
				var pc_config = {
					"iceServers": []
				};
				var peer = null;
				try {
					peer = new webkitRTCPeerConnection(pc_config);
				} catch (e) {
					console.log("建立连接失败,错误:" + e.message);
				}

				// 发送所有ICE候选者给对方
				peer.onicecandidate = function(evt) {
					if (evt.candidate) {
						console.log(evt.candidate);
						sendCandidate({
							type: "candidate",
							sdpMLineIndex: evt.candidate.sdpMLineIndex,
							sdpMid: evt.candidate.sdpMid,
							candidate: evt.candidate.candidate
						});
					}
				};
				console.log('添加本地视频流...');
				peer.addStream(localStream);

				peer.addEventListener("addstream", onRemoteStreamAdded, false);
				peer.addEventListener("removestream", onRemoteStreamRemoved, false);

				// 当接收到远程视频流时,使用本地video元素进行显示
				function onRemoteStreamAdded(event) {
					console.log("添加远程视频流");
					// remoteVideo.src = window.URL.createObjectURL(event.stream);
					remoteVideo.srcObject = event.stream;
				}

				// 当远程结束通信时,取消本地video元素中的显示
				function onRemoteStreamRemoved(event) {
					console.log("移除远程视频流");
					remoteVideo.src = "";
				}

				return peer;
			}

			function sendOffer() {
				peerConnection = prepareNewConnection();
				peerConnection.createOffer(function(sessionDescription) { //成功时调用
					peerConnection.setLocalDescription(sessionDescription);
					console.log("发送: SDP");
					console.log(sessionDescription);
					sendSDP(sessionDescription);
				}, function(err) { //失败时调用
					console.log("创建Offer失败");
				}, mediaConstraints);
			}

			function setOffer(evt) {
				if (peerConnection) {
					console.error('peerConnection已存在!');
					return;
				}
				peerConnection = prepareNewConnection();
				peerConnection.setRemoteDescription(new RTCSessionDescription(evt));
			}

			function sendAnswer(evt) {
				console.log('发送Answer,创建远程会话描述...');
				if (!peerConnection) {
					console.error('peerConnection不存在!');
					return;
				}

				peerConnection.createAnswer(function(sessionDescription) { //成功时
					peerConnection.setLocalDescription(sessionDescription);
					console.log("发送: SDP");
					console.log(sessionDescription);
					sendSDP(sessionDescription);
				}, function() { //失败时
					console.log("创建Answer失败");
				}, mediaConstraints);
			}

			function setAnswer(evt) {
				if (!peerConnection) {
					console.error('peerConnection不存在!');
					return;
				}
				peerConnection.setRemoteDescription(new RTCSessionDescription(evt));
			}

			//-------- 处理用户UI事件 -----
			// 开始建立连接
			function connect() {
				if (!localStream) {
					alert("请首先捕获本地视频数据.");
				} else if (peerStarted || !socketRead) {
					alert("请刷新页面后重试.");
				} else {
					sendOffer();
					peerStarted = true;
				}
			}

			// 停止连接
			function hangUp() {
				console.log("挂断.");
				stop();
			}

			function stop() {
				peerConnection.close();
				peerConnection = null;
				peerStarted = false;
			}
		</script>
	</body>
</html>

三、本地打开测试

因为打开摄像头就露脸了,所以就初始化截个图吧

四、搭建STUN和TURN服务:传送门

五、更改 html 配置

                var pc_config = {
                    "iceServers": [{
                        url: "stun:ip:端口"
                    }, {
                        url: "turn:ip:端口",
                        credential: "kurento",
                        username: "kurento"
                    }]
                };

注:如果想要非局域网测试,需要把 STUN、TURN 和 websocket服务 要部署到公网环境,然后记得更改 html内的 websocket ip和端口

注:以上内容仅提供参考和交流,请勿用于商业用途,如有侵权联系本人删除!

有关Spring Boot WebSocket + WebRTC 实现点对点视频通话功能Demo的更多相关文章

  1. ruby - 如何根据特征实现 FactoryGirl 的条件行为 - 2

    我有一个用户工厂。我希望默认情况下确认用户。但是鉴于unconfirmed特征,我不希望它们被确认。虽然我有一个基于实现细节而不是抽象的工作实现,但我想知道如何正确地做到这一点。factory:userdoafter(:create)do|user,evaluator|#unwantedimplementationdetailshereunlessFactoryGirl.factories[:user].defined_traits.map(&:name).include?(:unconfirmed)user.confirm!endendtrait:unconfirmeddoenden

  2. ruby-on-rails - Cucumber 是否只是 rspec 的包装器以帮助将测试组织成功能? - 2

    只是想确保我理解了事情。据我目前收集到的信息,Cucumber只是一个“包装器”,或者是一种通过将事物分类为功能和步骤来组织测试的好方法,其中实际的单元测试处于步骤阶段。它允许您根据事物的工作方式组织您的测试。对吗? 最佳答案 有点。它是一种组织测试的方式,但不仅如此。它的行为就像最初的Rails集成测试一样,但更易于使用。这里最大的好处是您的session在整个Scenario中保持透明。关于Cucumber的另一件事是您(应该)从使用您的代码的浏览器或客户端的角度进行测试。如果您愿意,您可以使用步骤来构建对象和设置状态,但通常您

  3. 华为OD机试用Python实现 -【明明的随机数】 2023Q1A - 2

    华为OD机试题本篇题目:明明的随机数题目输入描述输出描述:示例1输入输出说明代码编写思路最近更新的博客华为od2023|什么是华为od,od薪资待遇,od机试题清单华为OD机试真题大全,用Python解华为机试题|机试宝典【华为OD机试】全流程解析+经验分享,题型分享,防作弊指南华为o

  4. 基于C#实现简易绘图工具【100010177】 - 2

    C#实现简易绘图工具一.引言实验目的:通过制作窗体应用程序(C#画图软件),熟悉基本的窗体设计过程以及控件设计,事件处理等,熟悉使用C#的winform窗体进行绘图的基本步骤,对于面向对象编程有更加深刻的体会.Tutorial任务设计一个具有基本功能的画图软件**·包括简单的新建文件,保存,重新绘图等功能**·实现一些基本图形的绘制,包括铅笔和基本形状等,学习橡皮工具的创建**·设计一个合理舒适的UI界面**注明:你可能需要先了解一些关于winform窗体应用程序绘图的基本知识,以及关于GDI+类和结构的知识二.实验环境Windows系统下的visualstudio2017C#窗体应用程序三.

  5. MIMO-OFDM无线通信技术及MATLAB实现(1)无线信道:传播和衰落 - 2

     MIMO技术的优缺点优点通过下面三个增益来总体概括:阵列增益。阵列增益是指由于接收机通过对接收信号的相干合并而活得的平均SNR的提高。在发射机不知道信道信息的情况下,MIMO系统可以获得的阵列增益与接收天线数成正比复用增益。在采用空间复用方案的MIMO系统中,可以获得复用增益,即信道容量成倍增加。信道容量的增加与min(Nt,Nr)成正比分集增益。在采用空间分集方案的MIMO系统中,可以获得分集增益,即可靠性性能的改善。分集增益用独立衰落支路数来描述,即分集指数。在使用了空时编码的MIMO系统中,由于接收天线或发射天线之间的间距较远,可认为它们各自的大尺度衰落是相互独立的,因此分布式MIMO

  6. 【Java入门】使用Java实现文件夹的遍历 - 2

    遍历文件夹我们通常是使用递归进行操作,这种方式比较简单,也比较容易理解。本文为大家介绍另一种不使用递归的方式,由于没有使用递归,只用到了循环和集合,所以效率更高一些!一、使用递归遍历文件夹整体思路1、使用File封装初始目录,2、打印这个目录3、获取这个目录下所有的子文件和子目录的数组。4、遍历这个数组,取出每个File对象4-1、如果File是否是一个文件,打印4-2、否则就是一个目录,递归调用代码实现publicclassSearchFile{publicstaticvoidmain(String[]args){//初始目录Filedir=newFile("d:/Dev");Datebeg

  7. ruby - Arrays Sets 和 SortedSets 在 Ruby 中是如何实现的 - 2

    通常,数组被实现为内存块,集合被实现为HashMap,有序集合被实现为跳跃列表。在Ruby中也是如此吗?我正在尝试从性能和内存占用方面评估Ruby中不同容器的使用情况 最佳答案 数组是Ruby核心库的一部分。每个Ruby实现都有自己的数组实现。Ruby语言规范只规定了Ruby数组的行为,并没有规定任何特定的实现策略。它甚至没有指定任何会强制或至少建议特定实现策略的性能约束。然而,大多数Rubyist对数组的性能特征有一些期望,这会迫使不符合它们的实现变得默默无闻,因为实际上没有人会使用它:插入、前置或追加以及删除元素的最坏情况步骤复

  8. ruby - "public/protected/private"方法是如何实现的,我该如何模拟它? - 2

    在ruby中,你可以这样做:classThingpublicdeff1puts"f1"endprivatedeff2puts"f2"endpublicdeff3puts"f3"endprivatedeff4puts"f4"endend现在f1和f3是公共(public)的,f2和f4是私有(private)的。内部发生了什么,允许您调用一个类方法,然后更改方法定义?我怎样才能实现相同的功能(表面上是创建我自己的java之类的注释)例如...classThingfundeff1puts"hey"endnotfundeff2puts"hey"endendfun和notfun将更改以下函数定

  9. ruby-on-rails - rails 功能测试 - 2

    在Rails自动生成的功能测试(test/functional/products_controller_test.rb)中,我看到以下代码:classProductsControllerTest我的问题是:方法调用products()在哪里/如何定义?products(:one)到底是什么意思?看代码,大概意思是“创建一个产品”,但是它是如何工作的呢?注意我是Ruby/Rails的新手,如果这些是微不足道的问题,我深表歉意。 最佳答案 如果您查看test/fixtures文件夹,您会看到一个products.yml文件。这是在您创建

  10. ruby - 实现k最近邻需要哪些数据? - 2

    我目前有一个reddit克隆类型的网站。我正在尝试根据我的用户之前喜欢的帖子推荐帖子。看起来K最近邻或k均值是执行此操作的最佳方法。我似乎无法理解如何实际实现它。我看过一些数学公式(例如k表示维基百科页面),但它们对我来说并没有真正意义。有人可以推荐一些伪代码,或者可以查看的地方,以便我更好地了解如何执行此操作吗? 最佳答案 K最近邻(又名KNN)是一种分类算法。基本上,您采用包含N个项目的训练组并对它们进行分类。如何对它们进行分类完全取决于您的数据,以及您认为该数据的重要分类特征是什么。在您的示例中,这可能是帖子类别、谁发布了该项

随机推荐