📖 第12课:Schema设计

阶段三:GraphQL

Schema是GraphQL的灵魂——它定义了整个API的类型系统、数据关系和操作入口。一个好的Schema让查询直觉自然,让类型安全贯穿前后端,让API演进无缝衔接。本课深入Schema设计原则、高级类型系统、Relay规范和Schema演进策略。

📐 Schema设计原则

五大设计原则

🔤 类型系统详解

标量类型与自定义标量

# 内置标量
Int        # 32位整数
Float      # 浮点数
String     # UTF-8字符串
Boolean    # 布尔值
ID         # 唯一标识符(序列化为String)

# 自定义标量——扩展类型系统
scalar DateTime    # ISO 8601日期时间
scalar Date        # 日期
scalar Email       # 邮箱(带验证)
scalar URL         # URL(带验证)
scalar JSON        # 自由格式JSON
scalar PositiveInt # 正整数

# 自定义标量的实现
const { GraphQLScalarType, Kind } = require('graphql');

const DateTimeScalar = new GraphQLScalarType({
  name: 'DateTime',
  description: 'ISO 8601格式的日期时间',
  serialize(value) {
    // 输出:Date → String
    return value instanceof Date ? value.toISOString() : value;
  },
  parseValue(value) {
    // 输入变量:String → Date
    return new Date(value);
  },
  parseLiteral(ast) {
    // 输入字面量:AST → Date
    if (ast.kind === Kind.STRING) return new Date(ast.value);
    return null;
  },
});

const EmailScalar = new GraphQLScalarType({
  name: 'Email',
  description: '邮箱地址',
  serialize: (v) => v,
  parseValue: (v) => {
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v))
      throw new Error('无效的邮箱格式');
    return v;
  },
  parseLiteral: (ast) => {
    if (ast.kind === Kind.STRING && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(ast.value))
      return ast.value;
    throw new Error('无效的邮箱格式');
  },
});

接口与联合类型

# 接口(Interface)—— 共享字段的抽象类型
interface Node {
  id: ID!
  createdAt: DateTime!
}

interface ContentItem implements Node {
  id: ID!
  title: String!
  author: User!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Post implements ContentItem & Node {
  id: ID!
  title: String!
  content: String!
  author: User!
  tags: [String!]!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Article implements ContentItem & Node {
  id: ID!
  title: String!
  body: String!
  summary: String
  author: User!
  category: String!
  createdAt: DateTime!
  updatedAt: DateTime!
}

# 联合类型(Union)—— 无共享字段的组合
union SearchResult = User | Post | Comment

type Query {
  search(query: String!): [SearchResult!]!
}

# 查询联合类型需要内联片段
query Search {
  search(query: "GraphQL") {
    ... on User { id name email }
    ... on Post { id title }
    ... on Comment { id body }
  }
}

枚举类型

# 枚举——限定值的集合
enum Role {
  ADMIN
  EDITOR
  VIEWER
}

enum PostStatus {
  DRAFT       # 草稿
  PUBLISHED   # 已发布
  ARCHIVED    # 已归档
}

enum SortOrder {
  ASC
  DESC
}

# 枚举输入
type Query {
  posts(status: PostStatus, sort: SortOrder = DESC): [Post!]!
}

🔗 关联设计模式

# 1. 一对多关联
type User {
  posts: [Post!]!       # 用户有多篇文章
}
type Post {
  author: User!         # 文章有一个作者
}

# 2. 多对多关联(通过中间类型)
type User {
  groups: [Group!]!     # 用户加入的组
}
type Group {
  members: [User!]!     # 组内成员
}

# 3. 自引用关联
type Comment {
  replies: [Comment!]!  # 评论的回复
  parent: Comment       # 父评论(可为空)
}

# 4. 多态关联
type Notification {
  target: Notifiable!   # 可以是Post/Comment/User
}
union Notifiable = Post | Comment | User

# 5. 分页关联(Relay规范)
type User {
  posts(first: Int, after: String): PostConnection!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  node: Post!
  cursor: String!
}

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

🛠️ 实战:博客平台Schema

// blog-schema.js - 完整博客平台Schema
const { buildSchema } = require('graphql');

const schema = buildSchema(`
  # ===== 自定义标量 =====
  scalar DateTime

  # ===== 枚举 =====
  enum Role { ADMIN EDITOR VIEWER }
  enum PostStatus { DRAFT PUBLISHED ARCHIVED }
  enum SortOrder { ASC DESC }

  # ===== 接口 =====
  interface Node {
    id: ID!
  }

  # ===== 核心类型 =====
  type User implements Node {
    id: ID!
    username: String!
    displayName: String!
    email: Email!
    avatar: URL
    bio: String
    role: Role!
    posts(status: PostStatus, first: Int, after: String): PostConnection!
    postCount(status: PostStatus): Int!
    followers(first: Int): UserConnection!
    following(first: Int): UserConnection!
    followerCount: Int!
    followingCount: Int!
    createdAt: DateTime!
  }

  type Post implements Node {
    id: ID!
    title: String!
    slug: String!
    content: String!
    excerpt: String
    author: User!
    status: PostStatus!
    tags: [Tag!]!
    comments(first: Int, after: String): CommentConnection!
    commentCount: Int!
    likes: Int!
    likedByMe: Boolean!
    readingTime: Int!
    createdAt: DateTime!
    updatedAt: DateTime!
    publishedAt: DateTime
  }

  type Comment implements Node {
    id: ID!
    body: String!
    author: User!
    post: Post!
    parent: Comment
    replies(first: Int): [Comment!]!
    replyCount: Int!
    createdAt: DateTime!
  }

  type Tag implements Node {
    id: ID!
    name: String!
    slug: String!
    postCount: Int!
    posts(first: Int, after: String): PostConnection!
  }

  # ===== 连接类型(Relay分页)=====
  type PostConnection {
    edges: [PostEdge!]!
    pageInfo: PageInfo!
    totalCount: Int!
  }
  type PostEdge {
    node: Post!
    cursor: String!
  }

  type CommentConnection {
    edges: [CommentEdge!]!
    pageInfo: PageInfo!
    totalCount: Int!
  }
  type CommentEdge {
    node: Comment!
    cursor: String!
  }

  type UserConnection {
    edges: [UserEdge!]!
    pageInfo: PageInfo!
    totalCount: Int!
  }
  type UserEdge {
    node: User!
    cursor: String!
  }

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

  # ===== 输入类型 =====
  input CreatePostInput {
    title: String!
    content: String!
    tags: [String!]! = []
    status: PostStatus = DRAFT
  }

  input UpdatePostInput {
    title: String
    content: String
    tags: [String!]
    status: PostStatus
  }

  input PostFilter {
    status: PostStatus
    authorId: ID
    tag: String
    search: String
  }

  input PostSort {
    field: PostSortField! = CREATED_AT
    order: SortOrder! = DESC
  }

  enum PostSortField {
    CREATED_AT
    UPDATED_AT
    PUBLISHED_AT
    LIKES
    COMMENT_COUNT
  }

  # ===== 操作 =====
  type Query {
    # 节点查询(全局ID查询)
    node(id: ID!): Node

    # 用户
    user(id: ID!): User
    userByUsername(username: String!): User
    users(role: Role, first: Int = 20, after: String): UserConnection!

    # 文章
    post(id: ID!): Post
    postBySlug(slug: String!): Post
    posts(filter: PostFilter, sort: PostSort, first: Int = 20, after: String): PostConnection!

    # 标签
    tag(slug: String!): Tag
    tags(first: Int = 50): [Tag!]!

    # 搜索
    search(query: String!, first: Int = 10): [SearchResult!]!
  }

  union SearchResult = User | Post | Tag

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

    addComment(postId: ID!, body: String!, parentId: ID): Comment!
    deleteComment(id: ID!): Boolean!

    toggleLike(postId: ID!): Post!
    followUser(userId: ID!): User!
    unfollowUser(userId: ID!): User!
  }
`);

// Resolver实现(简化)
const data = {
  users: [
    { id: '1', username: 'zhangsan', displayName: '张三', email: 'zhang@example.com', role: 'ADMIN', bio: 'API设计讲师', createdAt: '2025-01-01' },
    { id: '2', username: 'lisi', displayName: '李四', email: 'li@example.com', role: 'EDITOR', bio: '技术写作者', createdAt: '2025-01-02' },
  ],
  posts: [
    { id: '1', title: 'GraphQL Schema设计', slug: 'graphql-schema', content: 'Schema是GraphQL的灵魂...', authorId: '1', status: 'PUBLISHED', tags: ['GraphQL'], likes: 42, createdAt: '2025-01-10' },
  ],
  tags: [
    { id: '1', name: 'GraphQL', slug: 'graphql' },
    { id: '2', name: 'REST', slug: 'rest' },
  ],
};

const rootResolver = {
  node: ({ id }) => {
    // 全局ID查找
    const user = data.users.find(u => u.id === id);
    if (user) return user;
    const post = data.posts.find(p => p.id === id);
    if (post) return post;
    return null;
  },
  user: ({ id }) => data.users.find(u => u.id === id),
  users: ({ role, first, after }) => {
    let result = [...data.users];
    if (role) result = result.filter(u => u.role === role);
    return { edges: result.slice(0, first).map(n => ({ node: n, cursor: n.id })), pageInfo: { hasNextPage: result.length > first, hasPreviousPage: false }, totalCount: result.length };
  },
  posts: ({ filter, sort, first, after }) => {
    let result = [...data.posts];
    if (filter?.status) result = result.filter(p => p.status === filter.status);
    if (filter?.authorId) result = result.filter(p => p.authorId === filter.authorId);
    return { edges: result.slice(0, first).map(n => ({ node: n, cursor: n.id })), pageInfo: { hasNextPage: false, hasPreviousPage: false }, totalCount: result.length };
  },
  createPost: ({ input }) => {
    const id = String(data.posts.length + 1);
    const post = { id, ...input, authorId: '1', slug: input.title.toLowerCase().replace(/\s+/g, '-'), likes: 0, createdAt: new Date().toISOString() };
    data.posts.push(post);
    return post;
  },
  User: {
    posts: (user, { first }) => {
      const userPosts = data.posts.filter(p => p.authorId === user.id);
      return { edges: userPosts.slice(0, first || 20).map(n => ({ node: n, cursor: n.id })), pageInfo: { hasNextPage: false, hasPreviousPage: false }, totalCount: userPosts.length };
    },
    postCount: (user) => data.posts.filter(p => p.authorId === user.id).length,
  },
  Post: {
    author: (post) => data.users.find(u => u.id === post.authorId),
    tags: (post) => data.tags.filter(t => post.tags?.includes(t.name)),
    commentCount: () => 0,
    likes: (post) => post.likes || 0,
    likedByMe: () => false,
    readingTime: (post) => Math.ceil((post.content?.length || 0) / 500),
  },
};

module.exports = { schema, rootResolver };

🔄 Schema演进策略

安全变更 vs 破坏性变更

安全变更(可以加)破坏性变更(不能改/删)
新增类型删除类型
新增字段(可空)删除字段
新增枚举值删除枚举值
新增查询/变更删除查询/变更
将可空字段改为非空(谨慎)将非空字段改为可空
新增接口实现修改接口字段签名
# Schema演进示例

# V1: 初始版本
type User {
  id: ID!
  name: String!
}

# V2: 安全演进
type User {
  id: ID!
  name: String!        # 保留旧字段
  displayName: String  # 新增可空字段(向后兼容)
  firstName: String    # 新增
  lastName: String     # 新增
}

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

📝 本课小结

  1. Schema设计以业务领域为核心,而非数据库结构
  2. 自定义标量扩展类型系统,提供验证能力
  3. 接口用于共享字段,联合类型用于异构组合
  4. Relay连接规范(Connection)是分页的标准模式
  5. Schema演进遵循"只加不改不删"原则
  6. 使用@deprecated指令标记废弃字段
  7. 全局ID(node接口)支持统一查询任何类型

💪 练习

练习1:设计社交媒体Schema

设计一个社交媒体平台的Schema,包含:用户、帖子、评论、点赞、关注、消息、通知。使用Relay连接规范。

练习2:实现自定义标量

实现PhoneNumber自定义标量,验证中国手机号格式(1开头11位数字)。

🏆 本课成就:Schema设计师