参数我明明都传了,怎么到后端就不见了

背景

有一个已经上线了很久的文件分片上传功能, 前端把文件分片后, 调用分片上传接口上传文件分片, 分片全部上传完毕后执行文件合并接口. 昨天突然遇到文件合并接口返回了一个分片数对不上的报错.

而且是大部分文件都没问题, 只有那一个文件有这样的问题, 百思得不得其解, 直接贡献了一次加班精力.

以为是环境问题, 结果发现这个包在各个环境里上传都是一致的失败.

通过 debug 发现是其中一个分片上传的接口的一个入参变成空字符串了, 但别的入参是正常的. 从浏览器里也可以看到这个接口的这个字段是有值的.

后面发现浏览器分片上传文件失败, 但是 Go 实现的文件上传工具是可以的.

解决办法

通过分析浏览器和上传工具上传分片的区别, 发现两者只有 http 请求的请求头 Content-Type 的值不同.

浏览器的是 application/x-www-form-urlencoded
客户端的是 application/octet-stream

司马当活马医, 让前端上传分片的时候改成下面这个头, 果然问题解决了.

原因分析

我们的服务端是 beego 框架, 知道是 Content-Type 导致的问题后, 我就看起了 beego 的参数获取流程 controller.BindForm

核心代码


func (r *Request) ParseForm() error {
var err error
if r.PostForm == nil {
if r.Method == "POST" || r.Method == "PUT" || r.Method == "PATCH" {
r.PostForm, err = parsePostForm(r) // !!!!!!!!!!!!!!
}
if r.PostForm == nil {
r.PostForm = make(url.Values)
}
}
if r.Form == nil {
if len(r.PostForm) > 0 {
r.Form = make(url.Values)
copyValues(r.Form, r.PostForm)
}
var newValues url.Values
if r.URL != nil {
var e error
newValues, e = url.ParseQuery(r.URL.RawQuery)
if err == nil {
err = e
}
}
if newValues == nil {
newValues = make(url.Values)
}
if r.Form == nil {
r.Form = newValues
} else {
copyValues(r.Form, newValues) // !!!!!!!!!!!!!!
}
}
return err
}

func parsePostForm(r *Request) (vs url.Values, err error) {
if r.Body == nil {
err = errors.New("missing form body")
return
}
ct := r.Header.Get("Content-Type")
// RFC 7231, section 3.1.1.5 - empty type
// MAY be treated as application/octet-stream
if ct == "" {
ct = "application/octet-stream"
}
ct, _, err = mime.ParseMediaType(ct)
switch {
case ct == "application/x-www-form-urlencoded": // !!!!!!!!!!!!!!
var reader io.Reader = r.Body
maxFormSize := int64(1<<63 - 1)
if _, ok := r.Body.(*maxBytesReader); !ok {
maxFormSize = int64(10 << 20) // 10 MB is a lot of text.
reader = io.LimitReader(r.Body, maxFormSize+1)
}
b, e := io.ReadAll(reader)
if e != nil {
if err == nil {
err = e
}
break
}
if int64(len(b)) > maxFormSize {
err = errors.New("http: POST too large")
return
}
vs, e = url.ParseQuery(string(b))
if err == nil {
err = e
}
case ct == "multipart/form-data":
// handled by ParseMultipartForm (which is calling us, or should be)
// TODO(bradfitz): there are too many possible
// orders to call too many functions here.
// Clean this up and write more tests.
// request_test.go contains the start of this,
// in TestParseMultipartFormOrder and others.
}
return
}

func copyValues(dst, src url.Values) {
for k, vs := range src {
dst[k] = append(dst[k], vs...) // !!!!!!!!!!!!!!
}
}

func parseFormToStruct(form url.Values, objT reflect.Type, objV reflect.Value) error {
for i := 0; i < objT.NumField(); i++ {
...

// !!!!!!!!!!!!!!
formValues := form[tag]
var value string
if len(formValues) == 0 {
defaultValue := fieldT.Tag.Get("default")
if defaultValue != "" {
value = defaultValue
} else {
continue
}
}
if len(formValues) == 1 {
value = formValues[0]
if value == "" {
continue
}
}

...
}
return nil
}

  1. 我们的上传接口是 PUT 方法, 且 Content-Type: application/x-www-form-urlencoded
  2. beego 先从 body 里解析 key value, body 就是我们的分片文件内容
  3. 然后 beego 再从 url 里解析 key value, 合并两个 kv
  4. Unmarshal kv 到入参接口体里, 如果 key 有多个值的话, 取空值

知道问题后, 我就手动创建了一个文件

key=qqq

curl 上传文件, 果然复现了

$ curl --location --request PUT 'http://localhost:8080/v1/adminManage/file/chunk/upload?partNumber=1&key=hack-1.tgz&uploadId=b082f9e6-11b8-4814-86dd-8a0054f03693' \
--header 'Cookie: c2f_prod_c2f_token=matrix-only' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-binary '@/Users/xiniu/Downloads/hack-1.tgz'

{
"code": 1001,
"success": false,
"errorMsg": "key,,uploadId,b082f9e6-11b8-4814-86dd-8a0054f03693,partNumber,1,body.len,7,参数异常"
}%

就是说明当时有个分片后的文件正好能解析成 kv 的格式, 且参数为 key, 与我们的入参冲突了, 佛了.