导语
2021腾讯游戏年度发布会在线上举行。今年,发布会以“超级数字场景”战略理念为核心,传递对游戏认知、产业边界的建设性思考,并通过60余款游戏产品与内容集中发布,展现腾讯游戏为玩家带来的丰富体验与多元价值。
本次发布会再次选择了云开发 CloudBase 作为技术选型之一,以极低的成本实现了实时弹幕系统,并保障稳定运行,为游戏爱好者带来了优质互动体验。下文将重点介绍项目组使用云开发实现弹幕功能的全过程。
“各部门注意,前方高能!”
一、业务背景
2021腾讯游戏年度发布会开发了专属小程序,包含直播、抽奖、观看回放等功能,其中所有的弹幕功能均基于云开发的实时数据推送实现。
在进行弹幕功能的技术选型前,开发同学梳理了业务场景:
- 弹幕实时互动
- 允许少量的弹幕丢失
- 仅发布会直播当晚使用
- 敏感信息/关键字过滤
在综合考虑成本、稳定性、与小程序适配性等多个方面后,项目最终选择了云开发的实时数据推送功能,早在去年的发布会里,项目组就使用了云开发的实时数据推送来实现直播节目单进度提醒等功能,在此基础上,把弹幕也统一搬上云开发。
二、技术实践
开发思路
一开始想直接把全部用户的弹幕集合直接监听,但官方限制单次监听数据不能大于5000条,且监听数据条越多初始化性能越差,超出上限会抛错并停止监听。最后设计为:用户弹幕插入集合a,监听数据集合b,使用云函数的定时器定期合并弹幕,并更新到对应的正在监听的数据记录上(如图)。
这样保证了用户监听的数据记录为恒定数量,这里采用10条记录(循环数组)汇总弹幕数据,每秒更新当前时间戳的所有弹幕到 index = timestamp%10 的数据记录上,同时弹幕刷新频率固定为1s,减轻前端由于数据频繁改动而不断 callback/ 渲染的性能消耗。
代码演示
用户发送弹幕部分代码:
exports.main = async (event, context) => {
// ...省略部分鉴权/黑名单/校验内容安全逻辑
let time = Math.ceil(new Date().getTime() / 1000);
// 插入弹幕
let res = await db.collection('danmu_all').add({
data: {
openid,
content,
time,
},
});
return {err: 0, msg: 'ok'};
};
弹幕合并处理:
exports.main = async (event, context) => {
// ....省略一部分非关键代码
// 只取其中100条弹幕,可动态调整
let time = Math.ceil(new Date().getTime() / 1000) - 1;
const result = await db
.collection('danmu_all')
.where({time}).limit(100).get();
let msg = [];
for (let i of result.data) {
msg.push({
openid: i.openid,
content: i.content,
});
}
// 更新循环数组的对应位置
db
.collection('watch_collection')
.where({index: time % 10})
.update({
data: {msg,time},
});
return msg;
}
前端处理消息通知,注意不要重复 watch。其中如果打开了云开发的匿名登录,那 H5 端的页面同样可以使用同步弹幕功能:
this.watcher = db.collection('watch_collection').watch({
onChange: function(snapshot) {
for (let i of snapshot.docChanges) {
// 忽略非更新的信息
if (!i.doc || i.queueType !== 'update') {
continue;
}
switch (i.doc.type) {
// ...省略其他类型的消息处理
case 'danmu':
// 弹幕渲染
livePage.showServerBarrage(i.doc.msg);
break;
}
}
},
});
至此,整个弹幕的核心功能已经完全实现。
二次优化
跑了一段时间后发现偶现丢弃几秒内的弹幕,后面查看执行日志,发现即使配置定时器为每秒执行一次,实际生产中也不是严格每秒执行一次,有时候会跳过1-3秒去执行,这里另外使用了 redis 去标记当前处理的进度,即使有跳过的秒数,也能往前回溯未处理的时间进行补录。其中云函数使用 redis 的教程可以查看官方云函数使用 redis 教程。
用户发送弹幕部分代码添加标记代码:
exports.main = async (event, context) => {
// ...省略部分鉴权跟校验内容安全代码
// ...省略插入代码
// 标记合并任务
await redisClient.zadd('danmu_task', time, time+'')
};
弹幕合并处理,注意:要 redis5.0 以上的才支持 zpopmin 命令,如需购买,需要选对版本。
exports.main = async (event, context) => {
//当前秒
let time = Math.ceil(new Date().getTime() / 1000) - 1;
while (true) {
// 弹出最小的任务
let minTask = await redisClient.zpopmin('danmu_task');
// 当前无任务
if (minTask.length <= 0) {
return;
}
// 当前秒的任务,往回塞,并结束
if (parseInt(minTask[0]) > time) {
redisClient.zadd('danmu_task', minTask[1], minTask[0]);
return;
}
// 执行合并任务
await danmuMerge(time);
}
};
安全逻辑上也做了一定的策略,如本地先渲染发送的弹幕,客户端收到弹幕推送时,判断 openid 为自己时候不渲染,这样即使用户的弹幕被过滤掉也能在本地展现,保留一定的用户体验。
另外,单个云函数的实例上限是1000,如果确定当晚流量比较大,可以考虑用多个云函数分摊流量。
管理后台的实现
同时,利用 watch 功能可以做到管理后台同步实时刷新客户端的弹幕,达到管理的目的,同一份代码前端和管理端都能复用:
节选部分管理后台代码:
methods: {
stop() {
this.watcher.close();
},
},
beforeDestroy() {
this.watcher.close();
},
mounted() {
this.app = this.$store.state.app;
this.db = this.app.database();
let that = this;
this.watcher = this.db.collection('danmu_merge').watch({
onChange(snapshot) {
for (let d of snapshot.docChanges) {
for (let v of d.msg) {
that.danmu.unshift(v);
}
}
if (that.danmu.length > 500) {
that.danmu = that.danmu.slice(0, 499);
}
},
});
集合的读权限设置在实时数据推送里同样生效,如果权限是设置为仅可读用户自己的数据,则监听的时候无法监听到非用户自己创建的数。
Tips
当时没注意到 watch 对数据库权限限制的问题,数据库权限默认为仅创建者可读写,循环数组第一次初始化是开发过程中在客户端创建,默认添加了当前用户的openid,导致其他用户无法读取到 merge 的数据,解决方法:删除 openid 字段或设置权限为全部人可读。
集合的读权限设置在实时数据推送里同样生效,如果权限是设置为仅可读用户自己的数据,则监听的时候无法监听到非用户自己创建的数。
三、项目成果与价值
基于云开发的云函数、实时数据推送、云数据库等能力,项目全程平稳运行,即便在发布会当晚流量峰值的时候,弹幕的写入运行稳定。在监听方面(读),watch 的性能能够稳定支持百万级同时在线。
最终,2名研发仅用2天就完成了弹幕系统的开发和调试。而在费用方面,支撑整个项目弹幕系统运行的总费用仅为100元左右,主要集中在数据库读写和云函数调用(目前监听数据库实时数据功能处于免费阶段,不会计算到数据库读取费用上),抛去其他模块的费用,实际弹幕模块可能仅消耗了小几十块钱,费用大大低于预期,相对比传统即时通讯等方案节省超过数十倍。
总体上,项目采用云开发,具备以下优势:
- 自带弹性扩缩容,可以抗住瞬时高并发流量,保障直播顺利进行;
- 费用便宜,只收取云函数调用和数据库读写费用,实时数据推送免费使用,非常适合项目;
- 安全稳定,项目的访问都基于云开发自带的微信私有链路实现,保证安全性;
- 自由度高,能够契合其他开发框架和服务。