← 中文资源

简体中文版本

用 DataLoader 构建 GraphQL 博客 API

一个完整的 GraphQL 博客 API:包含 schema、resolver、游标分页、按请求 DataLoader 批处理与 N+1 回归测试。

阅读英文原文

项目概述

本项目实现基于 DataLoader 的 GraphQL 博客 API:客户端可在一次请求中查询文章、作者与评论,用游标分页,且不会触发 N+1 读取。GraphQL 让客户端自由组合查询,也让每个字段意外触发一次读取成为可能,本项目的重点是在测试中断言查询次数。

架构与关键决策

单一端点解析并执行操作,字段经由服务模块解析而非直连数据库;每个请求创建一个加载器实例按 id 批量取作者与评论,把 N+1 折叠为每关系一次查询。加载器按请求与实体类型创建,因此批处理不跨鉴权边界、缓存实体不会串到其他用户。分页用稳定排序键的游标而非偏移,因为订阅源与被写入并发,偏移会让页边界随新行移动。

实施与测试

在临时端口启动并以类型化客户端运行真实操作,断言响应形状;用包裹连接池的查询计数器断言取 N 篇文章时作者查询数有界且与 N 无关;在页间插入文档断言分页不重不漏。

限制与后续

  • 仅支持一种排序的游标分页,未暴露任意排序。
  • 无持久化查询白名单。
  • 不含订阅,实时更新由本集合的独立服务承担。

代码示例

pnpm init
pnpm add graphql-yoga graphql dataloader pg
pnpm add -D typescript tsx vitest
graphql-blog-api/
├── src/schema.ts
├── src/resolvers.ts
├── src/dataloaders.ts
├── src/db.ts
├── src/context.ts
├── tests/api.test.ts
└── package.json
type Query {
  posts(first: Int!, after: String): PostConnection!
  post(id: ID!): Post
}
type Post { id: ID! title: String! author: Author! comments: [Comment!]! }
export function buildContext(req) {
  return { pool, requestId: req.id, loaders: createLoaders(pool) };
}
const rows = await pool.query(
  'SELECT * FROM posts WHERE (created_at, id) < ($1,$2) ORDER BY created_at DESC, id DESC LIMIT $3',
  [cursor.created_at, cursor.id, first + 1],
);
export const createLoaders = (pool) => ({
  authorById: new DataLoader(async (ids) => {
    const { rows } = await pool.query('SELECT * FROM authors WHERE id = ANY($1)', [ids]);
    const byId = new Map(rows.map((r) => [r.id, r]));
    return ids.map((id) => byId.get(id) ?? null);
  }),
});
if (title.trim().length < 3) throw new GraphQLError('title too short');
const { rows } = await pool.query('INSERT INTO posts(title, author_id) VALUES ($1,$2) RETURNING *', [title, authorId]);
pnpm vitest run