这几天在项目中遇到的问题,稍微整理一下。
HTML 表单是我们常用来进行浏览器与服务器数据交互的途径,除了传递普通的文本数据,通过 type="file"的 input 控件,我们还可以向服务器发送文件。但在目前的实际使用中,基于 HTML 表单的文件选择框有着下面两个不尽人意之处:
对于文件选择的数量,事实上在 HTML4.01 规范 中对文件选择框的描述虽然没有明确提出可以同时选择多个文件,但从其用词可以看出,文件选择控件事实上是应该支持同时选择多个文件的:
This control type allows the user to select files so that their contents may be submitted with a form. The INPUT element is used to create a file select control.
可以看见,“文件”一词使用的是复数形式。同时,RFC 1867: Form-based File Upload in HTML 的 3.3 节中,也有这样的描述:
If multiple files are selected, they should be transferred together using the multipart/mixed format.
说明文件选择框应当能够处理同时选择多个文件的情况。但需要注意的是,这个描述出现在这篇规范文档的 3. Suggested implementation 章节中,仅仅是建议的实现方式,所以除了以前有几个版本的 Opera 曾经支持过多文件选择,其余浏览器都仅仅实现了单个文件的选择功能。
为了解决这个问题,HTML5 中进行了明确 ,带有 multiple 属性的 input 控件将可以有多个取值,自然也包括文件选择框。但这不是今天说的重点,目前的困境还是摆在我们眼前的。
对于文件上传的进度,就更不好办了:一是文件上传本身就无法使用 AJAX 来进行页内无刷新上传,需要使用隐藏 iframe 来实现。而获取上传进度还得依靠 AJAX 向服务器端发送请求来获取,利用的手段可以是 AJAX 轮询、iframe + htmlfile 甚至是 WebSocket,但本质上做的事情都有违常理:浏览器自己发送了多少内容,却需要一遍遍地请求服务器来进行告知。其次,由于文件上传和进度查询采用的 是不同的数据通道,还得使用额外数据来维护两者之间的联系,显得非常繁复。
好了,说了那么一大段就是为了引出目前使用 Flash 来进行文件上传的原因。目前我们项目采用的是SWFUpload 这个 Flash 文件上传工具,支持文件批量选择,并且可以方便地进行上传进度的查询。但是在我们的项目的之前版本中,当页面需要使用 session 中的用户信息时,却无法在 Firefox 下使用它进行正常的文件上传。
在 IE 下,上传工具能够工作,而一到 Firefox 下,SWFUpload 就会提示错误 302。通过从 Fiddler 抓包,发现上传文件的请求被 302 重定向到了登陆页面,所以无法正常上传文件。也就是说,之前用户登陆后的 session 信息在 Flash 发送的上传请求中丢失了。再一看请求的 header,当中没有可以用来标识当前 session ID 的 cookie 值(对于 J2EE 为 JSESSIONID,PHP 为 PHPSESSID 等)。这是什么原因呢?经过一番搜索,找到了 Adobe 官方的一篇文章 ,说道:
A FileReference.upload() currently does not use the same cookies as your browser session if you are using Firefox (on Windows). In such cases, cookie information has to be inserted manually.
好吧,这个解释虽然很清楚地表明,Flash 在 Firefox 下进行文件上传不会使用浏览器 cookie 中的 session ID,但是貌似并没有回答文章标题当中的“为什么”,即为什么不使用和浏览器中的 session ID 来进行通信。
看来 cookie 是指望不上了,那只能像上面说的那样“手工”传递 session ID 了。于是我们可以在 URL 中拼接一个如 ";jsessionid=blahblah" 的字符串(注意要拼在路径之后,参数之前),像下面这样:
http://example.com/app;jsessionid=blahblah?q=blah
按Servlet 规范 7.1.3 节中的规定,当客户端不接受 cookie 时,Servlet 容器应当能解析出这类 URL 中的 JSESSIONID,来维持当前 session 的状态。其他服务器端环境中也有类似的机制。于是我们对文件上传功能做了一定的修改:在用户进入上传页面时,就把当前的 session ID 写到页面中,作为 JavaScript 变量拼接到 SWFUpload 上传文件的目标 URL 中,Flash 就可以利用我们人工传入的 JSESSIONID 来维持登陆状态了。当开始上传时,Flash 通过自己的连接通道向服务器发送数据,但是因为请求的路径中包含了 session 信息所以可以和浏览器的 session 状态保持同步,服务器可以通过 URL 中的 session ID 来获取用户登录的信息。
于是,302 错误被解决了,看起来一切都 OK 了。可是,不久我们又发现了新的问题:当用户打开上传页面后较长时间没有操作,服务器端的 session 超时了,这个时候进行文件的上传用的还是超时的那个 session ID,所以会失败也是应该的。但是当这个时候用户重新进行了登录,再次进入上传页面上传,却仍然会失败,只有重新启动浏览器才能再次进行上传。这是为什么 呢?从服务器端错误来看,还是用户的登陆信息丢失的问题,也就是说,Flash 在上传文件时,还是没能将用户进行过登陆的这个 session ID 成功传递给服务器端。通过在 Fiddler 中抓包分析,终于找到了原因。下面几张示意图大致描述了这个问题产生的过程:
登 陆后 Flash 第一次进行上传时,自身没有 JSESSIONID 这个 Cookie,所以使用手工拼接到请求 URL 中的浏览器 JSESSIONID 即 XXX 进行提交,服务器解析出了 JSESSIONID 并且在内存中找到了其对应的 session 信息,成功进行了身份验证,最后返回上传成功的信息。
然 而当 XXX 这个 session ID 超时以后,Flash 仍然以刚才重写过的 URL 发送请求(图中[1]),服务器解析出 JSESSIONID 后发现已经没有 XXX 这个 session 了,于是重新创建了一个 ID 为 YYY 的 session(图中[2]),并且在返回的响应中除了登陆失败的信息外还加入了 SET-COOKIE:JSESSIONID=YYY 的请求头(图中[3])。于是这时在 Flash 的 LSO (“Flash cookies”) 中却把这个不带用户登录信息的 session ID 保存了下来。
发 现登陆超时后,用户重新登陆,在新的 ID 为 ZZZ 的 session 中留下了有效的登录信息,进入上传页面后,生成了新的拼接 JSESSIONID 的 URL 传递给 Flash 控件,此时进行上传时, Flash 发送的请求既包含了 URL 中的 JSESSIONID,也带了 Cookie 请求头中的 JSESSIONID(图中[1])。按 Servlet 规范,当请求有 Cookie 头中的 JSESSIONID 时,URL 中的就被忽略了,于是服务端判断此请求应该在 YYY 这个 session 中进行处理(图中[2]),于是导致了用户看似已登录,Flash 请求却使用了另一未登录的 session 导致上传失败。
所以,要让 Flash 能够正确使用 URL 中指定的 session,就必须防止 flash cookies 中记录 JSESSIONID 值。如果 Set-cookie 头是 Web 服务器或 Servlet 容器自动加上的,那就需要我们在 Web 应用中将对应的 Cookie 值删除。下面以 Java 代码为例,我们可以通过添加一个名称同样为 JSESSIONID 的立即失效(通过设置 Max-Age=0,参见RFC 2109: HTTP State Management Mechanism 的 4.3.3 节)的 Cookie 来覆盖之前的值:
Java代码:
Cookie cookie =new Cookie("JSESSIONID", "");
cookie.setMaxAge(0);
response.addCookie(cookie);
这样在 session 超时时,上传文件失败返回的请求中会带这样的头:
Set-cookie:JSESSIONID=YYY;
Set-cookie:JSESSIONID="";Max-Age=0
于是在 flash cookie 中 YYY 这个 JSESSIONID 刚刚被设置就被清除了,在重新登陆后 Flash 发送的请求就不会带有 Cookie 头,不会覆盖 URL 中设置的值了。
总结一下,虽然这个过程说起来挺复杂,但解决方法还是比较简单明了的: