问题描述
文件上传时,用户定义 Content-Disposition 是失效的。
笔者在写压力测试脚本的时候,有个上传页面,服务器是根据用户传过来的 Content-Disposition 里的 filename 值来定义保存文件的文件名的。但是测试人员不可能为每一次请求都准备一个不同的文件(这个工作量海了去了),所以 JMeter 传给服务器的 Content-Disposition 里的 filename 必须是随机而不重复的。
有人问,用户真实上传时,浏览器传给服务器的 filename 也是上传文件名吗?不是的,js 这样修改的 filename:
- uploader.onBeforeUploadItem = function (item) {
- //修改名字
- var timeStamp = new Date().getTime();
- var fileName = item.file.name;
- item.file.name = timeStamp + fileName.substr(fileName.lastIndexOf('.'));
- var day = $filter('date')(new Date(), 'yyyyMMdd');
- item.url = [item.url, "batchImport", item.importType, day, session.userId].join("/");
- };
笔者尝试了多种办法,试图修改服务器接收到的 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 方法是这样写死的:
- /**
- * Write the start of a file multipart, up to the point where the
- * actual file content should be written
- */
- private void writeStartFileMultipart(OutputStream out, String filename,
- String nameField, String mimetype)
- throws IOException {
- write(out, "Content-Disposition: form-data; name=\""); // $NON-NLS-1$
- write(out, nameField);
- write(out, "\"; filename=\"");// $NON-NLS-1$
- write(out, new File(filename).getName());
- writeln(out, "\""); // $NON-NLS-1$
- writeln(out, "Content-Type: " + mimetype); // $NON-NLS-1$
- writeln(out, "Content-Transfer-Encoding: binary"); // $NON-NLS-1$
- out.write(CRLF);
- }
虽然很气愤,但觉着总算没来错地方,继续看源码。
查看 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 方法也印证了这个:
- try {
- conn = setupConnection(url, method, res);
- // Attempt the connection:
- savedConn = conn;
- conn.connect();
- break;
- } catch (BindException e) {
- if (retry >= MAX_CONN_RETRIES) {
- log.error("Can't connect after "+retry+" retries, "+e);
- throw e;
- }
- log.debug("Bind exception, try again");
- if (conn!=null) {
- savedConn = null; // we don't want interrupt to try disconnection again
- conn.disconnect();
- }
- setUseKeepAlive(false);
- continue; // try again
- } catch (IOException e) {
- log.debug("Connection failed, giving up");
- throw e;
- }
- }
- if (retry > MAX_CONN_RETRIES) {
- // This should never happen, but...
- throw new BindException();
- }
- // Nice, we've got a connection. Finish sending the request:
- if (method.equals(HTTPConstants.POST)) {
- String postBody = sendPostData(conn);
- res.setQueryString(postBody);
- }
conn = setupConnection(url, method, res); 建立连接的时候就将头写好了(参加下边的 setupConnection 方法),后边的 String postBody = sendPostData(conn); 才开始发送文件等包体。
为什么 JMeter 设置 HTTP 信息头里不管用呢?
看看 org.apache.jmeter.protocol.http.sampler.HTTPJavaImpl 的 setupConnection 方法:
- protected HttpURLConnection setupConnection(URL u, String method, HTTPSampleResult res) throws IOException {
- SSLManager sslmgr = null;
- if (HTTPConstants.PROTOCOL_HTTPS.equalsIgnoreCase(u.getProtocol())) {
- try {
- sslmgr=SSLManager.getInstance(); // N.B. this needs to be done before opening the connection
- } catch (Exception e) {
- log.warn("Problem creating the SSLManager: ", e);
- }
- }
-
-
- final HttpURLConnection conn;
- final String proxyHost = getProxyHost();
- final int proxyPort = getProxyPortInt();
- if (proxyHost.length() > 0 && proxyPort > 0){
- Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort));
- //TODO - how to define proxy authentication for a single connection?
- // It's not clear if this is possible
- // String user = getProxyUser();
- // if (user.length() > 0){
- // Authenticator auth = new ProxyAuthenticator(user, getProxyPass());
- // }
- conn = (HttpURLConnection) u.openConnection(proxy);
- } else {
- conn = (HttpURLConnection) u.openConnection();
- }
-
-
- // Update follow redirects setting just for this connection
- conn.setInstanceFollowRedirects(getAutoRedirects());
-
-
- int cto = getConnectTimeout();
- if (cto > 0){
- conn.setConnectTimeout(cto);
- }
-
-
- int rto = getResponseTimeout();
- if (rto > 0){
- conn.setReadTimeout(rto);
- }
-
-
- if (HTTPConstants.PROTOCOL_HTTPS.equalsIgnoreCase(u.getProtocol())) {
- try {
- if (null != sslmgr){
- sslmgr.setContext(conn); // N.B. must be done after opening connection
- }
- } catch (Exception e) {
- log.warn("Problem setting the SSLManager for the connection: ", e);
- }
- }
-
-
- // a well-bahaved browser is supposed to send 'Connection: close'
- // with the last request to an HTTP server. Instead, most browsers
- // leave it to the server to close the connection after their
- // timeout period. Leave it to the JMeter user to decide.
- if (getUseKeepAlive()) {
- conn.setRequestProperty(HTTPConstants.HEADER_CONNECTION, HTTPConstants.KEEP_ALIVE);
- } else {
- conn.setRequestProperty(HTTPConstants.HEADER_CONNECTION, HTTPConstants.CONNECTION_CLOSE);
- }
-
-
- conn.setRequestMethod(method);
- setConnectionHeaders(conn, u, getHeaderManager(), getCacheManager());
- String cookies = setConnectionCookie(conn, u, getCookieManager());
-
-
- setConnectionAuthorization(conn, u, getAuthManager());
-
-
- if (method.equals(HTTPConstants.POST)) {
- setPostHeaders(conn);
- } else if (method.equals(HTTPConstants.PUT)) {
- setPutHeaders(conn);
- }
-
-
- if (res != null) {
- res.setRequestHeaders(getConnectionHeaders(conn));
- res.setCookies(cookies);
- }
-
-
- return conn;
- }
这个方法里建立了一个 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 即可:
- /**
- * Write the start of a file multipart, up to the point where the
- * actual file content should be written
- */
- private void writeStartFileMultipart(OutputStream out, String filename,
- String nameField, String mimetype)
- throws IOException {
- write(out, "Content-Disposition: form-data; name=\""); // $NON-NLS-1$
- write(out, nameField);
- write(out, "\"; filename=\"");// $NON-NLS-1$
- write(out, nameField);
- writeln(out, "\""); // $NON-NLS-1$
- writeln(out, "Content-Type: " + mimetype); // $NON-NLS-1$
- writeln(out, "Content-Transfer-Encoding: binary"); // $NON-NLS-1$
- out.write(CRLF);
- }
其实只改了一句,就是把原来的 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 方法,
找到以下句:
- ViewableFileBody[] fileBodies = new ViewableFileBody[files.length];
- for (int i=0; i < files.length; i++) {
- HTTPFileArg file = files[i];
- fileBodies[i] = new ViewableFileBody(new File(file.getPath()), file.getMimeType());
- multiPart.addPart(file.getParamName(),fileBodies[i]);
- }
- post.setEntity(multiPart);
-
-
- if (multiPart.isRepeatable()){
- ByteArrayOutputStream bos = new ByteArrayOutputStream();
- for(ViewableFileBody fileBody : fileBodies){
- fileBody.hideFileData = true;
- }
- multiPart.writeTo(bos);
- for(ViewableFileBody fileBody : fileBodies){
- fileBody.hideFileData = false;
- }
- bos.flush();
- // We get the posted bytes using the encoding used to create it
- postedBody.append(new String(bos.toByteArray(),
- contentEncoding == null ? "US-ASCII" // $NON-NLS-1$ this is the default used by HttpClient
- : contentEncoding));
- bos.close();
可以看出,文件信息就在 postedBody.append(new String(bos.toByteArray(), 句写入 post 体(读者感兴趣的话可以去断点跟踪,或者打 log 验证),它写入的就是这个 ViewableFileBody 对象。找到 ViewableFileBody 类,其源码为:
- // Helper class so we can generate request data without dumping entire file contents
- private static class ViewableFileBody extends FileBody {
- private boolean hideFileData;
-
- public ViewableFileBody(File file, String mimeType) {
- super(file, mimeType);
- hideFileData = false;
- }
-
-
- @Override
- public void writeTo(final OutputStream out) throws IOException {
- if (hideFileData) {
- out.write("<actual file content, not shown here>".getBytes());// encoding does not really matter here
- } else {
- super.writeTo(out);
- }
- }
- }
可以看出它继承自 org.apache.http.entity.mime.content.FileBody,FileBody 有 getFilename 方法,查看其源码:
- public String getFilename() {
- return this.file.getName();
- }
好了,就从这里入手了。修改 org.apache.jmeter.protocol.http.sampler.HTTPHC4Impl 的内部类 ViewableFileBody 如下:
- // Helper class so we can generate request data without dumping entire file contents
- private static class ViewableFileBody extends FileBody {
- private boolean hideFileData;
-
- public ViewableFileBody(File file, String mimeType) {
- super(file, mimeType);
- hideFileData = false;
- }
-
-
- @Override
- public void writeTo(final OutputStream out) throws IOException {
- if (hideFileData) {
- out.write("<actual file content, not shown here>".getBytes());// encoding does not really matter here
- } else {
- super.writeTo(out);
- }
- }
-
- @Override
- public String getFilename() {
- String filename = this.getFile().getName();
- filename = System.currentTimeMillis() + filename.substring(filename.lastIndexOf('.'));
- return filename;
-
- }
- }
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。这个留给聪明的读者朋友去实现吧:)