目錄 前言&介紹 安裝Pomelo 創建項目並啟動 創建項目 項目結構說明 啟動 測試連接 聊天伺服器 新建gate和chat伺服器 配置master.json 配置servers.json 配置adminServer.json 解決伺服器分配問題 實現gate.gateHandler.queryE ...
目錄
前言&介紹
Pomelo:一個快速、可擴展、Node.js分散式游戲伺服器框架
從三四年前接觸Node.js開始就接觸到了Pomelo,從Pomelo最初的版本到現在,總的來說網易出品還算不錯,但是發展不算快;用它做過一些項目和小游戲表現還不錯。
用它的主要好處:
1. 入門簡單,有比較豐富的文檔和示例(雖然現在看版本也比較老了,但是入門沒什麼問題)
2.分散式多進程且擴展簡單(單進程多線程,每個伺服器都是一個Node進程,通過配置文件就可以管理集群)
3.可以不去關註底層和網路相關邏輯,聚焦業務邏輯的處理,對於有Web伺服器開發經驗卻沒有游戲伺服器開發經驗來說還是比較友好的
4.提供了很多工具和客戶端支持(像IOS、Android & Java、Javascript、C、Cocos2d-x、U3D等)
......
入門參考鏈接
https://github.com/NetEase/pomelo/wiki/Home-in-Chinese
其它鏈接:
https://github.com/NetEase/pomelo
https://www.npmjs.com/package/pomelo
安裝Pomelo
安裝要求
Windows下安裝要求環境
Python (2.5 < 版本 < 3)
VC++編譯器
PS: Windows新環境自已檢查一下,我本機環境已經裝好python2.7,Visaul Studio也安裝了所以也有VC++編譯器
其它操作系統應該問題不大
官方安裝介紹文檔:https://github.com/NetEase/pomelo/wiki/%E5%AE%89%E8%A3%85pomelo
全局安裝Pomelo
npm install pomelo -g
安裝成功後如下圖,可以看到現在最新版本為2.2.5
說明:Pomelo光是安裝可能出現各種失敗
1. 回頭去檢查一下,Python和VC++編輯器是否有問題
2.如果以前全局安裝過Pomelo,最好刪除掉 “C:\Users\當前用戶\AppData\Roaming\npm\node_modules”目錄下Pomelo文件夾和“C:\Users\當前用戶\AppData\Roaming\npm-cache”目錄下Pomelo開頭的文件夾
3.如果並不報錯,npm卡住不動,多數是網路原因,重覆多安幾次;或者打開FQ工具試試;也可以用淘寶鏡像 cnpm 安裝
創建項目並啟動
pomelo init 項目名
執行創建項目命令後,出現如下圖選擇項(Please select underly connector, 1 for websocket(native socket), 2 for socket.io, 3 for wss, 4 for socket.io(wss), 5 for udp, 6 for mqtt: [1])
這是讓你選擇connector的協議,除了5 for udp,其它都是長連接,我們接下來選擇 2 for socket.io
在上圖cmd中輸入2,並回車,選擇socket.io繼續安裝
這裡connector協議可以通過app.js配置進行修改// app configuration
app.configure('production|development', 'connector', function(){ app.set('connectorConfig', { connector : pomelo.connectors.sioconnector,
...
}); });
成功後,轉到項目根目錄,執行安裝項目執行 npm-install.bat 依賴項 (其它平臺執行npm-install.sh)
cd 項目目錄
npm-install.bat
項目創建完成後,目錄如下圖
game-server : 游戲伺服器,所有游戲伺服器功能和邏輯都在此目錄下
game-server/app.js:入口文件
game-server/app: 存放游戲邏輯和功能相關代碼都這個子目錄下,servers目錄下可以新建多個目錄來創建不同類型的伺服器,在pomelo中,使用路徑來區分伺服器類型
game-server/config:存放游戲伺服器配置文件目錄,像日誌、伺服器、資料庫等幾乎所有配置文件都可以存放到此目錄下
game-server/logs:日誌目錄,存放游戲伺服器所有日誌文件
web-server: web伺服器(如果你是個H5游戲,這裡就是Web客戶端,如果是IOS、Andriod客戶端,這目錄就沒什麼用)
shared:公共代碼存放處,這裡要以放一些共用代碼
啟動game-server
cd game-server
pomelo start
啟動命令執行成功後,出現如下圖錯誤提示
[2017-11-23 11:54:42.226] [ERROR] console - Option path is not valid. Please refer to the README. [2017-11-23 11:54:42.226] [ERROR] console - Option close timeout is not valid. Please refer to the README. [2017-11-23 11:54:42.226] [ERROR] console - Option heartbeats is not valid. Please refer to the README. [2017-11-23 11:54:42.226] [ERROR] console - Option log level is not valid. Please refer to the README.
問題原因和解決方式
原因:新版的socket.io用法不正確的導致的,官方早已修複,就是沒有publish到npm包中
修複方式:把node_modules目錄下的pomelo中sioconnector.js(../game-server/node_modules/pomelo/lib/connectors/sioconnector.js)
替換為 https://github.com/NetEase/pomelo/blob/master/lib/connectors/sioconnector.js
替換後再啟動game-server,就沒有這些錯誤提示了^_^!
1.啟動web-server
cd web-server
node app
啟動後如下圖
會發些有一些提示,這是express寫法問題,可以打開web-server根目錄下app.js,按如下修改
//var app = express.createServer(); 註釋掉這一行代碼,替換為下麵這一行代碼 var app = express();
再啟動時無express用法提示^_^!
2.打開http://localhost:3001
如上圖,點擊“Test Game Server”按鈕,提示“game server is ok.”,則此項目game-server可用^_^!
聊天伺服器
上面大體瞭解了pomelo,要入門還是以一個聊天伺服器為入門示例最好,其它邏輯相對簡單,入門學習不會因其它游戲邏輯影響。
官方有個非常好的示例:https://github.com/NetEase/chatofpomelo 官方也有很多說明
網上也有很多文章分析講解這項目,我就不完全解釋些項目了,接下來我就在上面新建的好的“PomeloDemo”的基礎上改成一個聊天伺服器
在app/servers目錄下新建gate和chat伺服器,新建好後目錄如下
gate伺服器:
在一般情況下用戶量一臺機器就可以支撐,但用戶量多了就得擴充伺服器,gate伺服器的作用就相當於前端負載均衡伺服器;
客戶端向gate伺服器發出請求,gate伺服器會給客戶端分配一個connector伺服器;
分配策略是根據客戶端的某一個key做hash得到connector的id,這樣就可以實現各個connector伺服器的負載均衡。這個一會兒會實現
connector伺服器:
接受客戶端請求,並將其路由到chat
伺服器,以及維護客戶端的鏈接;
同時,接收客戶端對後端伺服器的請求,按照用戶配置的路由策略,將請求路由給具體的後端伺服器。當後端伺服器處理完請求或者需要給客戶端推送消息的時候,connector伺服器同樣會扮演一個中間角色,完成對客戶端的消息發送;
connector伺服器會同時擁有clientPort和port,其中clientPort用來監聽客戶端的連接,port埠用來給後端提供服務;
chat伺服器:
handler和remote決定了伺服器的行為;
handler接收用戶發送過來的send請求,remote由connector RPC發起遠程調用時調用;
在remote里由於涉及到用戶的加入和退出,所以會有對channel的操作。
其實也可以提前瞭解一些Pomelo中的術語,不分別解釋,可以提前看看:https://github.com/NetEase/pomelo/wiki/%E6%9C%AF%E8%AF%AD%E8%A7%A3%E9%87%8A
{
"development": {
"id": "master-server-1", "host": "127.0.0.1", "port": 15005
},
"production": {
"id": "master-server-1", "host": "127.0.0.1", "port": 15005
}
}
master.json
打開config目錄下servers.json文件,配置好各種 type 的伺服器,配置如下
{
"development":{
"connector":[
{"id":"connector-server-1", "host":"127.0.0.1", "port":14050, "clientPort": 13050, "frontend": true},
{"id":"connector-server-2", "host":"127.0.0.1", "port":14051, "clientPort": 13051, "frontend": true},
{"id":"connector-server-3", "host":"127.0.0.1", "port":14052, "clientPort": 13052, "frontend": true}
],
"chat":[
{"id":"chat-server-1", "host":"127.0.0.1", "port":16050},
{"id":"chat-server-2", "host":"127.0.0.1", "port":16051},
{"id":"chat-server-3", "host":"127.0.0.1", "port":16052}
],
"gate":[
{"id": "gate-server-1", "host": "127.0.0.1", "clientPort": 15014, "frontend": true}
]
},
"production":{
"connector":[
{"id":"connector-server-1", "host":"127.0.0.1", "port":14050, "clientPort": 13050, "frontend": true},
{"id":"connector-server-2", "host":"127.0.0.1", "port":14051, "clientPort": 13051, "frontend": true},
{"id":"connector-server-3", "host":"127.0.0.1", "port":14052, "clientPort": 13052, "frontend": true}
],
"chat":[
{"id":"chat-server-1", "host":"127.0.0.1", "port":16050},
{"id":"chat-server-2", "host":"127.0.0.1", "port":16051},
{"id":"chat-server-3", "host":"127.0.0.1", "port":16052}
],
"gate":[
{"id": "gate-server-1", "host": "127.0.0.1", "clientPort": 15014, "frontend": true}
]
}
}
servers.json
解釋一下配置中的各欄位:
id: 字元串類型的應用伺服器ID
host:應用伺服器的IP或者功能變數名稱
port:RPC請求監聽的埠
clientPort: 前端伺服器的客戶端請求的監聽埠
frontend:bool類型,是否是前端伺服器,預設: false
可選參數:
max-connections:前端伺服器最大客戶連接數
args: node/v8
配置,如配置為"args": "--debug=5858 "
這樣就可以啟用項目調試(沒用過,臨時問了一下谷歌,看別人是這麼解釋的^_^!)
打開config目錄下adminServer.json文件,配置好各種 type 的伺服器,配置如下
[{
"type": "connector",
"token": "agarxhqb98rpajloaxn34ga8xrunpagkjwlaw3ruxnpaagl29w4rxn"
}, {
"type": "chat",
"token": "agarxhqb98rpajloaxn34ga8xrunpagkjwlaw3ruxnpaagl29w4rxn"
},{
"type": "gate",
"token": "agarxhqb98rpajloaxn34ga8xrunpagkjwlaw3ruxnpaagl29w4rxn"
}]
adminServer.json
它有什麼作用,可以看一下以下2個鏈接
http://blog.csdn.net/nynyvkhhiiii/article/details/49249915
https://github.com/NetEase/pomelo-admin#server-master-auth
從上面的servers.json配置的修改可以看出與最開始創建出來的項目一個伺服器相比,connector和chat我都配置了三個伺服器
這就要解決客戶端請求伺服器分配問題
解決思路:用戶訪問gate伺服器,使用用戶的uid的crc32的校驗碼與connector伺服器的個數取餘,從而得到一個connector伺服器,把這個connector伺服器分配給請求用戶
在app目錄下新建util目錄,目錄下新建“dispatcher.js”和 “routeUtil.js”文件,處理此伺服器分配邏輯
var crc = require('crc'); module.exports.dispatch = function(uid, connectors) { var index = Math.abs(crc.crc32(uid)) % connectors.length; return connectors[index]; };dispatcher.js
var exp = module.exports; var dispatcher = require('./dispatcher'); exp.chat = function(session, msg, app, cb) { var chatServers = app.getServersByType('chat'); if(!chatServers || chatServers.length === 0) { cb(new Error('can not find chat servers.')); return; } var res = dispatcher.dispatch(session.get('rid'), chatServers); cb(null, res.id); };routeUtil.js
準備好這些文件後,在game-server伺服器入口文件app.js中添加配配置
var pomelo = require('pomelo'); var routeUtil = require('./app/util/routeUtil'); /** * Init app for client. */ var app = pomelo.createApp(); app.set('name', 'PomeloDemo'); // app configuration // app.configure('production|development', 'connector', function(){ app.configure('production|development', function(){ // route configures app.route('chat', routeUtil.chat); app.set('connectorConfig', { connector : pomelo.connectors.sioconnector, // 'websocket', 'polling-xhr', 'polling-jsonp', 'polling' transports : ['websocket', 'polling'], heartbeats : true, closeTimeout : 60 * 1000, heartbeatTimeout : 60 * 1000, heartbeatInterval : 25 * 1000 }); // filter configures app.filter(pomelo.timeout()); }); // start app app.start(); process.on('uncaughtException', function (err) { console.error(' Caught exception: ' + err.stack); });app.filter(pomelo.timeout()); 過濾器,pomelo內置了一些過濾器,可以自行去瞭解一下,也可以根據自已的需求去自定義!
註意:
app.configure('production|development', 'connector', function(){
修改為
app.configure('production|development', function(){
這個如果不修改,在啟動調用時會遇到 engine.io 中報錯 TypeError: Cannot read property 'indexOf' of undefined at Server.verify !
6.實現 gate.gateHandler.queryEntry
作用:用戶連接gate伺服器,返回分配的connector
在gate目錄下handler下新建gateHandler.js,代碼如下
var dispatcher = require('../../../util/dispatcher'); module.exports = function(app) { return new Handler(app); }; var Handler = function(app) { this.app = app; }; var handler = Handler.prototype; /** * Gate handler that dispatch user to connectors. * * @param {Object} msg message from client * @param {Object} session * @param {Function} next next stemp callback * */ handler.queryEntry = function(msg, session, next) { var uid = msg.uid; if(!uid) { next(null, { code: 500 }); return; } // get all connectors var connectors = this.app.getServersByType('connector'); if(!connectors || connectors.length === 0) { next(null, { code: 500 }); return; } // select connector var res = dispatcher.dispatch(uid, connectors); next(null, { code: 200, host: res.host, port: res.clientPort }); };gateHandler.js
chat伺服器會接受connector的遠程調用,完成channel維護中的用戶的加入以及離開
module.exports = function(app) { return new ChatRemote(app); }; var ChatRemote = function(app) { this.app = app; this.channelService = app.get('channelService'); }; /** * Add user into chat channel. * * @param {String} uid unique id for user * @param {String} sid server id * @param {String} name channel name * @param {boolean} flag channel parameter * */ ChatRemote.prototype.add = function(uid, sid, name, flag, cb) { var channel = this.channelService.getChannel(name, flag); var username = uid.split('*')[0]; var param = { route: 'onAdd', user: username }; channel.pushMessage(param); if( !! channel) { channel.add(uid, sid); } cb(this.get(name, flag)); }; /** * Get user from chat channel. * * @param {Object} opts parameters for request * @param {String} name channel name * @param {boolean} flag channel parameter * @return {Array} users uids in channel * */ ChatRemote.prototype.get = function(name, flag) { var users = []; var channel = this.channelService.getChannel(name, flag); if( !! channel) { users = channel.getMembers(); } for(var i = 0; i < users.length; i++) { users[i] = users[i].split('*')[0]; } return users; }; /** * Kick user out chat channel. * * @param {String} uid unique id for user * @param {String} sid server id * @param {String} name channel name * */ ChatRemote.prototype.kick = function(uid, sid, name, cb) { var channel = this.channelService.getChannel(name, false); // leave channel if( !! channel) { channel.leave(uid, sid); } var username = uid.split('*')[0]; var param = { route: 'onLeave', user: username }; channel.pushMessage(param); cb(); };chatRemote.js
可以看到上面代碼中的add和kick分別對應著加入和離開channel
chat伺服器執行聊天邏輯,維護channel信息,一個房間就是一個channel,一個channel里有多個用戶,當有用戶發起聊天的時候,就會將其內容廣播到整個channel。
var chatRemote = require('../remote/chatRemote'); module.exports = function(app) { return new Handler(app); }; var Handler = function(app) { this.app = app; }; var handler = Handler.prototype; /** * Send messages to users * * @param {Object} msg message from client * @param {Object} session * @param {Function} next next stemp callback * */ handler.send = function(msg, session, next) { var rid = session.get('rid'); var username = session.uid.split('*')[0]; var channelService = this.app.get('channelService'); var param = { route: 'onChat', msg: msg.content, from: username, target: msg.target }; channel = channelService.getChannel(rid, false); //the target is all users if(msg.target == '*') { channel.pushMessage(param); } //the target is specific user else { var tuid = msg.target + '*' + rid; var tsid = channel.getMember(tuid)['sid']; channelService.pushMessageByUids(param, [{ uid: tuid, sid: tsid }]); } next(null, { route: msg.route }); };chatHandler.js
這裡面是發送消息(給房間內所有人和指定用戶)
主要完成接受客戶端的請求,維護與客戶端的連接,路由客戶端的請求到chat伺服器;
module.exports = function(app) { return new Handler(app); }; var Handler = function(app) { this.app = app; }; var handler = Handler.prototype; /** * New client entry chat server. * * @param {Object} msg request message * @param {Object} session current session object * @param {Function} next next stemp callback * @return {Void} */ handler.enter = function(msg, session, next) { var self = this; var rid = msg.rid; var uid = msg.username + '*' + rid var sessionService = self.app.get('sessionService'); //duplicate log in if( !! sessionService.getByUid(uid)) { next(null, { code: 500, error: true }); return; } session.bind(uid); session.set('rid', rid); session.push('rid', function(err) { if(err) { console.error('set rid for session service failed! error is : %j', err.stack); } }); session.on('closed', onUserLeave.bind(null, self.app)); //put user into channel self.app.rpc.chat.chatRemote.add(session, uid, self.app.get('serverId'), rid, true, function(users){ next(null, { users:users }); }); }; /** * User log out handler * * @param {Object} app current application * @param {Object} session current session object * */ var onUserLeave = function(app, session) { if(!session || !session.uid) { return; } app.rpc.chat.chatRemote.kick(session, session.uid, app.get('serverId'), session.get('rid'), null); };entryHandler.js
這裡完成的主要就是RPC遠程調用chat伺服器chatRemote中的實現
到此這個聊天伺服器實現就完成, 打開命令行工具,執行沒有錯誤信息,基本就成功了!
cd game-server目錄
pomelo start
編寫web聊天客戶端測試
我就在web-server目錄中寫了個測試客戶端
把結構改了一下,換成了ejs模版,代碼如下
routes中index.js文件代碼
var express = require('express'); var router = express.Router(); router.get('/', function (req, res, next) { res.render('index', { title: 'Nodejs學習筆記(十六)--- Pomelo介紹&入門' }); }); module.exports = router;index.js
views中index.ejs文件代碼
<html> <head><title><%= title %></title></head> <body> <div id="tipMsg" style="color:red; height:30px;"></div> <div id="pnlLogin"> <h1>1.登錄(連接Gate伺服器)</h1> 用戶名:<input id="txtUserName" type="text" maxlength="20" ></br> 房間號:<input id="txtRoomId" type="text" maxlength="8" > <input id="btnLogin" type="button" value="點擊登錄" /> <br/> </div> <div id="pnlChat" style="display:none;"> <h1>2.聊天室</h1> 用戶名:<span id="spUserName" style="color:blue;padding-right:50px;"></span> 房間號:<span id="spRoomId" style="color:blue;padding-right:50px;"></span> <div id="txtMessage" style="width:800px; height:400px;border:1px solid #000; overflow-y:auto; overflow-x:hidden; "></div> <br/> 發送給:<select id="selUserList" style="width:200px;"> <option value="*">所有人</option> </select> <br/> <br/> <textarea id="txtSendMessage" type="text" style="width:690px;height:80px; overflow:auto;float:left;" ></textarea> <input id="btnSend" type="button" value="發送" style="margin-left:10px; height:80px; width:100px;float:left;" /> </div> </body> </html> <script src="js/socket.io.js"></script> <script src="js/pomeloclient.js"></script> <script src="js/jquery-1.11.2.min.js"></script> <script type="text/javascript"> $(function(){ var rid = ''; var uname = ''; //監聽"onAdd", 當有新用戶加入時觸發 pomelo.on('onAdd', function(data) { var user = data.user; $('#txtMessage').append('<span style="color:green;">[上線提醒]:歡迎 ' + user + ' 加入聊天室<span><br/>'); //添加到用戶列表 $('#selUserList').append('<option value="' + user + '">' + user + '</option>'); }); //監聽"onLeave", 當有用戶離開聊天室時觸發 pomelo.on('onLeave', function(data) { var user = data.user; $('#txtMessage').append('<span style="color:green;">[離線提醒]: ' + user + ' 離開聊天室<span><br/>'); //從用戶列表移除 $('#selUserList option[value="' + user + '"]').remove(); }); // 監聽"onChat", 接收消息 pomelo.on('onChat', function(data) { var from = data.from, target = data.target, msg = data.msg; if(msg === null) return; var name = (target == '*' ? '所有人' : target); var time = getNowFormatDate(); $("#txtMessage").append('<span style="color:blue;">[' + time +'][' + from + '] 對 [' + name + '] 說: ' + msg + '<span><br/>'); }); //當從聊天斷開時 pomelo.on('disconnect', function(reason) { $('#pnlLogin').show(); $('#pnlChat').hide(); }); //登錄 $('#btnLogin').on('click', function(){ var userNameReg = /^[a-zA-Z0-9]+$/, roomIdReg = /^[0-9]+$/; uname = $.trim($('#txtUserName').val()), rid = $.trim($('#txtRoomId').val()); if(uname.length == 0){ alert('請輸入登錄名'); return false; } if(!userNameReg.test(uname)) { alert('登錄名只能由字母或數字組成'); return false; } if(rid.length == 0){ alert('請輸入房間號'); return false; } if(!roomIdReg.test(rid)) { alert('房間號只能是數字'); return false; } queryEntry(uname, function(host, port){ $('#tipMsg').append('Gate 連接成功! host:' + host + ' port:' + port + '<br/>'); //連接聊天伺服器 pomelo.init({ host: host, port: port, log: true }, function() { var route = "connector.entryHandler.enter"; pomelo.request(route, { username: uname, rid: rid }, function(data) { if(data.error) { $('#tipMsg').append('Chat 連接失敗!<br/>'); return; } $('#tipMsg').append('Chat 連接成功!<br/>'); $('#pnlLogin').hide(); $('#pnlChat').show(); $('#spUserName').text(uname); $('#spRoomId').text(rid); //載入當前聊天室 已線上用戶列表 $.each(data.users, function(i, item){ if(item != uname){ $('#selUserList').append('<option value="' + item + '">' + item + '</option>'); } }); }); }); }); }); //發送消息 $('#btnSend').on('click', function(){ var route = "chat.chatHandler.send", target = $("#selUserList").val(), msg = $.trim($("#txtSendMessage").val()); if(msg.length == 0){ alert('不能發送空消息!'); return; } pomelo.request(route, { rid: rid, content: msg, from: uname, target: target }, function(data) { $("#txtSendMessage").val(''); if(from == uname) { var name = (target == '*' ? '所有人' : target); var time = getNowFormatDate(); $("#txtMessage").append('<span style="color:blue;">[' + time +'][' + from + '] 對 [' + name + '] 說: ' + msg + '<span><br/>'); } }); }); }) //連接Gate伺服器 function queryEntry(uid, callback) { var route = 'gate.gateHandler.queryEntry'; pomelo.init({ host: '127.0.0.1', port: 15014, log: true }, function() { pomelo.request(route, { uid: uid }, function(data) { pomelo.disconnect(); if(data.code === 500) { alert('用戶名在此房間中已存在,請重新輸入新的用戶名!'); return; } callback(data.host, data.port); }); }); }; function getNowFormatDate() { var date = new Date(); var seperator1 = "-"; var seperator2 = ":"; var month = date.getMonth() + 1; var strDate = date.getDate(); if (month >= 1 && month <= 9) { month = "0" + month; } if (strDate >= 0 && strDate <= 9) { strDate = "0" + strDate; } var currentdate = date.getFullYear() + seperator1 + month + seperator1 + strDate + " " + date.getHours() + seperator2 + date.getMinutes() + seperator2 + date.getSeconds(); return currentdate; } </script>
app.js代碼如下:
var express = require('express'); var path = require('path'); var logger = require('morgan'); var cookieParser = require('cookie-parser'); var bodyParser = require('body-parser'); var app = express(); var index = require('./routers/index.js'); // views engine setup app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); app.use(logger('dev')); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: false })); app.use(cookieParser()); app.use(express.static(path.join(__dirname, 'public'