记录文件已经传输的位置,下次传输时从该位置继续,而不是从头开始。

要实现这个功能,客户端和服务器端需要协同工作,主要涉及以下几个关键点:
- 请求范围:客户端需要告诉服务器它需要文件的哪一部分,这通过 HTTP 协议的
Range请求头实现,Range: bytes=1024-表示从第 1024 个字节开始请求直到文件末尾。 - 状态响应:服务器需要能够理解
Range请求,并根据请求返回相应的数据块,它需要返回一个206 Partial Content状态码,表示这是部分内容响应。 - 本地记录:客户端需要维护一个本地文件(例如一个记录文件
.record),用于存储当前已下载的文件大小,每次程序启动时,先读取这个记录文件,获取已下载的字节数,作为新的Range请求的起始点。 - 文件合并:当所有数据块下载完成后,客户端需要将下载的临时文件与本地已存在的部分文件合并,形成一个完整的文件,在 Java NIO 的帮助下,这个过程可以非常高效。
下面,我们将通过一个完整的例子来展示客户端和服务器的实现。
项目结构
BreakpointResumeDemo/
├── src/
│ ├── server/
│ │ └── FileServer.java
│ └── client/
│ ├── FileClient.java
│ └── DownloadRecord.java
└── testfile.txt (一个用于测试的任意文件)
服务器端实现 (FileServer.java)
服务器需要处理两种请求:
- 完整文件请求:当客户端第一次请求文件或没有
Range头时,返回整个文件(200 OK)。 - 请求:当客户端带有
Range头时,返回请求的数据块(206 Partial Content)。
// src/server/FileServer.java
package server;
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class FileServer {
private static final int PORT = 12345;
private static final String FILE_TO_SEND = "testfile.txt"; // 服务器上要发送的文件
public static void main(String[] args) {
try (ServerSocket serverSocket = new ServerSocket(PORT)) {
System.out.println("服务器已启动,监听端口 " + PORT);
while (true) {
try (Socket clientSocket = serverSocket.accept();
DataInputStream in = new DataInputStream(clientSocket.getInputStream());
DataOutputStream out = new DataOutputStream(clientSocket.getOutputStream())) {
System.out.println("客户端已连接: " + clientSocket.getInetAddress());
// 1. 读取客户端请求
String request = in.readUTF();
System.out.println("收到请求: " + request);
// 2. 检查是否是Range请求
String rangeHeader = parseRangeHeader(request);
Path filePath = Paths.get(FILE_TO_SEND);
long fileSize = Files.size(filePath);
if (rangeHeader != null && !rangeHeader.isEmpty()) {
// 处理断点续传请求
handlePartialRequest(out, filePath, fileSize, rangeHeader);
} else {
// 处理完整文件请求
handleFullRequest(out, filePath, fileSize);
}
} catch (IOException e) {
System.err.println("处理客户端请求时出错: " + e.getMessage());
}
}
} catch (IOException e) {
System.err.println("无法启动服务器: " + e.getMessage());
}
}
private static String parseRangeHeader(String request) {
// 简单的解析,实际HTTP协议更复杂
// 格式如: "GET /testfile.txt HTTP/1.1\r\nRange: bytes=1024-"
String[] lines = request.split("\r\n");
for (String line : lines) {
if (line.startsWith("Range: bytes=")) {
return line.substring("Range: bytes=".length());
}
}
return null;
}
private static void handleFullRequest(DataOutputStream out, Path filePath, long fileSize) throws IOException {
System.out.println("处理完整文件请求...");
out.writeUTF("HTTP/1.1 200 OK\r\n");
out.writeUTF("Content-Type: application/octet-stream\r\n");
out.writeUTF("Content-Length: " + fileSize + "\r\n");
out.writeUTF("\r\n"); // 空行,分隔头部和内容
Files.copy(filePath, out);
System.out.println("完整文件发送完毕。");
}
private static void handlePartialRequest(DataOutputStream out, Path filePath, long fileSize, String range) throws IOException {
System.out.println("处理部分内容请求,Range: " + range);
// 解析Range,"1024-" 或 "1024-2047"
String[] rangeParts = range.split("-");
long startByte = Long.parseLong(rangeParts[0]);
long endByte = (rangeParts.length > 1 && !rangeParts[1].isEmpty()) ?
Long.parseLong(rangeParts[1]) : fileSize - 1;
// 验证Range有效性
if (startByte >= fileSize || endByte >= fileSize || startByte > endByte) {
out.writeUTF("HTTP/1.1 416 Range Not Satisfiable\r\n");
out.writeUTF("\r\n");
System.err.println("无效的Range请求: " + range);
return;
}
long contentLength = endByte - startByte + 1;
out.writeUTF("HTTP/1.1 206 Partial Content\r\n");
out.writeUTF("Content-Type: application/octet-stream\r\n");
out.writeUTF("Content-Length: " + contentLength + "\r\n");
out.writeUTF("Content-Range: bytes " + startByte + "-" + endByte + "/" + fileSize + "\r\n");
out.writeUTF("\r\n"); // 空行
try (InputStream in = Files.newInputStream(filePath)) {
// 跳过已发送的字节
in.skip(startByte);
// 发送请求的数据块
byte[] buffer = new byte[4096];
long bytesRemaining = contentLength;
while (bytesRemaining > 0) {
int read = in.read(buffer, 0, (int) Math.min(buffer.length, bytesRemaining));
if (read == -1) break;
out.write(buffer, 0, read);
bytesRemaining -= read;
}
}
System.out.println("部分内容发送完毕。");
}
}
客户端实现
客户端分为两个主要类:DownloadRecord 用于管理下载记录,FileClient 是主逻辑。

1 下载记录管理 (DownloadRecord.java)
这个类负责读取和保存已下载的文件大小。
// src/client/DownloadRecord.java
package client;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class DownloadRecord {
private static final String RECORD_FILE_SUFFIX = ".record";
public static long getDownloadedSize(String fileName) {
Path recordPath = Paths.get(fileName + RECORD_FILE_SUFFIX);
if (Files.exists(recordPath)) {
try {
String content = new String(Files.readAllBytes(recordPath));
return Long.parseLong(content.trim());
} catch (IOException | NumberFormatException e) {
System.err.println("读取下载记录文件失败,将从头开始下载: " + e.getMessage());
}
}
return 0; // 文件或记录不存在,从头开始
}
public static void saveDownloadedSize(String fileName, long downloadedSize) {
Path recordPath = Paths.get(fileName + RECORD_FILE_SUFFIX);
try {
Files.write(recordPath, String.valueOf(downloadedSize).getBytes());
} catch (IOException e) {
System.err.println("保存下载记录失败: " + e.getMessage());
}
}
public static void deleteRecordFile(String fileName) {
try {
Files.deleteIfExists(Paths.get(fileName + RECORD_FILE_SUFFIX));
} catch (IOException e) {
System.err.println("删除下载记录文件失败: " + e.getMessage());
}
}
}
2 文件客户端 (FileClient.java)
客户端主逻辑,负责发送请求、接收数据、合并文件。
// src/client/FileClient.java
package client;
import java.io.*;
import java.net.Socket;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
public class FileClient {
private static final String SERVER_HOST = "localhost";
private static final int SERVER_PORT = 12345;
private static final String SERVER_FILE = "testfile.txt";
private static final String LOCAL_FILE = "downloaded_" + SERVER_FILE;
private static final int BUFFER_SIZE = 4096;
public static void main(String[] args) {
long downloadedSize = DownloadRecord.getDownloadedSize(LOCAL_FILE);
try (Socket socket = new Socket(SERVER_HOST, SERVER_PORT);
DataInputStream in = new DataInputStream(socket.getInputStream());
DataOutputStream out = new DataOutputStream(socket.getOutputStream())) {
// 1. 发送带有Range头的请求
String request;
if (downloadedSize > 0) {
request = "GET " + SERVER_FILE + " HTTP/1.1\r\nRange: bytes=" + downloadedSize + "-\r\n\r\n";
System.out.println("发送断点续传请求,已下载: " + downloadedSize + " bytes");
} else {
request = "GET " + SERVER_FILE + " HTTP/1.1\r\n\r\n";
System.out.println("发送完整文件请求");
}
out.writeUTF(request);
out.flush();
// 2. 读取服务器响应头
String responseHeader = in.readUTF();
System.out.println("收到响应头:\n" + responseHeader);
// 3. 解析响应头
String[] headerLines = responseHeader.split("\r\n");
String statusLine = headerLines[0];
long contentLength = 0;
String contentRange = null;
boolean isPartialContent = statusLine.startsWith("HTTP/1.1 206");
for (int i = 1; i < headerLines.length; i++) {
String line = headerLines[i];
if (line.startsWith("Content-Length:")) {
contentLength = Long.parseLong(line.substring("Content-Length:".length()).trim());
}
if (line.startsWith("Content-Range:")) {
contentRange = line.substring("Content-Range:".length()).trim();
}
}
System.out.println("状态码: " + (isPartialContent ? "206 (Partial Content)" : "200 (OK)"));
System.out.println("内容长度: " + contentLength);
// 4. 接收文件内容
Path localFilePath = Paths.get(LOCAL_FILE);
long totalFileSize = downloadedSize; // 初始值为已下载大小
// 如果是第一次下载,创建新文件;否则,以追加模式打开
StandardOpenOption openOption = downloadedSize > 0 ? StandardOpenOption.APPEND : StandardOpenOption.CREATE;
try (RandomAccessFile fileOutputStream = new RandomAccessFile(localFilePath.toFile(), "rw")) {
// 如果是断点续传,定位到文件末尾
if (downloadedSize > 0) {
fileOutputStream.seek(downloadedSize);
}
byte[] buffer = new byte[BUFFER_SIZE];
int bytesRead;
long totalBytesRead = 0;
while ((bytesRead = in.read(buffer)) != -1) {
fileOutputStream.write(buffer, 0, bytesRead);
totalBytesRead += bytesRead;
DownloadRecord.saveDownloadedSize(LOCAL_FILE, downloadedSize + totalBytesRead);
System.out.printf("下载进度: %.2f MB / %.2f MB%n",
(downloadedSize + totalBytesRead) / (1024.0 * 1024.0),
(totalFileSize + contentLength) / (1024.0 * 1024.0));
}
}
System.out.println("\n文件下载完成!");
// 5. (可选) 合并逻辑
// 在这个简单的例子中,我们直接使用APPEND模式,所以不需要额外的合并步骤。
// 但如果下载的是临时块文件,则需要在这里进行合并。
// 下载完成后删除记录文件
DownloadRecord.deleteRecordFile(LOCAL_FILE);
} catch (IOException e) {
System.err.println("客户端错误: " + e.getMessage());
e.printStackTrace();
}
}
}
如何运行和测试
-
准备文件:在项目根目录下创建一个
testfile.txt文件,内容可以随意写一些,"Hello, this is a test file for breakpoint resume."。 -
编译代码:
(图片来源网络,侵删)javac -d . src/server/*.java src/client/*.java
-
启动服务器:
java server.FileServer
你会看到 "服务器已启动,监听端口 12345" 的提示。
-
第一次启动客户端: 在一个新的终端窗口中运行客户端:
java client.FileClient
- 客户端会发送一个完整文件请求。
- 服务器会返回
200 OK和整个文件。 - 客户端会下载文件并保存为
downloaded_testfile.txt,同时创建一个downloaded_testfile.txt.record文件,记录下载的总大小。 - 下载完成后,
.record文件会被删除。
-
模拟断点续传:
- 中断下载:在客户端下载过程中(比如看到进度条在动时),按
Ctrl+C强制终止程序。 - 检查记录:你会发现目录下多了
downloaded_testfile.txt(部分文件)和downloaded_testfile.txt.record(记录文件),打开.record文件,里面是一个数字,表示已下载的字节数。 - 重新启动客户端:
java client.FileClient
- 观察结果:
- 客户端会读取
.record文件,发现之前已经下载了一部分。 - 它会发送一个带有
Range头的请求(Range: bytes=1024-)。 - 服务器会返回
206 Partial Content和剩余的文件内容。 - 客户端会以追加模式将接收到的内容写入到
downloaded_testfile.txt文件的末尾。 - 下载完成后,
.record文件再次被删除。
- 客户端会读取
- 中断下载:在客户端下载过程中(比如看到进度条在动时),按
总结与扩展
这个例子展示了 Java Socket 实现断点续传的核心原理。
关键点回顾:
- 协商机制:通过自定义的类 HTTP 协议(
Range请求头和206响应码)来协商传输范围。 - 状态持久化:客户端使用一个简单的记录文件来维护下载状态。
- 流式处理:使用
InputStream和OutputStream进行高效的流式数据传输,避免一次性加载大文件到内存。 - 文件追加:利用
RandomAccessFile的seek()方法和APPEND模式,实现数据的无缝续写。
可以扩展的方向:
- 更健壮的协议:使用真正的 HTTP 协议(如
HttpURLConnection或第三方库如 Apache HttpClient),它们内置了对Range请求的支持,更标准、更可靠。 - 多线程/多线程下载:将大文件分成多个块,用多个线程同时下载不同的块,可以极大地提高下载速度,这需要服务器支持多范围请求(
Range: bytes=0-1023, 2048-3071)。 - 图形用户界面:为客户端添加一个 GUI,显示下载进度条、速度、剩余时间等信息,提升用户体验。
- 哈希校验:下载完成后,计算本地文件和服务器文件的 MD5/SHA1 哈希值,以验证文件的完整性和一致性,防止数据损坏。
