首页 > 代码库 > JMeter 上传文件时,如何参数化 Content-Disposition 的 filename?

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 参数

添加 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 信息头管理器

添加 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

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)。如果我们 HTTP 请求选中的是 HttpClient4,或者 HttpClient3.1 呢(如果你没选,JMeter 默认是 HttpClient4,笔者就是使用的默认的,也就是说没选 Implementation)?以下是 HttpClient4 的解决办法。
Implementation 选择

        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 = http://www.mamicode.com/true;>
        可以看出,文件信息就在 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 = http://www.mamicode.com/false;>
        可以看出它继承自 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 = http://www.mamicode.com/false;>
        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。

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

        嗯,对,没错,就是 org.apache.jmeter.protocol.http.sampler.HTTPHC3Impl。这个留给聪明的读者去实现吧:)

JMeter 上传文件时,如何参数化 Content-Disposition 的 filename?