Spring Boot WebSocket + WenRTC实现视频通话功能

参考书籍:HTML5 与 CSS3权威指南

初识WebRTC

实现让电话、电视及计算机都能够进行通信的公共平台,一个可以实现点对点视频聊天的Web应用程序,这就是WebRTC的目标。

WebRTC API是一个与getUserMedia方法紧密相关的API,它提供一种访问客户端本地的摄像头或麦克风设备的能力。

总体来说,WebRTC包含三个API:

  • MediaStream(getUserMedia)
  • RTCPeerConnection
  • RTCDataChannel

手工建立WebRTC通信

WebRTC通信是指将实时取得的视频、音频等数据流(亦称字节流)在浏览器之间进行通信,即RTCPeerConnection。RTCPeerConnection具有两个特征:

  • Peer-to-Peer(P2P)通信:浏览器与浏览器之间的直接通信。
  • 使用UDP/IP:虽然不像TCP/IP那样确保每一个字节的到达,但是网络负荷量也较小。与数据的可靠性相比,更重视实时性。UDP端口号可以动态分配,范围在49152~65535之间。

建立P2P通信

为了在浏览器与浏览器之间进行P2P通信,必须首先知道对方的IP地址及动态分配的UDP端口号。因此在建立P2P通信之前,首先需要使用WebRTC交换一些信息。

(一)SDP:会话描述协议

SDP(Session Description Protocol)是一种会话描述协议,它以字符串的形式显示如下所示的一些浏览器信息:

  • 在浏览器与浏览器之间所进行的会话中将要使用的媒体种类(音频、视频)、媒体格式(codec)。
  • 通信双方所使用的IP地址及端口号。
  • P2P的数据传输协议,在WebRTC中为Secure RTP。
  • 通信时所使用的带宽。
  • 会话的属性(名称、标识符、激活时间)等。

(二)ICE:NAT穿越的协议(有点类似于内网穿透)

ICE(Interactive Connectivity Establishment)是一种在以UDP为基础的请求/回答模式的多媒体会话用于实现NAT穿越的协议。它以清单的形式描述P2P通信时可以使用的如下所示的一些通信途径:

  • 使用P2P进行直接通信。
  • 使用STUN(为了穿越NAT而进行端口映射)实现突破NAT网关的P2P通信。
  • 使用TURN中继服务器进行突破防火墙的中继通信。

ICE协议在网络上通过最短途径(网络负荷最小的途径)选择被发现的候选者,并按优先级依序列举这些候选者。

实现信令(WebRTC + SpringBoot Socket)

在P2P通信之前首先需要交换SDP信息与ICE信息,该过程称为“信令”。而进行信息交互的服务器成为信令服务器。

前端实现


<html>

<head>
    <title>交换SDP信息与ICE信息title>
    <meta name="viewport" content="width=device-width,
initial-scale=1,maximum-scale=1" />
head>

<body>
    <button type="button" onclick="startVideo();">开始捕获视频信息button>
    <button type="button" onclick="stopVideo();">停止捕获视频信息button>
        
    <button type="button" onclick="connect();">建立连接button>
    <button type="button" onclick="hangUp();">挂断button>
    <br />
    <div>
        <video id="local-video" autoplay style="width: 240px; height: 180px;
    border: 1px solid black;">video>
        <video id="remote-video" autoplay style="width: 240px; height: 180px;
    border: 1px solid black;">video>
    div>
    <p>

    <script>
        // ===================以下是socket=======================
        var user = Math.round(Math.random()*1000) + ""
        var socketUrl = "ws://127.0.0.1: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: false },
                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 stopVideo() {
            localVideo.src = "";
            localStream.stop();
        }

        //---------------------- 处理连接 -----------------------
        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 (!peerStarted && localStream && socketRead) {
                sendOffer();
                peerStarted = true;
            } else {
                alert("请首先捕获本地视频数据.");
            }
        }

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

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

后端实现

package com.one.socket.socketserver;

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--;
    }
}

Spring Boot WebSocket + WenRTC实现视频通话功能_第1张图片
在这里插入图片描述

说明:视频通话需要开启两个窗口,首先在A、B窗口分别开启视频捕获,然后将A窗口作为通话发起方,点击建立连接,便实现了自己与自己进行视频通话,所以两个窗口中的内容是相同的。

大致流程

Spring Boot WebSocket + WenRTC实现视频通话功能_第2张图片

你可能感兴趣的:(java)