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 all 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)); // no cache
service.asyncMethod(3).then(res => console.log(res)); // from cache
service.asyncMethod(4).then(res => console.log(res)); // no cache because another argument
17 changes: 7 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,9 @@ 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_OPTIONS: CacheOptions = {
expiration: 'absolute',
scope: 'class',
storage: 'memory',
size: null,
};
6 changes: 6 additions & 0 deletions lib/cache/cacheProvider/CacheProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { ClassType } from '../../interfaces/class';
import { Cache } from '../caches/Cache';

export interface CacheProvider<K = any> {
get(instance: ClassType): Cache<K>;
}
19 changes: 19 additions & 0 deletions lib/cache/cacheProvider/ClassCacheProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Cache } from '../caches/Cache';
import { CacheFactory } from '../caches/factory';
import { CacheProvider } from './CacheProvider';

export class ClassCacheProvider<K = any> implements CacheProvider<K> {

private cache: Cache<K> = null;

constructor(private readonly cacheFactory: CacheFactory) { }

public get(): Cache<K> {
if (!this.cache) {
this.cache = this.cacheFactory.create();
}

return this.cache;
}

}
22 changes: 22 additions & 0 deletions lib/cache/cacheProvider/InstanceCacheProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ClassType } from '../../interfaces/class';
import { Cache } from '../caches/Cache';
import { CacheFactory } from '../caches/factory';
import { CacheProvider } from './CacheProvider';

export class InstanceCacheProvider<K = any> implements CacheProvider<K> {

private instanceCaches = new WeakMap<ClassType, Cache<K>>();

constructor(private readonly cacheFactory: CacheFactory) { }

public get(instance: ClassType): Cache<K> {
const hasCache = this.instanceCaches.has(instance);
if (!hasCache) {
const cache = this.cacheFactory.create();
this.instanceCaches.set(instance, cache);
}

return this.instanceCaches.get(instance);
}

}
26 changes: 26 additions & 0 deletions lib/cache/cacheProvider/factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Factory } from '../../interfaces/factory';
import { CacheFactory } from '../caches/factory';
import { CacheProvider } from './CacheProvider';
import { ClassCacheProvider } from './ClassCacheProvider';
import { InstanceCacheProvider } from './InstanceCacheProvider';

export class CacheProviderFactory implements Factory<CacheProvider, ['class' | 'instance']> {

constructor(
private readonly cacheFactory: CacheFactory,
) { }

public create(scope: 'class' | 'instance') {
switch (scope) {
case 'class':
return new ClassCacheProvider(this.cacheFactory);

case 'instance':
return new InstanceCacheProvider(this.cacheFactory);

default:
throw new Error(`@cache invalid scope option: ${scope}.`);
}
}

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

export class Cache<K = any> {

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));
return this.storage.get<V>(keyHash);
}

public async has(key: K): Promise<boolean> {
const keyHash = this.hash.calculate(key);
return this.storage.has(keyHash);
}

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

}
24 changes: 24 additions & 0 deletions lib/cache/caches/factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Factory } from '../../interfaces/factory';
import { HashService } from '../../utils/hash';
import { ExpirationFactory } from '../expirations/factory';
import { StorageFactory } from '../storages/factory';
import { Cache } from './Cache';

export class CacheFactory<K = any> implements Factory<Cache<K>> {

constructor(
private readonly hash: HashService,
private readonly expirationFactory: ExpirationFactory,
private readonly storageFactory: StorageFactory,
private readonly expiration: 'absolute' | 'sliding',
private readonly storage: 'memory',
) { }

public create(): Cache<K> {
const storage = this.storageFactory.create(this.storage);
const expiration = this.expirationFactory.create(this.expiration);

return new Cache(storage, expiration, this.hash);
}

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

export class AbsoluteExpiration implements Expiration {

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

constructor(
private readonly timeout: number,
) { }

public add(key: string, clearCallback: (key: string) => unknown): void {
if (this.expirations.has(key)) {
return;
}

this.expirations.add(key);
setTimeout(() => this.clear(key, clearCallback), this.timeout);
}

private clear(key: string, clearCallback: (key: string) => unknown): void {
this.expirations.delete(key);
clearCallback(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, clearCallback: (key: string) => unknown): void;
}
39 changes: 39 additions & 0 deletions lib/cache/expirations/SlidingExpiration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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, clearCallback: (key: string) => unknown): void {
if (this.expirations.has(key)) {
this.update(key, clearCallback);
} else {
this.addKey(key, clearCallback);
}
}

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

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

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

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

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

export class ExpirationFactory implements Factory<Expiration, ['absolute' | 'sliding']> {

constructor(
private readonly timeout: number,
) { }

public create(expiration: 'absolute' | 'sliding'): Expiration {
switch (expiration) {
case 'absolute':
return new AbsoluteExpiration(this.timeout);

case 'sliding':
return new SlidingExpiration(this.timeout);

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

}
80 changes: 80 additions & 0 deletions lib/cache/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { CacheOptions, DEFAULT_OPTIONS } from './CacheOptions';
import { ExpirationFactory } from './expirations/factory';
import { StorageFactory } from './storages/factory';
import { CacheFactory } from './caches/factory';
import { HashService } from '../utils/hash';
import { CacheProviderFactory } from './cacheProvider/factory';
import { CacheProvider } from './cacheProvider/CacheProvider';

export { CacheOptions };

type TimeoutCacheOptions = CacheOptions & { 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: TimeoutCacheOptions): MethodDecorator;
export function cache(timeout: number, options?: CacheOptions): MethodDecorator;
export function cache(
timeoutOrOptions: number | TimeoutCacheOptions,
optionsOrVoid: CacheOptions = DEFAULT_OPTIONS,
): MethodDecorator {

const { timeout, options } = parseParameters(timeoutOrOptions, optionsOrVoid);
const cacheProvider = createCacheProvider(timeout, options);

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

descriptor.value = async function (...args: any[]) {
const cacheService = cacheProvider.get(this);

const isCached = await cacheService.has(args);
if (isCached) {
return cacheService.get(args);
}

const value = await method(...args);
cacheService.set(args, value);
return value;
};

return descriptor;
};
}

function parseParameters(
timeoutOrOptions: number | TimeoutCacheOptions,
optionsOrVoid: CacheOptions,
) {

if (typeof timeoutOrOptions === 'number') {
return {
timeout: timeoutOrOptions,
options: { ...DEFAULT_OPTIONS, ...optionsOrVoid },
};
}

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

function createCacheProvider(
timeout: number,
{ expiration, scope, size, storage }: CacheOptions,
): CacheProvider {

const hashService = new HashService();
const expirationFactory = new ExpirationFactory(timeout);
const storageFactory = new StorageFactory(size);

const cacheFactory =
new CacheFactory(hashService, expirationFactory, storageFactory, expiration, storage);

return new CacheProviderFactory(cacheFactory).create(scope);
}
Loading