背景
项目使用nestjs作为服务端开发框架,使用了内置的缓存功能。项目内有很多功能都是清单类的,清单列表在编辑或删除后,通过请求刷新列表信息时,会出现获取的数据与为修改前的数据一致的情况。 具体现象: 待办模块中有获取待办列表的接口:/todo/get-list
,删除待办接口:/todo/delete-todo
在删除待办的场景中,前端先请求/todo/delete-todo
删除指定待办,接口返回成功后请求/todo/get-list
刷新待办列表。这时候就会出现列表不更新的情况。
原因分析
第一时间我以为是删除的逻辑有问题,查看代码没问题,调试删除接口,执行没问题,包括数据库中的数据也是删除成功,删除的接口没问题。 接口没问题,那么会不会是前端列表更新的问题?删除成功获取到新列表数据后,页面数据没更新导致的?这个也很快就排除,数据获取没问题,页面更新没问题。 那就剩下列表接口的问题,在进入页面时,第一次请求列表接口,返回5条待办数据,然后删除最后一条待办,再次请求列表接口,还是返回5条待办数据。wtf。。。
进一步分析
首先判断列表数据查询是否有问题,因为删除是软删除,先排除查询条件错误的问题。查询条件并没有问题。 排除查询条件问题后,接下来排除是否是返回数据处理逻辑的问题,然而,数据处理逻辑只是做了简单的加工,没有进一步查询补充数据的操作。 到这里我有点懵,没见过这种情况,现在能确定的肯定是接口出问题,而且是偶发的,因为添加操作后也会刷新列表的请求,但是一直没出现这个问题。 只能从入口排查,于是,我在controller打上断点,然而,删除后的第二次列表接口请求并没有加进入到controller中。
看下nestjs的请求的执行流程:
既然没有进入controller,那肯定就是在前面某一个节点提前响应了请求,我的项目中只使用了拦截器和管道,在两个地方打上断点,结果,拦截器正常执行,但是管道没有被执行。OK,可以确定是拦截器的问题,但是项目中的自定义拦截器并没有对正常的请求直接返回的操作。过一遍项目的代码,发现有一个非自定义的拦截器:
ts
@Module({
imports: [
],
controllers: [],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: CacheInterceptor
},
});
这是在全局注入的缓存功能,然后在官网上看到这样的描述: 引入了缓存后,默认会缓存GET请求。
GET REQUEST 缓存
GET请求缓存这个行为,主要是为了减少短时间内大量相同的请求对服务端的负荷。那么nestjs具体是怎么去做这个缓存的,存储的规则是什么,如果不需要这个缓存,要怎么处理呢? 直接看CacheInterceptor
的源码:
ts
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const key = this.trackBy(context);
const ttlValueOrFactory =
this.reflector.get(CACHE_TTL_METADATA, context.getHandler()) ??
this.reflector.get(CACHE_TTL_METADATA, context.getClass()) ??
null;
if (!key) {
return next.handle();
}
try {
const value = await this.cacheManager.get(key);
if (!isNil(value)) {
return of(value);
}
const ttl = isFunction(ttlValueOrFactory)
? await ttlValueOrFactory(context)
: ttlValueOrFactory;
return next.handle().pipe(
tap(async response => {
if (response instanceof StreamableFile) {
return;
}
const args = [key, response];
if (!isNil(ttl)) {
args.push(this.cacheManagerIsv5OrGreater ? ttl : { ttl });
}
try {
await this.cacheManager.set(...args);
} catch (err) {
Logger.error(
`An error has occurred when inserting "key: ${key}", "value: ${response}"`,
'CacheInterceptor',
);
}
}),
);
} catch {
return next.handle();
}
}
CacheInterceptor
的执行函数首先通过trackBy
方法获取到缓存的key,如果key存在,调用CacheManager获取缓存,缓存存在,返回。如果没有缓存,追加了一个response的管道(pipe),做一个response的缓存。 这里有两个点:
- 缓存的key是什么
- 缓存的时间是多少
缓存的key
看下trackBy
:
ts
protected trackBy(context: ExecutionContext): string | undefined {
const httpAdapter = this.httpAdapterHost.httpAdapter;
// 是否为Http请求
const isHttpApp = httpAdapter && !!httpAdapter.getRequestMethod;
// 获取上下文中CACHE_KEY_METADATA的信息
const cacheMetadata = this.reflector.get(
CACHE_KEY_METADATA,
context.getHandler(),
);
if (!isHttpApp || cacheMetadata) {
return cacheMetadata;
}
const request = context.getArgByIndex(0);
if (!this.isRequestCacheable(context)) {
return undefined;
}
return httpAdapter.getRequestUrl(request);
}
如果是Http请求的话,会调用isRequestCacheable
方法判断是否需要缓存。 isRequestCacheable
方法:
ts
protected allowedMethods = ['GET'];
protected isRequestCacheable(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
return this.allowedMethods.includes(req.method); // 判断是否是允许缓存的请求类型
}
这里只有GET
请求会被缓存。缓存的key为trackBy
返回的httpAdapter.getRequestUrl(request);
即当前的请求的url。
缓存的有效时间
CacheInterceptor
通过下面的方式来获取默认ttl(生存时间)的方法:
ts
const ttlValueOrFactory =
this.reflector.get(CACHE_TTL_METADATA, context.getHandler()) ??
this.reflector.get(CACHE_TTL_METADATA, context.getClass()) ??
null;
就获取上下文中CACHE_TTL_METADATA
的配置的方法。在设置缓存的时候使用这个方法:
ts
if (!isNil(ttl)) {
args.push(this.cacheManagerIsv5OrGreater ? ttl : { ttl });
}
假如没有设置这时间,那么就会使用默认的缓存有效时间。这个时间是5s。
怎么解决
ok,原因理清楚了,那么怎么解决这个问题呢?
1.修改url
最简单的做法,既然使用url来做key,那么只要让每次请求的url不一样就行了,前端在每次请求的时候都带上一个时间戳改变url。
js
`/todo/get-list?v=${+new Date()}`
但是,简单归简单,这方法实在太不优雅了。
2.重载CacheInterceptor
既然是因为引入了CacheInterceptor
导致的问题,但是项目又需要缓存拦截器,那么自己写一个CacheInterceptor也是一个好办法:
ts
import { CacheInterceptor } from '@nestjs/common';
export class RequestCacheInterceptor extends CacheInterceptor {
protected isRequestCacheable(): boolean {
return false;
}
}
这里我写了一个RequestCacheInterceptor
继承了CacheInterceptor
,然后重载了isRequestCacheable方法,直接返回false。其他的方法保持不变。然后引入这个Interceptor:
ts
@Module({
imports: [
],
controllers: [],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: RequestCacheInterceptor
},
});
这样就取消了GET请求的缓存。
方案扩展
上面通过重构CacheInterceptor
取消了GET请求的缓存,那如果部分接口需要缓存,部分不需要缓存(按需)要怎么实现呢?
1.清单管理
我们可以通过一个清单把需要缓存(或者不缓存)的接口管理起来,在CacheInterceptor
中判断是否需要缓存。
allowed-cache-api.ts
ts
export default [
'/todo/get-list'
];
ts
import { CacheInterceptor } from '@nestjs/common';
import AllowedCacheApis from './allowed-cache-api';
export class RequestCacheInterceptor extends CacheInterceptor {
protected isRequestCacheable(context: ExecutionContext): boolean {
// 获取当前请求的信息
const req = context.switchToHttp().getRequest();
// 如果是允许缓存的接口
if (AllowedCacheApis.includes(req.path)) {
return true;
}
return false;
}
}
这个方法可以有效的区分需要缓存和不缓存的接口,但是需要再全局维护一个缓存清单,如果接口数量比较大或者是默认缓存需要维护不缓存的接口,那就很容易出现问题。有没有其他办法呢?
2.在controller中维护清单
假如我们把全局清单去掉,在每个controller中去维护这个清单,是否也可以? 在controller中加入清单:
ts
@Controller('todo')
export class TodoController {
constructor(private readonly service: Service) {}
// 这里需要使用静态变量,否则在上下文内不好读取
static allowedCacheApis = [
'getList'
];
@AllowedCache()
@Get('get-list')
getList(): string {
.....
}
}
拦截器中:
ts
import { CacheInterceptor } from '@nestjs/common';
export class RequestCacheInterceptor extends CacheInterceptor {
protected isRequestCacheable(context: ExecutionContext): boolean {
// 获取当前类
const currentClass = context.getClass();
// 获取当前方法
const currHandler = context.getHandler().name;
// 如果是允许缓存的接口
if (currClass.allowedCacheApis.includes(currHandler)) {
return true;
}
return false;
}
}
在拦截器中,通过运行上下文,获取到当前执行的controller,并取出清单。然后再获取当前执行的接口方法名进行判断。这里需要注意的是,获取到的是接口方法名getList
而不是接口路径get-list
,因为这里context.getHandler()
获取的是方法Function getList
,其name
为getList
。
虽然这么处理可以把清单分散到各个入口中自行管理,但是感觉还是不够优雅,有没有更优雅的解决方案呢?
3.装饰器
更优雅的解决方案,最好当然是在定义接口的时候就把要不要缓存也描述了,按照nestjs的风格,给接口加一个描述的装饰器来描述接口缓存与否应该是最符合nestjs的风格的方式,也是比较优雅的方式吧。 首先,先自定义一个装饰器: allowed-cache-decorator.ts
ts
import { SetMetadata, applyDecorators } from '@nestjs/common';
// 允许缓存的装饰器
export function AllowedCache() {
// 添加一个metadata信息allowedCache为true,表示允许缓存
return applyDecorators(SetMetadata('allowedCache', true));
}
在接口中添加装饰器
ts
import { AllowedCache } from './allowed-cache-decorator';
@Controller('todo')
export class TodoController {
constructor(private readonly appService: AppService) {}
// get-list接口
@AllowedCache()
@Get('get-list')
getList(@Query() { name }): string {
console.log('controller.....');
console.log(name);
return this.appService.getHello();
}
}
拦截器中:
ts
import {
CacheInterceptor,
ExecutionContext
} from '@nestjs/common';
export class RequestCacheInterceptor extends CacheInterceptor {
protected isRequestCacheable(context: ExecutionContext): boolean {
// CacheInterceptor默认引入Reflector,可以通过Reflector获取上下文中写入meta data
const allowed = this.reflector.get('allowedCache', context.getHandler());
return !!allowed;
}
}
总结
本文是由于一个非正常现象引起发的一系列的探索和思考的记录,如果有缺漏或错误,请不吝指出。