高并发系统设计 -- 大文件业务

上传

  • 分片
  • 断点
  • 秒传(判断文件哈希值)

前端不断的发送请求,如果用户暂停上传的话,那么就是前端停止发送请求就可以了。我分片了,而且记录了分片的相关信息,所以实现了断点功能。

前端把文件进行分片,然后用多次请求发给后端,请求的内容里面包含了文件的很多相关信息。

public class MultipartFileParam {
    // 用户id
    private String uid;
    // 任务ID
    private String id;
    // 总分片数量
    private int chunks;
    // 当前为第几块分片
    private int chunk;
    // 当前分片大小
    private long size = 0L;
    // 文件名
    private String name;
    // 分片对象
    private MultipartFile file;
    // MD5值
    private String md5;
}

我们约定常量key,也就是redis里面的key:

/**
 * 常量表
 */
const (
	// FileMd5Key 保存文件所在的路径 eg:FILE_MD5:468s4df6s4a
	FileMd5Key = "FILE_MD5:"
	// FileUploadStatus 保存上传文件的状态
	FileUploadStatus = "FILE_UPLOAD_STATUS"
)

约定枚举类:

/**
 * 1 开头为判断文件在系统的状态
 */
const (
	// IsHave 文件以及存在了
	IsHave = 160
	// NoHave 该文件没有上传过
	NoHave = 161
	// IngHave 该文件上传了一部分
	IngHave = 162
)

FILE_UPLOAD_STATUS里面存放的值是false或者true,如果是false的话就说明文件上传没有完成,如果是true的话就说明文件的上传已经完成了,我们的系统里面保存有这个文件。

判断文件的上传状态以及获取文件还有哪些分片还有被完成:

@PostMapping("/checkFileMd5")
public Object checkFileMd5(String md5, int chunks) {
    // 从redis里面获取这个文件的相关信息
    Object o = stringRedisTemplate.opsForHash().get(Constants.FILE_UPLOAD_STATUS, md5);
    if (o == null) {
        // 如果这里取出来是空的话,就说明这个文件是还没有开始上传的
        return new ResultVo<>(ResultStatus.NO_HAVE);
    }
    String str = o.toString();
    boolean proccessing = Boolean.parseBoolean(str);
    if (proccessing == true) {
        // 这样的话就说明文件已经上传完成了
        return new ResultVo<>(ResultStatus.IS_HAVE);
    } else {
        // 这里就说明文件还没有上传完成,我们需要给前端返回有哪些块儿没有上传完成
        List<String> missChunk = new ArrayList<>();
        // 我们需要从redis里面的相关信息中读取到这个文件还有哪些没有被读取完成的
        // 如果bitmap的值是1,那么就说明你已经完成了,否则就说明你是没有完成的
        String key = Constants.FILE_PROCESSING_STATUS + md5;
        List<Long> list = stringRedisTemplate.opsForValue().bitField(key, BitFieldSubCommands.create()
                .get(BitFieldSubCommands.BitFieldType.unsigned(10)).valueAt(0));
        Long num = list.get(0);
        List<String> missChunkList = new ArrayList<>();
        int i = 0;
        while (i < chunks) {
            // 让num & 1,如果答案是0的话就说明编号为1的地方是还没有上传成功的,如果是1的话就说明已经上传成功了
            if ((num & 1) == 0) {
                missChunkList.add(String.valueOf(i));
            }
            // 把num向右边移动
            num = num >> 1;
            i++;
        }
        return new ResultVo<>(ResultStatus.ING_HAVE, missChunkList);
    }
}

这里是基于bitmap去做的,如果位是1,那么就代表这个分片以及上传完成了,如果位是0表示这个分片还没有上传完成。

但是我们也可以创建一个.conf文件,把相关信息使用.conf文件记录下来也可以。当比特位的值是Byte.MAX_VALUE的时候就代表着这个位置的分片是已经上传完成了。

很显然使用bitmap的方式是比后者使用.conf文件要更加优秀的,但是由于前后端合作出现了问题,所以我们暂时使用前者的方法。

@RequestMapping(value = "checkFileMd5", method = RequestMethod.POST)
@ResponseBody
public Object checkFileMd5(String md5) throws IOException {
    // 从redis之中获取MD5信息
    Object processingObj = stringRedisTemplate.opsForHash().get(Constants.FILE_UPLOAD_STATUS, md5);
    if (processingObj == null) {
        return new ResultVo(ResultStatus.NO_HAVE);
    }
    String processingStr = processingObj.toString();
    boolean processing = Boolean.parseBoolean(processingStr);
    String value = stringRedisTemplate.opsForValue().get(Constants.FILE_MD5_KEY + md5);
    if (processing) {
        return new ResultVo(ResultStatus.IS_HAVE, value);
    } else {
        File confFile = new File(value);
        byte[] completeList = FileUtils.readFileToByteArray(confFile);
        List<String> missChunkList = new LinkedList<>();
        for (int i = 0; i < completeList.length; i++) {
            if (completeList[i] != Byte.MAX_VALUE) {
                missChunkList.add(i + "");
            }
        }
        return new ResultVo<>(ResultStatus.ING_HAVE, missChunkList);
    }
}

接下来就是文件的正式上传过程:

/**
 * 上传文件
 *
 * @param param
 * @param request
 * @return
 * @throws Exception
 */
@RequestMapping(value = "/fileUpload", method = RequestMethod.POST)
@ResponseBody
public ResponseEntity fileUpload(MultipartFileParam param, HttpServletRequest request) {
    // 用于检测是否是一个文件上传的请求,如果是true,那么就说明是一个带上了文件的表单,如果是false就说明是一个普通表单
    boolean isMultipart = ServletFileUpload.isMultipartContent(request);
    if (isMultipart) {
        logger.info("上传文件start!");
        try {
            // 方法1
            storageService.uploadFileRandomAccessFile(param);
        } catch (IOException e) {
            e.printStackTrace();
            logger.error("文件上传失败, {}", param.toString());
        }
        logger.info("上传文件end!");
    }
    return ResponseEntity.ok().body("上传成功!");
}

这个属于是核心方法。

@Override
public void uploadFileRandomAccessFile(MultipartFileParam param) throws IOException {
    String fileName = param.getName();
    String tempDirPath = finalDirPath + param.getMd5();
    String tempFileName = fileName + "_tmp";
    File tmpDir = new File(tempDirPath);
    File tmpFile = new File(tempDirPath, tempFileName);
    if (!tmpDir.exists()) {
        tmpDir.mkdirs();
    }

    RandomAccessFile accessTmpFile = new RandomAccessFile(tmpFile, "rw");
    long offset = CHUNK_SIZE * param.getChunk();
    // 定位到该分片的偏移量
    accessTmpFile.seek(offset);
    // 写入该分片数据
    accessTmpFile.write(param.getFile().getBytes());
    // 释放
    accessTmpFile.close();

    boolean isOk = checkAndSetUploadProgress(param, tempDirPath);
    if (isOk) {
        boolean flag = renameFile(tmpFile, fileName);
        System.out.println("upload complete !!" + flag + " name=" + fileName);
    }
}

文件重命名:

/**
 * 文件重命名
 *
 * @param toBeRenamed   将要修改名字的文件
 * @param toFileNewName 新的名字
 * @return
 */
public boolean renameFile(File toBeRenamed, String toFileNewName) {
    // 检查要重命名的文件是否存在,是否是文件
    if (!toBeRenamed.exists() || toBeRenamed.isDirectory()) {
        log.info("File does not exist: " + toBeRenamed.getName());
        return false;
    }
    String p = toBeRenamed.getParent();
    File newFile = new File(p + File.separatorChar + toFileNewName);
    // 修改文件名
    return toBeRenamed.renameTo(newFile);
}

检查并修改文件上传进度

/**
 * 检查并修改文件上传进度
 *
 * @param param
 * @param uploadDirPath
 * @return
 * @throws IOException
 */
private boolean checkAndSetUploadProgress(MultipartFileParam param, String uploadDirPath) throws IOException {
    String fileName = param.getName();
    File confFile = new File(uploadDirPath, fileName + ".conf");
    RandomAccessFile accessConfFile = new RandomAccessFile(confFile, "rw");
    // 把该分段标记为 true 表示完成
    System.out.println("set part " + param.getChunk() + " complete");
    accessConfFile.setLength(param.getChunks());
    accessConfFile.seek(param.getChunk());
    accessConfFile.write(Byte.MAX_VALUE);

    // completeList 检查是否全部完成,如果数组里是否全部都是(全部分片都成功上传)
    byte[] completeList = FileUtils.readFileToByteArray(confFile);
    byte isComplete = Byte.MAX_VALUE;
    for (int i = 0; i < completeList.length && isComplete == Byte.MAX_VALUE; i++) {
        // 与运算, 如果有部分没有完成则 isComplete 不是 Byte.MAX_VALUE
        isComplete = (byte) (isComplete & completeList[i]);
        System.out.println("check part " + i + " complete?:" + completeList[i]);
    }

    accessConfFile.close();
    if (isComplete == Byte.MAX_VALUE) {
        stringRedisTemplate.opsForHash().put(Constants.FILE_UPLOAD_STATUS, param.getMd5(), "true");
        // 然后我们需要把这个conf文件删除掉
        String value = stringRedisTemplate.opsForValue().get(Constants.FILE_MD5_KEY + param.getMd5());
        File conf = new File(value);
        conf.delete();
        stringRedisTemplate.opsForValue().set(Constants.FILE_MD5_KEY + param.getMd5(), uploadDirPath + "/" + fileName);
        return true;
    } else {
        // 如果你不存在这个哈希key的话,就顺便的创造这个哈希的值,如果存在的话,反正里面是false,就直接跳过就可以了
        if (!stringRedisTemplate.opsForHash().hasKey(Constants.FILE_UPLOAD_STATUS, param.getMd5())) {
            stringRedisTemplate.opsForHash().put(Constants.FILE_UPLOAD_STATUS, param.getMd5(), "false");
        }
        // 如果不存在的话就创建这个东西
        if (!stringRedisTemplate.hasKey(Constants.FILE_MD5_KEY + param.getMd5())) {
            stringRedisTemplate.opsForValue().set(Constants.FILE_MD5_KEY + param.getMd5(), uploadDirPath + "/" + fileName + ".conf");
        }
        return false;
    }
}

下载

实际上需要客户端和服务器:我的用户发送一个请求,说要下载,然后请求是发送给客户端的,客户端先去问服务器支不支持断点下载,如果支持的话就不断的发送请求给服务器实现断点下载。因此我的项目需要实现的有客户但,以及服务器。

Go进阶49:HTTP断点续传多线程下载原理

一个比较常见的场景,就是断点续传/下载,在网络情况不好的时候,可以在断开连接以后,仅继续获取部分内容. 例如在网上下载软件,已经下载了 95% 了,此时网络断了,如果不支持范围请求,那就只有被迫重头开始下载.但是如果有范围请求的加持,就只需要下载最后 5% 的资源,避免重新下载.

另一个场景就是多线程下载,对大型文件,开启多个线程, 每个线程下载其中的某一段,最后下载完成之后, 在本地拼接成一个完整的文件,可以更有效的利用资源.

一图胜千言

Golang HTTP Range Request

2. Range & Content-Range

HTTP1.1 协议(RFC2616)开始支持获取文件的部分内容,这为并行下载以及断点续传提供了技术支持. 它通过在 Header 里两个参数实现的,客户端发请求时对应的是 Range ,服务器端响应时对应的是 Content-Range.

$ curl --location --head 'https://download.jetbrains.com/go/goland-2020.2.2.exe'
date: Sat, 15 Aug 2020 02:44:09 GMT
content-type: text/html
content-length: 138
location: https://download-cf.jetbrains.com/go/goland-2020.2.2.exe
server: nginx
strict-transport-security: max-age=31536000; includeSubdomains;
x-frame-options: DENY
x-content-type-options: nosniff
x-xss-protection: 1; mode=block;
x-geocountry: United States
x-geocode: US

HTTP/1.1 200 OK
Content-Type: binary/octet-stream
Content-Length: 338589968
Connection: keep-alive
x-amz-replication-status: COMPLETED
Last-Modified: Wed, 12 Aug 2020 13:01:03 GMT
x-amz-version-id: p7a4LsL6K1MJ7UioW7HIz_..LaZptIUP
Accept-Ranges: bytes
Server: AmazonS3
Date: Fri, 14 Aug 2020 21:27:08 GMT
ETag: "1312fd0956b8cd529df1100d5e01837f-41"
X-Cache: Hit from cloudfront
Via: 1.1 8de6b68254cf659df39a819631940126.cloudfront.net (CloudFront)
X-Amz-Cf-Pop: PHX50-C1
X-Amz-Cf-Id: LF_ZIrTnDKrYwXHxaOrWQbbaL58uW9Y5n993ewQpMZih0zmYi9JdIQ==
Age: 19023

Range

The Range 是一个请求首部,告知服务器返回文件的哪一部分. 在一个 Range 首部中,可以一次性请求多个部分,服务器会以 multipart 文件的形式将其返回. 如果服务器返回的是范围响应,需要使用 206 Partial Content 状态码. 假如所请求的范围不合法,那么服务器会返回 416 Range Not Satisfiable 状态码,表示客户端错误. 服务器允许忽略 Range 首部,从而返回整个文件,状态码用 200 .Range:(unit=first byte pos)-[last byte pos]

Range 头部的格式有以下几种情况:

Range: <unit>=<range-start>-
Range: <unit>=<range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>, <range-start>-<range-end>

Content-Range

假如在响应中存在 Accept-Ranges 首部(并且它的值不为 “none”),那么表示该服务器支持范围请求(支持断点续传). 例如,您可以使用 cURL 发送一个 HEAD 请求来进行检测.curl -I http://i.imgur.com/z4d4kWk.jpg

HTTP/1.1 200 OK
...
Accept-Ranges: bytes
Content-Length: 146515

在上面的响应中, Accept-Ranges: bytes 表示界定范围的单位是 bytes . 这里 Content-Length 也是有效信息,因为它提供了要检索的图片的完整大小.

如果站点未发送 Accept-Ranges 首部,那么它们有可能不支持范围请求.一些站点会明确将其值设置为 “none”,以此来表明不支持.在这种情况下,某些应用的下载管理器会将暂停按钮禁用.

Run go run main.go
2020/08/15 02:15:31 开始[9]下载from:376446150 to:418273495
2020/08/15 02:15:31 开始[0]下载from:0 to:41827349
2020/08/15 02:15:31 开始[1]下载from:41827350 to:83654699
2020/08/15 02:15:31 开始[5]下载from:209136750 to:250964099
2020/08/15 02:15:31 开始[6]下载from:250964100 to:292791449
2020/08/15 02:15:31 开始[7]下载from:292791450 to:334618799
2020/08/15 02:15:31 开始[2]下载from:83654700 to:125482049
2020/08/15 02:15:31 开始[8]下载from:334618800 to:376446149
2020/08/15 02:15:31 开始[4]下载from:167309400 to:209136749
2020/08/15 02:15:31 开始[3]下载from:125482050 to:167309399
2020/08/15 02:15:36 开始合并文件
2020/08/15 02:15:38 文件SHA-256校验成功

 文件下载完成耗时: 7.169149 second

代码实现

// 文件分片
type filePart struct {
   Index int    // 文件分片的序号
   From  int    // 开始的byte
   To    int    // 结束的byte
   Data  []byte // http 下载得到的文件内容
}

// FileDownloader 文件下载器
type FileDownloader struct {
   fileSize       int
   url            string
   outputFileName string
   totalPart      int // 下载线程
   outputDir      string
   doneFilePart   []filePart
}

func NewFileDownloader(url, outputFileName, outputDir string, totalPart int) *FileDownloader {
   return &FileDownloader{
      fileSize:       0,
      url:            url,
      outputFileName: outputFileName,
      totalPart:      totalPart,
      outputDir:      outputDir,
      doneFilePart:   make([]filePart, totalPart),
   }
}

func (h *HandlerUser) DownLoadFile(ctx *gin.Context) {
   outputFileName := ctx.Query("filename")
   outputDir := ctx.Query("dir")
   // 我上传在考研云的文件也需要生成上传和下载的URL
   url := ctx.Query("url")
   downloader := NewFileDownloader(url, outputFileName, outputDir, common.DownloadChunks)
   if err := downloader.Run(); err != nil {
      log.Println(err)
   }
   ctx.JSON(http.StatusOK, "文件下载完成")
}

// 创建一个request
func (d *FileDownloader) getNewRequest(method string) (*http.Request, error) {
   r, err := http.NewRequest(method, d.url, nil)
   if err != nil {
      return nil, err
   }
   r.Header.Set("User-Agent", "lxy")
   return r, nil
}

// 获取要下载的文件的基本信息
func (d *FileDownloader) head() (int, error) {
   r, err := d.getNewRequest("HEAD")
   if err != nil {
      return 0, err
   }
   resp, err := http.DefaultClient.Do(r)
   if err != nil {
      return 0, err
   }
   if resp.StatusCode > 299 {
      return 0, errors.New(fmt.Sprintf("Can't process, response is %v", resp.StatusCode))
   }
   // 检查是否支持断点续传
   if resp.Header.Get("Accept-Ranges") != "bytes" {
      return 0, errors.New("服务器不支持断点续传")
   }
   return strconv.Atoi(resp.Header.Get("Content-Length"))
}

// Run 开始下载任务
func (d *FileDownloader) Run() error {
   fileTotalSize, err := d.head()
   if err != nil {
      log.Println(err)
   }
   d.fileSize = fileTotalSize

   jobs := make([]filePart, d.totalPart)
   eachSize := fileTotalSize / d.totalPart

   for i := range jobs {
      jobs[i].Index = i
      if i == 0 {
         jobs[i].From = 0
      } else {
         jobs[i].From = jobs[i-1].To + 1
      }
      if i < d.totalPart-1 {
         jobs[i].To = jobs[i].From + eachSize
      } else {
         // the last filePart
         jobs[i].To = fileTotalSize - 1
      }
   }

   var wg sync.WaitGroup
   for _, j := range jobs {
      wg.Add(1)
      go func(job filePart) {
         defer wg.Done()
         err := d.downloadPart(job)
         if err != nil {
            log.Println("下载文件失败: ", err, job)
         }
      }(j)
   }
   wg.Wait()
   return d.mergeFileParts()
}

// 下载分片
func (d *FileDownloader) downloadPart(c filePart) error {
   r, err := d.getNewRequest("GET")
   if err != nil {
      log.Println(err)
   }
   log.Printf("开销下载[%d]下载from:%d to: %d\n", c.Index, c.From, c.To)
   r.Header.Set("Range", fmt.Sprintf("bytes=%v-%v", c.From, c.To))
   resp, err := http.DefaultClient.Do(r)
   if err != nil {
      log.Println(err)
   }
   if resp.StatusCode > 299 {
      return errors.New(fmt.Sprintf("服务器错误状态码: %v", resp.StatusCode))
   }
   defer resp.Body.Close()
   bs, err := ioutil.ReadAll(resp.Body)
   if err != nil {
      log.Println(err)
   }
   if len(bs) != (c.To - c.From + 1) {
      return errors.New("下载文件分片长度错误")
   }
   c.Data = bs
   d.doneFilePart[c.Index] = c
   return nil
}

func (d *FileDownloader) mergeFileParts() error {
   log.Println("开始合并文件")
   path := d.outputDir + d.outputFileName
   mergedFile, err := os.Create(path)
   if err != nil {
      log.Println(err)
   }
   defer mergedFile.Close()
   totalSize := 0
   for _, s := range d.doneFilePart {
      mergedFile.Write(s.Data)
      totalSize += len(s.Data)
   }
   if totalSize != d.fileSize {
      return errors.New("文件不完善")
   }
   return nil
}

进阶版实现 - 比上面的稍微高级一点

1.实现的功能点

  • 支持批量上传下载文件,并进行md5值校验;
  • 支持查看文件列表;
  • 支持断点续传和下载文件;
  • 支持大文件切片上传和大文件切片下载;
  • 支持分片失败重传和失败重下载;
  • 支持控制每个文件上传和下载的最大goroutine数量;

2.实现设计

2.1.总体上传下载文件结构图

img

2.2.上传文件流程图

img

流程概述:

(1)上传文件时首先判断文件是否大于1M,如果小于1M则没必要进行分片,直接整个文件通过HTTP请求发送到Server端进行保存;

(2)如果文件大于1M,则首先判断该文件是不是上传了一部分的文件,这个通过查找当前目录下有没有对应元数据文件来判断,如果没有则是全新要上传的文件,否则是需要断点续传的文件;

(3)如果是全新要上传的文件,则会先对该文件进行计算,比如该文件能被切分成多个个分片,生成上传uuid,作为唯一上传标识,并把这些数据保存到本地文件(创建一个隐藏文件,后缀加上.uploading);

(4)同时还需向服务端先发送一个请求,让其先在保存目录创建一个uuid的目录,用来待会保存分片文件使用,同时服务端也会生成一个元数据文件;

(5)如果是需要断点续传的文件,则需要请求服务端获取该文件还缺少哪些文件分片没有上传(服务端只需从保存目录中已保存的序号结合文件元数据即可识别到哪些序号还没上传),并将这些切片序号发送回给客户端,假设有1,5,6,7,没发送,则服务端只需要发送1,5,-1即可标识,1分片和5到最后一个分片都没上传成功;

(6)在上传前会先启动一个goroutine,专门用来重传失败分片的,它会不断的从RetryChannel通道中读分片数据,如果没有则阻塞,如果有则重传该分片,如果再失败则再发送到通道中,可以看做当队列使用;

(7)开始读文件,如果是续传的,还可以根据续传情况进行偏移量偏移,跳过指定的切片段,读时每次只读1M,然后判断该切片是否已被上传过,如果上传过则无须再上传,可直接跳过,否则就创建一个goroutine进行异步上传;

(8)当所有goroutine都运行完成表示切片都上传完成了则发送请求告诉服务端切片已上传完成,服务端也会把文件状态置为active。

2.3.下载文件流程图

img

流程概述:实现思路跟上传相似,这里不再概述。

2.4.核心的设计点

(1)文件分片

将大文件进行分片,定义分片规格为1M,比如有5M大小的文件,那么就会分成文件名为0,1,2,3,4,5这6个文件,上传到服务端后,服务端会创建一个uuid的文件夹用来保存这5个分片文件,并且会记录一个元数据文件,里面保存着该元数据文件对应哪个目录,文件切片大小、文件大小和文件md5等原信息。对于小于1M的普通文件则不进行切片处理,就是正常的一个文件,所以我程序里规定了文件名为.slice结尾的文件则为切片文件,否则为普通文件。

(2)断点续传和断点续载

首先是断点续传,在开始传文件时,我会创建一个隐藏文件作为上传元数据文件,比如文件名是file.txt,则我创建的元数据文件名为.file.txt.uploading,里面记录着文件元数据信息,比如上传UUID、文件大小和文件md5等信息,如果用户上传完成,则起最后会被删除掉,表示整个文件都上传完成了,假设上传过程中出现了中断,则下次重新上传该文件时我检测到该隐藏文件就知道它是还未上传完整的文件,会先去服务端请求看缺少哪些分片数据,比如该文件一共有1,2,3,4,5片,服务端响应回来说只收到了1,3,5片,那么待会我就只需要把0,2,4片重传一次即可。

断点续载同理,也是需要在客户端维护元数据,且通过查找已下载的分片来找出未下载的分片序号,然后只需要重新下载没有的分片即可。

(3)失败重传

上传器和下载器的结构体定义我都会定义一个RetryChannel,这是一个分片结构体类型的通道,当分片上传或下载失败时,会将分片发送到这个通道,在上传或下载开始时我都会启动一个goroutine,专门负责从这个通道读数据,读到了就对这个分片进行重新上传或下载。

(4)并发上传或下载

并发使用了go的goroutine,并发单位以文件切片为单位,同时通过通道(申请有数量限制的通道)的方式控制运行的goroutine的数量,同时采用go里的同步信号量来控制是否所有goroutine都运行完成了。

2.5.项目代码结构

客户端目录结构:

img

上传器结构体定义:

// Uploader 上传器
type Uploader struct {
    common.FileMetadata             // 文件元数据
    common.SliceSeq                 // 需要重传的序号
    waitGoroutine   sync.WaitGroup  // 同步goroutine
    NewLoader       bool            // 是否是新创建的上传器
    FilePath        string          // 上传文件路径
    SliceBytes      int             // 切片大小
    RetryChannel    chan *FilePart  // 重传channel通道
    MaxGtChannel    chan struct{}   // 限制上传的goroutine的数量通道
    StartTime       int64           // 上传开始时间
}

下载器结构体定义:

// Downloader 下载器
type Downloader struct {
    common.FileMetadata                 // 文件元数据
    common.SliceSeq                     // 需要重传的序号
    waitGoroutine   sync.WaitGroup      // 同步goroutine
    DownloadDir     string              // 下载文件保存目录
    RetryChannel    chan int            // 重传channel通道
    MaxGtChannel    chan struct{}       // 限制上传的goroutine的数量通道
    StartTime       int64               // 下载开始时间
}

服务端目录结构:

img

文件的传输采用的是HTTP协议,服务端的工作主要是起一个HTTP Server,然后监听对应URL,绑定对应的响应方法,同时把接收到的文件数据保存到指定目录下。

3.使用示例

使用方法可用–help查看:

img

上传、列出和下载使用方法示例(图中的下载路径和文件路径可以自行修改,且确保服务端FtpServer目录下的etc目录下的config.json文件里指定的StoreDir指定目录存在):

服务端先启动:进入项目目录,执行:go run main.go

上传文件示例:

客户端运行:

img

服务端响应:

img

列出文件列表示例:

img

下载文件示例:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6pGIhxnL-1673588682953)(null)]

4.可改进点

当然,这个小项目可改进点还有很多,我这里列出几个我想到的:

(1)需重传的序号计算算法还可以实现的更好点,比如还没传的,可以用1-3,5-9这样来表示;

(2)当前的md5计算是计算整个文件的,但其实可以给每个分片都赋予一个md5,这样就不用再最后累计一边整个文件的md5,降低读IO次数;

(3)下载文件时,其实可以开辟一个指定size的空洞文件,然后接收到文件分片可以按照偏移量写到给新文件中,避免了最后一步的合并过程的IO。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

胡桃姓胡,蝴蝶也姓胡

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值