UNPKG

moleculer

Version:

Fast & powerful microservices framework for Node.JS

951 lines (813 loc) 22.5 kB
/* * moleculer * Copyright (c) 2017 Ice Services (https://github.com/ice-services/moleculer) * MIT Licensed */ "use strict"; const Promise = require("bluebird"); const EventEmitter2 = require("eventemitter2").EventEmitter2; const Transit = require("./transit"); const ServiceRegistry = require("./service-registry"); const E = require("./errors"); const utils = require("./utils"); const Logger = require("./logger"); const Validator = require("./validator"); const BrokerStatistics = require("./statistics"); const healthInfo = require("./health"); const JSONSerializer = require("./serializers/json"); // Registry strategies const { STRATEGY_ROUND_ROBIN } = require("./constants"); // Circuit-breaker states const { CIRCUIT_HALF_OPEN } = require("./constants"); const _ = require("lodash"); const glob = require("glob"); const path = require("path"); const LOCAL_NODE_ID = null; // `null` means local nodeID /** * Service broker class * * @class ServiceBroker */ class ServiceBroker { /** * Creates an instance of ServiceBroker. * * @param {any} options * * @memberOf ServiceBroker */ constructor(options) { this.options = _.defaultsDeep(options, { nodeID: null, logger: null, logLevel: "info", transporter: null, requestTimeout: 0 * 1000, requestRetry: 0, maxCallLevel: 0, heartbeatInterval: 10, heartbeatTimeout: 30, registry: { strategy: STRATEGY_ROUND_ROBIN, preferLocal: true }, circuitBreaker: { enabled: false, maxFailures: 5, halfOpenTime: 10 * 1000, failureOnTimeout: true, failureOnReject: true }, cacher: null, serializer: null, validation: true, metrics: false, metricsRate: 1, statistics: false, internalActions: true // ServiceFactory: null, // ContextFactory: null }); // Promise constructor this.Promise = Promise; // Class factories this.ServiceFactory = this.options.ServiceFactory || require("./service"); this.ContextFactory = this.options.ContextFactory || require("./context"); // Self nodeID this.nodeID = this.options.nodeID || utils.getNodeID(); // Logger this._loggerCache = {}; this.logger = this.getLogger("BROKER"); // Local event bus this.bus = new EventEmitter2({ wildcard: true, maxListeners: 100 }); // Internal maps this.services = []; this.serviceRegistry = new ServiceRegistry(this.options.registry); this.serviceRegistry.init(this); // Middlewares this.middlewares = []; // Cacher this.cacher = this.options.cacher; if (this.cacher) { this.cacher.init(this); } // Serializer this.serializer = this.options.serializer; if (!this.serializer) this.serializer = new JSONSerializer(); this.serializer.init(this); // Validation if (this.options.validation !== false) { this.validator = new Validator(); if (this.validator) { this.validator.init(this); } } // Transit if (this.options.transporter) { this.transit = new Transit(this, this.options.transporter); } // Counter for metricsRate this.sampleCount = 0; if (this.options.statistics) this.statistics = new BrokerStatistics(this); this.getNodeHealthInfo = () => healthInfo(this); // Register internal actions if (this.options.internalActions) this.registerInternalActions(); // Graceful exit this._closeFn = () => { /* istanbul ignore next */ this.stop(); }; process.setMaxListeners(0); process.on("beforeExit", this._closeFn); process.on("exit", this._closeFn); process.on("SIGINT", this._closeFn); } /** * Start broker. If has transporter, transporter.connect will be called. * * @memberOf ServiceBroker */ start() { return Promise.resolve() .then(() => { // Call service `started` handlers this.services.forEach(service => { if (service && service.schema && _.isFunction(service.schema.started)) { service.schema.started.call(service); } }); return null; // avoid Bluebird warning }) .catch(err => { /* istanbul ignore next */ this.logger.error("Unable to start all services!", err); }) .then(() => { if (this.transit) return this.transit.connect(); }) .then(() => { this.logger.info(`Broker started. NodeID: ${this.nodeID}\n`); }); } /** * Stop broker. If has transporter, transporter.disconnect will be called. * * @memberOf ServiceBroker */ stop() { return Promise.resolve() .then(() => { // Call service `started` handlers this.services.forEach(service => { if (service && service.schema && _.isFunction(service.schema.stopped)) { service.schema.stopped.call(service); } }); return null; // avoid Bluebird warning }) .catch(err => { /* istanbul ignore next */ this.logger.error("Unable to stop all services!", err); }) .then(() => { if (this.transit) { return this.transit.disconnect(); } }) .then(() => { this.logger.info(`Broker stopped. NodeID: ${this.nodeID}\n`); process.removeListener("beforeExit", this._closeFn); process.removeListener("exit", this._closeFn); process.removeListener("SIGINT", this._closeFn); }); } /** * Start REPL mode * * @memberof ServiceBroker */ /* istanbul ignore next */ repl() { let repl; try { repl = require("moleculer-repl"); } catch (error) { console.error("The 'moleculer-repl' package is missing! Please install it with 'npm install moleculer-repl' command!"); // eslint-disable-line no-console this.logger.error("The 'moleculer-repl' package is missing! Please install it with 'npm install moleculer-repl' command!"); this.logger.debug("ERROR", error); } if (repl) repl(this); } /** * Get a custom logger for sub-modules (service, transporter, cacher, context...etc) * * @param {String} name name of module * @returns * * @memberOf ServiceBroker */ getLogger(name) { let logger = this._loggerCache[name]; if (logger) return logger; logger = Logger.wrap(this.options.logger, name, this.options.logLevel); this._loggerCache[name] = logger; return logger; } /** * Fatal error. Print the message to console (if logger is not exists). And exit the process (if need) * * @param {String} message * @param {Error?} err * @param {boolean} [needExit=true] * * @memberof ServiceBroker */ fatal(message, err, needExit = true) { if (err) this.logger.debug("ERROR", err); console.error(message); // eslint-disable-line no-console this.logger.fatal(message); if (needExit) process.exit(2); } /** * Load services from a folder * * @param {string} [folder="./services"] Folder of services * @param {string} [fileMask="*.service.js"] Service filename mask * @returns {Number} Number of found services * * @memberOf ServiceBroker */ loadServices(folder = "./services", fileMask = "*.service.js") { this.logger.info(`Search services in '${folder}/${fileMask}'...`); let serviceFiles; if (Array.isArray(fileMask)) serviceFiles = fileMask.map(f => path.join(folder, f)); else serviceFiles = glob.sync(path.join(folder, fileMask)); if (serviceFiles) { serviceFiles.forEach(servicePath => { this.loadService(servicePath); }); } return serviceFiles.length; } /** * Load a service from file * * @param {string} Path of service * @returns {Service} Loaded service * * @memberOf ServiceBroker */ loadService(filePath) { let fName = path.resolve(filePath); this.logger.debug(`Load service from '${path.basename(fName)}'...`); let schema = require(fName); if (_.isFunction(schema)) { let svc = schema(this); if (svc instanceof this.ServiceFactory) return svc; else return this.createService(svc); } else { return this.createService(schema); } } /** * Create a new service by schema * * @param {any} schema Schema of service * @param {any=} schemaMods Modified schema * @returns {Service} * * @memberOf ServiceBroker */ createService(schema, schemaMods) { let s = schema; if (schemaMods) s = utils.mergeSchemas(schema, schemaMods); let service = new this.ServiceFactory(this, s); return service; } /** * Register a local service * * @param {Service} service * * @memberOf ServiceBroker */ registerLocalService(service) { this.services.push(service); this.serviceRegistry.registerService(null, service); //this.emitLocal(`register.service.${service.name}`, service); this.logger.info(`'${service.name}' service is registered!`); } /** * Register a remote service * * @param {any} nodeID NodeID if it is on a remote server/node * @param {any} service * * @memberOf ServiceBroker */ registerRemoteService(nodeID, service) { this.serviceRegistry.registerService(nodeID, service); if (service.actions) { _.forIn(service.actions, action => { this.registerAction(nodeID, Object.assign({}, action, { service })); }); } } /** * Register an action in a local server * * @param {any} nodeID NodeID if it is on a remote server/node * @param {any} action action schema * * @memberOf ServiceBroker */ registerAction(nodeID, action) { // Wrap middlewares on local actions if (!nodeID) this.wrapAction(action); this.serviceRegistry.registerAction(nodeID, action); /*const res = this.serviceRegistry.registerAction(nodeID, action); if (res) { this.emitLocal(`register.action.${action.name}`, { nodeID, action }); }*/ } /** * Wrap action handler for middlewares * * @param {any} action * * @memberOf ServiceBroker */ wrapAction(action) { let handler = action.handler; if (this.middlewares.length) { let mws = Array.from(this.middlewares); handler = mws.reduce((handler, mw) => { return mw(handler, action); }, handler); } action.handler = handler; return action; } /** * Unregister services by node * * @param {String} nodeID * * @memberof ServiceBroker */ unregisterServicesByNode(nodeID) { this.serviceRegistry.unregisterServicesByNode(nodeID); } /** * Unregister an action on a local server. * It will be called when a remote node disconnected. * * @param {any} nodeID NodeID if it is on a remote server/node * @param {any} action action schema * * @memberOf ServiceBroker */ unregisterAction(nodeID, action) { this.serviceRegistry.unregisterAction(nodeID, action); } /** * Register internal actions * * @memberOf ServiceBroker */ registerInternalActions() { this.serviceRegistry.registerService(null, { name: "$node", settings: {} }); const addAction = (name, handler) => { this.registerAction(LOCAL_NODE_ID, { name, cache: false, handler: Promise.method(handler), service: { name: "$node" } }); }; addAction("$node.list", () => { let res = []; const localNode = this.transit.getNodeInfo(); localNode.id = null; localNode.available = true; res.push(localNode); this.transit.nodes.forEach(node => { //res.push(pick(node, ["nodeID", "available"])); res.push(node); }); return res; }); addAction("$node.services", ctx => { let res = []; const services = this.serviceRegistry.getServiceList(ctx.params); services.forEach(svc => { let item = res.find(o => o.name == svc.name && o.version == svc.version); if (item) { item.nodes.push(svc.nodeID); // Merge services _.forIn(svc.actions, (action, name) => { if (action.protected === true) return; if (!item.actions[name]) item.actions[name] = _.omit(action, ["handler", "service"]); }); } else { item = _.pick(svc, ["name", "version", "settings"]); item.nodes = [svc.nodeID]; item.actions = {}; _.forIn(svc.actions, (action, name) => { if (action.protected === true) return; item.actions[name] = _.omit(action, ["handler", "service"]); }); res.push(item); } }); return res; }); addAction("$node.actions", ctx => { return this.serviceRegistry.getActionList(ctx.params); }); addAction("$node.health", () => this.getNodeHealthInfo()); if (this.statistics) { addAction("$node.stats", () => { return this.statistics.snapshot(); }); } } /** * Subscribe to an event * * @param {any} name * @param {any} handler * * @memberOf ServiceBroker */ on(name, handler) { this.bus.on(name, handler); } /** * Subscribe to an event once * * @param {any} name * @param {any} handler * * @memberOf ServiceBroker */ once(name, handler) { this.bus.once(name, handler); } /** * Unsubscribe from an event * * @param {any} name * @param {any} handler * * @memberOf ServiceBroker */ off(name, handler) { this.bus.off(name, handler); } /** * Get a local service by name * * @param {any} serviceName * @returns * * @memberOf ServiceBroker */ getService(serviceName) { return this.services.find(service => service.name == serviceName); } /** * Has a local service by name * * @param {any} serviceName * @returns * * @memberOf ServiceBroker */ hasService(serviceName) { return this.services.find(service => service.name == serviceName) != null; } /** * Has an action by name * * @param {any} actionName * @returns * * @memberOf ServiceBroker */ hasAction(actionName) { return this.serviceRegistry.hasAction(actionName); } /** * Get an action by name * * @param {any} actionName * @returns {Object} * * @memberOf ServiceBroker */ getAction(actionName) { const item = this.serviceRegistry.findAction(actionName); if (item) { return item.nextAvailable(); } return null; } /** * Check has available action handler * * @param {any} actionName * @returns * * @memberOf ServiceBroker */ isActionAvailable(actionName) { const item = this.serviceRegistry.findAction(actionName); return item && item.count() > 0; } /** * Add a middleware to the broker * * @param {any} mw * * @memberOf ServiceBroker */ use(...mws) { mws.forEach(mw => { if (mw) this.middlewares.push(mw); }); } /** * Create a new Context instance * * @param {Object} action * @param {String?} nodeID * @param {Object?} params * @param {Object?} opts * @returns {Context} * * @memberof ServiceBroker */ createNewContext(action, nodeID, params, opts) { const ctx = new this.ContextFactory(this, action); ctx.nodeID = nodeID; ctx.setParams(params); // RequestID if (opts.requestID != null) ctx.requestID = opts.requestID; else if (opts.parentCtx != null && opts.parentCtx.requestID != null) ctx.requestID = opts.parentCtx.requestID; // Meta if (opts.parentCtx != null && opts.parentCtx.meta != null) ctx.meta = _.assign({}, opts.parentCtx.meta, opts.meta); else if (opts.meta != null) ctx.meta = opts.meta; // Timeout ctx.timeout = opts.timeout; ctx.retryCount = opts.retryCount; if (opts.parentCtx != null) { ctx.parentID = opts.parentCtx.id; ctx.level = opts.parentCtx.level + 1; } // Metrics if (opts.parentCtx != null) ctx.metrics = opts.parentCtx.metrics; else ctx.metrics = this.shouldMetric(); // ID, parentID, level if (ctx.metrics || nodeID) { ctx.generateID(); } return ctx; } /** * Call an action (local or remote) * * @param {any} actionName name of action * @param {any} params params of action * @param {any} opts options of call (optional) * @returns * * @performance-critical * @memberOf ServiceBroker */ call(actionName, params, opts = {}) { if (opts.timeout == null) opts.timeout = this.options.requestTimeout || 0; if (opts.retryCount == null) opts.retryCount = this.options.requestRetry || 0; let endpoint; if (typeof actionName !== "string") { endpoint = actionName; actionName = endpoint.action.name; } else { if (opts.nodeID) { // Direct call endpoint = this.serviceRegistry.getEndpointByNodeID(actionName, opts.nodeID); if (!endpoint) { this.logger.warn(`Service '${actionName}' is not available on '${opts.nodeID}' node!`); return Promise.reject(new E.ServiceNotFoundError(actionName, opts.nodeID)); } } else { // Find action by name let actions = this.serviceRegistry.findAction(actionName); if (actions == null) { this.logger.warn(`Service '${actionName}' is not registered!`); return Promise.reject(new E.ServiceNotFoundError(actionName)); } // Get an endpoint endpoint = actions.nextAvailable(); if (endpoint == null) { const errMsg = `Service '${actionName}' is not available!`; this.logger.warn(errMsg); return Promise.reject(new E.ServiceNotFoundError(actionName)); } } } // Expose action info let action = endpoint.action; let nodeID = endpoint.nodeID; this.logger.debug(`Call action '${actionName}' on node '${nodeID || "<local>"}'`); // Create context let ctx; if (opts.ctx != null) { // Reused context ctx = opts.ctx; ctx.nodeID = nodeID; ctx.action = action; } else { // New root context ctx = this.createNewContext(action, nodeID, params, opts); } if (this.options.maxCallLevel > 0 && ctx.level > this.options.maxCallLevel) { return this.Promise.reject(new E.MaxCallLevelError({ level: ctx.level, action: actionName })); } // Call handler or transfer request let p; if (endpoint.local) { // Add metrics start if (ctx.metrics === true || ctx.timeout > 0 || this.statistics) ctx._metricStart(ctx.metrics); p = action.handler(ctx); // Timeout handler if (ctx.timeout > 0) p = p.timeout(ctx.timeout); if (ctx.metrics === true || this.statistics) { // Add metrics & statistics p = p.then(res => { this._finishCall(ctx, null); return res; }); } } else { p = this.transit.request(ctx); // Timeout handler if (ctx.timeout > 0) p = p.timeout(ctx.timeout); } // Handle half-open state in circuit breaker if (this.options.circuitBreaker.enabled && endpoint.state === CIRCUIT_HALF_OPEN) { p = p.then(res => { endpoint.circuitClose(); return res; }); } // Error handler p = p.catch(err => this._callErrorHandler(err, ctx, endpoint, opts)); // Pointer to Context p.ctx = ctx; return p; } /** * Error handler for `call` method * * @param {Error} err * @param {Context} ctx * @param {Endpoint} endpoint * @param {Object} opts * @returns * * @memberOf ServiceBroker */ _callErrorHandler(err, ctx, endpoint, opts) { const actionName = ctx.action.name; const nodeID = ctx.nodeID; if (!(err instanceof Error)) { err = new E.MoleculerError(err, 500); } if (err instanceof Promise.TimeoutError) err = new E.RequestTimeoutError(actionName, nodeID || this.nodeID); err.ctx = ctx; if (nodeID) { // Remove pending request this.transit.removePendingRequest(ctx.id); } if (this.options.circuitBreaker.enabled) { if (err instanceof E.RequestTimeoutError) { if (this.options.circuitBreaker.failureOnTimeout) endpoint.failure(); } else if (err.code >= 500 && this.options.circuitBreaker.failureOnReject) { endpoint.failure(); } } if (err instanceof E.RequestTimeoutError) { // Retry request if (ctx.retryCount-- > 0) { this.logger.warn(`Action '${actionName}' call timed out on '${nodeID}'!`); this.logger.warn(`Recall '${actionName}' action (retry: ${ctx.retryCount + 1})...`); opts.ctx = ctx; // Reuse this context return this.call(actionName, ctx.params, opts); } } // Need it? this.logger.error("Action request error!", err); this._finishCall(ctx, err); // Handle fallback response if (opts.fallbackResponse) { this.logger.warn(`Action '${actionName}' returns fallback response!`); if (_.isFunction(opts.fallbackResponse)) return opts.fallbackResponse(ctx); else return Promise.resolve(opts.fallbackResponse); } return Promise.reject(err); } _finishCall(ctx, err) { if (ctx.metrics || this.statistics) { ctx._metricFinish(err, ctx.metrics); } if (this.statistics) this.statistics.addRequest(ctx.action.name, ctx.duration, err ? err.code || 500 : null); } /** * Check should metric the current call * * @returns * * @memberOf ServiceBroker */ shouldMetric() { if (this.options.metrics) { this.sampleCount++; if (this.sampleCount * this.options.metricsRate >= 1) { this.sampleCount = 0; return true; } } return false; } /** * Emit an event (global & local) * * @param {string} eventName * @param {any} payload * @returns {boolean} * * @memberOf ServiceBroker */ emit(eventName, payload) { if (this.transit) this.transit.emit(eventName, payload); return this.emitLocal(eventName, payload); } /** * Emit an event only local * * @param {string} eventName * @param {any} payload * @param {string=} nodeID of server * @returns {boolean} * * @memberOf ServiceBroker */ emitLocal(eventName, payload, sender) { this.logger.debug("Event emitted:", eventName); return this.bus.emit(eventName, payload, sender); } } // Set version of Moleculer ServiceBroker.prototype.MOLECULER_VERSION = require("../package.json").version; module.exports = ServiceBroker;