乐闻世界logo
搜索文章和话题

GraphQL

GraphQL 是一种 API 技术,旨在描述现代 Web 应用程序复杂的嵌套数据依赖关系。它通常被认为是 SOAP 或 REST 的替代品
GraphQL
查看更多相关内容
GraphQL 安全有哪些最佳实践## GraphQL 安全最佳实践 GraphQL 的灵活性虽然强大,但也带来了独特的安全挑战。以下是保护 GraphQL API 的关键安全措施。 ### 1. 认证与授权 #### 认证机制 ```javascript const { ApolloServer } = require('apollo-server'); const jwt = require('jsonwebtoken'); const server = new ApolloServer({ typeDefs, resolvers, context: ({ req }) => { // 从请求头获取 token const token = req.headers.authorization || ''; try { // 验证 token const decoded = jwt.verify(token, process.env.JWT_SECRET); return { user: decoded }; } catch (error) { return { user: null }; } } }); ``` #### 授权中间件 ```javascript const { AuthenticationError, ForbiddenError } = require('apollo-server-express'); const resolvers = { Query: { me: (parent, args, context) => { if (!context.user) { throw new AuthenticationError('未认证'); } return context.user; }, users: (parent, args, context) => { if (!context.user || context.user.role !== 'ADMIN') { throw new ForbiddenError('需要管理员权限'); } return User.findAll(); } } }; ``` ### 2. 查询深度限制 #### 防止深度嵌套攻击 ```javascript const depthLimit = require('graphql-depth-limit'); const server = new ApolloServer({ typeDefs, resolvers, validationRules: [ depthLimit(7, { ignore: ['ignoredField'] // 忽略特定字段 }) ] }); ``` **为什么需要**: - 防止恶意客户端发送深度嵌套查询 - 避免服务器资源耗尽 - 防止 DoS 攻击 ### 3. 查询复杂度限制 #### 限制查询复杂度 ```javascript const { createComplexityLimitRule } = require('graphql-validation-complexity'); const complexityLimitRule = createComplexityLimitRule(1000, { onCost: (cost) => console.log(`查询复杂度: ${cost}`), createError: (max, actual) => { return new Error(`查询复杂度 ${actual} 超过最大限制 ${max}`); } }); const server = new ApolloServer({ typeDefs, resolvers, validationRules: [complexityLimitRule] }); ``` #### 自定义复杂度计算 ```javascript const typeDefs = ` type Query { user(id: ID!): User @cost(complexity: 1) users(limit: Int!): [User!]! @cost(complexity: 5, multipliers: ["limit"]) posts(first: Int!): [Post!]! @cost(complexity: 10, multipliers: ["first"]) } `; ``` ### 4. 速率限制 #### 使用 express-rate-limit ```javascript const rateLimit = require('express-rate-limit'); const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 分钟 max: 100, // 限制每个 IP 100 个请求 message: '请求过于频繁,请稍后再试' }); app.use('/graphql', limiter); ``` #### GraphQL 特定的速率限制 ```javascript const { GraphQLComplexityLimit } = require('graphql-complexity-limit'); const server = new ApolloServer({ typeDefs, resolvers, validationRules: [ new GraphQLComplexityLimit({ maxComplexity: 1000, onExceed: (complexity) => { throw new Error(`查询复杂度 ${complexity} 超过限制`); } }) ] }); ``` ### 5. 输入验证 #### 使用 Yup 验证 ```javascript const yup = require('yup'); const createUserSchema = yup.object().shape({ name: yup.string().required().min(2).max(100), email: yup.string().email().required(), age: yup.number().min(0).max(150).optional() }); const resolvers = { Mutation: { createUser: async (_, { input }) => { // 验证输入 await createUserSchema.validate(input); // 创建用户 return User.create(input); } } }; ``` #### GraphQL Schema 验证 ```graphql input CreateUserInput { name: String! @constraint(minLength: 2, maxLength: 100) email: String! @constraint(format: "email") age: Int @constraint(min: 0, max: 150) } ``` ### 6. 字段级权限控制 #### 使用指令 ```graphql directive @auth(requires: Role) on FIELD_DEFINITION enum Role { USER ADMIN } type User { id: ID! name: String! email: String! @auth(requires: ADMIN) salary: Float @auth(requires: ADMIN) } ``` ```javascript const resolvers = { User: { email: (user, args, context) => { if (context.user?.role !== 'ADMIN') { throw new ForbiddenError('无权访问该字段'); } return user.email; }, salary: (user, args, context) => { if (context.user?.role !== 'ADMIN') { throw new ForbiddenError('无权访问该字段'); } return user.salary; } } }; ``` ### 7. 防止查询注入 #### 参数化查询 ```javascript // 不好的做法 - 字符串拼接 const query = `SELECT * FROM users WHERE id = '${userId}'`; // 好的做法 - 参数化查询 const query = 'SELECT * FROM users WHERE id = ?'; const result = await db.query(query, [userId]); ``` #### 使用 ORM ```javascript // 使用 Sequelize ORM const user = await User.findOne({ where: { id: userId } }); ``` ### 8. CORS 配置 ```javascript const server = new ApolloServer({ typeDefs, resolvers, cors: { origin: ['https://yourdomain.com'], // 只允许特定域名 credentials: true, methods: ['POST'] } }); ``` ### 9. 错误处理 #### 不暴露敏感信息 ```javascript const server = new ApolloServer({ typeDefs, resolvers, formatError: (error) => { // 生产环境不暴露详细错误信息 if (process.env.NODE_ENV === 'production') { return new Error('服务器内部错误'); } // 开发环境返回完整错误信息 return error; } }); ``` #### 自定义错误类型 ```graphql type Error { code: String! message: String! field: String } type UserResult { user: User errors: [Error!]! } type Mutation { createUser(input: CreateUserInput!): UserResult! } ``` ### 10. 日志与监控 #### 记录查询日志 ```javascript const server = new ApolloServer({ typeDefs, resolvers, plugins: [ { requestDidStart: () => ({ didResolveOperation: (context) => { console.log('查询:', context.request.operationName); console.log('变量:', JSON.stringify(context.request.variables)); } }) } ] }); ``` #### 监控异常查询 ```javascript const server = new ApolloServer({ typeDefs, resolvers, plugins: [ { requestDidStart: () => ({ didEncounterErrors: (context) => { context.errors.forEach(error => { console.error('查询错误:', error.message); // 发送到监控系统 monitoringService.logError(error); }); } }) } ] }); ``` ### 11. 查询白名单 #### 使用 persisted queries ```javascript const { createPersistedQueryLink } = require('@apollo/client/link/persisted-queries'); const { sha256 } = require('crypto-hash'); const link = createPersistedQueryLink({ sha256, useGETForHashedQueries: true }); // 只允许预定义的查询 const allowedQueries = new Set([ 'hash1', 'hash2' }); const server = new ApolloServer({ typeDefs, resolvers, persistedQueries: { cache: new Map(), ttl: 3600 } }); ``` ### 12. 安全检查清单 - [ ] 实施认证机制(JWT、OAuth) - [ ] 实施授权机制(基于角色的访问控制) - [ ] 限制查询深度 - [ ] 限制查询复杂度 - [ ] 实施速率限制 - [ ] 验证所有输入 - [ ] 实施字段级权限控制 - [ ] 防止查询注入 - [ ] 配置 CORS - [ ] 正确处理错误 - [ ] 记录查询日志 - [ ] 监控异常查询 - [ ] 使用查询白名单 - [ ] 定期安全审计 - [ ] 保持依赖更新 ### 13. 常见安全威胁及防护 | 威胁 | 描述 | 防护措施 | |------|------|----------| | 深度嵌套攻击 | 恶意客户端发送深度嵌套查询 | 限制查询深度 | | 复杂度攻击 | 发送高复杂度查询消耗资源 | 限制查询复杂度 | | DoS 攻击 | 大量请求导致服务不可用 | 速率限制、查询白名单 | | 注入攻击 | 恶意输入导致 SQL 注入 | 参数化查询、输入验证 | | 未授权访问 | 访问未授权的数据 | 认证、授权、字段级权限控制 | | 信息泄露 | 错误信息暴露敏感数据 | 正确的错误处理 |
服务端 · 2月21日 17:01
GraphQL 与 REST API 的核心区别是什么## GraphQL 与 REST API 的核心区别 GraphQL 是一种用于 API 的查询语言和运行时环境,它与 REST API 有以下核心区别: ### 1. 数据获取方式 - **REST**: 客户端需要访问多个端点来获取相关数据,可能导致过度获取或获取不足 - **GraphQL**: 客户端可以在单个请求中精确指定所需的数据字段,避免过度获取和获取不足 ### 2. 端点设计 - **REST**: 基于资源的端点设计,每个资源有独立的 URL - **GraphQL**: 单一端点,所有请求都发送到同一个 URL ### 3. 版本控制 - **REST**: 通常需要版本控制(如 /api/v1/users) - **GraphQL**: 通过 Schema 演进,无需版本控制,可以废弃字段而不破坏现有客户端 ### 4. 请求方法 - **REST**: 使用标准 HTTP 方法(GET、POST、PUT、DELETE) - **GraphQL**: 使用 POST 方法发送查询、变更和订阅 ### 5. 响应格式 - **REST**: 响应格式由服务器决定 - **GraphQL**: 响应格式由客户端查询决定 ### 6. 缓存策略 - **REST**: 可以利用 HTTP 缓存机制 - **GraphQL**: 需要实现自定义缓存策略(如 Apollo Client 缓存) ### 7. 实时数据 - **REST**: 通常需要轮询或使用 WebSocket - **GraphQL**: 原生支持订阅(Subscriptions)实现实时数据更新 ### 8. 错误处理 - **REST**: 使用 HTTP 状态码表示错误 - **GraphQL**: 在响应体中包含 errors 字段,HTTP 状态码通常为 200 ### 9. 类型系统 - **REST**: 通常使用 OpenAPI/Swagger 进行文档化,但不是强类型 - **GraphQL**: 内置强类型系统,Schema 定义了所有可用的类型和操作 ### 10. 文档 - **REST**: 需要手动维护 API 文档 - **GraphQL**: Schema 本身就是文档,可以通过工具自动生成 ## 适用场景 **GraphQL 适合**: - 需要从多个数据源获取复杂嵌套数据的应用 - 移动应用,需要减少网络请求和数据传输 - 需要灵活数据需求的多个客户端 - 快速迭代的产品,需要频繁调整 API **REST 适合**: - 简单的 CRUD 操作 - 需要利用 HTTP 缓存的场景 - 公共 API,需要简单易用 - 团队对 GraphQL 不熟悉的场景
服务端 · 2月21日 17:01
GraphQL Schema 设计有哪些最佳实践## GraphQL Schema 设计最佳实践 GraphQL Schema 是 API 的核心,良好的 Schema 设计能够提高开发效率、减少维护成本,并提供更好的开发者体验。 ### 1. Schema 基础结构 #### 类型定义 ```graphql type User { id: ID! name: String! email: String! age: Int posts: [Post!]! createdAt: DateTime! } type Post { id: ID! title: String! content: String! author: User! comments: [Comment!]! createdAt: DateTime! } type Comment { id: ID! text: String! author: User! post: Post! createdAt: DateTime! } ``` #### 输入类型 ```graphql input CreateUserInput { name: String! email: String! age: Int } input CreatePostInput { title: String! content: String! authorId: ID! } ``` #### 枚举类型 ```graphql enum PostStatus { DRAFT PUBLISHED ARCHIVED } ``` #### 联合类型 ```graphql union SearchResult = User | Post | Comment ``` #### 接口类型 ```graphql interface Node { id: ID! } type User implements Node { id: ID! name: String! email: String! } type Post implements Node { id: ID! title: String! content: String! } ``` ### 2. 命名规范 #### 类型命名 - 使用 PascalCase(首字母大写) - 使用描述性名称 - 避免缩写 - 使用单数形式 **示例**: ```graphql # 好的命名 type UserProfile { } type Article { } type ShoppingCart { } # 不好的命名 type user { } type Art { } type ShopCart { } ``` #### 字段命名 - 使用 camelCase(首字母小写) - 使用动词或名词 - 避免使用保留字 **示例**: ```graphql # 好的命名 type User { firstName: String! lastName: String! fullName: String! isActive: Boolean! } # 不好的命名 type User { first_name: String! LastName: String! Full_Name: String! active: Boolean! } ``` #### 参数命名 - 使用 camelCase - 描述性名称 - 包含单位或类型信息 **示例**: ```graphql # 好的命名 query GetUsers($limit: Int, $offset: Int) { } query GetPosts($after: String, $first: Int) { } # 不好的命名 query GetUsers($l: Int, $o: Int) { } query GetPosts($a: String, $f: Int) { } ``` ### 3. 类型设计原则 #### 避免过度嵌套 ```graphql # 不好的设计 - 过度嵌套 type User { posts { comments { author { posts { comments { # 无限嵌套 } } } } } } # 好的设计 - 合理嵌套 type User { posts(limit: Int): [Post!]! } type Post { comments(limit: Int): [Comment!]! } ``` #### 使用输入类型封装参数 ```graphql # 不好的设计 - 参数过多 mutation CreateUser($name: String!, $email: String!, $age: Int, $address: String, $phone: String) { } # 好的设计 - 使用输入类型 input CreateUserInput { name: String! email: String! age: Int address: String phone: String } mutation CreateUser($input: CreateUserInput!) { } ``` #### 实现分页 ```graphql # 基于游标的分页(推荐) type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! startCursor: String endCursor: String } type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo! totalCount: Int! } type PostEdge { node: Post! cursor: String! } type Query { posts(after: String, first: Int): PostConnection! } # 基于偏移的分页 type PostResult { posts: [Post!]! total: Int! page: Int! pageSize: Int! } type Query { posts(offset: Int, limit: Int): PostResult! } ``` ### 4. 错误处理 #### 自定义错误类型 ```graphql type Error { code: String! message: String! field: String } type UserResult { user: User errors: [Error!]! } type Mutation { createUser(input: CreateUserInput!): UserResult! } ``` #### 使用可空字段 ```graphql type Mutation { # 返回可空类型表示可能失败 createUser(input: CreateUserInput!): User } ``` ### 5. 版本控制与演进 #### 废弃字段 ```graphql type User { id: ID! name: String! # 废弃字段,提供替代方案 fullName: String @deprecated(reason: "Use 'name' instead") email: String! } ``` #### 添加新字段 ```graphql type User { id: ID! name: String! email: String! # 新增字段,不影响现有客户端 phoneNumber: String } ``` ### 6. 性能优化 #### 使用 DataLoader ```javascript const userLoader = new DataLoader(async (userIds) => { const users = await User.findAll({ where: { id: userIds } }); return userIds.map(id => users.find(user => user.id === id)); }); ``` #### 字段级权限控制 ```graphql type User { id: ID! name: String! # 敏感字段需要权限 email: String! @auth(requires: ADMIN) salary: Float @auth(requires: ADMIN) } ``` ### 7. 文档与描述 #### 添加描述 ```graphql """ 用户类型,包含用户的基本信息 """ type User { """ 用户唯一标识符 """ id: ID! """ 用户姓名,最大长度 100 字符 """ name: String! """ 用户邮箱地址,必须是有效的邮箱格式 """ email: String! } ``` ### 8. 最佳实践总结 1. **保持简洁**: 避免过度设计,保持 Schema 简洁明了 2. **一致性**: 在整个项目中保持命名和结构的一致性 3. **可扩展性**: 设计时考虑未来的扩展需求 4. **向后兼容**: 添加新功能时保持向后兼容 5. **文档化**: 为所有类型、字段和参数添加描述 6. **性能考虑**: 避免过度嵌套,合理使用分页 7. **安全性**: 实现适当的权限控制和验证 8. **测试**: 为 Resolver 编写单元测试和集成测试
服务端 · 2月21日 17:01
GraphQL 有哪些高级概念和架构设计模式## GraphQL 高级概念与架构设计 GraphQL 不仅仅是查询语言,它还包含许多高级概念和架构设计模式,掌握这些对于构建大规模 GraphQL 应用至关重要。 ### 1. 联合类型(Union Types) #### 定义联合类型 ```graphql union SearchResult = User | Post | Comment type Query { search(query: String!): [SearchResult!]! } ``` #### 实现联合类型 Resolver ```javascript const resolvers = { SearchResult: { __resolveType: (obj) => { if (obj.email) { return 'User'; } if (obj.title) { return 'Post'; } if (obj.text) { return 'Comment'; } return null; } }, Query: { search: async (_, { query }) => { const users = await User.search(query); const posts = await Post.search(query); const comments = await Comment.search(query); return [...users, ...posts, ...comments]; } } }; ``` #### 使用联合类型 ```graphql query SearchResults($query: String!) { search(query: $query) { ... on User { id name email } ... on Post { id title author { name } } ... on Comment { id text author { name } } } } ``` ### 2. 接口类型(Interface Types) #### 定义接口 ```graphql interface Node { id: ID! createdAt: DateTime! } type User implements Node { id: ID! createdAt: DateTime! name: String! email: String! } type Post implements Node { id: ID! createdAt: DateTime! title: String! content: String! } type Query { node(id: ID!): Node } ``` #### 实现接口 Resolver ```javascript const resolvers = { Node: { __resolveType: (obj) => { if (obj.email) { return 'User'; } if (obj.title) { return 'Post'; } return null; } }, Query: { node: async (_, { id }) => { // 尝试从不同数据源获取 const user = await User.findById(id); if (user) return user; const post = await Post.findById(id); if (post) return post; throw new Error('Node not found'); } } }; ``` ### 3. 自定义指令(Custom Directives) #### 创建自定义指令 ```graphql directive @auth(requires: Role) on FIELD_DEFINITION directive @cache(ttl: Int) on FIELD_DEFINITION directive @transform(type: String) on FIELD_DEFINITION enum Role { USER ADMIN } ``` #### 实现指令 ```javascript const { makeExecutableSchema } = require('@graphql-tools/schema'); const { defaultFieldResolver } = require('graphql'); const authDirective = (schema) => { return makeExecutableSchema({ typeDefs: schema, resolvers: { Query: { // ... existing resolvers } }, directiveResolvers: { auth: (next, source, args, context) => { const { requires } = args; if (!context.user || context.user.role !== requires) { throw new Error('Unauthorized'); } return next(); }, cache: async (next, source, args, context) => { const { ttl } = args; const cacheKey = `cache:${JSON.stringify(source)}`; // 尝试从缓存获取 const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); } // 执行 resolver const result = await next(); // 缓存结果 await redis.setex(cacheKey, ttl, JSON.stringify(result)); return result; } } }); }; ``` ### 4. 订阅(Subscriptions) #### 定义订阅 ```graphql type Subscription { postCreated: Post! postUpdated(postId: ID!): Post! commentAdded(postId: ID!): Comment! } ``` #### 实现订阅 ```javascript const { PubSub } = require('graphql-subscriptions'); const RedisPubSub = require('graphql-redis-subscriptions').RedisPubSub; const pubsub = new RedisPubSub({ connection: { host: 'localhost', port: 6379 } }); const POST_CREATED = 'POST_CREATED'; const POST_UPDATED = 'POST_UPDATED'; const COMMENT_ADDED = 'COMMENT_ADDED'; const resolvers = { Subscription: { postCreated: { subscribe: () => pubsub.asyncIterator([POST_CREATED]) }, postUpdated: { subscribe: (_, { postId }) => { const filteredIterator = pubsub.asyncIterator([POST_UPDATED]); return { [Symbol.asyncIterator]() { return (async function* () { for await (const event of filteredIterator) { if (event.postUpdated.id === postId) { yield event; } } })(); } }; } }, commentAdded: { subscribe: (_, { postId }) => { const filteredIterator = pubsub.asyncIterator([COMMENT_ADDED]); return { [Symbol.asyncIterator]() { return (async function* () { for await (const event of filteredIterator) { if (event.commentAdded.postId === postId) { yield event; } } })(); } }; } } }, Mutation: { createPost: async (_, { input }) => { const post = await Post.create(input); pubsub.publish(POST_CREATED, { postCreated: post }); return post; }, updatePost: async (_, { id, input }) => { const post = await Post.update(id, input); pubsub.publish(POST_UPDATED, { postUpdated: post }); return post; }, addComment: async (_, { input }) => { const comment = await Comment.create(input); pubsub.publish(COMMENT_ADDED, { commentAdded: comment }); return comment; } } }; ``` ### 5. 数据加载器模式(DataLoader Pattern) #### 批量加载 ```javascript const DataLoader = require('dataloader'); class DataLoaderContext { constructor() { this.userLoader = new DataLoader(this.batchGetUsers); this.postLoader = new DataLoader(this.batchGetPosts); } async batchGetUsers(userIds) { const users = await User.findAll({ where: { id: userIds } }); return userIds.map(id => users.find(user => user.id === id)); } async batchGetPosts(postIds) { const posts = await Post.findAll({ where: { id: postIds } }); return postIds.map(id => posts.find(post => post.id === id)); } } ``` #### 在 Resolver 中使用 ```javascript const resolvers = { Post: { author: (post, _, context) => { return context.dataLoaders.userLoader.load(post.authorId); }, comments: (post, _, context) => { return context.dataLoaders.commentLoader.load(post.id); } }, Query: { posts: async (_, __, context) => { const posts = await Post.findAll(); return posts; } } }; ``` ### 6. Schema 拆分与组合 #### Schema 拆分 ```graphql # user.graphql type User { id: ID! name: String! email: String! } extend type Query { user(id: ID!): User users: [User!]! } extend type Mutation { createUser(input: CreateUserInput!): User updateUser(id: ID!, input: UpdateUserInput!): User deleteUser(id: ID!): Boolean } ``` ```graphql # post.graphql type Post { id: ID! title: String! content: String! author: User! } extend type Query { post(id: ID!): Post posts: [Post!]! } extend type Mutation { createPost(input: CreatePostInput!): Post updatePost(id: ID!, input: UpdatePostInput!): Post deletePost(id: ID!): Boolean } ``` #### Schema 组合 ```javascript const { mergeTypeDefs } = require('@graphql-tools/merge'); const { loadFilesSync } = require('@graphql-tools/load-files'); const typeDefsArray = loadFilesSync(path.join(__dirname, './schemas')); const typeDefs = mergeTypeDefs(typeDefsArray); ``` ### 7. 微服务架构中的 GraphQL #### 网关模式 ```javascript const { ApolloServer } = require('apollo-server'); const { ApolloGateway } = require('@apollo/gateway'); const { readFileSync } = require('fs'); const gateway = new ApolloGateway({ supergraphSdl: readFileSync('./supergraph.graphql').toString(), serviceList: [ { name: 'users', url: 'http://localhost:4001/graphql' }, { name: 'posts', url: 'http://localhost:4002/graphql' }, { name: 'comments', url: 'http://localhost:4003/graphql' } ] }); const server = new ApolloServer({ gateway, subscriptions: false }); server.listen().then(({ url }) => { console.log(`Gateway ready at ${url}`); }); ``` #### 联邦模式 ```graphql # users service extend type Post @key(fields: "id") { id: ID! @external author: User } type User @key(fields: "id") { id: ID! name: String! email: String! } ``` ```graphql # posts service type Post @key(fields: "id") { id: ID! title: String! content: String! authorId: ID! author: User } extend type User @key(fields: "id") { id: ID! @external posts: [Post!]! } ``` ### 8. 错误处理与重试 #### 自定义错误类 ```javascript class GraphQLError extends Error { constructor(message, code, extensions = {}) { super(message); this.name = 'GraphQLError'; this.code = code; this.extensions = extensions; } } class ValidationError extends GraphQLError { constructor(message, field) { super(message, 'VALIDATION_ERROR', { field }); } } class AuthenticationError extends GraphQLError { constructor(message = 'Authentication required') { super(message, 'AUTHENTICATION_ERROR'); } } class AuthorizationError extends GraphQLError { constructor(message = 'Not authorized') { super(message, 'AUTHORIZATION_ERROR'); } } ``` #### 错误处理中间件 ```javascript const formatError = (error) => { if (error instanceof GraphQLError) { return { message: error.message, code: error.code, extensions: error.extensions }; } // 生产环境不暴露详细错误 if (process.env.NODE_ENV === 'production') { return { message: 'Internal server error', code: 'INTERNAL_SERVER_ERROR' }; } return error; }; ``` ### 9. 测试策略 #### Resolver 单元测试 ```javascript const { resolvers } = require('./resolvers'); describe('User resolvers', () => { describe('Query.user', () => { it('should return user by id', async () => { const mockUser = { id: '1', name: 'John', email: 'john@example.com' }; User.findById = jest.fn().mockResolvedValue(mockUser); const result = await resolvers.Query.user(null, { id: '1' }); expect(result).toEqual(mockUser); expect(User.findById).toHaveBeenCalledWith('1'); }); it('should throw error if user not found', async () => { User.findById = jest.fn().mockResolvedValue(null); await expect( resolvers.Query.user(null, { id: '1' }) ).rejects.toThrow('User not found'); }); }); }); ``` #### 集成测试 ```javascript const { ApolloServer } = require('apollo-server'); const { createTestClient } = require('apollo-server-testing'); const { typeDefs, resolvers } = require('./schema'); describe('GraphQL API', () => { const server = new ApolloServer({ typeDefs, resolvers }); const { query, mutate } = createTestClient(server); describe('Query.users', () => { it('should return all users', async () => { const GET_USERS = ` query GetUsers { users { id name email } } `; const { data, errors } = await query(GET_USERS); expect(errors).toBeUndefined(); expect(data.users).toBeDefined(); expect(Array.isArray(data.users)).toBe(true); }); }); describe('Mutation.createUser', () => { it('should create a new user', async () => { const CREATE_USER = ` mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id name email } } `; const variables = { input: { name: 'John Doe', email: 'john@example.com' } }; const { data, errors } = await mutate(CREATE_USER, { variables }); expect(errors).toBeUndefined(); expect(data.createUser).toBeDefined(); expect(data.createUser.name).toBe('John Doe'); }); }); }); ``` ### 10. 高级架构模式 #### CQRS 模式 ```javascript const resolvers = { Query: { // 读操作 - 使用优化的查询 user: async (_, { id }, { readDb }) => { return readDb.User.findById(id); } }, Mutation: { // 写操作 - 使用事件溯源 createUser: async (_, { input }, { writeDb, eventBus }) => { const user = await writeDb.User.create(input); await eventBus.publish('USER_CREATED', { user }); return user; } } }; ``` #### 事件溯源模式 ```javascript class EventStore { async saveEvent(aggregateId, eventType, payload) { await Event.create({ aggregateId, eventType, payload: JSON.stringify(payload), timestamp: new Date() }); } async getEvents(aggregateId) { const events = await Event.findAll({ where: { aggregateId }, order: [['timestamp', 'ASC']] }); return events.map(event => ({ ...event, payload: JSON.parse(event.payload) })); } } ``` ### 11. 高级概念总结 | 概念 | 用途 | 优势 | |------|------|------| | 联合类型 | 处理多种类型的返回结果 | 灵活的数据结构 | | 接口类型 | 定义共享字段 | 代码复用、类型安全 | | 自定义指令 | 声明式功能 | 可重用、可组合 | | 订阅 | 实时数据推送 | 实时性、低延迟 | | DataLoader | 批量加载 | 性能优化、减少查询 | | Schema 组合 | 模块化设计 | 可维护性、团队协作 | | 微服务 | 分布式架构 | 可扩展性、独立部署 | | 错误处理 | 统一错误管理 | 一致性、可调试性 | | 测试策略 | 质量保证 | 可靠性、可维护性 | | 架构模式 | 解决复杂问题 | 可扩展性、灵活性 |
服务端 · 2月21日 17:00
GraphQL 项目开发有哪些最佳实践## GraphQL 项目开发最佳实践 在实际项目中使用 GraphQL 时,遵循最佳实践可以帮助团队构建可维护、可扩展和高质量的 GraphQL API。以下是项目开发中的关键最佳实践。 ### 1. 项目结构组织 #### 推荐的项目结构 ``` graphql-project/ ├── src/ │ ├── graphql/ │ │ ├── schema/ │ │ │ ├── index.graphql │ │ │ ├── types/ │ │ │ │ ├── user.graphql │ │ │ │ ├── post.graphql │ │ │ │ └── comment.graphql │ │ │ ├── queries/ │ │ │ │ └── index.graphql │ │ │ ├── mutations/ │ │ │ │ └── index.graphql │ │ │ └── subscriptions/ │ │ │ └── index.graphql │ │ ├── resolvers/ │ │ │ ├── index.ts │ │ │ ├── user.resolver.ts │ │ │ ├── post.resolver.ts │ │ │ └── comment.resolver.ts │ │ ├── directives/ │ │ │ ├── auth.directive.ts │ │ │ ├── cache.directive.ts │ │ │ └── index.ts │ │ ├── loaders/ │ │ │ ├── user.loader.ts │ │ │ ├── post.loader.ts │ │ │ └── index.ts │ │ ├── utils/ │ │ │ ├── schema-merger.ts │ │ │ ├── validator.ts │ │ │ └── error-handler.ts │ │ └── context.ts │ ├── models/ │ │ ├── User.ts │ │ ├── Post.ts │ │ └── Comment.ts │ ├── services/ │ │ ├── userService.ts │ │ ├── postService.ts │ │ └── commentService.ts │ └── index.ts ├── tests/ │ ├── unit/ │ │ └── resolvers/ │ └── integration/ │ └── api/ ├── package.json └── tsconfig.json ``` #### 模块化 Schema ```graphql # types/user.graphql type User { id: ID! name: String! email: String! createdAt: DateTime! updatedAt: DateTime! } input CreateUserInput { name: String! email: String! } input UpdateUserInput { name: String email: String } ``` ```graphql # queries/index.graphql extend type Query { user(id: ID!): User users(limit: Int, offset: Int): [User!]! } ``` ```graphql # mutations/index.graphql extend type Mutation { createUser(input: CreateUserInput!): User! updateUser(id: ID!, input: UpdateUserInput!): User! deleteUser(id: ID!): Boolean! } ``` ### 2. 代码规范 #### 命名约定 ```typescript // 类型定义使用 PascalCase type User { id: ID! name: String! } // 字段使用 camelCase type User { firstName: String! lastName: String! } // 输入类型以 Input 结尾 input CreateUserInput { name: String! email: String! } // 枚举使用 PascalCase enum UserRole { ADMIN USER GUEST } ``` #### Resolver 命名 ```typescript // Resolver 文件命名: *.resolver.ts // user.resolver.ts export const userResolvers = { Query: { user: () => {}, users: () => {} }, Mutation: { createUser: () => {}, updateUser: () => {}, deleteUser: () => {} }, User: { posts: () => {}, comments: () => {} } }; ``` ### 3. 错误处理 #### 统一错误格式 ```typescript class GraphQLError extends Error { constructor( public message: string, public code: string, public extensions?: Record<string, any> ) { super(message); this.name = 'GraphQLError'; } } class ValidationError extends GraphQLError { constructor(message: string, public field?: string) { super(message, 'VALIDATION_ERROR', { field }); } } class NotFoundError extends GraphQLError { constructor(resource: string, id: string) { super(`${resource} with id ${id} not found`, 'NOT_FOUND'); } } class AuthenticationError extends GraphQLError { constructor(message = 'Authentication required') { super(message, 'AUTHENTICATION_ERROR'); } } class AuthorizationError extends GraphQLError { constructor(message = 'Not authorized') { super(message, 'AUTHORIZATION_ERROR'); } } ``` #### 错误处理中间件 ```typescript const formatError = (error: any) => { if (error instanceof GraphQLError) { return { message: error.message, code: error.code, extensions: error.extensions }; } // 生产环境不暴露详细错误 if (process.env.NODE_ENV === 'production') { return { message: 'Internal server error', code: 'INTERNAL_SERVER_ERROR' }; } return error; }; ``` ### 4. 日志记录 #### 结构化日志 ```typescript import winston from 'winston'; const logger = winston.createLogger({ level: 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.json() ), transports: [ new winston.transports.Console(), new winston.transports.File({ filename: 'combined.log' }) ] }); // 在 Resolver 中使用 export const resolvers = { Query: { user: async (_, { id }, context) => { logger.info('Fetching user', { userId: id }); try { const user = await userService.findById(id); logger.info('User fetched successfully', { userId: id }); return user; } catch (error) { logger.error('Error fetching user', { userId: id, error }); throw error; } } } }; ``` ### 5. 测试策略 #### 单元测试 ```typescript import { userResolvers } from './user.resolver'; import { UserService } from '../services/userService'; describe('User Resolvers', () => { describe('Query.user', () => { it('should return user by id', async () => { const mockUser = { id: '1', name: 'John', email: 'john@example.com' }; jest.spyOn(UserService, 'findById').mockResolvedValue(mockUser); const result = await userResolvers.Query.user(null, { id: '1' }); expect(result).toEqual(mockUser); }); it('should throw NotFoundError if user not found', async () => { jest.spyOn(UserService, 'findById').mockResolvedValue(null); await expect( userResolvers.Query.user(null, { id: '1' }) ).rejects.toThrow('User with id 1 not found'); }); }); }); ``` #### 集成测试 ```typescript import { ApolloServer } from 'apollo-server'; import { createTestClient } from 'apollo-server-testing'; import { typeDefs, resolvers } from './schema'; describe('GraphQL API Integration Tests', () => { const server = new ApolloServer({ typeDefs, resolvers }); const { query, mutate } = createTestClient(server); describe('Query.users', () => { it('should return all users', async () => { const GET_USERS = ` query GetUsers { users { id name email } } `; const { data, errors } = await query(GET_USERS); expect(errors).toBeUndefined(); expect(data.users).toBeDefined(); expect(Array.isArray(data.users)).toBe(true); }); }); describe('Mutation.createUser', () => { it('should create a new user', async () => { const CREATE_USER = ` mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id name email } } `; const variables = { input: { name: 'John Doe', email: 'john@example.com' } }; const { data, errors } = await mutate(CREATE_USER, { variables }); expect(errors).toBeUndefined(); expect(data.createUser).toBeDefined(); expect(data.createUser.name).toBe('John Doe'); }); }); }); ``` ### 6. 文档生成 #### 使用 GraphQL Code Generator ```typescript // codegen.yml schema: ./src/graphql/schema/**/*.graphql documents: ./src/graphql/documents/**/*.graphql generates: ./src/generated/graphql.ts: plugins: - typescript - typescript-resolvers config: contextType: ./context#Context scalars: DateTime: Date ``` #### 自动生成文档 ```typescript import { ApolloServer } from 'apollo-server'; const server = new ApolloServer({ typeDefs, resolvers, introspection: true, // 启用内省 playground: true, // 启用 Playground plugins: [ { requestDidStart: () => ({ didResolveOperation: (context) => { // 记录查询信息 console.log('Operation:', context.request.operationName); console.log('Variables:', context.request.variables); } }) } ] }); ``` ### 7. 性能监控 #### 使用 Apollo Studio ```typescript import { ApolloServerPluginUsageReporting } from 'apollo-server-core'; const server = new ApolloServer({ typeDefs, resolvers, plugins: [ ApolloServerPluginUsageReporting({ apiKey: process.env.APOLLO_KEY, graphRef: 'my-graph@current' }) ] }); ``` #### 自定义监控 ```typescript import { promisify } from 'util'; const metrics = { queryDuration: new Map<string, number[]>(), queryCount: new Map<string, number>() }; const server = new ApolloServer({ typeDefs, resolvers, plugins: [ { requestDidStart: () => ({ didResolveOperation: (context) => { const operationName = context.request.operationName || 'anonymous'; const duration = context.metrics.duration; if (!metrics.queryDuration.has(operationName)) { metrics.queryDuration.set(operationName, []); } metrics.queryDuration.get(operationName)!.push(duration); metrics.queryCount.set( operationName, (metrics.queryCount.get(operationName) || 0) + 1 ); } }) } ] }); ``` ### 8. 部署策略 #### Docker 化 ```dockerfile # Dockerfile FROM node:18-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . RUN npm run build EXPOSE 4000 CMD ["npm", "start"] ``` ```yaml # docker-compose.yml version: '3.8' services: graphql: build: . ports: - "4000:4000" environment: - NODE_ENV=production - DATABASE_URL=postgresql://user:pass@db:5432/graphql depends_on: - db - redis db: image: postgres:14 environment: - POSTGRES_DB=graphql - POSTGRES_USER=user - POSTGRES_PASSWORD=pass redis: image: redis:7 ``` #### CI/CD 流程 ```yaml # .github/workflows/ci.yml name: CI on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: 18 - run: npm ci - run: npm run lint - run: npm run test - run: npm run build deploy: needs: test runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v3 - uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - run: docker build -t ghcr.io/${{ github.repository }}:latest . - run: docker push ghcr.io/${{ github.repository }}:latest ``` ### 9. 版本控制 #### Schema 演进策略 ```graphql # 添加新字段 - 向后兼容 type User { id: ID! name: String! email: String! # 新增字段 phoneNumber: String } # 废弃字段 - 提供替代方案 type User { id: ID! name: String! # 废弃字段 fullName: String @deprecated(reason: "Use 'name' instead") email: String! } # 修改类型 - 需要谨慎 # 不好的做法 type User { id: ID! age: String # 从 Int 改为 String } # 好的做法 - 添加新字段,逐步迁移 type User { id: ID! age: Int ageString: String @deprecated(reason: "Use 'age' instead") } ``` ### 10. 团队协作 #### Schema 评审流程 1. **设计阶段**: 团队讨论 Schema 设计 2. **文档阶段**: 编写详细的 Schema 文档 3. **评审阶段**: 团队成员评审 Schema 4. **实现阶段**: 实现 Resolver 和业务逻辑 5. **测试阶段**: 编写测试用例 6. **部署阶段**: 部署到测试环境 7. **监控阶段**: 监控 API 性能和错误 #### 代码审查清单 - [ ] Schema 设计合理 - [ ] 命名符合规范 - [ ] 错误处理完善 - [ ] 性能优化到位 - [ ] 安全措施完备 - [ ] 测试覆盖充分 - [ ] 文档完整准确 - [ ] 日志记录合理 ### 11. 最佳实践总结 | 方面 | 最佳实践 | |------|----------| | 项目结构 | 模块化、分层清晰 | | 代码规范 | 统一命名约定、代码风格 | | 错误处理 | 统一错误格式、详细错误信息 | | 日志记录 | 结构化日志、关键操作记录 | | 测试策略 | 单元测试、集成测试、E2E 测试 | | 文档生成 | 自动生成、保持更新 | | 性能监控 | 实时监控、性能分析 | | 部署策略 | 容器化、CI/CD 自动化 | | 版本控制 | 向后兼容、渐进式演进 | | 团队协作 | 代码审查、知识共享 |
服务端 · 2月21日 17:00
GraphQL 错误处理有哪些最佳实践## GraphQL 错误处理最佳实践 GraphQL 提供了灵活的错误处理机制,但正确实现错误处理对于构建健壮的 API 至关重要。以下是 GraphQL 错误处理的关键策略和最佳实践。 ### 1. GraphQL 错误结构 #### 基本错误响应格式 ```json { "data": { "user": null }, "errors": [ { "message": "User not found", "locations": [ { "line": 2, "column": 3 } ], "path": ["user"], "extensions": { "code": "NOT_FOUND", "timestamp": "2024-01-01T12:00:00Z" } } ] } ``` #### 错误字段说明 - **message**: 错误描述信息 - **locations**: 错误在查询中的位置 - **path**: 错误发生的字段路径 - **extensions**: 自定义扩展信息 ### 2. 自定义错误类 #### 创建错误类层次结构 ```javascript class GraphQLError extends Error { constructor(message, code, extensions = {}) { super(message); this.name = 'GraphQLError'; this.code = code; this.extensions = extensions; } } class ValidationError extends GraphQLError { constructor(message, field) { super(message, 'VALIDATION_ERROR', { field }); } } class NotFoundError extends GraphQLError { constructor(resource, id) { super(`${resource} with id ${id} not found`, 'NOT_FOUND', { resource, id }); } } class AuthenticationError extends GraphQLError { constructor(message = 'Authentication required') { super(message, 'AUTHENTICATION_ERROR'); } } class AuthorizationError extends GraphQLError { constructor(message = 'Not authorized') { super(message, 'AUTHORIZATION_ERROR'); } } class ConflictError extends GraphQLError { constructor(message) { super(message, 'CONFLICT_ERROR'); } } class RateLimitError extends GraphQLError { constructor(retryAfter) { super('Rate limit exceeded', 'RATE_LIMIT_ERROR', { retryAfter }); } } ``` ### 3. 在 Resolver 中抛出错误 #### 基本错误抛出 ```javascript const resolvers = { Query: { user: async (_, { id }) => { const user = await User.findById(id); if (!user) { throw new NotFoundError('User', id); } return user; } }, Mutation: { createUser: async (_, { input }) => { // 验证输入 if (!input.email || !isValidEmail(input.email)) { throw new ValidationError('Invalid email address', 'email'); } // 检查用户是否已存在 const existingUser = await User.findByEmail(input.email); if (existingUser) { throw new ConflictError('User with this email already exists'); } return User.create(input); } } }; ``` #### 部分错误处理 ```javascript const resolvers = { Mutation: { createUsers: async (_, { inputs }) => { const results = []; const errors = []; for (const input of inputs) { try { const user = await User.create(input); results.push({ success: true, user }); } catch (error) { errors.push({ success: false, input, error: error.message }); } } return { results, errors, total: inputs.length, successCount: results.length, failureCount: errors.length }; } } }; ``` ### 4. 错误格式化 #### 自定义错误格式化器 ```javascript const formatError = (error) => { // 处理自定义错误 if (error.originalError instanceof GraphQLError) { return { message: error.message, code: error.originalError.code, extensions: error.originalError.extensions }; } // 处理验证错误 if (error.originalError instanceof ValidationError) { return { message: error.message, code: 'VALIDATION_ERROR', field: error.originalError.field }; } // 生产环境不暴露详细错误 if (process.env.NODE_ENV === 'production') { return { message: 'Internal server error', code: 'INTERNAL_SERVER_ERROR' }; } // 开发环境返回完整错误信息 return { message: error.message, code: 'INTERNAL_SERVER_ERROR', stack: error.stack }; }; const server = new ApolloServer({ typeDefs, resolvers, formatError }); ``` ### 5. 错误日志记录 #### 结构化错误日志 ```javascript const winston = require('winston'); const logger = winston.createLogger({ level: 'error', format: winston.format.combine( winston.format.timestamp(), winston.format.json() ), transports: [ new winston.transports.File({ filename: 'error.log' }) ] }); const server = new ApolloServer({ typeDefs, resolvers, plugins: [ { requestDidStart: () => ({ didEncounterErrors: (context) => { context.errors.forEach((error) => { logger.error('GraphQL Error', { message: error.message, code: error.extensions?.code, path: error.path, locations: error.locations, query: context.request.query, variables: context.request.variables }); }); } }) } ] }); ``` ### 6. 错误恢复策略 #### 降级处理 ```javascript const resolvers = { Query: { userProfile: async (_, { id }, { dataSources }) => { try { // 尝试从主数据源获取 return await dataSources.userAPI.getUser(id); } catch (error) { // 如果主数据源失败,使用缓存数据 const cachedUser = await redis.get(`user:${id}`); if (cachedUser) { logger.warn('Using cached user data due to API failure', { userId: id }); return JSON.parse(cachedUser); } // 如果缓存也没有,返回默认数据 logger.error('Failed to fetch user data', { userId: id, error }); return { id, name: 'Unknown User', isFallback: true }; } } } }; ``` #### 重试机制 ```javascript async function retryOperation(operation, maxRetries = 3, delay = 1000) { let lastError; for (let i = 0; i < maxRetries; i++) { try { return await operation(); } catch (error) { lastError = error; // 如果是可重试的错误,等待后重试 if (isRetryableError(error) && i < maxRetries - 1) { await new Promise(resolve => setTimeout(resolve, delay * (i + 1))); continue; } throw error; } } throw lastError; } function isRetryableError(error) { const retryableCodes = ['NETWORK_ERROR', 'TIMEOUT', 'SERVICE_UNAVAILABLE']; return retryableCodes.includes(error.code); } const resolvers = { Query: { externalData: async () => { return retryOperation(async () => { return await externalAPI.fetchData(); }); } } }; ``` ### 7. 错误类型设计 #### 错误结果类型 ```graphql type Error { code: String! message: String! field: String details: String } type UserResult { user: User errors: [Error!]! success: Boolean! } type Mutation { createUser(input: CreateUserInput!): UserResult! updateUser(id: ID!, input: UpdateUserInput!): UserResult! } ``` #### 实现 ```javascript const resolvers = { Mutation: { createUser: async (_, { input }) => { const errors = []; // 验证输入 if (!input.name) { errors.push({ code: 'REQUIRED_FIELD', message: 'Name is required', field: 'name' }); } if (!input.email) { errors.push({ code: 'REQUIRED_FIELD', message: 'Email is required', field: 'email' }); } else if (!isValidEmail(input.email)) { errors.push({ code: 'INVALID_EMAIL', message: 'Invalid email format', field: 'email' }); } // 如果有错误,返回错误信息 if (errors.length > 0) { return { user: null, errors, success: false }; } // 创建用户 try { const user = await User.create(input); return { user, errors: [], success: true }; } catch (error) { return { user: null, errors: [{ code: 'INTERNAL_ERROR', message: 'Failed to create user', details: error.message }], success: false }; } } } }; ``` ### 8. 错误监控和告警 #### 错误监控集成 ```javascript const Sentry = require('@sentry/node'); Sentry.init({ dsn: process.env.SENTRY_DSN, environment: process.env.NODE_ENV }); const server = new ApolloServer({ typeDefs, resolvers, plugins: [ { requestDidStart: () => ({ didEncounterErrors: (context) => { context.errors.forEach((error) => { // 发送错误到 Sentry Sentry.captureException(error, { tags: { graphql: true, operation: context.request.operationName }, extra: { query: context.request.query, variables: context.request.variables } }); }); } }) } ] }); ``` #### 错误告警 ```javascript const alertThreshold = { errorRate: 0.05, // 5% 错误率 errorCount: 100 // 100 个错误 }; let errorCount = 0; let requestCount = 0; const server = new ApolloServer({ typeDefs, resolvers, plugins: [ { requestDidStart: () => ({ willSendResponse: (context) => { requestCount++; if (context.response.errors && context.response.errors.length > 0) { errorCount += context.response.errors.length; // 检查是否需要告警 const errorRate = errorCount / requestCount; if (errorRate > alertThreshold.errorRate || errorCount > alertThreshold.errorCount) { sendAlert({ message: 'High error rate detected', errorRate, errorCount, requestCount }); } } } }) } ] }); ``` ### 9. 错误处理最佳实践总结 | 实践 | 说明 | |------|------| | 使用自定义错误类 | 创建清晰的错误层次结构 | | 提供详细的错误信息 | 包含错误代码、消息和上下文 | | 部分错误处理 | 允许部分成功的操作 | | 错误格式化 | 统一错误响应格式 | | 错误日志记录 | 记录所有错误用于分析 | | 错误恢复 | 实现降级和重试机制 | | 错误监控 | 实时监控错误率 | | 错误告警 | 及时通知团队 | ### 10. 常见错误场景及处理 | 场景 | 错误类型 | 处理方式 | |------|----------|----------| | 资源不存在 | NotFoundError | 返回 404 错误 | | 验证失败 | ValidationError | 返回字段级错误 | | 认证失败 | AuthenticationError | 返回 401 错误 | | 授权失败 | AuthorizationError | 返回 403 错误 | | 数据冲突 | ConflictError | 返回 409 错误 | | 速率限制 | RateLimitError | 返回 429 错误 | | 网络错误 | NetworkError | 重试或降级 | | 服务不可用 | ServiceUnavailableError | 使用缓存或降级 |
服务端 · 2月21日 17:00
GraphQL 订阅(Subscriptions)如何实现## GraphQL 订阅(Subscriptions)实现详解 GraphQL 订阅允许客户端实时接收服务器推送的数据更新,是构建实时应用的关键功能。以下是 GraphQL 订阅的详细实现方案。 ### 1. 订阅基础概念 #### 订阅的工作原理 1. 客户端通过 WebSocket 建立持久连接 2. 客户端发送订阅查询 3. 服务器保持连接并监听事件 4. 当事件发生时,服务器推送数据到客户端 5. 客户端接收并处理更新 #### 订阅 vs 轮询 | 特性 | 订阅 | 轮询 | |------|------|------| | 实时性 | 高 | 低 | | 服务器负载 | 低(事件驱动) | 高(持续查询) | | 网络开销 | 低(按需推送) | 高(定期请求) | | 实现复杂度 | 高 | 低 | | 适用场景 | 实时更新 | 定期检查 | ### 2. 服务器端实现 #### 使用 graphql-subscriptions ```javascript const { PubSub } = require('graphql-subscriptions'); const pubsub = new PubSub(); const POST_CREATED = 'POST_CREATED'; const POST_UPDATED = 'POST_UPDATED'; const COMMENT_ADDED = 'COMMENT_ADDED'; const typeDefs = ` type Post { id: ID! title: String! content: String! author: User! createdAt: DateTime! } type Comment { id: ID! text: String! author: User! post: Post! createdAt: DateTime! } type Subscription { postCreated: Post! postUpdated(postId: ID!): Post! commentAdded(postId: ID!): Comment! } `; const resolvers = { Subscription: { postCreated: { subscribe: () => pubsub.asyncIterator([POST_CREATED]) }, postUpdated: { subscribe: (_, { postId }) => { const asyncIterator = pubsub.asyncIterator([POST_UPDATED]); return { [Symbol.asyncIterator]() { return (async function* () { for await (const event of asyncIterator) { // 过滤:只返回指定帖子的更新 if (event.postUpdated.id === postId) { yield event; } } })(); } }; } }, commentAdded: { subscribe: (_, { postId }) => { const asyncIterator = pubsub.asyncIterator([COMMENT_ADDED]); return { [Symbol.asyncIterator]() { return (async function* () { for await (const event of asyncIterator) { if (event.commentAdded.postId === postId) { yield event; } } })(); } }; } } }, Mutation: { createPost: async (_, { input }) => { const post = await Post.create(input); pubsub.publish(POST_CREATED, { postCreated: post }); return post; }, updatePost: async (_, { id, input }) => { const post = await Post.update(id, input); pubsub.publish(POST_UPDATED, { postUpdated: post }); return post; }, addComment: async (_, { input }) => { const comment = await Comment.create(input); pubsub.publish(COMMENT_ADDED, { commentAdded: comment }); return comment; } } }; ``` #### 使用 Redis PubSub ```javascript const { RedisPubSub } = require('graphql-redis-subscriptions'); const pubsub = new RedisPubSub({ connection: { host: 'localhost', port: 6379, retry_strategy: (options) => { if (options.error && options.error.code === 'ECONNREFUSED') { return new Error('Redis connection refused'); } if (options.total_retry_time > 1000 * 60 * 60) { return new Error('Redis retry time exhausted'); } if (options.attempt > 10) { return undefined; } return Math.min(options.attempt * 100, 3000); } } }); // 使用方式与内存 PubSub 相同 const resolvers = { Subscription: { postCreated: { subscribe: () => pubsub.asyncIterator([POST_CREATED]) } } }; ``` ### 3. Apollo Server 订阅实现 #### 配置 Apollo Server ```javascript const { ApolloServer } = require('apollo-server-express'); const { createServer } = require('http'); const { WebSocketServer } = require('ws'); const { useServer } = require('graphql-ws/lib/use/ws'); const server = new ApolloServer({ typeDefs, resolvers, context: ({ req, connection }) => { // HTTP 请求的 context if (req) { return { token: req.headers.authorization }; } // WebSocket 连接的 context if (connection) { return { token: connection.context.authorization }; } } }); const httpServer = createServer(server); // 创建 WebSocket 服务器 const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' }); useServer( { schema: server.schema, context: (ctx) => { // 验证连接 const token = ctx.connectionParams?.authorization; if (!token) { throw new Error('Unauthorized'); } return { token }; }, onConnect: (ctx) => { console.log('Client connected'); return { authorization: ctx.connectionParams?.authorization }; }, onDisconnect: (ctx, code, reason) => { console.log('Client disconnected', { code, reason }); } }, wsServer ); server.listen().then(({ url }) => { console.log(`Server ready at ${url}`); }); ``` ### 4. 客户端实现 #### Apollo Client 订阅 ```javascript import { ApolloClient, InMemoryCache, split, HttpLink } from '@apollo/client'; import { WebSocketLink } from '@apollo/client/link/ws'; import { getMainDefinition } from '@apollo/client/utilities'; // HTTP 链接 const httpLink = new HttpLink({ uri: 'http://localhost:4000/graphql' }); // WebSocket 链接 const wsLink = new WebSocketLink({ uri: 'ws://localhost:4000/graphql', options: { reconnect: true, connectionParams: { authToken: localStorage.getItem('token') }, lazy: true, connectionCallback: (error) => { if (error) { console.error('WebSocket connection error:', error); } else { console.log('WebSocket connected'); } } } }); // 分割链接 const splitLink = split( ({ query }) => { const definition = getMainDefinition(query); return ( definition.kind === 'OperationDefinition' && definition.operation === 'subscription' ); }, wsLink, httpLink ); const client = new ApolloClient({ link: splitLink, cache: new InMemoryCache() }); // 使用订阅 import { gql, useSubscription } from '@apollo/client'; const POST_CREATED_SUBSCRIPTION = gql` subscription OnPostCreated { postCreated { id title content author { name } createdAt } } `; function PostList() { const { data, loading, error } = useSubscription(POST_CREATED_SUBSCRIPTION); if (loading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return ( <div> <h3>New Post Created:</h3> <p>{data.postCreated.title}</p> </div> ); } ``` #### React Hooks 订阅 ```javascript import { useSubscription, useMutation } from '@apollo/client'; function ChatRoom({ roomId }) { const MESSAGE_ADDED = gql` subscription OnMessageAdded($roomId: ID!) { messageAdded(roomId: $roomId) { id text author { name } createdAt } } `; const SEND_MESSAGE = gql` mutation SendMessage($roomId: ID!, $text: String!) { sendMessage(roomId: $roomId, text: $text) { id text } } `; const { data: messageData, loading } = useSubscription(MESSAGE_ADDED, { variables: { roomId } }); const [sendMessage] = useMutation(SEND_MESSAGE); const handleSendMessage = (text) => { sendMessage({ variables: { roomId, text } }); }; return ( <div> {loading ? ( <div>Connecting...</div> ) : ( <div> <MessageList messages={messageData?.messageAdded} /> <MessageInput onSend={handleSendMessage} /> </div> )} </div> ); } ``` ### 5. 订阅过滤 #### 基于参数的过滤 ```javascript const resolvers = { Subscription: { notification: { subscribe: (_, { userId, types }) => { const asyncIterator = pubsub.asyncIterator(['NOTIFICATION']); return { [Symbol.asyncIterator]() { return (async function* () { for await (const event of asyncIterator) { const notification = event.notification; // 过滤用户 if (userId && notification.userId !== userId) { continue; } // 过滤类型 if (types && !types.includes(notification.type)) { continue; } yield event; } })(); } }; } } } }; ``` #### 基于权限的过滤 ```javascript const resolvers = { Subscription: { userUpdate: { subscribe: async (_, __, context) => { // 验证用户权限 if (!context.user) { throw new Error('Unauthorized'); } const asyncIterator = pubsub.asyncIterator(['USER_UPDATE']); return { [Symbol.asyncIterator]() { return (async function* () { for await (const event of asyncIterator) { const update = event.userUpdate; // 只返回当前用户的更新 if (update.userId !== context.user.id) { continue; } // 只返回有权限查看的字段 const filteredUpdate = filterSensitiveFields(update, context.user.role); yield { userUpdate: filteredUpdate }; } })(); } }; } } } }; ``` ### 6. 订阅错误处理 #### 连接错误处理 ```javascript const wsLink = new WebSocketLink({ uri: 'ws://localhost:4000/graphql', options: { reconnect: true, retryAttempts: 5, connectionParams: async () => { const token = await getAuthToken(); return { token }; }, on: { connected: () => console.log('WebSocket connected'), error: (error) => { console.error('WebSocket error:', error); // 尝试重新连接 }, closed: (event) => { console.log('WebSocket closed:', event); // 清理资源 } } } }); ``` #### 订阅错误处理 ```javascript function useSubscriptionWithErrorHandling(query, options) { const { data, error, loading } = useSubscription(query, options); useEffect(() => { if (error) { console.error('Subscription error:', error); // 根据错误类型处理 if (error.networkError) { // 网络错误,尝试重连 handleNetworkError(error); } else if (error.graphQLErrors) { // GraphQL 错误 handleGraphQLError(error); } } }, [error]); return { data, error, loading }; } ``` ### 7. 订阅性能优化 #### 批量发布 ```javascript class BatchPublisher { constructor(pubsub, eventName, batchSize = 10, flushInterval = 100) { this.pubsub = pubsub; this.eventName = eventName; this.batchSize = batchSize; this.flushInterval = flushInterval; this.batch = []; this.flushTimer = null; } add(event) { this.batch.push(event); if (this.batch.length >= this.batchSize) { this.flush(); } else if (!this.flushTimer) { this.flushTimer = setTimeout(() => this.flush(), this.flushInterval); } } flush() { if (this.batch.length === 0) return; // 批量发布 this.pubsub.publish(this.eventName, { batch: this.batch }); this.batch = []; if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null; } } } // 使用批量发布器 const batchPublisher = new BatchPublisher(pubsub, 'BATCH_EVENTS'); // 添加事件到批次 batchPublisher.add({ type: 'event1', data: {} }); batchPublisher.add({ type: 'event2', data: {} }); ``` #### 订阅节流 ```javascript function useThrottledSubscription(query, options, throttleMs = 1000) { const { data, loading } = useSubscription(query, options); const [throttledData, setThrottledData] = useState(null); const lastUpdate = useRef(0); useEffect(() => { if (data) { const now = Date.now(); if (now - lastUpdate.current > throttleMs) { setThrottledData(data); lastUpdate.current = now; } } }, [data, throttleMs]); return { data: throttledData, loading }; } ``` ### 8. 订阅监控 #### 连接监控 ```javascript const connectionMetrics = { activeConnections: 0, totalConnections: 0, disconnections: 0 }; const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' }); useServer( { schema: server.schema, onConnect: () => { connectionMetrics.totalConnections++; connectionMetrics.activeConnections++; console.log('Connection metrics:', connectionMetrics); }, onDisconnect: () => { connectionMetrics.activeConnections--; connectionMetrics.disconnections++; console.log('Connection metrics:', connectionMetrics); } }, wsServer ); ``` #### 订阅指标 ```javascript const subscriptionMetrics = new Map(); function trackSubscription(eventName) { if (!subscriptionMetrics.has(eventName)) { subscriptionMetrics.set(eventName, { count: 0, lastPublished: null }); } const metrics = subscriptionMetrics.get(eventName); metrics.count++; metrics.lastPublished = new Date(); } // 在发布事件时追踪 pubsub.publish(POST_CREATED, { postCreated: post }); trackSubscription(POST_CREATED); ``` ### 9. 订阅最佳实践 | 实践 | 说明 | |------|------| | 使用 Redis PubSub | 支持分布式部署 | | 实现连接认证 | 确保订阅安全 | | 添加错误处理 | 提高稳定性 | | 实现过滤机制 | 减少不必要的数据推送 | | 监控连接状态 | 及时发现问题 | | 使用批量发布 | 提高性能 | | 实现重连机制 | 提高可靠性 | | 限制订阅数量 | 防止资源耗尽 | | 设置超时时间 | 避免僵尸连接 | | 记录订阅日志 | 便于调试和分析 | ### 10. 常见问题及解决方案 | 问题 | 原因 | 解决方案 | |------|------|----------| | 连接频繁断开 | 网络不稳定、超时 | 实现自动重连、增加超时时间 | | 订阅延迟高 | 服务器负载高、处理慢 | 优化性能、使用批量发布 | | 内存泄漏 | 未正确清理订阅 | 确保取消订阅、清理资源 | | 数据不一致 | 缓存未更新 | 实现缓存失效机制 | | 安全问题 | 未验证连接 | 实现连接认证和授权 |
服务端 · 2月21日 17:00
GraphQL 缓存策略有哪些实现方式## GraphQL 缓存策略与实现 GraphQL 的缓存机制对于提高性能、减少服务器负载和改善用户体验至关重要。以下是 GraphQL 缓存的各种策略和实现方法。 ### 1. 客户端缓存 #### Apollo Client 缓存 ```javascript import { ApolloClient, InMemoryCache } from '@apollo/client'; const client = new ApolloClient({ uri: 'https://api.example.com/graphql', cache: new InMemoryCache({ typePolicies: { Query: { fields: { posts: { keyArgs: ['filter'], merge(existing, incoming) { return incoming; } } } }, Post: { keyFields: ['id', 'slug'] } } }) }); ``` #### 缓存策略配置 ```javascript const cache = new InMemoryCache({ typePolicies: { Query: { fields: { // 缓存单个结果 user: { read(_, { args, toReference }) { return toReference({ __typename: 'User', id: args.id }); } }, // 缓存列表 posts: { keyArgs: ['filter', 'sort'], merge(existing = [], incoming) { return [...existing, ...incoming]; } }, // 缓存分页数据 paginatedPosts: { keyArgs: false, merge(existing = { edges: [] }, incoming) { return { ...incoming, edges: [...existing.edges, ...incoming.edges] }; } } } } } }); ``` ### 2. 服务器端缓存 #### Redis 缓存实现 ```javascript const Redis = require('ioredis'); const redis = new Redis(); async function cachedResolver(parent, args, context, info) { const cacheKey = `graphql:${info.fieldName}:${JSON.stringify(args)}`; try { // 尝试从缓存获取 const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); } // 执行实际查询 const result = await fetchData(args); // 缓存结果(5 分钟过期) await redis.setex(cacheKey, 300, JSON.stringify(result)); return result; } catch (error) { console.error('Cache error:', error); // 缓存失败时直接查询 return await fetchData(args); } } const resolvers = { Query: { user: cachedResolver, posts: cachedResolver, post: cachedResolver } }; ``` #### Memcached 缓存 ```javascript const Memcached = require('memcached'); const memcached = new Memcached('localhost:11211'); async function memcachedResolver(parent, args, context, info) { const cacheKey = `graphql:${info.fieldName}:${JSON.stringify(args)}`; return new Promise((resolve, reject) => { memcached.get(cacheKey, async (err, data) => { if (err) { console.error('Memcached error:', err); return resolve(await fetchData(args)); } if (data) { return resolve(JSON.parse(data)); } // 执行查询并缓存 const result = await fetchData(args); memcached.set(cacheKey, JSON.stringify(result), 300, (err) => { if (err) console.error('Memcached set error:', err); }); resolve(result); }); }); } ``` ### 3. 缓存失效策略 #### 基于时间的失效 ```javascript const TTL = { SHORT: 60, // 1 分钟 MEDIUM: 300, // 5 分钟 LONG: 3600, // 1 小时 VERY_LONG: 86400 // 24 小时 }; async function timeBasedResolver(parent, args, context, info) { const cacheKey = `graphql:${info.fieldName}:${JSON.stringify(args)}`; const ttl = getTTL(info.fieldName); const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); } const result = await fetchData(args); await redis.setex(cacheKey, ttl, JSON.stringify(result)); return result; } function getTTL(fieldName) { const ttlMap = { 'user': TTL.MEDIUM, 'posts': TTL.SHORT, 'post': TTL.LONG, 'comments': TTL.SHORT }; return ttlMap[fieldName] || TTL.MEDIUM; } ``` #### 基于事件的失效 ```javascript const eventBus = new EventEmitter(); // 监听数据变更事件 eventBus.on('user.updated', async (userId) => { const pattern = `graphql:user:*${userId}*`; const keys = await redis.keys(pattern); if (keys.length > 0) { await redis.del(keys); } }); eventBus.on('post.created', async () => { const pattern = 'graphql:posts*'; const keys = await redis.keys(pattern); if (keys.length > 0) { await redis.del(keys); } }); // 在 Mutation 中触发事件 const resolvers = { Mutation: { updateUser: async (_, { id, input }) => { const user = await User.update(id, input); eventBus.emit('user.updated', id); return user; }, createPost: async (_, { input }) => { const post = await Post.create(input); eventBus.emit('post.created'); return post; } } }; ``` ### 4. 缓存预热 #### 预加载热门数据 ```javascript async function warmupCache() { console.log('Warming up cache...'); // 预加载热门用户 const popularUsers = await User.findPopular(100); for (const user of popularUsers) { const cacheKey = `graphql:user:${JSON.stringify({ id: user.id })}`; await redis.setex(cacheKey, 3600, JSON.stringify(user)); } // 预加载最新帖子 const latestPosts = await Post.findLatest(50); const cacheKey = `graphql:posts:${JSON.stringify({ limit: 50 })}`; await redis.setex(cacheKey, 300, JSON.stringify(latestPosts)); console.log('Cache warmed up successfully'); } // 在应用启动时执行 warmupCache(); ``` ### 5. 缓存穿透保护 #### 布隆过滤器 ```javascript const { BloomFilter } = require('bloom-filters'); // 创建布隆过滤器 const userBloomFilter = new BloomFilter(1000000, 0.01); // 初始化时填充布隆过滤器 async function initBloomFilter() { const userIds = await User.getAllIds(); userIds.forEach(id => userBloomFilter.add(id)); } async function protectedResolver(parent, args, context, info) { const { id } = args; // 检查 ID 是否可能存在 if (!userBloomFilter.has(id)) { // ID 肯定不存在,直接返回 null return null; } // 可能存在,查询缓存或数据库 const cacheKey = `graphql:user:${JSON.stringify(args)}`; const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); } const user = await User.findById(id); if (user) { await redis.setex(cacheKey, 300, JSON.stringify(user)); } else { // 缓存空值,防止缓存穿透 await redis.setex(cacheKey, 60, JSON.stringify(null)); } return user; } ``` ### 6. 缓存雪崩保护 #### 随机过期时间 ```javascript function getRandomTTL(baseTTL, variance = 0.2) { const randomFactor = 1 + (Math.random() * variance * 2 - variance); return Math.floor(baseTTL * randomFactor); } async function avalancheProtectedResolver(parent, args, context, info) { const cacheKey = `graphql:${info.fieldName}:${JSON.stringify(args)}`; const baseTTL = getTTL(info.fieldName); const ttl = getRandomTTL(baseTTL); const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); } const result = await fetchData(args); await redis.setex(cacheKey, ttl, JSON.stringify(result)); return result; } ``` #### 互斥锁 ```javascript async function lockedResolver(parent, args, context, info) { const cacheKey = `graphql:${info.fieldName}:${JSON.stringify(args)}`; const lockKey = `lock:${cacheKey}`; // 尝试获取缓存 const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); } // 尝试获取锁 const lock = await redis.set(lockKey, '1', 'NX', 'EX', 10); if (lock) { try { // 获取锁成功,执行查询 const result = await fetchData(args); await redis.setex(cacheKey, 300, JSON.stringify(result)); return result; } finally { // 释放锁 await redis.del(lockKey); } } else { // 获取锁失败,等待并重试 await new Promise(resolve => setTimeout(resolve, 100)); return lockedResolver(parent, args, context, info); } } ``` ### 7. CDN 缓存 #### 使用 persisted queries ```javascript import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries'; import { sha256 } from 'crypto-hash'; const persistedQueryLink = createPersistedQueryLink({ sha256, useGETForHashedQueries: true }); const client = new ApolloClient({ link: persistedQueryLink.concat(httpLink), cache: new InMemoryCache() }); ``` #### 配置 CDN ```javascript const server = new ApolloServer({ typeDefs, resolvers, cacheControl: { defaultMaxAge: 60, stripFormattedExtensions: false, calculateHttpHeaders: true }, plugins: [ require('apollo-cache-control')({ defaultMaxAge: 60 }) ] }); ``` ### 8. 缓存监控 #### 缓存命中率监控 ```javascript const cacheMetrics = { hits: 0, misses: 0, errors: 0 }; async function monitoredResolver(parent, args, context, info) { const cacheKey = `graphql:${info.fieldName}:${JSON.stringify(args)}`; try { const cached = await redis.get(cacheKey); if (cached) { cacheMetrics.hits++; return JSON.parse(cached); } cacheMetrics.misses++; const result = await fetchData(args); await redis.setex(cacheKey, 300, JSON.stringify(result)); return result; } catch (error) { cacheMetrics.errors++; throw error; } } // 定期报告缓存指标 setInterval(() => { const total = cacheMetrics.hits + cacheMetrics.misses; const hitRate = total > 0 ? cacheMetrics.hits / total : 0; console.log('Cache Metrics:', { hits: cacheMetrics.hits, misses: cacheMetrics.misses, errors: cacheMetrics.errors, hitRate: `${(hitRate * 100).toFixed(2)}%` }); }, 60000); ``` ### 9. 缓存策略总结 | 策略 | 适用场景 | 优势 | 劣势 | |------|----------|------|------| | 客户端缓存 | 重复查询相同数据 | 减少网络请求 | 占用客户端内存 | | 服务器端缓存 | 高频查询 | 减少数据库负载 | 需要维护缓存一致性 | | 基于时间失效 | 数据变化不频繁 | 实现简单 | 可能返回过期数据 | | 基于事件失效 | 数据变化频繁 | 数据实时性高 | 实现复杂 | | 缓存预热 | 热门数据 | 提升首次访问性能 | 需要识别热门数据 | | CDN 缓存 | 静态数据 | 减少服务器负载 | 不适合动态数据 | ### 10. 缓存最佳实践 - [ ] 根据数据特性选择合适的缓存策略 - [ ] 设置合理的缓存过期时间 - [ ] 实现缓存失效机制 - [ ] 监控缓存命中率 - [ ] 防止缓存穿透、雪崩、击穿 - [ ] 使用缓存预热提升性能 - [ ] 考虑使用 CDN 加速静态数据 - [ ] 定期清理无效缓存 - [ ] 实现缓存降级机制 - [ ] 记录缓存操作日志
服务端 · 2月21日 17:00
GraphQL 测试有哪些策略和最佳实践## GraphQL 测试策略与最佳实践 GraphQL API 的测试对于确保代码质量和系统稳定性至关重要。以下是 GraphQL 测试的全面策略和最佳实践。 ### 1. 测试类型 #### 单元测试 测试单个 Resolver 函数的逻辑。 ```javascript import { userResolvers } from './user.resolver'; describe('User Resolvers', () => { describe('Query.user', () => { it('should return user by id', async () => { const mockUser = { id: '1', name: 'John', email: 'john@example.com' }; User.findById = jest.fn().mockResolvedValue(mockUser); const result = await userResolvers.Query.user(null, { id: '1' }); expect(result).toEqual(mockUser); expect(User.findById).toHaveBeenCalledWith('1'); }); it('should throw error if user not found', async () => { User.findById = jest.fn().mockResolvedValue(null); await expect( userResolvers.Query.user(null, { id: '1' }) ).rejects.toThrow('User not found'); }); }); describe('Mutation.createUser', () => { it('should create a new user', async () => { const input = { name: 'John Doe', email: 'john@example.com' }; const createdUser = { id: '1', ...input }; User.create = jest.fn().mockResolvedValue(createdUser); const result = await userResolvers.Mutation.createUser( null, { input } ); expect(result).toEqual(createdUser); expect(User.create).toHaveBeenCalledWith(input); }); }); }); ``` #### 集成测试 测试多个组件协同工作。 ```javascript import { ApolloServer } from 'apollo-server'; import { createTestClient } from 'apollo-server-testing'; import { typeDefs, resolvers } from './schema'; describe('GraphQL API Integration Tests', () => { const server = new ApolloServer({ typeDefs, resolvers }); const { query, mutate } = createTestClient(server); describe('Query.users', () => { it('should return all users', async () => { const GET_USERS = ` query GetUsers { users { id name email } } `; const { data, errors } = await query(GET_USERS); expect(errors).toBeUndefined(); expect(data.users).toBeDefined(); expect(Array.isArray(data.users)).toBe(true); }); }); describe('Mutation.createUser', () => { it('should create a new user', async () => { const CREATE_USER = ` mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id name email } } `; const variables = { input: { name: 'John Doe', email: 'john@example.com' } }; const { data, errors } = await mutate(CREATE_USER, { variables }); expect(errors).toBeUndefined(); expect(data.createUser).toBeDefined(); expect(data.createUser.name).toBe('John Doe'); }); }); }); ``` #### E2E 测试 测试完整的用户流程。 ```javascript import { createHttpLink } from 'apollo-link-http'; import { ApolloClient } from 'apollo-client'; import { InMemoryCache } from 'apollo-cache-inmemory'; import fetch from 'node-fetch'; describe('E2E Tests', () => { let client; beforeAll(() => { client = new ApolloClient({ link: createHttpLink({ uri: 'http://localhost:4000/graphql', fetch }), cache: new InMemoryCache() }); }); it('should complete user registration and login flow', async () => { // 注册用户 const REGISTER = ` mutation Register($input: RegisterInput!) { register(input: $input) { id name email } } `; const registerResult = await client.mutate({ mutation: REGISTER, variables: { input: { name: 'John Doe', email: 'john@example.com', password: 'password123' } } }); expect(registerResult.data.register).toBeDefined(); // 登录用户 const LOGIN = ` mutation Login($email: String!, $password: String!) { login(email: $email, password: $password) { token user { id name } } } `; const loginResult = await client.mutate({ mutation: LOGIN, variables: { email: 'john@example.com', password: 'password123' } }); expect(loginResult.data.login.token).toBeDefined(); expect(loginResult.data.login.user.name).toBe('John Doe'); }); }); ``` ### 2. 测试工具 #### Jest ```javascript // jest.config.js module.exports = { testEnvironment: 'node', setupFilesAfterEnv: ['<rootDir>/tests/setup.js'], testMatch: ['**/tests/**/*.test.js'], collectCoverageFrom: [ 'src/**/*.js', '!src/**/*.test.js' ], coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80 } } }; ``` #### Mocha + Chai ```javascript const { expect } = require('chai'); const { ApolloServer } = require('apollo-server'); const { createTestClient } = require('apollo-server-testing'); describe('GraphQL API Tests', () => { let server; let client; beforeEach(() => { server = new ApolloServer({ typeDefs, resolvers }); client = createTestClient(server); }); it('should return user', async () => { const { data } = await client.query({ query: 'query { user(id: "1") { id name } }' }); expect(data.user).to.exist; expect(data.user.name).to.be.a('string'); }); }); ``` ### 3. Mock 数据 #### 使用 Mock 数据库 ```javascript const mockDatabase = { users: [ { id: '1', name: 'John', email: 'john@example.com' }, { id: '2', name: 'Jane', email: 'jane@example.com' } ], posts: [ { id: '1', title: 'Post 1', authorId: '1' }, { id: '2', title: 'Post 2', authorId: '2' } ] }; const mockUserModel = { findById: (id) => { return Promise.resolve( mockDatabase.users.find(user => user.id === id) ); }, findAll: () => { return Promise.resolve(mockDatabase.users); }, create: (data) => { const newUser = { id: String(mockDatabase.users.length + 1), ...data }; mockDatabase.users.push(newUser); return Promise.resolve(newUser); } }; // 在测试中使用 jest.mock('../models/User', () => mockUserModel); ``` #### 使用 Faker.js 生成测试数据 ```javascript const faker = require('faker'); function generateMockUser() { return { id: faker.random.uuid(), name: `${faker.name.firstName()} ${faker.name.lastName()}`, email: faker.internet.email(), age: faker.datatype.number({ min: 18, max: 80 }) }; } function generateMockUsers(count = 10) { return Array.from({ length: count }, generateMockUser); } describe('User Tests', () => { it('should handle multiple users', async () => { const mockUsers = generateMockUsers(5); User.findAll = jest.fn().mockResolvedValue(mockUsers); const result = await resolvers.Query.users(); expect(result).toHaveLength(5); expect(result[0].email).toMatch(/@/); }); }); ``` ### 4. 测试 Context #### 测试认证 Context ```javascript describe('Authenticated Resolvers', () => { it('should return current user', async () => { const mockUser = { id: '1', name: 'John' }; const context = { user: mockUser }; const result = await resolvers.Query.me(null, {}, context); expect(result).toEqual(mockUser); }); it('should throw error if not authenticated', async () => { const context = { user: null }; await expect( resolvers.Query.me(null, {}, context) ).rejects.toThrow('Authentication required'); }); }); ``` #### 测试数据源 Context ```javascript describe('Data Source Tests', () => { it('should use data source', async () => { const mockDataSource = { getUser: jest.fn().mockResolvedValue({ id: '1', name: 'John' }) }; const context = { dataSources: { userAPI: mockDataSource } }; await resolvers.Query.user(null, { id: '1' }, context); expect(mockDataSource.getUser).toHaveBeenCalledWith('1'); }); }); ``` ### 5. 测试错误处理 #### 测试验证错误 ```javascript describe('Validation Tests', () => { it('should validate email format', async () => { const input = { name: 'John', email: 'invalid-email' }; await expect( resolvers.Mutation.createUser(null, { input }) ).rejects.toThrow('Invalid email format'); }); it('should require required fields', async () => { const input = { name: 'John' // email is missing }; await expect( resolvers.Mutation.createUser(null, { input }) ).rejects.toThrow('Email is required'); }); }); ``` #### 测试 GraphQL 错误 ```javascript describe('Error Handling Tests', () => { it('should return GraphQL error for not found', async () => { User.findById = jest.fn().mockResolvedValue(null); const { data, errors } = await client.query({ query: 'query { user(id: "999") { id name } }' }); expect(data.user).toBeNull(); expect(errors).toBeDefined(); expect(errors[0].message).toContain('not found'); }); }); ``` ### 6. 测试订阅 #### 测试订阅 Resolver ```javascript describe('Subscription Tests', () => { it('should publish post created event', async () => { const mockPost = { id: '1', title: 'New Post' }; const mockIterator = { [Symbol.asyncIterator]: jest.fn().mockReturnValue( (async function* () { yield { postCreated: mockPost }; })() ) }; pubsub.asyncIterator = jest.fn().mockReturnValue(mockIterator); const iterator = resolvers.Subscription.postCreated.subscribe(); const result = await iterator.next(); expect(result.value.postCreated).toEqual(mockPost); }); }); ``` #### 测试订阅过滤 ```javascript describe('Subscription Filtering Tests', () => { it('should filter subscriptions by userId', async () => { const mockIterator = { [Symbol.asyncIterator]: jest.fn().mockReturnValue( (async function* () { yield { notification: { userId: '1', message: 'Hello' } }; yield { notification: { userId: '2', message: 'Hi' } }; })() ) }; pubsub.asyncIterator = jest.fn().mockReturnValue(mockIterator); const iterator = resolvers.Subscription.notification.subscribe( null, { userId: '1' } ); const results = []; for await (const event of iterator) { results.push(event); if (results.length >= 2) break; } expect(results).toHaveLength(1); expect(results[0].notification.userId).toBe('1'); }); }); ``` ### 7. 性能测试 #### 测试查询性能 ```javascript describe('Performance Tests', () => { it('should handle large datasets efficiently', async () => { const largeDataset = generateMockUsers(10000); User.findAll = jest.fn().mockResolvedValue(largeDataset); const startTime = Date.now(); const result = await resolvers.Query.users(); const duration = Date.now() - startTime; expect(result).toHaveLength(10000); expect(duration).toBeLessThan(1000); // 应该在 1 秒内完成 }); }); ``` #### 测试 N+1 查询 ```javascript describe('N+1 Query Tests', () => { it('should not have N+1 query problem', async () => { const posts = generateMockPosts(10); const users = generateMockUsers(10); Post.findAll = jest.fn().mockResolvedValue(posts); User.findById = jest.fn() .mockImplementation((id) => Promise.resolve(users.find(u => u.id === id)) ); const result = await resolvers.Query.posts(); // 应该只调用一次批量查询,而不是 10 次单独查询 expect(User.findById).not.toHaveBeenCalledTimes(10); }); }); ``` ### 8. 测试覆盖率 #### 配置覆盖率 ```javascript // package.json { "scripts": { "test": "jest", "test:coverage": "jest --coverage", "test:watch": "jest --watch" } } ``` #### 生成覆盖率报告 ```bash npm run test:coverage ``` ### 9. 测试最佳实践 | 实践 | 说明 | |------|------| | 测试所有 Resolvers | 确保每个 Resolver 都有测试 | | 使用 Mock 数据 | 隔离测试,避免依赖外部服务 | | 测试错误场景 | 验证错误处理逻辑 | | 测试边界条件 | 测试空值、极大值等 | | 保持测试独立 | 每个测试应该独立运行 | | 使用描述性名称 | 测试名称应该清楚描述测试内容 | | 测试 Context | 验证认证、授权等 Context 功能 | | 测试订阅 | 确保订阅功能正常工作 | | 性能测试 | 确保查询性能符合预期 | | 监控覆盖率 | 保持高测试覆盖率 | ### 10. 常见测试问题及解决方案 | 问题 | 原因 | 解决方案 | |------|------|----------| | 测试不稳定 | 异步操作未正确处理 | 使用 async/await、适当的等待 | | Mock 失败 | Mock 配置不正确 | 检查 Mock 的配置和调用 | | 测试慢 | 测试数据量过大 | 使用较小的测试数据集 | | 覆盖率低 | 未测试所有代码路径 | 增加测试用例,覆盖所有分支 | | 测试难以维护 | 测试代码复杂 | 重构测试代码,使用测试工具 |
服务端 · 2月21日 17:00
GraphQL 查询、变更和订阅有什么区别## GraphQL 查询(Query)、变更(Mutation)和订阅(Subscription)的区别 GraphQL 提供了三种主要的操作类型,每种类型都有特定的用途和语义。 ### 1. 查询(Query) **定义**: 用于获取数据,类似于 REST 中的 GET 请求。 **特点**: - 只读操作,不会修改服务器上的数据 - 可以并行执行多个查询 - 可以嵌套查询以获取相关数据 - 可以使用参数来过滤或定制结果 **示例**: ```graphql query GetUser($userId: ID!) { user(id: $userId) { id name email posts { id title createdAt } } } ``` **使用场景**: - 获取用户信息 - 列表查询 - 数据详情展示 - 报表生成 ### 2. 变更(Mutation) **定义**: 用于修改服务器上的数据,类似于 REST 中的 POST、PUT、DELETE 请求。 **特点**: - 会修改服务器上的数据 - 按顺序执行(串行),确保数据一致性 - 通常返回修改后的数据或操作结果 - 可以包含输入对象和参数 **示例**: ```graphql mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id name email createdAt } } mutation UpdateUser($userId: ID!, $input: UpdateUserInput!) { updateUser(id: $userId, input: $input) { id name email updatedAt } } mutation DeleteUser($userId: ID!) { deleteUser(id: $userId) { success message } } ``` **使用场景**: - 创建新资源 - 更新现有资源 - 删除资源 - 批量操作 ### 3. 订阅(Subscription) **定义**: 用于实时数据推送,当服务器端数据发生变化时,客户端会自动收到更新。 **特点**: - 建立持久连接(通常使用 WebSocket) - 服务器主动推送数据到客户端 - 适用于实时性要求高的场景 - 可以包含过滤条件 **示例**: ```graphql subscription OnUserCreated { userCreated { id name email createdAt } } subscription OnPostUpdated($postId: ID!) { postUpdated(postId: $postId) { id title content updatedAt } } ``` **使用场景**: - 聊天应用 - 实时通知 - 实时数据监控 - 协作编辑 ## 核心区别对比 | 特性 | Query | Mutation | Subscription | |------|-------|----------|--------------| | 数据修改 | 只读 | 修改数据 | 只读 | | 执行方式 | 并行 | 串行 | 持久连接 | | 实时性 | 按需请求 | 按需请求 | 实时推送 | | 网络协议 | HTTP | HTTP | WebSocket | | 缓存 | 可缓存 | 不可缓存 | 不可缓存 | | 幂等性 | 幂等 | 非幂等 | 非幂等 | ## 最佳实践 ### Query 最佳实践 - 避免过度获取,只请求需要的字段 - 使用参数进行数据过滤 - 合理使用分页 - 利用 GraphQL 的类型系统进行数据验证 ### Mutation 最佳实践 - 使用输入对象(Input Types)封装参数 - 返回修改后的完整数据 - 提供清晰的成功/失败响应 - 实现适当的错误处理和验证 ### Subscription 最佳实践 - 提供过滤条件减少不必要的数据推送 - 实现连接管理和重连机制 - 考虑使用负载均衡处理大量订阅 - 设置合理的超时和心跳机制 ## 性能考虑 - **Query**: 使用 DataLoader 解决 N+1 查询问题 - **Mutation**: 使用事务确保数据一致性 - **Subscription**: 使用消息队列处理高并发订阅
服务端 · 2月21日 17:00