node-busmq
Version:
A high performance, highly-available and scalable, message bus and queueing system for node.js backed by Redis
688 lines (615 loc) • 20.5 kB
JavaScript
var fs = require('fs');
var events = require('events');
var util = require('util');
var crypto = require('crypto');
var Logger = require('./logger');
var driver = require('./driver');
var Connection = require('./connection');
var WSPool = require('./wspool');
var Queue = require('./queue');
var Channel = require('./channel');
var Persistify = require('./persistify');
var Pubsub = require('./pubsub');
var Service = require('./service');
var FederationServer = require('./fedserver');
var Federate = require('./federate');
/**
* A message bus.
* Options:
* - redis - url string of a single redis to connect to or an array of string urls to connects to. a url must have the form redis://<host>[:<port>]
* - layout
* 'direct': direct connection to redis instances (default)
* 'sentinels': urls provided are that of sentinels (only supported by the ioredis driver)
* 'cluster': urls provided belong to a redis cluster (only supported by the ioredis driver)
* - redisOptions: options to pass to the redis client on instantiation (default to {}). Driver specific.
* - driver: redis driver to use: 'node-redis' or 'ioredis' (defaults to 'node-redis')
* - federate - object with the following options:
* - server - an http/https server object to listen for incoming federate connections. if undefined then federation server will not be open.
* - secret - a secret key to authenticate with the federates. this key must be shared among all federates. default is 'notsosecret'.
* - path - specifies the path within the server to accept federation connections on
* @constructor
*/
function Bus(options) {
events.EventEmitter.call(this);
this.id = "bus:" + crypto.randomBytes(8).toString('hex');
this.notificationsChannel = 'bus:notifications';
this.logger = new Logger(this.id);
this.connections = [];
this.connectionsCache = {};
this.connectionsCacheTimeout = 10*60*1000; // keep connections cached around for 10 minutes
this.online = false;
this.eventsCallbacks = {};
this._options(options);
this._startFederationServer();
if (this.options.redis.length > 0) {
this._loadScripts();
} else {
var _this = this;
process.nextTick(function() {
_this.emit('online');
});
}
}
util.inherits(Bus, events.EventEmitter);
/**
* Setup bus options
* @param options options to set
* @private
*/
Bus.prototype._options = function(options) {
this.options = options || {};
if (!this.options.redis) {
this.options.redis = [];
}
if (typeof this.options.redis === 'string') {
this.options.redis = [this.options.redis];
}
if (this.options.logLevel) {
this.logger.level(this.options.logLevel);
}
if (this.options.logger) {
this.withLog(this.options.logger);
}
this.driver = driver(this.options.driver || 'node-redis');
this.logger.isDebug() && this.logger.debug('using "'+this.driver.name+'" driver');
// this.setupDriver(this.options.driver);
this.options.layout = this.options.layout || 'direct';
this._setupWsPool();
};
/**
* Turn debug messages to the log on or off
* @param on
*/
Bus.prototype.debug = function(on) {
this.logger.level(on ? 5 : 2);
return this;
};
/**
* Set the logger object
* @param logger
*/
Bus.prototype.withLog = function(logger) {
this.logger.withLog(logger);
return this;
};
/**
* Setup the federation server
* @private
*/
Bus.prototype._startFederationServer = function() {
if (this.options.federate && this.options.federate.server) {
this.fedServer = new FederationServer(this, this.options.federate);
}
};
/**
* Create the websocket federation pool
* @private
*/
Bus.prototype._setupWsPool = function() {
var _this = this;
this.wspool = new WSPool(this, this.options.federate);
this.wspool.on('notification', function(message, source) {
switch(message.event) {
case 'queue':
_this._onNotificationQueue(message, source);
break;
default:
_this.logger.isDebug() && _this.logger.debug('received unrecognized notification message from ' + source + ': ' + JSON.stringify(message));
}
});
this.wspool.on('federating', function(url) {
_this.emit('federating', url);
})
};
/**
* Handle an event that a foreign queue in a federated bus was touched
* @param message
* @param source
* @private
*/
Bus.prototype._onNotificationQueue = function(message, source) {
var _this = this;
this._connection(message.key, function(conn) {
if (conn) {
// set the location of the key to the source of the notification event
conn.hset(message.key, 'source', source, function(err, resp) {
if (err) {
_this.logger.isDebug() && _this.logger.debug('error setting source for ' + message.key + ' to ' + source + ': ' + err);
}
});
conn.expire(message.key, message.ttl, function(err, resp) {
if (err) {
_this.logger.isDebug() && _this.logger.debug('error expiring ' + message.key + ' to ' + message.ttl + ': ' + err);
}
})
}
});
};
/**
* Load the lua scripts used for managing the queue
* @private
*/
Bus.prototype._loadScripts = function() {
this.scripts = {};
this.scriptsToLoad = 0;
this.scriptsLoaded = false;
this._readScript('push');
this._readScript('pop');
this._readScript('ack');
this._readScript('index');
};
/**
* Read a single script to memory
* @param name the script name to load
* @private
*/
Bus.prototype._readScript = function(name) {
++this.scriptsToLoad;
var _this = this;
var file = __dirname + '/lua/'+name+'.lua';
fs.readFile(file, function(err, content) {
if (err) {
_this.logger.isDebug() && _this.logger.debug('error reading lua script ' + file + ': ' + JSON.stringify(err));
_this.emit('error', err);
return;
}
_this.scripts[name] = {content: content.toString().trim(), name: name};
if (--_this.scriptsToLoad === 0) {
_this.scriptsLoaded = true;
_this._online();
}
});
};
/**
* Get the hash of a script by name
* @param name
* @returns {*}
* @private
*/
Bus.prototype._script = function(name) {
return this.scripts[name].hash;
};
/**
* Connect the bus to the specified urls.
* Events: error, online, offline
*/
Bus.prototype.connect = function() {
if (this.connections.length > 0) {
this.emit('error', 'already connected');
return;
}
var _this = this;
var directConnections = this.options.redis;
if (this.options.layout === 'direct') {
this.logger.isDebug() && this.logger.debug("bus connecting directly to redises @ " + this.options.redis);
var readies = 0;
directConnections.forEach(function(param, index) {
// open the connections to redis
createConnection(index,_this,
function() {
_this.logger.isDebug() && _this.logger.debug("connection "+(index+1)+" of "+ _this.options.redis.length + " is ready");
// emit the ready event only when all connections are ready
if (++readies === _this.connections.length) {
_this.connectionsReady = true;
_this._online();
}
},
function(err) {
if (readies > 0 && --readies === 0) {
_this.connectionsReady = false;
_this._offline(err);
}
}
)
});
} else if (this.options.layout === 'sentinels') {
//create a single connection via the sentinels
this.logger.isDebug() && this.logger.debug("bus connecting via sentinels @ " + this.options.redis);
createConnection(0,_this,
function() {
_this.logger.isDebug() && _this.logger.debug("connection via sentinels is ready");
_this.connectionsReady = true;
_this._online();
},
function(err) {
_this._offline(err);
}
)
} else if (this.options.layout === 'cluster') {
createConnection(-1,_this,
function() {
_this.logger.isDebug() && _this.logger.debug("connection to cluster is ready");
_this.connectionsReady = true;
_this._online();
},
function(err) {
_this._offline(err);
}
)
} else {
_this.emit('error', 'unsupported connection layout: '+this.options.layout);
return;
}
if (this.fedServer) {
this.fedServer.listen();
}
};
function createConnection(index, bus, readyCallback, endCallback) {
var connection = new Connection(index, bus);
bus.connections.push(connection);
var _onConnectionEnd = function (err) {
endCallback(err);
};
var _onConnectionClose = function (err) {
};
var _onConnectionError = function (err) {
bus.emit('error', err);
};
var _onConnectionConnect = function () {
};
var _onConnectionReady = function () {
readyCallback();
};
//var _onConnectionSubscribe = function (channel, count) {
// if (bus.eventsCallbacks[channel]) {
// bus.eventsCallbacks[channel]('subscribe', channel, count);
// }
//};
//
//var _onConnectionUnsubscribe = function (channel, count) {
// if (bus.eventsCallbacks[channel]) {
// bus.eventsCallbacks[channel]('unsubscribe', channel, count);
// }
//};
var _onConnectionMessage = function (message) {
if (bus.eventsCallbacks[message.channel]) {
bus.eventsCallbacks[message.channel].forEach(function(cb) {
cb('message', message.channel, message.message);
});
}
};
var _onConnectionCleanup = function() {
connection.removeListener("end", _onConnectionEnd);
connection.removeListener("close", _onConnectionClose);
connection.removeListener("error", _onConnectionError);
connection.removeListener("connect", _onConnectionConnect);
connection.removeListener("ready", _onConnectionReady);
//connection.removeListener("subscribe", _onConnectionSubscribe);
//connection.removeListener("unsubscribe", _onConnectionUnsubscribe);
connection.removeListener("message", _onConnectionMessage);
connection.removeListener("cleanup", _onConnectionCleanup);
};
connection.on("end", _onConnectionEnd);
connection.on("close", _onConnectionClose);
connection.on("error", _onConnectionError);
connection.on("connect", _onConnectionConnect);
connection.on("ready", _onConnectionReady);
//connection.on("subscribe", _onConnectionSubscribe); //ioredis does not emit this
//connection.on("unsubscribe", _onConnectionUnsubscribe);
connection.on("message", _onConnectionMessage);
connection.on('cleanup', _onConnectionCleanup);
connection.connect();
}
/**
* Invoked when the bus is online, i.e. all connections are ready
*/
Bus.prototype._online = function() {
if (this.connectionsReady && this.scriptsLoaded) {
this.logger.isDebug() && this.logger.debug("uploading scripts to redis");
var _this = this;
var scriptKeys = Object.keys(_this.scripts);
var dones = scriptKeys.length * this.connections.length;
// load the scripts to redis
this.connections.forEach(function(connection) {
scriptKeys.forEach(function(key) {
var script = _this.scripts[key];
// send the script to redis
var handler = function(err, resp) {
if (err) {
_this.emit('error', 'failed to upload script ' + script.name + ' to redis: ' + err);
return;
}
else {
// cluster will send back the hash as many times as there are nodes
script.hash = Array.isArray(resp) ? resp[0] : resp;
}
if (--dones === 0) {
_this.online = true;
_this.logger.isDebug() && _this.logger.debug("bus is online");
_this.emit('online');
}
};
connection.script('load', script.content, handler);
});
});
}
};
/**
* Invoked when the bus is offline, i.e. all connections are down
*/
Bus.prototype._offline = function(err) {
var shouldEmit = this.isOnline();
this.online = false;
this.logger.isDebug() && this.logger.debug("bus is offline");
if (shouldEmit) {
this.emit('offline', err);
}
};
/**
* Returns whether the bus is online or not
* @returns {boolean|*}
*/
Bus.prototype.isOnline = function() {
return this.online;
};
/**
* Get the first available connection
* @returns {*}
* @private
*/
Bus.prototype._connectionOne = function() {
return this.connections[0];
};
/**
* Get the next available connection
* @returns {*}
* @private
*/
Bus.prototype._connectionFor = function(key) {
var sum = 0;
for (var i = 0; i < key.length; ++i) {
sum += key.charCodeAt(i);
}
var index = sum % this.connections.length;
return this.connections[index];
};
/**
* Get an existing connection to redis to serve the specified key.
* A connection is calculated from the key if the key does not exist anywhere.
* @param key
* @param cb
* @private
*/
Bus.prototype._connection = function(key, cb) {
if (typeof key === 'function') {
cb = key;
key = undefined;
}
// support for returning a connection without specifying a key
if (!key) {
cb && cb(this._connectionOne());
return this._connectionOne();
}
// if we have the connection association already cached
if (this.connectionsCache[key]) {
cb && cb(this.connectionsCache[key].connection);
return;
}
// most of the time the connections state will be stable so the fastest
// way is to directly search a specific connection.
var _this = this;
var responses = this.connections.length;
var _callback = function(connection) {
if (connection) {
// keep the association of the key with the connection in the cache for 10 minutes
_this.connectionsCache[key] = {
connection: connection,
timeout: setTimeout(function() {
// delete the key->connection association cache
delete _this.connectionsCache[key];
}, _this.connectionsCacheTimeout)
};
cb && cb(connection);
_callback = null;
return;
}
if (--responses === 0 && _callback) {
// got a response from all the connections
_callback(_this._connectionFor(key));
}
};
// search for the key in all the connections
this.connections.forEach(function(c) {
if (!c.isReady()) {
_callback && _callback(null);
return;
}
c.exists(key, function(err, resp) {
if (err) {
_this.emit('error', "error searching for existing key " + key + ": " + err);
}
if (resp === 1) {
// found it
_callback && _callback(c);
} else {
_callback && _callback(null);
}
});
});
};
/**
* Get an existing connection to redis to serve the specified key.
* A connection is calculated from the key if the key does not exist anywhere.
* @param key
* @param cb
*/
Bus.prototype.connection = function(key, cb) {
return this._connection(key, cb);
};
/**
* Disconnect all redis connections, close the fedserver and close all the wspool websocket connections
*/
Bus.prototype.disconnect = function() {
this.logger.isDebug() && this.logger.debug("disconnecting");
var _this = this;
this.once('offline', function() {
_this.emit('cleanup');
});
var connections = this.connections;
this.connections = [];
connections.forEach(function(c) {
c.disconnect();
});
Object.keys(this.connectionsCache).forEach(function(key) {
clearTimeout(_this.connectionsCache[key].timeout);
});
this.connectionsCache = {};
if (this.fedServer) {
this.fedServer.close();
this.fedServer = null;
}
if (this.wspool) {
this.wspool.close();
this.wspool = null;
}
};
/**
* Subscribe to events on the specified channels on the provided connection
* @param connection
* @param channels
* @param cb
* @private
*/
Bus.prototype._subscribe = function(connection, channels, cb) {
if (!Array.isArray(channels)) {
channels = [channels];
}
var _this = this;
channels.forEach(function(channel) {
_this.eventsCallbacks[channel] = _this.eventsCallbacks[channel] || [];
_this.eventsCallbacks[channel].push(cb);
});
connection.subscribe(channels, function(err) {
if (err) {
_this.emit('error', "error subscribing to channels " + channels + ": " + err);
return;
}
channels.forEach(function(c) {
cb('subscribe', c);
});
});
};
/**
* Unsubscribe from events on the specified channels on the provided connection
* @param connection
* @param channels
* @param cb
* @private
*/
Bus.prototype._unsubscribe = function(connection, channels, cb) {
if (!Array.isArray(channels)) {
channels = [channels];
}
var _this = this;
channels.forEach(function(channel) {
if (_this.eventsCallbacks[channel]) {
var index = _this.eventsCallbacks[channel].indexOf(cb);
if (index !== -1) {
_this.eventsCallbacks[channel].splice(index, 1);
if (_this.eventsCallbacks[channel].length === 0) {
delete _this.eventsCallbacks[channel];
}
connection.unsubscribe(channels, function(err) {
if (err) {
_this.emit('error', "error unsubscribing from channels " + channels + ": " + err);
return;
}
channels.forEach(function(c) {
cb('unsubscribe', c);
});
});
}
}
});
};
/**
* Create a message queue.
* @param name
* @returns {Queue} a Queue object
*/
Bus.prototype.queue = function(name) {
return new Queue(this, name);
};
/***
* Create a bi-directional channel.
* @param name the name of the channel
* @param local the name of the local endpoint. default is 'client' if calling #connect and 'server' if calling #listen.
* @param remote the name of the remote endpoint. default is 'server' if calling #connect and 'client' if calling #listen.
* @returns {Channel} a Channel object
*/
Bus.prototype.channel = function(name, local, remote) {
return new Channel(this, name, local, remote);
};
/**
* Persistify the provided object, enabling the object to be persisted to redis
* @param name name of the object
* @param object the object to persist
* @param attributes array of property names to persist in the object. The properties will be automatically defined on the object.
* @returns {*}
*/
Bus.prototype.persistify = function(name, object, attributes) {
return Persistify(this, name, object, attributes);
};
/**
* Create a pubsub channel.
* @param name
* @returns {Pubsub} a Pubsub object
*/
Bus.prototype.pubsub = function(name) {
return new Pubsub(this, name);
};
/**
* Create a service object.
* @param name the name of the service
* @returns {Pubsub} a Service object
*/
Bus.prototype.service = function(name) {
return new Service(this, name);
};
/**
* Convert all eligible methods in the provided object into promise based methods instead of callback based methods
* @param {*} object the object whose methods to convert
* @param {*} methods the method names to convert
*/
Bus.prototype.promisify = function(object, methods) {
if (!util.promisify) {
throw new Error(`promisify is not supported in node ${process.version}`);
}
methods.forEach(function(method) {
object[method] = util.promisify(object[method]);
});
return object;
};
/**
* Federate an object to the specified remote bus
* @param object queue, channel or persisted object
* @param target string url of the remote bus to federate to
* @returns {Federate} the federation object. It's possible to use the federation object only after the 'ready' event is emitted.
*/
Bus.prototype.federate = function(object, target) {
return new Federate(object, target, this.wspool, this.options.federate);
};
function create(options) {
return new Bus(options);
}
exports = module.exports.create = create;