Skip to content

Latest commit

 

History

History
721 lines (575 loc) · 20.2 KB

File metadata and controls

721 lines (575 loc) · 20.2 KB

Spring for GraphQL 数据分片加载实现指南

📚 目录

  1. 概述
  2. 架构设计
  3. 核心特性
  4. 快速开始
  5. 使用示例
  6. 性能优化
  7. 最佳实践

概述

本项目基于 Spring for GraphQL 实现了声明式按需加载的数据分片加载方案,通过 FragmentsDataLoader 技术,实现了高效的数据查询和传输。

为什么选择 Spring for GraphQL?

  • 官方支持:Spring 官方推荐的 GraphQL 解决方案
  • 声明式编程:通过 @SchemaMapping 声明式定义字段解析
  • Fragments 原生支持:前端通过 Fragments 精确控制数据需求
  • DataLoader 集成:自动批量加载,解决 N+1 查询问题
  • 类型安全:基于 Schema 定义,提供完整的类型检查

与传统 REST 的对比

特性 REST API GraphQL
数据获取 多个端点,可能过度获取 单一端点,按需获取
版本管理 需要维护多个版本 无需版本,持续演进
N+1 问题 容易出现,需手动优化 DataLoader 自动解决
文档维护 需手动编写 Schema 即文档
前端灵活性 受后端 API 限制 前端自由组合字段

架构设计

整体架构

┌─────────────────────────────────────────────────────────────┐
│                        前端 Vue 3                            │
│  ┌───────────────┐  ┌───────────────┐  ┌───────────────┐   │
│  │  列表页组件   │  │  详情页组件   │  │  管理后台      │   │
│  │  (基础字段)   │  │  (完整内容)   │  │  (Token审计)   │   │
│  └───────┬───────┘  └───────┬───────┘  └───────┬───────┘   │
│          │                  │                  │            │
│          └──────────────────┼──────────────────┘            │
│                             ↓                               │
│                    GraphQL Fragments                        │
│              (定义不同场景的字段组合)                         │
└──────────────────────────────┬──────────────────────────────┘
                               ↓
                        HTTP POST /graphql
                               ↓
┌─────────────────────────────────────────────────────────────┐
│                   Spring for GraphQL                        │
│  ┌─────────────────────────────────────────────────────┐   │
│  │              GraphQL Schema (schema.graphqls)       │   │
│  │  - 定义类型、查询、变更                              │   │
│  │  - 声明式字段定义                                    │   │
│  └──────────────────────┬──────────────────────────────┘   │
│                         ↓                                   │
│  ┌─────────────────────────────────────────────────────┐   │
│  │         GraphQL Controller (@SchemaMapping)         │   │
│  │  ┌──────────────┐  ┌──────────────┐  ┌──────────┐  │   │
│  │  │ @QueryMapping│  │@SchemaMapping│  │DataLoader│  │   │
│  │  │ (入口查询)    │  │ (字段解析)    │  │(批量加载)│  │   │
│  │  └──────┬───────┘  └──────┬───────┘  └─────┬────┘  │   │
│  └─────────┼──────────────────┼─────────────────┼───────┘   │
│            └──────────────────┼─────────────────┘           │
│                               ↓                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │              Service Layer (业务逻辑)                 │   │
│  └──────────────────────┬──────────────────────────────┘   │
│                         ↓                                   │
│  ┌─────────────────────────────────────────────────────┐   │
│  │         Repository Layer (数据访问)                   │   │
│  │  ┌──────────────┐  ┌──────────────┐  ┌──────────┐  │   │
│  │  │ RequirementDoc│  │    User      │  │  History │  │   │
│  │  │  Repository   │  │  Repository  │  │Repository│  │   │
│  │  └──────┬───────┘  └──────┬───────┘  └─────┬────┘  │   │
│  └─────────┼──────────────────┼─────────────────┼───────┘   │
└────────────┼──────────────────┼─────────────────┼───────────┘
             └──────────────────┼─────────────────┘
                                ↓
                        ┌───────────────┐
                        │  MySQL 数据库  │
                        └───────────────┘

数据流程

  1. 前端发起请求:根据页面需求,使用不同的 Fragment 组合查询
  2. GraphQL 解析:Spring for GraphQL 解析请求,确定需要哪些字段
  3. 按需执行:只执行对应字段的 @SchemaMapping 方法
  4. 批量加载:DataLoader 收集所有待加载 ID,批量查询数据库
  5. 数据组装:将查询结果按 GraphQL Schema 组装返回
  6. 前端接收:前端只接收请求的字段,减少流量消耗

核心特性

1. Fragments 声明式加载

定义 Fragment

# 基础字段片段
fragment RequirementDocBasicFields on RequirementDoc {
  id
  title
  version
  createdAt
}

# 带用户信息片段
fragment RequirementDocWithUser on RequirementDoc {
  id
  title
  user {
    username
    avatar
  }
}

使用 Fragment

# 列表页:只加载基础字段
query GetDocList {
  requirementDocs(page: 0, size: 10) {
    content {
      ...RequirementDocBasicFields
    }
  }
}

# 详情页:加载完整内容
query GetDocDetail($id: Long!) {
  requirementDoc(id: $id) {
    ...RequirementDocWithUser
    content
    statistics {
      wordCount
      editCount
    }
  }
}

2. @SchemaMapping 字段解析

后端实现

@Controller
public class RequirementDocGraphQLController {
    
    // 入口查询
    @QueryMapping
    public List<RequirementDoc> requirementDocs(@Argument Integer page, @Argument Integer size) {
        return repository.findAll(PageRequest.of(page, size)).getContent();
    }
    
    // 字段解析:只有前端请求 user 字段时才执行
    @SchemaMapping(typeName = "RequirementDoc", field = "user")
    public CompletableFuture<User> user(RequirementDoc doc) {
        // DataLoader 会自动批量加载
        return CompletableFuture.supplyAsync(() -> 
            userRepository.findById(doc.getUserId()).orElse(null)
        );
    }
    
    // 字段解析:只有前端请求 statistics 字段时才执行
    @SchemaMapping(typeName = "RequirementDoc", field = "statistics")
    public RequirementDocStatistics statistics(RequirementDoc doc) {
        // 按需计算统计信息
        return computeStatistics(doc);
    }
}

3. DataLoader 批量加载

传统方式(N+1 问题)

查询 10 个文档:
SELECT * FROM requirement_docs LIMIT 10;        -- 1 次
SELECT * FROM users WHERE id = 1;               -- 10 次
SELECT * FROM users WHERE id = 2;
...
SELECT * FROM users WHERE id = 10;
总计:11 次查询

DataLoader 批量加载

查询 10 个文档:
SELECT * FROM requirement_docs LIMIT 10;        -- 1 次
SELECT * FROM users WHERE id IN (1,2,...,10);   -- 1 次(批量)
总计:2 次查询

配置 DataLoader

@Configuration
public class DataLoaderConfig {
    
    @Bean
    public BatchLoaderRegistry batchLoaderRegistry(UserRepository userRepository) {
        BatchLoaderRegistry registry = new BatchLoaderRegistry();
        
        // 用户批量加载器
        registry.forTypePair(Long.class, User.class)
                .registerMappedBatchLoader((userIds, batchEnvironment) -> {
                    // 批量查询所有用户
                    List<User> users = userRepository.findAllById(userIds);
                    
                    // 转换为 Map 供 GraphQL 分发
                    Map<Long, User> userMap = users.stream()
                            .collect(Collectors.toMap(User::getId, user -> user));
                    
                    return Mono.just(userMap);
                });
        
        return registry;
    }
}

快速开始

后端配置

1. 添加依赖 (pom.xml)

<!-- Spring for GraphQL -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-graphql</artifactId>
</dependency>

<!-- GraphQL Extended Scalars -->
<dependency>
    <groupId>com.graphql-java</groupId>
    <artifactId>graphql-java-extended-scalars</artifactId>
    <version>21.0</version>
</dependency>

2. 配置 GraphQL (application.yml)

spring:
  graphql:
    path: /graphql
    graphiql:
      enabled: true
      path: /graphiql
    schema:
      locations: classpath:graphql/**/*.graphqls
    cors:
      allowed-origins: 
        - http://localhost:3000

3. 创建 Schema (schema.graphqls)

type Query {
    requirementDocs(page: Int, size: Int): [RequirementDoc!]!
}

type RequirementDoc {
    id: Long!
    title: String!
    content: String
    user: User
    statistics: RequirementDocStatistics
}

4. 实现控制器

@Controller
public class RequirementDocGraphQLController {
    
    @QueryMapping
    public List<RequirementDoc> requirementDocs(@Argument Integer page, @Argument Integer size) {
        return repository.findAll(PageRequest.of(page, size)).getContent();
    }
    
    @SchemaMapping(typeName = "RequirementDoc", field = "user")
    public User user(RequirementDoc doc) {
        return userRepository.findById(doc.getUserId()).orElse(null);
    }
}

前端配置

1. 安装依赖

npm install axios

2. 创建 GraphQL 客户端

// utils/graphqlClient.js
import axios from 'axios';

class GraphQLClient {
  async execute({ query, variables }) {
    const response = await axios.post('/graphql', {
      query,
      variables
    });
    return response.data;
  }
}

export default new GraphQLClient();

3. 定义 Fragments

// graphql/fragments.js
export const BASIC_FIELDS = `
  fragment BasicFields on RequirementDoc {
    id
    title
    createdAt
  }
`;

4. 在组件中使用

<script setup>
import graphqlClient from '@/utils/graphqlClient';
import { BASIC_FIELDS } from '@/graphql/fragments';

const loadDocs = async () => {
  const query = `
    ${BASIC_FIELDS}
    query GetDocs {
      requirementDocs {
        ...BasicFields
      }
    }
  `;
  
  const result = await graphqlClient.execute({ query });
  return result.data.requirementDocs;
};
</script>

使用示例

场景 1:列表页(最小字段)

需求:展示文档列表,只需要标题、版本、创建时间

Fragment 定义

fragment RequirementDocBasicFields on RequirementDoc {
  id
  title
  version
  createdAt
}

查询

query GetRequirementDocsList($page: Int, $size: Int) {
  requirementDocs(page: $page, size: $size) {
    content {
      ...RequirementDocBasicFields
    }
    pageInfo {
      totalElements
      hasNext
    }
  }
}

优势

  • 每条记录约 200 bytes
  • 100 条记录仅需传输 ~20KB
  • 不触发 content、user、statistics 等字段的加载

场景 2:列表页(带作者信息)

需求:展示文档列表 + 作者头像和名称

Fragment 定义

fragment RequirementDocWithUser on RequirementDoc {
  id
  title
  version
  user {
    username
    avatar
  }
}

查询

query GetRequirementDocsWithUser($page: Int, $size: Int) {
  requirementDocs(page: $page, size: $size) {
    content {
      ...RequirementDocWithUser
    }
  }
}

后端处理

@SchemaMapping(typeName = "RequirementDoc", field = "user")
public CompletableFuture<User> user(RequirementDoc doc) {
    // DataLoader 会收集所有 userId,一次性批量查询
    return CompletableFuture.supplyAsync(() -> 
        userRepository.findById(doc.getUserId()).orElse(null)
    );
}

优势

  • DataLoader 自动批量加载用户
  • 10 个文档 = 1 次文档查询 + 1 次批量用户查询(共 2 次 SQL)
  • 避免了传统的 N+1 查询问题

场景 3:详情页(完整内容 + 统计)

需求:展示完整文档内容、作者信息、统计数据

Fragment 定义

fragment RequirementDocFullDetail on RequirementDoc {
  id
  title
  content
  version
  createdAt
  updatedAt
  user {
    username
    email
    avatar
  }
  statistics {
    wordCount
    editCount
    lastEditedAt
    published
  }
}

查询

query GetRequirementDocDetail($id: Long!) {
  requirementDoc(id: $id) {
    ...RequirementDocFullDetail
  }
}

后端处理

@SchemaMapping(typeName = "RequirementDoc", field = "statistics")
public RequirementDocStatistics statistics(RequirementDoc doc) {
    // 只在前端请求 statistics 时才计算
    int wordCount = doc.getContent() != null ? doc.getContent().length() : 0;
    int editCount = historyRepository.countByDocId(doc.getId());
    
    return RequirementDocStatistics.builder()
            .wordCount(wordCount)
            .editCount(editCount)
            .lastEditedAt(doc.getUpdatedAt())
            .published(false)
            .build();
}

优势

  • 统计信息按需计算,列表页不会浪费计算资源
  • 前端可灵活组合需要的字段
  • 减少不必要的数据传输

性能优化

1. 流量节省对比

场景:查询 100 个文档

方案 数据量 说明
REST(完整对象) ~500KB 包含所有字段,无论是否需要
GraphQL(基础字段) ~20KB 只传输 id、title、version
GraphQL(带用户) ~35KB 额外加载 user.username、avatar
GraphQL(完整内容) ~500KB 详情页加载完整内容

流量节省:列表页使用基础字段,节省 96% 流量

2. SQL 查询优化

N+1 问题示例

传统方式(查询 10 个文档 + 作者):
SELECT * FROM requirement_docs LIMIT 10;      -- 1 次
SELECT * FROM users WHERE id = 1;             -- 10 次
...
总计:11 次查询,耗时 ~110ms

DataLoader 优化

DataLoader 方式:
SELECT * FROM requirement_docs LIMIT 10;      -- 1 次
SELECT * FROM users WHERE id IN (1,2,...,10); -- 1 次
总计:2 次查询,耗时 ~20ms

性能提升:查询次数减少 82%,响应时间减少 82%

3. 按需计算优化

统计信息计算

@SchemaMapping(typeName = "RequirementDoc", field = "statistics")
public RequirementDocStatistics statistics(RequirementDoc doc) {
    // 只有前端请求时才执行
    // 列表页不请求 statistics,节省计算资源
    return computeStatistics(doc);
}

优势

  • 列表页:不计算统计信息,节省 CPU
  • 详情页:按需计算,提供完整数据

最佳实践

1. Fragment 命名规范

# 按用途命名
fragment RequirementDocBasicFields on RequirementDoc { ... }
fragment RequirementDocWithUser on RequirementDoc { ... }
fragment RequirementDocFullContent on RequirementDoc { ... }

# 按页面命名
fragment RequirementDocListItem on RequirementDoc { ... }
fragment RequirementDocDetailView on RequirementDoc { ... }
fragment RequirementDocAdminView on RequirementDoc { ... }

2. 避免过度嵌套

不推荐

query {
  requirementDoc(id: 1) {
    user {
      requirementDocs {
        user {
          requirementDocs {
            ...  # 循环嵌套
          }
        }
      }
    }
  }
}

推荐

# 限制嵌套深度
query {
  requirementDoc(id: 1) {
    user {
      username
      avatar
    }
  }
}

3. 合理使用 DataLoader

适用场景

  • 一对多关系(如文档 → 作者)
  • 需要批量加载的场景
  • 高频查询的关联数据

不适用场景

  • 一次性查询的单个对象
  • 复杂的聚合计算
  • 实时性要求极高的数据

4. 错误处理

@SchemaMapping(typeName = "RequirementDoc", field = "user")
public User user(RequirementDoc doc) {
    try {
        return userRepository.findById(doc.getUserId())
                .orElseThrow(() -> new RuntimeException("User not found"));
    } catch (Exception e) {
        log.error("Failed to load user for doc {}", doc.getId(), e);
        return null;  // 返回 null,不影响其他字段
    }
}

5. 安全性考虑

权限控制

@SchemaMapping(typeName = "RequirementDoc", field = "tokenUsage")
public TokenUsageInfo tokenUsage(RequirementDoc doc, @AuthenticationPrincipal UserPrincipal principal) {
    // 只有管理员才能查看 Token 使用情况
    if (!principal.hasRole("ADMIN")) {
        throw new AccessDeniedException("Access denied");
    }
    return computeTokenUsage(doc);
}

查询复杂度限制

spring:
  graphql:
    schema:
      introspection:
        enabled: false  # 生产环境禁用 Schema 探查

总结

核心优势

  1. 按需加载:前端通过 Fragments 精确控制数据需求
  2. 避免 N+1:DataLoader 自动批量加载,性能优异
  3. 声明式编程@SchemaMapping 清晰表达字段解析逻辑
  4. 灵活组合:同一个 API 满足多种场景
  5. 类型安全:Schema 定义提供完整类型检查

适用场景

  • ✅ 前端页面数据需求差异化大
  • ✅ 存在大量关联数据查询
  • ✅ 需要灵活组合字段
  • ✅ 移动端需要减少流量消耗
  • ✅ 多客户端(Web/Mobile/Desktop)共用 API

下一步

  1. 访问 GraphiQL 界面:http://localhost:5000/graphiql
  2. 测试查询示例:参考 vue-app/src/graphql/requirementDoc.fragments.js
  3. 查看演示页面:访问 /graphql-demo 路由
  4. 监控性能:使用 Spring Boot Actuator 查看 GraphQL 指标

📖 更多文档