Câu hỏi đầu tiên của mọi người mới: "dự án Go nên có cấu trúc thư mục thế nào?"
Câu trả lời không được ưa chuộng: bắt đầu bằng một tệp.
Không có chuẩn chính thức
Kho golang-standards/project-layout trên GitHub có hơn bốn mươi nghìn sao và không phải chuẩn chính thức — nhóm Go đã nhiều lần nói vậy. Nó mô tả bố cục của các dự án rất lớn, và chép nó vào một dịch vụ CRUD là cách nhanh nhất để có bảy tầng thư mục cho hai trăm dòng mã.
Bố cục đúng phụ thuộc kích thước. Đây là ba mức tôi thấy hợp lý.
Mức 1: một package
duan/
├── go.mod
├── main.go
├── kho.go
└── kho_test.go
Mọi thứ trong package main. Không có import nội bộ, không có vòng phụ thuộc, không phải nghĩ gì.
Mức này đủ cho công cụ dòng lệnh và dịch vụ nhỏ — vài nghìn dòng vẫn hoàn toàn ổn. Đừng tách sớm.
Mức 2: tách theo miền
duan/
├── go.mod
├── main.go
├── donhang/ nghiệp vụ đơn hàng
├── khachhang/ nghiệp vụ khách hàng
└── kho/ truy cập dữ liệu
Tách khi package main bắt đầu khó đọc. Nguyên tắc quan trọng nhất:
Tách theo miền nghiệp vụ, không tách theo tầng kỹ thuật.
donhang/ chứa cả kiểu, logic và truy cập dữ liệu của đơn hàng. Đừng tạo models/, services/, repositories/ — đó là bố cục Java, và trong Go nó dẫn tới việc mọi thay đổi phải sửa ba package.
Dấu hiệu bạn đang tách sai: thêm một trường vào một entity mà phải sửa bốn thư mục.
Mức 3: nhiều binary và ranh giới rõ
duan/
├── go.mod
├── cmd/
│ ├── api/main.go binary thứ nhất
│ └── worker/main.go binary thứ hai
├── internal/
│ ├── donhang/
│ ├── kho/
│ └── http/
└── pkg/ chỉ khi CÓ người ngoài dùng thật
cmd/ khi có nhiều binary. Mỗi thư mục con là một package main. Nội dung phải mỏng — đọc cấu hình, nối các mảnh, gọi Run(). Logic nằm ở internal/.
internal/ cho mọi thứ không phải API công khai. Trình biên dịch ép buộc điều này, như bài 3 đã nói.
pkg/ là thư mục gây tranh cãi nhất. Nó chỉ có nghĩa khi bạn thật sự xuất bản thư viện cho người khác. Với dịch vụ nội bộ, pkg/ không thêm gì so với đặt package ở gốc — mà lại thêm một tầng thư mục.
Lời khuyên của tôi: bỏ qua pkg/ trừ khi có lý do cụ thể.
Ba dấu hiệu đã đến lúc tách package
Tệp quá dài để tìm thứ gì đó. Không có con số ma thuật; khi bạn bắt đầu dùng tìm kiếm thay vì cuộn, đó là lúc.
Muốn giấu chi tiết cài đặt. Bài 19 đã nói: chữ thường chỉ bảo vệ ở ranh giới package. Cần giấu thật thì phải có package riêng.
Có hai nhóm thay đổi vì lý do khác nhau. Đây là tiêu chí tốt nhất, và nó đúng với mọi ngôn ngữ.
Ngược lại, đừng tách chỉ vì tệp có nhiều hơn 200 dòng. Tệp dài trong Go là bình thường — thư viện chuẩn có tệp hàng nghìn dòng.
Phụ thuộc vòng là lỗi biên dịch
Go cấm package A import B trong khi B import A. Không có ngoại lệ, không có cờ để tắt.
Nghe khắt khe, nhưng nó ép bạn suy nghĩ về hướng phụ thuộc ngay từ đầu. Ba cách gỡ khi gặp:
Tách phần chung ra package thứ ba — thường là các kiểu dữ liệu.
Đảo chiều bằng interface. Package cấp thấp khai interface cho thứ nó cần, package cấp cao cài đặt. Đây là chỗ "interface thuộc về phía người dùng" ở bài 13 trả công.
Gộp lại. Nếu hai package phụ thuộc vòng, có khi chúng vốn là một.
Tệp trong package
Trong một package, cách chia tệp là tự do. Quy ước hay dùng:
kho/
├── kho.go kiểu chính và API
├── truy_van.go nhóm chức năng
├── kho_test.go test
└── doc.go chỉ chứa comment tài liệu (tuỳ chọn)
Tệp _test.go không được biên dịch vào binary. Tệp có hậu tố _linux.go, _windows.go chỉ biên dịch trên nền tảng tương ứng — cơ chế build tag ở bài 54.
Thử ba mươi giây
go list -deps ./... | grep -v '^\(internal/\)\?[a-z]*$' | grep -c .
Đếm số package bên ngoài mà dự án bạn kéo vào. Và:
go mod graph | wc -l
Nếu con số thứ hai lớn hơn bạn tưởng nhiều lần, dự án của bạn đang mang theo một cây phụ thuộc mà không ai nhìn tới — đúng thứ bài 59 sẽ nói khi bàn về lỗ hổng bảo mật.
Ngày mai: chuỗi, byte và rune — vì sao len("Tiếng Việt") trả về 14.