GQLoom 入门指南 - 使用 Zod + Drizzle 构建 GraphQL 服务端应用

前言

在 Node.js 生态中,构建 GraphQL API 通常需要手动编写大量的样板代码:定义 Schema、编写 Resolver、处理类型验证、数据库操作等。

今天给大家介绍一个全新的解决方案 ------ GQLoom,它能够让你用最熟悉的 TypeScript 类型库(如 Valibot、Zod)来构建类型安全的 GraphQL API,大大提升开发效率。

什么是 GQLoom?

GQLoom 是一个 Code First GraphQL Schema 框架,它能够将 TypeScript/JavaScript 生态中的运行时类型编织成 GraphQL Schema。简单来说,你可以用 Valibot 或 Zod 定义的类型直接生成 GraphQL API,无需重复编写 Schema 定义。

实战项目:猫舍管理系统

为了让大家更好地理解 GQLoom 的强大之处,我们将一起构建一个完整的猫舍管理系统,这个项目将涵盖:

  • 🐱 猫咪信息管理:录入、查询、更新猫咪的基本信息(姓名、生日等)
  • 👤 用户(猫主人)管理:用户注册、登录认证、查看自己的猫咪
  • 🔗 关联查询:查询猫咪的主人信息,查看用户的所有猫咪
  • 🛡️ 权限控制:只有登录用户才能添加猫咪,确保数据安全

技术栈介绍

我们将使用以下现代化技术栈:

  • 🚀 TypeScript - 类型安全的 JavaScript 超集
  • 🟢 Node.js - JavaScript 运行时环境
  • 📊 GraphQL - 强大的 API 查询语言
  • 🧘 GraphQL Yoga - 现代化的 GraphQL 服务器
  • 🗄️ Drizzle ORM - 轻量级、类型安全的 ORM
  • Zod - 强大的 TypeScript 优先验证库
  • 🧵 GQLoom - 将运行时类型编织成 GraphQL Schema 的框架

环境准备

在开始之前,请确保你的开发环境满足以下要求:

  • Node.js 20+ - 推荐使用最新的 LTS 版本
  • npm/yarn/pnpm - 包管理器(本文使用 npm)
  • 代码编辑器 - VS Code 或其他支持 TypeScript 的编辑器

创建应用

项目结构

我们的应用将有以下的结构:

lua 复制代码
cattery/
├── src/
│   ├── contexts/
│   │   └── index.ts
│   ├── providers/
│   │   └── index.ts
│   ├── resolvers/
│   │   ├── cat.ts
│   │   ├── index.ts
│   │   └── user.ts
│   ├── schema/
│   │   └── index.ts
│   └── index.ts
├── drizzle.config.ts
├── package.json
└── tsconfig.json

其中,src 目录下的各个文件夹或文件的职能如下:

  • contexts: 存放上下文,如当前用户;
  • providers: 存放需要与外部服务交互的功能,如数据库连接、Redis 连接;
  • resolvers: 存放 GraphQL 解析器;
  • schema: 存放 schema,主要是数据库表结构;
  • index.ts: 用于以 HTTP 服务的形式运行 GraphQL 应用;

提示

GQLoom 对项目的文件结构没有任何要求,这里只提供一个参考,在实践中你可以按照需求和喜好组织文件。

初始化项目

首先,让我们新建文件夹并初始化项目:

bash 复制代码
mkdir cattery
cd ./cattery
npm init -y

然后,我们将安装一些必要的依赖来以便在 Node.js 运行中 TypeScript 应用:

bash 复制代码
npm i -D typescript @types/node tsx
npx tsc --init

接下来,我们将安装 GQLoom 以及 Zod 相关依赖:

bash 复制代码
npm i graphql graphql-yoga @gqloom/core zod @gqloom/zod

你好 世界

让我们编写第一个解析器:

typescript 复制代码
// src/resolvers/index.ts
import { query, resolver } from "@gqloom/core"
import * as z from "zod"

const helloResolver = resolver({
  hello: query(z.string())
    .input({
      name: z
        .string()
        .nullish()
        .transform((x) => x ?? "World"),
    })
    .resolve(({ name }) => `Hello ${name}!`),
})

export const resolvers = [helloResolver]

我们需要将这个解析器编织成 GraphQL Schema,并以 HTTP 服务器的形式运行它:

typescript 复制代码
// src/index.ts
import { createServer } from "node:http"
import { weave } from "@gqloom/core"
import { ZodWeaver } from "@gqloom/zod"
import { createYoga } from "graphql-yoga"
import { resolvers } from "src/resolvers"

const schema = weave(ZodWeaver, ...resolvers)

const yoga = createYoga({ schema })
createServer(yoga).listen(4000, () => {
  console.info("Server is running on http://localhost:4000/graphql")
})

很好,我们已经创建了一个简单的 GraphQL 应用。

接下来我们尝试运行这个应用,在 package.json 里添加 dev 脚本:

json 复制代码
{
  "scripts": {
    "dev": "tsx watch src/index.ts"
  }
}

现在让我们运行一下:

bash 复制代码
npm run dev

在浏览器中打开 http://localhost:4000/graphql 就可以看到 GraphQL 演练场了。

让我们尝试发送一个 GraphQL 查询,在演练场里输入:

graphql 复制代码
{
  hello(name: "GQLoom")
}

点击查询按钮,就可以看到结果了:

json 复制代码
{
  "data": {
    "hello": "Hello GQLoom!"
  }
}

到此为止,我们已经创建了一个最简单的 GraphQL 应用。

接下来我们将使用 Drizzle ORM 来与数据库交互并添加完整的功能。

初始化数据库和表格

首先,让我们安装 Drizzle ORM,我们将使用它来操作 SQLite 数据库。

bash 复制代码
npm i @gqloom/drizzle drizzle-orm @libsql/client dotenv
npm i -D drizzle-kit

定义数据库表格

接下来在 src/schema/index.ts 文件中定义数据库表格,我们将定义 userscats 两个表格,并建立它们之间的关系:

typescript 复制代码
// src/schema/index.ts
import { drizzleSilk } from "@gqloom/drizzle"
import { relations } from "drizzle-orm"
import * as t from "drizzle-orm/sqlite-core"

export const users = drizzleSilk(
  t.sqliteTable("users", {
    id: t.int().primaryKey({ autoIncrement: true }),
    name: t.text().notNull(),
    phone: t.text().notNull().unique(),
  })
)

export const usersRelations = relations(users, ({ many }) => ({
  cats: many(cats),
}))

export const cats = drizzleSilk(
  t.sqliteTable("cats", {
    id: t.integer().primaryKey({ autoIncrement: true }),
    name: t.text().notNull(),
    birthday: t.integer({ mode: "timestamp" }).notNull(),
    ownerId: t
      .integer()
      .notNull()
      .references(() => users.id),
  })
)

export const catsRelations = relations(cats, ({ one }) => ({
  owner: one(users, {
    fields: [cats.ownerId],
    references: [users.id],
  }),
}))

初始化数据库

我们需要创建一个配置文件:

typescript 复制代码
// drizzle.config.ts
import "dotenv/config"
import { defineConfig } from "drizzle-kit"

export default defineConfig({
  out: "./drizzle",
  schema: "./src/schema/index.ts",
  dialect: "sqlite",
  dbCredentials: {
    url: process.env.DB_FILE_NAME ?? "file:local.db",
  },
})

然后我们运行 drizzle-kit push 命令在数据库中建立已定义的表格:

bash 复制代码
npx drizzle-kit push

使用数据库

为了在应用中使用数据库,我们需要创建一个数据库实例:

typescript 复制代码
// src/providers/index.ts
import { drizzle } from "drizzle-orm/libsql"
import * as schema from "src/schema"

export const db = drizzle(process.env.DB_FILE_NAME ?? "file:local.db", {
  schema,
})

解析器

现在,我们可以在解析器中使用数据库,我们将创建一个用户解析器添加以下操作:

  • usersByName: 通过名称查找用户
  • userByPhone: 通过手机号码查找用户
  • createUser: 创建一个用户

在完成用户解析器后,我们还需要将它添加到 src/resolvers/index.ts 文件里的 resolvers 中:

typescript 复制代码
// src/resolvers/user.ts
import { mutation, query, resolver } from "@gqloom/core"
import { eq } from "drizzle-orm"
import { db } from "src/providers"
import { users } from "src/schema"
import * as z from "zod"

export const userResolver = resolver.of(users, {
  usersByName: query(users.$list())
    .input({ name: z.string() })
    .resolve(({ name }) => {
      return db.query.users.findMany({
        where: eq(users.name, name),
      })
    }),

  userByPhone: query(users.$nullable())
    .input({ phone: z.string() })
    .resolve(({ phone }) => {
      return db.query.users.findFirst({
        where: eq(users.phone, phone),
      })
    }),

  createUser: mutation(users)
    .input({
      data: z.object({
        name: z.string(),
        phone: z.string(),
      }),
    })
    .resolve(async ({ data }) => {
      const [user] = await db.insert(users).values(data).returning()
      return user
    }),
})
typescript 复制代码
// src/resolvers/index.ts
import { query, resolver } from "@gqloom/core"
import { userResolver } from "src/resolvers/user"
import * as z from "zod"

const helloResolver = resolver({
  hello: query(z.string())
    .input({
      name: z
        .string()
        .nullish()
        .transform((x) => x ?? "World"),
    })
    .resolve(({ name }) => `Hello ${name}!`),
})

export const resolvers = [helloResolver, userResolver]

很好,现在让我们在演练场尝试一下:

GraphQL Mutation:

graphql 复制代码
mutation {
  createUser(data: {name: "Bob", phone: "001"}) {
    id
    name
    phone
  }
}

Response:

json 复制代码
{
  "data": {
    "createUser": {
      "id": 1,
      "name": "Bob",
      "phone": "001"
    }
  }
}

继续尝试找回刚刚创建的用户:

GraphQL Query:

graphql 复制代码
{
  usersByName(name: "Bob") {
    id
    name
    phone
  }
}

Response:

json 复制代码
{
  "data": {
    "usersByName": [
      {
        "id": 1,
        "name": "Bob",
        "phone": "001"
      }
    ]
  }
}

当前用户上下文

首先让我们为应用添加 asyncContextProvider 中间件来启用异步上下文:

typescript 复制代码
// src/index.ts
import { createServer } from "node:http"
import { weave } from "@gqloom/core"
import { asyncContextProvider } from "@gqloom/core/context"
import { ZodWeaver } from "@gqloom/zod"
import { createYoga } from "graphql-yoga"
import { resolvers } from "src/resolvers"

const schema = weave(asyncContextProvider, ZodWeaver, ...resolvers)

const yoga = createYoga({ schema })
createServer(yoga).listen(4000, () => {
  console.info("Server is running on http://localhost:4000/graphql")
})

接下来,让我们尝试添加一个简单的登录功能,再为用户解析器添加一个查询操作:

  • mine: 返回当前用户信息

为了实现这个查询,首先得有登录功能,让我们来简单写一个:

typescript 复制代码
// src/contexts/index.ts
import { createMemoization, useContext } from "@gqloom/core/context"
import { eq } from "drizzle-orm"
import { GraphQLError } from "graphql"
import type { YogaInitialContext } from "graphql-yoga"
import { db } from "src/providers"
import { users } from "src/schema"

export const useCurrentUser = createMemoization(async () => {
  const phone =
    useContext<YogaInitialContext>().request.headers.get("authorization")
  if (phone == null) throw new GraphQLError("Unauthorized")

  const user = await db.query.users.findFirst({ where: eq(users.phone, phone) })
  if (user == null) throw new GraphQLError("Unauthorized")
  return user
})

在上面的代码中,我们创建了一个用于获取当前用户的上下文函数,它将返回当前用户的信息。我们使用 createMemoization() 将此函数记忆化,这确保在同一个请求内此函数仅执行一次,以避免多余的数据库查询。

我们使用 useContext() 获取了 Yoga 提供的上下文(Context),并从请求头中获取了用户的手机号码,并根据手机号码查找用户,如果用户不存在,则抛出 GraphQLError

注意 如你所见,这个登录功能非常简陋,仅作为演示使用,完全不保证安全性。在实践中通常推荐使用 session 或者 JWT 等方案。

现在,我们在解析器里添加新的查询操作:

typescript 复制代码
// src/resolvers/user.ts
import { mutation, query, resolver } from "@gqloom/core"
import { eq } from "drizzle-orm"
import { useCurrentUser } from "src/contexts"
import { db } from "src/providers"
import { users } from "src/schema"
import * as z from "zod"

export const userResolver = resolver.of(users, {
  mine: query(users).resolve(() => useCurrentUser()),

  usersByName: query(users.$list())
    .input({ name: z.string() })
    .resolve(({ name }) => {
      return db.query.users.findMany({
        where: eq(users.name, name),
      })
    }),

  userByPhone: query(users.$nullable())
    .input({ phone: z.string() })
    .resolve(({ phone }) => {
      return db.query.users.findFirst({
        where: eq(users.phone, phone),
      })
    }),

  createUser: mutation(users)
    .input({
      data: z.object({
        name: z.string(),
        phone: z.string(),
      }),
    })
    .resolve(async ({ data }) => {
      const [user] = await db.insert(users).values(data).returning()
      return user
    }),
})

如果我们在演练场里之间调用这个新的查询,应用程序将给我们未认证的错误:

GraphQL Query:

graphql 复制代码
{
  mine {
    id
    name
    phone
  }
}

Response:

json 复制代码
{
  "errors": [
    {
      "message": "Unauthorized",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "mine"
      ]
    }
  ],
  "data": null
}

点开演练场下方的 Headers,并在请求头里添加 authorization 字段,这里我们使用在上一步中创建的 Bob 的手机号码,这样我们就作为Bob登录了:

Headers:

json 复制代码
{
  "authorization": "001"
}

GraphQL Query:

graphql 复制代码
{
  mine {
    id
    name
    phone
  }
}

Response:

json 复制代码
{
  "data": {
    "mine": {
      "id": 1,
      "name": "Bob",
      "phone": "001"
    }
  }
}

解析器工厂

接下来,我们将添加与猫咪相关的业务逻辑。

我们使用解析器工厂来快速创建接口:

typescript 复制代码
// src/resolvers/cat.ts
import { field, resolver } from "@gqloom/core"
import { drizzleResolverFactory } from "@gqloom/drizzle"
import { db } from "src/providers"
import { cats } from "src/schema"
import * as z from "zod"

const catResolverFactory = drizzleResolverFactory(db, cats)

export const catResolver = resolver.of(cats, {
  cats: catResolverFactory.selectArrayQuery(),

  age: field(z.number())
    .derivedFrom("birthday")
    .input({
      currentYear: z.number().int().nullish().transform((x) => x ?? new Date().getFullYear()),
    })
    .resolve((cat, { currentYear }) => {
      return currentYear - cat.birthday.getFullYear()
    }),
})
typescript 复制代码
// src/resolvers/index.ts
import { query, resolver } from "@gqloom/core"
import { catResolver } from "src/resolvers/cat"
import { userResolver } from "src/resolvers/user"
import * as z from "zod"

const helloResolver = resolver({
  hello: query(z.string())
    .input({
      name: z
        .string()
        .nullish()
        .transform((x) => x ?? "World"),
    })
    .resolve(({ name }) => `Hello ${name}!`),
})

export const resolvers = [helloResolver, userResolver, catResolver]

在上面的代码中,我们使用 drizzleResolverFactory() 创建了 catResolverFactory,用于快速构建解析器。

我们添加了一个使用 catResolverFactory 创建了一个选取数据的查询 ,并将它命名为 cats,这个查询将提供完全的对 cats 表的查询操作。

此外,我们还为猫咪添加了额外的 age 字段,用以获取猫咪的年龄。

接下来,让我们尝试添加一个 createCat 的变更。我们希望只有登录用户能访问这个接口,并且被创建的猫咪将归属于当前用户:

typescript 复制代码
// src/resolvers/cat.ts (部分代码)
createCats: catResolverFactory.insertArrayMutation().input(
  z.object({
    values: z.array(
      z.object({
        name: z.string(),
        birthday: z.string().transform((x) => new Date(x)),
      }).transform(async ({ name, birthday }) => ({
        name,
        birthday,
        ownerId: (await useCurrentUser()).id,
      }))
    ),
  })
),

在上面的代码中,我们使用 catResolverFactory 创建了一个向 cats 表格添加更多数据的变更,并且我们重写了这个变更的输入。在验证输入时,我们使用 useCurrentUser() 获取当前登录用户的 ID,并将作为 ownerId 的值传递给 cats 表格。

现在让我们在演练场尝试添加几只猫咪:

GraphQL Mutation:

graphql 复制代码
mutation {
  createCats(data: {values: [{name: "Whiskers", birthday: "2020-01-01"}, {name: "Whiskers", birthday: "2020-01-01"}]}) {
    id
    name
    age
  }
}

Headers:

json 复制代码
{
  "authorization": "001"
}

Response:

json 复制代码
{
  "data": {
    "createCats": [
      {
        "id": 1,
        "name": "Mittens",
        "age": 4
      },
      {
        "id": 2,
        "name": "Fluffy",
        "age": 3
      }
    ]
  }
}

让我们使用 cats 查询再确认一下数据库的数据:

GraphQL Query:

graphql 复制代码
{
  cats {
    id
    name   
    age
  }
}

Response:

json 复制代码
{
  "data": {
    "cats": [
      {
        "id": 1,
        "name": "Mittens",
        "age": 4
      },
      {
        "id": 2,
        "name": "Fluffy",
        "age": 3
      }
    ]
  }
}

关联对象

我们希望在查询猫咪的时候可以获取到猫咪的拥有者,并且在查询用户的时候也可以获取到他所有的猫咪。

这在 GraphQL 中非常容易实现。

让我们为 cats 添加额外的 owner 字段,并为 users 添加额外的 cats 字段:

typescript 复制代码
// src/resolvers/cat.ts
import { field, resolver } from "@gqloom/core"
import { drizzleResolverFactory } from "@gqloom/drizzle"
import { useCurrentUser } from "src/contexts"
import { db } from "src/providers"
import { cats } from "src/schema"
import * as z from "zod"

const catResolverFactory = drizzleResolverFactory(db, cats)

export const catResolver = resolver.of(cats, {
  cats: catResolverFactory.selectArrayQuery(),

  age: field(z.number())
    .derivedFrom("birthday")
    .input({
      currentYear: z.number().int().nullish().transform((x) => x ?? new Date().getFullYear()),
    })
    .resolve((cat, { currentYear }) => {
      return currentYear - cat.birthday.getFullYear()
    }),

  owner: catResolverFactory.relationField("owner"),

  createCats: catResolverFactory.insertArrayMutation().input(
    z.object({
      values: z.array(
        z.object({
          name: z.string(),
          birthday: z.string().transform((x) => new Date(x)),
        }).transform(async ({ name, birthday }) => ({
          name,
          birthday,
          ownerId: (await useCurrentUser()).id,
        }))
      ),
    })
  ),
})
typescript 复制代码
// src/resolvers/user.ts
import { mutation, query, resolver } from "@gqloom/core"
import { drizzleResolverFactory } from "@gqloom/drizzle"
import { eq } from "drizzle-orm"
import { useCurrentUser } from "src/contexts"
import { db } from "src/providers"
import { users } from "src/schema"
import * as z from "zod"

const userResolverFactory = drizzleResolverFactory(db, users)

export const userResolver = resolver.of(users, {
  cats: userResolverFactory.relationField("cats"),

  mine: query(users).resolve(() => useCurrentUser()),

  usersByName: query(users.$list())
    .input({ name: z.string() })
    .resolve(({ name }) => {
      return db.query.users.findMany({
        where: eq(users.name, name),
      })
    }),

  userByPhone: query(users.$nullable())
    .input({ phone: z.string() })
    .resolve(({ phone }) => {
      return db.query.users.findFirst({
        where: eq(users.phone, phone),
      })
    }),

  createUser: mutation(users)
    .input({
      data: z.object({
        name: z.string(),
        phone: z.string(),
      }),
    })
    .resolve(async ({ data }) => {
      const [user] = await db.insert(users).values(data).returning()
      return user
    }),
})

在上面的代码中,我们使用解析器工厂为 cats 创建了 owner 字段;同样地,我们还为 users 创建了 cats 字段。

在幕后,解析器工厂创建的关系字段将使用 DataLoader 从数据库查询以避免 N+1 问题。

让我们在演练场尝试一下查询猫的所有者:

GraphQL Query:

graphql 复制代码
{
  cats {
    id
    name
    age
    owner {
      id
      name
      phone
    }
  }
}

Response:

json 复制代码
{
  "data": {
    "cats": [
      {
        "id": 1,
        "name": "Mittens",
        "age": 4,
        "owner": {
          "id": 1,
          "name": "Bob",
          "phone": "001"
        }
      },
      {
        "id": 2,
        "name": "Fluffy",
        "age": 3,
        "owner": {
          "id": 1,
          "name": "Bob",
          "phone": "001"
        }
      }
    ]
  }
}

让我们尝试一下查询当前用户的猫咪:

GraphQL Query:

graphql 复制代码
{
  mine {
    name
    cats {
      id
      name
      age
    }
  }
}

Headers:

json 复制代码
{
  "authorization": "001"
}

Response:

json 复制代码
{
  "data": {
    "mine": {
      "name": "Bob",
      "cats": [
        {
          "id": 1,
          "name": "Mittens",
          "age": 4
        },
        {
          "id": 2,
          "name": "Fluffy",
          "age": 3
        }
      ]
    }
  }
}

总结

在本篇文章中,我们创建了一个简单的 GraphQL 服务端应用。我们使用了以下工具:

  • Zod: 用于定义和验证输入;
  • Drizzle: 用于操作数据库,并且直接使用 Drizzle 表格作为 GraphQL 输出类型;
  • 上下文: 用于在程序的不同部分之间共享数据,这对于实现登录、追踪日志等场景非常有用;
  • 解析器工厂: 用于快速创建解析器和操作;
  • GraphQL Yoga: 用于创建 GraphQL HTTP 服务,并且提供了 GraphiQL 演练场;

我们的应用实现了添加和查询 userscats 的功能,但限于篇幅没有实现更新和删除功能,可以通过解析器工厂来快速添加。

下一步

相关推荐
GoldenaArcher2 天前
GraphQL 工程化篇 III:引入 Prisma 与数据库接入
数据库·后端·graphql
canonical-entropy12 天前
NopGraphQL 的设计创新:从 API 协议到通用信息操作引擎
低代码·graphql·可逆计算·nop平台
canonical_entropy13 天前
NopGraphQL 的设计创新:从 API 协议到通用信息操作引擎
后端·低代码·graphql
刘立军1 个月前
使用pyHugeGraph查询HugeGraph图数据
python·graphql
麦兜*1 个月前
MongoDB 与 GraphQL 结合:现代 API 开发新范式
java·数据库·spring boot·mongodb·spring·maven·graphql
0wioiw01 个月前
Nodejs(④GraphQL)
后端·graphql
Sui_Network1 个月前
GraphQL RPC 与通用索引器公测介绍:为 Sui 带来更强大的数据层
javascript·人工智能·后端·rpc·去中心化·区块链·graphql
幂简集成1 个月前
GraphQL API 性能优化实战:在线编程作业平台指南
后端·性能优化·graphql
鼠鼠我捏,要死了捏2 个月前
GraphQL 与 REST 在微服务架构中的对比与设计实践
graphql·rest·microservices