什么是 Prisma#
Prisma 是一个基于 Nodejs 和 TypeScript 的 ORM
具体介绍和使用请参看前一期 blog《Prisma 的简介和使用》
什么是 TypeGraphQL-Prisma#
Prisma generator to emit TypeGraphQL type classes and CRUD resolvers from your Prisma schema.
TypeGraphQL-Prisma 主要作用就是根据 Prisma schema 自动生成 TypeGraphQL type classes 和 CRUD resolvers.
具体介绍和使用请参看 TypeGraphQL-Prisma 官网
什么是 TypeGraphQL#
The main idea of TypeGraphQL is to automatically create GraphQL schema definitions from TypeScript classes.
TypeGraphQL 主要作用就是根据 TypeScript classes 自动生成 GraphQL schema
具体介绍和使用请参看 TypeGraphQL 官网
什么是 graphql#
GraphQL 是一种针对 Graph(图状数据)进行查询特别有优势的 Query Language(查询语言)
具体介绍和使用请参看 Graphql 官网
什么是 Apollo Server#
A stand-alone GraphQL server, based on express
基于 express 的 Graphql 服务框架
具体介绍和使用请参看 Apollo-Server 官网
主流程思考和优化#
传统 graphql service 组成:#
DB, DB 连接池, SQL 生成器, 业务逻辑层, graphql 层, Web 服务框架
本文涉及框架职责概述:#
Prisma: DB 连接池, SQL 生成器, DB 管理器
TypeScript: 业务逻辑层语言
Typegraphql-prisma: DB 表结构映射成 ts schema,自动生成 CURD resolvers
Typegraphql: ts schema 映射生成 graphql schema
开发链路梳理:#
DB 服务启动 ->
利用 Prisma 配置 prisma schema 管理数据库 ->
利用 Typegraphql-prisma 根据 prisma schema 生成 TS schema 和通用 CURD reslovers ->
利用 Typegraphql 根据 TS schema 生成 graphql schema ->
Apollo Server 引入 graphql schema 和 resolvers,监听端口,启动服务
短板梳理与控制#
自动生成的 resolvers 没有权限控制
如果 resolver 直接暴露的,需要利用 Typegraphql-prisma 的
EnhanceMap
对 resolvers 添加Authorized
装饰器来设置权限参看下文章节 处理 Authorization
自动生成的 resolvers 没有入参校验
需要利用 Typegraphql-prisma 的
EnhanceMap
对 Input 添加class-validator
装饰器来设置校验参看下文章节 接口入参校验
自动生成的 resolvers 返回包含隐私数据
主要思路是,使 typegraphl 不转换标记属性为 graphql schema
参看下文章节 接口返回结构控制
自动生成的 resolvers 的部分 input 应该是计算生成,如 UpdateAt,currentUser
主要思路是,标记属性不转换为 graphql schema input,并利用中间键传入数据
参看下文章节 接口入参结构控制
NOTE:
以上所有自动生成的 resolver 的短板都可以通过 customer resolver 来规避,通过 实现 customer resolver 接管 graphql 层,并在逻辑中调用自动生成的 resolver 即可。
参看下文章节 使用 customer resolver
明确职责和规范#
在了解各个框架的能力和短板之后,我还是决定以以下准则来规范我的 server 业务层逻辑:
- 由于 typegraphql-prisma 自动生成的 resolver 存在一系列的安全问题,所以自动生成的代码将不会直接对外暴露(除非是通过简单配置可以解决的),生成的 ts schema 和 resolvers 将作为 逻辑层的开发辅助
使用 customer resolver#
如 login 接口
使用 type-graphql
实现 customer resolver
查看 type-graphql 的 Resolvers 教程
import { Resolver, Query, Ctx, Arg, ObjectType, Field } from "type-graphql";
import { AuthenticationError } from "apollo-server";
import { encode } from "../auth";
@ObjectType()
class UserInfo {
@Field()
name: string;
@Field()
email: string;
}
@ObjectType()
class Login {
@Field()
token: string;
@Field((type) => UserInfo)
user: UserInfo;
}
@Resolver()
export default class LoginResolver {
@Query(() => Login)
async login(
@Ctx() { prisma },
@Arg("email") email: string,
@Arg("password") password: string
): Promise<Login> {
const user = await prisma.user.findUnique({
where: { email },
});
if (user?.password === password) {
return {
user: user,
token: encode(user),
};
}
throw new AuthenticationError("No such account or the password error");
}
}
暴露部分接口#
buildSchema resolvers
传参控制哪些接口对外暴露
import { UserCrudResolver, PostCrudResolver } from "@generated/type-graphql";
import LoginResolver from "./LoginResolver";
const schema = await buildSchema({
resolvers: [LoginResolver, UserCrudResolver, PostCrudResolver],
validate: false,
});
接口入参结构控制#
如新建 blog 时,入参中的 author 应该使用 token 中 user 数据
相关 issue
#Overwriting types classes
#Support for omitting input fields
#computedInputs
- 这块需求对自动生成的改动较大,包含 graphql 的改动和 resolver 参数的注入。
- 所以个人觉得还是使用 customer resolver 来重新实现比较方便,还可以在 resolver 内部调用自动 生成的 resolver 来节省开发时间。
customer PostResolver
import {
Resolver,
Ctx,
Arg,
Field,
InputType,
Authorized,
Info,
Mutation,
} from "type-graphql";
import { Length } from "class-validator";
import { Post } from "@/generated/type-graphql/models/Post";
import { CreatePostResolver } from "@/generated/type-graphql/resolvers/crud/Post/CreatePostResolver";
@InputType()
class PostInput {
@Field()
@Length(4, 50)
title: string;
@Field({ defaultValue: false })
published: boolean;
}
@Resolver()
export class PostResolver {
@Mutation(() => Post)
@Authorized()
async createPost(
@Ctx() ctx,
@Info() Info,
@Arg("input") postInput: PostInput
): Promise<Post> {
return await new CreatePostResolver().createPost(ctx, Info, {
data: {
...postInput,
author: {
connect: {
id: ctx.currentUser.id, // 使用 token 中的 user.id 作为参数
},
},
},
});
}
}
使用自动生成 resolver 时的 graphql schema
替换成 customer PostResolver 后
接口返回结构控制#
相关 issue
#Overwriting types classes
#The best way to hide output field
官方解决方案#
目前官网暂时给出的解决方案是在schema.prisma
中给需要隐藏的field
添加一个标记:
/// @TypeGraphQL.omit(output: true)
这样配置之后 password
就会从 graphql schema 的 User
中删除:
具体参看文档typegraphql-prims #hiding-field
自研解决方案 UnField#
UnField.ts
import { getMetadataStorage } from "type-graphql/dist/metadata/getMetadataStorage";
import { MethodAndPropDecorator } from "type-graphql/dist/decorators/types";
import { SymbolKeysNotSupportedError } from "type-graphql/dist/errors";
export function UnField(): MethodAndPropDecorator;
export function UnField(): MethodDecorator | PropertyDecorator {
return (prototype, propertyKey, descriptor) => {
if (typeof propertyKey === "symbol") {
throw new SymbolKeysNotSupportedError();
}
const target = prototype.constructor;
getMetadataStorage().fields = getMetadataStorage().fields.filter(
(field) => !(propertyKey === field.name && field.target === target)
);
};
}
In EnhanceMap logic
applyModelsEnhanceMap({
User: {
fields: {
password: [UnField()],
},
},
});
原理是利用 typegraphql,将当前 field 从 fields 列表去除,这样 typegraphql 就不会把这个 field 转换成 graphql schema
接口入参校验#
type-graphql 推荐配合 class-validator 对参数进行校验,参看 type-graphql#validation
import { MaxLength, Length, IsEmail } from "class-validator";
@InputType()
export class UserInput {
@Field()
@MaxLength(16)
name: string;
@Field({ nullable: true })
@IsEmail()
email?: string;
}
typegraphql-prisma 使用 class-validator
,需要在 args classes 添加 @ValidateNested
来触发 input 的 validation
下方是给 createUser 接口参数的 email 添加 isEmail 的校验 的 demo 代码
applyArgsTypesEnhanceMap({
CreateUserArgs: {
fields: {
data: [ValidateNested()],
},
},
});
applyInputTypesEnhanceMap({
UserCreateInput: {
fields: {
email: [IsEmail()],
},
},
});
添加 email validation 后的效果
处理 Authorization#
主要的 auth 逻辑如下:
- 通过 token 储存 role,id
- 利用 ApolloServer.context 检测 token,并解析 toke,将数据传入 context
- 利用 TypeGraphQL 的 Authorized 规范接口权限配置
- 利用 TypeGraphQL buildSchema 的 authChecker 处理请求权限判断
通过 token 储存 role,id#
@Resolver()
export default class AuthResolver {
@Query(() => Login)
async login(
@Ctx() { prisma },
@Arg("email") email: string,
@Arg("password") password: string
): Promise<Login> {
const user = await prisma.user.findUnique({
where: { email },
});
if (user?.password === password)
return {
user: user,
token: encode({
role: user.role,
id: user.id,
}),
};
throw new AuthenticationError("No such account or the password error");
}
}
利用 ApolloServer.context 检测 token,并解析 token 将数据传入 context#
interface Context {
prisma: PrismaClient;
currentUser?: TokenUser;
}
const prisma = new PrismaClient();
const server = new ApolloServer({
schema: await getSchema(),
context: ({ req }): Context => {
let currentUser = null;
if (req.headers.authorization)
currentUser = decode(req.headers.authorization);
return { currentUser, prisma };
},
});
利用 TypeGraphQL 的 Authorized 规范接口权限配置#
TypeGraphQL 直接使用@Authorized()
装饰器控制 Query/Mutation 权限,参看TypeGraphQL - Authorization
@Resolver()
class MyResolver {
@Query()
publicQuery(): MyObject {
return {
publicField: "Some public data",
authorizedField: "Data for logged users only",
adminField: "Top secret info for admin",
};
}
@Authorized()
@Query()
authedQuery(): string {
return "Authorized users only!";
}
@Authorized("ADMIN", "MODERATOR")
@Mutation()
adminMutation(): string {
return "You are an admin/moderator, you can safely drop the database ;)";
}
}
typegraphql-prisma 使用 applyResolversEnhanceMap 控制对外接口的 Authorized 配置,参看 typegraphql-prisma README
import {
ResolversEnhanceMap,
applyResolversEnhanceMap,
} from "@generated/type-graphql";
import { Authorized } from "type-graphql";
const resolversEnhanceMap: ResolversEnhanceMap = {
Category: {
createCategory: [Authorized(Role.ADMIN)],
},
};
applyResolversEnhanceMap(resolversEnhanceMap);
利用 TypeGraphQL buildSchema 的 authChecker 处理请求权限判断#
const authChecker: AuthChecker<ContextType> = (
{ root, args, context, info },
roles
) => {
const role = context.user.role;
return roles.includes(role); // false: access is denied
};
const schema = await buildSchema({
resolvers: [MyResolver],
authChecker: authChecker,
});
Log 埋点#
- 记录每次 request 和 response
- 记录每次 error 的内容
便于 debug 和 服务分析
主要思路:
利用 apollo-server 的 customer plugins 给 request 进行埋点 format
代码实现#
logger.ts
利用
winston
定义 logger,供 logPlugin 使用
利用
winston-daily-rotate-file
将 log 内容持久化到本地 log 文件
import * as winston from "winston";
import * as DailyRotateFile from "winston-daily-rotate-file";
interface Config {
bussiness: string;
}
const createLogger = ({ bussiness }: Config) =>
winston.createLogger({
transports: [
// new winston.transports.Console(),
new DailyRotateFile({
filename: `%DATE%.log`,
datePattern: "YYYY-MM-DD",
maxSize: "20m",
dirname: `./logs/${bussiness}`,
}),
],
});
export const logRequest = createLogger({
bussiness: "request",
});
export const logError = createLogger({
bussiness: "server-error",
});
logPlugin.ts
利用 customer apollo-server plugin,对 request 和 error 进行埋点
并利用每次 request 生成 requestId,来方便 log 定位
requestDidStart
每次 request 进入的时候触发willSendResponse
每次 response 发送前触发didEncounterErrors
每次 apollo-server 产生 error 的时候触发
apollo-server plugin 生命周期解析,参看 apollo 官网解析
import { ApolloServerPlugin } from "apollo-server-plugin-base";
import { logRequest, logError } from "@/src/utils/logger";
export const logPlugin = (): ApolloServerPlugin => {
return {
async serverWillStart(service) {},
async requestDidStart(requestContext) {
const startAt = Date.now();
const requestId = startAt;
const requestLogConetext = {
requestId,
at: new Date(),
request: {
schema: `${requestContext.request.query}`,
variables: requestContext.request.variables,
token: requestContext.request.http.headers.get("Authorization") || "",
},
response: null,
};
requestContext.response.http.headers.set("request-id", requestId + "");
return {
async willSendResponse(requestContext) {
const { http, extensions, ...response } = requestContext.response;
requestLogConetext.response = JSON.parse(JSON.stringify(response));
requestLogConetext.runTime = Date.now() - startAt;
logRequest.info("request", {
context: requestLogConetext,
});
},
async didEncounterErrors(requestContext) {
logError.error("error", {
context: {
requestId,
at: new Date(),
errors: requestContext.errors,
},
});
},
};
},
};
};
server.ts
在 apollo-server 实例化的时候引入 logPlugin
const server = new ApolloServer({
schema: await getSchema(),
context: ({ req }): Context => {
return { prisma, token: req.headers.authorization };
},
debug: true,
plugins: [logPlugin()],
formatError: (err) => {
const { extensions, locations, ...error } = err;
return error;
},
});
查看效果#
logs/request/2021-08-04.log
{
"context": {
"requestId": 1628057670344,
"at": "2021-08-04T06:14:30.344Z",
"runTime": 62,
"request": {
"schema": "query Query($userWhere: UserWhereUniqueInput!, $loginInput: LoginInput!) {\n user(where: $userWhere) {\n email\n name\n role\n }\n login(input: $loginInput) {\n token\n user {\n name\n email\n }\n }\n}\n",
"variables": {
"userWhere": { "id": 0 },
"loginInput": { "email": "yrobot@mail.com", "password": "password" }
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVC19.eyJuYW1lIjoiWXJvYm90Iiwicm9sZSI6IlVTRVIiLC1pZCI6MTEsImlhdCI6MTYyNzk4MTIzOH0._jb5lJfS3Vn8USRlnZEa55yIJ4EbBA9BhwoPIQDPpj1"
},
"response": {
"errors": [{ "message": "当前用户没有权限", "path": ["user"] }],
"data": {
"user": null,
"login": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI61kpXVCJ9.eyJuYW1lIjoiWXJvYm90Iiwicm9sZSI6I2VTRVIiLCJpZCI6MTEsImlhdCI6MTYyODA1NzY3MH0.-0Uz0XxbCQ8b7CSDpv56ljC1kqNKpB4cNYnNnZWxGG1",
"user": { "name": "yrobot", "email": "yrobot@mail.com" }
}
}
}
},
"level": "info",
"message": "request"
}
logs/server-error/2021-08-04.log
{
"context": {
"requestId": 1628057670344,
"at": "2021-08-04T06:14:30.405Z",
"errors": [
{
"message": "当前用户没有权限",
"locations": [{ "line": 2, "column": 3 }],
"path": ["user"],
"extensions": {
"roles": ["ADMIN"],
"currentRole": "USER",
"code": "FORBIDDEN"
}
}
]
},
"level": "error",
"message": "error"
}
example 代码地址#
获取 demo 代码 并 运行#
run this three commond lines in your terminal
git clone git@github.com:yrobot-demo/prisma-demo.git ./prisma-demo
cd ./prisma-demo
git checkout graphql
接下来跟着 README.md
的教程一步步走即可