GraphQL 架构折腾手记

这次 API 重构,从 REST 迁移到 GraphQL,解决了不少实际问题,但也踩了一些坑。

最初的问题

原来的 API 是标准的 REST 风格:

GET /api/users/:id           # 获取用户信息
GET /api/users/:id/posts     # 获取用户的文章
GET /api/posts/:id/comments  # 获取文章的评论

问题很明显:

过度获取:前端只需要用户名,但 API 返回了完整信息,包括邮箱、手机号等敏感数据。

获取不足:要显示一篇文章,需要调用三次 API:文章详情、作者信息、评论列表。

多端适配困难:Web 端和移动端对数据的需求不一样,Web 需要完整信息,移动端只需要简略信息。

文档维护困难:接口改了,文档没跟上,前端按旧文档调用,结果报错。

GraphQL 的基本思路

GraphQL 的核心思想是:让前端告诉后端它需要什么数据,而不是后端决定返回什么数据。

一个 GraphQL 查询大概长这样:

query GetPost($id: ID!) {
  post(id: $id) {
    id
    title
    content
    author {
      id
      name
      avatar
    }
    comments {
      id
      content
      author {
        name
      }
    }
  }
}

前端只要改一下查询,就能控制返回的数据结构,不用后端改接口。

sequenceDiagram participant Client as 客户端 participant REST as REST API participant GQL as GraphQL API Note over Client,REST: REST:多次请求 Client->>REST: GET /api/posts/1 REST-->>Client: 文章信息 Client->>REST: GET /api/users/1 REST-->>Client: 作者信息 Client->>REST: GET /api/posts/1/comments REST-->>Client: 评论列表 Note over Client,GQL: GraphQL:单次请求 Client->>GQL: 单个查询请求<br/>指定需要的字段 GQL-->>Client: 精确的数据结构

实践中的迁移

Schema 定义

先定义 GraphQL Schema:

type User {
  id: ID!
  name: String!
  email: String!
  avatar: String
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  comments: [Comment!]!
}

type Comment {
  id: ID!
  content: String!
  author: User!
  post: Post!
}

type Query {
  user(id: ID!): User
  post(id: ID!): Post
  posts: [Post!]!
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!
}

type Subscription {
  postCreated: Post!
  commentAdded(postId: ID!): Comment!
}

解析器实现

每个字段都需要一个解析器(Resolver):

const resolvers = {
  Query: {
    user: (parent, args) => {
      return db.user.findUnique({ where: { id: args.id } });
    },
    post: (parent, args) => {
      return db.post.findUnique({ where: { id: args.id } });
    },
    posts: () => {
      return db.post.findMany();
    }
  },

  User: {
    posts: (parent) => {
      return db.post.findMany({ where: { authorId: parent.id } });
    }
  },

  Post: {
    author: (parent) => {
      return db.user.findUnique({ where: { id: parent.authorId } });
    },
    comments: (parent) => {
      return db.comment.findMany({ where: { postId: parent.id } });
    }
  },

  Comment: {
    author: (parent) => {
      return db.user.findUnique({ where: { id: parent.authorId } });
    }
  }
};

踩过的坑

坑一:N+1 查询问题

GraphQL 的嵌套查询很容易导致 N+1 问题:

// 问题:每篇文章都单独查一次作者
const posts = await db.post.findMany();
for (const post of posts) {
  post.author = await db.user.findUnique({ where: { id: post.authorId } });
}

10 篇文章,就要查 11 次数据库(1 次查文章 + 10 次查作者)。

解决:使用 DataLoader 批处理。

const DataLoader = require('dataloader');

// 创建 loader
const userLoader = new DataLoader(async (userIds) => {
  const users = await db.user.findMany({ where: { id: { in: userIds } } });
  return userIds.map(id => users.find(user => user.id === id));
});

// 在解析器中使用
const resolvers = {
  Post: {
    author: (parent) => {
      return userLoader.load(parent.authorId);
    }
  }
};

DataLoader 会把同一批请求合并,10 次查询变成 1 次批量查询。

坑二:查询复杂度无限制

GraphQL 允许前端自由写查询,如果不加限制,一个恶意查询就能把服务器搞崩:

# 恶意查询:深度嵌套
query {
  user(id: "1") {
    posts {
      author {
        posts {
          author {
            posts {
              # 无限嵌套
            }
          }
        }
      }
    }
  }
}

解决:限制查询深度和复杂度。

const depthLimit = require('graphql-depth-limit');
const { createComplexityLimitRule } = require('graphql-validation-complexity');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(7),  // 限制查询深度
    createComplexityLimitRule(1000)  // 限制复杂度
  ]
});

坑三:权限控制复杂

REST 里,权限控制相对简单:谁有权限访问哪个 URL 就行。GraphQL 里,权限控制要细到字段级别。

type User {
  id: ID!
  name: String!
  email: String!      # 需要登录
  phone: String!      # 需要登录且是本人
  admin: Boolean!     # 只有管理员可见
}

解决:在解析器里加权限检查。

const resolvers = {
  User: {
    email: (parent, args, context) => {
      if (!context.user) {
        throw new Error('Unauthorized');
      }
      return parent.email;
    },
    phone: (parent, args, context) => {
      if (!context.user || context.user.id !== parent.id) {
        throw new Error('Unauthorized');
      }
      return parent.phone;
    },
    admin: (parent, args, context) => {
      if (!context.user || !context.user.isAdmin) {
        throw new Error('Unauthorized');
      }
      return parent.admin;
    }
  }
};

坑四:缓存不友好

REST 可以直接利用 HTTP 缓存,GraphQL 只有一个 endpoint(通常是 /graphql),没法靠 URL 区分不同请求,缓存难做。

解决

  • 使用 Apollo Client 的客户端缓存
  • 后端实现查询结果缓存(基于查询字符串)
  • 关键数据用 REST,其他用 GraphQL,混合方案

迁移后的效果

指标RESTGraphQL改善
平均请求数3167%
数据传输量50KB15KB70%
API 数量25388%
前端改需求次数-

前端开发效率明显提升,改需求不需要后端改接口,前端自己改查询就行。

什么时候用 GraphQL

适合用的场景

  • 数据需求复杂,关联查询多
  • 多端接入,需求差异大
  • 前端团队希望控制数据结构
  • 实时数据需求强

不适合用的场景

  • 简单 CRUD 操作,REST 够用
  • 需要强 HTTP 缓存支持
  • 团队没有 GraphQL 经验,学习成本高
  • 对延迟极其敏感,GraphQL 解析有开销

写在最后

GraphQL 不是要完全替代 REST,而是提供了一种不同的解决问题的方式。

简单场景用 REST,复杂场景考虑 GraphQL。或者混合:核心 CRUD 用 REST,复杂查询用 GraphQL。

技术选型的核心是匹配需求,不是追新。如果你团队对 REST 很熟,用户量不大,需求也简单,没必要硬上 GraphQL。


这次迁移花了两个月,中间有过反复。但回头看,GraphQL 带来的灵活性确实有价值,特别是对前端开发效率的提升。

版权声明: 本文首发于 指尖魔法屋-GraphQL 架构折腾手记https://blog.thinkmoon.cn/post/17-graphql-rest-migration/) 转载或引用必须申明原指尖魔法屋来源及源地址!