客户端和服务器端需要记录文件已经传输了多少字节(即“断点”),下次传输时,从这个断点位置继续,而不是从头开始。
实现断点续传的关键在于 HTTP 协议中的 Range 请求头,我们将模拟这个机制,在自定义的 Socket 协议中实现类似的功能。
核心概念
-
客户端:
- 发送一个文件请求,并告知服务器自己已经拥有文件的哪些部分(通过
Range请求头)。 - 接收服务器返回的数据块,并将其写入文件的正确位置。
- 维护一个本地文件,记录已下载的部分。
- 发送一个文件请求,并告知服务器自己已经拥有文件的哪些部分(通过
-
服务器端:
- 接收客户端的请求,解析
Range请求头,了解客户端需要文件的哪个部分。 - 根据请求,读取文件的相应部分(而不是整个文件)。
- 将读取的数据块发送给客户端。
- 接收客户端的请求,解析
-
Range请求头:- 这是实现断点续传的灵魂,它的格式通常是
bytes=start-end。 Range: bytes=1024-2047表示请求从文件的第 1024 字节到第 2047 字节(共 1024 字节)的数据。- 如果客户端是第一次请求,没有
Range头,或者Range头表示bytes=0-,则表示请求整个文件。
- 这是实现断点续传的灵魂,它的格式通常是
-
状态码:
- 206 Partial Content: 服务器成功处理了部分 GET 请求,这是断点续传成功返回的标志性状态码。
- 200 OK: 表示返回的是完整文件,通常用于首次下载或服务器不支持断点续传。
实现步骤
我们将分步实现一个简单的文件服务器和客户端。
第 1 步:定义自定义协议
为了让 Socket 通信双方能“听懂”对方,我们需要定义一个简单的协议。
客户端请求格式 (字符串形式):
[文件名] [RANGE] [startByte] [endByte]
[文件名]: 要请求的文件名。[RANGE]: 字符串 "RANGE",表示这是一个断点续传请求。[startByte]: 开始字节的偏移量。[endByte]: 结束字节的偏移量,如果为 -1,表示请求到文件末尾。
示例请求:
- 首次下载整个文件:
myfile.txt 0 -1 - 续传,从 1024 字节开始:
myfile.txt RANGE 1024 -1 - 续传,请求 1024 到 2047 的块:
myfile.txt RANGE 1024 2047
服务器响应格式:
- 状态行:
HTTP/1.1 206 Partial Content或HTTP/1.1 200 OK - 头部:
Content-Range: bytes start-end/totalSize(对于 206 响应)Content-Length: actualDataSize(本次发送的数据块大小)
- 体: 文件的实际数据块。
第 2 步:服务器端实现
服务器需要监听端口,接收客户端请求,解析协议,并发送文件的部分内容。
FileServer.java
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 SERVER_PORT = 12345;
private static final String FILE_DIRECTORY = "server_files/"; // 服务器存放文件的目录
public static void main(String[] args) {
try (ServerSocket serverSocket = new ServerSocket(SERVER_PORT)) {
System.out.println("服务器启动,监听端口 " + SERVER_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);
String[] parts = request.split(" ");
String fileName = parts[0];
long startByte = Long.parseLong(parts[1]);
long endByte = Long.parseLong(parts[2]);
Path filePath = Paths.get(FILE_DIRECTORY, fileName);
if (!Files.exists(filePath)) {
System.out.println("文件不存在: " + fileName);
out.writeUTF("HTTP/1.1 404 Not Found");
continue;
}
long fileSize = Files.size(filePath);
// 2. 处理请求逻辑
if (endByte == -1 || endByte >= fileSize) {
endByte = fileSize - 1;
}
long contentLength = endByte - startByte + 1;
// 3. 发送响应头
StringBuilder responseHeader = new StringBuilder();
if (startByte > 0) {
responseHeader.append("HTTP/1.1 206 Partial Content\r\n");
responseHeader.append("Content-Range: bytes ").append(startByte).append("-").append(endByte).append("/").append(fileSize).append("\r\n");
} else {
responseHeader.append("HTTP/1.1 200 OK\r\n");
// 如果是首次下载,整个文件长度
contentLength = fileSize;
endByte = fileSize - 1;
}
responseHeader.append("Content-Length: ").append(contentLength).append("\r\n");
responseHeader.append("\r\n"); // 头部和 body 之间的空行
out.writeUTF(responseHeader.toString());
System.out.println("已发送响应头:\n" + responseHeader);
// 4. 发送文件数据块
try (RandomAccessFile file = new RandomAccessFile(filePath.toFile(), "r")) {
file.seek(startByte);
byte[] buffer = new byte[4096];
long bytesRemaining = contentLength;
int bytesRead;
while (bytesRemaining > 0 && (bytesRead = file.read(buffer, 0, (int) Math.min(buffer.length, bytesRemaining))) != -1) {
out.write(buffer, 0, bytesRead);
bytesRemaining -= bytesRead;
}
}
System.out.println("文件块发送完毕。");
} catch (IOException e) {
System.err.println("处理客户端请求时出错: " + e.getMessage());
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
第 3 步:客户端实现
客户端负责发起请求,接收数据,并将其写入文件的正确位置,为了演示续传,我们会在下载中途手动停止,然后重新启动客户端。
FileClient.java
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.StandardOpenOption;
public class FileClient {
private static final String SERVER_ADDRESS = "localhost";
private static final int SERVER_PORT = 12345;
private static final String SAVE_DIRECTORY = "client_files/"; // 客户端保存文件的目录
public static void main(String[] args) {
String fileName = "my_large_file.zip"; // 假设服务器上有这个大文件
Path savePath = Paths.get(SAVE_DIRECTORY, fileName);
try {
// 确保保存目录存在
Files.createDirectories(savePath.getParent());
// 检查本地文件是否存在,以确定断点
long downloadedBytes = 0;
if (Files.exists(savePath)) {
downloadedBytes = Files.size(savePath);
System.out.println("发现本地已下载部分文件,大小: " + downloadedBytes + " 字节,尝试断点续传...");
}
try (Socket socket = new Socket(SERVER_ADDRESS, SERVER_PORT);
DataInputStream in = new DataInputStream(socket.getInputStream());
DataOutputStream out = new DataOutputStream(socket.getOutputStream())) {
// 1. 发送请求
String request;
if (downloadedBytes > 0) {
request = fileName + " RANGE " + downloadedBytes + " -1";
} else {
request = fileName + " 0 -1";
}
out.writeUTF(request);
System.out.println("已发送请求: " + request);
// 2. 接收并解析响应头
String responseHeader = in.readUTF();
System.out.println("收到响应头:\n" + responseHeader);
// 简单解析响应头
if (responseHeader.startsWith("HTTP/1.1 200")) {
System.out.println("服务器返回完整文件,开始下载...");
// 对于200响应,我们不知道文件总大小,直接覆盖写入
try (FileOutputStream fos = new FileOutputStream(savePath.toFile(), false);
BufferedOutputStream bos = new BufferedOutputStream(fos)) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
bos.write(buffer, 0, bytesRead);
}
}
} else if (responseHeader.startsWith("HTTP/1.1 206")) {
System.out.println("服务器返回部分内容,开始续传...");
// 提取 Content-Range 和 Content-Length
String[] lines = responseHeader.split("\r\n");
long contentLength = 0;
for (String line : lines) {
if (line.startsWith("Content-Length:")) {
contentLength = Long.parseLong(line.split(":")[1].trim());
}
}
// 3. 写入文件数据块 (追加模式)
try (RandomAccessFile file = new RandomAccessFile(savePath.toFile(), "rw")) {
file.seek(downloadedBytes); // 移动到断点位置
byte[] buffer = new byte[4096];
long bytesRemaining = contentLength;
int bytesRead;
while (bytesRemaining > 0 && (bytesRead = in.read(buffer, 0, (int) Math.min(buffer.length, bytesRemaining))) != -1) {
file.write(buffer, 0, bytesRead);
bytesRemaining -= bytesRead;
System.out.printf("已下载: %d / %d 字节 (总计: %d)%n",
downloadedBytes + (contentLength - bytesRemaining),
contentLength,
downloadedBytes + contentLength);
}
}
} else {
System.err.println("下载失败,服务器响应: " + responseHeader);
return;
}
System.out.println("文件下载完成!");
} catch (IOException e) {
System.err.println("与服务器通信时出错: " + e.getMessage());
// 模拟网络中断,程序退出,文件不完整
System.err.println("模拟网络中断,文件下载被终止。");
System.exit(1); // 退出以测试续传
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
如何测试断点续传
-
准备工作:
- 创建一个
server_files目录,并在里面放一个较大的文件(my_large_file.zip)。 - 创建一个
client_files目录。 - 编译并运行
FileServer。 - 编译并运行
FileClient。
- 创建一个
-
首次下载:
- 客户端会请求整个文件。
- 服务器返回
200 OK。 - 客户端将完整文件写入
client_files/my_large_file.zip。 - 注意: 在
FileClient的main方法末尾,我们故意调用了System.exit(1)来模拟程序意外中断,在实际应用中,这可能是网络断开、程序崩溃等情况。
-
续传:
- 再次运行
FileClient。 - 客户端首先检查
client_files/my_large_file.zip是否存在。 - 它会发现文件存在,并获取其大小(10MB)。
- 客户端会发送一个
RANGE请求:my_large_file.zip RANGE 10485760 -1(假设文件大小为10MB)。 - 服务器收到请求后,会从文件的第 10MB 位置开始读取剩余内容,并返回
206 Partial Content。 - 客户端接收到数据块后,使用
RandomAccessFile的seek()方法定位到文件末尾,然后将新数据追加进去,而不是覆盖。
- 再次运行
总结与优化
- 协议设计: 我们设计了一个简单的文本协议,在生产环境中,可以使用更复杂的二进制协议或直接基于 HTTP,这样可以利用成熟的 Web 服务器(如 Nginx)和客户端库。
- 文件校验: 下载完成后,可以通过计算文件的 MD5 或 SHA1 值与服务器提供的值进行比对,以确保文件的完整性和正确性。
- 多线程/多线程下载: 对于大文件,可以将文件分成多个块,用多个线程同时下载不同的块,最后合并,这可以大大提高下载速度。
- 用户界面: 一个真实的下载工具会有进度条、速度显示、暂停/继续按钮等,暂停功能就是简单地停止客户端程序,续传功能则是再次启动客户端。
- 服务器端优化: 服务器端可以使用
FileChannel和transferTo()方法进行零拷贝传输,性能更高。
这个例子完整地展示了 Java Socket 断点续传的核心原理和实现方法,你可以基于此进行扩展和优化。
