在线视频会议应用是基于浏览器的能力 WebRTC 以及 腾讯云开发 CloudBase 能力构建而成的应用. 在云开发的助力下, 一个复杂的在线会议应用, 一个人一两天即可完成.
云开发 CloudBase 开通,参加:https://console.cloud.tencent.com/tcb?tdl_anchor=techsite
应用体验地址: Online Meeting Powered by Tencent Cloudbase
项目源码地址: Github
注: 应用仅供演示之用, 目前仅支持两人视频会议, 功能还不够完善, 还有许多可完善之处.
创建会议后可将会议地址发给他人, 或者在本机另起一浏览器窗口(未避免数据混乱, 可开隐私模式窗口, 或使用另一个浏览器)打开会议地址 来体验
可以在线一键部署或通过本地部署的方式,来独立部署一个自己的在线视频会议应用
只需要点击下方按钮,跳转到腾讯云控制台,即可在云端一键安装一个在线视频会议应用
ENV_ID
的值 tcb-demo-10cf5b
修改为自己的环境 IDnpm run deploy
即可本应用用到的能力、工具、框架有:
如果你不清楚项目开发的基本命令, 可阅读本项目使用的模版的 readme.md
云开发(CloudBase)是云端一体化的后端云服务 ,采用 serverless 架构,免去了应用构建中繁琐的服务器搭建和运维。同时云开发提供的静态托管、命令行工具(CLI)、Flutter SDK 等能力降低了应用开发的门槛。使用云开发可以构建完整的小程序/小游戏、H5、Web、移动 App 等应用。
CloudBase Framework 是云开发官方出品的开源前后端一体化部署工具,无需改动代码,实现前后端一键托管部署,支持常见的框架和语言,支持自动识别并部署。不仅可以部署应用前后端到 Serverless,还可以扩展更多后端能力。
Github 地址: https://github.com/TencentCloudBase/cloudbase-framework
整个应用的构建, 从项目初始化到最终可以一键部署, 共分为 6 个部分. 为方便读者查阅,主要的代码实现分了 6 次提交, 下述说明中会列出每一步对应的提交 commit.
localhost
及 127.0.0.1
(不限端口), 不支持其他自定义的本机域名、IP首先需要全局安装 Cloudbase CLI
npm i @cloudbase/cli@latest -g
使用以下命令来使用云开发的 react 应用模版创建一个 React 云开发项目
cloudbase init --template react-starter
npm i ant-d @ant-design/icons -S
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>;
}
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
meeting-simple
创建会议的前端 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
获取会议信息和加入会议的前端 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;
}
注:
本步骤对应的 git commit
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);
});
}
用于更新 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);
},
});
}
本步骤对应的 git commit
修改“加入会议”的前端 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,
};
}
};
清理数据的云函数的核心实现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);
}
};
本步骤对应的 git commit
在 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 是云开发开源的云原生前后端一体化部署工具,支持主流前后端框架,前后端一键托管部署在云端一体化平台,支持支持小程序、Web、Flutter、后端服务等多个平台。
Github 开源地址:https://github.com/TencentCloudBase/cloudbase-framework
欢迎给 CloudBase Framework 一个 🌟 star
欢迎大家参与 CloudBase Framework 的开发工作,成为我们的贡献者,我们将会在云开发社区展示贡献者的作品和信息,同时也会有惊喜奖励。
您可以选择如下的贡献方式:
CloudBase Framework 的发展离不开社区的积极贡献,这是我们的核心贡献者列表,再次感谢大家的贡献:https://github.com/TencentCloudBase/cloudbase-framework#contributors-
云开发(Tencent CloudBase,TCB)是腾讯云提供的云原生一体化开发环境和工具平台,为开发者提供高可用、自动弹性扩缩的后端云服务,包含计算、存储、托管等 serverless 化能力,可用于云端一体化开发多种端应用(小程序,公众号,Web 应用,Flutter 客户端等),帮助开发者统一构建和管理后端服务和云资源,避免了应用开发过程中繁琐的服务器搭建及运维,开发者可以专注于业务逻辑的实现,开发门槛更低,效率更高。
开通云开发:https://console.cloud.tencent.com/tcb?tdl_anchor=techsite
产品文档:https://cloud.tencent.com/product/tcb?from=12763
技术文档:https://cloudbase.net?from=10004
技术交流加 Q 群:601134960
最新资讯关注微信公众号【腾讯云云开发】