精彩文章免费看

进阶全栈之路之 nest 篇(二)Graphql&RESTful篇

Nest + TypeOrm + Postgres + GraphQL(DataLoader) + Restful + Redis + MQ + Jest

扯: 这都是满满的干货,是一个入口即话的饼。如同饥饿的你,遇上美食;干渴的你,遇上甘露;平穷的你,遇上金钱!!!

所需环境:Node(v12.19.0)、Redis、postgres

瞎比到此,本偏地址

graphql 的使用

  • 这里使用js写过graphql人盆友应该懂,需要维护两遍字段,ts则不需要。
  • 使用TS生成 graphql的 type代码。这里可以用数据库表加注解的方式生成掉。
  • @Field({ nullable: true })可以省略, 有个默认标记为 nullable为 true的就是了(不是很推荐)。
  • // ObjectType 这里说的是 User 将会转化成 graphql 的 type格式的代码
    // NoIdBase 是我的一个基础类:里面包含了,createTime,deleteTime,等等也是一个带ObjectType注解的;类
    @ObjectType()
    @Entity('user')
    export class User extends NoIdBase {
      @Field({ nullable: true, description: 'id' })
      @PrimaryColumn()
      id: string;
      @Index({})
      @Field({ nullable: true })
      @Column({ comment: 'name', nullable: true })
      name: string;
      @Field({ nullable: true })
      @Column({ comment: '角色id', nullable: true })
      roleNo: string;
      @Field({ nullable: true })
      @Index({ unique: true })
      @Column({ comment: '邮箱', nullable: true })
      email: string;
    

    这里随便写了两个例子,一个是Query的,一个Mutation的,QueryParams 是我封装的一个类型,用于传参,排序和分页的。

      @Query(() => [User], { description: '查询用户列表' })
      async users(@Args('queryParams') { filter, order, pagination }: QueryParams) {
        return await this.userService.getUserList({ pagination, filter, order });
      @Mutation(() => User, { description: '我是备注' })
      async createUser() {
        return this.userService.getUserList(null);
    
      // app.module.ts 注册一把
      imports: [
        GraphQLModule.forRoot({
          autoSchemaFile: true,
          resolvers: { JSON: GraphQLJSON },
    

    在app.module.ts 里面注册了一把以后,运行以后,应该会看到下图。可以看到继承的参数也是有在里面的。

    graphql、restful的 封装分页查询

    这里就大概说一下,封装的分页的思路和大概的代码。
    对于很多admin表来说,一般都会有 字段、排序、和分页的条件查询。基础的查询表除了表名称不一样,其他都是一致的。

    // restful写法
    @body('queryParams') { filter, order, pagination }: QueryParams
    // graphql写法
    @Args('queryParams') { filter, order, pagination }: QueryParams
    

    QueryParams 类的详情如下,当然,graphql 和 restful 简单处理一下,都能是一样的。只是一些是注解赋默认值,一些是class 赋默认值。

    @ObjectType()
    @InputType({})
    export class QueryParams implements IQueryParams {
      @Field(() => graphqlTypeJson, { nullable: true })
      filter?: JSON;
      @Field(() => graphqlTypeJson, { nullable: true })
      order?: JSON;
      @Field(() => PageInput, { nullable: true, defaultValue: { page: 0, limit: 10 } })
      pagination?: PageInput;
    

    这个就是基本表的查询封装的泛型函数,直接把表的class放入参数即可,详情请看代码。这个是基于Typeorm 的postgres 数据库做的封装,如果是monogo啥的,参数就让前端传就好了,很多处理就不用了,请自行改一把就好了。‘’

    * 通用Query查询接口 * @param T 实体表 class * @param tableName 表名称 * @param queryParams 前端传递参数 * @param customCondition 自定义条件 export const generalList = async <T>(T: any, talbeName: string, queryParams: IQueryParams, customCondition: FindConditions<T>): Promise<Pagination<T>> => { // 时间参数处理 timeParamsHandle(customCondition, queryParams.filter); // 排序参数处理(这里默认按创建时间最新的排) const orderByCondition = orderParamsHandle(talbeName, queryParams.order); const [data, total] = await createQueryBuilder<T>(T, talbeName) .skip(queryParams.pagination.page) .take(queryParams.pagination.limit) .where(customCondition) .orderBy(orderByCondition) .getManyAndCount(); return new Pagination<T>({ data, total });

    封装角色:全局守卫,大概功能如下(每个请求都会过这里)

  • 如果不写@Roles()的注解,那么不做任何事情,直接跳过;
  • 这里将restful的请求以及 graphql的请求合并在一起校验;
  • 如果用户有这个角色,则返回true,否则返回没有权限;
  • import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
    import { Observable } from 'rxjs';
    import { Reflector } from '@nestjs/core';
    import { User } from '../../entity/user/user.entity';
    import { GqlExecutionContext } from '@nestjs/graphql';
    @Injectable()
    export class RolesGuard implements CanActivate {
      constructor(private readonly reflector: Reflector) {}
      canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
        const roles = this.reflector.get<string[]>('roles', context.getHandler());
        // 无角色注解时,api任何角色都能访问
        if (!roles) {
          return true;
        let user: User;
        const request = context.switchToHttp().getRequest();
        if (request) {
          user = request.user;
        } else {
          const ctx = GqlExecutionContext.create(context);
          const graphqlRequest = ctx.getContext().req;
          user = graphqlRequest.user;
        // 如果用户包含这个角色则返回true
        const hasRole = () => user.rolesList.some((role: string) => roles.indexOf(role) > -1);
        return user && user.rolesList && hasRole();
    

    使用: 在 AppModule 中导入使用即可。这里 GlobalAuthGuard 放前头全,因为 Roles 需要用到上下文里的user。

    // AppModule
      // xxx此处省略很多
      providers: [
          // 设置校验方式
          provide: APP_GUARD,
          useClass: GlobalAuthGuard,
          // 设置全局角色守卫
          provide: APP_GUARD,
          useClass: RolesGuard,
      controllers: [],
    export class AppModule {}
    

    封装守卫:全局守卫(每个请求都会过这里,这里做的是jwt和本地的登录校验,以及如果不需要验证,则直接跳过)

  • 大部分的接口都的需要验证身份(如果没有写啥注解的话,默认都是走jwt校验的);
  • 登录接口需要走本地的验证(我这里封装了一个注解为:@LoginAuth(),如果是登录,直接走本地校验);
  • 不需要验证的接口(我这里封装了一个注解为:@NoAuth(),如果有这个注解,直接返回true,不做校验);
  • // GlobalAuthGuard 全局写,能减少很多代码量
    @Injectable()
    export class GlobalAuthGuard implements CanActivate {
      constructor(private readonly reflector: Reflector) {}
      canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
        // 获取登录的注解
        const loginAuth = this.reflector.get<boolean>('login-auth', context.getHandler());
        // 在这里取metadata中的no-auth,得到的会是一个bool
        const noAuth = this.reflector.get<boolean>('no-auth', context.getHandler());
        if (noAuth) {
          return true;
        const guard = GlobalAuthGuard.getAuthGuard(loginAuth);
        // 执行所选策略Guard的canActivate方法
        return guard.canActivate(context);
      // 根据NoAuth的t/f选择合适的策略Guard
      private static getAuthGuard(loginAuth: boolean): IAuthGuard {
        if (loginAuth) {
          return new LocalAuthGuard();
        } else {
          return new JwtAuthGuard();
    

    使用: 在 AppModule 中导入使用即可。

    // AppModule
      // xxx此处省略很多
      providers: [
          // 设置校验方式
          provide: APP_GUARD,
          useClass: GlobalAuthGuard,
      controllers: [],
    export class AppModule {}
    

    这里说句,其实UseGuards 也是有顺序的,假如你就是要自己单独使用 UseGuards 在各个controller 或者 resolver 中,如果有两个 Guards ,一个jwt验证,一个role验证权限验证。那么应该是 jwt先执行,role后执行,那么这里是:@UseGuards(JwtAuthGuard,RolesGuard)

    JwtAuthGuard 的 graphql 问题

    如果不对graphql的上下文做处理,这里将会报错:* "message": "Unknown authentication strategy "jwt"",* 所以,代码如下:

    import { ExecutionContext, Injectable } from '@nestjs/common';
    import { GqlExecutionContext } from '@nestjs/graphql';
    import { AuthGuard } from '@nestjs/passport';
    @Injectable()
    export class JwtAuthGuard extends AuthGuard('jwt') {
      getRequest(context: ExecutionContext) {
        const restfulRequest = context.switchToHttp().getRequest();
        const ctx = GqlExecutionContext.create(context);
        const graphqlRequest = ctx.getContext().req;
        if (restfulRequest) {
          // restful
          return restfulRequest;
        } else if (graphqlRequest) {
         // graphql
          return graphqlRequest;
    

    Graphql 使用 @nestjs/passport的 login 问题

    在Nest.js官方文档中,@nestjs/passport 是被推荐的,在使用过程中,对于之前的使用中,之前有遇到过一些坑。

  • 对于restful 接口而已,当时我是用postman 试的接口,由于没有写 "Content-Type: application/json",导致一直报错,调试库代码的时候,发现根本么有进去,库对于这种情况也没有任何报错,记得写这个。
  • @nestjs/passport 如果使用graphql的时候,发现是不行的,因为这个的上下文,在库里面的代码就是写死的,使用的restful的上下文,restful 的上下问是通过: context.switchToHttp().getRequest() 获取。如果使用graphql时是,发现null。所以,不能用graphql 去登录。
  • graphql 的 data-loader问题

  • graphql 本身采用挂载的问题,这样就有一个n+1问题,假如一个用户下面有一个用户配置表,查10个用户,就要查10次配置表,这样导致用户表查了一次,配置表查了10次。如果挂多了子域,就会查询很慢很慢。data-loader解析出来以后是一个 id in [xxx,xxx,xxx] 这样的形式做的,只查一次。
  • 这里采用的是 nestjs-dataloader 基于 dataloader封装的库,这里还和作者比叨逼叨了一会,原来理解错了。nestjs-dataloader的例子是这样的:
  • import { Module } from '@nestjs/common';
    import { APP_INTERCEPTOR } from '@nestjs/core';
    import {DataLoaderInterceptor} from 'nestjs-dataloader'
    @Module({
      providers: [
        AccountResolver,
        AccountLoader,
          provide: APP_INTERCEPTOR,
          useClass: DataLoaderInterceptor,
    export class ResolversModule { }
    

    当你再写一个的时候,也就是这样的时候:

        AccountResolver,
        UserResolver,
    

    这样就会报:Nest could not find xxx element (this provider does not exist in the current context),作者说这个 DataLoaderModule 模块是一个全局的模块,他设计的时候的里面是只注册一次的 。后面交流了一番大概是这样写,(具体交流详情点这里 issues):

    @Module({
      imports: [TypeOrmModule.forFeature([XXX1, XXX2, XX3])],
      providers: [
        xxx1DataLoader,
        xxx2DataLoader,
        xxx3DataLoader,
        xxx4DataLoader,
        xxx5DataLoader,
        xxx6DataLoader,
          provide: APP_INTERCEPTOR,
          useClass: DataLoaderInterceptor,
    export class DataLoaderModule {}
    

    这里的DataLoader还得注意这个点:keys.map 不能省略,就是如果10个key去查,那么必须返回10个,即使它是null的,如果没有的话,它将不知道怎么去对应数据。将会报错。还有一个 import * as DataLoader from 'dataloader'; 记得这么写,好像不这么写有的时候会报错(具体交流详情点这里 issues)。

    import * as DataLoader from 'dataloader';
    import { NestDataLoader } from 'nestjs-dataloader';
    @Injectable()
    export class UserConfigDataLoader implements NestDataLoader<string, UserConfig> {
      constructor(@InjectRepository(UserConfig) private userConfigRepository: Repository<UserConfig>) {}
      generateDataLoader(): DataLoader<string, UserConfig> {
        return new DataLoader<string, UserConfig>(async (keys: string[]) => {
          const loadedEntities = await this.userConfigRepository.find({ userId: In(keys) });
          return keys.map(key => loadedEntities.find(entity => entity.userId === key));
    

    使用方法如下:users 查询

      //  返回userList 列表
      @Query(() => [User], { description: '查询用户列表' })
      async users() {
        return  await userRepo.find();
    

    它底下挂一个userConfig对象。 @ResolveField()的意思是说它是一个子域;@Parent()说它的父级为User

    * 获取用户配置 @ResolveField() async userConfig(@Parent() user: User, @Loader(UserConfigDataLoader.name) dataLoader: DataLoader<string, UserConfig>) { return await dataLoader.load(user.id);

    子域挂载的时候一定要在 父级class 定义变量。 不写这个要不然会报* Error: Undefined type error. Make sure you are providing an explicit type for the "userConfig" of the "UserResolver" class.

    6RqfhK8.png

    这里用这个graphql-query-complexity 库的话,一个字段为一个复杂度(默认设置为1),然后只要你限制复杂度最大值就好,不管你嵌套多少层。防止你query的字段太多,来回嵌套的情况。

    query {
      author(id: "abc") {    # complexity: 1
          title              # complexity: 1
    

    大概设置是这样的(记得更新到npm包,如果不是的话,有一个版本可能会报,error TS2420: Class 'ComplexityPlugin' incorrectly implements interface 'ApolloServerPlugin<Record<string, any>>'.)

    import { HttpStatus } from '@nestjs/common';
    import { GraphQLSchemaHost, Plugin } from '@nestjs/graphql';
    import { ApolloServerPlugin, GraphQLRequestListener } from 'apollo-server-plugin-base';
    import { fieldExtensionsEstimator, getComplexity, simpleEstimator } from 'graphql-query-complexity';
    import { CustomException } from '../http-handle/custom-exception';
    @Plugin()
    export class ComplexityPlugin implements ApolloServerPlugin {
      constructor(private gqlSchemaHost: GraphQLSchemaHost) {}
      requestDidStart(): GraphQLRequestListener {
        const { schema } = this.gqlSchemaHost;
        return {
          didResolveOperation({ request, document }) {
            const complexity = getComplexity({
              schema,
              operationName: request.operationName,
              query: document,
              variables: request.variables,
              estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })],
            // 一个 graphql 字段为一个复杂度,最多不能超过50个字段.
            if (complexity > 50) {
              throw new CustomException(`GraphQL query is too complex: ${complexity}. Maximum allowed complexity: 50`, HttpStatus.BAD_REQUEST);
      // app.module.ts 中
      providers: [
        ComplexityPlugin,
    

    如果 graphql 用了 transform.interceptor 注意啦!

    // 像这个封装的话,只对resetful进行了封装,并没有对graphql的返回进行封装,而apollo 的返回是需要一个data的。如果没有的话会报:Expected Iterable, but did not find one for field "Query.xxx".",

    * 封装正确的返回格式 * data, * code: 200, * message: 'success' @Injectable() export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> { intercept(context: ExecutionContext, next: CallHandler<T>): Observable<Response<T>> { return next.handle().pipe( map(data => { return { data, code: 200, message: 'success',

    应该做如下的额外操作:

    interface Response<T> {
      data: T;
     * 封装正确的返回格式
     *  data,
     *  code: 200,
     *  message: 'success'
    @Injectable()
    export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
      intercept(context: ExecutionContext, next: CallHandler<T>): Observable<any> {
        const ctx = GqlExecutionContext.create(context);
        const graphqlRequest = ctx.getContext().req;
        const restfulRequest = context.switchToHttp().getRequest();
        if (restfulRequest) {
          return next.handle().pipe(
            map(data => {
              return {
                data,
                code: 200,
                message: 'success',
        } else if (graphqlRequest) {
          return next.handle().pipe(tap());
    

    如果本地要运行,请把跟目录下面的config/dev 改成知道的数据库连接即可。
    synchronize: true的时候,是会同步表,自动建表的。千万别在prod的时候开这个。

    export default {
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      username: 'postgres',
      password: 'postgres',
      database: 'liang',
      timezone: 'UTC',
      charset: 'utf8mb4',
      synchronize: true,
      logging: false,
      autoLoadEntities: true,
    

    送佛送到西,再给一个数据库脚本吧。

    // userConfig
    INSERT INTO "public"."userConfig"("id", "createTime", "updateTime", "deleteTime", "version", "userId", "fee", "feeType") VALUES ('c7e35f7c-6bd2-429e-a65c-19440525e321', '2020-11-22 10:42:57', '2020-11-22 10:43:00.07196', NULL, 1, 'b5d57af1-7118-48c4-ac75-7bb282d5a5b2', '10', '我是枚举String');
    // user
    INSERT INTO "public"."user"("createTime", "updateTime", "deleteTime", "version", "name", "phone", "roleNo", "locked", "email", "id") VALUES ('2020-11-21 20:09:42', '2020-11-21 20:10:42.834375', NULL, 1, '梁梁', '18668436515', '100', 'f', '1449681915@qq.com', 'b5d57af1-7118-48c4-ac75-7bb282d5a5b2');
    

    最近比较忙,希望下一篇,不会像这一课一样,写这么久了。

    纯原创以及手写,github希望大家点个star。谢谢。

    最后编辑于:2020-11-22 11:13