- 主题
- 0
- 积分
- 854
- 贝壳
- 854 个
- 性别
- 男
- 来自
- 贵州遵义
- 注册时间
- 2005-3-26
- 最后登录
- 2011-2-26
|
[转载] Uploading files without a full postback
Well, ever since i've started working with ATLAS (I mean, AJAX extensions:) )I've been trying to build a cool control which would let me upload files like gmail does. As most of you know by now, reading files from the browser in a way that works in all of them is not an easy task. So, about 3 or 4 months ago, i started developing a client behavior which i called FileUpload. As you might guess, this will be a long post where i'm going to explain the major design decisions i've made while building my component. Before going on, here are some things which youmust know and completly understand before reading and using my code:
I'm not giving any kind of support for this code. So, there's really no need to send me lots and lots of emails. I've only built this control to learn new stuff and to show that it's really possible for a mere mortal (like myself) to produce something cool which might help others;
you can only use this control with IIS 6 and above. In IIS 5.1 there's a bug which the MS team marked as non fixable which prevents me from returning a Response.Redirect from the server side when the current request exceeds the maximum allowed size. btw, this control will not work correctly with the internal server that comes with VS;
Internaly, the client behavior makes use of window.frames[0] in non IE clients. This means that the control will only work properly if you don't use iframes on the page. alternatively, you can change the code so that you can add those elements to the page;
The control has only been tested in Firefox 2.0 RC3 and Internet Explorer. I think it may need some care if anyone decides to port it to Opera.
If you've read it and agreed, then please download the files here.
Presenting the idea
So, what's the problem we're having here? We can't access the contents of a file from a browser. I'm already seeing several guys saying that i'm wrong and that it can be done easily if we use FSO. Well, the problem is that FSO only works in IE (after all, it's a COM object). Ok, so where does this leave us? yes, that's right: we'll have to do it with the classical <input type="file"> control and use the correct ammount of Javascripts and iframes :)
The plan is simple (as always, the devil is on the details): we must add a hidden iframe which will host an <input type="file"> control inside a form. Then, we can perform the postback by submitting that form progamatically. Since I wanted to support IE and Firefox, I had to take into account that there are several differences between what you can do with Javascript in each browser. For instance, in IE there's no need to show the <input type="file"> control since we can show a window that lets the user pick the file he's interested in programaticaly (ie, by writing some javascript). On the other hand, Firefox doesn't like this and will only show the file dialog when the user clicks on the button associated with the input control.
Another conclusion that can be reached from the 1st paragraph of this section is that we need to be able to save the file on the server side and to validate its size (you'll se why soon). Before that, lets start exploring the client size behavior.
The client behavior
The client behavior is maintained in uploadbehavior.js file which is embedded in the FileUploader dll. The behavior (which is called UploadBehavior) exposes the following properties:
frameId: you can use this property to set the ID of the hidden frame that is used to upload the file to the server. You only need to worry with this file when you have several UploadBehaviors on the same page (which might not work in the current release - specially if you use updatepanels and firefox);
inputId: it lets you specify the ID of the input <type="file"> that's created dinamically inside the hidden iframe;
url: used to specify the url of the handler that receives the file bytes (by default, the predefined handler is used);
During the initialization, the behavior starts by creating a handler and setting it to the click event fired by the associated HTML control. It also creates the hidden iframe and adds it to current HTML document:
_createFrame : function() {
var frame = document.createElement( "iframe" );
frame.name = this._frameId;
frame.id = this._frameId;
if( Sys.Browser.agent == Sys.Browser.InternetExplorer ){
document.body.appendChild( frame );
}
else{
var parent = this.get_element().parentNode;
parent.insertBefore( frame, this.get_element() );
}
//added this here so that if a second behavior exists on the page
//its iframe gets correctly filled
this._generateIFrameContent();
frame.style.display = "none"
},
//responsible for setting up the behavior
//it starts by creating a hidden iframe
//which will be used for uploading the control
initialize : function(){
LA.UploadBehavior.callBaseMethod( this, "initialize" );
this._clickHandler = Function.createDelegate( this, this._click );
$addHandler( this.get_element(), "click", this._clickHandler );
//create a frame
this._createFrame();
}
As you can see, if the current browser is not IE, then we insert the hidden iframe before the associated HTML control. The objective is to create the illusion that iframe is on the same place as the HTML associated control. the _generateIFrameContent is a interesting method that is responsible for generatig the contents of the hidden iframe:
_generateIFrameContent : function(){
var frameWnd = window.frames[this._frameId];
var frameObj = document.getElementById(this._frameId);
var queryStringDic = {};
queryStringDic["id"] = this._currentId.toLocaleString();
queryStringDic["retMethod"] = "LA.UploadBehavior.uploadComplete"
var queryStringEncoded = Sys.Net.WebRequest._createQueryString(queryStringDic);
var c + this._url +
"?" + queryStringEncoded +"' enctype='multipart/form-data'><input type='file' style='width:100%;height:100%' id='" +
this._inputId +"' name='" + this._inputId +"'\/><input type='hidden' id='inputId' name='inputId' value='" + this._inputId +
"' \/><input type='hidden' id='currentId' name='currentId' value='" + this._currentId.toLocaleString() +"' \/></form></body></html>"
//firefox has some problems recreating the iframe
//so i'll just go into the position and let's see what happen
if( !frameWnd.document ){
//just get frame at pos 0;
frameWnd = window.frames[0];
}
frameWnd.document.open();
frameWnd.document.write( content );
frameWnd.document.close();
//set event handlers
this._setHandler( frameWnd );
},
As you can see, there's a small problem with Firefox: when you delete the iframe (which is done on the dispose method) and try to recreate it, you'll get a null document if you try to access the iframe through its name. To solve that, I start by checking the document property and when it's null, i'll just get the first iframe placed at position 0. It's important to keep in mind that this will only happen when you put the iframe inside an UpdatePanel and that in Firefox you can only use one behavior of this type per page in those scenarios (unless you find another way of reaching into the correct iframe).
As we've seen, the upload is started when you click on the associated HTML control. the _click method is responsible for registering the behavior in a global dictionary added to the page. This dictionary is important since it'll let you have several controls on the same page and you'll be able to get the correct answers on all the uploads. Currently, the method performs the following tasks:
_click : function(){
LA.UploadBehavior._registerOnDictionary(this._currentId.toLocaleString(), this);
var frameWnd = window.frames[this._frameId];
var frameObj = document.getElementById(this._frameId);
//firefox has some problems recreating the iframe
//so i'll just go into the position and let's see what happen
if( !frameWnd.document ){
//just get frame at pos 0;
frameWnd = window.frames[0];
}
this._generateIFrameContent();
if( Sys.Browser.agent == Sys.Browser.InternetExplorer ){
frameObj.style.display = "none"
frameWnd.document.getElementById( this._inputId ).click();
}
else{
this._updateFrameSize( true );
this.get_element().style.display = "none"
}
return false;
},
One of the things that you should keep in mind is that iframes are special objects. When you need to access the properties of the DOM element that has been appended to the page, you should get a reference through the document.getElementById method; on the other hand, when you need to access the contents of the inner document of the window loaded inside of the iframe, you need to get a reference through the windows.frames collection maintained by our window. If you really must ask, the _updateFrameSize is a helper method that tries to set the size of the iframe so that it only occupies the space reserverd by the associated HTML control.
I don't know if you've noticed, but the _generateIFrameContent method i also responsible for setting a handler that will handle the propertychanged event that is fired by the <input type="file"> control that exists inside the iframe. Again, we must check the browser because IE and Firefox don't agree (again) on the name of the event that is fired when a property of an HTML control changes. The method _setHandler is presented in the next lines:
_setHandler : function( frame ) {
var file = frame.document.getElementById( this._inputId );
this._propertyChangedHandler = Function.createDelegate( this, this._onPropertyChanged );
if( Sys.Browser.agent == Sys.Browser.InternetExplorer ){
file.attachEvent( "onpropertychange", this._propertyChangedHandler );
}
else{
file.addEventListener( "change", this._propertyChangedHandler, false );
}
},
wtf? why am im using the attachEvent and the addEventListener instead of using the new $addHandler method? well, it happens that the method generated an exception when you try to handle the propertychange event of a control placed inside another window. Since i didn't had the time to investigate the issue, i just kept going and used those old methods for setting my handlers. As you might expect, the _onPropertyChanged method tries to submit the form maintained in the hidden iframe.
Since I wanted to use the client behavior from xml-script, i had to add a descriptor:
LA.UploadBehavior.descriptor = {
properties: [
{ name:"frameId", type: String },
{ name: "url", type: String },
{ name: "fileId", type:String },
{ name: "inputId", type: String }
],
events: [
{ name: "uploadCompleted" }
]
}
Before ending the client portion, it's also important to show the global methods that are responsible for maintaining the global dictionary and for updating the internal state of the behavior:
//global dictionary used to maintain references to
//behaviors that have started an upload
var ___myDic = new Object();
//adds a new entry to the dictionary
LA.UploadBehavior._registerOnDictionary = function(id, ref){ ___myDic[id] = ref; }
//removes reference from the dictionary
LA.UploadBehavior._removeFromDictionary = function(id){ delete ___myDic[id]; }
//global (static) method which is used to handle the uploadComplete event
LA.UploadBehavior.uploadComplete = function( info, id ){
var obj = ___myDic[id];
LA.UploadBehavior._removeFromDictionary(id);
if( Sys.Browser.agent != Sys.Browser.InternetExplorer ){
obj.get_element().style.display = ""
obj._updateFrameSize( false );
}
obj.set_fileId( info );
obj._raiseEvent( "uploadCompleted", Sys.EventArgs.Empty );
}
The uploadComplete is a static method which must be called from the server side handler to signal the end of the upload. it receives two parameters: the first, points to the name that was given to the file during the save operation performed by the server handler; the second, has the ID of the behavior that was saved previously to the global dictionary. And i think i've said everything about the client behavior. now, let's check the server handler.
The server handler
If you don't want to build a customized handler, you can reuse the one that comes with the file upload control dll. This is a very simple handler which saves the file to the current directory (normally, the root directory of the app) and is responsible for building and sending a response to the client behavior that started the upload. This response is really important! If you don't send one back, or if it gets lost somewhere or if the client behavior doesn't receive it in a predefined format, you won't be able to use the behavior to upload more files without performing a refresh (you'll start getting access denied errors).
btw, if you build a custom handler, you're expected to use the inputId and id query string parameters to get the id of the <input type="file"> that was responsible for the upload and the ID used by the behavior to register itself on the global client dictionary introduced by the client behavior file. You must also build the javascript reply and you should use the retMethod query string parameter to get the name of the method that you must call on the client side (so, what you really must do is return a predefined javascript method call from the handler). Here's the current handler's ProcessRequest implementation:
public void ProcessRequest(HttpContext context)
{
string idFileServer = "0"
string fileId = ""
HttpPostedFile file = null;
try
{
fileId = context.Request.Params["inputId"];
file = context.Request.Files[fileId];
if (file != null)
{
Guid guid = Guid.NewGuid();
file.SaveAs(context.Server.MapPath(guid.ToString()));
idFileServer = guid.ToString();
}
}
catch
{
idFileServer = "0"
}
string method = context.Request.QueryString["retMethod"].Substring(1, context.Request.QueryString["retMethod"].Length - 2);
string id = context.Request.QueryString["id"].Substring(1, context.Request.QueryString["id"].Length - 2);
string reply = string.Concat("<script type='text/javascript'>parent.window.",
method,
"('", idFileServer, "','",
id,
"');</script>");
context.Response.Clear();
context.Response.ClearContent();
context.Response.Write(reply);
context.Response.Flush();
context.Response.Close();
}
The module
If you take a look at the server side code, you'll find a module called FileUploadModule which handles the BeginRequest and the Error event (both fired by the application object). The begin request event is responsible for searching for a specific query string parameter. When it finds it, it returns a reply that says that there was an error during the upload of the file. This specific parameter is added to the url by the HandleError method. If you look at the code, you'll see that the module will only add that parameter and perform a Response.Redirect if we get a specific error code and if we find the retMethod parameter in the query string of the current request.
If you don't recognise the number of the error code, then don't worry :) it's the code associated with the error you get when the current request exceeds the maximum allowed size. When this happens, you're only able to return a redirect to the client. if you try to handle the request and perform a response.write, you'll see that the response will never reach the client. Btw, the response.redirect doesn't work in IIS 5.1 (at least, on XP machines). I've asked for MS support and after several emails exchanged, the answer was that IIS 5.1 didn't handle those scenarios very well and that they wouldn't fix that. Yep, this problem kept me from releasing this component for at least 3 months...
on a personal note, I'm not really sure that I should blame only IIS for this. If Firefox and IE behaved like Opera and sent only the headers + the expect:100 continue header, the final solution could have been more elegant. I'm not putting the code of the module here since you can dowload it.
The extender
The only thing that is left is the extender. Though i could have reused the toolkit infraestructure, I've just built a simple extender by using the classes provided by the AJAX extensions dll. The extender's responsability is to inject the javascript code on the client side and that's why it only exposes server side wrappers for the properties defined by the client behavior. Again, I'm not showing the code since you can download it and check it out for yourselves.
The demo site
The demo site contains 2 ASP.NET pages that show how you can use the extender to expand a label and how you can combine it with a control placed inside an UpdatePanel. Since we're talking about an extender, you can associate it with any other control you may see fit.
web.config configuration
You'll have to add at least the module to the web.config of your app if you want to use the control. If you also intend to use the handler, don't forget to configure IIS for the extension (if you're using IIS 6) and to add it to the <handlers> section.
And that's all. Yes, there are still lots of things that can be improved, but i'll leave that as an exercise for you :)
Above all, remember: you can use the control in any way you see fit but i'm not givin any kinf of support. You're also free to change it and tweek it to your hearts contempt. The only thing i ask is for a comment on the files saying that you've based your control on my code. |
|