核心只有一點:**記錄已傳輸位置,下次從該位置繼續**。類比「遊戲存檔」:玩到第 4 關存檔,下次讀檔直接從第 4 關開始。
客戶端 服務端
| |
|--- GET /file.zip Range: bytes=0-999 --->| 首次請求前 1000 字節
|<-- 206 Partial Content, Content-Range -->|
| |
|--- GET /file.zip Range: bytes=1000- --->| 斷點續傳:從 1000 字節繼續
|<-- 206 Partial Content, Content-Range -->|
| |
關鍵 Header:
Range: bytes=start-end (end 可省略表示到結尾)206 Partial Content、Content-Range: bytes start-end/total、Accept-Ranges: bytes支持隨機讀寫,內部維護文件指針,關鍵方法:
seek(long pos):移動指針到指定位置getFilePointer():獲取當前指針位置read(byte[]) / write(byte[]):從指針位置讀寫"r" 只讀,"rw" 讀寫
public class ResumableDownloader {
public void download(String urlStr, File destFile) throws IOException {
long downloaded = 0;
if (destFile.exists()) {
downloaded = destFile.length(); // 已下載長度 = 斷點位置
}
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
// 關鍵:設置 Range 頭
if (downloaded > 0) {
conn.setRequestProperty("Range", "bytes=" + downloaded + "-");
}
conn.setRequestProperty("User-Agent", "Mozilla/5.0");
conn.connect();
int responseCode = conn.getResponseCode();
if (responseCode == 200) {
// 服務端不支持 Range,重新下載
downloaded = 0;
destFile.delete();
} else if (responseCode != 206) {
throw new IOException("Unexpected response: " + responseCode);
}
// 獲取總長度 (用於進度條)
String contentRange = conn.getHeaderField("Content-Range"); // bytes 1000-1999/2000
long total = Long.parseLong(contentRange.substring(contentRange.lastIndexOf('/') + 1));
// RandomAccessFile 定位寫入
try (RandomAccessFile raf = new RandomAccessFile(destFile, "rw");
InputStream in = conn.getInputStream()) {
raf.seek(downloaded); // 移動到斷點位置
byte[] buffer = new byte[8192];
int len;
long progress = downloaded;
while ((len = in.read(buffer)) != -1) {
raf.write(buffer, 0, len);
progress += len;
printProgress(progress, total);
}
}
System.out.println("\n下載完成: " + destFile.getAbsolutePath());
}
private void printProgress(long current, long total) {
int percent = (int) (current * 100.0 / total);
System.out.print("\r[" + "=".repeat(percent/2) + " ".repeat(50-percent/2) + "] " + percent + "%");
}
}
public void upload(File file, String uploadUrl) throws IOException {
long uploaded = queryUploadedFromServer(file); // 需服務端提供查詢接口
try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
raf.seek(uploaded); // 跳過已上傳部分
HttpURLConnection conn = (HttpURLConnection) new URL(uploadUrl).openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("Content-Range", "bytes " + uploaded + "-" + (file.length()-1) + "/" + file.length());
conn.setRequestProperty("Content-Type", "application/octet-stream");
try (OutputStream out = conn.getOutputStream()) {
byte[] buffer = new byte[8192];
int len;
while ((len = raf.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
// 檢查響應碼 200/201
}
}
@GetMapping("/download/{fileId}")
public ResponseEntity download(@PathVariable String fileId,
@RequestHeader(value = "Range", required = false) String rangeHeader) {
File file = fileService.getFile(fileId);
long fileLen = file.length();
long start = 0, end = fileLen - 1;
if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
String[] range = rangeHeader.substring(6).split("-");
start = Long.parseLong(range[0]);
if (range.length > 1 && !range[1].isEmpty()) end = Long.parseLong(range[1]);
}
long contentLength = end - start + 1;
InputStreamResource resource = new InputStreamResource(
new FileInputStream(file) {{ skip(start); }} // 跳過已發送部分
);
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
.header(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + fileLen)
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(contentLength))
.contentLength(contentLength)
.body(resource);
}
public void multiThreadDownload(String url, File dest, int threadCount) throws Exception {
long fileSize = getFileSize(url);
long blockSize = fileSize / threadCount;
// 預分配文件空間
try (RandomAccessFile raf = new RandomAccessFile(dest, "rw")) {
raf.setLength(fileSize);
}
ExecutorService pool = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
final int idx = i;
long start = idx * blockSize;
long end = (idx == threadCount - 1) ? fileSize - 1 : (start + blockSize - 1);
pool.submit(() -> {
try {
downloadRange(url, dest, start, end);
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
latch.countDown();
}
});
}
latch.await();
pool.shutdown();
System.out.println("多線程下載完成");
}
private void downloadRange(String urlStr, File dest, long start, long end) throws IOException {
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestProperty("Range", "bytes=" + start + "-" + end);
try (RandomAccessFile raf = new RandomAccessFile(dest, "rw");
InputStream in = conn.getInputStream()) {
raf.seek(start);
byte[] buf = new byte[8192];
int len;
while ((len = in.read(buf)) != -1) raf.write(buf, 0, len);
}
}
fileId -> downloadedBytes,防止進程重啟丟失.tmp 後綴,完成後原子重命名GET /upload/status?fileId=xxx 返回已接收字節數斷點續傳核心三部曲:客戶端記錄位置 → 請求帶 Range 頭 → RandomAccessFile seek 定位讀寫。HTTP 協議原生支持 (Range/206/Content-Range),Java RandomAccessFile 提供底層隨機訪問能力。掌握這套機制,即可實現從簡單下載工具到企業級大文件傳輸系統的完整功能。