SpringBoot+WebSocket+Vue+PeerJs实现WebRTC视频通话功能

Posted june-YiDai

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了SpringBoot+WebSocket+Vue+PeerJs实现WebRTC视频通话功能相关的知识,希望对你有一定的参考价值。

开发背景

博主正在担任一款电商app的全栈开发,其中涉及到一个视频通话功能。但是由于业务需求及成本考虑,不能使用第三方提供的SDK进行开发。所以博主选择使用PeerJs+WebSocket来实现这个功能。

专业名词、术语解释

WebSocket

WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket通信协议于2011年被IETF定为标准RFC 6455,并由RFC7936补充规范。WebSocket API也被W3C定为标准。
WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

WebRTC

WebRTC(Web Real-Time Communications) 是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。WebRTC 包含的这些标准使用户在无需安装任何插件或者第三方的软件的情况下,创建点对点(Peer-to-Peer)的数据分享和电话会议成为可能

PeerJs

PeerJS simplifies WebRTC peer-to-peer data, video, and audio calls.
PeerJS wraps the browser’s WebRTC implementation to provide a complete, configurable, and easy-to-use peer-to-peer connection API. Equipped with nothing but an ID, a peer can create a P2P data or media stream connection to a remote peer.

以上内容来源于PeerJs官网介绍,大概的意思如下(仅供参考,博主英语不好):

PeerJS简化了WebRTC点对点数据、视频和音频呼叫。
PeerJS封装了浏览器的WebRTC实现 提供一个完整 可配置且易于使用的点对点连接API,只需要一个id就能创建到远程的P2P数据或媒体流连接。

PeerJs官网:https://peerjs.com
PeerJs官方文档:https://peerjs.com/docs/

技术应用场景及分析

WebSocket

主要用于即使通讯,辅助建立P2P视频通话,WebSocket即时通讯不是本文探讨的重点,有关WebSocket即时通讯的相关内容可以参考博主的另一篇WebSocket即时通讯的博客:https://blog.csdn.net/daiyi666/article/details/124824543?spm=1001.2014.3001.5501

WebRTC

经过上面对WebRTC的官方解释,估计很多小伙伴还是有点懵的状态。简而言之呢,WebRTC就是用于实现端到端这样的一个即时通讯的技术,也就是说无需经过服务器中转(建立连接以后)。相信很多了解过IPV4技术的小伙伴都知道,如果两个终端处在不同的网络下,是无法直接进行通信的,因为经过了NAT,而WebRTC可以借助一个公网的服务器,我们称之为“信令服务器”,以及一个“ICE”服务器进行身份信息交换和打洞,打洞完成之后即可进行P2P通信,且不再需要服务器进行中转

WebRTC架构图

WebRTC常用API

API用途
getUserMedia()获取用户的摄像头以及麦克风
RTCPeerConnection()端到端连接之间建立音视频通信及 NAT 穿透
RTCDataChannel()端到端之间数据共享

编码实现

前端(关键代码)

打开摄像头和麦克风

要实现视频通话,首先应该要能够打开摄像头和麦克风,那么如何在浏览器中打开摄像头和麦克风,还有如何解决浏览器兼容问题呢,请参考以下代码:

function getUserMedia(constrains) 
    if (window.navigator.mediaDevices.getUserMedia) 
        return window.navigator.mediaDevices.getUserMedia(constrains);
     else if (window.navigator.webkitGetUserMedia) 
        return window.navigator.webkitGetUserMedia(constrains);
     else if (window.navigator.mozGetUserMedia) 
        return window.navigator.mozGetUserMedia(constrains);
     else if (window.navigator.getUserMedia) 
        return window.navigator.getUserMedia(constrains);
    

getUserMedia函数将会返回一个Promise对象,这个Promise对象就封装了摄像头和麦克风的流媒体,而参数constrains是作为一个约束出现,通过这个约束对象,可以设置获取到的视频或音频的一些参数,如视频宽高,消除回音等,具体请参考https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia传送门

预览摄像头画面(vue3环境)

模板

<template>
	<video ref="localCameraVideo"></video>
	<video ref="remoteCameraVideo"></video>
</template>

script(setup 语法糖)

import  onMounted, ref  from "vue";
const localCameraVideo = ref();
const remoteCameraVideo = ref();

onMounted(() => 
    getUserMedia().then(userMedia => 
    	//通过video对象的srcObject 赋值userMedia 就能预览到画面
	    localCameraVideo.value.srcObject = userMedia;
	    localCameraVideo.value.play();
	);
);

注意事项

  • video标签需要有宽高,不然会显示不出来
  • getUserMedia需要在localhost或者https环境中才能打开摄像头,否则将会报错,如果要部署测试,又没有https证书,那么可以通过设置谷歌浏览器参数绕过安全检测,具体操作是,右键谷歌浏览器图标->属性->目标

    在路径后面添加以下参数:--unsafely-treat-insecure-origin-as-secure="你的服务器地址",示例:"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" --unsafely-treat-insecure-origin-as-secure="http://192.168.50.111:8080"

建立WebSocket连接

博主使用了reconnecting-websocket库进行WebSocket连接,可根据实际情况选择其他库或者用原生WebSocket进行编码,有关WebSocket即时通讯的相关内容可以参考博主的另一篇WebSocket即时通讯的博客:https://blog.csdn.net/daiyi666/article/details/124824543?spm=1001.2014.3001.5501

function establishWebSocketConnection() 
	const webSocketConnection = new ReconnectingWebSocket("你的WebSocket服务器地址");
	
	webSocketConnection.onopen = () => 
		//do something
	;
	
	webSocketConnection.onmessage = event => 
		//do something
	;

	return webSocketConnection;

可能有的小伙伴困惑为什么要用WebSocket,这是因为后面创建了Peer对象之后会有一个ID,这个ID是全局唯一的,代表着一个Peer客户端,我们需要用WebSocket把这个ID发送给服务器,以及客户端离线之后从服务器上移除这个ID,还有从服务器即使更新在线的客户端等,当然也可以用ajax,只是WebSocket更具备即时性

创建Peer对象,并将ID通过WebSocket发送到服务器

function createPeerSendToWebSocketServer(webSocketConnection) 
	const peer = new Peer();
	//当peer被打开时被执行,peerId是全局唯一的
	peer.on("open", peerId => 
		console.log("peer opend, the peerId is:", peerId);
		
		peer.on("close", () => 
			console.log("peer close....");
			//(呼叫方和接收方的ID都应该保存到服务器)
			//此处应该发送一个JSON对象给服务器,方便判断,以下是伪代码,用于演示
			webSocketConnection.send(peerId);
		);
		
		//(呼叫方和接收方的ID都应该保存到服务器)
		//此处应该发送一个JSON对象给服务器,方便判断,以下是伪代码,用于演示
		webSocketConnection.send(peerId);
	);
	
	return peer;

呼叫对方

//peerId是对方的peer id,localUserMedia是上面通过getUserMedia获取到的(promise调then)
const call = peer.call(peerId, localUserMedia);

//当得到流时调用,remoteUserMedia 是对方的流媒体,直接赋值给video的srcObject 属性,就可以看到对方的画面了
call.on("stream", remoteUserMedia => 
      remoteCameraVideo.value.srcObject = remoteUserMedia;
      remoteCameraVideo.value.play();
  );
);

接受方

//当收到对方的呼叫时调用,mediaConnection 封装了媒体连接
peer.on("call", mediaConnection => 
	//通过mediaConnection相应给对方自己的媒体信息
	mediaConnection.answer(localUserMedia);
	
	//当得到流时调用,remoteUserMedia 是对方的流媒体
	mediaConnection.on("stream", remoteCameraStream => 
	   remoteCameraVideo.value.srcObject = remoteCameraStream;
	   remoteCameraVideo.value.play();
	);
);

后端(关键代码)

pom依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.3</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    
    <groupId>com.fenzhimedia</groupId>
    <artifactId>video-call</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>video-call</name>
    <description>video-call</description>
    
    <properties>
        <java.version>17</java.version>
    </properties>
    
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

注入serverEndpointExporter

package com.fenzhimedia.configuration;

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

/**
 * @author Yi Dai 484201132@qq.com
 * @since 2022/10/18 10:07
 */

@Configuration
public class WebsocketConfiguration 

    @Bean
    public ServerEndpointExporter serverEndpointExporter() 
        return new ServerEndpointExporter();
    



监听websocket事件

package com.fenzhimedia.websocket;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnMessage;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;

/**
 * @author Yi Dai 484201132@qq.com
 * @since 2022/3/7 15:47
 */

@Slf4j
@Component
@ServerEndpoint("/videoCallServerEndpoint")
public class VideoCallServerEndpoint 
    
    @OnMessage
    public void onMessage(Session session, String stringMessage) 
        log.info("onMessage:the session is:,the stringMessage is:", session, stringMessage);
    

    @OnClose
    public void onClose(Session session) 
        log.info("onClose:the session is:", session);
    

    @OnError
    public void onError(Session session, Throwable e) 
        log.info("onError:the session is:,e:", session, e);
        e.printStackTrace();
    
    

优化建议

以上代码为伪代码,用于演示,有很多可以优化的地方;如在vue挂在完成之后立即创建websocket连接,然后创建peer对象注册到服务器中,这样才能保证在线状态

演示demo

前端

https://gitee.com/daiyi-personal/video-call-web.git

后台(Java)

https://gitee.com/daiyi-personal/video-call-java.git

SpringBoot+Vue+WebSocket 实现在线聊天

一、前言

本文将基于 SpringBoot + Vue + WebSocket 实现一个简单的在线聊天功能

页面如下:
在这里插入图片描述

在线体验地址:http://www.zhengqingya.com:8101

二、SpringBoot + Vue + WebSocket 实现在线聊天

1、引入websocket依赖

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

2、websocket 配置类

@Configuration
public class WebSocketConfig {
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

3、websocket 处理类Controller

@Slf4j
@Component
@ServerEndpoint("/groupChat/{sid}/{userId}")
public class WebSocketServerController {

    /**
     * 房间号 -> 组成员信息
     */
    private static ConcurrentHashMap<String, List<Session>> groupMemberInfoMap = new ConcurrentHashMap<>();
    /**
     * 房间号 -> 在线人数
     */
    private static ConcurrentHashMap<String, Set<Integer>> onlineUserMap = new ConcurrentHashMap<>();

    /**
     * 收到消息调用的方法,群成员发送消息
     *
     * @param sid:房间号
     * @param userId:用户id
     * @param message:发送消息
     */
    @OnMessage
    public void onMessage(@PathParam("sid") String sid, @PathParam("userId") Integer userId, String message) {
        List<Session> sessionList = groupMemberInfoMap.get(sid);
        Set<Integer> onlineUserList = onlineUserMap.get(sid);
        // 先一个群组内的成员发送消息
        sessionList.forEach(item -> {
            try {
                // json字符串转对象
                MsgVO msg = JSONObject.parseObject(message, MsgVO.class);
                msg.setCount(onlineUserList.size());
                // json对象转字符串
                String text = JSONObject.toJSONString(msg);
                item.getBasicRemote().sendText(text);
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
    }

    /**
     * 建立连接调用的方法,群成员加入
     *
     * @param session
     * @param sid
     */
    @OnOpen
    public void onOpen(Session session, @PathParam("sid") String sid, @PathParam("userId") Integer userId) {
        List<Session> sessionList = groupMemberInfoMap.computeIfAbsent(sid, k -> new ArrayList<>());
        Set<Integer> onlineUserList = onlineUserMap.computeIfAbsent(sid, k -> new HashSet<>());
        onlineUserList.add(userId);
        sessionList.add(session);
        // 发送上线通知
        sendInfo(sid, userId, onlineUserList.size(), "上线了~");
    }


    public void sendInfo(String sid, Integer userId, Integer onlineSum, String info) {
        // 获取该连接用户信息
        User currentUser = ApplicationContextUtil.getApplicationContext().getBean(UserMapper.class).selectById(userId);
        // 发送通知
        MsgVO msg = new MsgVO();
        msg.setCount(onlineSum);
        msg.setUserId(userId);
        msg.setAvatar(currentUser.getAvatar());
        msg.setMsg(currentUser.getNickName() + info);
        // json对象转字符串
        String text = JSONObject.toJSONString(msg);
        onMessage(sid, userId, text);
    }

    /**
     * 关闭连接调用的方法,群成员退出
     *
     * @param session
     * @param sid
     */
    @OnClose
    public void onClose(Session session, @PathParam("sid") String sid, @PathParam("userId") Integer userId) {
        List<Session> sessionList = groupMemberInfoMap.get(sid);
        sessionList.remove(session);
        Set<Integer> onlineUserList = onlineUserMap.get(sid);
        onlineUserList.remove(userId);
        // 发送离线通知
        sendInfo(sid, userId, onlineUserList.size(), "下线了~");
    }

    /**
     * 传输消息错误调用的方法
     *
     * @param error
     */
    @OnError
    public void OnError(Throwable error) {
        log.info("Connection error");
    }
}

4、websocket 消息显示类

@Data
@ApiModel(description = "websocket消息内容")
public class MsgVO {

    @ApiModelProperty(value = "用户id")
    private Integer userId;

    @ApiModelProperty(value = "用户名")
    private String username;

    @ApiModelProperty(value = "用户头像")
    private String avatar;

    @ApiModelProperty(value = "消息")
    private String msg;

    @ApiModelProperty(value = "在线人数")
    private int count;

}

5、前端页面

温馨小提示:当用户登录成功之后,可以发起websocket连接,存在store中...

下面只是单页面的简单实现

<template>
  <div class="chat-box">
    <header>聊天室 (在线:{{count}}人)</header>
    <div class="msg-box" ref="msg-box">
      <div
        v-for="(i,index) in list"
        :key="index"
        class="msg"
        :style="i.userId == userId?\'flex-direction:row-reverse\':\'\'"
      >
        <div class="user-head">
          <img :src="i.avatar" height="30" width="30" :title="i.username">
        </div>
        <div class="user-msg">
          <span :style="i.userId == userId?\' float: right;\':\'\'" :class="i.userId == userId?\'right\':\'left\'">{{i.content}}</span>
        </div>
      </div>
    </div>
    <div class="input-box">
      <input type="text" ref="sendMsg" v-model="contentText" @keyup.enter="sendText()" />
      <div class="btn" :class="{[\'btn-active\']:contentText}" @click="sendText()">发送</div>
    </div>
  </div>
</template>

<script>
  export default {
    data() {
      return {
        ws: null,
        count: 0,
        userId: this.$store.getters.id, // 当前用户ID
        username: this.$store.getters.name, // 当前用户昵称
        avatar: this.$store.getters.avatar, // 当前用户头像
        list: [], // 聊天记录的数组
        contentText: "" // input输入的值
      };
    },
    mounted() {
      this.initWebSocket();
    },
    destroyed() {
      // 离开页面时关闭websocket连接
      this.ws.onclose(undefined);
    },
    methods: {
      // 发送聊天信息
      sendText() {
        let _this = this;
        _this.$refs["sendMsg"].focus();
        if (!_this.contentText) {
          return;
        }
        let params = {
          userId: _this.userId,
          username: _this.username,
          avatar: _this.avatar,
          msg: _this.contentText,
          count: _this.count
        };
        _this.ws.send(JSON.stringify(params)); //调用WebSocket send()发送信息的方法
        _this.contentText = "";
        setTimeout(() => {
          _this.scrollBottm();
        }, 500);
      },
      // 进入页面创建websocket连接
      initWebSocket() {
        let _this = this;
        // 判断页面有没有存在websocket连接
        if (window.WebSocket) {
          var serverHot =  window.location.hostname;
          let sip = \'房间号\'
          // 填写本地IP地址 此处的 :9101端口号 要与后端配置的一致!
          var url = \'ws://\' + serverHot + \':9101\' + \'/groupChat/\' + sip + \'/\' + this.userId; // `ws://127.0.0.1/9101/groupChat/10086/聊天室`
          let ws = new WebSocket(url);
          _this.ws = ws;
          ws.onopen = function(e) {
            console.log("服务器连接成功: " + url);
          };
          ws.onclose = function(e) {
            console.log("服务器连接关闭: " + url);
          };
          ws.onerror = function() {
            console.log("服务器连接出错: " + url);
          };
          ws.onmessage = function(e) {
            //接收服务器返回的数据
            let resData = JSON.parse(e.data)
            _this.count = resData.count;
            _this.list = [
              ..._this.list,
              { userId: resData.userId, username: resData.username, avatar: resData.avatar, content: resData.msg }
            ];
          };
        }
      },
      // 滚动条到底部
      scrollBottm() {
          let el = this.$refs["msg-box"];
          el.scrollTop = el.scrollHeight;
      }
    }
  };
</script>

<style lang="scss" scoped>
  .chat-box {
    margin: 0 auto;
    background: #fafafa;
    position: absolute;
    height: 100%;
    width: 100%;
    max-width: 700px;
  header {
    position: fixed;
    width: 100%;
    height: 3rem;
    background: #409eff;
    max-width: 700px;
    display: flex;
    justify-content: center;
    align-items: center;
    font-weight: bold;
    color: white;
    font-size: 1rem;
  }
  .msg-box {
    position: absolute;
    height: calc(100% - 6.5rem);
    width: 100%;
    margin-top: 3rem;
    overflow-y: scroll;
  .msg {
    width: 95%;
    min-height: 2.5rem;
    margin: 1rem 0.5rem;
    position: relative;
    display: flex;
    justify-content: flex-start !important;
  .user-head {
    min-width: 2.5rem;
    width: 20%;
    width: 2.5rem;
    height: 2.5rem;
    border-radius: 50%;
    background: #f1f1f1;
    display: flex;
    justify-content: center;
    align-items: center;
  .head {
    width: 1.2rem;
    height: 1.2rem;
  }
  // position: absolute;
  }
  .user-msg {
    width: 80%;
  // position: absolute;
    word-break: break-all;
    position: relative;
    z-index: 5;
  span {
    display: inline-block;
    padding: 0.5rem 0.7rem;
    border-radius: 0.5rem;
    margin-top: 0.2rem;
    font-size: 0.88rem;
  }
  .left {
    background: white;
    animation: toLeft 0.5s ease both 1;
  }
  .right {
    background: #53a8ff;
    color: white;
    animation: toright 0.5s ease both 1;
  }
  @keyframes toLeft {
    0% {
      opacity: 0;
      transform: translateX(-10px);
    }
    100% {
      opacity: 1;
      transform: translateX(0px);
    }
  }
  @keyframes toright {
    0% {
      opacity: 0;
      transform: translateX(10px);
    }
    100% {
      opacity: 1;
      transform: translateX(0px);
    }
  }
  }
  }
  }
  .input-box {
    padding: 0 0.5rem;
    position: absolute;
    bottom: 0;
    width: 100%;
    height: 3.5rem;
    background: #fafafa;
    box-shadow: 0 0 5px #ccc;
    display: flex;
    justify-content: space-between;
    align-items: center;
  input {
    height: 2.3rem;
    display: inline-block;
    width: 100%;
    padding: 0.5rem;
    border: none;
    border-radius: 0.2rem;
    font-size: 0.88rem;
  }
  .btn {
    height: 2.3rem;
    min-width: 4rem;
    background: #e0e0e0;
    padding: 0.5rem;
    font-size: 0.88rem;
    color: white;
    text-align: center;
    border-radius: 0.2rem;
    margin-left: 0.5rem;
    transition: 0.5s;
  }
  .btn-active {
    background: #409eff;
  }
  }
  }
</style>

本文案例demo源码

https://gitee.com/zhengqingya/xiao-xiao-su

以上是关于SpringBoot+WebSocket+Vue+PeerJs实现WebRTC视频通话功能的主要内容,如果未能解决你的问题,请参考以下文章

springboot+vue实现websocket

Springboot+vue3集成使用WebSocket

Springboot+vue3集成使用WebSocket

Springboot+vue3集成使用WebSocket

SpringBoot实现WebSocket发送接收消息 + Vue实现SocketJs接收发送消息

vue+websocket+Springboot实现的即时通信开源项目