likes
comments
collection
share

Nestjs:关于请求缓存这件事

作者站长头像
站长
· 阅读数 12

背景

项目使用nestjs作为服务端开发框架,使用了内置的缓存功能。项目内有很多功能都是清单类的,清单列表在编辑或删除后,通过请求刷新列表信息时,会出现获取的数据与为修改前的数据一致的情况。 具体现象: 待办模块中有获取待办列表的接口:/todo/get-list,删除待办接口:/todo/delete-todo 在删除待办的场景中,前端先请求/todo/delete-todo删除指定待办,接口返回成功后请求/todo/get-list刷新待办列表。这时候就会出现列表不更新的情况。

原因分析

第一时间我以为是删除的逻辑有问题,查看代码没问题,调试删除接口,执行没问题,包括数据库中的数据也是删除成功,删除的接口没问题。 接口没问题,那么会不会是前端列表更新的问题?删除成功获取到新列表数据后,页面数据没更新导致的?这个也很快就排除,数据获取没问题,页面更新没问题。 那就剩下列表接口的问题,在进入页面时,第一次请求列表接口,返回5条待办数据,然后删除最后一条待办,再次请求列表接口,还是返回5条待办数据。wtf。。。

进一步分析

首先判断列表数据查询是否有问题,因为删除是软删除,先排除查询条件错误的问题。查询条件并没有问题。 排除查询条件问题后,接下来排除是否是返回数据处理逻辑的问题,然而,数据处理逻辑只是做了简单的加工,没有进一步查询补充数据的操作。 到这里我有点懵,没见过这种情况,现在能确定的肯定是接口出问题,而且是偶发的,因为添加操作后也会刷新列表的请求,但是一直没出现这个问题。 只能从入口排查,于是,我在controller打上断点,然而,删除后的第二次列表接口请求并没有加进入到controller中。 Nestjs:关于请求缓存这件事

看下nestjs的请求的执行流程: Nestjs:关于请求缓存这件事

既然没有进入controller,那肯定就是在前面某一个节点提前响应了请求,我的项目中只使用了拦截器和管道,在两个地方打上断点,结果,拦截器正常执行,但是管道没有被执行。OK,可以确定是拦截器的问题,但是项目中的自定义拦截器并没有对正常的请求直接返回的操作。过一遍项目的代码,发现有一个非自定义的拦截器:

@Module({
  imports: [
  ],
  controllers: [],
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: CacheInterceptor
    },
});

这是在全局注入的缓存功能,然后在官网上看到这样的描述: Nestjs:关于请求缓存这件事 引入了缓存后,默认会缓存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。

Nestjs:关于请求缓存这件事

怎么解决

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,其namegetList

虽然这么处理可以把清单分散到各个入口中自行管理,但是感觉还是不够优雅,有没有更优雅的解决方案呢?

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 data
    const allowed = this.reflector.get('allowedCache', context.getHandler());

    return !!allowed;
  }
}

总结

本文是由于一个非正常现象引起发的一系列的探索和思考的记录,如果有缺漏或错误,请不吝指出。