I have been meaning to give the DWR (Direct Web Remoting) AJAX toolkit a shot for some time now. I consider myself a first generation AJAX programmer (as someone who has used XmlHttpRequest), but since I dont know any of the newer AJAX toolkits such as Prototype and DOJO, I just dont get no respect from the hotshot AJAX types (that was a joke BTW). Apart from that, considering that any session remotely related to AJAX played to overflowing crowds in this year's JavaOne, this was something I should have looked at quite some time ago. But since I dont do too much front end development, this was not something I needed to know, so I let it slide. So I finally got around to looking at DWR, and in this article, I describe an AJAX component using DWR and Velocity that can be served up within a portal-style page.
The component provides CRUD (Create, Retrieve, Update and Delete) functionality for a business object. The component is modelled as a state machine. The state diagram is shown below, where the nodes are views provided by the component, and the edges are the operations that are permitted on it.
DWR works by creating Javascript proxies for Java beans that are available to the servlet context. Methods can be called on the proxies in Javascript just as if they were regular Java objects. Each Javascript method call needs to provide an additional callback method parameter, which defines what to do with the result once it is available from the backend. This is because the calls are asynchronous (the A in AJAX) and the Javascript method call does not wait for the backend to respond. The callback method typically parses the return value of the method call and pops it into a span tag in the page.
In the example, I have used a BookReview bean (since I had some test data from one of my previous projects) but this strategy can be extended to allow any object to be exposed. Also, in my example, the service that provides data to the state machine is a local JDBC service, but could just as well have been a client talking to a remote webservice to get data.
I used Spring for the MVC framework, integrating DWR with it based on the instructions in Bram Smeet's weblog, and the DWR-Spring integration page on the DWR site. Unlike Bram Smeet's example however, where he uses Javascript to pull apart the bean returned from the backend service and populate the span tag, I went with the approach of using Velocity templates on the server to create HTML snippets and return the HTML, which the callback function then popped into the span tag. You would probably guess that I am no hotshot Javascript coder (and you'll be right), but the reason for this is more than just to avoid writing Javascript. So the reasons are, in no particular order:
- Avoid having to write any more Javascript than absolutely necessary.
- It is easier to unit test at the Java layer than the Javascript layer.
- Java has had better tool support than Javascript (although thats changing).
- Ability to cache Velocity templates on the server for performance.
Why Velocity? Well, since we are bypassing the standard request-response cycle using DWR, I could not use JSPs, since JSPs need to have a pageContext populated by the controller at the end of the request-response cycle. The other option was to have generated the HTML directly in the service classes using System.out.println() calls, but that would have taken us back to the dark ages of web programming. Velocity templates provide a clean separation of the view from the model without forcing us to participate in the HTTP request-response cycle. In the case of the webservice setup, the templates can live on the front end application, and the component look and feel can be tweaked without any changes to the webservice client. Even in the case of the basic setup, having templates is more maintainable, since we can change the presentation without affecting the underlying service layer.
Configuration
The configuration is based on information in Bram Smeet's weblog and the DWR-Spring integration pages, so there is nothing new here, I am just including it in here for completeness. I list below the contents of the web.xml, dwr.xml and the Spring comp-servlet.xml.
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 | <!DOCTYPE web-app PUBLIC "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN" "http://java.sun.com/dtd/web-app_2_3.dtd" >
<!-- WEB-INF/web.xml -->
<web-app>
<display-name>DWR/Velocity Component Test</display-name>
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<servlet>
<servlet-name>comp</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet>
<servlet-name>dwr-invoker</servlet-name>
<servlet-class>uk.ltd.getahead.dwr.DWRServlet</servlet-class>
<init-param>
<param-name>debug</param-name>
<param-value>true</param-value>
</init-param>
</servlet>
<servlet-mapping>
<servlet-name>comp</servlet-name>
<url-pattern>*.do</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>dwr-invoker</servlet-name>
<url-pattern>/dwr/*</url-pattern>
</servlet-mapping>
</web-app>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 | <!DOCTYPE dwr PUBLIC "-//GetAhead Limited//DTD Direct Web Remoting 1.0//EN" "http://www.getahead.ltd.uk/dwr/dwr10.dtd">
<!-- WEB-INF/dwr.xml -->
<dwr>
<allow>
<create creator="new" javascript="JDate">
<param name="class" value="java.util.Date" />
</create>
<create creator="spring" javascript="BookReviewService">
<param name="beanName" value="bookReviewService" />
<param name="location" value="classpath:comp-servlet.xml" />
</create>
</allow>
</dwr>
|
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 | <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE beans PUBLIC "-//SPRING//DTD BEAN//EN" "http://www.springframework.org/dtd/spring-beans.dtd" >
<!-- WEB-INF/comp-servlet.xml -->
<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close">
<property name="driverClassName" value="com.mysql.jdbc.Driver" />
<property name="url" value="jdbc:mysql://localhost:3306/bookshelfdb" />
<property name="username" value="root" />
<property name="password" value="mysql" />
</bean>
<bean id="bookReviewService" class="org.component.services.BookReviewService">
<property name="dataSource" ref="dataSource" />
</bean>
<bean id="bookReviewController" class="org.component.controllers.BookReviewController">
<property name="service" ref="bookReviewService" />
</bean>
<bean id="simpleUrlHandlerMapping" class="org.springframework.web.servlet.handler.SimpleUrlHandlerMapping">
<property name="mappings">
<props>
<prop key="main.do">bookReviewController</prop>
</props>
</property>
</bean>
</beans>
|
The Service
The code for the BookReviewService class is shown below. It consists of a set of public methods that the Javascript proxy can call, all of which return a String. The mergeContent() method takes a bean and a template name and renders the bean into the template. The BookReview bean is a simple JavaBean holder of properties, and the BookReviewCollection is a wrapper over a List<BookReview> which also contains the current page number and the total number of pages that can be displayed. In the interests of keeping this blog post to a manageable size, neither of these beans are shown, but they are trivial to implement.
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 | // BookReviewService.java
package org.component.services;
import java.io.StringWriter;
import java.util.List;
import java.util.Map;
import javax.sql.DataSource;
import org.apache.commons.lang.StringUtils;
import org.apache.log4j.Logger;
import org.apache.velocity.Template;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.Velocity;
import org.component.beans.BookReview;
import org.component.beans.BookReviewCollection;
import org.springframework.jdbc.core.ColumnMapRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
public class BookReviewService {
public static final String DEFAULT_ORDER_BY = "name";
private static final Logger log = Logger.getLogger(BookReviewService.class);
private static final int NUM_ROWS_PER_PAGE = 5;
private static final String TEMPLATE_DIR = "src/main/resources/templates";
private DataSource dataSource;
public BookReviewService() {
super();
}
public void setDataSource(DataSource dataSource) {
this.dataSource = dataSource;
}
public String getAllReviews(int page, String orderBy, boolean isOrderAscending) {
log.debug("getAllReviews(page=" + page + ", orderBy=" + orderBy + ", isOrderAscending=" + isOrderAscending + ")");
return getAllReviewsAndMergeToTemplate(page, orderBy, isOrderAscending, "all_reviews");
}
public String getAllReviewsAndMergeToTemplate(int page, String orderBy, boolean isOrderAscending, String templateFile) {
log.debug("getAllReviewsAndMergeToTemplate(page=" + page + ", orderBy=" + orderBy + ", isOrderAscending=" + isOrderAscending + ", templateFile=" + templateFile + ")");
JdbcTemplate jt = new JdbcTemplate(dataSource);
String limitStr = String.valueOf(page * NUM_ROWS_PER_PAGE) + "," + String.valueOf(NUM_ROWS_PER_PAGE);
if (orderBy == null) {
orderBy = "name";
}
List list = jt.queryForList(
"select id, name, author, review from books order by " +
(StringUtils.isEmpty(orderBy) ? DEFAULT_ORDER_BY : orderBy) +
(isOrderAscending ? " ASC" : " DESC") +
" limit " + limitStr, new Object[0]);
BookReview[] reviews = new BookReview[list.size()];
for (int i = 0; i < reviews.length; i++) {
Map row = (Map) list.get(i);
reviews[i] = new BookReview();
reviews[i].setId((Long) row.get("id"));
reviews[i].setBookTitle((String) row.get("name"));
reviews[i].setReviewer((String) row.get("author"));
reviews[i].setReviewText((String) row.get("review"));
}
int numReviews = jt.queryForInt("select count(*) from books");
int lastPage = (int) Math.ceil((double) numReviews / NUM_ROWS_PER_PAGE); BookReviewCollection collection = new BookReviewCollection();
collection.setCurrentPage(page);
collection.setLastPage(lastPage);
collection.setReviews(reviews);
return mergeContent(collection, templateFile);
}
public String getReview(int id) {
log.debug("getReview(id=" + id + ")");
BookReview review = getBookReview(id);
return mergeContent(review, "single_review");
}
public String addOrEditReviewForm(int id, String bookTitle, String reviewer, String text) {
log.debug("addOrEditReviewForm(id=" + id + ", bookTitle=" + bookTitle + ", reviewer=" + reviewer + ", text=" + text + ")");
BookReview review = new BookReview();
review.setId((long) id);
review.setBookTitle(bookTitle);
review.setReviewer(reviewer);
review.setReviewText(text);
return mergeContent(review, "add_edit_review");
}
public String previewReview(int id, String bookTitle, String reviewer, String text) {
log.debug("previewReview(id=" + id + ", bookTitle=" + bookTitle + ", reviewer=" + reviewer + ", text=" + text + ")");
BookReview review = new BookReview();
review.setId((long) id);
review.setBookTitle(bookTitle);
review.setReviewer(reviewer);
review.setReviewText(text);
return mergeContent(review, "preview_review");
}
public String saveReview(int id, String bookTitle, String reviewer, String text) {
log.debug("saveReview(id=" + id + ", bookTitle=" + bookTitle + ", reviewer=" + reviewer + ", text=" + text + ")");
JdbcTemplate jt = new JdbcTemplate(dataSource);
if (id == 0) {
jt.update("insert into books (id, name, author, review) values (0, ?, ?, ?)",
new Object[] {bookTitle, reviewer, text});
}
return getAllReviews(0, DEFAULT_ORDER_BY, true);
}
public String deleteReview(int id) {
log.debug("deleteReview(id=" + id + ")");
JdbcTemplate jt = new JdbcTemplate(dataSource);
if (id != 0) {
jt.update("delete from books where id=?", new Object[] {new Long(id)});
}
return getAllReviews(0, DEFAULT_ORDER_BY, true);
}
// for package access by test class
protected BookReview getBookReview(int id) {
BookReview review = new BookReview();
if (id != 0) {
JdbcTemplate jt = new JdbcTemplate(dataSource);
Map row = (Map) jt.queryForObject("select id, name, author, review from books where id=?", new Object[] {id}, new ColumnMapRowMapper());
review.setId((Long) row.get("id"));
review.setBookTitle((String) row.get("name"));
review.setReviewer((String) row.get("author"));
review.setReviewText((String) row.get("review"));
} else {
review.setId(0L);
}
return review;
}
private String mergeContent(Object bean, String templateFile) {
try {
Velocity.init();
VelocityContext vc = new VelocityContext();
vc.put("bean", bean);
Template t = Velocity.getTemplate(TEMPLATE_DIR + "/" + templateFile + ".vm");
StringWriter writer = new StringWriter();
t.merge(vc, writer);
writer.flush();
writer.close();
return writer.getBuffer().toString();
} catch (Exception e) {
log.error("Error merging content", e);
return "";
}
}
}
|
The Velocity Templates
The Velocity templates have a 1:1 correspondence with the nodes in our state diagram above. The main.vm template represents the component as it will first appear when the containing page is invoked. Notice the span tag named "component". This is where all the subsequent content pulled from method calls on the BookReviewService bean will be put.
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 | ## main.vm
<!--
test page: http://localhost:8080/smart-component/dwr/index.html
this page: http://localhost:8080/smart-component/test.html
-->
<html>
<head>
<title>BookReviews</title>
<script type="text/javascript" src="/smart-component/dwr/interface/BookReviewService.js"></script>
<script type="text/javascript" src="/smart-component/dwr/engine.js"></script>
<script type="text/javascript" src="/smart-component/dwr/util.js"></script>
</head>
<body>
<script type="text/javascript">
var callback = function(contents) {
document.getElementById('component').innerHTML = contents;
}
</script>
<span id="component">
<table cellspacing="2" cellpadding="2" border="1">
<tr>
<td><b>Book Title</b></td>
<td><b>Reviewer</b></td>
<td><b>Review</b></td>
<td><b>Edit</b></td>
<td><b>Delete</b></td>
</tr>
#foreach ($review in ${bean.reviews})
<tr>
<td>${review.bookTitle}</td>
<td>${review.reviewer}</td>
<td>${review.reviewText}</td>
<td><input type="button" name="edit" value="Edit" onClick="BookReviewService.addOrEditReviewForm('${review.id}', '${review.bookTitle}', '${review.reviewer}', '${review.reviewText}', callback);" /></td>
<td><input type="button" name="delete" value="Delete" onClick="BookReviewService.deleteReview('${review.id}', callback);" /></td>
</tr>
#end
</table>
<input type="button" name="add" value="Add Review" onClick="BookReviewService.addOrEditReviewForm('0', '', '', '', callback);" />
|
#if (${bean.currentPage} > 0 && ${bean.currentPage} < ${bean.lastPage})
#set ($prevPage = ${bean.currentPage} - 1)
<input type="button" name="prevPage" value="Previous Page" onClick="BookReviewService.getAllReviews('${prevPage}', '', 'true', callback);" />
#end
#if (${bean.currentPage} == 0)
#set ($nextPage = ${bean.currentPage} + 1)
<input type="button" name="nextPage" value="Next Page" onClick="BookReviewService.getAllReviews('${nextPage}', '', 'true', callback);" />
#end
</span>
</body>
</html>
|
The other pages are all_reviews.vm, add_edit_review.vm, single_review.vm and preview_review.vm. The all_reviews.vm contains the template for the list view, the add_edit_review.vm is the form template, and the single_review.vm and preview_review.vm are templates for the single book review view and the preview view (before saving). One thing to notice in the add_edit_review.vm is that it is not enclosed in a form tag. Enclosing the form in a form tag will make it do a request on submit, which we don't want.
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 | ## all_reviews.vm
<table cellspacing="2" cellpadding="2" border="1">
<tr>
<td><b>Book Title</b></td>
<td><b>Reviewer</b></td>
<td><b>Review</b></td>
<td><b>Edit</b></td>
<td><b>Delete</b></td>
</tr>
#foreach ($review in ${bean.reviews})
<tr>
<td>${review.bookTitle}</td>
<td>${review.reviewer}</td>
<td>${review.reviewText}</td>
<td><input type="button" name="edit" value="Edit" onClick="BookReviewService.addOrEditReviewForm('${review.id}', '${review.bookTitle}', '${review.reviewer}', '${review.reviewText}', callback);" /></td>
<td><input type="button" name="delete" value="Delete" onClick="BookReviewService.deleteReview('${review.id}', callback);" /></td>
</tr>
#end
</table>
<input type="button" name="add" value="Add Review" onClick="BookReviewService.addOrEditReviewForm('0', '', '', '', callback)" />
|
#if (${bean.currentPage} > 0 && ${bean.currentPage} < ${bean.lastPage})
#set ($prevPage = ${bean.currentPage} - 1)
<input type="button" name="prevPage" value="Previous Page" onClick="BookReviewService.getAllReviews('${prevPage}', '', 'true', callback);" />
#end
#if (${bean.currentPage} == 0)
#set ($nextPage = ${bean.currentPage} + 1)
<input type="button" name="nextPage" value="Next Page" onClick="BookReviewService.getAllReviews('${nextPage}', '', 'true', callback)" />
#end
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | ## add_edit_review.vm
<input id="add_edit.id" type="hidden" name="id" value="$!{bean.id}" />
<table cellspacing="3" cellpadding="0" border="0">
<tr>
<td><b>Title:</b></td>
<td><input id="add_edit.bookTitle" type="text" name="name" value="$!{bean.bookTitle}" /></td>
</tr>
<tr>
<td><b>Your name:</b></td>
<td><input id="add_edit.reviewer" type="text" name="author" value="$!{bean.reviewer}" /></td>
</tr>
<tr><td colspan="2"><b>Comment</td></tr>
<tr>
<td colspan="2"><textarea id="add_edit.reviewText" cols="80" rows="10" name="text">$!{bean.reviewText}</textarea></td>
</tr>
</table>
<input type="button" name="preview" value="Preview" onClick="BookReviewService.previewReview(document.getElementById('add_edit.id').value, document.getElementById('add_edit.bookTitle').value, document.getElementById('add_edit.reviewer').value, document.getElementById('add_edit.reviewText').value, callback);" />
<input type="button" name="cancel" value="Cancel" onClick="BookReviewService.getAllReviews('0', '', 'true', callback);" />
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | ## single_review.vm
<table>
<tr>
<td><b>Title:</b> ${bean.bookTitle}</td>
</tr>
<tr>
<td><b>Reviewed by:</b> ${bean.reviewer}</td>
</tr>
<tr>
<td><b>Review:</b> ${bean.reviewText}</td>
</tr>
</table>
<input type="button" name="edit" value="Edit" onClick="BookReviewService.addOrEditReviewForm('${bean.id}', '${bean.bookTitle}', '${bean.reviewer}', '${bean.reviewText}', callback);" />
<input type="button" name="delete" value="Delete" onClick="BookReviewService.deleteReview('${bean.id}', callback);" />
<input type="button" name="list" value="Back to List" onClick="BookReviewService.getAllReviews('0', '', 'true', callback);" />
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | ## preview_review.vm
<table>
<tr>
<td><b>Title:</b>
<td>${bean.bookTitle}</td>
</tr>
<tr>
<td><b>Reviewed by:</b>
<td>${bean.reviewer}</td>
</tr>
<tr>
<td colspan="2">${bean.reviewText}</td>
</tr>
<tr>
</tr>
</table>
<input type="button" name="save" value="Save" onClick="BookReviewService.saveReview('${bean.id}', '${bean.bookTitle}', '${bean.reviewer}', '${bean.reviewText}', callback);" />
<input type="button" name="edit" value="Edit" onClick="BookReviewService.addOrEditReviewForm('${bean.id}', '${bean.bookTitle}', '${bean.reviewer}', '${bean.reviewText}', callback);" />
<input type="button" name="cancel" value="Cancel" onClick="BookReviewService.getAllReviews('0', '', 'true', callback);" />
|
Bootstrapping the Component
Since Javascript is event based, there has to be some event to start it up. I tried starting the list view with an onLoad event, but that was getting very confusing, since I could not populate the same span tag for all subsequent events. So I decided to bootstrap the component with a standard Spring Controller. So when you type this URL into your browser,
1 | http://localhost:8080/comp/main.do
|
The main.vm template is used to provide an initial listing of BookReview objects in the database. The code for the Controller is straightforward, it just invokes the BookReviewService.getAllReviewsAndMergeToTemplate() method and writes directly to the ServletOutputStream.
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 | package org.component.controllers;
import java.io.OutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.log4j.Logger;
import org.component.services.BookReviewService;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.Controller;
public class BookReviewController implements Controller {
private static final Logger log = Logger.getLogger(BookReviewController.class);
private BookReviewService service;
public BookReviewController() {
super();
}
public void setService(BookReviewService service) {
this.service = service;
}
public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
String htmlOutput = service.getAllReviewsAndMergeToTemplate(0, BookReviewService.DEFAULT_ORDER_BY, true, "main");
OutputStream ostream = response.getOutputStream();
ostream.write(htmlOutput.getBytes());
ostream.flush();
ostream.close();
return null;
}
}
|
Possible DWR Bug
I could not make method calls on onClick events on links work with DWR and Firefox 1.5. It looks like it may be a bug in DWR since the Javascript error message points to engine.js, a DWR supplied file. That is why the templates have so many buttons, since onClick events are triggered correctly if the link is replaced with a button. If anybody has made it work, please let me know.
Conclusion
The combination of Velocity templates to generate HTML on the server and DWR clients to consume it makes for very readable and maintainable code, compared to using XmlHttpRequest calls from Javascript. AJAX is definitely here to stay, and opens up lots of possibilities for partitioning application functionality.