Hình dung mỗi package như một ngôi nhà. Cái tên viết hoa chữ đầu là người được phép ra đường — hàng xóm, tức các package khác, gặp và gọi được. Tên viết thường là người ở trong nhà. Nhưng — và đây là chỗ hay làm người từ Java bất ngờ — mọi người trong cùng một nhà thấy nhau hết: không có chuyện "riêng tư giữa các phòng", nên một trường viết thường của struct này vẫn bị mọi hàm cùng package đọc thoải mái. Muốn giấu một thứ thật sự, bạn phải xây cho nó một ngôi nhà riêng, chứ không phải chỉ viết thường. Go có đúng hai mức truy cập, và bạn chọn bằng cách viết hoa hay viết thường chữ cái đầu.
Quy tắc
package kho
var A = 1 // xuất khẩu: package khác dùng được
var b = 2 // không xuất khẩu: chỉ trong package kho
func TimKiem() {} // dùng được
func chuanHoa() {} // không
kho.A dùng được từ main vì A viết HOA
kho.chuanHoa KHÔNG dùng được vì viết thường -> lỗi biên dịch
Áp dụng cho mọi thứ: biến, hằng, hàm, kiểu, trường struct, method.
So với Java bốn mức (public, protected, default, private), Go chỉ có hai. Không có protected vì không có kế thừa; không có mức "cùng package" riêng vì mọi thứ trong cùng package đều thấy nhau, kể cả trường viết thường của struct khác.
Đơn vị đóng gói là package, không phải kiểu
Đây là điểm dễ nhầm nhất với người từ Java:
package kho
type Nguoi struct{ ten string } // ten viết thường
func Doc(n Nguoi) string { return n.ten } // CÙNG package -> đọc được
ten là "private", nhưng bất kỳ hàm nào trong package kho đều đọc và ghi được — mọi người cùng nhà thấy nhau. Đóng gói bảo vệ ranh giới package, không bảo vệ ranh giới kiểu.
Hệ quả: nếu bạn muốn thật sự giấu một thứ, hãy đặt nó vào package riêng — chứ không phải chỉ viết thường.
internal/: mức thứ ba trên thực tế
Bài 3 đã nhắc: thư mục tên internal chỉ cho phép import từ cùng module — một khu phố có cổng.
github.com/toi/duan/
├── internal/kho/ <- chỉ duan/ dùng được
└── api/ <- ai cũng dùng
Đây là cách duy nhất để có "public trong dự án, private với bên ngoài" — thứ tương đương internal của C# hay module của Java 9.
Với thư viện, tôi khuyên: mặc định đặt mọi thứ vào internal/, chỉ đưa ra ngoài khi chắc chắn muốn giữ tương thích lâu dài. Cái gì đã xuất khẩu thì rất khó rút lại.
Hệ quả với JSON và phản chiếu
Đây là chỗ quy tắc chữ hoa gây bất ngờ nhất:
type Nguoi struct {
Ten string // marshal được
tuoi int // BỊ BỎ QUA, im lặng
}
encoding/json, encoding/xml, và mọi thư viện dùng phản chiếu chỉ thấy trường xuất khẩu. Trường viết thường không được ghi ra, không được đọc vào, và không có cảnh báo nào. Thư viện đứng ngoài đường, nó chỉ thấy người được phép ra đường.
Đây là lỗi tôi thấy người mới mắc rất nhiều: struct có vẻ đúng, JSON ra thiếu trường, và không biết tại sao. Bài 45 sẽ nói kỹ.
Muốn tên JSON viết thường mà trường vẫn xuất khẩu thì dùng tag:
type Nguoi struct {
Ten string `json:"ten"`
}
Quy ước đặt tên
Go có quy ước khá chặt, và golint từng bắt bạn theo:
Không dùng gạch dưới. xuLyDonHang, không phải xu_ly_don_hang.
Viết tắt giữ nguyên chữ hoa: HTTPServer, userID, parseURL — không phải HttpServer, userId.
Tên ngắn cho phạm vi hẹp. for i, v := range chứ không phải for index, value. Biến sống một dòng thì một chữ cái là đủ; biến sống cả hàm thì nên dài hơn.
Getter không có tiền tố Get. Method đọc trường ten đặt tên là Ten(), không phải GetTen(). Setter thì vẫn SetTen().
Tên package ngắn, viết thường, không số nhiều: kho chứ không phải cac_kho hay khoUtils. Và tránh util, common, helper — chúng không nói gì cả.
Một ràng buộc bất ngờ tôi gặp khi viết bài này: đặt thư mục tên con làm Go từ chối biên dịch:
malformed import path "b20/con": "con" disallowed as path element component on Windows
Go bảo vệ tính khả chuyển bằng cách cấm các tên thiết bị dành riêng của Windows — con, prn, aux, nul, com1–com9, lpt1–lpt9. Chi tiết nhỏ nhưng sẽ làm bạn mất mười phút nếu không biết.
Nếu muốn nhớ cái bẫy JSON mãi mãi, thử đúng ba mươi giây:
type T struct {
A string
b string
}
j, _ := json.Marshal(T{A: "x", b: "y"})
fmt.Println(string(j))
In ra {"A":"x"}. Trường b biến mất không dấu vết, vì thư viện đứng ngoài đường không thấy được người ở trong nhà. Ba mươi giây, và bạn sẽ nhớ kiểm chữ hoa mỗi khi JSON thiếu trường.
Mẫu số chung
"Ai thấy được cái gì" là câu mọi ngôn ngữ phải trả lời, và có hai trục mà Go chọn chỗ đứng rất riêng.
Trục thứ nhất — đơn vị đóng gói là gì. Java, C++, C# giấu ở tầng lớp: private là riêng của từng lớp, lớp khác không thấy. Go và Rust thì giấu ở tầng package/module: trong cùng một package, mọi thứ thấy nhau, và "giấu thật" nghĩa là tách sang package khác, không phải thêm một từ khoá. Nhầm mình đang ở tầng nào chính là lý do người từ Java cứ tưởng một trường struct viết thường sẽ được che khỏi hàm cùng package.
Trục thứ hai — viết thế nào và ai cưỡng chế. Go dùng chữ cái đầu viết hoa, do trình biên dịch cưỡng chế. Rust dùng từ khoá pub. Java/C# dùng từ khoá. Python thì chỉ có quy ước gạch dưới đầu tên — không gì ngăn bạn đụng vào _private, nó tin vào sự tử tế. Số mức cũng trải dài: từ "gần như không cưỡng chế" của Python tới bốn mức của Java.
Và một cái bẫy lặp lại ở mọi ngôn ngữ, đáng mang theo: thư viện serialize/phản chiếu chỉ chạm được tới thứ luật hiển thị cho phép nó chạm — nên một trường bị giấu khỏi thư viện sẽ biến mất lặng lẽ khỏi JSON. Go bỏ qua trường viết thường; còn Java lại bất ngờ theo chiều ngược — Jackson với được vào trường private qua phản chiếu, nên dữ liệu bạn tưởng kín lại bị ghi ra. Sợi chỉ chung: khi một trường không chịu serialize (hoặc serialize ra thứ không nên), câu hỏi đầu tiên ở mọi ngôn ngữ là thư viện có nhìn thấy nổi cái trường này không — vì luật hiển thị quyết định điều đó trước cả logic của bạn.
Ngày mai: init() và thứ tự khởi tạo package.
Bài tập làm thử
Bài 1 (đọc hiểu). Đoạn mã sau in ra JSON gì?
type T struct {
A string
b string
}
j, _ := json.Marshal(T{A: "x", b: "y"})
fmt.Println(string(j))
Đáp án
In ra {"A":"x"}. Trường b (viết thường, không xuất khẩu) biến mất hoàn toàn khỏi JSON, không có cảnh báo nào. encoding/json và mọi thư viện dùng phản chiếu chỉ thấy được các trường xuất khẩu (viết hoa chữ đầu) — trường không xuất khẩu, dù có giá trị hợp lệ, hoàn toàn vô hình với thư viện đứng ngoài package.
Bài 2 (sửa lỗi). Hàm sau trong package kho được viết bởi người quen tư duy Java, cho rằng trường ten viết thường sẽ được bảo vệ khỏi các hàm khác. Chỉ ra hiểu lầm và giải thích Go thực sự đóng gói ở đơn vị nào.
package kho
type Nguoi struct{ ten string }
func Doc(n Nguoi) string { return n.ten } // tác giả nghĩ đây là vi phạm private
Đáp án
Đây không phải lỗi hay vi phạm gì cả — mã hoàn toàn hợp lệ và đúng chuẩn Go. Hiểu lầm: người quen Java nghĩ đóng gói bảo vệ ở tầng kiểu (struct/class) như private trong Java bảo vệ theo lớp. Nhưng Go đóng gói ở tầng package: mọi hàm nằm trong cùng package (ở đây là kho) đều thấy và đọc/ghi được trường viết thường của bất kỳ struct nào trong package đó — "mọi người cùng nhà thấy nhau hết". ten chỉ thực sự bị giấu khỏi các package khác. Muốn giấu thật với cả code trong dự án, phải đặt kiểu đó vào một package riêng (ví dụ internal/).
Bài 3 (đọc hiểu). Thư mục internal/kho/ nằm trong module github.com/toi/duan có thể được import từ đâu, và không thể được import từ đâu?
Đáp án
internal/kho/ chỉ import được từ mã nằm trong cùng module github.com/toi/duan (ví dụ từ github.com/toi/duan/api). Nó không import được từ bất kỳ module nào khác bên ngoài, kể cả khi module đó là public trên GitHub. Đây là cách Go tạo ra "mức thứ ba" trên thực tế — "public trong dự án, private với bên ngoài" — tương đương khái niệm internal của C# hay module của Java 9.
Bài 4 (vận dụng thực tế). Bạn có một struct Nguoi với trường xuất khẩu Ten, nhưng muốn khi serialize ra JSON, tên trường trong JSON là ten (viết thường) thay vì Ten. Viết khai báo struct đúng cách mà không cần đổi trường thành không xuất khẩu (điều sẽ làm JSON không thấy được trường đó nữa).
Đáp án
type Nguoi struct {
Ten string `json:"ten"`
}
Dùng tag json:"ten" để chỉ định tên trường trong JSON là ten viết thường, trong khi trường Go vẫn giữ nguyên Ten viết hoa (xuất khẩu) để encoding/json vẫn nhìn thấy và serialize được nó. Nếu đổi hẳn trường thành ten (không xuất khẩu), JSON sẽ hoàn toàn bỏ qua trường đó — im lặng, không báo lỗi.
Bài 5 (bẫy/đánh đổi). Bài viết nói Go và Java xử lý ngược nhau khi một trường bị giấu gặp thư viện phản chiếu/serialize. Nêu sự khác biệt đó, và giải thích tại sao "sợi chỉ chung" giữa hai ngôn ngữ vẫn là cùng một câu hỏi cần đặt ra khi gỡ lỗi.
Đáp án
Khác biệt: Go bỏ qua hoàn toàn trường viết thường (không xuất khẩu) khi serialize — dữ liệu biến mất lặng lẽ khỏi JSON. Java lại theo chiều ngược lại: Jackson có thể với được vào trường private thông qua phản chiếu (reflection) nếu được cấu hình phù hợp, nên dữ liệu bạn tưởng đã "kín" (private) vẫn có thể bị ghi ra ngoài JSON — rủi ro rò rỉ dữ liệu nhạy cảm.
Sợi chỉ chung: dù hướng lỗi ngược nhau (Go giấu quá mức cần thiết, Java lộ quá mức cần thiết), câu hỏi đầu tiên cần đặt ra khi một trường không chịu serialize đúng (hoặc serialize ra thứ không nên có) đều là: "thư viện có nhìn thấy nổi cái trường này không?" — vì luật hiển thị (visibility rules) của ngôn ngữ quyết định điều đó trước cả khi bất kỳ logic nghiệp vụ nào của bạn được xét tới.