在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 = ; //消息号用于指明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('處理響應錯誤');
}
}
- 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: ,
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.沒有函數着色,敲出來的代碼心裡不踏實。
解決辦法
要解決這個問題我目前的辦法是,将proto對象生成對應的js代碼,如果還想做的更好,可以學習Creator那樣,生成一個d.ts檔案。
六、覺知你心中的痛
在開發中不能覺知到開發體驗,估計也很難覺知到使用者體驗,因為自己就是自己項目的使用者。不能覺知到痛,如何去解決痛?