UNPKG

moleculer

Version:

Fast & powerful microservices framework for Node.JS

536 lines (484 loc) 12.7 kB
/* * moleculer * Copyright (c) 2023 MoleculerJS (https://github.com/moleculerjs/moleculer) * MIT Licensed */ /* eslint-disable no-unused-vars */ "use strict"; const _ = require("lodash"); const crypto = require("crypto"); const { METRIC } = require("../metrics"); const { isObject, isFunction, isDate } = require("../utils"); /** * Import types * * @typedef {import("../service-broker")} ServiceBroker * @typedef {import("../context")} Context * @typedef {import("./base").CacherOptions} CacherOptions * @typedef {import("./base")} CacherBaseClass */ /** * Abstract cacher class * * @implements {CacherBaseClass} */ class Cacher { /** * Creates an instance of Cacher. * * @param {CacherOptions} opts * * @memberof Cacher */ constructor(opts) { /** @type {CacherOptions} */ this.opts = _.defaultsDeep(opts, { ttl: null, keygen: null, maxParamsLength: null, missingResponse: undefined }); /** @type {boolean} Flag indicating the connection status */ this.connected = null; // Init as null for backward compatibility } /** * Initialize cacher * * @param {ServiceBroker} broker * * @memberof Cacher */ init(broker) { this.broker = broker; this.metrics = broker.metrics; if (this.broker) { this.logger = broker.getLogger("cacher"); if (this.opts.prefix) { this.prefix = this.opts.prefix + "-"; } else { this.prefix = "MOL-"; if (this.broker.namespace) this.prefix += this.broker.namespace + "-"; } this.registerMoleculerMetrics(); } } /** * Register Moleculer Transit Core metrics. */ registerMoleculerMetrics() { this.metrics.register({ name: METRIC.MOLECULER_CACHER_GET_TOTAL, type: METRIC.TYPE_COUNTER, rate: true }); this.metrics.register({ name: METRIC.MOLECULER_CACHER_GET_TIME, type: METRIC.TYPE_HISTOGRAM, quantiles: true, unit: METRIC.UNIT_MILLISECONDS }); this.metrics.register({ name: METRIC.MOLECULER_CACHER_FOUND_TOTAL, type: METRIC.TYPE_COUNTER, rate: true }); this.metrics.register({ name: METRIC.MOLECULER_CACHER_SET_TOTAL, type: METRIC.TYPE_COUNTER, rate: true }); this.metrics.register({ name: METRIC.MOLECULER_CACHER_SET_TIME, type: METRIC.TYPE_HISTOGRAM, quantiles: true, unit: METRIC.UNIT_MILLISECONDS }); this.metrics.register({ name: METRIC.MOLECULER_CACHER_DEL_TOTAL, type: METRIC.TYPE_COUNTER, rate: true }); this.metrics.register({ name: METRIC.MOLECULER_CACHER_DEL_TIME, type: METRIC.TYPE_HISTOGRAM, quantiles: true, unit: METRIC.UNIT_MILLISECONDS }); this.metrics.register({ name: METRIC.MOLECULER_CACHER_CLEAN_TOTAL, type: METRIC.TYPE_COUNTER, rate: true }); this.metrics.register({ name: METRIC.MOLECULER_CACHER_CLEAN_TIME, type: METRIC.TYPE_HISTOGRAM, quantiles: true, unit: METRIC.UNIT_MILLISECONDS }); this.metrics.register({ name: METRIC.MOLECULER_CACHER_EXPIRED_TOTAL, type: METRIC.TYPE_COUNTER, rate: true }); } /** * Close cacher * * @memberof Cacher */ close() { /* istanbul ignore next */ return Promise.resolve(); } /** * Get a cached content by key * * @param {any} key * * @returns {Promise<any>} * @memberof Cacher */ get(key) { /* istanbul ignore next */ throw new Error("Not implemented method!"); } /** * Get a cached content and ttl by key * * @param {any} key * @returns {Promise<any>} * @memberof Cacher */ getWithTTL(key) { /* istanbul ignore next */ throw new Error("Not implemented method!"); } /** * Set a content by key to cache * * @param {any} key * @param {any} data * @param {Number?} ttl * * @returns {Promise<any>} * @memberof Cacher */ set(key, data, ttl) { /* istanbul ignore next */ throw new Error("Not implemented method!"); } /** * Delete a content by key from cache * * @param {string|Array<string>} key * * @returns {Promise<any>} * @memberof Cacher */ del(key) { /* istanbul ignore next */ throw new Error("Not implemented method!"); } /** * Clean cache. Remove every key by match * /@param {string|Array<string>} match string. Default is "**" * @returns {Promise<any>} * @memberof Cacher */ clean(/*match = "**"*/) { /* istanbul ignore next */ throw new Error("Not implemented method!"); } /** * Try to acquire a lock * * @param {string|Array<string>} key * @param {number?} ttl * * @returns {Promise<any>} * @memberof Cacher */ tryLock(key, ttl) { /* istanbul ignore next */ throw new Error("Not implemented method!"); } /** * Acquire a lock * * @param {string|Array<string>} key * @param {number?} ttl * * @returns {Promise<any>} * @memberof Cacher */ lock(key, ttl) { /* istanbul ignore next */ throw new Error("Not implemented method!"); } /** * Get a value from params or meta by `key`. * If the key starts with `#` it reads from `meta`. * If the key starts with `@` it reads from `headers`. * * @param {String} key * @param {Object} params * @param {Object} meta * @param {Object} headers * @returns {any} * @memberof Cacher */ _getParamMetaValue(key, params, meta, headers) { if (key.startsWith("#") && meta != null) return _.get(meta, key.slice(1)); if (key.startsWith("@") && headers != null) return _.get(headers, key.slice(1)); else if (params != null) return _.get(params, key); } /** * Default cache key generator * * @param {Object} action * @param {Object} opts * @param {Context} ctx * @returns {String} * @memberof Cacher */ defaultKeygen(action, opts, ctx) { if (!action) return undefined; const { params, meta, headers } = ctx ?? {}; if (params || meta || headers) { const keyPrefix = action.name + ":"; if (opts?.keys) { if (opts.keys.length == 1) { // Fast solution for ['id'] key const val = this._getParamMetaValue(opts.keys[0], params, meta, headers); return keyPrefix + this._hashedKey(this._generateKeyFromObject(val)); } if (opts.keys.length > 0) { return ( keyPrefix + this._hashedKey( opts.keys.reduce((a, key, i) => { const val = this._getParamMetaValue(key, params, meta, headers); const valKey = this._generateKeyFromObject(val); return ( a + (i ? "|" : "") + (isObject(val) || Array.isArray(val) ? this._hashedKey(valKey) : valKey) ); }, "") ) ); } } else { return keyPrefix + this._hashedKey(this._generateKeyFromObject(params)); } } return action.name; } /** * Hash key if it's too long. * * @param {String} key * @returns {String} */ _hashedKey(key) { if (typeof key !== "string") key = String(key); const maxParamsLength = this.opts.maxParamsLength; if (!maxParamsLength || maxParamsLength < 44 || key.length <= maxParamsLength) return key; const prefixLength = maxParamsLength - 44; const base64Hash = crypto.createHash("sha256").update(key).digest("base64"); if (prefixLength < 1) return base64Hash; return key.substring(0, prefixLength) + base64Hash; } _generateKeyFromObject(obj) { if (Array.isArray(obj)) { return "[" + obj.map(o => this._generateKeyFromObject(o)).join("|") + "]"; } else if (isDate(obj)) { return obj.valueOf(); } else if (isObject(obj)) { return Object.keys(obj) .map(key => [key, this._generateKeyFromObject(obj[key])].join("|")) .join("|"); } else if (typeof obj === "string") { return '"' + obj + '"'; } else if (obj != null) { return obj.toString(); } else if (obj === null) { return "null"; } else { return "undefined"; } } /** * Get a cache key by name and params. * Concatenate the name and the hashed params object * * @param {Object} action * @param {Object} opts * @param {Context} ctx * @returns {String} */ getCacheKey(action, opts, ctx) { if (opts && isFunction(opts.keygen)) return opts.keygen.call(this, action, opts, ctx); else if (isFunction(this.opts.keygen)) return this.opts.keygen.call(this, action, opts, ctx); else return this.defaultKeygen(action, opts, ctx); } /** * Register cacher as a middleware * * @memberof Cacher */ middleware() { return { name: "Cacher", localAction: (handler, action) => { const opts = _.defaultsDeep( {}, isObject(action.cache) ? action.cache : { enabled: !!action.cache } ); opts.lock = _.defaultsDeep( {}, isObject(opts.lock) ? opts.lock : { enabled: !!opts.lock } ); if (opts.enabled !== false) { const isEnabledFunction = isFunction(opts.enabled); return function cacherMiddleware(ctx) { if (isEnabledFunction) { if (!opts.enabled.call(ctx.service, ctx)) { // Cache is disabled. Call the handler only. return handler(ctx); } } // Disable caching with `ctx.meta.$cache = false` if (ctx.meta["$cache"] === false) return handler(ctx); // Cache is enabled but not in healthy state // More info: https://github.com/moleculerjs/moleculer/issues/978 if (this.connected === false) { this.logger.debug( "Cacher is enabled but it is not connected at the moment... Calling the handler" ); return handler(ctx); } const cacheKey = this.getCacheKey(action, opts, ctx); // Using lock if (opts.lock.enabled !== false) { return this.middlewareWithLock(ctx, cacheKey, handler, opts); } else { // Not using lock return this.middlewareWithoutLock(ctx, cacheKey, handler, opts); } }.bind(this); } return handler; } }; } /** * Middleware functionality with lock support. * * @param {Context} ctx * @param {string} cacheKey * @param {Function} handler * @param {Object} opts * @returns {Promise<any>} */ middlewareWithLock(ctx, cacheKey, handler, opts) { let cachePromise; if (opts.lock.staleTime && this.getWithTTL) { // If enable cache refresh cachePromise = this.getWithTTL(cacheKey).then(({ data, ttl }) => { if (data != null) { if (opts.lock.staleTime && ttl && ttl < opts.lock.staleTime) { // Cache is stale, try to refresh it. this.tryLock(cacheKey, opts.lock.ttl) .then(unlock => { return handler(ctx) .then(result => { // Save the result to the cache and release the lock. return this.set(cacheKey, result, opts.ttl).then(() => unlock() ); }) .catch((/*err*/) => { return this.del(cacheKey).then(() => unlock()); }); }) .catch((/*err*/) => { // The cache is refreshing on somewhere else. }); } } return data; }); } else { cachePromise = this.get(cacheKey); } return cachePromise.then(data => { if (data !== this.opts.missingResponse) { // Found in the cache! Don't call handler, return with the content ctx.cachedResult = true; return data; } // Not found in the cache! Acquire a lock return this.lock(cacheKey, opts.lock.ttl).then(unlock => { return this.get(cacheKey).then(content => { if (content != null) { // Cache found. Realse the lock and return the value. ctx.cachedResult = true; return unlock().then(() => { return content; }); } // Call the handler return handler(ctx) .then(result => { // Save the result to the cache and realse the lock. this.set(cacheKey, result, opts.ttl).then(() => unlock()); return result; }) .catch(e => { return unlock().then(() => { return Promise.reject(e); }); }); }); }); }); } /** * Middleware functionality without lock support. * * @param {Context} ctx * @param {string} cacheKey * @param {Function} handler * @param {Object} opts * @returns {Promise<any>} */ middlewareWithoutLock(ctx, cacheKey, handler, opts) { return this.get(cacheKey).then(content => { if (content !== this.opts.missingResponse) { // Found in the cache! Don't call handler, return with the content ctx.cachedResult = true; return content; } // Call the handler return handler(ctx).then(result => { // Save the result to the cache this.set(cacheKey, result, opts.ttl); return result; }); }); } /** * Return all cache keys with available properties (ttl, lastUsed, ...etc). * * @returns {Promise<Array<Object>>} */ getCacheKeys() { // Not available return Promise.resolve(null); } } module.exports = Cacher;