GraphQL 工程化篇 III:引入 Prisma 与数据库接入
上一篇笔记简单地过了一遍 repo 中 basic 这个模块的配置,包括项目结构设置、TS Config、Codegen、nodemon 和 logging,总体上完成了一个比较简陋,但是开发端可以热部署,并且有一定的数据追踪能力的项目
这种程度的项目已经可以支撑起大部分的中小型项目需求,除了一个点------数据库。basics 中的数据配置都是基于 mock data------也就是 json 文件实现的,而在真实生产环境中,数据要么来自于数据库,要么来自于调用的其他 API
这篇笔记主要就是打通数据库这个点,当然,还是会有一些基础篇中出现过的重复的内容,在之前的笔记中提到过,完整的 repo 地址在:
https://github.com/GoldenaArcher/graphql-case-studies
当前页面所有的内容都是在 prisma
下的内容,包括项目架构理念、ORM(也就是 prisma 的选择),以及一些基础的实现。 prisma
本身就是基于 basics
实现的,而且 repo 已经提供了,所以这里不会贴完整的代码,只是会贴一些核心代码,与知识点相关联的部分
本篇笔记依旧假设你对 GQL 的使用有些基础的了解,知道 schema、resolvers、query、mutation 的基本使用方法。并对项目模块有一定程度的理解,知道 repo、service、controller 分层的概念和设计思路。GQL 完全 0 基础的可以参考下面的笔记查漏补缺:
- 基础篇
- 工程化篇
对项目设计理念感兴趣的可以看下其他的工程化笔记:
- [MERN 项目实战] MERN Multi-Vendor 电商平台开发笔记(v1.0 初版结构 + 技术实践)
- [MERN 项目实战] MERN Multi-Vendor 电商平台开发笔记(v2.0 从 bug 到结构优化的工程记录)
- [MERN 项目实战] MERN Multi-Vendor 电商平台开发笔记(v2.1 基础工程化:Turborepo + Yarn Workspaces)
这三篇笔记虽然是 MERN 的内容,不过核心思路还是基于 controller ↔ service ↔ repository 的实现,目前我对 GQL 的感觉就是,resolvers/mutations/queries/subscriptions 层某种程度上就是实现了 controller 层的功能,负责具体的路由,和 dispatch 请求,具体的业务逻辑应该还是在 services 层实现比较好,而 repository 则是负责 DB 相关事务
结构重构
这是之前的项目结构:
bash
❯ tree . -I "node_modules"
.
├── codegen.config.ts
├── mock
│ ├── comments.json
│ ├── posts.json
│ └── users.json
├── nodemon.json
├── package-lock.json
├── package.json
├── src
│ ├── generated
│ │ └── graphql.ts
│ ├── graphql
│ │ ├── context
│ │ │ ├── pubsub.ts
│ │ │ └── type.ts
│ │ ├── pubsub
│ │ │ └── startMockCountPublisher.ts
│ │ ├── resolvers
│ │ │ ├── comment
│ │ │ │ ├── data.ts
│ │ │ │ ├── mutations.ts
│ │ │ │ ├── queries.ts
│ │ │ │ ├── resolvers.ts
│ │ │ │ └── subscriptions.ts
│ │ │ ├── index.ts
│ │ │ ├── mutation.ts
│ │ │ ├── post
│ │ │ │ ├── data.ts
│ │ │ │ ├── mutations.ts
│ │ │ │ ├── queries.ts
│ │ │ │ ├── resolvers.ts
│ │ │ │ └── subscriptions.ts
│ │ │ ├── query.ts
│ │ │ ├── subscription.ts
│ │ │ └── user
│ │ │ ├── data.ts
│ │ │ ├── mutations.ts
│ │ │ ├── queries.ts
│ │ │ └── resolver.ts
│ │ ├── schema
│ │ │ ├── comment.graphql
│ │ │ ├── index.ts
│ │ │ ├── post.graphql
│ │ │ ├── root.graphql
│ │ │ ├── subscription.graphql
│ │ │ └── user.graphql
│ │ └── schema.ts
│ ├── index.ts
│ └── utils
│ └── logger.ts
└── tsconfig.json
13 directories, 39 files
从一个更加系统化的角度去看,当前结构至少缺了 service 层和 repository 层,所以我把结构重新升级了一下,然后这是目前 prisma 项目下的结构:
bash
❯ tree . -I "node_modules"
.
├── codegen.config.ts
├── docker-compose.yaml
├── generated
│ └── prisma
│ ├── client.d.ts
│ ├── client.js
│ ├── default.d.ts
│ ├── default.js
│ ├── edge.d.ts
│ ├── edge.js
│ ├── index-browser.js
│ ├── index.d.ts
│ ├── index.js
│ ├── libquery_engine-darwin-arm64.dylib.node
│ ├── package.json
│ ├── query_engine_bg.js
│ ├── query_engine_bg.wasm
│ ├── runtime
│ │ ├── edge-esm.js
│ │ ├── edge.js
│ │ ├── index-browser.d.ts
│ │ ├── index-browser.js
│ │ ├── library.d.ts
│ │ ├── library.js
│ │ ├── react-native.js
│ │ ├── wasm-compiler-edge.js
│ │ └── wasm-engine-edge.js
│ ├── schema.prisma
│ ├── wasm-edge-light-loader.mjs
│ ├── wasm-worker-loader.mjs
│ ├── wasm.d.ts
│ └── wasm.js
├── jest.config.ts
├── local.env
├── nodemon.json
├── package-lock.json
├── package.json
├── prisma
│ ├── schema.prisma
│ └── seed
│ ├── data
│ │ ├── comments.json
│ │ ├── posts.json
│ │ └── users.json
│ ├── index.ts
│ ├── seedComment.ts
│ ├── seedPost.ts
│ └── seedUser.ts
├── README.md
├── src
│ ├── errors
│ │ └── app.error.ts
│ ├── generated
│ │ └── graphql.ts
│ ├── graphql
│ │ ├── context
│ │ │ ├── index.ts
│ │ │ ├── pubsub.ts
│ │ │ └── type.ts
│ │ ├── loaders
│ │ │ ├── comment.loader.ts
│ │ │ ├── index.ts
│ │ │ ├── post.loader.ts
│ │ │ └── user.loader.ts
│ │ ├── plugins
│ │ │ └── loggingPlugin.ts
│ │ ├── pubsub
│ │ │ └── startMockCountPublisher.ts
│ │ ├── resolvers
│ │ │ ├── auth
│ │ │ │ └── mutation.ts
│ │ │ ├── comment
│ │ │ │ ├── comment.mapper.ts
│ │ │ │ ├── mutations.ts
│ │ │ │ ├── queries.ts
│ │ │ │ ├── resolvers.ts
│ │ │ │ └── subscriptions.ts
│ │ │ ├── index.ts
│ │ │ ├── mutation.ts
│ │ │ ├── post
│ │ │ │ ├── mutations.ts
│ │ │ │ ├── post.mapper.ts
│ │ │ │ ├── queries.ts
│ │ │ │ ├── resolvers.ts
│ │ │ │ └── subscriptions.ts
│ │ │ ├── query.ts
│ │ │ ├── subscription.ts
│ │ │ └── user
│ │ │ ├── mutations.ts
│ │ │ ├── queries.ts
│ │ │ ├── resolvers.ts
│ │ │ └── user.mappers.ts
│ │ ├── schema
│ │ │ ├── auth.graphql
│ │ │ ├── comment.graphql
│ │ │ ├── index.ts
│ │ │ ├── post.graphql
│ │ │ ├── root.graphql
│ │ │ ├── subscription.graphql
│ │ │ └── user.graphql
│ │ └── schema.ts
│ ├── index.ts
│ ├── prisma
│ │ ├── index.ts
│ │ └── repository
│ │ ├── comment.repo.ts
│ │ ├── post.repo.ts
│ │ └── user.repo.ts
│ ├── services
│ │ ├── auth.service.ts
│ │ ├── comment.service.ts
│ │ ├── post.service.ts
│ │ └── user.service.ts
│ ├── tracing
│ │ └── asyncStore.ts
│ └── utils
│ ├── auth.test.ts
│ ├── auth.ts
│ ├── loadder.ts
│ ├── logger.ts
│ ├── prisma.ts
│ └── withWrapper.ts
├── test.env
└── tsconfig.json
26 directories, 99 files
这里会略过一些配置文件,讲一下核心的修改内容
docker compose
只负责数据库的部分,我也花了一些时间想要容器化 CLI,但是后面看了下,挑战还是比较大的,等到后面项目全都做完了------目前 UI 还没开始,再研究下 production build 好了
generated
这是 prisma 自动生成的文件夹,包括各种 type
prisma
schema.prisma
这里主要储存的就是 prisma 相关的文件,schema.prisma
放的是 db 数据类型,后面会更详细地过一遍 prisma 相关的内容- seed
这里是我自己实现的内容,主要负责从 JSON 文件里面拿数据,然后初始化数据库
其作用类似于 sql,不过因为使用了 prisma 的封装,所以可以使用 TS/JS 实现 - migration
这个文件夹没有出现,不过在做数据库迁徙(版本改变),又跑了npx prisma migrate
指令时会自动生成,适合有 production 环境,并且需要维护渐近演化的团队
从维护版本演化的功能来说,有点类似于 liquibase 的作用
src
- errors
这里就是 error 的集中处理,一方面抛出的一场可以标准化,另外一方面就是给后面的 tracing 使用 - graphql
- loaders
放 data loader 的地方,data loaders 因为需要统一导出存放到 context 里面,所以最终还是单独抽出来放在一个文件夹里进行统一管理 - plugins
管理 plugins 的结构,目前只有一个loggingPlugin
,具体内容会在后面做 logging refactor 的时候提到
- loaders
- db
处理 db 相关的业务,也就是 repo 层,目前的 ORM 对接的只有 prisma,所以 repo 下面全都是和 prisma 相关的
目前的这个情况是所有的 DB 都通过 prisma 这个 ORM,所以要修改的话直接改 prisma 的配置就行了,src 下的代码不需要进行修改 - services
业务逻辑所在的地方,负责各种的验证 - tracing
这个等到之后的模块会细说,这部分的内容,如果是要上线的项目+结构比较复杂的话,还是挺有必要的
整体来说,主要的变更是将 mock data 从 JSON 转化成正式的数据库关联,其他的结构变化主要因为 mock data 的变更而更新
其余的就是增添了一些新的功能,方便 logging 和 tracing
Prisma
Prisma 出现的年代其实挺早了,我应该是在 4、5 年前搜索 GQL 相关的内容时有看到过这个工具,不过当时 Prisma 和 GQL 是一个强绑定的关系,我那时候主要也专注在 Rest 相关的实现,就没有继续
年初的时候我在折腾 MERN 的项目时也考虑过接入 ORM 的实现,不过当时的数据库用的是 MongoDB,找了一下相关的 ORM,主要还是以 TypeORM 推荐居多
现在这个新项目用的则是 Prisma,所以我也看了看这个工具,结果发现,真的蛮好用的。它从 v2 升级后就解除了和 GQL 的强绑定关系,从 GQL 数据层脱离,更专注于一个 ORM 及集成工具平台的定位。不过它对关系型数据库的支持更好,网上关于 Node/Express 相关的技术栈则更加的偏好 MongoDB 这种 NoSQL。关键字的不匹配导致这些年里 Prisma 都没怎么出现在我的搜索框前列,差点错过一个好工具
目前 Prisma 上的下载量已经是 TypeORM 的两倍了
Prisma 的取舍我觉得更关注在这么几点:
- 对于工程化的需求
这里的工程化不止说的一个术语,而是关于版本、团队、工具管理之类的,可以说上升到了管理哲学这种比较抽象的层级
Prisma 的优点在于它自动生成 SDK,生成的 type 很强,本身也有生成对应的 migration 版本的功能,缺点就在于前期布置比较大,业务比较简单的中小型项目之中,不需要很好的追踪版本变化的团队里,Prisma 就有点太重了
除此之外,还可以自动生成对应的 migration 版本,如果后端以前用的是 liquibase,对这种工具应该会有一些偏好 - TS 的接受程度
这里的接受程度指的是完全投入到 TS 中,而不是用as any
去做强转的接受程度。如果新项目,或者整个队伍都是非常乐意去改 TS 的,那么使用 Prisma 也是一个比较好的选择
但是换言之,如果项目只想要在 JS 环境上运行------最近两年其实也有不少复古风潮,觉得 TS 的限定太死,类型推导不够聪明,有些工具生成的类型过于抽象难以使用,反而从 TS 退回了 JS,这个时候 Prisma 的优点就不明显了,反而 TypeORM、sequelize 这两个 ORM 对 JS 的支持更好 - 工具的冲突
本来我想把它归类到工程化或者 TS 中,但是想想还是单独抽了出来
这里主要指的是 Prisma 生成的类型与 validator,如 Yup/Zod 会产生的类型冲突以及转换。本质上来说这不是 Prisma 的问题,而是任何自动生成的自动生成的 db schema 都和 validator 都会产生的冲突
如果没办法很好的解决这种冲突,又不想手动 infer 一堆的类型,那么可能只能二选一了...... - 数据库的选择
Prisma 目前支持的数据库比较少,关系型数据库是第一公民,其中 PostgreSQL 是支持最好的,NoSQL 只是勉强支持,维持基本功能
项目如果想要使用更加传统的 DB,如 MySQL、Oracle,或者其他 DB,那么 Prisma 可能不是最好的选择
下载依赖
"@prisma/client"
和 "prisma"
,需要维持版本一致
schema
从 v2 之后,schema 的实现就比较稳定了,目前支持的结构是这样的:
scheme
generator client {
provider = "prisma-client-js"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @unique @default(uuid())
name String
email String @unique
age Int?
active Boolean @default(false)
password String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Post Post[]
Comment Comment[]
}
model Post {
id String @id @unique @default(uuid())
title String
body String
published Boolean @default(false)
archived Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
author String
user User @relation(fields: [author], references: [id])
Comment Comment[]
}
model Comment {
id String @id @unique @default(uuid())
text String
orphaned Boolean @default(false)
archived Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
postId String
userId String
post Post @relation(fields: [postId], references: [id])
user User @relation(fields: [userId], references: [id])
}
除了关联部分的语法稍微有些特殊,其他的和普通 db/schema 的定义类似
完整的文档在这里:Models,这里没用到的一些其他常见配置还有:
-
自增长
即
@default(autoincrement())
,一般用在 id 上比较多 -
enum
用法为:
schemeenum Role { USER ADMIN } model User { role Role @default(USER) }
关于更细节的部分,建议参考官方文档,除此之外还可以使用 @@map
进行关联------如果名称和数据库的名称不一致,使用 @@schema
的 multi-schema 支持------官方文档目前说 only supported for PostgreSQL, CockroachDB, and SQL Server
generate
-
npx prisma generate
该指令会读取 schema 文档并且生成 Prisma Client
也就是一整套的 SDK,后面用法会提到
-
npx prisma db push
该指令会将生成的 prisma 推到数据库
如果新增 field 的话可能会导致数据库类型不一致被拒绝,这一点也是需要注意的。我之前遇到这种情况是新增了一个 field 密码,但是没有提供默认值
后来是修改了 db 里的数据,跑了一下 pull,查看确认 schema 没啥问题,再跑了 generate 和 push,这个操作应该不是最正确的,不过能用
补充说明:可以使用 npx prisma studio
指令生成一个 UI,prisma 的 UI 还是挺友好的:

不擅长写 query 者的福音
使用 SDK
我这里是单独使用了一个文件作为 prisma 的 entry point
这一步不是必须的,不过能保证在项目启动时只新建一个 instance:
tsx
import { PrismaClient } from "../../generated/prisma/client";
const prisma = new PrismaClient();
export default prisma;
⚠️: 这里需要注意的是 PrismaClient
,必须要从 generated/client
里面导入。这个 generated 的文件夹才会包含所有的 SDK,包括对应的方法
👀: 还有一个 PrismaClient
是 @prisma/client
提供的,后者没用,只是一个空的类型,没有任何相关的 TS 提示
♨️: spring 自动管理 connection pool 这种实现真好啊......不过本地开发没什么大问题就是了
使用 SDK 的方法就比较简单了,prisma 提供了一些基础的,比如说 findUnique
, findAll
这种方法,直接调用即可,下面列举了一些常用的方法:
tsx
import prisma from "../index";
import type { Comment, Prisma } from "../../../generated/prisma";
const repo = prisma.comment;
const getCommentById = async (id: string): Promise<Comment | null> => {
return await prisma.comment.findUnique({
where: { id },
});
};
const getNonArchivedComments = async (
args?: Prisma.CommentFindManyArgs
): Promise<Comment[]> => {
return await repo.findMany({
...args,
where: { ...args?.where, archived: false },
});
};
const createComment = async (
data: Prisma.CommentCreateInput
): Promise<Comment> => {
return await prisma.comment.create({ data });
};
const updateComment = async (
id: string,
data: Prisma.CommentUpdateInput
): Promise<Comment> => {
return await prisma.comment.update({
where: { id },
data,
});
};
const orphanCommentsByPostId = async (postId: string): Promise<number> => {
const result = await prisma.comment.updateMany({
where: { postId },
data: { orphaned: true },
});
return result.count;
};
const getTotalCount = async (
where: Prisma.CommentWhereInput
): Promise<number> => {
return await repo.count({
where,
});
};
Service 层调用
这里就简单的贴点代码了:
tsx
import type { Prisma } from "../../generated/prisma";
import type {
Comment,
CommentConnection,
CommentWhereInput,
CreateCommentInput,
Post,
UpdateCommentInput,
User,
} from "../generated/graphql";
import authService from "./auth.service";
import postService from "./post.service";
import {
buildCommentConnection,
mapDBCommentToComment,
} from "../graphql/resolvers/comment/comment.mapper";
import commentRepository from "../db/repository/comment.repo";
import userRepository from "../db/repository/user.repo";
import { withServiceWrapper } from "../utils/withWrapper";
import { buildFindManyArgs } from "../utils/prisma";
import { AuthError, ForbiddenError, NotFoundError } from "../errors/app.error";
const checkCommentCanBeUpdated = async (
id: string,
user: User | null | undefined
): Promise<[boolean, Post]> => {
if (!user || !user.id) {
throw new AuthError("User not authenticated");
}
if (!(await userRepository.checkUserExistsAndIsActive(user.id))) {
throw new NotFoundError("User");
}
const dbComment = await commentRepository.getCommentById(id);
if (!dbComment) {
throw new NotFoundError("Comment");
}
const post = await postService.findPostById(dbComment.postId);
if (!post) {
throw new NotFoundError("Post");
}
if (!authService.checkIsSameUser(user, dbComment.userId)) {
throw new ForbiddenError("User not authorized to archive this comment");
}
if (!authService.checkIsSameUser(user, dbComment.userId)) {
throw new ForbiddenError("User not authorized to update this comment");
}
return [true, post];
};
const findAvailableComments = async (
where?: CommentWhereInput,
first?: number | null,
skip?: number | null,
after?: string | null
): Promise<CommentConnection> => {
const cleaned: Prisma.CommentWhereInput = {};
if (where?.text) cleaned.text = { ...where.text } as Prisma.StringFilter;
const args: Prisma.CommentFindManyArgs =
buildFindManyArgs<Prisma.CommentFindManyArgs>(cleaned, first, skip, after);
const [comments, hasNextPage, totalCount] = await Promise.all([
commentRepository.getNonArchivedComments(args),
commentRepository.hasNextPage(
after ?? "",
args.orderBy as Prisma.CommentOrderByWithRelationInput[],
cleaned
),
commentRepository.getTotalCount(cleaned),
]);
return buildCommentConnection(comments, after, hasNextPage, totalCount);
};
const findCommentById = async (id: string): Promise<Comment | null> => {
const dbComment = await commentRepository.getCommentById(id);
if (!dbComment) {
return null;
}
return mapDBCommentToComment(dbComment);
};
const createComment = async (
data: CreateCommentInput,
user: User | null | undefined
): Promise<Comment> => {
if (!user || !user.id) {
throw new AuthError("User not authenticated");
}
if (!(await userRepository.checkUserExistsAndIsActive(user.id))) {
throw new NotFoundError("User");
}
if (!(await postService.checkPostExists(data.post))) {
throw new NotFoundError("Post");
}
const payload: Prisma.CommentCreateInput = {
text: data.text,
post: {
connect: {
id: data.post,
},
},
user: {
connect: {
id: user.id,
},
},
};
return mapDBCommentToComment(await commentRepository.createComment(payload));
};
const archiveComment = async (
id: string,
user: User | null | undefined
): Promise<Comment> => {
return updateComment(id, { archived: true }, user);
};
const updateComment = async (
id: string,
data: UpdateCommentInput,
user: User | null | undefined
): Promise<Comment> => {
const [canBeUpdated, post] = await checkCommentCanBeUpdated(id, user);
if (!canBeUpdated) {
throw new ForbiddenError("Comment not found or not authorized to update");
}
const payload: Prisma.CommentUpdateInput = {};
if (data.text != null) {
payload.text = data.text;
}
return mapDBCommentToComment(
await commentRepository.updateComment(id, payload),
post
);
};
const rawCommentService = {
findAvailableComments,
createComment,
updateComment,
archiveComment,
findCommentById,
};
const commentService = withServiceWrapper(rawCommentService, "comment");
export default commentService;
export type CommentService = typeof rawCommentService;
这里主要负责的就是一些业务逻辑相关的内容,返回的数据会通过 mapDBCommentToComment
这种 mapper 处理
隐藏的坑
这是之前在写 Query 的时候想到的一个问题,就是当 query 操作,尤其是 findMany
如果要出现联动操作,如下面的代码:
tsx
const getNonArchivedComments = async (
args?: Prisma.CommentFindManyArgs
): Promise<Comment[]> => {
return await repo.findMany({
...args,
where: { ...args?.where, archived: false },
});
};
假设这里同时还需要验证 user 和 post 的状况是,就没有办法在 findMany
后,用 map
调用,否则在 comment → user → post → comment 这种可能发生的链式调用的情况下,同时又会出现 N+1 的情况,很容易拉爆服务器和数据库
目前来说能想到的解决方案是:
- 在 controller 中用多建几个方法,手动修改
where
,尤其是加入 user 和 post 状态的判定 - 在 repo 层使用 dataloader → 这个看到可以实现,不过还没有自己实际上手操作过
根据项目的业务情况,方案 1 应该是可以解决大部分的问题了......
想提到这一点,主要也是因为之前在搜索的时候,有人会以为 Prisma 可以解决 N+1 的问题。但是本质上,还是在单独 findMany
的时候显示使用 include/select
去 mask 了部分问题,并没有考虑到 query 的嵌套调用
而后者才是最难 debug,也是 N+1 最容易出现的地方
Mappers
上一篇笔记就降到的 mapper 问题,这里终于开始补了,首先看一下现在手动实现的 mapper:
tsx
export const mapDBCommentToComment = (
data: DbComment,
post?: Post
): Comment => {
return {
...data,
author: null,
post: post ?? null,
};
};
这里把 post/author 设置成 null
会有一些的风险,90%的情况不会有问题------因为 resolver 会处理,但是在没有调用到 resolver 的情况下,我碰到过直接 query post,然后拿到过 null 的情况,这也是为什么 post 会变成一个 optional field......就成了不断打补丁......(头疼......)
但是如果强制转换,如使用 author: data.userId as unknown as User
, TS 的报错会让人头疼到想哭:
Type 'User' is missing the following properties from type '{ name: string; id: string; createdAt: Date; updatedAt: Date; email: string; age: number | null; active: boolean; password: string; role: Role; }': createdAt, updatedAt, password, role(2739)
最终还是只能用 as any
搞定......
⬆️:这里 user
已经用 mapper 修改过了,所以 GQL 期待的对象是数据库对象,这也证明了 GQL 可以正常转换完成 DB entity ↔ GQL entity 之间的转换
而使用了 codegen 中的 mapper 属性,如:
tsx
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
generates: {
"./src/generated/graphql.ts": {
config: {
contextType: "../graphql/context/type#GraphQLContext",
mappers: {
User: "../../generated/prisma/index#User",
},
mapperTypeSuffix: "Model",
},
},
},
};
export default config;
就能够很好的解决各种 mapping 问题,同时 GQL 也能够比较准确的转换 DB 数据和 GQL 数据之间的差异,从而更加的省心
mapperTypeSuffix
也能够不写代码的同时,解决变量名冲突的问题。其主要原因是 codegen 会生成对应类型: export type UserModel = Prisma.User;
,并且在 mapping 的过程中,始终将 schema 中的 User
与 UserModel
进行一一映射。而 suffix Model
又能够避免类型名一致而导致的冲突
👀:mappers 和 contextType,是基于 GQL 生成的对象所在的地址,也就是 prisma/src/generated
所在的位置。这个 generated
文件要去找 context type,就得去 prisma/src/graphql/context/type.ts
去找,而 Prisma 所在的文件地址,是在 src 外层: prisma/generated/prisma/index.d.ts
,所以得多爬一层出去找对应的路径。之前没意识到这点,mappers 折腾了好久......
⚠️:这里的 context mapping 也解决了 resolvers 中的 context 类型推导不正确,加上了对应的 mapper 后,resolvers 中的 context 就会自动被推导为 GraphQLContext
如果有直接可以访问 DB 的权限,或者允许多层嵌套,那么我 强力推荐 还是把 mappers 写完,类型的转换会轻松很多,也可以比较轻松地避免 null
之类的问题
Mappers 补充
我之前的结论是:
如果只是做 BFF 层,并且没有多层嵌套的需求,那就不需要直接访问数据库,那么 mapper 的实现没有什么必要,因为 data aggregation 都是由开发负责实现的,自然也没有多写一个 type 的需求
我看了一下项目的代码,发现就算只是做 BFF 层,其实我们也使用了 mapper 去简化操作,只不过这里 mapping 的对象从 prisma 转向了 resolvers 中生成的类型,修改如下:
tsx
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
generates: {
"./src/generated/graphql.ts": {
config: {
contextType: "../graphql/context/type#GraphQLContext",
mappers: {
User: "../../generated/prisma/index#User",
UserConnection: "./graphql#UserConnection",
Post: "../../generated/prisma/index#Post",
PostConnection: "./graphql#PostConnection",
Comment: "../../generated/prisma/index#Comment",
CommentConnection: "./graphql#CommentConnection",
},
mapperTypeSuffix: "Model",
// typesPrefix: "Gql",
},
plugins: ["typescript", "typescript-resolvers"],
},
},
};
export default config;
这个 connection 有点 self-reference,不过本质上就是为了搞定 TS 的报错,后面 pagination 会提到这个 Connection
的类型。这里会冲突的主要原因是里面的 edge 包含 User
,如果不动态转换一下, prisma 和 schema 还是会出现不兼容的问题
它本质上做了这几件事儿:
tsx
import type {
User as UserModel,
Post as PostModel,
Comment as CommentModel,
} from "../../generated/prisma/index";
import type {
UserConnection as UserConnectionModel,
PostConnection as PostConnectionModel,
CommentConnection as CommentConnectionModel,
} from "./graphql";
相当于就是用 as
做了个类型转换,这样 GQL 内部可以消化类型的转换