0x00. 解决思路
- Sever端负责接收指令(文件路径、第几块、每块大小),读取相应的文件中的块数据,返回给Client(顺便附上有效数据长度、MD5)。
- Client端负责控制断点,通过断点向Server发送指令,接收数据后判断数据完整性(有效数据长度、MD5),再将数据写入目标文件。
- 由于Java流处理类实在繁多,本次实验就统一采用
In/OutputStream和FileIn/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版权协议,转载请附上原文出处链接和本声明。