在cocos creator中使用protobufjs(一)
在cocos creator中使用protobufjs(二)
通過(guò)前面兩篇我們探索了如何在creator中使用protobuf,并且讓其能正常工作在瀏覽器、JSB上,最后聊到protobuf在js項(xiàng)目中使用上的一些痛點(diǎn)。這篇博文我要把這些痛點(diǎn)一條一條地扳開(kāi),分析為什么它讓我痛,以及我的治療方案。

一、proto文件的加載問(wèn)題
我遇到的第一個(gè)痛點(diǎn)就是proto文件的加載問(wèn)題。有人可能會(huì)問(wèn),前面不是講了怎么加載方法很簡(jiǎn)單的:
...
let builder = new protobuf.Builder();
protobuf.loadProtoFile('aaa.proto', builder);
protobuf.loadProtoFile('bbb.proto', builder);
...
protobufjs是一個(gè)很優(yōu)秀的庫(kù),他提供的loadProtoFile接口簡(jiǎn)單直接,但是在真實(shí)的項(xiàng)目開(kāi)發(fā)中會(huì)像是上面這樣的嗎?proto文件是一開(kāi)始就設(shè)計(jì)好了,固定不變的嗎?文件名會(huì)修改嗎?文件會(huì)新增、刪除嗎?
痛點(diǎn)分析
我只有第一天在cocos-js項(xiàng)目中使用proto時(shí)是將一個(gè)一個(gè)的proto文件名寫(xiě)死在loadProtoFile的參數(shù)中的,因?yàn)槟鞘俏抑型緟⑴c的項(xiàng)目,當(dāng)時(shí)我就發(fā)現(xiàn)了問(wèn)題:
- 路徑名、文件較長(zhǎng)容易寫(xiě)錯(cuò)字。
- 項(xiàng)目開(kāi)發(fā)中協(xié)議會(huì)不斷新增,會(huì)寫(xiě)漏,少加載了proto文件。
- 某些原因會(huì)修改proto文件名,原來(lái)加載的沒(méi)及時(shí)修改,加載時(shí)會(huì)出錯(cuò)。
-
人工手寫(xiě)這個(gè)加載文件會(huì)很累,效率低下,容易出錯(cuò),在文件眾多的情況下極度消耗腦細(xì)胞。
解決辦法
編寫(xiě)代碼來(lái)生成代碼
我的解決辦法是編寫(xiě)一個(gè)程序,掃描proto文件目錄,生成一個(gè)文件列表的數(shù)組,從而完全解放人工操作。
//protoFiles.js 用腳本自動(dòng)生成的文件
module.exports = [
res/proto/aaa.proto,
res/proto/bbb.proto,
res/proto/zzz.proto,
res/proto/login/xxx.proto
...
]
//pbhelper.js 編寫(xiě)一個(gè)加載器
let protoFiles = require('protoFiles'); //導(dǎo)入自動(dòng)生成的proto文件列表
...
loadProtoFile() {
let builder = new protobuf.Builder();
//遍歷文件名,逐一加載
protoFiles.forEach((protoFile) => {
protobuf.loadProtoFile(protoFile, builder);
})
...
}
從此再也不用擔(dān)心proto文件加載方面的問(wèn)題了。
解放更多人工操作
在編寫(xiě)proto掃描腳本的同時(shí),還可以將proto文件同步到自己的工程目錄中,以解決proto文件的手工復(fù)制粘貼問(wèn)題,如果你還要更進(jìn)一步,還可以將svn/git的拉取給做了。
總結(jié)一下腳本要做的事:
1.從svn或git獲取最新的proto文件(svn: svn up, git: git pull origin master)
2.將proto文件同步到工程目錄
3.掃描工程目錄中的proto文件,生成一個(gè)文件列表數(shù)組
Creator中的新發(fā)現(xiàn)
最早在Creator中使用proto時(shí)我也是使用的上面的方法,但隨著對(duì)Creator的了解越來(lái)越多,我就在想,Creator不是管理了我們所有的資源了嗎?cc.loader.loadResDir不是要以加載一個(gè)目錄下的所有資源,是否可以有更簡(jiǎn)單的辦法?于是我嘗試著去調(diào)試loadResDir函數(shù)有驚喜發(fā)現(xiàn)。
let files = [];
//xxx是assets/resources目錄下的一個(gè)目錄名
cc.loader._resources.getUuidArray('xxx', null, files);
//files會(huì)得到所有的文件名
cc.log(files);
通過(guò)這個(gè)發(fā)現(xiàn),可以省去生成protoFiles.js的工作了。
二、proto對(duì)象的實(shí)例化問(wèn)題
proto對(duì)象的實(shí)例化是一個(gè)痛點(diǎn),估計(jì)很多人會(huì)覺(jué)得有點(diǎn)小題大作。protobufjs不是提供了操作方法嗎,那么簡(jiǎn)單:
//實(shí)例化登錄請(qǐng)求
let loginReq = new pb.LoginRep();
loginReq.account = 'zxh';
loginReq.password = '123456';
//假如net是封裝好了的網(wǎng)絡(luò)模塊
net.send(pb.ActionCode.LOGIN, loginRsp, (data) => {
//收到數(shù)據(jù),反序列化
let loginRsp = pb.LoginRsp.decode(data);
...
});
如果是做過(guò)網(wǎng)絡(luò)開(kāi)發(fā)的應(yīng)該對(duì)上面的代碼不難理解,這里還是簡(jiǎn)單的解釋一下:
1.xxxRep是客戶(hù)端請(qǐng)求消息,xxxRsp 是服務(wù)器響應(yīng)消息,成對(duì)的設(shè)計(jì)請(qǐng)求、響應(yīng)協(xié)議比較好管理。
2.pb.ActionCode.LOGIN是一個(gè)常量定義,是設(shè)計(jì)的請(qǐng)求操作碼,用于服務(wù)器識(shí)別你發(fā)的消息是登錄請(qǐng)求,而不是其它,不然序列化后的二進(jìn)制內(nèi)容服務(wù)器無(wú)法反序列化。
3.這里沒(méi)有出現(xiàn)客戶(hù)端proto對(duì)象的序列化操作,因?yàn)榭梢苑庋b到net.send函數(shù)中,所以它不足以成為一個(gè)痛點(diǎn)。
4.net.send中的回調(diào)函數(shù)是客戶(hù)端響應(yīng)處理函數(shù),通過(guò)參數(shù)獲得服務(wù)器發(fā)送的數(shù)據(jù),因?yàn)槎M(jìn)制數(shù)據(jù),所以需要用pb.LoginRsp.decode(data)進(jìn)行反序列化。
痛點(diǎn)分析
let loginReq = new pb.LoginRep();
- 在js中使用proto有個(gè)特點(diǎn),proto對(duì)象一般IDE都沒(méi)有代碼提示和著色,在用調(diào)用proto對(duì)象解碼時(shí)輸入效率低下,還容易打錯(cuò)。
- 這句代碼暴露了協(xié)議細(xì)節(jié),如果pb.LoginRep改名了也不知道,代碼會(huì)報(bào)錯(cuò)。
- net.send(pb.ActionCode.LOGIN, loginReq, () => { }) 明明已經(jīng)是發(fā)送的登錄消息了,為什么還需要一個(gè)操作碼呢?感覺(jué)有些累贅、重復(fù)。
解決辦法
工廠模式
如果能像下面一樣是不是會(huì)更清爽:
//使用工廠函數(shù)獲得LoginReq對(duì)象
let req = pb.newReq(pb.ActionCode.LOGIN);
req.account = 'zxh';
req.password = '123456';
//在工廠函數(shù)時(shí)做個(gè)小動(dòng)作:req.action = pb.ActionCode.LOGIN
//send時(shí)就不需要消息號(hào)參數(shù)了。
net.send(req, ...);
通過(guò)pb.newReq隱藏協(xié)議細(xì)節(jié),也不需要管消息的名字,用的什么protobuf庫(kù),返回的req上綁定上action消息號(hào)減少調(diào)用send時(shí)的重復(fù)參數(shù),上層操作簡(jiǎn)單明了。
除了設(shè)計(jì)工廠函數(shù)外,還需要定義pb.ActionCode.LOGIN,讓它能被IDE自動(dòng)提示、代碼補(bǔ)全,文本著色,我們會(huì)省心很多。

三、proto對(duì)象的反序列化問(wèn)題
我們?cè)倏聪路葱蛄谢膱?chǎng)景
...
//發(fā)送數(shù)據(jù),net假如是封裝好了的網(wǎng)絡(luò)模塊
net.send(pb.ActionCode.LOGIN, loginReq, (data) => {
//發(fā)送的是登錄請(qǐng)求,反序列化時(shí)要用登錄響應(yīng),不然會(huì)失敗
let loginRsp = pb.LoginRsp.decode(data);
...
});
痛點(diǎn)分析
反序列化成為痛點(diǎn)有部分原因與實(shí)例化相同,而且當(dāng)你收到一個(gè)響應(yīng)時(shí),該用那個(gè)proto對(duì)象去反序列化會(huì)殺死不少腦細(xì)包,特別是在設(shè)計(jì)協(xié)議消息名字時(shí)不注意規(guī)范時(shí)更容易出錯(cuò)。
解決辦法
1.設(shè)計(jì)通信協(xié)議頭
2.請(qǐng)求\響應(yīng)唯一序列號(hào)
3.工廠模式

通信協(xié)議頭是客戶(hù)端、服務(wù)器在收到二進(jìn)制數(shù)據(jù)時(shí),可以使用一個(gè)固定的協(xié)議結(jié)構(gòu)去反序列也稱(chēng)之為解碼。 解碼后可以獲得基本的數(shù)據(jù),比如路由號(hào)、時(shí)間戳、用戶(hù)ID、下層協(xié)議數(shù)據(jù)(二進(jìn)制)等,大概如下:
message PBMessage{
int32 action = 1; //消息號(hào)用于指明data字段(標(biāo)識(shí)下層協(xié)議類(lèi)型)
int32 sequence = 2; //請(qǐng)求序列
uint64 timestamp = 3; //時(shí)間戳
int32 userID = 4; //帳號(hào)
bytes data = 5; //請(qǐng)求或響應(yīng)數(shù)據(jù)(序列化后的二進(jìn)制數(shù)據(jù))
}
其中的sequence字段是客戶(hù)端向服務(wù)器發(fā)出一個(gè)請(qǐng)求時(shí),生成的唯一ID。當(dāng)服務(wù)器響應(yīng)你這個(gè)請(qǐng)求時(shí),傳回這個(gè)sequence,通過(guò)這個(gè)sequence + action你就能確定你的響應(yīng)消息對(duì)象,從而正確解碼。
//收到網(wǎng)絡(luò)數(shù)據(jù)
message(event) {
var pbMessage = pb.PBMessage.decode(event.data);
//從緩存對(duì)象中取出請(qǐng)求時(shí)的參數(shù)對(duì)象
var obj = this.cache[pbMessage.sequence];
//刪除緩存數(shù)據(jù)
delete this.cache[pbMessage.sequence];
try{
//檢測(cè)緩存數(shù)據(jù)是否存在
if (!obj) {
return;
}
//使用工廠創(chuàng)建響應(yīng)對(duì)象
let rsp = pb.newRsp(obj.action, obj.data);
//調(diào)用請(qǐng)求時(shí)的回函數(shù)
obj.callback(rsp);
}catch(e) {
cc.log('處理響應(yīng)錯(cuò)誤');
}
}
- cache是緩存net.send時(shí)的參數(shù)包括:action、sequence、callback,其中sequence是自動(dòng)生成的并以它為key。
- 當(dāng)收到服務(wù)器數(shù)據(jù)時(shí),先解碼PBMessage,用解碼后的sequence去查找出action。
- 使用action和data做為響應(yīng)工廠函數(shù)的參數(shù),反序列化出響應(yīng)對(duì)象。
- 調(diào)用響應(yīng)處理函數(shù)。
這時(shí)響應(yīng)函數(shù)就可以很輕松的處理業(yè)務(wù)了
//發(fā)送數(shù)據(jù),net假如是封裝好了的網(wǎng)絡(luò)模塊
net.send(loginReq, (loginRsp) => {
//直接訪問(wèn)響應(yīng)對(duì)象,不需去解碼了
this.label.string = loginRsp.player.name;
...
});
核心問(wèn)題
不論是解決實(shí)例化還是反序列化,最核心的問(wèn)題是實(shí)現(xiàn)那兩個(gè)工廠函數(shù)
let req = newReq(action);
let rsp = newRsp(action, data);
而實(shí)現(xiàn)這兩個(gè)工廠函數(shù)的前提是明確請(qǐng)求操作碼、請(qǐng)求對(duì)象、響應(yīng)對(duì)象,需要建立一個(gè)映射表,類(lèi)似下面的定義
//proto中定義Action
enum ActionCode {
LOGIN: 1,
LOGOUT: 2,
}
//protoMap.js文件
protoMap = {
1: {
req: pb.LoginRes,
rsp: pb.LoginRsp,
}
...
}
有了protoMap工廠函數(shù)就簡(jiǎn)單了
//工廠函數(shù)
let protoMap = require('protoMap');
//請(qǐng)求工廠函數(shù)
newReq(action) {
let obj = protoMap[action];
let req = new obj.req();
req.action = action;
return req;
}
//響應(yīng)工廠函數(shù)
newRsp(action, data) {
let obj = protoMap[action];
return obj.rsp.decode(data);
}
四、protoMap如何而來(lái)?
我們的問(wèn)題是不是都解決呢?如果你覺(jué)得都解決了,那是高興的太早了。
目前protoMap.js文件是需要人手工去編寫(xiě)的,同樣的問(wèn)題又來(lái)了。
痛點(diǎn)分析
1 一個(gè)項(xiàng)目與服務(wù)器的請(qǐng)求少則幾十個(gè),多則上百上千,手工方式維護(hù)protoMap的難度大。
2.手工編寫(xiě)這個(gè)protoMap.js文件在協(xié)議新增、修改、刪除時(shí)容易出錯(cuò)。
3.出了錯(cuò)問(wèn)題還很不好找,只有在調(diào)用到的地方才能暴露問(wèn)題。
解決辦法
編寫(xiě)代碼來(lái)生成代碼
因?yàn)閜rotoMap.js是根據(jù)proto的定義動(dòng)態(tài)變化的,我采取的辦法是通過(guò)一個(gè)程序去分析proto文件生成protoMap代碼。不過(guò)這里為了讓protoMap生成器不要太復(fù)雜,我在proto定義ActionCode時(shí)做了點(diǎn)小手腳
//proto中定義Action
enum ActionCode {
LOGIN: 1, //LoginReq;LoginRsp;
LOGOUT: 2, //LogoutReq;LogoutRsp;
}
在定義ActionCode時(shí),我們?yōu)槊恳粋€(gè)消息碼加上注釋?zhuān)谝粋€(gè)是請(qǐng)求,第二個(gè)是響應(yīng)。
如果在設(shè)計(jì)協(xié)議時(shí),能有嚴(yán)格的規(guī)范可以將注釋寫(xiě)的簡(jiǎn)單些。
enum ActionCode {
LOGIN: 1, //Login
LOGOUT: 2, //Logout
}
通過(guò)在ActionCode中加點(diǎn)小手腳,再去解析這段文本,生成protoMap會(huì)簡(jiǎn)單很多了。在protoMap生成器中,可以去校驗(yàn)一下注釋中寫(xiě)的請(qǐng)求、響應(yīng)對(duì)象是否正確。
還有一種方案是在請(qǐng)求協(xié)議上添加注釋?zhuān)?/p>
//action:1
message LoginReq {
...
}
//action:2
message LogoutReq {
...
}
這種方案我也在項(xiàng)目中使用過(guò),也可以方便提取生成protoMap。
五、最后的痛
關(guān)于protobuf在js中還剩下最后一個(gè)痛,那就是目前的IDE都不能支持proto對(duì)象屬性的
自動(dòng)補(bǔ)全,代碼提示,文本著色
let req = pb.newReq(pb.ActionCode.LOGIN);
req.useName = 'zxh'; //這里應(yīng)該是userName被寫(xiě)成useName
req.pwd = '123456'; //這里應(yīng)該是password被寫(xiě)成pwd
痛點(diǎn)分析
1.js中沒(méi)有代碼提示容易筆誤,而且問(wèn)題大多數(shù)在運(yùn)行到代碼那一刻才會(huì)暴露出來(lái)。
2.沒(méi)有自動(dòng)補(bǔ)全需要多打很多字。
3.沒(méi)有函數(shù)著色,敲出來(lái)的代碼心里不踏實(shí)。

解決辦法
要解決這個(gè)問(wèn)題我目前的辦法是,將proto對(duì)象生成對(duì)應(yīng)的js代碼,如果還想做的更好,可以學(xué)習(xí)Creator那樣,生成一個(gè)d.ts文件。
六、覺(jué)知你心中的痛
在開(kāi)發(fā)中不能覺(jué)知到開(kāi)發(fā)體驗(yàn),估計(jì)也很難覺(jué)知到用戶(hù)體驗(yàn),因?yàn)樽约壕褪亲约喉?xiàng)目的用戶(hù)。不能覺(jué)知到痛,如何去解決痛?

