1. 程式人生 > >在cocos creator中使用protobufjs(三)

在cocos creator中使用protobufjs(三)

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

一、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. 人工手寫這個載入檔案會很累,效率低下,容易出錯,在檔案眾多的情況下極度消耗腦細胞。

解決辦法

編寫程式碼來生成程式碼

我的解決辦法是編寫一個程式,掃描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自動提示、程式碼補全,文字著色,我們會省心很多。
利用工廠模式隱藏實現細節

三、proto物件的反序列化問題

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

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

痛點分析

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

解決辦法

1.設計通訊協議頭
2.請求\響應唯一序列號
3.工廠模式

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

message PBMessage{
    int32 action = 1;     //訊息號用於指明data欄位(標識下層協議型別)
    int32 sequence = 2;   //請求序列
    uint64 timestamp = 3; //時間戳
    int32 userID = 4;     //帳號
    bytes data = 5;       //請求或響應資料(序列化後的二進位制資料)
}

其中的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: 1,
  LOGOUT: 2,    
}

//protoMap.js檔案
protoMap = {
    1: {
        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: 1,  //LoginReq;LoginRsp;
  LOGOUT: 2, //LogoutReq;LogoutRsp;   
}

在定義ActionCode時,我們為每一個訊息碼加上註釋,第一個是請求,第二個是響應。
如果在設計協議時,能有嚴格的規範可以將註釋寫的簡單些。

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

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

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

//action:1
message LoginReq {
    ...
}

//action:2
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.沒有函式著色,敲出來的程式碼心裡不踏實。

程式碼提示、自動補全可以提高開發效率、減少出錯

解決辦法

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

六、覺知你心中的痛

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