当前位置: 首页 > 科技观察

Go项目实战:一步步搭建并发文件下载器

时间:2023-03-13 13:10:35 科技观察

大家好,我是polarisxu。今天给大家带来一个实战项目。建议一定要自己做。在往下看之前,不妨想一想Go中如何实现并发下载器。01原理对于服务器上的一个文件,我们要并发下载到本地。很容易想到要把文件分成多个部分,然后开启多个goroutine并发下载,最后将多个部分合并成一个文件。达到并发下载的目的。现在的问题是,如何将服务器上的一个文件分割成多个文件呢?这需要了解HTTP协议。HTTP协议有一个响应头:Accept-Ranges,服务器通过它来标识自己支持部分请求,也叫范围请求。如果服务器支持部分请求,我们就可以实现并发下载。这个header有两个可能的值:Accept-Ranges:bytesAccept-Ranges:nonenone:不支持任何部分请求单元,因为相当于不返回这个header,所以很少使用。但是有些浏览器,比如IE9,会根据这个header禁用或者去掉下载管理器的暂停按钮。bytes:部分请求的单位是bytes(字节)。所以,我们在并发下载之前,首先要发起Head请求,确认服务器是否支持部分请求。例如:resp,err:=http.Head("https://studygolang.com/dl/golang/go1.16.5.src.tar.gz")iferr!=nil{returnerr}ifresp.StatusCode==http。StatusOK&&resp.Header.Get("Accept-Ranges")=="bytes"{//Supportpartialrequest}确认服务器支持部分请求,接下来就是如何进行部分请求了。这使用HTTP请求标头:Range。(详见:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Range)Range告诉服务器返回文件的哪一部分。在一个Range头中,一次可以请求多个部分,服务器会以多部分文件的形式返回。如果服务器返回范围响应,则应使用206PartialContent状态代码。如果请求的范围无效,服务器将返回416RangeNotSatisfiable状态码,表示客户端错误。允许服务器忽略Range头,返回整个文件,状态码为200。具体语法:Range:=-Range:=-Range:=-,-Range:=-,-,-范围使用的单位,通常是字节。一个整数,以特定单位指示范围的起始值。一个整数,以特定单位表示范围的结束值。此值是可选的,如果不存在,则表示范围扩展到文档的末尾。例如:Range:bytes=200-1000,2000-6576,19000-掌握以上知识点后,最后要做的就是将下载的部分合并成一个文件。需要注意每个部分的顺序,比如按照先后顺序编号为1、2、3等。02手工实现一个。知道原理并不代表你真的会。我们应该切实认识一个,加深认识。在本地目录下创建一个目录:downloader。$mkdirdownloader$cddownloader$gomodinitgithub.com/polaris1119/downloader命令行参数控制为了让工具更好用,我们应该支持命令行参数而不是硬编码,比如要下载的URL,并发编号,以及输出文件名等等。关于命令行参数控制,除了使用标准库flag外,我更喜欢github.com/urfave/cli,最新版本v2。创建文件main.go,内容如下:packagemainimport("log""os""runtime""github.com/urfave/cli/v2")funcmain(){//默认并发concurrencyN:=runtime.NumCPU()app:=&cli.App{Name:"downloader",Usage:"Fileconcurrencydownloader",Flags:[]cli.Flag{&cli.StringFlag{Name:"url",Aliases:[]string{"u"},Usage:"`URL`todownload",Required:true,},&cli.StringFlag{Name:"output",Aliases:[]string{"o"},Usage:"Output`filename`",},&cli.IntFlag{Name:"concurrency",别名:[]string{"n"},Value:concurrencyN,Usage:"Concurrency`number`",},},Action:func(c*cli.Context)error{returnnil},}err:=app.Run(os.Args)iferr!=nil{log.Fatal(err)}}执行gomodtidy并下载必要的包。然后执行:$gorunmain.go-hNAME:downloader-FileconcurrencydownloaderUSAGE:downloader[globaloptions]command[commandoptions][arguments...]COMMANDS:help,hShowsalistofcommandsorhelpforonecommandGLOBALOPTIONS:--urlURL,-uURURRLtodownload--outputfilename,-ofilenameOutputfilenum--concurrency--nnumberConcurrencynumber(default:8)--help,-hshowhelp(default:false)关于cli库的使用,可以参考官方文档,写的很详细,例子也很多。检查是否支持并发下载新建一个文件downloader.go,定义一个结构体Dowloader:packagemaintypeDownloaderstruct{concurrencyint}funcNewDownloader(concurrencyint)*Downloader{return&Downloader{concurrency:concurrency}}在结构体中添加Download方法:func(d*Downloader)下载(strURL,filenamestring)error{iffilename==""{filename=path.Base(strURL)}resp,err:=http.Head(strURL)iferr!=nil{returnerr}ifresp.StatusCode==http.StatusOK&&resp.Header.Get("Accept-Ranges")=="bytes"{return.multiDownload(strURL,filename,int(resp.ContentLength))}return.singleDownload(strURL,filename)}func(d*Downloader)multiDownload(strURL,filenamestring,contentLenint)error{returnil}func(d*Downloader)singleDownload(strURL,filenamestring)error{returnnil}通过Head请求,判断是否支持部分请求。在原理部分已经解释过了;如果不支持,直接下载整个文件;支持部分请求时,可以通过Head请求响应中的ContentLength获取文件总大小。有了文件总大小和并发数,就可以知道每个部分的大小了。这部分并发下载的第一点是如何发起部分请求:req,err:=http.NewRequest("GET","https://apache.claz.org/zookeeper/zookeeper-3.7.0/apache-zookeeper-3.7.0-bin.tar.gz",nil)iferr!=nil{returnerr}rangeStart:=2000rangeStop:=3000req.Header.Set("Range",fmt.Sprintf("bytes=%d-%d",rangeStart,rangeStop))res,err:=http.DefaultClient.Do(req)我们可以封装成一个方法:func(d*Downloader)downloadPartial(strURL,filenamestring,rangeStart,rangeEnd,iint){ifrangeStart>=rangeEnd{return}req,err:=http.NewRequest("GET",strURL,nil)iferr!=nil{log.Fatal(err)}req.Header.Set("范围",fmt.Sprintf("bytes=%d-%d",rangeStart,rangeEnd))resp,err:=http.DefaultClient.Do(req)iferr!=nil{log.Fatal(err)}deferresp.Body.Close()flags:=os.O_创建|os.O_WRONLYpartFile,err:=os.OpenFile(d.getPartFilename(filename,i),flags,0666)iferr!=nil{log.Fatal(err)}deferpartFile.Close()buf:=make([]byte,32*1024)_,err=io.CopyBuffer(partFile,resp.Body,buf)iferr!=nil{iferr==io.EOF{rreturn}log.Fatal(err)}}//getPartDir存放一些文件的目录func(d*Downloader)getPartDir(filenamestring)string{returnstrings.SplitN(filename,".",2)[0]}//getPartFilename构造部分文件名func(d*Downloader)getPartFilename(filenamestring,partNumint)string{partDir:=d.getPartDir(filename)returnfmt.Sprintf("%s/%s-%d",partDir,filename,partNum)}通过发起Range请求后,将请求内容写入本地文件;为了方便后续合并,文件名加上序号,这是downloadPartial最后一个参数的作用;rangeStart和rangeEnd分别代表Range的起点和终点;那么multiDownload方法是如何分块的,这和同时请求多个url很相似,使用sync.WaitGroup来控制:func(d*Downloader)multiDownload(strURL,filenamestring,contentLenint)error{partSize:=contentLen/d.concurrency//创建部分文件的存放目录partDir:=d.getPartDir(filename)os.Mkdir(partDir,0777)deferos.RemoveAll(partDir)varwgsync.WaitGroupwg.Add(d.concurrency)rangeStart:=0fori:=0;我