Adding Search to AppFuse with Compass
Over 5 years ago, I recognized that AppFuse needed to have a search feature and entered an issue in JIRA. Almost 4 years later, a Compass Tutorial was created and shortly after Shay Banon (Compass Founder), sent in a patch. From the message he sent me:
A quick breakdown of enabling search:
- Added Searchable annotations to the User and Address.
- Defined Compass bean, automatically scanning the model package for mapped searchable classes. It also automatically integrates with Spring transaction manager, and stores the index on the file system ([work dir]/target/test-index).
- Defined CompassTemplate (similar in concept to HibernateTemplate).
- Defined CompassSearchHelper. Really helps to perform search since it does pagination and so on.
- Defined CompassGps, basically it allows for index operation allowing to completely reindex the data from the database. JPA and Hiberante also automatically mirror changes done through their API to the index. iBatis uses AOP.
Fast forward 2 years and I finally found the time/desire to put a UI on the backend Compass implementation that Shay provided. Yes, I realize that Compass is being replaced by ElasticSearch. I may change to use ElasticSearch in the future; now that the search feature exists, I hope to see it evolve and improve.
Since Shay's patch integrated the necessary Spring beans for indexing and searching, the only thing I had to do was to implement the UI. Rather than having an "all objects" results page, I elected to implement it so you could search on an entity's list screen. I started with Spring MVC and added a search() method to the UserController:
@RequestMapping(method = RequestMethod.GET) public ModelAndView handleRequest(@RequestParam(required = false, value = "q") String query) throws Exception { if (query != null && !"".equals(query.trim())) { return new ModelAndView("admin/userList", Constants.USER_LIST, search(query)); } else { return new ModelAndView("admin/userList", Constants.USER_LIST, mgr.getUsers()); } } public List<User> search(String query) { List<User> results = new ArrayList<User>(); CompassDetachedHits hits = compassTemplate.findWithDetach(query); log.debug("No. of results for '" + query + "': " + hits.length()); for (int i = 0; i < hits.length(); i++) { results.add((User) hits.data(i)); } return results; }
At first, I used compassTemplate.find(), but got an error because I wasn't using an OpenSessionInViewFilter. I decided to go with findWithDetach() and added the following search form to the top of the userList.jsp page:
<div id="search"> <form method="get" action="${ctx}/admin/users" id="searchForm"> <input type="text" size="20" name="q" id="query" value="${param.q}" placeholder="Enter search terms"/> <input type="submit" value="<fmt:message key="button.search"/>"/> </form> </div>
NOTE: I tried using HTML5's <input type="search">, but found Canoo WebTest doesn't support it.
Next, I wrote a unit test to verify everything worked as expected. I found I had to call compassGps.index() as part of my test to make sure my index was created and up-to-date.
public class UserControllerTest extends BaseControllerTestCase { @Autowired private CompassGps compassGps; @Autowired private UserController controller; public void testSearch() throws Exception { compassGps.index(); ModelAndView mav = controller.handleRequest("admin"); Map m = mav.getModel(); List results = (List) m.get(Constants.USER_LIST); assertNotNull(results); assertTrue(results.size() >= 1); assertEquals("admin/userList", mav.getViewName()); } }
After getting this working, I started integrating similar code into AppFuse's other web framework modules (Struts, JSF and Tapestry). When I was finished, they all looked pretty similar from a UI perspective.
Struts:
<div id="search"> <form method="get" action="${ctx}/admin/users" id="searchForm"> <input type="text" size="20" name="q" id="query" value="${param.q}" placeholder="Enter search terms..."/> <input type="submit" value="<fmt:message key="button.search"/>"/> </form> </div>
JSF:
<div id="search"> <h:form id="searchForm"> <h:inputText id="q" name="q" size="20" value="#{userList.query}"/> <h:commandButton value="#{text['button.search']}" action="#{userList.search}"/> </h:form> </div>
Tapestry:
<div id="search"> <t:form method="get" t:id="searchForm"> <t:textfield size="20" name="q" t:id="q"/> <input t:type="submit" value="${message:button.search}"/> </t:form> </div>
One frustrating thing I found was that Tapestry doesn't support method="get" and AFAICT, neither does JSF 2. With JSF, I had to make my UserList bean session-scoped or the query parameter would be null when it listed the results. Tapestry took me the longest to implement, mainly because I had issues figuring out how it's easy-to-understand-once-you-know onSubmit() handlers worked and I had the proper @Property and @Persist annotations on my "q" property. This tutorial was the greatest help for me. Of course, now that it's all finished, the code looks pretty intuitive.
Feeling proud of myself for getting this working, I started integrating this feature into AppFuse's code generation and found I had to add quite a bit of code to the generated list pages/controllers.
So I went on a bike ride...
While riding, I thought of a much better solution and added the following search method to AppFuse's GenericManagerImpl.java. In the code I added to pages/controllers previously, I'd already refactored to use CompassSearchHelper and I continued to do so in the service layer implementation.
@Autowired private CompassSearchHelper compass; public List<T> search(String q, Class clazz) { if (q == null || "".equals(q.trim())) { return getAll(); } List<T> results = new ArrayList<T>(); CompassSearchCommand command = new CompassSearchCommand(q); CompassSearchResults compassResults = compass.search(command); CompassHit[] hits = compassResults.getHits(); if (log.isDebugEnabled() && clazz != null) { log.debug("Filtering by type: " + clazz.getName()); } for (CompassHit hit : hits) { if (clazz != null) { if (hit.data().getClass().equals(clazz)) { results.add((T) hit.data()); } } else { results.add((T) hit.data()); } } if (log.isDebugEnabled()) { log.debug("Number of results for '" + q + "': " + results.size()); } return results; }
This greatly simplified my page/controller logic because now all I had to do was call manager.search(query, User.class) instead of doing the Compass login in the controller. Of course, it'd be great if I didn't have to pass in the Class to filter by object, but that's the nature of generics and type erasure.
Other things I learned along the way:
- To index on startup, I added compassGps.index() to the StartupListener..
- In unit tests that leveraged transactions around methods, I had to call compassGps.index() before any transactions started.
- To scan multiple packages for searchable classes, I had to add a LocalCompassBeanPostProcessor.
But more than anything, I was reminded it always helps to take a bike ride when you don't like the design of your code.
This feature and many more will be in AppFuse 2.1, which I hope to finish by the end of the month. In the meantime, please feel free to try out the latest snapshot.
One thing about Tapestry is that when you don't want to use parts of it, you can get that out of the way. In your situation, you want an HTML form, but don't really need the more advanced Tapestry mechanisms.
In your template:
And in your Java code:
I call this dropping down to servlet mode.
A Tapestry Form component is built for the worst-case, most complex scenario: one where there are loops and conditionals inside the form, where all kinds of state needs to be encoded into the form (as the t:formdata hidden field, thus the restriction to POST not GET), and where Tapestry needs to set up client-side and server-side validation.
Hope that helps! I'm trying to thing of a suitable title to add this to the FAQ.
Posted by Howard Lewis Ship on March 16, 2011 at 08:51 PM MDT #