Gói os là cửa vào hệ điều hành. Bài này về phần bạn dùng nhiều nhất: 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.

Với tệp lớn thì nó là quả bom: tệp 2GB cần 2GB RAM. Lúc đó đọc theo luồng:

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.

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. Đâ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:

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

filepath.Clean xử lý .., nhưng phải có bước kiểm tiền tố sau đó.

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).

Đâ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.

Thử ba mươi giây

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

Chạy với một tệp vài GB và xem RAM của tiến trình. Rồi đổi sang bufio.Scanner và chạy lại.

Ba mươi giây đó 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.

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