Переглянути джерело

feat(core): Implement CacheStrategy and CacheService

Relates to #3043
Michael Bromley 1 рік тому
батько
коміт
489c9c0c4b

+ 6 - 2
packages/core/src/cache/cache.module.ts

@@ -1,9 +1,13 @@
 import { Module } from '@nestjs/common';
 
+import { ConfigModule } from '../config/config.module';
+
+import { CacheService } from './cache.service';
 import { RequestContextCacheService } from './request-context-cache.service';
 
 @Module({
-    providers: [RequestContextCacheService],
-    exports: [RequestContextCacheService],
+    imports: [ConfigModule],
+    providers: [RequestContextCacheService, CacheService],
+    exports: [RequestContextCacheService, CacheService],
 })
 export class CacheModule {}

+ 74 - 0
packages/core/src/cache/cache.service.ts

@@ -0,0 +1,74 @@
+import { Injectable } from '@nestjs/common';
+import { JsonCompatible } from '@vendure/common/lib/shared-types';
+
+import { ConfigService } from '../config/config.service';
+import { Logger } from '../config/index';
+import { CacheStrategy, SetCacheKeyOptions } from '../config/system/cache-strategy';
+
+/**
+ * @description
+ * The CacheService is used to cache data in order to optimize performance.
+ *
+ * Internally it makes use of the configured {@link CacheStrategy} to persist
+ * the cache into a key-value store.
+ *
+ * @since 3.1.0
+ */
+@Injectable()
+export class CacheService {
+    protected cacheStrategy: CacheStrategy;
+    constructor(private configService: ConfigService) {
+        this.cacheStrategy = this.configService.systemOptions.cacheStrategy;
+    }
+
+    /**
+     * @description
+     * Gets an item from the cache, or returns undefined if the key is not found, or the
+     * item has expired.
+     */
+    async get<T extends JsonCompatible<T>>(key: string): Promise<T | undefined> {
+        try {
+            const result = await this.cacheStrategy.get(key);
+            if (result) {
+                Logger.debug(`CacheService hit for key [${key}]`);
+            }
+            return result as T;
+        } catch (e: any) {
+            Logger.error(`Could not get key [${key}] from CacheService`, undefined, e.stack);
+        }
+    }
+
+    /**
+     * @description
+     * Sets a key-value pair in the cache. The value must be serializable, so cannot contain
+     * things like functions, circular data structures, class instances etc.
+     *
+     * Optionally a "time to live" (ttl) can be specified, which means that the key will
+     * be considered stale after that many milliseconds.
+     */
+    async set<T extends JsonCompatible<T>>(
+        key: string,
+        value: T,
+        options?: SetCacheKeyOptions,
+    ): Promise<void> {
+        try {
+            await this.cacheStrategy.set(key, value, options);
+            Logger.debug(`Set key [${key}] in CacheService`);
+        } catch (e: any) {
+            Logger.error(`Could not set key [${key}] in CacheService`, undefined, e.stack);
+        }
+    }
+
+    /**
+     * @description
+     * Deletes an item from the cache.
+     */
+    async delete(key: string): Promise<void> {
+        try {
+            await this.cacheStrategy.delete(key);
+            Logger.debug(`Deleted key [${key}] from CacheService`);
+        } catch (e: any) {
+            Logger.error(`Could not delete key [${key}] from CacheService`, undefined, e.stack);
+        }
+    }
+}

+ 2 - 0
packages/core/src/config/default-config.ts

@@ -48,6 +48,7 @@ import { InMemorySessionCacheStrategy } from './session-cache/in-memory-session-
 import { defaultShippingCalculator } from './shipping-method/default-shipping-calculator';
 import { defaultShippingEligibilityChecker } from './shipping-method/default-shipping-eligibility-checker';
 import { DefaultShippingLineAssignmentStrategy } from './shipping-method/default-shipping-line-assignment-strategy';
+import { InMemoryCacheStrategy } from './system/in-memory-cache-strategy';
 import { DefaultTaxLineCalculationStrategy } from './tax/default-tax-line-calculation-strategy';
 import { DefaultTaxZoneStrategy } from './tax/default-tax-zone-strategy';
 import { RuntimeVendureConfig } from './vendure-config';
@@ -220,6 +221,7 @@ export const defaultConfig: RuntimeVendureConfig = {
     },
     plugins: [],
     systemOptions: {
+        cacheStrategy: new InMemoryCacheStrategy({ cacheSize: 10_000 }),
         healthChecks: [new TypeORMHealthCheckStrategy()],
         errorHandlers: [],
     },

+ 52 - 0
packages/core/src/config/system/cache-strategy.ts

@@ -0,0 +1,52 @@
+import { JsonCompatible } from '@vendure/common/lib/shared-types';
+
+import { InjectableStrategy } from '../../common/types/injectable-strategy';
+
+/**
+ * @description
+ * Options available when setting the value in the cache.
+ */
+export interface SetCacheKeyOptions {
+    /**
+     * @description
+     * The time-to-live for the cache key in milliseconds. This means
+     * that after this time period, the key will be considered stale
+     * and will no longer be returned from the cache. Omitting
+     * this is equivalent to having an infinite ttl.
+     */
+    ttl?: number;
+}
+
+/**
+ * @description
+ * The CacheStrategy defines how the underlying shared cache mechanism is implemented.
+ *
+ * It is used by the {@link CacheService} to take care of storage and retrieval of items
+ * from the cache.
+ *
+ * @since 3.1.0
+ */
+export interface CacheStrategy extends InjectableStrategy {
+    /**
+     * @description
+     * Gets an item from the cache, or returns undefined if the key is not found, or the
+     * item has expired.
+     */
+    get<T extends JsonCompatible<T>>(key: string): Promise<T | undefined>;
+
+    /**
+     * @description
+     * Sets a key-value pair in the cache. The value must be serializable, so cannot contain
+     * things like functions, circular data structures, class instances etc.
+     *
+     * Optionally a "time to live" (ttl) can be specified, which means that the key will
+     * be considered stale after that many milliseconds.
+     */
+    set<T extends JsonCompatible<T>>(key: string, value: T, options?: SetCacheKeyOptions): Promise<void>;
+
+    /**
+     * @description
+     * Deletes an item from the cache.
+     */
+    delete(key: string): Promise<void>;
+}

+ 63 - 0
packages/core/src/config/system/in-memory-cache-strategy.ts

@@ -0,0 +1,63 @@
+import { JsonCompatible } from '@vendure/common/lib/shared-types';
+
+import { CacheStrategy, SetCacheKeyOptions } from './cache-strategy';
+
+export interface CacheItem<T> {
+    value: JsonCompatible<T>;
+    expires?: number;
+}
+
+/**
+ * A {@link CacheStrategy} that stores the cache in memory using a simple
+ * JavaScript Map.
+ *
+ * **Caution** do not use this in a multi-instance deployment because
+ * cache invalidation will not propagate to other instances.
+ *
+ * @since 3.1.0
+ */
+export class InMemoryCacheStrategy implements CacheStrategy {
+    protected cache = new Map<string, CacheItem<any>>();
+    protected cacheSize = 10_000;
+
+    constructor(config?: { cacheSize?: number }) {
+        if (config?.cacheSize) {
+            this.cacheSize = config.cacheSize;
+        }
+    }
+
+    async get<T extends JsonCompatible<T>>(key: string): Promise<T | undefined> {
+        const hit = this.cache.get(key);
+        if (hit) {
+            const now = new Date().getTime();
+            if (!hit.expires || (hit.expires && now < hit.expires)) {
+                return hit.value;
+            } else {
+                this.cache.delete(key);
+            }
+        }
+    }
+
+    async set<T extends JsonCompatible<T>>(key: string, value: T, options?: SetCacheKeyOptions) {
+        if (this.cache.has(key)) {
+            // delete key to put the item to the end of
+            // the cache, marking it as new again
+            this.cache.delete(key);
+        } else if (this.cache.size === this.cacheSize) {
+            // evict oldest
+            this.cache.delete(this.first());
+        }
+        this.cache.set(key, {
+            value,
+            expires: options?.ttl ? new Date().getTime() + options.ttl : undefined,
+        });
+    }
+
+    async delete(key: string) {
+        this.cache.delete(key);
+    }
+
+    private first() {
+        return this.cache.keys().next().value;
+    }
+}

+ 10 - 0
packages/core/src/config/vendure-config.ts

@@ -53,6 +53,7 @@ import { SessionCacheStrategy } from './session-cache/session-cache-strategy';
 import { ShippingCalculator } from './shipping-method/shipping-calculator';
 import { ShippingEligibilityChecker } from './shipping-method/shipping-eligibility-checker';
 import { ShippingLineAssignmentStrategy } from './shipping-method/shipping-line-assignment-strategy';
+import { CacheStrategy } from './system/cache-strategy';
 import { ErrorHandlerStrategy } from './system/error-handler-strategy';
 import { HealthCheckStrategy } from './system/health-check-strategy';
 import { TaxLineCalculationStrategy } from './tax/tax-line-calculation-strategy';
@@ -1058,6 +1059,15 @@ export interface SystemOptions {
      * @since 2.2.0
      */
     errorHandlers?: ErrorHandlerStrategy[];
+    /**
+     * @description
+     * Defines the underlying method used to store cache key-value pairs which powers the
+     * {@link CacheService}.
+     *
+     * @since 3.1.0
+     * @default InMemoryCacheStrategy
+     */
+    cacheStrategy?: CacheStrategy;
 }
 
 /**