Go'da io.Reader/io.Writer, os ve bufio: Dosya İşlemleri, Stream Kompozisyonu ve embed
Go'da dosya, gzip, HTTP body ve string aynı tek method'luk io.Reader'ı karşılar; fonksiyon dosya adı yerine io.Reader alırsa 1 GB'lık log sabit bellekle işlenir. bufio.Scanner'ın 64 KB satır sınırı ve sessiz durması, yazılan dosyada Close hatası, csv.Writer'ı flush etmek, LimitReader/MultiWriter/TeeReader ile kompozisyon ve e-posta şablonunu //go:embed ile binary'ye gömmek.
Sürüm notu: Bu yazıdaki kodlar Go 1.27.4 ile derlenip çalıştırılmıştır; Bookstore module'ünün go satırı 1.26.0'dır ve kod Go 1.26.11 ile de derlenip testlerden geçer. embed Go 1.16, errors.Join Go 1.20 ile gelmiştir. 1 GB'lık log ölçümleri başka işlerin de çalıştığı bir geliştirici makinesinde yapıldı; süreler yön göstermek için.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) log'unda bir gün 70 KB'lık bir satır beliriyor: bir istemci çok uzun bir URL göndermiş. Log'u satır satır okuyan kod şöyle:
func main() {
log := "line 1\n" + strings.Repeat("x", 70_000) + "\nline 3\n" // ortada 70 KB'lık bir satır
sc := bufio.NewScanner(strings.NewReader(log))
n := 0
for sc.Scan() {
n++
}
fmt.Println("lines read:", n) // kısa bir dosya gibi görünür
fmt.Println("sc.Err():", sc.Err())
sc = bufio.NewScanner(strings.NewReader(log))
sc.Buffer(make([]byte, 0, 64<<10), 1<<20) // 1 MB'a kadar satırlara izin ver
n = 0
for sc.Scan() {
n++
}
fmt.Println("lines read:", n, "err:", sc.Err())
}
Üç satırlık girdide ilk döngü kaç satır sayar ve hata verir mi?
$ go run ./labs/18-scanner
lines read: 1
sc.Err(): bufio.Scanner: token too long
lines read: 3 err: <nil>
Bir satır saydı ve sessizce durdu. bufio.Scanner bir satırı varsayılan olarak en fazla 64 KB'lık bir buffer'da tutuyor; daha uzun bir satır gelince Scan false dönüyor ve sebebi sadece Err'de yazıyor. Err'e bakmayan bir rapor, log'un geri kalanını hiç okumadan "bitti" der. Bu yazıda Go'da dosya ve stream işlerini görüyoruz: io.Reader ve io.Writer, os ile dosya açıp kapatmak, bufio, encoding/csv, reader ve writer'ları birleştirmek ve 11'de söz verdiğim embed. Bookstore'a 1 GB'lık sipariş log'unu sabit bellekle günlük satış raporuna çeviren bir package ve binary'ye gömülü bir e-posta şablonu geliyor.
io.Reader ve io.Writer: İki Küçük Interface
.NET'te Stream, Read, Write, Seek, CanRead, Length ve daha fazlasını taşıyan tek bir abstract class. Go'da bunun yerine tek method'luk interface'ler var:
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
io.Closer, io.Seeker, io.ReaderAt ayrı; ihtiyaç olunca io.ReadCloser gibi birleşimleri kullanılıyor. 04'te konuştuğumuz "küçük interface'ler" fikrinin standart kütüphanedeki en başarılı örneği bu: *os.File, *gzip.Reader, *bytes.Buffer, *strings.Reader, HTTP request body'si ve TCP bağlantısı aynı Read'i karşılıyor. Read'in sözleşmesi .NET'teki gibi: istenen kadar byte dönmeyebilir ve son parçayla birlikte io.EOF dönebilir, yani önce n byte'ı işleyip sonra hataya bakmak gerekiyor. .NET'te I/O İşlemleri serisinin "Stream Modeli" yazısında anlattığımız az byte dönme sorunu burada da geçerli; tam len(p) byte gerekiyorsa io.ReadFull var.
Dosya Açmak, Kapatmak ve Close Hatası
Raporu dosyadan dosyaya çalıştıran lab, girdinin .gz olup olmadığına göre bir reader'ı başka bir reader'la sarıyor:
func run(in, out string) (err error) {
f, err := os.Open(in)
if err != nil {
return err
}
defer f.Close() // sadece okuma: Close hatası bir şey kaybettirmez
var r io.Reader = f
if strings.HasSuffix(in, ".gz") {
gz, err := gzip.NewReader(f) // bir Reader'ı saran bir Reader
if err != nil {
return err
}
defer gz.Close() // f'i kapatmaz
r = gz
}
w, err := os.Create(out)
if err != nil {
return err
}
defer func() { err = errors.Join(err, w.Close()) }() // Close başarılı olana kadar yazma bitmiş sayılmaz
ist, err := time.LoadLocation("Europe/Istanbul")
if err != nil {
return err
}
return report.DailySales(r, w, ist)
}
Okumak için açılan dosyada defer f.Close() yeterli. Yazmak için açılan dosyada durum farklı: işletim sistemi veriyi kendi buffer'ında tutabilir ve bir hata (disk dolu, ağ diski koptu) ancak Close'ta ortaya çıkabilir. defer w.Close() bu hatayı çöpe atıyor; named return ve errors.Join ile fonksiyonun hatasına eklemek bunun en kısa yolu. gzip.Reader.Close'un altındaki dosyayı kapatmadığına dikkat; .NET'teki leaveOpen: true Go'da varsayılan davranış ve her katmanı kendin kapatıyorsun. Küçük dosyalar için os.ReadFile ve os.WriteFile tek satır; yollar için filepath.Join Windows'ta ters bölü, Linux'ta düz bölü kullanıyor ve klasör ağacı için os.MkdirAll var.
bufio.Scanner ile 1 GB'lık Log
Bookstore API'si bu yazıdan itibaren her siparişte 13'teki JSON log'una bir "order placed" satırı yazıyor:
{"time":"2026-11-15T06:21:35.098623+03:00","level":"INFO","msg":"order placed","order_id":"ORD-1001","items":1,"total":"699.80","request_id":"XKEL4VWWA5GM"}
Yeni internal/report package'ı bu satırlardan depo saatine göre günlük bir satış özetini CSV (Comma-Separated Values; alanları virgülle ayrılmış düz metin) olarak çıkarıyor. İmzasında dosya yok; bir io.Reader alıp bir io.Writer'a yazıyor:
// DailySales r'den JSON log satırlarını okur ve w'ya her gün için bir CSV
// satırı yazar. Günler loc'taki takvim günleridir. Her seferinde bir satır okuduğu için
// bellek kullanımı log'un boyutuyla büyümez.
func DailySales(r io.Reader, w io.Writer, loc *time.Location) error {
days := make(map[string]*day)
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64<<10), MaxLineBytes)
line := 0
for sc.Scan() {
line++
if !bytes.Contains(sc.Bytes(), marker) {
continue
}
var e orderPlaced
if err := json.Unmarshal(sc.Bytes(), &e); err != nil {
return fmt.Errorf("line %d: %w", line, err)
}
total, err := catalog.ParseMoney(e.Total)
if err != nil {
return fmt.Errorf("line %d: %w", line, err)
}
key := e.Time.In(loc).Format(time.DateOnly)
d := days[key]
if d == nil {
d = &day{}
days[key] = d
}
d.orders++
d.items += e.Items
d.revenue += total
}
if err := sc.Err(); err != nil { // bu olmadan uzun bir satır döngüyü sessizce bitirir
return fmt.Errorf("after line %d: %w", line, err)
}
cw := csv.NewWriter(w)
if err := cw.Write([]string{"date", "orders", "items", "revenue"}); err != nil {
return err
}
for _, key := range slices.Sorted(maps.Keys(days)) {
d := days[key]
row := []string{key, strconv.Itoa(d.orders), strconv.Itoa(d.items), d.revenue.Decimal()}
if err := cw.Write(row); err != nil {
return err
}
}
cw.Flush() // csv.Writer da buffer'lar
return cw.Error()
}
Buffer çağrısı satır sınırını 1 MB'a çıkarıyor. bytes.Contains ile yapılan ön kontrol, her dört satırdan üçünü JSON'a hiç çevirmeden atlıyor; sc.Bytes() bir kopya değil, Scanner'ın buffer'ına bakan bir slice ve bir sonraki Scan'de değişiyor, bu yüzden saklamak gerekirse kopyalanmalı. Testler bu fonksiyona strings.NewReader ve bytes.Buffer veriyor; dosya, klasör ya da mock gerekmiyor. labs/18-genlog ile 1 GB'lık (6.144.588 satır, 1.536.147 sipariş) yapay bir log ürettim ve raporu üzerinde çalıştırdım:
$ ./bin/report orders.log daily.csv
done in 1.5s, memory from the OS: 13 MB
$ head -4 daily.csv
date,orders,items,revenue
2026-11-01,94530,189043,50592794.25
2026-11-02,108120,216189,57829449.78
2026-11-03,108063,216529,57921928.43
$ ./bin/report orders.log.gz daily-gz.csv
done in 2s, memory from the OS: 17 MB
$ ./bin/readall orders.log
1536147 orders in 900ms, memory from the OS: 1181 MB
1 GB'lık dosya 13 MB bellekle işlendi; 36 MB'lık gzip'li hâli de aynı CSV'yi 17 MB ile üretti, tek fark gzip.NewReader katmanı. labs/18-readall aynı dosyada sadece siparişleri sayıyor, JSON'a hiç çevirmiyor; bu yüzden daha hızlı ama os.ReadFile ve bytes.Split yüzünden işletim sisteminden 1,1 GB'tan fazla bellek aldı. Bu, 13'te kullandığımız --memory=256m gibi bir container limitinin dört katından fazla. 1 Kasım'ın diğer günlerden az olması da bir doğrulama: yapay log 00:00 UTC'de (Coordinated Universal Time; saat dilimi farkı olmayan referans saat) başlıyor, bu da Istanbul saatiyle 03:00.
encoding/csv ve Flush
encoding/csv, RFC (Request for Comments; internet standartlarının yayımlandığı belge serisi) 4180'in tanımladığı biçime göre tırnak ve kaçış karakterlerini doğru yazıp okuyor; 15'teki strings.Split ile yaptığımız bölme tırnaklı bir alanda bozulurdu. Türkçe Excel'in noktalı virgülünü okumak için r.Comma = ';' yeterli. Bir tuzağı var: csv.Writer kendi içinde buffer kullanıyor.
for _, flush := range []bool{false, true} {
path := filepath.Join(dir, fmt.Sprintf("daily-%v.csv", flush))
f, err := os.Create(path)
if err != nil {
panic(err)
}
cw := csv.NewWriter(f) // kendi bufio.Writer'ında buffer'lar
_ = cw.Write([]string{"date", "orders"})
_ = cw.Write([]string{"2026-11-14", "108330"})
if flush {
cw.Flush()
if err := cw.Error(); err != nil {
panic(err)
}
}
if err := f.Close(); err != nil { // dosyayı kapatmak csv.Writer'ı flush etmez
panic(err)
}
fmt.Printf("flush=%v: %d bytes on disk\n", flush, size(path))
}
$ go run ./labs/18-flush
flush=false: 0 bytes on disk
flush=true: 30 bytes on disk
Dosyayı kapatmak csv.Writer'ı flush etmiyor; Flush unutulursa dosya oluşuyor ama boş kalıyor ve hiçbir yerde hata görmüyorsun. Flush'ın kendisi hata dönmediği için ardından cw.Error()'a bakmak gerekiyor; DailySales'in son iki satırı bunu yapıyor. bufio.Writer için de kural aynı: labs/18-genlog 1 MB'lık bir bufio.Writer kullanıyor ve son satırı return w.Flush().
Reader ve Writer'ları Birleştirmek
Küçük interface'lerin asıl kazancı kompozisyon. Kitap kapağı yükleyen bir endpoint düşün: body'yi dosyaya yazarken boyutu sınırlamak ve SHA (Secure Hash Algorithm; verinin içeriğinden sabit uzunlukta bir özet üreten hash fonksiyonu ailesi) ailesinden SHA-256 özetini hesaplamak istiyorsun, veriyi bir kere okuyarak:
// saveCover body'yi en fazla maxCover byte olarak dir/name'e kopyalar ve
// yazdığının boyutunu ve SHA-256'sını tek geçişte döner.
func saveCover(dir, name string, body io.Reader) (n int64, sum string, err error) {
f, err := os.Create(filepath.Join(dir, name))
if err != nil {
return 0, "", err
}
defer func() { err = errors.Join(err, f.Close()) }()
h := sha256.New()
limited := io.LimitReader(body, maxCover+1) // bir byte fazlası: fazla büyük bir body'yi fark etmek için
n, err = io.Copy(io.MultiWriter(f, h), limited)
if err != nil {
return n, "", err
}
if n > maxCover {
return n, "", errTooLarge
}
return n, fmt.Sprintf("%x", h.Sum(nil))[:16], nil
}
$ go run ./labs/18-compose
4000 fedafb0efa4bfcb8 <nil>
1048577 cover is larger than 1 MB
5 "Dune\n" "Dune\n"
io.LimitReader 1 MB'tan bir byte fazlasını okuyor; tam 1 MB'lık bir dosyayı kabul edip daha büyüğünü fark etmenin yolu bu. io.MultiWriter her yazmayı hem dosyaya hem hash'e gönderiyor, io.Copy da 32 KB'lık bir buffer'la sonuna kadar kopyalıyor. Son satırdaki io.TeeReader okunan her şeyi bir writer'a da kopyalıyor; log'a ya da hash'e "yan çıktı" almak için. io.MultiReader ise birkaç reader'ı arka arkaya bağlıyor. HTTP tarafında aynı işi 10'daki http.MaxBytesReader yapıyor. Hepsi .NET'teki decorator zincirinin (GZipStream içinde FileStream) karşılığı, ama her parça tek bir iş yapıyor.
embed: Şablonu Binary'ye Gömmek
11'de sqlstore şema dosyasını bir string'e gömüyordu:
//go:embed schema.sql
var schema string
Aynı mekanizma birden çok dosya için embed.FS veriyor. Bookstore'un sipariş onay e-postası artık internal/notifications/templates/order_confirmation.txt'te duruyor ve text/template ile oluşturuluyor:
// templates binary'ye derlenir; deploy edilecek bir dosya yok.
//
//go:embed templates/*.txt
var templates embed.FS
var mails = template.Must(template.ParseFS(templates, "templates/*.txt"))
Subject: Your Bookstore order {{.ID}}
Hello {{.CustomerID}},
thank you for your order. We received it on {{.Placed}}.
{{range .Items}}
{{.Quantity}} x {{.Title}} ({{.ISBN}}) {{.Total}}
{{- end}}
Total: {{.Total}}
Estimated delivery: {{.Delivery}}
Bookstore
$ go run ./labs/18-email
Subject: Your Bookstore order ORD-1001
Hello ada,
thank you for your order. We received it on 13.11.2026 14:59.
2 x Dune (9780441172719) 699.80 TRY
1 x Emma (9780141439587) 199.00 TRY
Total: 898.80 TRY
Estimated delivery: 17.11.2026
Bookstore
OrderConfirmation(w io.Writer, ...) e-postayı bir writer'a yazıyor: lab'da os.Stdout, testte strings.Builder, ileride bir SMTP (Simple Mail Transfer Protocol; e-posta gönderme protokolü) client'ı. 16'daki teslim tahmini ve depo saati de burada kullanılıyor. template.Must şablonu package yüklenirken parse ediyor; şablonda bir syntax hatası varsa program ilk e-postada değil, açılışta duruyor. //go:embed'in birkaç kuralı var. Pattern'ler package klasörüne göre ve Windows'ta da düz bölü ile yazılıyor; .. ile module dışına çıkamıyorsun. Hiçbir dosyayla eşleşmeyen bir pattern build'i durduruyor:
$ go build .
main.go:8:12: pattern templates/*.html: no matching files found
Klasör adı verince . ya da _ ile başlayan dosyalar dışarıda kalıyor; templates/* ve all:templates onları da alıyor. Klasörde bir .draft.txt varken üç pattern'i denedim:
func main() {
for name, f := range map[string]embed.FS{"templates": dir, "templates/*": star, "all:templates": all} {
var files []string
fs.WalkDir(f, ".", func(p string, d fs.DirEntry, err error) error {
if !d.IsDir() {
files = append(files, p)
}
return nil
})
fmt.Println(name, files)
}
}
$ go run . | sort
all:templates [templates/.draft.txt templates/order_confirmation.txt]
templates [templates/order_confirmation.txt]
templates/* [templates/.draft.txt templates/order_confirmation.txt]
Editörlerin bıraktığı geçici dosyaların ya da .env gibi gizli dosyaların binary'ye girmemesi için klasör adı vermek ve pattern'i dosya uzantısıyla daraltmak güvenli seçim; Bookstore templates/*.txt kullanıyor.
Üretimde Dikkat
Fonksiyonların imzasında dosya adı değil io.Reader/io.Writer olsun. DailySales aynı kodla dosyayı, gzip'li dosyayı, HTTP body'sini ve testteki string'i işliyor; dosyayı açıp kapatmak main'in ya da handler'ın işi.
Büyük girdide belleği sabit tut. Satır satır okuma ya da io.Copy ile akış, girdinin boyutundan bağımsız bir bellek kullanımı veriyor; os.ReadFile ve io.ReadAll'u boyutunu bildiğin küçük girdilere bırak ve dışarıdan gelen girdiyi her zaman io.LimitReader ya da http.MaxBytesReader ile sınırla.
Yazdığın dosyayı yerinde güncelleme. Rapor yazarken program çökerse yarım bir CSV kalır; önce aynı klasörde geçici bir dosyaya yaz, Close hatasını kontrol et, sonra os.Rename ile asıl adına taşı.
Sık Düşülen Tuzaklar
sc.Err()'e bakmamak. Uzun bir satır, okuma hatası ya da bozuk gzip döngüyü sessizce bitirir; rapor eksik ama "başarılı" görünür.
Yazılan dosyanın Close hatasını yok saymak. defer f.Close() okumada sorun değil; yazmada hatayı fonksiyonun sonucuna ekle.
csv.Writer ya da bufio.Writer'ı flush etmeden bırakmak. Dosya oluşur ama boş ya da eksik kalır; Flush'tan sonra Error()'a da bak.
sc.Bytes()'ı saklamak. Bir sonraki Scan aynı buffer'ın üzerine yazar; saklayacaksan bytes.Clone ya da sc.Text() kullan.
//go:embed ile klasörü olduğu gibi gömmek. templates/* gizli dosyaları da alır; uzantıyla daralt ve gömülen dosyaları fs.WalkDir ile bir testte listele.
Küçük Bir Deneme
★☆☆ labs/18-report'u geçici dosyaya yazıp os.Rename ile taşıyacak şekilde değiştir. Yazma sırasında programı Ctrl+C ile durdurup klasörde ne kaldığına bak.
★★☆ DailySales'e en çok satan beş ISBN'i (kitaplara verilen 13 haneli uluslararası numara) ekle. Bunun için log satırına ISBN'leri de yazman gerekiyor; slog.Group ya da bir slice attribute deneyip orderPlaced'ı ona göre güncelle ve 17'deki gibi sıralı bir sonuç üret.
★★★ POST /api/books/{isbn}/cover endpoint'ini saveCover ile yaz: body'yi 14'teki os.Root ile açılmış bir klasöre kaydet, boyut sınırını aşan isteğe 413 dön ve dosyanın ilk 512 byte'ını http.DetectContentType ile kontrol edip JPEG (fotoğraflar için yaygın sıkıştırılmış resim biçimi) değilse reddet. İpucu: ilk 512 byte'ı okuyup io.MultiReader ile body'nin geri kalanına geri bağlaman gerekecek.
Dosyaları ve stream'leri gördük; serinin son yazısında aynı io.Reader bir HTTP cevabının body'si olarak karşımıza çıkıyor. Bir sonraki yazıda Go'da HTTP client yazıyoruz: http.Client'ın timeout'ları, context ile iptal, connection'ların yeniden kullanılması ve retry.