解析客户端参数#

1.简单键值对#

发送#

curl -H "Content-Type: application/x-www-form-urlencoded" \
-d id=100 \
-d name=golang \
localhost/books

这时curl会把数据拼接为id=100&name=golang形式放入请求体中,不对数据进行编码,意味着如果数据中含有& ? = @等符号时,服务端将无法解析出键值对。

解析#

服务端收到请求后发现内容类型为application/x-www-form-urlencoded,所以当r.ParseForm()函数被执行时,它会将r.Body中的数据解析为键值对形式,并保存在map结构中,以便使用r.FormValue()函数获取。

func books(w http.ResponseWriter, r *http.Request) {
    id := r.FormValue("id")
    name := r.FormValue("name")
}

2.编码的键值对#

发送#

curl -H "Content-Type: application/x-www-form-urlencoded" \
localhost/books
--data-urlencode 'id=100' \
--data-urlencode "bookContent=`</tmp/book.txt`"

这时curl同样会将所有键值对用&符号拼接起来,不同的是它会将所有值部分编码为%39%AC+%0B形式,也就是用三个字节代表一个字节,这样服务端就不会出现解析不了的问题,如果有多个键值对,必须用多个--data-urlencode标识。

解析#

服务端收到请求后发现内容类型为application/x-www-form-urlencoded,所以当r.ParseForm()函数被执行时,它会将r.Body中的数据解析为键值对形式,同时所有数据会被解码为原始数据(其实在上一种情况中,数据同样会被解码一次,但解码后的数据与解码前是一样的),然后保存在map结构中,以便使用r.FormValue()函数获取。

func books(w http.ResponseWriter, r *http.Request) {
    id := r.FormValue("id")
    bookContent := r.FormValue("bookContent")
}

3.二进制数据#

发送#

curl -H "Content-Type: text/plain" \
--data-binary @file.txt \
'localhost/books?id=100&name=book1'

这时我们并不希望我们的数据被服务端当做键对来解析,所以应该用其它字段值来替换application/x-www-form-urlencoded,比如我们设置为text/plainapplication/json都是可以的,这里没有用-d是因为-d会默认去掉文件内容中所有的\n\r,然后放入请求体中,显然我们并不希望数据被偷偷改掉,所以应该用--data-binary选项来代替-d

解析#

服务端收到请求后发现内容类型为text/plain,这时你仍可以执行r.ParseForm()函数,但它只会解析query中的参数而不会解析r.Body中的数据。

func books(w http.ResponseWriter, r *http.Request) {
    id := r.FormValue("id")
    name := r.FormValue("name")
    data, _ := ioutil.ReadAll(r.Body)
}

4.字符串#

发送#

curl -H "Content-Type: application/json" \
-d '{"k1": "v1"}' \
localhost/books

这时数据被直接放入请求体,它与text/plain的行为是一样的。

解析#

处理方式与前一种相同

func books(w http.ResponseWriter, r *http.Request) {
    data, _ := ioutil.ReadAll(r.Body)
}

5.上传文件#

发送#

curl localhost/books \
-F file1=@/tmp/book1.txt \
-F file1=@/tmp/book2.txt

这时r.Header中会被设置一个标识符,告诉服务端Body中的内容怎样分割,且每个Part中又有一个单独的Content-Type。

map[Accept:[*/*] Content-Length:[355] Content-Type:[multipart/form-data; boundary=------------------------Pz6m7Zp0pHUzWednvxzfx0] User-Agent:[curl/8.5.0]]

r.Body中的内容如下。

--------------------------Pz6m7Zp0pHUzWednvxzfx0
Content-Disposition: form-data; name="file1"; filename="book1.txt"
Content-Type: text/plain
hello
--------------------------Pz6m7Zp0pHUzWednvxzfx0
Content-Disposition: form-data; name="file2"; filename="book2.txt"
Content-Type: text/plain
world
--------------------------Pz6m7Zp0pHUzWednvxzfx0--

解析#

服务端发现内容类型为multipart/form-data,会读出紧跟在后面的标识符,然后将r.Body中的数据用该标识符切分成开,切分开的每个数据块中都含有一个name字段,这时我们就可以用该name获取相应的数据了。需要注意的是,这种方式下curl虽然是流式发送数据,但r.FormFile函数并不会立即返回,它从r.Body中读取数据后写入一个临时文件中,等curl把所有数据发送完了才返回,如果中途断开连接,后端将拿不到任何数据,所以该方式下无法实现断点续传。

func books(w http.ResponseWriter, r *http.Request) {
    file1, book1Meta, err := r.FormFile("file1")
    file2, book2Meta, err := r.FormFile("file2")
    ...
}

6.断点上传#

网上主流的断点上传的方案是分片上传,续传的时候先检查之前传了哪些分片,然后重新上传剩余分片。这种方案通常需要额外存储这些分片信息,实现起来复杂。

本节介绍一种简单高效的思路来实现断点续传,这种方式不需要将文件分片,也无需额外的工作来存储上传进度等信息。

发送#

在前端先拿到文件的流对象,然后从断点位置发送剩余数据,示例如下,其中offset变量指后端已经落盘的文件大小。

const file = fileInput.files[0];
fetch('/upload', {
    method: 'POST',
    headers: {"Upload-Offset": offset},
    body: file.slice(offset, file.size),
})

如果用golang编写客户端,则示例代码如下。

func (p *Client) UploadFile(src string, offset int64) error {
	fd, err := os.Open(src)
	fd.Seek(offset, io.SeekStart)
	req, err := http.NewRequest(http.MethodPost, reqUrl, fd)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Upload-Offset", fmt.Sprint(offset))
        resp, err := p.httpCli.Do(req)
        // 省略......
}

解析#

func books(w http.ResponseWriter, r *http.Request) {
    fd, err := os.OpenFile("/tmp/file.txt", os.O_RDWR|os.O_SYNC, 0644)
    offset := r.Header.Get("Upload-Offset")
    fd.Seek(offset, io.SeekStart)
    _, err := io.Copy(fd, part)
}

总结#

当服务器端收到一个HTTP请求时,需要从原始的二进制数据中解析出客户端参数,这些参数大概可以分三个部分。

请求方法与URL#

这两个部分都可以自定义,虽然HTTP协议标准定义了几种请求类型,如:POSTGETDELETEPATCH等,但大多数编程语言中并没有硬性规定,所以如果你喜欢的话,可以使用任何字符串来代替POST。URL后面的query参数会被服解析出来放在map结构中,golang的http库还会对URL进行安全检查,比如不能含有..

请求头#

这里的参数都是键值对形式,用户可以添加自定义的请求头,并且也没有限制。但有一个常用的请求头:Content-Type,它标识了请求体的数据格式,服务器端在收到请求后,会根据该字段来解析请求体中的数据,HTTP协议标准为这个字段预定义了几十种值,但我们常用的只有几种:application/x-www-form-urlencodedmultipart/form-dataapplication/jsontext/plain等。

请求体#

这里是最主要的存放数据的地方,服务端收到请求后,先判断请求头Content-Type的值:

  1. 如果是application/x-www-form-urlencoded,则认为请求本中的数据为key=value形式,对数据进行解码然后保存在map中;
  2. 如果是multipart/form-data,则用符识符对数据进行切分,然后保存在map中;
  3. 如果是application/jsontext/plain等类型,则不对请求体进行处理。



本站所有作品均采用知识共享署名-非商业性使用-禁止演绎 3.0 中国大陆许可协议进行许可。