概述
在互联网中,上传和下载都是非常常见的操作,而且不同于内网环境,通常这些操作都是通过 HTTP 协议实现的,所以在这篇文章中我尝试解释一下 HTTP 常用的上传和下载的实现,并且在协议层尝试解释一下 HTTP 的实现原理。
文件上传
在 HTTP 中,我们要上传文件的时候一般都是通过 Form 实现的,对于 Form 有个属性可以用于定义提交的编码方式:enctype
,它的可选值有:
application/x-www-form-urlencoded
: 默认的类型.multipart/form-data
: 允许通过 file 类型的<input>
用于上传文件.text/plain
: 不常用的方式,直接将数据传输到后端
图 1:multipart/form-data |
---|
图 2:text/plain |
---|
application/x-www-form-urlencoded
的作用是将表单的值进行 url 编码,然后放入 body 中,这种方式就不推荐了,效率很低,处理也比较麻烦。
此外,还有一种常用的方法是直接上传二进制,但是和 multipart/form-data
的区别就是这种方式一般不能传递文件名,并且一般只能传输一个文件(当然你可以自定义协议传输多个,但是一般只传一个):
图 3:直接上传二进制 |
---|
分块上传
HTTP 协议其实不存在真正意义分片上传,但是我们可以做到的一个效果就是不需要一次性将文件都读取完全,然后再上传;我们可以先读一部分的文件,然后写到 HTTP 连接中,然后继续读接下来的部分,然后继续写入 HTTP 连接中,伪代码就是:
1. create http upload connection
2. write HTTP Content-Type & Content-Length
3. write Body: "----------------------------651775247284855567875859^M
Content-Disposition: form-data; name="file"; filename="Xnip2023-05-09_07-19-21.jpg"^M
Content-Type: image/jpeg"
4. read part of the source file
5. write data read from step 4
6. repeat 4 & 5 until finish read and write
Go 实现
[root@liqiang.io]# cat upload.go
func uploadHandle(w http.ResponseWriter, req *http.Request) {
for k, vs := range req.Form {
fmt.Printf("req.Form[%s]: %+v\n", k, vs)
}
for k, vs := range req.PostForm {
fmt.Printf("req.PostForm[%s]: %+v\n", k, vs)
}
fmt.Printf("req.MultipartForm: %+v\n", req.MultipartForm)
bytes, err := io.ReadAll(req.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
fmt.Printf("req.GetBody()[:200]: %s\n", bytes[:200])
fmt.Printf("req.GetBody(): %d bytes\n", len(bytes))
}
当我通过 multipart/form-data
上传的时候,输出的结果为:
[root@liqiang.io]# go run ./main.go
2023/08/19 11:34:36 Listening on :3000...
req.MultipartForm: <nil>
req.GetBody()[:200]: ----------------------------683958435590837571575629
Content-Disposition: form-data; name="file"; filename="Xnip2023-05-09_07-19-21.jpg"
Content-Type: image/jpeg
����JFIF����tExifMM
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 协议支持服务端进行流式响应,所谓的流式响应就是说当服务端不能或者不想一次就将要响应的内容准备好的时候,服务端可以先响应部分的内容,然后后续可以不断地补充响应,直到内容完成响应,时序差不多是这样的:
client ------download_file-------> server
client <----------part-1------------- server
client <----------part-2------------- server
client <----------part-3------------- server
client <----------part-4------------- server
client <----------last-part---------- server
这里的实现要点就是:
- response 的 header 里面不要包含
Content-Length
,无论你是否可以预估响应的文件长度(因为你分块响应的时候传输的不只是你的 payload,还会有协议内容,所以Content-Length != 你的文件长度
- 每次响应的 part 的协议为
<当前响应的文件长度[hex]\r\n你的响应内容\r\n
- 当你响应完之后,通知客户端的方式为
0\r\n\r\n
,这样客户端就知道没有更多内容了,可以收尾了。
代码实现
[root@liqiang.io]# cat main.go
package main
import (
"bytes"
"fmt"
"io"
"time"
"github.com/gin-gonic/gin"
)
func chanWriter(opChan chan string) {
for index := 0; index < 5; index++ {
opChan <- fmt.Sprintf("num: %d", index)
time.Sleep(time.Second)
}
close(opChan)
}
func main() {
api := gin.Default()
api.GET("/download", func(c *gin.Context) {
opChan := make(chan string)
go chanWriter(opChan)
c.Stream(func(w io.Writer) bool {
output, ok := <-opChan
if !ok {
return false
}
outputBytes := bytes.NewBufferString(output + "\n")
c.Writer.Write(outputBytes.Bytes())
return true
})
})
api.Run(":8080")
}
然后访问的时候可以看到响应的格式是先传一个后续字符的长度(注意,这里不包括 \r\n
),然后再传后续的字符:
图 4:Chunk reponse 下载 |
---|
断点续下载
HTTP 协议支持范围下载,这极大地方便了文件的下载和节省了服务器的带宽,只要是通过这几个元数据实现:range
、if-range
、content-range
、accept-range
。
request header
Range
Range主要用来设置获取数据的范围,格式如下:
Range: <unit>=<range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>, <range-start>-<range-end>
<unit>
类型,一般来说是bytes;<range-start>
表示范围的起始值,一般是数字,如果不是数字就看服务端逻辑如何处理;<range-end>
表示范围的结束值。这个值是可选的,如果不存在,表示此范围一直延伸到文档结束,如果非数字,同上。
如: 获取 0-10字节的数据和15到结尾的数据
Range: bytes=0-10,15-
If-Range
If-Range 主要用来判断是否满足范围请求的条件,举个例子,假设昨天你用迅雷下载了一部电影但是没有下载完,今天你要接着下载,当再次下载时客户端就需要和服务器验证这部电影的资源内容有没有发生变化,If-Range在这里就是做验证使用的 。
response meta
header/body: Content-Range
Content-Range 表示响应数据的内容范围,语法格式如下:
Content-Range: <unit> <range-start>-<range-end>/<size>
Content-Range: <unit> <range-start>-<range-end>/*
Content-Range: <unit> */<size>
<unit>
类型,一般来说是bytes;<range-start>
区间的起始值;<range-end>
区间的结束值;<size>
整个文件的大小(如果大小未知则用 “*“ 表示)
例如:
Content-Range: bytes 10-15/22
Accept-Ranges
Accept-Ranges 用于服务器响应,告诉浏览器是否支持 Range,
语法:
Accept-Ranges: bytes
Accept-Ranges: none
- none 不支持任何范围请求单位,由于其等同于没有返回此头部,因此很少使用。不过一些浏览器,比如IE9,会依据该头部去禁用或者移除下载管理器的暂停按钮;
- bytes 一般情况
代码实现
[root@liqiang.io]# cat main.go
package main
import (
"flag"
"log"
"net/http"
)
var (
dirPath = "./static"
)
func main() {
flag.StringVar(&dirPath, "dir", "./static", "Directory to serve static files from")
flag.Parse()
http.Handle("/", http.FileServer(http.Dir(dirPath)))
log.Print("Listening on :3000...")
err := http.ListenAndServe(":3000", nil)
if err != nil {
log.Fatal(err)
}
}
验证一下是否支持 Range,先用 HEAD 看一下资源的元数据:
图 5:查询元数据 |
---|
图 6:Range 下载 |
---|
图 7:Range 相应的 body |
---|
这里有几个地方值得关注:
- HTTP status code 是 206(Partial Content);
- 数据是分段返回,段与段之间用 boundary 分隔开;
图 8:If-Range Condition |
---|
这里可以看到,我把 If-Range
的值设置到文件的最后修改时间之前,这时 HTTP Server 的逻辑就不是分块下载了,而是全部下载,所以文件过大了(对于 Postman 来说)。
但是,奇怪的是,我将 If-Range
并且超过 Last-Modify
,并没有达到预期的分块下载,而还是全量的,怀疑可能是 Go 实现的问题:
图 9:这个是图片说明 |
---|