远程兴起,前端音视频通话?学

news2024/11/27 10:41:41

前言

看到前两篇文章都非常受欢迎,加起来有一千多赞了👍🏻 ,非常的开心!🤖继续加油!冲!
我们在前两篇文章中大概讲解了 音视频媒体流的获取,处理,以及在上一篇文章中为了更直观的演示 WebRTC 建立点对点通信的过程,通过手动交换 sdp 来建 p2p 连接,实现了一个最简单的音视频通话,但在实际的应用场景中,我们几乎不可能会通过手动来交换 sdp ,因为这样会增加很多的工作量,也不方便,所以我们借助一个信令服务器来帮助我们实现自动建立连接的这个过程。

从这篇文章中你将学到

  • 学会如何制作 https 的自签名证书
  • 学会如何使用 nginx 反向代理 https
  • 学会使用 socket.io 来实现客户端与信令服务器的通信
  • 了解并实现一个 WebRTC + 信令服务器自动建立连接的音视频实时通话

体验地址

👉🏻 1.本文示例在线体验地址
👉🏻 2.本文客户端源代码地址
👉🏻 3.本文服务端源代码地址

你可以开两个浏览器 tab 或者用不同的设备,根据说明即可体验。

了解信令前,需要了解的几个概念

结合上一篇讲到的 SDP , NAT , ICE,我们还需要了解:STUNTURN服务,以及ICE候选者的种类

候选者的种类

WebRTC 众多的链接候选者中,可以分为三类:

  • host:本机候选者(设备的 ipv4 或 ipv6 地址,即内网地址,一般会有两个,分别对应 udp 和 tcp,ip 相同,端口不同),ICE 尝试与对方建立 P2P 连接时的首选,一般这个地址为内网地址,如果双方位于同一个内网,那么直接建立成功。
  • srflx:P2P 链接候选者(STUN 服务返回的你这个主机的外网地址),在第一次尝试连接失败时,说明双方不在同一个内网,ICE 将使用 STUN 服务获取主机的公网地址以及映射端口,然后开始尝试通过用这个公网 IP 和对方建立连接。
  • relay:中继服务器候选者,如果第二次仍然失败了(当 STUN 不适用时(某些 NAT 会为每个连接分配不同的端口,导致获取的端口和视频连接端口并不一致),那么意味着双方无法直接建立 P2P 连接,这时候就只能通过一个中继服务器,即 TURN 服务器来和双方建立连接,然后中转他们之间传输的内容,这种对服务器开销最大,而且也会增加时延,所以只有在没得选择的情况下采用。

所以可以总结为三类候选者中,host 候选者的优先级是最高的,当 host 类型的候选者无法建立链接的时候,WebRTC 会从 srflx 候选者中进行连通性测试,也就是尝试通过 P2P 的方式连接双方,如果失败才会尝试使用 relay 的方式进行链接。

STUN (Session Traversal Utilities for NAT)

我们需要知道的是:主机要想访问公网资源,必须有自己的公网地址,只有这样,我们才能在访问资源主机的时候,让它通过我们主机的公网地址找到我们的主机,并把你想要访问的资源发送给你。

一般情况下,在一个网段内的主机只有内网 IP 和端口号,那内网的主机是如何访问公网资源的呢?实际上,内网的网关都有 NAT 的功能,NAT 的功能是将内网 IP 映射转换成公网地址。当我们的内网主机想要访问公网资源的时候,内网网关会将请求的内网地址映射成公网地址,然后将请求发送到要访问的公网服务器上,服务器处理好请求之后,将响应数据传递给请求中携带的公网地址上,该公网接收到响应数据之后,它的网关就会通过之前的地址映射最终中转给内网的主机。通过这种方式实现内网主机访问公网资源的需求。
基于以上的这种方式,我们可以知道内网主机虽然不知道自己在公网的地址,但是内网主机访问的服务器是知道内网主机对应的公网 IP 的,于是我们在公网中架设一台服务器,通过这台服务器可以询问到自己的公网地址。实际上这一询问流程已经被定义成了一套规范,就是 STUN 协议。
NAT 遍历操作由 Session Traversal Utilities for NAT (STUN) 服务器执行。 STUN 方法是一种用于终端检查其“公共 IP 地址和端口”的过程的协议。当客户端向 STUN 服务器发送请求时,它会发送通信所需的信息以及客户端用来与其他设备通信的公共 IP 地址。但是,即使在这种情况下,如果无法进行通信,也会将其传输到 TURN 服务器。

简单概括就是,我们要知道自己的外网 IP 地址,依靠的就是 STUN 服务。客户端通过给公网的 STUN 服务器发送请求获得自己的公网地址信息,以及是否能够被(穿过路由器)访问。

协议地址

TURN (Session Traversal Utilities for NAT)

WebRTC 通信双方通过 P2P 的方式无法建立链接的情况下,会使用 relay 服务进行中转服务。 relay 是所有候选者中优先级最低的链接方式,但是 relay 也是连通率最高的方式。WebRTC 通信双方通过向 TURN 服务器发送 Allocation 指令获得在 relay 服务器上的端口,用于中转 UDP 数据。

总结上面的内容,WebRTC 的通信双方在进行链接之前会按照优先级收集链接的候选者,按照优先级的高低,分别是:在本级收集所有的 host 类型的候选者进行内网链接,通过 STUN 协议收集 srflx 候选者进行 P2P 链接,通过 TURN 协议收集 relay 候选者通过中转服务器链接并传输 UDP 数据。

很显然这种方式是开销很大的,所以只有在没得选择的情况下采用。

协议地址

一般我们都会通过 coturn会restund来搭建 STUN 和 TURN 服务。非常的方便,我使用的是开源社区提供的 coturn,它是一个比较成熟的项目。我会在第 4,5 篇文章讲到如何搭建它,这篇我们直接用现成的 STUN 服务就行。至于 TURN服务...🥲 目前我还没有一个比较好的服务器,能顶得住这种,我后面看看搞一台合适的过来搭建一个,供大家在线体验一下。

所以这篇文章我们做的 demo 可以在内网,以及网络环境不是特别复杂的外网下进行音视频通话。(一方位于 NAT 网络内部,或者双方都在 非对称NAT网络内的情况)

什么叫信令?

我们知道 WebRTC 想要直接通过 P2P 连接进行通信,需要一个中继的过程(在两个终端之间传递控制信息的过程),这个中继的过程就称之为信令
所以简单来说,信令就是在两个设备之间发送控制信息以确定通信协议、信道、媒体编解码器和格式以及数据传输方法以及任何所需的路由信息的过程,而执行此操作的服务器称为信令服务器

信令服务器按照与聊天室相同的方式对连接的节点进行逻辑分组,并帮助各端相互交换 SDP 等信息。

关于 WebRTC 的信令流程最重要的一点是: 「信令在规范中并没有定义」 所以开发者需要自己决定如何实现这个过程。开发者可以为应用程序引擎选择任意的信息协议(如 SIP 或 XMPP),任意双向通信信道(如 WebSocket 或 XMLHttpRequest) 与持久连接服务器的 API(如 Google Channel API)一起工作。

根据上面的说明我们就可以知道信令服务器的实现方式有很多种,可以根据要开发的服务的性质使用现有的信令协议,也可以通过轮询/长轮询或 websocket 等适当的双向通信通道来实现。

由于信令的核心是交换异步发送的对等信息(SDP,Candidate)。因此,将其实现为支持全双工通信的 websocket 最为合适。下面我面会通过 socket.io 来实现一个简单的信令服务器。

有点像村里相亲的,一开始不认识对方,没法直接联系,需要通过媒介,中间人来传递消息后,你们可能知道对方的位置啊,联系方式啊等等才能很好的直接建立联系。

这张图很清楚的描述了这个过程。

各端通过信令服务器交换 SDP 信息,然后各端通过 P2P 连接进行通信。

关于信令更多的详细内容可以参考:MDN 信令的介绍,MDN 信令与视频通话

信令服务器的具体实现

下面我们主要使用 socket.io 来实现一个简单的信令服务器。

为什么使用它呢?

就像 Axios 是对 XMLHttpRequest 的封装, 而 Socket.io 就是对 WebSocket 的封装,并且实现了 WebSocket 的服务端代码。Socket.IO 将 WebSocket 和轮询(Polling)机制以及其它的实时通信方式封装成了通用的接口,并且在服务端实现了这些实时机制的相应代码。也就是说,WebSocket 仅仅是 Socket.IO 实现实时通信的一个子集。Socket.IO 简化了 WebSocket API,统一了返回传输的 API。

总结一下就是:

用它来写非常的简单方便,下面我们就用 express 配合 socket.io 来实现一个简单的信令服务器。

信令服务的搭建

我们用 nodejs 来写服务端,首先我们要安装一下 socket.io

# 服务端
npm i express socket.io
复制代码

然后我们创建一个 server.js 文件,用来启动一个没有业务逻辑的信令服务。

import http from 'http'
import { Server } from 'socket.io'
import express from 'express'

const port = 3000
const app = express()
const httpServer = http.createServer(app)
// 创建信令服务器
const io = new Server(httpServer, {
  cors: {
    origin: '*', // 允许跨域
    methods: ['GET', 'POST'], // 允许的请求方式
    allowedHeaders: '*', // 允许的请求头
    credentials: true, // 允许携带cookie
  },
  allowEIO3: true, // 是否启用与Socket.IO v2客户端的兼容性
  transport: ['websocket'], // 仅允许websocket,["polling", "websocket"]
})

// 在指定端口启动服务器
httpServer.listen(port, () => {
  console.log('\n Http server up and running at => http://%s:%s', httpServer.address().address, httpServer.address().port)
})

// 监听用户连接
io.on('connection', (socket) => {
  console.log('connection~')

  // 监听连接断开
  socket.on('disconnect', () => {
    console.log('disconnect~')
  })
})
复制代码

这里我们使用了 express 来创建一个简单的服务,然后使用 socket.io 来创建一个 websocket 服务。

这样一个 简单 websocket 服务架子就搭好了。我们先启动这个服务.

node server.js
复制代码

是不是很简单~~

客户端需要做的事情

然后我们在客户端也要安装配套的 socket.io-client

# 客户端
npm i socket.io-client
复制代码

然后我们在客户端代码中引入 socket.io-client,并且连接到我们刚启动的信令服务。

import io from 'socket.io-client'

// 连接到信令服务
const socket = io('http://localhost:3000')
复制代码

由于 WebRTC 是需要在 https 协议下才能使用的,所以我们需要在本地生成一个 自签名的https 证书。(当然,你客户端直接在 localhost 下测试就不需要通过 https 了)。

要不然,你在 https 协议的页面,请求 http 资源时,浏览器会报错,因为 https 页面中的资源必须是 https 的,否则浏览器会阻止加载。

https,对应的我们信令服务的地址也需要是 https,不然就会报错

所以下面我们需要自签一张证书。

自签证书

我们可以通过 openssl 生成自签证书,并将其保存在本地。我一直觉得用 OpenSSL 弄好麻烦,这里我使用 mkcert,它作为本地 https 的快速解决方案,用起来非常方便。

mkcert 是一个用 Go 语言编写的工具,它可以轻松地为本地开发生成有效的 TLS 证书。它使用了一个名为 local CA 的根证书,这个根证书是由 mkcert 生成的,它会被安装到系统的受信任的根证书列表中。

下面一起来通过 mkcert 生成自签证书,并将其保存在本地。

安装 mkcert 并生成证书

我这边使用的是 macos ,安装起来很方便,其他系统的安装方式可以参考 mkcert 文档,本地 https 快速解决方案——mkcert,基本都差不多

# 安装 mkcert
brew install mkcert
# 安装完成后,执行↓
mkcert -install
复制代码

安装完成后,我们就可以使用 mkcert 生成自签证书了,这里我生成了一个名为 localhost 的证书,证书保存在当前目录下。

生成证书也很简单,就一行命令

# mkcert domain1 [domain2 [...]]

# 本地的直接这样就ok
mkcert localhost 127.0.0.1 ::1
复制代码

生成的证书包含两个文件,一个是证书文件,一个是私钥文件,这两个文件都是必须的,因为证书文件是公开的,而私钥文件是私有的,它们是一对。

localhost.pem
localhost-key.pem
复制代码

生成完毕后,不管你是在 nginx 中使用还是在 node 中使用,只需要将在 nginx 或者 node 中指定证书文件和私钥文件的路径即可。

node 中使用

import { Server } from 'socket.io'
import express from 'express'
import https from 'https'
import path from 'path'
import { fileURLToPath } from 'url'
import fs from 'fs'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

//https证书
const options = {
  cert: fs.readFileSync(path.join(__dirname, '../assets/localhost.pem')),
  key: fs.readFileSync(path.join(__dirname, '../assets/localhost-key.pem')),
}
const app = express()
const httpsServer = https.createServer(options, app)

httpsServer.listen(3333, '0.0.0.0', () => {
  console.log('Https server up and running...')
})
复制代码

nginx 中使用

在本地测试的话怎么样都行,到线上环境的时候,可以去各大云服务商申请证书,上传到服务器和自签的证书一样使用就行了,这块就不多说了。

(需要注意的是,ip 证书非常的贵,一般都是直接用一个域名证书,然后通过 nginx 做转发)


或者你只是想在线上测试的话,也一样通过 mkcert 工具来生成本地的自签 HTTPS 证书就行了,只不过这个证书浏览器会提示不安全,但是用来测试还是可以的。

server {
    #SSL 默认访问端口号为 443
    listen 12345 ssl;
    #请填写证书文件的相对路径或绝对路径
    ssl_certificate /path/to/localhost.pem;
    #请填写私钥文件的相对路径或绝对路径
    ssl_certificate_key /path/to/localhost-key.pem;
    #请填写绑定证书的域名
    server_name localhost;

    location / {
      proxy_pass http://localhost:3000;

      # 为了让代理服务器了解客户端将协议切换到 WebSocket 的意图,下面三个标头必须加上
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection 'upgrade';

      # http://nginx.org/en/docs/http/websocket.html
      # https://echizen.github.io/tech/2018/10-21-nginx-websocket
    }
}
复制代码

客户端使用

比如我这边用 vite 创建的项目,我在 vite.config.ts 中要配置 https,直接按照下面的代码配置就行了。其他脚手架生成的项目也是类似的。

// https://vitejs.dev/config/
import * as fs from 'fs'

export default defineConfig((config) => ({
  // 配置主机、端口、https…
  server: {
    https: {
      key: fs.readFileSync(`${__dirname}/localhost-key.pem`),
      cert: fs.readFileSync(`${__dirname}/localhost.pem`),
    },
  },
}))
复制代码

ok, 到这里,铺垫工作就完成了,下面我们就可以正式开始写前后端的相关逻辑了。

信令服务的逻辑实现

还记的我们上一篇文章中是如何实现 p2p 通信的吗?

我们通过手动传输本地和远程的 SDP等信息来实现 P2P 通信,所以这次我们只需要把这个过程交给 socket 服务来处理就行了。

首先我们需要安装并引入 socket.io-client,然后连接到我们上一小节启动的信令服务。

import io from 'socket.io-client'
const socket = io('https://localhost:3000')
复制代码

然后我们需要监听一些服务端的事件,这个我们根据具体需求来定义。

socket.io 最主要的就是 on 和 emit 两个方法,在客户端 on 用来监听服务端的事件,emit 用来触发服务端的事件。在服务端 on 用来监听客户端的事件,emit 用来触发客户端的事件。还有一些其他的 api 我们直接对着文档来就行,所以说它使用起来非常的简单。

定义客户端需要监听的事件

ok,我们先来看一下我们需要监听的事件。

// 连接成功时触发
socket.on('connect', () => {
  handleConnect()
})

// 断开连接时触发
socket.on('disconnect', (reason) => {
  if (reason === 'io server disconnect') {
    // 断线是由服务器发起的,重新连接。
    socket.connect()
  }
  ElMessage.warning('您已断开连接')
})
// 服务端发送报错信息
socket.on('error', (data) => {
  ElMessage.error(data)
})
// 当有用户加入房间时触发
socket.on('welcome', (data) => {
  ElMessage.success(data.userId === userId ? '🦄成功加入房间' : `🦄${data.userId}加入房间`)
})
// 当有用户离开房间时触发
socket.on('leave', (data) => {
  ElMessage.warning(data.userId === userId ? '🦄成功离开房间' : `🦄${data.userId}离开房间`)
})
// 当有用户发送消息时触发
socket.on('message', (data) => {})
// 创建offer,发送给远端
socket.on('createOffer', (data) => {
  // 如果已经创建过,直接发送
  if (offerSdp) {
    socket.emit('offer', {
      userId,
      roomId: roomId.value,
      sdp: offerSdp,
    })
    return
  }
  createOffer() // 创建 offer
})
// 收到offer,创建answer
socket.on('offer', (data) => {
  createAnswer(data.sdp)
})
// 收到answer,设置远端sdp
socket.on('answer', (data) => {
  addAnswer(data.sdp)
})
复制代码

当然你也可以根据自己的习惯直接把所有事件都包在 socket.on('message',(data)=>{}) 里,data 里加好 type 就行,这样只需要保留几个关键事件,其余的都走 message 事件的逻辑。

定义信令服务端需要监听的事件

// 用户连接
io.on('connection', (socket) => {
  console.log('connection~')
  // 用户加入房间
  socket.on('join', (data) => {
    console.log('join~', data)
    handleUserJoin(socket, data)
  })
  // 用户离开房间
  socket.on('leave', (data) => {
    console.log('leave', data)
    handleUserDisconnect(socket)
  })
  // 监听连接断开
  socket.on('disconnect', () => {
    console.log('disconnect~')
    handleUserDisconnect(socket)
  })
  //=============================
  // 用户发送 offer
  socket.on('offer', (data) => {
    socket.to(data.roomId).emit('offer', data)
  })
  // 用户发送 answer
  socket.on('answer', (data) => {
    socket.to(data.roomId).emit('answer', data)
  })
  // 用户发送消息
  socket.on('message', (data) => {
    console.log('message', data)
  })
})
复制代码

客户端加入房间

接下来我们需要实现客户端加入房间的逻辑,这个逻辑其实就是告诉服务端,我要加入某个房间,然后服务端会把我这个房间的其他客户端的信息告诉我。

// 随机一个用户名,后面你可以自己改成输入框让用户输入
const userId = Math.random().toString(36).substring(2)
// 房间号,这里随便写一个,后面你可以自己改成输入框让用户输入
const roomId = 123

// 加入房间
function joinRoom() {
  socket.emit('join', { userId, roomId })
}
复制代码

服务端接手客户端加入房间的逻辑

服务端接手客户端加入房间的逻辑,其实就是把客户端的信息保存到服务端的内存中,然后把这个房间的其他客户端的信息告诉客户端。

// 服务端,当用户加入房间
socket.on('join', (data) => {
  handleUserJoin(socket, data)
})

// 房间信息
const ROOM_LIST = []
// 每个房间最多容纳的人数
const MAX_USER_COUNT = 2
// 用户加入房间
function handleUserJoin(socket, data) {
  const filterRoom = ROOM_LIST.filter((item) => item.roomId === data.roomId)[0]
  let room = { roomId: data.roomId, userList: [] }

  // 判断房间是否存在
  if (filterRoom) {
    room = filterRoom
  } else {
    ROOM_LIST.push(room)
  }

  // 每个房间人数不超过预设的人数
  if (room.userList.length >= MAX_USER_COUNT) {
    socket.emit('error', '房间人数已满,请稍后再试')
    return
  }

  // 当房间里的人数为0且管理员还没有设置,设置管理员
  if (room.userList.length === 0) {
    room.admin = data.userId
  }

  // 判断用户是否已经在房间里
  if (room.userList.some((item) => item.userId === data.userId)) {
    socket.emit('error', '用户已在房间里')
    return
  }
  // 把用户信息保存到房间里
  room.userList.push(data)
  console.log(data.userId, '加入房间')

  socket.userId = data.userId
  socket.roomId = data.roomId

  // 将用户加入房间
  socket.join(data.roomId)
  // 通知房间内的其他用户
  socket.to(data.roomId).emit('welcome', data)
  // 通知自己加入房间成功,
  socket.emit('joined', data)
}
复制代码

客户端创建提案

这里,我们主要对上一篇创建提案的代码中添加 socket 发送的逻辑,我们需要在当有 ICE 候选改变时,将这些 本地的 SDP 描述发送到服务端,服务端再将这些信息转发给远程的客户端。这里我们先写发送逻辑,后面再去服务端写好接收逻辑就行。

// 成功加入房间
socket.on('joined', (room, id) => {
  ElMessage.success('🦄🦄🦄成功加入房间')
  createOffer()
})

// 创建 offer
async function createOffer() {
  // 当一个新的offer ICE候选人被创建时触发事件
  peerConnection.onicecandidate = async (event) => {
    if (event.candidate) {
      offerSdp = JSON.stringify(peerConnection.localDescription)
      // 发送 offer
      if (offerSdp) {
        socket.emit('offer', {
          userId,
          roomId: roomId.value,
          sdp: offerSdp,
        })
      }
    }
  }
  const offer = await peerConnection.createOffer()
  await peerConnection.setLocalDescription(offer)
}
复制代码

其中的 onicecandidate 事件,是用来监听 ICE 服务器返回的候选地址,当 ICE 服务器返回一个新的候选地址时,就会触发该事件,这里我们通过 socket.emit 将这个候选地址发送给信令服务。

当你在后期连接成功的时候,其实可以把 候选人信息(candidate) 打印出来可以看看,当两个设备在同一个内网中连接的时候, candidate 的地址为一个 ipv6 长格式的内网地址和一个 ipv4 的内网地址。

当两个设备不在同一个内网中连接的时候,可以看到 candidate 的地址最后为一个 ipv4 的外网地址,说明它尝试了两次连接,第一次是内网连接,第二次是外网连接。证明了前面说的三种类型的先后连接方式。

信令服务端接收提案

接下来我们需要在服务端接收到客户端发送的提案后,将这个提案转发给远端的客户端。

// 接收 offer
socket.on('offer', (data) => {
  // console.log('offer', data)
  socket.to(data.roomId).emit('offer', data)
})
复制代码

客户端接收远程的提案

接下来我们需要在客户端接收到远程的提案后,将这个提案设置成 RemoteDescription。然后创建应答,将应答设置成本地描述,在候选人信息改变时,将应答发送给服务端。

// 创建 answer
async function createAnswer(val: string) {
  const offer = JSON.parse(val)
  peerConnection.onicecandidate = async (event) => {
    // 当一个新的 answer ICE candidate 被创建时
    if (event.candidate) {
      socket.emit('answer', {
        userId,
        roomId: roomId.value,
        sdp: JSON.stringify(peerConnection.localDescription),
      })
    }
  }
  await peerConnection.setRemoteDescription(offer)
  const answer = await peerConnection.createAnswer()
  await peerConnection.setLocalDescription(answer)
}
复制代码

客户端创建 answer 的逻辑

作为接收方,在拿到 offer 后,我们就可以创建 answer 并设置到本地描述中,然后通过信令服务器发送 answer 给对端。

const createAnswer = async () => {
  // 解析字符串
  const offer = JSON.parse(offerSdp)
  pc.onicecandidate = async (event) => {
    // Event that fires off when a new answer ICE candidate is created
    if (event.candidate) {
      answerSdp = JSON.stringify(pc.localDescription)
    }
  }
  await pc.setRemoteDescription(offer)
  const answer = await pc.createAnswer()
  await pc.setLocalDescription(answer)
}
复制代码

客户端最后再添加 answer 的逻辑

作为发起方,接下来我们需要在客户端接收到 接收方的应答后,将这个应答设置成 RemoteDescription。这样,一个最简单的 WebRTC 通信流程就完成了。

// 添加 answer(应答)
const addAnswer = async () => {
  const answer = JSON.parse(answerSdp)
  if (!pc.currentRemoteDescription) {
    pc.setRemoteDescription(answer)
  }
}
复制代码

离开房间

// 离开房间
function handleLeave() {
  // 关闭对等连接
  peerConnection.close()
  // 发送离开的消息
  socket.emit('leave', { userId, roomId: roomId.value })
  // 关闭socket连接
  socket.disconnect()
}
复制代码

这样我们大体的 WebRTC 通信流程就完成了,下面我们就把它部署到服务器上。

部署信令服务

我们可以用 Docker 配合 Nginx 来部署你的服务,这里不做重点讲,我们主要通过 pm2 来帮忙部署这个服务,让我们可以快捷的测试看看, pm2 是一个带有负载均衡功能的 Node 应用的进程管理器,可以让你的 Node 应用始终保持在线,同时提供了一些其他的功能,比如日志记录、进程监控、进程守护等。

安装 pm2

# 安装 pm2
npm install pm2 -g
复制代码

启动服务

# 启动
pm2 start index.js
# 查看
pm2 list
# 停止
pm2 stop index.js
# 重启
pm2 restart index.js
# 删除
pm2 delete index.js
复制代码

开机自启动

# 生成开机启动脚本
pm2 startup
# 保存当前进程列表
pm2 save
复制代码

最后

👉🏻 1.本文示例在线体验地址
👉🏻 2.本文客户端源代码地址
👉🏻 3.本文服务端源代码地址

你可以开两个浏览器 tab 或者用不同的设备,输入房间号进去体验下。

本篇文章主要是通过信令服务队上一篇文章的进阶处理,实现了自动连接的音视频通话。大家如果有什么问题,可以在评论区留言,我会及时回复。

如果您喜欢这个专栏的文章,或者对您有一些帮助,可以点赞支持一下,谢谢大家~🌸

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/42260.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

(免费分享)基于jsp的CRM客户管理-带论文

一、课题来源: 自有商业活动,客户关系就一直是商业活动中的一个核心问题,同时也成了商务活动成功的审核标准。在全球经济一体化为时代背景的时代,企业面临着为公司带来更多的客户、为管理者提供准确的信息和便捷的操作查询手段 、…

新旧iphone短信转移,苹果旧手机短信导入新手机

短信携带重要信息内容,新旧iphone短信转移?您可能知道,iOS设备上不支持导出iPhone简讯,更不用说打印iPhone上的短信了。幸运的是,有一些可行的方法可以将iPhone短信导入到另一个iPhone,继续阅读以获得更多帮…

软件协会第01次活动第05次任务布置:爱心代码+演奏歌曲+typora使用pandoc导出+github注册登录+函数练习+写csdn文章

了解更多关注软协官网:https://www.csuftsap.cn/ 来自软件协会编辑,注册会员即可获取全部开源.md资源,请勿转载,归软件协会所有。 文章目录:star: 软件协会第01次活动第05次任务布置1.程序员的浪漫-爱心代码(升级版&a…

css3手册

文章目录布局弹性盒生成弹性容器和弹性项目更改方向主轴排列侧轴排列弹性项目伸缩主轴换行网格生成网格布局定义行和列改变排列方向单元格之间的间隙单元格内部的对齐网格项目定位视觉阴影盒子阴影文字阴影圆角背景渐变变形translate 平移scale 缩放rotate 旋转改变变形原点多种…

spring MVC源码探索之AbstractHandlerMethodMapping

AbstractHandlerMethodMapping 是什么 官方解释是这样的。 /*** Abstract base class for {link HandlerMapping} implementations that define* a mapping between a request and a {link HandlerMethod}.** <p>For each registered handler method, a unique mapping…

Java项目:ssm毕业论文管理系统

作者主页&#xff1a;源码空间站2022 简介&#xff1a;Java领域优质创作者、Java项目、学习资料、技术互助 文末获取源码 毕业设计管理系统 1、本系统使用SSM框架 2、有管理员、教师、学生三种角色&#xff0c;管理员使用admin/admin登录&#xff0c;教师使用t_01/6666登录&a…

JavaWeb简单实例——DBUtils

简单介绍&#xff1a; DBUtils是一个用来简化我们JDBC的编码工作量的一个工具。它可以在不影响数据库访问性能的情况下简化我们的代码编辑量。DBUtils的作用主要是&#xff1a;写数据&#xff0c;读数据&#xff0c;优化性能。 常用的类和对应的API&#xff1a; QureyRunner…

Spring Framework 6正式发布,携JDK 17Jakarta EE开启新篇章

本文已被https://yourbatman.cn收录&#xff1b;女娲Knife-Initializr工程可公开访问啦&#xff1b;程序员专用网盘https://wangpan.yourbatman.cn&#xff1b;技术专栏源代码大本营&#xff1a;https://github.com/yourbatman/tech-column-learning&#xff1b;公号后台回复“…

1.什么是闭包

什么是闭包 1. 概念 闭包&#xff08;closure&#xff09;指有权访问另一个函数作用域中变量的函数。—《JavaScript高级程设计》 简单理解就是一个函数。 2. 如何产生闭包&#xff1f; 当一个嵌套的内部函数引用了嵌套的外部函数的变量&#xff08;函数&#xff09;时&…

CANoe-vTESTstudio之Test Diagram编辑器(元素介绍)

Test Diagram编辑器里的工具箱,有多个图形符号,它们是组成测试图表的图形元素,具有不同的作用。图形元素能够高效并快速地创建测试图表,然后生成测试用例 1. 基本测试设计元素 基本元素用来创建图形设计 1.1 Setup Setup元素的测试代码能够执行一次,在检查测试用例之前…

特别有用!Jmeter命令行执行时设置并发数和循环次数的方法

Jmeter命令行方式运行概述 之前写过一篇文章介绍如何在centos上部署jmeter来执行性能测试&#xff0c;链接如下&#xff1a; https://blog.csdn.net/liwenxiang629/article/details/124140833 因为大多数linux服务器都是没有GUI界面的&#xff0c;这就需要我们通过命令行的方…

MobPush Android For Unity

集成准备 注册账号 使用MobSDK之前&#xff0c;需要先在MobTech官网注册开发者账号&#xff0c;并获取MobTech提供的AppKey和AppSecret&#xff0c;详情可以点击查看注册流程 下载.unitypackage包 打开 Github 下载 MobPush-For-Unity 项目&#xff0c;下载完成后直接双击或…

【图神经网络论文整理】(十)—— How Powerful are Graph Neural Networks?:GIN

作者信息&#xff1a;Keyulu Xu, Weihua Hu, Jure Leskovec, Stefanie Jegelka论文来源&#xff1a;Computer Vision and Pattern Recognition论文地址&#xff1a;https://arxiv.org/abs/1810.00826 本文介绍的论文是《How Powerful are Graph Neural Networks?》。 作者提…

MySQL表的增删查改(嘎嘎详细~

hello呀&#xff01;各位&#xff0c;这里是Sunlightʊə。 目前大三&#xff0c;主要在学习Java语言。可以一起交流呀&#xff01; 相关文章&#xff1a; MySQL数据库的基础操作&#xff08;简单、基础版 专栏&#xff1a; Java数据结构 Java基础语法 MySQL基础 目录 新增&am…

流式 Isotype control 流式细胞仪control组

流式细胞术是非常让人着迷的实验。在众多医学研究手段里,如果说弱水三千只取一瓢的话,那我会首选流式细胞术。从我个人感受来讲,流式细胞术高速客观,具有统计学意义,能够处理复杂样本并同时获取多种参数,最最关键的是它性能可靠,可重复性非常好。 虽然也存在一些局限,…

3款超实用的电脑软件,免费又良心,内存满了也绝不卸载

超强的3款电脑软件&#xff0c;每款都是百里挑一的精品。 1、视频画质增强器 这是国人开发的图片视频增强工具&#xff0c;完全免费无任何弹屏广告&#xff0c;它能将画质很差的图片&#xff0c;一键转化为高清大图&#xff0c;同时还能无损放大图片&#xff0c;图片输出格式支…

第150篇 笔记-元宇宙(Metaverse)

定义&#xff1a;元宇宙是一个整体虚拟世界的概念&#xff0c;它与现实世界并行存在&#xff0c;提供主权数字所有权、独特的在线身份、互联环境和沉浸式体验。 随着最近区块链生态系统中NFT的爆炸&#xff0c;以及Facebook的头部转向“Meta”&#xff0c;元宇宙已进入主流公众…

代码源每日一题div1 枚举倍数 平方计数

平方计数 - 题目 - Daimayuan Online Judge 题意&#xff1a; 思路&#xff1a; 首先注意到暴力枚举一定超时&#xff0c;因此我们考虑只枚举一个指针&#xff0c;然后推一推式子降低另一个指针的复杂度 对于完全平方数这个条件&#xff0c;我们无法直接转换 即对于每一个a[…

1.3 测控电路的信号类型、测控电路的类型与组成、测控电路的发展趋势

笔者电子信息专业硕士毕业&#xff0c;获得过多次电子设计大赛、大学生智能车、数学建模国奖&#xff0c;现就职于南京某半导体芯片公司&#xff0c;从事硬件研发&#xff0c;电路设计研究。对于学电子的小伙伴&#xff0c;深知入门的不易&#xff0c;特开次博客交流分享经验&a…

技术指南 | 如何集成Perforce版本控制系统Helix Core (P4V) 与敏捷规划工具Hansoft

Helix Core是Perforce公司旗下一款集源代码管理和内容协作为一体的版本配置与管理工具&#xff0c;可以帮助您管理随时间推移而产生的数字资产&#xff08;代码&#xff0c;文件等&#xff09;变更&#xff0c;处理每天数以千万计的传输&#xff0c;上千TB的数据&#xff0c;以…