进阶全栈之路之 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 里面注册了一把以后,运行以后,应该会看到下图。可以看到继承的参数也是有在里面的。


进阶全栈之路之 nest 篇(二)Graphql&RESTful篇_第1张图片
graphql 运行界面效果

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: any, talbeName: string, queryParams: IQueryParams, customCondition: FindConditions): Promise> => {
  // 时间参数处理
  timeParamsHandle(customCondition, queryParams.filter);
  // 排序参数处理(这里默认按创建时间最新的排)
  const orderByCondition = orderParamsHandle(talbeName, queryParams.order);

  const [data, total] = await createQueryBuilder(T, talbeName)
    .skip(queryParams.pagination.page)
    .take(queryParams.pagination.limit)
    .where(customCondition)
    .orderBy(orderByCondition)
    .getManyAndCount();
  return new Pagination({ data, total });
};

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

  1. 如果不写@Roles()的注解,那么不做任何事情,直接跳过;
  2. 这里将restful的请求以及 graphql的请求合并在一起校验;
  3. 如果用户有这个角色,则返回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 | Observable {
    const roles = this.reflector.get('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 | Observable {

    // 获取登录的注解
    const loginAuth = this.reflector.get('login-auth', context.getHandler());

    // 在这里取metadata中的no-auth,得到的会是一个bool
    const noAuth = this.reflector.get('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 是被推荐的,在使用过程中,对于之前的使用中,之前有遇到过一些坑。

  1. 对于restful 接口而已,当时我是用postman 试的接口,由于没有写 "Content-Type: application/json",导致一直报错,调试库代码的时候,发现根本么有进去,库对于这种情况也没有任何报错,记得写这个。
  2. @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 {
  constructor(@InjectRepository(UserConfig) private userConfigRepository: Repository) {}
  generateDataLoader(): DataLoader {
    return new DataLoader(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) {
    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.

进阶全栈之路之 nest 篇(二)Graphql&RESTful篇_第2张图片
DataLoader运行界面

graphql 的 query 数据层数过多,来回嵌套问题。

之前我们这有遇到过这个问题,get 请求的时候,返回是 200。但是数据一直回不来,报错为net::ERR_CONTENT_LENGTH_MISMATCH 200 (OK),最后发现,是这个查询已经很多字段了,100多个吧,后面来了一个实习生,又在这来回嵌套了很多字段。为了避免这种来回嵌套,查询字段太多,导致服务器性能下降的情况。有说限制请求体大小的,有说限制层级(也就是深度)的。后面发现nest 有一个复杂度的库,他解决的也就是如下图的问题。

进阶全栈之路之 nest 篇(二)Graphql&RESTful篇_第3张图片
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>'.)

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 implements NestInterceptor> {
  intercept(context: ExecutionContext, next: CallHandler): Observable> {
    return next.handle().pipe(
      map(data => {
        return {
          data,
          code: 200,
          message: 'success',
        };
      }),
    );
  }
}

应该做如下的额外操作:

interface Response {
  data: T;
}

/**
 * 封装正确的返回格式
 * {
 *  data,
 *  code: 200,
 *  message: 'success'
 * }
 */
@Injectable()
export class TransformInterceptor implements NestInterceptor> {
  intercept(context: ExecutionContext, next: CallHandler): Observable {
    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', '[email protected]', 'b5d57af1-7118-48c4-ac75-7bb282d5a5b2');

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

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

你可能感兴趣的:(进阶全栈之路之 nest 篇(二)Graphql&RESTful篇)