3.6.2 GraphQL Schema 设计

3.6.2 GraphQL Schema 设计 #

GraphQL Schema 是 API 的核心,它定义了数据的结构、类型和操作。良好的 Schema 设计是构建高质量 GraphQL API 的基础。本节将详细介绍如何设计和实现 GraphQL Schema,包括类型定义、关系建模、最佳实践等内容。

Schema 定义语言(SDL) #

GraphQL Schema 使用 Schema 定义语言(SDL)来描述 API 的结构。SDL 是一种简洁、易读的语言,用于定义类型、字段和操作。

基本语法 #

# 标量类型
scalar Date
scalar Email
scalar URL

# 枚举类型
enum UserRole {
  ADMIN
  MODERATOR
  USER
  GUEST
}

# 对象类型
type User {
  id: ID!
  name: String!
  email: Email!
  role: UserRole!
  createdAt: Date!
  updatedAt: Date!
}

# 输入类型
input CreateUserInput {
  name: String!
  email: Email!
  password: String!
  role: UserRole = USER
}

# 根类型
type Query {
  user(id: ID!): User
  users(filter: UserFilter): [User!]!
}

type Mutation {
  createUser(input: CreateUserInput!): User!
}

type Subscription {
  userCreated: User!
}

类型系统详解 #

1. 标量类型 #

标量类型是 GraphQL 类型系统的叶子节点,代表具体的数据值。

# 内置标量类型
scalar ID # 唯一标识符,序列化为字符串
scalar String # UTF-8 字符串
scalar Int # 32位有符号整数
scalar Float # 双精度浮点数
scalar Boolean # true 或 false
# 自定义标量类型
scalar Date # 日期时间
scalar Email # 邮箱地址
scalar URL # URL 地址
scalar JSON # JSON 对象
scalar Upload # 文件上传

在 Go 中实现自定义标量类型:

package main

import (
    "fmt"
    "regexp"
    "time"
    "github.com/graphql-go/graphql"
    "github.com/graphql-go/graphql/language/ast"
)

// Date 标量类型
var DateType = graphql.NewScalar(graphql.ScalarConfig{
    Name:        "Date",
    Description: "Date custom scalar type",
    Serialize: func(value interface{}) interface{} {
        switch v := value.(type) {
        case time.Time:
            return v.Format(time.RFC3339)
        case *time.Time:
            return v.Format(time.RFC3339)
        default:
            return nil
        }
    },
    ParseValue: func(value interface{}) interface{} {
        switch v := value.(type) {
        case string:
            t, err := time.Parse(time.RFC3339, v)
            if err != nil {
                return nil
            }
            return t
        default:
            return nil
        }
    },
    ParseLiteral: func(valueAST ast.Value) interface{} {
        switch valueAST := valueAST.(type) {
        case *ast.StringValue:
            t, err := time.Parse(time.RFC3339, valueAST.Value)
            if err != nil {
                return nil
            }
            return t
        default:
            return nil
        }
    },
})

// Email 标量类型
var EmailType = graphql.NewScalar(graphql.ScalarConfig{
    Name:        "Email",
    Description: "Email custom scalar type",
    Serialize: func(value interface{}) interface{} {
        return value
    },
    ParseValue: func(value interface{}) interface{} {
        if email, ok := value.(string); ok {
            if isValidEmail(email) {
                return email
            }
        }
        return nil
    },
    ParseLiteral: func(valueAST ast.Value) interface{} {
        if valueAST, ok := valueAST.(*ast.StringValue); ok {
            if isValidEmail(valueAST.Value) {
                return valueAST.Value
            }
        }
        return nil
    },
})

func isValidEmail(email string) bool {
    emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
    return emailRegex.MatchString(email)
}

2. 对象类型 #

对象类型是 GraphQL Schema 的核心,定义了数据的结构和字段。

type User {
  # 基本字段
  id: ID!
  name: String!
  email: Email!
  avatar: URL

  # 枚举字段
  role: UserRole!
  status: UserStatus!

  # 时间字段
  createdAt: Date!
  updatedAt: Date!
  lastLoginAt: Date

  # 关联字段
  profile: UserProfile
  posts: [Post!]!
  comments: [Comment!]!

  # 带参数的字段
  posts(
    first: Int = 10
    after: String
    filter: PostFilter
    orderBy: PostOrderBy
  ): PostConnection!

  # 计算字段
  fullName: String!
  postCount: Int!
  isOnline: Boolean!
}

type UserProfile {
  bio: String
  website: URL
  location: String
  birthday: Date
  interests: [String!]!
  socialLinks: [SocialLink!]!
}

type SocialLink {
  platform: SocialPlatform!
  url: URL!
  username: String!
}

enum SocialPlatform {
  TWITTER
  FACEBOOK
  INSTAGRAM
  LINKEDIN
  GITHUB
}

在 Go 中定义对象类型:

// 用户类型定义
var UserType = graphql.NewObject(graphql.ObjectConfig{
    Name:        "User",
    Description: "User object",
    Fields: graphql.Fields{
        "id": &graphql.Field{
            Type:        graphql.NewNonNull(graphql.ID),
            Description: "User ID",
        },
        "name": &graphql.Field{
            Type:        graphql.NewNonNull(graphql.String),
            Description: "User name",
        },
        "email": &graphql.Field{
            Type:        graphql.NewNonNull(EmailType),
            Description: "User email",
        },
        "role": &graphql.Field{
            Type:        graphql.NewNonNull(UserRoleEnum),
            Description: "User role",
        },
        "createdAt": &graphql.Field{
            Type:        graphql.NewNonNull(DateType),
            Description: "User creation date",
        },
        "profile": &graphql.Field{
            Type:        UserProfileType,
            Description: "User profile",
            Resolve: func(p graphql.ResolveParams) (interface{}, error) {
                user := p.Source.(*User)
                return getUserProfile(user.ID)
            },
        },
        "posts": &graphql.Field{
            Type:        graphql.NewList(graphql.NewNonNull(PostType)),
            Description: "User posts",
            Args: graphql.FieldConfigArgument{
                "first": &graphql.ArgumentConfig{
                    Type:         graphql.Int,
                    DefaultValue: 10,
                    Description:  "Number of posts to fetch",
                },
                "after": &graphql.ArgumentConfig{
                    Type:        graphql.String,
                    Description: "Cursor for pagination",
                },
            },
            Resolve: func(p graphql.ResolveParams) (interface{}, error) {
                user := p.Source.(*User)
                first := p.Args["first"].(int)
                after, _ := p.Args["after"].(string)
                return getUserPosts(user.ID, first, after)
            },
        },
        "postCount": &graphql.Field{
            Type:        graphql.NewNonNull(graphql.Int),
            Description: "Number of posts by user",
            Resolve: func(p graphql.ResolveParams) (interface{}, error) {
                user := p.Source.(*User)
                return getUserPostCount(user.ID)
            },
        },
    },
})

3. 枚举类型 #

枚举类型定义了一组预定义的值。

enum UserRole {
  ADMIN
  MODERATOR
  USER
  GUEST
}

enum UserStatus {
  ACTIVE
  INACTIVE
  SUSPENDED
  PENDING
}

enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
  DELETED
}

enum OrderDirection {
  ASC
  DESC
}

在 Go 中定义枚举类型:

var UserRoleEnum = graphql.NewEnum(graphql.EnumConfig{
    Name:        "UserRole",
    Description: "User role enumeration",
    Values: graphql.EnumValueConfigMap{
        "ADMIN": &graphql.EnumValueConfig{
            Value:       "admin",
            Description: "Administrator role",
        },
        "MODERATOR": &graphql.EnumValueConfig{
            Value:       "moderator",
            Description: "Moderator role",
        },
        "USER": &graphql.EnumValueConfig{
            Value:       "user",
            Description: "Regular user role",
        },
        "GUEST": &graphql.EnumValueConfig{
            Value:       "guest",
            Description: "Guest role",
        },
    },
})

var PostStatusEnum = graphql.NewEnum(graphql.EnumConfig{
    Name:        "PostStatus",
    Description: "Post status enumeration",
    Values: graphql.EnumValueConfigMap{
        "DRAFT": &graphql.EnumValueConfig{
            Value:       "draft",
            Description: "Draft post",
        },
        "PUBLISHED": &graphql.EnumValueConfig{
            Value:       "published",
            Description: "Published post",
        },
        "ARCHIVED": &graphql.EnumValueConfig{
            Value:       "archived",
            Description: "Archived post",
        },
    },
})

4. 接口类型 #

接口定义了一组字段,可以被多个对象类型实现。

interface Node {
  id: ID!
  createdAt: Date!
  updatedAt: Date!
}

interface Timestamped {
  createdAt: Date!
  updatedAt: Date!
}

interface Authored {
  author: User!
  createdAt: Date!
}

type User implements Node & Timestamped {
  id: ID!
  createdAt: Date!
  updatedAt: Date!
  name: String!
  email: Email!
}

type Post implements Node & Timestamped & Authored {
  id: ID!
  createdAt: Date!
  updatedAt: Date!
  author: User!
  title: String!
  content: String!
}

type Comment implements Node & Timestamped & Authored {
  id: ID!
  createdAt: Date!
  updatedAt: Date!
  author: User!
  content: String!
  post: Post!
}

在 Go 中定义接口类型:

var NodeInterface = graphql.NewInterface(graphql.InterfaceConfig{
    Name:        "Node",
    Description: "An object with an ID",
    Fields: graphql.Fields{
        "id": &graphql.Field{
            Type:        graphql.NewNonNull(graphql.ID),
            Description: "The ID of the object",
        },
        "createdAt": &graphql.Field{
            Type:        graphql.NewNonNull(DateType),
            Description: "Creation timestamp",
        },
        "updatedAt": &graphql.Field{
            Type:        graphql.NewNonNull(DateType),
            Description: "Last update timestamp",
        },
    },
    ResolveType: func(p graphql.ResolveTypeParams) *graphql.Object {
        switch p.Value.(type) {
        case *User:
            return UserType
        case *Post:
            return PostType
        case *Comment:
            return CommentType
        default:
            return nil
        }
    },
})

// 确保类型实现接口
var UserType = graphql.NewObject(graphql.ObjectConfig{
    Name:        "User",
    Description: "User object",
    Interfaces:  []*graphql.Interface{NodeInterface},
    Fields: graphql.Fields{
        "id": &graphql.Field{
            Type: graphql.NewNonNull(graphql.ID),
        },
        "createdAt": &graphql.Field{
            Type: graphql.NewNonNull(DateType),
        },
        "updatedAt": &graphql.Field{
            Type: graphql.NewNonNull(DateType),
        },
        "name": &graphql.Field{
            Type: graphql.NewNonNull(graphql.String),
        },
        // ... 其他字段
    },
})

5. 联合类型 #

联合类型表示一个值可能是几种类型中的一种。

union SearchResult = User | Post | Comment

union MediaContent = Image | Video | Document

type Image {
  id: ID!
  url: URL!
  width: Int!
  height: Int!
  alt: String
}

type Video {
  id: ID!
  url: URL!
  duration: Int!
  thumbnail: URL
}

type Document {
  id: ID!
  url: URL!
  filename: String!
  size: Int!
  mimeType: String!
}

type Query {
  search(query: String!): [SearchResult!]!
  media(id: ID!): MediaContent
}

在 Go 中定义联合类型:

var SearchResultUnion = graphql.NewUnion(graphql.UnionConfig{
    Name:        "SearchResult",
    Description: "Search result union",
    Types:       []*graphql.Object{UserType, PostType, CommentType},
    ResolveType: func(p graphql.ResolveTypeParams) *graphql.Object {
        switch p.Value.(type) {
        case *User:
            return UserType
        case *Post:
            return PostType
        case *Comment:
            return CommentType
        default:
            return nil
        }
    },
})

6. 输入类型 #

输入类型用于变更操作的参数。

input CreateUserInput {
  name: String!
  email: Email!
  password: String!
  role: UserRole = USER
  profile: CreateUserProfileInput
}

input CreateUserProfileInput {
  bio: String
  website: URL
  location: String
  interests: [String!]
}

input UpdateUserInput {
  name: String
  email: Email
  role: UserRole
  profile: UpdateUserProfileInput
}

input UpdateUserProfileInput {
  bio: String
  website: URL
  location: String
  interests: [String!]
}

input UserFilter {
  role: UserRole
  status: UserStatus
  createdAfter: Date
  createdBefore: Date
  search: String
}

input PostFilter {
  status: PostStatus
  authorId: ID
  tags: [String!]
  createdAfter: Date
  createdBefore: Date
}

input OrderBy {
  field: String!
  direction: OrderDirection!
}

在 Go 中定义输入类型:

var CreateUserInputType = graphql.NewInputObject(graphql.InputObjectConfig{
    Name:        "CreateUserInput",
    Description: "Input for creating a user",
    Fields: graphql.InputObjectConfigFieldMap{
        "name": &graphql.InputObjectFieldConfig{
            Type:        graphql.NewNonNull(graphql.String),
            Description: "User name",
        },
        "email": &graphql.InputObjectFieldConfig{
            Type:        graphql.NewNonNull(EmailType),
            Description: "User email",
        },
        "password": &graphql.InputObjectFieldConfig{
            Type:        graphql.NewNonNull(graphql.String),
            Description: "User password",
        },
        "role": &graphql.InputObjectFieldConfig{
            Type:         UserRoleEnum,
            DefaultValue: "user",
            Description:  "User role",
        },
    },
})

var UserFilterInputType = graphql.NewInputObject(graphql.InputObjectConfig{
    Name:        "UserFilter",
    Description: "Filter for user queries",
    Fields: graphql.InputObjectConfigFieldMap{
        "role": &graphql.InputObjectFieldConfig{
            Type:        UserRoleEnum,
            Description: "Filter by user role",
        },
        "status": &graphql.InputObjectFieldConfig{
            Type:        UserStatusEnum,
            Description: "Filter by user status",
        },
        "search": &graphql.InputObjectFieldConfig{
            Type:        graphql.String,
            Description: "Search in name and email",
        },
        "createdAfter": &graphql.InputObjectFieldConfig{
            Type:        DateType,
            Description: "Filter users created after this date",
        },
        "createdBefore": &graphql.InputObjectFieldConfig{
            Type:        DateType,
            Description: "Filter users created before this date",
        },
    },
})

关系建模 #

1. 一对一关系 #

type User {
  id: ID!
  name: String!
  profile: UserProfile # 一对一关系
}

type UserProfile {
  id: ID!
  user: User! # 反向关系
  bio: String
  avatar: URL
}

2. 一对多关系 #

type User {
  id: ID!
  name: String!
  posts: [Post!]! # 一对多关系
}

type Post {
  id: ID!
  title: String!
  author: User! # 多对一关系
}

3. 多对多关系 #

type User {
  id: ID!
  name: String!
  followedTags: [Tag!]! # 多对多关系
}

type Tag {
  id: ID!
  name: String!
  followers: [User!]! # 多对多关系
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  tags: [Tag!]! # 多对多关系
}

# 使用连接表建模多对多关系
type UserTagConnection {
  user: User!
  tag: Tag!
  followedAt: Date!
}

分页设计 #

1. 基于游标的分页(推荐) #

type Query {
  users(first: Int, after: String, last: Int, before: String): UserConnection!
}

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type UserEdge {
  node: User!
  cursor: String!
}

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

2. 基于偏移的分页 #

type Query {
  users(offset: Int = 0, limit: Int = 10): UserList!
}

type UserList {
  users: [User!]!
  totalCount: Int!
  hasMore: Boolean!
}

在 Go 中实现分页:

// 基于游标的分页实现
func resolveUsers(p graphql.ResolveParams) (interface{}, error) {
    first, _ := p.Args["first"].(int)
    after, _ := p.Args["after"].(string)

    if first == 0 {
        first = 10 // 默认值
    }

    // 解析游标
    var afterID int
    if after != "" {
        decoded, err := base64.StdEncoding.DecodeString(after)
        if err != nil {
            return nil, err
        }
        afterID, _ = strconv.Atoi(string(decoded))
    }

    // 查询数据
    users, err := getUsersAfter(afterID, first+1) // 多查询一个用于判断是否有下一页
    if err != nil {
        return nil, err
    }

    hasNextPage := len(users) > first
    if hasNextPage {
        users = users[:first] // 移除多查询的那一个
    }

    // 构建边和游标
    edges := make([]UserEdge, len(users))
    for i, user := range users {
        cursor := base64.StdEncoding.EncodeToString([]byte(strconv.Itoa(user.ID)))
        edges[i] = UserEdge{
            Node:   user,
            Cursor: cursor,
        }
    }

    var startCursor, endCursor string
    if len(edges) > 0 {
        startCursor = edges[0].Cursor
        endCursor = edges[len(edges)-1].Cursor
    }

    return UserConnection{
        Edges: edges,
        PageInfo: PageInfo{
            HasNextPage:     hasNextPage,
            HasPreviousPage: after != "",
            StartCursor:     startCursor,
            EndCursor:       endCursor,
        },
        TotalCount: getTotalUserCount(),
    }, nil
}

错误处理 #

1. 字段级错误 #

type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
}

type CreateUserPayload {
  user: User
  errors: [UserError!]!
}

type UserError {
  field: String
  message: String!
  code: String!
}

2. 全局错误处理 #

func handleGraphQLError(err error) *gqlerrors.Error {
    switch e := err.(type) {
    case *ValidationError:
        return gqlerrors.NewError(
            e.Message,
            nil,
            "",
            nil,
            []int{},
            map[string]interface{}{
                "code": "VALIDATION_ERROR",
                "field": e.Field,
            },
        )
    case *AuthenticationError:
        return gqlerrors.NewError(
            "Authentication required",
            nil,
            "",
            nil,
            []int{},
            map[string]interface{}{
                "code": "AUTHENTICATION_ERROR",
            },
        )
    default:
        return gqlerrors.NewError(
            "Internal server error",
            nil,
            "",
            nil,
            []int{},
            map[string]interface{}{
                "code": "INTERNAL_ERROR",
            },
        )
    }
}

Schema 最佳实践 #

1. 命名约定 #

# 使用 PascalCase 命名类型
type UserProfile { }
type PostComment { }

# 使用 camelCase 命名字段
type User {
  firstName: String!
  lastName: String!
  createdAt: Date!
}

# 使用 SCREAMING_SNAKE_CASE 命名枚举值
enum UserRole {
  SUPER_ADMIN
  CONTENT_MODERATOR
  REGULAR_USER
}

2. 字段设计原则 #

# 好的设计:语义清晰
type User {
  id: ID!
  name: String!
  email: String!
  isActive: Boolean!
  createdAt: Date!
}

# 避免:过于技术化的字段名
type User {
  pk: ID! # 应该用 id
  usr_nm: String! # 应该用 name
  email_addr: String! # 应该用 email
  is_del: Boolean! # 应该用 isActive
}

3. 版本控制策略 #

type User {
  id: ID!
  name: String!
  email: String!

  # 废弃字段
  username: String! @deprecated(reason: "Use name instead")

  # 新增字段
  displayName: String!

  # 可选字段用于向后兼容
  profile: UserProfile
}

4. 性能考虑 #

# 使用连接模式避免 N+1 问题
type User {
  posts(first: Int, after: String): PostConnection!
}

# 提供计数字段避免昂贵的查询
type User {
  postCount: Int!
  followerCount: Int!
}

# 使用数据加载器批量获取数据
type Post {
  author: User! # 使用 DataLoader 批量加载
  comments: [Comment!]! # 使用 DataLoader 批量加载
}

通过遵循这些设计原则和最佳实践,你可以创建出结构清晰、性能良好、易于维护的 GraphQL Schema。在下一节中,我们将学习如何在 Go 语言中实现这些 Schema 定义。