📖 第13课:查询与变更

阶段三:GraphQL

Query和Mutation是GraphQL与数据交互的两把钥匙。Query负责读取数据,支持别名、片段、变量等强大特性;Mutation负责写入数据,保证顺序执行。本课深入查询的高级特性和变更的最佳实践。

🔍 Query高级特性

变量(Variables)

# 变量让查询可复用、可缓存、更安全

# 查询定义
query GetUsers($limit: Int = 20, $role: Role, $cursor: String) {
  users(limit: $limit, role: $role, after: $cursor) {
    edges {
      node { id name email role }
      cursor
    }
    pageInfo { hasNextPage endCursor }
    totalCount
  }
}

# 变量值
{
  "limit": 10,
  "role": "EDITOR",
  "cursor": null
}

# 使用curl发送带变量的查询
curl -s -X POST http://localhost:3000/graphql \
  -H "Content-Type: application/json" \
  -d '{
    "query": "query GetUsers($limit: Int, $role: Role) { users(limit: $limit, role: $role) { edges { node { id name } } } }",
    "variables": { "limit": 5, "role": "EDITOR" }
  }'
💡 变量的好处:1) 避免字符串拼接,防止注入 2) 查询文档可独立缓存 3) 类型检查在变量层自动完成 4) 客户端代码更清晰。永远不要用模板字符串拼接查询参数!

别名(Aliases)

# 别名:同一查询中多次请求同一字段,用不同参数
query CompareUsers {
  admin: user(id: "1") {
    name
    role
  }
  editor: user(id: "2") {
    name
    role
  }
  # 不用别名会冲突!
}

# 响应
{
  "data": {
    "admin": { "name": "张三", "role": "ADMIN" },
    "editor": { "name": "李四", "role": "EDITOR" }
  }
}

片段(Fragments)

# 片段:可复用的字段组合,DRY原则

fragment UserFields on User {
  id
  name
  email
  avatar
  role
}

fragment PostFields on Post {
  id
  title
  excerpt
  createdAt
  author { ...UserFields }
}

# 在查询中使用片段
query GetPostsWithAuthors {
  posts(first: 10) {
    edges {
      node {
        ...PostFields
        comments(first: 3) {
          edges {
            node {
              body
              author { ...UserFields }
            }
          }
        }
      }
    }
  }
}

# 内联片段:处理接口和联合类型
query Search {
  search(query: "GraphQL") {
    ... on User { id name email }
    ... on Post { id title author { name } }
    ... on Tag { id name slug }
  }
}

指令(Directives)

# @include 和 @skip:条件查询
query GetUsers($withPosts: Boolean!, $role: Role) {
  users(role: $role) {
    edges {
      node {
        id
        name
        posts @include(if: $withPosts) {
          edges { node { id title } }
        }
        postCount @include(if: $withPosts)
      }
    }
  }
}

# 变量
{ "withPosts": true, "role": "ADMIN" }

# @skip:条件跳过
query GetPosts($skipContent: Boolean!) {
  posts {
    edges {
      node {
        id
        title
        content @skip(if: $skipContent)  # 列表页跳过内容
        excerpt
      }
    }
  }
}

# @deprecated:标记废弃字段
type User {
  name: String! @deprecated(reason: "使用 displayName 代替")
  displayName: String!
}

✏️ Mutation最佳实践

Mutation设计模式

# 模式1: 输入类型模式(推荐)
type Mutation {
  createPost(input: CreatePostInput!): CreatePostPayload!
  updatePost(input: UpdatePostInput!): UpdatePostPayload!
  deletePost(input: DeletePostInput!): DeletePostPayload!
}

input CreatePostInput {
  title: String!
  content: String!
  tags: [String!]!
  status: PostStatus = DRAFT
  clientMutationId: String   # Relay规范:幂等性保障
}

type CreatePostPayload {
  post: Post                  # 新创建的资源
  user: User                  # 可能影响的其他资源
  clientMutationId: String
}

# 模式2: 简单模式(小型API)
type Mutation {
  createPost(title: String!, content: String!): Post!
  updatePost(id: ID!, title: String, content: String): Post!
  deletePost(id: ID!): Boolean!
}
💡 Mutation执行保证:GraphQL规范要求Mutation中的多个字段按顺序执行(Query并行执行)。这意味着mutation { a b c }中a先于b,b先于c。但最佳实践是一个Mutation只包含一个操作,确保行为可预测。

Mutation响应设计

# 成功响应
mutation {
  createPost(input: { title: "测试", content: "内容" }) {
    post { id title status createdAt }
    clientMutationId
  }
}
# → { "data": { "createPost": { "post": {...}, "clientMutationId": null } } }

# 验证错误
mutation {
  createPost(input: { title: "", content: "" }) {
    post { id }
  }
}
# → {
#   "data": { "createPost": null },
#   "errors": [{
#     "message": "验证失败",
#     "extensions": {
#       "code": "VALIDATION_ERROR",
#       "details": [
#         { "field": "title", "message": "标题不能为空" },
#         { "field": "content", "message": "内容不能为空" }
#       ]
#     }
#   }]
# }

🛠️ 实战:完整查询变更服务

// query-mutation-server.js
const express = require('express');
const { createHandler } = require('graphql-http/lib/use/express');
const { buildSchema } = require('graphql');

const app = express();

const schema = buildSchema(`
  scalar DateTime

  enum TaskStatus { TODO IN_PROGRESS DONE CANCELLED }
  enum Priority { LOW MEDIUM HIGH URGENT }

  type User {
    id: ID!
    name: String!
    email: String!
    tasks(status: TaskStatus, first: Int = 20): [Task!]!
    taskCount(status: TaskStatus): Int!
  }

  type Task {
    id: ID!
    title: String!
    description: String
    status: TaskStatus!
    priority: Priority!
    assignee: User
    tags: [String!]!
    dueDate: DateTime
    createdAt: DateTime!
    updatedAt: DateTime!
  }

  type TaskConnection {
    edges: [TaskEdge!]!
    pageInfo: PageInfo!
    totalCount: Int!
  }

  type TaskEdge {
    node: Task!
    cursor: String!
  }

  type PageInfo {
    hasNextPage: Boolean!
    hasPreviousPage: Boolean!
    startCursor: String
    endCursor: String
  }

  input CreateTaskInput {
    title: String!
    description: String
    priority: Priority = MEDIUM
    assigneeId: ID
    tags: [String!]! = []
    dueDate: DateTime
  }

  input UpdateTaskInput {
    title: String
    description: String
    status: TaskStatus
    priority: Priority
    assigneeId: ID
    tags: [String!]
    dueDate: DateTime
  }

  type Query {
    me: User!
    user(id: ID!): User
    users(first: Int = 20): [User!]!

    task(id: ID!): Task
    tasks(status: TaskStatus, priority: Priority, assigneeId: ID, tag: String, first: Int = 20, after: String): TaskConnection!

    searchTasks(query: String!): [Task!]!
  }

  type Mutation {
    createTask(input: CreateTaskInput!): Task!
    updateTask(id: ID!, input: UpdateTaskInput!): Task!
    deleteTask(id: ID!): Boolean!
    assignTask(taskId: ID!, userId: ID!): Task!
    changeStatus(taskId: ID!, status: TaskStatus!): Task!
  }
`);

// 数据
const users = [
  { id: '1', name: '张三', email: 'zhang@example.com' },
  { id: '2', name: '李四', email: 'li@example.com' },
];

const tasks = [
  { id: '1', title: '设计API', description: '完成RESTful API设计', status: 'IN_PROGRESS', priority: 'HIGH', assigneeId: '1', tags: ['API', '设计'], dueDate: '2025-02-01', createdAt: '2025-01-10', updatedAt: '2025-01-15' },
  { id: '2', title: '编写文档', description: 'API使用文档', status: 'TODO', priority: 'MEDIUM', assigneeId: '2', tags: ['文档'], dueDate: '2025-02-15', createdAt: '2025-01-11', updatedAt: '2025-01-11' },
  { id: '3', title: '性能优化', description: 'API响应时间优化', status: 'TODO', priority: 'URGENT', assigneeId: '1', tags: ['API', '性能'], dueDate: '2025-01-25', createdAt: '2025-01-12', updatedAt: '2025-01-12' },
  { id: '4', title: '添加测试', description: '单元测试和集成测试', status: 'DONE', priority: 'MEDIUM', assigneeId: '2', tags: ['测试'], dueDate: null, createdAt: '2025-01-08', updatedAt: '2025-01-14' },
];

let nextTaskId = 5;

const root = {
  me: () => users[0],
  user: ({ id }) => users.find(u => u.id === id),
  users: ({ first }) => users.slice(0, first),
  task: ({ id }) => tasks.find(t => t.id === id),
  tasks: ({ status, priority, assigneeId, tag, first, after }) => {
    let result = [...tasks];
    if (status) result = result.filter(t => t.status === status);
    if (priority) result = result.filter(t => t.priority === priority);
    if (assigneeId) result = result.filter(t => t.assigneeId === assigneeId);
    if (tag) result = result.filter(t => t.tags.includes(tag));

    const startIdx = after ? result.findIndex(t => t.id === after) + 1 : 0;
    const sliced = result.slice(startIdx, startIdx + first);

    return {
      edges: sliced.map(t => ({ node: t, cursor: t.id })),
      pageInfo: { hasNextPage: startIdx + first < result.length, hasPreviousPage: startIdx > 0, endCursor: sliced[sliced.length - 1]?.id },
      totalCount: result.length,
    };
  },
  searchTasks: ({ query }) => {
    const q = query.toLowerCase();
    return tasks.filter(t => t.title.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q));
  },
  User: {
    tasks: (user, { status }) => {
      let result = tasks.filter(t => t.assigneeId === user.id);
      if (status) result = result.filter(t => t.status === status);
      return result;
    },
    taskCount: (user, { status }) => {
      let result = tasks.filter(t => t.assigneeId === user.id);
      if (status) result = result.filter(t => t.status === status);
      return result.length;
    },
  },
  Task: {
    assignee: (task) => task.assigneeId ? users.find(u => u.id === task.assigneeId) : null,
  },
  // Mutations
  createTask: ({ input }) => {
    const id = String(nextTaskId++);
    const task = { id, ...input, status: 'TODO', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
    tasks.push(task);
    return task;
  },
  updateTask: ({ id, input }) => {
    const task = tasks.find(t => t.id === id);
    if (!task) throw new Error(`任务 ${id} 不存在`);
    Object.assign(task, input, { updatedAt: new Date().toISOString() });
    return task;
  },
  deleteTask: ({ id }) => {
    const idx = tasks.findIndex(t => t.id === id);
    if (idx === -1) return false;
    tasks.splice(idx, 1);
    return true;
  },
  assignTask: ({ taskId, userId }) => {
    const task = tasks.find(t => t.id === taskId);
    if (!task) throw new Error(`任务 ${taskId} 不存在`);
    if (!users.find(u => u.id === userId)) throw new Error(`用户 ${userId} 不存在`);
    task.assigneeId = userId;
    task.updatedAt = new Date().toISOString();
    return task;
  },
  changeStatus: ({ taskId, status }) => {
    const task = tasks.find(t => t.id === taskId);
    if (!task) throw new Error(`任务 ${taskId} 不存在`);
    task.status = status;
    task.updatedAt = new Date().toISOString();
    return task;
  },
};

app.all('/graphql', createHandler({ schema, rootValue: root }));
app.listen(3000, () => console.log('📋 查询变更服务运行在 http://localhost:3000/graphql'));

测试高级查询

# 1. 带变量的查询
curl -s -X POST http://localhost:3000/graphql \
  -H "Content-Type: application/json" \
  -d '{
    "query": "query($status: TaskStatus, $first: Int) { tasks(status: $status, first: $first) { edges { node { id title priority } } totalCount } }",
    "variables": { "status": "TODO", "first": 5 }
  }' | jq .
{
  "data": {
    "tasks": {
      "edges": [
        {"node":{"id":"2","title":"编写文档","priority":"MEDIUM"}},
        {"node":{"id":"3","title":"性能优化","priority":"URGENT"}}
      ],
      "totalCount": 2
    }
  }
}
# 2. 别名查询
curl -s -X POST http://localhost:3000/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"{ todo: tasks(status: TODO) { totalCount } done: tasks(status: DONE) { totalCount } all: tasks { totalCount } }"}' | jq .

# 3. 片段查询
curl -s -X POST http://localhost:3000/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"fragment TaskFields on Task { id title status priority } { tasks(first: 3) { edges { node { ...TaskFields assignee { name } } } } }"}' | jq .

# 4. 条件查询(@include)
curl -s -X POST http://localhost:3000/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"query($withDesc: Boolean!) { tasks(first: 3) { edges { node { id title description @include(if: $withDesc) } } } }", "variables": {"withDesc": false}}' | jq .

# 5. Mutation: 创建任务
curl -s -X POST http://localhost:3000/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"mutation { createTask(input: { title: \"Code Review\", priority: HIGH, tags: [\"review\"] }) { id title status priority } }"}' | jq .

# 6. Mutation: 更改状态
curl -s -X POST http://localhost:3000/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"mutation { changeStatus(taskId: \"2\", status: IN_PROGRESS) { id title status } }"}' | jq .

📝 本课小结

  1. 变量让查询可复用、可缓存、更安全
  2. 别名允许同一查询中多次使用同名字段
  3. 片段实现字段组合的复用,遵循DRY原则
  4. @include/@skip指令实现条件查询
  5. Mutation使用输入类型模式,响应包含变更后的资源
  6. Mutation字段按顺序执行,Query并行执行
  7. Relay规范的clientMutationId支持幂等性

💪 练习

练习1:优化查询

将以下冗余查询用片段重构:

{ user1: user(id:"1") { id name email avatar role } user2: user(id:"2") { id name email avatar role } }

练习2:批量Mutation

设计一个批量更新任务状态的Mutation,支持一次更新多个任务。

🏆 本课成就:查询变更大师