1. Trừu tượng nhiều tầng
Khi bạn mới bắt đầu lập trình, mọi thứ có vẻ đơn giản: viết một lớp, gọi một phương thức, nhận kết quả. Nhưng trong các dự án thực tế, mọi thứ trở nên phức tạp: hàng chục lớp, hàng trăm phương thức, hàng nghìn dòng mã... Và nếu có cả một nhóm cùng làm — bài toán còn khó hơn nữa! Làm sao không chìm trong mớ hỗn độn này?
Câu trả lời — chia nhỏ cái phức tạp thành cái đơn giản, và tốt hơn nữa — thành các cấp độ trừu tượng.
Các cấp độ trừu tượng là gì?
Hãy hình dung như các tầng trong một tòa nhà lớn: mỗi tầng có “cuộc sống” riêng, nhưng tất cả đều liên kết với nhau. Trong lập trình, thường chia thành các tầng (cấp độ) sau:
- Giao diện người dùng (UI) — những gì người dùng nhìn thấy.
- Logic nghiệp vụ — các quy tắc và quy trình hiện thực hóa bản chất của ứng dụng.
- Truy cập dữ liệu (DAO, Repository) — làm việc với cơ sở dữ liệu hoặc tệp.
Mỗi tầng làm việc với các trừu tượng, không cần biết chi tiết của các tầng khác. Ví dụ, logic nghiệp vụ không quan tâm UI được hiện thực ra sao hay dữ liệu được lưu ở đâu — điều quan trọng là có các phương thức như saveOrder() hoặc findUserById().
So sánh đời thường
Hãy tưởng tượng một nhà hàng. Khách (UI) đặt món qua phục vụ (trừu tượng của giao diện), đầu bếp (logic nghiệp vụ) nấu ăn, còn thủ kho (truy cập dữ liệu) theo dõi nguyên liệu trong kho. Khách không biết đầu bếp nấu thế nào, và đầu bếp cũng không quan tâm kho để khoai tây ở đâu — miễn là khi cần thì có ngay.
2. Ví dụ: kiến trúc nhiều tầng trong thực tế
Hãy phát triển dự án học tập của chúng ta — chẳng hạn ứng dụng quản lý nhiệm vụ (task manager). Ta đã biết tạo các lớp cho nhiệm vụ, giờ làm bức tranh phức tạp hơn và chia ứng dụng thành các tầng.
Xác định các trừu tượng
- Task — mô tả trừu tượng của một nhiệm vụ: có tiêu đề, trạng thái, các phương thức để hoàn thành.
- TaskRepository — trừu tượng để lưu trữ nhiệm vụ (không quan trọng ở đâu — trong bộ nhớ, tệp, cơ sở dữ liệu).
- TaskService — logic nghiệp vụ: thêm nhiệm vụ, tìm kiếm, thực thi.
Các lớp trừu tượng và interface
// Tầng nghiệp vụ
public abstract class Task {
private String title;
private boolean completed;
public Task(String title) {
this.title = title;
this.completed = false;
}
public abstract void complete();
public String getTitle() { return title; }
public boolean isCompleted() { return completed; }
protected void setCompleted(boolean completed) { this.completed = completed; }
}
// Tầng lưu trữ dữ liệu (trừu tượng)
public interface TaskRepository {
void save(Task task);
Task findByTitle(String title);
List<Task> findAll();
}
Hiện thực các tầng
Hiện thực Task
public class WorkTask extends Task {
private String deadline;
public WorkTask(String title, String deadline) {
super(title);
this.deadline = deadline;
}
@Override
public void complete() {
setCompleted(true);
System.out.println("Nhiệm vụ công việc '" + getTitle() + "' đã được hoàn thành trước hạn " + deadline);
}
}
Hiện thực TaskRepository
public class InMemoryTaskRepository implements TaskRepository {
private List<Task> tasks = new ArrayList<>();
@Override
public void save(Task task) {
tasks.add(task);
}
@Override
public Task findByTitle(String title) {
for (Task task : tasks) {
if (task.getTitle().equals(title)) {
return task;
}
}
return null;
}
@Override
public List<Task> findAll() {
return new ArrayList<>(tasks);
}
}
Hiện thực TaskService
public class TaskService {
private TaskRepository repository;
public TaskService(TaskRepository repository) {
this.repository = repository;
}
public void addTask(Task task) {
repository.save(task);
}
public void completeTask(String title) {
Task task = repository.findByTitle(title);
if (task != null) {
task.complete();
} else {
System.out.println("Không tìm thấy nhiệm vụ: " + title);
}
}
public void showAllTasks() {
for (Task task : repository.findAll()) {
System.out.println(task.getTitle() + " — " + (task.isCompleted() ? "đã hoàn thành" : "chưa hoàn thành"));
}
}
}
Sử dụng trong lớp chính
public class Main {
public static void main(String[] args) {
TaskRepository repo = new InMemoryTaskRepository();
TaskService service = new TaskService(repo);
service.addTask(new WorkTask("Làm báo cáo", "2025-07-15"));
service.addTask(new WorkTask("Chuẩn bị bài thuyết trình", "2025-07-16"));
service.showAllTasks();
service.completeTask("Làm báo cáo");
service.showAllTasks();
}
}
Chúng ta thu được gì?
- Lớp chính (Main) không biết kho lưu nhiệm vụ được tổ chức thế nào — nó làm việc với trừu tượng TaskRepository.
- TaskService không biết có những loại nhiệm vụ nào — nó làm việc với lớp trừu tượng Task.
- Nếu ngày mai ta muốn lưu nhiệm vụ trong cơ sở dữ liệu thay vì bộ nhớ — chỉ cần hiện thực lớp mới DatabaseTaskRepository, không phải viết lại logic nghiệp vụ và UI.
- Nếu xuất hiện loại nhiệm vụ mới, ví dụ HomeTask, — chỉ cần thêm một lớp con mới.
3. Lợi ích cho làm việc nhóm
Trong các dự án lớn hiếm khi một người viết tất cả. Thông thường đội ngũ chia thành “frontend”, “backend”, “nhà phát triển lưu trữ dữ liệu” v.v. Các trừu tượng giúp họ không “dẫm chân” nhau như thế nào?
Phân tách trách nhiệm
Mỗi người làm việc ở cấp độ trừu tượng của mình.
- Một lập trình viên viết hiện thực TaskRepository để làm việc với cơ sở dữ liệu.
- Người khác phụ trách logic nghiệp vụ (TaskService).
- Người thứ ba phát triển giao diện người dùng.
“Hợp đồng” giữa các tầng được cố định bởi các trừu tượng.
Miễn là mọi người thống nhất rằng TaskRepository có các phương thức save, findByTitle, findAll — chi tiết hiện thực không quan trọng.
Dễ kiểm thử và thay thế thành phần
- Có thể dễ dàng thay một hiện thực bằng hiện thực khác (ví dụ, cho test dùng InMemoryTaskRepository, còn ở môi trường production — làm việc với cơ sở dữ liệu).
- Tester có thể thay tầng dữ liệu bằng “stub” (mock) để kiểm thử logic nghiệp vụ một cách độc lập.
Phát triển độc lập
- Nếu ai đó muốn thêm loại nhiệm vụ mới, họ không làm hỏng mã hiện tại — chỉ cần hiện thực một lớp con Task mới.
- Nếu xuất hiện cách lưu trữ dữ liệu mới, chỉ thay đổi hiện thực của interface, phần mã còn lại không bị đụng tới.
4. Best practices: làm sao không lạm dụng trừu tượng
Trừu tượng như muối trong món ăn: thiếu thì nhạt, quá tay thì hỏng. Một vài lời khuyên:
Dùng trừu tượng ở nơi thực sự giúp đơn giản hóa hệ thống.
Đừng tạo lớp trừu tượng chỉ vì “có trừu tượng cho hay”. Nếu bạn chỉ có một loại nhiệm vụ, có thể chưa cần trừu tượng.
Ghi tài liệu cho các lớp và phương thức trừu tượng.
Tài liệu tốt giúp hiểu chính xác phần kế thừa cần hiện thực gì và vì sao.
Cố gắng để trừu tượng có ý nghĩa.
Lớp trừu tượng phải thể hiện hành vi và/hoặc trạng thái thực sự chung.
Không trộn lẫn trách nhiệm.
Đừng thêm vào lớp trừu tượng các phương thức chỉ một số ít lớp con cần.
5. Trừu tượng trong hệ thống lớn: ví dụ thực tế
Hãy xem trừu tượng hoạt động thế nào trong một dự án thật sự lớn — ví dụ, cửa hàng trực tuyến.
Các tầng của hệ thống
- Controller (UI): nhận yêu cầu của người dùng (ví dụ, “đặt hàng”).
- Service (logic nghiệp vụ): kiểm tra tồn kho, tính giảm giá, tạo đơn hàng.
- Repository (truy cập dữ liệu): lưu đơn hàng, sản phẩm, người dùng vào cơ sở dữ liệu.
Ví dụ về các trừu tượng
// Trừu tượng cho dịch vụ xử lý đơn hàng
public interface OrderService {
void createOrder(Order order);
Order findOrderById(String id);
}
// Trừu tượng cho kho lưu trữ đơn hàng
public interface OrderRepository {
void save(Order order);
Order findById(String id);
}
Mỗi tầng chỉ biết về trừu tượng của chính nó. Nếu ngày mai quyết định lưu đơn hàng trên cloud — chỉ cần thay đổi hiện thực của OrderRepository.
Tương tác giữa các tầng — sơ đồ
[UI/Controller] <--> [OrderService (trừu tượng)] <--> [OrderRepository (trừu tượng)] <--> [Cơ sở dữ liệu]
- Mỗi tầng làm việc với một trừu tượng, không biết chi tiết của tầng bên dưới.
- Điều này cho phép phát triển, kiểm thử và cải tiến từng tầng một cách độc lập.
Trừu tượng và bảo trì mã
- Dễ thêm tính năng mới (các loại nhiệm vụ, thanh toán, phương thức vận chuyển mới).
- Dễ sửa lỗi (sửa bug ở một chỗ — mọi lớp kế thừa đều nhận cập nhật).
- Dễ kiểm thử (có thể thay thế các tầng bằng “stub” cho unit test).
6. Lỗi điển hình khi thiết kế trừu tượng
Lỗi №1: Trừu tượng hóa quá mức. Đôi khi ta muốn tạo lớp trừu tượng cho mọi thứ. Nhưng nếu bạn chỉ có một loại thực thể, đừng vì “mốt” mà tạo trừu tượng — chỉ làm mã phức tạp thêm.
Lỗi №2: Trừu tượng quá mơ hồ. Nếu lớp trừu tượng mô tả quá nhiều thứ và không có phạm vi trách nhiệm rõ ràng, các lớp con sẽ buộc phải hiện thực những phương thức không cần thiết hoặc chứa các trường “chết”.
Lỗi №3: Vi phạm nguyên tắc trách nhiệm đơn. Lớp trừu tượng chỉ nên chịu trách nhiệm cho một miền hành vi. Đừng trộn, ví dụ, phương thức lưu trữ và phương thức logic nghiệp vụ trong cùng một lớp trừu tượng.
Lỗi №4: Liên kết chặt giữa các tầng. Nếu tầng logic nghiệp vụ phụ thuộc trực tiếp vào hiện thực cụ thể của kho lưu trữ (ví dụ, dùng new InMemoryTaskRepository() bên trong), thì khi thay kho lưu trữ bạn sẽ phải viết lại toàn bộ mã. Hãy dùng trừu tượng (interface, lớp trừu tượng) để làm lỏng liên kết.
Lỗi №5: Thiếu tài liệu. Trừu tượng là một “hợp đồng”, và cần được mô tả rõ ràng. Nếu không viết rõ lớp con phải làm gì, rất dễ gặp lỗi bất ngờ hoặc “sáng tạo” ngoài ý muốn của đồng đội.
GO TO FULL VERSION