Hình dung io.Reader và io.Writer như một đầu nối chuẩn — một hình dạng duy nhất mà mọi nguồn cắm vào và mọi đích cắm ra. Tệp, kết nối mạng, chuỗi, tệp nén, luồng mã hoá: tất cả mang cùng một cái ren, nên ráp được với nhau tuỳ ý. Ba gói fmt, io, bufio là nền của mọi thứ đọc ghi trong Go, và bài này về cách chúng ghép với nhau.

io.Reader và io.Writer

type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }

Mỗi cái đúng một method, và đó là toàn bộ lý do chúng ở khắp nơi — cái ren càng đơn giản, càng nhiều thứ vặn vừa. Bài 13 đã nói: interface càng nhỏ, càng nhiều thứ thoả mãn nó.

Thoả mãn io.Reader: tệp, kết nối mạng, thân HTTP request, chuỗi, bộ đệm, tệp nén, luồng mã hoá. Viết một hàm nhận io.Reader là nó làm việc với tất cả.

b, _ := io.ReadAll(strings.NewReader("xin chào"))
io.Copy(&buf, r)

io.Copy là hàm tôi dùng nhiều nhất: chép từ Reader sang Writer với bộ đệm 32KB, không nạp hết vào bộ nhớ.

Vài kiểu tiện ích đáng nhớ:

io.Discard              // Writer vứt mọi thứ
io.LimitReader(r, n)    // chỉ đọc n byte đầu — chống tấn công gửi dữ liệu vô hạn
io.MultiWriter(a, b)    // ghi ra nhiều đích cùng lúc
io.TeeReader(r, w)      // đọc từ r, đồng thời ghi bản sao vào w

io.LimitReader đáng dùng ở mọi chỗ đọc dữ liệu từ ngoài vào.

bufio: gom lời gọi hệ thống

  ghi thẳng 200.000 lần : 58 ms
  qua bufio.Writer      :  1 ms

Năm mươi tám lần.

Nguyên nhân: mỗi f.Write() là một lời gọi hệ thống, và lời gọi hệ thống đắt — mỗi lần như một lượt qua cửa soát vé phải trả phí. Ghi thẳng từng mẩu là xếp hàng qua cửa hai trăm nghìn lần; bufio.Writer chất dữ liệu đầy một bộ đệm 4KB rồi mới qua cửa một chuyến.

w := bufio.NewWriter(f)
defer w.Flush()          // BẮT BUỘC
for ... { w.WriteString(...) }
Quên Flush() là mất dữ liệu. Phần còn trong bộ đệm không bao giờ xuống đĩa, và f.Close() KHÔNG tự flush bộ đệm của bufio — nó chỉ đóng tệp bên dưới.

Thứ tự đúng khi dùng cả hai: w.Flush() trước f.Close(). Vì defer chạy ngược (bài 26), viết defer f.Close() rồi defer w.Flush() sẽ cho đúng thứ tự.

Và như bài 30 đã nói, với tệp ghi thì lỗi Flush cũng cần được kiểm.

Chiều đọc tương tự: bufio.NewReader(f) gom nhiều lần đọc nhỏ thành ít lời gọi hệ thống.

bufio.Scanner: đọc từng dòng

sc := bufio.NewScanner(f)
for sc.Scan() {
	dong := sc.Text()
}
if err := sc.Err(); err != nil { ... }
  quét được 3 dòng, err=<nil>

sc.Err() là dòng người ta hay quên. Scan() trả false cho cả hai trường hợp: hết dữ liệu, và có lỗi. Không kiểm Err() thì lỗi đọc giữa chừng trông y hệt đọc xong.

Và một giới hạn hay cắn: mặc định 64KB mỗi dòng. Dòng dài hơn thì Scan() trả false với lỗi bufio.Scanner: token too long.

sc.Buffer(make([]byte, 1024*1024), 1024*1024)   // nâng lên 1MB

Với dữ liệu không kiểm soát được độ dài dòng, dùng bufio.Reader.ReadString('\n') thay vì Scanner.

Scanner tách được theo thứ khác:

sc.Split(bufio.ScanWords)   // theo từ
sc.Split(bufio.ScanRunes)   // theo rune

fmt: động từ định dạng

fmt.Printf("%v %+v %#v %T %q\n", x, x, x, x, s)

Năm động từ đáng thuộc:

%v — dạng mặc định. %+v — thêm tên trường của struct, thứ bạn muốn 90% thời gian khi gỡ lỗi. %#v — cú pháp Go, dán lại vào mã được. %T — in ra kiểu, công cụ tốt nhất khi làm việc với any (bài 14). %q — chuỗi có nháy và thoát ký tự, cực hữu ích với dữ liệu có khoảng trắng ẩn.

Hai hàm phân biệt:

fmt.Sprintf(...)   // trả về chuỗi
fmt.Fprintf(w, ...) // ghi vào Writer bất kỳ

Fprintf với os.Stdout chính là Printf. Viết hàm nhận io.Writer thay vì in thẳng làm nó test được — lại là cái đầu nối chuẩn ở đầu bài.

Và nhớ từ bài 12: định nghĩa String() string là fmt tự gọi nó — nhưng cẩn thận đệ quy vô hạn nếu bên trong lại Sprintf("%v", chính nó).

fmt chậm hơn bạn nghĩ

fmt dùng phản chiếu, nên nó chậm hơn nhiều so với nối chuỗi trực tiếp. Trong đường chạy nóng:

s := "id=" + strconv.Itoa(n)          // nhanh
s := fmt.Sprintf("id=%d", n)           // chậm hơn nhiều

Với mã thường thì không đáng bận tâm — độ đọc quan trọng hơn. Nhưng trong vòng lặp hàng triệu lần hoặc trong hàm log gọi liên tục, khác biệt là thật. Bài 52 sẽ đo.

Nếu chỉ nhớ một cái bẫy từ cả bài, để nó là cái này — thử trong ba mươi giây:

f, _ := os.Create("/tmp/t")
w := bufio.NewWriter(f)
w.WriteString("dữ liệu quan trọng")
f.Close()                  // KHÔNG có w.Flush()

Mở /tmp/t — tệp rỗng. Thêm w.Flush() trước f.Close() và dữ liệu xuất hiện. Đây là lỗi bufio phổ biến nhất, và nó im lặng hoàn toàn.

Mẫu số chung

Hai ý tưởng trong bài này là hai thứ mọi ngôn ngữ đều có, chỉ khác cách gọi tên.

Một: một interface đọc/ghi tối giản là thứ cho phép nén, mã hoá, đệm và mạng ráp vào nhau như các lớp thay thế được — đúng mẫu "decorator" kinh điển. Java có InputStream/OutputStream (và Reader/Writer cho ký tự), .NET có Stream, Python có "đối tượng giống file" theo kiểu vịt, Node có Readable/Writable. Ai từng viết new BufferedReader(new InputStreamReader(new FileInputStream(...))) của Java là đã chồng đúng cái đầu nối chuẩn này; io.Reader của Go chỉ là bản nhẹ nhất, đúng hai method.

Hai: đệm để nhiếp chi phí lời gọi hệ thống là phổ quát, và nó luôn kèm một nghĩa vụ flush. Java có BufferedWriter, C có FILE* cùng fflush, Python có io.BufferedWriter — và "in ra mà không thấy gì vì chưa flush" là kinh nghiệm đau của lập trình viên ở mọi ngôn ngữ. Nhưng có một cái bẫy di động thật đáng nhớ: đóng một luồng có đệm có tự flush hay không thì tuỳ ngôn ngữ — close() của Java và fclose của C có flush, còn f.Close() của Go không flush bufio.Writer, vì Go cố ý tách bộ đệm khỏi tệp thành hai vật riêng; bạn sở hữu cái flush. Quen Java rồi quên Flush() ở Go là mất dữ liệu trong im lặng.

Sợi chỉ chung đáng mang theo: thông lượng I/O bị chi phối bởi số lần vượt ranh giới (lời gọi hệ thống), không phải số byte — nên hãy gom; và mọi bộ đệm là một lời hứa bạn phải thanh toán — dữ liệu chưa flush là dữ liệu chưa được lưu, bất kể màn hình có báo "đã ghi" hay không.

Ngày mai: os và làm việc với tệp.

Bài tập làm thử

Bài 1 (đọc hiểu). Đoạn mã sau mở tệp /tmp/t để ghi, dùng bufio.Writer, rồi đóng tệp. Sau khi chạy, mở lại /tmp/t sẽ thấy gì?

f, _ := os.Create("/tmp/t")
w := bufio.NewWriter(f)
w.WriteString("dữ liệu quan trọng")
f.Close()
Đáp án

Tệp rỗng. f.Close() không tự flush bộ đệm của bufio.Writer — nó chỉ đóng tệp bên dưới. Dữ liệu vẫn còn nằm trong bộ đệm của w và không bao giờ xuống đĩa. Phải gọi w.Flush() trước f.Close() để dữ liệu thực sự được ghi.

Bài 2 (sửa lỗi). Sửa đoạn mã ở Bài 1 để dữ liệu được ghi đúng, đồng thời đảm bảo thứ tự Flush chạy trước Close dù cả hai đều dùng defer.

Đáp án
f, _ := os.Create("/tmp/t")
defer f.Close()
w := bufio.NewWriter(f)
defer w.Flush()
w.WriteString("dữ liệu quan trọng")

Vì defer chạy theo thứ tự ngược (LIFO — vào sau ra trước), khai defer f.Close() trước rồi defer w.Flush() sau sẽ khiến w.Flush() chạy trước f.Close() lúc hàm kết thúc — đúng thứ tự cần thiết.

Bài 3 (vận dụng thực tế). Bạn cần đọc một tệp log theo từng dòng để xử lý. Một số dòng trong tệp log dài hơn 64KB (ví dụ dòng chứa stack trace lớn). Viết mã đọc bằng bufio.Scanner xử lý được trường hợp này, và giải thích vì sao cần bước đó.

Đáp án
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 1024*1024), 1024*1024) // nâng giới hạn lên 1MB
for sc.Scan() {
	dong := sc.Text()
	xuLy(dong)
}
if err := sc.Err(); err != nil {
	log.Fatal(err)
}

bufio.Scanner mặc định giới hạn 64KB mỗi dòng (token); dòng nào dài hơn sẽ làm Scan() trả false kèm lỗi token too long, và nếu không kiểm sc.Err() thì trường hợp này trông y hệt "đọc xong hết dữ liệu". Gọi sc.Buffer(...) để nâng giới hạn, và luôn kiểm sc.Err() sau vòng lặp vì Scan() trả false cho cả hai trường hợp hết dữ liệu và có lỗi.

Bài 4 (bẫy/đánh đổi). Bài viết đo được ghi thẳng 200.000 lần mất 58ms, còn qua bufio.Writer chỉ mất 1ms — nhanh gấp 58 lần. Giải thích nguyên nhân gốc của chênh lệch này, và nêu một tình huống mà việc dùng bufio lại không đáng, hoặc phải cẩn trọng.

Đáp án

Nguyên nhân: mỗi f.Write() trực tiếp là một lời gọi hệ thống, và lời gọi hệ thống đắt — giống mỗi lần phải qua cửa soát vé mất phí. bufio.Writer chất dữ liệu đầy một bộ đệm (mặc định 4KB) rồi mới thực hiện lời gọi hệ thống một lần, giảm mạnh số lần "qua cửa". Tình huống cần cẩn trọng: dùng bufio.Writer mà quên gọi Flush() trước khi chương trình kết thúc hoặc trước khi đóng tệp — dữ liệu còn trong bộ đệm sẽ mất hoàn toàn và im lặng, đúng như bẫy ở Bài 1.

Bài 5 (đọc hiểu — io.Reader/Writer và động từ fmt). Với Fprintf, đoạn mã sau in ra gì, và giải thích vì sao viết hàm nhận io.Writer thay vì in thẳng ra os.Stdout lại giúp hàm dễ test hơn?

func ChaoHoi(w io.Writer, ten string) {
	fmt.Fprintf(w, "Xin chào, %s!\n", ten)
}

var buf bytes.Buffer
ChaoHoi(&buf, "Minh")
fmt.Print(buf.String())
Đáp án

In ra Xin chào, Minh!. fmt.Fprintf ghi vào bất kỳ io.Writer nào — ở đây là &buf (một *bytes.Buffer), thay vì luôn in ra màn hình. Viết hàm nhận io.Writer giúp test được: trong test, truyền vào một bytes.Buffer để kiểm nội dung ghi ra mà không cần bắt output thật của os.Stdout; trong chương trình thật, truyền os.Stdout (khi đó Fprintf với os.Stdout chính là Printf). Đây là ứng dụng trực tiếp của "đầu nối chuẩn" io.Writer.