杰瑞科技汇

Java中socket断点续传如何实现?

客户端和服务器端需要记录文件已经传输了多少字节(即“断点”),下次传输时,从这个断点位置继续,而不是从头开始。

实现断点续传的关键在于 HTTP 协议中的 Range 请求头,我们将模拟这个机制,在自定义的 Socket 协议中实现类似的功能。

核心概念

  1. 客户端:

    • 发送一个文件请求,并告知服务器自己已经拥有文件的哪些部分(通过 Range 请求头)。
    • 接收服务器返回的数据块,并将其写入文件的正确位置。
    • 维护一个本地文件,记录已下载的部分。
  2. 服务器端:

    • 接收客户端的请求,解析 Range 请求头,了解客户端需要文件的哪个部分。
    • 根据请求,读取文件的相应部分(而不是整个文件)。
    • 将读取的数据块发送给客户端。
  3. Range 请求头:

    • 这是实现断点续传的灵魂,它的格式通常是 bytes=start-end
    • Range: bytes=1024-2047 表示请求从文件的第 1024 字节到第 2047 字节(共 1024 字节)的数据。
    • 如果客户端是第一次请求,没有 Range 头,或者 Range 头表示 bytes=0-,则表示请求整个文件。
  4. 状态码:

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

如何测试断点续传

  1. 准备工作:

    • 创建一个 server_files 目录,并在里面放一个较大的文件(my_large_file.zip)。
    • 创建一个 client_files 目录。
    • 编译并运行 FileServer
    • 编译并运行 FileClient
  2. 首次下载:

    • 客户端会请求整个文件。
    • 服务器返回 200 OK
    • 客户端将完整文件写入 client_files/my_large_file.zip
    • 注意: 在 FileClientmain 方法末尾,我们故意调用了 System.exit(1) 来模拟程序意外中断,在实际应用中,这可能是网络断开、程序崩溃等情况。
  3. 续传:

    • 再次运行 FileClient
    • 客户端首先检查 client_files/my_large_file.zip 是否存在。
    • 它会发现文件存在,并获取其大小(10MB)。
    • 客户端会发送一个 RANGE 请求:my_large_file.zip RANGE 10485760 -1 (假设文件大小为10MB)。
    • 服务器收到请求后,会从文件的第 10MB 位置开始读取剩余内容,并返回 206 Partial Content
    • 客户端接收到数据块后,使用 RandomAccessFileseek() 方法定位到文件末尾,然后将新数据追加进去,而不是覆盖。

总结与优化

  • 协议设计: 我们设计了一个简单的文本协议,在生产环境中,可以使用更复杂的二进制协议或直接基于 HTTP,这样可以利用成熟的 Web 服务器(如 Nginx)和客户端库。
  • 文件校验: 下载完成后,可以通过计算文件的 MD5 或 SHA1 值与服务器提供的值进行比对,以确保文件的完整性和正确性。
  • 多线程/多线程下载: 对于大文件,可以将文件分成多个块,用多个线程同时下载不同的块,最后合并,这可以大大提高下载速度。
  • 用户界面: 一个真实的下载工具会有进度条、速度显示、暂停/继续按钮等,暂停功能就是简单地停止客户端程序,续传功能则是再次启动客户端。
  • 服务器端优化: 服务器端可以使用 FileChanneltransferTo() 方法进行零拷贝传输,性能更高。

这个例子完整地展示了 Java Socket 断点续传的核心原理和实现方法,你可以基于此进行扩展和优化。

分享:
扫描分享到社交APP
上一篇
下一篇