DEV Community

Jones Charles
Jones Charles

Posted on

Building a Production-Ready File Download System with GoFrame

Introduction ๐Ÿ‘‹

Hey devs! If you're building web applications with Go, you've probably encountered the need to handle file downloads. While it might seem straightforward at first, implementing production-ready file downloads comes with its own set of challenges. In this guide, I'll share practical insights on building robust file download functionality using GoFrame.

TL;DR: We'll cover everything from basic file downloads to production-ready implementations with GoFrame, including handling large files, implementing resume support, and optimizing performance.

What We'll Cover ๐Ÿ“

  • Setting up basic file downloads
  • Handling large files without memory issues
  • Implementing resume/partial downloads
  • Adding security measures
  • Optimizing for production
  • Real-world examples and gotchas

Why GoFrame for File Downloads? ๐Ÿค”

Before we dive in, you might be wondering why choose GoFrame for handling file downloads. Here's the deal: while you could implement file downloads with standard Go libraries, GoFrame provides some nice abstractions that make our lives easier:

  • Stream-based processing (goodbye memory issues!)
  • Built-in middleware support
  • Clean error handling
  • Progress tracking capabilities

Getting Started ๐ŸŒฑ

First things first, let's set up our environment. Make sure you have Go installed (1.16+), then grab GoFrame:

go get -u github.com/gogf/gf/v2@latest
Enter fullscreen mode Exit fullscreen mode

Basic Implementation

Let's start with a simple file download handler. This is your entry point to understanding how GoFrame handles downloads:

package handler

import (
    "github.com/gogf/gf/v2/net/ghttp"
)

func SimpleDownload(r *ghttp.Request) {
    filePath := r.Get("file").String()

    // Quick security check
    if !isValidFile(filePath) {
        r.Response.WriteStatus(403)
        return
    }

    // Set headers and serve
    r.Response.Header().Set("Content-Type", "application/octet-stream")
    r.Response.ServeFileDownload(filePath)
}
Enter fullscreen mode Exit fullscreen mode

Pretty straightforward, right? But wait - there's more to consider when building for production! ๐Ÿ› ๏ธ

Security First! ๐Ÿ”’

Before we get too excited about serving files, let's talk security. Here's a robust validation function to prevent common security issues:

var allowedExtensions = map[string]bool{
    ".txt":  true,
    ".pdf":  true,
    ".doc":  true,
    ".xlsx": true,
}

func isValidFile(filePath string) bool {
    // ๐Ÿšซ Path traversal check
    if strings.Contains(filePath, "..") {
        return false
    }

    // ๐Ÿ“ File existence & type check
    fileInfo, err := os.Stat(filePath)
    if err != nil || !fileInfo.Mode().IsRegular() {
        return false
    }

    // ๐Ÿ“ Size check (100MB limit)
    if fileInfo.Size() > 100*1024*1024 {
        return false
    }

    // ๐Ÿ” Extension check
    ext := strings.ToLower(filepath.Ext(filePath))
    return allowedExtensions[ext]
}
Enter fullscreen mode Exit fullscreen mode

Handling Large Files Like a Pro ๐Ÿ‹๏ธโ€โ™‚๏ธ

Now, here's where things get interesting. When dealing with large files, you don't want to load everything into memory. Here's a streaming implementation that won't make your server cry:

func StreamDownload(r *ghttp.Request) {
    const bufSize = 32 * 1024 // 32KB chunks

    file := r.Get("file").String()
    fd, err := os.Open(file)
    if err != nil {
        r.Response.WriteStatus(500)
        return
    }
    defer fd.Close() // Don't forget this! ๐Ÿ˜‰

    info, _ := fd.Stat()
    r.Response.Header().Set("Content-Length", gconv.String(info.Size()))

    // Stream in chunks
    buf := make([]byte, bufSize)
    for {
        n, err := fd.Read(buf)
        if n > 0 {
            r.Response.Write(buf[:n])
        }
        if err == io.EOF {
            break
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Resume Support: Because Downloads Sometimes Fail ๐Ÿ˜…

Let's be real - downloads can fail, especially for large files. Here's how to implement resume support:

func ResumeDownload(r *ghttp.Request) {
    rangeHeader := r.Header.Get("Range")
    if rangeHeader != "" {
        // Handle range request
        // ... (previous range parsing code)

        r.Response.Header().Set("Content-Range",
            fmt.Sprintf("bytes %d-%d/%d", start, end, fileSize))
        r.Response.WriteStatus(206) // Partial Content

        // Serve the requested chunk
        streamFileContent(r, fd, end-start+1)
    }
}
Enter fullscreen mode Exit fullscreen mode

Real-World Scenarios and Solutions ๐ŸŽฏ

Let's look at some common scenarios you might encounter and how to handle them:

1. Handling Different File Types ๐Ÿ“

Different file types often need different handling. Here's a practical example:

func SmartDownload(r *ghttp.Request) {
    filePath := r.Get("file").String()
    fileExt := strings.ToLower(filepath.Ext(filePath))

    switch fileExt {
    case ".pdf":
        // For PDFs, we might want to allow preview
        r.Response.Header().Set("Content-Type", "application/pdf")
        r.Response.Header().Set("Content-Disposition", "inline")
    case ".csv":
        // For CSVs, we might want to add BOM for Excel compatibility
        r.Response.Header().Set("Content-Type", "text/csv")
        r.Response.Write("\xEF\xBB\xBF") // Add BOM
    case ".mp4":
        // For videos, support range requests for streaming
        handleVideoStream(r)
    default:
        // Default download behavior
        r.Response.Header().Set("Content-Type", "application/octet-stream")
        r.Response.Header().Set("Content-Disposition", "attachment")
    }

    r.Response.ServeFileDownload(filePath)
}
Enter fullscreen mode Exit fullscreen mode

2. Browser Compatibility Handling ๐ŸŒ

Different browsers handle downloads differently. Here's how to handle it:

func BrowserAwareDownload(r *ghttp.Request) {
    filename := r.Get("file").String()
    userAgent := r.Header.Get("User-Agent")

    // Handle filename encoding for different browsers
    if strings.Contains(userAgent, "MSIE") || 
       strings.Contains(userAgent, "Edge") {
        // URL encode for IE/Edge
        filename = url.QueryEscape(filename)
    } else {
        // RFC 5987 encoding for others
        filename = fmt.Sprintf("UTF-8''%s", 
            url.QueryEscape(filename))
    }

    disposition := fmt.Sprintf("attachment; filename*=%s", filename)
    r.Response.Header().Set("Content-Disposition", disposition)
}
Enter fullscreen mode Exit fullscreen mode

3. Download Progress Monitoring ๐Ÿ“Š

Here's how to implement progress monitoring with WebSocket updates:

type Progress struct {
    Total     int64   `json:"total"`
    Current   int64   `json:"current"`
    Speed     float64 `json:"speed"`
    Remaining int     `json:"remaining"`
}

func ProgressDownload(r *ghttp.Request) {
    ws, err := r.WebSocket()
    if err != nil {
        return
    }

    file := r.Get("file").String()
    info, _ := os.Stat(file)
    total := info.Size()

    // Create a custom reader that reports progress
    reader := &ProgressReader{
        Reader: bufio.NewReader(file),
        Total:  total,
        OnProgress: func(current int64) {
            progress := Progress{
                Total:     total,
                Current:   current,
                Speed:    calculateSpeed(current),
                Remaining: calculateRemaining(current, total),
            }
            ws.WriteJSON(progress)
        },
    }

    io.Copy(r.Response.Writer, reader)
}
Enter fullscreen mode Exit fullscreen mode

Production Tips and Best Practices ๐Ÿ’ก

After implementing file downloads in numerous production environments, here are some battle-tested tips:

Always Rate Limit ๐Ÿšฆ

var downloadLimiter = rate.NewLimiter(rate.Limit(100), 200)
Enter fullscreen mode Exit fullscreen mode

Implement Caching ๐Ÿ—„๏ธ

func CachedDownload(r *ghttp.Request) {
    if data := memCache.Get(file); data != nil {
        return serveContent(r, data)
    }
    // ... fallback to disk
}
Enter fullscreen mode Exit fullscreen mode

Monitor Everything ๐Ÿ“Š

Here's a practical monitoring implementation:

type DownloadMetrics struct {
    ActiveDownloads    *atomic.Int64
    TotalBytes        *atomic.Int64
    ErrorCount        *atomic.Int64
    DownloadDurations *metrics.Histogram
}

func NewDownloadMetrics() *DownloadMetrics {
    return &DownloadMetrics{
        ActiveDownloads:    atomic.NewInt64(0),
        TotalBytes:        atomic.NewInt64(0),
        ErrorCount:        atomic.NewInt64(0),
        DownloadDurations: metrics.NewHistogram(metrics.HistogramOpts{
            Buckets: []float64{.1, .5, 1, 2.5, 5, 10, 30},
        }),
    }
}

func (m *DownloadMetrics) Track(r *ghttp.Request) func() {
    start := time.Now()
    m.ActiveDownloads.Add(1)

    return func() {
        m.ActiveDownloads.Add(-1)
        duration := time.Since(start).Seconds()
        m.DownloadDurations.Observe(duration)
    }
}
Enter fullscreen mode Exit fullscreen mode

Implement Circuit Breakers ๐Ÿ”Œ

Protect your system with circuit breakers:

type DownloadBreaker struct {
    breaker *gobreaker.CircuitBreaker
}

func NewDownloadBreaker() *DownloadBreaker {
    return &DownloadBreaker{
        breaker: gobreaker.NewCircuitBreaker(gobreaker.Settings{
            Name:        "download-breaker",
            MaxRequests: 100,
            Interval:    10 * time.Second,
            Timeout:     30 * time.Second,
            ReadyToTrip: func(counts gobreaker.Counts) bool {
                failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
                return counts.Requests >= 10 && failureRatio >= 0.6
            },
        }),
    }
}
Enter fullscreen mode Exit fullscreen mode

Implement Graceful Shutdown ๐Ÿ›‘

Handle shutdowns properly:

type DownloadManager struct {
    activeDownloads sync.WaitGroup
    shutdownCh     chan struct{}
}

func (dm *DownloadManager) HandleDownload(r *ghttp.Request) {
    dm.activeDownloads.Add(1)
    defer dm.activeDownloads.Done()

    // Create download context with shutdown signal
    ctx, cancel := context.WithCancel(r.Context())
    defer cancel()

    go func() {
        select {
        case <-dm.shutdownCh:
            // Save progress and cleanup
            cancel()
        case <-ctx.Done():
            return
        }
    }()

    // Proceed with download...
}

func (dm *DownloadManager) Shutdown(timeout time.Duration) error {
    close(dm.shutdownCh)

    // Wait for active downloads with timeout
    c := make(chan struct{})
    go func() {
        dm.activeDownloads.Wait()
        close(c)
    }()

    select {
    case <-c:
        return nil
    case <-time.After(timeout):
        return errors.New("shutdown timeout")
    }
}
Enter fullscreen mode Exit fullscreen mode

Advanced Features and Examples ๐Ÿš€

1. Zip Download on the Fly ๐Ÿ“ฆ

Need to create ZIP archives dynamically? Here's how:

func ZipDownload(r *ghttp.Request) {
    files := r.GetArray("files")

    r.Response.Header().Set("Content-Type", "application/zip")
    r.Response.Header().Set("Content-Disposition", 
        "attachment; filename=archive.zip")

    zw := zip.NewWriter(r.Response.Writer)
    defer zw.Close()

    for _, file := range files {
        // Add file to zip
        f, err := os.Open(file)
        if err != nil {
            continue
        }
        defer f.Close()

        // Create zip entry
        header := &zip.FileHeader{
            Name:   filepath.Base(file),
            Method: zip.Deflate,
        }

        writer, err := zw.CreateHeader(header)
        if err != nil {
            continue
        }

        io.Copy(writer, f)
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Cloud Storage Integration ๐ŸŒฅ๏ธ

Here's an example integrating with S3-compatible storage:

func CloudDownload(r *ghttp.Request) {
    bucket := "my-bucket"
    key := r.Get("key").String()

    // Get object from S3
    input := &s3.GetObjectInput{
        Bucket: aws.String(bucket),
        Key:    aws.String(key),
    }

    result, err := s3Client.GetObject(input)
    if err != nil {
        r.Response.WriteStatus(500)
        return
    }
    defer result.Body.Close()

    // Set headers
    r.Response.Header().Set("Content-Type", *result.ContentType)
    r.Response.Header().Set("Content-Length", 
        fmt.Sprintf("%d", *result.ContentLength))

    // Stream the response
    io.Copy(r.Response.Writer, result.Body)
}
Enter fullscreen mode Exit fullscreen mode

3. Download Queue Implementation ๐Ÿ“‹

For managing large numbers of downloads:

type DownloadQueue struct {
    queue    chan DownloadJob
    workers  int
    metrics  *DownloadMetrics
}

type DownloadJob struct {
    ID       string
    FilePath string
    Priority int
    Callback func(error)
}

func NewDownloadQueue(workers int) *DownloadQueue {
    dq := &DownloadQueue{
        queue:   make(chan DownloadJob, 1000),
        workers: workers,
        metrics: NewDownloadMetrics(),
    }

    for i := 0; i < workers; i++ {
        go dq.worker()
    }

    return dq
}

func (dq *DownloadQueue) worker() {
    for job := range dq.queue {
        // Process download job
        err := processDownload(job)
        if job.Callback != nil {
            job.Callback(err)
        }
    }
}

func (dq *DownloadQueue) Enqueue(job DownloadJob) error {
    select {
    case dq.queue <- job:
        return nil
    default:
        return errors.New("queue full")
    }
}
Enter fullscreen mode Exit fullscreen mode

Comprehensive Error Handling Patterns ๐ŸŽฏ

Let's look at how to handle various error scenarios robustly:

// Custom error types for better error handling
type DownloadError struct {
    Code    int
    Message string
    Err     error
}

func (e *DownloadError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("%s: %v", e.Message, e.Err)
    }
    return e.Message
}

// Error codes
const (
    ErrFileNotFound = iota + 1000
    ErrPermissionDenied
    ErrQuotaExceeded
    ErrInvalidFileType
    ErrFileTooLarge
)

// Robust download handler with error handling
func RobustDownload(r *ghttp.Request) {
    defer func() {
        if err := recover(); err != nil {
            // Log the stack trace
            debug.PrintStack()
            // Return 500 error to client
            r.Response.WriteStatus(500)
        }
    }()

    file := r.Get("file").String()

    // Validate request
    if err := validateDownloadRequest(r); err != nil {
        handleDownloadError(r, err)
        return
    }

    // Check user quota
    if err := checkUserQuota(r); err != nil {
        handleDownloadError(r, &DownloadError{
            Code:    ErrQuotaExceeded,
            Message: "Download quota exceeded",
            Err:     err,
        })
        return
    }

    // Attempt file download
    if err := streamFileWithRetry(r, file); err != nil {
        handleDownloadError(r, err)
        return
    }
}

// Error handler for different scenarios
func handleDownloadError(r *ghttp.Request, err error) {
    var downloadErr *DownloadError
    if errors.As(err, &downloadErr) {
        switch downloadErr.Code {
        case ErrFileNotFound:
            r.Response.WriteStatus(404)
            r.Response.WriteJson(g.Map{
                "error": "File not found",
                "details": downloadErr.Message,
            })
        case ErrPermissionDenied:
            r.Response.WriteStatus(403)
            r.Response.WriteJson(g.Map{
                "error": "Access denied",
                "details": downloadErr.Message,
            })
        case ErrQuotaExceeded:
            r.Response.WriteStatus(429)
            r.Response.WriteJson(g.Map{
                "error": "Quota exceeded",
                "details": downloadErr.Message,
            })
        default:
            r.Response.WriteStatus(500)
            r.Response.WriteJson(g.Map{
                "error": "Internal server error",
                "reference": uuid.New().String(),
            })
        }
        return
    }

    // Handle generic errors
    r.Response.WriteStatus(500)
}

// Retry mechanism for transient failures
func streamFileWithRetry(r *ghttp.Request, file string) error {
    const maxRetries = 3
    const baseDelay = 100 * time.Millisecond

    var lastErr error
    for attempt := 0; attempt < maxRetries; attempt++ {
        err := streamFile(r, file)
        if err == nil {
            return nil
        }

        lastErr = err

        // Don't retry on certain errors
        if errors.Is(err, os.ErrNotExist) || 
           errors.Is(err, os.ErrPermission) {
            return err
        }

        // Exponential backoff
        delay := baseDelay * time.Duration(math.Pow(2, float64(attempt)))
        time.Sleep(delay)
    }

    return fmt.Errorf("failed after %d attempts: %w", maxRetries, lastErr)
}
Enter fullscreen mode Exit fullscreen mode

Specific Use Case Examples ๐Ÿ”

1. Video Streaming with HLS Support ๐ŸŽฅ

func VideoStreamHandler(r *ghttp.Request) {
    videoPath := r.Get("video").String()

    // Check if requesting manifest
    if strings.HasSuffix(videoPath, ".m3u8") {
        serveHLSManifest(r, videoPath)
        return
    }

    // Check if requesting segment
    if strings.HasSuffix(videoPath, ".ts") {
        serveHLSSegment(r, videoPath)
        return
    }

    // Serve video file directly with range support
    serveVideoWithRange(r, videoPath)
}

func serveHLSManifest(r *ghttp.Request, path string) {
    r.Response.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
    r.Response.Header().Set("Cache-Control", "max-age=5")

    // Read and serve manifest
    content, err := ioutil.ReadFile(path)
    if err != nil {
        r.Response.WriteStatus(404)
        return
    }

    r.Response.Write(content)
}

func serveVideoWithRange(r *ghttp.Request, path string) {
    info, err := os.Stat(path)
    if err != nil {
        r.Response.WriteStatus(404)
        return
    }

    file, err := os.Open(path)
    if err != nil {
        r.Response.WriteStatus(500)
        return
    }
    defer file.Close()

    rangeHeader := r.Header.Get("Range")
    if rangeHeader != "" {
        ranges, err := parseRange(rangeHeader, info.Size())
        if err != nil {
            r.Response.WriteStatus(416)
            return
        }

        if len(ranges) > 0 {
            start, end := ranges[0][0], ranges[0][1]
            r.Response.Header().Set("Content-Range",
                fmt.Sprintf("bytes %d-%d/%d", start, end, info.Size()))
            r.Response.Header().Set("Content-Length",
                fmt.Sprintf("%d", end-start+1))
            r.Response.WriteStatus(206)

            file.Seek(start, 0)
            io.CopyN(r.Response.Writer, file, end-start+1)
            return
        }
    }

    r.Response.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
    r.Response.Header().Set("Content-Type", "video/mp4")
    io.Copy(r.Response.Writer, file)
}
Enter fullscreen mode Exit fullscreen mode

2. Large Excel File Generation ๐Ÿ“Š

func ExcelDownloadHandler(r *ghttp.Request) {
    // Create a new file
    f := excelize.NewFile()
    defer f.Close()

    // Create buffered writer
    buf := new(bytes.Buffer)
    writer := bufio.NewWriter(buf)

    // Start progress tracking
    progress := 0
    total := 1000000 // Example: 1 million rows

    // Stream data writing
    for i := 0; i < total; i++ {
        // Write row data
        row := []interface{}{
            fmt.Sprintf("Data %d", i),
            time.Now(),
            rand.Float64(),
        }
        cell, _ := excelize.CoordinatesToCellName(1, i+1)
        f.SetSheetRow("Sheet1", cell, &row)

        // Update progress every 1%
        currentProgress := (i * 100) / total
        if currentProgress > progress {
            progress = currentProgress
            // Send progress through WebSocket if needed
            sendProgress(r, progress)
        }
    }

    // Set headers for Excel file
    r.Response.Header().Set("Content-Type", 
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
    r.Response.Header().Set("Content-Disposition", 
        "attachment; filename=large-report.xlsx")

    // Save to response writer
    if err := f.Write(r.Response.Writer); err != nil {
        r.Response.WriteStatus(500)
        return
    }
}

func sendProgress(r *ghttp.Request, progress int) {
    // Implementation depends on your WebSocket setup
    // Example using gorilla/websocket
    if ws, ok := r.GetCtxVar("ws").(*websocket.Conn); ok {
        ws.WriteJSON(map[string]interface{}{
            "progress": progress,
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

3. PDF Generation and Download ๐Ÿ“„

func PDFDownloadHandler(r *ghttp.Request) {
    // Create PDF document
    pdf := gofpdf.New("P", "mm", "A4", "")
    pdf.AddPage()

    // Add content
    pdf.SetFont("Arial", "B", 16)
    pdf.Cell(40, 10, "Generated Report")

    // Add table
    pdf.SetFont("Arial", "", 12)
    data := [][]string{
        {"Column 1", "Column 2", "Column 3"},
        {"Data 1", "Data 2", "Data 3"},
        // ... more rows
    }

    for i, row := range data {
        for j, col := range row {
            pdf.Cell(40, 10, col)
            if j == len(row)-1 {
                pdf.Ln(-1)
            }
        }
        if i == 0 {
            pdf.Ln(-1)
        }
    }

    // Set headers
    r.Response.Header().Set("Content-Type", "application/pdf")
    r.Response.Header().Set("Content-Disposition", 
        "attachment; filename=report.pdf")

    // Write to response
    if err := pdf.Output(r.Response.Writer); err != nil {
        r.Response.WriteStatus(500)
        return
    }
}
Enter fullscreen mode Exit fullscreen mode

Common Gotchas to Avoid ๐Ÿšจ

  1. Memory Leaks

    • Always use defer for cleanup
    • Don't read entire files into memory
  2. Security Issues

    • Validate file paths
    • Check file permissions
    • Limit file types
  3. Performance Problems

    • Use buffered reading
    • Implement proper caching
    • Don't forget to close files

Real-World Example: A Complete Download Service ๐ŸŒŸ

Here's a production-ready download service combining all the concepts we've discussed:

type DownloadService struct {
    limiter  *rate.Limiter
    cache    *Cache
    metrics  *Metrics
}

func (s *DownloadService) ServeDownload(r *ghttp.Request) {
    // 1. Rate limiting
    if err := s.limiter.Wait(r.Context()); err != nil {
        r.Response.WriteStatus(429)
        return
    }

    // 2. Try cache
    if data := s.cache.Get(r.Get("file").String()); data != nil {
        s.metrics.RecordHit()
        return s.serveContent(r, data)
    }

    // 3. Stream file with resume support
    s.streamWithResume(r)
}
Enter fullscreen mode Exit fullscreen mode

Testing Your Implementation ๐Ÿงช

Don't forget to test! Here's a quick test case to get you started:

func TestDownload(t *testing.T) {
    s := g.Server()
    s.BindHandler("/download", DownloadHandler)

    client := g.Client()
    r, err := client.Get("/download?file=test.txt")

    assert.Nil(t, err)
    assert.Equal(t, 200, r.StatusCode)
}
Enter fullscreen mode Exit fullscreen mode

Wrapping Up ๐ŸŽ

Building production-ready file downloads with GoFrame isn't just about calling ServeFileDownload. It's about:

  • Handling security properly
  • Managing resources efficiently
  • Supporting resume capabilities
  • Monitoring and maintaining performance

What's Next?

  • Add progress tracking for downloads
  • Implement cloud storage integration
  • Add support for file preprocessing
  • Explore streaming compression

Resources ๐Ÿ“š

Let me know in the comments if you have any questions or want to share your own experiences with file downloads in Go! ๐Ÿ’ฌ


Like this article? Follow me for more Go development tips and tutorials! ๐Ÿš€

Top comments (0)