This is the first post in a four part series about a wine rating and recommendation Web application, named VinWiki, built using open source technology. The purpose of this series is to document key design and implementation decisions, which may be of interest to anyone wanting to build an intelligent Web application using Java technologies. The end result will not be a 100% functioning Web application, but will have enough functionality to prove the concepts. Specifically, here is a basic roadmap of the concepts covered in each post:
- Introduction (May 24, 2010): Covers project setup, primary domain objects, and basic UI constructs such as server-side pagination, dynamic menus, and bookmarkable URLs with JSF / RichFaces / Facelets.
- Full-text Search (June 14, 2010): Implements full-text search using Hibernate Search with Lucene 2.9.2.
- Integrating with the Web (July 8, 2010): Authentication and registration using Facebook Connect and sharing/liking bookmarkable URLs on Facebook.
- Recommendations (Sept 23, 2010): Provide wine recommendations using Apache Mahout.
One of my other interests is figuring out how to harvest intelligence from the interactions and contributions of users on a Web site and then apply that intelligence to improve the personal experience with the application as well as the application as a whole. The science behind this is called Collective Intelligence. To keep things simple, I consider a Web application to be "intelligent" if it has the following key ingredients:
- automatically improves its capabilities as more users contribute to it,
- scalable (machine learning algorithms do better with large amounts of data),
- mashable (simple Web services that expose data and services to other applications), and
- doesn't pretend to be more than it is!
- JBoss AS 4.2.3
- Seam 2.2.0
- Hibernate (Core, EntityManager, Annotations, Validator & Search)
- JSF 1.2 / RichFaces 3.3.2 SR1 / Facelets
- Lucene 2.9.2
- Mahout 0.3 / Hadoop 0.20.2
- Algorithms of the Intelligent Web
- Collective Intelligence in Action
- Mahout in Action
- Programming Collective Intelligence (code samples in Python, but still a great read and Python is a fun scripting language to know anyway ;-)
- Rate wines you've already tried
- Find and read ratings for wines you may like to try
- Receive recommendations of new wines you should to try based on your previous ratings
- Register New User Account view solution
- Basic Navigation: Browse Wine by Region, Date, Tag, or Popularity view solution
- View detailed information for a specific wine view solution
- Add Rating for an Existing Wine view solution
- Add New Wine view solution
- Edit Wine view solution
- Search for Wine view solution
- Share wines and ratings with Facebook friends view solution
- View Recommendations view solution
JPA Entity | Description |
Region | Encapsulates information about a geographical area that produces wine, such as Bordeaux. Implements the org.vinwiki.model.Composite interface to represent a hierarchical tree of regions and sub-regions. |
Varietal | Encapsulates information about grape variety used to make wine, such as Chardonnay. |
VarietalPct | Represents the percentage of a varietal in a specific wine. |
Winery | Encapsulates information about a wine producer, such as Robert Mondavi. A Winery may have a latitude and longitude specified, which would allow us to show the winery on map of wineries in a region. |
Wine | Encapsulates information about a specific wine, uniquely identified by name, winery, and vintage (typically the year the grapes were harvested). |
User | Encapsulates information about a registered user in the application. |
Preferences | Encapsulates user-supplied settings used to personalize the application. |
Tag | Holds information for a keyword created by a user for rating a wine. |
UserTag | Associates a tag with a user's rating. |
Rating | Represents a specific user's rating (score and comments) of a specific wine. |
- 104 wine regions / sub-regions
- 487 grape varietals
- 772 wineries
- 2,025 wines
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_region", nullable = true) public Region getParentRegion() { return this.parentRegion; }
Of course, we also need to enable the second-level cache in persistence.xml. I found it easiest to use Ehcache initially but you should research the other providers, e.g. JBoss Cache, to determine the most appropriate solution for your application. I'll revisit this decision when I tackle clustering this application in the cloud. For now, here are the salient properties specified in persistence.xml:
<property name="hibernate.cache.provider_class" value="net.sf.ehcache.hibernate.EhCacheProvider"/> <property name="hibernate.cache.use_query_cache" value="true"/> <property name="hibernate.cache.use_second_level_cache" value="true"/> <property name="hibernate.cache.region_prefix" value=""/> <property name="hibernate.generate_statistics" value="true"/> <property name="hibernate.session_factory_name" value="SessionFactories/vinwikiSF"/>
hibernateMBeanName = new ObjectName("Hibernate:type=statistics,application=vinwiki"); StatisticsService mBean = new StatisticsService(); mBean.setSessionFactoryJNDIName("SessionFactories/vinwikiSF"); ManagementFactory.getPlatformMBeanServer().registerMBean(mBean, hibernateMBeanName);
<cache:eh-cache-provider/>
The nav component can be in one of two states during its lifecycle: A) guest mode when #{identity.loggedIn} is false or B) authenticated user mode when #{identity.loggedIn} is true. This is because Seam does not create a new session component after a user is authenticated.
When a new session is created, we need to generate a default view of the wine data; a list of the Most Recent Wines added to the application seems like a good choice. I use Seam's @Factory annotation to provide the default view (the Seam documentation calls this pull-style MVC):
@Factory("mainMenu") public void initMainMenu() { if (mainMenu == null) { mainMenu = buildMainMenu(); } // Ensure the current PagedDataFetcher is in-sync with the current request syncDataFetcherWithRequest(); }
@Observer(Identity.EVENT_POST_AUTHENTICATE) public void postAuthenticate(Identity identity) { cleanupDataProvider(); // after login, display the user menu instead of the guest menu mainMenu = getMainMenu(); syncDataFetcherWithRequest(); }
<a4j:form prependId="false"> <rich:panelMenu id="mainMenu" binding="#{mainMenu}"/> </a4j:form>
Seam makes this possible using URL re-writing and page parameters. My solution is largely based on the Blog example provided in the Seam documentation. When the menu was constructed, I set the action for the California region menu item to be /region.xhtml?r=California. In pages.xml, I link the "r" request parameter to the region property of an event-scoped component bookmarkable, see org.vinwiki.action.BookmarkableRequestInfo. In addition, I use a rewrite rule to make the URL more intuitive (/region/California instead of /region.seam?r=California):
<page view-id="/region.xhtml"> <rewrite pattern="/region/{r}"/> <param name="r" value="#{bookmarkable.region}"/> </page>
One drawback to allowing navigation requests to be bookmarked in this manner is that I had to duplicate the contents of view/home.xhtml into filter.xhtml because it seems that adding multiple rewrite patterns on a single page leads to some weird URLs. I'm sure this could be overcome with some work, but since I'm using Facelets, the amount of duplication is minimal (and of course the UI is not final so the filter page may end up being different anyway). In the next posting, I'll show you how to make search results bookmarkable as well.
And, the corresponding JSF syntax (from view/WEB-INF/facelets/itemTable.xhtml:
- My solution is based largely on the sample code provided with RichFaces (see org.richfaces.demo.extendeddatamodel.AuctionDataModel). Essentially, my <rich:dataTable> interacts with an event-scoped component navDataModel that extends org.ajax4jsf.model.SerializableDataModel. Under the covers, the DataModel (see org.vinwiki.action.PagedDataModelBase) component does everything needed to support AJAX-driven pagination except the actual loading of data. To load data, the DataModel delegates to a org.vinwiki.action.PagedDataProvider, whose implementation is a session-scoped component that loads and caches items from the database.
- Under the covers, the number of rows displayed to the user per page comes from the user's preferences.
- The RichFaces <rich:datascroller> provides the paging mechanism for our paged data table.
As you might expect, the concrete implementation of org.vinwiki.action.PagedDataProvider is our handy nav component which extends org.vinwiki.action.PagedDataProviderBase. PagedDataProviderBase does most of the work except the actual fetching of a page of data from the database, which is provided by a org.vinwiki.action.PagedDataFetcher. There is a concrete implementation of org.vinwiki.action.PagedDataFetcher for each type of navigation. For example, the org.vinwiki.action.FetchRegion class provides Wine objects for a specific region to the data provider. Notice that my current PagedDataFetcher implementations are *not* Seam components and expect the EntityManager and User ID to be passed to them. I chose this approach to make it really easy to build new types of navigation queries; all you have to do is provide a query to return a page of data and a query to return the total number of items available to the current user.
The PagedDataProviderBase maps each starting index to a List of Item objects. A Map is a good solution if you are using the <rich:datascroller> because the user can jump from page 1 to 10 without going through pages 2-9 first. If you're using a sequential paging mechanism then a simple List should suffice. Of course the Map could grow very big if the user visits many pages in the scroller. I'll leave it as an exercise for the reader to solve using SoftReferences.
You didn't know the Rat Pack liked wine and wrote Latin did you ;-)
There are a number of possible activities the wine details view can offer the user, including:
- Add / edit rating
- View a list of similar wines (MoreLikeThis)
- Contextual display Ads
- Navigation controls to view the next wine in the results
- Edit information about the wine itself
<s:link view="/wine.xhtml" value="#{item.fullName}" style="font-size:14px;"> <f:param name="wid" value="#{item.id}"/> </s:link>
<page view-id="/wine.xhtml"> <rewrite pattern="/wine/{wid}"/> <param name="wid" value="#{viewWine.wineId}"/> </page>
@Begin(flushMode = FlushModeType.MANUAL, join = true)
public void setWineId(Long wineId) {
...
}
<a4j:commandLink value="#{messages.backToHome}" action="#{viewWine.endViewWine()}" oncomplete="backToHome()" style="font-size:14px;"/>
function backToHome() { var currentHost = document.location.protocol+"//"+document.location.host; if (document.referrer && document.referrer.startsWith(currentHost)) { history.back(); } else { window.location.replace("/vinwiki/"); } }
- lookup a wine on-the-fly from the home page, or
- view details for a specific wine and then add the rating from the wine details view.
From view/WEB-INF/facelets/headerControls.xhtml
<a4j:commandLink value="Add Rating" action="#{ratingHome.beginRatingWine()}" reRender="addRatingPanel" oncomplete="#{rich:component('addRatingPanel')}.show()" styleClass="hdrLink"/>
So now let's see how on-the-fly lookup works ... from view/WEB-INF/facelets/addRating.xhtml
- Decorate the form field as a required field using Seam's <s:decorate> tag and a Facelets template. If a validation error occurs, Seam will decorate the field with error information.
- When the "onblur" event fires on the input field, send an AJAX request to the server to determine if the user selected a known wine.
- Attach a RichFaces suggestion box <rich:suggestionbox> to the input field to auto-complete the wine name as the user types. On the server, the RichFaces suggestion box invokes the #{ratingHome.autoCompleteWine} action. I'll discuss the RichFaces request queue in more detail below, but for now, you should realize that it is dangerous to have an auto-complete component without some sort of request flood control in place.
There's a fair amount of complex machinery going on in this one tag! Let's analyze it step-by-step:
- A RichFaces <a4j:commandButton> submits the form using AJAX to invoke the #{ratingHome.saveRating} action within the same conversation started when the user clicked on Add Rating.
- The reRender attribute tells RichFaces to re-render the addRatingPanel and itemTable components in the component tree and updated in the browser DOM after the AJAX response is completed. This ensures that the panel will display any errors that occur on the server.
- If there are no errors, close the modal panel. Notice that I'm calling the hasJsfErrMsg JavaScript function, which returns true if there are any error messages queued in the FacesContext. The hasJsfErrMsg function is defined in my main Facelets layout template (view/layout/template.xhtml) and relies on an <a4j:outputPanel> to update the value of a hidden field with ID 'jsfMsgMaxSev' on every AJAX request. The value for this field is pulled from the FacesContext.getMaximumSeverity() method.
<a4j:outputPanel ajaxRendered="true"> <a4j:form prependId="false"> <h:inputHidden id="jsfMsgMaxSev" value="#{jsf.maxMsgSev}"/> </a4j:form> </a4j:outputPanel>
Recall that we are already in a conversation when viewing the wine. Thus, the Add Rating operation will occur in a nested conversation using the ratingHome component.
@Begin(nested=true, flushMode=FlushModeType.MANUAL) public void beginRatingWine(Wine currentWine) { // attach the objects needed to create a rating to the // extended PersistenceContext for this conversation user = getEntityManager().merge(currentUser); wine = getEntityManager().find(Wine.class, currentWine.getId()); updateStateForWine(); info("User {0} has starting rating wine {1} in NESTED conversation {2}.", user.getUserName(), wine.getFullName(), Conversation.instance().getId()); }
@Factory("wine")
public Wine initWine() {
return getInstance();
}
So that about covers the specific use cases for this posting. Now, let's look at some specific UI implemenation details that will help you build better apps with Seam and RichFaces.
Here are some tips to keep in mind when opening a ModalPanel within a conversation:
- The reRender attribute should include the ID of the panel you are opening. This ensures the UI components in the panel reflect the state of the conversation.
- When the action completes, use JavaScript to show the panel via oncomplete="#{rich:component('prefPanel')}.show()"
- In the action handler on the server, be sure to load any entities needed by the ModalPanel into the extended PersistenceContext for the conversation:
// Out-ject the User object that we're updating, vs. the one that came in ... @Out protected User updatingUser = null; // Just a handy reference to the preferences object we're updating ... @Out protected Preferences updatingPrefs = null; @Begin(flushMode = FlushModeType.MANUAL, join = true) public void beginEditPreferences() { // load the User entity to edit preferences for into the extended PC for this conversation updatingUser = getEntityManager().find(User.class, currentUser.getId()); updatingPrefs = updatingUser.getPreferences(); initDob(); }
- The action handler should end the conversation only if it completes successfully. So be careful with using @End with AJAX action handlers of type void. I prefer to use Conversation.instance().end() when dealing with AJAX actions as it makes it more explicit when the conversation ends.
- Be sure to re-render the form after submit so validation errors are displayed correctly.
- As mentioned previously, use a simple JavaScript function to determine if there are FacesMessages with severity ERROR or greater queued in the FacesContext; close the panel if there are no errors.
<context-param> <param-name>org.richfaces.queue.global.enabled</param-name> <param-value>true</param-value> </context-param>
mysql> create database vinwiki; mysql> grant all privileges on vinwiki.* to 'vinwiki'@'localhost' identified by 'vinwiki'; mysql> use vinwiki; mysql> source vinwiki.sql;
mysql> create database vinwiki_test; mysql> grant all privileges on vinwiki_test.* to 'vinwiki'@'localhost' identified by 'vinwiki';
Wow! Thank you for sharing this incredibly valuable information. Great examples.
ReplyDeleteLooking forward to the rest of the series. Excellent work!
ReplyDeleteFYI: I downloaded the vinwiki.zip, ran the mysql stuff and tried to deploy on JBoss. I got a NullpointerException and it failed on deployment. I will digg into it further when I get a chance. But I think it would be better to create a GIT or SVN repository (or even google code) project so that we can submit bugfixes/enhancements etc so that it would benefit entire community.
ReplyDeletePlease post the NPE you received when trying to deploy.
ReplyDeleteI definitely plan to add this to a site where people can check out the project and monitor changes (probably Google code). I will post this once I've completed the 3rd posting (~1 week from now). Do you have any other suggestions on sites where I could post this?
I've only tested with JBoss 4.2.3. It seems that something went terribly wrong in the deployment because the following line is producing the NPE:
ReplyDeleteIndexReader reader = readerProvider.openReader(searchFactory.getDirectoryProviders(Wine.class)[0]);
For now, try to use JBoss 4.2.3 ... I'm planning to port the app to JBoss 6.x (with the latest Seam, JSF, RF, and Hibernate) in a few weeks.
I can confirm that JBoss 4.2.3 worked fine
ReplyDeletewell done!! examples like this are very usefull, thank you very much
ReplyDeleteHey, this tutorial is the best I have seen so far about integrating a lot of new technologies and APIs... Where can I download the vinwiki.zip??? Could you provide the github or source-code repository and we all could work on it and learn?
ReplyDeletethanks
Marcello