Sunday, October 01, 2006

GWT: AJAX framework for the Javascript averse

I have been meaning to check out GWT (Google Web Toolkit) since JavaOne this year, when I overheard someone tell someone else how "absolutely freakin' wonderful" it was. But since most of the AJAX related work I did was concerned with returning data from the server for AJAX-ified front end components, the opportunity did not come up. Recently though, based on something I had to do at work, I decided to use GWT to try and build a test component with two dropdown lists for my personal book reviews application. The objective was to check out how easy/hard it would be to do, and to create a template for the stuff I had to do at work. This article describes the code for this widget and the things I had to do to get it working.

Very briefly, GWT allows you to build your Javascript widgets using Java and the GWT widget class library. It allows you to generate Javascript code from the Java code you write. Only the Javascript code will be shipped to your production server.

Problem definition

So here's the problem. I have a bunch of reviews on books on various subjects, so I would like to be able to categorize the books by type, then by title. There are 2 dropdowns. The first dropdown lists the various categories of books I have reviewed, such as "Linux", "Java", "Databases", etc. The second dropdown lists the book titles in the category selected in the first dropdown. Once I select a category from the first dropdown and a title from the second dropdown, and then hit the Go button, the review should show up in a TextArea below the dropdowns.

Download GWT

The first step was to download the GWT SDK from the GWT site. GWT class libraries (the widgets) are available under an Apache 2.0 license, but the toolkit itself is covered under a slightly different license. However, that should not bother most developers, since GWT is free for both commercial and non-commercial use, and Google's license does not assert any rights to the code you create with GWT, so its yours, free and clear. GWT is currently available for Windows and Linux. I downloaded the Linux version.

Create project

GWT comes with some scripts to build an Eclipse application. There is also scripts to build an Ant application, for those who do not use Eclipse, but I did not look at that. There are three scripts that need to be run, the first to create the Eclipse project, the second to create an empty GWT application skeleton, and the third to create a JUnit test case for the application.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[sujit@cyclone gwt-linux-1.0.21]$ ./projectCreator -eclipse bookshelf -out /home/sujit/src/bookshelf
Created directory /home/sujit/src/bookshelf/src
Created file /home/sujit/src/bookshelf/.project
Created file /home/sujit/src/bookshelf/.classpath

[sujit@cyclone gwt-linux-1.0.21]$ ./applicationCreator -eclipse bookshelf -out /home/sujit/src/bookshelf org.sujit.bookshelf.client.BookShelfViewer
Created directory /home/sujit/src/bookshelf/src/org/sujit/bookshelf
Created directory /home/sujit/src/bookshelf/src/org/sujit/bookshelf/client
Created directory /home/sujit/src/bookshelf/src/org/sujit/bookshelf/public
Created file /home/sujit/src/bookshelf/src/org/sujit/bookshelf/BookShelfViewer.gwt.xml
Created file /home/sujit/src/bookshelf/src/org/sujit/bookshelf/public/BookShelfViewer.html
Created file /home/sujit/src/bookshelf/src/org/sujit/bookshelf/client/BookShelfViewer.java
Created file /home/sujit/src/bookshelf/BookShelfViewer.launch
Created file /home/sujit/src/bookshelf/BookShelfViewer-shell
Created file /home/sujit/src/bookshelf/BookShelfViewer-compile

[sujit@cyclone gwt-linux-1.0.21]$ ./junitCreator -junit /usr/java/eclipse/plugins/org.junit_3.8.1/junit.jar -eclipse BookShelfViewer -out /home/sujit/src/bookshelf org.sujit.bookshelf.test.BookShelfViewerTest
Created directory /home/sujit/src/bookshelf/src/org/sujit/bookshelf/test
Created file /home/sujit/src/bookshelf/src/org/sujit/bookshelf/BookShelfViewerTest.gwt.xml
Created file /home/sujit/src/bookshelf/src/org/sujit/bookshelf/test/BookShelfViewerTest.java
Created file /home/sujit/src/bookshelf/BookShelfViewerTest-hosted.launch
Created file /home/sujit/src/bookshelf/BookShelfViewerTest-web.launch
Created file /home/sujit/src/bookshelf/BookShelfViewerTest-hosted
Created file /home/sujit/src/bookshelf/BookShelfViewerTest-web

Notice that this means that you have a project for each GWT component that you decide to build. For those who prefer developing within a monolithic environment, where there is a single web project, and we just keep adding new functionality into it, this may not be the best thing, but having your web project built off a number of tiny projects does lead to more maintainability, and the ability to release code with the confidence that you haven't broken something somewhere else.

XML Configuration

Configuration is quite simple. There is a ${projectname}.gwt.xml that provides the information about the entry point to our GWT module. The entry-point element provides the class name for our main widget, and the servlet-path provides information about the backend service.

Note that the configuration is all for development. In production, you will simply ship the generated Javascript code on the front end, and the servlet on the backend.

1
2
3
4
5
6
7
<module>
  <!-- Inherit the core Web Toolkit stuff.                  -->
  <inherits name='com.google.gwt.user.User'/>
  <!-- Specify the app entry point class.                   -->
  <entry-point class='org.sujit.bookshelf.client.BookShelfViewer'/>
  <servlet path="/bookshelf-service" class="org.sujit.bookshelf.server.BookShelfServiceServlet" />
</module>

The Widget Code

The Widget Code is built off the components available in the GWT Widget class library. The style should be familiar to anyone who has written Java Swing code. The onModuleLoad() method is called when the module loads, and is the place where the widget is defined. The onChange() method is called whenever the selected item changes in either ListBox object, and the onClick() method is called when the Go button is clicked. The constructory specifies the location of the backend service. The getXXX() and setXXX() methods provide accessors and mutators for the internal variables which store the contents of the ListBox and the TextArea objects. The loadXXX() methods are responsible for pulling data off the backend component (more on that below), and the refreshXXX() methods are responsible for taking the backend data and populating the ListBox and TextArea widgets.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
package org.sujit.bookshelf.client;

import java.util.Iterator;
import java.util.List;

import com.google.gwt.core.client.EntryPoint;
import com.google.gwt.core.client.GWT;
import com.google.gwt.user.client.rpc.AsyncCallback;
import com.google.gwt.user.client.rpc.ServiceDefTarget;
import com.google.gwt.user.client.ui.Button;
import com.google.gwt.user.client.ui.ChangeListener;
import com.google.gwt.user.client.ui.ClickListener;
import com.google.gwt.user.client.ui.HorizontalPanel;
import com.google.gwt.user.client.ui.Label;
import com.google.gwt.user.client.ui.ListBox;
import com.google.gwt.user.client.ui.RootPanel;
import com.google.gwt.user.client.ui.TextArea;
import com.google.gwt.user.client.ui.VerticalPanel;
import com.google.gwt.user.client.ui.Widget;

public class BookShelfViewer implements EntryPoint, ChangeListener, ClickListener {

  private static final String WAIT_MESSAGE = "Retrieving, please wait...";

  // data to be shown in widget
  private List categories;
  private List titlesInCategory;
  private String review;

  // internal tracking indexes
  private int selectedCategoryIndex;
  private int selectedTitleIndex;

  // we are listening on events raised by these widgets.
  private ListBox categoryListBox;
  private ListBox titleListBox;
  private Button goButton;
  private TextArea reviewTextArea;

  // Async interface of a service
  private BookShelfServiceAsync bookShelfService;

  public BookShelfViewer() {
    super();
    bookShelfService = (BookShelfServiceAsync) GWT.create(BookShelfService.class);
    ServiceDefTarget serviceEndPoint = (ServiceDefTarget) bookShelfService;
    serviceEndPoint.setServiceEntryPoint("/bookshelf-service");
  }

  public List getCategories() {
    return this.categories;
  }

  public void setCategories(List categories) {
    this.categories = categories;
  }

  public String getReview() {
    return this.review;
  }

  public void setReview(String review) {
    this.review = review;
  }

  public int getSelectedCategoryIndex() {
    return this.selectedCategoryIndex;
  }

  public void setSelectedCategoryIndex(int selectedCategoryIndex) {
    this.selectedCategoryIndex = selectedCategoryIndex;
  }

  public int getSelectedTitleIndex() {
    return this.selectedTitleIndex;
  }

  public void setSelectedTitleIndex(int selectedTitleIndex) {
    this.selectedTitleIndex = selectedTitleIndex;
  }

  public List getTitlesInCategory() {
    return this.titlesInCategory;
  }

  public void setTitlesInCategory(List titlesInCategory) {
    this.titlesInCategory = titlesInCategory;
  }

  /**
   * This is the entry point method.
   */
   public void onModuleLoad() {

     loadCategories();
     loadTitlesInCategory("Databases");

     Label label = new Label("Choose your poison:");

     categoryListBox = new ListBox();
     categoryListBox.setVisibleItemCount(1);
     categoryListBox.setSelectedIndex(0);
     refreshCategoryListBox(categories);
     categoryListBox.addChangeListener(this);
     setSelectedCategoryIndex(0);

     titleListBox = new ListBox();
     titleListBox.setVisibleItemCount(1);
     titleListBox.setSelectedIndex(0);
     refreshTitleListBox(titlesInCategory);
     titleListBox.addChangeListener(this);
     setSelectedTitleIndex(0);

     goButton = new Button();
     goButton.setText("Go!");
     goButton.addClickListener(this);

     reviewTextArea = new TextArea();
     reviewTextArea.setCharacterWidth(50);
     reviewTextArea.setVisibleLines(10);
     loadReviews(getSelectedCategoryIndex(), getSelectedTitleIndex());
     refreshReviewTextArea(review);

     HorizontalPanel toolbarPanel = new HorizontalPanel();
     toolbarPanel.add(label);
     toolbarPanel.add(categoryListBox);
     toolbarPanel.add(titleListBox);
     toolbarPanel.add(goButton);

     VerticalPanel componentPanel = new VerticalPanel();
     componentPanel.add(toolbarPanel);
     componentPanel.add(reviewTextArea);

     RootPanel.get().add(componentPanel);
   }

   /**
    * Event raised by selections in the category list box and title list boxes
    * will trigger this method.
    * @see com.google.gwt.user.client.ui.ChangeListener#onChange(com.google.gwt.user.client.ui.Widget)
    * @param sender the widget that raised the event.
    */
   public void onChange(Widget sender) {
     if (sender == categoryListBox) {
       // update the title list box
       int selectedIndex = categoryListBox.getSelectedIndex();
       setSelectedCategoryIndex(selectedIndex);
       String categoryName = (String) categories.get(selectedIndex);
       loadTitlesInCategory(categoryName);
       refreshTitleListBox(titlesInCategory);
     } else if (sender == titleListBox) {
       int selectedIndex = titleListBox.getSelectedIndex();
       setSelectedTitleIndex(selectedIndex);
     }
   }

   /**
    * Events raised by clicking the "Go" button will trigger this method.
    * @see com.google.gwt.user.client.ui.ClickListener#onClick(com.google.gwt.user.client.ui.Widget)
    * @param sender the widget that raised this event.
    */
   public void onClick(Widget sender) {
     if (sender == goButton) {
       loadReviews(getSelectedCategoryIndex(), getSelectedTitleIndex());
       refreshReviewTextArea(review);
     }
   }

   private void loadCategories() {
     bookShelfService.getCategories(new AsyncCallback() {
       public void onFailure(Throwable caught) {
         reviewTextArea.setText(caught.toString());
       }
       public void onSuccess(Object result) {
         setCategories((List) result);
         refreshCategoryListBox(getCategories());
       }
     });
   }

   private void refreshCategoryListBox(List categories) {
     categoryListBox.clear();
     if (categories != null) {
       for (Iterator it = categories.iterator(); it.hasNext();) {
         String category = (String) it.next();
         categoryListBox.addItem(category);
       }
     } else {
       categoryListBox.addItem(WAIT_MESSAGE);
     }
   }

   private void loadTitlesInCategory(String categoryName) {
     bookShelfService.getTitles(categoryName, new AsyncCallback() {
      public void onFailure(Throwable caught) {
        reviewTextArea.setText(caught.toString());
      }
      public void onSuccess(Object result) {
        setTitlesInCategory((List) result);
        refreshTitleListBox(getTitlesInCategory());
      }
     });
   }

   private void refreshTitleListBox(List titlesByCategory) {
     titleListBox.clear();
     if (titlesByCategory != null) {
       for (Iterator it = titlesByCategory.iterator(); it.hasNext();) {
         String title = (String) it.next();
         titleListBox.addItem(title);
       }
     } else {
       titleListBox.addItem(WAIT_MESSAGE);
     }
   }

   private void loadReviews(int selectedCategoryIndex, int selectedTitleIndex) {
     String categoryName = categoryListBox.getItemText(selectedCategoryIndex);
     String titleName = titleListBox.getItemText(selectedTitleIndex);
     bookShelfService.getReviewText(categoryName, titleName, new AsyncCallback() {
       public void onFailure(Throwable caught) {
         setReview(caught.toString());
       }
       public void onSuccess(Object result) {
         setReview((String) result);
         refreshReviewTextArea(getReview());
       }
     });
   }

   private void refreshReviewTextArea(String review) {
     if (review != null) {
       reviewTextArea.setText(review);
     } else {
       reviewTextArea.setText(WAIT_MESSAGE);
     }
   }
}

Communicating with the Server

In order to communicate with the server, we need two interfaces on the client side, and an implementing class on the server side. The implementing class is a Servlet, and subclasses the RemoteServiceServlet class provided with the GWT toolkit. The first interface on the client side is an interface specifying what services are exposed by the Servlet, and the second one is an Async version of the service interface that is used by the GWT callback mechanism. Notice that the Async version always returns void and has an extra AsyncCallback parameter for each corresponding method. The AsynCallback is where the results are passed back to the client.

1
2
3
4
5
6
7
package org.sujit.bookshelf.client;

public interface BookShelfService extends RemoteService {
  public List getCategories();
  public List getTitles(String category);
  public String getReviewText(String category, String title);
}
1
2
3
4
5
6
7
8
9
package org.sujit.bookshelf.client;

import com.google.gwt.user.client.rpc.AsyncCallback;

public interface BookShelfServiceAsync {
  public void getCategories(AsyncCallback callback);
  public void getTitles(String category, AsyncCallback callback);
  public void getReviewText(String category, String title, AsyncCallback callback);
}

On the server side, there is a simple Servlet which implements the BookShelfService interface and extends the GWT RemoteServiceServlet. Although there are no guidelines on where to place it, I decided to put it in a server directory, a sibling of the client directory, and that seems to work fine.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package org.sujit.bookshelf.server;

import javax.servlet.ServletException;

import org.sujit.bookshelf.client.BookShelfService;

import com.google.gwt.user.server.rpc.RemoteServiceServlet;

public class BookShelfServiceServlet extends RemoteServiceServlet implements BookShelfService {

  public void init() throws ServletException {
    super.init();
    // database setup
  }

  public List getCategories() {
    // database call
  }

  public String getReviewText(String category, String title) {
    // database call
  }

  public List getTitles(String category) {
    // database call
  }
}

Handling Asynchronicity

If you look at any of the loadXXX() methods in the BookShelfViewer.java widget code, you will notice that calls to the BookShelfService servlet instantiate an anonymous AsyncCallback inner class. The result object in onSuccess() contains the object returned by the corresponding call to the server, so you will need to cast it appropriately.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
   private void loadReviews(int selectedCategoryIndex, int selectedTitleIndex) {
     String categoryName = categoryListBox.getItemText(selectedCategoryIndex);
     String titleName = titleListBox.getItemText(selectedTitleIndex);
     bookShelfService.getReviewText(categoryName, titleName, new AsyncCallback() {
       public void onFailure(Throwable caught) {
         setReview(caught.toString());
       }
       public void onSuccess(Object result) {
         setReview((String) result);
         refreshReviewTextArea(getReview());
       }
     });
   }

Another thing that took me some time to get used to was the fact that because AsyncCallback is asynchronous, the widget can call for data after it is requested from the server but before it is returned. As a result you are likely to get a NullPointerException on the client. The trick is to check for whether the result is null, and if so, to replace it with a generic "Waiting for server..." message, like the refreshXXX() methods here. As a user, you will probably never notice the message, but your application will not get the NullPointer.

1
2
3
4
5
6
7
   private void refreshReviewTextArea(String review) {
     if (review != null) {
       reviewTextArea.setText(review);
     } else {
       reviewTextArea.setText(WAIT_MESSAGE);
     }
   }

Running the shell

To run the application in the GWT shell, we need to start the ${projectName}-shell script that was generated when we ran the applicationCreator script. This gives us two windows, one to show the log messages, as shown below. The shell starts an embedded Tomcat server on port 8888.

Testing

Its great that GWT provides a script to create a JUnit test case, but in my opinion this is of limited use, since it does not support events. To fire events, you presumably have to call these methods in your test, and verify that the ListBoxes get populated as you intend. It may be more useful to test the server side thorougly, and verify manually that some of the cases are adequately handled on the front end. But that may just be true for this component, not for more complicated ones.

Deployment

The Javascript files are deployed into www/${componentClassName} directory. For those of you who did not particularly fancy my plain old HTML component, GWT offers you the ability to style the component from within Java by linking in a CSS Style sheet. Tweaking the Javascript is really not an option, and is counter-productive, since now you have to apply the same tweaks each time you change your Java code. And you have only to look at each cache file to realize how difficult it is going to be to tweak the Javascript. From what I have found, GWT supports Netscape, Firefox, MSIE, Opera and Safari, by generating different versions of the Javascript code and using browser signature sniffing to direct to the right Javascript.

Conclusion

The best part about GWT is that the developer does not have to know or write Javascript. Many expert Java developers trivialize Javascript, as if it were beneath them to have to write Javascript, but speaking for myself, I just don't know Javascript as well as I know Java. Also, Javascript is a more forgiving language, so it allows you to get away with incorrect syntax, but will manifest itself as runtime errors which are very hard to debug. Tool support for Javascript is also not as good as for Java. Personally, therefore, I found it easier to work with the GWT's Swing style code, even though I haven't done much desktop Java programming. One of the deal breakers with GWT is the quality of the generated Javascript, which comes back in one big block without indentation and such, so its very hard to tweak. However, since GWT works by generating Javascript, tweaking the Javascript will actually lead to a maintenance nightmare in any case, so the correct approach would be to fix the Java code to do what you want.

Overall, I found GWT to be quite flexible and easy to work with. The scripts to create an project make it very easy to get up and running with GWT. The GWT shell is also a real time saver since all you have to do to push your Java code changes out to Javascript is to click the Refresh button. Working with a server component does not work quite so well in the shell, especially if you have to use JDBC libraries and such. I got around this by mocking out my server component to not use JDBC, but looking back, I could have either put my server component outside on another application server (a good move in any case, since that way I could test it independently), or by adding the JDBC JAR files in the BookShelfViewer.lanch file.

GWT seems to be most suitable for AJAX applications that are standalone, in the sense that they do not interact with other components (AJAX or non-AJAX) on the web page. For example, GWT may not be the best choice if my Go button needed to send a form action request back to the server with a consequent page turn. However, if the component controls its own life cycle, such as something like Google maps, which are pretty much full page AJAX rich clients, then GWT would definitely be the way to go.

4 comments:

  1. Thanks for writing this article. Do you have the complete source available for download?

    ReplyDelete
  2. Hi, you are welcome, and sorry, no I do not. However, all the code that is relevant is in the article itself, you just have to copy and paste into the right places.

    ReplyDelete
  3. Excellent post. Thanks for posting. Keep it up..

    ReplyDelete

Comments are moderated to prevent spam.