1. 程式人生 > 實用技巧 >基於雲開發 CloudBase 搭建線上視訊會議應用教程

基於雲開發 CloudBase 搭建線上視訊會議應用教程

基於雲開發 CloudBase 搭建線上視訊會議應用

線上視訊會議應用是基於瀏覽器的能力 WebRTC 以及 騰訊雲開發 CloudBase 能力構建而成的應用. 在雲開發的助力下, 一個複雜的線上會議應用, 一個人一兩天即可完成.
雲開發CloudBase開通,參加:https://console.cloud.tencent.com/tcb?tdl_anchor=techsite

線上體驗 Demo

應用體驗地址: Online Meeting Powered by Tencent Cloudbase
專案原始碼地址: Github

注: 應用僅供演示之用, 目前僅支援兩人視訊會議, 功能還不夠完善, 還有許多可完善之處.
建立會議後可將會議地址發給他人, 或者在本機另起一瀏覽器視窗(未避免資料混亂, 可開隱私模式視窗, 或使用另一個瀏覽器)開啟會議地址 來體驗

在自己的雲開發環境中部署

可以線上一鍵部署或通過本地部署的方式,來獨立部署一個自己的線上視訊會議應用

線上一鍵部署

只需要點選下方按鈕,跳轉到騰訊雲控制檯,即可在雲端一鍵安裝一個線上視訊會議應用

本地部署

  1. 修改 .env 檔案中的 ENV_ID 的值 tcb-demo-10cf5b 修改為自己的環境 ID
  2. 命令列 cd 到本目錄中, 執行 npm run deploy 即可

技術解析

本應用用到的能力、工具、框架有:

  1. CloudBase Framework 用於專案基礎目錄結構生成, 一鍵部署
  2. Simple Peer 流行的 WebRTC 庫
  3. 雲開發-雲函式, 包括雲函式的定時呼叫
  4. 雲開發-資料庫
  5. 雲開發-靜態網站託管
  6. React
  7. Ant design

如果你不清楚專案開發的基本命令, 可閱讀本專案使用的模版的 readme.md

背景知識

Web RTC

  1. WebRTC 即 Web 實時通訊技術, 由一系列瀏覽器 API 組成, 包括 navigator.getUserMedia**, MediaStream, RTC相關的全域性物件
  2. WebRTC 是一種 P2P 的通訊技術, 瀏覽器之間可以對等連線. 但瀏覽器之間需要通過一個信令伺服器來交換信令後方可建立連線
  3. 瀏覽器的信令資訊的獲取需要一個 ICE 伺服器, 一般預設會使用谷歌的公共伺服器

雲開發

雲開發(CloudBase)是雲端一體化的後端雲服務 ,採用 serverless 架構,免去了應用構建中繁瑣的伺服器搭建和運維。同時雲開發提供的靜態託管、命令列工具(CLI)、Flutter SDK 等能力降低了應用開發的門檻。使用雲開發可以構建完整的小程式/小遊戲、H5、Web、移動 App 等應用。

CloudBase Framework

CloudBase Framework 是雲開發官方出品的開源前後端一體化部署工具,無需改動程式碼,實現前後端一鍵託管部署,支援常見的框架和語言,支援自動識別並部署。不僅可以部署應用前後端到 Serverless,還可以擴充套件更多後端能力。

Github 地址: https://github.com/TencentCloudBase/cloudbase-framework

完整搭建步驟:從 0 到 1 實現一個線上會議應用

整個應用的構建, 從專案初始化到最終可以一鍵部署, 共分為 6 個部分. 為方便讀者查閱,主要的程式碼實現分了 6 次提交, 下述說明中會列出每一步對應的提交 commit.

第 1 步 初始化專案和視訊頁面

注意要點:

  1. 在進行操作之前, 請確保已經註冊騰訊雲賬戶
  2. WebRTC 需要瀏覽器支援, 只有現代瀏覽器才支援, 建議使用 Chrome、Firefox 來體驗, 具體相容性可檢視 caniuse
  3. 由於安全策略限制, WebRTC 僅支援 https 協議網站; 其為方便本地開發, 也支援 http 的 localhost127.0.0.1 (不限埠), 不支援其他自定義的本機域名、IP
  4. WebRTC 並不具備穿透內網功能, 測試體驗時, 確保雙方機器都處於公網之中並能訪問雲開發相關域名

操作步驟

  1. 初始化專案結構

首先需要全域性安裝 Cloudbase CLI

npm i @cloudbase/cli@latest -g

使用以下命令來使用雲開發的 react 應用模版建立一個 React 雲開發專案

cloudbase init --template react-starter
  1. 引入 UI 庫 ant-design
npm i ant-d @ant-design/icons -S
  1. 增加 landing 頁, 用於檢測 WebRTC 能力以及判斷使用者是否授予攝像頭及麥克風訪問許可權

landing 頁面核心程式碼 meeting-simple/src/landing/index.js

import { LoadingOutlined, WarningOutlined } from "@ant-design/icons";
import React, { useEffect, useState } from "react";
import * as utils from "../utils";
// import * as api from './meeting/api'

export default function Landing(props) {
  // 檢測 RTC 支援
  return !utils.isSupportRTC() ? (
    <NotSupport />
  ) : (
    <NotReady setReady={props.setReady} />
  );
}

// 不支援時的顯示
function NotSupport() {
  // ...
}

// 支援 RTC 時的顯示
function NotReady(props) {
  const [permissionState, setPermissionState] = useState("prompt");
  const [timeCount, setTimeCount] = useState(0);
  const [loadingState, setLoadingState] = useState("init");

  const retry = () => {
    setTimeCount(timeCount + 1);
  };

  // 不同狀態時的提示資訊,prompt、granted、denied
  const permissionStr = {
    prompt: (
      <p>
        Please allow camera and microphone access to continue, you can turn off
        camera or microphone later in meeting
      </p>
    ),
    denied: (
      <p>
        You should granted camera microphone permissions,{" "}
        <a onClick={retry}>click to retry</a>
      </p>
    ),
    granted: <p>Loading meeting info...</p>,
  };

  useEffect(() => {
    (async () => {
      // 檢測許可權
      const status = await utils.checkMediaPermission();
      // 設定授權資訊
      setPermissionState(status ? "granted" : "denied");
      if (!status) return;
      try {
        // 從瀏覽器引數拿到會話資訊
        const sessID = location.hash.slice(1);
        // if (sessID) {
        //   await api.getSessionInfo(sessID)
        // }
        props.setReady("landing");
      } catch (error) {
        console.warn("failed to get session info", error);
        setLoadingState("Failed to get meeting info: " + JSON.stringify(error));
      }
    })();
  }, [timeCount]);
  const tip =
    permissionStr[permissionState] ||
    (loadingState === "init" ? "loading..." : loadingState);
  return <div className="landing-mask"><!--loading 資訊--></div>;
}
  1. 增加 Video-window 頁, 用於支援視訊畫面顯示

Video-window 核心程式碼 meeting-simple/src/meeting/video-window/index.js

import React, { useRef, useEffect } from "react";
import * as utils from "../../utils";

export default function VideoWindow(props) {
  const videoRef = useRef(null);

  useEffect(() => {
    const updateStream = (stream) => {
      // video 物件對應的dom
      const dom = videoRef.current;
      if (!dom) return;
      // 自己則 mute 靜音
      dom.muted = !props.peer;
      if ("srcObject" in dom) {
        dom.srcObject = stream;
        dom.onloadedmetadata = function () {
          dom.play();
        };
        return;
      }
      // 設定實時視訊的 stream 地址
      dom.src = URL.createObjectURL(stream);
      dom.play();
    };

    if (props.peer) {
      props.peer.on("stream", updateStream);
      return;
    }
    // 獲得 mediaStream
    utils.getMediaStream().then(updateStream);

    return () => {
      if (!props.peer) return;
      props.peer.off("stream", updateStream);
    };
  }, [props.peer]);

  return (
    <video
      ref={videoRef}
      controls={!!props.peer}
      width="640"
      height="480"
    ></video>
  );
}

工具方法的核心實現meeting-simple/src/utils.js,檢測是否支援 WebRTC、

/** 檢查是否支援 WebRTC */
export function isSupportRTC() {
  return !!navigator.mediaDevices;
}
// 檢測是否有media許可權
export async function checkMediaPermission() {
  // 請求獲得媒體流輸入(包含聲音和視訊)
  const stream = await navigator.mediaDevices.getUserMedia({
    audio: true,
    video: true,
  });

  // 判斷是否有視訊和聲音軌道輸入
  const result =
    stream.getAudioTracks().length && stream.getVideoTracks().length;

  // 終止媒體流輸入
  revokeMediaStream(stream);

  return result;
}

// 終止媒體流
export function revokeMediaStream(stream) {
  if (!stream) return;
  const tracks = stream.getTracks();

  tracks.forEach(function (track) {
    track.stop();
  });
}

let cachedMediaStream = null;
export async function getMediaStream() {
  if (cachedMediaStream) {
    return Promise.resolve(cachedMediaStream);
  }
  // 請求媒體流輸入
  const stream = await navigator.mediaDevices.getUserMedia({
    audio: true,
    video: true,
  });

  revokeMediaStream(cachedMediaStream);
  cachedMediaStream = stream;

  return cachedMediaStream;
}

程式碼提交記錄

本步驟對應的 git commit

第 2 步 支援建立會議

注意要點:

  1. 瀏覽器端呼叫雲開發能力需要藉助官方 npm 包 tcb-js-sdk, 官方文件
  2. 因為視訊會議應用無需註冊, 即需要匿名使用雲開發能力, 呼叫能力前, 需要在雲開發 登入授權 中開啟 「匿名登入」
  3. 使用雲開發能力(不論是在瀏覽器端、Node 端或其他端)呼叫資料庫時, 操作端 collection 必須存在, 否則會報錯. 所以在本步驟應當提前進入雲開發資料庫控制檯 建立視訊會議使用的 collection meeting-simple
  4. 使用 JS sdk 呼叫雲開發能力時, 需保證呼叫的域名已加入雲開發WEB 安全域名中, 以避免呼叫時出現跨域問題. 即本地開發使用的域名應增加進 WEB 安全域名 中.

操作步驟

  1. 增加 「建立會議」介面
  2. 增加雲開發能力呼叫模組 「api.js」, 新增 建立會議方法(通過雲開發 js sdk 連線資料庫建立記錄)

建立會議的前端 API 核心程式碼 meeting-simple/src/meeting/api.js

import tcb from "tcb-js-sdk";

// 初始化雲開發 JSSDK
const app = tcb.init({
  env: "tcb-demo-10cf5b",
});

// 初始化 auth
const auth = app.auth({
  persistence: "local",
});

const db = app.database();

// 會議表名稱
const MEETING_COLLECTION = "meeting-simple";

// 匿名登入
async function signIn() {
  if (auth.hasLoginState()) return true;
  await auth.signInAnonymously();
  return true;
}

// 建立會議
export async function createMeeting(meeting) {
  await signIn();
  meeting.createdAt = Date.now();
  // 新增一條會議的記錄
  const result = await db.collection(MEETING_COLLECTION).add(meeting);
  return result;
}

程式碼提交記錄

本步驟對應的 git commit

第 3 步 實現加入會議功能

操作步驟

  1. 增加 「加入會議」介面
  2. 在 「api.js」中增加方法(直接呼叫雲開發資料庫能力)獲取會議資訊、加入會議

獲取會議資訊和加入會議的前端 API 的核心程式碼 meeting-simple/src/meeting/api.js

// 獲取會議資訊
export async function getMeeting(meetingId) {
  await signIn();
  // 呼叫 db 查詢資料
  const result = await db.collection(MEETING_COLLECTION).doc(meetingId).get();
  if (!result.data || !result.data.length) return;
  const meeting = result.data[0];

  meeting.hasPass = !!meeting.pass;
  delete meeting.pass;
  return meeting;
}

// 加入會議
export async function joinMeeting(data) {
  await signIn();
  // 查詢會議資訊
  const result = await db.collection(MEETING_COLLECTION).doc(data.id).get();
  if (!result.data || !result.data.length)
    throw new Error("meeting not exists");

  const meeting = result.data[0];
  // 前端對比會議 pass 碼來驗證,安全性較低,會在第 5 步進行優化
  if (meeting.pass && meeting.pass === data.pass)
    throw new Error("passcode not match");
  return true;
}

注:

  1. 資料庫需要設定成公開訪問, 否則匿名使用者無法查詢到相關資訊: 進入資料庫找到對應 collection, 切換至 「許可權設定」, 選擇 「所有使用者可讀,僅建立者及管理員可寫」並儲存

程式碼提交記錄

本步驟對應的 git commit

第 4 步 實現實時加入會議

操作步驟

  1. 增加 simple-peer 來管理 WebRTC 客戶端
import Peer from "simple-peer";
import * as utils from "./utils";
import * as api from "./api";

export async function createPeer(initiator, meetingId) {
  const peer = new Peer({ initiator });
  const stream = await utils.getMediaStream();
  peer.addStream(stream);

  // peer 接收到 signal 事件時,呼叫 peer.signal(data) 來建立連線,那麼如何拿到 data 資訊呢
  peer.on("signal", (e) => {
    console.log("[peer event]signal", e);
    // 呼叫更新寫入資料庫
    updateTicket(e, initiator, meetingId);
  });
  peer.on("connect", (e) => {
    console.log("[peer event]connect", e);
  });
  peer.on("data", (e) => {
    console.log("[peer event]data", e);
  });
  peer.on("stream", (e) => {
    console.log("[peer event]stream", e);
  });
  peer.on("track", (e) => {
    console.log("[peer event]track", e);
  });
  peer.on("close", () => {
    console.log("[peer event]close");
  });
  peer.on("error", (e) => {
    console.log("[peer event]error", e);
  });
  return peer;
}

let cachedTickets = [];
let tid = 0;

function updateTicket(signal, isInitiator, meetingId) {
  cachedTickets.push(signal);
  clearTimeout(tid);
  tid = setTimeout(async () => {
    const tickets = cachedTickets.splice(0);
    try {
      // 寫入資料庫
      const result = await api.updateTicket({
        meetingId,
        tickets,
        type: isInitiator ? "offer" : "answer",
      });
      console.warn("[updateTicket] success", result);
    } catch (error) {
      console.warn("[updateTicket] failed", error);
    }
  }, 100);
}

export function signalTickets(peer, tickets) {
  tickets.forEach((item) => {
    peer.signal(item);
  });
}
  1. 增加雲函式 「更新 ticket」(用於更新 WebRTC 客戶端的連線資訊)並手動部署雲函式, 增加對會議記錄對監聽(即使用資料庫的實時推送能力)

用於更新 WebRTC 客戶端的連線資訊的雲函式的核心程式碼 meeting-simple/cloudfunctions/update-ticket-meeting-simple/index.js

const cloud = require("@cloudbase/node-sdk");

   const MEETING_COLLECTION = "meeting-simple";

   exports.main = async (data) => {
  const app = cloud.init({
       env: cloud.SYMBOL_CURRENT_ENV,
     });

     const collection = app.database().collection(MEETING_COLLECTION);

     try {
    // 查詢會議資訊
       const result = await collection.doc(data.meetingId).get();
       if (!result.data || !result.data.length)
         throw new Error("meeting not exists");
       const meeting = result.data[0];

       const changed = {};
    changed[data.type] = meeting[data.type] ||

       // 若新的tickets中包含 offer 或 answer, 則已經儲存的tickets資訊無效
    if (data.tickets.some((tk) => ["offer", "answer"].includes(tk.type))) {
         changed[data.type] = data.tickets;
       } else {
         changed[data.type].push(...data.tickets);
       }

       // 另一方資訊已經被接受使用, 已無效, 清空之, 避免 客戶端 watch 時使用無效資料
    changed[data.type === "offer" ? "answer" : "offer"] = null;

       // 更新會議資訊
    const res = await collection.doc(data.meetingId).update(changed);
       return {
         code: 0,
         data: res,
       };
     } catch (error) {
       return {
         code: 1,
         message: error.message,
       };
     }
   };

更新票據和監聽會議資訊變更的前端 API 核心程式碼 meeting-simple/src/meeting/api.js

// 更新票據
export async function updateTicket(data) {
  await signIn();
  const res = await app.callFunction({
    name: "update-ticket-meeting-simple",
    data,
  });
  return res;
}

let watcher = null;
export async function watchMeeting(meetingId, onChange) {
  await signIn();

  // 如果有監聽,關閉監聽
  watcher && watcher.close();

  // 新建資料庫監聽
  watcher = db
    .collection(MEETING_COLLECTION)
    .doc(meetingId)
    .watch({
      onChange: (snapshot) => {
        console.error(snapshot);

        if (
          !snapshot.docChanges ||
          !snapshot.docChanges.length ||
          !snapshot.docChanges[0].doc
        )
          return;

        // 回撥最新的資料庫文件資訊
        onChange(snapshot.docChanges[0].doc);
      },
      onError: (err) => {
        console.log("watch error", err);
      },
    });
}
  1. 優化會議資訊的獲取提升體驗

注意

  1. 監聽資料庫變化亦需要將資料庫設定為公開訪問, 即上述第三步中的注意事項 2 所述
  2. 匿名使用者無法修改其他匿名使用者建立的記錄. 根據資料庫安全策略, 雖同為匿名使用者, 但不同客戶端的匿名使用者標誌不一樣, 故不能操作他人的記錄. 而云函式有用管理員級別的資料庫操作許可權, 故 「更新 ticket」功能採用了雲函式來編寫

程式碼提交記錄

本步驟對應的 git commit

第 5 步 提升非公開會議訪問的安全性, 優化資料庫使用

操作步驟

  1. 將會議密碼分表儲存

修改“加入會議”的前端 API 核心程式碼 meeting-simple/src/meeting/api.js

// 加入會議
export async function joinMeeting(data) {
  await signIn();
  // 加入會議改為使用呼叫雲函式校驗,保證密碼安全
  const result = await app.callFunction({
    name: "join-meeting-meeting-simple",
    data,
  });
  if (result.result.code) {
    throw new Error(result.result.message);
  }

  return true;
}

負責加入會議時進行密碼校驗的雲函式的核心程式碼 meeting-simple/cloudfunctions/join-meeting-meeting-simple/index.js

const tcb = require("@cloudbase/node-sdk");
const MEETING_COLLECTION = "meeting-simple";
const MEETING_PASS_COLLECTION = "meeting-simple-pass";
const app = tcb.init({
  env: tcb.SYMBOL_CURRENT_ENV,
});
const db = app.database();

exports.main = async function (evt) {
  try {
    const result = await db.collection(MEETING_COLLECTION).doc(evt.id).get();
    if (!result.data || !result.data.length)
      return { code: 1, message: "meeting not exists" };
    const meeting = result.data[0];

    if (meeting.hasPass) {
      // 查詢會議密碼
      const passResult = await db
        .collection(MEETING_PASS_COLLECTION)
        .where({ meetingId: evt.id })
        .get();
      if (!passResult.data || !passResult.data.length)
        return { code: 2, message: "passcode not found" };
      const passInfo = passResult.data[0];
      // 對比會議密碼
      if (passInfo.pass !== evt.pass)
        return {
          code: 3,
          message: "passcode not match",
        };
    }
    return { code: 0 };
  } catch (error) {
    return {
      code: 3,
      message: error.message,
    };
  }
};
  1. 資料庫 collection 定期清理舊的無用記錄

清理資料的雲函式的核心實現meeting-simple/cloudfunctions/autoclear-meeting-meeting-simple/index.js

const tcb = require("@cloudbase/node-sdk");
const MEETING_COLLECTION = "meeting-simple";
const MEETING_PASS_COLLECTION = "meeting-simple-pass";
const app = tcb.init({
  env: tcb.SYMBOL_CURRENT_ENV,
});
const db = app.database();
/**
 * 定時觸發, 清理兩天前的會議記錄
 *
{
    "triggers": [
        {
            "name": "clear-time-trigger",
            "type": "timer",
            "config": "0 0 2 * * * *"
        }
    ]
}
 */

exports.main = async function () {
  const now = Date.now();
  // 2天前
  const threshold = now - 2 * 24 * 60 * 60 * 1000;
  const _ = db.command;
  try {
    // 查詢建立時間兩天前的會議記錄,進行刪除
    await db
      .collection(MEETING_COLLECTION)
      .where({
        createdAt: _.lte(threshold),
      })
      .remove();

    // 查詢建立時間兩天前的密碼記錄,進行刪除
    await db
      .collection(MEETING_PASS_COLLECTION)
      .where({
        createdAt: _.lte(threshold),
      })
      .remove();
  } catch (error) {
    console.log("failed to batch remove", error);
  }
};

注意:

  1. 定期清理資料庫使用了雲函式的定時觸發器

程式碼提交記錄

本步驟對應的 git commit

第 6 步 使用 cloudbase framework 一鍵部署

  1. 增加靜態部署功能, 使用了 website 外掛
  2. 增加部署雲函式功能, 包括雲函式定時呼叫的設定, 使用了function 外掛
  3. 增加資料 collection 的建立, 包括 collection 訪問許可權的設定, 使用了 database 外掛

meeting-simple/.env 檔案中宣告環境變數資訊

PUBLIC_URL=./
ENV_ID=tcb-demo-10cf5b

meeting-simple/cloudbaserc.json 檔案中宣告靜態資源、雲函式和資料庫等各個資源的構建和部署資訊

{
  "envId": "{{env.ENV_ID}}",
  "$schema": "https://framework-1258016615.tcloudbaseapp.com/schema/latest.json",
  "version": "2.0",
  "functionRoot": "cloudfunctions",
  "framework": {
    "plugins": {
      "client": {
        "use": "@cloudbase/framework-plugin-website",
        "inputs": {
          "buildCommand": "npm run build",
          "outputPath": "build",
          "cloudPath": "/meeting-simple",
          "envVariables": {
            "REACT_APP_ENV_ID": "{{env.ENV_ID}}"
          }
        }
      },
      "db": {
        "use": "@cloudbase/framework-plugin-database",
        "inputs": {
          "collections": [
            {
              "collectionName": "meeting-simple",
              "aclTag": "READONLY"
            },
            {
              "collectionName": "meeting-simple-pass"
            }
          ]
        }
      },
      "server": {
        "use": "@cloudbase/framework-plugin-function",
        "inputs": {
          "functionRootPath": "cloudfunctions",
          "functions": [
            {
              "name": "autoclear-meeting-meeting-simple",
              "triggers": [
                {
                  "name": "clear-time-trigger",
                  "type": "timer",
                  "config": "0 0 2 * * * *"
                }
              ]
            },
            { "name": "join-meeting-meeting-simple" },
            { "name": "create-meeting-meeting-simple" },
            { "name": "update-ticket-meeting-simple" }
          ]
        }
      }
    }
  }
}

執行 ClouBase Framework 的一鍵部署命令

cloudbase framework deploy


更多 CloudBase Framework 外掛可閱讀CloudBase Framework 官方文件

程式碼提交記錄

本步驟對應的 git commit

總結

在本次實戰案例裡面,我們通過了解了 WebRTC 的基本使用,通過線上會議系統的實戰了解了基於雲開發開發一個應用的完整流程,學會使用了資料庫實時推送能力的使用、匿名使用者使用資料庫的安全策略問題及雲函式定時呼叫功能,掌握了使用 CloudBase Framework 一鍵部署前後端應用這一工具來快速交付。


CloudBase Framework 開源專案介紹