Go có hai khái niệm dễ lẫn: package là đơn vị tổ chức mã, module là đơn vị quản lý phiên bản. 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. khokho/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, 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đường dẫn import của module. 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.jsongo.mod đã làm việc đó. go.sum là danh sách mã băm mật mã, 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 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". 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.

Thử 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 '\['

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.

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.