Hình dung go run như nấu lại một món từ nguyên liệu sống mỗi lần bạn muốn ăn — kể cả chỉ nếm một miếng cũng phải bắc bếp. Còn go build là nấu sẵn, cho vào hộp, lúc ăn chỉ việc mở nắp. Nghe thì cái nấu-sẵn hiển nhiên thắng, nhưng khi đang nêm nếm công thức thì nấu-lại-mỗi-lần lại tiện hơn: sửa một chút, nếm ngay, không phải nghĩ tới chuyện đóng hộp. Cả bài này cài Go và chạy chương trình đầu tiên, nhưng phần đáng đọc nằm ở cuối — khác biệt giữa go run và go build không phải chuyện phong cách, và số đo cho thấy vì sao.

Cài

macOS: brew install go. Linux: tải từ go.dev/dl, giải nén vào /usr/local, thêm /usr/local/go/bin vào PATH. Windows: tải bộ cài .msi.

Kiểm:

$ go version
go version go1.23.12 linux/arm64

Cần Go 1.21 trở lên cho sê-ri này — slog, min/max dựng sẵn và vài thứ khác cần bản đó.

Một điểm khác Java đáng nói ngay: Go không có khái niệm "JDK và JRE riêng". Một bản cài, vừa biên dịch vừa chạy. Và bạn cũng không cần cài Go trên máy chủ — bài 1 đã đo: binary tĩnh, chép lên là chạy.

Đừng đụng vào GOPATH nữa. Nó là cách tổ chức mã từ trước 2018 và mọi hướng dẫn nhắc tới nó đều đã cũ.

Chương trình đầu tiên

package main

import "fmt"

func main() {
	fmt.Println("Xin chào từ Go")
}

Bốn thứ cần biết ngay từ bốn dòng này:

package main là bắt buộc cho chương trình chạy được. Mọi tệp Go đều thuộc một package, và package tên main là package đặc biệt: nó sinh ra binary. Package tên khác thì sinh ra thư viện.

func main() là điểm vào, không tham số, không giá trị trả về. Muốn đọc tham số dòng lệnh thì dùng os.Args; muốn trả mã thoát thì os.Exit.

import phải được dùng. Import một package rồi không dùng là lỗi biên dịch, không phải cảnh báo. Điều này làm nhiều người mới bực, và nó là ví dụ đầu tiên của triết lý Go: thà khó chịu lúc viết còn hơn để mã rác tích tụ.

Không có dấu chấm phẩy. Trình biên dịch tự chèn, nên bạn không gõ. Nhưng nó cũng bắt bạn đặt dấu { cùng dòng với func — xuống dòng là lỗi.

gofmt không phải tuỳ chọn

gofmt -w main.go      # hoặc: go fmt ./...

Go có một định dạng chuẩn, do công cụ áp đặt, không cấu hình được. Thụt lề bằng tab, khoảng trắng ở đâu, xuống dòng thế nào — tất cả cố định.

Nghe độc đoán, nhưng nó xoá sổ hẳn một loại tranh cãi trong đội, và làm mã của mọi dự án Go trên thế giới trông như nhau. Với tôi đây là một trong những quyết định đúng nhất của ngôn ngữ.

Trình soạn thảo của bạn nên chạy gofmt khi lưu. VS Code với tiện ích Go làm sẵn.

go run khác go build chỗ nào

Đây là phần có số đo.

  go run main.go (3 lần)     : 1492 ms | 62 ms | 74 ms
  ./app          (3 lần)     :    1 ms |  1 ms |  1 ms

Chênh sáu mươi lần, và lý do rất đơn giản: go run biên dịch rồi mới chạy, mỗi lần. Nó dựng một binary tạm trong thư mục cache, chạy nó, rồi xoá đi — đúng là bắc bếp nấu lại mỗi bữa. ./app thì chỉ mở hộp ra ăn.

Lần đầu 1492 ms vì cache còn rỗng. Từ lần thứ hai, Go dùng lại kết quả biên dịch nên xuống còn 62 ms — nhưng vẫn phải liên kết và khởi động lại.

Nên dùng cái nào:

go run khi đang viết và thử — gõ một lệnh, thấy kết quả ngay. Đây là lúc nêm nếm.

go build khi đo hiệu năng, khi chạy nhiều lần, và luôn luôn khi đem lên máy chủ. Đừng bao giờ chạy go run trong sản xuất: bạn cần Go cài trên máy đó, cần mã nguồn nằm đó, và trả 60 ms mỗi lần khởi động. Không ai nấu lại từng đĩa khi dọn cho khách.

Một biến thể hay dùng: go install biên dịch rồi đặt binary vào $GOPATH/bin (mặc định ~/go/bin) — tiện cho công cụ dòng lệnh bạn muốn gọi từ mọi nơi.

Bốn lệnh dùng hằng ngày

go run .          # chạy package trong thư mục hiện tại
go build ./...    # build mọi package
go test ./...     # chạy mọi test
go vet ./...      # tìm lỗi mà trình biên dịch không bắt

go vet đáng chạy thường xuyên. Nó bắt những thứ hợp lệ về cú pháp nhưng gần như chắc chắn sai — chuỗi định dạng không khớp đối số, khoá bị sao chép, so sánh vô nghĩa. Rẻ và hay đúng.

Dấu ./... nghĩa là "package này và mọi package con". Bạn sẽ gõ nó rất nhiều.

Và nếu muốn tận mắt thấy Go nghiêm tới đâu, thử đúng ba mươi giây:

mkdir /tmp/thu && cd /tmp/thu
go mod init thu
printf 'package main\nimport "fmt"\nimport "os"\nfunc main(){ fmt.Println("chào", os.Args) }\n' > main.go
go run . mot hai ba

Rồi thử xoá dòng import "os" mà vẫn giữ os.Args, và ngược lại — giữ import mà bỏ chỗ dùng. Cả hai đều là lỗi biên dịch, và ba mươi giây đó cho bạn thấy Go nghiêm khắc tới mức nào với những thứ Java chỉ cảnh báo.

Mẫu số chung

Hai quyết định trong bài này — một lệnh "run" giấu đi bước biên dịch, và một công cụ định dạng duy nhất không cãi được — thoạt nghe độc đoán, nhưng thật ra là hai bài học cả ngành đã học và chép lại.

  • Chuyện run-so-với-build có ở mọi ngôn ngữ biên dịch. Rust có cargo run so với cargo build — song song một-một với Go, cargo run cũng biên dịch trước rồi mới chạy. Java thì lộ hẳn hai bước (javac rồi java). Còn Python/Node cảm giác nhanh khi lặp chính vì chúng thông dịch — không có bước build để giấu, nên lúc nào cũng như go run. Điều Go (và Rust) làm khéo là cho một lệnh khiến ngôn ngữ biên dịch cảm giác scripty lúc đang viết, mà vẫn cho ra binary thật khi cần.
  • Chuyện gofmt còn có sức ảnh hưởng lớn hơn: Go là kẻ tiên phong của "một định dạng đúng, không cấu hình", và cả ngành nối gót — Rust có rustfmt, Python có black (khẩu hiệu nửa đùa: "chọn màu nào cũng được, miễn là màu đen"), JS có Prettier. Trước Go, mỗi đội đốt hàng giờ cãi nhau về tab hay space; sau Go, câu hỏi đó biến mất khỏi bàn.

Sợi chỉ chung đáng mang theo: Go đánh đổi một chút khó chịu lúc viết để xoá cả một loại hỗn độn về sau — import thừa là lỗi cứng chứ không phải cảnh báo, định dạng không thương lượng, { phải cùng dòng. Nghe như ngôn ngữ đang càu nhàu, nhưng đó là cùng canh bạc mà mọi ngôn ngữ hiện đại ít nhiều đều đặt: nghiêm ngay bây giờ để sạch về sau. Cảnh báo mà ai cũng phớt lờ thì vô dụng; một lỗi cứng thì buộc phải sửa, và mã của bạn không bao giờ tích rác kiểu "import từng có ích ba tháng trước". Một khi quen, bạn sẽ thấy thiếu nó ở những ngôn ngữ dễ tính hơn.

Ngày mai: package, module và go.mod — cách Go tổ chức mã và quản lý phụ thuộc.