[Series Thực Chiến] Chinh phục Queue - Phần 2: "Đóng gói" công việc chuyên nghiệp với Job Classes
Chào anh em, chúng ta lại gặp nhau trong series "Chinh phục Queue".
Ở phần trước, chúng ta đã thấy Queue giải cứu các API chậm chạp như thế nào bằng cơ chế xử lý bất đồng bộ (Asynchronous). Nếu anh em nào lỡ quên hoặc chưa xem, hãy quay lại Phần 1 để nắm được "tư tưởng" trước khi đi tiếp nhé.
Hôm nay, chúng ta sẽ đi sâu vào phần code thực tế. Làm sao để tổ chức code Queue cho gọn gàng, dễ bảo trì và đặc biệt là trông thật "Senior"? Câu trả lời chính là Job Classes.
1. Vấn đề của cách viết code "Nghiệp dư"
Ở cuối bài trước, mình có để lại một đoạn code demo giả định thế này:
// Cách viết bộc phát, thiếu tổ chức
Queue.push('SEND_WELCOME_EMAIL', { email: user.email, name: user.name });
Nhiều anh em mới tiếp cận Queue thường mắc phải sai lầm này: Bắn trực tiếp một cái tên String (Event Name) và một cục Data (Payload) vào Queue, sau đó viết một đống hàm rải rác khắp nơi để "bắt" và xử lý.
Tại sao cách này lại "dở"?
- Khó bảo trì: Khi project to lên với 50 loại Queue khác nhau (gửi mail, resize ảnh, export excel...), bạn sẽ lạc lối không biết đoạn code nào đang xử lý cái Data nào.
- Thiếu tính đóng gói: Bạn không thể dễ dàng định nghĩa các "luật" riêng cho từng tác vụ (ví dụ: Job gửi mail thì cho phép lỗi thử lại 3 lần, còn Job charge tiền thẻ tín dụng thì tuyệt đối không được tự động thử lại).
- Khó viết Unit Test: Logic dính chặt vào Framework hoặc thư viện Queue.
Để giải quyết vấn đề này, các Framework Backend hiện đại (như Laravel của PHP, hay NestJS của Node.js) đều áp dụng một Design Pattern rất hay: Tạo một Class riêng biệt để đại diện cho một Công việc (Job).
2. Job Class là gì?
Hiểu đơn giản, Job Class là một chiếc hộp (Object) chứa 2 thứ:
- Dữ liệu cần thiết (Payload/State): Ví dụ như Email của user, nội dung tin nhắn.
- Hành động (Action): Những gì cần làm với mớ dữ liệu đó.
Nguyên tắc cốt lõi ở đây là Single Responsibility Principle (SRP) - Mỗi Job Class chỉ chịu trách nhiệm làm đúng một việc duy nhất.
3. Cấu trúc chuẩn của một Job Class
Dù bạn đang dùng ngôn ngữ nào (Go, PHP, Node.js, C++...), một Job Class tiêu chuẩn thường sẽ có cấu trúc 3 phần chính như sau. Để anh em dễ hình dung, mình sẽ dùng TypeScript (rất gần gũi với anh em viết Node.js hoặc NestJS) để demo nhé:
// Định nghĩa một Interface cơ bản cho mọi Job trong hệ thống
export interface JobInterface {
handle(): Promise<void>;
}
// === TẠO JOB CLASS: SendWelcomeEmailJob ===
export class SendWelcomeEmailJob implements JobInterface {
// 1. CÁC THÔNG SỐ CẤU HÌNH CỦA JOB (Tùy chọn)
public tries: number = 3; // Nếu lỗi, tự động thử lại tối đa 3 lần
public backoff: number = 5000; // Mỗi lần thử lại cách nhau 5000ms (5s)
public queueName: string = 'emails'; // Đưa vào hàng đợi ưu tiên thấp
// 2. DỮ LIỆU ĐẦU VÀO (State)
private userEmail: string;
private userName: string;
// Constructor để nhận data khi khởi tạo Job
constructor(email: string, name: string) {
this.userEmail = email;
this.userName = name;
}
// 3. LOGIC XỬ LÝ CHÍNH (Action)
// Thằng Worker khi bốc Job này ra, nó chỉ cần gọi hàm handle() là xong!
public async handle(): Promise<void> {
try {
console.log(`[Job Đang Chạy] Đang gửi email tới ${this.userEmail}...`);
// Gọi Service gửi mail thực tế ở đây (AWS SES, SendGrid, Mailgun...)
await MailService.send({
to: this.userEmail,
subject: `Chào mừng ${this.userName} đến với hệ thống!`,
template: 'welcome_template'
});
console.log(`[Job Thành Công] Đã gửi xong cho ${this.userEmail}`);
} catch (error) {
console.error(`[Job Thất Bại] Không thể gửi mail cho ${this.userEmail}`);
// Ném lỗi ra ngoài để hệ thống Queue biết mà tiến hành Retry (nếu còn số lần tries)
throw error;
}
}
}
4. Sự lột xác của Controller (API)
Khi đã đóng gói mọi thứ vào SendWelcomeEmailJob, đoạn code ở API Đăng ký user của bạn sẽ trở nên "sạch sẽ" và thanh lịch đến bất ngờ:
import { SendWelcomeEmailJob } from './jobs/SendWelcomeEmailJob';
app.post('/api/register', async (req, res) => {
// 1. Tạo user (nhanh gọn)
const user = await Database.createUser(req.body);
// 2. Khởi tạo Job Object với Data
const emailJob = new SendWelcomeEmailJob(user.email, user.name);
// 3. Đẩy nguyên cái Object Job này vào Queue (Thay vì truyền String lằng nhằng)
await QueueDispatcher.dispatch(emailJob);
return res.status(200).json({ message: "Đăng ký thành công!" });
});
Tại sao cách viết này mang đậm chất "Senior"?
- Clean Code: Controller giờ đây siêu mỏng (Thin Controller). Nó không thèm quan tâm việc gửi mail diễn ra như thế nào, nó chỉ biết "Khởi tạo Job và ném đi".
- Dễ tái sử dụng: Giả sử ngày mai sếp yêu cầu viết một tool chạy bằng dòng lệnh (CLI) để gửi lại email cho những ai chưa nhận được. Bạn chỉ cần gọi lại class
SendWelcomeEmailJobở file CLI mà không cần copy/paste lại logic gửi mail. - Rõ ràng rành mạch: Nhìn vào cấu trúc thư mục
app/jobs/, bạn biết chính xác hệ thống của mình có thể xử lý ngầm những tác vụ gì.
Hé lộ bài tiếp theo...
Vậy là chúng ta đã biết cách tạo ra một Job Class cực chuẩn. Nhưng hãy khoan...
Ở đoạn code trên có một dòng ảo thuật: QueueDispatcher.dispatch(emailJob). Làm thế quái nào mà một cái Class (Object) trong bộ nhớ RAM của Node.js lại có thể "chui" vào trong một hệ thống Queue (như Redis hay RabbitMQ) nằm ở một server khác? Và làm sao để khi Worker lấy ra, nó vẫn còn nguyên hình hài là cái Class đó để gọi hàm handle()?
Quá trình "biến hình" cực kỳ thú vị này sẽ được giải mã chi tiết trong Phần 3: Đưa Job vào trong Queue - Nghệ thuật Serialize và Dispatching.
Anh em nhớ upvote và bookmark bài viết để hóng phần tiếp theo nhé. Chúc anh em tối ưu hệ thống mượt mà!
All rights reserved