`
herman_liu76
  • 浏览: 96657 次
  • 性别: Icon_minigender_1
  • 来自: 上海
社区版块
存档分类
最新评论

处理web文件上传的FileUpload包的代码设计分析与上传监控

阅读更多
    FileUpload 是 Apache commons下面的一个子项目,用来实现Java环境下面的文件上传功能,与常见的SmartUpload齐名。

    上传我们一般就直接使用现成的工具来实现就好了,很多工具非常好用,如果理解了它的原理,不仅有助于选择不同的工具,还有助于处理遇到的问题,或者改进工具,或者使用工具中不知道的其它功能。

    本文章分三部分:首先介绍基本上传原理,接着是重点介绍FileUpload源码中几要的几个类的协作关系,主要是内部iterator类,流的数据处理方式,以及一个输入流依次分成几股后,依次分别写入不同的输出流的过程。掌握些套路,感受下作者是设计思想。最后介绍如何监听上传进度,包括设计思想与部分代码。


一、 先说说基本的上传过程(熟悉的可以略过)
    1.客户端:
    客户端代码是一个form,类型enctype用multipart/form-data,这样可以把文件中的数据作为流式数据上传,不管是什么文件类型,均可上传。
<form action="doUpload.jsp" method="post" enctype="multipart/form-data">
    上传的文件:<input type="file" name="upfile" size="50">
    <input type="submit" value="提交">
</form>

     浏览器会把相应的数据按请求的格式发送到web服务上的,而web服务器可以把请求转成Request对象。
   请求的结构示例如下:关注【boundary】,用来对请求体分块的一个字符串。
POST /t2/upload.do HTTP/1.1
User-Agent: SOHUWapRebot
Accept-Language: zh-cn,zh;q=0.5
Accept-Charset: GBK,utf-8;q=0.7,*;q=0.7
Connection: keep-alive
Content-Length: 60408
Content-Type:multipart/form-data; boundary=ZnGpDtePMx0KrHh_G0X99Yef9r8JZsRJSXC
Host: w.sohu.com
 
--ZnGpDtePMx0KrHh_G0X99Yef9r8JZsRJSXC
Content-Disposition: form-data;name="desc"
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
 
[......][......][......][......]...........................
--ZnGpDtePMx0KrHh_G0X99Yef9r8JZsRJSXC
Content-Disposition: form-data;name="pic"; filename="photo.jpg"
Content-Type: application/octet-stream
Content-Transfer-Encoding: binary
 
[图片二进制数据]
--ZnGpDtePMx0KrHh_G0X99Yef9r8JZsRJSXC--


     2.服务器端:    
     对于服务器端的Request的处理,先举一个简单的处理过程。
String contentType = request.getContentType();
if(contentType.indexOf("multipart/form-data") >= 0){//【读入上传的数据,只处理文件类型表单】
in = new DataInputStream(request.getInputStream());
int formDataLength = request.getContentLength();
	if(formDataLength > MAX_SIZE){
		out.println("<P>上传的文件字节数不可以超过" + MAX_SIZE + "</p>");
	return;
}
//保存上传文件的数据
byte dataBytes[] = new byte[formDataLength];//【表单中的数据的长度!!!】
int byteRead = 0;
int totalBytesRead = 0;
//上传的数据保存在byte数组
while(totalBytesRead < formDataLength){
	byteRead = in.read(dataBytes,totalBytesRead,formDataLength);
	totalBytesRead += byteRead;
}
//根据byte数组创建字符串
String file = new String(dataBytes);【把所有的表单流内容转成String来处理,太大估计有问题了!!!】
//out.println(file);
//取得上传的数据的文件名
String saveFile = file.substring(file.indexOf("filename=\"") + 10);
saveFile = saveFile.substring(0,saveFile.indexOf("\n"));
saveFile = saveFile.substring(saveFile.lastIndexOf("\\") + 1,saveFile.indexOf("\""));
int lastIndex = contentType.lastIndexOf("=");
//【取得数据的分隔字符串,这个分界线很重要】
String boundary = contentType.substring(lastIndex + 1,contentType.length());
//创建保存路径的文件名
String fileName = rootPath + saveFile;
int pos;
pos = file.indexOf("filename=\"");
pos = file.indexOf("\n",pos) + 1;
pos = file.indexOf("\n",pos) + 1;
pos = file.indexOf("\n",pos) + 1;
int boundaryLocation = file.indexOf(boundary,pos) - 4;
//out.println(boundaryLocation);
//取得文件数据的开始的位置
int startPos = ((file.substring(0,pos)).getBytes()).length;
//out.println(startPos);
//取得文件数据的结束的位置
int endPos = ((file.substring(0,boundaryLocation)).getBytes()).length;
//out.println(endPos);
//检查上载文件是否存在
File checkFile = new File(fileName);
if(checkFile.exists()){
	out.println("<p>" + saveFile + "文件已经存在.</p>");
}
//检查上载文件的目录是否存在
File fileDir = new File(rootPath);
if(!fileDir.exists()){
	fileDir.mkdirs();
}
//创建文件的写出类
fileOut = new FileOutputStream(fileName);【准备把请求中的内容写入这个文件】
//保存文件的数据
fileOut.write(dataBytes,startPos,(endPos - startPos));【表单中的byte[]数据选择头尾位置写入文件中去!!!!!】
fileOut.close();
	out.println(saveFile + "文件成功上载.</p>");
}else{
String content = request.getContentType();
	out.println("<p>上传的数据类型不是multipart/form-data</p>");
}
}




二、FileUpload中的主要对象与协作关系
   
1.首先我们想一下,作者当时面对的问题与解决思路是什么?

    多(包括大)文件上传后,数据肯定比较大,web服务器给我一个有inputStream的请求,中间是有分割标识的每个文件。那么既然有重复的内容(或某对象)反复出现,那么想到内部用到iterator来得到这么一个源对象(循环肯定是减少重复的基本方式),而得到源对象后,我需要的是把这个源对象保存在服务器的什么地方(应该是可配置的目标对象)。那么就是设计源对象与目标对象,另外由于是处理流,源对象的核心数据是输入流,目标对象的核心部分是输出流。难点是不能一次读入所有的输入流(上面的处理可不是apache的风格),那一次只读入一小部分数据(长度一定要大于boundary的)时,不一定正好是你要的长度,可能有多种情况,比如这部分数据可能正好读到分割线一部分,或者读到文件部分结尾还带有一部分下一段的数据。
    直接看一下作者是如何解决的:核心类FileUploadBase中有这个方法用来处理请求对象。
    public List<FileItem> parseRequest(RequestContext ctx)这个方法不长,而且其中主要的对象都有了。
    FileItemIterator就是iterator对象,用来得到每一个分段源对象。
    FileItemStreamImpl就是每一个分段源对象。
    FileItemFactory就是目标对象的工厂,根据源对象的一些属性生成目标对象。
    FileItem就是每一个分段的目标对象。
    在每一次的iterator循环的过程中,得到源对象,再得到目标对象,再用Streams.copy把源对象中的最重要的文件流数据写入到目标对象的输出流上去。至于输出流指向哪里就看配置了。也许是内存,也许是临时文件。
    另外,MultipartStream是其中非常重要的真正处理流数据的一个对象。

2.FileItemIteratorImpl迭代器
    FileItemIteratorImpl是FileUploadBase中的内部类,这个设计可以在一些容器类,比如hashmap等源代码中看到,hashmap中耗用迭代的是keyset对象。FileItemIteratorImpl迭代的对象是FileItemStreamImpl,这个是FileUploadBase内部类的内部类了。
    hasNext()与findNextItem()是FileItemIteratorImpl中的重要方法,而在构造迭代器时,有一句:multi = new MultipartStream(**),就是间接持有总的输入流。因为迭代的对象一定要从流中得到,所以迭代器持有并操作这个流。构造中先得到一些全局性的属性,比如编码,bundary啊之类的东西放在迭代器中存着。

3.FileItemStreamImpl迭代对象
    构造好迭代器后调用findNextItem()来产生迭代对象,这时又从持有的流中得到每一个分段的信息,比如fileName,Content-Type之类的。有了这几个信息就可以new一个FileItemStreamImpl对象了,记着这个是源对象。在new源对象的过程中,除了赋几个分段简单属性外,有一句很重要:itemStream = multi.newInputStream();multi是迭代器持有的总的输入流,这时候为迭代对象赋了一个新的流对象。

3.多个输入流对象的处理
    仔细看这个new出来的分段流对象itemStream的代码,发现是总的流中的一个内部类,new这个分段流的过程中只是设置一下读取流的位置信息,而每new一次就重置一下。可以看出实际上的这么多输入流持有的最基本的输入流只有一个,就是请求中的输入流。这个基本流被迭代器的包装流multi持有并操作,而迭代过程中又被包装流multi中的小弟分段流itemStream来持有并操作,而分段流只在迭代对象生成时生成。真能装,任何对象包装了流,自己也可以叫流了。

4.目标对象中的输出流对象
    List<FileItem>是处理的结果,FileItem就是处理后的每一个迭代对象对应的每一个结果对象。一旦迭代对象FileItemStreamImpl产生了,就生成FileItem对象,最核心的文件数据是怎么从迭代对象到结果对象的呢?
    Streams.copy(item.openStream(), fileItem.getOutputStream(), true);
    就是上面这句,从迭代对象的那个小弟输入流中,不断的写到结果对象的输出流中。输出流是:dfos=new DeferredFileOutputStream(sizeThreshold, outputFile)对象。从参数看的出有一个size控制,上传项目文件的临时数据可以存储在内存中或硬盘上。这个依赖于上传项目的大小(即:数据的字节)。
    另外这个输出流对象还可以获取输入流,也就是整个处理完了,给用户返回List<FileItem>后,用户再从上传后的内存或者硬盘临时文件中再得到一个输入流,之后按用户的需求,可以存在文件服务器或者数据库中。dfos是上面说的输出流。
    public InputStream getInputStream()
        throws IOException {
        if (!isInMemory()) {
            return new FileInputStream(dfos.getFile());
        }
        if (cachedContent == null) {
            cachedContent = dfos.getData();
        }
        return new ByteArrayInputStream(cachedContent);
    }


 
5.其它值得学习的地方
    MultipartStream持有Request输入流,给迭代对象所用的内部子流对象也是操作的Request输入流。边
    readByte()//从请求流中读一部分数据放入buffer中。
    findSeparator()//在buffer中找到boundary,如果只读到一部分boundary,就不读了,把buffer中的部分boundary移动到buffer前面,再读一部分数据进来。
    其他还有头部的分割,回车换行的处理,细节就不分析了。都在MultipartStream中,可以学到很多处理流与buffer,以及其中字符的处理。

6.总结
    FileUpload的处理不是一次性读出所有请求流数据,而是读一小块,分析一小块,首先读一部分数据产生迭代器对象的一些属性,如果读到分割就产生一个迭代对象,读到分割中的正文就让流去写入内存或者临时文件。然后读到分块结尾就再产生一个迭代对象重复上面的工作。设计上十分巧妙,读取流过程中不走回头路

    据说要有图才好,比较直观,那配一个手画版。



7.hashmap中的iterator复习
    既然提到处理重复数据时用的iterator,随便提一下hashmap,可以对比一下。
    Entry<K,V>就是内部定义的迭代对象。
    HashIterator是抽象的迭代器,hashmap中可以迭代Entry,也可以迭代key,还可以迭代value,核心是迭代Entry。
    迭代器一般持有当前迭代值,或者下一值,还有计数等公共的东西。
    hasNext()与next()一般是迭代器提供给外部的接口方法。
    抽象的迭代器与三个继承的迭代器的关系就是next不同。抽象的有一个nextEntry(),三个继承的有next()方法去调用,返回的都是Entry,只是最后取的不一样,分别是Entry,key,value而已。
    内部类实现一个接口,让主类对外呈现另一功能面。而fileUpload中迭代是给自己内部使用的。

三、上传进度的监控

    很多时候上传大文件,需要告诉客户端上传的进度。下面就介绍一下如何设计。
1.设计思想
    通过以上的分析,我们看到真正的上传数据写入内存或者临时文件,是由目标对象FileItem的输出流来操作的,就是FileItem的实现类DiskFileItem。它里面有一个方法是getOutputStream()。那我们安排一个人来监听这个输出流的输出过程(数据大小)不就可以了。输出流做任何操作的时候,都让监听人记录下来,那这个输出流就应该被包装一下,让包装后的输出流持有源输出流,并持有监听人来记录。

    我们定义这个包装后的输出流叫:MonitoredOutputStream,它持有原来的输出流和监听人。DiskFileItem里的获取输出流当然就是获取包装后的了,那这个方法要重写了,干脆DiskFileItem产生一个继承类MonitoredDiskFileItem来重写此方法。
    DiskFileItemFactory是DiskFileItem的工厂类,看来也要重写了,因为它要生产的是MonitoredDiskFileItem了。我们也用继承的方式修改createItem()的方法,这样就产生了MonitoredDiskFileItemFactory类。
   
    看来从底层发生一个变化,它的上层所有的类都要发生变化了,还好层次不深。另外我们在底层使用了一个监听人,那需要从外部传进去,最外部就是MonitoredDiskFileItemFactory了,它的构造函数里可以传监听人进去。另外监听人不能只是听,他应该有个小本子把监听到的东西记录下来。监听人自带作业本(内部类)。


2.总结一下
    由于监控输出流,要有一个监听人(1个类)插入到输出流中,从此有了代理输出流(1 个类)。从而造成上层的所有类(2个类)发生变化。另外监听人自带作业本(1个内部类),监听最好弄个接口出来比较酷(1个接口)。





3.如何使用
    首先是标准的上传请求过程,另外就是页面不断请求获取上传进度的数据的过程。服务器需要处理这两个请求。

    3.1标准上传的过程
        把监听人的作业本保存在session中。
	//产生一个监听人,设置总的数据长度。
	UploadListener listener = new UploadListener(request.getContentLength());
	listener.start();// 启动监听状态,记在监听人的作业本中
	// 将监听器对象的状态保存在Session中
	session.setAttribute("FILE_UPLOAD_STATS", listener.getFileUploadStats());
	session.setAttribute("bytesRead", "0");
	// 创建MonitoredDiskFileItemFactory对象
	FileItemFactory factory = new MonitoredDiskFileItemFactory(listener);//新设计的工厂
	ServletFileUpload upload = new ServletFileUpload(factory);//把这个工厂给fileUpload组件使用。
	List<FileItem> items = upload.parseRequest(request);//fileUpload处理请求。
	listener.done();// 停止使用监听器
	....



    3.2查询进度的过程
         从session中得到数据,可以算出一些结果传给前台了。
	UploadListener.FileUploadStats fileUploadStats = (UploadListener.FileUploadStats) session.getAttribute("FILE_UPLOAD_STATS");
	if (fileUploadStats != null) {
		long bytesProcessed = fileUploadStats.getBytesRead();// 获得已经上传的数据大小
		long sizeTotal = fileUploadStats.getTotalSize();// 获得上传文件的总大小
		// 计算上传完成的百分比
		long percentComplete = (long) Math.floor(((double) bytesProcessed / (double) sizeTotal) * 100.0);
		// 获得上传已用的时间
		long timeInSeconds = fileUploadStats.getElapsedTimeInSeconds();
		// 计算平均上传速率
		double uploadRate = bytesProcessed / (timeInSeconds + 0.00001);
		// 计算总共所需时间
		double estimatedRuntime = sizeTotal / (uploadRate + 0.00001);//(其它略)
              }



4.疑问
   
    为何我们要在FileItem的输出流上做文章呢?为何不在它的输入流上做文章呢?这是因为fileupload使用对外提供的接口很少,就几句话,如果处理那个迭代源对象FileItemStream的输入流读数据就可能就改的面目全非了,所以目标对象FileItem上的输出流上比较好处理。

    另外我们看到源码中有一个notify,不知道可以通知什么?会不会是人家已经考虑到这方面的需求了?我还没研究这里,有了再补充。


四、 答疑与另一种进度监控方式

    1.上面的上传监控中的问题:
    又研究fileUpload中的nitify与listener,发现上面的进度条监控是存在bug的。总的上传数据量是request.getContentLength(),它是除了请求BODY的长度,包含了分割boundary,回车,还有每个文件名等属性,当然还有文件本身。
    而在目标对象FileItem上的输出流来源于源对象FileItemStream的输入流,而这个输入流只是真正的文件本身。所以所有的文件正文数据加在一起是永远小于< request.getContentLength()的。当然上传的比例要不要那么精确是另一回事!所以这个也是可以用的。正文越大,精确度越高。

    2.fileUpload中已经有了上传监控的考虑:
    再回到fileUpload中的notify与listener,notify,看看他们的关系。
    FileUploadBase中可以从外部设置进去一个setProgressListener,设置好以后,MultipartStream(持有请求流,并可生成分段流)中的静态内部类Notifier就可以new出来了。子类先有了就可以设置给父类MultipartStream了。在生成迭代器的时候有这么两句:

            notifier = new MultipartStream.ProgressNotifier(listener, requestSize);//用监听器做参数生成一个通知器
            multi = new MultipartStream(input, boundary, notifier);//通知器做参数生成总处理流对象。

    看到上面两句有点体会:监听器都是与底层的对象输入输出打交道,而用户操作的是上层的对象,如何把监听器从外面传给底层呢?三中的方法是改写类,修改构造函数。而这里的方法用了通知与监听的两个结构来处理。通知器当成静态内部类预先埋在里面,通知器记录数据,与三中的记事本相似。可以仔细体会一下异同点。

    Notifier里面持有监听器,还有几个属性:contentLength总长度,bytesRead已经读了多少,items文件个数记录。那么关系就清楚了,在总处理流的读请求流的过程中,读到数据就累加设置bytesRead,读到分段就累加设计items的数(迭代器的findNextItem方法中确实有notifier.noteItem(),用来累加个数),而在累加数据数与文件分段数的时候都调用监听器的核心方法:listener.update(bytesRead, contentLength, items);,把通知器中的值告诉监听器。

    这下明白了,这个fileUpload中本身就有通知器一直记录着上传情况,你要是传个监听器就会得到上传情况。而且这个数据完全没有bug,上传的比例不仅是正文数据的比例,还包括每段正文的属性,分割字符在内的完整的body的数据的比例。


   3.那我们如何从客户端得到上传情况呢?
    自己设计一个监听器,可以在源码的test包中看到ProgressListenerImpl,监听器也有属性,包括总长度,读取长度,分段数,与通知器中一样。还有一个核心方法update。把监听器扔到FileUploadBase(或者ServletFileUpload)中就可以被通知了,同时把这个监听器也扔到session中去,客户端就可以反复请求获取监听器中的值了。这一点与三中的方法差不多了,就不赘述了。


   欢迎看过的朋友留言、提出意见、欢迎交流~
  • 大小: 68.7 KB
  • 大小: 161.8 KB
分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics