天天看點

在cocos creator中使用protobufjs(三)一、proto檔案的加載問題二、proto對象的執行個體化問題三、proto對象的反序列化問題四、protoMap如何而來?五、最後的痛六、覺知你心中的痛

在cocos creator中使用protobufjs(一)

在cocos creator中使用protobufjs(二)

通過前面兩篇我們探索了如何在creator中使用protobuf,并且讓其能正常工作在浏覽器、JSB上,最後聊到protobuf在js項目中使用上的一些痛點。這篇博文我要把這些痛點一條一條地扳開,分析為什麼它讓我痛,以及我的治療方案。

在cocos creator中使用protobufjs(三)一、proto檔案的加載問題二、proto對象的執行個體化問題三、proto對象的反序列化問題四、protoMap如何而來?五、最後的痛六、覺知你心中的痛

一、proto檔案的加載問題

我遇到的第一個痛點就是proto檔案的加載問題。有人可能會問,前面不是講了怎麼加載方法很簡單的:

...
let builder = new protobuf.Builder();
protobuf.loadProtoFile('aaa.proto', builder);
protobuf.loadProtoFile('bbb.proto', builder);
...
           

protobufjs是一個很優秀的庫,他提供的loadProtoFile接口簡單直接,但是在真實的項目開發中會像是上面這樣的嗎?proto檔案是一開始就設計好了,固定不變的嗎?檔案名會修改嗎?檔案會新增、删除嗎?

痛點分析

我隻有第一天在cocos-js項目中使用proto時是将一個一個的proto檔案名寫死在loadProtoFile的參數中的,因為那是我中途參與的項目,當時我就發現了問題:

1. 路徑名、檔案較長容易寫錯字。

2. 項目開發中協定會不斷新增,會寫漏,少加載了proto檔案。

3. 某些原因會修改proto檔案名,原來加載的沒及時修改,加載時會出錯。

4. 人工手寫這個加載檔案會很累,效率低下,容易出錯,在檔案衆多的情況下極度消耗腦細胞。

在cocos creator中使用protobufjs(三)一、proto檔案的加載問題二、proto對象的執行個體化問題三、proto對象的反序列化問題四、protoMap如何而來?五、最後的痛六、覺知你心中的痛

解決辦法

編寫代碼來生成代碼

我的解決辦法是編寫一個程式,掃描proto檔案目錄,生成一個檔案清單的數組,進而完全解放人工操作。

//protoFiles.js 用腳本自動生成的檔案
module.exports = [
  res/proto/aaa.proto,
  res/proto/bbb.proto,
  res/proto/zzz.proto,
  res/proto/login/xxx.proto
  ...
]

//pbhelper.js 編寫一個加載器
let protoFiles = require('protoFiles'); //導入自動生成的proto檔案清單
...
loadProtoFile() {
    let builder = new protobuf.Builder();
    //周遊檔案名,逐一加載
    protoFiles.forEach((protoFile) => {
        protobuf.loadProtoFile(protoFile, builder);
    })
    ...
}
           

從此再也不用擔心proto檔案加載方面的問題了。

解放更多人工操作

在編寫proto掃描腳本的同時,還可以将proto檔案同步到自己的工程目錄中,以解決proto檔案的手工複制粘貼問題,如果你還要更進一步,還可以将svn/git的拉取給做了。

總結一下腳本要做的事:

1.從svn或git擷取最新的proto檔案(svn: svn up, git: git pull origin master)

2.将proto檔案同步到工程目錄

3.掃描工程目錄中的proto檔案,生成一個檔案清單數組

Creator中的新發現

最早在Creator中使用proto時我也是使用的上面的方法,但随着對Creator的了解越來越多,我就在想,Creator不是管理了我們所有的資源了嗎?cc.loader.loadResDir不是要以加載一個目錄下的所有資源,是否可以有更簡單的辦法?于是我嘗試着去調試loadResDir函數有驚喜發現。

let files = [];
//xxx是assets/resources目錄下的一個目錄名
cc.loader._resources.getUuidArray('xxx', null, files);
//files會得到所有的檔案名
cc.log(files);
           

通過這個發現,可以省去生成protoFiles.js的工作了。

二、proto對象的執行個體化問題

proto對象的執行個體化是一個痛點,估計很多人會覺得有點小題大作。protobufjs不是提供了操作方法嗎,那麼簡單:

//執行個體化登入請求
let loginReq = new pb.LoginRep();
loginReq.account = 'zxh';
loginReq.password = '123456';
//假如net是封裝好了的網絡子產品
net.send(pb.ActionCode.LOGIN, loginRsp, (data) => {
    //收到資料,反序列化
    let loginRsp = pb.LoginRsp.decode(data);
    ...
});
           

如果是做過網絡開發的應該對上面的代碼不難了解,這裡還是簡單的解釋一下:

1.xxxRep是用戶端請求消息,xxxRsp 是伺服器響應消息,成對的設計請求、響應協定比較好管理。

2.pb.ActionCode.LOGIN是一個常量定義,是設計的請求操作碼,用于伺服器識别你發的消息是登入請求,而不是其它,不然序列化後的二進制内容伺服器無法反序列化。

3.這裡沒有出現用戶端proto對象的序列化操作,因為可以封裝到net.send函數中,是以它不足以成為一個痛點。

4.net.send中的回調函數是用戶端響應處理函數,通過參數獲得伺服器發送的資料,因為二進制資料,是以需要用pb.LoginRsp.decode(data)進行反序列化。

痛點分析

let loginReq = new pb.LoginRep();
  1. 在js中使用proto有個特點,proto對象一般IDE都沒有代碼提示和着色,在用調用proto對象解碼時輸入效率低下,還容易打錯。
  2. 這句代碼暴露了協定細節,如果pb.LoginRep改名了也不知道,代碼會報錯。
  3. net.send(pb.ActionCode.LOGIN, loginReq, () => { }) 明明已經是發送的登入消息了,為什麼還需要一個操作碼呢?感覺有些累贅、重複。

解決辦法

工廠模式

如果能像下面一樣是不是會更清爽:

//使用工廠函數獲得LoginReq對象
let req = pb.newReq(pb.ActionCode.LOGIN);
req.account = 'zxh';
req.password = '123456';
//在工廠函數時做個小動作:req.action = pb.ActionCode.LOGIN
//send時就不需要消息号參數了。
net.send(req, ...);
           

通過pb.newReq隐藏協定細節,也不需要管消息的名字,用的什麼protobuf庫,傳回的req上綁定上action消息号減少調用send時的重複參數,上層操作簡單明了。

除了設計工廠函數外,還需要定義pb.ActionCode.LOGIN,讓它能被IDE自動提示、代碼補全,文本着色,我們會省心很多。

在cocos creator中使用protobufjs(三)一、proto檔案的加載問題二、proto對象的執行個體化問題三、proto對象的反序列化問題四、protoMap如何而來?五、最後的痛六、覺知你心中的痛

三、proto對象的反序列化問題

我們再看下反序列化的場景

...
//發送資料,net假如是封裝好了的網絡子產品
net.send(pb.ActionCode.LOGIN, loginReq, (data) => {
    //發送的是登入請求,反序列化時要用登入響應,不然會失敗
    let loginRsp = pb.LoginRsp.decode(data);
    ...
});
           

痛點分析

反序列化成為痛點有部分原因與執行個體化相同,而且當你收到一個響應時,該用那個proto對象去反序列化會殺死不少腦細包,特别是在設計協定消息名字時不注意規範時更容易出錯。

解決辦法

1.設計通信協定頭

2.請求\響應唯一序列号

3.工廠模式

在cocos creator中使用protobufjs(三)一、proto檔案的加載問題二、proto對象的執行個體化問題三、proto對象的反序列化問題四、protoMap如何而來?五、最後的痛六、覺知你心中的痛

通信協定頭是用戶端、伺服器在收到二進制資料時,可以使用一個固定的協定結構去反序列也稱之為解碼。 解碼後可以獲得基本的資料,比如路由号、時間戳、使用者ID、下層協定資料(二進制)等,大概如下:

message PBMessage{
    int32 action = ;     //消息号用于指明data字段(辨別下層協定類型)
    int32 sequence = ;   //請求序列
    uint64 timestamp = ; //時間戳
    int32 userID = ;     //帳号
    bytes data = ;       //請求或響應資料(序列化後的二進制資料)
}
           

其中的sequence字段是用戶端向伺服器發出一個請求時,生成的唯一ID。當伺服器響應你這個請求時,傳回這個sequence,通過這個sequence + action你就能确定你的響應消息對象,進而正确解碼。

//收到網絡資料
message(event) {
    var pbMessage = pb.PBMessage.decode(event.data);
    //從緩存對象中取出請求時的參數對象
    var obj = this.cache[pbMessage.sequence];
    //删除緩存資料
    delete this.cache[pbMessage.sequence];
    try{
        //檢測緩存資料是否存在
        if (!obj) {
            return;
        }
        //使用工廠建立響應對象
        let rsp = pb.newRsp(obj.action, obj.data);
        //調用請求時的回函數 
        obj.callback(rsp);
    }catch(e) {
      cc.log('處理響應錯誤');
    }        
}
           
  1. cache是緩存net.send時的參數包括:action、sequence、callback,其中sequence是自動生成的并以它為key。
  2. 當收到伺服器資料時,先解碼PBMessage,用解碼後的sequence去查找出action。
  3. 使用action和data做為響應工廠函數的參數,反序列化出響應對象。
  4. 調用響應處理函數。

這時響應函數就可以很輕松的處理業務了

//發送資料,net假如是封裝好了的網絡子產品
net.send(loginReq, (loginRsp) => {
    //直接通路響應對象,不需去解碼了
    this.label.string = loginRsp.player.name;
    ...
});
           

核心問題

不論是解決執行個體化還是反序列化,最核心的問題是實作那兩個工廠函數

let req = newReq(action);

let rsp = newRsp(action, data);

而實作這兩個工廠函數的前提是明确請求操作碼、請求對象、響應對象,需要建立一個映射表,類似下面的定義

//proto中定義Action
enum ActionCode {
  LOGIN: ,
  LOGOUT: ,    
}

//protoMap.js檔案
protoMap = {
    : {
        req: pb.LoginRes,
        rsp: pb.LoginRsp,
    }
    ...
}
           

有了protoMap工廠函數就簡單了

//工廠函數
let protoMap = require('protoMap');
//請求工廠函數
newReq(action) {
  let obj = protoMap[action];
  let req = new obj.req();
  req.action = action;
  return req;
}

//響應工廠函數
newRsp(action, data) {
  let obj = protoMap[action];
  return obj.rsp.decode(data);
}
           

四、protoMap如何而來?

我們的問題是不是都解決呢?如果你覺得都解決了,那是高興的太早了。

目前protoMap.js檔案是需要人手工去編寫的,同樣的問題又來了。

痛點分析

1 一個項目與伺服器的請求少則幾十個,多則上百上千,手工方式維護protoMap的難度大。

2.手工編寫這個protoMap.js檔案在協定新增、修改、删除時容易出錯。

3.出了錯問題還很不好找,隻有在調用到的地方才能暴露問題。

解決辦法

編寫代碼來生成代碼

因為protoMap.js是根據proto的定義動态變化的,我采取的辦法是通過一個程式去分析proto檔案生成protoMap代碼。不過這裡為了讓protoMap生成器不要太複雜,我在proto定義ActionCode時做了點小手腳

//proto中定義Action
enum ActionCode {
  LOGIN: ,  //LoginReq;LoginRsp;
  LOGOUT: , //LogoutReq;LogoutRsp;   
}
           

在定義ActionCode時,我們為每一個消息碼加上注釋,第一個是請求,第二個是響應。

如果在設計協定時,能有嚴格的規範可以将注釋寫的簡單些。

enum ActionCode {
  LOGIN: ,  //Login
  LOGOUT: , //Logout
}
           

通過在ActionCode中加點小手腳,再去解析這段文本,生成protoMap會簡單很多了。在protoMap生成器中,可以去校驗一下注釋中寫的請求、響應對象是否正确。

還有一種方案是在請求協定上添加注釋:

//action:
message LoginReq {
    ...
}

//action:
message LogoutReq {
  ...
}
           

這種方案我也在項目中使用過,也可以友善提取生成protoMap。

五、最後的痛

關于protobuf在js中還剩下最後一個痛,那就是目前的IDE都不能支援proto對象屬性的

自動補全,代碼提示,文本着色
let req = pb.newReq(pb.ActionCode.LOGIN);
req.useName = 'zxh'; //這裡應該是userName被寫成useName
req.pwd = '123456';  //這裡應該是password被寫成pwd
           

痛點分析

1.js中沒有代碼提示容易筆誤,而且問題大多數在運作到代碼那一刻才會暴露出來。

2.沒有自動補全需要多打很多字。

3.沒有函數着色,敲出來的代碼心裡不踏實。

在cocos creator中使用protobufjs(三)一、proto檔案的加載問題二、proto對象的執行個體化問題三、proto對象的反序列化問題四、protoMap如何而來?五、最後的痛六、覺知你心中的痛

解決辦法

要解決這個問題我目前的辦法是,将proto對象生成對應的js代碼,如果還想做的更好,可以學習Creator那樣,生成一個d.ts檔案。

六、覺知你心中的痛

在開發中不能覺知到開發體驗,估計也很難覺知到使用者體驗,因為自己就是自己項目的使用者。不能覺知到痛,如何去解決痛?

在cocos creator中使用protobufjs(三)一、proto檔案的加載問題二、proto對象的執行個體化問題三、proto對象的反序列化問題四、protoMap如何而來?五、最後的痛六、覺知你心中的痛