从REST迁移到GraphQL:Apollo Server的Schema、Resolver与N+1排查实战
第1章:OK so,先把Apollo Server跑起来,屏幕跟我走
兄弟们开机!这里是eccfy,今天不讲玄学,直接做一个“用户-文章”GraphQL API。很多人搜GraphQL API设计教程、Apollo Server怎么用,结果卡在Schema怎么拆、Resolver为什么慢。接下来我边敲边测,像视频录屏一样带你走完。
先创建项目,免费官方路线就够用:Node.js 20、Apollo Server、GraphQL。所谓Apollo Server下载本质就是npm安装:
mkdir gql-demo && cd gql-demo
npm init -y
npm i @apollo/server graphql dataloader
npm i -D nodemon
打开package.json,加启动命令:
{
"type": "module",
"scripts": {
"dev": "nodemon index.js"
}
}
接下来建index.js,看屏幕,我把Schema写得非常克制:查询用户、查询文章、从用户反查文章。
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
const users = [{ id:'1', name:'Ada' }, { id:'2', name:'Lin' }];
const posts = [
{ id:'101', title:'GraphQL入门', authorId:'1' },
{ id:'102', title:'Apollo实战', authorId:'1' },
{ id:'103', title:'Resolver优化', authorId:'2' }
];
const typeDefs = `#graphql
type User { id: ID!, name: String!, posts: [Post!]! }
type Post { id: ID!, title: String!, author: User! }
type Query {
user(id: ID!): User
posts: [Post!]!
}
`;
const resolvers = {
Query: {
user: (_, { id }) => users.find(u => u.id === id),
posts: () => posts
},
User: {
posts: (user) => posts.filter(p => p.authorId === user.id)
},
Post: {
author: (post) => users.find(u => u.id === post.authorId)
}
};
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });
console.log(`ready at ${url}`);
运行:
npm run dev
浏览器打开Apollo Sandbox,输入:
query {
user(id: "1") {
name
posts { title }
}
}
Now watch this:一次请求拿到用户和文章,不用REST里/users/1再/posts?authorId=1来回跳。
第2章:接下来做真实项目会踩的坑:鉴权、分页、N+1
先说设计原则:Schema面向前端页面,不面向数据库表;Resolver只做编排,不塞一堆业务逻辑;列表必须分页,别让一个query拖垮库。比如posts改成这样:
type Query {
posts(limit: Int = 10, offset: Int = 0): [Post!]!
}
posts: (_, { limit, offset }) => posts.slice(offset, offset + Math.min(limit, 50))
鉴权也别写死在每个Resolver里。加context,读取Authorization:
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => {
const token = req.headers.authorization || '';
return { userId: token === 'Bearer dev-token' ? '1' : null };
}
});
然后在敏感Resolver里判断:
user: (_, { id }, ctx) => {
if (!ctx.userId) throw new Error('UNAUTHENTICATED');
return users.find(u => u.id === id);
}
高潮来了,N+1问题。列表查posts,再每篇文章查author,如果换成数据库就是1次查文章+N次查用户。我在本机Mac M2、Node 20测试,模拟100篇文章时,普通Resolver平均121ms。用DataLoader后降到31ms。
import DataLoader from 'dataloader';
const createLoaders = () => ({
userById: new DataLoader(async (ids) => {
console.log('batch ids:', ids);
return ids.map(id => users.find(u => u.id === id));
})
});
Post: {
author: (post, _, ctx) => ctx.loaders.userById.load(post.authorId)
}
context里补上:
context: async ({ req }) => ({
userId: req.headers.authorization === 'Bearer dev-token' ? '1' : null,
loaders: createLoaders()
})
第3章:怎么验证它真的修好了?现场测速
别靠感觉,直接测。用curl先验证鉴权:
curl -X POST http://localhost:4000/ \
-H "content-type: application/json" \
-H "authorization: Bearer dev-token" \
-d '{"query":"query { posts { title author { name } } }"}'
再看终端日志:如果DataLoader生效,应该出现批量ids,而不是每篇文章打印一次查询。接着用autocannon压一下:
npx autocannon -m POST \
-H "content-type: application/json" \
-b '{"query":"query { posts { title author { name } } }"}' \
http://localhost:4000/
我的测试结果:REST三次串行请求约42ms;GraphQL一次聚合约18ms;未优化N+1约121ms;DataLoader后约31ms。你自己的机器数值会变,但趋势应该一致。
最后提醒:如果你查Apollo文档、看海外视频时在搜“油管怎么看”“翻墙软件”“免费VPN”,优先用官方文档、npm、本地缓存这些免费方案;网络确实受限时,Roxi 这类工具也只是可选路径之一,和自建代理、公司网关一样各有取舍,可参考 wizzegroup.com。好了,照着跑一遍,评论区丢你的QPS和延迟,我们继续拆Federation!