Go có hai khái niệm dễ lẫn. Hình dung như sách: package là một chương — đơn vị tổ chức mã; module là cả cuốn sách — đơn vị được đánh phiên bản, có mã số riêng, và là thứ người khác trích dẫn, phụ thuộc vào. Bài này phân biệt chúng và đi qua hai tệp bạn sẽ gặp trong mọi dự án Go.

Package

Một package là một thư mục. Mọi tệp .go trong cùng thư mục phải khai cùng tên package, và chúng thấy được mọi thứ của nhau — kể cả những thứ viết thường.

duan/
├── main.go          package main
└── kho/
    ├── kho.go       package kho
    └── truy_van.go  package kho

Ba điều khác Java:

Tên package không phải đường dẫn. Thư mục internal/dichvu/donhang/ có thể chứa package donhang. Đường dẫn dùng để import, tên package dùng để gọi.

Một thư mục, một package. Không thể có hai package trong cùng thư mục — trừ package test kết thúc bằng _test.

Không có package con. kho và kho/sql là hai package hoàn toàn độc lập; package cha không tự động thấy gì của package con.

Module

Module là tập hợp package được đánh phiên bản cùng nhau — cả cuốn sách — khai bằng go.mod ở thư mục gốc:

$ go mod init vd/b2
$ cat go.mod
module vd/b2

go 1.23.12

Dòng module là đường dẫn import của module — cái mã số của cuốn sách. Với thư viện công khai, nó phải là URL thật — github.com/nguoidung/duan — vì Go tải mã bằng chính đường dẫn đó. Với ứng dụng nội bộ, đặt gì cũng được.

Dòng go khai phiên bản ngôn ngữ tối thiểu. Nó không phải "phiên bản Go dùng để build" mà là "tính năng ngôn ngữ nào được phép dùng".

Thêm một phụ thuộc

import "github.com/google/uuid"
$ go mod tidy
go: downloading github.com/google/uuid v1.6.0
go: found github.com/google/uuid in github.com/google/uuid v1.6.0
$ cat go.mod
module vd/b2

go 1.23.12

require github.com/google/uuid v1.6.0

Không có bước "thêm vào tệp cấu hình rồi mới import". Bạn import trước, chạy go mod tidy, và nó tự tìm, tải, ghi vào go.mod.

go mod tidy làm hai chiều: thêm cái đang dùng mà chưa khai, và xoá cái đã khai mà không còn dùng. Chạy nó trước mỗi lần commit là thói quen tốt.

go.sum và bảo vệ chuỗi cung ứng

$ wc -l go.sum
2

Hai dòng cho một phụ thuộc: một mã băm của mã nguồn module, một của chính go.mod của nó.

go.sum không phải tệp khoá phiên bản như package-lock.json — go.mod đã làm việc đó. go.sum là danh sách mã băm mật mã — dấu vân tay của đúng ấn bản bạn đã duyệt — và Go kiểm chúng ở mỗi lần build. Nếu ai đó sửa mã của một phiên bản đã phát hành, build của bạn thất bại thay vì âm thầm dùng mã lạ.

Cộng thêm hai thứ chạy mặc định: proxy module (proxy.golang.org) giữ bản sao vĩnh viễn của mọi phiên bản đã phát hành, nên module bị xoá khỏi GitHub vẫn build được; và checksum database (sum.golang.org) là sổ ghi công khai chỉ-thêm để phát hiện chuyện đánh tráo.

Cả hai đều nên commit: go.mod và go.sum.

Chọn phiên bản: khác Maven, khác npm

Đây là chỗ Go làm khác mọi hệ sinh thái khác.

Khi hai phụ thuộc cần hai phiên bản khác nhau của cùng một thư viện, Go dùng Minimal Version Selection: nó chọn phiên bản thấp nhất thoả mãn được tất cả.

Nghe ngược đời. Maven chọn "gần nhất trong cây", npm chọn mới nhất trong khoảng cho phép, Gradle chọn mới nhất. Go chọn thấp nhất đủ dùng.

Lý do: nó làm build tái lập được. Cùng mã nguồn, cùng go.mod, thì hôm nay và một năm sau cho ra đúng cùng một tập phiên bản — không phụ thuộc vào việc ai vừa phát hành gì. Bài 91 của sê-ri Java đã đo: với Maven, chỉ cần đảo thứ tự hai dòng khai báo là phiên bản thư viện đổi. Với Go, chuyện đó không xảy ra.

Nâng cấp là hành động tường minh:

go get -u ./...              # nâng mọi phụ thuộc lên bản minor/patch mới nhất
go get github.com/x/y@v1.5.0 # ghim một phiên bản cụ thể
go list -m -u all            # xem có gì để nâng, không nâng

internal/: đóng gói ở mức thư mục

Thư mục tên internal có ý nghĩa đặc biệt với trình biên dịch: chỉ mã nằm trong cùng module mới import được.

github.com/toi/duan/
├── internal/kho/       <- chỉ duan/ dùng được
└── api/                <- ai cũng dùng được

Đây là cách duy nhất Go cho bạn nói "phần này không phải API công khai" — như một phụ lục riêng mà cuốn sách tự đọc được nhưng sách khác không trích được. Với thư viện, nó rất đáng dùng — mọi thứ ngoài internal là lời hứa bạn phải giữ.

Quy ước bố cục dự án

Không có chuẩn chính thức, nhưng đây là bố cục tôi thấy hợp lý cho một dịch vụ:

duan/
├── go.mod
├── cmd/api/main.go      <- điểm vào, mỏng
├── internal/
│   ├── donhang/         <- nghiệp vụ
│   ├── kho/             <- truy cập dữ liệu
│   └── http/            <- handler
└── pkg/                 <- chỉ khi có thứ thật sự dùng chung

Lời khuyên thẳng: đừng dựng cấu trúc này ngay từ đầu. Bắt đầu bằng một main.go ở thư mục gốc, tách package khi nào thấy đau. Rất nhiều dự án Go nhỏ bị chôn dưới bảy tầng thư mục vì chép bố cục của Kubernetes.

Chi tiết hơn ở bài 21.

Nếu chỉ chạy một thứ sau bài này, để go mod tidy soi hộ bạn trong ba mươi giây:

cd duan-cua-ban && go mod tidy && git diff go.mod

Nếu go.mod đổi, bạn vừa phát hiện phụ thuộc khai thừa hoặc thiếu. Và go list -m -u all | grep '\[' cho mỗi dòng có dấu ngoặc vuông là một phụ thuộc đã có bản mới hơn — danh sách đó chính là thứ bài 59 sẽ nói tới khi bàn về lỗ hổng trong phụ thuộc.

Mẫu số chung

Hai ý ở đây vượt khỏi Go. Một: mọi hệ sinh thái đều tách đơn vị tổ chức mã khỏi đơn vị đánh phiên bản và phân phối. Go có package (chương) so với module (cuốn sách); Java có package (không gian tên) so với JAR/artifact; Python có package-thư-mục so với wheel trên PyPI; Rust có module so với crate; npm có thư mục tệp so với gói được phát hành. Lý do chung: "tôi cấu trúc mã của mình thế nào" và "tôi đánh phiên bản, xuất bản, và phụ thuộc vào cái gì" là hai mối quan tâm khác nhau — và ranh giới công khai của đơn vị phân phối (các package ngoài internal, phần API xuất khẩu) chính là lời hứa giữ-ổn-định bạn ký với người dùng.

Hai: một phụ thuộc là mã của bên thứ ba không đáng tin đang chui vào bản build của bạn, nên mọi hệ trưởng thành đều ghim nó bằng mã băm mật mã và kiểm ở mỗi lần build — go.sum, integrity trong package-lock, Cargo.lock, hash của pip; giữ một bản sao bất biến để gói bị xoá không làm gãy bạn (proxy của Go — bài học left-pad của npm); và ngày càng dùng một sổ công khai chỉ-thêm để bắt đánh tráo (checksum database của Go, cùng ý tưởng với Certificate Transparency). Sợi chỉ chung: tách "tôi tổ chức mã thế nào" khỏi "tôi xuất bản và phụ thuộc cái gì", coi ranh giới phân phối là một lời hứa ổn định (đánh dấu phần còn lại là internal), và đừng bao giờ tin một phụ thuộc chỉ qua cái tên — ghim và kiểm đúng từng byte, vì chuỗi cung ứng là một mặt tấn công.

Ngày mai: biến, hằng và kiểu cơ bản — cùng lý do Go không cho ép kiểu ngầm.