GraphQL API 开发:现代化 API 设计与实现指南
深入探讨 GraphQL API 的设计原理、开发实践、性能优化和最佳实践,帮助开发者构建高效、灵活的现代化 API 服务。
2025年9月18日
DocsLib Team
GraphQLAPI后端开发数据查询性能优化
GraphQL API 开发:现代化 API 设计与实现指南
GraphQL 简介
GraphQL 是由 Facebook 开发的一种用于 API 的查询语言和运行时。它提供了一种更高效、强大和灵活的替代方案来替代传统的 REST API。
GraphQL 的核心优势
- 精确数据获取:客户端可以精确指定需要的数据
- 单一端点:所有操作通过一个 URL 端点进行
- 强类型系统:提供完整的类型安全保障
- 实时订阅:内置支持实时数据推送
- 自文档化:Schema 即文档
- 版本无关:通过字段演进而非版本控制
GraphQL vs REST
特性 | GraphQL | REST |
---|---|---|
数据获取 | 精确获取所需数据 | 固定数据结构 |
端点数量 | 单一端点 | 多个端点 |
过度获取 | 避免 | 常见问题 |
欠获取 | 避免 | 需要多次请求 |
缓存 | 复杂 | 简单 |
学习曲线 | 较陡峭 | 较平缓 |
GraphQL 核心概念
Schema 定义语言 (SDL)
# 标量类型
scalar Date
scalar Upload
scalar JSON
# 枚举类型
enum UserRole {
ADMIN
MODERATOR
USER
GUEST
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
# 接口类型
interface Node {
id: ID!
createdAt: Date!
updatedAt: Date!
}
interface Timestamped {
createdAt: Date!
updatedAt: Date!
}
# 联合类型
union SearchResult = User | Post | Product
# 对象类型
type User implements Node {
id: ID!
username: String!
email: String!
firstName: String
lastName: String
avatar: String
bio: String
role: UserRole!
isActive: Boolean!
lastLoginAt: Date
createdAt: Date!
updatedAt: Date!
# 关联字段
posts(first: Int, after: String, status: PostStatus): PostConnection!
followers(first: Int, after: String): UserConnection!
following(first: Int, after: String): UserConnection!
orders(first: Int, after: String, status: OrderStatus): OrderConnection!
# 计算字段
fullName: String
postsCount: Int!
followersCount: Int!
followingCount: Int!
}
type Post implements Node {
id: ID!
title: String!
slug: String!
content: String!
excerpt: String
status: PostStatus!
publishedAt: Date
createdAt: Date!
updatedAt: Date!
# 关联字段
author: User!
category: Category
tags: [Tag!]!
comments(first: Int, after: String): CommentConnection!
# 计算字段
readTime: Int!
wordCount: Int!
viewsCount: Int!
likesCount: Int!
commentsCount: Int!
}
type Category implements Node {
id: ID!
name: String!
slug: String!
description: String
color: String
createdAt: Date!
updatedAt: Date!
# 关联字段
posts(first: Int, after: String): PostConnection!
parent: Category
children: [Category!]!
# 计算字段
postsCount: Int!
}
type Tag implements Node {
id: ID!
name: String!
slug: String!
color: String
createdAt: Date!
updatedAt: Date!
# 关联字段
posts(first: Int, after: String): PostConnection!
# 计算字段
postsCount: Int!
}
type Comment implements Node {
id: ID!
content: String!
createdAt: Date!
updatedAt: Date!
# 关联字段
author: User!
post: Post!
parent: Comment
replies(first: Int, after: String): CommentConnection!
# 计算字段
repliesCount: Int!
likesCount: Int!
}
type Product implements Node {
id: ID!
name: String!
slug: String!
description: String!
sku: String!
price: Money!
compareAtPrice: Money
images: [ProductImage!]!
status: ProductStatus!
createdAt: Date!
updatedAt: Date!
# 关联字段
category: ProductCategory!
brand: Brand
variants: [ProductVariant!]!
reviews(first: Int, after: String): ReviewConnection!
# 计算字段
averageRating: Float
reviewsCount: Int!
isInStock: Boolean!
}
type Money {
amount: Float!
currency: String!
}
type ProductImage {
id: ID!
url: String!
alt: String
width: Int
height: Int
isPrimary: Boolean!
}
# 分页类型
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type UserEdge {
node: User!
cursor: String!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type CommentEdge {
node: Comment!
cursor: String!
}
type CommentConnection {
edges: [CommentEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
# 输入类型
input CreateUserInput {
username: String!
email: String!
password: String!
firstName: String
lastName: String
bio: String
}
input UpdateUserInput {
username: String
email: String
firstName: String
lastName: String
bio: String
avatar: Upload
}
input CreatePostInput {
title: String!
content: String!
excerpt: String
categoryId: ID
tagIds: [ID!]
status: PostStatus = DRAFT
}
input UpdatePostInput {
title: String
content: String
excerpt: String
categoryId: ID
tagIds: [ID!]
status: PostStatus
}
input PostFilters {
authorId: ID
categoryId: ID
tagIds: [ID!]
status: PostStatus
search: String
dateRange: DateRangeInput
}
input DateRangeInput {
start: Date!
end: Date!
}
input SortInput {
field: String!
direction: SortDirection!
}
enum SortDirection {
ASC
DESC
}
# 根类型
type Query {
# 用户查询
me: User
user(id: ID, username: String): User
users(
first: Int
after: String
filters: UserFilters
sort: SortInput
): UserConnection!
# 文章查询
post(id: ID, slug: String): Post
posts(
first: Int
after: String
filters: PostFilters
sort: SortInput
): PostConnection!
# 分类查询
category(id: ID, slug: String): Category
categories: [Category!]!
# 标签查询
tag(id: ID, slug: String): Tag
tags: [Tag!]!
# 搜索
search(
query: String!
first: Int
after: String
types: [String!]
): SearchConnection!
# 产品查询
product(id: ID, slug: String): Product
products(
first: Int
after: String
filters: ProductFilters
sort: SortInput
): ProductConnection!
}
type Mutation {
# 用户操作
register(input: CreateUserInput!): AuthPayload!
login(email: String!, password: String!): AuthPayload!
logout: Boolean!
updateProfile(input: UpdateUserInput!): User!
deleteAccount: Boolean!
# 文章操作
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
publishPost(id: ID!): Post!
# 评论操作
createComment(postId: ID!, content: String!, parentId: ID): Comment!
updateComment(id: ID!, content: String!): Comment!
deleteComment(id: ID!): Boolean!
# 点赞操作
likePost(id: ID!): Post!
unlikePost(id: ID!): Post!
likeComment(id: ID!): Comment!
unlikeComment(id: ID!): Comment!
# 关注操作
followUser(id: ID!): User!
unfollowUser(id: ID!): User!
}
type Subscription {
# 实时通知
notificationAdded(userId: ID!): Notification!
# 实时评论
commentAdded(postId: ID!): Comment!
# 实时点赞
postLiked(postId: ID!): Post!
# 在线状态
userOnlineStatus(userId: ID!): UserOnlineStatus!
}
type AuthPayload {
token: String!
user: User!
expiresAt: Date!
}
type Notification {
id: ID!
type: NotificationType!
title: String!
message: String!
isRead: Boolean!
createdAt: Date!
# 关联数据
actor: User
target: Node
}
enum NotificationType {
LIKE
COMMENT
FOLLOW
MENTION
SYSTEM
}
type UserOnlineStatus {
userId: ID!
isOnline: Boolean!
lastSeenAt: Date
}
服务器端实现
Node.js + Apollo Server 实现
// package.json 依赖
{
"dependencies": {
"apollo-server-express": "^3.12.0",
"graphql": "^16.6.0",
"express": "^4.18.2",
"mongoose": "^7.0.3",
"jsonwebtoken": "^9.0.0",
"bcryptjs": "^2.4.3",
"dataloader": "^2.2.2",
"graphql-upload": "^16.0.2",
"graphql-subscriptions": "^2.0.0",
"subscriptions-transport-ws": "^0.11.0"
}
}
// server.js - 服务器设置
const express = require('express')
const { ApolloServer } = require('apollo-server-express')
const { createServer } = require('http')
const { SubscriptionServer } = require('subscriptions-transport-ws')
const { execute, subscribe } = require('graphql')
const mongoose = require('mongoose')
const jwt = require('jsonwebtoken')
const typeDefs = require('./schema')
const resolvers = require('./resolvers')
const { createDataLoaders } = require('./dataloaders')
const { pubsub } = require('./pubsub')
/**
* 创建 GraphQL 服务器
*/
async function createApolloServer() {
// 连接数据库
await mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true
})
const app = express()
const httpServer = createServer(app)
// 创建 Apollo Server
const server = new ApolloServer({
typeDefs,
resolvers,
context: async ({ req, connection }) => {
// WebSocket 连接(订阅)
if (connection) {
return {
...connection.context,
dataloaders: createDataLoaders(),
pubsub
}
}
// HTTP 请求
let user = null
const token = req.headers.authorization?.replace('Bearer ', '')
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET)
user = await User.findById(decoded.userId)
} catch (error) {
console.error('Token verification failed:', error.message)
}
}
return {
user,
dataloaders: createDataLoaders(),
pubsub,
req
}
},
plugins: [
{
requestDidStart() {
return {
willSendResponse(requestContext) {
// 记录查询性能
console.log(`Query executed in ${Date.now() - requestContext.request.http.startTime}ms`)
}
}
}
}
],
introspection: process.env.NODE_ENV !== 'production',
playground: process.env.NODE_ENV !== 'production'
})
await server.start()
server.applyMiddleware({ app, path: '/graphql' })
// 设置订阅服务器
const subscriptionServer = SubscriptionServer.create(
{
schema: server.schema,
execute,
subscribe,
onConnect: async (connectionParams) => {
// 处理 WebSocket 连接认证
const token = connectionParams.authorization?.replace('Bearer ', '')
let user = null
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET)
user = await User.findById(decoded.userId)
} catch (error) {
throw new Error('Authentication failed')
}
}
return { user }
},
onDisconnect: () => {
console.log('Client disconnected')
}
},
{
server: httpServer,
path: '/graphql'
}
)
return { server, app, httpServer, subscriptionServer }
}
/**
* 启动服务器
*/
async function startServer() {
try {
const { httpServer } = await createApolloServer()
const PORT = process.env.PORT || 4000
httpServer.listen(PORT, () => {
console.log(`🚀 Server ready at http://localhost:${PORT}/graphql`)
console.log(`🚀 Subscriptions ready at ws://localhost:${PORT}/graphql`)
})
} catch (error) {
console.error('Failed to start server:', error)
process.exit(1)
}
}
startServer()
数据模型定义
// models/User.js
const mongoose = require('mongoose')
const bcrypt = require('bcryptjs')
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true,
trim: true,
minlength: 3,
maxlength: 30
},
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true
},
password: {
type: String,
required: true,
minlength: 6
},
firstName: {
type: String,
trim: true,
maxlength: 50
},
lastName: {
type: String,
trim: true,
maxlength: 50
},
avatar: {
type: String
},
bio: {
type: String,
maxlength: 500
},
role: {
type: String,
enum: ['ADMIN', 'MODERATOR', 'USER', 'GUEST'],
default: 'USER'
},
isActive: {
type: Boolean,
default: true
},
lastLoginAt: {
type: Date
},
emailVerified: {
type: Boolean,
default: false
},
emailVerificationToken: {
type: String
},
passwordResetToken: {
type: String
},
passwordResetExpires: {
type: Date
}
}, {
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true }
})
// 虚拟字段
userSchema.virtual('fullName').get(function() {
if (this.firstName && this.lastName) {
return `${this.firstName} ${this.lastName}`
}
return this.firstName || this.lastName || this.username
})
userSchema.virtual('postsCount', {
ref: 'Post',
localField: '_id',
foreignField: 'author',
count: true
})
// 索引
userSchema.index({ username: 1 })
userSchema.index({ email: 1 })
userSchema.index({ createdAt: -1 })
userSchema.index({ 'username': 'text', 'firstName': 'text', 'lastName': 'text' })
// 中间件
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next()
try {
const salt = await bcrypt.genSalt(12)
this.password = await bcrypt.hash(this.password, salt)
next()
} catch (error) {
next(error)
}
})
// 实例方法
userSchema.methods.comparePassword = async function(candidatePassword) {
return bcrypt.compare(candidatePassword, this.password)
}
userSchema.methods.generateAuthToken = function() {
const payload = {
userId: this._id,
username: this.username,
role: this.role
}
return jwt.sign(payload, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRES_IN || '7d'
})
}
module.exports = mongoose.model('User', userSchema)
// models/Post.js
const mongoose = require('mongoose')
const slugify = require('slugify')
const postSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true,
maxlength: 200
},
slug: {
type: String,
unique: true,
lowercase: true
},
content: {
type: String,
required: true
},
excerpt: {
type: String,
maxlength: 500
},
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
category: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Category'
},
tags: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Tag'
}],
status: {
type: String,
enum: ['DRAFT', 'PUBLISHED', 'ARCHIVED'],
default: 'DRAFT'
},
publishedAt: {
type: Date
},
featuredImage: {
type: String
},
seo: {
metaTitle: String,
metaDescription: String,
keywords: [String]
},
stats: {
views: { type: Number, default: 0 },
likes: { type: Number, default: 0 },
shares: { type: Number, default: 0 },
comments: { type: Number, default: 0 }
}
}, {
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true }
})
// 虚拟字段
postSchema.virtual('readTime').get(function() {
const wordsPerMinute = 200
const wordCount = this.content.split(/\s+/).length
return Math.ceil(wordCount / wordsPerMinute)
})
postSchema.virtual('wordCount').get(function() {
return this.content.split(/\s+/).length
})
postSchema.virtual('commentsCount', {
ref: 'Comment',
localField: '_id',
foreignField: 'post',
count: true
})
// 索引
postSchema.index({ author: 1, createdAt: -1 })
postSchema.index({ category: 1, status: 1 })
postSchema.index({ tags: 1, status: 1 })
postSchema.index({ status: 1, publishedAt: -1 })
postSchema.index({ slug: 1 })
postSchema.index({ 'title': 'text', 'content': 'text', 'excerpt': 'text' })
// 中间件
postSchema.pre('save', function(next) {
if (this.isModified('title') && !this.slug) {
this.slug = slugify(this.title, {
lower: true,
strict: true
})
}
if (this.isModified('status') && this.status === 'PUBLISHED' && !this.publishedAt) {
this.publishedAt = new Date()
}
if (!this.excerpt && this.content) {
this.excerpt = this.content.substring(0, 200) + '...'
}
next()
})
module.exports = mongoose.model('Post', postSchema)
Resolver 实现
// resolvers/index.js
const { AuthenticationError, ForbiddenError, UserInputError } = require('apollo-server-express')
const { withFilter } = require('graphql-subscriptions')
const User = require('../models/User')
const Post = require('../models/Post')
const Comment = require('../models/Comment')
const { pubsub } = require('../pubsub')
/**
* 认证检查中间件
*/
const requireAuth = (user) => {
if (!user) {
throw new AuthenticationError('You must be logged in to perform this action')
}
return user
}
/**
* 权限检查中间件
*/
const requireRole = (user, roles) => {
requireAuth(user)
if (!roles.includes(user.role)) {
throw new ForbiddenError('You do not have permission to perform this action')
}
return user
}
const resolvers = {
// 查询解析器
Query: {
/**
* 获取当前用户信息
*/
me: (parent, args, { user }) => {
return requireAuth(user)
},
/**
* 获取用户信息
*/
user: async (parent, { id, username }, { dataloaders }) => {
if (id) {
return dataloaders.userLoader.load(id)
}
if (username) {
return User.findOne({ username })
}
throw new UserInputError('Must provide either id or username')
},
/**
* 获取用户列表
*/
users: async (parent, { first = 20, after, filters, sort }, { dataloaders }) => {
const query = {}
// 应用过滤器
if (filters) {
if (filters.role) query.role = filters.role
if (filters.isActive !== undefined) query.isActive = filters.isActive
if (filters.search) {
query.$text = { $search: filters.search }
}
}
// 分页处理
if (after) {
query._id = { $gt: after }
}
// 排序处理
let sortOption = { createdAt: -1 }
if (sort) {
sortOption = { [sort.field]: sort.direction === 'ASC' ? 1 : -1 }
}
const users = await User.find(query)
.sort(sortOption)
.limit(first + 1)
const hasNextPage = users.length > first
const edges = users.slice(0, first).map(user => ({
node: user,
cursor: user._id.toString()
}))
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor
},
totalCount: await User.countDocuments(query)
}
},
/**
* 获取文章信息
*/
post: async (parent, { id, slug }, { dataloaders }) => {
if (id) {
return dataloaders.postLoader.load(id)
}
if (slug) {
return Post.findOne({ slug, status: 'PUBLISHED' })
}
throw new UserInputError('Must provide either id or slug')
},
/**
* 获取文章列表
*/
posts: async (parent, { first = 20, after, filters, sort }) => {
const query = { status: 'PUBLISHED' }
// 应用过滤器
if (filters) {
if (filters.authorId) query.author = filters.authorId
if (filters.categoryId) query.category = filters.categoryId
if (filters.tagIds?.length) query.tags = { $in: filters.tagIds }
if (filters.status) query.status = filters.status
if (filters.search) {
query.$text = { $search: filters.search }
}
if (filters.dateRange) {
query.publishedAt = {
$gte: filters.dateRange.start,
$lte: filters.dateRange.end
}
}
}
// 分页处理
if (after) {
query._id = { $gt: after }
}
// 排序处理
let sortOption = { publishedAt: -1 }
if (sort) {
sortOption = { [sort.field]: sort.direction === 'ASC' ? 1 : -1 }
}
const posts = await Post.find(query)
.sort(sortOption)
.limit(first + 1)
.populate('author category tags')
const hasNextPage = posts.length > first
const edges = posts.slice(0, first).map(post => ({
node: post,
cursor: post._id.toString()
}))
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor
},
totalCount: await Post.countDocuments(query)
}
},
/**
* 搜索功能
*/
search: async (parent, { query, first = 20, after, types }) => {
const searchResults = []
// 搜索用户
if (!types || types.includes('User')) {
const users = await User.find(
{ $text: { $search: query } },
{ score: { $meta: 'textScore' } }
)
.sort({ score: { $meta: 'textScore' } })
.limit(first)
searchResults.push(...users.map(user => ({ __typename: 'User', ...user.toObject() })))
}
// 搜索文章
if (!types || types.includes('Post')) {
const posts = await Post.find(
{ $text: { $search: query }, status: 'PUBLISHED' },
{ score: { $meta: 'textScore' } }
)
.sort({ score: { $meta: 'textScore' } })
.limit(first)
searchResults.push(...posts.map(post => ({ __typename: 'Post', ...post.toObject() })))
}
// 按相关性排序
searchResults.sort((a, b) => (b.score || 0) - (a.score || 0))
const edges = searchResults.slice(0, first).map((result, index) => ({
node: result,
cursor: `search_${index}`
}))
return {
edges,
pageInfo: {
hasNextPage: searchResults.length > first,
hasPreviousPage: false,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor
},
totalCount: searchResults.length
}
}
},
// 变更解析器
Mutation: {
/**
* 用户注册
*/
register: async (parent, { input }) => {
// 检查用户是否已存在
const existingUser = await User.findOne({
$or: [
{ email: input.email },
{ username: input.username }
]
})
if (existingUser) {
throw new UserInputError('User with this email or username already exists')
}
// 创建新用户
const user = new User(input)
await user.save()
// 生成 JWT token
const token = user.generateAuthToken()
return {
token,
user,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7天后过期
}
},
/**
* 用户登录
*/
login: async (parent, { email, password }) => {
// 查找用户
const user = await User.findOne({ email })
if (!user) {
throw new AuthenticationError('Invalid email or password')
}
// 验证密码
const isValidPassword = await user.comparePassword(password)
if (!isValidPassword) {
throw new AuthenticationError('Invalid email or password')
}
// 更新最后登录时间
user.lastLoginAt = new Date()
await user.save()
// 生成 JWT token
const token = user.generateAuthToken()
return {
token,
user,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
}
},
/**
* 创建文章
*/
createPost: async (parent, { input }, { user }) => {
requireAuth(user)
const post = new Post({
...input,
author: user._id
})
await post.save()
await post.populate('author category tags')
return post
},
/**
* 更新文章
*/
updatePost: async (parent, { id, input }, { user }) => {
requireAuth(user)
const post = await Post.findById(id)
if (!post) {
throw new UserInputError('Post not found')
}
// 检查权限
if (post.author.toString() !== user._id.toString() && user.role !== 'ADMIN') {
throw new ForbiddenError('You can only edit your own posts')
}
Object.assign(post, input)
await post.save()
await post.populate('author category tags')
return post
},
/**
* 删除文章
*/
deletePost: async (parent, { id }, { user }) => {
requireAuth(user)
const post = await Post.findById(id)
if (!post) {
throw new UserInputError('Post not found')
}
// 检查权限
if (post.author.toString() !== user._id.toString() && user.role !== 'ADMIN') {
throw new ForbiddenError('You can only delete your own posts')
}
await Post.findByIdAndDelete(id)
// 删除相关评论
await Comment.deleteMany({ post: id })
return true
},
/**
* 创建评论
*/
createComment: async (parent, { postId, content, parentId }, { user, pubsub }) => {
requireAuth(user)
const post = await Post.findById(postId)
if (!post) {
throw new UserInputError('Post not found')
}
const comment = new Comment({
content,
author: user._id,
post: postId,
parent: parentId
})
await comment.save()
await comment.populate('author')
// 更新文章评论数
await Post.findByIdAndUpdate(postId, {
$inc: { 'stats.comments': 1 }
})
// 发布实时事件
pubsub.publish('COMMENT_ADDED', {
commentAdded: comment,
postId
})
return comment
},
/**
* 点赞文章
*/
likePost: async (parent, { id }, { user, pubsub }) => {
requireAuth(user)
const post = await Post.findByIdAndUpdate(
id,
{ $inc: { 'stats.likes': 1 } },
{ new: true }
).populate('author category tags')
if (!post) {
throw new UserInputError('Post not found')
}
// 发布实时事件
pubsub.publish('POST_LIKED', {
postLiked: post,
postId: id
})
return post
}
},
// 订阅解析器
Subscription: {
/**
* 评论添加订阅
*/
commentAdded: {
subscribe: withFilter(
() => pubsub.asyncIterator(['COMMENT_ADDED']),
(payload, variables) => {
return payload.postId === variables.postId
}
)
},
/**
* 文章点赞订阅
*/
postLiked: {
subscribe: withFilter(
() => pubsub.asyncIterator(['POST_LIKED']),
(payload, variables) => {
return payload.postId === variables.postId
}
)
}
},
// 类型解析器
User: {
/**
* 用户文章列表
*/
posts: async (user, { first = 20, after, status }, { dataloaders }) => {
const query = { author: user._id }
if (status) query.status = status
if (after) query._id = { $gt: after }
const posts = await Post.find(query)
.sort({ createdAt: -1 })
.limit(first + 1)
.populate('category tags')
const hasNextPage = posts.length > first
const edges = posts.slice(0, first).map(post => ({
node: post,
cursor: post._id.toString()
}))
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor
},
totalCount: await Post.countDocuments(query)
}
},
/**
* 用户文章数量
*/
postsCount: async (user) => {
return Post.countDocuments({ author: user._id, status: 'PUBLISHED' })
}
},
Post: {
/**
* 文章作者
*/
author: (post, args, { dataloaders }) => {
return dataloaders.userLoader.load(post.author)
},
/**
* 文章分类
*/
category: (post, args, { dataloaders }) => {
return post.category ? dataloaders.categoryLoader.load(post.category) : null
},
/**
* 文章标签
*/
tags: (post, args, { dataloaders }) => {
return dataloaders.tagLoader.loadMany(post.tags || [])
},
/**
* 文章评论
*/
comments: async (post, { first = 20, after }) => {
const query = { post: post._id }
if (after) query._id = { $gt: after }
const comments = await Comment.find(query)
.sort({ createdAt: -1 })
.limit(first + 1)
.populate('author')
const hasNextPage = comments.length > first
const edges = comments.slice(0, first).map(comment => ({
node: comment,
cursor: comment._id.toString()
}))
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor
},
totalCount: await Comment.countDocuments(query)
}
},
/**
* 文章阅读时间
*/
readTime: (post) => {
const wordsPerMinute = 200
const wordCount = post.content.split(/\s+/).length
return Math.ceil(wordCount / wordsPerMinute)
},
/**
* 文章字数
*/
wordCount: (post) => {
return post.content.split(/\s+/).length
},
/**
* 文章浏览数
*/
viewsCount: (post) => {
return post.stats?.views || 0
},
/**
* 文章点赞数
*/
likesCount: (post) => {
return post.stats?.likes || 0
},
/**
* 文章评论数
*/
commentsCount: (post) => {
return post.stats?.comments || 0
}
},
Comment: {
/**
* 评论作者
*/
author: (comment, args, { dataloaders }) => {
return dataloaders.userLoader.load(comment.author)
},
/**
* 评论所属文章
*/
post: (comment, args, { dataloaders }) => {
return dataloaders.postLoader.load(comment.post)
},
/**
* 父评论
*/
parent: (comment, args, { dataloaders }) => {
return comment.parent ? dataloaders.commentLoader.load(comment.parent) : null
},
/**
* 子评论
*/
replies: async (comment, { first = 20, after }) => {
const query = { parent: comment._id }
if (after) query._id = { $gt: after }
const replies = await Comment.find(query)
.sort({ createdAt: 1 })
.limit(first + 1)
.populate('author')
const hasNextPage = replies.length > first
const edges = replies.slice(0, first).map(reply => ({
node: reply,
cursor: reply._id.toString()
}))
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor
},
totalCount: await Comment.countDocuments(query)
}
}
},
// 联合类型解析器
SearchResult: {
__resolveType: (obj) => {
if (obj.username) return 'User'
if (obj.title) return 'Post'
if (obj.name) return 'Product'
return null
}
}
}
module.exports = resolvers
DataLoader 实现
// dataloaders/index.js
const DataLoader = require('dataloader')
const User = require('../models/User')
const Post = require('../models/Post')
const Category = require('../models/Category')
const Tag = require('../models/Tag')
const Comment = require('../models/Comment')
/**
* 创建用户数据加载器
*/
const createUserLoader = () => {
return new DataLoader(async (userIds) => {
const users = await User.find({ _id: { $in: userIds } })
const userMap = new Map(users.map(user => [user._id.toString(), user]))
return userIds.map(id => userMap.get(id.toString()))
})
}
/**
* 创建文章数据加载器
*/
const createPostLoader = () => {
return new DataLoader(async (postIds) => {
const posts = await Post.find({ _id: { $in: postIds } })
.populate('author category tags')
const postMap = new Map(posts.map(post => [post._id.toString(), post]))
return postIds.map(id => postMap.get(id.toString()))
})
}
/**
* 创建分类数据加载器
*/
const createCategoryLoader = () => {
return new DataLoader(async (categoryIds) => {
const categories = await Category.find({ _id: { $in: categoryIds } })
const categoryMap = new Map(categories.map(category => [category._id.toString(), category]))
return categoryIds.map(id => categoryMap.get(id.toString()))
})
}
/**
* 创建标签数据加载器
*/
const createTagLoader = () => {
return new DataLoader(async (tagIds) => {
const tags = await Tag.find({ _id: { $in: tagIds } })
const tagMap = new Map(tags.map(tag => [tag._id.toString(), tag]))
return tagIds.map(id => tagMap.get(id.toString()))
})
}
/**
* 创建评论数据加载器
*/
const createCommentLoader = () => {
return new DataLoader(async (commentIds) => {
const comments = await Comment.find({ _id: { $in: commentIds } })
.populate('author')
const commentMap = new Map(comments.map(comment => [comment._id.toString(), comment]))
return commentIds.map(id => commentMap.get(id.toString()))
})
}
/**
* 创建用户文章数量加载器
*/
const createUserPostsCountLoader = () => {
return new DataLoader(async (userIds) => {
const results = await Post.aggregate([
{
$match: {
author: { $in: userIds.map(id => mongoose.Types.ObjectId(id)) },
status: 'PUBLISHED'
}
},
{
$group: {
_id: '$author',
count: { $sum: 1 }
}
}
])
const countMap = new Map(results.map(result => [result._id.toString(), result.count]))
return userIds.map(id => countMap.get(id.toString()) || 0)
})
}
/**
* 创建文章评论数量加载器
*/
const createPostCommentsCountLoader = () => {
return new DataLoader(async (postIds) => {
const results = await Comment.aggregate([
{
$match: {
post: { $in: postIds.map(id => mongoose.Types.ObjectId(id)) }
}
},
{
$group: {
_id: '$post',
count: { $sum: 1 }
}
}
])
const countMap = new Map(results.map(result => [result._id.toString(), result.count]))
return postIds.map(id => countMap.get(id.toString()) || 0)
})
}
/**
* 创建所有数据加载器
*/
const createDataLoaders = () => {
return {
userLoader: createUserLoader(),
postLoader: createPostLoader(),
categoryLoader: createCategoryLoader(),
tagLoader: createTagLoader(),
commentLoader: createCommentLoader(),
userPostsCountLoader: createUserPostsCountLoader(),
postCommentsCountLoader: createPostCommentsCountLoader()
}
}
module.exports = {
createDataLoaders
}
客户端实现
React + Apollo Client
// package.json 依赖
{
"dependencies": {
"@apollo/client": "^3.7.10",
"graphql": "^16.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"graphql-ws": "^5.12.1"
}
}
// apollo-client.js - Apollo Client 配置
import { ApolloClient, InMemoryCache, createHttpLink, split } from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'
import { getMainDefinition } from '@apollo/client/utilities'
import { createClient } from 'graphql-ws'
// HTTP 链接
const httpLink = createHttpLink({
uri: process.env.REACT_APP_GRAPHQL_HTTP_URI || 'http://localhost:4000/graphql'
})
// WebSocket 链接(用于订阅)
const wsLink = new GraphQLWsLink(
createClient({
url: process.env.REACT_APP_GRAPHQL_WS_URI || 'ws://localhost:4000/graphql',
connectionParams: () => {
const token = localStorage.getItem('authToken')
return {
authorization: token ? `Bearer ${token}` : ''
}
}
})
)
// 认证链接
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('authToken')
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : ''
}
}
})
// 根据操作类型选择链接
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query)
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
)
},
wsLink,
authLink.concat(httpLink)
)
// 创建 Apollo Client
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
keyArgs: ['filters'],
merge(existing = { edges: [], pageInfo: {} }, incoming) {
return {
...incoming,
edges: [...existing.edges, ...incoming.edges]
}
}
}
}
},
User: {
fields: {
posts: {
keyArgs: ['status'],
merge(existing = { edges: [], pageInfo: {} }, incoming) {
return {
...incoming,
edges: [...existing.edges, ...incoming.edges]
}
}
}
}
}
}
}),
defaultOptions: {
watchQuery: {
errorPolicy: 'all'
},
query: {
errorPolicy: 'all'
}
}
})
export default client
GraphQL 查询和变更
// queries/user.js
import { gql } from '@apollo/client'
/**
* 获取当前用户信息
*/
export const GET_ME = gql`
query GetMe {
me {
id
username
email
firstName
lastName
fullName
avatar
bio
role
isActive
postsCount
followersCount
followingCount
createdAt
updatedAt
}
}
`
/**
* 获取用户信息
*/
export const GET_USER = gql`
query GetUser($id: ID, $username: String) {
user(id: $id, username: $username) {
id
username
email
firstName
lastName
fullName
avatar
bio
role
isActive
postsCount
followersCount
followingCount
createdAt
updatedAt
posts(first: 10, status: PUBLISHED) {
edges {
node {
id
title
slug
excerpt
publishedAt
readTime
viewsCount
likesCount
commentsCount
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
}
`
/**
* 获取用户列表
*/
export const GET_USERS = gql`
query GetUsers(
$first: Int
$after: String
$filters: UserFilters
$sort: SortInput
) {
users(first: $first, after: $after, filters: $filters, sort: $sort) {
edges {
node {
id
username
firstName
lastName
fullName
avatar
bio
role
postsCount
followersCount
createdAt
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}
`
// queries/post.js
/**
* 获取文章信息
*/
export const GET_POST = gql`
query GetPost($id: ID, $slug: String) {
post(id: $id, slug: $slug) {
id
title
slug
content
excerpt
status
publishedAt
readTime
wordCount
viewsCount
likesCount
commentsCount
createdAt
updatedAt
author {
id
username
fullName
avatar
bio
}
category {
id
name
slug
color
}
tags {
id
name
slug
color
}
comments(first: 20) {
edges {
node {
id
content
createdAt
likesCount
repliesCount
author {
id
username
fullName
avatar
}
replies(first: 5) {
edges {
node {
id
content
createdAt
author {
id
username
fullName
avatar
}
}
}
totalCount
}
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
}
`
/**
* 获取文章列表
*/
export const GET_POSTS = gql`
query GetPosts(
$first: Int
$after: String
$filters: PostFilters
$sort: SortInput
) {
posts(first: $first, after: $after, filters: $filters, sort: $sort) {
edges {
node {
id
title
slug
excerpt
publishedAt
readTime
viewsCount
likesCount
commentsCount
author {
id
username
fullName
avatar
}
category {
id
name
slug
color
}
tags {
id
name
slug
color
}
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}
`
// mutations/auth.js
/**
* 用户注册
*/
export const REGISTER = gql`
mutation Register($input: CreateUserInput!) {
register(input: $input) {
token
expiresAt
user {
id
username
email
firstName
lastName
fullName
role
}
}
}
`
/**
* 用户登录
*/
export const LOGIN = gql`
mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) {
token
expiresAt
user {
id
username
email
firstName
lastName
fullName
role
}
}
}
`
// mutations/post.js
/**
* 创建文章
*/
export const CREATE_POST = gql`
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
slug
content
excerpt
status
createdAt
author {
id
username
fullName
}
category {
id
name
slug
}
tags {
id
name
slug
}
}
}
`
/**
* 更新文章
*/
export const UPDATE_POST = gql`
mutation UpdatePost($id: ID!, $input: UpdatePostInput!) {
updatePost(id: $id, input: $input) {
id
title
slug
content
excerpt
status
updatedAt
category {
id
name
slug
}
tags {
id
name
slug
}
}
}
`
/**
* 点赞文章
*/
export const LIKE_POST = gql`
mutation LikePost($id: ID!) {
likePost(id: $id) {
id
likesCount
}
}
`
// subscriptions/post.js
/**
* 评论添加订阅
*/
export const COMMENT_ADDED = gql`
subscription CommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
content
createdAt
author {
id
username
fullName
avatar
}
}
}
`
/**
* 文章点赞订阅
*/
export const POST_LIKED = gql`
subscription PostLiked($postId: ID!) {
postLiked(postId: $postId) {
id
likesCount
}
}
`
### 5. 高级特性
#### 5.1 错误处理
```javascript
// utils/errors.js
import { ApolloError } from 'apollo-server-express'
/**
* 自定义错误类
*/
export class ValidationError extends ApolloError {
constructor(message, field) {
super(message, 'VALIDATION_ERROR', { field })
}
}
export class NotFoundError extends ApolloError {
constructor(resource) {
super(`${resource} not found`, 'NOT_FOUND', { resource })
}
}
export class UnauthorizedError extends ApolloError {
constructor(message = 'Unauthorized') {
super(message, 'UNAUTHORIZED')
}
}
// 在 Resolver 中使用
const resolvers = {
Query: {
user: async (parent, { id }) => {
const user = await User.findById(id)
if (!user) {
throw new NotFoundError('User')
}
return user
}
}
}
5.2 缓存策略
// 客户端缓存配置
import { InMemoryCache } from '@apollo/client'
const cache = new InMemoryCache({
typePolicies: {
Post: {
fields: {
comments: {
merge(existing = [], incoming) {
return [...existing, ...incoming]
}
}
}
},
Query: {
fields: {
posts: {
keyArgs: ['filter', 'sort'],
merge(existing = { edges: [] }, incoming) {
return {
...incoming,
edges: [...existing.edges, ...incoming.edges]
}
}
}
}
}
}
})
5.3 性能优化
// 使用 DataLoader 解决 N+1 问题
import DataLoader from 'dataloader'
/**
* 批量加载用户数据
*/
const userLoader = new DataLoader(async (userIds) => {
const users = await User.find({ _id: { $in: userIds } })
const userMap = new Map(users.map(user => [user._id.toString(), user]))
return userIds.map(id => userMap.get(id.toString()))
})
/**
* 批量加载文章分类
*/
const categoryLoader = new DataLoader(async (categoryIds) => {
const categories = await Category.find({ _id: { $in: categoryIds } })
const categoryMap = new Map(categories.map(cat => [cat._id.toString(), cat]))
return categoryIds.map(id => categoryMap.get(id.toString()))
})
// 在 Resolver 中使用
const resolvers = {
Post: {
author: (post, args, { loaders }) => {
return loaders.user.load(post.author)
},
category: (post, args, { loaders }) => {
return loaders.category.load(post.category)
}
}
}
6. 测试
6.1 单元测试
// tests/resolvers/user.test.js
import { createTestClient } from 'apollo-server-testing'
import { gql } from 'apollo-server-express'
import { server } from '../../src/server'
describe('User Resolvers', () => {
const { query, mutate } = createTestClient(server)
test('should get user by id', async () => {
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
username
email
}
}
`
const response = await query({
query: GET_USER,
variables: { id: '1' }
})
expect(response.errors).toBeUndefined()
expect(response.data.user).toMatchObject({
id: '1',
username: 'testuser',
email: 'test@example.com'
})
})
test('should register new user', async () => {
const REGISTER = gql`
mutation Register($input: CreateUserInput!) {
register(input: $input) {
token
user {
id
username
email
}
}
}
`
const response = await mutate({
mutation: REGISTER,
variables: {
input: {
username: 'newuser',
email: 'new@example.com',
password: 'password123',
firstName: 'New',
lastName: 'User'
}
}
})
expect(response.errors).toBeUndefined()
expect(response.data.register.token).toBeDefined()
expect(response.data.register.user.username).toBe('newuser')
})
})
6.2 集成测试
// tests/integration/api.test.js
import request from 'supertest'
import { app } from '../../src/app'
describe('GraphQL API Integration', () => {
test('should handle complex query with nested data', async () => {
const query = `
query {
posts(first: 5) {
edges {
node {
id
title
author {
username
fullName
}
category {
name
}
tags {
name
}
comments(first: 3) {
edges {
node {
content
author {
username
}
}
}
}
}
}
}
}
`
const response = await request(app)
.post('/graphql')
.send({ query })
.expect(200)
expect(response.body.data.posts.edges).toHaveLength(5)
expect(response.body.data.posts.edges[0].node.author).toBeDefined()
expect(response.body.data.posts.edges[0].node.category).toBeDefined()
})
})
7. 部署与监控
7.1 生产环境配置
// config/production.js
module.exports = {
server: {
port: process.env.PORT || 4000,
cors: {
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['https://yourdomain.com'],
credentials: true
},
introspection: false,
playground: false
},
database: {
uri: process.env.MONGODB_URI,
options: {
useNewUrlParser: true,
useUnifiedTopology: true,
maxPoolSize: 10,
serverSelectionTimeoutMS: 5000
}
},
redis: {
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
password: process.env.REDIS_PASSWORD
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: '7d'
}
}
7.2 监控和日志
// middleware/monitoring.js
import { ApolloServerPluginLandingPageDisabled } from 'apollo-server-core'
import { ApolloServerPluginUsageReporting } from 'apollo-server-core'
/**
* Apollo Studio 监控插件
*/
export const monitoringPlugins = [
ApolloServerPluginUsageReporting({
sendVariableValues: { none: true },
sendHeaders: { none: true }
}),
ApolloServerPluginLandingPageDisabled(),
{
requestDidStart() {
return {
didResolveOperation(requestContext) {
console.log(`Operation: ${requestContext.request.operationName}`)
},
didEncounterErrors(requestContext) {
console.error('GraphQL errors:', requestContext.errors)
}
}
}
}
]
8. 最佳实践总结
8.1 Schema 设计原则
- 以业务为导向:Schema 应该反映业务需求,而不是数据库结构
- 保持一致性:命名规范、错误处理、分页模式等要保持一致
- 版本控制:使用字段废弃而不是删除,保持向后兼容
- 安全考虑:限制查询深度、复杂度,防止恶意查询
8.2 性能优化
- 使用 DataLoader:解决 N+1 查询问题
- 查询分析:监控慢查询,优化数据库索引
- 缓存策略:合理使用查询缓存和持久化查询
- 分页实现:使用基于游标的分页
8.3 开发工具
- GraphQL Playground:开发环境下的查询测试工具
- Apollo Studio:生产环境监控和分析
- GraphQL Code Generator:自动生成类型定义和客户端代码
- ESLint + GraphQL:代码质量检查
总结
GraphQL 为现代 API 开发提供了强大而灵活的解决方案。通过本文的介绍,你应该能够:
- 理解 GraphQL 的核心概念和优势
- 使用 Apollo Server 构建 GraphQL API
- 在客户端使用 Apollo Client 消费 GraphQL API
- 实现高级特性如缓存、错误处理、性能优化
- 编写测试并部署到生产环境
GraphQL 的学习曲线相对较陡,但一旦掌握,它将大大提升你的 API 开发效率和用户体验。建议在实际项目中逐步应用这些概念,通过实践来加深理解。