27 điểm bởi GN⁺ 2025-04-24 | 4 bình luận | Chia sẻ qua WhatsApp
  • 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 (Username chẳ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 CategoryBlogPost sang 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

 
bus710 2025-04-25

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ế.

 
bungker 2025-04-24

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.

 
iwanhae 2025-04-24

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ớ...

 
GN⁺ 2025-04-24
Ý 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

    • Điều này buộc phải tách biệt các mối quan tâm một cách phù hợp
    • Khi xuất hiện phụ thuộc vòng tròn thì đó là dấu hiệu thiết kế có vấn đề, và bài viết giải thích rất tốt cách xử lý
    • Thỉnh thoảng có thể giải quyết phụ thuộc vòng tròn bằng cách dùng con trỏ hàm được gói khác ghi đè lại
    • Giá mà trình biên dịch Go đưa ra thông tin hữu ích hơn khi tạo ra phụ thuộc vòng tròn
    • Hiện tại nó cung cấp danh sách tất cả các gói liên quan trong vòng lặp, danh sách này có thể khá dài, trong khi thủ phạm thường là phần vừa được thay đổi gần nhất
  • Một bài đăng blog rất hay

    • Trang web này có nhiều bài viết đáng kinh ngạc, và nếu bạn thích tìm hiểu về lập trình hàm thì rất đáng để xem
    • Liên kết
  • Một kỹ thuật bổ sung liên quan đến lời khuyên "chuyển sang gói thứ ba"

    • Khi tạo nhiều cấu trúc mô hình (SQL, Protobuf, GraphQL, v.v.), có thể thiết lập hướng phụ thuộc rõ ràng giữa các tầng được sinh ra
    • Cung cấp toàn bộ mã được sinh ra cho mã ứng dụng như một "gói nền tảng" để ghép mọi thứ lại với nhau
    • Trước khi áp dụng kỹ thuật này, từng có vấn đề "model import model theo vòng tròn", nhưng sau khi thêm một tầng cấu trúc bổ sung thì nó đã biến mất hoàn toàn
  • 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

    • Thực ra trong Go vẫn có thể làm vậy bằng go:linkname
  • Gợ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

    • Tóm lại là, điều đó cũng không nên làm
  • Đâ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