【Java项目整理】-> 多文件自平衡云传输 -> 从零搭建一个分布式中间件

Ⅰ 前言

此篇文章将详细分析一个中间件 多文件自平衡云传输(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拼接成完整的文件。
在这里插入图片描述
以此类推,当一台设备请求下载的时候,我们可以让多台机器同时给它发送文件,每台机器只用发送一部分就好。

这里我圈了朵云,当作一个局域网,同样道理,不同的局域网之间也可以这样进行文件的传输。
在这里插入图片描述
这些百分比只是一个例子,在实际的实现中会根据情况进行调整。

注意这里我们传输的文件,并不只是一个文件,也可以是多个文件,或者是包含着多级目录的文件夹,文件格式当然除了文本文件、图片还包括视频等,都可以传输。

该项目的需求大致如下:

  1. 支持资源管理,包括增删改查;
  2. 支持多客户端同时发送和接收文件;
  3. 支持服务器端参与资源发送;
  4. 可配置发送端最大数量;
  5. 支持多发送端,多文件的分片发送与接收;
  6. 支持多发送端文件接收进度条;
  7. 支持自定义分发策略,缺省时提供默认分发策略;
  8. 支持自定义发送端负载均衡策略,缺省时提供默认负载均衡策略;
  9. 支持断点续传。

和之前写的项目 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. 断点续传

既然是网络传输,自然有失败的风险。因此,我们还需要一个断点续传的功能。什么意思?就是接收方在接收的过程中,出现了异常,在下一次接收时,要接着上一次接收的内容继续接收,已经接收过的内容不能再接收第二遍。

在传输信息块的时候,我们会先发送一个信息头,这个在最开始就定义好了。
在这里插入图片描述

根据这个信息头,我们可以很轻松地得知当前信息块的信息。所以要得知一个文件的哪些块没有收到,就可以根据每次接收的信息头中的信息来统计。

比如我我们把一个文件分成了十块。

在这里插入图片描述

第一次发送了三块,SectionHeaderoffsetlength是1 和 3。
在这里插入图片描述
那么,没收到的信息块就是两块,第一块是 offset = 0, length = 1的。(图中绿色)
在这里插入图片描述

第二块的 offset = 4, length = 6。(图中蓝色)
在这里插入图片描述

所以,随着不断接收到新的文件块,我们可以不断调整未收到的信息块的偏移量和长度,每个接收的文件都维护一个UnreceivedSections,最终如果这个文件的UnreceivedSections还有片段,说明这些片段就是未接收到的,需要重新传递。

在这里插入图片描述
这个类的初始化,必须要输入所传的文件的编号,不然我们并不知道这些文件块属于哪个文件,也必须传入这个文件的总长度,以上面我画的图举例子就是 10,不知道文件大小我们也是无法知道文件块到底传完了没有。

在这里插入图片描述

这个list是一个还未接收到的文件块的列表,注意我圈起来的地方,一开始初始化的时候,因为还没有开始接收文件块,所以列表里就加入了一个文件块,就是从 0 到这个文件的末尾。下面的块分成十块是我们自己为了好看逻辑分的,在刚开始要接收这个比如大小为 10 的文件之前,那unreceivedSectonList自然也就只有这个把整个白色的块当作一个还未接收到的文件块。

在这里插入图片描述

好,现在我们开始接收信息了哦。这时我们接收到了offset = 1length = 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 = 1offset = 4, length = 6 这两段还未接收到的文件块加入。

按照这个规律,基本每接收到一个新的文件块,就会将一段未接收的信息块分成三段,然后中间那段就是新的文件块的地方,代表已经接收到了,所以我们要将原来的一大块删掉,将分成的左右两段分别加入进去。

现在逻辑很清晰了,我们来看看代码如何实现。

可以看到,当unreceivedSectonList里只有offset = 0, length = 10 的时候,我们只需要把这个取出,再把它分成三段即可。那如果已经有很多块了呢?比如第二次我们再接收一个文件块。
在这里插入图片描述
前面第一次插入的时候,这个文件被分成了三块,第二次插入,又将右边的这段分成了三段。

这句话听着很简单,将右边的分成了三段,那怎么知道要分的是左边的还是右边的呢?这就需要考虑代码的实现了。

在这里插入图片描述

不要忘记,unreceivedSectonList是一个列表,我们只需要遍历这个列表,对于第二次插入的情况而言,遍历到的就是 offset = 0, length = 1offset = 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

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值