diff --git a/documentation.md b/documentation.md index 0ab7098..9dbbad3 100644 --- a/documentation.md +++ b/documentation.md @@ -25,6 +25,25 @@ In Hydra, a service instance is simply a process which uses Hydra to handle micr > ☕ For a quick overview of what Hydra offers refer to the end of this document for a list of public methods. +* [Installing Hydra](#installing-hydra) +* [Using Hydra](#using-hydra) + * [Importing Hydra](#importing-hydra) + * [Initialization](#initialization) + * [Hydra modes](#hydra-modes) + * [Service mode](#service-mode) + * [Consumer mode](#consumer-mode) + * [Service Discovery](#service-discovery) + * [Presence](#presence) + * [Health and Presence](#health-and-presence) + * [Using Hydra to monitor services](#using-hydra-to-monitor-services) + * [Messaging](#messaging) + * [Inter-service messaging](#inter-service-messaging) + * [Built-in message channels](#built-in-message-channels) + * [UMF messaging](#umf-messaging) + * [Hydra Methods](#hydra-methods) + * [Hydra Plugins](#hydra-plugins) + + # Installing Hydra To use Hydra from another project: @@ -676,3 +695,7 @@ Marks a queued message as either completed or not */ markQueueMessage(message, completed, reason) ``` + +# Hydra plugins + +See the [Plugin documentation](/plugins.md). diff --git a/events.js b/events.js new file mode 100644 index 0000000..7dc6444 --- /dev/null +++ b/events.js @@ -0,0 +1,24 @@ +'use strict'; + +/** + * @name HydraEvent + * @description EventEmitter event names for Hydra + */ +class HydraEvent { + /** + * @return {string} config update event + * @static + */ + static get CONFIG_UPDATE_EVENT() { + return 'configUpdate'; + } + /** + * @return {string} update message type + * @static + */ + static get UPDATE_MESSAGE_TYPE() { + return 'configRefresh'; + } +} + +module.exports = HydraEvent; diff --git a/index.js b/index.js index 2bfd048..661e795 100755 --- a/index.js +++ b/index.js @@ -2,6 +2,13 @@ 'use strict'; const Promise = require('bluebird'); +Promise.series = (iterable, action) => { + return Promise.mapSeries( + iterable.map(action), + (value, index, length) => value || iterable[index].name || null + ); +}; + const EventEmitter = require('events'); const redis = require('redis'); const moment = require('moment'); @@ -48,19 +55,64 @@ class Hydra extends EventEmitter { this._updatePresence = this._updatePresence.bind(this); this._updateHealthCheck = this._updateHealthCheck.bind(this); this.registeredRoutes = []; + this.registeredPlugins = []; this.publisherChannels = {}; this.subscriberChannels = {}; } + /** + * @name use + * @summary Adds plugins to Hydra + * @param {...object} plugins - plugins to register + * @return {object} - Promise which will resolve when all plugins are registered + */ + use(...plugins) { + return Promise.series(plugins, plugin => this._registerPlugin(plugin)); + } + + /** + * @name _registerPlugin + * @summary Registers a plugin with Hydra + * @param {object} plugin - HydraPlugin to use + * @return {object} Promise or value + */ + _registerPlugin(plugin) { + this.registeredPlugins.push(plugin); + return plugin.setHydra(this); + } + /** * @name init + * @summary Register plugins then continue initialization + * @param {object} config - configuration object containing hydra specific keys/values + * @return {object} promise - resolves with this._init + */ + init(config) { + return new Promise((resolve, reject) => { + Promise.series(this.registeredPlugins, plugin => plugin.setConfig(config)) + .then((...results) => { + resolve(this._init(config)); + }) + .catch(err => this._logMessage('error', err.toString())); + }); + } + + /** + * @name _init * @summary Initialize Hydra with config object. * @param {object} config - configuration object containing hydra specific keys/values * @return {object} promise - resolving if init success or rejecting otherwise */ - init(config) { + _init(config) { return new Promise((resolve, reject) => { + let ready = () => { + Promise.series(this.registeredPlugins, plugin => plugin.onServiceReady()) + .then((...results) => { + resolve(); + }) + .catch(err => this._logMessage('error', err.toString())); + }; this._connectToRedis(config); this.redisdb.select(HYDRA_REDIS_DB, (err, result) => { if (err) { @@ -79,11 +131,11 @@ class Hydra extends EventEmitter { require('dns').lookup(require('os').hostname(), (err, address, fam) => { this.config.serviceIP = address; this._updateInstanceData(); - resolve(); + ready(); }); } else { this._updateInstanceData(); - resolve(); + ready(); } } }); @@ -1291,6 +1343,10 @@ class IHydra extends Hydra { return super.init(config); } + use(...plugins) { + return super.use(...plugins); + } + /** * @name _shutdown * @summary Shutdown hydra safely. diff --git a/package.json b/package.json index 6d21dd0..f512bf2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fwsp-hydra", - "version": "0.11.5", + "version": "0.12.0", "author": "Carlos Justiniano", "description": "A library for building microservices - built on Redis", "keywords": [ @@ -27,6 +27,7 @@ "fwsp-server-response": "2.2.3", "fwsp-umf-message": "0.4.3", "humanize-duration": "3.9.1", + "lodash": "4.17.2", "moment": "2.15.2", "redis": "2.6.2", "request": "2.76.0", diff --git a/plugin.js b/plugin.js new file mode 100644 index 0000000..6754d24 --- /dev/null +++ b/plugin.js @@ -0,0 +1,67 @@ +'use strict'; + +const isEqual = require('lodash/isEqual'); +const HydraEvent = require('./events'); + +/** + * @name HydraPlugin + * @description Extend this for hydra plugins + */ +class HydraPlugin { + /** + * @param {string} pluginName - unique name for the plugin + */ + constructor(pluginName) { + this.name = pluginName; + } + /** + * @name setHydra + * @param {object} hydra - hydra instance + */ + setHydra(hydra) { + this.hydra = hydra; + this.hydra.on( + HydraEvent.CONFIG_UPDATE_EVENT, + config => this.updateConfig(config) + ); + } + /** + * @name setConfig + * @param {object} hydraConfig - the hydra config + */ + setConfig(hydraConfig) { + this.hydraConfig = hydraConfig; + this.opts = hydraConfig.plugins[this.name]; + } + /** + * @name updateConfig + * @param {object} serviceConfig - the service-level config + * @param {object} serviceConfig.hydra - the hydra-level config + */ + updateConfig(serviceConfig) { + this.serviceConfig = serviceConfig; + this.hydraConfig = serviceConfig.hydra; + let opts = this.hydraConfig.plugins[this.name]; + if (!isEqual(this.opts, opts)) { + this.configChanged(opts); + } + } + /** + * @name configChanged + * @summary Handles changes to the plugin configuration + * @param {object} opts - the new plugin config + */ + configChanged(opts) { + console.log(`[override] [${this.name}] handle changed config`); + console.dir(opts, {colors: true, depth: null}); + } + /** + * @name onServiceReady + * @summary Called by Hydra when the service has initialized, but before the init Promise resolves + */ + onServiceReady() { + console.log(`[override] [${this.name}] hydra service ready`); + } +} + +module.exports = HydraPlugin; diff --git a/plugins.md b/plugins.md new file mode 100644 index 0000000..eeef7b9 --- /dev/null +++ b/plugins.md @@ -0,0 +1,166 @@ +# Hydra plugins + +Hydra's behavior can be extended through plugins. +This allows different Hydra services to easily take advantage of shared features. + +## Overview + +Hydra plugins extend the HydraPlugin class. + +Plugins should be registered before Hydra is initialized via hydra.init. + +E.g. + +``` +const YourPlugin = require('./your-plugin'); +hydra.use(new YourPlugin()); +``` + +Hydra will automatically call several hooks defined by the plugin class: + +| Hook | Description +| --- | --- +| `setHydra(hydra)` | called during plugin registration +| `setConfig(config)` | called before hydra initialization +| `updateConfig(serviceConfig)` | when the service-level config changes, will call configChanged if this plugin's options have changed +| `configChanged(options)` | when the plugin-level options change +| `onServiceReady()` | when the service has initialized but before the hydra.init Promise resolves + +### Hook return values + +`setHydra`, `setConfig`, and `onServiceReady` can return a value or a Promise. + +The actual return value isn't important; if the hook returns a value, success is assumed. +If an error in plugin initialization should result in the service failing to start, +the plugin hook should throw an Error. + +Similarly if a Promise is returned and resolves, success is assumed; the resolve() value is ignored. +Fatal errors should reject(). + +## Quick Tutorial + +Set up a plugin in five easy steps. + +### 1. Set up a hydra service: + +``` +$ yo fwsp-hydra +? Name of the service (`-service` will be appended automatically) pingpong +? Host the service runs on? +? Port the service runs on? 0 +? What does this service do? demo +? Does this service need auth? No +? Is this a hydra-express service? No +? Set up logging? No +? Run npm install? Yes + +$ cd pingpong-service +``` + +### 2. Create pong-plugin.js: + +***Tip:** On OS X, you can copy this snippet and then `pbpaste > pong-plugin.js`* + +```javascript +// whenever a 'ping' event is emitted, a 'pong' event is emitted after a user-defined delay +const Promise = require('bluebird'); +const HydraPlugin = require('fwsp-hydra/plugin'); +class PongPlugin extends HydraPlugin { + constructor() { + super('example'); // unique identifier for the plugin + } + // called at the beginning of hydra.init + // the parent class will locate the plugin config and set this.opts + // can return a Promise or a value + // in this case, there's no return statement, so that value is undefined + setConfig(hydraConfig) { + super.setConfig(hydraConfig); + this.configChanged(this.opts); + this.hydra.on('ping', () => { + Promise.delay(this.opts.pongDelay).then(() => { + this.hydra.emit('pong'); + }); + }) + } + // called when the config for this plugin has changed (via HydraEvent.CONFIG_UPDATE_EVENT) + // if you need access to the full service config, override updateConfig(serviceConfig) + configChanged(opts) { + if (this.opts.pongDelay === "random") { + this.opts.pongDelay = Math.floor(Math.random() * 3000); + console.log(`Random delay = ${this.opts.pongDelay}`); + } + } + // called after hydra has initialized but before hydra.init Promise resolves + // can return a Promise or a value + // this will delay by the port number in ms for demonstration of Promise + onServiceReady() { + console.log(`[example plugin] hydra service running on ${this.hydra.config.servicePort}`); + console.log('[example plugin] delaying serviceReady...'); + return new Promise((resolve, reject) => { + Promise.delay(this.hydra.config.servicePort) + .then(() => { + console.log('[example plugin] delayed serviceReady, pinging...'); + this.hydra.emit('ping'); + resolve(); + }); + }); + } +} +module.exports = PongPlugin; +``` + +### 3. Update `hydra` section of config.json: + +```json +{ + "hydra": { + "plugins": { + "example": { + "pongDelay": 2000 + } + } + } +} +``` + +### 4. Set up hydra service entry-point script: +```javascript +const version = require('./package.json').version; +const hydra = require('fwsp-hydra'); + +// install plugin +const PongPlugin = require('./pong-plugin'); +hydra.use(new PongPlugin()); + +// add some console.logs so we can see the events happen +hydra.on('ping', () => console.log('PING!')); +hydra.on('pong', () => { + console.log('PONG!'); + // send a ping back, after a random delay of up to 2s, to keep the rally going + setTimeout(() => hydra.emit('ping'), Math.floor(Math.random() * 2000)); +}); + + +let config = require('fwsp-config'); +config.init('./config/config.json') + .then(() => { + config.version = version; + config.hydra.serviceVersion = version; + hydra.init(config.hydra) + .then(() => hydra.registerService()) + .then(serviceInfo => { + + console.log(serviceInfo); // so we see when the serviceInfo resolves + + let logEntry = `Starting ${config.hydra.serviceName} (v.${config.version})`; + hydra.sendToHealthLog('info', logEntry); + }) + .catch(err => { + console.log('Error initializing hydra', err); + }); + }); +``` + +### 5. Try it out! + +Run `npm start`. After an initial delay, you should start seeing PING!s and PONG!s.