POMELO 采用多进程的架构可以很好的实现游戏服务器(进程)的扩展性,达到支撑较多在线用户、降低服务器压力等要求。进程间通信采用RPC的形式来完成,pomelo的RPC实现的相当精巧。采用类似如下的方式就可以调用remote服务器提供的服务:
proxies.user.test.service.echo(routeParam, 'hello', function(err, resp) {
if(err) {
console.error(err.stack);
return;
}
console.log(resp);
});
上面的一段RPC调用可以理解为:
调用namespace类型为user、服务器类型为test的service模块的echo接口
现在听着有些拗口,没关系,且听我慢慢来分析:)
pomelo-rpc的源码我阅读+debug了不下30次,下面我将依照从底层数据交换模块到上层业务逻辑分发处理的方式依次介绍服务端与客户端的源码架构。
一般来说我们在写socket数据通信模块有几个问题是必须要去解决的,譬如说:
- 粘包的问题
- 丢包以及乱序的问题
- ip地址过滤
- 缓冲队列的实现
- 与上层模块的交互模式
这里把pomelo-rpc实现过的来说一说。nodejs 内置一个events模块。这也导致了把一个模块封装成一个事件收发器是相当自然的一件事情:
var Acceptor = function(opts, cb) {
EventEmitter.call(this);
this.bufferMsg = opts.bufferMsg;
this.interval = opts.interval || 300;
this.whitelist= opts.whitelist;
this._interval = null;
this.sockets = {};
this.msgQueues = {};
this.server = null;
this.notify = cb;
};
util.inherits(Acceptor, EventEmitter);
利用node内置的util提供的继承函数,简单两句话Acceptor继承了events.翻开nodejs源代码 inherits
函数的实现也是相当简单:
var inherits = function(sub, super) {
var tmp = function() {}
tmp.prototype = super.prototype;
sub.prototype = new tmp();
}
通过这种寄生组合式的继承避免了调用两次父类的构造函数,这里就不多展开了。
看到Acceptor构造函数接收一些配置信息:
bufferMsg
: 配置是否启用缓冲队列interval
: 配置定时数据发送模块的间隔, Acceptor开启监听的时候,根据配置信息来确定是否开启一个定时器,定时刷新缓冲:
if(this.bufferMsg) {
this._interval = setInterval(function() {
flush(self);
}, this.interval);
}
flush函数主要做的是负责把缓冲的数据通过socket.io接口写出去:
var flush = function(acceptor) {
var sockets = acceptor.sockets;
var queues = acceptor.msgQueues;
var queue, socket;
for(var socketId in queues) {
socket = sockets[socketId];
if(!socket) {
delete queues[socketId];
continue;
}
queue = queues[socketId];
if(!queue.length) {
continue;
}
socket.emit('message', queue);
queues[socketId] = [];
}
};
每个客户端链接对应一个数据缓冲队列,通过发送’message’消息的方式把数据发出。
IP地址过滤
开启监听后,如果有客户端链接(on connection 事件),第一件事情是IP地址过滤,IP地址白名单也是通过构造函数注入:whitelist
.若IP地址非法则关闭链接,输出警告信息。
数据处理模块
上层模块通知配置信息注入一个notify
回调函数, acceptor
监听到数据后首先把数据抛给上层。上层处理完毕后判断如果需要缓冲则写入队列,否则马上发送出去:
acceptor.notify.call(null, pkg.msg, function() {
var args = Array.prototype.slice.call(arguments);
for(var i = 0, l = args.length; i < l; i++) {
if(args[i] instanceof Error) {
args[i] = cloneError(args[i]);
}
}
var resp = {id: pkg.id, resp: Array.prototype.slice.call(args)};
if(acceptor.bufferMsg) {
enqueue(socket, acceptor, resp);
}
else {
socket.emit('message', resp);
}
});
架在acceptor模块上面的是gateway
模块,该模块主要负责acceptor模块的创建销毁以及状态控制。首先在创建acceptor模块的时候传入一个函数:
this.acceptor = this.acceptorFactory.create(opts, function(msg, cb) {
dispatcher.route(msg, cb);
});
通过工厂方法来构建一个acctpor实例,这样底层数据处理模块可以方便的更换通信协议。这里回调函数做的一个工作是调用分发函数,把请求交给具体的服务提供方。来看看dispatcher的实现:
var Dispatcher = function(services) {
EventEmitter.call('this');
var self = this;
this.on('reload', function(services) {
self.services = services;
});
this.services = services;
};
util.inherits(Dispatcher, EventEmitter);
同样Dispatcher模块也变成一个事件收发器。同时构造器接收一个services
参数。依据改参数配合路由请求时传入的参数,就能把请求交给具体的子模块。所以,dispatcher.route(msg, cb);
只不过是匹配下参数调用对应接口罢了。看到构造器还监听了一个reload
事件,该事件有什么作用呢?这其实就是pomelo的RPC 热拔插模块的实现。实现起来比较简单:
var watchServices = function(gateway, dispatcher) {
var paths = gateway.opts.paths;
var app = gateway.opts.context;
for(var i = 0; i < paths.length; i++) {
(function(index) {
fs.watch(paths[index].path, function(event, name) {
if(event === 'change') {
var res = {};
var item = paths[index];
var m = Loader.load(item.path, app);
if(m) {
res[namespace] = res[namespace] || {};
for(var s in m) {
res[item.namespace][s] = m[s];
}
}
dispatcher.emit('reload', res);
}
});
})(i);
}
};
gateway
模块在启动的时候会根据配置信息调用一个watchServices
监听模块的变化。如果数据文件发生变化则重新加载services
并通知路由分发模块。为了保证服务器与客户端的正常通信,除了底层数据格式的一致,还有一个是路由信息的匹配。如果调用Gateway传入的配置路径是如下形式:
var paths = [
{namespace: 'user', path: __dirname + '/remote/test'}
];
假设当前目录下有/remote/test/service.js
文件,文件包含两个接口test1/test2
。 load
之后返回的对象形式如下:
{
service: {
test1: 'function xxx',
test2: 'function yyy'
}
}
同时在pomelo你们有系统RPC服务以及自定义RPC服务,完整的路由信息如下:
services: {
sys: {
sys_module1: {
sys_module1_interface1: 'xxx'
}
},
user: {
user_module1: {
user_module1_interface1: 'yyy'
}
}
}
服务端其他东西都比较简单了,为了理清楚脉络,以上代码是经过删减的,如果有兴趣可以到这里取。