nestjs:GET REQUEST 缓存
背景
项目使用nestjs作为服务端开发框架,使用了内置的缓存功能。项目内有很多功能都是清单类的,清单列表在编辑或删除后,通过请求刷新列表信息时,会出现获取的数据与为修改前的数据一致的情况。
具体现象:
待办模块中有获取待办列表的接口:/todo/get-list
,删除待办接口:/todo/delete-todo
在删除待办的场景中,前端先请求/todo/delete-todo
删除指定待办,接口返回成功后请求/todo/get-list
刷新待办列表。这时候就会出现列表不更新的情况。
原因分析
第一时间我以为是删除的逻辑有问题,查看代码没问题,调试删除接口,执行没问题,包括数据库中的数据也是删除成功,删除的接口没问题。
接口没问题,那么会不会是前端列表更新的问题?删除成功获取到新列表数据后,页面数据没更新导致的?这个也很快就排除,数据获取没问题,页面更新没问题。
那就剩下列表接口的问题,在进入页面时,第一次请求列表接口,返回5条待办数据,然后删除最后一条待办,再次请求列表接口,还是返回5条待办数据。wtf。。。
进一步分析
首先判断列表数据查询是否有问题,因为删除是软删除,先排除查询条件错误的问题。查询条件并没有问题。
排除查询条件问题后,接下来排除是否是返回数据处理逻辑的问题,然而,数据处理逻辑只是做了简单的加工,没有进一步查询补充数据的操作。
到这里我有点懵,没见过这种情况,现在能确定的肯定是接口出问题,而且是偶发的,因为添加操作后也会刷新列表的请求,但是一直没出现这个问题。
只能从入口排查,于是,我在controller打上断点,然而,删除后的第二次列表接口请求并没有加进入到controller中。
看下nestjs的请求的执行流程:
既然没有进入controller,那肯定就是在前面某一个节点提前响应了请求,我的项目中只使用了拦截器和管道,在两个地方打上断点,结果,拦截器正常执行,但是管道没有被执行。OK,可以确定是拦截器的问题,但是项目中的自定义拦截器并没有对正常的请求直接返回的操作。过一遍项目的代码,发现有一个非自定义的拦截器:
@Module({imports: [],controllers: [],providers: [{provide: APP_INTERCEPTOR,useClass: CacheInterceptor},
});
这是在全局注入的缓存功能,然后在官网上看到这样的描述:
引入了缓存后,默认会缓存GET请求。
GET REQUEST 缓存
GET请求缓存这个行为,主要是为了减少短时间内大量相同的请求对服务端的负荷。那么nestjs具体是怎么去做这个缓存的,存储的规则是什么,如果不需要这个缓存,要怎么处理呢?
直接看CacheInterceptor
的源码:
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
:
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
方法:
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(生存时间)的方法:
const ttlValueOrFactory =this.reflector.get(CACHE_TTL_METADATA, context.getHandler()) ??this.reflector.get(CACHE_TTL_METADATA, context.getClass()) ??null;
就获取上下文中CACHE_TTL_METADATA
的配置的方法。在设置缓存的时候使用这个方法:
if (!isNil(ttl)) {args.push(this.cacheManagerIsv5OrGreater ? ttl : { ttl });
}
假如没有设置这时间,那么就会使用默认的缓存有效时间。这个时间是5s。
怎么解决
ok,原因理清楚了,那么怎么解决这个问题呢?
1.修改url
最简单的做法,既然使用url来做key,那么只要让每次请求的url不一样就行了,前端在每次请求的时候都带上一个时间戳改变url。
`/todo/get-list?v=${+new Date()}`
但是,简单归简单,这方法实在太不优雅了。
2.重载CacheInterceptor
既然是因为引入了CacheInterceptor
导致的问题,但是项目又需要缓存拦截器,那么自己写一个CacheInterceptor也是一个好办法:
import { CacheInterceptor } from '@nestjs/common';export class RequestCacheInterceptor extends CacheInterceptor {protected isRequestCacheable(): boolean {return false;}
}
这里我写了一个RequestCacheInterceptor
继承了CacheInterceptor
,然后重载了isRequestCacheable方法,直接返回false。其他的方法保持不变。然后引入这个Interceptor:
@Module({imports: [],controllers: [],providers: [{provide: APP_INTERCEPTOR,useClass: RequestCacheInterceptor},
});
这样就取消了GET请求的缓存。
方案扩展
上面通过重构CacheInterceptor
取消了GET请求的缓存,那如果部分接口需要缓存,部分不需要缓存(按需)要怎么实现呢?
1.清单管理
我们可以通过一个清单把需要缓存(或者不缓存)的接口管理起来,在CacheInterceptor
中判断是否需要缓存。
allowed-cache-api.ts
export default ['/todo/get-list'
];
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中加入清单:
@Controller('todo')
export class TodoController {constructor(private readonly service: Service) {}// 这里需要使用静态变量,否则在上下文内不好读取static allowedCacheApis = ['getList'];@AllowedCache()@Get('get-list')getList(): string {.....}
}
拦截器中:
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
import { SetMetadata, applyDecorators } from '@nestjs/common';// 允许缓存的装饰器
export function AllowedCache() {// 添加一个metadata信息allowedCache为true,表示允许缓存return applyDecorators(SetMetadata('allowedCache', true));
}
在接口中添加装饰器
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();}
}
拦截器中:
import {CacheInterceptor,ExecutionContext
} from '@nestjs/common';export class RequestCacheInterceptor extends CacheInterceptor {protected isRequestCacheable(context: ExecutionContext): boolean {// CacheInterceptor默认引入Reflector,可以通过Reflector获取上下文中写入meta dataconst allowed = this.reflector.get('allowedCache', context.getHandler());return !!allowed;}
}
总结
本文是由于一个非正常现象引起发的一系列的探索和思考的记录,如果有缺漏或错误,请不吝指出。