Saturday, March 04, 2006

Tapestry Newbie Recipes

Recently, I set out to learn Tapestry by writing a web-based tool for managing a Drools rules repository for Drools 3.0. The tool is not done yet, but I did learn enough about how to use Tapestry for various scenarios, which I share here. These recipes would be most useful to people who have used classic web MVC frameworks such as Spring and Struts, since I mostly highlight the difference in approach between the classic MVC way and the Tapestry way of doing things.

When designing a new web application, I typically build the CRUD (Create, Retrieve, Update and Delete) views of all the entities first, and worry about the navigation later. There are only two views required per entity, as can be seen from the state diagram on the right. The List view will show a list of the entities available in the database filtered by some query criteria, or all entities available if there is no query applied. The Edit view will show a form which will allow editing an existing entity, or adding a new entity. There could be a third view, a Show View, where you show a similar form based view of the entity, but the Edit view could be repurposed for this with a little HTML magic.

However, before I start on the recipes on how to generate the Edit and List views with Tapestry, a little background on how Tapestry differs from other web MVC frameworks is in order. The classic web MVC application has the following layers:

  • The Model: These are typically JavaBeans that provide getters and setters to expose the properties required by the view JSPs, and are populated via a service manager which exposes services from the Data Access Objects (DAOs).
  • The Controller: Controller classes connect the Model (data) beans with the View (rendering) beans. A web MVC framework usually provides an uber Servlet class which would delegate to one of the Controller classes based on the request it recieves.
  • The View: Views are typically JSPs in Java based web frameworks, although Velocity templates are also a popular alternative.

On the other hand, Tapestry applications are layered in the following manner.

  • The Java bean: This roughly corresponds with the model layer described above, with one important addition. It also contains listener methods, which respond to events sent from the HTML view component. So in a sense, this layer contains the Model and part of the controller logic to do routing.
  • The page specification: The page specification (.page) connects the HTML view layer with the JavaBean layer. It contains the full class name of the JavaBean, and contains mappings between the HTML elements (specified by the jwcid attribute) to the properties exposed by the JavaBean via getXXX() and setXXX() methods.
  • The HTML view: The HTML view is a plain HTML file. HTML elements that Tapestry would use for dynamic content are marked with the jwcid attribute. The actual dynamic content is specified using OGNL syntax. Buttons and links which are marked with the jwcid attribute can be configured to fire events which are handled by the JavaBean mapped to the view.

So whats so great about Tapestry, other than the fact that it layers its stuff a little differently? First, unlike any other Java based MVC, the views are all perfectly valid HTML, which means that a web designer who does not know JSP can work on the HTML without any fear of breaking the page, and that the page will show up correctly in a standard HTML viewer. Second, like Velocity, it removes the temptation for the web developer to put fancy logic inside the view layer in the form of JSP scriptlets. Third, the listener framework built into Tapestry makes routing much simpler than classic web MVC frameworks. Fourth, because Tapestry is so component-based, there are a large number of Tapestry and contributed components that provide complex HTML functionality (including Javascript) which you can use like prebuilt Lego blocks to build your web pages faster.

The downside of Tapestry, obviously, is its differences from classic web MVC frameworks, necessiating a steep learning curve for people coming from these frameworks. But comprehensive documentation is included with the Tapestry distribution, and if you spend the time to go through and understand this information, I think it will be time well spent, and you will be rewarded with faster development cycles and cleaner and more maintainable code.

There are some other downsides, too, for which there are no clear-cut answers. For one, the URLs generated by Tapestry are non-intuitive and not bookmarkable. Second, Tapestry depends heavily on sessions, and I have a feeling that it may not scale to large volumes, although Tapestry has been used to build the ServerSide.com application (although the author was Howard Lewis Ship, the author of Tapestry himself), which handles quite high loads. So for these, its really a tradeoff. If you dont care about bookmarking URLs or really heavy loads, then Tapestry's benefits may worth looking at.

Recipe: Typical Tapestry directory structure: The Tapestry directory structure is very formalized, and as far as I know, there is no way to change it to suit your standards police. For an application called myapp, the directory structure is as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
    myapp
      +-- context (contains all the .html files for pages)
      |     +-- WEB-INF (contains .page (page specification),
      |     |    |                .jwc (component specification),
      |     |    |                .html files for components,
      |     |    |                myapp.application,
      |     |    |            and web.xml files).
      |     |    +-- lib (contains all the .jar files needed)
      |     |    +-- classes (contains .properties, .xml and other property files,
      |     |                 generated .class files from src directory).
      |     +-- css (contains stylesheet, can have different name)
      |     +-- images (contains image files, can have different name)
      +-- src (the root of the Java source tree)

Recipe: The Visit and Global objects: The Visit and Global objects are developer built objects that per application. These objects are visible to the ApplicationEngine (Tapestry's version of the uber-servlet) via the myapp.properties file as shown below. The Visit object provides session-specific functionality, such as login authentication, and the Global object provides application wide functionality.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE application PUBLIC                                                                                  "-//Apache Software Foundation//Tapestry Specification 3.0//EN"                                                                                  "http://jakarta.apache.org/tapestry/dtd/Tapestry_3_0.dtd"> <application name="pluto" engine-class="org.apache.tapestry.engine.BaseEngine">
    <description>My Application</description>
    <property name="org.apache.tapestry.visit-class">
        com.mycompany.myapp.Visit
    </property>
    <property name="org.apache.tapestry.global-class">
        com.mycompany.myapp.Global     
    </property>     
    <!-- you are almost certain to need the contrib component library -->
    <library id="contrib" specification-path="/org/apache/tapestry/contrib/Contrib.library" />
    <!-- if you need file uploads -->
    <extension name="org.apache.tapestry.multipart-decoder"
        class="org.apache.tapestry.multipart.DefaultMultipartDecoder">                                                                                  <configure property-name="maxSize" type="double" value="-1" />
    </extension>
</application>

Recipe: The List Page:Let us consider a simple bean with two properties, an id and name. Here is the code for the bean:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class SimpleBean {
    private String id;
    private String name;

    /** Default constructor */
    public SimpleBean() {
        id = 0;
        name = "";
    }

    // public getters and setter omitted for brevity
}

To build a List page for this entity, you would need to have something to get the list of all SimpleBeans from the database based on some criteria. This is usually available from the Visit object or the Global object as a method. Here are the Java bean, the page specification and the HTML page for the SimpleBean list component.

 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
// Filename: SimpleBeanList.java
public class SimpleBeanList extends BasePage implements PageRenderListener {

    private List<SimpleBean> simpleBeanList;

    public List<SimpleBean> getSimpleBeanList() {
    }

    public void setSimpleBeanList(List<SimpleBean> simpleBeanList) {
        this.simpleBeanList = simpleBeanList;
    }

    /**
     * This is called before the page renders on the browser. We call out
     * to the DataManager() to populate the List before we start.
     */
    public void pageBeginRender(PageEvent event) {
        Visit visit = (Visit) getVisit();
        setSimpleBeanList(visit.getDataManager().getSimpleBeanList());
    }

    /**
     * This method is called when the Add link is clicked from the page.
     */
    public void add(IRequestCycle cycle) {
        SimpleBean simpleBean = new SimpleBean();
        SimpleBeanEdit simpleBeanEdit = (SimpleBeanEdit) cycle.getPage("SimpleBeanEdit");
        simpleBeanEdit.setSimpleBean(simpleBean);
        cycle.activate(simpleBeanEdit);
    }

    /**
     * This method is called when the id for a displayed SimpleBean is 
     * clicked on the page.
     */
    public void select(IRequestCycle cycle) {
        // get the id parameter in parameters[0]
        Object[] parameters = cycle.getServiceParameters();
        Visit visit = (Visit) getVisit();
        SimpleBean simpleBean = visit.getDataManager().getSimpleBean((Long) parameters[0]);
        SimpleBeanEdit simpleBeanEdit = (SimpleBeanEdit) cycle.getPage("SimpleBeanEdit");
        simpleBeanEdit.setSimpleBean(simpleBean);
        cycle.activate(simpleBeanEdit);
    }
}
1
2
3
4
5
6
7
8
9
<!-- Filename: SimpleBeanList.page -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE page-specification
    PUBLIC "-//Apache Software Foundation//Tapestry Specification 3.0//EN"     "http://jakarta.apache.org/tapestry/dtd/Tapestry_3_0.dtd">
    <page-specification class="com.mycompany.myapp.SimpleBeanList">
    <description>MyApp</description>
    <property-specification name="simpleBeanList" type="java.util.List" persistent="yes" />       
    <property-specification name="simpleBean" type="com.mycompany.myapp.SimpleBean" /> 
</page-specification> 
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- Filename: SimpleBeanList.html -->
<table cellspacing="3" cellpadding="3" border="1">
  <tr>
    <th>Id</th>
    <th>Name</th>
  </tr>
  <span jwcid="@Foreach" source="ognl:simpleBeanList" value="ognl:simpleBean">
    <tr>
      <td>
          <span jwcid="@DirectLink" listener="ognl:listeners.select" parameters="ognl:simpleBean.id">
          <span jwcid="@Insert" value="ognl:simpleBean.id" />
        </span>
      </td>
      <td>
        <span jwcid="@Insert" value="ognl:simpleBean.name" />
      </td>
    </tr>
  </span>
</table>
<hr />
<span jwcid="@DirectLink" listener="ognl:listeners.add">Add New</span>
&nbsp;|&nbsp;
<span jwcid="@PageLink" page="Home">Home</span><br />

Recipe: The Edit Page: The Edit page shows a form with all the properties of the SimpleBean (except the id) exposed to an update. There is a Save and Delete button, which will result in the bean being saved (if new) or updated (if existing), and deleted respectively.

 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
// Filename: SimpleBeanEdit.java
public class SimpleBeanList extends BasePage {
    private SimpleBean simpleBean;

    public SimpleBean getSimpleBean() { return simpleBean; }
    public void setSimpleBean(SimpleBean simpleBean) { this.simpleBean = simpleBean; }
    
    /** 
     * Called when the Save button is clicked
     */
    public void save(IRequestCycle cycle) {
        Visit visit = (Visit) getVisit();
        visit.getDataManager().save(simpleBean);
        cycle.activate("SimpleBeanList");
    }

    /**
     * Called when the delete button is clicked.
     */
    public void delete(IRequestCycle cycle) {
        Visit visit = (Visit) getVisit();
        visit.getDataManager().delete(simpleBean);
        cycle.activate("SimpleBeanList");
    }

    /**
     * Called when the cancel button is clicked (if exists).
     */
    public void cancel(IRequestCycle cycle) {
        detach();
        cycle.activate("SimpleBeanList");
    }
}
1
2
3
4
5
6
7
8
<!-- Filename: SimpleBeanEdit.page -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE page-specification
    PUBLIC "-//Apache Software Foundation//Tapestry Specification 3.0//EN"
    "http://jakarta.apache.org/tapestry/dtd/Tapestry_3_0.dtd">
<page-specification class="com.mycompany.myapp.SimpleBeanEdit">
    <description>MyApp</description>
</page-specification>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- Filename: SimpleBeanEdit.html -->
<form jwcid="@Form">
  <table cellspacing="3" cellpadding="3" border="0">
    <tr>
      <td><b>Id:</b></td>
      <td>
        <span jwcid="@Insert" value="ognl:simpleBean.id" />
      </td>
    </tr>
    <tr>
      <td><b>Name:</b></td>
      <td>
        <span jwcid="@TextField" value="ognl:simpleBean.name" />
      </td>
    </tr>
    <tr>
      <td><input type="submit" value="Save" jwcid="@Submit" listener="ognl:listeners.save" /></td>
      <td><input type="reset" value="Delete" jwcid="@Submit" listener="ognl:listeners.delete" /></td>
    </tr>
  </table>
  <hr />
  <span jwcid="@PageLink" page="SimpleBeanList">Back to List</span>
</form>

Recipe: Login interception: Most dynamic applications are password protected somehow, since you dont want just about anybody with physical access to be able to edit or delete your data. This is also quite simple, just implement the following subclass of BasePage and extend all the application pages from this one instead of BasePage. In other words, you interject additional functionality via this class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class ProtectedBasePage extends BasePage implements PageValidateListener {
    public void pageValidate(PageEvent event) {
        Visit visit = (Visit) getVisit();
        if (loggedOn) { // add application logic to figure this out
            return;
        } else {
            Login login = (Login) getRequestCycle().getPage("Login");
            login.setCallback(new PageCallback(this));
            throw new PageRedirectException(login);
        }
    }
}

Recipe: Creating components: Looking back on my original design for the tool, I feel it would have been a better decision if I had decided to build the List and Edit pages for each of the entities as components, and then hook them up into very thin wrapper pages once the workflow is known. Creating components is not very different from creating pages. Instead of a .page specification, you create a .jwc specification, which defines the parameters the component would take, if any. Also, you would extend BaseComponent instead of BasePage. Since your component will typically be an aggregation of other Tapestry or Tapestry:contrib components which already know how to render themselves, you should make the component subclass abstract and not implement the renderComponent() method. Here is an example that shows a little "welcome" component, whose text changes based on whether you are logged in or not.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Filename: WelcomeComponent.java
public abstract class WelcomeComponent extends BaseComponent {

    public String getUserName() {
        Visit visit = (Visit) getPage().getEngine().getVisit();
        return visit.getLoggedInUser().getName();
    }

    /**
     * Called when the logout link is clicked.
     */
    public void logout(IRequestCycle cycle) {
        Visit visit = (Visit) getPage().getEngine().getVisit();
        visit.invalidateSession();
        cycle.activate("Login");
    }
}
1
2
3
4
5
6
7
<!-- Filename: WelcomeComponent.jwc -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE component-specification PUBLIC
  "-//Apache Software Foundation//Tapestry Specification 3.0//EN"
  "http://jakarta.apache.org/tapestry/dtd/Tapestry_3_0.dtd">
<component-specification class="com.mycompany.myapp.components.WelcomeComponent"
    allow-body="no" allow-informal-parameters="no"> </component-specification> 
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<!-- Filename: WelcomeComponent.html -->
<span jwcid="$content$">
  <font size="-1">
    <b>
      <span jwcid="@contrib:Choose">
        <span jwcid="@contrib:When" condition="ognl:userName == null">
          Welcome. Please login.
        </span>
        <span jwcid="@contrib:Otherwise">
          Welcome, <span jwcid="@Insert" value="ognl:userName" />.
          <span jwcid="@DirectLink" listener="ognl:listeners.logout">Logout</span>
        </span>
      </span>
    </b>
  </font>
  <br />
</span>

Recipe: Reskinning the application: We can use the Border component for this. We really want to build up our own Border component with the specified template. The template would contain a RenderBody component which would pull up the specified component in that case. The pages would need to change to have the following enclosing tags to have a skin specified by the MyAppBorder component:

1
2
3
4
5
<html jwcid="$content$">
  <body jwcid="@MyAppBorder" subTitle="ognl:pageName">
  ... the original page contents ...
  </body>
</html>

There are still other things I have to figure out, such as how to preserve state when a user clicks a link on a partially filled form. The link could be create an additional sub-entity which is not created yet. I would like the user to go to an add form for this sub-entity, and once entry is completed, the sub-entity should be saved to the database, and the user directed back to the original page, with the filled in data intact. I will write about these issues once I figure out how to do it.

Be the first to comment. Comments are moderated to prevent spam.