一、问题描述

最近笔者在用jmeter对一个文件存储服务做压测,由于对jmeter不太熟悉,遇到了一些坑,其中有一个就是用表单上传文件时,一直失败,原因竟是手动加了http请求头: Content-Type=multipart/form-data

去掉就好了~今天跟大家记录下问题分析的过程。

二、分析过程

1、问题初现

遇到这个问题,报的错是405,但是查看结果树中,请求的方法就是POST,所以没有从返回的异常中得到什么有用的价值;

2、比对项目中的传参,一模一样

笔者的第一感觉是请求头、或者表单参数传错了,因为原本的jmeter脚本就是对照着项目中的代码写的, 所以将自己之前在自己项目中写的代码跑了一下,看了传参,然而发现一模一样,项目中也能上传成功~这就有点懵了

划重点 ,项目中用的客户端是Spring RestTemplate

具体表单参数由于比较敏感,这里就不贴了

3、使用postman重放一下接口,成功了

用一模一样的参数在postman中重新请求了一遍,发现竟然成功了~

4、抓包比对,发现boundary的存在

由于肉眼上看到的参数都是一模一样的,那只能联想到是RestTemplate、postman自动帮你做了什么事情,所以要看发送出网络请求的实际参数,那就只能抓包来看看了 于是通过tcpdummp分别抓jmeter、postman发起请求时的包

jmeter抓到的请求头

postman抓到的请求头

发现表单参数一模一样,只有请求头有点不太一样,发现postman的请求头, Content-Type 中多了个boundary;

于是笔者将boundary复制一遍到jmeter的 Content-Type 中,发现还是失败~

于是了解下这个boundary是干嘛用的,经查阅资料,参考RFC规范: datatracker.ietf.org/doc/html/rf… 他的作用大概如下:

当content-type为multipart/form-data类型,数据体中传输了多个参数时,需要用boundary指定分隔符。请求接收端就是通过boundary的值作为分隔符,来解析参数。

5、为什么postman、RestTemplate会有boundary?

postman 实际上在postman中,当我们选择请求body为 form-data 时,postman会默认帮我们生成一个请求头 multipart/form-data; boundary=<calculated when request is sent> ,只不过这类默认的请求头被隐藏起来了,取消隐藏就可以看到,另一个是就算自己申明了 Content-Type=multipart/form-data ,也会被他覆盖,因此可以成功请求。

这里笔者记得看过一篇文章说道,postman也是在某个版本之后,才在当你手动写了请求头 Content-Type=multipart/form-data ,还会自动生成boundary,在此之前手动声明也会报错,但是具体版本不记得了,如果你遇到了可以下载新版本的postman

RestTemplate

笔者通过一路debug,找到了生成boundary的逻辑,笔者在代码中手动加了 Content-Type=multipart/form-data ,但是他会判断Content-Type,如果是multipart/form-data,就会自动帮你生成boundary,并写入到发出的消息中,其关键代码如下:

org.springframework.http.converter.FormHttpMessageConverter#write 方法:
这里首先会判断这个消息体的类型,是否有多部分组成,如果多部分组成,则调用 writeMultipart 方法

public void write(MultiValueMap<String, ?> map, MediaType contentType, HttpOutputMessage outputMessage)
      throws IOException, HttpMessageNotWritableException {
   if (!isMultipart(map, contentType)) {
      writeForm((MultiValueMap<String, String>) map, contentType, outputMessage);
   else {
      writeMultipart((MultiValueMap<String, Object>) map, outputMessage);

org.springframework.http.converter.FormHttpMessageConverter#writeMultipart方法
这个方法会先生成一个随机字符串,即boundary,然后将调用writeParts()方法,将所有参数用boundary分隔

private void writeMultipart(final MultiValueMap<String, Object> parts, HttpOutputMessage outputMessage) throws IOException {
   // 生成一个随机字符串
   final byte[] boundary = generateMultipartBoundary();
   Map<String, String> parameters = Collections.singletonMap("boundary", new String(boundary, "US-ASCII"));
   MediaType contentType = new MediaType(MediaType.MULTIPART_FORM_DATA, parameters);
   HttpHeaders headers = outputMessage.getHeaders();
   headers.setContentType(contentType);
   // 这个判断条件先不管,不是重点
   if (outputMessage instanceof StreamingHttpOutputMessage) {
      StreamingHttpOutputMessage streamingOutputMessage = (StreamingHttpOutputMessage) outputMessage;
      streamingOutputMessage.setBody(new StreamingHttpOutputMessage.Body() {
         @Override
         public void writeTo(OutputStream outputStream) throws IOException {
            // 将boundary,参数,写入到写出的消息流中
            writeParts(outputStream, parts, boundary);
            writeEnd(outputStream, boundary);
   else {
      // 将boundary,参数,写入到写出的消息流中
      writeParts(outputMessage.getBody(), parts, boundary);
      writeEnd(outputMessage.getBody(), boundary);

org.springframework.http.converter.FormHttpMessageConverter#writeParts方法
将所有参数,通过boundary分隔,然后写入OutputStream

private void writeParts(OutputStream os, MultiValueMap<String, Object> parts, byte[] boundary) throws IOException {
   for (Map.Entry<String, List<Object>> entry : parts.entrySet()) {
      String name = entry.getKey();
      for (Object part : entry.getValue()) {
         if (part != null) {
            writeBoundary(os, boundary);
            writePart(name, getHttpEntity(part), os);
            writeNewLine(os);

综上,这里的流程符合我们前面查阅资料看到的规范

6、那jmeter就不会自动帮你生成boundary?

会生成boundary吗?会
经过查阅资料,实际上jmeter也会自动帮你生成boundary,会失败只是因为手动加了Content-Type=multipart/form-data,覆盖了默认帮你生成的Content-Type

那怎么搞才可以?
其实去掉手动加的Content-Type,jmeter会默认帮你生成的,经过笔者验证,去掉之后就可以上传成功,抓到的包也符合预期(请求头带有boundary、请求体通过boundary分隔)

那为什么前面手动加上了boundary,还是不行
这是因为你手动加的boundary,覆盖了jmeter自动生成的,而实际上jmeter在处理消息体时,是以他自己生成的boundary分隔的,请求接收端根据你手动写的boundary去做解析,自然会报错。

7、jmeter就不会像postman那样,规避一下手动加入Content-Type的问题吗?

笔者思考到这个问题的时候,看了下自己的jmeter版本,是5.2.1,查了下才发现这个版本在2019年12月的时候发布的,已经两年多了,那这两年多jmeter有没有可能修复了这个问题呢? 去官网看看发布记录:jmeter.apache.org/changes.htm…
没有找到相关的描述

也可能是笔者英语不太好,眼尖的读者可以帮忙找找看。

实际上呢?不甘心的我,还是下载了一个最新版本的5.5,试了一下,真的可以,就算手动加了Content-Type,发出的请求,也会自动帮你加上boundary~

基础很重要

其实这应该是一个http规范的问题,非常基础,如果笔者知道这个知识点,在看到jmeter发出的请求头,没有boundary的时候,就能发现到问题了。 所以说,基础知识可能平时你觉得没用到,但是总会在不经意间帮你解决了很多问题。

分析问题才有所收获

前期笔者在查阅资料的时候,发现很多类似的问题,在使用postman、httpclient、jmeter都遇到了这个问题,但是解决办法都是把Content-Type=multipart/form-data删掉,然后就没有下文了。因此笔者也才会想记录下这个问题,了解下原因,能有个参考,也有所收获。

分类:
后端