สำรวจเชิงลึก: 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 ก็จะ:
- วิเคราะห์และตรวจสอบการสอบถามข้อมูลเทียบกับสคีมา supergraph
- สร้างแผนการดำเนินการที่กำหนดว่าต้องสอบถาม subgraphs ใดบ้าง
- แก้ไขข้อมูลอ้างอิง entity ข้ามพรมแดนของบริการโดยใช้
__resolveReference - รวมผลลัพธ์จากหลายบริการในการตอบสนองเดี่ยว
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 จะทำงาน แต่ก็มีข้อจำกัดที่มีนัยสำคัญ:
| Aspect | Schema Stitching | Federation |
|---|---|---|
| Type Extension | ตั้งค่า resolver ด้วยตนเอง | Declarative @key directives |
| Entity Resolution | ตรรมชาติของ resolver ที่กำหนดเอง | Built-in __resolveReference |
| Type Safety | จำกัด การแมปด้วยตนเอง | Strong SDL-driven composition |
| Query Planning | Basic ปัญหา N+1 ที่อาจเกิดขึ้น | Intelligent batches requests |
| Composition Checks | ด้วยตนเองหรือเครื่องมือที่กำหนดเอง | Apollo Composition Checks (CI/CD) |
| Development Experience | Verbose error-prone | Declarative type-safe |
| Performance Monitoring | Insight จำกัด | 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 (Production-Recommended)
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:
- Analyzes โครงสร้างของการสอบถามข้อมูล
- Determines ของ subgraphs ที่เป็นเจ้าของ fields ใด
- Plans entity references ข้ามพรมแดน
- Batches requests เพื่อลดจำนวน round trips
- 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
| Aspect | GraphQL Federation | REST Microservices |
|---|---|---|
| Query Specificity | ดึงข้อมูลเพียงสิ่งที่คุณต้องการ | Over/under-fetching |
| Multiple Resources | Single request multiple fields | Multiple endpoints multiple requests |
| Versioning | Additive schema evolution | API versioning (v1, v2, v3) |
| Type Safety | Strong schema auto-generated types | Manual API contracts |
| Caching | Field-level caching persisted queries | HTTP caching cache invalidation |
| Monitoring | Detailed query insights | Basic request/response logging |
| Composition | Declarative federation directives | Service discovery API gateways |
| Error Handling | Partial success possible | All-or-nothing responses |
| Learning Curve | Moderate (GraphQL + Federation concepts) | Low (standard REST patterns) |
| Production Maturity | Production-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 ที่ประสบความสำเร็จคือ:
- ขอบเขตความเป็นเจ้าของที่ชัดเจนและการแก้ไข entity
- การปรับปรุงประสิทธิภาพที่มีความคิดเห็น (batching caching query planning)
- ความปลอดภัยประเภทที่แข็งแกร่งและการควบคุมสคีมา
- การติดตามและการสังเกตการณ์ที่ครอบคลุม
- การออกแบบที่มีความปลอดภัยก่อนอื่นพร้อมการรับรองความถูกต้องและการให้สิทธิ์ที่เหมาะสม
ไม่ว่าคุณจะเริ่มต้นสถาปัตยกรรม microservices ใหม่หรือปรับปรุงระบบที่มีอยู่ federation ให้พื้นฐานที่มั่นคงสำหรับ GraphQL APIs ที่สเกลได้และบำรุงรักษาได้