Mục lục
Hành Trình Chuyển Đổi Ngôn Ngữ và Khám Phá Nguyên Tắc Đảo Ngược Phụ Thuộc
Sau nhiều năm làm việc với Java, một cơ hội nghề nghiệp mới đã đưa tôi đến với Golang. Đây là một sự chuyển đổi thú vị nhưng cũng đầy thách thức, bởi lẽ hai ngôn ngữ này có nhiều điểm khác biệt cơ bản về xử lý lỗi, mô hình đồng thời (concurrency), cơ chế đóng gói (encapsulation), và nhiều khía cạnh khác. Tuy nhiên, trong bài viết này, tôi muốn tập trung vào một nguyên tắc thiết kế phần mềm cụ thể đã thực sự tỏa sáng trong Golang: Nguyên tắc Đảo ngược Phụ thuộc (Dependency Inversion Principle – DIP).
[](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu2noaolpsvtfil5vriim.jpg)
Tầm Quan Trọng Cốt Lõi Của Nguyên Tắc Đảo Ngược Phụ Thuộc (DIP)
Tôi luôn coi Nguyên tắc Đảo ngược Phụ thuộc là một trong những nguyên tắc quan trọng nhất trong lập trình hiện đại. Mặc dù không có mã nào là hoàn hảo và đôi khi chúng ta phải linh hoạt trong việc tuân thủ các quy tắc, nhưng DIP là yếu tố then chốt giúp mã nguồn của bạn dễ kiểm thử (testable) và có tính module hóa cao (modularised). Nếu bỏ qua nguyên tắc này, toàn bộ cơ sở mã (codebase) có thể nhanh chóng trở nên lộn xộn, ngay cả khi bạn vẫn kiên trì tuân thủ các nguyên tắc SOLID khác. DIP đảm bảo rằng các module cấp cao không phụ thuộc vào các module cấp thấp, mà cả hai đều phụ thuộc vào các abstraction (trừu tượng hóa).
Mục tiêu của bài viết này là chia sẻ cách các tính năng độc đáo của Golang có thể giúp bạn đẩy nguyên tắc Đảo ngược Phụ thuộc lên một tầm cao mới, mang lại sự linh hoạt và tính bảo trì vượt trội cho các dự án phần mềm.
Đừng lo lắng nếu bạn chưa quen thuộc với Golang hay Java. Tôi đã cố gắng giữ cho các ví dụ đơn giản và dễ hiểu, có thể tự giải thích mà không cần kiến thức nền tảng sâu rộng về cả hai ngôn ngữ.
Kịch Bản Thử Nghiệm: Quy Trình Giao Hàng Đơn Giản
Để minh họa cho vấn đề và giải pháp, chúng ta sẽ xem xét một kịch bản thực tế bao gồm ba module (hoặc package) chính: `orders` (đơn hàng), `users` (người dùng) và `delivery` (giao hàng). Nhiệm vụ của chúng ta là tạo ra một trường hợp sử dụng để đưa một `Order` hiện có vào quy trình của bộ phận `Delivery`. Các bước thực hiện như sau:
- Tìm kiếm `Order` (đơn hàng) theo ID.
- Lấy thông tin `User` (người dùng) đã tạo `Order` đó.
- Kiểm tra xem `User` có bị chặn hay không.
- Kiểm tra xem bộ phận `Delivery` có đủ tài nguyên để xử lý quy trình giao hàng cho `Order` đó không.
- Nếu mọi thứ đều ổn, tiến hành đưa `Order` vào quy trình `Delivery`.
Java: Cách Tiếp Cận Truyền Thống và Những Hạn Chế Tiềm Ẩn
Trong Java, giả sử chúng ta có ba interface là `DeliveryService`, `UserService`, và `OrderRepository` cung cấp các phương thức cần thiết cho trường hợp sử dụng này (và có thể cả những trường hợp khác). Đây là khai báo tiềm năng:
public interface UserService {
User getById(long id);
void create(User user);
void update(User user);
}
public interface DeliveryService {
// Một số payload chung chung. Định dạng không quan trọng cho bài viết này
boolean hasResourcesToDeliver(String orderPayload);
void putOrderForDelivery(long orderId, String orderPayload);
void cancelDelivery(long orderId);
Status getDeliveryStatus(long orderId);
}
public interface OrderRepository {
Order findById(long orderId);
List<Order> findAll();
void create(Order order);
void update(Order order);
}
public interface OrderService {
void initDelivery(long orderId);
}
Bây giờ, hãy viết một triển khai `OrderService` có thể có:
public class OrderServiceImpl implements OrderService {
private final UserService userService;
private final DeliveryService deliveryService;
private final OrderRepository orderRepository;
public OrderServiceImpl(UserService userService, DeliveryService deliveryService, OrderRepository orderRepository) {
this.userService = userService;
this.deliveryService = deliveryService;
this.orderRepository = orderRepository;
}
@Override
public void initDelivery(long orderId) {
Order order = orderRepository.findById(orderId);
User user = userService.getById(order.getPostedById());
if (user.isBlocked()) {
throw new InitDeliveryException("User is blocked");
}
if (!deliveryService.hasResourcesToDeliver(order.getPayload())) {
throw new InitDeliveryException("No resources to deliver");
}
deliveryService.putOrderForDelivery(orderId, order.getPayload());
}
}
(Lưu ý: Trong một ứng dụng thực tế, các lệnh gọi giữa các dịch vụ khác nhau có thể đưa hệ thống vào trạng thái không nhất quán nếu một lệnh gọi thất bại. Chúng ta sẽ bỏ qua những vấn đề đó để giữ cho ví dụ đơn giản.)
Bạn có thể nhận thấy rằng `OrderServiceImpl` không yêu cầu tất cả các phương thức trong các interface đã được inject. Một số phương thức hoàn toàn không được sử dụng, ví dụ như `OrderRepository.findAll`, `DeliveryService.getDeliveryStatus`, hoặc `UserService.update`. Điều này có nghĩa là module `orders` đang ngầm định nhập khẩu những phương thức không cần thiết từ các module khác.
Nếu bạn là một nhà phát triển Java, khả năng cao bạn sẽ chấp nhận điều này (tôi cũng vậy). Chắc chắn, bạn có thể khai báo một interface khác với phạm vi `package-private` chỉ được sử dụng cụ thể bởi `OrderServiceImpl`. Tuy nhiên, điều này sẽ dẫn đến một trong hai kết quả sau:
Các Triển Khai Thực Tế Phải Thực Hiện Cả Các Interface Cụ Thể Đó
Điều này sẽ trông như thế này:
public class UserServiceImpl implements UserService, LocalUserService {
// ...
}
Trong đó `LocalUserService` là interface cụ thể chỉ chứa phương thức `getById`. Cách tiếp cận này buộc module `users` phải phụ thuộc vào `orders` (vì nó phải import `LocalUserService`), điều này không tốt cho sự cô lập của các module.
Tạo Các Triển Khai Riêng Biệt Của Các Interface Đó và Ủy Quyền Lệnh Gọi
Đây là một cách khác:
// package-private interface được sử dụng bởi OrderServiceImpl
interface LocalUserService {
User getById(long id);
}
class LocalUserServiceImpl implements LocalUserService {
private final UserService delegate;
public LocalUserServiceImpl(UserService delegate) {
this.delegate = delegate;
}
@Override
public User getById(long id) {
return delegate.getById(id);
}
}
Giải pháp này không phá vỡ ranh giới module, nhưng nó đòi hỏi rất nhiều mã boilerplate (mã lặp lại không cần thiết) và có thể làm giảm độ bao phủ kiểm thử. Về mặt kỹ thuật, đây là một giải pháp hợp lệ, nhưng tôi cho rằng nó không thực tế cho đại đa số các trường hợp.
Giải Pháp Đột Phá Với Golang: Sức Mạnh Từ Interface Ngầm Định
Nhìn về phía trước, Golang cho phép chúng ta giải quyết vấn đề này một cách thanh lịch và hiệu quả hơn nhiều. Nhưng hãy đi từng bước một, bắt đầu với thiết lập tương tự như Java.
type OrderService interface {
InitDelivery(orderId int64) error
}
type orderServiceImpl struct {
userService UserService // Đây cũng là các kiểu interface
deliveryService DeliveryService
orderRepository OrderRepository
}
func New(u UserService, d DeliveryService, or OrderRepository) OrderService {
return &orderServiceImpl{
userService: u,
deliveryService: d,
orderRepository: or,
}
}
// Triển khai phương thức của OrderService
func (o *orderServiceImpl) InitDelivery(orderId int64) error {
order := o.orderRepository.FindById(orderId)
user := o.userService.GetById(order.PostedById)
if user.Blocked {
return errors.New("user is blocked")
}
// Chú ý: Có lỗi nhỏ trong code gốc, cần chuyển order.Payload thay vì orderId
if !o.deliveryService.HasResourcesForDelivery(order.Payload) {
return errors.New("no resources to deliver")
}
o.deliveryService.PutOrderForDelivery(orderId, order.Payload)
return nil
}
// Các interface UserService, DeliveryService, OrderRepository đầy đủ
type User struct {
ID int64
Blocked bool
// ...
}
type Order struct {
ID int64
PostedById int64
Payload string
// ...
}
type UserService interface {
GetById(id int64) User
Create(user User)
Update(user User)
}
type DeliveryService interface {
HasResourcesForDelivery(orderPayload string) bool
PutOrderForDelivery(orderId int64, orderPayload string)
CancelDelivery(orderId int64)
GetDeliveryStatus(orderId int64) Status // Giả sử Status là một kiểu dữ liệu nào đó
}
type OrderRepository interface {
FindById(orderId int64) Order
FindAll() []Order
Create(order Order)
Update(order Order)
}
Cho đến lúc này, không có sự khác biệt lớn giữa cách tiếp cận của Java và Golang. Nhưng đây chính là lúc một tính năng thú vị của Golang phát huy tác dụng. Bạn thấy đấy, Java đòi hỏi việc triển khai interface một cách *minh bạch (explicit)*. Ngược lại, Golang không làm vậy. Nếu một struct có tất cả các phương thức của một interface với cùng chữ ký, trình biên dịch sẽ hiểu rằng struct đó *ngầm định (implicitly)* triển khai interface đó.
Sự khác biệt nhỏ này cho phép chúng ta tái cấu trúc `OrderService` theo cách sau:
- Tạo các interface cục bộ (local private interfaces) chỉ chứa các phương thức *cần thiết* được gọi bên trong hàm `InitDelivery`.
- Chấp nhận các interface cục bộ này làm tham số của hàm `New`.
- Truyền các triển khai thực tế khi gọi hàm `New` mà không cần thay đổi mã nguồn của các triển khai đó.
Hãy xem sơ đồ dưới đây để hiểu rõ nguyên tắc:
[](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1haem0z9ql4grvpe14qy.png)
Và đây là mã nguồn đã được tái cấu trúc:
package orders
import (
"errors"
// Giả sử có các kiểu Order, User từ các package khác
// hoặc được định nghĩa ở đây để đơn giản
)
// Các kiểu dữ liệu cần thiết cho ví dụ
type User struct {
ID int64
Blocked bool
// ...
}
type Order struct {
ID int64
PostedById int64
Payload string
// ...
}
// OrderService định nghĩa API cho module orders
type OrderService interface {
InitDelivery(orderId int64) error
}
// orderServiceImpl là triển khai cụ thể của OrderService
type orderServiceImpl struct {
userService localUserService
deliveryService localDeliveryService
orderRepository localOrderRepository
}
// Các interface cục bộ chỉ định nghĩa các phương thức cần thiết
type localUserService interface {
GetById(userId int64) User // Đã sửa tên tham số cho rõ ràng hơn
}
type localDeliveryService interface {
HasResourcesForDelivery(orderPayload string) bool
PutOrderForDelivery(orderId int64, orderPayload string) // Đã sửa tham số PutOrderForDelivery
}
type localOrderRepository interface {
FindById(orderId int64) Order
}
// Hàm khởi tạo New chấp nhận các interface cục bộ
func New(u localUserService, d localDeliveryService, or localOrderRepository) OrderService {
return &orderServiceImpl{
userService: u,
deliveryService: d,
orderRepository: or,
}
}
// Triển khai phương thức InitDelivery
func (o *orderServiceImpl) InitDelivery(orderId int64) error {
order := o.orderRepository.FindById(orderId)
user := o.userService.GetById(order.PostedById)
if user.Blocked {
return errors.New("user is blocked")
}
// Lỗi logic nhỏ trong code gốc, cần sử dụng order.Payload thay vì orderId
if !o.deliveryService.HasResourcesForDelivery(order.Payload) {
return errors.New("no resources to deliver")
}
// Lỗi logic nhỏ trong code gốc, cần sử dụng order.Payload thay vì orderId
o.deliveryService.PutOrderForDelivery(orderId, order.Payload)
return nil
}
// Ví dụ về các triển khai thực tế (có thể nằm trong các package khác)
// Để minh họa, giả sử có sẵn các struct sau:
type RealUserService struct {}
func (r *RealUserService) GetById(id int64) User { /* ... */ return User{ID: id, Blocked: false} }
func (r *RealUserService) Create(user User) { /* ... */ }
func (r *RealUserService) Update(user User) { /* ... */ }
type RealDeliveryService struct {}
func (r *RealDeliveryService) HasResourcesForDelivery(payload string) bool { /* ... */ return true }
func (r *RealDeliveryService) PutOrderForDelivery(orderId int64, payload string) { /* ... */ }
func (r *RealDeliveryService) CancelDelivery(orderId int64) { /* ... */ }
func (r *RealDeliveryService) GetDeliveryStatus(orderId int64) string { /* ... */ return "delivered" }
type RealOrderRepository struct {}
func (r *RealOrderRepository) FindById(orderId int64) Order { /* ... */ return Order{ID: orderId, PostedById: 123, Payload: "some_payload"} }
func (r *RealOrderRepository) FindAll() []Order { /* ... */ return nil }
func (r *RealOrderRepository) Create(order Order) { /* ... */ }
func (r *RealOrderRepository) Update(order Order) { /* ... */ }
// Khi khởi tạo OrderService ở main hoặc một package cao hơn:
/*
func main() {
realUserSvc := &RealUserService{}
realDeliverySvc := &RealDeliveryService{}
realOrderRepo := &RealOrderRepository{}
// Các triển khai thực tế vẫn thỏa mãn các local interfaces
// do chúng chứa tất cả các phương thức cần thiết
orderSvc := New(realUserSvc, realDeliverySvc, realOrderRepo)
orderSvc.InitDelivery(1)
}
*/
Sự thay đổi này có vẻ không quá lớn, nhưng nó thực sự thay đổi cách bạn tư duy về thiết kế phần mềm.
Lợi Ích Vượt Trội Của Golang Trong Việc Áp Dụng DIP
1. Giảm Sự Phụ Thuộc Trực Tiếp và Nâng Cao Khả Năng Tái Cấu Trúc
Module của bạn không còn phụ thuộc trực tiếp vào các module khác với toàn bộ interface của chúng. Điều này giúp dễ dàng hơn trong việc tái cấu trúc và mở rộng các interface bên ngoài. Bạn chỉ quan tâm đến *những gì bạn cần* từ module khác, không phải *tất cả những gì nó cung cấp*.
2. Đơn Giản Hóa Quá Trình Kiểm Thử
Nếu bạn có một interface cục bộ với các phương thức chuyên dụng, sẽ không cần phải mock/stub các phương thức không được gọi trong trường hợp sử dụng cụ thể này khi viết unit test. Điều này không chỉ làm cho các bài kiểm thử trở nên gọn gàng, dễ đọc hơn mà còn giảm thiểu khả năng lỗi do mock sai hoặc thiếu sót.
3. Khả Năng Chống Chịu Với Thay Đổi Giao Diện Bên Ngoài
Ngay cả khi chữ ký của một interface bên ngoài thay đổi, nó cũng sẽ không phá vỡ mã nguồn và các bài kiểm thử trong module hiện tại của bạn. Chắc chắn, bạn sẽ không thể truyền cấu trúc đó làm tham số phương thức nữa, điều này có thể dẫn đến lỗi biên dịch tại thời điểm gọi hàm `New`. Tuy nhiên, các module khác sử dụng phương thức đó vẫn không bị ảnh hưởng. Điều này cho phép bạn quyết định xem có cần tái cấu trúc hay tốt hơn là truyền một triển khai proxy trong quá trình khởi tạo, mang lại sự linh hoạt đáng kể.
Kết Luận và Suy Ngẫm Về Nguyên Tắc Thiết Kế
Ban đầu, tôi thấy việc triển khai interface ngầm định trong Golang có vẻ bất tiện, đặc biệt là khi so sánh với Java, nơi mọi thứ đều minh bạch. Nhưng khi bạn bắt đầu giải quyết các vấn đề thiết kế kiến trúc phần mềm phức tạp, bạn sẽ hiểu được lý do sâu xa đằng sau tính năng này. Nó mang lại sự linh hoạt đáng kinh ngạc trong việc quản lý các phụ thuộc và duy trì tính module hóa.
Tất nhiên, điều này không có nghĩa là bạn phải tạo một interface cục bộ cho mọi tương tác. Đôi khi, việc dựa vào interface bên ngoài là đủ và dễ dàng hơn. Nhưng việc có khả năng làm điều đó một cách khác, khi cần thiết, là một lợi thế cực kỳ lớn mà Golang mang lại.
Cảm ơn bạn đã đọc bài viết này. Nếu bạn đã chuyển đổi từ Java sang Golang (hoặc ngược lại), hãy để lại suy nghĩ của bạn ở phần bình luận bên dưới. Sẽ rất thú vị khi được thảo luận về các quan điểm khác nhau! Chúc bạn một ngày tốt lành!