NodeJS 实现视频通话/直播

2,617 阅读9分钟

前言:叮叮当,叮叮当,铃儿响叮当。 全文分为两种方式去实现视频通话或者说是直播

技术选型

1、P2P实现
2、流媒体服务实现

流媒体服务实现

1、nodejs-media-server(经典版)
2、mediasoup(流行版)

区别

Mediasoup和Node.js Media Server都是基于Node.js的开源流媒体服务器框架,用于构建实时通信应用和流媒体处理系统。

  1. 架构和设计理念:Mediasoup采用了可扩展的SFU(Selective Forwarding Unit)架构,它将音视频流转发给参与者而不进行混流。这种架构适用于实时通信应用,如视频会议、直播和WebRTC应用。Node.js Media Server则更加灵活,可以用于构建不同类型的流媒体应用,包括录制、转码和实时处理。
  2. WebRTC支持:Mediasoup专注于提供高性能、稳定的WebRTC通信。它通过WebRTC技术实现了低延迟、高质量的音视频通信,并提供了丰富的API和功能来管理和控制通信会话。Node.js Media Server也支持WebRTC,但不像Mediasoup那样专注于WebRTC通信。
  3. API和功能:Mediasoup提供了丰富的API和功能来管理WebRTC通信,包括处理音视频流、音频混音、流量控制等。它提供了灵活的路由策略和媒体处理能力。Node.js Media Server也提供了一些流媒体处理功能,如录制、转码和实时处理,但功能较Mediasoup更为全面。
  4. 社区和生态系统:Mediasoup拥有活跃的开发者社区和强大的生态系统,提供了广泛的文档、示例和插件支持。Node.js Media Server相对较小的社区。

需要根据具体的应用需求和技术要求来选择合适的框架。如果你需要构建实时通信应用,并且专注于WebRTC通信,Mediasoup可能更适合。如果你需要更广泛的流媒体处理功能,如录制、转码和实时处理,Node.js Media Server可能更适合。

P2P 实现

1、WebRtc 对等通信

特点

  1. 低延迟:WebRTC P2P通信允许直接在浏览器之间建立点对点的连接,绕过传统的中心化服务器中转。这减少了通信路径的长度和延迟,使得实时通信应用能够实现更低的延迟。
  2. 高带宽利用率:在传统的中心化服务器模型中,所有数据流都必须通过服务器进行中转,这会增加服务器的负载和网络带宽需求。WebRTC P2P通信允许直接在对等方之间传输数据,减少了对服务器带宽的依赖,提高了带宽利用率。
  3. 增强的隐私和安全性:由于WebRTC P2P通信直接在对等方之间建立连接,而不需要通过中间服务器,因此减少了数据在网络中的暴露风险。这提供了更好的隐私和安全性,特别是在敏感数据或实时通信场景中。
  4. 可扩展性:WebRTC P2P通信允许直接在对等方之间建立多对多的连接,这意味着通信可以同时在多个对等方之间进行。这提供了更好的可扩展性,可以支持更多的参与者同时进行实时通信。
  5. 减少服务器成本:通过使用WebRTC P2P通信,可以减少对中心化服务器的依赖,从而降低服务器的成本和维护复杂性。对于规模较小的应用,可能可以完全避免使用中心化服务器。
  6. 麻烦:在涉及对称NAT的网络环境中,通过WebRTC P2P建立连接可能需要使用技术如STUN(会话穿越实用工具)和TURN(转发用户数据)服务器来帮助中转数据

关键

接下来会使用三种不同的方式去实现 视频通话/直播 的功能。

P2P实现

先看图,搂一眼大概就好

未命名绘图.png

太多理论就不在这里赘述了,因为可以google查到。我简单的用故事描述一下大致原理。
A特工想和B特工说一些机密情报 要讲上一天一夜,然后A特工就在黑板上(信令服务器)写了特殊暗语,B特工就找到了特殊暗语,并在下面回复了暗语表达了内心想听情报的诉求。但是B特工不知道A住在哪,A特工就说我在渣渣银行某个保险柜里放了我的地址,你拿着我给你的暗号就能知道我的地址,然后B特工就去了银行(Ice服务器)找到了A特工的地址,然后双方就进行了友好的交流。

故事讲的稀烂,希望能知道个大概,下面有专业版本的解释,不抄进来是不想让文章篇幅太长。🔗链接在此。接下来,开干。

ICE服务器(turn服务器 & stun服务)

为什么需要turn服务呢?文字太长我就不抄了 👉点击链接👈

stun服务 一般用的就是google提供的

stun:stun.l.google.com:19302

但是turn服务器需要自己搭建,我使用的是华为云服务器,操作系统是Ubuntu。

第一步 首先安装 coturn

sudo apt-get update -y

sudo apt-get install coturn

第二步 编辑配置文件

sudo vim /etc/default/coturn

// 修改配置文件中 这一行代码 取消注释即可
TURNSERVER_ENABLED=1

第三步 用systemctl启动coturn服务

systemctl start coturn

第四步 防火墙允许端口

sudo ufw allow 3478

第五步 配置coturn

sudo vim /etc/turnserver.conf

第六步 完整配置如下

#Turn server name and realm 这个名字要用的
realm=bain
server-name=turnantalk
# 写你的服务器的IP地址
listening-ip=0.0.0.0
external-ip=116.205.246.245

# Use fingerprint in TURN message
fingerprint
# Specify the user for the TURN authentification <你的用户名:自己设的密码>
user=bain:999999
lt-cred-mech

# Main listening port
listening-port=3478

# Further ports that are open for communication
min-port=10000
max-port=20000

# Log file path
log-file=/home/ubuntu/server/turn/turnserver.log

# Enable verbose logging
verbose


# Enable long-term credential mechanism
lt-cred-mech

# If running coturn version older than 4.5.2, uncomment these rules and ensure
# that you have listening-ip set to ipv4 addresses only.
# Prevent Loopback bypass https://github.com/coturn/coturn/security/advisories/GHSA-6g6j-r9rf-cm7p
#denied-peer-ip=0.0.0.0-0.255.255.255
#denied-peer-ip=127.0.0.0-127.255.255.255
#denied-peer-ip=::1

第七步 重启

sudo service coturn restart

如何验证呢? 使用 👉 验证地址 没报错就行啦,如下图。 image.png

那如果不行怎么办呢?
先试试能不能ping通机器。 排查下:华为云服务器这边是需要对安全组规则进行设置,入出方向规则进行放开。

信令服务器

我选的技术栈是 egg+egg-socket。 image.png 设计这个信令服务器需要考虑这么几个事情。 他的作用: 1、做一些校验 2、开房间 3、对事件进行转发

校验

其实是偏向于业务测,就是比如房间人数限制,参数校验等等。 对于实际应用影响不大,就简单提一下。
举例: /middleware/connection.js

/**
 * 在每一个客户端连接或者退出时发生作用
 * @param app
 * @returns {(function(*, *): Promise<void>)|*}
 */
module.exports = (app) => {
  return async (ctx, next) => {
    const { room } = ctx.socket.handshake.query;
    if (!room) {
      return ctx.socket.emit('error','无房间号')
    }
    const  clientsInRoom = app.io.sockets.adapter.rooms[room];
    const numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0;
    // 限制房间人数
    if(numClients >= 2){
      return ctx.socket.emit('full',room)
    }
    await next();
  };
};

开房间 对事件进行转发

class ChatController extends Controller {
  async ping() {
    const message = this.ctx.args[0];
    await this.ctx.socket.emit("res", `Hi! I've got your message: ${message}`);
  }

  async message(){
    const message = this.ctx.args[0];
    switch (message.action){
      case 'join': return this.handleJoin();
      case 'leave': return this.disconnect();
      case 'offer': return this.handleSend(message);
      case 'answer': return this.handleSend(message);
      case 'candidate': return this.handleSend(message);
      default: return this.ping();
    }
  }

  async handleSend({action,data}){
    const { room, userId } = this.ctx.socket.handshake.query;
    this.ctx.logger.info(`type:${action}-时间戳${Date.now()}`)
    this.ctx.socket.broadcast.to(room).emit(action, { data });
  }

  async handleJoin(){
    const { room, userId } = this.ctx.socket.handshake.query;
    // 加入房间
    this.ctx.socket.join(room);
    // 单线程没问题 线上环境最好使用redis 之类
    if(!this.ctx.app.rooms){
      this.ctx.app.rooms = {}
    }
    if(!this.ctx.app.rooms[room]){
      return this.ctx.app.rooms[room] = {
        [userId]: {}
      }
    }
    this.ctx.app.rooms[room][userId] = {};
    //  new people 有新人加入房间
    this.ctx.socket.broadcast.to(room).emit('new-people', { userId });
  }


  async disconnect() {
    const { room, userId } = this.ctx.socket.handshake.query;
    this.ctx.logger.info(`room:${room}- userId:${userId}- 时间戳${Date.now()}`)
    this.ctx.socket.broadcast.to(room).emit('leave', { msg: userId });
  }
}

本文未对 媒体协商、网络协商、offer、answer、candidate 等 专业名词进行过多的解释,主要是文字内容过多,知识细节太多。所以,看这里👉链接👈。

以下是前端核心部分,

export class VideoControl {
  constructor(props) {
    this.client = null; // 房间
    this.socket = null;
    this.local = {
      pc: null,
      stream: null,
      elementId: props.locale,
    };
    this.remote = {
      pc: null,
      elementId: props.remote,
      stream: null,
      uid: null,
    };
  }

  //获取权限 音频权限 视频权限
  getUserDevicePermissions() {
    return navigator.mediaDevices
      .getUserMedia({ video: true, audio: true })
      .then((stream) => {
        // 用户已授权访问摄像头和麦克风
        // 可以使用stream对象进行进一步的操作
        this.isAuthorized = true;
        // 成功获取到视频流
        const videoDom = document.createElement("video");
        videoDom.style.height = "100%";
        videoDom.style.width = "100%";
        videoDom.srcObject = stream;
        this.local.stream = stream;
        document.getElementById(this.local.elementId).append(videoDom);
        videoDom.play();
      })
      .catch((error) => {
        // 用户拒绝了访问权限或发生了错误
        // 可以在此处处理错误情况
        this.isAuthorized = false;
      });
  }

  //初始化
  async init() {
    // 获取摄像头
    this.getCameras();
    this.connectWs();
  }

  // 建立ws链接
  connectWs() {
    const socket = io("ws://127.0.0.1:7001", {
      query: {
        room: "demo",
        userId: `client_${Math.random()}`,
      },
      forceNew: false,
      transports: ["websocket", "polling"],
      reconnectionDelay: 5000,
      autoConnect: true,
      reconnection: true,
      secure: true,
    });

    this.socket = socket;
    // 监听连接成功事件
    socket.on("connect", () => {
      console.log("Connected to the server");
      // 执行一些初始化操作或显示连接成功的提示
    });
    socket.on("new-people", ({ userId }) => {
      //有新人加入
      this.remote.uid = userId;
      this.getOffer();
    });
    socket.on("offer", ({ data }) => {
      this.handleRemoteOffer(data);
    });

    socket.on("leave", ({ data }) => {
      this.handleLeave();
    });

    socket.on("answer", ({ data }) => {
      this.handleAnswer(data);
    });

    socket.on("candidate", ({ data }) => {
      this.handleIceCandidate(data);
    });
    socket.on("disconnect", () => {
      this.handleLeave();
    });
    //重新连接时,将transports选项重置为Websocket
    socket.on("reconnect_attempt", () => {
      socket.io.opts.transports = ["polling", "websocket"];
    });
  }

  // 获取offer sdp
  getOffer() {
    // 获取实例
    const pc = this.createRTCPeerConnection();
    pc.createOffer()
      .then((offer) => {
        return pc.setLocalDescription(offer);
      })
      .then(() => {
        this.socket.emit("chat", {
          action: "offer",
          data: pc.localDescription,
        });
      });
  }

  async handleRemoteOffer(offer) {
    const pc = this.createRTCPeerConnection();
    const remoteOffer = new RTCSessionDescription(offer);
    // 设置远程描述
    await pc.setRemoteDescription(remoteOffer);

    // 创建 answer
    const answer = await pc.createAnswer();

    // 设置本地描述
    await pc.setLocalDescription(answer);

    // 发送 answer 给对方
    this.socket.emit("chat", { action: "answer", data: answer });
  }

  // 处理收到的 answer
  async handleAnswer(answer) {
    const remoteAnswer = new RTCSessionDescription(answer);

    // 设置远程描述
    await this.remote.pc.setRemoteDescription(remoteAnswer);
  }

  // 处理收到的 ICE 候选者
  async handleIceCandidate(candidate) {
    const iceCandidate = new RTCIceCandidate(candidate);

    // 添加 ICE 候选者到连接
    this.remote.pc.addIceCandidate(iceCandidate);
  }

  // 共享屏幕
  shareScreen() {
    // 获取屏幕共享流
    navigator.mediaDevices
      .getDisplayMedia({ video: true, audio: true })
      .then((stream) => {
        // 在这里处理获取到的屏幕共享流
        const videoDom = document.createElement("video");
        videoDom.style.height = "100%";
        videoDom.style.width = "100%";
        videoDom.srcObject = stream;
        this.local.stream = stream;
        console.log('stream',stream)
        document.getElementById(this.local.elementId).append(videoDom);
        videoDom.play();
      })
      .catch(function (error) {
        console.error("获取屏幕共享流失败:", error);
      });
  }

  // 用户加入房间
  async join() {
    this.socket.emit("chat", {
      action: "join",
    });
  }

  // 共享视频
  async shareVideo(){
    this.getUserDevicePermissions()
  }

  // 切换摄像头

  // 获取摄像头信息 包含个数
  getCameras() {
    navigator.mediaDevices
      .enumerateDevices()
      .then((devices) => {
        // 遍历设备列表
        const cameraList = [];
        devices.forEach(function (device) {
          if (device.kind === "videoinput") {
            cameraList.push(device);
          }
        });
        this.videoDevices = cameraList;
      })
      .catch(function (error) {
        console.error("获取设备列表失败:", error);
      });
  }

  // 结束
  async leave() {
    this.socket.emit('chat',{action: 'leave'})
    this.handleLeave();
  }

  handleLeave(){
    document.getElementById(this.remote.elementId).innerHTML = '';
    this.remote.pc = null;
    this.remote.stream = null;
  }


  // 创建rtc 实例
  createRTCPeerConnection() {
    const configuration = {
      iceServers: [
        {
          urls: ["stun:stun.l.google.com:19302"], // STUN服务器地址 一般使用google的 不
        },
        {
          urls: [""], // TURN服务器地址
          username: "", // TURN服务器的用户名
          credential: "", // TURN服务器的密码
        },
      ],

    };
    let pc = new RTCPeerConnection(configuration);
    this.remote.pc = pc;
    if (this.local.stream) {
      // 获取音频和视频轨道
      const audioTrack = this.local.stream.getAudioTracks()[0];
      const videoTrack = this.local.stream.getVideoTracks()[0];
      // 创建新的媒体流
      const mediaStream = new MediaStream();
      mediaStream.addTrack(audioTrack);
      mediaStream.addTrack(videoTrack);
      // 将媒体流添加到连接
      pc.addTrack(audioTrack, mediaStream);
      pc.addTrack(videoTrack, mediaStream);
      // pc.addStream(this.local.stream)
    } else {
      // 没有本地视频
      pc.addStream(new MediaStream());
    }
    const that = this;
    pc.onicecandidate = function (event) {
      if (event.candidate !== null)
        that.socket.emit("chat", {action:'candidate',data: event.candidate});
    };

    pc.ontrack = function (event) {
      const track = event.track;
      const stream = event.streams[0];
      if(track.kind === 'video'){
        const videoDom = document.createElement("video");
        videoDom.style.height = "100%";
        videoDom.style.width = "100%";
        videoDom.srcObject = new MediaStream([track]);
        that.remote.stream = stream;
        document.getElementById(that.remote.elementId).append(videoDom);
        videoDom.play();
      }
    };
    return pc;
  }
}

效果展示:

image.png 剩余两种实现方法,写不完了,太长了 待续....