多文件自平衡云传输
Ⅰ 前言
此篇文章将详细分析一个中间件 多文件自平衡云传输(FilesCloudTransmission) 是如何搭建起来的。
源码可以在我的 github 中查看:FilesCloudTransmission
先来介绍一下这个框架。
一般我们要下载比如一个电影的话,最简单就是两种方式,一个是HTTP协议,通过浏览器下载,但是这个在文件稍微一大的时候,会把你卡崩溃。
还有一种就是FTP(文件传输协议) FTP采用两个 TCP 连接来传输一个文件。
但是无论是HTTP还是FTP,都难以解决单一服务器的带宽压力。因为它们都使用的是传统的客户端服务器的方式。
这时候,P2P协议应运而生,资源开始并不集中在服务器上,而是分散地存储在多台设备上。当用户要下载文件的时候,不需要到服务器上下,可以直接在就近的设备上下载。下载成功之后,你也成为了这些设备中的一员(peer),当你附近有设备需要下载文件时,可能就会从你这里下载。所以这个P2P网络参与的人越多,下载就越快。
关于网络协议我就不再多说,并不是这篇文章的目的。从上面的P2P协议出发,可以引出我的多文件自平衡云传输项目。
这个框架要实现的就是,当一台机器请求从服务器下载文件的时候,服务器会让有这个文件的设备一起向请求的设备发送文件,每个设备发送一部分,最终在要下载文件的机器上拼凑成一个完整的文件。
和P2P协议不同的是,P2P协议是一对一的,从就近的设备传输文件,而多文件云传输做的是多个设备一起传输文件,显然这样会更加快。
比如现在,第一次,客户端B向服务器请求文件1。
那现在客户端A也要下载文件文件1,这时候客户端B也会给客户端A一起传输文件1,有可能服务器传百分之十,客户端B传百分之九十,然后服务器传输的百分之十和客户端B传输的百分之九十最终在客户端A拼接成完整的文件。
以此类推,当一台设备请求下载的时候,我们可以让多台机器同时给它发送文件,每台机器只用发送一部分就好。
这里我圈了朵云,当作一个局域网,同样道理,不同的局域网之间也可以这样进行文件的传输。
这些百分比只是一个例子,在实际的实现中会根据情况进行调整。
注意这里我们传输的文件,并不只是一个文件,也可以是多个文件,或者是包含着多级目录的文件夹,文件格式当然除了文本文件、图片还包括视频等,都可以传输。
该项目的需求大致如下:
- 支持资源管理,包括增删改查;
- 支持多客户端同时发送和接收文件;
- 支持服务器端参与资源发送;
- 可配置发送端最大数量;
- 支持多发送端,多文件的分片发送与接收;
- 支持多发送端文件接收进度条;
- 支持自定义分发策略,缺省时提供默认分发策略;
- 支持自定义发送端负载均衡策略,缺省时提供默认负载均衡策略;
- 支持断点续传。
和之前写的项目 CsFramework 一样,这个框架也要求要留出开放接口,可以在此基础上进行二次开发。
Ⅱ 框架的搭建
A. 通信协议
a. 信息头的定义
在讲解我们的框架和P2P协议不同之处的时候,我想大家都看到了一个明显的特征,就是要将文件分片传送。
先来看看文件传输方的难点。
在一开始我们可以规定一个每次传输文件的大小,比如 10M,超过 10M 就需要进行分片发送,否则直接发送。这样每次传送的文件块都是等长的。但是我们知道,一个文件的大小是没法确定的,它一定会是我们规定的容量的倍数吗?所以需要传输的最后一块文件块大小我们是无法确定的。这也意味着,我们很难在传输文件之前去规定好每次传输的大小,这就要求传输方,必须传输长度可变的字节流信息。
对于接收方呢?由于存在多个线程同时发送文件块,最后接收到的文件块大概率是乱序的,甚至都不知道哪个是哪个的文件,所以接收方面临着每次接收的信息不确定有多大,顺序乱序无法拼装的困局。
那怎么解决呢?这就要我们传输文件的时候传送一个信息头,接收方通过解码这个信息头,必须知道接收的是哪个文件,接收的文件块的位置,需要接收的长度以及最后才会接收文件本身的信息。
因此我们需要定义一个信息的传输格式,包含四个成员:文件编号,偏移量,文件长度,文件字节流内容。 其中,文件编号、偏移量、文件长度构成一个信息头,用来描述当前传输的信息。
这里我定义一个信息头。
package com.tyz.transmission.protocol;
/**
* 每次传输文件块时的信息头,接收方需要解析这个信息头,
* 获取需要接收的文件的编号、文件块的偏移量和长度。
*
* @author tyz
*/
public class SectionHeader {
/** 文件块所属的文件编号 */
private int fileId;
/** 文件块的起始偏移量 */
private long offset;
/** 文件块的长度 */
private long length;
public SectionHeader(int fileId, long offset, long length) {
this.fileId = fileId;
this.offset = offset;
this.length = length;
}
/**
* @return 文件块所属的文件编号
*/
public int getFileId() {
return fileId;
}
/**
* @return 文件块的起始偏移量
*/
public long getOffset() {
return offset;
}
/**
* @return 文件块的长度
*/
public long getLength() {
return length;
}
}
其中,文件 id 我用int
来表示,其他两个用long
来表示,那么,这个信息头的长度就是 20 字节。在前面说了,文件传输采用字节流的方式传送,也就是二进制。所以我们需要把这三个信息转化成一个byte[24]
的数组。
所以这里我直接写了一个类,做字节流和普通类型的转换,我还是将它放在了我的工具包下,完整代码如下:
package com.tyz.util;
/**
* 字节流转换器,将其他类型的数据转成二进制数组
* 将二进制数组转换成普通数据
*
* @author tyz
*/
public class BytesTranslator {
private static final int SHORT_BYTE_COUNT = 2;
private static final int INT_BYTE_COUNT = 4;
private static final int LONG_BYTE_COUNT = 8;
public BytesTranslator() {
}
/**
* 将int型数据转换成二进制数组 (4 * 8b)
*
* @param value 需要转换的int类型数据
* @return 数据转换的二进制数组
*/
public static byte[] toBytes(int value) {
byte[] res = new byte[INT_BYTE_COUNT];
for (int i = 0; i < res.length; i++) {
res[i] = (byte) ((value >>> (i << 3)) & 0xFF);
}
return res;
}
/**
* 将int型数据转换成二进制,加到数组 {@code bytes}
* 中从 {@code offset} 开始的地方
*
* @param value 需要转换的int型数据
* @return 添加数据后的二进制数组
*/
public static byte[] toBytes(byte[] bytes, int offset, int value) {
for (int i = 0; i < INT_BYTE_COUNT; i++) {
bytes[i + offset] = (byte) ((value >>> (i << 3)) & 0xFF);
}
return bytes;
}
/**
* 将long型数据转换成二进制数组 (8 * 8b)
*
* @param value 需要转换的long型数据
* @return 数据转换的二进制数组
*/
public static byte[] toBytes(long value) {
byte[] res = new byte[LONG_BYTE_COUNT];
for (int i = 0; i < res.length; i++) {
res[i] = (byte) ((value >>> (i << 3)) & 0xFF);
}
return res;
}
/**
* 将long型数据转换成二进制,加到数组 {@code bytes}
* 中从 {@code offset} 开始的地方
*
* @param value 需要转换的long型数据
* @return 添加数据后的二进制数组
*/
public static byte[] toBytes(byte[] bytes, int offset, long value) {
for (int i = 0; i < LONG_BYTE_COUNT; i++) {
bytes[i + offset] = (byte) ((value >>> (i << 3)) & 0xFF);
}
return bytes;
}
/**
* 将short型数据转换成二进制数组 (2 * 8b)
*
* @param value 需要转换的short型数据
* @return 数据转换的二进制数组
*/
public static byte[] toBytes(short value) {
byte[] res = new byte[SHORT_BYTE_COUNT];
for (int i = 0; i < res.length; i++) {
res[i] = (byte) ((value >>> (i << 3)) & 0xFF);
}
return res;
}
/**
* 将short型数据转换成二进制,加到数组 {@code bytes}
* 中从 {@code offset} 开始的地方
*
* @param value 需要转换的short型数据
* @return 添加数据后的二进制数组
*/
public static byte[] toBytes(byte[] bytes, int offset, short value) {
for (int i = 0; i < LONG_BYTE_COUNT; i++) {
bytes[i + offset] = (byte) ((value >>> (i << 3)) & 0xFF);
}
return bytes;
}
/**
* 将二进制数组 {@code bytes} 转换成十进制的 int值
*
* @param bytes 需要转换的二进制数组
* @return 转换的 int型 值
*/
public static int toInt(byte[] bytes) {
if (bytes.length != INT_BYTE_COUNT) {
throw new InvalidBytesNumberException("bytes length [" +
bytes.length + "] is not 4, can't transfer to [int].");
}
int res = 0;
for (int i = 0; i < INT_BYTE_COUNT; i++) {
res |= (bytes[i] << (i << 3)) & (0xFF << (i << 3));
}
return res;
}
/**
* 将二进制数组 {@code bytes} 从 {@code offset} 起后四位,
* 转换成 int型 的十进制数。
*
* @param bytes 需要转换的二进制数组
* @param offset 起始下标偏移量
* @return 转换的 int型 值
*/
public static int toInt(byte[] bytes, int offset) {
if (bytes.length - offset < INT_BYTE_COUNT) {
throw new InvalidBytesNumberException("bytes rest length [" +
(bytes.length - offset) + "] is less than 4, can't transfer to [int].");
}
byte[] tempBytes = new byte[INT_BYTE_COUNT];
System.arraycopy(bytes, offset, tempBytes, 0, tempBytes.length);
return toInt(tempBytes);
}
/**
* 将二进制数组 {@code bytes} 转换成十进制的 long值
*
* @param bytes 需要转换的二进制数组
* @return 转换的 long型 值
*/
public static int toLong(byte[] bytes) {
if (bytes.length != LONG_BYTE_COUNT) {
throw new InvalidBytesNumberException("bytes length [" +
bytes.length + "] is not 8, can't transfer to [long].");
}
int res = 0;
for (int i = 0; i < LONG_BYTE_COUNT; i++) {
res |= (bytes[i] << (i << 3)) & (0xFF << (i << 3));
}
return res;
}
/**
* 将二进制数组 {@code bytes} 从 {@code offset} 起后四位,
* 转换成 long型 的十进制数。
*
* @param bytes 需要转换的二进制数组
* @param offset 起始下标偏移量
* @return 转换的 long型 值
*/
public static int toLong(byte[] bytes, int offset) {
if (bytes.length - offset < LONG_BYTE_COUNT) {
throw new InvalidBytesNumberException("bytes rest length [" +
(bytes.length - offset) + "] is less than 8, can't transfer to [long].");
}
byte[] tempBytes = new byte[LONG_BYTE_COUNT];
System.arraycopy(bytes, offset, tempBytes, 0, tempBytes.length);
return toLong(tempBytes);
}
/**
* 将二进制数组 {@code bytes} 转换成十进制的 short值
*
* @param bytes 需要转换的二进制数组
* @return 转换的 short型 值
*/
public static int toShort(byte[] bytes) {
if (bytes.length != SHORT_BYTE_COUNT) {
throw new InvalidBytesNumberException("bytes length [" +
bytes.length + "] is not 2, can't transfer to [short].");
}
int res = 0;
for (int i = 0; i < SHORT_BYTE_COUNT; i++) {
res |= (bytes[i] << (i << 3)) & (0xFF << (i << 3));
}
return res;
}
/**
* 将二进制数组 {@code bytes} 从 {@code offset} 起后四位,
* 转换成 short型 的十进制数。
*
* @param bytes 需要转换的二进制数组
* @param offset 起始下标偏移量
* @return 转换的 short型 值
*/
public static int toShort(byte[] bytes, int offset) {
if (bytes.length - offset < SHORT_BYTE_COUNT) {
throw new InvalidBytesNumberException("bytes rest length [" +
(bytes.length - offset) + "] is less than 2, can't transfer to [short].");
}
byte[] tempBytes = new byte[SHORT_BYTE_COUNT];
System.arraycopy(bytes, offset, tempBytes, 0, tempBytes.length);
return toShort(tempBytes);
}
}
所以我们的信息头就构造完成了,我直接将代码贴出。
package com.tyz.transmission.protocol;
import com.tyz.util.BytesTranslator;
/**
* 每次传输文件块时的信息头,接收方需要解析这个信息头,
* 获取需要接收的文件的编号、文件块的偏移量和长度。
*
* @author tyz
*/
public class SectionHeader {
/** 信息头的总字节数 */
public static final int SECTION_HEADER_LENGTH = 20;
/** 信息头中文件编号的总字节数 */
public static final int HEADER_ID_LENGTH = 4;
/** 信息头中偏移量的总字节数 */
public static final int HEADER_OFFSET_LENGTH = 8;
/** 文件块所属的文件编号 */
private int fileId;
/** 文件块的起始偏移量 */
private long offset;
/** 文件块的长度 */
private long length;
/**
* 将二进制字节流 {@code bytes} 转换成文件编号、文件块偏移量和文件块长度
*
* @param bytes 二进制数组
*/
public SectionHeader(byte[] bytes) {
this.fileId = BytesTranslator.toInt(bytes, 0);
this.offset = BytesTranslator.toLong(bytes, HEADER_ID_LENGTH);
this.length = BytesTranslator.toLong(bytes, HEADER_ID_LENGTH + HEADER_OFFSET_LENGTH);
}
/**
* 初始化信息头
*
* @param fileId 文件块所属文件编号
* @param offset 文件块初始偏移量
* @param length 文件块长度
*/
public SectionHeader(int fileId, long offset, long length) {
this.fileId = fileId;
this.offset = offset;
this.length = length;
}
/**
* 将文件编号、文件块偏移量和文件块长度按顺序转换成字节流
* 4B + 8B + 8B = 20B
*
* @return 文件编号、文件块偏移量和文件块长度转换成的字节流
*/
public byte[] toBytes() {
byte[] bytes = new byte[SECTION_HEADER_LENGTH];
BytesTranslator.toBytes(bytes, 0, this.fileId);
BytesTranslator.toBytes(bytes, HEADER_ID_LENGTH, this.offset);
BytesTranslator.toBytes(bytes, HEADER_ID_LENGTH + HEADER_OFFSET_LENGTH, this.length);
return bytes;
}
/**
* @return 文件块所属的文件编号
*/
public int getFileId() {
return fileId;
}
/**
* @return 文件块的起始偏移量
*/
public long getOffset() {
return offset;
}
/**
* @return 文件块的长度
*/
public long getLength() {
return length;
}
}
再强调一下,我们的文件传输分两步,第一步先发送信息头SectionHeader
编码成的二进制字节流,第二步发送真正的信息体。接收方先接收到的是信息头,对其进行解析,确认文件的信息和需要接收的文件块大小,然后进行第二步接收。
能做到这一点,是基于Socket
的特性,面向连接的TCP
和数据的顺序发送。
b. 文件块的接收与发送
前面定义好了信息头,我们再来看看具体的数据要如何发。
首先要明确的是,我们在通信信道发送的数据是字节流,所以所有的数据都要变成二进制数组(byte[]
)的形式发送。因此,我们可以先定义一个基类BaseTransmitter
,完成byte[]
的发送与接收工作。
注意接收的时候,需要传递一个参数length
,这也是我们定义信息头的目的,就是要让接收方知道每次需要接收的文件大小。
现在我们可以根据需要,再定义一个子类FileBlockTransmitter
来完成具体的文件块收发工作,接收和发送都必须按照协议,先发送的是信息头,接收方再根据信息头来完成真正信息块的接收工作。
这里我们还需要再提供一个接口,使得文件块在被接收之后能得到相应的处理,比如我们后面需要将它拼接成完整的文件等。
在收发文件块的子类中,需要将其作为成员,并且在使用这个子类时,必须实现这个接口。
因为继承了BaseTransmitter
,所以子类可以直接使用recive()
和send()
方法进行字节流的发送与接收。
注意SECTION_HEADER_LENGTH
是我在信息头SectionHeader
中定义的常量,就是20,是信息头的大小,我们要避免程序中出现 magic number,因此尽量用static final 的常量来取代这些看不出来意义的数字。
B. 数据传输
a. 断点续传
既然是网络传输,自然有失败的风险。因此,我们还需要一个断点续传的功能。什么意思?就是接收方在接收的过程中,出现了异常,在下一次接收时,要接着上一次接收的内容继续接收,已经接收过的内容不能再接收第二遍。
在传输信息块的时候,我们会先发送一个信息头,这个在最开始就定义好了。
根据这个信息头,我们可以很轻松地得知当前信息块的信息。所以要得知一个文件的哪些块没有收到,就可以根据每次接收的信息头中的信息来统计。
比如我我们把一个文件分成了十块。
第一次发送了三块,SectionHeader
的offset
和length
是1 和 3。
那么,没收到的信息块就是两块,第一块是 offset = 0, length = 1的。(图中绿色)
第二块的 offset = 4, length = 6。(图中蓝色)
所以,随着不断接收到新的文件块,我们可以不断调整未收到的信息块的偏移量和长度,每个接收的文件都维护一个UnreceivedSections
,最终如果这个文件的UnreceivedSections
还有片段,说明这些片段就是未接收到的,需要重新传递。
这个类的初始化,必须要输入所传的文件的编号,不然我们并不知道这些文件块属于哪个文件,也必须传入这个文件的总长度,以上面我画的图举例子就是 10,不知道文件大小我们也是无法知道文件块到底传完了没有。
这个list
是一个还未接收到的文件块的列表,注意我圈起来的地方,一开始初始化的时候,因为还没有开始接收文件块,所以列表里就加入了一个文件块,就是从 0 到这个文件的末尾。下面的块分成十块是我们自己为了好看逻辑分的,在刚开始要接收这个比如大小为 10 的文件之前,那unreceivedSectonList
自然也就只有这个把整个白色的块当作一个还未接收到的文件块。
好,现在我们开始接收信息了哦。这时我们接收到了offset = 1
,length = 3
的一个文件块。
这时候原本的一个完整的文件块是不是就被分成了三块,分别是 还未接收到的offset = 0, length = 1
,新接收到的offset = 1, length = 3
,还未接收到的offset = 4, length = 6
。
原本的unreceivedSectonList
里只有一个元素,就是offset = 0, length = 10
的文件块,随着中间一块文件块的接收,我们是不是得把这个列表更新了?
按照上面说的,首先要把 offset = 0, length = 10
这段删掉,再把 offset = 0, length = 1
,offset = 4, length = 6
这两段还未接收到的文件块加入。
按照这个规律,基本每接收到一个新的文件块,就会将一段未接收的信息块分成三段,然后中间那段就是新的文件块的地方,代表已经接收到了,所以我们要将原来的一大块删掉,将分成的左右两段分别加入进去。
现在逻辑很清晰了,我们来看看代码如何实现。
可以看到,当unreceivedSectonList
里只有offset = 0, length = 10
的时候,我们只需要把这个取出,再把它分成三段即可。那如果已经有很多块了呢?比如第二次我们再接收一个文件块。
前面第一次插入的时候,这个文件被分成了三块,第二次插入,又将右边的这段分成了三段。
这句话听着很简单,将右边的分成了三段,那怎么知道要分的是左边的还是右边的呢?这就需要考虑代码的实现了。
不要忘记,unreceivedSectonList
是一个列表,我们只需要遍历这个列表,对于第二次插入的情况而言,遍历到的就是 offset = 0, length = 1
,offset = 4, length = 6
这两段,新接收到的文件块偏移量 offset = 6
,显然比第一段的最右边的端点 0 + 1 = 1
,还要远,所以这就可以判断一定不是在左边那段插。
经过这个search()
方法,我们就得到了要插入的片段,在这个例子中就是右边这段。
接着第二步,删掉原来的片段,也就是红框圈起来的这段,再将还剩余的两段(1, 2)加入到unreceivedSectonList
中去。
/**
* 在 {@code unreceivedSectonList} 中清除对应 {@code receivedSection} 的片段。
* 当此方法返回为true时,表明接收文件块已经完成,接收方可以关闭文件流。
*
* @param receivedSection 接收到的文件块
* @return 当 {@code unreceivedSectonList} 为空时返回true,此时说明文件块已经全
* 部接收完毕。
*/
public boolean receiveSection(SectionHeader receivedSection) {
int index = search(receivedSection.getOffset(), receivedSection.getLength());
if (index == NOT_FOUND) {
return true;
}
// 得到文件块需要插入的片段
SectionHeader unreceivedSection = this.unreceivedSectonList.get(index);
this.unreceivedSectonList.remove(index);
long rightOffset = receivedSection.getOffset() + receivedSection.getLength();
int rightLength = (int) (unreceivedSection.getOffset() + unreceivedSection.getLength() - rightOffset);
if (rightLength > 0) {
SectionHeader rightSection = new SectionHeader(this.fileId, rightOffset, rightLength);
this.unreceivedSectonList.add(index, rightSection);
}
long leftOffset = unreceivedSection.getOffset();
int leftLength = (int) (receivedSection.getOffset() - unreceivedSection.getOffset());
if (leftLength > 0) {
SectionHeader leftSection = new SectionHeader(this.fileId, leftOffset, leftLength);
this.unreceivedSectonList.add(index, leftSection);
}
return isEmpty();
}
这里没什么难的,就是算不同段的下标,要注意的是,我们应该先把右边那段插入在列表中,再插入左边那段。因为ArrayList
的插入操作会把要插入的位置之后的元素向后挪,再插入,所以先插入右边那段再插左边,右边的就会被自动挤到后面,这样列表就还是从左到右排布的。
现在完成了一个文件的还未接收文件块的信息表,我们的程序是要传送多个文件的,所以还需要再用一个集合将这个类统一管理起来。最理想的容器当然就是散列表,以文件编号为键,UnreceivedSections
类为值,建立一个ConcurrentHas