基於雲開發 CloudBase 搭建在線影片會議應用教程

基於雲開發 CloudBase 搭建在線影片會議應用

在線影片會議應用是基於瀏覽器的能力 WebRTC 以及 騰訊雲開發 CloudBase 能力構建而成的應用. 在雲開發的助力下, 一個複雜的在線會議應用, 一個人一兩天即可完成.
雲開發CloudBase開通,參加://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 伺服器, 一般默認會使用Google的公共伺服器

雲開發

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

CloudBase Framework

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

Github 地址: //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": "//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 開源項目介紹

🚀 CloudBase Framework 是雲開發開源的雲原生前後端一體化部署工具,支援主流前後端框架,前後端一鍵託管部署在雲端一體化平台,支援支援小程式、Web、Flutter、後端服務等多個平台。

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

歡迎給 CloudBase Framework 一個 🌟 star

CloudBase Framework 核心貢獻者計劃

歡迎大家參與 CloudBase Framework 的開發工作,成為我們的貢獻者,我們將會在雲開發社區展示貢獻者的作品和資訊,同時也會有驚喜獎勵。

您可以選擇如下的貢獻方式:

CloudBase Framework 的發展離不開社區的積極貢獻,這是我們的核心貢獻者列表,再次感謝大家的貢獻://github.com/TencentCloudBase/cloudbase-framework#contributors-

產品介紹

雲開發(Tencent CloudBase,TCB)是騰訊雲提供的雲原生一體化開發環境和工具平台,為開發者提供高可用、自動彈性擴縮的後端雲服務,包含計算、存儲、託管等serverless化能力,可用於雲端一體化開發多種端應用(小程式,公眾號,Web 應用,Flutter 客戶端等),幫助開發者統一構建和管理後端服務和雲資源,避免了應用開發過程中繁瑣的伺服器搭建及運維,開發者可以專註於業務邏輯的實現,開發門檻更低,效率更高。
開通雲開發://console.cloud.tencent.com/tcb?tdl_anchor=techsite
產品文檔://cloud.tencent.com/product/tcb?from=12763
技術文檔://cloudbase.net?from=10004
技術交流加Q群:601134960
最新資訊關注微信公眾號【騰訊云云開發】