Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

cache decorator #3

Open
wants to merge 10 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions examples/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { cache } from '../lib/cache/';

class Service {

@cache(1000, { scope: 'class' })
public asyncMethod(n: number): Promise<number> {
return new Promise((resolve) => {
setTimeout(() => resolve(n ** 2), 10);
});
}

}

const service = new Service();

service.asyncMethod(3).then(res => console.log(res)); // first call => no cache
service.asyncMethod(3).then(res => console.log(res)); // second call => from cache
service.asyncMethod(4).then(res => console.log(res)); // first call => no cache
JohnDoePlusPlus marked this conversation as resolved.
Show resolved Hide resolved
21 changes: 11 additions & 10 deletions lib/cache.ts → lib/cache/CacheOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ export type CacheOptions = {
* `absolute` (default) strategy expires after the the set amount of time.
* `sliding` strategy expires after the method hasn't been called in a set amount of time.
*/
expiration: 'absolute' | 'sliding', // TODO: allow custom policy to be injected
expiration?: 'absolute' | 'sliding', // TODO: allow custom policy to be injected

/**
* The cache scope.
* The `class` (default) scope defines a single method scope for all class instances.
* The `instance` scope defines a per-instance method scope.
* The hash key is calculated using `object-hash` of current arguments list.
*/
scope?: 'class' | 'instance',

Expand All @@ -27,11 +26,13 @@ export type CacheOptions = {
size?: number,
};

/**
* Caches the result of a method.
* @param timeout cache timeout in milliseconds.
* @param options (optional) caching options.
*/
export function cache(timeout: number, options?: CacheOptions) {
throw new Error('Not implemented.');
}
export const DEFAULT_EXPIRATION = 'absolute';
export const DEFAULT_SCOPE = 'class';
export const DEFAULT_STORAGE = 'memory';
export const DEFAULT_SIZE = null;
export const DEFAULT_OPTIONS: CacheOptions = {
expiration: DEFAULT_EXPIRATION,
scope: DEFAULT_SCOPE,
storage: DEFAULT_STORAGE,
size: DEFAULT_SIZE,
};
6 changes: 6 additions & 0 deletions lib/cache/caches/Cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { ClassType } from '../../interfaces/class';

export interface Cache<K> {
set<V>(key: K, value: V, instance: ClassType): Promise<void>;
get<V>(key: K, instance: ClassType): Promise<V>;
JohnDoePlusPlus marked this conversation as resolved.
Show resolved Hide resolved
}
32 changes: 32 additions & 0 deletions lib/cache/caches/ClassCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Expiration } from '../expirations/Expiration';
import { HashService } from '../../utils/hash/hash';
import { Storage } from '../storages/Storage';
import { Cache } from './Cache';

export class ClassCache<K = any> implements Cache<K> {

constructor(
private readonly storage: Storage,
private readonly expiration: Expiration,
private readonly hash: HashService,
) { }

public async set<V>(key: K, value: V): Promise<void> {
const keyHash = this.hash.calculate(key);

await this.storage.set(keyHash, value);
this.expiration.add(keyHash, key => this.delete(key));
}

public async get<V>(key: K): Promise<V> {
const keyHash = this.hash.calculate(key);

this.expiration.add(keyHash, key => this.delete(key));
JohnDoePlusPlus marked this conversation as resolved.
Show resolved Hide resolved
return this.storage.get<V>(keyHash);
}

private async delete(key: string): Promise<void> {
await this.storage.delete(key);
}

}
49 changes: 49 additions & 0 deletions lib/cache/caches/InstanceCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { ClassType } from '../../interfaces/class';
import { Expiration } from '../expirations/Expiration';
import { HashService } from '../../utils/hash/hash';
import { Storage } from '../storages/Storage';
import { Cache } from './Cache';

export class InstanceCache<K = any> implements Cache<K> {

private readonly instanceStorage = new WeakMap<ClassType, [Storage, Expiration]>();

constructor(
private readonly storage: () => Storage,
private readonly expiration: () => Expiration,
private readonly hash: HashService,
) { }

public async set<V>(key: K, value: V, instance: ClassType): Promise<void> {
const keyHash = this.hash.calculate(key);
const [storage, expiration] = this.instanceData(instance);

await storage.set(keyHash, value);
expiration.add(keyHash, () => this.delete(keyHash, instance));
}

public async get<V>(key: K, instance: ClassType): Promise<V> {
const keyHash = this.hash.calculate(key);
const [storage, expiration] = this.instanceData(instance);

expiration.add(keyHash, () => this.delete(keyHash, instance));
return storage.get(keyHash);
}

private async delete(key: string, instance: ClassType): Promise<void> {
const [storage] = this.instanceData(instance);

await storage.delete(key);
}

private instanceData(instance: ClassType): [Storage, Expiration] {
if (!this.instanceStorage.has(instance)) {
const storage = this.storage();
const expiration = this.expiration();
this.instanceStorage.set(instance, [storage, expiration]);
}

return this.instanceStorage.get(instance);
}

}
27 changes: 27 additions & 0 deletions lib/cache/expirations/AbsoluteExpiration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Expiration } from './Expiration';

export class AbsoluteExpiration implements Expiration {

private readonly expirations = new Set<string>();

constructor(
private readonly timeout: number,
) { }

public add(key: string, clear: (key: string) => unknown): void {
JohnDoePlusPlus marked this conversation as resolved.
Show resolved Hide resolved
if (this.expirations.has(key)) {
return;
}

this.expirations.add(key);
setTimeout(this.clear(key, clear), this.timeout);
JohnDoePlusPlus marked this conversation as resolved.
Show resolved Hide resolved
}

private clear(key: string, clear: (key: string) => unknown): () => void {
return () => {
this.expirations.delete(key);
clear(key);
};
}

}
3 changes: 3 additions & 0 deletions lib/cache/expirations/Expiration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface Expiration {
add(key: string, clear: (key: string) => unknown): void;
}
35 changes: 35 additions & 0 deletions lib/cache/expirations/SlidingExpiration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Expiration } from './Expiration';

export class SlidingExpiration implements Expiration {

private readonly expirations = new Map<string, number>();

constructor(
private readonly timeout: number,
) { }

public add(key: string, clear: (key: string) => unknown): void {
this.expirations.has(key) ? this.update(key, clear) : this.addKey(key, clear);
}

private addKey(key: string, clear: (key: string) => unknown): void {
const timeoutId = setTimeout(
() => {
this.expirations.delete(key);
clear(key);
},
this.timeout,
);

this.expirations.set(key, timeoutId as any);
}

private update(key: string, clear: (key: string) => unknown): void {
const timeoutId = this.expirations.get(key);
clearTimeout(timeoutId as any);

this.expirations.delete(key);
this.addKey(key, clear);
}

}
44 changes: 44 additions & 0 deletions lib/cache/factories/cacheFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { CacheOptions } from '..';
JohnDoePlusPlus marked this conversation as resolved.
Show resolved Hide resolved
import { HashService } from '../../utils/hash/hash';
import { Cache } from '../caches/Cache';
import { ClassCache } from '../caches/ClassCache';
import { InstanceCache } from '../caches/InstanceCache';
import { expirationFactory } from './expirationFactory';
import { storeFactory } from './storeFactory';

const cacheFactories: ReadonlyMap<
'class' | 'instance',
<K>(timeout: number, options: CacheOptions) => Cache<K>
> = new Map<'class' | 'instance', <K>(timeout: number, options: CacheOptions) => Cache<K>>()
.set('class', classCacheFactory)
.set('instance', instanceCacheFactory);

export function cacheFactory<K = any>(timeout: number, options: CacheOptions): Cache<K> {
const { scope } = options;

const factory = cacheFactories.get(scope);

if (!factory) {
throw new Error(`@cahce Scope type is not suported: ${scope}.`);
}

return factory(timeout, options);
}

function classCacheFactory<K>(timeout: number, options: CacheOptions): ClassCache<K> {
const storage = storeFactory(options);
const expiration = expirationFactory(timeout, options);
const hash = new HashService();

return new ClassCache<K>(storage, expiration, hash);
}

function instanceCacheFactory<K>(timeout: number, options: CacheOptions): InstanceCache<K> {
const hash = new HashService();

return new InstanceCache(
() => storeFactory(options),
() => expirationFactory(timeout, options),
hash,
);
}
21 changes: 21 additions & 0 deletions lib/cache/factories/expirationFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { CacheOptions } from '..';
import { AbsoluteExpiration } from '../expirations/AbsoluteExpiration';
import { Expiration } from '../expirations/Expiration';
import { SlidingExpiration } from '../expirations/SlidingExpiration';

const expirationFactories: ReadonlyMap<'absolute' | 'sliding', (timeout: number) => Expiration> =
new Map<'absolute' | 'sliding', (timeout: number) => Expiration>()
.set('absolute', timeout => new AbsoluteExpiration(timeout))
.set('sliding', timeout => new SlidingExpiration(timeout));

export function expirationFactory(timeout: number, options: CacheOptions): Expiration {
const { expiration } = options;

const factory = expirationFactories.get(expiration);

if (!factory) {
throw new Error(`@cache Expiration type is not supported: ${expiration}.`);
}

return factory(timeout);
}
19 changes: 19 additions & 0 deletions lib/cache/factories/storeFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { CacheOptions } from '..';
JohnDoePlusPlus marked this conversation as resolved.
Show resolved Hide resolved
import { MemoryStorage } from '../storages/MemoryStorage';
import { Storage } from '../storages/Storage';

const storeFactories: ReadonlyMap<'memory', (limit: number) => Storage> =
new Map<'memory', (limit: number) => Storage>()
.set('memory', limit => new MemoryStorage(limit));

export function storeFactory(options: CacheOptions): Storage {
const { size, storage } = options;

const factory = storeFactories.get(storage);

if (!factory) {
throw new Error(`@cache Storage type is not supported: ${storage}.`);
}

return factory(size);
}
68 changes: 68 additions & 0 deletions lib/cache/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { CacheOptions, DEFAULT_OPTIONS } from './CacheOptions';
import { cacheFactory } from './factories/cacheFactory';

export { CacheOptions };

interface CacheOptionsAndTimeout extends CacheOptions {
JohnDoePlusPlus marked this conversation as resolved.
Show resolved Hide resolved
timeout: number;
}

/**
* Caches the result of a method.
* @param timeout cache timeout in milliseconds.
* @param options (optional) caching options.
*/
export function cache(timeout: number): MethodDecorator;
export function cache(options: CacheOptionsAndTimeout): MethodDecorator;
export function cache(timeout: number, options?: CacheOptions): MethodDecorator;
export function cache(
timeoutOrOptions: number | CacheOptionsAndTimeout,
optionsOrVoid: CacheOptions = DEFAULT_OPTIONS,
): MethodDecorator {

const { timeout, options } = parseParameters(timeoutOrOptions, optionsOrVoid);
const cacheService = cacheFactory(timeout, options);

return function (_: any, __: any, descriptor: PropertyDescriptor) {
const method = descriptor.value;

descriptor.value = async function (...args: any[]) {
const cachedValue = await cacheService.get(args, this);
if (cachedValue) {
return cachedValue;
}

try {
const value = await method(...args);
cacheService.set(args, value, this);
return value;
} catch (error) {
return Promise.reject(error);
JohnDoePlusPlus marked this conversation as resolved.
Show resolved Hide resolved
}
};

return descriptor;
};
}

interface Parameters {
timeout: number;
options: CacheOptions;
}
JohnDoePlusPlus marked this conversation as resolved.
Show resolved Hide resolved

function parseParameters(
timeoutOrOptions: number | CacheOptionsAndTimeout,
optionsOrVoid: CacheOptions,
): Parameters {
if (typeof timeoutOrOptions === 'number') {
return {
timeout: timeoutOrOptions,
options: { ...DEFAULT_OPTIONS, ...optionsOrVoid },
};
}

return {
timeout: timeoutOrOptions.timeout,
options: { ...DEFAULT_OPTIONS, ...timeoutOrOptions },
};
}
Loading