Skip to content

Commit

Permalink
feat(cache): add CacheInterceptor (#1576)
Browse files Browse the repository at this point in the history
  • Loading branch information
cipchk authored Feb 1, 2024
1 parent 4957399 commit 837f4f0
Show file tree
Hide file tree
Showing 12 changed files with 302 additions and 17 deletions.
1 change: 1 addition & 0 deletions _mock/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './user';
export * from './chart';
export * from './upload';
export * from './test';
6 changes: 6 additions & 0 deletions _mock/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { HttpHeaders, HttpResponse } from "@angular/common/http";

export const TESTS = {
'/test': { ok: true },
'/test/cache-control': () => new HttpResponse({ body: 'cache-control', headers: new HttpHeaders({ 'cache-control': 'max-age=10' }) }),
};
22 changes: 22 additions & 0 deletions packages/cache/docs/interceptor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
order: 3
title: Interceptor
type: Documents
---

# 写在前面

搭配 `httpCacheInterceptor` Http 拦截器,可以将缓存应用到 Http 请求当中。它只有几个特征:

- 支持缓存过期时间
- 支持自定义缓存 KEY
- 支持任何 Http 请求、任何数据格式
- 符合 Http 缓存响应标准 `Cache-Control`

# 如何使用

`withInterceptors` 中引入 `httpCacheInterceptor`

```ts
provideHttpClient(withInterceptors([httpCacheInterceptor]))
```
3 changes: 2 additions & 1 deletion packages/cache/public_api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './src/interface';
export * from './src/cache.service';
export * from './src/cache.module';
export * from './src/cache.interceptor';
export * from './src/token';
118 changes: 118 additions & 0 deletions packages/cache/src/cache.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { HttpClient, HttpContext, HttpResponse, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Type } from '@angular/core';
import { TestBed } from '@angular/core/testing';

import { httpCacheInterceptor } from './cache.interceptor';
import { CacheService } from './cache.service';
import { CACHE } from './token';

describe('cache: interceptor', () => {
let http: HttpClient;
let httpBed: HttpTestingController;
let cacheSrv: CacheService;

function genModule(): void {
TestBed.configureTestingModule({
imports: [],
providers: [provideHttpClient(withInterceptors([httpCacheInterceptor])), provideHttpClientTesting()]
});
http = TestBed.inject(HttpClient);
httpBed = TestBed.inject(HttpTestingController as Type<HttpTestingController>);
cacheSrv = TestBed.inject(CacheService);
cacheSrv.set('a', 'a', { type: 'm' });
}

beforeEach(genModule);

it('should be working', () => {
const logSpy = spyOn(console, 'log');
http.get('/test', { responseType: 'text', context: new HttpContext().set(CACHE, { key: 'a' }) }).subscribe();
expect(logSpy).toHaveBeenCalled();
expect(logSpy.calls.first().args[0]).toBe(`%c👽GET->/test->from cache(onle in development)`);
});

it('should be truth request and cache data of response when is not cache', done => {
const key = 'b';
const res = 'ok';
http
.get('/test', { responseType: 'text', context: new HttpContext().set(CACHE, { key, expire: 60 }) })
.subscribe(res => {
expect(res).toBe(res);
expect((cacheSrv.getNone(key) as HttpResponse<string>)?.body).toBe(res);
done();
});
httpBed.expectOne('/test').flush(res);
});

it('should be support cache-control', done => {
const key = 'b';
const res = 'ok';
http.get('/test', { responseType: 'text', context: new HttpContext().set(CACHE, { key }) }).subscribe(res => {
expect(res).toBe(res);
expect((cacheSrv.getNone(key) as HttpResponse<string>)?.body).toBe(res);
done();
});
httpBed.expectOne('/test').flush(res, { headers: { 'cache-control': 'max-age=60' } });
});

it('should be support POST data', done => {
const key = 'b';
const res = 'ok';
http.post(key, { responseType: 'text', context: new HttpContext().set(CACHE, { key }) }).subscribe(() => {
expect((cacheSrv.getNone(key) as HttpResponse<string>)?.body).toBe(res);
done();
});
httpBed.expectOne(key).flush(res, { headers: { 'cache-control': 'max-age=60' } });
});

describe('Ignore cache', () => {
it('when response cache-control', done => {
const key = 'b';
const res = 'ok';
http.get(key, { responseType: 'text' }).subscribe(res => {
expect(res).toBe(res);
expect((cacheSrv.getNone(key) as HttpResponse<string>)?.body).toBe(res);
done();
});
httpBed.expectOne(key).flush(res, { headers: { 'cache-control': 'max-age=60' } });
});

it('when is not set CACHE', done => {
const key = 'b';
const res = 'ok';
http.get('/test', { responseType: 'text' }).subscribe(res => {
expect(res).toBe(res);
expect(cacheSrv.has(key)).toBe(false);
done();
});
httpBed.expectOne('/test').flush(res);
});

it('when enabled is false', done => {
const key = 'b';
const res = 'ok';
http
.get(key, { responseType: 'text', context: new HttpContext().set(CACHE, { enabled: false }) })
.subscribe(res => {
expect(res).toBe(res);
expect(cacheSrv.has(key)).toBe(false);
done();
});
httpBed.expectOne(key).flush(res, { headers: { 'cache-control': 'max-age=60' } });
});

it('when expire is 0', done => {
const key = 'b';
const res = 'ok';
http
.get('/test', { responseType: 'text', context: new HttpContext().set(CACHE, { expire: 0 }) })
.subscribe(res => {
expect(res).toBe(res);
expect(cacheSrv.has(key)).toBe(false);
done();
});
httpBed.expectOne('/test').flush(res);
});
});
});
69 changes: 69 additions & 0 deletions packages/cache/src/cache.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { HttpEvent, HttpInterceptorFn, HttpResponseBase } from '@angular/common/http';
import { inject } from '@angular/core';
import { map, of, OperatorFunction } from 'rxjs';

import { AlainConfigService } from '@delon/util/config';

import { CacheService } from './cache.service';
import { CacheOptions, CACHE } from './token';

/**
* Cache interceptor
*
* 缓存拦截器
*
* @example
* provideHttpClient(withInterceptors([httpCacheInterceptor])),
*/
export const httpCacheInterceptor: HttpInterceptorFn = (req, next) => {
const cog = inject(AlainConfigService).merge('cache', {})!.interceptor;
const options: CacheOptions = {
enabled: true,
emitNotify: true,
saveType: 'm',
...cog,
...req.context.get(CACHE)
};
const srv = inject(CacheService);
const mapPipe: OperatorFunction<HttpEvent<any>, HttpEvent<any>> = map(ev => save(srv, ev, options));
if (options.enabled === false) {
return next(req).pipe(mapPipe);
}

if (options.key == null) {
options.key = req.urlWithParams;
}

const cacheData = srv.getNone<HttpEvent<any>>(options.key);
if (cacheData != null) {
if (typeof ngDevMode === 'undefined' || ngDevMode) {
console.log(
`%c👽${req.method}->${req.urlWithParams}->from cache(onle in development)`,
'background:#000;color:#1890ff',
req,
cacheData
);
}
return of(cacheData);
}

return next(req).pipe(mapPipe);
};

function save(srv: CacheService, ev: HttpEvent<any>, options: CacheOptions): HttpEvent<any> {
if (!(ev instanceof HttpResponseBase) || !(ev.status >= 200 && ev.status < 300)) return ev;
let expire = options.expire;
if (expire == null) {
const ageMatch = /max-age=(\d+)/g.exec(ev.headers.get('cache-control')?.toLowerCase() ?? '');
if (ageMatch == null) return ev;
expire = +ageMatch[1];
}
if (expire > 0) {
srv.set(options.key!!, ev, {
type: options.saveType!!,
expire: expire
});
}
return ev;
}
4 changes: 0 additions & 4 deletions packages/cache/src/cache.module.ts

This file was deleted.

17 changes: 8 additions & 9 deletions packages/cache/src/cache.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,14 @@ export class CacheService implements OnDestroy {
private meta: Set<string> = new Set<string>();
private freqTick = 3000;
private freqTime: any;
private cog: AlainCacheConfig;

constructor(cogSrv: AlainConfigService) {
this.cog = cogSrv.merge('cache', {
mode: 'promise',
reName: '',
prefix: '',
meta_key: '__cache_meta'
})!;
private cog: AlainCacheConfig = inject(AlainConfigService).merge('cache', {
mode: 'promise',
reName: '',
prefix: '',
meta_key: '__cache_meta'
})!;

constructor() {
if (!this.platform.isBrowser) return;
this.loadMeta();
this.startExpireNotify();
Expand Down
3 changes: 1 addition & 2 deletions packages/cache/src/cache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { firstValueFrom, Observable, of, filter } from 'rxjs';

import { AlainCacheConfig, provideAlainConfig } from '@delon/util/config';

import { DelonCacheModule } from './cache.module';
import { CacheService } from './cache.service';
import { ICache } from './interface';

Expand Down Expand Up @@ -42,7 +41,7 @@ describe('cache: service', () => {
providers.push(provideAlainConfig({ cache: options }));
}
TestBed.configureTestingModule({
imports: [HttpClientTestingModule, DelonCacheModule],
imports: [HttpClientTestingModule],
providers
});

Expand Down
46 changes: 46 additions & 0 deletions packages/cache/src/token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { HttpContextToken } from '@angular/common/http';

export interface CacheOptions {
/**
* Whether to enable it, if `Cache-control: max-age=xxx` is found when the request returns, it will be automatically cached, and the next request will be forced to obtain data from the cache unless `enabled: false` is specified; default `true`
*
* 是否启用,当启用后若请求返回时发现 `Cache-control: max-age=xxx` 时自动缓存,下一次请求时除非指定 `enabled: false` 否则强制从缓存中获取数据;默认 `true`
*/
enabled?: boolean;
/**
* Specify the cache unique key, which is used to distinguish cache entries, and the default is based on the requested URL
*
* 指定缓存唯一键,用于区分缓存条目,默认以请求的 URL 为准
*/
key?: string;
/**
* Specify the storage method, `m` means memory, `s` means persistence; default: `m`
*
* 指定存储方式,`m` 表示内存,`s` 表示持久化;默认:`m`
*/
saveType?: 'm' | 's';
/**
* Expire time, the highest priority when returning `Cache-control: max-age=xxx`, unit `second`
*
* 过期时间,当返回 `Cache-control: max-age=xxx` 时优先级最高,单位 `秒`
*/
expire?: number;
/**
* Whether to trigger a notification, default: `true`
*
* 是否触发通知,默认:`true`
*/
emitNotify?: boolean;
}

/**
* Cache options (Don't forget to register `CacheInterceptor`)
*
* 缓存配置项(不要忘记注册 `CacheInterceptor`)
*
* @example
* this.http.get(`my`, {
* context: new HttpContext().set(CACHE, { key: 'user-data' })
* })
*/
export const CACHE = new HttpContextToken<CacheOptions>(() => ({}));
3 changes: 2 additions & 1 deletion packages/cache/tsconfig.lib.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"rootDir": ".",
"paths": {
"@delon/*": ["../../dist/@delon/*"]
}
},
"types": ["../dev-mode-types"]
}
}
27 changes: 27 additions & 0 deletions packages/util/config/cache/cache.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,31 @@ export interface AlainCacheConfig {
* Custom request
*/
request?: (key: string) => Observable<unknown>;
/**
* Default configuration of interceptor
*
* 拦截器默认配置项
*/
interceptor?: AlainCacheInterceptor;
}

export interface AlainCacheInterceptor {
/**
* Whether to enable, default `true`
*
* 是否启用,默认 `true`
*/
enabled?: boolean;
/**
* Specify the storage method, `m` means memory, `s` means persistence; default: `m`
*
* 指定存储方式,`m` 表示内存,`s` 表示持久化;默认:`m`
*/
saveType?: 'm' | 's';
/**
* Whether to trigger a notification, default: `true`
*
* 是否触发通知,默认:`true`
*/
emitNotify?: boolean;
}

0 comments on commit 837f4f0

Please sign in to comment.