偶遇 Content-Length 和 Transfer-Encoding

4,245 阅读4分钟

背景介绍

开发微信小程序的过程中,需要对接微信的内容审核接口,即上传一张图片,检查是是否违法违规。用 axios 上传返回 412,用 request 上传则可以。

但是如果把微信的接口换成自己的,都可以成功接收到数据,所以肯定是用 axios 和 request 发送 HTTP 请求的时候,有一些区别。那如何找到这个区别呢,只能通过抓包了。

抓包排查

首先打开 Charles,然后分别运行 axios 和 request 的代码,注意,要在代码里面配上代理,我们看下 request 的代码:

var request = require('request')
var fs = require('fs')
var options = {
  method: 'POST',
  url:
    'https://api.weixin.qq.com/wxa/img_sec_check?access_token=xxx',
  headers: {
    'Content-Type': 'application/json',
  },
  formData: {
    contentType: 'image/jpeg',
    value: fs.createReadStream('/Users/keliq/Pictures/jzm.jpeg'),
  },
  proxy: 'http://127.0.0.1:8888',
  rejectUnauthorized: false,
}
request(options, function (error, response) {
  if (error) throw new Error(error)
  console.log(response.body)
})

抓包结果如下:

  1. 请求

    POST /wxa/img_sec_check?access_token=xxx HTTP/1.1
    Content-Type: multipart/form-data; boundary=--------------------------630489123810205833486663
    host: api.weixin.qq.com
    content-length: 11998
    Connection: close
    
    ----------------------------630489123810205833486663
    Content-Disposition: form-data; name="contentType"
    
    image/jpeg
    ----------------------------630489123810205833486663
    Content-Disposition: form-data; name="value"; filename="jzm.jpeg"
    Content-Type: image/jpeg
    
    ÿØÿà...
    
  2. 响应

    HTTP/1.1 200 OK
    Date: Mon, 05 Apr 2021 06:59:26 GMT
    Content-Type: application/json; encoding=utf-8
    RetKey: 11
    LogicRet: 42001
    Content-Length: 81
    Connection: close
    
    {"errcode":42001,"errmsg":"access_token expired rid: 606ab54e-4b90bc8a-66e6fc25"}
    

再看 axios 的代码

var axios = require('axios')
var FormData = require('form-data')
var fs = require('fs')
var data = new FormData()
data.append('contentType', 'image/jpeg')
data.append('value', fs.createReadStream('/Users/keliq/Pictures/jzm.jpeg'))

var config = {
  method: 'post',
  url:
    'https://api.weixin.qq.com/wxa/img_sec_check?access_token=xxxx',
  headers: {
    'Content-Type': 'application/json',
    ...data.getHeaders(),
  },
  data: data,
  proxy: {
    host: '127.0.0.1',
    port: 8888,
  },
}

axios(config)
  .then(function (response) {
    console.log(JSON.stringify(response.data))
  })
  .catch(function (error) {
    console.log(error)
  })

抓包结果如下:

  1. 请求

    POST /wxa/img_sec_check?access_token=xxx HTTP/1.1
    Accept: application/json, text/plain, */*
    Content-Type: multipart/form-data; boundary=--------------------------571426341398317845770943
    User-Agent: axios/0.21.1
    host: api.weixin.qq.com
    Transfer-Encoding: chunked
    Connection: close
    
    ----------------------------571426341398317845770943
    Content-Disposition: form-data; name="contentType"
    
    image/jpeg
    ----------------------------571426341398317845770943
    Content-Disposition: form-data; name="value"; filename="jzm.jpeg"
    Content-Type: image/jpeg
    
    ÿØÿà...
    
  2. 响应

    HTTP/1.1 412 Precondition Failed
    Date: Mon, 05-Apr-2021 07:00:41 GMT
    Content-Length: 0
    Connection: close
    

经过对比发现,axios 比 request 多了一个 Transfer-Encoding 头部,值为 chunked,而 request 比 axios 多了一个 Content-Length 头部,因此这两个头部的区别才是导致微信接口返回 412 的元凶。

了解 Content-Length 和 Transfer-Encoding 头

网上查了很多资料,发现了朴瑞卿的这一篇文章 里面说得非常详细,在此摘录部分内容如下:

  • Content-Length:表示 HTTP 消息长度,用十进制数字表示的八位字节的数目
  • Transfer-Encoding:如果在请求处理完成前无法获取消息长度,此时应该使用 Transfer-Encoding: chunked

Content-Length 的工作原理

Content-Length 应该是精确的,否则就会导致异常,这个大小是包含了所有内容编码的,比如对文本文件进行了 gzip 压缩的话,Content-Length 首部指的就是压缩后的大小而不是原始大小。那如果提供的数值不准确会怎样呢?

  • Content-Length > 实际长度:服务端/客户端读取到消息结尾后会等待下一个字节,会无响应直到超时。
  • Content-Length < 实际长度:第一次消息被截断,第二次解析混乱。

Transfer-Encoding 的工作原理

数据以一系列分块的形式进行发送,在每一个分块的开头需要添加当前分块的长度,以十六进制的形式表示,后面紧跟着 \r\n ,之后是分块本身,后面也是 \r\n,终止块是一个常规的分块,不同之处在于其长度为 0,图示如下:

抓包后的结果:

解决方案

找到问题就好办了,第一反应就是在 axios 的拦截器中把 Transfer-Encoding 头干掉,然后把 Content-Length 头给加上:

axios.interceptors.request.use(
  config => {
    console.log('config', config.headers)
    delete config.headers['Transfer-Encoding']
    return config
  },
  error => Promise.reject(error)
)

然而还是太天真了,Transfer-Encoding 根本干不掉,后来发现在Content-Length 和 Transfer-Encoding 这两个头部是不共存的,也就是说,如果我手动增加了 Content-Length 头,会自动干掉 Transfer-Encoding 头...

那如何获取 Content-Length 长度呢?最后在 cnodejs 社区竟然发现了一模一样的问题,作者深入研究 request、axios 和 form-data 源码,最终找到了解决方案,就是利用 form-data 的 getLength 方法获取长度,然后手动添加 Content-Length 头。

最后改造后的代码如下:

var axios = require('axios')
var FormData = require('form-data')
var fs = require('fs')

async function request() {
  var data = new FormData()
  data.append('contentType', 'image/jpeg')
  data.append('value', fs.createReadStream('/Users/keliq/Pictures/jzm.jpeg'))
  var len = await new Promise((resolve, reject) => {
    return data.getLength((err, length) => (err ? reject(err) : resolve(length)))
  })
  var config = {
    method: 'post',
    url: 'https://api.weixin.qq.com/wxa/img_sec_check?access_token=xxx',
    headers: {
      'Content-Type': 'application/json',
      ...data.getHeaders(),
      'Content-Length': len,
    },
    data: data,
    proxy: {
      host: '127.0.0.1',
      port: 8888,
    },
  }

  axios(config)
    .then(function (response) {
      console.log(JSON.stringify(response.data))
    })
    .catch(function (error) {
      console.log('error')
    })
}

request()