top
Loading...
AJAX+JSF組件實現高性能的文件上載
一、 引言

基于瀏覽器的文件上傳,特別是對于通過<input type="file">標簽包含到Web頁面來實現上傳的情況,還存在較嚴重的性能問題。我們知道,超過10MB的上傳文件經常導致一種非常痛苦的用戶體驗。一旦用戶提交了文件,在瀏覽器把文件上傳到服務器的過程中,界面看上去似乎處于靜止狀態。由于這一切發生在后臺,所以許多沒有耐心的用戶開始認為服務器"掛"了,因而再次提交文件,這當然使得情況變得更糟糕。

為了盡可能使得文件上傳感覺更友好些,一旦用戶提交文件,許多站點將顯示一個中間過程動畫(例如一旋轉圖標)。盡管這一技術在上傳提交到服務器時起一些作用,但它還是提供了太少的有關文件上傳狀態的信息。解決這個問題的另外一種嘗試是實現一個applet——它通過FTP把文件上傳到服務器。這一方案的缺點是:限制了你的用戶,必須要有一個支持Java的瀏覽器。

在本文中,我們將實現一個具有AJAX能力的組件——它不僅實現把文件上傳到服務器,而且"實時地"監視文件上傳的實際過程。這個組件工作的四個階段顯示于下面的圖1,2,3和4中:


圖1.階段1:選擇文件上傳

圖2.階段2:上傳該文件到服務器

圖3.階段3:上傳完成

圖4.階段4:文件上傳摘要

二、 實現該組件

首先,我們分析創建多部分過濾的過程,它將允許我們處理并且監視文件上傳。然后,我們將繼續實現JavaServer Faces(JSF)組件-它將提供給用戶連續的回饋,以支持AJAX的進度條方式。

(一) 多部分過濾:UploadMultipartFilter

多部分過濾的任務是攔截到來的文件上傳并且把該文件寫到一個服務器上的臨時目錄中。同時,它還將監視接收的字節數并且確定已經上載該文件的程度。幸運的是,現在有一個優秀的Jakarta-Commons開源庫可以利用(FileUpload),可以由它來負責分析一個HTTP多部分請求并且把文件上傳到服務器。我們要做的是擴展該庫并且加入我們需要的"鉤子"來監視已經處理了多少字節。

public class UploadMultipartFilter implements Filter{
public void doFilter(ServletRequest request,ServletResponse response,FilterChain chain)
throws IOException, ServletException {
HttpServletRequest hRequest = (HttpServletRequest)request;
//檢查是否我們在處理一個多部分請求
String contentHeader = hRequest.getHeader("content-type");
boolean isMultipart = ( contentHeader != null && contentHeader.indexOf("multipart/form-data") != -1);
if(isMultipart == false){
chain.doFilter(request,response);
}else{
UploadMultipartRequestWrapper wrapper = new UploadMultipartRequestWrapper(hRequest);
chain.doFilter(wrapper,response);
}
...
}

正如你所見,UploadMultipartFilter類簡單地檢查了當前的請求是否是一個多部分請求。如果該請求不包含文件上傳,該請求將被傳遞到請求鏈中的下一個過濾,而不進行任何另外的處理。否則,該請求將被包裝在一個UploadMultipartRequestWrapper中。

(二) UploadMultipartRequestWrapper類

public class UploadMultipartRequestWrapper
extends HttpServletRequestWrapper{
private Map<String,String> formParameters;
private Map<String,FileItem> fileParameters;
public UploadMultipartRequestWrapper(HttpServletRequest request) {
super(request);
try{
ServletFileUpload upload = new ServletFileUpload();
upload.setFileItemFactory(new ProgressMonitorFileItemFactory(request));
List fileItems = upload.parseRequest(request);
formParameters = new HashMap<String,String>();
fileParameters = new HashMap<String,FileItem>();
for(int i=0;i<fileItems.size();i++){
FileItem item = (FileItem)fileItems.get(i);
if(item.isFormField() == true){
formParameters.put(item.getFieldName(),item.getString());
}else{
fileParameters.put(item.getFieldName(),item);
request.setAttribute(item.getFieldName(),item);
}
}
}catch(FileUploadException fe){
//請求時間超過-用戶可能已經轉到另一個頁面。
//作一些記錄
//...
}
...

在UploadMultipartRequestWrapper類中,我們將初始化ServletFileUpload類,它負責分析我們的請求并且把文件寫到服務器上的缺省臨時目錄。ServletFileUpload實例針對在該請求中遇到的每一個字段創建一個FileItem實例(它們包含文件上傳和正常的表單元素)。之后,一個FileItem實例用于檢索一個提交字段的屬性,或者,在文件上傳的情況下,檢索一個到底層的臨時文件的InputStream。總之,UploadMultipartRequestWrapper負責分析該文件并且設置任何FileItem-它在該請求中把文件上傳描述為屬性。然后,這些屬性由JSF組件所進一步收集,而正常表單字段的行為保持不變。

默認情況下,通用FileUpload庫將使用DiskFileItems類的實例來處理文件上傳。盡管DiskFileItem在處理整個臨時文件業務時是很有用的,但在準確監視該文件已經處理程度方面存在很少支持。自版本1.1以來,通用FileUpload庫能夠使開發者指定用于創建FileItem的工廠。我們將使用ProgressMonitorFileItemFactory和ProgressMonitorFileItem類來重載缺省行為并監視文件上傳過程。

(三) ProgressMonitorFileItemFactory類

public class ProgressMonitorFileItemFactory extends DiskFileItemFactory {
private File temporaryDirectory;
private HttpServletRequest requestRef;
private long requestLength;
public ProgressMonitorFileItemFactory(HttpServletRequest request) {
super();
temporaryDirectory = (File)request.getSession().getServletContext().getAttribute("javax.servlet.context.tempdir");
requestRef = request;
String contentLength = request.getHeader("content-length");
if(contentLength != null){requestLength = Long.parseLong(contentLength.trim());}
}
public FileItem createItem(String fieldName, String contentType,boolean isFormField, String fileName) {
SessionUpdatingProgressObserver observer = null;
if(isFormField == false) //這必須是一文件上傳.
observer = new SessionUpdatingProgressObserver(fieldName,fileName);
ProgressMonitorFileItem item = new ProgressMonitorFileItem(
fieldName,contentType,isFormField,
fileName,2048,temporaryDirectory,
observer,requestLength);
return item;
}
...
public class SessionUpdatingProgressObserver implements ProgressObserver {
private String fieldName;
private String fileName;
...
public void setProgress(double progress) {
if(request != null){
request.getSession().setAttribute("FileUpload.Progress."+fieldName,progress);
request.getSession().setAttribute("FileUpload.FileName."+fieldName,fileName);
}
}
}
}

ProgressMonitorFileItemFactory Content-Length頭由瀏覽器設置并且假定它是被設置的上傳文件的精確長度。這種確定文件長度的方法確實限制了你在每次請求中上傳的文件-如果有多個文件在該請求中被編碼的話,不過這個值是不精確的。這是由于,瀏覽器僅僅發送一個Content-Length頭,而不考慮上傳的文件數目。

除了創建ProgressMonitorFileItem實例之外,ProgressMonitorFileItemFactory還注冊了一個ProgressObserver實例,它將由ProgressMonitorFileItem來發送文件上傳過程中的更新。我們所使用的ProgressObserver的實現(SessionUpdatingProgressObserver)針對被提交字段的id把進度百分數設置到用戶的會話中。然后,這個值可以由JSF組件存取以便把更新發送給用戶。

(四) ProgressMonitorFileItem類

public class ProgressMonitorFileItem extends DiskFileItem {
private ProgressObserver observer;
private long passedInFileSize;
...
private boolean isFormField;
...
@Override
public OutputStream getOutputStream() throws IOException {
OutputStream baseOutputStream = super.getOutputStream();
if(isFormField == false){
return new BytesCountingOutputStream(baseOutputStream);
}else{return baseOutputStream;}
}
...
private class BytesCountingOutputStream extends OutputStream{
private long previousProgressUpdate;
private OutputStream base;
public BytesCountingOutputStream(OutputStream ous){ base = ous; }
...
private void fireProgressEvent(int b){
bytesRead += b;
...
double progress = (((double)(bytesRead)) / passedInFileSize);
progress *= 100.0
observer.setProgress();
}
}
}

ProgressMonitorFileItem把DiskFileItem的缺省OutputStream包裝到一個BytesCountingOutputStream中,這可以在每次讀取一定數目的字節后更新相關的ProgressObserver。

(五) 支持AJAX的JavaServer Faces(JSF)上傳組件

這個組件負責生成HTML文件上傳標簽,顯示一個進度條以監視文件上傳,并且生成一旦文件上傳成功需要被顯示的組件。使用JavaServer Faces實現這個組件的一個主要優點是,大多數復雜性被隱藏起來。開發人員只需要把組件標簽添加到JSP,而后由組件負責所有的AJAX及相關的進度條監控細節問題。下面的JSP代碼片斷用于把上傳組件添加到頁面上。

<comp:fileUpload
value="#{uploadPageBean.uploadedFile}"
uploadIcon="images/upload.png"
styleClass="progressBarDiv"
progressBarStyleClass="progressBar"
cellStyleClass="progressBarCell"
activeStyleClass="progressBarActiveCell">
<%--下面是一旦文件上傳完成將成為可見的組件--%>
<h:panelGrid columns="2" cellpadding="2" cellspacing="0" width="100%">
<f:facet name="header">
<h:outputText styleClass="text"
value="文件上傳成功." />
</f:facet>
<h:panelGroup style="text-align:left;display:block;width:100%;">
<h:commandButton action="#{uploadPageBean.reset}"
image="images/reset.png"/>
</h:panelGroup>
<h:panelGroup style="text-align:right;display:block;width:100%;">
<h:commandButton action="#{uploadPageBean.nextPage}"
image="images/continue.png"/>
</h:panelGroup>
</h:panelGrid>
</comp:fileUpload>

文件上傳組件的value屬性需要用一個擁有一個FileItem的屬性綁定到一個bean上。組件只有在該文件被服務器成功收到時才顯示。

三、 實現AJAX文件上傳組件

實質上,上載組件或者生成一個完整的自已,或者在一個AJAX請求的情況下,只生成部分XML以更新在頁面上進度條的狀態。為了防止JavaServer Faces生成完整的組件樹(這會帶來不必要的負荷),我們還需要實現一個PhaseListener(PagePhaseListener)以取消該faces的請求處理的其它部分-如果遇到一個AJAX請求的話。我在本文中略去了所有的關于標準配置(faces-config.xml和標簽庫)的討論,因為它們相當直接且已經在以前討論過;而且這一切都包含在隨同本文的源碼中,你可以詳細分析。

(一) AJAX文件上傳組件生成器

該組件和標簽類的實現比較簡單。大量的邏輯被包含到生成器中,具體地說,它負責以下:

· 編碼整個的上傳組件(和完整的HTML文件上傳標簽)、文件被上傳完成后要顯示的組件,還有實現AJAX請求的客戶端JavaScript代碼。

· 適當地處理部分AJAX請求并且發送回必要的XML。

· 解碼一個文件上傳并且把它設置為一個FileItem實例。

(二) 編碼整個上傳組件

前面已經提及,文件上傳組件由三個階段組成。在該組件的整個編碼期間,我們將詳細分析這三個階段的編碼。注意,在頁面上的該組件的可視化(使用CSS顯示)屬性將由AJAX JavaScript來控制。

(三) 階段一

圖5顯示了該上傳組件的第一個階段。


圖5.選擇文件上傳


在第一階段中,我們需要生成HTML文件Upload標簽和點擊Upload按鈕時相應的執行代碼。一旦用戶點擊了Upload按鈕,表單將被一個IFRAME(為防止頁面阻塞)提交并初始化第二個階段。下面是生成代碼的一部分:

//文件上傳組件
writer.startElement("input", component);
writer.writeAttribute("type", "file", null);
writer.writeAttribute("name", component.getClientId(context), "id");
writer.writeAttribute("id", component.getClientId(context),"id");
if(input.getValue() != null){
//如果可用,則生成該文件名.
FileItem fileData = (FileItem)input.getValue();
writer.writeAttribute("value", fileData.getName(), fileData.getName());
}
writer.endElement("input");
String iconURL = input.getUploadIcon();
//生成圖像,并把JavaScript事件依附到其上.
writer.startElement("div", component);
writer.writeAttribute("style","display:block;width:100%;text-align:center;", "style");
writer.startElement("img", component);
writer.writeAttribute("src",iconURL,"src");
writer.writeAttribute("type","image","type");
writer.writeAttribute("style","cursor:hand;cursor:pointer;","style");
UIForm form = FacesUtils.getForm(context,component);
if(form != null) {
String getFormJS = "document.getElementById('" + form.getClientId(context) + "')";
String jsFriendlyClientID = input.getClientId(context).replace(":","_");
//設置表單的編碼為multipart以用于文件上傳,并且通過一個IFRAME
//來提交它的內容。該組件的第二個階段也在500毫秒后被初始化.
writer.writeAttribute("onclick",getFormJS + ".encoding='multipart/form-data';" +
getFormJS + ".target='" + iframeName + "';" + getFormJS + ".submit();" +
getFormJS + ".encoding='application/x-www-form-urlencoded';" +
getFormJS + ".target='_self';" +
"setTimeout('refreshProgress" + jsFriendlyClientID + "();',500);",null);
}
...
writer.endElement("img");
//現在實現我們將要把該文件/表單提交到的IFRAME.
writer.startElement("iframe", component);
writer.writeAttribute("id", iframeName, null);
writer.writeAttribute("name",iframeName,null);
writer.writeAttribute("style","display:none;",null);
writer.endElement("iframe");
writer.endElement("div");
writer.endElement("div"); //階段1結束

(四) 階段二

第二階段是顯示當前百分比的進度條和標簽,如圖6所示。該進度條是作為一個具有100個內嵌span標簽的div標簽實現的。這些將由AJAX JavaScript根據來自于服務器的響應進行設置。


圖6.上傳文件到服務器

writer.startElement("div",component);
writer.writeAttribute("id", input.getClientId(context) + "_stage2", "id");
...
writer.writeAttribute("style","display:none", "style");
String progressBarID = component.getClientId(context) + "_progressBar";
String progressBarLabelID = component.getClientId(context) + "_progressBarlabel";
writer.startElement("div", component);
writer.writeAttribute("id",progressBarID,"id");
String progressBarStyleClass = input.getProgressBarStyleClass();
if(progressBarStyleClass != null)
writer.writeAttribute("class",progressBarStyleClass,"class");
for(int i=0;i<100;i++){
writer.write("<span> </span>");
}
writer.endElement("div");
writer.startElement("div",component);
writer.writeAttribute("id",progressBarLabelID,"id");
...
writer.endElement("div");
writer.endElement("div"); //階段2結束

(五) 階段三

最后,作為階段三,一旦文件成功上傳,需要被顯示的組件即被生成,見圖7。這些是在生成器的encodeChildren方法中實現的。


圖7.上傳完成

public void encodeChildren(FacesContext context,
UIComponent component) throws IOException {
ResponseWriter writer = context.getResponseWriter();
UIFileUpload input = (UIFileUpload)component;
//一旦文件上傳成功,處理將被顯示的子結點
writer.startElement("div", component);
writer.writeAttribute("id", input.getClientId(context) + "_stage3", "id"); //階段3.
if(input.getValue() == null){
writer.writeAttribute("style","display:none;",null);
}else{
writer.writeAttribute("style","display:block",null);
}
List<UIComponent> children = input.getChildren();
for(UIComponent child : children){
FacesUtils.encodeRecursive(context,child);
}
writer.endElement("div"); //階段3結束
}

四、處理AJAX請求

AJAX請求的生成是在這個組件的解碼方法中處理的。我們需要檢查這是否是一個實際的AJAX請求(為了區別于正常的編譯行為),然后基于由ProgressMonitorFileItemFactory類的SessionUpdatingProgressObserver實例設置在會話中的值把一個XML響應發送回客戶端。

public void decode(FacesContext context, UIComponent component) {
UIFileUpload input = (UIFileUpload) component;
//檢查是否這是一個上傳進度請求,或是一個實際的上傳請求.
ExternalContext extContext = context.getExternalContext();
Map parameterMap = extContext.getRequestParameterMap();
String clientId = input.getClientId(context);
Map requestMap = extContext.getRequestParameterMap();
if(requestMap.get(clientId) == null){
return;//什么也不做,返回
}
if(parameterMap.containsKey(PROGRESS_REQUEST_PARAM_NAME)){
//這是一個在該文件請求中的得到進度信息的請求.
//得到該進度信息并把它生成為XML
HttpServletResponse response = (HttpServletResponse)context.getExternalContext().getResponse();
//設置響應的頭信息
response.setContentType("text/xml");
response.setHeader("Cache-Control", "no-cache");
try {
ResponseWriter writer = FacesUtils.setupResponseWriter(context);
writer.startElement("progress", input);
writer.startElement("percentage", input);
//從會話中獲得當前進度百分數(由過濾器所設置).
Double progressCount = (Double)extContext.getSessionMap().
get("FileUpload.Progress." +input.getClientId(context));
if(progressCount != null){
writer.writeText(progressCount, null);
}else{
writer.writeText("1", null);//我們還沒有收到上傳
}
writer.endElement("percentage");
writer.startElement("clientId", input);
writer.writeText(input.getClientId(context), null);
writer.endElement("clientId");
writer.endElement("progress");
} catch(Exception e){
//做一些錯誤記錄...
}
}else{
//正常的譯碼請求.
...

五、 正常的譯碼行為

在正常的編譯期間,文件上傳生成器從請求屬性中檢索FileItem,正是在此處它被過濾器所設置,并且更新該組件的值綁定。然后,該會話中的進度被更新到100%,這樣在頁面上的JavaScript就可以把組件送入第3個階段。

//正常的譯碼請求.
if(requestMap.get(clientId).toString().equals("file")){
try{
HttpServletRequest request = (HttpServletRequest)extContext.getRequest();
FileItem fileData = (FileItem)request.getAttribute(clientId);
if(fileData != null) input.setSubmittedValue(fileData);
//現在我們需要清除與該項相關的任何進度
extContext.getSessionMap().put("FileUpload.Progress." + input.getClientId(context),new Double(100));
}catch(Exception e){
throw new RuntimeException("不能處理文件上傳" +" - 請配置過濾器.",e);
}
}

客戶端JavaScript負責向服務器發出進度請求并通過不同階段來移動組件。為了簡化處理所有的瀏覽器特定的XMLHttpRequest對象的問題,我選用了Matt Krause提供的AjaxRequest.js庫。該庫最大限度地減少我們需要編寫的JavaScript代碼的數量,同時可以使這個組件正常工作。也許把這部分JavaScript代碼打包為該組件的一部分,然后從PhaseListener生成它更好一些,但是,我已經通過定義一個到JSP頁面上的JavaScript庫的鏈接來盡力使得它簡單。

組件中的getProgressBarJavaScript方法被調用以生成JavaScript。使JavaScript正常工作通常是實現AJAX組件最困難的部分;不過我想,下面的代碼已經非常清晰易于理解了。盡管在我的示例中JavaScript是嵌入到Java代碼中的,但是把它放到一個外部獨立的文件中也許更好一些。在本文中,我只是想使問題更為簡單些且只關心本文的主題。下面是一個將由組件生成的JavaScript的示例。其中假定,fileUpload1是被賦值到該文件組件的客戶端JSF Id,而uploadForm是HTML表單的Id。

function refreshProgress(){
// 假定我們正在進入到階段2.
document.getElementById('fileUpload1_stage1').style.display = 'none';
document.getElementById('fileUpload1_stage2').style.display = '';
document.getElementById('fileUpload1_stage3').style.display = 'none';
//創建AJAX寄送
AjaxRequest.post(
{
//指定正確的參數,以便
//該組件在服務器端被正確處理
'parameters':{ 'uploadForm':'uploadForm',
'fileUpload1':'fileUpload1',
'jsf.component.UIFileUpload':'1',
'ajax.abortPhase':'4' } //Abort at Phase 4.
//指定成功處理相應的回調方法.
,'onSuccess':function(req) {
var xml = req.responseXML;
if( xml.getElementsByTagName('clientId').length == 0) {
setTimeout('refreshProgress()',200); return;
}
var clientId = xml.getElementsByTagName('clientId');
clientId = clientId[0].firstChild.nodeValue + '_progressBar';
//從XML獲取百分比
var percentage = xml.getElementsByTagName('percentage')[0].firstChild.nodeValue;
var innerSpans = document.getElementById(clientId).getElementsByTagName('span');
document.getElementById(clientId + 'label').innerHTML = Math.round(percentage) + '%';
//基于當前進度,設置這些span的式樣類。
for(var i=0;i<innerSpans.length;i++){
if(i < percentage){
innerSpans[i].className = 'active';
}else{
innerSpans[i].className = 'passive';
}
}
//如果進度不是100,我們需要繼續查詢服務器以實現更新.
if(percentage != 100){
setTimeout('refreshProgress()',400);
} else {
//文件上傳已經完成,我們現在需要把該組件送入到第3個階段.
document.getElementById('fileUpload1_stage1').style.display = 'none';
document.getElementById('fileUpload1_stage2').style.display = 'none';
document.getElementById('fileUpload1_stage3').style.display = '';
}
}
});
}
return builder.toString();

六、 結論

我很希望,本文能夠在有關如何使得文件上傳更具有用戶友好性,并且把AJAX和JavaServer Faces用于實現高級用戶接口組件的可能性方面引發你的進一步思考。毫無疑問,本文中的方案比較冗長并且有可能得到進一步的改進。我希望你能詳細地分析一下本文中所提供的完整的源代碼來深入理解本文中所討論的概念。
作者:http://www.zhujiangroad.com
來源:http://www.zhujiangroad.com
北斗有巢氏 有巢氏北斗