杰瑞科技汇

Java Socket断点续传如何实现?

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

Java Socket断点续传如何实现?-图1
(图片来源网络,侵删)

下面我将分步讲解实现原理,并提供一个完整的、可运行的客户端和服务器端示例。

核心原理

断点续传主要依赖于 HTTP 协议中的一个头部字段:Range

  1. 客户端请求

    • 当客户端开始下载一个文件时,它会发送一个普通的 GET 请求。
    • 如果下载中断后再次开始,客户端会发送一个 GET 请求,并带上 Range 头部。
    • Range 头部的格式是 bytes=start-end
      • Range: bytes=0-499:请求文件的第 0 个字节到第 499 个字节。
      • Range: bytes=500-:请求从第 500 个字节开始到文件末尾的所有内容。
    • 客户端还需要记录一个本地的“临时文件”,这个文件的大小就是已经下载的字节数,也就是下一次请求的 start 位置。
  2. 服务器响应

    Java Socket断点续传如何实现?-图2
    (图片来源网络,侵删)
    • 服务器收到带有 Range 头的请求后,会理解这是一个断点续传请求。
    • 服务器不会返回整个文件,而是只请求的那部分数据。
    • 在 HTTP 响应中,服务器会使用 Content-Range 头部来告知客户端返回的是文件的哪一部分,格式为 bytes start-end/total_length
    • 服务器的响应状态码不再是 200 OK,而是 206 Partial Content),表示这是一个部分响应。
  3. 客户端处理

    • 客户端收到 206 响应后,解析 Content-Range 头,确认接收到的数据是正确的。
    • 客户端将接收到的数据追加到之前创建的临时文件末尾,而不是覆盖它。
    • 当临时文件的大小与服务器上的文件总大小一致时,下载完成,客户端可以将临时文件重命名为正式文件。

实现步骤

我们将创建两个 Java 程序:一个服务器端(FileServer.java)和一个客户端(FileClient.java)。

服务器端 (FileServer.java)

服务器需要能够处理普通 GET 请求和带有 Range 头的 GET 请求。

  • 处理普通 GET:返回整个文件,状态码 200 OK
  • 处理 Range GET:根据 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(); // 换行
        }
    }
}

如何运行和测试

  1. 准备文件

    • 在你的项目根目录下,创建一个名为 testfile.zip 的文件(可以是任意大小,建议几MB以上,方便观察进度),或者,在代码中修改 FILE_TO_SEND 为你已有的任意文件名。
  2. 运行服务器

    • 编译并运行 FileServer.java
    • 控制台会显示:服务器启动,监听端口 8888
  3. 第一次运行客户端

    • 编译并运行 FileClient.java
    • 你会看到:开始新下载...,然后是下载进度条。
    • 下载完成后,会生成 testfile_downloaded.zip 文件。
  4. 模拟中断并续传

    • 方法A(强制中断):在客户端下载过程中(比如进度到50%时),强制停止客户端程序(在IDE中点击停止按钮,或在命令行按 Ctrl+C)。
    • 方法B(观察临时文件):你会发现目录下多了一个 testfile_downloaded.zip.tmp 文件,这个文件的大小就是已经下载的部分。
    • 重新运行客户端:再次运行 FileClient.java
    • 你会看到:发现临时文件,大小: XXX 字节,将从断点继续下载。,然后进度会从上次中断的地方继续计算,直到100%完成。
    • 下载成功后,testfile_downloaded.zip.tmp 文件会消失,并重命名为 testfile_downloaded.zip

进阶与注意事项

  • 更健壮的HTTP解析:上面的示例为了简化,HTTP头的解析比较简单,在生产环境中,建议使用成熟的 HTTP 库(如 Apache HttpClient, OkHttp)来处理请求和响应,它们内置了对 Range206 状态码的支持。
  • 多线程下载:断点续传通常和多线程下载结合,一个大文件被分成多个部分(5 个部分),每个线程负责下载一个部分,每个线程都需要记录自己负责的那一部分的下载进度,最后再将所有部分合并成一个完整文件,这需要对服务器进行多次 Range 请求(Range: bytes=0-1023, Range: bytes=1024-2047...)。
  • 服务器端实现:上面的服务器是一个简单的演示,真实的服务器(如 Nginx, Apache)已经内置了对断点续传的完美支持,如果你需要用 Java 写一个生产级的文件服务器,可以考虑使用 Netty 等网络框架,它们能更高效、更稳定地处理高并发连接。
  • 校验机制:为了确保下载的文件完整性,可以在下载完成后计算文件的 MD5 或 SHA-1 值,与服务器提供的校验值进行比对,如果中途损坏,可以重新下载或从断点继续。
分享:
扫描分享到社交APP
上一篇
下一篇