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: 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 });
};
封装角色:全局守卫,大概功能如下(每个请求都会过这里)
- 如果不写@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 | 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 是被推荐的,在使用过程中,对于之前的使用中,之前有遇到过一些坑。
- 对于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 {
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.
graphql 的 query 数据层数过多,来回嵌套问题。
之前我们这有遇到过这个问题,get 请求的时候,返回是 200。但是数据一直回不来,报错为net::ERR_CONTENT_LENGTH_MISMATCH 200 (OK),最后发现,是这个查询已经很多字段了,100多个吧,后面来了一个实习生,又在这来回嵌套了很多字段。为了避免这种来回嵌套,查询字段太多,导致服务器性能下降的情况。有说限制请求体大小的,有说限制层级(也就是深度)的。后面发现nest 有一个复杂度的库,他解决的也就是如下图的问题。
这里用这个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。谢谢。