0

Go Zero to Hero - Bài 71: Lập trình IO: Package io (Reader và Writer)

1. Triết lý Lego của Golang: Mọi thứ đều là Dòng chảy (Stream)

Trong Go, package io không chứa các đoạn code xử lý file hay mạng phức tạp. Nó chỉ định nghĩa các Interface. Sự thiên tài nằm ở chỗ: Bất kỳ cấu trúc dữ liệu nào trong Go (File ổ cứng, Kết nối mạng, Bộ đệm RAM, Gzip archive) miễn là tuân thủ 2 interface này, chúng đều có thể cắm vào nhau và truyền dữ liệu cho nhau như những khối Lego.

Điều này cực kỳ hoàn hảo cho Clean Architecture: Tầng nghiệp vụ (Usecase) của bạn chỉ cần yêu cầu một io.Reader. Nó không cần biết dữ liệu đến từ một file Text cũ kỹ hay từ một luồng HTTP hiện đại. Tính đóng gói (Decoupling) đạt mức tuyệt đối!


2. Nguồn phát dữ liệu: io.Reader

Interface io.Reader chỉ yêu cầu bạn cài đặt đúng 1 phương thức duy nhất:

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

Cách nó hoạt động:

Bạn đưa cho nhau một cái xô rỗng (slice p có kích thước nhất định). Hàm Read sẽ múc nước (dữ liệu) từ nguồn, đổ vào cái xô đó, và trả về n (số lít nước thực tế múc được). Khi nguồn cạn kiệt, nó trả về một lỗi đặc biệt là io.EOF (End Of File).

Thực chiến: Đọc luồng dữ liệu thô

Thay vì dùng các hàm đọc "một cục" (đưa cả file 1GB vào RAM gây sập Server), io.Reader ép bạn đọc theo từng mảng nhỏ (Chunk), giúp RAM luôn ở mức ổn định.

package main

import (
    "fmt"
    "io"
    "strings"
)

func main() {
    // Giả lập một luồng dữ liệu đến từ thiết bị quét vé
    // strings.Reader là một struct đã implement sẵn io.Reader
    stream := strings.NewReader("AFC-1001|AFC-1002|AFC-1003|AFC-1004")

    // Tạo một cái "xô" chứa tối đa 8 bytes cho mỗi lần múc
    buffer := make([]byte, 8)

    for {
        // Múc dữ liệu từ stream đổ vào buffer
        n, err := stream.Read(buffer)

        // Nếu chạm đáy (cạn nước) -> Thoát vòng lặp
        if err == io.EOF {
            fmt.Println("\n✅ Đã đọc hết luồng dữ liệu!")
            break
        }
        
        // Nếu có lỗi khác (rớt mạng, hỏng đĩa...)
        if err != nil {
            fmt.Println("Lỗi đọc dữ liệu:", err)
            break
        }

        // Xử lý n bytes vừa đọc được (Ép kiểu mảng byte sang string để in ra)
        // Chú ý: Chỉ lấy đúng n bytes, vì phần còn dư của buffer có thể là rác cũ
        fmt.Printf("[Múc được %d bytes]: %s\n", n, string(buffer[:n]))
    }
}

3. Nơi tiêu thụ dữ liệu: io.Writer

Ngược lại với Reader, io.Writer là miệng cống xả dữ liệu. Nó cũng chỉ có 1 phương thức:

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

Cách nó hoạt động:

Bạn đưa cho nó một cái xô đầy nước (slice p). Hàm Write sẽ đổ xô nước đó vào đích đến (Ghi ra file, bắn qua mạng...), và trả về n (số bytes đã ghi thành công).

Các đối tượng quen thuộc như os.File, http.ResponseWriter, hay bytes.Buffer (mà chúng ta đưa vào sync.Pool ở Bài 63) đều đã implement sẵn io.Writer.


4. Tuyệt kỹ Backend: Khớp nối với io.Copy

Sẽ rất cực khổ nếu lúc nào bạn cũng phải tự tạo buffer []byte, rồi viết vòng lặp for để múc từ Reader đổ sang Writer. Golang cung cấp một hàm "Phép thuật" làm hộ bạn từ A-Z: io.Copy(dst Writer, src Reader).

Hàm này sẽ tự động tạo một buffer nhỏ (thường là 32KB) ẩn dưới nền, liên tục hút cạn src và bơm thẳng vào dst cho đến khi src báo EOF. Chi phí RAM gần như bằng 0 dù file có nặng tới hàng Terabyte!

Hãy xem kịch bản API tải về file báo cáo lịch sử quẹt vé:

package main

import (
    "fmt"
    "io"
    "net/http"
    "os"
)

func downloadLogAPI(w http.ResponseWriter, r *http.Request) {
    // 1. Mở file log khổng lồ từ ổ cứng (os.File là một io.Reader)
    file, err := os.Open("/var/log/afc_transactions.csv")
    if err != nil {
        http.Error(w, "File not found", http.StatusNotFound)
        return
    }
    defer file.Close()

    // Cài đặt Header báo cho trình duyệt biết đây là file tải về
    w.Header().Set("Content-Disposition", "attachment; filename=afc_log.csv")

    // 2. KẾT NỐI ĐƯỜNG ỐNG BẰNG io.Copy
    // w (http.ResponseWriter) chính là một io.Writer
    // file chính là một io.Reader
    // io.Copy sẽ Stream dữ liệu thẳng từ ổ cứng -> RAM (32KB) -> Card mạng khách hàng
    _, err = io.Copy(w, file)
    
    if err != nil {
        fmt.Println("Lỗi stream file:", err)
    }
}

func main() {
    http.HandleFunc("/download", downloadLogAPI)
    // http.ListenAndServe(":8080", nil) // Mở server
}

Nhờ io.Copy, máy chủ của bạn có thể phục vụ hàng ngàn người tải file cùng lúc mà không bị bục RAM!


5. Các công cụ "Độ chế" đường ống (Tùy chọn nâng cao)

Package io còn cung cấp vô vàn các đầu nối (Adapter) cực kỳ thú vị để xử lý luồng:

  • io.LimitReader(r, n): Lắp một bộ đo vào ống nước. Chặn luồng lại chỉ cho phép hút đúng tối đa n bytes rồi tự ngắt mạch báo EOF (Rất hữu dụng để chặn hacker upload file quá to làm đầy ổ cứng).
  • io.MultiReader(r1, r2, ...): Nối đuôi nhiều cái Reader lại với nhau thành 1 luồng dài vô tận. Cạn ống 1 sẽ tự động múc tiếp ống 2.
  • io.MultiWriter(w1, w2, ...): Hoạt động như một cút nối chữ T. Bơm nước vào 1 đầu, nó tự động sao chép (Broadcast) ra n đầu Writer khác. (Dùng để vừa ghi Log ra màn hình Terminal, vừa ghi đồng thời xuống File).

💡 Tổng kết

Bạn đã nắm được tư duy xử lý dữ liệu ở tầng thấp nhất của Go:

  • Bản chất: io.Reader (Hút dữ liệu vào) và io.Writer (Xả dữ liệu ra).
  • Streaming: Việc đọc theo từng Chunk nhỏ bằng buffer giúp bảo vệ RAM khỏi việc phình to đột biến (OOM).
  • Vũ khí tối thượng: Luôn dùng io.Copy để nối thẳng Nguồn vào Đích một cách tối ưu nhất.
  • Clean Architecture: Biến io.Reader/Writer thành tham số hàm giúp logic của bạn hoàn toàn độc lập với hệ thống hạ tầng (File, Network).

All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.