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

news2024/11/18 8:45:38

上传

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

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

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

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。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/160098.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

ftp vsftp 登录

打开windows资管管理器&#xff08;文件夹&#xff09;输入目标路径&#xff0c;如&#xff1a;ftp://192.168.1.1输入账号密码。 删除用户已保存的密码&#xff08;仅密码&#xff0c;名称记录还在&#xff09; 两种方法都可以试试&#xff0c;适用不同情况 情况-方法一&am…

Set、Map、类数组,傻傻区分不清楚?

前言 大家都知道&#xff0c;数组和对象是两种不同的数据结构&#xff0c;虽说在js数据类型中都属于Object&#xff0c;但是还是有一定的区别&#xff0c;通过字面量以及isArray、instanceof等方法&#xff0c;我们很好区分这两者。由于使用场景的原因js中衍生了很多类似的数据…

基于java(springboot+mybatis)网上音乐商城设计和实现以及论文报告

基于java(springbootmybatis)网上音乐商城设计和实现以及论文报告 博主介绍&#xff1a;5年java开发经验&#xff0c;专注Java开发、定制、远程、文档编写指导等,csdn特邀作者、专注于Java技术领域 作者主页 超级帅帅吴 Java毕设项目精品实战案例《500套》 欢迎点赞 收藏 ⭐留言…

Spring Boot 热部署

Spring Boot 热部署一、添加热部署框架支持二、Settings 开启项目自动编译三、开启运行中热部署四、使用 Debug 启动 (非 Run)一、添加热部署框架支持 或者右击鼠标添加依赖&#xff1a; 或者使用插件&#xff1a; 二、Settings 开启项目自动编译 三、开启运行中热部署 老版…

【数据库概论】第一章 绪论

第一章 绪论 1.1 数据库系统概述 数据库的四个基本概念 1.数据 数据是数据库中存储的基本对象&#xff0c;一般数据是描述事物的符号记录&#xff0c;这种符号记录可以输数字&#xff0c;也可以是文字、徒刑、音频等。 2.数据库 数据库是长期存储在计算机内有组织的&…

Leetcode动态规划题解

第一题 509. 斐波那契数 题目描述&#xff1a;斐波那契数&#xff08;通常用 F(n) 表示&#xff09;形成的序列称为斐波那契数列 。该数列由 0 和 1 开始&#xff0c;后面的每一项数字都是前面两项数字的和。也就是&#xff1a; F(0) 0&#xff0c;F(1) 1 F(n) F(n - 1) …

【计算机网络】计算机网络基础

计算机是人类社会不可或缺的工具&#xff0c;而单独的一台计算机的功能也是有限的&#xff0c;计算机需要和其它的设备相互连接通信形成的计算机网络才能对人类发展带来巨大的影响。 目录 计算机网络 通信协议 网络结构 网络边缘 接入网 网络核心 时延和吞吐量 时延 吞…

.Net Core6.0项目发布在IIS上访问404的问题

ASP.Net Core6.0项目发布在IIS上访问404的问题 进入线程池画面&#xff0c;将当前程序的线程池设为“无托管代码” 修改配置文件 Web.config&#xff0c;以下缺一不可 需要引用架包&#xff1a;Swashbuckle.AspNetCore.SwaggerUI.NetCore 6.0 自带集成了Swagger , 在发布项目时…

C++模板(函数模板、类模板)

目录 一、泛型编程 二、函数模板 函数模板概念 函数模板格式 函数模板的原理 函数模板的实例化 模板参数的匹配原则 三、类模板 类模板的定义格式 类模板的实例化 四、扩展 函数模板一定是推演&#xff1f;类模板一定是指定&#xff1f; 模板的分离编译 一…

MySQL高级【行级锁】

1&#xff1a;行级锁1.1&#xff1a;介绍行级锁&#xff0c;每次操作锁住对应的行数据。锁定粒度最小&#xff0c;发生锁冲突的概率最低&#xff0c;并发度最高。应用在 InnoDB存储引擎中。 InnoDB的数据是基于索引组织的&#xff0c;行锁是通过对索引上的索引项加锁来实现的&a…

WPF中Binding数据校验、并捕获异常信息的三种方式

Binding数据校验、并捕获异常信息的三种方式 WPF在使用Binding时&#xff0c;经常需要进行数据校验&#xff0c;如果校验失败需要捕获失败的原因&#xff0c;并加以展示&#xff0c;本文主要介绍数据校验异常并捕获的三种方式。 依赖属性异常捕获 先定义一个依赖属性 publi…

【Nacos】Nacos配置中心的使用与SpringCloud整合

在微服务架构中&#xff0c;当系统从一个单体应用&#xff0c;被拆分成分布式系统上一个个服务节点后&#xff0c;配置文件也必须跟着迁移&#xff08;分割&#xff09;&#xff0c;这样配置就分散了&#xff0c;不仅如此&#xff0c;分散中还包含着冗余。配置中心将配置从各应…

哪儿有微服务开源项目?

随着数字化时代的到来&#xff0c;微服务开源项目的应用价值逐渐凸显。作为提升企业办公协作效率的低代码开发平台项目&#xff0c;其表现出来的灵活性、易操作、简便的特性&#xff0c;成为现代化办公管理中的重要合作伙伴。我们今天一起来了解什么是微服务开源项目。 一、微服…

基于JavaWeb实现蜀南调味品商城物流配货系统

一、项目介绍 本文系统利用JavaWeb技术&#xff0c;设计和实现了连接公司、客户公司、物流运输为桥梁的销售配送管理系统&#xff0c;并以网络技术和信息技术在销售配送中的应用为重点&#xff0c;实现员工登录模块、员工信息管理模块、库存管理模块、订单处理模块、包装管理模…

高通Wi-Fi 7网络芯片方案IPQ9574,IPQ9554,IPQ9514,IPQ9570,IPQ9550,IPQ9510

networking pro 1620&#xff1a;芯片型号IPQ9574&#xff0c;支持4频段16路数据流&#xff0c;峰值速率33Gbps&#xff0c;支持4个2.5G口&#xff0c;1个5G口&#xff0c;1个万兆口&#xff1b;networking pro 1220&#xff1a;芯片型号IPQ9574&#xff0c;支持3频段12路数据流…

三个案例详解不同网段之间如何互通

当然还可以通过三层交换机划分VLAN配置更好。这里主要讲通过普通路由器之间互通一台路由器连接另外一台路由器&#xff0c;这两台路由器分别连接不同的网段&#xff0c;那么如果要这两个网段互通&#xff0c;则必须配置路由&#xff0c;这个就是静态路由。案例一、不同网段之间…

【练习】Day06

努力经营当下&#xff0c;直至未来明朗&#xff01; 文章目录一、选择二、编程最小时间差答案1. 选择2. 编程普通小孩也要热爱生活&#xff01; 一、选择 散列技术中的冲突是指&#xff08; &#xff09; A. 两个元素具有相同的序号 B. 两个元素的键值不同&#xff0c;而其他…

Linux权限理解

✅<1>主页&#xff1a;我的代码爱吃辣 &#x1f4c3;<2>知识讲解&#xff1a;C ☂️<3>开发环境&#xff1a;Visual Studio 2022 &#x1f4ac;<4>前言&#xff1a;linux当中对于权限的理解。 &#x1f490;一.生活中的权限 &#x1f338;二.Linux权限…

一文让你弄懂多租户数据库设计⽅案

文章目录前言一、设计方案二、方案剖析三、方案总结四、方案选型五、引申问题的解决方案六、写在最后前言 多租户是SaaS&#xff08;Software-as-a-Service&#xff09;下的一个概念&#xff0c;意思为软件即服务&#xff0c;即通过网络提供软件服务。 SaaS平台供应商将应用软…

微软的AD登录loginRedirect

我这边技术栈是reactts 如果你是vue&#xff0c;直接将tsx文件改成jsx就可以或者不该也没问题 上篇文章介绍了msal 的弹框登录&#xff0c;先介绍下重定向登录这个相对弹框登录要烦很多。。。中国内网看我查询的资料很少&#xff0c;只有微软系的公司才会有相对应的需求。此处自…