概述

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

文件上传

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

图 1:multipart/form-data
图 2:text/plain

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

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

图 3:直接上传二进制

分块上传

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

Go 实现

  1. [root@liqiang.io]# 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. [root@liqiang.io]# 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. [root@liqiang.io]# 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 下载

断点续下载

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>

如: 获取 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>

例如:

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

Accept-Ranges

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

语法:

  1. Accept-Ranges: bytes
  2. Accept-Ranges: none

代码实现

  1. [root@liqiang.io]# 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:查询元数据
图 6:Range 下载
图 7:Range 相应的 body

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

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

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

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

图 9:这个是图片说明

Ref