项目概述
本项目实现基于 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