打开APP
userphoto
未登录

开通VIP,畅享免费电子书等14项超值服

开通VIP
JMeter 源码解析之一:JMeter 上传文件时,如何参数化 Content

        问题描述

        文件上传时,用户定义 Content-Disposition 是失效的。
        笔者在写压力测试脚本的时候,有个上传页面,服务器是根据用户传过来的 Content-Disposition 里的 filename 值来定义保存文件的文件名的。但是测试人员不可能为每一次请求都准备一个不同的文件(这个工作量海了去了),所以 JMeter 传给服务器的 Content-Disposition 里的 filename 必须是随机而不重复的。
        有人问,用户真实上传时,浏览器传给服务器的 filename 也是上传文件名吗?不是的,js 这样修改的 filename:
[javascript] view plain copy
print?
  1. uploader.onBeforeUploadItem = function (item) {  
  2.     //修改名字  
  3.     var timeStamp = new Date().getTime();  
  4.     var fileName = item.file.name;  
  5.     item.file.name = timeStamp + fileName.substr(fileName.lastIndexOf('.'));  
  6.     var day = $filter('date')(new Date(), 'yyyyMMdd');  
  7.     item.url = [item.url, "batchImport", item.importType, day, session.userId].join("/");  
  8. };  

        笔者尝试了多种办法,试图修改服务器接收到的 filename 值,结果都失败了。笔者尝试的办法有:

        1. 添加 HTTP 参数


        如图所示,我们期待服务器接收到的 filename 值是 00004000.xls,而不是 00000000.xls。
        结果服务器接收到的是 00000000.xls。服务器返回给客户端的存储路径为证:/batchImport/merAdd/20141128/1/00000000.xls。查看本次 HTTP 请求,可以看到以下信息:
POST http://serverIP/upload/batchImport/merAdd/20141128/1

POST data:
--DoZtX5jrOIxJTocysPzYJ1WVqtoagXMQHHqho4i
Content-Disposition: form-data; name="Content-Disposition"
Content-Type: text/plain; charset=US-ASCII
Content-Transfer-Encoding: 8bit

form-data; name="14170058206940.xls"; filename="00004000.xls"
--DoZtX5jrOIxJTocysPzYJ1WVqtoagXMQHHqho4i
Content-Disposition: form-data; name="file"; filename="00000000.xls"
Content-Type: application/vnd.ms-excel
Content-Transfer-Encoding: binary

<actual file content, not shown here>
--DoZtX5jrOIxJTocysPzYJ1WVqtoagXMQHHqho4i--

Cookie Data:
$Version=0; JSESSIONID=AC79777AEFE5AFC690623FCCB09E5DD5; $Path=/

Request Headers:
Connection: keep-alive
Content-Length: 34786
Content-Type: multipart/form-data; boundary=DoZtX5jrOIxJTocysPzYJ1WVqtoagXMQHHqho4i
Host: serverIP
User-Agent: Apache-HttpClient/4.2.6 (java 1.5)

        看来 JMeter 把我们的 Content-Disposition 参数名字都丢了。

        2. 添加 HTTP 信息头管理器


        如图所示,我们期待服务器接收到的 filename 值是 40004000.xls,而不是 00000000.xls。
        然后我们发次请求,然后查看本次 HTTP 请求,可以看到以下信息:
POST http://serverIP/upload/batchImport/merAdd/20141128/1

POST data:
--BNKvCNweqwpTJToYINcDn6JJfzjazBE550a-
Content-Disposition: form-data; name="file"; filename="00000000.xls"
Content-Type: application/vnd.ms-excel
Content-Transfer-Encoding: binary

<actual file content, not shown here>
--BNKvCNweqwpTJToYINcDn6JJfzjazBE550a---

Cookie Data:
$Version=0; JSESSIONID=49AB53310FB7241B5544B4E747A58F80; $Path=/

Request Headers:
Connection: keep-alive
Content-Disposition: form-data; name="file"; filename="40004000.xls"
Content-Length: 34535
Content-Type: multipart/form-data; boundary=BNKvCNweqwpTJToYINcDn6JJfzjazBE550a-
Host: serverIP
User-Agent: Apache-HttpClient/4.2.6 (java 1.5)

        这次 JMeter 没有把我们的 Content-Disposition 弄丢,它出现在了 Request Headers 里边。但是服务器貌似读取的是 POST data 中 Content-Disposition 里的那个 filename。有服务器返回给客户端的存储路径为证:/batchImport/merAdd/20141128/1/00000000.xls

        3. 使用 BeanShell


        如图所示,我们期待服务器接收到的 filename 值是 40004004.xls,而不是 00000000.xls。我们怀着期待的心情再次向服务器发起请求。请求如下:
POST http://serverIP/upload/batchImport/merAdd/20141128/1

POST data:
--LWS2eUVPPPuDxcfT7dS4RpqQJe2uP_0lAme6Qx2Q
Content-Disposition: form-data; name="file"; filename="00000000.xls"
Content-Type: application/vnd.ms-excel
Content-Transfer-Encoding: binary

<actual file content, not shown here>
--LWS2eUVPPPuDxcfT7dS4RpqQJe2uP_0lAme6Qx2Q--

Cookie Data:
$Version=0; JSESSIONID=81514F48024CE0B4CB53DB0CBC283C11; $Path=/

Request Headers:
Connection: keep-alive
Content-Length: 34543
Content-Type: multipart/form-data; boundary=LWS2eUVPPPuDxcfT7dS4RpqQJe2uP_0lAme6Qx2Q
Host: serverIP
User-Agent: Apache-HttpClient/4.2.6 (java 1.5)

        结果是不管是前置 BeanShell,还是 BeanShell 监听器,显然对于我们的需求无能为力。
        向万能的谷歌求助,得到的结果基本都是 It's impossible。
        最后笔者怀着郁闷的心情去找这个项目的责任人,试图说服他,服务器不应该以客户端传来的 filename 对保存文件进行命名,应该有自己的一套随机生成文件名的规则,得到的答复却是:NO。
        万般无奈之下,笔者只好去看 JMeter 的源代码了。好嘛,JMeter 2.12 的源代码(src 目录下的纯 *.java 文件)足足有 6.75 MB。而且还是用 Ant 代码管理的,黑压压的看着森人。哎,不爽也得看,没办法啊,谁让咱要吃性能测试这碗饭呢,工作总是要继续的吧。
        硬着头皮看了一下午,结果很不幸,发现 JMeter 是把 Content-Disposition 里的 filename 写死的,它压根儿就没想留给用户对  filename 进行参数化途径!
        比如 org.apache.jmeter.protocol.http.sampler.PostWriter 的 writeStartFileMultipart 方法是这样写死的:
  1. /** 
  2.  * Write the start of a file multipart, up to the point where the 
  3.  * actual file content should be written 
  4.  */  
  5. private void writeStartFileMultipart(OutputStream out, String filename,  
  6.         String nameField, String mimetype)  
  7.         throws IOException {  
  8.     write(out, "Content-Disposition: form-data; name=\""); // $NON-NLS-1$  
  9.     write(out, nameField);  
  10.     write(out, "\"; filename=\"");// $NON-NLS-1$  
  11.     write(out, new File(filename).getName());  
  12.     writeln(out, "\""); // $NON-NLS-1$  
  13.     writeln(out, "Content-Type: " + mimetype); // $NON-NLS-1$  
  14.     writeln(out, "Content-Transfer-Encoding: binary"); // $NON-NLS-1$  
  15.     out.write(CRLF);  
  16. }  

        虽然很气愤,但觉着总算没来错地方,继续看源码。
        查看 PostWriter 的单元测试代码 org.apache.jmeter.protocol.http.sampler.PostWriterTest,在测试 sendPostData 方法里有以下语句:
        postWriter.setHeaders(connection, sampler);
        postWriter.sendPostData(connection, sampler);
        也就是说先写头,再写 post 包体。org.apache.jmeter.protocol.http.sampler.HTTPJavaImpl 的 sample 方法也印证了这个:
  1.     try {  
  2.         conn = setupConnection(url, method, res);  
  3.         // Attempt the connection:  
  4.         savedConn = conn;  
  5.         conn.connect();  
  6.         break;  
  7.     } catch (BindException e) {  
  8.         if (retry >= MAX_CONN_RETRIES) {  
  9.             log.error("Can't connect after "+retry+" retries, "+e);  
  10.             throw e;  
  11.         }  
  12.         log.debug("Bind exception, try again");  
  13.         if (conn!=null) {  
  14.             savedConn = null; // we don't want interrupt to try disconnection again  
  15.             conn.disconnect();  
  16.         }  
  17.         setUseKeepAlive(false);  
  18.         continue; // try again  
  19.     } catch (IOException e) {  
  20.         log.debug("Connection failed, giving up");  
  21.         throw e;  
  22.     }  
  23. }  
  24. if (retry > MAX_CONN_RETRIES) {  
  25.     // This should never happen, but...  
  26.     throw new BindException();  
  27. }  
  28. // Nice, we've got a connection. Finish sending the request:  
  29. if (method.equals(HTTPConstants.POST)) {  
  30.     String postBody = sendPostData(conn);  
  31.     res.setQueryString(postBody);  
  32. }     

        conn = setupConnection(url, method, res); 建立连接的时候就将头写好了(参加下边的 setupConnection 方法),后边的 String postBody = sendPostData(conn); 才开始发送文件等包体。
        为什么 JMeter 设置 HTTP 信息头里不管用呢?
        看看 org.apache.jmeter.protocol.http.sampler.HTTPJavaImpl 的 setupConnection 方法:
  1.     protected HttpURLConnection setupConnection(URL u, String method, HTTPSampleResult res) throws IOException {  
  2.         SSLManager sslmgr = null;  
  3.         if (HTTPConstants.PROTOCOL_HTTPS.equalsIgnoreCase(u.getProtocol())) {  
  4.             try {  
  5.                 sslmgr=SSLManager.getInstance(); // N.B. this needs to be done before opening the connection  
  6.             } catch (Exception e) {  
  7.                 log.warn("Problem creating the SSLManager: ", e);  
  8.             }  
  9.         }  
  10.   
  11.   
  12.         final HttpURLConnection conn;  
  13.         final String proxyHost = getProxyHost();  
  14.         final int proxyPort = getProxyPortInt();  
  15.         if (proxyHost.length() > 0 && proxyPort > 0){  
  16.             Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort));  
  17.             //TODO - how to define proxy authentication for a single connection?  
  18.             // It's not clear if this is possible  
  19. //            String user = getProxyUser();  
  20. //            if (user.length() > 0){  
  21. //                Authenticator auth = new ProxyAuthenticator(user, getProxyPass());  
  22. //            }  
  23.             conn = (HttpURLConnection) u.openConnection(proxy);  
  24.         } else {  
  25.             conn = (HttpURLConnection) u.openConnection();  
  26.         }  
  27.   
  28.   
  29.         // Update follow redirects setting just for this connection  
  30.         conn.setInstanceFollowRedirects(getAutoRedirects());  
  31.   
  32.   
  33.         int cto = getConnectTimeout();  
  34.         if (cto > 0){  
  35.             conn.setConnectTimeout(cto);  
  36.         }  
  37.   
  38.   
  39.         int rto = getResponseTimeout();  
  40.         if (rto > 0){  
  41.             conn.setReadTimeout(rto);  
  42.         }  
  43.   
  44.   
  45.         if (HTTPConstants.PROTOCOL_HTTPS.equalsIgnoreCase(u.getProtocol())) {  
  46.             try {  
  47.                 if (null != sslmgr){  
  48.                     sslmgr.setContext(conn); // N.B. must be done after opening connection  
  49.                 }  
  50.             } catch (Exception e) {  
  51.                 log.warn("Problem setting the SSLManager for the connection: ", e);  
  52.             }  
  53.         }  
  54.   
  55.   
  56.         // a well-bahaved browser is supposed to send 'Connection: close'  
  57.         // with the last request to an HTTP server. Instead, most browsers  
  58.         // leave it to the server to close the connection after their  
  59.         // timeout period. Leave it to the JMeter user to decide.  
  60.         if (getUseKeepAlive()) {  
  61.             conn.setRequestProperty(HTTPConstants.HEADER_CONNECTION, HTTPConstants.KEEP_ALIVE);  
  62.         } else {  
  63.             conn.setRequestProperty(HTTPConstants.HEADER_CONNECTION, HTTPConstants.CONNECTION_CLOSE);  
  64.         }  
  65.   
  66.   
  67.         conn.setRequestMethod(method);  
  68.         setConnectionHeaders(conn, u, getHeaderManager(), getCacheManager());  
  69.         String cookies = setConnectionCookie(conn, u, getCookieManager());  
  70.   
  71.   
  72.         setConnectionAuthorization(conn, u, getAuthManager());  
  73.   
  74.   
  75.         if (method.equals(HTTPConstants.POST)) {  
  76.             setPostHeaders(conn);  
  77.         } else if (method.equals(HTTPConstants.PUT)) {  
  78.             setPutHeaders(conn);  
  79.         }  
  80.   
  81.   
  82.         if (res != null) {  
  83.             res.setRequestHeaders(getConnectionHeaders(conn));  
  84.             res.setCookies(cookies);  
  85.         }  
  86.   
  87.   
  88.         return conn;  
  89.     }  

        这个方法里建立了一个 http 连接,并且在返回连接之前,先把用户 HTTP 信息头管理器里的内容写进连接(setConnectionHeaders(conn, u, getHeaderManager(), getCacheManager()); 句),然后调用 PostWriter 的写头方法(就是 setPostHeaders(conn); 句)。这也解释了本文上边的两个 Content-Disposition 的问题。
        也就是说 Content-Disposition 头写了两次!很不幸的是,HTTP 并没对 Content-Disposition 做重复性校验!更不幸的是,即便是 HTTP 会对 Content-Disposition 做重复性校验,我们的头信息管理器里自定义的也不会起效,上边代码已经说明了,JMeter 会先写头信息管理器里的属性,然后再调用 PostWriter 进行 Content-Disposition 写入,后者会对前者进行覆盖!
        这简直是糟透了。这应该是 JMeter 的一个 bug,或者说做的不够好的地方,因为它把我们自定义 Content-Disposition 这条路堵死了。

        解决方案

        HTTP 请求 - Implementation 选择的是 Java 的解决办法

        自己动手,丰衣足食。既然 JMeter 把这条路堵死了,那么我们可以去把这条路打开 —— 只需调整下 PostWriter 的源代码的 writeStartFileMultipart 即可:
  1. /** 
  2.  * Write the start of a file multipart, up to the point where the 
  3.  * actual file content should be written 
  4.  */  
  5. private void writeStartFileMultipart(OutputStream out, String filename,  
  6.         String nameField, String mimetype)  
  7.         throws IOException {  
  8.     write(out, "Content-Disposition: form-data; name=\""); // $NON-NLS-1$  
  9.     write(out, nameField);  
  10.     write(out, "\"; filename=\"");// $NON-NLS-1$  
  11.     write(out, nameField);  
  12.     writeln(out, "\""); // $NON-NLS-1$  
  13.     writeln(out, "Content-Type: " + mimetype); // $NON-NLS-1$  
  14.     writeln(out, "Content-Transfer-Encoding: binary"); // $NON-NLS-1$  
  15.     out.write(CRLF);  
  16. }  

        其实只改了一句,就是把原来的 write(out, new File(filename).getName()); 改为 write(out, nameField);
        然后将 JMeter 安装目录下的 lib/ext 目录中的 ApacheJMeter_http.jar 解压缩,将我们修改编译好的 PostWriter.class 把原来的翻盖掉,重新打包(jar -cvf ApacheJMeter_http.jar *),把原有的 ApacheJMeter_http.jar 删掉,使用新打包的。
        上边是采样器 JVM 默认 HTTP 请求的解决办法(也就是你的采样器 - HTTP 请求 - Implementation 选择的是 Java,参考下图)。如果你没选,JMeter 默认是 HttpClient4。笔者就是使用的默认的,也就是说没选 Implementation。如果我们 HTTP 请求选中的是 HttpClient4,或者 HttpClient3.1,又该如何调整 JMeter 源码呢?以下是 HttpClient4 的解决办法。

        HTTP 请求 - Implementation 选择的是 HttpClient4 的解决办法

        查看 org.apache.jmeter.protocol.http.sampler.HTTPHC4Impl 的 sendPostData 方法,
        找到以下句:
  1. ViewableFileBody[] fileBodies = new ViewableFileBody[files.length];  
  2. for (int i=0; i < files.length; i++) {  
  3.     HTTPFileArg file = files[i];  
  4.     fileBodies[i] = new ViewableFileBody(new File(file.getPath()), file.getMimeType());  
  5.     multiPart.addPart(file.getParamName(),fileBodies[i]);  
  6. }  
  7. post.setEntity(multiPart);  
  8.   
  9.   
  10. if (multiPart.isRepeatable()){  
  11.     ByteArrayOutputStream bos = new ByteArrayOutputStream();  
  12.     for(ViewableFileBody fileBody : fileBodies){  
  13.         fileBody.hideFileData = true;  
  14.     }  
  15.     multiPart.writeTo(bos);  
  16.     for(ViewableFileBody fileBody : fileBodies){  
  17.         fileBody.hideFileData = false;  
  18.     }  
  19.     bos.flush();  
  20.     // We get the posted bytes using the encoding used to create it  
  21.     postedBody.append(new String(bos.toByteArray(),  
  22.             contentEncoding == null ? "US-ASCII" // $NON-NLS-1$ this is the default used by HttpClient  
  23.             : contentEncoding));  
  24.     bos.close();  

        可以看出,文件信息就在 postedBody.append(new String(bos.toByteArray(), 句写入 post 体(读者感兴趣的话可以去断点跟踪,或者打 log 验证),它写入的就是这个 ViewableFileBody 对象。找到 ViewableFileBody 类,其源码为:
  1. // Helper class so we can generate request data without dumping entire file contents  
  2. private static class ViewableFileBody extends FileBody {  
  3.     private boolean hideFileData;  
  4.       
  5.     public ViewableFileBody(File file, String mimeType) {  
  6.         super(file, mimeType);  
  7.         hideFileData = false;  
  8.     }  
  9.   
  10.   
  11.     @Override  
  12.     public void writeTo(final OutputStream out) throws IOException {  
  13.         if (hideFileData) {  
  14.             out.write("<actual file content, not shown here>".getBytes());// encoding does not really matter here  
  15.         } else {  
  16.             super.writeTo(out);  
  17.         }  
  18.     }  
  19. }  

        可以看出它继承自 org.apache.http.entity.mime.content.FileBody,FileBody 有 getFilename 方法,查看其源码:
  1. public String getFilename() {  
  2.     return this.file.getName();  
  3. }  

        好了,就从这里入手了。修改 org.apache.jmeter.protocol.http.sampler.HTTPHC4Impl 的内部类 ViewableFileBody 如下:
  1. // Helper class so we can generate request data without dumping entire file contents  
  2. private static class ViewableFileBody extends FileBody {  
  3.     private boolean hideFileData;  
  4.       
  5.     public ViewableFileBody(File file, String mimeType) {  
  6.         super(file, mimeType);  
  7.         hideFileData = false;  
  8.     }  
  9.   
  10.   
  11.     @Override  
  12.     public void writeTo(final OutputStream out) throws IOException {  
  13.         if (hideFileData) {  
  14.             out.write("<actual file content, not shown here>".getBytes());// encoding does not really matter here  
  15.         } else {  
  16.             super.writeTo(out);  
  17.         }  
  18.     }  
  19.       
  20.     @Override  
  21.     public String  getFilename() {  
  22.         String filename = this.getFile().getName();  
  23.         filename = System.currentTimeMillis() + filename.substring(filename.lastIndexOf('.'));  
  24.         return filename;  
  25.           
  26.     }  
  27. }  

        OK,编译 - 覆盖 - 打包,然后把 JMeter 安装目录下的 lib/ext 下的原有的 ApacheJMeter_http.jar 删掉,使用新打包的。重新执行测试,截取的 HTTP 请求如下:
POST http://serverIP/upload/batchImport/merAdd/20141128/1

POST data:
--QpmvliwpJdOaJSQGKd-Ux3tR_7HnPX3s1K8KA
Content-Disposition: form-data; name="file"; filename="1417182984171.xls"
Content-Type: application/vnd.ms-excel
Content-Transfer-Encoding: binary

<actual file content, not shown here>
--QpmvliwpJdOaJSQGKd-Ux3tR_7HnPX3s1K8KA--

Cookie Data:
$Version=0; JSESSIONID=56E8E454EA4F1378AAE45DD0A89A9FE5; $Path=/

Request Headers:
Connection: keep-alive
Content-Length: 34542
Content-Type: multipart/form-data; boundary=QpmvliwpJdOaJSQGKd-Ux3tR_7HnPX3s1K8KA
Host: serverIP
User-Agent: Apache-HttpClient/4.2.6 (java 1.5)

        可以看到,filename 终于不再是 00000000.xls 了,服务器返回的存储路径是 /batchImport/merAdd/20141128/1/1417182984171.xls。成功了。
        备注:笔者下载的 JMeter Binaries 和 Source 的版本都是 2.12(也就是说本文所引用的 JMeter 源代码都可以从 JMeter2.12 中找到,官方下载地址:https://archive.apache.org/dist/jmeter/source/apache-jmeter-2.12_src.zip)。

        HTTP 请求 - Implementation 选择的是 HttpClient3.1 的解决办法

        嗯,对,没错,就是 org.apache.jmeter.protocol.http.sampler.HTTPHC3Impl。这个留给聪明的读者朋友去实现吧:)    
本站仅提供存储服务,所有内容均由用户发布,如发现有害或侵权内容,请点击举报
打开APP,阅读全文并永久保存 查看更多类似文章
猜你喜欢
类似文章
【热】打开小程序,算一算2024你的财运
JMeter-Http Cookie Manager--研究资料收集
Android封装的http请求实用工具类
Android HttpClient上传文件与Httpconnection知识小结
android 中对apache httpclient及httpurlconnection的选择
httpclient
Jmeter在压力测试过程中出现Out Time超时的错误的解决方案
更多类似文章 >>
生活服务
热点新闻
分享 收藏 导长图 关注 下载文章
绑定账号成功
后续可登录账号畅享VIP特权!
如果VIP功能使用有故障,
可点击这里联系客服!

联系客服