Java:Socket断点传输大文件

0x00. 解决思路

  • Sever端负责接收指令(文件路径、第几块、每块大小),读取相应的文件中的块数据,返回给Client(顺便附上有效数据长度、MD5)。
  • Client端负责控制断点,通过断点向Server发送指令,接收数据后判断数据完整性(有效数据长度、MD5),再将数据写入目标文件。
  • 由于Java流处理类实在繁多,本次实验就统一采用 In/OutputStreamFileIn/FileOutputStream 字节流传输类来传输数据。
  • 多线程的Server还没摸清楚,这里就只用单线程了。
  • Client发给Server的指令为UTF-8编码的字符串,格式为文件路径|第几块|每块大小
  • Server发送给Client的数据为字节数组,格式为:
    数据长度,4字节int数据MD5,16字节数组文件块数据,长度由数据长度指定

0x01. Server端实现

  • 监听端口,等待Client连接:

    ServerSocket server = new ServerSocket(port);  // 监听localhost:port
    Socket handle = server.accept();  // 等待Client接入
    
    OutputStream out = handle.getOutputStream();
    InputStream in = handle.getInputStream();
    
  • 设定指令缓冲区:

    final int orderBufSize = 1024;
    byte[] orderBuf = new byte[orderBufSize];
    
  • 一直监听指令,直到Client结束:

    while (true) {
        // 接收并解析Client发过来的指令
        
        // 通过指令读取指定文件中的指定块
    
        // 数据附上有效数据长度和MD5值
    
        // 将数据发送给Client
    }
    // 关闭连接
    
    • 接收并解析Client发过来的指令:

      // 接收指令
      int orderLen = in.read(orderBuf, 0, orderBufSize);
      if (orderLen <= 0) {
          break;
      }
      String orderStr = new String(orderBuf, 0, orderLen, "UTF-8");
      // 解析指令
      String[] orders = orderStr.trim().split("\\|");  // 正则表达式,用竖线分割字符串
      final String filePath = orders[0];  // 目标文件路径
      final int pieceIndex = Integer.parseInt(orders[1]);  // 目标数据块
      final int pieceBufSize = Integer.parseInt(orders[2]);  // 每块大小
      
    • 读取指定文件中的指定块:

      byte[] pieceBuf = new byte[pieceBufSize]; // 文件内容缓冲区
      FileInputStream file = new FileInputStream(filePath);
      file.skip(pieceIndex * (long) pieceBufSize); // 跳转到目标块
      int pieceLen = file.read(pieceBuf);  // 读取目标块
      file.close();
      
    • 数据附上有文件内容长度和MD5值。长度是int,要传输就要先转换成byte[]:

      // 获取md5值
      MessageDigest md = MessageDigest.getInstance("MD5");
      md.update(pieceBuf);
      byte[] md5 = md.digest();
      // 将要返回给Client的数据合并,包括文件内容长度、内容MD5值、文件内容
      byte[] lenArr = ByteBuffer.allocate(4).putInt(pieceLen).array();
      ByteBuffer byteBuffer = ByteBuffer.allocate(lenArr.length + md5.length + pieceBuf.length);
      byte[] res = byteBuffer.put(lenArr).put(md5).put(pieceBuf).array();
      

      这里用到了 java.nio.ByteBuffer 这个类,byte[]操作很方便,比如byte[]->int,int->byte[]。

    • 将数据发送给Client:

      out.write(res);
      

0x02. Client端实现

  • 先做三个断点操作函数:

    // 断点不存在或出现错误则返回backup
    private static int GetBreakPoint(String bpFile, int backup) {
        FileReader fr = null;
        try {
            fr = new FileReader(bpFile);
            BufferedReader br = new BufferedReader(fr);
            String numStr = br.readLine();
            return Integer.parseInt(numStr);
        } catch (NumberFormatException | IOException e) {
            return backup;
        } finally {
            try {
              if (fr != null) {
                  fr.close();
              }
            } catch (IOException e) {
              e.printStackTrace();
            }
        }
      }
    
      private static void SetBreakPoint(String bpFile, int num) throws FileNotFoundException {
        PrintStream f = new PrintStream(bpFile);
        f.println(num);
        f.close();
      }
    
      private static void DelBreakPoint(String bpFile) {
        File f = new File(bpFile);
        if (f.exists()) {
            f.delete();
        }
      }
    
  • 设定srcFile和dstFile为源文件和目标文件。

  • 读取断点,断点文件为BreakPoint.index

    String bpFile = "BreakPoint.index";
    int begin = GetBreakPoint(bpFile, 0);
    
  • 连接到Server:

    Socket client = new Socket("localhost", port);
    OutputStream out = client.getOutputStream();
    InputStream in = client.getInputStream();
    
  • 设置缓冲区:

    final int bufLen = 8 * 1000 * 1000; // 8M
    byte[] pieceBuf = new byte[4 + 16 + bufSize]; // 文件内容长度4字节,md5值16字节
    
  • Client主体:

    for (int pieceIndex = begin; ; ++pieceIndex) {
       SetBreakPoint(bpFile, pieceIndex);
    
       // 向Server发送指令
    
       // 接收数据
    
       // 分析数据,判断文件是否结束,校验MD5
    
       // 将数据写入文件
    }
    DelBreakPoint(bpFile);
    // 关闭连接
    
    • 向Server发送指令:

      String order = String.format("%s|%d|%d", srcFile, pieceIndex, bufSize);
      out.write(order.getBytes("UTF-8"));
      
    • 接收数据,直到数据读完或缓冲区满:

      int sum = 0, len;
      while ((len = in.read(pieceBuf, sum, 20 + bufSize - sum)) > 0 && sum < 20 + bufSize) {
              sum += len;
      }
      
    • 分析数据,判断文件是否结束,校验md5:

      // 解析Server返回的数据
      byte[] temp = new byte[4];
      System.arraycopy(pieceBuf, 0, temp, 0, 4);
      int pieceLen = ByteBuffer.wrap(temp).getInt();
      if (pieceLen <= 0) {
          // 文件读取结束
          break;
      }
      // MD5校验
      byte[] md5 = new byte[16];
      System.arraycopy(pieceBuf, 4, md5, 0, 16);
      if (!CheckMD5(pieceBuf, 20, md5)) {
          // MD5校验失败,重新请求该文件块
          pieceIndex--;
          continue;
      }
      

      这里也用到了Java自带类 java.nio.ByteBuffer 来做byte[]->int。
      CheckMD5是对java.security.MessageDigest的封装:

        /**
        * MD5校验函数,判断data数组中start位置之后数据的MD5值是否和md5参数一致
        *
        * @param data  待校验字节数组
        * @param start 数据起始下标
        * @param md5   md5对照字节数组
        * @return 校验结果
        */
        private static Boolean CheckMD5(byte[] data, int start, byte[] md5) {
            MessageDigest md;
            try {
                md = MessageDigest.getInstance("MD5");
                if (start == 0) {
                    md.update(data);
                } else {
                    byte[] part = new byte[data.length - start];
                    System.arraycopy(data, start, part, 0, part.length);
                    md.update(part);
                }
      
            } catch (NoSuchAlgorithmException e) {
                return false;
            }
            byte[] result = md.digest();
            for (int i = 0; i < result.length; ++i) {
                if (md5[i] != result[i]) {
                    return false;
                }
            }
            return true;
          }
      
    • 将数据写入文件:

      FileOutputStream fw = new FileOutputStream(dstFile, true);  // 追加到文件末尾
      fw.write(pieceBuf, 20, pieceLen);
      fw.close();
      

0x03. 测试

在本地运行Server和Client,使用Socket传输一个8G的文件(机械硬盘->固态硬盘)用时180秒;而使用操作系统的复制操作用时75秒。为什么会有这么大的差距呢?我觉得原因主要有以下几点:

  • 文件内容需要先被Server读取至内存,再使用Socket通过localhost发送给Client的Socket,再由Client写入至硬盘。这个过程,远没有操作系统“读取+写入”来得高效。
  • 由于采用了文件分块机制,所以每传输一次大文件,Socket和Client实际上进行了很多次操作。假如说大文件分成了100块依次进行传输,那么Client就需要发送100次指令、执行100次文件写入操作,Server需要执行100次文件读取操作,所以缓存大小在一定程度上也会影响Socket传输的效率。

0x04. 过程感言

  • Java的流处理类实在太多,有处理字节的,有处理字符的,有处理一行字符的,有处理各种数据类型的,实在令人头晕。这里就直接统一用最底层的类,省去了很多麻烦。
  • 由于TCP数据包的最大长度是65535,所以Client接收64M数据时不能一次接收完,需要循环接收。由于刚开始不太了解,所以一直不能接收到完整的数据,说明了基础的重要性。
  • TCP协议底层使用了CRC做校验,上层再做一次MD5校验更保险(虽然这次传输8G文件100+次传输都没有错错误,汗……)。

版权声明:本文为my_precious原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。