Thursday, April 17, 2008

Spawning a thread for a lengthy operation in Wicket

Sometimes you need to do time-consuming tasks and for whatever reason, you cannot perform that operation using Ajax. I needed to support the upload of files that could potentially take a long time, and since the file upload field in Wicket doesn't support Ajax, it got me thinking of a generic way to do this. Note that although I applied this technique to a file upload, it can be used for anything.


What I came up with was a page that shows a message to the user that the lengthy operation is occurring (in the form of a spinner/wait icon and some text), and when the operation completes the spinner is hidden and the message changes. The lengthy operation itself happens in a separate thread.


If the user leaves the page while the operation is running and then returns, he will see the busy message, and the form fields are disabled. If he returns to the page after the operation completes, he sees the normal page, that is, he can start another operation if he likes.


This is a summary of what I needed this page to do:



  • Don't run the operation using Ajax; instead start a background thread

  • Allow users to leave the page, perform other tasks, then return to see if the operation has completed

  • Display a spinner and a message during the operation, and use Ajax to hide the spinner and change the message when the operation completes

  • Disable the form fields while the operation is running to prevent the user from running another operation in parallel


The solution consists of the UploadPage.java that spawns the thread that performs the operation, and MySession.java that saves the state of the operation in the session:


UploadPage.java


package wicket.quickstart;

import java.io.IOException;
import java.io.InputStream;

import org.apache.wicket.AttributeModifier;
import org.apache.wicket.ajax.AjaxSelfUpdatingTimerBehavior;
import org.apache.wicket.behavior.HeaderContributor;
import org.apache.wicket.markup.html.WebMarkupContainer;
import org.apache.wicket.markup.html.WebPage;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.form.Button;
import org.apache.wicket.markup.html.form.Form;
import org.apache.wicket.markup.html.form.upload.FileUpload;
import org.apache.wicket.markup.html.form.upload.FileUploadField;
import org.apache.wicket.model.AbstractReadOnlyModel;
import org.apache.wicket.model.PropertyModel;
import org.apache.wicket.util.time.Duration;

public class UploadPage extends WebPage
{
private static final long serialVersionUID = 4692700450356316897L;

/**
* Transient because no need to serialize
*/
private transient FileUpload uploadFile;

public UploadPage()
{
add(HeaderContributor.forCss(UploadPage.class, "UploadPage.css"));

// Reset the completion flag. It's used to display a message that is
// only displayed once in the status label, using ajax when the upload
// is complete. When the user visits the page again, we don't want to
// display this message.
getMySession().setUploadComplete(false);

final WebMarkupContainer spinner = new WebMarkupContainer("spinner");
// This AttributeModifier is used to hide the spinner when the upload is
// finished.
final AttributeModifier am = new AttributeModifier("style", true,
new AbstractReadOnlyModel()
{
private static final long serialVersionUID = 2013912742253160111L;

public Object getObject()
{
return (getMySession().isUploading()) ? ""
: "display:none";
}
});
spinner.add(am);
spinner.add(new AjaxSelfUpdatingTimerBehavior(Duration.seconds(5)));
add(spinner);

final Label status = new Label("uploadstatus",
new AbstractReadOnlyModel()
{
private static final long serialVersionUID = 938943178761943953L;

@Override
public Object getObject()
{
if (getMySession().isUploading())
{
return getString("uploading");
}
else if (getMySession().isUploadComplete())
{
return getString("uploadcomplete");
}
else
{
return "";
}
}
});
status.add(new AjaxSelfUpdatingTimerBehavior(Duration.seconds(5)));
add(status);

final Form myform = new Form("myform");

final FileUploadField uploadField = new FileUploadField("uploadFile",
new PropertyModel(this, "uploadFile"));
uploadField.setEnabled(!getMySession().isUploading());
myform.add(uploadField);

final Button cancel = new Button("cancel")
{
private static final long serialVersionUID = 691332069442892669L;

@Override
public void onSubmit()
{
setResponsePage(HomePage.class);
}
};
cancel.setEnabled(!getMySession().isUploading());
myform.add(cancel);

final Button ok = new Button("ok")
{
private static final long serialVersionUID = -590104379892310699L;

@Override
public void onSubmit()
{
if (getMySession().isUploading())
return;
// Start a thread that will continue running even if the user
// goes to another page.
final ImportThread it = new ImportThread(getMySession(),
uploadFile);
it.start();
// Refresh the page in order to disable the form field and
// buttons.
setResponsePage(UploadPage.class);
}
};
ok.setEnabled(!getMySession().isUploading());
myform.add(ok);

add(myform);
}

public FileUpload getImportFile()
{
return uploadFile;
}

public void setImportFile(FileUpload uploadFile)
{
this.uploadFile = uploadFile;
}

/** This class does the actual uploading */
private static class ImportThread extends Thread
{
private final MySession session;
private final FileUpload uploadFile;

public ImportThread(MySession session, FileUpload uploadFile)
{
this.session = session;
this.uploadFile = uploadFile;
}

@SuppressWarnings("static-access")
public void run()
{
session.setIsUploading(true);
try
{
@SuppressWarnings("unused")
final InputStream in = uploadFile.getInputStream();
// Do something with input stream here...
// Sleep to simulate time-consuming work
Thread.sleep(10000);
session.setUploadComplete(true);
}
catch (IOException e)
{
session.error(e.getMessage());
}
catch (InterruptedException e)
{
session.error(e.getMessage());
}
finally
{
session.setIsUploading(false);
}
}
}

public MySession getMySession()
{
return (MySession) getSession();
}
}

UploadPage.html


<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:wicket="http://wicket.sourceforge.net/" xml:lang="en"
lang="en">
<body>
<p><wicket:link><a href="HomePage.html">Home</a></wicket:link></p>
<div class="spinner" wicket:id="spinner">&nbsp;</div>
<div class="uploadstatus">
<span wicket:id="uploadstatus">[Importing]</span>
</div>
<div class="uploadform">
<form wicket:id="myform">
<table>
<tr>
<td>File:</td><td><input type="file" wicket:id="uploadFile" /></td>
</tr>
<tr>
<td><input type="submit" wicket:id="ok" value="OK" /></td>
<td><input type="submit" wicket:id="cancel" value="Cancel" /></td>
</tr>
</table>
</form>
</div>
</body>
</html>

UploadPage.css


div.spinner {
float: left;
margin-left: 15px;
margin-bottom: 15px;
padding-left: 16px;
background-image:url(/app/resources/org.apache.wicket.ajax.AbstractDefaultAjaxBehavior/indicator.gif);
background-repeat: no-repeat;
width: 16;
height: 16;
background-color: transparent;
}


div.uploadstatus {
float: left;
margin-left: 20px;
margin-bottom: 15px;
width: 90%;
}

div.uploadform {
float: left;
width: 100%;
}

UploadPage.properties


uploading=Uploading file, please wait...
uploadcomplete=File upload is complete.


MySession.java


package wicket.quickstart;

import org.apache.wicket.Request;
import org.apache.wicket.protocol.http.WebSession;

public final class MySession extends WebSession
{
private static final long serialVersionUID = 159108722454986819L;

/** Tracks the status of the lengthy process of uploading.
* These are declared volatile to make sure the JVM writes the value
* of the flag from the ImportThread to the Wicket thread.
*/
private volatile boolean uploading, uploadComplete;

protected MySession(Request req)
{
super(req);
}

public boolean isUploading()
{
return uploading;
}

/**
* Set when the upload thread starts, and reset when the upload ends or
* fails.
*/
public void setIsUploading(boolean uploading)
{
this.uploading = uploading;
}

public boolean isUploadComplete()
{
return uploadComplete;
}

/**
* Set when the upload thread succeeds, and reset when the upload page is
* reloaded.
*/
public void setUploadComplete(boolean uploadComplete)
{
this.uploadComplete = uploadComplete;
}
}

6 comments:

Philip Johnson said...

Wow! This is _exactly_ the code sample I have been looking for. Thanks so much for taking the time to post it!

Cheers,
Philip

lars said...

Hi,

There is a visibility issue in your example. The uploading and uploadComplete flag are accessed by two different Threads (Wicket's Thread and the ImportThread). If you don't do proper locking or declare those properties as volatile it might happen that a change to one of those properties is never seen by the other Thread. The JVM allows this behavior for optimization reasons.
Too prevent the visibility problem I suggest you declare the uploading and uploadComplete flags as volatile. This forces the JVM to write the changes made to those properties to main memory and is therefor seen by all Threads.

Julian Sinai said...

Philip, I'm glad I could help you.

Lars, thanks for pointing out this potential problem. It did not turn out to be an issue in my testing, I believe because I only allow one Wicket Thread and the ImportThread per user session. The issue might occur if I allowed multiple ImportThreads per user session. However, since what you suggest is a better practice, I've made the change.

andy said...

This is great and just what I've been looking for.

My only thing now is that if somebody uploads a file, when it's complete I get the 'upload complete' message. But all the fields are still disabled until I exit the page and re-enter it.

Is there any way to re-enable the buttons once the upload is complete automatically?

Thanks
Andrew

Julian Sinai said...

andy, you can reenable the buttons using the same technique as for the spinner:

final Button cancel = new Button("cancel")
{
private static final long serialVersionUID = 691332069442892669L;

@Override
public void onSubmit()
{
setResponsePage(HomePage.class);
}
@Override
public boolean isEnabled()
{
return !getMySession().isUploading();
}
};
cancel.add(new AjaxSelfUpdatingTimerBehavior(Duration.seconds(5)));
myform.add(cancel);

However, this doesn't work for the Browse button, at least on IE. The file upload control is a different beast.

andy said...

I've tried this, but if I try to write out the file (uploadFile.writeTo) in the thread it sometimes fails with a "read error". if I move the writeTo into the main submit button processing it works fine. Do you know of any potential problems in doing this?