解析ASP.NET应用程序中上传文件的方案
在Web程序中上传文件是很常见的需求。利用HTTP协议上传文件的方式非常有限,最常见的莫过于使用<input type="file" />元素进行上传。这种上传方式会将内容使用multipart/form-data方案进行编码,并将内容POST到服务器端。使用multipart/form-data编码方式与默认的application/x-url-encoded编码方式相比,在大数据量情况下效率要高很多。
使用<input type="file" />上传文件最大的优势在于编程方便,几乎各种服务器端技术都对这种上传方式做了良好的封装,使得程序员能够直观地对客户端上传的文件进行处理。不过总体来说,这个协议并不适合做文件传输,解析数据流内容的代价相对较高,并且没有一些例如断点续传的机制来辅助,导致在上传大文件时经常会力不从心。
有朋友认为使用<input type="file" />上传文件最大的问题在于内存占用太高,由于需要将整个文件载入内存进行处理,导致如果用户上传文件太大,或者同时上传的用户太多,会造成服务器端内存耗尽。这个观点其实是错误的。对于某些服务器端的技术,例如Spring Framework,或者早期ASP.NET 1.1时,为了供程序处理,都会将用户上传的内容完全载入内存,这的确会带来问题。但是其实协议本身并没有规定服务器端应该使用何种方式来处理上传的文件。例如在现在的ASP.NET 2.0中就已经会在用户上传数据超过一定数量之后将其存在硬盘中的临时文件中,而这点对于开发人员完全透明,也就是说,开发人员可以像以前一样进行数据流的处理。
ASP.NET 2.0启用硬盘临时文件的阈值(threshold)是可配置的:
<system.web>
<httpRuntime
maxRequestLength="Int32"
requestLengthDiskThreshold="Int32" />
</system.web>
maxRequestLength自不必说,刚接触ASP.NET的朋友总会发现上传文件不能超过4M,这就是因为maxRequestLength的大小默认为4096,这就限制着每个请求的大小不得超过4096KB。这么做的目的是为了保护应用程序不受恶意请求的危害。当请求超过maxRequestLength之后,ASP.NET处理程序将不会处理该请求。这里和ASP.NET抛出一个异常是不同的,这就是为什么如果用户上传文件太大,看到的并非是ASP.NET应用程序中指定的错误页面(或者默认的),因为ASP.NET还没有对这个请求进行处理。requestLengthDiskThreshold就是刚才所提到的阈值,其默认值为256,即一个请求内容超过256KB时就会启用硬盘作为缓存。这个阈值理论上和客户端是否是在上传内容无关,只要客户端发来的请求大于这个值即可。因此,在ASP.NET 2.0中服务器的内存不会因为客户端的异常请求而耗尽。
如果我们需要在ASP.NET(如果没有特别说明,以下ASP.NET均指ASP.NET 2.0)应用中上传文件,我们一般就会直接使用<asp:FileUpload />控件进行文件上传。如果一个页面中存在<asp:FileUpload />控件,那么页面中form元素的enctype就会被自动改为multipart/form-data,而且我们可以在页面PostBack之后通过<asp:FileUpload />控件的引用来获得客户端通过该控件所上传得文件。不过,如果上传文件的功能需要较为特别的需求——例如需要进度条提示,<asp:FileUpload />控件就无能为力了。
确切地说,应该是<input type="file" />所能提供的支持非常有限,因此一些特殊需求我们不能实现——严格说来,应该是无法轻易地、直接地实现。这样,在实现这些功能时,我们就会绕一个大大的弯。为了避免每次实现相同功能时都要费神费时地走一遍弯路,因此出现了各种上传组件。上传组件提供了封装好的功能,使得我们在实现文件上传功能时变得轻松了很多。例如几乎所有的上传组件都直接或间接地提供了进度提示的功能,有的提供了当前的百分比数值,有的则直接提供了一套UI;有的组件只提供了简单的UI,有的却提供了一整套上传、删除的管理界面。此外,有的组件还提供了防止客户端恶意上传的能力。
关于ASP.NET下的上传组件,最广为流传的方式莫过于在ASP.NET Pipeline的BeginRequest事件中截获当前的HttpWorkerRequest对象,然后直接调用其ReadEntityBody等方法获取客户端传递过来的数据流,并加以分析和处理。在ASP.NET 1.1时期,这么做的目的是为了直接将数据写入硬盘,以避免上传内容消耗太多服务器内存,但是现在自然已经不会因为这个原因而这么做了。从客户端发起请求到一定规模的数据传输完毕需要一段时间,那么从HttpWorkerRequest对象中读取数据流自然需要一段时间,而在这段时间内,客户端可以使用新的请求进行轮询来获得当前上传的状况。这就是获得上传进度的最传统的做法。这个做法的原理很容易理解,但是写出一个完整的组件其实很不容易,尤其是各种细节方面的问题会让人感到防不胜防。此类组件中最成功且最著名的莫过于NeatUpload了。
NeatUpload是一个开源组件,使用LGPL(Lesser General Public License)许可协议,也就是说它是“business-friendly”的。NeatUpload可以在ASP.NET和mono中使用,能够将上传的文件存在硬盘中或者Sql Server数据库中。NeatUpload提供了两个服务器控件:<NeatUpload:InputFile>和<NeatUpload:ProgressBar>。前者用于代替<asp:FileUpload />,可以通过它访问到用户通过特定上传框上传的内容;后者则是一个进度条显示控件,负责使用弹出窗口或内联的形式显示上传的进度。弹出窗口自不必说,而所谓的“内联”方式其实只是在页面中嵌入一个Iframe元素,然后通过不断刷新iframe中的页面来进行进度展示而已——可见它和弹出窗口显示方式的区别仅仅在页面所处的位置。当然,如果我们希望将其移植为AJAX形式也不难,只需开发一个页面,继承NeatUpload提供的ProgressPage类,并通过ProgressPage所提供的一些属性(总字节数,已上传字节数,已花时间,etc.)来获得当前上传的进度,最后直接使用Response.Write输出JSON形式的数据即可。事实上原本在iframe(或新窗口)中的页面,也是继承了ProgressPage类,并且使用HTML的方式进行呈现而已,本质上并没有太大区别。
不过个人认为,其实NeatUpload的实用价值不高(这点稍后再述),它最大的意义还在于提供了一个完整的优秀的示例。NeatUpload设计精巧,注释完整,是个不可多得学习案例。如果能够将NeatUpload的代码研究一遍,那么相信在编程能力和ASP.NET的理解上都会上一个新的台阶。此外,在NeatUpload站点上还能够发现NeatHtml。NeatHtml是一个开源的Web组件,用于显示不安全的内容(主要是用户输入内容,例如博客评论,论坛帖子等等),主要用于避免跨站脚本(XSS,Cross-Site Scripting)等安全问题。作为组件的作者,Dean还将NeatHtml所用到的技术总结为一篇Whitepaper,感兴趣的朋友可以看一下,这是一份不可多得的技术资料。
顺便提一下,个人认为目前很多开发人员的编程能力还不够,似乎很多人都过早地把精力放在了“设计”,或者某个特定的技术上,而忽略了最基础的“编程能力”,也就是将一段思路转化为代码实现的能力。我发现,很多朋友在解决问题的时候,似乎都能很快得到解决方案并且叙述出来,但是真正要使用代码来表现出来时却显得困难重重。其实在工作中,思路或解决方案可以通过讨论而获得,但是真正转化为代码的时候只能靠自己了。而且编程能力其实和所谓的“工作经验”无关,我建议以“应届毕业生”“自居”的朋友,可以定心地锻炼一下自己的编程能力。
与NeatUpload类似的开源组件还有Memba Velodoc XP Edition,它是Velodoc文件管理系统的核心。不过严格说来,这不仅仅是一个上传组件,而是一套文件管理的解决方案,它包含:
- 一个兼容IIS 7集成管道模式的ASP.NET Http Module,支持大文件上传使用(有趣的是,NeatUpload申明,IIS 7的一个Bug使它无法在IIS 7集成管道模式中使用)。
- 一个支持断点续传的ASP.NET Http Handler。
- 一系列ASP.NET服务器端控件,提供了文件上传功能所需的UI,包括一个多文件上传控件,一个ListView控件和一个进度条控件。
- 一个Web应用程序,可以替换FTP的交换文件方式,支持Email发送链接。它也是上面所提到的组件的使用示例。
- 一个Windows Service,用于定期清理旧文件。
- 一个测试项目、一个部署项目、以及一个安装项目。
- 文档。
回到NeatUpload组件。说实话,我始终不喜欢这种进度获取方式,因为我觉得通过一个额外的请求对服务器进行轮询无疑是一个累赘。事实上,如果需要上传大文件并且获得上传进度,目前最好的方式应该是使用RIA方式。最典型的RIA上传方式就是利用Flash了。ActionScript 2.0中已经存在FileReference和FileReferenceList组件以支持单文件和多文件的上传,有了这两个组件,上传的各种信息已经能够完全在客户端获得,而上传进度也自然能够计算出来。FileReference和FileReferenceList组件非常容易使用,就连像我这样对Flash一窍不通的人,也能在短时间内作出一个简单的上传功能。但是自从有了swfupload,世界就变得更美好了。
严格说来,通过FileReference所得到的上传进度是“客户端发送数据的进度”,而像NeatUpload的做法得到的是“服务器端接受数据的进度”,两者不可混为一谈。
swfupload也是个开源组件,顾名思义是使用Flash进行上传。不过对于swfupload来说,Flash的作用主要是“控制”,而不是“展示”,这无疑给了开发人员更大的灵活性。swfupload的实现方式自然是利用了FileReference和FileReferenceList组件所提供的功能,通过Flash与JavaScript的交互能力,使得开发文件上传功能变得非常优雅和容易。有了swfupload,开发人员可以使用JavaScript来实现各种显示方式,开发像Flicker一样酷酷的上传界面也不再是非常困难的事情了。
swfupload是个客户端组件,它对于服务器端来说完全透明,也就是说,服务器端只需要使用对待普通form的方式来处理即可。例如在ASP.NET中我们可以使用Generic Handler来处理客户端的文件上传。如下,fileCollection变量即为客户端Post至服务器端所有文件的集合,我们可以使用name或下标的方式来获得其中的HttpPostedFile对象。:
public class UploadHandler : IHttpHandler
{
public void ProcessRequest(HttpContext context)
{
HttpFileCollection fileColllection = context.Request.Files;
...
}
public bool IsReusable { ... }
}
既然Flash提供了文件上传功能,Silverlight作为微软主推的RIA技术也不会缺了这项功能。这篇文章源自Silverlight 2.0的Quick Starts,展示了如何使用Silverlight 2.0开发文件上传的功能,感兴趣的朋友可以一读。
围绕着ASP.NET中上传文件这个话题也讨论了不少了,还有什么没有涉及到的吗?个人认为其实至少还有一个非常重要问题是没有讨论过,那就是在处理上传文件时占用ASP.NET处理线程的问题。众所周知,ASP.NET处理请求时会用到线程池中的线程,当线程池中的线程被用完之后没有被处理的请求只能排队了。因此增大ASP.NET应用程序吞吐量的一个重要手段,就是为一些耗时的操作使用异步处理方式(事实上这一命题可以在大部分应用中成立)。例如一个数据库查询操作需要3秒钟,如果不使用异步操作,处理线程就会被阻塞,直至查询完成。如果使用异步方式来执行数据库查询,在这3秒钟内线程就可以用户处理其他请求,当异步操作结束之后,ASP.NET就会使用另一个线程来继续处理这个请求。
上传大文件也是一个长时间占用处理线程的工作,而且遗憾的是,这无法使用异步操作来完成(通过异步操作来释放处理线程需要操作系统的支持,因此只有少量功能可以使用异步操作)。如果一个文件上传需要3分钟时间,那么在这3分钟内就会独占一个处理线程,如果上传文件的连接一多,就会大大影响应用程序的性能——就像遭受了某种方式的DOS攻击一样。因此,即使使用了像NeatUpload和swfupload这样的组件,也无法解决上传连接过多造成可用线程减少的问题。要解决这个问题并不容易,以下是两种思路(欢迎大家就此问题进行讨论):
- 扩展IIS,使上传文件或处理文件的过程不经ASP.NET处理,以减少ASP.NET应用程序线程的消耗。现在有了IIS 7,如果使用集成管道模式,应该也可以使用托管代码进行扩展。
- 使用额外的ASP.NET应用程序处理文件上传,以节省上传文件的线程对原ASP.NET应用程序线程的消耗。
就先说到这里吧。