HTTP POST form-data return '400: Data Error.'

问题描述

最近在做的以图搜图功能需要边缘端设备通过HTTP协议接受一张待查找图像。架构在边缘端使用Pistach库实现了一个简单的HTTP服务器,其中核心为报文的parser(仅支持multipart/form-data请求格式),架构根据postman的http报文,主要使用std::stringstd::map来处理和存储报文的各个部分。因为缺少Java后端以及云端未来将移植到Go平台,我尝试使用Go的ntp/http包实现一个简单的客户端,使用的直接是Postman辅助生成的Go-Native代码,但是Go代码始终返回的是报文一直是400状态码和Data Error.

能够确定的是Postman的相应POST请求能够得到正确的处理,再后续调查中发现400是服务端定义的返回,于是开始调查原因。

HTTP POST Request

POST /upload HTTP/1.1Host: 10.0.23.176:8001Content-Length: 283Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW​----WebKitFormBoundary7MA4YWxkTrZu0gWContent-Disposition: form-data; name="image"; filename="ricardolu.jpeg"Content-Type: image/jpeg​(data)----WebKitFormBoundary7MA4YWxkTrZu0gWContent-Disposition: form-data; name="args"​{"format":".jpeg"}----WebKitFormBoundary7MA4YWxkTrZu0gW

以上报文拷贝于Postman,关于multipart/form-data的详细请求数据大体是这样,但是在具体的细节上网上有很多种说法,在RFC 2388中也仅仅给出了一个不完全的例子,这里仍然以Postman给出的实例作介绍。一个multipart/form-data的报文包含多个part,每个part都有对应的Content-Disposition头,其他的头信息为可选项,例如Content-Type。

Content-Disposition 包含了 type 和 一个名字为 name 的 parameter,type 是 form-data,name 参数的值则为表单控件(也即 field)的名字,如果是文件,那么还有一个 filename 参数,值就是文件名。

Go client

package main​import (  "fmt"  "bytes"  "mime/multipart"  "os"  "path/filepath"  "io"  "net/http"  "io/ioutil")​func main() {​  url := "http://10.0.23.176:8001/upload"  method := "POST"​  payload := &bytes.Buffer{}  writer := multipart.NewWriter(payload)  file, errFile1 := os.Open("/home/ts/Downloads/ricardolu.jpeg")  defer file.Close()  part1,         errFile1 := writer.CreateFormFile("image",filepath.Base("/home/ts/Downloads/ricardolu.jpeg"))  _, errFile1 = io.Copy(part1, file)  if errFile1 != nil {    fmt.Println(errFile1)    return  }  _ = writer.WriteField("args", "{\"format\":\".jpeg\"}")  err := writer.Close()  if err != nil {    fmt.Println(err)    return  }​​  client := &http.Client {  }  req, err := http.NewRequest(method, url, payload)​  if err != nil {    fmt.Println(err)    return  }  req.Header.Set("Content-Type", writer.FormDataContentType())  res, err := client.Do(req)  if err != nil {    fmt.Println(err)    return  }  defer res.Body.Close()​  body, err := ioutil.ReadAll(res.Body)  if err != nil {    fmt.Println(err)    return  }  fmt.Println(string(body))}

这部分Go客户端代码由Postman生成,实现非常简单,仅仅是上述HTTP POST请求的Go实现。理论上来说我的服务端能够正常处理由Postman发出的POST请求,那么也同样能处理这个Go语言实现的客户端请求,但每次运行程序的返回都是400Data Error.

在最开始我以为是客户端的问题,于是查询了一些ntp/http包的相关文档,在客户端和服务端均添加了一些log语句,尝试对比客户端和服务端,Postman和Go客户端之间报文区别。

application/octet-stream

multipart/form-data中的文件内容如果是通过填充表单来获得,那么上传的时候,Content-Type 会被自动设置(识别)成相应的格式,如果没法识别,那么就会被设置成 application/octet-stream如果多个文件被填充成单个表单项,那么它们的请求格式则会是multipart/mixed

在确认服务端能够正确收到由Go客户端发送的请求之后,我注意到Postman请求的Content-Type为image/jpeg,而Go客户端请求的Content-Typeapplication/octet-stream,于是我开始尝试使用对应的接口修改Go请求的Content-Typeimage/jpeg

req.Header.Set("Content-Disposition", "multipart/form-data")req.Header.Set("Content-Type", "image/jpeg")

但是直接这么修改,客户端会报错:

2022/01/19 02:45:21 http: panic serving 10.0.23.176:56864: runtime error: invalid memory address or nil pointer dereferencegoroutine 10 [running]:net/http.(*conn).serve.func1(0x40002241e0)        /usr/local/go/src/net/http/server.go:1802 +0xe4panic({0x213a20, 0x417150})        /usr/local/go/src/runtime/panic.go:1052 +0x2b4main.handleUploadFile({0x2a1a50, 0x40000b81c0}, 0x40000aa200)        /home/tc-eb5/local/go/src/github.com/gesanqiu/go-restful-example/user-resource.go:24 +0x44net/http.HandlerFunc.ServeHTTP(0x25edd0, {0x2a1a50, 0x40000b81c0}, 0x40000aa200)        /usr/local/go/src/net/http/server.go:2047 +0x40net/http.(*ServeMux).ServeHTTP(0x423da0, {0x2a1a50, 0x40000b81c0}, 0x40000aa200)        /usr/local/go/src/net/http/server.go:2425 +0x18cnet/http.serverHandler.ServeHTTP({0x4000174000}, {0x2a1a50, 0x40000b81c0}, 0x40000aa200)        /usr/local/go/src/net/http/server.go:2879 +0x45cnet/http.(*conn).serve(0x40002241e0, {0x2a27c0, 0x4000114db0})        /usr/local/go/src/net/http/server.go:1930 +0xb54created by net/http.(*Server).Serve        /usr/local/go/src/net/http/server.go:3034 +0x4ac

由于我不是前端人员所以我也不知道问题出在哪,但是在后续的调查中,我发现Go的multipart.Writer 提供的CreateFormFile() 函数的具体实现如下:

// CreateFormFile is a convenience wrapper around CreatePart. It creates// a new form-data header with the provided field name and file name.func (w *Writer) CreateFormFile(fieldname, filename string) (io.Writer, error){    h := make(textproto.MIMEHeader)    h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, escapeQuotes(fieldname), escapeQuotes(filename)))    h.Set("Content-Type", "application/octet-stream")    return w.CreatePart(h)}

可以看到这个接口将默认的part头的Content-Type设置为了application/octet-stream,于是我不再使用这个接口直接创建文件类型的Part头部,而是仿照这个接口的实现手动创建一个Part头部:

h := make(textproto.MIMEHeader)h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, escapeQuotes(fieldname), escapeQuotes(filename)))h.Set("Content-Type", "image/jpeg")part, err :=w.CreatePart(h)​file, err := os.Open(filename)_, err = io.Copy(part1, file)if err != nil {  fmt.Println(err)  return}

使用这个实现能够使得Go报文信息至少在表面上与Postman一致,但返回依然是400。我最后甚至使用了restful包来实现同样请求工作,虽然我的C++服务端无法成功处理Go客户端的请求,但是我使用Go实现的服务端是能够成功除了来自客户端的请求并且能够正确将报文中的文件解析出来。于是在尝试了几种版本的客户端实现都失败之后,开始将思路转移到服务端本身。

Server Parser

首先阅读服务端的源码,定位到了400状态码的返回点为解析报文失败,无法获得请求中的文本信息和图片信息,于是开始通读解析器的实现。服务端在实现上使用自定义的ContentItem类和RequestBodyParser来存储报文的Part和解析报文,其中解析器的实现比较简单,以multipart/form-databoundary分隔符来截断不同的Part。

multipart/form-data的boundary

RFC 2388给出的例子中,boundary是Part的分割符,除了存在于每个Part的头部,在最后还会以--boundary--的格式作为一个form-data请求的结尾。关于boundary,它的内容允许自定义,我没有查到具体的标准,无论是长度、内容甚至是开头的“-符号的数量和含义都没有做具体的定义,只要求唯一。

我在服务端返回400之前将接收到的报文打印之后,我注意到到Go客户端和Postman两者请求的唯一区别就是boundary。

GetData()

    bool GetData (size_t& pos, size_t& len) {        const std::string separator ("------------------------");        pos = body_.find (separator + sync_, cur_);​        if (std::string::npos != pos) {            size_t tmp = pos;            len = pos - cur_;            pos = cur_;            cur_ = tmp;​            while (body_ [pos + len - 1] == (const char) 0x0D ||                   body_ [pos + len - 1] == (const char) 0x0A) {                len = len - 1;            }​            return TRUE;        } else {            pos = std::string::npos;            len = 0;            return FALSE;        }    }

可以看到关于报文的解析,实现上非常简单,主要是针对字符串内容的截断操作,以上代码将提取出每个Part所携带的具体的数据内容。在开头定义了一个seprator字符串,为boundary的开头,数量为24个-,恰好与我服务端受到的Postman的报文的boundary开头的-符号个数相同(这里为什么是24个的原因不明,上面的Postman请求中boundary也并不是24个),对比之下Go服务端发送的请求boundary仅有两个-,于是GetData()这个函数的pos始终是std::string::npos,于是我无法提出去Part中的数据,于是返回了400。

我看到的文档中都没有对boundary做更详细的定于,只是根据大部分文档的例子都是使用--作为boundary的开头而非更多个的-作为boundary的开头,在我将separator改为--后,能够成功处理来自Go客户端的请求。

Postscript:这个Bug实在特殊,并不具备太多的学习和参考价值,仅作简单记录,在调查这个问题的时候我想起几年前的我曾经立誓不会接触任何与计算机网络相关的工作,但现在我想有机会的话我还是想看更多优秀的代码是如何实现的,毕竟这个问题假如C++有更完善的HTTP库,应该不会出现。

Last updated