在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();
- 在js中使用proto有個特點,proto物件一般IDE都沒有程式碼提示和著色,在用呼叫proto物件解碼時輸入效率低下,還容易打錯。
- 這句程式碼暴露了協議細節,如果pb.LoginRep改名了也不知道,程式碼會報錯。
- 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.工廠模式
通訊協議頭是客戶端、伺服器在收到二進位制資料時,可以使用一個固定的協議結構去反序列也稱之為解碼。 解碼後可以獲得基本的資料,比如路由號、時間戳、使用者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('處理響應錯誤');
}
}
- cache是快取net.send時的引數包括:action、sequence、callback,其中sequence是自動生成的並以它為key。
- 當收到伺服器資料時,先解碼PBMessage,用解碼後的sequence去查找出action。
- 使用action和data做為響應工廠函式的引數,反序列化出響應物件。
- 呼叫響應處理函式。
這時響應函式就可以很輕鬆的處理業務了
//傳送資料,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檔案。
六、覺知你心中的痛
在開發中不能覺知到開發體驗,估計也很難覺知到使用者體驗,因為自己就是自己專案的使用者。不能覺知到痛,如何去解決痛?