杰瑞科技汇

Java Socket 断点续传如何实现?

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

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

要实现这个功能,客户端和服务器端需要协同工作,主要涉及以下几个关键点:

  1. 请求范围:客户端需要告诉服务器它需要文件的哪一部分,这通过 HTTP 协议的 Range 请求头实现,Range: bytes=1024- 表示从第 1024 个字节开始请求直到文件末尾。
  2. 状态响应:服务器需要能够理解 Range 请求,并根据请求返回相应的数据块,它需要返回一个 206 Partial Content 状态码,表示这是部分内容响应。
  3. 本地记录:客户端需要维护一个本地文件(例如一个记录文件 .record),用于存储当前已下载的文件大小,每次程序启动时,先读取这个记录文件,获取已下载的字节数,作为新的 Range 请求的起始点。
  4. 文件合并:当所有数据块下载完成后,客户端需要将下载的临时文件与本地已存在的部分文件合并,形成一个完整的文件,在 Java NIO 的帮助下,这个过程可以非常高效。

下面,我们将通过一个完整的例子来展示客户端和服务器的实现。


项目结构

BreakpointResumeDemo/
├── src/
│   ├── server/
│   │   └── FileServer.java
│   └── client/
│       ├── FileClient.java
│       └── DownloadRecord.java
└── testfile.txt  (一个用于测试的任意文件)

服务器端实现 (FileServer.java)

服务器需要处理两种请求:

  1. 完整文件请求:当客户端第一次请求文件或没有 Range 头时,返回整个文件(200 OK)。
  2. 请求:当客户端带有 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 是主逻辑。

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

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();
        }
    }
}

如何运行和测试

  1. 准备文件:在项目根目录下创建一个 testfile.txt 文件,内容可以随意写一些,"Hello, this is a test file for breakpoint resume."。

  2. 编译代码

    Java Socket 断点续传如何实现?-图3
    (图片来源网络,侵删)
    javac -d . src/server/*.java src/client/*.java
  3. 启动服务器

    java server.FileServer

    你会看到 "服务器已启动,监听端口 12345" 的提示。

  4. 第一次启动客户端: 在一个新的终端窗口中运行客户端:

    java client.FileClient
    • 客户端会发送一个完整文件请求。
    • 服务器会返回 200 OK 和整个文件。
    • 客户端会下载文件并保存为 downloaded_testfile.txt,同时创建一个 downloaded_testfile.txt.record 文件,记录下载的总大小。
    • 下载完成后,.record 文件会被删除。
  5. 模拟断点续传

    • 中断下载:在客户端下载过程中(比如看到进度条在动时),按 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 响应码)来协商传输范围。
  • 状态持久化:客户端使用一个简单的记录文件来维护下载状态。
  • 流式处理:使用 InputStreamOutputStream 进行高效的流式数据传输,避免一次性加载大文件到内存。
  • 文件追加:利用 RandomAccessFileseek() 方法和 APPEND 模式,实现数据的无缝续写。

可以扩展的方向

  1. 更健壮的协议:使用真正的 HTTP 协议(如 HttpURLConnection 或第三方库如 Apache HttpClient),它们内置了对 Range 请求的支持,更标准、更可靠。
  2. 多线程/多线程下载:将大文件分成多个块,用多个线程同时下载不同的块,可以极大地提高下载速度,这需要服务器支持多范围请求(Range: bytes=0-1023, 2048-3071)。
  3. 图形用户界面:为客户端添加一个 GUI,显示下载进度条、速度、剩余时间等信息,提升用户体验。
  4. 哈希校验:下载完成后,计算本地文件和服务器文件的 MD5/SHA1 哈希值,以验证文件的完整性和一致性,防止数据损坏。
分享:
扫描分享到社交APP
上一篇
下一篇