Implementing Chunked File Downloads with Spring Boot and Vue.js

To support resumable and chunked downloads, the file server must handle HTTP Range requests. We configure Nginx as a reverse proxy in front of FastDFS, enabling the slice module for efficient caching of range requests.

Installing Nginx with Slice Module:
The slice module is required for splitting large files into smaller pieces during caching. Ensure Nginx is compiled with this module (version 1.9.8 or later).

# Download and extract Nginx
wget https://nginx.org/download/nginx-1.12.1.tar.gz
tar -zxvf nginx-1.12.1.tar.gz
cd nginx-1.12.1

# Configure with the slice module
./configure --with-http_slice_module
make && make install

Nginx Configuration:
Moddify nginx.conf to enable slicing and range request handling for the file storage location.

http {
    gzip on;
    proxy_cache_path /tmp/nginx/cache levels=1:2 keys_zone=cache_zone:100m;

    server {
        # ... other configurations

        location /group1/M00 {
            alias /home/fdfs_storage/data;
            
            # Enable slicing (1MB slices)
            slice 1m;
            
            # Cache configuration
            proxy_cache cache_zone;
            proxy_cache_key $uri$is_args$args$slice_range;
            proxy_cache_valid 200 206 1h;
            
            # Pass Range header to backend/proxy
            proxy_set_header Range $slice_range;
        }
    }
}

2. Spring Boot Proxy Service

Since the frontend might not have direct access to the Nginx file server (due to network segmentation or CORS policies), a Spring Boot proxy service is implemented. This service forwards client requests to the Nginx server while preserving necessary headers like Range.

import org.springframework.http.*;
import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import javax.servlet.http.*;
import java.io.InputStream;
import java.net.URI;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;

@RestController
public class FileProxyController {

    private final String FILE_SERVER_URL = "http://192.168.41.171";

    @RequestMapping(value = "/proxy", method = {RequestMethod.HEAD, RequestMethod.GET})
    public void proxyDownload(HttpServletRequest request, HttpServletResponse response) {
        try {
            // Construct target URL
            String query = request.getQueryString();
            String path = request.getRequestURI();
            String targetUrl = FILE_SERVER_URL + path.replace("/proxy", "");
            
            if (query != null && !query.isEmpty()) {
                targetUrl += "?" + query;
            }
            
            // Create the remote request
            URI targetUri = new URI(targetUrl);
            HttpMethod method = HttpMethod.resolve(request.getMethod());
            if (method == null) return;

            // Use a simple client request to forward the request
            ResponseEntity<byte[]> remoteResponse = new RestTemplate().exchange(
                targetUri, 
                method, 
                null, 
                byte[].class
            );

            // Copy status code
            response.setStatus(remoteResponse.getStatusCodeValue());

            // Copy headers (especially Content-Length, Content-Range, Accept-Ranges)
            HttpHeaders remoteHeaders = remoteResponse.getHeaders();
            remoteHeaders.forEach((key, valueList) -> {
                for (String value : valueList) {
                    response.addHeader(key, value);
                }
            });

            // Handle Range header forwarding if present in the original request
            Enumeration<String> headerNames = request.getHeaderNames();
            while (headerNames.hasMoreElements()) {
                String headerName = headerNames.nextElement();
                if ("range".equalsIgnoreCase(headerName)) {
                    response.setHeader("Range", request.getHeader(headerName));
                }
            }

            // Write the response body
            if (remoteResponse.getBody() != null) {
                StreamUtils.copy(remoteResponse.getBody(), response.getOutputStream());
            }

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

3. Frontend Implementation with Vue.js

The frontend logic involves fetching the file size using a HEAD request, calculating chunk ranges, downloading chunks sequentially, and merging them into a single file. We also implement a progress bar to visualize the downlaod status.

API Definitions:

// api.js
import request from '@/utils/request';

const API_URL = '/api/proxy';

// Fetch file metadata (size)
export function fetchFileMetadata(params) {
  return request({
    url: API_URL,
    method: 'HEAD',
    params
  });
}

// Download a specific byte range
export function downloadChunk(rangeHeader) {
  return request({
    url: API_URL,
    method: 'GET',
    responseType: 'arraybuffer',
    headers: {
      'Range': `bytes=${rangeHeader}`
    }
  });
}

Download Logic:

export default {
  data() {
    return {
      isDownloading: false,
      progressPercent: 0
    };
  },
  methods: {
    async initiateChunkedDownload(fileId) {
      const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB chunks
      this.isDownloading = true;
      this.progressPercent = 0;

      try {
        // 1. Get file size
        const metadata = await fetchFileMetadata({ id: fileId });
        const fileSize = parseInt(metadata.headers['content-length'], 10);

        if (!fileSize || fileSize === 0) throw new Error("Unable to determine file size.");

        const chunks = [];
        let downloadedBytes = 0;
        let startByte = 0;
        let partIndex = 0;
        const totalParts = Math.ceil(fileSize / CHUNK_SIZE);

        // 2. Download loop
        while (startByte < fileSize) {
          const endByte = Math.min(startByte + CHUNK_SIZE - 1, fileSize - 1);
          const range = `${startByte}-${endByte}`;

          // Request the chunk
          const response = await downloadChunk(range);
          const chunkBlob = new Blob([response.data], { type: 'application/octet-stream' });
          chunks.push(chunkBlob);

          // Update progress
          partIndex++;
          downloadedBytes += (endByte - startByte + 1);
          this.progressPercent = Math.floor((partIndex / totalParts) * 100);

          startByte = endByte + 1;
        }

        // 3. Merge and download
        const finalBlob = new Blob(chunks, { type: 'application/zip' });
        const downloadUrl = window.URL.createObjectURL(finalBlob);
        const link = document.createElement('a');
        link.href = downloadUrl;
        link.download = 'archive.zip';
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
        window.URL.revokeObjectURL(downloadUrl);

        this.progressPercent = 100;
        this.$message.success("Download complete!");

      } catch (err) {
        console.error("Download error:", err);
        this.$message.error("Download failed: " + err.message);
      } finally {
        this.isDownloading = false;
      }
    }
  }
};

4. Progress Bar Component

A modal component provides visual feedback during the download process.

<!-- DownloadModal.vue -->
<template>
  <div v-if="visible" class="modal-overlay">
    <div class="modal-container">
      <div class="modal-header">
        <h3>Downloading File</h3>
      </div>
      <div class="modal-body">
        <progress :value="progress" max="100" class="progress-bar"></progress>
        <p class="progress-text">{{ progress }}% Completed</p>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'DownloadModal',
  props: {
    visible: Boolean,
    progress: Number
  }
};
</script>

<style scoped>
.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1000;
}

.modal-container {
  background: white;
  padding: 20px;
  border-radius: 8px;
  width: 400px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}

.progress-bar {
  width: 100%;
  height: 20px;
}

.progress-text {
  text-align: center;
  margin-top: 10px;
}
</style>

Usage in Parent Component:

<template>
  <div>
    <button @click="initiateChunkedDownload('12345')" :disabled="isDownloading">
      Download
    </button>
    <DownloadModal :visible="isDownloading" :progress="progressPercent" />
  </div>
</template>

<script>
import DownloadModal from './DownloadModal.vue';

export default {
  components: { DownloadModal },
  // ... data and methods defined previously
};
</script>

Tags: Spring Boot Vue.js nginx FastDFS File Download

Posted on Sun, 16 Aug 2026 16:51:18 +0000 by ahmadmunif