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>