Hình dung làm việc với tệp như làm việc trong một phòng lưu trữ hồ sơ. Gói os là cái cửa vào phòng — cửa vào hệ điều hành. Bài này về thứ bạn dùng nhiều nhất trong đó: tệp.

Đọc: hai cách, hai đánh đổi

du, err := os.ReadFile(ten)     // nạp TOÀN BỘ vào bộ nhớ

Gọn nhất, và đúng cho tệp nhỏ — cấu hình, template, dữ liệu vài megabyte. Như ôm cả tập tài liệu ra một lần: ổn với một tờ ghi nhớ.

Với tệp lớn thì nó là quả bom: tệp 2GB cần 2GB RAM — không ai ôm nổi một hồ sơ hai nghìn trang. Lúc đó đọc theo luồng, từng trang một tại bàn:

f, err := os.Open(ten)
if err != nil { return err }
defer f.Close()

sc := bufio.NewScanner(f)
for sc.Scan() { xuLy(sc.Text()) }
return sc.Err()

Bộ nhớ dùng không phụ thuộc kích thước tệp. Đây là kết luận giống bài 57 sê-ri Java về Files.lines so với readAllLines.

Quy tắc: biết chắc tệp nhỏ thì ReadFile, còn lại thì luồng.

Ghi

os.WriteFile(ten, du, 0644)          // tạo mới hoặc ghi đè

Hoặc mở tường minh khi cần kiểm soát:

f, err := os.OpenFile(ten, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)

Các cờ hay dùng: O_APPEND ghi thêm vào cuối, O_EXCL cùng O_CREATE để thất bại nếu tệp đã tồn tại (hữu ích cho khoá tệp), O_TRUNC cắt về rỗng.

Quyền 0644 là số bát phân: chủ đọc-ghi, còn lại chỉ đọc. Dấu 0 đầu bắt buộc — 644 là số thập phân và cho quyền hoàn toàn khác.

Và như bài 30 đã đo: với tệp ghi, phải kiểm lỗi Close().

Ghi an toàn: tệp tạm rồi đổi tên

Ghi đè trực tiếp là nguy hiểm — chương trình chết giữa chừng thì bạn có tệp hỏng một nửa. Đừng sửa trực tiếp lên bản hồ sơ gốc.

tmp := ten + ".tmp"
if err := os.WriteFile(tmp, du, 0644); err != nil { return err }
return os.Rename(tmp, ten)      // đổi tên là NGUYÊN TỬ trên cùng hệ thống tệp

os.Rename nguyên tử trên POSIX: hoặc tệp cũ, hoặc tệp mới, không bao giờ ở giữa — viết một bản sạch rồi tráo tấm biển tên trong một động tác, người đọc không bao giờ bắt gặp hồ sơ đang sửa dở. Đây là mẫu chuẩn cho mọi thứ quan trọng — cấu hình, trạng thái, checkpoint.

Điều kiện: tệp tạm phải nằm cùng hệ thống tệp với tệp đích. Đặt ở /tmp rồi rename sang /var có thể thất bại.

Đường dẫn

filepath.Join("a", "b", "c.txt")    // dùng dấu phân cách của HỆ ĐIỀU HÀNH
path.Join(...)                       // LUÔN dùng "/" — cho URL, không cho tệp

Dùng filepath cho tệp, path cho URL. Nhầm hai gói này là lỗi chỉ lộ ra trên Windows.

filepath.Ext("a/b.txt")   // ".txt"
filepath.Base("a/b.txt")  // "b.txt"
filepath.Dir("a/b.txt")   // "a"
filepath.Abs(p)

Và như bài 99 sê-ri Java đã nói, kiểm duyệt đường dẫn khi tên đến từ người dùng:

goc = filepath.Clean(goc)
dich := filepath.Clean(filepath.Join(goc, ten))
if dich != goc && !strings.HasPrefix(dich, goc+string(filepath.Separator)) {
	return errors.New("đường dẫn không hợp lệ")
}

filepath.Clean xử lý .., nhưng phải kiểm tiền tố kèm dấu phân cách (goc + /): nếu chỉ so tiền tố trần thì thư mục anh em cùng tên đầu (ví dụ user1 và user12) vẫn lọt.

Kiểm tệp tồn tại

if _, err := os.Stat(ten); errors.Is(err, os.ErrNotExist) {
	// không tồn tại
}

Dùng errors.Is như bài 24, đừng so chuỗi.

Nhưng lời khuyên thật là: đừng kiểm trước rồi mở. Giữa hai lời gọi, tệp có thể bị xoá — đó là lỗi đua kinh điển. Cứ mở và xử lý lỗi:

f, err := os.Open(ten)
if errors.Is(err, os.ErrNotExist) { ... }

Duyệt thư mục

mucs, err := os.ReadDir(thuMuc)      // Go 1.16+, nhanh hơn ioutil.ReadDir cũ
for _, m := range mucs {
	fmt.Println(m.Name(), m.IsDir())
}

Đệ quy toàn cây:

filepath.WalkDir(goc, func(p string, d fs.DirEntry, err error) error {
	if err != nil { return err }
	if d.IsDir() && d.Name() == ".git" { return fs.SkipDir }
	return nil
})

fs.SkipDir bỏ qua cả nhánh — rất hữu ích. WalkDir nhanh hơn filepath.Walk cũ vì không gọi Stat cho mọi mục.

io/fs: trừu tượng hoá hệ thống tệp

Từ Go 1.16, fs.FS là interface cho "một cây tệp":

func XuLy(fsys fs.FS, ten string) error {
	du, err := fs.ReadFile(fsys, ten)
	...
}

Hàm này nhận được: hệ thống tệp thật (os.DirFS("/du-lieu")), tệp nhúng trong binary (embed.FS), tệp zip, hoặc hệ thống tệp giả trong test (fstest.MapFS) — một cái kệ chuẩn mà mã không cần biết trang giấy đến từ tủ hồ sơ thật, từ phong bì niêm trong binary, hay từ tủ giả dựng cho buổi diễn tập.

Đây là ứng dụng trực tiếp của "interface thuộc về phía người dùng" ở bài 13, và nó làm mã đọc tệp test được mà không cần tệp thật.

embed: nhúng tệp vào binary

import _ "embed"

//go:embed cauhinh.json
var cauHinh []byte

//go:embed static/*
var static embed.FS

Tệp nằm trong binary, nên vẫn giữ được lời hứa "chép một tệp lên là chạy" của bài 1. Rất hợp cho template, tài nguyên web, migration SQL.

Chú ý dòng import _ "embed" bắt buộc khi bạn chỉ dùng chỉ thị mà không dùng kiểu embed.FS.

Nếu chỉ thử một thứ, lấy một tệp vài GB rồi so hai cách trong ba mươi giây:

du, _ := os.ReadFile("/duong/dan/tep-lon.log")
fmt.Println(len(du))

Chạy và xem RAM của tiến trình, rồi đổi sang bufio.Scanner và chạy lại. Đó là khác biệt giữa một công cụ chạy được trên mọi tệp và một công cụ chết khi log to lên.

Mẫu số chung

Bốn ý trong bài này là nền của làm-việc-với-tệp ở mọi ngôn ngữ. Một: đọc cả tệp vào bộ nhớ là tốn O(kích thước tệp) ở đâu cũng vậy — readAllBytes của Java, read() của Python, readFile của Node — nên thứ gì có thể to thì phải đọc theo luồng; kích thước bạn thử không phải kích thước sản xuất đưa cho bạn. Hai: ghi đè an toàn không bao giờ là sửa tại chỗ mà là ghi-tệp-tạm-rồi-đổi-tên-nguyên-tử — vì rename là cú tráo-một-con-trỏ của hệ thống tệp, cùng thứ nguyên tử tất-cả-hoặc-không-gì với một commit CSDL hay một cú dời con trỏ của Git: người đọc thấy bản cũ hoặc bản mới, không bao giờ thấy bản rách dở.

Ba: đừng dùng dấu phân cách cứng — filepath của Go, pathlib của Python, Path.Combine của .NET đều sinh ra để cùng một mã chạy trên cả / lẫn \. Bốn, và là điều tinh tế nhất: đừng "kiểm rồi dùng" một tài nguyên người khác đổi được (một tệp, một dòng CSDL) — khe hở giữa lúc kiểm và lúc dùng là một cuộc đua (TOCTOU), và cũng là một lỗ hổng bảo mật; cứ thử rồi xử lý lỗi — đúng châm ngôn "xin lỗi dễ hơn xin phép" (EAFP) của Python. Sợi chỉ chung: đọc theo luồng mọi thứ không chắc chắn là nhỏ, tráo tệp bằng đổi-tên-nguyên-tử thay vì sửa tại chỗ, và đừng bao giờ kiểm-rồi-dùng một thứ người khác chạm được — cứ thử và bắt lỗi.

Ngày mai: encoding/json — và bốn cái bẫy của nó.

Bài tập làm thử

Bài 1 (đọc hiểu). Giải thích vì sao đoạn mã sau là "quả bom" tiềm ẩn nếu ten trỏ tới một tệp log 5GB, và nêu cách viết lại dùng đọc theo luồng:

du, err := os.ReadFile(ten)
if err != nil { return err }
for _, dong := range strings.Split(string(du), "\n") {
	xuLy(dong)
}
Đáp án

os.ReadFile nạp toàn bộ nội dung tệp vào bộ nhớ cùng lúc — tệp 5GB cần khoảng 5GB RAM (cộng thêm bản sao string từ strings.Split). Với tệp lớn, bộ nhớ dùng phụ thuộc trực tiếp kích thước tệp. Viết lại dùng luồng, bộ nhớ không phụ thuộc kích thước tệp:

f, err := os.Open(ten)
if err != nil { return err }
defer f.Close()

sc := bufio.NewScanner(f)
for sc.Scan() { xuLy(sc.Text()) }
return sc.Err()

Bài 2 (sửa lỗi). Đoạn mã sau muốn ghi cấu hình một cách an toàn (không để tệp hỏng dở khi chương trình crash giữa chừng) nhưng vẫn ghi đè trực tiếp. Viết lại theo mẫu tệp-tạm-rồi-đổi-tên:

func GhiCauHinh(ten string, du []byte) error {
	return os.WriteFile(ten, du, 0644)
}
Đáp án
func GhiCauHinh(ten string, du []byte) error {
	tmp := ten + ".tmp"
	if err := os.WriteFile(tmp, du, 0644); err != nil {
		return err
	}
	return os.Rename(tmp, ten)
}

os.Rename nguyên tử trên POSIX: người đọc luôn thấy hoặc bản cũ hoặc bản mới hoàn chỉnh, không bao giờ thấy bản dở dang. Lưu ý: tệp tạm phải nằm cùng hệ thống tệp với tệp đích, nếu không Rename có thể thất bại.

Bài 3 (vận dụng). Viết một hàm Go kiểm tra một tệp có tồn tại hay không TRƯỚC KHI ghi đè, nhưng làm đúng theo khuyến nghị của bài viết là tránh lỗi đua TOCTOU (kiểm-rồi-dùng). Gợi ý: dùng cờ O_EXCL.

Đáp án
f, err := os.OpenFile(ten, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if errors.Is(err, os.ErrExist) {
	return errors.New("tệp đã tồn tại")
}
if err != nil {
	return err
}
defer f.Close()
_, err = f.Write(du)
return err

Thay vì gọi os.Stat rồi mới Open (có khe hở giữa hai lời gọi khiến tệp có thể được tạo bởi tiến trình khác ở giữa), O_EXCL cùng O_CREATE khiến hệ điều hành tự thất bại nguyên tử nếu tệp đã tồn tại — không còn khe hở đua.

Bài 4 (bẫy/đánh đổi). Đoạn mã sau chạy đúng trên Linux/macOS nhưng lỗi hoặc cho kết quả sai trên Windows. Tìm lỗi:

duongDan := goc + "/" + tenTep
Đáp án

Dùng dấu / cứng thay vì filepath.Join. Trên Windows dấu phân cách là \, và nhiều API hệ thống không xử lý đúng đường dẫn trộn lẫn dấu phân cách theo kiểu tuỳ tiện. Sửa:

duongDan := filepath.Join(goc, tenTep)

filepath.Join dùng dấu phân cách của đúng hệ điều hành đang chạy. Lưu ý: path.Join (gói path, không phải path/filepath) luôn dùng / — chỉ dùng cho URL, không dùng cho đường dẫn tệp.

Bài 5 (đọc hiểu/bẫy bảo mật). Đoạn mã sau kiểm duyệt đường dẫn do người dùng cung cấp nhưng có lỗ hổng. Chỉ ra lỗ hổng và sửa theo đúng mẫu trong bài:

func AnToan(goc, ten string) (string, error) {
	dich := filepath.Join(goc, ten)
	return dich, nil
}
Đáp án

Hàm không kiểm gì cả sau khi Join — nếu ten chứa ../../etc/passwd, filepath.Join sẽ giải quyết .. và trả về đường dẫn nằm ngoài goc, cho phép người dùng đọc/ghi tệp bất kỳ trên hệ thống (path traversal). Sửa:

func AnToan(goc, ten string) (string, error) {
	goc = filepath.Clean(goc)
	dich := filepath.Clean(filepath.Join(goc, ten))
	if dich != goc && !strings.HasPrefix(dich, goc+string(filepath.Separator)) {
		return "", errors.New("đường dẫn không hợp lệ")
	}
	return dich, nil
}

filepath.Clean xử lý .., nhưng bắt buộc kiểm tiền tố kèm dấu phân cách (goc+/) — so tiền tố trần sẽ để lọt thư mục anh em cùng tên đầu như user1 vs user12.