پکیج 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ها:
- Producer/Consumer با پایان مشخص
- Writer را حتماً Close/CloseWithError کنید؛ در غیر این صورت خواننده هرگز EOF نمیگیره و گیر میکنه. نمونهٔ درست توی مثالها و منبع پیوست هست.
- سمت مصرفکننده از
io.Copy
استفاده کن تا قواعدEOF
و شمارش بایتها خودکار رعایت بشه.
- خطا را propagate کن
- اگر وسط کار producer خطا خورد،
CloseWithError
بده تا consumer مطلع شه و سریعتر fail کنه. این باور غلط که «همیشه Close ساده کافیه» در استریمهای طولانی دردسرسازه. (قواعد خطا/EOF در منابع پیوست مرور شده.)
- اگر وسط کار producer خطا خورد،
- بافر جایی دیگه، Pipe اینجا
io.Pipe
خودش بافر قابلتوجهی نداره؛ برای throughput بهتر ازbufio
یاio.CopyBuffer
روی مقصد/مبدأ استفاده کن؛
- Fan-in/Fan-out با Pipeهای زنجیرهای
- میتونی چند مرحلهٔ پردازش (encode → compress → encrypt) رو با چند goroutine و چند Pipe زنجیره کنی. دقت کن هر مرحله خروجی رو تا آخر drain کنه و بهموقع ببنده، وگرنه مرحلهٔ قبل block میشه.
- از 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
با خطاهای دیگر
#
io.EOF
خطا نیست؛ علامت پایان جریانه. وقتی منبع داده تموم میشه، بعضی Readerهاn>0
وerr==io.EOF
برمیگردونن (یعنی «این آخریشه!»). بعضیهای دیگه ممکنه همون لحظهerr==nil
بدن و خواندن بعدیio.EOF
بده. پس «پایان» همیشه در فراخوانی بعدی قطعی میشه. نتیجه؟ حلقهی خواندن باید رویio.EOF
از حلقه خارج بشه؛ ولی قبلش هر بایتی که باn>0
اومده رو پردازش کن. این دقیقاً همون «قوانین خواندن»یه که روی Readerها تأکید شده.n>0
همراه باerr!=nil
: طبق قواعد Reader، ممکنه قبل از وقوع خطا هنوز چند بایت معتبر تویp[:n]
داشته باشید (مثلاً سوکت ناگهانی بسته شده). حواستون باشه اون بایتها از دست نرن؛ اول مصرفشون کنید، بعد تصمیم بگیرید ریترای کنید یا قطع.io.ErrUnexpectedEOF
باio.EOF
فرق داره.io.ErrUnexpectedEOF
یعنی «داده زودتر از حد انتظار تموم شد» (مثلاً وسط یک بلاک ساختاریافته). این یکی خطای واقعی است و معمولاً باید به کاربر گزارش بشه یا عملیات رو ریترای/رولبک کنید. (نمونههای مرسوم هنگامio.CopyN
یاio.ReadFull
اتفاق میافته.)- الگوی صحیح حلقهی خواندن: تا وقتی
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
کنترل کامل جریان را داشته باشید.