GraphQL API服务设计与实现
使用Apollo Server和Prisma构建可扩展的GraphQL API服务,包含Schema设计、DataLoader优化、认证授权和N+1查询解决方案
2024年3月5日
源代码GraphQLApolloAPI设计后端
项目简介
这是一个使用Apollo Server和Prisma构建的完整GraphQL API服务。项目实现了用户管理、文章系统和评论功能,展示了GraphQL在实际项目中的最佳实践,包括Schema设计、性能优化、认证授权和错误处理。
核心功能
1. GraphQL API
- 类型安全的Schema定义
- Query和Mutation操作
- Subscription实时订阅
- 分页和筛选
2. 性能优化
- DataLoader批量查询
- N+1问题解决
- 查询复杂度限制
- 缓存策略
3. 认证授权
- JWT令牌认证
- 基于角色的权限控制
- 字段级别权限
- GraphQL指令
4. 错误处理
- 统一错误格式
- 自定义错误类型
- 错误日志记录
技术栈
- Node.js + TypeScript
- Apollo Server 4
- GraphQL
- Prisma ORM
- PostgreSQL
- Redis (缓存)
- JWT认证
- DataLoader
GraphQL Schema设计
# schema.graphql
# 用户类型
type User {
id: ID!
email: String!
name: String!
role: Role!
posts: [Post!]!
createdAt: DateTime!
updatedAt: DateTime!
}
# 文章类型
type Post {
id: ID!
title: String!
content: String!
published: Boolean!
author: User!
authorId: ID!
comments: [Comment!]!
tags: [Tag!]!
createdAt: DateTime!
updatedAt: DateTime!
}
# 评论类型
type Comment {
id: ID!
content: String!
author: User!
authorId: ID!
post: Post!
postId: ID!
createdAt: DateTime!
}
# 标签类型
type Tag {
id: ID!
name: String!
posts: [Post!]!
}
# 枚举:用户角色
enum Role {
USER
ADMIN
}
# 分页类型
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type PostEdge {
node: Post!
cursor: String!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
# Query查询
type Query {
# 用户查询
me: User
user(id: ID!): User
users(first: Int, after: String): [User!]!
# 文章查询
post(id: ID!): Post
posts(
first: Int
after: String
where: PostWhereInput
orderBy: PostOrderByInput
): PostConnection!
# 搜索
searchPosts(query: String!): [Post!]!
}
# Mutation修改
type Mutation {
# 用户操作
signup(input: SignupInput!): AuthPayload!
login(input: LoginInput!): AuthPayload!
# 文章操作
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
publishPost(id: ID!): Post!
# 评论操作
createComment(input: CreateCommentInput!): Comment!
deleteComment(id: ID!): Boolean!
}
# Subscription订阅
type Subscription {
postCreated: Post!
commentAdded(postId: ID!): Comment!
}
# Input类型
input SignupInput {
email: String!
password: String!
name: String!
}
input LoginInput {
email: String!
password: String!
}
input CreatePostInput {
title: String!
content: String!
tags: [String!]
}
input UpdatePostInput {
title: String
content: String
published: Boolean
}
input CreateCommentInput {
content: String!
postId: ID!
}
input PostWhereInput {
title: StringFilter
published: Boolean
author: UserWhereInput
}
input StringFilter {
contains: String
startsWith: String
endsWith: String
}
# 认证响应
type AuthPayload {
token: String!
user: User!
}
scalar DateTime核心实现
1. Apollo Server配置
// src/server.ts
import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { readFileSync } from 'fs'
import { resolvers } from './resolvers'
import { createContext } from './context'
const typeDefs = readFileSync('./schema.graphql', 'utf-8')
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (error) => {
// 自定义错误格式
console.error('GraphQL Error:', error)
return {
message: error.message,
code: error.extensions?.code,
path: error.path,
}
},
plugins: [
// 查询复杂度限制
{
requestDidStart: async () => ({
async didResolveOperation(requestContext) {
const complexity = calculateComplexity(requestContext.document)
if (complexity > 1000) {
throw new Error('Query is too complex')
}
},
}),
},
],
})
const { url } = await startStandaloneServer(server, {
context: createContext,
listen: { port: 4000 },
})
console.log(`🚀 Server ready at ${url}`)2. Context和认证
// src/context.ts
import { PrismaClient } from '@prisma/client'
import { Request } from 'express'
import jwt from 'jsonwebtoken'
import DataLoader from 'dataloader'
const prisma = new PrismaClient()
export interface Context {
prisma: PrismaClient
userId?: string
userRole?: string
loaders: {
userLoader: DataLoader<string, User>
postLoader: DataLoader<string, Post>
commentLoader: DataLoader<string, Comment[]>
}
}
// 验证JWT令牌
function getUserFromToken(token: string) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
userId: string
role: string
}
return decoded
} catch (error) {
return null
}
}
export async function createContext({ req }: { req: Request }): Promise<Context> {
const token = req.headers.authorization?.replace('Bearer ', '')
const user = token ? getUserFromToken(token) : null
return {
prisma,
userId: user?.userId,
userRole: user?.role,
loaders: {
userLoader: createUserLoader(prisma),
postLoader: createPostLoader(prisma),
commentLoader: createCommentLoader(prisma),
},
}
}3. DataLoader实现(解决N+1问题)
// src/loaders/userLoader.ts
import DataLoader from 'dataloader'
import { PrismaClient, User } from '@prisma/client'
export function createUserLoader(prisma: PrismaClient) {
return new DataLoader<string, User>(async (userIds) => {
// 批量查询所有用户
const users = await prisma.user.findMany({
where: {
id: {
in: [...userIds],
},
},
})
// 创建id到user的映射
const userMap = new Map(users.map((user) => [user.id, user]))
// 按照请求顺序返回结果
return userIds.map((id) => userMap.get(id) || new Error(`User ${id} not found`))
})
}
// src/loaders/commentLoader.ts
export function createCommentLoader(prisma: PrismaClient) {
return new DataLoader<string, Comment[]>(async (postIds) => {
// 批量查询所有评论
const comments = await prisma.comment.findMany({
where: {
postId: {
in: [...postIds],
},
},
})
// 按postId分组
const commentsByPostId = new Map<string, Comment[]>()
for (const comment of comments) {
if (!commentsByPostId.has(comment.postId)) {
commentsByPostId.set(comment.postId, [])
}
commentsByPostId.get(comment.postId)!.push(comment)
}
// 返回每个postId对应的评论数组
return postIds.map((id) => commentsByPostId.get(id) || [])
})
}4. Resolvers实现
// src/resolvers/index.ts
import { GraphQLError } from 'graphql'
import { Context } from '../context'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
export const resolvers = {
Query: {
// 获取当前用户
me: (_parent: any, _args: any, context: Context) => {
if (!context.userId) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
return context.loaders.userLoader.load(context.userId)
},
// 获取单个文章
post: async (_parent: any, { id }: { id: string }, context: Context) => {
return context.prisma.post.findUnique({
where: { id },
})
},
// 分页查询文章
posts: async (
_parent: any,
{
first = 10,
after,
where,
orderBy,
}: {
first?: number
after?: string
where?: any
orderBy?: any
},
context: Context
) => {
const posts = await context.prisma.post.findMany({
take: first + 1, // 多取一个判断是否有下一页
skip: after ? 1 : 0,
cursor: after ? { id: after } : undefined,
where,
orderBy: orderBy || { createdAt: 'desc' },
})
const hasNextPage = posts.length > first
const edges = posts.slice(0, first).map((post) => ({
node: post,
cursor: post.id,
}))
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor,
},
totalCount: await context.prisma.post.count({ where }),
}
},
// 搜索文章
searchPosts: async (
_parent: any,
{ query }: { query: string },
context: Context
) => {
return context.prisma.post.findMany({
where: {
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ content: { contains: query, mode: 'insensitive' } },
],
},
take: 20,
})
},
},
Mutation: {
// 用户注册
signup: async (
_parent: any,
{ input }: { input: { email: string; password: string; name: string } },
context: Context
) => {
const hashedPassword = await bcrypt.hash(input.password, 10)
const user = await context.prisma.user.create({
data: {
email: input.email,
password: hashedPassword,
name: input.name,
},
})
const token = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET!,
{ expiresIn: '7d' }
)
return { token, user }
},
// 用户登录
login: async (
_parent: any,
{ input }: { input: { email: string; password: string } },
context: Context
) => {
const user = await context.prisma.user.findUnique({
where: { email: input.email },
})
if (!user) {
throw new GraphQLError('Invalid credentials', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
const valid = await bcrypt.compare(input.password, user.password)
if (!valid) {
throw new GraphQLError('Invalid credentials', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
const token = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET!,
{ expiresIn: '7d' }
)
return { token, user }
},
// 创建文章
createPost: async (
_parent: any,
{ input }: { input: { title: string; content: string; tags?: string[] } },
context: Context
) => {
if (!context.userId) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
return context.prisma.post.create({
data: {
title: input.title,
content: input.content,
authorId: context.userId,
tags: {
connectOrCreate: input.tags?.map((name) => ({
where: { name },
create: { name },
})),
},
},
include: {
author: true,
tags: true,
},
})
},
// 删除文章
deletePost: async (
_parent: any,
{ id }: { id: string },
context: Context
) => {
if (!context.userId) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
const post = await context.prisma.post.findUnique({
where: { id },
})
if (!post || post.authorId !== context.userId) {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
})
}
await context.prisma.post.delete({ where: { id } })
return true
},
},
// 字段解析器
Post: {
author: (parent: any, _args: any, context: Context) => {
// 使用DataLoader避免N+1问题
return context.loaders.userLoader.load(parent.authorId)
},
comments: (parent: any, _args: any, context: Context) => {
// 使用DataLoader批量加载评论
return context.loaders.commentLoader.load(parent.id)
},
},
Comment: {
author: (parent: any, _args: any, context: Context) => {
return context.loaders.userLoader.load(parent.authorId)
},
},
}5. Subscription订阅
// src/subscriptions.ts
import { PubSub } from 'graphql-subscriptions'
const pubsub = new PubSub()
export const subscriptionResolvers = {
Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator(['POST_CREATED']),
},
commentAdded: {
subscribe: (_parent: any, { postId }: { postId: string }) => {
return pubsub.asyncIterator([`COMMENT_ADDED_${postId}`])
},
},
},
}
// 发布事件
export function publishPostCreated(post: any) {
pubsub.publish('POST_CREATED', { postCreated: post })
}
export function publishCommentAdded(postId: string, comment: any) {
pubsub.publish(`COMMENT_ADDED_${postId}`, { commentAdded: comment })
}遇到的挑战
问题1:N+1查询问题
描述:查询文章列表时,为每个文章单独查询作者信息,导致大量数据库查询
解决方案: 使用DataLoader批量加载,将多个单独查询合并为一个批量查询
问题2:查询深度限制
描述:恶意用户可能构造深度嵌套的查询,导致服务器负载过高
解决方案:
- 限制查询深度
- 限制查询复杂度
- 实现查询超时
import { getComplexity, simpleEstimator } from 'graphql-query-complexity'
const complexity = getComplexity({
schema,
query: request.document,
variables: request.variables,
estimators: [simpleEstimator({ defaultComplexity: 1 })],
})
if (complexity > 1000) {
throw new Error('Query is too complex')
}性能优化
- DataLoader批量加载
- 查询结果缓存
- 数据库查询优化
- 字段级别的缓存
总结
GraphQL为API开发提供了灵活而强大的方案。通过合理的Schema设计、DataLoader优化和认证授权机制,可以构建高性能、类型安全的API服务。项目展示了GraphQL在实际应用中的核心技术和最佳实践。