最近用云开发写仿了一个很古老的小游戏 http://stonelf.gitee.io/letter/letter.html 大家可以点进去玩一下。分享一下过程中踩的一些坑。
云开发(Tencent Cloud Base,TCB)是腾讯云为移动开发者提供的一站式后端云服务,可以看作是云函数SCF的升级版。
作为推进serverless的急先锋,SCF提供了弹性的计算能力,也能通过api网关提供弹性的接入能力,但是SCF服务里面不包括后台数据库,因此虽然你可以通过SDK连接到云数据库,但是始终要多关心一个数据库Server,这让我们在说起serverless时,有时显得不够原教旨主义。
而TCB给开发者提供了一个免费和几乎免维护的的no-sql数据库(可以付费升级),这就让原教旨主义的无服务器开发的有了更多的可玩性。此外还很贴心的提供了容易上手的web端、小程序端和服务器端的SDK,让玩转TCB的门槛大大降低。尤其是有一套包装好的服务器端推送的SDK实现(见 数据库web端SDK 的最后一段《数据库实时推送》),可以很方便的开发实时应用。
由于闲闲还在美国幼儿园“留学”,这几天想复刻一个异地互动的flash游戏letters,正好可以用得上这两个特性。
letters是一个很傻的游戏,任何人都可以访问网站看到当前的字母排列,然后可以在屏幕上拖动字母,其他正在访问网页的人会即时看到字母被拖走了,因此也可以多人合作来把字母排列成特定的样子,或者相互搞破坏偷走别人的字母让他拼不成想要的样子。原游戏基于flash实现,现在已经难以怀旧了。重新开发这个游戏除了实现字母拖放互动之外,还想支持现代浏览器和平板,同时还想允许把一个字母换成别的字母,在换掉的时候别人的浏览器上也要实时显示出变化。
实现上很简单:
首先是实现一些基础的页面上的拖放逻辑,并兼容pad。为了兼容不同的显示设备和不同的浏览器大小,字体要随着屏幕宽度缩放,而位置全部要采用百分比定位。这是纯前端部分的工作,可以在文末的连接中跳转到游戏网页上查看源码。
然后就是需要捕获到每次拖放结束的事件,把被拖放的字母的新位置发送给后端的云函数,这部分的代码很简单,拼凑一个http post请求把要发送的数据丢到body里面就好。
代码语言:javascript复制function sendData(data){
fetch("//service-j3t56zj5-1258721286.ap-shanghai.apigateway.myzijiebao.com/release/", {
cache: "no-cache",
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
}).then(function(response) {
return response.json()
}).then(function(json) {
if (isNaN(json.errorCode)) {
console.log("发送成功")
} else {
console.log(JSON.stringify(json))
}
}).catch(function(ex) {
console.log('parsing failed', ex)
})
}
后端的服务程序也很简单,接收到更新的字母后往数据库里面写。不过要考虑到第一个进来玩的人由于数据库里什么都没有,要生成一点原始数据给他玩。
代码语言:javascript复制'use strict';
const app = require("tcb-admin-node");
const chars = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9","?","?","?","?","?","?","?","?","?","?","❤️","?","?"]
exports.main = async (event, context, callback) => {
console.log("nevent.body:" event.body)
let parameters = JSON.parse(event.body),id=-1,x=-1,y=-1,c='';
if(!!parameters){
if(!isNaN(parameters["id"])){
id=parseInt(parameters["id"]);
}
if(!isNaN(parameters["x"])){
x=parameters["x"]-0;
}
if(!isNaN(parameters["y"])){
y=parameters["y"]-0;
}
if(parameters["char"] && parameters["char"].length<3){
c=parameters["char"]
}
}
app.init({env: 'game-acebe0'})
const db = app.database();
const _ = db.command
let collection = db.collection('game');
let all = collection.where({char:_.neq('')})
let data = (await all.orderBy("_id", "asc").get()).data
if(!data){
await db.createCollection("game")
collection = db.collection('game');
data = [];
}
if(data.length<chars.length){
var dataHash = {};
for(var i=0;i<data.length;i ){
dataHash[data[i]["_id"]]=true;
}
for(let i=0;i<chars.length;i ){
if(!dataHash[i]){
let t = {
_id:i,
char:chars[i],
x:6*(i) 5,
y:7*Math.floor(i/15),
color:Math.floor(Math.random()*256*256*256)
}
collection.add(t)
data.push(t)
}
}
}
if(id>=0 && id<chars.length){//id 合法
let query =collection.where({"_id":id-0})
if(x>=0 && x<=100 && y>=0 && y<=100 && c.length==0){ //坐标合法
let now = Date.now();
let q=await query.update({"x":x,"y":y})
console.log("n 更新时间" (Date.now()-now) "毫秒,更新了" q.updated "条记录")
data[id]["x"]=x
data[id]["y"]=y
}
if(c && c.length>0){
console.log("change char")
await query.update({"char":c})
}
}
return data
}
TCB的云函数和SCF的一样可以通过api-gateway提供http/https接入能力,只是不像SCF那样可以直接在云函数里面添加api-gateway,而是需要在同服务区的api-gateway里面去把cloud function绑定到api的后端配置中。如果不熟悉api-gateway配置也可以直接把TCB的云函数代码粘贴到SCF的编辑器里面当普通的云函数配置运行,然后就可以在函数服务的触发方式里面添加api-gateway并一步到位的发布服务了。TCB的服务器SDK在SCF中不但工作良好,而且beta用户还可以启用依赖自动安装这样的黑科技,连开发的“服务器”都不需要,真正实现原教旨主义的serverless。唯一要注意的是TCB和SCF的云函数入口有exports.main_handler 和 exports.main 的写法差异。
这里我遇到了云开发的第一个大坑:写初始数据的时候无法批量写入。这就导致我只能一条一条的写入,而很多条数据如果串行写入的话等待数据库返回的时间就会被累加,任何网络抖动都会导致整个函数超时退出。如果并行写入的话会超过免费用户可用的数据库连接数上限导致部分写失败。刚开始我每次检查到数据库的初始数据不完整就重新初始化,然后又一次大量的写入导致部分失败……10分钟不到就把一整天的写操作限额用光了。
一个填坑方案是等待云开发的批量写入接口放出来,不过我想到了 《云原生应用的“十二要素”》中大家一再强调的一个原则:
面向失败的设计:
……大多数云计算的基础设施天生就是短暂的,与本地环境的硬件相比,也更容易出现故障。结合我们大多数人在设计分布式系统时所遵循的原则,你必须设计出能够容忍服务消失或者被重新部署的系统。
是的,我们可以并且应该在设计上让系统容忍这个错误并继续良好运作。具体讲,我先一股脑把所有的初始数据同步丢进去写数据库然后基于部分成功的结果让系统运作起来,然后在下次函数运行的时候看看哪些操作写失败了,再补写一下。通常补写两三次以后就全部都成功了。
在写操作完成后,需要让所有的网页端侦听到数据库发生了什么变化,并把这些变化用动画表现到网页上。参考SDK文档,很容易就用webSDK实现了网页端到侦听:
代码语言:javascript复制fetch("//service-1c18mywx-1258721286.ap-shanghai.apigateway.myzijiebao.com/release/").then(
(response) => {
return response.json()
}).then((json) => {
ticket = json.ticket;
return auth.signInWithTicket(ticket)
}).then(() => {
// 登录成功
const db = app.database();
const _ = db.command
const collection = db.collection('game');
let ref = collection.where({
_id: _.gt(-1)
}).watch({
onChange: snapshot => {
var docs = snapshot.docChanges;
if (docs.length > 0) {
show(docs);
} else {
console.log("获取初始化数据失败")
}
},
onError: error => {
console.log("收到error", error)
}
})
})
这里有个小坑,要获得侦听数据库的权限,需要先接收到服务器端下发的一个ticket来授权,也就是上面代码的“// 登录成功”前面的部分。而服务区端需要写一个生成ticket的云函数并通过API网关把它发布出去:
代码语言:javascript复制'use strict';
const tcb = require('tcb-admin-node');
exports.main = (event, context, callback) => {
tcb.init({
// ...
env: 'game-acebe0',
credentials: {"private_key_id":".........................",
"private_key":"-----BEGIN RSA PRIVATE KEY-----n...............n-----END RSA PRIVATE KEY-----n"}
});
let customUserId = Math.floor(Math.random()*10000000).toString();
const ticket = tcb.auth().createTicket(customUserId, {
refresh: 10 * 60 * 1000 // 每十分钟刷新一次登录态, 默认为一小时
});
return {"ticket":ticket};
};
因为这是个开放的匿名游戏,所以没有对授权做什么限制,谁都可以申请都ticket来获取服务器数据,但是这样有DoS隐患。还好用的是免费服务,如果被攻击导致访问量超过限额,服务会自动终止,危害有限。
此外对数据库权限也要限制为“所有用户可读,仅管理员可写”,这样稍微提升一点点干坏事的门槛(虽然还是谁都可以通过云函数匿名写,但是能写的字段和写的方式收到代码逻辑的限制)。
完成了授权和客户端登录,在网页上就可以侦听到所有人的拖动和字符修改操作并且做相应的渲染了。至此一个游戏的逻辑大体完成。网页端发补到了gitee page上:http://stonelf.gitee.io/letter/letter.html 需要看完整的前端代码只要打开页面后查看页面源码就可以了。
总结一下过程中遇到的坑和吸取的经验教训:
1 云开发暂时还没有批量写的能力,大量数据不管是顺序写还是并行写都容易失败,这不能通过全量重新写来解决。
——伟大领袖教导我们:面向失败做设计
2 云开发的云函数没有打通api-gateway来提供http服务,要自己去同服务区的api-gateway中绑定云函数,或者把云函数放到SCF中去。
3 web前端的server-push比自己主动轮询后台数据要更及时,而且可以提供大得多的免费并发访问能力,而且,还不用耗费api-gateway和云函数的访问和执行限额。
4 web前端的SDK能力很强大,需要配合数据库的权限配置来限制访问者进行意想之外的写操作。
最后,其实挺希望云函数能有一个跨实例的快速的存储共享机制的。因为云函数自己没有状态,只能通过数据库来维护状态,而且数据库操作受到网络抖动、并发数限制等因素等影响,在延迟和读写成功率上都难以保障,需要做更多的容错设计。如果有一块小小的共享存储空间可以保存一点session等信息并让它保持在一个设定的session过期时间内都可以访问,很多设计可以简单很多。如果实例能被限制在同一个母机上,这个存储用内存实现就最好了。如果实例跨了一个集群内的多台主机,在集群内部实现一个共享的存储访问,也比跑出去访问数据库要可靠和快速的多。按照程序运行内存的收费标准(MB秒)收费或者提供免费额度就可以了