4.27 آموزش کار با پکیج io

4.27 آموزش کار با پکیج io

io

پکیج io در زبان Go یکی از ابزارهای پایه و همه‌فن‌حریف برای کار با عملیات Input/Output است. فلسفه‌اش اینه که ما بدون وابستگی به نوع خاص داده (مثل فایل، شبکه یا حافظه) بتونیم از یک Interface ساده استفاده کنیم. این یعنی اگر یک شیء فقط متد مورد نیاز رو پیاده‌سازی کنه، می‌تونه در تمام جاهایی که اون رابط انتظار میره استفاده بشه. مثلا io.Reader فقط یک متد Read داره و io.Writer یک متد Write، ولی همین دو قرارداد ساده پایه تمام سیستم I/O Abstraction در Go رو تشکیل میدن.

اینترفیس‌های اصلی این پکیج مثل io.Reader و io.Writer رو میشه ترکیب کرد و ساختارهای جدیدی ساخت، مثل io.ReadWriter که هم قابلیت خواندن داره و هم نوشتن، یا io.ReadCloser که علاوه بر خواندن، قابلیت بستن منبع داده رو هم فراهم می‌کنه. این ترکیب‌ها به ما امکان میدن با منابع داده‌ای مختلف، از فایل گرفته تا اتصال شبکه، به شکلی یکپارچه کار کنیم. وقتی کد رو بر اساس اینترفیس‌ها بنویسیم، نه‌تنها تست‌پذیرتر و قابل توسعه‌تر میشه، بلکه وابستگی به پیاده‌سازی‌های خاص هم از بین میره.

پکیج io فقط به رابط‌ها محدود نیست و مجموعه‌ای از Helper Functions رو هم ارائه میده که کار رو به شدت ساده‌تر و بهینه‌تر می‌کنن. مثلا io.Copy داده رو مستقیم از یک Reader به یک Writer منتقل می‌کنه بدون اینکه ما نیاز به نوشتن حلقه خواندن و نوشتن داشته باشیم. io.MultiWriter خروجی رو به چند مقصد به طور همزمان ارسال می‌کنه، و io.TeeReader برای زمانی که می‌خوای داده رو بخونی و همزمان یک نسخه ازش رو برای Logging یا Debugging ذخیره کنی عالیه. همچنین ابزارهایی مثل io.LimitReader یا io.SectionReader وجود دارن که اجازه میدن فقط بخش خاصی از داده رو پردازش کنیم.

در بخش پیشرفته‌تر، این پکیج امکاناتی مثل io.Pipe رو هم ارائه میده که دو سر ورودی و خروجی رو به هم وصل می‌کنه و به‌ویژه برای Streaming و ارتباط بین Goroutines بسیار کاربردیه. نکته مهم توی استفاده از این پکیج مدیریت درست خطاست؛ مثلا تفاوت io.EOF که فقط نشون‌دهنده پایان داده‌ست با یک خطای واقعی رو باید بدونیم. همچنین استفاده از بافرها (bufio) برای بهبود کارایی و کاهش هزینه I/O در پروژه‌های Production توصیه میشه.

4.27.1 معرفی پکیج io و فلسفه طراحی #

پکیج io در زبان Go یک بخش کلیدی از کتابخانه استاندارد است که پایه و اساس تمام عملیات Input/Output را فراهم می‌کند. هدف اصلی این پکیج، ایجاد یک Abstraction ساده اما قدرتمند برای خواندن و نوشتن داده است، بدون این‌که برنامه‌نویس به منبع یا مقصد خاصی وابسته باشد. این یعنی چه داده از یک فایل بیاید، چه از یک اتصال شبکه یا حتی از حافظه، کدی که آن را پردازش می‌کند یکسان خواهد بود.

در قلب این پکیج دو Interface بسیار ساده قرار دارند: io.Reader و io.Writer.

  • io.Reader: فقط یک متد Read دارد که داده را به صورت بایت‌اسلایس می‌خواند.
  • io.Writer: فقط یک متد Write دارد که داده را به یک مقصد ارسال می‌کند.

هر نوعی (type) که این متدها را پیاده‌سازی کند، به طور خودکار به یک منبع یا مقصد داده قابل استفاده در اکوسیستم io تبدیل می‌شود. همین سادگی باعث شده که اجزای مختلف سیستم، از جمله پکیج‌هایی مثل os، net، bufio و compress، بتوانند به راحتی با هم ترکیب شوند.

یکی از مزیت‌های بزرگ این طراحی این است که به‌جای وابستگی به نوع خاص، وابستگی به رفتار داریم. مثلا تابعی که یک io.Reader را می‌گیرد، می‌تواند بدون تغییر، هم از یک فایل روی دیسک بخواند و هم از یک جریان داده آنلاین یا حتی داده تولیدشده در حافظه. این قابلیت انعطاف‌پذیری بالا را ممکن می‌سازد و کد را برای تست و توسعه آسان‌تر می‌کند.

در نتیجه، پکیج io نه فقط یک ابزار برای کار با داده، بلکه یک لایه انتزاعی است که ساختار و معماری برنامه را ساده، منسجم و مقیاس‌پذیر نگه می‌دارد. این فلسفه مینیمالیستی و ماژولار، از دلایلی است که Go را برای پروژه‌های بزرگ و طولانی‌مدت به انتخابی محبوب تبدیل کرده است.

4.27.1.1 اهمیت abstraction در I/O #

وقتی صحبت از Input/Output می‌شود، خیلی‌ها اولین چیزی که به ذهنشان می‌رسد یک فایل روی دیسک یا یک اتصال شبکه است. اما در عمل، منابع داده می‌توانند بسیار متنوع باشند: یک فایل محلی، یک API، یک بافر حافظه، یا حتی داده تولیدشده لحظه‌ای توسط یک الگوریتم. اگر برای هر کدام بخواهیم کد جداگانه بنویسیم، خیلی زود با یک مجموعه توابع و کلاس‌های تکراری و پیچیده روبه‌رو می‌شویم که نگهداری آن‌ها کابوس خواهد بود.

اینجاست که Abstraction وارد میدان می‌شود. با تعریف یک Interface مشترک مثل io.Reader یا io.Writer، می‌توانیم منطق اصلی کد را مستقل از منبع یا مقصد داده بنویسیم. این یعنی یک تابعی که داده را از ورودی می‌خواند و روی خروجی می‌نویسد، می‌تواند بدون تغییر هم روی فایل کار کند، هم روی شبکه، و هم روی داده‌های در حافظه.

مثلا تصور کنید می‌خواهیم یک تابع بنویسیم که محتوا را از یک Reader به یک Writer منتقل کند:

func TransferData(src io.Reader, dst io.Writer) error {
    _, err := io.Copy(dst, src)
    return err
}

این تابع اصلاً اهمیتی نمی‌دهد که src یک فایل است (*os.File)، یک اتصال TCP (net.Conn) یا حتی یک رشته متنی در حافظه (strings.Reader). کافی است آن منبع متد Read را داشته باشد. همین موضوع باعث می‌شود کد قابل استفاده مجدد، ساده و به راحتی تست‌پذیر شود.

به این شکل، abstraction در I/O مثل یک پل عمل می‌کند که لایه منطق برنامه را از جزئیات فنی منابع داده جدا می‌کند. این کار نه تنها خوانایی و نگهداری کد را بهتر می‌کند، بلکه توسعه ویژگی‌های جدید را هم سریع‌تر و بی‌خطرتر می‌سازد.

4.27.1.2 نقش io در کتابخانه استاندارد Go #

پکیج io رو میشه به‌نوعی ستون فقرات عملیات Input/Output در کل اکوسیستم Go دونست. خیلی از پکیج‌های کتابخانه استاندارد روی همین رابط‌های ساده io.Reader و io.Writer ساخته شدن. این یعنی وقتی با اینترفیس‌های io کار می‌کنید، عملاً دارید با یک استاندارد مشترک صحبت می‌کنید که بقیه پکیج‌ها هم اون رو می‌فهمن.

برای مثال:

  • پکیج os که برای کار با فایل‌ها استفاده میشه، وقتی فایل رو با os.Open باز می‌کنید، یک شیء برمی‌گردونه که هم io.Reader هست، هم io.Writer، هم io.Seeker و هم io.Closer.
  • پکیج net برای کار با شبکه، اتصالات TCP و HTTP رو به شکلی پیاده‌سازی کرده که اون‌ها هم این اینترفیس‌ها رو داشته باشن.
  • پکیج bufio که برای افزایش کارایی از بافر استفاده می‌کنه، عملاً روی همین رابط‌ها سوار شده و می‌تونه هر چیزی که io.Reader یا io.Writer باشه رو بپذیره.
  • پکیج‌های فشرده‌سازی مثل compress/gzip و compress/zlib هم با همین استاندارد کار می‌کنن، بنابراین می‌تونید یک فایل gzip رو بخونید و بدون تغییر خاصی روی خروجی HTTP بفرستید.

این یکپارچگی باعث میشه بتونید اجزای مختلف رو خیلی راحت به هم وصل کنید. مثلا می‌تونید یک فایل فشرده رو باز کنید، خروجیش رو مستقیم از طریق شبکه بفرستید، یا برعکس داده‌های دریافتی رو مستقیماً داخل یک فایل ذخیره کنید، همه این‌ها فقط با چند خط کد.

در واقع پکیج io نقش یک زبان مشترک رو بین اجزای مختلف Go بازی می‌کنه. وقتی هر منبع یا مقصد داده این رابط‌ها رو پیاده‌سازی می‌کنه، شما می‌تونید اون‌ها رو به صورت ماژولار ترکیب کنید و بدون دغدغه از تفاوت‌های داخلی هر منبع، به یک جریان داده واحد برسید. این دقیقاً همون چیزی هست که Go رو در پروژه‌های بزرگ و چندبخشی، ساده و قابل اعتماد نگه می‌داره.

4.27.2 اینترفیس‌های اصلی پکیج io #

پکیج io بر پایه چند Interface کلیدی ساخته شده که هر کدوم رفتار مشخصی رو تعریف می‌کنن. این اینترفیس‌ها شبیه یک قرارداد عمل می‌کنن؛ هر نوعی که این قرارداد رو پیاده‌سازی کنه، می‌تونه به عنوان ورودی یا خروجی در هر کدی که با اون اینترفیس کار می‌کنه استفاده بشه. این رویکرد باعث میشه کدها انعطاف‌پذیر، قابل توسعه و ماژولار باقی بمونن.

4.27.2.1 io.Reader و متد Read #

io.Reader مهم‌ترین و پرکاربردترین اینترفیس پکیج io است:

type Reader interface {
    Read(p []byte) (n int, err error)
}
  • پارامتر p: یک Byte Slice است که داده‌ها در آن قرار می‌گیرند.

  • برگشتی n: تعداد بایت‌هایی که خوانده شده.

  • برگشتی err: اگر خطایی رخ دهد برگردانده می‌شود، که ممکن است io.EOF باشد تا پایان داده را نشان دهد.

هر نوعی که این متد را پیاده‌سازی کند، می‌تواند به عنوان یک Reader استفاده شود. این منبع داده می‌تواند یک فایل، شبکه، حافظه یا هر چیز دیگری باشد.

مثال ساده خواندن از یک رشته:

r := strings.NewReader("Hello IO")
buf := make([]byte, 4)
for {
    n, err := r.Read(buf)
    fmt.Printf("Read: %s\n", buf[:n])
    if err == io.EOF {
        break
    }
}

یک مثال کامل:

package main

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

func main() {
	r := strings.NewReader("Hello IO")
	buf := make([]byte, 4)

	for {
		n, err := r.Read(buf)
		if n > 0 {
			fmt.Printf("Read: %s\n", buf[:n])
		}
		if err == io.EOF {
			break
		}
		if err != nil {
			fmt.Println("Error:", err)
			break
		}
	}
}

4.27.2.2 io.Writer و متد Write #

io.Writer مکمل io.Reader است:

type Writer interface {
    Write(p []byte) (n int, err error)
}
  • پارامتر p: داده‌هایی که باید نوشته شوند.
  • برگشتی n: تعداد بایت‌هایی که نوشته شده‌اند.
  • برگشتی err: خطا هنگام نوشتن (مثلاً فضای ناکافی یا قطع ارتباط).

هر چیزی که متد Write را داشته باشد، می‌تواند مقصد داده باشد، چه این مقصد یک فایل باشد، چه یک اتصال شبکه یا حتی یک بافر حافظه.

مثال ساده نوشتن روی خروجی استاندارد:

msg := []byte("Hello Writer\n")
os.Stdout.Write(msg)

مثال کامل:

package main

import (
	"os"
)

func main() {
	msg := []byte("Hello Writer\n")
	_, err := os.Stdout.Write(msg)
	if err != nil {
		panic(err)
	}
}

4.27.2.3 ترکیب اینترفیس‌ها #

در پکیج io، اینترفیس‌ها می‌توانند با هم ترکیب شوند تا قابلیت‌های بیشتری ارائه دهند:

  • io.ReadWriter: ترکیب Reader و Writer.
  • io.ReadCloser: ترکیب Reader و Closer .
  • io.WriteCloser: ترکیب Writer و Closer.
  • io.ReadWriteCloser: ترکیب Reader، Writer و Closer.

این ترکیب‌ها باعث می‌شوند بتوانید منابع داده‌ای داشته باشید که چندین قابلیت را همزمان ارائه می‌دهند، مثل یک فایل که هم قابل خواندن است، هم نوشتن و هم بستن.

به عنوان مثال:

package main

import (
	"fmt"
	"os"
)

func main() {
	// os.File هم Reader است، هم Writer، هم Closer
	file, err := os.Create("example.txt")
	if err != nil {
		panic(err)
	}
	defer file.Close()

	_, err = file.Write([]byte("Hello File\n"))
	if err != nil {
		panic(err)
	}

	fmt.Println("Data written to example.txt successfully")
}

4.27.2.4 io.Seeker و جابه‌جایی در داده‌ها #

io.Seeker امکان حرکت در یک منبع داده را فراهم می‌کند:

type Seeker interface {
    Seek(offset int64, whence int) (int64, error)
}
  • offset: تعداد بایت‌هایی که باید جابه‌جا شود.
  • whence: نقطه مرجع برای جابه‌جایی (io.SeekStart, io.SeekCurrent, io.SeekEnd).

مثال تغییر مکان در یک فایل:

file, _ := os.Open("data.txt")
file.Seek(10, io.SeekStart) // حرکت به بایت دهم از ابتدای فایل

io.Seeker معمولاً همراه Reader یا Writer استفاده می‌شود و برای کار با داده‌هایی که نیاز به دسترسی تصادفی دارند (مثل پایگاه داده یا فرمت‌های باینری خاص) ضروری است.

به عنوان مثال:

package main

import (
	"fmt"
	"io"
	"os"
)

func main() {
	file, err := os.Create("seek.txt")
	if err != nil {
		panic(err)
	}
	defer file.Close()

	// نوشتن داده اولیه
	file.Write([]byte("0123456789"))

	// رفتن به بایت پنجم
	_, err = file.Seek(5, io.SeekStart)
	if err != nil {
		panic(err)
	}

	// بازنویسی از بایت پنجم به بعد
	file.Write([]byte("XYZ"))

	fmt.Println("File updated successfully")
}

4.27.3 توابع کمکی کاربردی #

پکیج io علاوه بر Interface ‌های پایه، یک سری Helper Functions هم دارد که خیلی از عملیات رایج I/O را ساده‌تر و بهینه‌تر انجام می‌دهند. این توابع باعث می‌شوند دیگر لازم نباشد حلقه‌های دستی برای خواندن و نوشتن بنویسیم و در عین حال از پیاده‌سازی‌های بهینه Go هم استفاده کنیم.

4.27.3.1 io.Copy و io.CopyN #

تابع io.Copy داده را از یک io.Reader به یک io.Writer منتقل می‌کند تا زمانی که منبع به انتها برسد یا خطایی رخ دهد.

package main

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

func main() {
	src := strings.NewReader("Hello io.Copy\n")
	_, err := io.Copy(os.Stdout, src)
	if err != nil {
		panic(err)
	}

	fmt.Println("Copy completed successfully")
}

اگر بخواهیم فقط تعداد مشخصی بایت منتقل کنیم، می‌توانیم از io.CopyN استفاده کنیم:

package main

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

func main() {
	src := strings.NewReader("ABCDEFGH")
	_, err := io.CopyN(os.Stdout, src, 4)
	if err != nil {
		panic(err)
	}

	fmt.Println("\nCopyN completed successfully")
}

4.27.3.2 io.MultiReader و io.MultiWriter #

  • io.MultiReader چند Reader را به هم وصل می‌کند تا خروجی‌شان پشت سر هم خوانده شود.
package main

import (
	"io"
	"os"
	"strings"
)

func main() {
	r1 := strings.NewReader("Hello ")
	r2 := strings.NewReader("World\n")

	mr := io.MultiReader(r1, r2)
	io.Copy(os.Stdout, mr)
}
  • io.MultiWriter داده را همزمان به چند Writer ارسال می‌کند.
package main

import (
	"io"
	"os"
	"strings"
)

func main() {
	file, _ := os.Create("multi.txt")
	defer file.Close()

	mw := io.MultiWriter(os.Stdout, file)
	io.Copy(mw, strings.NewReader("Hello MultiWriter\n"))
}

4.27.3.3 io.TeeReader #

io.TeeReader داده را از یک Reader می‌خواند و در حین خواندن، همان داده را در یک Writer هم می‌نویسد.

package main

import (
	"io"
	"os"
	"strings"
)

func main() {
	src := strings.NewReader("Hello TeeReader\n")
	tr := io.TeeReader(src, os.Stdout)

	// خواندن کامل باعث می‌شود داده هم روی stdout نوشته شود
	io.ReadAll(tr)
}

4.27.3.4 io.LimitReader و io.LimitWriter #

این توابع حجم داده را محدود می‌کنند.

package main

import (
	"io"
	"os"
	"strings"
)

func main() {
	src := strings.NewReader("This is a long text")
	limited := io.LimitReader(src, 7)
	io.Copy(os.Stdout, limited) // فقط ۷ بایت اول چاپ می‌شود
}

4.27.3.5 io.ReadAll #

io.ReadAll تمام داده را تا پایان منبع (io.EOF) می‌خواند و در حافظه نگه می‌دارد (برای داده‌های بزرگ باید با احتیاط استفاده شود).

package main

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

func main() {
	data := strings.NewReader("ReadAll example")
	all, err := io.ReadAll(data)
	if err != nil {
		panic(err)
	}
	fmt.Println(string(all))
}

4.27.3.6 io.SectionReader #

io.SectionReader اجازه می‌دهد فقط بخشی از یک منبع داده را بخوانیم.

package main

import (
	"fmt"
	"io"
	"os"
)

func main() {
	file, _ := os.Open("section.txt")
	defer file.Close()

	// فرض می‌کنیم فایل حداقل ۱۰ بایت دارد
	section := io.NewSectionReader(file, 3, 5) // از بایت سوم، ۵ بایت بخوان
	buf := make([]byte, 5)
	_, err := section.Read(buf)
	if err != nil && err != io.EOF {
		panic(err)
	}

	fmt.Printf("Section data: %s\n", buf)
}

4.27.4 کار با io.Pipe و ارتباط بین Goroutines #

پکیج io یک ابزار جالب و قدرتمند به اسم io.Pipe داره که برای اتصال مستقیم دو goroutine به هم استفاده میشه. ایده اینه که یک goroutine داده‌ها رو به PipeWriter می‌نویسه و یک goroutine دیگه همون لحظه اون داده‌ها رو از PipeReader می‌خونه؛ یعنی بینشون یک کانال داده با API شبیه io.Reader/io.Writer ایجاد میشه.

برخلاف فایل یا بافر، io.Pipe داده رو جایی ذخیره نمی‌کنه؛ بلکه هر چیزی که نوشته میشه، باید همون لحظه توسط Reader خونده بشه. اگه Reader آماده نباشه، Writer بلاک میشه، و برعکس. این باعث میشه برای streaming real-time یا پردازش خط‌به‌خط داده عالی باشه، بدون اینکه کل داده تو حافظه بارگذاری بشه.

یک کاربرد مهمش وقتی هست که بخوای داده رو از یک منبع بخونی، قبل از ذخیره یا ارسال، پردازش کنی، و نتیجه رو به مقصد برسونی — و این کار رو با چند goroutine موازی انجام بدی. برای مثال io.Pipe برای اتصال encoder/decoder به ورودی یا خروجی در لحظه عالیه، مثل گرفتن خروجی یک پردازش و ارسال مستقیمش به gzip بدون ذخیره‌ی موقت.

4.27.4.1 مفاهیم PipeReader و PipeWriter #

io.Pipe یه لولهٔ درون‌حافظه‌ای می‌سازه: یه سرش *io.PipeReader و سر دیگه‌ش *io.PipeWriter . هر بایتی که در PipeWriter می‌نویسی، از PipeReader خونده می‌شه—معمولاً توی دو تا goroutine جدا. این برای زمانی عالیه که تولید داده (producer) و مصرف اون (consumer) هم‌زمان و بدون بافر بزرگ کار کنن. Backpressure طبیعی هم داریم: اگه خواننده کند باشه، نویسنده بلاک می‌شه (و برعکس)، پس هر دو سمت باید «زنده» باشن. ایدهٔ پایه و مثال سادهٔ همین الگو توی منابع پیوست هم اومده.

دو نکتهٔ حیاتی:

  • همیشه انتهای Writer رو وقتی کارت تموم شد Close یا بهتر از اون CloseWithError کن؛ وگرنه خواننده تا ابد منتظر می‌مونه → deadlock.
  • مصرف‌کننده تا EOF بخونه (یا Copy کنه). قواعد خواندن/EOF رو جدی بگیر—خیلی از باگ‌های استریم همین‌جان.

مثال ۱: ابتدایی‌ترین اتصال Producer/Consumer با io.Copy #

package main

import (
	"bytes"
	"fmt"
	"io"
	"os"
)

func main() {
	// منبع دادهٔ ما: یک بافر درون‌حافظه‌ای
	src := new(bytes.Buffer)
	src.WriteString("hello pipe\n")
	src.WriteString("line 2\n")

	pr, pw := io.Pipe()

	// Producer: می‌نویسه داخل pw
	go func() {
		defer func() {
			_ = pw.Close() // پایان جریان؛ مهم برای جلوگیری از بن‌بست
		}()
		if _, err := io.Copy(pw, src); err != nil {
			// در صورت خطا بهتره CloseWithError بدیم:
			// _ = pw.CloseWithError(err)
			fmt.Println("producer copy error:", err)
		}
	}()

	// Consumer: از pr می‌خونه و می‌ریزه روی Stdout
	if _, err := io.Copy(os.Stdout, pr); err != nil {
		fmt.Println("consumer copy error:", err)
	}
}

این الگو تقریباً همون چیزیه که در نمونه‌های پیوست هم می‌بینی: Pipe()، یک goroutine برای نوشتن، و io.Copy برای خواندن تا EOF.

4.27.4.2 استفاده در جریان داده (Streaming) #

جذابیت اصلی io.Pipe اینه که می‌تونی پایپ‌لاین‌های استریم بسازی: ورودی از یه جا میاد، وسط راه encode/zip/hash می‌شه، و خروجی همزمان جای دیگه می‌ره—همه بدون اینکه کل داده رو تو حافظه نگه داری. نمونه‌ها و توصیه‌های استریمی در مقالات پیوست زیاد تأکید می‌کنن که به جای ReadAll، جریان بده و تو مسیر پردازش کن.

مثال ۲: فشرده‌سازی on-the-fly با compress/gzip روی pipe #

  • Producer دادهٔ خام رو می‌خونه و داخل gzip.Writer می‌نویسه که خروجی‌ش PipeWriter ماست.
  • Consumer از PipeReader می‌خونه و مستقیم به فایل می‌نویسه.
package main

import (
	"compress/gzip"
	"fmt"
	"io"
	"os"
	"strings"
)

func main() {
	input := strings.NewReader(strings.Repeat("Go IO Pipe\n", 5))

	pr, pw := io.Pipe()

	// Producer: gzip روی pw
	go func() {
		defer func() {
			_ = pw.Close() // اول gzip.Close، بعد pw.Close
		}()
		gzw := gzip.NewWriter(pw)
		if _, err := io.Copy(gzw, input); err != nil {
			_ = gzw.Close()
			_ = pw.CloseWithError(err)
			return
		}
		_ = gzw.Close()
	}()

	// Consumer: خروجی gzip شده می‌ره توی فایل
	out, err := os.Create("out.gz")
	if err != nil {
		panic(err)
	}
	defer out.Close()

	if _, err := io.Copy(out, pr); err != nil {
		fmt.Println("copy error:", err)
	}
	fmt.Println("done -> out.gz")
}

نکتهٔ مهم: ترتیب بستن‌ها: اول gzw.Close() تا footer نوشته بشه، بعد pw.Close() تا EOF به مصرف‌کننده برسد. این دقیقا همان الگوی «اتصال goroutine‌ها با Pipe و Copy»‌ است.

مثال ۳: محاسبهٔ checksum همزمان با عبور داده (Pipe + TeeReader) #

گاهی می‌خوای همزمان که داده رو می‌فرستی، هش/چک‌سام هم بگیری. io.TeeReader همین کارو می‌کنه.

package main

import (
	"crypto/sha256"
	"fmt"
	"io"
	"os"
	"strings"
)

func main() {
	src := strings.NewReader(strings.Repeat("data...", 100))

	pr, pw := io.Pipe()

	// Producer: src -> TeeReader(hasher) -> pw
	go func() {
		defer pw.Close()
		hasher := sha256.New()
		tr := io.TeeReader(src, hasher)
		if _, err := io.Copy(pw, tr); err != nil {
			_ = pw.CloseWithError(err)
			return
		}
		sum := hasher.Sum(nil)
		fmt.Fprintf(os.Stderr, "sha256: %x\n", sum)
	}()

	// Consumer: pr -> stdout (یا هر مقصد دیگری)
	if _, err := io.Copy(os.Stdout, pr); err != nil {
		fmt.Println("copy error:", err)
	}
}

این الگوی «داده رد می‌شه، همزمان پردازش جانبی انجام می‌دیم».

4.27.4.3 الگوهای همزمانی با io.Pipe #

چند الگوی پرکاربرد و Best Practiceها:

  1. Producer/Consumer با پایان مشخص
    • Writer را حتماً Close/CloseWithError کنید؛ در غیر این صورت خواننده هرگز EOF نمی‌گیره و گیر می‌کنه. نمونهٔ درست توی مثال‌ها و منبع پیوست هست.
    • سمت مصرف‌کننده از io.Copy استفاده کن تا قواعد EOF و شمارش بایت‌ها خودکار رعایت بشه.
  2. خطا را propagate کن
    • اگر وسط کار producer خطا خورد، CloseWithError بده تا consumer مطلع شه و سریع‌تر fail کنه. این باور غلط که «همیشه Close ساده کافیه» در استریم‌های طولانی دردسرسازه. (قواعد خطا/EOF در منابع پیوست مرور شده.)
  3. بافر جایی دیگه، Pipe اینجا
    • io.Pipe خودش بافر قابل‌توجهی نداره؛ برای throughput بهتر از bufio یا io.CopyBuffer روی مقصد/مبدأ استفاده کن؛
  4. Fan-in/Fan-out با Pipeهای زنجیره‌ای
    • می‌تونی چند مرحلهٔ پردازش (encode → compress → encrypt) رو با چند goroutine و چند Pipe زنجیره کنی. دقت کن هر مرحله خروجی رو تا آخر drain کنه و به‌موقع ببنده، وگرنه مرحلهٔ قبل block می‌شه.
  5. از ReadAll پرهیز کن
    • برای دادهٔ بزرگ، به‌جای io.ReadAll استریم کن.

نکات Production-Ready خلاصه #

  • همیشه پایان جریان را سیگنال بده: Close() یا CloseWithError(err).
  • سمت مصرف‌کننده تا EOF بخونه؛ ساده‌ترین: io.Copy(dst, pr). قواعد EOF/Read را رعایت کن.
  • در pipelineها ترتیب بستن wrapperها مهمه (مثلاً اول gzip.Close بعد PipeWriter.Close).
  • عملکرد: برای انتقال‌های بزرگ، از io.CopyBuffer یا bufio.Writer/Reader کمک بگیر و سایز بافر رو بسته به سناریو تنظیم کن.
  • اگر مرحله‌ای error داد، سریعاً propagate کن تا همهٔ goroutineها بدونن باید جمع کنن؛ لوپ‌های بی‌پایان مصرف CPU می‌شن.

4.27.5 پیاده‌سازی Reader و Writer سفارشی #

ایده‌ی اصلی اینجاست: هر چیزی که متد Read([]byte) (int, error) داشته باشه یک io.Reader و هر چیزی که متد Write([]byte) (int, error) داشته باشه یک io.Writer محسوب می‌شه. این دو رابط پایهٔ تمام ورودی/خروجیِ «جریان‌محور» هستن و باهاشون می‌تونیم اجزای قابل‌اتصال بسازیم.

4.27.5.1 ساخت یک Custom Reader #

بیایید یک Reader بسازیم که حروف را به UpperCase تبدیل می‌کند. این Reader خودش دادهٔ خام را از یک Readerِ زیری می‌خواند و هنگام بازگشت، تبدیل را اعمال می‌کند.

package main

import (
	"bytes"
	"fmt"
	"io"
	"os"
	"strings"
)

type UpperReader struct {
	r io.Reader // زیرساخت خواندن
}

// Read باید تا حد امکان بافر p را پر کند، تعداد بایت‌های نوشته‌شده و خطا را برگرداند.
func (u *UpperReader) Read(p []byte) (int, error) {
	n, err := u.r.Read(p)
	if n > 0 {
		// فقط همان تکه‌ای را که پر شده upper کنیم
        // (تبدیل درجا روی p[:n])
		copy(p[:n], bytes.ToUpper(p[:n]))
	}
	return n, err
}

func main() {
	src := strings.NewReader("go io is composable!\n")
	ur := &UpperReader{r: src}
	if _, err := io.Copy(os.Stdout, ur); err != nil {
		fmt.Fprintln(os.Stderr, "copy error:", err)
	}
}

نکات مهم طراحی (طبق توصیه‌های فصل I/O):

  • سعی کنید روی p[:n] کار کنید و از ایجاد allocation ‌های اضافی پرهیز کنید.
  • اجازه بدید خطاهای زیری (مثل io.EOF) همون‌طور عبور کنن تا io.Copy رفتار درست داشته باشه.

4.27.5.2 ساخت یک Custom Writer #

حالا یک Writer می‌سازیم که به ابتدای هر خط یک پیشوند اضافه کند؛ این یعنی باید مرز خطوط را مدیریت کنیم و ممکن است تکه‌خطوط بین فراخوانی‌های Write بمانند.

package main

import (
	"bufio"
	"bytes"
	"fmt"
	"io"
	"os"
)

type LinePrefixWriter struct {
	w       io.Writer
	prefix  []byte
	pending bool   // آیا در ابتدای خط جدید هستیم؟
	buf     []byte // بافر carry برای تکه‌خط‌های بدون \n
}

func NewLinePrefixWriter(w io.Writer, prefix string) *LinePrefixWriter {
	return &LinePrefixWriter{w: w, prefix: []byte(prefix), pending: true}
}

func (lp *LinePrefixWriter) Write(p []byte) (int, error) {
	total := 0
	// دادهٔ جدید را به بافر داخلی اضافه می‌کنیم
	lp.buf = append(lp.buf, p...)
	sc := bufio.NewScanner(bytes.NewReader(lp.buf))
	sc.Split(bufio.ScanLines)

	var out bytes.Buffer
	for sc.Scan() {
		line := sc.Bytes()
		// ScanLines انتهای \n را حذف می‌کند؛ باید خودمان اضافه کنیم
		if lp.pending {
			out.Write(lp.prefix)
		}
		out.Write(line)
		out.WriteByte('\n')
		lp.pending = true // بعد از نوشتن \n ابتدای خط بعدی هستیم
		total += len(line) + 1
	}
	if err := sc.Err(); err != nil {
		return 0, err
	}

	// تشخیص اینکه آیا ورودی با \n تمام شده یا تکه‌خط نیمه‌کاره داریم
	if len(lp.buf) > 0 && lp.buf[len(lp.buf)-1] != '\n' {
		// آخرین توکن توسط Scanner برگردانده نمی‌شود؛ نگهش می‌داریم
		// و pending را false می‌کنیم چون وسط خط هستیم.
		lastNL := bytes.LastIndexByte(lp.buf, '\n')
		if lastNL >= 0 {
			lp.buf = append([]byte{}, lp.buf[lastNL+1:]...) // نگه داشتن دمِ خط
		}
		lp.pending = false
	} else {
		lp.buf = lp.buf[:0] // همه مصرف شده
		lp.pending = true
	}

	// خروجی بافر را یکجا بنویسیم
	nw, err := lp.w.Write(out.Bytes())
	if err != nil {
		return nw, err
	}
	return len(p), nil // قرارداد Writer: تعداد بایت‌های از ورودیِ p پذیرفته‌شده
}

func main() {
	lw := NewLinePrefixWriter(os.Stdout, "[LOG] ")
	_, _ = lw.Write([]byte("hello"))
	_, _ = lw.Write([]byte(" world\nnext line\npartial"))
	_, _ = lw.Write([]byte(" tail\n"))
}

نکات:

  • Writer باید تا حد امکان «همهٔ p» را بپذیرد و اگر کمتر نوشت، مقدار برگشتی را دقیق بده (قابل‌استناد برای backpressure ).
  • استفاده از بافر داخلی برای تکه‌خط‌ها یک الگوی رایج است. مراقب رشد بافر باشید.

4.27.5.3 پیاده‌سازی io.ReaderFrom و io.WriterTo برای بهینه‌سازی #

تابع io.Copy یک میان‌بُر مهم دارد:

  • اگر Reader متد io.WriterTo را پیاده‌سازی کند، io.Copy به‌جای حلقهٔ پیش‌فرض، r.WriteTo(dst) را صدا می‌زند.
  • اگر Writer متد io.ReaderFrom را پیاده‌سازی کند، io.Copy از w.ReadFrom(src) استفاده می‌کند.

این مسیرها اجازه می‌دهند پیاده‌سازیِ شما مسیر داده را بهینه کند (مثلاً استفاده از بافرهای بزرگ‌تر، جلوگیری از کپی‌های اضافه، یا بهره‌بردن از توابع سیستم‌عاملی). به الگوهای استاندارد I/O و توضیحات دربارهٔ io.Copy در منابع اشاره‌شده رجوع کنید.

در کُد زیر، برای UpperReader، متد WriteTo را اضافه می‌کنیم تا مسیر «Reader → Writer» مستقیم‌تر شود. و برای LinePrefixWriter، متد ReadFrom را می‌گذاریم تا داده را بکشد.

package main

import (
	"bytes"
	"fmt"
	"io"
	"os"
	"strings"
)

type UpperReader struct{ r io.Reader }

func (u *UpperReader) Read(p []byte) (int, error) {
	n, err := u.r.Read(p)
	if n > 0 {
		copy(p[:n], bytes.ToUpper(p[:n]))
	}
	return n, err
}

// WriterTo: به io.Copy اجازه می‌دهد مستقیماً از WriteTo استفاده کند.
func (u *UpperReader) WriteTo(w io.Writer) (int64, error) {
	var total int64
	buf := make([]byte, 32*1024) // بافر بزرگ‌تر برای throughput بهتر
	for {
		n, err := u.r.Read(buf)
		if n > 0 {
			blk := bytes.ToUpper(buf[:n])
			m, werr := w.Write(blk)
			total += int64(m)
			if werr != nil {
				return total, werr
			}
			if m < n {
				return total, io.ErrShortWrite
			}
		}
		if err != nil {
			if err == io.EOF {
				return total, nil
			}
			return total, err
		}
	}
}

type LinePrefixWriter struct {
	w       io.Writer
	prefix  []byte
	pending bool
}

// ReaderFrom: به io.Copy اجازه می‌دهد Writer خودش از منبع بخواند.
func (lp *LinePrefixWriter) ReadFrom(r io.Reader) (int64, error) {
	var total int64
	buf := make([]byte, 32*1024)
	var carry []byte

	writeLine := func(line []byte) error {
		if lp.pending {
			if _, err := lp.w.Write(lp.prefix); err != nil {
				return err
			}
		}
		if _, err := lp.w.Write(line); err != nil {
			return err
		}
		if _, err := lp.w.Write([]byte{'\n'}); err != nil {
			return err
		}
		lp.pending = true
		return nil
	}

	for {
		n, err := r.Read(buf)
		if n > 0 {
			total += int64(n)
			chunk := append(carry, buf[:n]...)
			start := 0
			for {
				i := bytes.IndexByte(chunk[start:], '\n')
				if i < 0 {
					// تکه‌خط ناقص
					carry = append([]byte{}, chunk[start:]...)
					lp.pending = false
					break
				}
				end := start + i
				if err2 := writeLine(chunk[start:end]); err2 != nil {
					return total, err2
				}
				start = end + 1
			}
		}
		if err != nil {
			if err == io.EOF {
				// هرچه مانده را بدون \n نهایی، با پیشوند بنویسیم
				if len(carry) > 0 {
					if lp.pending {
						if _, werr := lp.w.Write(lp.prefix); werr != nil {
							return total, werr
						}
					}
					if _, werr := lp.w.Write(carry); werr != nil {
						return total, werr
					}
					lp.pending = false
				}
				return total, nil
			}
			return total, err
		}
	}
}

func NewLinePrefixWriter(w io.Writer, prefix string) *LinePrefixWriter {
	return &LinePrefixWriter{w: w, prefix: []byte(prefix), pending: true}
}

func main() {
	// مسیر بهینه‌شده: io.Copy ابتدا WriterTo روی Reader را امتحان می‌کند
	r := &UpperReader{r: strings.NewReader("hello\nGo io\n")}
	w := NewLinePrefixWriter(os.Stdout, "[UP] ")
	if _, err := io.Copy(w, r); err != nil {
		fmt.Fprintln(os.Stderr, "copy error:", err)
	}
}

چرا این کار سریع‌تر می‌شود؟

  • WriteTo و ReadFrom به شما کنترل بافر و مسیر داده را می‌دهند، پس کپی‌های کمتر و تخصیص‌های کمتر خواهید داشت؛
  • مطابق توضیحات منابع I/O، io.Copy طبق قرارداد ابتدا WriterTo را روی Reader و بعد ReaderFrom را روی Writer چک می‌کند؛ داشتن یکی از این‌ها کافی‌ست تا حلقهٔ پیش‌فرضِ کپی کنار گذاشته شود.

4.27.6 مدیریت خطا در io #

کار با I/O توی Go قشنگه چون همه‌چیز «جریان بایت»‌ه؛ اما همین‌جا اکثر خطاهای ریزه‌میزه هم رخ می‌دن: از برگشتن n>0 همراه با یک err گرفته تا تشخیص پایان داده با io.EOF و هندل کردن عملیات طولانی که باید قابل لغو باشن. این بخش خیلی جمع‌وجور ولی کاربردی می‌گه «چه خطایی عادیه، چی بحرانیه و چطور تولیدی (Production-Ready) هندلش کنیم». بر اساس مقاله‌های پیوست‌شده درباره‌ی I/O استریمی و الگوهای خطا (و چند نکته از اسناد استاندارد)، قواعد مهم Read/Write، تمایز io.EOF، و مدیریت عملیات طولانی با Timeout و Cancellation رو مرور می‌کنیم.

4.27.6.1 تفاوت io.EOF با خطاهای دیگر #

  1. io.EOF خطا نیست؛ علامت پایان جریانه. وقتی منبع داده تموم می‌شه، بعضی Readerها n>0 و err==io.EOF برمی‌گردونن (یعنی «این آخریشه!»). بعضی‌های دیگه ممکنه همون لحظه err==nil بدن و خواندن بعدی io.EOF بده. پس «پایان» همیشه در فراخوانی بعدی قطعی می‌شه. نتیجه؟ حلقه‌ی خواندن باید روی io.EOF از حلقه خارج بشه؛ ولی قبلش هر بایتی که با n>0 اومده رو پردازش کن. این دقیقاً همون «قوانین خواندن»یه که روی Readerها تأکید شده.
  2. n>0 همراه با err!=nil: طبق قواعد Reader، ممکنه قبل از وقوع خطا هنوز چند بایت معتبر توی p[:n] داشته باشید (مثلاً سوکت ناگهانی بسته شده). حواستون باشه اون بایت‌ها از دست نرن؛ اول مصرفشون کنید، بعد تصمیم بگیرید ریترای کنید یا قطع.
  3. io.ErrUnexpectedEOF با io.EOF فرق داره. io.ErrUnexpectedEOF یعنی «داده زودتر از حد انتظار تموم شد» (مثلاً وسط یک بلاک ساختاریافته). این یکی خطای واقعی است و معمولاً باید به کاربر گزارش بشه یا عملیات رو ریترای/رول‌بک کنید. (نمونه‌های مرسوم هنگام io.CopyN یا io.ReadFull اتفاق می‌افته.)
  4. الگوی صحیح حلقه‌ی خواندن: تا وقتی err==nil ادامه بده؛ اگر err==io.EOF بود از حلقه خارج شو؛ اگر err!=nil بود، بسته به سناریو لاگ/بازگشت خطا. (و یادت باشه قبل از خروج هر چیزی در p[:n] رو مصرف کنی.

مثال (الگوی درستِ برخورد با EOF و خطاهای جزئی):

package main

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

func main() {
	// می‌تونی این رو با فایل هم تست کنی: f, _ := os.Open("file.txt"); defer f.Close(); r := f
	r := strings.NewReader("Clear is better than clever")

	buf := make([]byte, 4)
	for {
		n, err := r.Read(buf)
		if n > 0 {
			fmt.Print(string(buf[:n])) // قبل از بررسی خطا، داده‌های دریافت‌شده رو مصرف کن
		}
		if err != nil {
			if err == io.EOF {
				break // پایان طبیعی جریان
			}
			// خطای واقعی (شبکه، دیسک، مجوز و...)
			fmt.Fprintln(os.Stderr, "read error:", err)
			return
		}
	}
}

این الگو دقیقاً همون چیزیه که در منابع رویش تأکید شده: ممکنه n کمتر از len(p) باشه، ممکنه n>0 همراه با خطا بیاد، و n=0, err=nil به معنی EOF نیست.

نکات پروداکشنی:

  • از io.Copy برای ساده‌سازی حلقه‌ها استفاده کن؛ خودش شمارش بایت و EOF رو درست مدیریت می‌کنه.
  • موقع Write، n!=len(p) و io.ErrShortWrite رو جدی بگیر.
  • خطاهای Close() رو چک کن؛ مخصوصاً روی فایل/شبکه. (اگرچه مثال‌های این بخش روی Reader تمرکز دارن، اما چک کردن Close توی خروجی هم مهمه.)

4.27.6.2 مدیریت خطا در عملیات طولانی #

هدف اینجاست: کد I/O طولانی شما باید قابل لغو باشه، زمان‌بندی داشته باشه، و به‌صورت پیوسته خطا/پیشرفت رو مدیریت کنه—بدون اینکه رم رو بترکونه یا فایل لاک بمونه.

1) لغو با context.Context (سراسری و ساده):
خیلی از APIهای خالص io کانتکست نمی‌گیرن، ولی می‌تونیم یک Reader «کانتکست-اگاه» بسازیم که اگر ctx.Done() شد، دیگه نخونه و ctx.Err() برگردونه. این روش برای هر منبعی جواب می‌ده (فایل، حافظه، Pipe، HTTP body و…).

package main

import (
	"context"
	"fmt"
	"io"
	"os"
	"strings"
	"time"
)

type ctxReader struct {
	ctx context.Context
	r   io.Reader
}

func (c *ctxReader) Read(p []byte) (int, error) {
	select {
	case <-c.ctx.Done():
		return 0, c.ctx.Err()
	default:
		return c.r.Read(p)
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()

	// منبع کند (شبیه‌سازی): هر 200ms چند بایت می‌ده
	src := io.Reader(strings.NewReader(strings.Repeat("DATA-", 1<<14)))
	r := &ctxReader{ctx: ctx, r: src}

	// خروجی: استاندارد (می‌تونی فایل هم بذاری)
	if _, err := io.Copy(os.Stdout, r); err != nil {
		fmt.Fprintln(os.Stderr, "copy stopped:", err) // deadline exceeded یا canceled
	}
}

نکته: برای شبکه‌ها (net.Conn) اصلاً لازم نیست دور Reader کُوری بپیچید؛ مستقیماً از SetDeadline/SetReadDeadline استفاده کنید تا Read/Write با Timeout قطع بشه. برای HTTP از http.Client با Timeout یا Context روی Request استفاده کنید. (الگوی خطا همان است: اگر n>0 برگشته، مصرف کن و بعد خطا را مدیریت کن.)

2) کنترل حافظه و اندازه‌ی داده:
در عملیات طولانی، وسوسه نشید io.ReadAll بزنید؛ برای داده‌های بزرگ باعث مصرف رم می‌شه. به‌جایش جریان‌محور (io.Copy، io.CopyBuffer با بافر قابل‌استفاده‌مجدد) کار کنید. اگر باید سقف اندازه را enforce کنید، io.LimitReader یا io.CopyN بگذارید و به io.ErrUnexpectedEOF حساس باشید.

3) پایش و لاگ خطای مرحله‌ای (Stream-aware):
اگر هم‌زمان می‌خواید پیشرفت یا checksum بگیرید، از io.TeeReader استفاده کنید تا جریان یکجا هم مصرف شود هم ثبت/هش شود—و هر خطا همان‌جا متوقف کند.

4) بستن منابع به‌موقع:

  • روی هر مسیر خروج (موفق/ناموفق) Close() را چک کنید و در حلقه‌ها defer Close() نگذارید (نشت دسته‌جمعی می‌ده). برای تعداد زیاد فایل‌ها، «باز کن—کار کن—ببند» را سریع انجام بده.

مثال (کپیِ قابل-لغو + محدودیت حجم + شمارنده‌ی پیشرفت):

package main

import (
	"context"
	"crypto/sha256"
	"fmt"
	"io"
	"os"
	"strings"
	"time"
)

type ctxReader struct {
	ctx context.Context
	r   io.Reader
}

func (c *ctxReader) Read(p []byte) (int, error) {
	select {
	case <-c.ctx.Done():
		return 0, c.ctx.Err()
	default:
		return c.r.Read(p)
	}
}

func main() {
	// منبع شبیه‌سازی‌شدهٔ بزرگ
	src := strings.NewReader(strings.Repeat("X", 10<<20)) // 10MB

	// 1) محدودیت 1MB
	limited := io.LimitReader(src, 1<<20) // 1 MiB

	// 2) پیشرفت/هش موازی با TeeReader
	hasher := sha256.New()
	tr := io.TeeReader(limited, hasher)

	// 3) لغو با Timeout
	ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
	defer cancel()
	cr := &ctxReader{ctx: ctx, r: tr}

	dst, err := os.Create("out.bin")
	if err != nil {
		fmt.Println("create:", err)
		return
	}
	defer func() {
		if err := dst.Close(); err != nil {
			fmt.Println("close:", err)
		}
	}()

	// کپی جریان‌محور
	written, err := io.Copy(dst, cr)
	if err != nil {
		fmt.Println("copy error:", err) // ممکنه context deadline exceeded باشه
	} else {
		fmt.Println("written bytes:", written)
	}

	fmt.Printf("sha256 (partial/complete): %x\n", hasher.Sum(nil))
}

چند Best Practice کوتاه برای عملیات طولانی:

  • «سقف ورودی» بگذار: io.LimitReader روی بدنه‌ی درخواست‌ها (مثلاً آپلود) تا از سوءاستفاده و OOM جلوگیری کنی.
  • «بافر درست» انتخاب کن و اگر حلقه‌ی طولانیه از io.CopyBuffer با یک بافر reuse‌شونده استفاده کن. (افزایش کارایی روی فایل/شبکه)
  • io.Copy خطاها و EOF رو درست مدیریت می‌کنه؛ وقتی خاص‌نویسی لازم نیست، از همون استفاده کن.

4.27.7 Best Practices در استفاده از io #

پکیج io قلب عملیات ورودی/خروجی (I/O) در Go است و تقریبا همه کتابخانه‌های استاندارد مربوط به فایل، شبکه، و پردازش داده بر پایه همین abstractionها ساخته شده‌اند. اما استفاده درست از آن می‌تواند تفاوت زیادی در کارایی، مصرف حافظه، و پایداری کد شما ایجاد کند. در ادامه چند بهترین شیوه‌ی مهم برای استفاده از این پکیج را بررسی می‌کنیم.

4.27.7.1 استفاده از Interface به جای نوع خاص #

یکی از فلسفه‌های کلیدی Go این است که به جای تکیه بر نوع خاص (مثل *os.File) از اینترفیس‌های عمومی مثل io.Reader و io.Writer استفاده کنیم.
این کار باعث می‌شود که کد ما انعطاف‌پذیر باشد و بتواند با هر منبع یا مقصد داده‌ای کار کند — چه فایل باشد، چه شبکه، چه حافظه.

مثال:

package main

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

func printData(r io.Reader) {
	buf := make([]byte, 16)
	for {
		n, err := r.Read(buf)
		if n > 0 {
			fmt.Print(string(buf[:n]))
		}
		if err == io.EOF {
			break
		}
		if err != nil {
			fmt.Println("Error:", err)
			break
		}
	}
}

func main() {
	// خواندن از یک رشته
	printData(strings.NewReader("Hello Go\n"))

	// خواندن از یک فایل
	file, _ := os.Open("test.txt")
	defer file.Close()
	printData(file)
}

در این مثال، تابع printData با هر چیزی که io.Reader را پیاده‌سازی کند کار می‌کند، بدون وابستگی به نوع خاص.

4.27.7.2 بهینه‌سازی با bufio #

خواندن و نوشتن مستقیم روی منابع I/O می‌تواند منجر به تعداد زیادی فراخوانی سیستم (syscall) و کاهش کارایی شود. برای همین bufio بافرهایی ارائه می‌دهد که باعث کاهش فراخوانی‌های I/O و افزایش سرعت می‌شوند.

نکته: همیشه وقتی با داده‌های زیاد یا عملیات کوچک و پرتکرار کار می‌کنید، از bufio.Reader یا bufio.Writer استفاده کنید.

مثال:

package main

import (
	"bufio"
	"fmt"
	"os"
)

func main() {
	file, err := os.Open("bigfile.txt")
	if err != nil {
		panic(err)
	}
	defer file.Close()

	reader := bufio.NewReader(file)

	for {
		line, err := reader.ReadString('\n')
		fmt.Print(line)
		if err != nil {
			break
		}
	}
}

4.27.7.3 مدیریت منابع با io.Closer و defer #

بسیاری از منابع I/O مثل فایل‌ها و کانکشن‌های شبکه نیاز به بستن (Close) دارند. پیاده‌سازی io.Closer و استفاده از defer تضمین می‌کند که حتی در صورت بروز خطا، منابع آزاد می‌شوند.

مثال:

package main

import (
	"fmt"
	"os"
)

func main() {
	file, err := os.Open("data.txt")
	if err != nil {
		panic(err)
	}
	defer file.Close() // تضمین آزاد شدن منبع

	buf := make([]byte, 32)
	n, _ := file.Read(buf)
	fmt.Println(string(buf[:n]))
}

4.27.7.4 انتخاب اندازه buffer مناسب #

اندازه بافر تأثیر مستقیم روی کارایی دارد. بافر کوچک ممکن است باعث افزایش تعداد فراخوانی‌ها و کندی برنامه شود؛ بافر بزرگ هم می‌تواند حافظه را هدر دهد.
به‌طور معمول، برای بیشتر کاربردها بافر 4KB تا 32KB مناسب است (همانند اندازه پیش‌فرض در bufio)، اما در عملیات شبکه‌ای یا پردازش فایل‌های بزرگ می‌توانید آزمایش و بهینه‌سازی کنید.

مثال:

package main

import (
	"io"
	"os"
)

func main() {
	src, _ := os.Open("large.bin")
	defer src.Close()

	dst, _ := os.Create("copy.bin")
	defer dst.Close()

	buf := make([]byte, 64*1024) // 64KB buffer
	io.CopyBuffer(dst, src, buf)
}

4.27.7.5 آپلود استریمیِ فایل بزرگ با ذخیره‌سازی امن (atomic) روی دیسک #

اصول #

  • استریم‌کردن: به‌جای نگه‌داشتن کل فایل در حافظه، ورودی را مستقیم به دیسک کپی کنید.
  • حداکثر اندازه: حتماً روی بدنه‌ی درخواست limit بگذارید.
  • فایل موقت + rename اتمی: اول در مسیر مقصد یک فایل موقت (*.part) بسازید، آخر کار که موفق بود، با os.Rename به نام نهایی منتقلش کنید. روی یک فایل‌سیستم، rename اتمی است.
  • پاکسازی مطمئن: هر خطا/لغو باعث حذف فایل موقت شود.
  • Context‑aware: اگر کلاینت آپلود را کنسل کرد، کپی متوقف شود (Go خودش روی r.Context() این را propagate می‌کند).

هندلر نمونه (HTTP، multipart/form-data) #

این نمونه آپلود تک‌فایله را نشان می‌دهد. برای چند فایل، حلقه روی پارت‌ها بزنید.

package upload

import (
	"bufio"
	"errors"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"strings"
)

const (
	maxUploadSize = 1 << 30 // 1GiB نمونه؛ بسته به نیاز تغییر دهید
	uploadDir     = "./uploads"
)

func UploadHandler(w http.ResponseWriter, r *http.Request) {
	// 1) محدود کردن اندازه بدنۀ درخواست (حفاظت از حافظه و DoS)
	r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)

	// 2) استریم‌کردن multipart بدون بافرکردن کل فرم
	mr, err := r.MultipartReader()
	if err != nil {
		http.Error(w, "invalid multipart request", http.StatusBadRequest)
		return
	}

	// 3) اطمینان از وجود مسیر ذخیره
	if err := os.MkdirAll(uploadDir, 0o755); err != nil {
		http.Error(w, "cannot create upload dir", http.StatusInternalServerError)
		return
	}

	// انتظار داریم یک part فایل به نام "file"
	var savedName string
	for {
		part, err := mr.NextPart()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			http.Error(w, "read part failed", http.StatusBadRequest)
			return
		}
		if part.FormName() != "file" || part.FileName() == "" {
			// سایر فیلدهای فرم را رد کنید (مثلاً متن و …)
			continue
		}

		// 4) ساخت فایل موقت در همان دایرکتوری مقصد (برای rename اتمی روی همان FS)
		tmp, err := os.CreateTemp(uploadDir, "*.part")
		if err != nil {
			http.Error(w, "cannot create temp file", http.StatusInternalServerError)
			return
		}
		tmpPath := tmp.Name()
		defer func() {
			// اگر تا آخر کار rename موفق نشود، فایل موقت پاک می‌شود
			tmp.Close()           // امن است حتی اگر قبلاً بسته شده باشد
			_ = os.Remove(tmpPath)
		}()

		// 5) استریم کپی با بافر معقول (کاهش syscall ها)
		bufw := bufio.NewWriterSize(tmp, 64*1024) // 64KiB؛ با بنچمارک تنظیم کنید

		// اگر بخواهید همزمان hash بگیرید:
		// h := sha256.New()
		// src := io.TeeReader(part, h)
		// _, err = io.CopyBuffer(bufw, src, make([]byte, 64*1024))

		_, err = io.CopyBuffer(bufw, part, make([]byte, 64*1024))
		closeErr := bufw.Flush()
		if err == nil {
			err = closeErr
		}
		// توجه: اگر کلاینت وسط کار قطع شود، copy با خطا برمی‌گردد.
		if err != nil {
			http.Error(w, "upload interrupted/failed", http.StatusBadRequest)
			return
		}

		// 6) اطمینان از پایداری روی دیسک (اختیاری، برای حساسیت بالا)
		if err := tmp.Sync(); err != nil {
			http.Error(w, "fsync failed", http.StatusInternalServerError)
			return
		}
		if err := tmp.Close(); err != nil {
			http.Error(w, "close failed", http.StatusInternalServerError)
			return
		}

		// 7) تولید نام نهایی امن
		finalName := sanitizeFilename(part.FileName())
		finalPath := filepath.Join(uploadDir, finalName)
		// در صورت وجود فایل همنام، یک suffix یکتا اضافه کنید
		finalPath = uniquifyPath(finalPath)

		// 8) جابجایی اتمی
		if err := os.Rename(tmpPath, finalPath); err != nil {
			http.Error(w, "atomic move failed", http.StatusInternalServerError)
			return
		}
		// از اینجا به بعد، فایل موقت را دیگر پاک نکنید
		savedName = filepath.Base(finalPath)
		// یک فایل را ذخیره کردیم؛ اگر فقط یک فایل می‌پذیرید، می‌توانید break کنید.
		break
	}

	if savedName == "" {
		http.Error(w, "no file part found", http.StatusBadRequest)
		return
	}

	w.WriteHeader(http.StatusCreated)
	_, _ = fmt.Fprintf(w, "uploaded: %s\n", savedName)
}

func sanitizeFilename(name string) string {
	// خیلی ساده: مسیرها را حذف و فضای خالی را _ کنیم؛ در عمل سخت‌گیرتر باشید.
	name = filepath.Base(name)
	name = strings.ReplaceAll(name, " ", "_")
	return name
}

func uniquifyPath(path string) string {
	dir := filepath.Dir(path)
	base := filepath.Base(path)
	ext := filepath.Ext(base)
	name := strings.TrimSuffix(base, ext)
	p := path
	i := 1
	for {
		if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
			return p
		}
		p = filepath.Join(dir, name+"("+strconv.Itoa(i)+")"+ext)
		i++
	}
}

نکات کلیدی #

  • http.MaxBytesReader جلوی خواندن بیش از حد را می‌گیرد؛ اگر بزرگ‌تر باشد با خطای 4xx برمی‌گردید.
  • به‌جای r.ParseMultipartForm از r.MultipartReader() استفاده کردیم تا کاملاً استریمی باشد.
  • اول روی فایل موقت می‌نویسیم، بعد Sync و Close و در پایان Rename اتمی. اگر هر جا خطا بخورد، defer فایل موقت را پاک می‌کند.
  • اگر حجم‌ها واقعاً بزرگند، بهتر است اندازه بافر را با بنچمارک روی محیط واقعی تنظیم کنید.
  • اگر نیاز دارید checksum/virus‑scan/thumbnail بسازید، از io.TeeReader (یا کانال‌های موازی با errgroup) استفاده کنید تا هنگام نوشتن، داده را همزمان به پردازش ثانویه بدهید.

آیا اینجا از io.Pipe استفاده کنیم؟ #

برای «کپی ساده‌ی ورودی به دیسک» معمولاً لازم نیست. io.Copy/io.TeeReader ساده‌تر و کم‌ریسک‌ترند.

io.Pipe وقتی می‌درخشد که:

  • تولیدکننده و مصرف‌کننده همزمان دارید و می‌خواهید backpressure طبیعی رخ دهد (تا وقتی مصرف‌کننده نخوانَد، تولیدکننده پیش نرود).
  • نیاز به تبدیل/فشرده‌سازی/اسکن در goroutine جدا دارید که داده را از همون استریم بگیرد.

نمونه‌ی کوتاه با io.Pipe همزمان:

pr, pw := io.Pipe()

// تولیدکننده: داده‌ی آپلود را در Pipe می‌نویسد
go func() {
	defer pw.Close()
	_, err := io.Copy(pw, part) // part همان multipart.Part
	if err != nil {
		// اگر خراب شد، Pipe را با خطا می‌بندیم تا مصرف‌کننده مطلع شود
		_ = pw.CloseWithError(err)
	}
}()

// مصرف‌کننده: همزمان روی دیسک می‌نویسد و پردازش می‌کند
go func() {
	defer pr.Close()
	dst, _ := os.CreateTemp(uploadDir, "*.part")
	defer dst.Close()
	// همزمان hash یا AV:
	// h := sha256.New()
	// multi := io.TeeReader(pr, h)
	_, err := io.Copy(dst, pr) // یا multi
	if err != nil {
		// خطا را log/propagate کنید و فایل موقت را حذف کنید
	}
	// ... Sync/Close/Rename مثل قبل
}()

احتیاط: با io.Pipe اگر یکی از طرفین خواندن/نوشتن را متوقف کند و دیگری چشم‌انتظار بماند، بن‌بست می‌گیرید. حتماً با مدیریت خطا/بستن و errgroup کنترل کامل جریان را داشته باشید.