JMeter 源码解析之一:JMeter 上传文件时,如何参数化 Content-Disposition 的 filename?
问题描述
文件上传时,用户定义 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)。