กลับไปที่บทความ
Architecture Backend GraphQL Microservices

GraphQL Federation เทคโนโลยีการสื่อสารระหว่างเซิร์ฟเวอร์

พลากร วรมงคล
19 เมษายน 2568 13 นาที

“คู่มือที่ครอบคลุมเกี่ยวกับ GraphQL Federation สำหรับการสื่อสารแบบ server-to-server — ครอบคลุมการจัดองค์ประกอบสคีมา การออกแบบ subgraph การแก้ไข entity gateway router การเพิ่มประสิทธิภาพประสิทธิการทำงาน และการปรับใช้สำหรับการใช้งานจริง”

สำรวจเชิงลึก: GraphQL Federation

GraphQL Federation เป็นรูปแบบที่มีประสิทธิภาพสำหรับการสร้างระบบ GraphQL ที่กระจายออกไป โดยที่หลายบริการที่เป็นอิสระสามารถมีส่วนร่วมในประกาศ GraphQL API เดี่ยวที่รวมเข้าด้วยกัน ซึ่งแตกต่างจากเซิร์ฟเวอร์ GraphQL แบบ monolithic แบบดั้งเดิม federation ช่วยให้ทีมต่างๆ เป็นเจ้าของและพัฒนาสคีมา GraphQL ของตนเองไปในขณะที่เชื่อมต่อพวกเขาอย่างราบรื่นผ่าน gateway router ที่ประสานการสอบถามข้อมูลในทุกบริการ

GraphQL Federation คืออะไร

GraphQL Federation แก้ไขความท้าทายที่สำคัญในสถาปัตยกรรม microservices: คุณจะจัดหา interface GraphQL แบบเดียวอย่างไรเมื่อตรรมชาติของธุรกิจของคุณแบ่งออกไปในหลายบริการ? Federation ช่วยให้แต่ละบริการสามารถกำหนดและเป็นเจ้าของส่วนของกราฟของตนเองไปในขณะที่ gateway ประกอบและเส้นทางการสอบถามข้อมูลไปยังบริการที่เหมาะสม แทนที่จะบังคับให้ทีมทั้งหมดทำงานบนสคีมาขนาดใหญ่เดียว

แนวคิด Supergraph

supergraph คือสคีมา GraphQL ที่สมบูรณ์และรวมเข้าด้วยกันซึ่งไคลเอนต์ใช้งาน เป็นส่วนประกอบของ subgraphs จำนวนมาย โดยที่แต่ละ subgraph เป็นบริการ GraphQL ที่เป็นอิสระซึ่งเป็นเจ้าของประเภทและฟิลด์ที่เฉพาะเจาะจง gateway router ดำเนินการประสานงานการสื่อสารระหว่าง subgraphs และจัดการกับตรรมชาติที่ซับซ้อนของการวางแผนและการดำเนินการสอบถามข้อมูล

เมื่อไคลเอนต์ส่งการสอบถามข้อมูลไปยัง gateway gateway ก็จะ:

  1. วิเคราะห์และตรวจสอบการสอบถามข้อมูลเทียบกับสคีมา supergraph
  2. สร้างแผนการดำเนินการที่กำหนดว่าต้องสอบถาม subgraphs ใดบ้าง
  3. แก้ไขข้อมูลอ้างอิง entity ข้ามพรมแดนของบริการโดยใช้ __resolveReference
  4. รวมผลลัพธ์จากหลายบริการในการตอบสนองเดี่ยว

Query Execution Flow

นี่คือวิธีการไหลของการสอบถามข้อมูลที่รวมเข้าด้วยกันผ่านระบบของคุณ:

sequenceDiagram
    participant Client
    participant Gateway as Apollo Gateway
    participant SubgraphA as Subgraph A<br/>(Users Service)
    participant SubgraphB as Subgraph B<br/>(Orders Service)

    Client->>Gateway: POST /graphql<br/>query { user(id: "1") { id name orders { id total } } }
    Gateway->>Gateway: Parse & validate query<br/>Create execution plan
    Gateway->>SubgraphA: query { user(id: "1") { id name __typename } }
    SubgraphA-->>Gateway: { user: { id: "1" name: "Alice" __typename: "User" } }
    Gateway->>SubgraphB: query { orders(userIds: ["1"]) { id total } }
    SubgraphB-->>Gateway: { orders: [{ id: "o1" total: 99.99 }] }
    Gateway->>Gateway: Merge results
    Gateway-->>Client: { user: { id: "1" name: "Alice" orders: [...] } }

หลักการหลัก

  • Owned schemas: แต่ละทีมเป็นเจ้าของสคีมา subgraph ของตนและสามารถปรับใช้อย่างอิสระ
  • Decentralized: ไม่มีทีมศูนย์กลางควบคุมกราฟทั้งหมด บริการจัดการประเภทของตัวเอง
  • Type expansion: ประเภทสามารถขยายได้ข้ามบริการโดยใช้ directive @key
  • Entity resolution: บริการแก้ไข entities ที่พวกเขาเป็นเจ้าของผ่านทาง __resolveReference
  • Transparent composition: ไคลเอนต์เห็น API เดี่ยวที่ราบรื่น
  • Query planning: gateway ปรับปรุงการดำเนินการสอบถามข้อมูลข้ามบริการ
  • Scalability: บริการสเกลเอบอย่างอิสระโดยไม่ส่งผลกระทบต่อตรรมชาติหลักของ gateway

Federation vs Schema Stitching

ก่อน federation นักพัฒนาใช้ schema stitching — รูปแบบที่ gateway จะรวมสคีมาจากหลายบริการและแก้ไขความสัมพันธ์ entity ด้วยตนเอง แม้ว่า stitching จะทำงาน แต่ก็มีข้อจำกัดที่มีนัยสำคัญ:

AspectSchema StitchingFederation
Type Extensionตั้งค่า resolver ด้วยตนเองDeclarative @key directives
Entity Resolutionตรรมชาติของ resolver ที่กำหนดเองBuilt-in __resolveReference
Type Safetyจำกัด การแมปด้วยตนเองStrong SDL-driven composition
Query PlanningBasic ปัญหา N+1 ที่อาจเกิดขึ้นIntelligent batches requests
Composition Checksด้วยตนเองหรือเครื่องมือที่กำหนดเองApollo Composition Checks (CI/CD)
Development ExperienceVerbose error-proneDeclarative type-safe
Performance MonitoringInsight จำกัดApollo managed federation support

ทำไม Federation ชนะ: Federation ได้รับการออกแบบมาโดยเฉพาะสำหรับระบบ GraphQL ที่กระจายออกไป มันขจัด boilerplate ของ stitching ขณะที่จัดหา mental model ที่ชัดเจน — แต่ละประเภทมีเจ้าของ และความสัมพันธ์ได้รับการประกาศอย่างชัดแจ้ง นี่ทำให้ federation เหมาะสำหรับองค์กรที่มีทีมและบริการหลายแห่ง

การกำหนด Subgraph Schemas

Subgraph schemas ใช้ Federation directives เพื่อประกาศความเป็นเจ้าของ ฟิลด์ภายนอก และการพึ่งพา ให้เราสำรวจ directives ที่สำคัญ:

Key Federation Directives

@key — ทำเครื่องหมาย field (หรือ fields) ที่ระบุตัวตนของ entity โดยเฉพาะในภายใน subgraph อย่างไม่ซ้ำกัน

type User @key(fields: "id") {
  id: ID!
  email: String!
  name: String!
}

@external — ประกาศ field ที่มีอยู่ใน subgraph อื่น ใช้เมื่อขยายประเภท

type Order @key(fields: "id") {
  id: ID!
  userId: String! @external
  total: Float!
}

@requires — ระบุว่า field ต้องการ fields อื่นๆ จากบริการเดียวกันที่จะดึงข้อมูลก่อน

type User @key(fields: "id") {
  id: ID!
  email: String!
  displayName: String! @requires(fields: "email")
}

@provides — ประกาศว่า resolver นี้จัดหา field จาก subgraph อื่น ซึ่งลดการเรียก federation

type Query {
  user(id: ID!): User
}

extend type Order {
  user: User @provides(fields: "email")
}

ตัวอย่าง: Users Subgraph Schema

# users-subgraph/schema.graphql

type Query {
  user(id: ID!): User
  users(ids: [ID!]!): [User!]!
}

type User @key(fields: "id") {
  id: ID!
  email: String!
  username: String!
  createdAt: DateTime!
  profile: UserProfile!
}

type UserProfile {
  avatar: String
  bio: String
  socialLinks: [SocialLink!]!
}

type SocialLink {
  platform: String!
  url: String!
}

scalar DateTime

ตัวอย่าง: Orders Subgraph Schema

# orders-subgraph/schema.graphql

type Query {
  order(id: ID!): Order
  orders(userId: ID!): [Order!]!
}

type Order @key(fields: "id") {
  id: ID!
  userId: String!
  items: [OrderItem!]!
  total: Float!
  status: OrderStatus!
  createdAt: DateTime!
  user: User!
}

extend type User @key(fields: "id") {
  id: ID! @external
  orders: [Order!]!
}

type OrderItem {
  productId: String!
  quantity: Int!
  price: Float!
}

enum OrderStatus {
  PENDING
  CONFIRMED
  SHIPPED
  DELIVERED
  CANCELLED
}

scalar DateTime

การนำ Subgraph ไปใช้งานกับ Apollo Server

ให้เราใช้งาน Users subgraph ในคุณภาพการผลิตใน TypeScript:

// users-subgraph/src/index.ts

import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { buildSubgraphSchema } from "@apollo/subgraph";
import gql from "graphql-tag";

// ============================================================================
// Type definitions
// ============================================================================

const typeDefs = gql`
  type Query {
    user(id: ID!): User
    users(ids: [ID!]!): [User!]!
  }

  type User @key(fields: "id") {
    id: ID!
    email: String!
    username: String!
    createdAt: DateTime!
    profile: UserProfile!
  }

  type UserProfile {
    avatar: String
    bio: String
    socialLinks: [SocialLink!]!
  }

  type SocialLink {
    platform: String!
    url: String!
  }

  scalar DateTime
`;

// ============================================================================
// Data models and database layer
// ============================================================================

interface UserProfile {
  avatar?: string;
  bio?: string;
  socialLinks: Array<{ platform: string; url: string }>;
}

interface User {
  id: string;
  email: string;
  username: string;
  createdAt: Date;
  profile: UserProfile;
}

// Mock database - replace with real DB in production
const userDatabase: Map<string, User> = new Map([
  [
    "user-1",
    {
      id: "user-1",
      email: "alice@example.com",
      username: "alice",
      createdAt: new Date("2024-01-15"),
      profile: {
        avatar: "https://example.com/avatars/alice.jpg",
        bio: "Software engineer interested in GraphQL",
        socialLinks: [
          { platform: "twitter", url: "https://twitter.com/alice" },
          { platform: "github", url: "https://github.com/alice" },
        ],
      },
    },
  ],
  [
    "user-2",
    {
      id: "user-2",
      email: "bob@example.com",
      username: "bob",
      createdAt: new Date("2024-02-20"),
      profile: {
        avatar: "https://example.com/avatars/bob.jpg",
        bio: "Product manager focused on developer tools",
        socialLinks: [{ platform: "linkedin", url: "https://linkedin.com/in/bob" }],
      },
    },
  ],
]);

// ============================================================================
// Resolvers
// ============================================================================

const resolvers = {
  Query: {
    user: async (_: unknown, { id }: { id: string }) => {
      const user = userDatabase.get(id);
      if (!user) {
        throw new Error(`User not found: ${id}`);
      }
      return user;
    },

    users: async (_: unknown, { ids }: { ids: string[] }) => {
      return ids
        .map((id) => userDatabase.get(id))
        .filter((user): user is User => user !== undefined);
    },
  },

  User: {
    __resolveReference: async (reference: { id: string }) => {
      const user = userDatabase.get(reference.id);
      if (!user) {
        throw new Error(`User not found: ${reference.id}`);
      }
      return user;
    },
  },

  DateTime: {
    __serialize: (value: Date) => value.toISOString(),
    __parseValue: (value: string) => new Date(value),
    __parseLiteral: (ast: any) => new Date(ast.value),
  },
};

// ============================================================================
// Server setup
// ============================================================================

async function startServer() {
  const schema = buildSubgraphSchema([{ typeDefs, resolvers }]);

  const server = new ApolloServer({
    schema,
    plugins: {
      didResolveOperation: async (context) => {
        console.log(`[Users] Executing query: ${context.operationName}`);
      },
      didEncounterErrors: async (context) => {
        context.errors.forEach((error) => {
          console.error(`[Users] GraphQL Error:`, error.message);
        });
      },
    },
  });

  const { url } = await startStandaloneServer(server, {
    listen: { port: 4001 },
  });

  console.log(`🚀 Users subgraph ready at ${url}`);
}

startServer().catch((error) => {
  console.error("Failed to start server:", error);
  process.exit(1);
});
// users-subgraph with Spring Boot + DGS Framework (Netflix DGS)
// pom.xml: com.netflix.graphql.dgs:graphql-dgs-spring-boot-starter

@DgsComponent
public class UserDataFetcher {

    // Mock database — replace with real DB
    private static final Map<String, User> USER_DB = Map.of(
        "user-1", new User("user-1", "alice@example.com", "alice",
            LocalDateTime.of(2024, 1, 15, 0, 0),
            new UserProfile("https://example.com/avatars/alice.jpg",
                "Software engineer interested in GraphQL",
                List.of(new SocialLink("twitter", "https://twitter.com/alice"),
                        new SocialLink("github", "https://github.com/alice")))),
        "user-2", new User("user-2", "bob@example.com", "bob",
            LocalDateTime.of(2024, 2, 20, 0, 0),
            new UserProfile("https://example.com/avatars/bob.jpg",
                "Product manager focused on developer tools",
                List.of(new SocialLink("linkedin", "https://linkedin.com/in/bob"))))
    );

    @DgsQuery
    public User user(@InputArgument String id) {
        var user = USER_DB.get(id);
        if (user == null) throw new DgsEntityNotFoundException("User not found: " + id);
        return user;
    }

    @DgsQuery
    public List<User> users(@InputArgument List<String> ids) {
        return ids.stream()
            .map(USER_DB::get)
            .filter(Objects::nonNull)
            .collect(Collectors.toList());
    }

    // Federation entity resolution
    @DgsEntityFetcher(name = "User")
    public User resolveReference(Map<String, Object> reference) {
        String id = (String) reference.get("id");
        var user = USER_DB.get(id);
        if (user == null) throw new DgsEntityNotFoundException("User not found: " + id);
        return user;
    }
}

// Data models
public record User(String id, String email, String username,
    LocalDateTime createdAt, UserProfile profile) {}
public record UserProfile(String avatar, String bio, List<SocialLink> socialLinks) {}
public record SocialLink(String platform, String url) {}

// Spring Boot main
@SpringBootApplication
public class UsersSubgraphApplication {
    public static void main(String[] args) {
        SpringApplication.run(UsersSubgraphApplication.class, args);
    }
}
# users-subgraph with FastAPI + Strawberry GraphQL
# pip install strawberry-graphql[fastapi] uvicorn

import strawberry
from strawberry.fastapi import GraphQLRouter
from strawberry.federation import Schema
from fastapi import FastAPI
from datetime import datetime
from typing import Optional

@strawberry.federation.type(keys=["id"])
class User:
    id: str
    email: str
    username: str
    created_at: datetime
    profile: "UserProfile"

    @classmethod
    def resolve_reference(cls, id: str) -> "User":
        user = USER_DB.get(id)
        if not user:
            raise ValueError(f"User not found: {id}")
        return user

@strawberry.type
class UserProfile:
    avatar: Optional[str]
    bio: Optional[str]
    social_links: list["SocialLink"]

@strawberry.type
class SocialLink:
    platform: str
    url: str

# Mock database
USER_DB: dict[str, User] = {
    "user-1": User(
        id="user-1", email="alice@example.com", username="alice",
        created_at=datetime(2024, 1, 15),
        profile=UserProfile(
            avatar="https://example.com/avatars/alice.jpg",
            bio="Software engineer interested in GraphQL",
            social_links=[
                SocialLink(platform="twitter", url="https://twitter.com/alice"),
                SocialLink(platform="github", url="https://github.com/alice"),
            ],
        ),
    ),
    "user-2": User(
        id="user-2", email="bob@example.com", username="bob",
        created_at=datetime(2024, 2, 20),
        profile=UserProfile(
            avatar="https://example.com/avatars/bob.jpg",
            bio="Product manager focused on developer tools",
            social_links=[SocialLink(platform="linkedin", url="https://linkedin.com/in/bob")],
        ),
    ),
}

@strawberry.type
class Query:
    @strawberry.field
    def user(self, id: str) -> User:
        user = USER_DB.get(id)
        if not user:
            raise ValueError(f"User not found: {id}")
        return user

    @strawberry.field
    def users(self, ids: list[str]) -> list[User]:
        return [u for uid in ids if (u := USER_DB.get(uid))]

schema = Schema(query=Query, enable_federation_2=True)
app = FastAPI()
app.include_router(GraphQLRouter(schema), prefix="/graphql")
// users-subgraph with ASP.NET Core + Hot Chocolate GraphQL
// NuGet: HotChocolate.AspNetCore, HotChocolate.ApolloFederation

[ExtendObjectType(OperationTypeNames.Query)]
public class UserQueries
{
    private static readonly Dictionary<string, User> UserDb = new()
    {
        ["user-1"] = new User("user-1", "alice@example.com", "alice",
            new DateTime(2024, 1, 15),
            new UserProfile("https://example.com/avatars/alice.jpg",
                "Software engineer interested in GraphQL",
                [new SocialLink("twitter", "https://twitter.com/alice"),
                 new SocialLink("github", "https://github.com/alice")])),
        ["user-2"] = new User("user-2", "bob@example.com", "bob",
            new DateTime(2024, 2, 20),
            new UserProfile("https://example.com/avatars/bob.jpg",
                "Product manager focused on developer tools",
                [new SocialLink("linkedin", "https://linkedin.com/in/bob")])),
    };

    public User? GetUser(string id) =>
        UserDb.TryGetValue(id, out var user) ? user : throw new Exception($"User not found: {id}");

    public IEnumerable<User> GetUsers(string[] ids) =>
        ids.Select(id => UserDb.GetValueOrDefault(id)).OfType<User>();
}

[Node]
[Key("id")]
public record User(string Id, string Email, string Username,
    DateTime CreatedAt, UserProfile Profile)
{
    // Federation entity resolver
    [NodeResolver]
    public static User? ResolveReference(string id) =>
        UserQueries.UserDb.GetValueOrDefault(id);
}

public record UserProfile(string? Avatar, string? Bio, List<SocialLink> SocialLinks);
public record SocialLink(string Platform, string Url);

// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services
    .AddGraphQLServer()
    .AddQueryType()
    .AddTypeExtension<UserQueries>()
    .AddType<User>()
    .AddApolloFederationV2();

var app = builder.Build();
app.MapGraphQL();
app.Run();

Entity Resolution และ __resolveReference

Entity resolution คือวิธีที่ federation ทราบวิธีการดึงข้อมูลจาก entity เฉพาะจากบริการของตนเอง ฟังก์ชัน __resolveReference เรียกว่าเมื่อ gateway ต้องการแก้ไข entity reference (โดยปกติ reference ไปยังประเภทจาก subgraph อื่น)

วิธีการทำงาน __resolveReference

เมื่อ gateway พบประเภทที่ต้องการการแก้ไข จะเรียก subgraph ของเจ้าของพร้อมกับ reference object ที่มี @key fields:

// The gateway sends this to the Users subgraph
{
  __typename: "User"
  id: "user-1"
}

// The subgraph's __resolveReference receives this object
User: {
  __resolveReference: async (reference: { id: string }) => {
    // Fetch and return the full User object
    return userDatabase.get(reference.id);
  }
}
// DGS: entity fetcher is the __resolveReference equivalent
@DgsEntityFetcher(name = "User")
public User resolveReference(Map<String, Object> reference) {
    // The gateway sends: { "__typename": "User", "id": "user-1" }
    String id = (String) reference.get("id");
    // Fetch and return the full User object
    return userDatabase.get(id);
}
# Strawberry: resolve_reference classmethod is the __resolveReference equivalent
@strawberry.federation.type(keys=["id"])
class User:
    id: str

    @classmethod
    def resolve_reference(cls, id: str) -> "User":
        # The gateway sends: { "__typename": "User", "id": "user-1" }
        # Fetch and return the full User object
        return user_database.get(id)
// Hot Chocolate: [NodeResolver] is the __resolveReference equivalent
[Node]
[Key("id")]
public record User(string Id, string Email, string Username)
{
    // The gateway sends: { "__typename": "User", "id": "user-1" }
    [NodeResolver]
    public static User? ResolveReference(string id) =>
        // Fetch and return the full User object
        UserDatabase.TryGetValue(id, out var user) ? user : null;
}

Entity Resolution ขั้นสูงกับ DataLoader

สำหรับระบบการผลิต ใช้ DataLoader เพื่อ batch entity resolution calls และป้องกัน N+1 ปัญหา:

// users-subgraph/src/dataloader.ts

import DataLoader from "dataloader";

export type DataLoaders = {
  userLoader: DataLoader<string, User | null>;
};

export function createDataLoaders(): DataLoaders {
  return {
    userLoader: new DataLoader<string, User | null>(async (userIds) => {
      // Batch fetch users from database
      const users = await db.users.findByIds(userIds);
      
      // Return in same order as requested
      return userIds.map((id) => users.find((u) => u.id === id) || null);
    }),
  };
}
// Spring Boot DataLoader with DGS
import com.netflix.graphql.dgs.DgsDataLoader;
import org.dataloader.BatchLoader;
import java.util.concurrent.CompletableFuture;

@DgsDataLoader(name = "userLoader")
public class UserDataLoader implements BatchLoader<String, User> {
    private final UserRepository userRepository;

    @Override
    public CompletableFuture<List<User>> load(List<String> userIds) {
        return CompletableFuture.supplyAsync(() -> {
            // Batch fetch users from database
            Map<String, User> usersById = userRepository.findAllById(userIds)
                .stream()
                .collect(Collectors.toMap(User::getId, u -> u));
            // Return in same order as requested
            return userIds.stream()
                .map(id -> usersById.getOrDefault(id, null))
                .collect(Collectors.toList());
        });
    }
}
# Python DataLoader with strawberry-graphql
from strawberry.dataloader import DataLoader

async def load_users(user_ids: list[str]) -> list[User | None]:
    # Batch fetch users from database
    users = await db.users.find_by_ids(user_ids)
    users_by_id = {u.id: u for u in users}
    # Return in same order as requested
    return [users_by_id.get(uid) for uid in user_ids]

def create_dataloaders():
    return {"user_loader": DataLoader(load_fn=load_users)}
// Hot Chocolate DataLoader
public class UserByIdDataLoader : BatchDataLoader<string, User>
{
    private readonly IUserRepository _repository;

    public UserByIdDataLoader(IBatchScheduler batchScheduler, IUserRepository repository)
        : base(batchScheduler)
    {
        _repository = repository;
    }

    protected override async Task<IReadOnlyDictionary<string, User>> LoadBatchAsync(
        IReadOnlyList<string> keys, CancellationToken ct)
    {
        // Batch fetch users from database
        var users = await _repository.FindByIdsAsync(keys, ct);
        return users.ToDictionary(u => u.Id);
    }
}

แล้วใช้มันใน __resolveReference ของคุณ:

User: {
  __resolveReference: async (
    reference: { id: string },
    _,
    context: { dataloaders: DataLoaders }
  ) => {
    const user = await context.dataloaders.userLoader.load(reference.id);
    if (!user) throw new Error(`User not found: ${reference.id}`);
    return user;
  }
}
@DgsEntityFetcher(name = "User")
public CompletableFuture<User> resolveReference(
        Map<String, Object> reference,
        DataFetchingEnvironment env) {
    String id = (String) reference.get("id");
    DataLoader<String, User> loader = env.getDataLoader("userLoader");
    return loader.load(id)
        .thenApply(user -> {
            if (user == null) throw new DgsEntityNotFoundException("User not found: " + id);
            return user;
        });
}
@strawberry.federation.type(keys=["id"])
class User:
    id: str

    @classmethod
    async def resolve_reference(cls, info: strawberry.types.Info, id: str) -> "User":
        user = await info.context["user_loader"].load(id)
        if not user:
            raise ValueError(f"User not found: {id}")
        return user
[ReferenceResolver]
public static async Task<User> ResolveReferenceAsync(
    string id,
    UserByIdDataLoader dataLoader,
    CancellationToken ct)
{
    var user = await dataLoader.LoadAsync(id, ct);
    if (user is null) throw new Exception($"User not found: {id}");
    return user;
}

ตั้งค่า Apollo Gateway / Apollo Router

Gateway (หรือ router) คือจุดเข้าที่ประกอบทั้งหมด subgraphs และเส้นทางการสอบถามข้อมูล Apollo จัดหา 2 ตัวเลือก:

Apollo Gateway (Node.js)

// gateway/src/index.ts

import { ApolloGateway, IntrospectAndCompose } from "@apollo/gateway";
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";

async function startGateway() {
  const gateway = new ApolloGateway({
    supergraphSdl: new IntrospectAndCompose({
      subgraphs: [
        { name: "users", url: "http://localhost:4001/graphql" },
        { name: "orders", url: "http://localhost:4002/graphql" },
      ],
      // Polling interval for schema changes
      pollIntervalInMs: 10000,
    }),
  });

  const server = new ApolloServer({
    gateway,
    // Context is shared with subgraphs
    context: async ({ req }) => ({
      userId: req.headers["x-user-id"],
      dataloaders: createDataLoaders(),
    }),
  });

  const { url } = await startStandaloneServer(server, {
    listen: { port: 4000 },
  });

  console.log(`🚀 Gateway ready at ${url}`);
}

startGateway().catch(console.error);
// Apollo Gateway equivalent in Java — use Apollo Router (Rust) in production
// For Java, Netflix DGS Gateway or Spring Cloud Gateway with GraphQL aggregation

@SpringBootApplication
@EnableFeignClients
public class GatewayApplication {

    @Bean
    public GraphQLSchema buildFederatedSchema() {
        // Using graphql-java federation for gateway composition
        var usersSchema = fetchSchemaFromSubgraph("http://localhost:4001/graphql");
        var ordersSchema = fetchSchemaFromSubgraph("http://localhost:4002/graphql");

        return FederatedSchemaBuilder.newFederatedSchema()
            .mergeSubgraph("users", usersSchema)
            .mergeSubgraph("orders", ordersSchema)
            .build();
    }

    public static void main(String[] args) {
        SpringApplication.run(GatewayApplication.class, args);
    }
}

// application.yml
// server.port: 4000
// subgraphs:
//   users: http://localhost:4001/graphql
//   orders: http://localhost:4002/graphql
//   poll-interval-ms: 10000
# Apollo Gateway equivalent in Python using Ariadne federation
# pip install ariadne starlette uvicorn httpx

from ariadne import make_executable_schema
from ariadne.contrib.federation import make_federated_schema
from starlette.applications import Starlette
from starlette.routing import Route
from ariadne.asgi import GraphQL
import httpx

async def start_gateway():
    # Fetch and compose schemas from subgraphs
    async with httpx.AsyncClient() as client:
        users_sdl = await fetch_subgraph_sdl(client, "http://localhost:4001/graphql")
        orders_sdl = await fetch_subgraph_sdl(client, "http://localhost:4002/graphql")

    # Build federated schema
    schema = make_federated_schema([users_sdl, orders_sdl])

    app = Starlette(routes=[
        Route("/graphql", GraphQL(schema, debug=True)),
    ])
    return app

async def fetch_subgraph_sdl(client: httpx.AsyncClient, url: str) -> str:
    response = await client.post(url, json={"query": "{ _service { sdl } }"})
    return response.json()["data"]["_service"]["sdl"]
// ASP.NET Core Federation Gateway using Hot Chocolate
// NuGet: HotChocolate.Stitching

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddHttpClient("users", c => c.BaseAddress = new Uri("http://localhost:4001/graphql"))
    .AddHttpClient("orders", c => c.BaseAddress = new Uri("http://localhost:4002/graphql"));

builder.Services
    .AddGraphQLServer()
    .AddRemoteSchema("users")
    .AddRemoteSchema("orders")
    .AddTypeExtensionsFromFile("./stitching.graphql"); // Optional local extensions

var app = builder.Build();
app.MapGraphQL(); // Listens on port 4000 by default
app.Run();

Apollo Router เป็น router ที่ใช้ประสิทธิภาพสูงที่ใช้ภาษา Rust ซึ่งพร้อมสำหรับการผลิต:

# router.yaml

supergraph:
  listen: 127.0.0.1:4000
  path: /graphql

subgraphs:
  users:
    routing_url: http://localhost:4001
  orders:
    routing_url: http://localhost:4002

plugins:
  authentication:
    subgraph:
      all:
        - propagate_header:
            named: "authorization"
  telemetry:
    apollo:
      api_key: ${APOLLO_KEY}

Query Planning และการดำเนินการ

Query planning คือสิ่งที่ gateway กำหนดลำดับการเรียก subgraph ที่ดีที่สุดเพื่อให้เป็นไปตามข้อกำหนดของการสอบถามข้อมูล Planner:

  1. Analyzes โครงสร้างของการสอบถามข้อมูล
  2. Determines ของ subgraphs ที่เป็นเจ้าของ fields ใด
  3. Plans entity references ข้ามพรมแดน
  4. Batches requests เพื่อลดจำนวน round trips
  5. Merges ผลลัพธ์ในโครงสร้างที่ถูกต้อง

ตัวอย่าง Query Plan

สำหรับการสอบถามข้อมูลนี้:

query {
  user(id: "user-1") {
    id
    username
    orders {
      id
      total
    }
  }
}

Query plan ดูเหมือนว่า:

1. [users] Query.user(id: "user-1") 
   → Returns User { id, username }

2. [orders] Reference Resolution
   → users.__resolveReference({ id: "user-1" })
   → Returns Order list for user

3. [merge] Combine results

เปิดใช้งาน query plan debugging:

const server = new ApolloServer({
  gateway,
  plugins: {
    didResolveOperation: async (context) => {
      console.log("Query Plan:", JSON.stringify(context.requestContext.queryPlan, null, 2));
    },
  },
});
// Enable query plan tracing via DGS instrumentation
@Component
public class QueryPlanLogger implements DgsExecutionCustomizer {
    private static final Logger log = LoggerFactory.getLogger(QueryPlanLogger.class);

    @Override
    public ExecutionInput.Builder customize(ExecutionInput.Builder builder,
            ExecutionInput executionInput, DgsRequestData requestData) {
        // Log the incoming query for debugging
        log.debug("Executing query: {}", executionInput.getQuery());
        // Apollo Router exposes query plans via the x-apollo-query-plan header
        return builder;
    }
}
# Enable query plan debugging via Strawberry/Ariadne extensions
from strawberry.extensions import QueryDepthLimiter
from strawberry.types import ExecutionContext

class QueryPlanLogger:
    def on_executing_start(self) -> None:
        pass  # Hook into execution lifecycle

# For Apollo Router, query plans appear in logs with:
# RUST_LOG=apollo_router=debug
# or use the --dev flag for Apollo Router to expose query plan traces
// Enable query plan tracing with Hot Chocolate diagnostic events
public class QueryPlanDiagnosticEventListener : ExecutionDiagnosticEventListener
{
    private readonly ILogger<QueryPlanDiagnosticEventListener> _logger;

    public override IDisposable ExecuteRequest(IRequestContext context)
    {
        return new RequestScope(_logger, context);
    }

    private class RequestScope : IDisposable
    {
        private readonly IRequestContext _context;
        private readonly ILogger _logger;

        public RequestScope(ILogger logger, IRequestContext context)
        {
            _logger = logger;
            _context = context;
        }

        public void Dispose()
        {
            if (_context.Result is IQueryResult result)
            {
                // Log query plan from extensions
                if (result.Extensions?.TryGetValue("queryPlan", out var plan) == true)
                    _logger.LogDebug("Query Plan: {Plan}", plan);
            }
        }
    }
}

ปัญหา N+1 และ DataLoader Pattern

ปัญหา N+1 แบบดั้งเดิมเกิดขึ้นเมื่อคุณดึงข้อมูล parent entity แล้วดึงข้อมูล children ทีละรายการ:

ปัญหา

query {
  users(limit: 100) {      # 1 query
    id
    orders {               # N queries (1 per user)
      id
    }
  }
}

ส่งผลให้เกิด 1 + N database queries ข้ามพรมแดนของบริการ

วิธีแก้: Batch Loading

นำวิธี batch reference resolution มาใช้บน orders subgraph:

// orders-subgraph/src/dataloaders.ts

import DataLoader from "dataloader";

export function createOrderDataLoaders() {
  const ordersByUserIdLoader = new DataLoader(async (userIds: string[]) => {
    // Single batch query for all user IDs
    const ordersByUserId = await db.orders.findByUserIds(userIds);
    
    // Return orders grouped by user ID in request order
    return userIds.map((userId) => ordersByUserId[userId] || []);
  }, {
    batchScheduleFn: (callback) => {
      // Delay batch by 5ms to allow accumulation
      setTimeout(callback, 5);
    },
  });

  return { ordersByUserIdLoader };
}
@DgsDataLoader(name = "ordersByUserIdLoader")
public class OrdersByUserIdDataLoader implements BatchLoader<String, List<Order>> {
    private final OrderRepository orderRepository;

    @Override
    public CompletableFuture<List<List<Order>>> load(List<String> userIds) {
        return CompletableFuture.supplyAsync(() -> {
            // Single batch query for all user IDs
            Map<String, List<Order>> ordersByUserId = orderRepository
                .findByUserIdIn(userIds)
                .stream()
                .collect(Collectors.groupingBy(Order::getUserId));
            // Return orders grouped by user ID in request order
            return userIds.stream()
                .map(uid -> ordersByUserId.getOrDefault(uid, List.of()))
                .collect(Collectors.toList());
        });
    }
}
async def load_orders_by_user_ids(user_ids: list[str]) -> list[list[Order]]:
    # Single batch query for all user IDs
    all_orders = await db.orders.find_by_user_ids(user_ids)
    orders_by_user: dict[str, list[Order]] = {}
    for order in all_orders:
        orders_by_user.setdefault(order.user_id, []).append(order)
    # Return orders grouped by user ID in request order
    return [orders_by_user.get(uid, []) for uid in user_ids]

def create_order_dataloaders():
    return {"orders_by_user_id_loader": DataLoader(load_fn=load_orders_by_user_ids)}
public class OrdersByUserIdDataLoader : GroupedDataLoader<string, Order>
{
    private readonly IOrderRepository _repository;

    public OrdersByUserIdDataLoader(IBatchScheduler batchScheduler, IOrderRepository repository)
        : base(batchScheduler)
    {
        _repository = repository;
    }

    protected override async Task<ILookup<string, Order>> LoadGroupedBatchAsync(
        IReadOnlyList<string> keys, CancellationToken ct)
    {
        // Single batch query for all user IDs
        var orders = await _repository.FindByUserIdsAsync(keys, ct);
        return orders.ToLookup(o => o.UserId);
    }
}

แล้วใช้มันใน resolver ของคุณ:

User: {
  orders: async (
    reference: { id: string },
    _,
    context: { dataloaders: DataLoaders }
  ) => {
    return context.dataloaders.ordersByUserIdLoader.load(reference.id);
  },
}
@DgsData(parentType = "User", field = "orders")
public CompletableFuture<List<Order>> getUserOrders(
        DgsDataFetchingEnvironment env) {
    String userId = env.getSource().toString(); // or cast to User
    DataLoader<String, List<Order>> loader = env.getDataLoader("ordersByUserIdLoader");
    return loader.load(userId);
}
@strawberry.federation.type(keys=["id"])
class User:
    id: str

    @strawberry.field
    async def orders(self, info: strawberry.types.Info) -> list["Order"]:
        return await info.context["orders_by_user_id_loader"].load(self.id)
[ExtendObjectType(typeof(User))]
public class UserOrdersExtension
{
    public async Task<IEnumerable<Order>> GetOrders(
        [Parent] User user,
        OrdersByUserIdDataLoader dataLoader,
        CancellationToken ct) =>
        await dataLoader.LoadAsync(user.Id, ct);
}

ตอนนี้ทั้งการสอบถามข้อมูลผู้ใช้งาน trigger:

  • 1 query เพื่อดึงข้อมูล 100 ผู้ใช้
  • 1 batch query เพื่อดึงข้อมูลออเดอร์ทั้งหมดสำหรับผู้ใช้เหล่านั้น
  • รวม: 2 queries แทนที่จะเป็น 101

การรับรองความถูกต้องและการให้สิทธิในกราฟที่รวมเข้าด้วยกัน

การรับรองความถูกต้องควรไหลจาก gateway ไปยังทั้งหมด subgraphs Gateway ตรวจสอบโทเค็นและส่งข้อมูลข้อมูลประจำตัวผ่าน context:

Token Validation ใน Gateway

// gateway/src/auth.ts

import jwt from "jsonwebtoken";

interface UserContext {
  userId: string;
  email: string;
  roles: string[];
}

export function authenticateRequest(authHeader?: string): UserContext | null {
  if (!authHeader) return null;

  const token = authHeader.replace("Bearer ", "");
  
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET!) as UserContext;
    return decoded;
  } catch (error) {
    console.error("Token verification failed:", error);
    return null;
  }
}
// gateway/src/main/java/auth/JwtAuthFilter.java
import io.jsonwebtoken.*;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
public class JwtAuthFilter extends OncePerRequestFilter {
    @Value("${jwt.secret}")
    private String jwtSecret;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);
            try {
                Claims claims = Jwts.parserBuilder()
                    .setSigningKey(jwtSecret.getBytes())
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
                var auth = new UsernamePasswordAuthenticationToken(
                    claims.getSubject(), null,
                    AuthorityUtils.createAuthorityList(
                        ((List<String>) claims.get("roles")).stream()
                            .map(r -> "ROLE_" + r).toArray(String[]::new)));
                SecurityContextHolder.getContext().setAuthentication(auth);
            } catch (JwtException e) {
                log.error("Token verification failed: {}", e.getMessage());
            }
        }
        filterChain.doFilter(request, response);
    }
}
# gateway/auth.py
import jwt
import os
from dataclasses import dataclass

@dataclass
class UserContext:
    user_id: str
    email: str
    roles: list[str]

def authenticate_request(auth_header: str | None) -> UserContext | None:
    if not auth_header:
        return None
    token = auth_header.replace("Bearer ", "")
    try:
        decoded = jwt.decode(
            token,
            os.getenv("JWT_SECRET", ""),
            algorithms=["HS256"],
        )
        return UserContext(
            user_id=decoded["userId"],
            email=decoded["email"],
            roles=decoded.get("roles", []),
        )
    except jwt.PyJWTError as e:
        print(f"Token verification failed: {e}")
        return None
// gateway/Auth/JwtAuthHandler.cs
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

public record UserContext(string UserId, string Email, List<string> Roles);

public static class AuthExtensions
{
    public static UserContext? AuthenticateRequest(string? authHeader)
    {
        if (authHeader is null) return null;
        var token = authHeader.Replace("Bearer ", "");
        try
        {
            var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();
            var key = Encoding.UTF8.GetBytes(
                Environment.GetEnvironmentVariable("JWT_SECRET") ?? "");
            handler.ValidateToken(token, new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(key),
                ValidateIssuer = false,
                ValidateAudience = false,
            }, out var validatedToken);
            var jwt = (System.IdentityModel.Tokens.Jwt.JwtSecurityToken)validatedToken;
            return new UserContext(
                jwt.Claims.First(c => c.Type == "userId").Value,
                jwt.Claims.First(c => c.Type == "email").Value,
                jwt.Claims.Where(c => c.Type == "roles").Select(c => c.Value).ToList()
            );
        }
        catch (Exception ex)
        {
            Console.Error.WriteLine($"Token verification failed: {ex.Message}");
            return null;
        }
    }
}

Context Propagation

const server = new ApolloServer({
  gateway,
  context: async ({ req }) => {
    const user = authenticateRequest(req.headers.authorization);
    
    return {
      user,
      authenticated: !!user,
      dataloaders: createDataLoaders(),
    };
  },
});
// Propagate authenticated user via GraphQL context
@Component
public class GraphQLContextBuilder implements DgsRequestCustomizer {
    @Override
    public DgsRequestData customize(DgsRequestData requestData, HttpServletRequest request) {
        var authHeader = request.getHeader("Authorization");
        var userContext = JwtAuthFilter.extractUserContext(request);
        return DgsRequestData.builder()
            .from(requestData)
            .extensions(Map.of(
                "user", userContext,
                "authenticated", userContext != null
            ))
            .build();
    }
}
# FastAPI context dependency
from fastapi import Request, Depends

async def get_graphql_context(request: Request) -> dict:
    auth_header = request.headers.get("authorization")
    user = authenticate_request(auth_header)
    return {
        "user": user,
        "authenticated": user is not None,
        "dataloaders": create_dataloaders(),
        "request": request,
    }

# In your GraphQL router setup:
app.include_router(GraphQLRouter(schema, context_getter=get_graphql_context))
// ASP.NET Core context propagation via middleware
public class GraphQLContextMiddleware
{
    private readonly RequestDelegate _next;

    public async Task InvokeAsync(HttpContext context)
    {
        var authHeader = context.Request.Headers.Authorization.ToString();
        var user = AuthExtensions.AuthenticateRequest(authHeader);

        context.Items["user"] = user;
        context.Items["authenticated"] = user is not null;

        await _next(context);
    }
}

// In Hot Chocolate, access via IResolverContext:
// var user = context.GetGlobalStateOrDefault<UserContext>("user");

Authorization ใน Subgraphs

ใช้ field-level directives สำหรับการให้สิทธิ:

# orders-subgraph/schema.graphql

directive @auth(roles: [String!]!) on FIELD_DEFINITION

type Order @key(fields: "id") {
  id: ID!
  total: Float! @auth(roles: ["ADMIN", "ORDER_VIEWER"])
  items: [OrderItem!]!
}

นำ directive ไปใช้:

import { getDirective, MapperKind, mapSchema } from "@graphql-tools/utils";

function authDirectiveTransformer(schema: GraphQLSchema) {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const authDirective = getDirective(schema, fieldConfig, "auth")[0];
      
      if (!authDirective) return fieldConfig;

      const originalResolver = fieldConfig.resolve;

      return {
        ...fieldConfig,
        resolve: async (source, args, context, info) => {
          if (!context.user) {
            throw new Error("Unauthorized: authentication required");
          }

          const requiredRoles: string[] = authDirective.roles;
          const hasRole = requiredRoles.some((role) =>
            context.user.roles.includes(role)
          );

          if (!hasRole) {
            throw new Error(`Forbidden: requires one of ${requiredRoles.join(", ")}`);
          }

          return originalResolver?.(source, args, context, info);
        },
      };
    },
  });
}
// Spring Security method-level authorization on DGS resolvers
@PreAuthorize("hasAnyRole('ADMIN', 'ORDER_VIEWER')")
@DgsData(parentType = "Order", field = "total")
public double getTotal(DgsDataFetchingEnvironment env) {
    Order order = env.getSource();
    return order.getTotal();
}

// Custom authorization directive via DGS instrumentation
@Component
public class AuthDirectiveInstrumentation extends SimpleInstrumentation {
    @Override
    public DataFetcher<?> instrumentDataFetcher(DataFetcher<?> dataFetcher,
            InstrumentationFieldFetchParameters params) {
        var field = params.getEnvironment().getFieldDefinition();
        var authDirective = field.getDirective("auth");
        if (authDirective == null) return dataFetcher;

        @SuppressWarnings("unchecked")
        List<String> requiredRoles = (List<String>) authDirective.getArgument("roles").getValue();

        return env -> {
            var authentication = SecurityContextHolder.getContext().getAuthentication();
            if (authentication == null || !authentication.isAuthenticated()) {
                throw new AccessDeniedException("Unauthorized: authentication required");
            }
            boolean hasRole = authentication.getAuthorities().stream()
                .anyMatch(a -> requiredRoles.contains(a.getAuthority().replace("ROLE_", "")));
            if (!hasRole) {
                throw new AccessDeniedException(
                    "Forbidden: requires one of " + String.join(", ", requiredRoles));
            }
            return dataFetcher.get(env);
        };
    }
}
# Strawberry custom permission class for field-level auth
import strawberry
from strawberry.permission import BasePermission
from strawberry.types import Info

class IsAuthenticated(BasePermission):
    message = "Unauthorized: authentication required"

    def has_permission(self, source, info: Info, **kwargs) -> bool:
        return info.context.get("authenticated", False)

class HasRole(BasePermission):
    def __init__(self, *roles: str):
        self.roles = roles
        self.message = f"Forbidden: requires one of {', '.join(roles)}"

    def has_permission(self, source, info: Info, **kwargs) -> bool:
        user = info.context.get("user")
        if not user:
            return False
        return any(r in user.roles for r in self.roles)

@strawberry.type
class Order:
    id: str

    @strawberry.field(
        permission_classes=[IsAuthenticated, HasRole("ADMIN", "ORDER_VIEWER")]
    )
    def total(self) -> float:
        return self._total
// Hot Chocolate field authorization with custom directive
using HotChocolate.Authorization;

[Authorize(Roles = new[] { "ADMIN", "ORDER_VIEWER" })]
public double Total => _total;

// Or via custom authorization handler
public class AuthDirectiveHandler : AuthorizationHandler
{
    protected override ValueTask<AuthorizeResult> AuthorizeAsync(
        IMiddlewareContext context, AuthorizeDirective directive)
    {
        var user = context.GetGlobalStateOrDefault<UserContext>("user");

        if (user is null)
            return new(AuthorizeResult.NotAllowed);

        var requiredRoles = directive.Roles ?? [];
        bool hasRole = requiredRoles.Any(role => user.Roles.Contains(role));

        if (!hasRole)
            return new(AuthorizeResult.NotAllowed);

        return new(AuthorizeResult.Allowed);
    }
}

// Register in Program.cs:
// builder.Services.AddGraphQLServer()
//     .AddAuthorizationHandler<AuthDirectiveHandler>();

การควบคุมสคีมาและ Composition Checks

เมื่อกราฟแบบรวมเข้าด้วยกันของคุณเติบโต การควบคุมสคีมากลายเป็นสิ่งสำคัญ Apollo จัดหา composition checks ที่ตรวจสอบการเปลี่ยนแปลงสคีมาก่อนที่จะมีการปรับใช้

การรวม Apollo Studio

// .apollo/apollo.config.js

module.exports = {
  client: {
    service: {
      name: "my-federated-graph",
      url: "http://localhost:4000/graphql",
    },
  },
};
// application.yml (Spring Boot DGS federation config)
// apollo:
//   graph-ref: "my-federated-graph@current"
//   key: "${APOLLO_KEY}"
// subgraph:
//   name: "users"
//   url: "http://localhost:4000/graphql"

// Equivalent Java bean configuration:
@Configuration
public class ApolloConfig {
    @Value("${apollo.graph-ref:my-federated-graph@current}")
    private String graphRef;

    @Value("${subgraph.url:http://localhost:4000/graphql}")
    private String subgraphUrl;
}
# apollo_config.py — equivalent configuration for Strawberry federation
import os

APOLLO_CONFIG = {
    "client": {
        "service": {
            "name": os.getenv("APOLLO_GRAPH_REF", "my-federated-graph"),
            "url": os.getenv("SUBGRAPH_URL", "http://localhost:4000/graphql"),
        }
    }
}
// appsettings.json equivalent for Hot Chocolate federation
// {
//   "Apollo": {
//     "GraphRef": "my-federated-graph@current",
//     "Key": "<APOLLO_KEY>"
//   },
//   "Subgraph": {
//     "Name": "users",
//     "Url": "http://localhost:4000/graphql"
//   }
// }

// In Program.cs, bind the configuration:
builder.Services.Configure<ApolloOptions>(
    builder.Configuration.GetSection("Apollo"));

CI/CD Composition Check

# .github/workflows/schema-check.yml

name: GraphQL Schema Check

on:
  pull_request:
    paths:
      - "schema.graphql"

jobs:
  composition-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: "18"

      - name: Install dependencies
        run: npm install

      - name: Run composition check
        run: npx apollo graph check --graph my-federated-graph
        env:
          APOLLO_KEY: ${{ secrets.APOLLO_KEY }}

กฎเพื่อบังคับใช้:

# apollo.config.yaml

composition:
  checkRules:
    - rule: BREAKING_SCHEMA_CHANGE
      level: ERROR
    - rule: TYPE_QUERY_ROOT_CHANGE
      level: ERROR
    - rule: DIRECTIVE_REMOVED
      level: WARN

ประสิทธิภาพ: Caching, Persisted Queries และ Query Limits

Response Caching

ตั้งค่า cache hints ที่ field level:

const resolvers = {
  User: {
    profile: {
      resolve: (user) => user.profile,
      extensions: {
        cacheControl: { maxAge: 3600 }, // 1 hour
      },
    },
    orders: {
      resolve: (user, _, { dataloaders }) => 
        dataloaders.ordersByUserIdLoader.load(user.id),
      extensions: {
        cacheControl: { maxAge: 300, scope: "PRIVATE" }, // 5 min, user-specific
      },
    },
  },
};
// Spring Boot with DGS — use @Cacheable for field-level caching
@DgsData(parentType = "User", field = "profile")
@Cacheable(value = "userProfiles", key = "#env.source.id")
public UserProfile getProfile(DgsDataFetchingEnvironment env) {
    User user = env.getSource();
    return user.getProfile(); // Cached for 1 hour via Spring Cache
}

@DgsData(parentType = "User", field = "orders")
public CompletableFuture<List<Order>> getOrders(DgsDataFetchingEnvironment env) {
    User user = env.getSource();
    // Loaded via DataLoader, cache hints set via Apollo Federation @cacheControl directive
    DataLoader<String, List<Order>> loader = env.getDataLoader("ordersByUserIdLoader");
    return loader.load(user.getId());
}

// In schema: add @cacheControl directive for Apollo Router
// type User { profile: UserProfile @cacheControl(maxAge: 3600) }
# Strawberry — cache hints via schema directive
import strawberry
from strawberry.types import Info

@strawberry.type
class User:
    id: str

    @strawberry.field(directives=[strawberry.directive_field("cacheControl", maxAge=3600)])
    def profile(self) -> "UserProfile":
        return self._profile  # 1 hour cache hint

    @strawberry.field
    async def orders(self, info: Info) -> list["Order"]:
        # 5 min, user-specific — set via @cacheControl(maxAge: 300, scope: PRIVATE)
        return await info.context["orders_by_user_id_loader"].load(self.id)
// Hot Chocolate — cache control via directives or response cache
using HotChocolate.Caching;

[ExtendObjectType(typeof(User))]
public class UserCacheExtensions
{
    [CacheControl(MaxAge = 3600)] // 1 hour
    public UserProfile GetProfile([Parent] User user) => user.Profile;

    [CacheControl(MaxAge = 300, Scope = CacheControlScope.Private)] // 5 min, private
    public async Task<IEnumerable<Order>> GetOrders(
        [Parent] User user,
        OrdersByUserIdDataLoader dataLoader,
        CancellationToken ct) =>
        await dataLoader.LoadAsync(user.Id, ct);
}

Persisted Queries

Persisted queries ลดขนาดของ payload และปรับปรุงความปลอดภัย:

import { createPersistedQueryPlugin } from "@apollo/server/plugin/persisted-queries";
import { InMemoryLRUCache } from "@apollo/utils.keyvaluecache";

const server = new ApolloServer({
  schema,
  cache: new InMemoryLRUCache(),
  plugins: [
    createPersistedQueryPlugin({
      cache: new InMemoryLRUCache(),
    }),
  ],
});
// Apollo Router handles Automatic Persisted Queries (APQ) natively
// For DGS, enable APQ via Spring Cache
@Configuration
public class PersistedQueriesConfig {
    @Bean
    public CacheManager cacheManager() {
        var cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofHours(24));
        return RedisCacheManager.builder(redisConnectionFactory())
            .withCacheConfiguration("persisted-queries", cacheConfig)
            .build();
    }
}

// Apollo Router router.yaml:
// apq:
//   enabled: true
//   subgraph:
//     all:
//       enabled: true
# Apollo Router handles APQ natively
# For Ariadne/Strawberry, implement a custom APQ middleware
import hashlib
import json
from starlette.middleware.base import BaseHTTPMiddleware

class AutoPersistedQueryMiddleware(BaseHTTPMiddleware):
    def __init__(self, app, cache=None):
        super().__init__(app)
        self.cache = cache or {}  # Use Redis in production

    async def dispatch(self, request, call_next):
        if request.method == "POST":
            body = await request.json()
            extensions = body.get("extensions", {})
            apq = extensions.get("persistedQuery", {})
            if apq and not body.get("query"):
                query_hash = apq.get("sha256Hash")
                cached_query = self.cache.get(query_hash)
                if cached_query:
                    # Inject cached query into request
                    body["query"] = cached_query
        return await call_next(request)
// Hot Chocolate — Automatic Persisted Queries
using HotChocolate.PersistedQueries;

// In Program.cs:
builder.Services
    .AddGraphQLServer()
    .UseAutomaticPersistedQueryPipeline()
    .AddInMemoryQueryStorage(); // Use Redis in production:
    // .AddRedisQueryStorage(redis);

// Client configuration (using Strawberry Shake or plain HttpClient):
// Add extensions: { persistedQuery: { version: 1, sha256Hash: "..." } }

ฝั่งของลูกค้า:

import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";

const link = createPersistedQueryLink({
  useGETForHashedQueries: true,
}).concat(httpLink);
// Java Apollo client (using com.apollographql.apollo3)
ApolloClient client = ApolloClient.Builder()
    .serverUrl("http://localhost:4000/graphql")
    .httpEngine(DefaultHttpEngine.Builder()
        .addInterceptor(new AutoPersistedQueryInterceptor())
        .build())
    .build();
# Python client with gql
from gql import Client
from gql.transport.aiohttp import AIOHTTPTransport
import hashlib, json

class PersistedQueryTransport(AIOHTTPTransport):
    async def execute(self, document, *args, **kwargs):
        query_str = print_ast(document)
        query_hash = hashlib.sha256(query_str.encode()).hexdigest()
        kwargs.setdefault("extensions", {})["persistedQuery"] = {
            "version": 1, "sha256Hash": query_hash
        }
        return await super().execute(document, *args, **kwargs)

transport = PersistedQueryTransport(url="http://localhost:4000/graphql")
client = Client(transport=transport)
// Strawberry Shake client (NuGet: StrawberryShake.Transport.Http)
builder.Services
    .AddMyGraphQLClient()
    .ConfigureHttpClient(c => c.BaseAddress = new Uri("http://localhost:4000/graphql"))
    .AddHttpMessageHandler<PersistedQueryHttpMessageHandler>();

// Persisted queries are handled automatically by Strawberry Shake
// during code generation with --persisted-query flag

ตรวจสอบความซับซ้อนของการสอบถาม

ป้องกันการสอบถามที่มีค่าใช้จ่ายสูง:

import { simpleEstimator, fieldExtensionsEstimator } from "graphql-query-complexity";

const server = new ApolloServer({
  schema,
  plugins: {
    didResolveOperation: (context) => {
      const complexity = getComplexity({
        schema,
        query: context.document,
        variables: context.variableValues,
        estimators: [fieldExtensionsEstimator(), simpleEstimator()],
      });

      if (complexity > 5000) {
        throw new Error(`Query too complex: ${complexity} > 5000`);
      }

      console.log(`Query complexity: ${complexity}`);
    },
  },
});
// DGS query complexity via custom instrumentation
@Component
public class QueryComplexityInstrumentation extends SimpleInstrumentation {
    private static final int MAX_COMPLEXITY = 5000;

    @Override
    public InstrumentationContext<ExecutionResult> beginExecuteOperation(
            InstrumentationExecuteOperationParameters params) {
        var document = params.getExecutionContext().getDocument();
        int complexity = calculateComplexity(document);

        if (complexity > MAX_COMPLEXITY) {
            throw new AbortExecutionException(
                String.format("Query too complex: %d > %d", complexity, MAX_COMPLEXITY));
        }

        log.info("Query complexity: {}", complexity);
        return super.beginExecuteOperation(params);
    }

    private int calculateComplexity(Document document) {
        // Use graphql-java ComplexityCalculator or implement custom logic
        return ComplexityCalculator.calculate(document);
    }
}
# Strawberry query complexity
from strawberry.extensions import QueryDepthLimiter
from strawberry.extensions.query_complexity import QueryComplexityExtension

schema = strawberry.Schema(
    query=Query,
    extensions=[
        QueryDepthLimiter(max_depth=10),
        QueryComplexityExtension(
            max_complexity=5000,
            estimators=[
                SimpleEstimator(default_complexity=1),
                FieldEstimator(),
            ],
        ),
    ],
)
// Hot Chocolate query complexity
using HotChocolate.Execution.Configuration;

builder.Services
    .AddGraphQLServer()
    .SetMaxAllowedComplexity(5000)
    .ModifyRequestOptions(opt =>
    {
        opt.Complexity.Enable = true;
        opt.Complexity.MaximumAllowed = 5000;
        opt.Complexity.DefaultComplexity = 1;
        opt.Complexity.DefaultResolverComplexity = 5;
    });

// Field-level complexity hints:
// [GraphQLComplexity(10)]
// public IEnumerable<Order> GetOrders() => ...

Federation vs REST Comparison

AspectGraphQL FederationREST Microservices
Query Specificityดึงข้อมูลเพียงสิ่งที่คุณต้องการOver/under-fetching
Multiple ResourcesSingle request multiple fieldsMultiple endpoints multiple requests
VersioningAdditive schema evolutionAPI versioning (v1, v2, v3)
Type SafetyStrong schema auto-generated typesManual API contracts
CachingField-level caching persisted queriesHTTP caching cache invalidation
MonitoringDetailed query insightsBasic request/response logging
CompositionDeclarative federation directivesService discovery API gateways
Error HandlingPartial success possibleAll-or-nothing responses
Learning CurveModerate (GraphQL + Federation concepts)Low (standard REST patterns)
Production MaturityProduction-ready (Apollo)Widely adopted stable

เมื่อเลือก Federation: คุณมีหลายทีม ต้องการการสอบถามข้อมูลที่ยืดหยุ่น ประเภทที่แข็งแกร่ง และพื้นผิว API ที่รวมเข้าด้วยกัน

เมื่อเลือก REST: บริการอย่างง่าย การดำเนินงาน CRUD มาตรฐาน หรือทีมที่ไม่คุ้นเคยกับ GraphQL

Production Checklist

ก่อนปรับใช้กราฟแบบรวมเข้าด้วยกันของคุณไปยังการผลิต:

สถาปัตยกรรม & ออกแบบ

  • เอกสารการเป็นเจ้าของ subgraph (ใครเป็นเจ้าของแต่ละประเภท/field)
  • กำหนดขอบเขตที่ชัดเจนระหว่างบริการ
  • วางแผนสำหรับความสอดคล้องที่เกิดขึ้นในที่สุดข้ามบริการ
  • ออกแบบสำหรับ graceful degradation (การล้มเหลวของบริการบางส่วน)
  • ตั้งค่าการแมปการพึ่งพา (subgraphs ใดเรียก subgraphs ใด)

การปรับใช้

  • นำ __resolveReference ไปใช้สำหรับประเภท @key ทั้งหมด
  • เพิ่ม DataLoader สำหรับการแก้ไข entity ของ batch
  • ใช้ TypeScript สำหรับความปลอดภัยของประเภทข้ามทั้งหมด subgraphs
  • นำการจัดการข้อผิดพลาดและการบันทึกที่เหมาะสมไปใช้
  • เพิ่มการติดตามรหัสคำขอสำหรับ distributed tracing
  • ตั้งค่า health check endpoints บน subgraphs ทั้งหมด

ประสิทธิภาพ

  • เปิดใช้งาน query complexity analysis
  • ตั้งค่า cache hints บน fields ที่มีการเข้าถึงบ่อย
  • ตั้งค่า persisted queries สำหรับแอปพลิเคชั่นไคลเอนต์
  • ใช้ Apollo Router แทน Apollo Gateway (ประสิทธิภาพที่ดีขึ้น)
  • ตั้งค่า timeouts ที่เหมาะสมสำหรับการเรียก subgraph
  • ทำการ load test ด้วยรูปแบบการสอบถามข้อมูลที่สมจริง
  • ติดตามเวลาการดำเนินการสอบถามข้อมูลข้ามบริการ

ความปลอดภัย

  • นำการรับรองความถูกต้องไปใช้บน gateway level
  • ใช้ authorization directives บน fields ที่ไวต่อข้อมูล
  • ตรวจสอบอินพุตทั้งหมดที่ gateway และ subgraph levels
  • ใช้ HTTPS สำหรับการสื่อสารแบบ service-to-service ทั้งหมด
  • หมุน JWT secrets อย่างสม่ำเสมอ
  • นำอัตราการจำกัดไปใช้บน gateway
  • บันทึกการตรวจสอบสำหรับการเปลี่ยนแปลงทางเอกสารทั้งหมด

การติดตามและการดำเนินการ

  • ตั้งค่า distributed tracing (Jaeger Datadog ฯลฯ)
  • ติดตามเวลาการตอบสนอง subgraph และอัตราข้อผิดพลาด
  • ตั้งค่าการแจ้งเตือนสำหรับความล้มเหลวของการตรวจสอบการจัดองค์ประกอบ
  • ตั้งค่าการแจ้งเตือนการเปลี่ยนแปลงสคีมา
  • นำ circuit breakers ไปใช้สำหรับ subgraphs ที่ล้มเหลว
  • ติดตาม gateway memory และ CPU usage
  • ตั้งค่า dashboards สำหรับ metrics ที่สำคัญ
  • เอกสารของ runbooks สำหรับปัญหาทั่วไป

การปรับใช้

  • ทำให้การตรวจสอบการจัดองค์ประกอบในช่วง CI/CD เป็นอัตโนมัติ
  • ใช้ semantic versioning สำหรับสคีมา subgraph
  • วางแผนการปรับใช้ที่ไม่มีเวลาหยุดชะงัก
  • ตั้งค่า canary deployments สำหรับการเปลี่ยนแปลงสคีมา
  • เก็บ gateway และ subgraph dependencies ที่จัดเรียง
  • เอกสารของขั้นตอนการย้อนกลับ
  • ทดสอบการเปลี่ยนแปลงสคีมาในการจัดเตรียมก่อนการผลิต

การควบคุม

  • สร้างกระบวนการทบทวนสคีมา
  • เอกสารของมาตรฐานการใช้งาน Federation directive
  • ตั้งค่า automated linting สำหรับสคีมา GraphQL
  • ตรวจสอบความล้มเหลวของการตรวจสอบการจัดองค์ประกอบเป็นทีม
  • บำรุงรักษา type coverage metrics
  • จัดตารางการทบทวนสถาปัตยกรรมที่สม่ำเสมอ

สรุป

GraphQL Federation จัดให้มีวิธีที่มีประสิทธิภาพและสเกลได้สำหรับการสร้างระบบ GraphQL ที่กระจายออกไป ด้วยการ decentralizing ownership ของสคีมาและการจัดหา composition ที่ประกาศ federation ช่วยให้ทีมอิสระเคลื่อนที่เร็วขึ้นในขณะที่บำรุงรักษา API surface ที่เรียบร้อย

กุญแจสำคัญต่อ federation graph ที่ประสบความสำเร็จคือ:

  1. ขอบเขตความเป็นเจ้าของที่ชัดเจนและการแก้ไข entity
  2. การปรับปรุงประสิทธิภาพที่มีความคิดเห็น (batching caching query planning)
  3. ความปลอดภัยประเภทที่แข็งแกร่งและการควบคุมสคีมา
  4. การติดตามและการสังเกตการณ์ที่ครอบคลุม
  5. การออกแบบที่มีความปลอดภัยก่อนอื่นพร้อมการรับรองความถูกต้องและการให้สิทธิ์ที่เหมาะสม

ไม่ว่าคุณจะเริ่มต้นสถาปัตยกรรม microservices ใหม่หรือปรับปรุงระบบที่มีอยู่ federation ให้พื้นฐานที่มั่นคงสำหรับ GraphQL APIs ที่สเกลได้และบำรุงรักษาได้


กลับไปยัง: Server-to-Server Communication Technologies

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

เขียนโดย พลากร วรมงคล

Software Engineer Specialist ประสบการณ์กว่า 20 ปี เขียนเกี่ยวกับ Architecture, Performance และการสร้างระบบ Production

เพิ่มเติมเกี่ยวกับผม

บทความที่เกี่ยวข้อง