Quick start (chạy thử ngay sau 5 phút)
Muốn dựng nhanh một GraphQL server để vọc thử mà không mất công cấu hình rườm rà? Bộ ba Node.js, TypeScript và Apollo Server v4 sẽ giúp bạn dựng xong prototype chỉ sau vài phút.
Trước hết, khởi tạo thư mục dự án và kéo các package cần thiết về máy:
mkdir graphql-ts-quickstart && cd graphql-ts-quickstart
npm init -y
npm install @apollo/server graphql
npm install -D typescript @types/node tsx
npx tsc --init
Tạo file src/index.ts rồi dán đoạn code tối giản dưới đây:
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
// 1. Định nghĩa Schema
const typeDefs = `#graphql
type Book {
id: ID!
title: String!
author: String!
}
type Query {
books: [Book!]!
}
`;
// 2. Dữ liệu mẫu
const books = [
{ id: '1', title: 'Clean Architecture', author: 'Robert C. Martin' },
{ id: '2', title: 'Refactoring', author: 'Martin Fowler' },
];
// 3. Xử lý Resolvers
const resolvers = {
Query: {
books: () => books,
},
};
// 4. Khởi động server
async function bootstrap() {
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });
console.log(`🚀 Server đã sẵn sàng tại: ${url}`);
}
bootstrap();
Khởi chạy server bằng lệnh:
npx tsx src/index.ts
Truy cập http://localhost:4000 trên trình duyệt. Giao diện Apollo Sandbox sẽ hiện lên ngay. Giờ bạn chỉ cần gõ thử query { books { title author } } và nhấn Run để kiểm tra kết quả.
Giải thích chi tiết: Schema và Resolvers
Vấn đề: Vì sao REST API dần hụt hơi trong ứng dụng phức tạp?
Hãy tưởng tượng bạn đang làm màn hình Feed cho ứng dụng di động. Giao diện đó cần gom đủ thứ: thông tin cá nhân, danh sách bài viết, bình luận gần nhất và số lượt thả tim. Với kiến trúc REST truyền thống, team frontend sẽ liên tục vấp phải hai rào cản:
- Over-fetching: Bạn chỉ cần
namevàavatarUrlđể hiển thị danh sách bạn bè. Thế nhưng endpoint/api/userslại hào phóng nhồi thêm cả số điện thoại, ngày sinh, địa chỉ nhà lẫn lịch sử giao dịch. Payload phình to từ vài KB lên hàng trăm KB một cách vô ích. - Under-fetching: Một endpoint không tài nào gom đủ dữ liệu. Để dựng xong một trang Profile, client phải bấm bụng bắn 3 đến 4 request tuần tự: gọi
/users/1, đợi xong gọi tiếp/users/1/posts, rồi lại lặp qua từng bài để lấy comment. Trên mạng 3G/4G chập chờn, độ trễ cộng dồn dễ dàng vượt ngưỡng 800ms.
GraphQL đảo ngược hoàn toàn cuộc chơi này. Client nắm quyền chỉ định chính xác cấu trúc dữ liệu mong muốn. Client yêu cầu field nào, server nhả đúng field đó — không thừa, không thiếu một byte.
Thiết kế Schema: Bản hợp đồng chuẩn mực
Trong GraphQL, Schema đóng vai trò bản hợp đồng cam kết giữa frontend và backend (Contract-Driven Development). Apollo Server dùng cú pháp SDL (Schema Definition Language) giúp bạn định nghĩa types, queries và mutations mạch lạc, dễ đọc.
Cùng xem ví dụ thiết kế Schema cho tính năng Blog có liên kết giữa User và Post:
export const typeDefs = `#graphql
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
authorId: ID!
author: User!
}
type Query {
posts: [Post!]!
user(id: ID!): User
}
input CreatePostInput {
title: String!
content: String!
authorId: ID!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
}
`;
Viết Resolvers chuẩn Type-Safe cùng TypeScript
Nếu Schema là bản thiết kế trên giấy, thì Resolver chính là công nhân thi công. Mỗi Resolver là một hàm đảm nhận việc tìm nạp dữ liệu cho một field cụ thể. Hàm này luôn nhận 4 tham số quen thuộc: (parent, args, contextValue, info).
Khi làm dự án lớn, bạn nên dùng GraphQL Code Generator để tự sinh type từ file schema. Còn trong phạm vi bài này, việc tự định nghĩa interface cho model in-memory cũng đủ giúp code tường minh và bắt lỗi compile-time cực tốt:
export interface UserModel {
id: string;
name: string;
email: string;
}
export interface PostModel {
id: string;
title: string;
content: string;
authorId: string;
}
// Giả lập cơ sở dữ liệu in-memory
export const fakeUsers: UserModel[] = [
{ id: 'u1', name: 'Nguyễn Văn A', email: '[email protected]' },
{ id: 'u2', name: 'Trần Thị B', email: '[email protected]' },
];
export const fakePosts: PostModel[] = [
{ id: 'p1', title: 'Học TypeScript từ đâu?', content: 'Nội dung bài 1...', authorId: 'u1' },
{ id: 'p2', title: 'Tối ưu Backend Node.js', content: 'Nội dung bài 2...', authorId: 'u1' },
{ id: 'p3', title: 'Kinh nghiệm phỏng vấn dev', content: 'Nội dung bài 3...', authorId: 'u2' },
];
Nâng cao: Trị dứt điểm N+1 Query bằng DataLoader
Hiện tượng N+1 Query: Quả bom nổ chậm trong GraphQL
Đa số lập trình viên mới làm quen với GraphQL thường viết resolver theo lối tư duy trực diện. Hãy nhìn cách mapping dữ liệu giữa Post và Author dưới đây:
export const resolvers = {
Query: {
posts: (): PostModel[] => {
console.log('Query: Lấy danh sách posts');
return fakePosts;
},
},
Post: {
author: (parent: PostModel): UserModel | undefined => {
console.log(`Field Resolver: Tìm user id ${parent.authorId}`);
return fakeUsers.find((u) => u.id === parent.authorId);
},
},
};
Client gửi lên một query lồng nhau rất thông thường:
query GetPostsWithAuthors {
posts {
id
title
author {
name
}
}
}
Theo dõi terminal, bạn sẽ thấy log nhảy ra như thế này:
Query: Lấy danh sách posts (1 lần)
Field Resolver: Tìm user id u1
Field Resolver: Tìm user id u1
Field Resolver: Tìm user id u2
Ba bài viết thì chạy 3 lần resolver con. Vậy chuyện gì xảy ra nếu danh sách trả về 100 bài viết? Resolver Post.author sẽ bị kích hoạt đúng 100 lần.
Trong ứng dụng production kết nối PostgreSQL hay MySQL, server sẽ bắn ra 1 câu query lấy bài viết + 100 câu query đơn lẻ lấy thông tin tác giả. Nếu hệ thống chịu tải 100 request/giây, database phải gánh ngay hơn 10.000 query/giây. Hậu quả là database connection pool cạn kiệt, CPU chạm trần 100% và server nhanh chóng ngừng phản hồi.
Đâu là nguyên nhân cốt lõi?
GraphQL duyệt dữ liệu dạng cây theo chiều sâu (depth-first execution). Từng resolver con thực thi hoàn toàn độc lập. Chúng chỉ biết dữ liệu từ parent truyền xuống chứ không hề hay biết các resolver anh em cùng cấp đang cần lấy dữ liệu của ai để gom lại một lần.
Giải pháp: Batching & Caching bằng DataLoader
Để phá vỡ thế bế tắc này, Meta đã tạo ra thư viện dataloader. Cơ chế của nó cực kỳ thông minh: tận dụng microtask queue trong Event Loop của Node.js để gom nhóm (batching).
Trong cùng một tick thực thi, thay vì bắn từng query rời rạc, DataLoader tạm hoãn lại vài microsecond. Nó đợi toàn bộ resolver cùng cấp đăng ký hết danh sách ID rồi gom tất cả vào một mảng duy nhất. Từ 100 câu lệnh lẻ tẻ, DataLoader chuyển đổi thành đúng một câu query duy nhất: SELECT * FROM users WHERE id IN ('u1', 'u2', ...).
Cài đặt thư viện vào project:
npm install dataloader
Khởi tạo DataLoader và gắn vào context của Apollo Server:
import DataLoader from 'dataloader';
import { UserModel, fakeUsers, PostModel, fakePosts } from './data';
// Hàm batch load: Nhận mảng ID và trả về mảng User tương ứng
async function batchUsers(userIds: readonly string[]): Promise<(UserModel | null)[]> {
console.log(`--> DataLoader gom batch query cho các ID: [${userIds.join(', ')}]`);
// Giả lập câu lệnh SQL: SELECT * FROM users WHERE id IN (...)
const users = fakeUsers.filter((u) => userIds.includes(u.id));
// BẮT BUỘC: Mảng trả về phải giữ đúng độ dài và đúng thứ tự index của userIds
return userIds.map((id) => users.find((u) => u.id === id) || null);
}
export interface GraphQLContext {
userLoader: DataLoader<string, UserModel | null>;
}
export function createContext(): GraphQLContext {
return {
userLoader: new DataLoader<string, UserModel | null>(batchUsers),
};
}
Chỉnh sửa lại resolver để mượn DataLoader thông qua context:
export const resolvers = {
Query: {
posts: (): PostModel[] => fakePosts,
},
Post: {
author: async (parent: PostModel, _: unknown, context: GraphQLContext) => {
// Đẩy ID vào hàng đợi DataLoader thay vì query DB trực tiếp
return context.userLoader.load(parent.authorId);
},
},
};
Cấu hình lại hàm khởi tạo server với context factory:
const server = new ApolloServer<GraphQLContext>({
typeDefs,
resolvers,
});
const { url } = await startStandaloneServer(server, {
context: async () => createContext(),
listen: { port: 4000 },
});
Chạy lại query ban nãy với 3 bài viết, terminal lúc này sẽ hiển thị:
--> DataLoader gom batch query cho các ID: [u1, u2]
DataLoader vừa làm xong hai việc cùng lúc: tự động gom nhóm các ID lại và loại bỏ luôn ID trùng lặp (deduplication). Từ 3 lần truy vấn riêng rẽ, server giờ chỉ tốn đúng 1 lượt nạp dữ liệu duy nhất.
Kinh nghiệm xương máu khi vận hành GraphQL production
- Vòng đời của DataLoader (Scope theo Request): Đừng bao giờ tạo DataLoader dưới dạng biến global singleton. Bạn phải luôn khởi tạo DataLoader mới bên trong hàm
createContext()của từng request. Nếu dùng chung giữa các request, người dùng B rất dễ đọc trúng cache chứa dữ liệu mật của người dùng A vừa gọi trước đó. - Nguyên tắc sống còn về thứ tự trong batch function: DataLoader bắt buộc mảng kết quả trả về phải có độ dài bằng mảng keys và khớp chính xác theo thứ tự index. Nếu cơ sở dữ liệu trả về sai thứ tự mà bạn quên map lại theo mảng ID đầu vào, bài viết của người này sẽ bị gắn nhầm tên tác giả của người khác.
- Kiểm soát payload JSON: Quá trình debug resolver lồng nhau thường sinh ra payload JSON khá phức tạp. Bạn có thể dùng các tool online tiện lợi như toolcraft.app (chẳng hạn mục JSON Formatter tại
toolcraft.app/vi/tools/developer/json-formatter) để format, validate hay lọc dữ liệu nhanh mà không cần cài extension cồng kềnh vào trình duyệt. - Chặn đứng DoS bằng Query Depth Limiting: Schema có quan hệ hai chiều rất dễ trở thành mục tiêu tấn công DoS qua query lồng vô tận:
posts { author { posts { author { posts { ... } } } } }. Hãy cài đặt ngay packagegraphql-depth-limitđể giới hạn độ sâu tối đa (thường từ 4 đến 5 tầng lồng nhau) trước khi request kịp chạm tới resolver.
