Cách thiết kế phân lớp trong Go
(jerf.org)- Ngôn ngữ Go nghiêm cấm tham chiếu vòng giữa các package, vì vậy tự nhiên thúc đẩy thiết kế phân lớp (layered design)
- Bài viết này giải thích cấu trúc phân tầng mà dự án Go gần như bắt buộc phải có, và cho rằng ngay cả khi không áp đặt thêm một kiến trúc riêng nào lên trên thì nó vẫn hoàn toàn hiệu quả
- Khi phát sinh phụ thuộc vòng, bài viết đưa ra các chiến lược refactor cụ thể và thực tế theo từng bước để giải quyết
- Mỗi package được thiết kế để có một đơn vị chức năng có ý nghĩa và độc lập, nên cũng thuận lợi cho kiểm thử, bảo trì, tách microservice
- Kết quả là cách làm này giúp ngăn vấn đề thường gặp trong thiết kế mã nguồn thực tế: "muốn một quả chuối nhưng lại phải mang cả khu rừng về"
Cách tiếp cận thiết kế phân lớp trong Go
Nguyên tắc cơ bản
- Go cấm tham chiếu vòng giữa các package
- Quan hệ import của mọi chương trình Go phải tạo thành đồ thị có hướng không chu trình (DAG)
- Cấu trúc này không phải là lựa chọn mà là quy tắc thiết kế được cưỡng chế ở cấp độ ngôn ngữ
Sự hình thành tự động của việc phân lớp package
- Ngoại trừ các package bên ngoài, các package nội bộ trong dự án có thể tự động được phân lớp theo độ sâu tham chiếu
- Như hình minh họa bên dưới, ở tầng thấp nhất là các package tiện ích cốt lõi như metrics, logging, cấu trúc dữ liệu dùng chung
- Sau đó các package cấp trên dần kết hợp chức năng và chồng lên phía trên
Đặc điểm của cách thiết kế này
- Các lớp được xác định dựa trên hướng tham chiếu chứ không phải trừu tượng hóa phân cấp
- Một package có thể tham chiếu nhiều package ở cấp thấp hơn
- Các cách thiết kế quen thuộc như MVC, kiến trúc lục giác cũng có thể "áp dụng" trên cấu trúc này
→ Tuy nhiên, bắt buộc phải cân nhắc các ràng buộc cấu trúc của Go
Chiến lược giải quyết tham chiếu vòng
Khi phát sinh tham chiếu vòng, hãy thử refactor theo thứ tự sau:
1. Di chuyển chức năng
- Cách được khuyến nghị nhất
- Phân tích chính xác chức năng gây ra vòng lặp, rồi chuyển nó đến vị trí phù hợp hơn về mặt logic
- Không dùng thường xuyên, nhưng cải thiện độ rõ ràng về mặt khái niệm nhiều nhất
2. Tách chức năng dùng chung thành package riêng
- Chuyển các kiểu hoặc hàm được cả hai bên cùng dùng (
Usernamechẳng hạn) sang package thứ ba - Dù package có vẻ nhỏ cũng nên mạnh dạn tách ra
→ Theo thời gian, package đó rất có thể sẽ lớn dần lên
3. Tạo package phối hợp ở tầng trên
- Tạo package thứ ba để phối hợp hai package đang phụ thuộc vòng với nhau
- Ví dụ: tách phụ thuộc hai chiều giữa
CategoryvàBlogPostsang package cấp trên
→ Các package cấp dưới vẫn giữ dạng dumb struct, còn chức năng thực tế được phối hợp ở package cấp trên
4. Giới thiệu interface
- Thay thế phụ thuộc bằng interface chỉ chứa các phương thức cần thiết cho struct hoặc hàm
- Loại bỏ phụ thuộc không cần thiết và tăng tính thuận tiện cho kiểm thử
- Tuy nhiên, nếu lạm dụng thì thiết kế có thể trở nên phức tạp hơn
5. Sao chép (Copy)
- Nếu đối tượng phụ thuộc rất nhỏ, có thể đơn giản sao chép để sử dụng
- Nhìn thì có vẻ vi phạm DRY, nhưng trong thực tế nhiều khi lại giúp làm rõ thiết kế
6. Gộp thành một package
- Nếu mọi cách trên đều không khả thi, hãy hợp nhất hai package
- Nếu package không trở nên quá lớn thì đây vẫn là lựa chọn chấp nhận được
→ Tuy nhiên, nên tránh gộp một cách máy móc và cần cân nhắc cẩn thận
Ưu điểm thực tiễn của cách thiết kế này
- Mỗi package là một đơn vị chức năng có ý nghĩa tự thân và có thể được kiểm thử độc lập
- Vì tham chiếu trong package bị giới hạn, nên có thể hiểu từng package riêng lẻ mà không cần nắm toàn bộ mã nguồn
- Tránh được việc toàn bộ phụ thuộc vô tình nối dính vào nhau (= vấn đề khu rừng), và thúc đẩy viết mã chỉ dùng đúng những gì cần thiết
- Cũng có thể trích xuất dễ dàng khi tách microservice
→ Phần lớn phụ thuộc đã được xác định rõ ràng
Kết luận
- Các ràng buộc trong thiết kế package của Go không phải là phiền toái, mà là cơ chế dẫn dắt đến thiết kế tốt
- Ngay cả không có kiến trúc đặc biệt nào, chỉ riêng cấu trúc tham chiếu giữa các package cũng đủ để hiện thực hóa một thiết kế vững chắc
- Phân tích tinh vi và chiến lược refactor đối với tham chiếu vòng không chỉ hữu ích cho Go mà còn có giá trị với các ngôn ngữ khác
4 bình luận
Lúc mới viết nhanh cho chạy được thì thấy vui thật
nhưng khi bắt đầu thêm test
lại phải nghĩ xem hồi đó tại sao mình làm thế.
Câu "Tôi chỉ muốn một quả chuối mà lại bị mang cả khu rừng đến" nghe buồn cười thật đấy.
Có lẽ một trong những điều khó nhất khi phát triển bằng Spring là vòng lặp phụ thuộc.. Cái cảm giác bức bối khi chúng cứ khởi tạo lẫn nhau vô hạn rồi sập vì rò rỉ bộ nhớ...
Ý kiến trên Hacker News
Việc không cho phép phụ thuộc vòng tròn là một lựa chọn thiết kế tuyệt vời khi xây dựng các chương trình quy mô lớn
Một bài đăng blog rất hay
Một kỹ thuật bổ sung liên quan đến lời khuyên "chuyển sang gói thứ ba"
Có vẻ như đang đọc một cuốn sách về phương pháp cấu trúc của Yourdon
Các gói không thể tham chiếu vòng tròn lẫn nhau
go:linknameGợi nhớ đến khái niệm cụ thể về randomizer
Một đặc điểm thú vị của Golang là không thể có phụ thuộc vòng tròn ở cấp độ package, nhưng lại có thể ở
go.modĐây là phần giải thích rất hay về cách Jerf nhìn nhận package và xử lý phụ thuộc vòng tròn