當(dāng)creator遇上protobufjs—效率

在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)題:

  1. 路徑名、文件較長(zhǎng)容易寫(xiě)錯(cuò)字。
  2. 項(xiàng)目開(kāi)發(fā)中協(xié)議會(huì)不斷新增,會(huì)寫(xiě)漏,少加載了proto文件。
  3. 某些原因會(huì)修改proto文件名,原來(lái)加載的沒(méi)及時(shí)修改,加載時(shí)會(huì)出錯(cuò)。
  4. 人工手寫(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();

  1. 在js中使用proto有個(gè)特點(diǎn),proto對(duì)象一般IDE都沒(méi)有代碼提示和著色,在用調(diào)用proto對(duì)象解碼時(shí)輸入效率低下,還容易打錯(cuò)。
  2. 這句代碼暴露了協(xié)議細(xì)節(jié),如果pb.LoginRep改名了也不知道,代碼會(huì)報(bào)錯(cuò)。
  3. 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ì)省心很多。

利用工廠模式隱藏實(shí)現(xiàn)細(xì)節(jié)
利用工廠模式隱藏實(shí)現(xiàn)細(xì)節(jié)

三、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.工廠模式

tcp協(xié)議頭

通信協(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ò)誤');
    }        
}
  1. cache是緩存net.send時(shí)的參數(shù)包括:action、sequence、callback,其中sequence是自動(dòng)生成的并以它為key。
  2. 當(dāng)收到服務(wù)器數(shù)據(jù)時(shí),先解碼PBMessage,用解碼后的sequence去查找出action。
  3. 使用action和data做為響應(yīng)工廠函數(shù)的參數(shù),反序列化出響應(yīng)對(duì)象。
  4. 調(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í)。

代碼提示、自動(dòng)補(bǔ)全可以提高開(kāi)發(fā)效率、減少出錯(cuò)

解決辦法

要解決這個(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é)知到痛,如何去解決痛?


最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • Spring Cloud為開(kāi)發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見(jiàn)模式的工具(例如配置管理,服務(wù)發(fā)現(xiàn),斷路器,智...
    卡卡羅2017閱讀 136,506評(píng)論 19 139
  • Android 自定義View的各種姿勢(shì)1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 178,781評(píng)論 25 709
  • 一、 不修改源碼讓protobufjs適應(yīng)多平臺(tái) 我們上一篇《在cocos creator中使用protobufj...
    張曉衡閱讀 6,441評(píng)論 3 8
  • 由于工程項(xiàng)目中擬采用一種簡(jiǎn)便高效的數(shù)據(jù)交換格式,百度了一下發(fā)現(xiàn)除了采用 xml、JSON 還有 ProtoBuf(...
    黃海佳閱讀 49,146評(píng)論 1 23
  • 當(dāng)孤單已經(jīng)變成一種習(xí)慣,習(xí)慣到我已經(jīng)不再去想該怎么辦,就算心煩意亂就算沒(méi)有人作伴,自由和落寞之間怎么換算。 今天偶...
    八度黑白閱讀 648評(píng)論 19 20

友情鏈接更多精彩內(nèi)容