Showing posts with label velocity. Show all posts
Showing posts with label velocity. Show all posts

Saturday, July 07, 2007

WebApp Scaffolds with Java, Spring and Maven2

Most dynamic web applications depend on a lot of data stored in databases, and ours is no exception. As the site grows in complexity, our current approach of maintaining the data using SQL is becoming less and less tenable. There are more tables than there were before, and one must understand the relationships before being able to update data. There is a crying need for simple CRUD based application data maintenance tools that operate on a set of one or more tables. However, because of the need to deliver our applications on tight deadlines, we frequently have no time to build these tools, so we end up like the shoemaker's children who have no shoes.

While this may seem like we are saving time by omitting non-essential items in our deliverables schedule, it's actually a really dumb move for several reasons. One, each time you have to add or otherwise maintain your application data, you have to go back and try to understand the data structure and rewrite the SQL to do it. This takes time, so essentially you are paying in time and effort over the life of the project what you would have paid up front...many times over. Two, because the process is manual, you will make mistakes at some point, and some of the mistakes may be catastrophic. Three, without a tool, you are stuck making the updates, since nobody but an engineer is insane enough to consider an SQL editor a long term data maintenance tool. With a web-based tool, if you are lucky, you can pass the work off to the person who needs the data changes in the first place. Even if you are not lucky and you are stuck making the changes yourself, life will still be easier with a tool.

I started working on a little personal project to build a project reporting system couple of weeks ago. It's fully database driven, the application will provide little more than an interface to the database. I had started working on a little framework, inspired by Ruby on Rails (RoR) about 2 years ago, that would generate most of the basic application, but I had shelved it because I did not have much use for it and I was not making much progress. I did consider existing Java based Rails-like frameworks in Java (Sails, Grails), but I decided to build my own because both Sails and Grails are rather large and hard to understand, and use all sorts of cool software which I don't need or care about. They also generate the application into a different structure than I expect. The framework is described in some detail below:

Because there is quite a lot of code (at least more than is possible to show in this blog entry), and there is a chance that I may enhance it in the future, I have applied for a SourceForge project to host the code. If I get it, the project would be called AutoCRUD, and the source code would be available there. Check there about a week from the date of this post.

The code is generated using the information in the database. For that reason, the table and column names must follow certain conventions, so this may be impractical to use for an existing project as is. However, the code is reasonably simple, so it may be possible to make it use a different set of naming conventions that you may follow consistently on your database. The database naming conventions the code depends on are listed below:

  1. Entities are represented by singular table names, eg. project, person, etc. Table names for tables representing entities must not contain underscores.
  2. Join tables that join two entities must be named with the two entity names separated by underscore, eg. project_person. These tables will have a single underscore.
  3. All tables must have an id column. The column data type must resolve to a Java long type. Different databases use different type mappings, for MySQL it is bigint(20). These columns should be defined as auto-incrementing (for MySQL, no other database is supported at the moment). Databases that support sequences (such as Oracle or PostgreSQL) should have an appropriately named sequence, but thats only for future compatibility issues.
  4. Foreign key reference columns must be named ${entityName}_id where entityName is the table which is being referenced from this table using the foreign key. Again, this does not matter at the moment, but may come up in the future.

So based on the rules above, I start off with the following schema for my database:

 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
create table project (
  id bigint(20) unsigned not null auto_increment,
  name varchar(32) not null,
  description varchar(256) not null,
  start_dt timestamp not null,
  end_dt timestamp,
  primary key(id)
);

create table person (
  id bigint(20) unsigned not null auto_increment,
  name varchar(32) not null,
  primary key(id)
);

create table project_person (
  id bigint(20) unsigned not null auto_increment,
  project_id bigint(20),
  person_id bigint(20),
  primary key(id)
);
create unique index ux1_project_person on project_person(project_id, person_id);
alter table project_person 
  add constraint fk_project foreign key(project_id) references project(id);
alter table project_person
  add constraint fk_person foreign key(person_id) references person(id);

create table task (
  id bigint(20) unsigned not null auto_increment,
  project_id bigint(20) not null,
  seq_id varchar(8) not null,
  name varchar(64) not null,
  est_hrs integer not null,
  primary key(id)
);
create unique index ux1_task on task(project_id, seq_id);
alter table task
  add constraint fk_project foreign key(project_id) references project(id);

create table hour (
  id bigint(20) unsigned not null auto_increment,
  task_id bigint(20) not null,
  log_date date not null,
  act_hrs integer not null,
  primary key(id)
);
create unique index ux1_hour on hour(task_id, log_date);
alter table hour 
  add constraint fk_task foreign key(task_id) references task(id);

The following artifacts need to be generated to build the Maven2 Spring web application.

  1. The web.xml file. This will be in src/main/webapp/WEB-INF and will contain the servlet definition for the Spring DispatcherServlet.
  2. The Spring application context file (*-servlet.xml). This would live in src/main/webapp/WEB-INF and contain all the ActiveRecord and controller definitions.
  3. The index.jsp landing page.
  4. The ActiveRecord subclasses for each individual tables in the schema. These will be packaged under the project package in the beans package.
  5. The list.jsp, show.jsp and edit.jsp files for each individual table. These will be placed in the src/main/webapp/ directory, each set of three JSP files will be in its own subdirectory that is the table name, eg. src/main/webapp/project/list.jsp.

All the templates for the various files are provided as Velocity .vm files, so they can be changed if you don't like my coding style, indentation, etc. I just show the generated code for a single entity project to keep this blog post to a reasonable size.

The bean subclass for the project entity looks like this. As you can see, internally its just a Map structure. The generation process gives it formal getters and setters which can be accessed from JSTL in the JSPs.

 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
public class Project extends ActiveRecord {

  public Project() {
    super();
    setFields(new String[] {
      "name",
      "description",
      "start_dt",
      "end_dt",
      "id"
    });
    setTableName("project");
  }

  public void setName(String inputValue) {
    setValue("name", inputValue);
  }
  
  public String getName() {
    return (String) getValue("name");
  }
  public void setDescription(String inputValue) {
    setValue("description", inputValue);
  }
  
  public String getDescription() {
    return (String) getValue("description");
  }
  public void setStartDt(java.sql.Timestamp inputValue) {
    setValue("start_dt", inputValue);
  }
  
  public java.sql.Timestamp getStartDt() {
    return (java.sql.Timestamp) getValue("start_dt");
  }
  public void setEndDt(java.sql.Timestamp inputValue) {
    setValue("end_dt", inputValue);
  }
  
  public java.sql.Timestamp getEndDt() {
    return (java.sql.Timestamp) getValue("end_dt");
  }
}

The code for ActiveRecord is shown below. It provides some methods that will be called from the generated code, and database persistence methods such as save(), delete(), findAll(), findById() and findBy() methods.

  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
public class ActiveRecord extends JdbcDaoSupport {

  private Map<String,Object> fields = new HashMap<String,Object>();
  private String tableName;

  /**
   * Called from within the generated setter methods in subclasses.
   * @param fieldName the database field name.
   * @param value the value.
   */
  public void setValue(String fieldName, Object value) {
    fields.put(fieldName, value);
  }

  /**
   * Called from within the generated getter methods in subclasses.
   * @param fieldName the database field name.
   * @return the value.
   */
  public Object getValue(String fieldName) {
    return fields.get(fieldName);
  }

  /**
   * Set the database column names. This is set by the generated subclasses
   * in their constructor.
   * @param fieldNames the database column names.
   */
  public void setFields(String[] fieldNames) {
    for (String fieldName : fieldNames) {
      if (fields.containsKey(fieldName)) {
        continue;
      }
      fields.put(fieldName, null);
    }
  }
  
  public Set<String> getFields() {
    return fields.keySet();
  }
  
  /**
   * Set the database table name. This is set by the generated subclasses
   * in their constructor.
   * @param tableName the database table name.
   */
  public void setTableName(String tableName) {
    this.tableName = tableName;
  }
  
  public String getTableName() {
    return tableName;
  }
  
  public List<ActiveRecord> find(String condition) throws Exception {
    if (StringUtils.isBlank(condition)) {
      return findAll();
    }
    Set<String> colNames = fields.keySet();
    StringBuilder queryBuilder = new StringBuilder();
    queryBuilder.append("select ").
      append(StringUtils.join(colNames.iterator(), ',')).
      append(" from ").
      append(getTableName()).
      append(" where ").append(condition);
    return getActiveRecords(queryBuilder.toString());
  }

  public List<ActiveRecord> findAll() throws Exception {
    Set<String> colNames = fields.keySet();
    StringBuilder queryBuilder = new StringBuilder();
    queryBuilder.append("select ").
      append(StringUtils.join(colNames.iterator(), ',')).
      append(" from ").
      append(getTableName());
    return getActiveRecords(queryBuilder.toString());
  }

  public ActiveRecord findById(long id) throws Exception {
    List<ActiveRecord> records = find("id=" + id);
    if (records.size() == 0) {
      return null;
    }
    if (records.size() > 1) {
      throw new Exception(getTableName() + ".id must be a primary key");
    }
    return records.get(0);
  }

  public long getId() {
    Object oid = fields.get("id");
    if (oid == null) {
      return 0L;
    }
    return new Long(oid.toString());
  }

  public long delete() throws Exception {
    StringBuilder queryBuilder = new StringBuilder();
    queryBuilder.append("delete from ").
      append(getTableName()).
      append(" where id=").
      append(getId());
    getJdbcTemplate().update(queryBuilder.toString());
    return getId();
  }

  public long save() throws Exception {
    ActiveRecord dbRecord = findById(getId());
    StringBuilder queryBuilder = new StringBuilder();
    if (this.equals(dbRecord)) {
      // record exists, do update
      queryBuilder.append("update ").
        append(getTableName()).
        append(" set ");
      Set<String> colNames = fields.keySet();
      Object[] params = new Object[colNames.size() - 1];
      int i = 0;
      for (String colName : colNames) {
        if (colName.equals("id")) {
          continue;
        }
        if (i > 0) {
          queryBuilder.append(",");
        }
        queryBuilder.append(colName).append("=?");
        params[i] = fields.get(colName);
        i++;
      }
      queryBuilder.append(" where id=").append(getId());
      getJdbcTemplate().update(queryBuilder.toString(), params);
    } else {
      // record is new, do insert
      Set<String> colNames = fields.keySet();
      Object[] params = new Object[colNames.size()];
      queryBuilder.append("insert into ").append(getTableName()).append("(");
      StringBuilder columnListBuilder = new StringBuilder();
      StringBuilder placeHolderBuilder = new StringBuilder();
      int i = 0;
      for (String colName : colNames) {
        if (i > 0) {
          columnListBuilder.append(",");
          placeHolderBuilder.append(",");
        }
        columnListBuilder.append(colName);
        placeHolderBuilder.append("?");
        params[i] = fields.get(colName);
        i++;
      }
      queryBuilder.append(columnListBuilder.toString()).
        append(")values(").
        append(placeHolderBuilder.toString()).
        append(")");
      getJdbcTemplate().update(queryBuilder.toString(), params);
      long id = getJdbcTemplate().queryForLong("select max(id) from " + getTableName());
      setValue("id", id);
    }
    return getId();
  }

  @Override
  public int hashCode() {
    return (int) getId();
  }
  
  @Override
  public boolean equals(Object obj) {
    if (!(obj instanceof ActiveRecord)) {
      return false;
    }
    ActiveRecord that = (ActiveRecord) obj;
    return (this.getId() == that.getId());
  }

  protected ActiveRecord newInstance(String className) throws Exception {
    Object obj = Class.forName(className).newInstance();
    if (obj instanceof ActiveRecord) {
      ActiveRecord activeRecord = (ActiveRecord) obj;
      activeRecord.setDataSource(getDataSource());
      activeRecord.setValue("id", new Long(0L));
      return activeRecord;
    } else {
      throw new Exception("Class:" + className + " must extend ActiveRecord");
    }
  }

  @SuppressWarnings("unchecked")
  private List<ActiveRecord> getActiveRecords(String query) throws Exception {
    List<Map<String,Object>> rows = getJdbcTemplate().queryForList(query);
    List<ActiveRecord> records = new ArrayList<ActiveRecord>();
    Set<String> colNames = fields.keySet();
    for (Map<String,Object> row : rows) {
      ActiveRecord record = newInstance(this.getClass().getName());
      for (String colName : colNames) {
        record.setValue(colName, row.get(colName));
      }
      records.add(record);
    }
    return records;
  }
}

We wrap the ActiveRecord instance in a transactional proxy, so its save() and delete() operations are transactional. The full bean definition is shown below. The definitions are automatically generated by the scaffold generation code. References to external beans such as dataSource and transactionManager are set up (by the generator) prior to declaring the bean.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  <bean id="project" class="org.springframework.transaction.interceptor.TransactionProxyFactoryBean">
    <property name="target">
      <bean class="net.sf.prozac.app.beans.Project" scope="prototype">
        <property name="dataSource" ref="dataSource"/>
      </bean>
    </property>
    <property name="transactionManager" ref="transactionManager"/>
    <property name="proxyTargetClass" value="true"/>
    <property name="transactionAttributes">
      <props>
        <prop key="*">PROPAGATION_REQUIRED,-Exception</prop>
        <prop key="find*">PROPAGATION_SUPPORTS</prop>
      </props>
    </property>
  </bean>

Sample usage of the Project ActiveRecord is shown from my JUnit test below. The usage is slightly less natural than if you had used a separate DAO for this bean, but it is not really hard to follow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
    Project project = (Project) context.getBean("project");
    project.setName("Rocket Launcher");
    project.setDescription("A test project to do rocket launching");
    project.setStartDt(new Timestamp(System.currentTimeMillis()));
    project.setEndDt(new Timestamp(System.currentTimeMillis()));
    long id = project.save();
    logger.debug("project.id=" + id);
    List<ActiveRecord> projects = project.findAll();
    for (ActiveRecord record : projects) {
      Project p1 = (Project) record;
      logger.debug("project.id=" + p1.getId());
      logger.debug("project.name=" + p1.getName());
      logger.debug("project.description=" + p1.getDescription());
      logger.debug("project.startDt=" + p1.getStartDt());
      logger.debug("project.endDt=" + p1.getEndDt());
      p1.delete();
    }

The ActiveController is a standard Spring MultiActionController which enforces the navigation logic. It uses a ParameterMethodName resolver, and the initial and default action is the list view. The action parameter determines which method is invoked. The code for the ActiveController is shown below:

 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
public class ActiveController extends MultiActionController {

  private ActiveRecord activeRecord;
  private String tableName;
  
  public void setActiveRecord(ActiveRecord activeRecord) {
    this.activeRecord = activeRecord;
  }
  
  public void setTableName(String tableName) {
    this.tableName = tableName;
  }
  
  public ModelAndView list(HttpServletRequest request, HttpServletResponse response) throws Exception {
    List<ActiveRecord> records = activeRecord.findAll();
    ModelAndView mav = new ModelAndView();
    mav.addObject("records", records);
    mav.addObject("fields", activeRecord.getFields());
    mav.setViewName(StringUtils.join(new String[] {tableName, "list"}, '/'));
    return mav;
  }
  
  public ModelAndView add(HttpServletRequest request, HttpServletResponse response) throws Exception {
    Set<String> fieldNames = activeRecord.getFields();
    ActiveRecord record = activeRecord.newInstance(activeRecord.getClass().getName());
    ModelAndView mav = new ModelAndView();
    mav.addObject("record", record);
    mav.setViewName(StringUtils.join(new String[] {tableName, "edit"}, '/'));
    return mav;
  }
  
  public ModelAndView edit(HttpServletRequest request, HttpServletResponse response) throws Exception {
    long id = ServletRequestUtils.getRequiredLongParameter(request, "id");
    ActiveRecord record = activeRecord.findById(id);
    ModelAndView mav = new ModelAndView();
    mav.addObject("record", record);
    mav.setViewName(StringUtils.join(new String[] {tableName, "edit"}, '/'));
    return mav;
  }
  
  public ModelAndView save(HttpServletRequest request, HttpServletResponse response) throws Exception {
    Set<String> fieldNames = activeRecord.getFields();
    ActiveRecord record = activeRecord.newInstance(activeRecord.getClass().getName());
    for (String fieldName : fieldNames) {
      String value = ServletRequestUtils.getStringParameter(request, fieldName);
      record.setValue(fieldName, value);
    }
    record.save();
    return list(request, response);
  }
  
  public ModelAndView remove(HttpServletRequest request, HttpServletResponse response) throws Exception {
    long id = ServletRequestUtils.getRequiredLongParameter(request, "id");
    ActiveRecord record = activeRecord.findById(id);
    record.delete();
    return list(request, response);
  }
  
  public ModelAndView show(HttpServletRequest request, HttpServletResponse response) throws Exception {
    long id = ServletRequestUtils.getRequiredLongParameter(request, "id");
    ActiveRecord record = activeRecord.findById(id);
    ModelAndView mav = new ModelAndView();
    mav.addObject("record", record);
    mav.addObject("fields", activeRecord.getFields());
    mav.setViewName(StringUtils.join(new String[] {tableName, "show"}, '/'));
    return mav;
  }
  
  public ModelAndView search(HttpServletRequest request, HttpServletResponse response) throws Exception {
    StringBuilder conditionBuilder = new StringBuilder();
    Set<String> fieldNames = activeRecord.getFields();
    for (String fieldName : fieldNames) {
      String value = ServletRequestUtils.getStringParameter(request, fieldName);
      if (StringUtils.isBlank(value)) {
        continue;
      }
      conditionBuilder.append(fieldName + "='" + value + "'");
    }
    List<ActiveRecord> records = activeRecord.find(conditionBuilder.toString());
    ModelAndView mav = new ModelAndView();
    mav.addObject("records", records);
    mav.addObject("fields", activeRecord.getFields());
    mav.setViewName(StringUtils.join(new String[] {tableName, "list"}, '/'));
    return mav;
  }
}

Currently, each ActiveRecord controller instance uses the ActiveController directly, although in keeping with RoR style, these should be empty subclasses which the user can override if desired. Here is the bean configuration for the projectController:

1
2
3
4
5
6
7
8
9
  <bean id="projectController" class="net.sf.prozac.framework.ActiveController">
    <property name="activeRecord" ref="project"/>
    <property name="tableName" value="project"/>
    <property name="methodNameResolver">
      <bean class="org.springframework.web.servlet.mvc.multiaction.ParameterMethodNameResolver">
        <property name="defaultMethodName" value="list"/>
      </bean>
    </property>
  </bean>

The three JSPs that are generated per table (or entity bean) are the list.jsp, edit.jsp and the show.jsp files. They live in their own subdirectory, named after the table, in the src/main/webapp directory. They are shown below:

  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
<!-- project/list.jsp -->
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/functions" prefix="fn" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/fmt" prefix="fmt" %>
    
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>project:list</title>
</head>
<body>
  <h1>project:list</h1>
  <table cellspacing="0" cellpadding="0" border="1" width="100%">
    <tr>
      <th>name</th>
      <th>description</th>
      <th>start_dt</th>
      <th>end_dt</th>
      <th>View</th>
    </tr>
    <c:forEach items="${records}" var="record">
    <tr>
      <td>${record.name}</td>
      <td>${record.description}</td>
      <td>${record.startDt}</td>
      <td>${record.endDt}</td>
      <td><a href="/prozac/project.do?action=show&id=${record.id}">View</a></td>
    </tr>
    </c:forEach>
  </table>
  <br/>
  <a href="/prozac/project.do?action=add">Add</a>
</body>
</html>

<!-- project/edit.jsp -->
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/functions" prefix="fn" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/fmt" prefix="fmt" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>project:edit</title>
</head>
<body>
  <h1>project:edit</h1>
  <form name="edit" action="/prozac/project.do" method="post">
    <input type="hidden" name="action" value="save"/>
    <input type="hidden" name="id" value="${record.id}"/>
    <table cellspacing="0" cellpadding="0" border="0" width="100%">
      <tr>
        <td><b>name</b></td>
        <td><input type="text" name="name" value="${record.name}"/></td>
      </tr>
      <tr>
        <td><b>description</b></td>
        <td><input type="text" name="description" value="${record.description}"/></td>
      </tr>
      <tr>
        <td><b>start_dt</b></td>
        <td><input type="text" name="startDt" value="${record.startDt}"/></td>
      </tr>
      <tr>
        <td><b>end_dt</b></td>
        <td><input type="text" name="endDt" value="${record.endDt}"/></td>
      </tr>
    </table>
    <input type="submit" name="submit" value="Submit"/>&nbsp;&nbsp;
    <input type="button" name="cancel" value="Cancel" onclick="javascript:window.location='/prozac/project.do?action=list'"/>
  </form>
</body>
</html>

<!-- project/show.jsp -->
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/functions" prefix="fn" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/fmt" prefix="fmt" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>project:show</title>
</head>
<body>
  <h1>project:show</h1>
  <table cellspacing="0" cellpadding="0" border="0" width="100%">
    <tr>
      <td><b>name</b></td>
      <td>${record.name}</td>
    </tr>
    <tr>
      <td><b>description</b></td>
      <td>${record.description}</td>
    </tr>
    <tr>
      <td><b>start_dt</b></td>
      <td>${record.startDt}</td>
    </tr>
    <tr>
      <td><b>end_dt</b></td>
      <td>${record.endDt}</td>
    </tr>
  </table>
  <input type="button" name="edit" value="Edit" onclick="javascript:window.location='/prozac/project.do?action=edit&id=${record.id}'"/>&nbsp;&nbsp;
  <input type="button" name="delete" value="Delete" onclick="javascript:window.location='/prozac/project.do?action=remove&id=${record.id}'"/>&nbsp;&nbsp;
  <input type="button" name="cancel" value="Cancel" onclick="javascript:window.location='/prozac/project.do?action=list'"/>
</body>
</html>

The generated files can be run right away without any editing, but admittedly the pages are not very pretty. At this point, it is fairly easy to just manually prettify it up. Alternatively, if we standardize on how the pages should look, and what widgets should be used for particular column data types, we could modify the velocity template files to produce this. So after generating all the files, I fire up Jetty using Maven's jetty6:run goal and point to localhost:8080/prozac (my application name).

Application Index Page
Project Lists Page (no entries currently). Click Add link to add a project.
Add Project Page. Cancel will send you back to list page, Submit will add the record and send you back to the list page (below).
Project List page with a single entry. Click on the View link to see the single project record.
Project view page. Click on Edit to edit the contents, delete to delete the contents and cancel to do nothing and return to the list. We select edit.
Project Edit page. We change the description and click the Submit button.
The Project List page with the change applied.

There are quite a few things that can be improved with this framework. First off, it works only against the MySQL database, which is the database I have on my laptop at the moment. I will need to make it work with Oracle if I want to use this at work. The other major improvement is in the use of appropriate widgets for various data types. I am not an expert on that end, so I guess I will have to get someone who is good at that to take a look there. Yet another thing I want to work on is to be able to specify associations, which both RoR and the Java RoR-like frameworks allow but AutoCRUD does not.

Update (July 15, 2007): I have uploaded the code described in the blog to the AutoCRUD project on Sourceforge.

Saturday, July 29, 2006

AJAX Component with DWR and Velocity

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);" />
      &nbsp;|
#if (${bean.currentPage} > 0 && ${bean.currentPage} < ${bean.lastPage})
#set ($prevPage = ${bean.currentPage} - 1)
      &nbsp;
      <input type="button" name="prevPage" value="Previous Page" onClick="BookReviewService.getAllReviews('${prevPage}', '', 'true', callback);" />
#end
#if (${bean.currentPage} == 0)
#set ($nextPage = ${bean.currentPage} + 1)
      &nbsp;
      <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)" />
&nbsp;|
#if (${bean.currentPage} > 0 && ${bean.currentPage} < ${bean.lastPage})
#set ($prevPage = ${bean.currentPage} - 1)
&nbsp;
<input type="button" name="prevPage" value="Previous Page" onClick="BookReviewService.getAllReviews('${prevPage}', '', 'true', callback);" />
#end
#if (${bean.currentPage} == 0)
#set ($nextPage = ${bean.currentPage} + 1)
&nbsp;
<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);" />
&nbsp;
<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>&nbsp;${bean.bookTitle}</td>
  </tr>
  <tr>
    <td><b>Reviewed by:</b>&nbsp;${bean.reviewer}</td>
  </tr>
  <tr>
    <td><b>Review:</b>&nbsp;${bean.reviewText}</td>
  </tr>
</table>
<input type="button" name="edit" value="Edit" onClick="BookReviewService.addOrEditReviewForm('${bean.id}', '${bean.bookTitle}', '${bean.reviewer}', '${bean.reviewText}', callback);" />&nbsp;
<input type="button" name="delete" value="Delete" onClick="BookReviewService.deleteReview('${bean.id}', callback);" />&nbsp;
<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);" />
&nbsp;
<input type="button" name="edit" value="Edit" onClick="BookReviewService.addOrEditReviewForm('${bean.id}', '${bean.bookTitle}', '${bean.reviewer}', '${bean.reviewText}', callback);" />
&nbsp;
<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.