Một trong những điều làm Go dễ triển khai là binary tĩnh duy nhất: scp một file lên máy chủ là chạy được, không cần cài runtime, không cần copy thư mục assets/. Nhưng ứng dụng thật luôn có tệp tĩnh — template HTML, file cấu hình mặc định, chứng chỉ, migration SQL, logo. Nếu chúng nằm cạnh binary dưới dạng file rời, ưu thế "một binary" biến mất. //go:embed (từ Go 1.16) đóng khoảng trống đó: nhúng tệp tĩnh thẳng vào binary lúc biên dịch. Bài này mổ xẻ ba dạng nhúng, và đo thật hai đánh đổi quan trọng: kích thước binary và tốc độ đọc.

Ba dạng nhận dữ liệu nhúng

//go:embed là một chỉ thị compiler (directive), không phải hàm. Đặt ngay trên một biến cấp gói, nó bảo compiler đọc tệp lúc build và gán nội dung vào biến. Có ba kiểu đích, mỗi kiểu cho một nhu cầu:

import (
	"embed"
	"io/fs"
)

//go:embed assets/loichao.txt
var loiChao string       // nhúng thẳng vào string

//go:embed assets/data.csv
var dataCSV []byte       // nhúng vào []byte

//go:embed assets
var assetFS embed.FS     // nhúng CẢ thư mục thành hệ tệp ảo

string và []byte cho đúng một tệp — hợp khi bạn cần nội dung một file cụ thể (một template, một khóa công khai). embed.FS nhúng cả thư mục (đệ quy) thành một hệ tệp chỉ-đọc trong bộ nhớ, dùng khi có nhiều tệp hoặc cần duyệt cây.

Ảnh chụp đoạn mã Go nền tối minh hoạ go embed nhúng tệp tĩnh thẳng vào binary ba dạng nhận, import embed và io fs, chỉ thị go embed assets loichao txt var loiChao string nhúng thẳng vào string, go embed assets data csv var dataCSV byte nhúng vào byte sửa được bản sao, go embed assets var assetFS embed FS nhúng cả thư mục thành FS ảo, embed FS dùng như hệ tệp read only b bằng assetFS ReadFile assets data csv đọc một tệp, fs WalkDir assetFS assets duyệt cây thư mục nhúng như os DirFS, embed FS thỏa fs FS cắm thẳng vào http FileServer template ParseFS text template mà không cần đĩa, luật của go embed chỉ thị đặt ngay trên biến gói package level var phải import embed kể cả khi chỉ dùng string hoặc byte đường dẫn tương đối thư mục nguồn không ra ngoài string byte đúng một tệp embed FS nhiều tệp hoặc thư mục all assets để gồm cả tệp ẩn bắt đầu bằng chấm hoặc gạch dưới

Hình 1: Ba dạng đích — string/[]byte cho một tệp, embed.FS cho cả thư mục. embed.FS thỏa interface fs.FS nên cắm thẳng vào http.FileServer, template.ParseFS mà không cần đĩa.

embed.FS là một fs.FS thật

Điểm mạnh nhất của embed.FS là nó thỏa interface fs.FS của thư viện chuẩn. Nghĩa là mọi thứ nhận fs.FS — http.FileServer, template.ParseFS, fs.WalkDir — dùng được ngay với dữ liệu nhúng, không cần đĩa:

b, _ := assetFS.ReadFile("assets/data.csv")   // đọc một tệp
fs.WalkDir(assetFS, "assets", func(p string, d fs.DirEntry, e error) error {
	// duyệt cây thư mục nhúng như os.DirFS
	return nil
})

Đo thật (Go 1.23): nhúng một thư mục assets chứa ba tệp, chương trình đọc và duyệt được cả cây — blob.bin (102400 byte), data.csv (23 byte), loichao.txt (29 byte). Đây là nền cho pattern phổ biến: nhúng cả thư mục templates/ hay static/ rồi phục vụ qua http.FileServerFS(assetFS) — server web một-binary không cần copy file tĩnh.

Đo thật đánh đổi 1: embed không nén

Đây là điều nhiều người tưởng nhầm. //go:embed không nén dữ liệu — nó lưu byte thô vào một section của binary. Để chứng minh, cùng một đoạn mã, chỉ đổi kích thước tệp nhúng:

Ảnh chụp bảng kết quả đo thật nền tối embed không nén 1 MB tệp bằng cộng 1 MB binary đọc nhanh hơn đĩa 1,7x, go run cộng go build cộng go test bench Go 1.23 arm64 10 core, ba dạng nhúng chạy thật string Xin chào từ tệp nhúng byte CSV 23 byte FS ReadFile 23 byte tệp trong assetFS blob bin 102400 byte data csv 23 byte loichao txt 29 byte, embed không nén cùng mã đổi kích thước tệp nhúng 1 byte binary 2155517 nhúng 1 MB toàn số 0 binary 3204093 phần tăng cộng 1048576 nhúng 1 MB ngẫu nhiên binary 3204093 phần tăng cộng 1048576, nhúng 1 MB làm binary tăng đúng 1 MB 1048576 byte dù dữ liệu toàn 0 nén được hay ngẫu nhiên embed lưu thô không nén, đọc embed FS trong RAM vs os ReadFile đĩa tệp 64 KB embed FS ReadFile trong RAM 4636 ns 65552 byte 2 alloc os ReadFile mở đọc đóng đĩa 7990 ns 74048 byte 5 alloc, embed nằm sẵn trong RAM không syscall mở đóng tệp ít cấp phát hơn 2 vs 5 nhanh hơn 1,7x đổi lại tốn RAM thường trú, cốt lõi 3 dạng string byte một tệp và embed FS thư mục không nén 1 MB tệp cộng 1 MB binary đọc nhanh 1,7x đĩa ít syscall alloc nhưng thường trú RAM

Hình 2: Cùng mã, đổi tệp nhúng: 1 byte → binary 2.155.517; 1 MB toàn số 0 → 3.204.093 (+1.048.576); 1 MB ngẫu nhiên → 3.204.093 (+1.048.576). Nhúng 1 MB làm binary tăng đúng 1 MB, dù dữ liệu nén được (toàn 0) hay không (ngẫu nhiên) — chứng tỏ embed lưu thô, không nén.

Nhúng 1 MB làm binary tăng chính xác 1.048.576 byte — và con số này giống hệt cho dữ liệu toàn số 0 (nén được cực tốt) lẫn dữ liệu ngẫu nhiên (không nén được). Nếu embed có nén, hai trường hợp sẽ khác nhau rất xa. Chúng bằng nhau tuyệt đối → embed lưu byte thô. Hệ quả thực tế: nhúng một video 50 MB làm binary phình thêm 50 MB. Nếu bạn cần nén, phải tự nén tệp trước khi nhúng (ví dụ nhúng file .gz) rồi giải nén lúc chạy.

Đo thật đánh đổi 2: đọc nhanh hơn đĩa

Đổi lại kích thước, dữ liệu nhúng nằm sẵn trong RAM (là một phần image của tiến trình), nên đọc nó không cần mở/đọc/đóng tệp qua syscall. Benchmark đọc một tệp 64 KB:

  • embed.FS.ReadFile: 4.636 ns/op, 65.552 B, 2 cấp phát.
  • os.ReadFile (đĩa): 7.990 ns/op, 74.048 B, 5 cấp phát.

embed nhanh hơn ~1,7 lần và ít cấp phát hơn (2 so với 5). Lý do: os.ReadFile phải gọi open, read, close — mỗi cái là một syscall, cộng cấp phát cho đường dẫn xử lý file descriptor. embed.FS.ReadFile chỉ cắt một lát (slice) từ dữ liệu đã có trong bộ nhớ. Cả hai vẫn cấp phát buffer 64 KB để trả về nội dung, nhưng embed bỏ được chi phí syscall. Với ứng dụng đọc template/asset liên tục, khác biệt này cộng dồn.

Đánh đổi cần cân nhắc

Tệp nhỏ, cần một-binary → embed thắng rõ. Template, cấu hình mặc định, migration SQL, khóa công khai, logo nhỏ — tổng cỡ vài trăm KB. Nhúng chúng làm binary lớn không đáng kể mà đổi lại triển khai cực gọn (một file, không lo thiếu asset lúc runtime). Đây là ca dùng đúng nhất của embed.

Tệp lớn, hay đổi → cân nhắc kỹ. Video, dataset, ảnh độ phân giải cao làm binary phình và tốn RAM thường trú (dữ liệu nhúng luôn trong bộ nhớ suốt đời tiến trình, không như file đĩa chỉ nạp khi đọc). Tệp thay đổi thường xuyên mà nhúng thì mỗi lần đổi phải build lại. Với những thứ này, phục vụ từ đĩa hoặc object storage (CDN, S3) thường đúng hơn.

Không có nén — tự lo nếu cần. Vì embed lưu thô, muốn tiết kiệm dung lượng binary với tệp nén được (JSON lớn, SQL), hãy nhúng bản .gz và gzip.NewReader lúc chạy. Đổi CPU giải nén lấy binary nhỏ hơn — cân theo bài toán. Với asset đã nén sẵn (PNG, JPEG, woff2), nhúng thẳng là đúng vì nén lại vô ích.

Ba ý mang về

  1. //go:embed nhúng tệp tĩnh vào binary dưới ba dạng: string và []byte cho một tệp, embed.FS cho cả thư mục — embed.FS thỏa fs.FS nên cắm thẳng vào http.FileServer, template.ParseFS, fs.WalkDir mà không cần đĩa.
  2. embed lưu thô, KHÔNG nén: đo thật, nhúng 1 MB làm binary tăng đúng 1.048.576 byte — giống hệt cho dữ liệu toàn 0 (nén được) lẫn ngẫu nhiên (không) — nên tệp lớn làm binary phình đúng bằng kích thước gốc; muốn nén phải tự nhúng bản .gz.
  3. Đọc từ embed nhanh hơn đĩa ~1,7x (4.636 ns so với 7.990 ns, ít cấp phát hơn) vì dữ liệu nằm sẵn trong RAM, không syscall mở/đọc/đóng — đổi lại là RAM thường trú suốt đời tiến trình; hợp cho asset nhỏ cần triển khai một-binary.

Bài tập làm thử

Bài 1 (đọc hiểu). Cho ba khai báo:

//go:embed assets/loichao.txt
var loiChao string

//go:embed assets/data.csv
var dataCSV []byte

//go:embed assets
var assetFS embed.FS

Nêu khác biệt về phạm vi (một tệp hay cả thư mục) và khi nào nên chọn dạng nào.

Đáp án

string và []byte chỉ nhúng nội dung của đúng một tệp được chỉ định — phù hợp khi cần nội dung một file cụ thể (một template, một khóa công khai, một file cấu hình mặc định). embed.FS nhúng cả một thư mục (đệ quy) thành một hệ tệp ảo chỉ-đọc trong bộ nhớ — dùng khi cần nhiều tệp hoặc cần duyệt cây thư mục (ví dụ nhúng toàn bộ thư mục templates/ hay static/). Chọn string/[]byte cho một tệp đơn lẻ đã biết trước tên; chọn embed.FS khi số lượng/tên tệp có thể thay đổi hoặc cần dùng với các API nhận fs.FS như http.FileServer hay template.ParseFS.

Bài 2 (vận dụng/viết code Go). Viết đoạn mã dùng embed.FS để phục vụ toàn bộ thư mục static/ qua HTTP, không cần đọc từ đĩa lúc runtime.

Đáp án
package main

import (
	"embed"
	"net/http"
)

//go:embed static
var assetFS embed.FS

func main() {
	http.Handle("/", http.FileServerFS(assetFS))
	http.ListenAndServe(":8080", nil)
}

Vì embed.FS thỏa mãn interface fs.FS của thư viện chuẩn, nó cắm thẳng được vào http.FileServerFS (hoặc http.FileServer(http.FS(assetFS)) ở các bản Go trước đó) mà không cần bất kỳ tệp nào tồn tại rời trên đĩa lúc chạy — toàn bộ nội dung static/ đã nằm sẵn trong binary.

Bài 3 (đọc số liệu đo thật). Bài đo: nhúng 1 MB dữ liệu toàn số 0 (nén được cực tốt) làm binary tăng đúng 1.048.576 byte; nhúng 1 MB dữ liệu ngẫu nhiên (không nén được) cũng làm binary tăng đúng 1.048.576 byte — hai con số bằng nhau tuyệt đối. Giải thích ý nghĩa của việc hai con số bằng nhau, và hệ quả thực tế khi nhúng một file video 50 MB.

Đáp án

Nếu //go:embed có nén dữ liệu, hai trường hợp (dữ liệu nén tốt vs dữ liệu ngẫu nhiên không nén được) sẽ cho ra mức tăng binary rất khác nhau — dữ liệu toàn số 0 sẽ nén nhỏ hơn nhiều so với dữ liệu ngẫu nhiên. Vì chúng bằng nhau tuyệt đối, điều đó chứng tỏ //go:embed lưu byte thô, không hề nén. Hệ quả thực tế: nhúng một video 50 MB sẽ làm binary phình thêm đúng ~50 MB, không hơn không kém, bất kể nội dung video có nén được nhiều hay ít về mặt lý thuyết. Muốn giảm kích thước binary trong trường hợp này, phải tự nén file (ví dụ thành .gz) trước khi nhúng, rồi giải nén bằng gzip.NewReader lúc chạy.

Bài 4 (vận dụng thực tế). Dịch vụ của bạn đọc một template HTML 20 KB hàng nghìn lần mỗi giây để render trang. Dựa theo số liệu đo (embed.FS.ReadFile: 4.636 ns/op, 2 cấp phát; os.ReadFile từ đĩa: 7.990 ns/op, 5 cấp phát, cho tệp 64 KB), việc chuyển từ đọc đĩa sang //go:embed có mang lại lợi ích rõ rệt trong trường hợp này không? Vì sao có sự khác biệt tốc độ đó?

Đáp án

Có, lợi ích rõ rệt cho trường hợp đọc lặp lại nhiều lần: os.ReadFile phải thực hiện các syscall open, read, close mỗi lần gọi — mỗi syscall có chi phí chuyển ngữ cảnh và cấp phát liên quan tới việc quản lý file descriptor (5 cấp phát trong đo thật). embed.FS.ReadFile chỉ cần cắt một lát (slice) từ dữ liệu đã nằm sẵn trong bộ nhớ tiến trình (image binary đã nạp vào RAM), không cần syscall mở/đóng tệp, nên nhanh hơn khoảng 1,7 lần và ít cấp phát hơn (2 so với 5). Với hàng nghìn lần đọc mỗi giây, khác biệt ~3.354 ns mỗi lần cộng dồn thành khác biệt đáng kể về tải CPU và độ trễ.

Bài 5 (bẫy/đánh đổi). Một lập trình viên quyết định nhúng một bộ dataset JSON 200 MB thay đổi hàng ngày vào binary bằng //go:embed, với lý do "để đọc nhanh hơn đĩa". Nêu ít nhất hai vấn đề với quyết định này dựa theo phần "đánh đổi cần cân nhắc" của bài.

Đáp án

Hai vấn đề chính: (1) Binary phình to và tốn RAM thường trú — nhúng 200 MB sẽ làm binary tăng đúng 200 MB (vì embed không nén), và dữ liệu nhúng luôn nằm trong bộ nhớ suốt đời tiến trình (khác file trên đĩa chỉ nạp khi đọc), gây lãng phí RAM đáng kể nếu dataset không được dùng liên tục. (2) Dataset thay đổi hàng ngày đòi hỏi build lại binary mỗi lần — vì nội dung được nhúng cố định lúc biên dịch, mỗi lần dataset cập nhật phải build lại và triển khai lại toàn bộ ứng dụng, mất đi sự linh hoạt của việc cập nhật dữ liệu độc lập với việc triển khai code. Theo bài, với dữ liệu lớn và hay đổi, phục vụ từ đĩa hoặc object storage (CDN, S3) thường là lựa chọn đúng hơn embed.

Phần sau ta xét một chỉ thị biên dịch khác định hình chính cái binary được tạo ra: Phần sau mổ xẻ build tags — cách biên dịch có điều kiện theo hệ điều hành, kiến trúc, và cờ tùy biến để một mã nguồn ra nhiều binary khác nhau.