02 July 2015
原来HTTP协议是如此的牛逼

大家都用过迅雷等下载工具,特点就是支持并发下载,断点续传。我们这里不介绍它,这个比较复杂了,逼人也不懂。本文只介绍狭义上的简易的断点续传和狭义上的多线程下载。跟之前一样,旨在研究原理,实际生活中基本没啥用,实测下来多线程下载比单线程下载还慢。。。太丢人了。

主要讲三个方面,如何HTTP的并发下载、通过Golang进行多协程开发、如何断点续传。

HTTP的并发下载

想要并发下载,就是把下载内容分块,然后并行下载这些块。这就要求服务器能够支持分块获取数据。大迅雷、电驴这种都有自己的协议,thunder://这种,我们只研究原理,就说说HTTP协议对于并发的支持。

  HTTP头   对应值   含义  
  Content-Length   14247   HTTP响应的Body大小,下载的时候,Body就是文件,也可以认为是文件大小,单位是比特  
  Content-Disposition   inline; filename=”bryce.jpg”   是MIME协议的扩展,MIME协议指示MIME用户代理如何显示附加的文件。当浏览器接收到头时,它会激活文件下载。这里还包含了文件名  
  Accept-Ranges   bytes   允许客户端以bytes的形式获取文件  
  Range   bytes=0-511   分块获取数据,这里表示获取第0到第511的数据,共512字节  

如果要下载一个文件,想知道这些文件的信息,例如文件名、文件大小、是否支持并发下载、文件类型都可以从响应的头里面获取。如何在下载前获得到这些内容而不是下载中获取,可以用HTTP提供的HEAD方法。HEAD方法只响应HTTP的头部分,不包含Body部分。

req, err := http.NewRequest("HEAD", get.Url, nil)
resp, err := get.GetClient.Do(req)

获取文件类型、文件名等参数。HTTP从Url到Head在到Body,你都可以认为是字符串,也确实是字符串,但是解析的时候不要自己以字符串的方式处理,要不恶心死你。Url的解析大Golang有net/url包支持,MIME有mime包支持,这都是原生包,别的语言必然也支持。

get.ContentLength = int(resp.ContentLength)
get.MediaType, get.MediaParams, _ = mime.ParseMediaType(get.Header.Get("Content-Disposition"))
log.Printf("Get %s MediaType:%s, Filename:%s, Length %d.\n", get.Url, get.MediaType, get.MediaParams["filename"], get.ContentLength)

输出

2015/07/02 09:56:47 Get https://res.cloudinary.com/cyeam/image/upload/v1537933530/cyeam/bryce.jpg MediaType:inline, Filename:bryce.jpg, Length 14247.

如果响应头里面还包含了Accept-Ranges,就说明服务器支持分块获取:

if get.Header.Get("Accept-Ranges") != "" {
	log.Printf("Server %s support Range by %s.\n", get.Header.Get("Server"), get.Header.Get("Accept-Ranges"))
} else {
	log.Printf("Server %s doesn't support Range.\n", get.Header.Get("Server"))
}

分模块下载,新建N个临时文件,我的命名规则是加个分块区间的后缀,例如bryce.jpg.0-512,这样可以省掉一个配置文件(主要是我懒的写)。将下载好的块存入临时文件里面,最后都下载完之后统一存入最终的文件里面。分块下载加个Range头就可以了。

range_i := fmt.Sprintf("%d-%d", get.DownloadRange[i][0], get.DownloadRange[i][1])
log.Printf("Download #%d bytes %s.\n", i, range_i)

defer get.TempFiles[i].Close()

req, err := http.NewRequest("GET", get.Url, nil)
req.Header.Set("Range", "bytes="+range_i)
resp, err := get.GetClient.Do(req)
defer resp.Body.Close()

最后将下载好的保持到文件里。这里是等这个块都下载完之后再写入硬盘,下载完之后都是保持在内存里面。

cnt, err := io.Copy(get.TempFiles[i], resp.Body)

多线程开发

并发下载的时候,要启动N个协程,主线程这时需要阻塞,等待这N个协程下载完毕。先开始想用channel自己写,不是特别会。。。用sync包辅助实现,它支持WaitGroup,正好可以解决我这里的问题。

在主线程里面启动N个协程,Add方法可以理解成增加一个任务,任务计数器加一;Wait方法用于阻塞,指导所有任务完成。

for i, _ := range get.DownloadRange {
	get.WG.Add(1)
	go get.Download(i)
}
get.WG.Wait()

在下载协程增加Done函数,协程结束之后通知任务完成,任务计数器减一。

defer get.WG.Done()

断点续传

这块最简单,如果任务下载暂停了,就是传输的内容不足。第一步创建临时文件的文件名后缀有派上用场了,读取到块应有的大小之后,检查块实际大小。通过文件的os.FileInfo就能获取到文件相关属性信息。这样再下载的时候就增加一个偏移量,跳过已经下载好的内容。

for i := 0; i < len(get.DownloadRange); i++ {
	range_i := fmt.Sprintf("%d-%d", get.DownloadRange[i][0], get.DownloadRange[i][1])
	temp_file, err := os.OpenFile(get.FilePath+"."+range_i, os.O_RDONLY|os.O_APPEND, 0)
	if err != nil {
		temp_file, _ = os.Create(get.FilePath + "." + range_i)
	} else {
		fi, err := temp_file.Stat()
		if err == nil {
			get.DownloadRange[i][0] += int(fi.Size())
		}
	}
	get.TempFiles = append(get.TempFiles, temp_file)
}

大概简单的原理就是这些,前面说了,比项目无法用于实际用途,原因如下:

  1. 需要有一个线程池来并发下载,目前的设计在下载大文件时会导致并发数过大。已经完成下载的线程还能继续下载未完成的块,这个又涉及到下任务的动态分配和动态拆分;
  2. 并发下载时的块大小,这个值也很讲究,一般4096和字节是一个内存块单位,以此数字的倍数下载比较节约内存,目前我这里的块大小是根据并发数计算的,比较水;
  3. 前面提到了,这是狭义的多线程下载,前提是服务器必须支持Range,否则还是无法并发获取数据,实际在做的时候最起码是会有几台下载完的服务器,这样自己的服务器就能开发支持分块取数据的协议来支持并发下载,市面上的也都是这么干;
  4. 还有下载进度条,就是类似于wget命令在控制台展示进度条和下载速度的日志,这个不太会写,查了查,据说可以通过fmt.Println("abc\rcde")实现,\r表示回车符,可以回到行首,我没试过,大家可以试试。

本文所涉及到的完整源码请参考


参考文献
  1. http协议 文件下载原理及多线程断点续传 - zhuhuiby
  2. Package sync - The Go Programming Language
  3. How to update command line output? - stackoverflow

原文链接:Golang实现多线程并发下载,转载请注明来源!

EOF