GraphQL 工程化篇 III:引入 Prisma 与数据库接入

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 的内容,不过核心思路还是基于 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 的时候提到
  • 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

    用法为:

    scheme 复制代码
    enum Role {
      USER
      ADMIN
    }
    
    model User {
      role    Role     @default(USER)
    }

关于更细节的部分,建议参考官方文档,除此之外还可以使用 @@map 进行关联------如果名称和数据库的名称不一致,使用 @@schema 的 multi-schema 支持------官方文档目前说 only supported for PostgreSQL, CockroachDB, and SQL Server

generate

  1. npx prisma generate

    该指令会读取 schema 文档并且生成 Prisma Client

    也就是一整套的 SDK,后面用法会提到

  2. 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 的情况,很容易拉爆服务器和数据库

目前来说能想到的解决方案是:

  1. 在 controller 中用多建几个方法,手动修改 where,尤其是加入 user 和 post 状态的判定
  2. 在 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 中的 UserUserModel 进行一一映射。而 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 内部可以消化类型的转换

相关推荐
川石课堂软件测试3 小时前
自动化测试之 Cucumber 工具
数据库·功能测试·网络协议·测试工具·mysql·单元测试·prometheus
沐雨橙风ιε3 小时前
Spring Boot整合Apache Shiro权限认证框架(实战篇)
java·spring boot·后端·apache shiro
RestCloud3 小时前
StarRocks 数据分析加速:ETL 如何实现实时同步与高效查询
数据库
桦说编程4 小时前
CompletableFuture 异常处理常见陷阱——非预期的同步异常
后端·性能优化·函数式编程
李广坤4 小时前
Springboot解决跨域的五种方式
后端
赴前尘4 小时前
Go 通道非阻塞发送:优雅地处理“通道已满”的场景
开发语言·后端·golang
cxyxiaokui0014 小时前
🔥不止于三级缓存:Spring循环依赖的全面解决方案
java·后端·spring
一线大码4 小时前
开发 Java 项目时的命名规范
java·spring boot·后端
neoooo4 小时前
Apollo兜底口诀
java·后端·架构