1. Giao diện như một hợp đồng: nền tảng của kiến trúc
Trong Java (và không chỉ vậy), interface — không chỉ là một tập phương thức. Đó là hợp đồng: cam kết rằng mọi lớp triển khai interface đều hỗ trợ một hành vi nhất định. Interface xác định cái gì cần được hiện thực, chứ không phải cách hiện thực.
Vì sao điều này quan trọng?
- Phân tách mã theo tầng. Nhờ interface, ta có thể tách “làm gì” khỏi “làm như thế nào”. Ví dụ, nếu bạn có interface PaymentService, thì các hiện thực khác nhau có thể xử lý thanh toán bằng thẻ ngân hàng, PayPal hoặc tiền mã hoá, nhưng mã gọi pay() không cần bận tâm chi tiết.
- Linh hoạt và khả năng mở rộng. Bạn có thể thêm một hiện thực mới của interface mà không cần thay đổi phần mã còn lại. Điều này đặc biệt quan trọng trong các đội lớn và dự án sống lâu.
- Khả năng kiểm thử. Nhờ interface, dễ dàng thay thế hiện thực bằng bản kiểm thử (mock) mà không đụng vào mã chính.
Ví dụ: tầng service và DAO
Xem ví dụ kinh điển trong ứng dụng nghiệp vụ. Giả sử ta có một interface để làm việc với người dùng:
public interface UserRepository {
User findById(int id);
void save(User user);
}
Trong các tình huống khác nhau, ta có thể hiện thực interface này theo nhiều cách:
- DatabaseUserRepository — lưu người dùng trong cơ sở dữ liệu.
- InMemoryUserRepository — lưu người dùng trong bộ nhớ (tiện cho kiểm thử).
- FileUserRepository — lưu người dùng vào tệp.
Mã làm việc với người dùng chỉ phụ thuộc vào interface:
public class UserService {
private final UserRepository userRepository;
// Tiêm phụ thuộc qua constructor
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public void registerUser(User user) {
userRepository.save(user);
}
}
Bây giờ ta có thể dễ dàng thay thế hiện thực của UserRepository mà không cần sửa mã của service.
2. Dependency Injection (tiêm phụ thuộc) và vai trò của interface
Dependency Injection (DI, tiêm phụ thuộc) — là một kỹ thuật kiến trúc, trong đó các phụ thuộc (ví dụ, các hiện thực của interface) được truyền vào đối tượng từ bên ngoài, thường qua constructor hoặc setter. Điều này cho phép xây dựng ứng dụng linh hoạt, dễ kiểm thử và dễ mở rộng.
Vì sao interface quan trọng đối với DI?
Nếu ta cố định hiện thực trong mã, việc thay thế sẽ khó khăn. Với interface, ta có thể cắm bất kỳ hiện thực nào mà không cần thay đổi mã chính.
Ví dụ về tiêm phụ thuộc
public interface NotificationSender {
void send(String message);
}
public class EmailNotificationSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("Gửi Email: " + message);
}
}
public class SmsNotificationSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("Gửi SMS: " + message);
}
}
// Lớp sử dụng NotificationSender
public class NotificationService {
private final NotificationSender sender;
public NotificationService(NotificationSender sender) {
this.sender = sender;
}
public void notifyUser(String message) {
sender.send(message);
}
}
Bây giờ bạn có thể dễ dàng kiểm thử NotificationService bằng cách truyền vào, chẳng hạn, một “stub” thay vì trình gửi thông báo thực.
3. Mẫu thiết kế và interface
Interface không chỉ về kiến trúc, mà còn là nền tảng cho các mẫu thiết kế. Nhiều mẫu không thể hiện thực nếu không có interface. Hãy xem những mẫu phổ biến.
Observer (Người quan sát)
Observer — mẫu cho phép một đối tượng (được quan sát) thông báo cho các đối tượng khác (quan sát viên) về thay đổi trạng thái của nó.
Sơ đồ UML (đơn giản hoá):
+------------------+ +------------------------+
| Subject |<------->| Observer |
+------------------+ +------------------------+
| +addObserver() | | +update() |
| +removeObserver()| +------------------------+
| +notifyObservers()|
+------------------+
Ví dụ mã:
import java.util.ArrayList;
import java.util.List;
// Interface của observer
public interface Observer {
void update(String event);
}
// Interface của subject
public interface Subject {
void addObserver(Observer observer);
void removeObserver(Observer observer);
void notifyObservers(String event);
}
// Triển khai subject
public class NewsAgency implements Subject {
private List<Observer> observers = new ArrayList<>();
@Override
public void addObserver(Observer observer) {
observers.add(observer);
}
@Override
public void removeObserver(Observer observer) {
observers.remove(observer);
}
@Override
public void notifyObservers(String event) {
for (Observer observer : observers) {
observer.update(event);
}
}
}
// Triển khai observer
public class NewsReader implements Observer {
private String name;
public NewsReader(String name) {
this.name = name;
}
@Override
public void update(String event) {
System.out.println(name + " nhận tin: " + event);
}
}
// Lớp main để chạy ví dụ
public class ObserverExample {
public static void main(String[] args) {
// Tạo "hãng tin" (subject)
NewsAgency agency = new NewsAgency();
// Tạo các observer
Observer alice = new NewsReader("Alice");
Observer bob = new NewsReader("Bob");
// Đăng ký observer nhận tin
agency.addObserver(alice);
agency.addObserver(bob);
// Gửi tin
agency.notifyObservers("Đã phát hành phiên bản Java mới!");
// Gỡ một observer và gửi thêm tin
agency.removeObserver(bob);
agency.notifyObservers("Tin tiếp theo dành cho người đăng ký");
}
}
Kết quả:
Alice nhận tin: Đã phát hành phiên bản Java mới!
Bob nhận tin: Đã phát hành phiên bản Java mới!
Strategy (Chiến lược)
Strategy — mẫu cho phép lựa chọn thuật toán hành vi lúc chạy, mà không thay đổi mã phía khách.
Sơ đồ UML (đơn giản hoá):
+------------------+
| Context |
+------------------+
| -strategy: Strat.|
| +setStrategy() |
| +execute() |
+------------------+
|
v
+------------------+
| Strategy |<-------------------------+
+------------------+ |
| +execute() | |
+------------------+ |
^ |
| |
+------------------+ +------------------+ |
| ConcreteA | | ConcreteB |---+
+------------------+ +------------------+
| +execute() | | +execute() |
+------------------+ +------------------+
Ví dụ mã:
public interface PaymentStrategy {
void pay(int amount);
}
public class CreditCardPayment implements PaymentStrategy {
@Override
public void pay(int amount) {
System.out.println("Thanh toán " + amount + " rúp bằng thẻ ngân hàng");
}
}
public class PaypalPayment implements PaymentStrategy {
@Override
public void pay(int amount) {
System.out.println("Thanh toán " + amount + " rúp qua PayPal");
}
}
public class OnlineStore {
private PaymentStrategy paymentStrategy;
public void setPaymentStrategy(PaymentStrategy strategy) {
this.paymentStrategy = strategy;
}
public void checkout(int amount) {
paymentStrategy.pay(amount);
}
}
// Sử dụng:
OnlineStore store = new OnlineStore();
store.setPaymentStrategy(new CreditCardPayment());
store.checkout(1000);
store.setPaymentStrategy(new PaypalPayment());
store.checkout(500);
Kết quả:
Thanh toán 1000 rúp bằng thẻ ngân hàng
Thanh toán 500 rúp qua PayPal
Command (Lệnh)
Command — mẫu đóng gói một yêu cầu thành đối tượng, cho phép truyền hành động như tham số.
Ví dụ mã:
public interface Command {
void execute();
}
public class LightOnCommand implements Command {
@Override
public void execute() {
System.out.println("Đèn đã bật!");
}
}
public class LightOffCommand implements Command {
@Override
public void execute() {
System.out.println("Đèn đã tắt!");
}
}
public class RemoteControl {
private Command command;
public void setCommand(Command command) {
this.command = command;
}
public void pressButton() {
command.execute();
}
}
// Sử dụng:
RemoteControl remote = new RemoteControl();
remote.setCommand(new LightOnCommand());
remote.pressButton(); // Đèn đã bật!
remote.setCommand(new LightOffCommand());
remote.pressButton(); // Đèn đã tắt!
4. Lợi ích của việc dùng interface trong kiến trúc
- Kết nối lỏng (Low Coupling). Mã chỉ phụ thuộc vào interface, không phụ thuộc hiện thực cụ thể. Điều này giúp việc thay thế, kiểm thử và mở rộng trở nên dễ dàng.
- Khả năng kiểm thử. Dễ dàng thay hiện thực thật bằng mock/stub khi viết unit test.
- Khả năng mở rộng. Có thể thêm hiện thực mới cho interface mà không đổi mã hiện có — nguyên tắc mở-đóng (OCP).
- Phát triển song song. Nhiều nhóm có thể độc lập hiện thực các phần khác nhau của hệ thống nếu họ có cùng interface.
- Tính linh hoạt của kiến trúc. Dễ dàng áp dụng các mẫu và phương pháp mới.
5. Lỗi thường gặp khi dùng interface trong kiến trúc
Lỗi № 1: Gắn chặt vào một hiện thực cụ thể.
Nếu bạn sử dụng các lớp cụ thể ở khắp nơi thay vì interface, thì bất kỳ thay đổi nào của hiện thực cũng đòi hỏi viết lại mã ở nhiều chỗ. Hãy luôn cố gắng lập trình “ở mức interface”.
Lỗi № 2: Interface quá lớn (God Interface).
Interface nên gọn và chịu trách nhiệm một lĩnh vực. Đừng nhồi nhét mọi thứ vào một interface — nếu không, hiện thực sẽ nặng nề và rối rắm.
Lỗi № 3: Bỏ qua lợi ích về khả năng kiểm thử.
Nếu bạn không dùng interface để thay thế phụ thuộc trong kiểm thử, test của bạn có thể chậm và thiếu ổn định, đặc biệt khi làm việc với cơ sở dữ liệu thực hoặc mạng.
Lỗi № 4: Có nhiều hiện thực nhưng lại thiếu DI.
Nếu bạn tạo nhiều hiện thực cho một interface nhưng lại cố định một hiện thực trong mã, bạn mất toàn bộ sự linh hoạt của kiến trúc. Hãy sử dụng tiêm phụ thuộc (DI)!
GO TO FULL VERSION