记录已经下载的字节数,下次连接时,从这个位置继续下载,这需要客户端和服务器端都进行配合。

下面我将分步讲解实现原理,并提供一个完整的、可运行的客户端和服务器端示例。
核心原理
断点续传主要依赖于 HTTP 协议中的一个头部字段:Range。
-
客户端请求:
- 当客户端开始下载一个文件时,它会发送一个普通的
GET请求。 - 如果下载中断后再次开始,客户端会发送一个
GET请求,并带上Range头部。 Range头部的格式是bytes=start-end。Range: bytes=0-499:请求文件的第 0 个字节到第 499 个字节。Range: bytes=500-:请求从第 500 个字节开始到文件末尾的所有内容。
- 客户端还需要记录一个本地的“临时文件”,这个文件的大小就是已经下载的字节数,也就是下一次请求的
start位置。
- 当客户端开始下载一个文件时,它会发送一个普通的
-
服务器响应:
(图片来源网络,侵删)- 服务器收到带有
Range头的请求后,会理解这是一个断点续传请求。 - 服务器不会返回整个文件,而是只请求的那部分数据。
- 在 HTTP 响应中,服务器会使用
Content-Range头部来告知客户端返回的是文件的哪一部分,格式为bytes start-end/total_length。 - 服务器的响应状态码不再是
200 OK,而是206 Partial Content),表示这是一个部分响应。
- 服务器收到带有
-
客户端处理:
- 客户端收到
206响应后,解析Content-Range头,确认接收到的数据是正确的。 - 客户端将接收到的数据追加到之前创建的临时文件末尾,而不是覆盖它。
- 当临时文件的大小与服务器上的文件总大小一致时,下载完成,客户端可以将临时文件重命名为正式文件。
- 客户端收到
实现步骤
我们将创建两个 Java 程序:一个服务器端(FileServer.java)和一个客户端(FileClient.java)。
服务器端 (FileServer.java)
服务器需要能够处理普通 GET 请求和带有 Range 头的 GET 请求。
- 处理普通
GET:返回整个文件,状态码200 OK。 - 处理
RangeGET:根据Range头计算起始和结束位置,读取文件的相应部分,返回状态码206 Partial Content,并设置Content-Range头。
客户端 (FileClient.java)
客户端需要实现下载逻辑,并能从中断处恢复。
- 检查本地文件:开始下载前,先检查本地是否存在同名临时文件。
- 计算
Range:如果存在临时文件,获取其大小,作为本次请求的Range起始位置,如果不存在,则从0开始。 - 发送请求:根据计算出的
Range,构造并发送 HTTP 请求给服务器。 - 处理响应:接收服务器的响应,如果是
206,则将数据追加到临时文件;如果是200,则直接写入(覆盖)临时文件。 - 完成下载:当下载完成时,将临时文件重命名为最终文件名。
完整代码示例
服务器端代码 (FileServer.java)
这个服务器会监听 8888 端口,并将当前目录下的 testfile.zip 文件提供给客户端下载。
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 = 8888;
private static final String FILE_TO_SEND = "testfile.zip"; // 确保这个文件存在
public static void main(String[] args) {
try (ServerSocket serverSocket = new ServerSocket(PORT)) {
System.out.println("服务器启动,监听端口 " + PORT);
while (true) {
try (Socket clientSocket = serverSocket.accept();
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
OutputStream out = clientSocket.getOutputStream()) {
System.out.println("客户端已连接: " + clientSocket.getInetAddress());
// 读取客户端的HTTP请求头
String requestLine = in.readLine();
System.out.println("收到请求: " + requestLine);
String rangeHeader = null;
while (true) {
String headerLine = in.readLine();
if (headerLine == null || headerLine.isEmpty()) {
break;
}
if (headerLine.startsWith("Range:")) {
rangeHeader = headerLine;
}
}
Path filePath = Paths.get(FILE_TO_SEND);
if (!Files.exists(filePath)) {
String errorResponse = "HTTP/1.1 404 Not Found\r\n\r\nFile Not Found";
out.write(errorResponse.getBytes());
System.out.println("文件不存在: " + FILE_TO_SEND);
continue;
}
long fileLength = Files.size(filePath);
byte[] fileBytes = Files.readAllBytes(filePath);
if (rangeHeader == null) {
// 没有Range头,返回整个文件
System.out.println("发送整个文件...");
String responseHeader = "HTTP/1.1 200 OK\r\n" +
"Content-Type: application/octet-stream\r\n" +
"Content-Length: " + fileLength + "\r\n" +
"Content-Disposition: attachment; filename=\"" + FILE_TO_SEND + "\"\r\n" +
"\r\n";
out.write(responseHeader.getBytes());
out.write(fileBytes);
} else {
// 有Range头,处理断点续传
System.out.println("处理断点续传请求: " + rangeHeader);
String[] rangeParts = rangeHeader.substring("bytes=".length()).split("-");
long start = Long.parseLong(rangeParts[0]);
long end = (rangeParts.length > 1 && !rangeParts[1].isEmpty()) ? Long.parseLong(rangeParts[1]) : fileLength - 1;
// 确保请求的范围有效
if (start >= fileLength || end >= fileLength || start > end) {
String errorResponse = "HTTP/1.1 416 Range Not Satisfiable\r\n\r\nInvalid Range";
out.write(errorResponse.getBytes());
System.out.println("无效的请求范围");
continue;
}
long contentLength = end - start + 1;
byte[] partialFileBytes = new byte[(int) contentLength];
System.arraycopy(fileBytes, (int) start, partialFileBytes, 0, (int) contentLength);
String responseHeader = "HTTP/1.1 206 Partial Content\r\n" +
"Content-Type: application/octet-stream\r\n" +
"Content-Length: " + contentLength + "\r\n" +
"Content-Range: bytes " + start + "-" + end + "/" + fileLength + "\r\n" +
"Content-Disposition: attachment; filename=\"" + FILE_TO_SEND + "\"\r\n" +
"\r\n";
out.write(responseHeader.getBytes());
out.write(partialFileBytes);
}
System.out.println("文件发送完成。");
} catch (IOException e) {
System.err.println("处理客户端请求时出错: " + e.getMessage());
}
}
} catch (IOException e) {
System.err.println("无法启动服务器: " + e.getMessage());
}
}
}
客户端代码 (FileClient.java)
这个客户端会连接服务器,下载 testfile.zip,并支持断点续传。
import java.io.*;
import java.net.Socket;
public class FileClient {
private static final String SERVER_ADDRESS = "localhost";
private static final int SERVER_PORT = 8888;
private static final String FILE_TO_RECEIVE = "testfile_downloaded.zip";
private static final String TEMP_FILE = FILE_TO_RECEIVE + ".tmp";
public static void main(String[] args) {
long downloadedBytes = 0;
File tempFile = new File(TEMP_FILE);
// 1. 检查本地是否存在临时文件,以确定断点续传的起始位置
if (tempFile.exists()) {
downloadedBytes = tempFile.length();
System.out.println("发现临时文件,大小: " + downloadedBytes + " 字节,将从断点继续下载。");
} else {
System.out.println("开始新下载...");
}
try (Socket socket = new Socket(SERVER_ADDRESS, SERVER_PORT);
InputStream in = socket.getInputStream();
FileOutputStream fileOut = new FileOutputStream(tempFile, downloadedBytes > 0); // true表示追加模式
BufferedOutputStream bufferedOut = new BufferedOutputStream(fileOut)) {
// 2. 构造HTTP请求
String requestHeader;
if (downloadedBytes > 0) {
requestHeader = "GET /" + FILE_TO_RECEIVE + " HTTP/1.1\r\n" +
"Host: " + SERVER_ADDRESS + "\r\n" +
"Range: bytes=" + downloadedBytes + "-\r\n" +
"Connection: close\r\n\r\n";
} else {
requestHeader = "GET /" + FILE_TO_RECEIVE + " HTTP/1.1\r\n" +
"Host: " + SERVER_ADDRESS + "\r\n" +
"Connection: close\r\n\r\n";
}
System.out.println("发送请求:\n" + requestHeader);
socket.getOutputStream().write(requestHeader.getBytes());
// 3. 读取HTTP响应头
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
String statusLine = reader.readLine();
System.out.println("收到响应: " + statusLine);
String contentLengthStr = null;
String contentRangeStr = null;
while (true) {
String headerLine = reader.readLine();
if (headerLine == null || headerLine.isEmpty()) {
break; // 头部结束
}
if (headerLine.startsWith("Content-Length:")) {
contentLengthStr = headerLine.substring("Content-Length:".length()).trim();
}
if (headerLine.startsWith("Content-Range:")) {
contentRangeStr = headerLine;
}
}
System.out.println("响应头解析完毕。");
// 4. 根据响应状态码处理数据
if (statusLine.startsWith("HTTP/1.1 200 OK")) {
System.out.println("服务器开始发送整个文件...");
long totalBytes = Long.parseLong(contentLengthStr);
writeFile(in, bufferedOut, totalBytes, downloadedBytes);
} else if (statusLine.startsWith("HTTP/1.1 206 Partial Content")) {
System.out.println("服务器开始发送文件片段...");
// Content-Range: bytes start-end/total
String[] parts = contentRangeStr.split("/");
long totalBytes = Long.parseLong(parts[1].trim());
writeFile(in, bufferedOut, totalBytes, downloadedBytes);
} else {
System.err.println("下载失败,服务器响应: " + statusLine);
return;
}
// 5. 下载完成,重命名临时文件
if (tempFile.renameTo(new File(FILE_TO_RECEIVE))) {
System.out.println("下载完成!文件已保存为: " + FILE_TO_RECEIVE);
} else {
System.err.println("下载完成,但无法重命名临时文件。");
}
} catch (IOException e) {
System.err.println("下载过程中出错: " + e.getMessage());
e.printStackTrace();
}
}
private static void writeFile(InputStream in, BufferedOutputStream out, long totalBytes, long alreadyDownloaded) throws IOException {
byte[] buffer = new byte[4096];
long bytesToRead = totalBytes - alreadyDownloaded;
long totalRead = 0;
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
totalRead += bytesRead;
// 打印下载进度
int progress = (int) ((totalRead + alreadyDownloaded) * 100 / totalBytes);
System.out.print("\r下载进度: " + progress + "%");
if (progress == 100) System.out.println(); // 换行
}
}
}
如何运行和测试
-
准备文件:
- 在你的项目根目录下,创建一个名为
testfile.zip的文件(可以是任意大小,建议几MB以上,方便观察进度),或者,在代码中修改FILE_TO_SEND为你已有的任意文件名。
- 在你的项目根目录下,创建一个名为
-
运行服务器:
- 编译并运行
FileServer.java。 - 控制台会显示:
服务器启动,监听端口 8888。
- 编译并运行
-
第一次运行客户端:
- 编译并运行
FileClient.java。 - 你会看到:
开始新下载...,然后是下载进度条。 - 下载完成后,会生成
testfile_downloaded.zip文件。
- 编译并运行
-
模拟中断并续传:
- 方法A(强制中断):在客户端下载过程中(比如进度到50%时),强制停止客户端程序(在IDE中点击停止按钮,或在命令行按
Ctrl+C)。 - 方法B(观察临时文件):你会发现目录下多了一个
testfile_downloaded.zip.tmp文件,这个文件的大小就是已经下载的部分。 - 重新运行客户端:再次运行
FileClient.java。 - 你会看到:
发现临时文件,大小: XXX 字节,将从断点继续下载。,然后进度会从上次中断的地方继续计算,直到100%完成。 - 下载成功后,
testfile_downloaded.zip.tmp文件会消失,并重命名为testfile_downloaded.zip。
- 方法A(强制中断):在客户端下载过程中(比如进度到50%时),强制停止客户端程序(在IDE中点击停止按钮,或在命令行按
进阶与注意事项
- 更健壮的HTTP解析:上面的示例为了简化,HTTP头的解析比较简单,在生产环境中,建议使用成熟的 HTTP 库(如 Apache HttpClient, OkHttp)来处理请求和响应,它们内置了对
Range和206状态码的支持。 - 多线程下载:断点续传通常和多线程下载结合,一个大文件被分成多个部分(5 个部分),每个线程负责下载一个部分,每个线程都需要记录自己负责的那一部分的下载进度,最后再将所有部分合并成一个完整文件,这需要对服务器进行多次
Range请求(Range: bytes=0-1023,Range: bytes=1024-2047...)。 - 服务器端实现:上面的服务器是一个简单的演示,真实的服务器(如 Nginx, Apache)已经内置了对断点续传的完美支持,如果你需要用 Java 写一个生产级的文件服务器,可以考虑使用 Netty 等网络框架,它们能更高效、更稳定地处理高并发连接。
- 校验机制:为了确保下载的文件完整性,可以在下载完成后计算文件的 MD5 或 SHA-1 值,与服务器提供的校验值进行比对,如果中途损坏,可以重新下载或从断点继续。
