15

HTTP 上传下载实现

 8 months ago
source link: https://liqiang.io/post/upload-and-download-with-http-protocol
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

HTTP 上传下载实现

@POST· 2023-08-20 08:33 · 69 min read

在互联网中,上传和下载都是非常常见的操作,而且不同于内网环境,通常这些操作都是通过 HTTP 协议实现的,所以在这篇文章中我尝试解释一下 HTTP 常用的上传和下载的实现,并且在协议层尝试解释一下 HTTP 的实现原理。

在 HTTP 中,我们要上传文件的时候一般都是通过 Form 实现的,对于 Form 有个属性可以用于定义提交的编码方式:enctype,它的可选值有:

  • application/x-www-form-urlencoded: 默认的类型.
  • multipart/form-data: 允许通过 file 类型的 <input> 用于上传文件.
  • text/plain: 不常用的方式,直接将数据传输到后端
图 1:multipart/form-data
22d229f316ba.png
图 2:text/plain
ff006f42176a.png

application/x-www-form-urlencoded 的作用是将表单的值进行 url 编码,然后放入 body 中,这种方式就不推荐了,效率很低,处理也比较麻烦。

此外,还有一种常用的方法是直接上传二进制,但是和 multipart/form-data 的区别就是这种方式一般不能传递文件名,并且一般只能传输一个文件(当然你可以自定义协议传输多个,但是一般只传一个):

图 3:直接上传二进制
1e141b6ab8ef.png

HTTP 协议其实不存在真正意义分片上传,但是我们可以做到的一个效果就是不需要一次性将文件都读取完全,然后再上传;我们可以先读一部分的文件,然后写到 HTTP 连接中,然后继续读接下来的部分,然后继续写入 HTTP 连接中,伪代码就是:



  1. 1. create http upload connection
  2. 2. write HTTP Content-Type & Content-Length
  3. 3. write Body: "----------------------------651775247284855567875859^M
  4. Content-Disposition: form-data; name="file"; filename="Xnip2023-05-09_07-19-21.jpg"^M
  5. Content-Type: image/jpeg"
  6. 4. read part of the source file
  7. 5. write data read from step 4
  8. 6. repeat 4 & 5 until finish read and write


  1. [[email protected]]# cat upload.go
  2. func uploadHandle(w http.ResponseWriter, req *http.Request) {
  3. for k, vs := range req.Form {
  4. fmt.Printf("req.Form[%s]: %+v\n", k, vs)
  5. }
  6. for k, vs := range req.PostForm {
  7. fmt.Printf("req.PostForm[%s]: %+v\n", k, vs)
  8. }
  9. fmt.Printf("req.MultipartForm: %+v\n", req.MultipartForm)
  10. bytes, err := io.ReadAll(req.Body)
  11. if err != nil {
  12. w.WriteHeader(http.StatusBadRequest)
  13. w.Write([]byte(err.Error()))
  14. return
  15. }
  16. fmt.Printf("req.GetBody()[:200]: %s\n", bytes[:200])
  17. fmt.Printf("req.GetBody(): %d bytes\n", len(bytes))
  18. }

当我通过 multipart/form-data 上传的时候,输出的结果为:



  1. [[email protected]]# go run ./main.go
  2. 2023/08/19 11:34:36 Listening on :3000...
  3. req.MultipartForm: <nil>
  4. req.GetBody()[:200]: ----------------------------683958435590837571575629
  5. Content-Disposition: form-data; name="file"; filename="Xnip2023-05-09_07-19-21.jpg"
  6. Content-Type: image/jpeg
  7. ����JFIF����tExifMM
  8. req.GetBody(): 279014 bytes
属性/方法 实现方式 读取 URL 读取 Body 支持文本 支持二进制
Form ParseForm()
PostForm ParseForm()
FormValue() 自动调用 ParseForm()
PostFormValue() 自动调用 ParseForm()
MultipartForm ParseMultipartForm()
FormFile() 自动调用 ParseMultipartForm

chunked

Data is sent in a series of chunks. The Content-Length header is omitted in this case and at the beginning of each chunk you need to add the length of the current chunk in hexadecimal format, followed by ‘\r\n’ and then the chunk itself, followed by another ‘\r\n’. The terminating chunk is a regular chunk, with the exception that its length is zero. It is followed by the trailer, which consists of a (possibly empty) sequence of header fields.

HTTP 协议支持服务端进行流式响应,所谓的流式响应就是说当服务端不能或者不想一次就将要响应的内容准备好的时候,服务端可以先响应部分的内容,然后后续可以不断地补充响应,直到内容完成响应,时序差不多是这样的:



  1. client ------download_file-------> server
  2. client <----------part-1------------- server
  3. client <----------part-2------------- server
  4. client <----------part-3------------- server
  5. client <----------part-4------------- server
  6. client <----------last-part---------- server

这里的实现要点就是:

  1. response 的 header 里面不要包含 Content-Length,无论你是否可以预估响应的文件长度(因为你分块响应的时候传输的不只是你的 payload,还会有协议内容,所以 Content-Length != 你的文件长度
  2. 每次响应的 part 的协议为 <当前响应的文件长度[hex]\r\n你的响应内容\r\n
  3. 当你响应完之后,通知客户端的方式为 0\r\n\r\n,这样客户端就知道没有更多内容了,可以收尾了。


  1. [[email protected]]# cat main.go
  2. package main
  3. import (
  4. "bytes"
  5. "fmt"
  6. "io"
  7. "time"
  8. "github.com/gin-gonic/gin"
  9. )
  10. func chanWriter(opChan chan string) {
  11. for index := 0; index < 5; index++ {
  12. opChan <- fmt.Sprintf("num: %d", index)
  13. time.Sleep(time.Second)
  14. }
  15. close(opChan)
  16. }
  17. func main() {
  18. api := gin.Default()
  19. api.GET("/download", func(c *gin.Context) {
  20. opChan := make(chan string)
  21. go chanWriter(opChan)
  22. c.Stream(func(w io.Writer) bool {
  23. output, ok := <-opChan
  24. if !ok {
  25. return false
  26. }
  27. outputBytes := bytes.NewBufferString(output + "\n")
  28. c.Writer.Write(outputBytes.Bytes())
  29. return true
  30. })
  31. })
  32. api.Run(":8080")
  33. }

然后访问的时候可以看到响应的格式是先传一个后续字符的长度(注意,这里不包括 \r\n),然后再传后续的字符:

图 4:Chunk reponse 下载
2f3b34ccf500.png

断点续下载

HTTP 协议支持范围下载,这极大地方便了文件的下载和节省了服务器的带宽,只要是通过这几个元数据实现:rangeif-rangecontent-rangeaccept-range

request header

Range

Range主要用来设置获取数据的范围,格式如下:



  1. Range: <unit>=<range-start>-<range-end>
  2. Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>, <range-start>-<range-end>
  • <unit> 类型,一般来说是bytes;
  • <range-start> 表示范围的起始值,一般是数字,如果不是数字就看服务端逻辑如何处理;
  • <range-end> 表示范围的结束值。这个值是可选的,如果不存在,表示此范围一直延伸到文档结束,如果非数字,同上。

如: 获取 0-10字节的数据和15到结尾的数据



  1. Range: bytes=0-10,15-

If-Range

If-Range 主要用来判断是否满足范围请求的条件,举个例子,假设昨天你用迅雷下载了一部电影但是没有下载完,今天你要接着下载,当再次下载时客户端就需要和服务器验证这部电影的资源内容有没有发生变化,If-Range在这里就是做验证使用的 。

response meta

header/body: Content-Range

Content-Range 表示响应数据的内容范围,语法格式如下:



  1. Content-Range: <unit> <range-start>-<range-end>/<size>
  2. Content-Range: <unit> <range-start>-<range-end>/*
  3. Content-Range: <unit> */<size>
  • <unit> 类型,一般来说是bytes;
  • <range-start> 区间的起始值;
  • <range-end> 区间的结束值;
  • <size> 整个文件的大小(如果大小未知则用 “*“ 表示)


  1. Content-Range: bytes 10-15/22

Accept-Ranges

Accept-Ranges 用于服务器响应,告诉浏览器是否支持 Range,



  1. Accept-Ranges: bytes
  2. Accept-Ranges: none
  • none 不支持任何范围请求单位,由于其等同于没有返回此头部,因此很少使用。不过一些浏览器,比如IE9,会依据该头部去禁用或者移除下载管理器的暂停按钮;
  • bytes 一般情况


  1. [[email protected]]# cat main.go
  2. package main
  3. import (
  4. "flag"
  5. "log"
  6. "net/http"
  7. )
  8. var (
  9. dirPath = "./static"
  10. )
  11. func main() {
  12. flag.StringVar(&dirPath, "dir", "./static", "Directory to serve static files from")
  13. flag.Parse()
  14. http.Handle("/", http.FileServer(http.Dir(dirPath)))
  15. log.Print("Listening on :3000...")
  16. err := http.ListenAndServe(":3000", nil)
  17. if err != nil {
  18. log.Fatal(err)
  19. }
  20. }

验证一下是否支持 Range,先用 HEAD 看一下资源的元数据:

图 5:查询元数据
16b6531454d5.png
图 6:Range 下载
e096a65f241d.png
图 7:Range 相应的 body
237a03bb65cb.png

这里有几个地方值得关注:

  1. HTTP status code 是 206(Partial Content);
  2. 数据是分段返回,段与段之间用 boundary 分隔开;
图 8:If-Range Condition
88a7d5340188.png

这里可以看到,我把 If-Range 的值设置到文件的最后修改时间之前,这时 HTTP Server 的逻辑就不是分块下载了,而是全部下载,所以文件过大了(对于 Postman 来说)。

但是,奇怪的是,我将 If-Range 并且超过 Last-Modify,并没有达到预期的分块下载,而还是全量的,怀疑可能是 Go 实现的问题:

图 9:这个是图片说明
3a8c2a7de38e.png

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK