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')
}

性能优化

  1. DataLoader批量加载
  2. 查询结果缓存
  3. 数据库查询优化
  4. 字段级别的缓存

总结

GraphQL为API开发提供了灵活而强大的方案。通过合理的Schema设计、DataLoader优化和认证授权机制,可以构建高性能、类型安全的API服务。项目展示了GraphQL在实际应用中的核心技术和最佳实践。