如何用React、Node.js和WebRTC实现视频会议

1,113 阅读9分钟

使用React、Node.js和WebRTC实现视频会议

视频会议是现代世界的一个关键功能。然而,由于其复杂性,大多数开发人员在实施时遇到了困难。

大流行为人们提供了在家工作的机会。然而,这需要高效的视频会议和数据包处理。

React.js和WebRTC是开发基于网络的视频会议应用的优秀平台。

我们将通过开发一个视频会议处理器来深入了解这些框架。

前提条件

读者应具备以下一些基本知识,以便继续学习。

  • 开始使用React.js ES6
  • 开始使用Node.js和命令终端
  • 开始使用Graphql和WebRTC

基于网络的视频会议的基础知识

视频会议是连接到互联网的两个或多个节点之间的视觉互动。它支持在多个节点之间传输静态图像、文本、全动态和高清晰度音频。

WebRTC

WebRTC是一项开源技术,为一个应用程序提供实时通信能力。它支持视频、音频和其他类型的数据在节点之间传输。

换句话说,它使开发者能够将语音和视频功能整合到他们的应用程序中。

视频会议系统的组成部分

基于网络的视频会议涉及各种框架和库的协同作用,其中包括以下内容。

  • 用于音频和视频数据传输的网络连接,涉及使用3G、4G或5G宽带。
  • 互联网协议语音(VoIP)和综合服务数字网络(ISDN)。
  • 麦克风和网络摄像头。
  • 显示屏或投影仪。
  • 基于软件的编码和解码技术(CODEC)。
  • 用于音频优化和实时通信的声学回声消除(AEC)软件。

对网络数据包处理的要求

为了使网络通信取得成功,必须有一个统一的标准来定义通信系统的结构。

支持包括音频和视频在内的多种数据类型的数字环境大大增加了视频会议应用的效率,包括更大的带宽利用率。

实施和编码

该应用是一个全栈项目,分为两个部分。

  1. 客户端
  2. 服务器端

客户端设置 - (index.js)

客户端界面是使用React.js ,这是一个轻量级的前端Javascript库。客户端界面的各个页面包括以下内容。

第1步:开始使用一个新的React应用程序

npx create-react-app react-video-conferencing-app

上面的命令应该能让你开始使用一个新的React应用,并安装了所有默认的依赖项。

cd react-video-conferencing-app
npm start

上面的命令将改变目录到你新的react应用,并启动开发服务器。

第2步:安装所需的客户端依赖项

为了成功开发,必须安装一些依赖项。它们能使React应用程序,以及执行特定的指令。

我们通过运行npm installyarn add ,在终端初始化一个空的Node.js项目来安装这些依赖项。

package.json 文件中添加以下依赖项,然后运行npm install 来下载它们。

 "apollo-cache-inmemory": "^1.1.9",
 "apollo-client": "^2.2.5",
 "apollo-client-preset": "^1.0.8",
 "apollo-link-http": "^1.4.0",
 "apollo-link-schema": "^1.0.6",
 "apollo-link-ws": "^1.0.7",
 "apollo-utilities": "^1.0.10",
 "classnames": "^2.2.5",
 "react-apollo": "^2.0.4",
 "react-dom": "^16.2.0",
 "react-redux": "^5.0.7",
 "react-router": "^4.2.0",
 "react-router-config": "^1.0.0-beta.4",
 "react-router-dom": "^4.2.2",
 "react-stay-scrolled": "^2.1.1",
 "redux": "^3.7.2",
 "redux-actions": "^2.2.1",
 "redux-devtools-extension": "^2.13.2",
 "redux-thunk": "^2.2.0",
 "socket.io": "^2.4.0",
 "socket.io-client": "^2.0.4",
 "socket.io-redis": "^5.2.0",
 "socketio-jwt": "^4.5.0",
 "style-loader": "^0.20.2",

第3步:设置客户端索引文件

这是整合客户端和服务器代码的主要文件。它能够初始化React DOM元素、Apollo-Client元素和WebRTC适配器。

 import 'webrtc-adapter';
 import { InMemoryCache } from 'apollo-cache-inmemory';
 import { ApolloClient } from 'apollo-client';
 import { split } from 'apollo-client-preset';
 import { HttpLink } from 'apollo-link-http';
 import { WebSocketLink } from 'apollo-link-ws';
 import { getMainDefinition } from 'apollo-utilities';
 import React from 'react';
 import { ApolloProvider } from 'react-apollo';
 import ReactDOM from 'react-dom';
 import { Provider } from 'react-redux';
 import { renderRoutes } from 'react-router-config';
 import { BrowserRouter } from 'react-router-dom';
 import './styles/index.scss';
 import routes from './routes';
 import store from './store';
 import { setToken } from './actions/token';
 store.dispatch(setToken(window.__JWT_TOKEN__));

 const httpLink = new HttpLink({
   uri: process.env.GRAPHQL_URI,
   credentials: 'same-origin',
 });

 const wsLink = new WebSocketLink({
   uri: process.env.GRAPHQL_WS_URI,
   options: {
     reconnect: true,
   },
 });

 const subscriptionMiddleware = {
   applyMiddleware(options, next) {
     const { token } = store.getState();
     options.connectionParams = { authToken: token };
     next();
   },
 };

 wsLink.subscriptionClient.use([subscriptionMiddleware]);
 const link = split(
   ({ query }) => {
     const { kind, operation } = getMainDefinition(query);
     return kind === 'OperationDefinition' && operation === 'subscription';
   },
   wsLink,
   httpLink,
 );

 const cache = new InMemoryCache().restore(window.__APOLLO_STATE__);

 const client = new ApolloClient({ link, cache, connectToDevTools: process.env.NODE_ENV === 'development' });
 delete window.__APOLLO_STATE__;
 delete window.__JWT_TOKEN__;

   render() {
     return (
       <Provider store={store}>
         <ApolloProvider client={client}>
           <BrowserRouter>
             {renderRoutes(routes, { userAgent: navigator.userAgent })}
           </BrowserRouter>
         </ApolloProvider>
       </Provider>
     );
   }
 }
 function render() {
   ReactDOM.hydrate(
     <Routes />,
     document.getElementById('entry-point')
 }
 render();

第4步:设置客户端的路由和页面

该应用程序有五个主要页面。

  • 主页
  • 登录/注册页面
  • 联系人页面
  • 信息页
  • 设置页面

而他们各自的路线实现如下。

  import React from 'react';
  import { Redirect } from 'react-router';
  import {
    INDEX_ROUTE,
    LOGIN_ROUTE,
    SIGNUP_ROUTE,
    CONTACTS_ROUTE,
    MESSAGES_ROUTE,
    CONTACT_REQUESTS_ROUTE,
    SETTINGS_ROUTE,
  } from '../constants';

  import PageLayout from '../containers/PageLayout';
  import Login from '../containers/Login';
  import Signup from '../containers/Signup';
  import Contacts from '../containers/Contacts';
  import Messages from '../containers/Messages';
  import Settings from '../containers/Settings';

  export default [{
    component: PageLayout,
    routes: [
      { path: INDEX_ROUTE, exact: true, component: () => <Redirect to={CONTACTS_ROUTE} /> },
      { path: LOGIN_ROUTE, component: Login },
      { path: SIGNUP_ROUTE, component: Signup },
      { path: CONTACTS_ROUTE, component: Contacts },
      { path: MESSAGES_ROUTE, component: Messages },
      { path: SETTINGS_ROUTE, component: Settings },
    ],
  }];

第5步:设置视频组件

视频组件是必不可少的,因为它促进了应用程序中各个节点之间的连接和通信。

它还将event listeners ,以连接设备的麦克风和网络摄像头。

视频组件可以实现以下操作。

  • 呼叫状态
  • 接受呼叫
  • 忽略呼叫
  • 挂断电话

下面是视频组件的实现。

  import React from 'react';
  import PropTypes from 'prop-types';
  import { connect } from 'react-redux';
  import classNames from 'classnames';

  import { preferOpus } from '../helpers/sdp-helpers';
  import {
    CallStatuses,
    acceptCall,
    ignoreCall,
    handleIceCandidate,
    sendSessionDescription,
    setCallStatusToInCall,
    setCallStatusToAvailable,
    setCallStatusToHangingUp,
    emitHangup,
  } from '../actions/call';

  import { addError } from '../actions/error';
  import Available from '../components/VideoChat/Available';
  import Calling from '../components/VideoChat/Calling';
  import ReceivingCall from '../components/VideoChat/ReceivingCall';
  import Controller from '../components/VideoChat/Controller';
  import CallOverlay from '../components/VideoChat/CallOverlay';
  import BannerContainer from '../components/Layout/BannerContainer';

   startPeerConnection() {
      try {
        this.peerConnection = new RTCPeerConnection({
          iceServers: this.props.iceServerConfig,
        });
        this.peerConnection.onicecandidate = this.props.handleIceCandidate;
        this.peerConnection.onaddstream = this.onRemoteStreamAdded.bind(this);
        this.peerConnection.onremovestream = this.onRemoteStreamRemoved.bind(this);
        this.peerConnection.addStream(this.localStream);
        if (!this.state.isInitiator) return;
        this.peerConnection.createOffer(
          this.setLocalDescriptionAndSendToPeer.bind(this),
          e => (
            console.log('createOffer() error', e)
            || this.props.addError('Something went wrong setting up the peer connection')
          )
        );
      } catch (err) {
        console.error(err);
        this.props.addError('Failed to create a connection.');
        this.startHangup();
      }
    }

   toggleAudioTrack() {
      return this.localStream.getAudioTracks().forEach(
        track => track.enabled = !track.enabled
      );
    }
    /**
     * @returns {undefined}
     */
    toggleVideoTrack() {
      return this.localStream.getVideoTracks().forEach(
        track => track.enabled = !track.enabled
      );
    }

  return (
        <div className="video-chat-container">
          <BannerContainer />
          <div className="remote-video-container">
            {[
              CallStatuses.AcceptingCall,
              CallStatuses.HangingUp,
            ].includes(this.props.status)
              && <CallOverlay />}
            <video
              ref={node => this.remoteVideo = node}
              className={classNames(
                'remote-video',
                [
                  CallStatuses.AcceptingCall,
                  CallStatuses.HangingUp,
                ].includes(this.props.status) && 'partially-transparent',
              )}
              autoPlay
            >
              <track kind="captions" />
            </video>
            <video
              ref={node => this.localVideo = node}
              className="local-video"
              autoPlay
              muted="muted"
            >
              <track kind="captions" />
            </video>
          </div>
          <Controller startHangup={this.startHangup} />
        </div>
      );

服务器端的设置

准备服务器实例包括以下内容。

mkdir server
cd server

确保服务器文件夹位于包含客户端代码的主应用程序文件夹中。

npm init

上面的命令应该能初始化服务器实例。

安装所需的服务器依赖项

为了让Node.js执行所需的服务器操作,必须在服务器文件夹中安装一些依赖项。

我们通过运行下面的命令来做到这一点。

npm install
"bcrypt": "^5.0.0",
"bluebird": "^3.5.1",
"body-parser": "^1.18.2",
"cors": "^2.8.4",
"css-loader": "^0.28.10",
"debug": "^3.1.0",
"dotenv": "^5.0.0",
"enum": "^2.5.0",
"express": "^4.16.2",
"express-graphql": "^0.6.12",
"express-jwt": "^6.0.0",
"extract-text-webpack-plugin": "^3.0.2",
"graphql": "^0.13.1",
"graphql-redis-subscriptions": "^1.4.0",
"graphql-subscriptions": "^0.5.8",
"graphql-tag": "^2.8.0",
"jsonwebtoken": "^8.1.1",
"lodash.clonedeep": "^4.5.0",
"lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",

设置服务器 - (index.js)

后台服务器的设置是使用Node.jsGraphql 。为了保证最佳性能,必须要有一个强大的服务器实例。

下面是基本的Node.js服务器设置。

import express from "express";
import bodyParser from "body-parser";
import morgan from "morgan";
import path from "path";
import compression from "compression";
import cors from "cors";
import models from "./models";
import deserealizeUser from "./lib/deserealize-user";

// globals
global.models = models;
const app = express();
app.enable("trust proxy");
// Dev middleware
app.use(morgan("dev"));
// App middleware
app.use(cors({ credentials: true }));
app.use(bodyParser.urlencoded({ extended: false, limit: "2mb" }));
app.use(bodyParser.json({ limit: "5mb" }));
app.use(compression());
app.use(express.static(path.join(".", "/public")));
// Views
app.set("view engine", "pug");
app.set("views", path.join(".", "/views/"));
app.use(deserealizeUser);

export default app;

设置Graphql服务器

Graphql服务器作为客户端和服务器之间的接口,为视频和音频数据传输提供一个强大的机制。

Graphql服务器完全配备了用于数据修改和变更的突变,用于数据获取的查询,以及用于实时数据实例监控的订阅。

下面是Graphql服务器的设置。

import graphqlExpress from "express-graphql";
import debug from "debug";
import { createServer } from "http";
import { execute, subscribe } from "graphql";
import { SubscriptionServer } from "subscriptions-transport-ws";

module.exports = function startServer() {
  /* eslint-disable global-require */
  if (!process.env.NODE_ENV) require("dotenv").load();

  const app = require("../src/server/app").default;
  const schema = require("../src/server/schema").default;
  const render = require("../src/server/routes/render").default;

  app.post("/graphql", graphqlExpress({ schema, graphiql: false }));
  app.use(render);
  const server = createServer(app);
  /**
   * onListen callback for server
   * @returns {undefined}
   */
  function onListen() {
    console.log(`Listening on port ${process.env.PORT}`);
    const addr = server.address();
    const bind =
      typeof addr === "string" ? `pipe ${addr}` : `port ${addr.port}`;
    debug(`Listening on ${bind}`);
  }
  /**
   * onError callback
   * @param {Error} err the error
   * @returns {undefined}
   */

  function onError(err) {
    if (err.syscall !== "listen") throw err;
    const bind =
      typeof port === "string"
        ? `Pipe ${process.env.PORT}`
        : `Port ${process.env.PORT}`;
    switch (err.code) {
      case "EACCESS":
        console.log(`${bind} requires elevated privilege`);
        break;
      case "EADDRINUSE":
        console.log(`${bind} is already in use`);
        break;
      default:
        throw err;
    }
  }

  server.on("listening", onListen);
  server.on("error", onError);
  server.listen(
    process.env.PORT,
    () =>
      new SubscriptionServer(
        {
          keepAlive: 1000,
          schema,
          execute,
          subscribe,
          onConnect: () => ({ app }),
        },
        {
          server,
          path: "/subscriptions",
        }
      )
  );
};

设置所需的突变、查询和订阅

第1步

第一个突变是用来与第二个用户建立数据传输连接。

下面是该突变的代码实现。

import {
  GraphQLObjectType,
  GraphQLInt,
  GraphQLBoolean,
  GraphQLString,
} from "graphql";

export default {
  type: new GraphQLObjectType({
    name: "CreateMessageThreadResponse",
    fields: {
      success: { type: GraphQLBoolean },
      message: { type: GraphQLString },
      threadId: { type: GraphQLInt },
    },
  }),
  name: "CreateMessageThread",
  args: {
    contactId: { type: GraphQLInt },
  },
  async resolve(parent, { contactId }) {
    try {
      const contact = await models.contact.findById(contactId, {
        where: { blocker_id: null },
      });
      if (!contact)
        return {
          success: false,
          message: "That contact is no longer reachable",
        };
      const thread = await models.message_thread.create({
        contact_id: contactId,
        user_1: contact.user_1,
        user_2: contact.user_2,
      });
      return { success: true, message: "Success", threadId: thread.id };
    } catch (err) {
      console.log(err);
      return {
        success: false,
        message: "Something went wrong creating your message",
      };
    }
  },
};

第2步

现在,我们的突变已经启动并运行,我们需要设置查询,以方便客户获取和检索数据。

下面是Graphql服务器的设置。

import { GraphQLList } from "graphql";
import Promise from "bluebird";
import moment from "moment";
import ContactRequest from "../types/ContactRequest";

export default {
  type: new GraphQLList(ContactRequest),
  name: "ContactRequests",
  async resolve(parent, args, req) {
    try {
      const pendingRequests = await models.contact_request.findAll({
        where: {
          recipient_id: req.user && req.user.id,
          status: models.contact_request.statuses.PENDING,
        },
        include: [
          {
            model: models.user,
            as: "sender",
          },
        ],
        order: [["createdAt", "DESC"]],
        limit: 100,
      });
      await Promise.map(pendingRequests, async (request) => {
        if (
          moment(request.createdAt) <
          moment().startOf("day").subtract(1, "month")
        ) {
          request.status = models.contact_request.statuses.EXPIRED;
          await request.save();
        }
      });
      return pendingRequests;
    } catch (err) {
      console.log(err);
      return [];
    }
  },
};

第3步

Graphql提供了一个名为subscription 的工具,以实现实时套接字监控。

下面是订阅的设置。

import { RedisPubSub } from "graphql-redis-subscriptions";
import url from "url";

export * from "./constants";

const redisUrl = url.parse(process.env.REDISCLOUD_URL);
export default new RedisPubSub({
  connection: {
    host: redisUrl.hostname,
    port: redisUrl.port,
    password: redisUrl.auth.split(":")[1],
  },
  retry_strategy: (options) => Math.max(options.attempt * 100, 3000),
});

错误处理和测试

应用程序通常会有bug和错误。因此,处理这些错误是应用程序开发的一个关键部分。

Graphql有几个错误,我们将对其进行分析和解决。

它们包括

  • 服务器错误--这些错误发生在服务器中,阻止服务器对客户端查询和突变的适当响应。
  • 事务错误--它们在服务器更新时发生。例如,当一个突变被执行时。
  • Apollo客户端错误 - 这些错误发生在相应库的核心中。

为了设置每个请求的错误策略,应该整合Graphql提供的以下代码块。

任何报告的错误都将属于error ,与从服务器或缓存检索的数据一起。

const MY_QUERY = gql`
  query WillFail {
    badField
    goodField
  }
`;
function ShowingSomeErrors() {
  const { loading, error, data } = useQuery(MY_QUERY, { errorPolicy: "all" });

  if (loading) return <span>loading...</span>;
  return (
    <div>
      <h2>Good: {data.goodField}</h2>
      <pre>
        Bad:{" "}
        {error.graphQLErrors.map(({ message }, i) => (
          <span key={i}>{message}</span>
        ))}
      </pre>
    </div>
  );
}

忽略错误

在应用开发过程中,错误可能会被忽略。在这种情况下,如果在代码执行过程中发生错误,我们希望返回null

我们使用下面的代码块实现这一功能。

onError(({ response, operation }) => {
  if (operation.operationName === "IgnoreErrorsQuery") {
    response.errors = null;
  }
});

使用React测试库

React测试库是测试React组件的轻量级解决方案,因为它在react-domreact-dom/test-utils之上提供了实用功能。

它是通过以下命令实现的。

npm install --save-dev @testing-library/react
node build/start

结论

这篇文章解释了实现基于网络的视频会议应用程序的基本原理、组件和要求。我们还讨论了错误处理和测试。