Showing posts with label jackrabbit. Show all posts
Showing posts with label jackrabbit. Show all posts

Saturday, September 15, 2007

Jackrabbit Event Handling

In his article, Advanced Java Content Repository API, Sunil Patil says that two of the most popular advanced features of a JCR compliant content repository (one of which is Jackrabbit) are Versioning and Observation. Since I was already looking at Jackrabbit, I decided to check out these APIs a bit to see if I could find some use for them.

I can see the Versioning API being useful for organizations who actually generate their own content, and would need to track any changes made to documents. This is particularly true in industries with strong compliance requirements, such as Finance, Healthcare, etc. Although we do generate some amount of internal content, typically they don't need to be maintained and revised, they just expire after a period of time, so we don't really have a need for version history. So I read about it in Sunil Patil's article, but didn't make any effort to actually try it in my own use case.

The Observation API looked interesting. It allows you to register Listeners on various predefined events such as a Node being removed or added, and Properties being added, removed or changed. I got interested in it because I thought that perhaps we could use these events to trigger legacy code that did not depend on the repository. As before, I decided to use the JCR module from the Spring Modules Project to make integration with Spring easier.

As an experiment, I decided to use the Observation API to trap a content update event, which would then trigger off a Lucene index update. The content update consists of dropping the content node for the content, creating a new one, and re-inserting the properties back in. The code for ContentUpdater.java 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
import java.io.File;
import java.io.IOException;

import javax.jcr.Node;
import javax.jcr.NodeIterator;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.query.Query;
import javax.jcr.query.QueryManager;
import javax.jcr.query.QueryResult;

import org.apache.log4j.Logger;
import org.springframework.beans.factory.annotation.Required;
import org.springmodules.jcr.JcrCallback;
import org.springmodules.jcr.JcrTemplate;

public class ContentUpdater {

  private static final Logger LOGGER = Logger.getLogger(ContentUpdater.class);
  
  private String contentSource;
  private JcrTemplate jcrTemplate;
  private IParser parser;

  @Required
  public void setContentSource(String contentSource) {
    this.contentSource = contentSource;
  }

  @Required
  public void setJcrTemplate(JcrTemplate jcrTemplate) {
    this.jcrTemplate = jcrTemplate;
  }

  @Required
  public void setParser(IParser parser) {
    this.parser = parser;
  }

  public void update(final File file) {
    jcrTemplate.execute(new JcrCallback() {
      public Object doInJcr(Session session) throws IOException, RepositoryException {
        try {
          DataHolder dataHolder = parser.parse(file);
          String contentId = dataHolder.getProperty("contentId");
          Node contentSourceNode = getContentNode(session, contentSource, null);
          Node contentNode = getContentNode(session, contentSource, contentId);
          if (contentNode != null) {
            contentNode.remove();
          }
          contentNode = contentSourceNode.addNode("content");
          for (String propertyKey : dataHolder.getPropertyKeys()) {
            String value = dataHolder.getProperty(propertyKey);
            contentNode.setProperty(propertyKey, value);
          }
          session.save();
        } catch (Exception e) {
          throw new IOException("Parse error", e);
        }
        return null;
      }
    }); 
  }
  
  public Node getContentNode(final Session session, final String contentSource, 
      final String contentId) throws Exception {
    if (contentId == null) {
      return session.getRootNode().getNode(contentSource);
    }
    QueryManager queryManager = session.getWorkspace().getQueryManager();
    Query query = queryManager.createQuery("//" + contentSource + 
      "/content[@contentId='" + contentId + "']", Query.XPATH);
    QueryResult result = query.execute();
    NodeIterator ni = result.getNodes();
    if (ni.hasNext()) {
      Node contentNode = ni.nextNode();
      return contentNode;
    } else {
      return null;
    }
  }
}

When the session.save() happens, a bunch of events are thrown out by Jackrabbit to be picked up by any interested EventListener objects. We define one such EventListener which listens to one specific event generated by the ContentUpdater.java class, and handles it. The code for the ContentUpdatedEventListener.java 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
import java.io.IOException;
import java.util.List;

import javax.jcr.Node;
import javax.jcr.NodeIterator;
import javax.jcr.Property;
import javax.jcr.PropertyIterator;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.observation.Event;
import javax.jcr.observation.EventIterator;
import javax.jcr.observation.EventListener;
import javax.jcr.query.Query;
import javax.jcr.query.QueryManager;
import javax.jcr.query.QueryResult;

import org.apache.log4j.Logger;
import org.springframework.beans.factory.annotation.Required;
import org.springmodules.jcr.JcrCallback;
import org.springmodules.jcr.JcrTemplate;

/**
 * Event listener that gets called whenever a source File node changes.
 */
public class ContentUpdatedEventListener implements EventListener {

  private static final Logger LOGGER = Logger.getLogger(ContentUpdatedEventListener.class);
  
  private JcrTemplate jcrTemplate;
  private List<IEventHandler> eventHandlers;

  @Required
  public void setJcrTemplate(JcrTemplate jcrTemplate) {
    this.jcrTemplate = jcrTemplate;
  }

  @Required
  public void setEventHandlers(List<IEventHandler> eventHandlers) {
    this.eventHandlers = eventHandlers;
  }

  public void onEvent(final EventIterator eventIterator) {
    jcrTemplate.execute(new JcrCallback() {
      public Object doInJcr(Session session) throws IOException, RepositoryException {
        while (eventIterator.hasNext()) {
          Event event = eventIterator.nextEvent();
          if (event.getType() == Event.NODE_ADDED) {
            QueryManager queryManager = session.getWorkspace().getQueryManager();
            Query query = queryManager.createQuery("/" + event.getPath(), Query.XPATH);
            QueryResult result = query.execute();
            NodeIterator nodes = result.getNodes();
            if (nodes.hasNext()) {
              Node contentNode = nodes.nextNode();
              PropertyIterator properties = contentNode.getProperties();
              DataHolder dataHolder = new DataHolder();
              while (properties.hasNext()) {
                Property property = properties.nextProperty();
                dataHolder.setProperty(property.getName(), property.getValue().getString());
              }
              LOGGER.debug("Did I get here?");
              for (IEventHandler eventHandler : eventHandlers) {
                try {
                  eventHandler.handle(dataHolder);
                } catch (Exception e) {
                  LOGGER.info("Failed to handle event:" + event.getPath() +  
                      " of type:" + event.getType() + 
                      " by " + eventHandler.getClass().getName(), e);
                }
              }
            }
          }
        }
        return null;
      }
    });
  }
}

To make the design more modular and cleaner, the EventListener can be injected with a List of IEventHandler objects, whose handle() method gets called in a for loop, so multiple actions can happen when an event is trapped by the Listener. The IEventHandler.java code is shown below:

1
2
3
public interface IEventHandler {
  public void handle(DataHolder holder) throws Exception;
}

A dummy implementation that does nothing but prints that it is updating a Lucene index is shown below, for illustration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import org.apache.log4j.Logger;
import org.springframework.beans.factory.annotation.Required;

/**
 * A dummy class to demonstrate event handling.
 */
public class LuceneIndexUpdateEventHandler implements IEventHandler {

  private static final Logger LOGGER = Logger.getLogger(LuceneIndexUpdateEventHandler.class);
  private String indexPath;

  @Required
  public void setIndexPath(String indexPath) {
    this.indexPath = indexPath;
  }

  public void handle(DataHolder holder) throws Exception {
    LOGGER.info("Updated Lucene index at:" + indexPath);
  }
}

Finally, we tie it all together with Spring configuration. Here is the applicationContext.xml file. Refer to my last post for the complete applicationContext.xml file, I just show the diffs here to highlight the changes and explain them:

 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
<beans ...>
  ...
  <bean id="jcrSessionFactory" class="org.springmodules.jcr.JcrSessionFactory">
    ...
    <property name="eventListeners">
      <list>
        <ref bean="contentUpdatedEventListenerDefinition"/>
      </list>
    </property>
  </bean>

  <!-- The updater -->
  <bean id="myRandomContentUpdater" class="com.mycompany.myapp.ContentUpdater">
    <property name="contentSource" value="myRandomContentSource"/>
    <property name="jcrTemplate" ref="jcrTemplate"/>
    <property name="parser" ref="someRandomDocumentParser"/>
  </bean>

  <!-- Linked to the EventListener via this bean -->
  <bean id="contentUpdatedEventListenerDefinition" class="org.springmodules.jcr.EventListenerDefinition">
    <property name="absPath" value="/"/>
    <property name="eventTypes" value="1"/><!-- Event.NODE_ADDED -->
    <property name="listener" ref="contentUpdatedEventListener"/>
  </bean>
  
  <!-- The EventListener -->
  <bean id="contentUpdatedEventListener" class="com.mycompany.myapp.ContentUpdatedEventListener">
    <property name="jcrTemplate" ref="jcrTemplate"/>
    <property name="eventHandlers">
      <list>
        <ref bean="luceneIndexUpdateEventHandler"/>
      </list>
    </property>
  </bean>

  <!-- The EventHandler -->
  <bean id="luceneIndexUpdateEventHandler" class="com.mycompany.myapp.LuceneIndexUpdateEventHandler">
    <property name="indexPath" value="/tmp/lucene"/>
  </bean>
  
</beans>

The first change is to register one or more EventListenerDefinition beans to the JcrSessionFactory. This is shown in the first block above. The second block is simply the configuration for the ContentUpdater. The third block is the EventListenerDefinition which says that the EventListener it defines listens to all events starting from root and fiters on event type 1 (Event.NODE_ADDED), and the actual reference to the EventListener bean. The fourth block is the definition and configuration for the ContentUpdatedEventListener EventListener implementation, which also takes in a List of IEventHandler objects. In our case the list contains only the reference to the dummy LuceneIndexUpdaterEventHandler class. The final block is the bean definition for the IEventHandler.

To run this code, I have a very simple JUnit harness that calls the ContentUpdater.update() method with a File reference. The node corresponding to the File is updated and an event sent, and we get to see a log message like the following in our logs. Notice that this log is usually emitted after JUnit's messages, signifying that this is called asynchronously.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.246 sec

Results :

Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

13 Sep 2007 09:33:29,509 INFO  com.healthline.jrtest.LuceneIndexUpdateEventHandler 
com.healthline.jrtest.LuceneIndexUpdateEventHandler.handle(LuceneIndexUpdateEventHandler.java:25)
(ObservationManager, ): Updated Lucene index at:/tmp/lucene
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESSFUL
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 8 seconds
[INFO] Finished at: Thu Sep 13 09:33:29 PDT 2007
[INFO] Final Memory: 11M/86M
[INFO] ------------------------------------------------------------------------

The Observation API reminds me of a middleware application that I maintained for a while at a previous job, which was a bridge between our various home-grown content management systems and our actual publishing system. Events were sent as HTTP requests, and were converted into actual publishing requests by the application and sent to the publishing system. Jackrabbit's Observation API would be a perfect fit in this situation, and it would be so much more elegant.

As I was exploring the Versioning and Observation APIs, I had an epiphany. I realized the reason I have this whole love-hate thing (love the features, can't find enough reason to implement it) with Jackrabbit is because its targeted to a business model different from mine. Jackrabbit (and I am guessing any CMS in general) are targeted to businesses which tend to manage their content in individual pieces, such as news stories in a news company or product spec sheets for manufacturing companies, for example. Unlike them, we manage our content in bulk, regenerating all content from a content provider in batch mode. That may change in the future, and perhaps it would then be time to reconsider.

Saturday, September 08, 2007

Spring loaded Jackrabbit

So far I haven't been very enthusiastic about Jackrabbit, and yet I keep writing about it. My lack of enthusiasm stems from the fact that it would quite an effort to move our existing content to any content repository, which is stored as a combination of flat files, database tables and Lucene indexes, as well as keep up with the steady flow of new content we are licensing. We also have tools and gadgets which require more granular access than that provided through Jackrabbit's standard query API.

However, of late, almost everything I do seems to be driven by whether I can apply it readily, which, in retrospect, seems to be a bit short-sighted. This was driven home to me recently when I was asked to implement an idea I had suggested (and developed a proof of concept for my own understanding) about a year ago. So what seems to be impractical today may not be so a year from now, so it may be worth spending time on some technology today in the hope that maybe the knowledge would be useful down the line. In fact, that's one reason I started with this blog in the first place. And there is no doubt that Jackrabbit is cool technology, and while there are still warts, I expect it to mature enough to justify production-quality use by the time I am ready to use it.

That said, one of the things which make a particular software attractive to me is its ability to be integrated with the Spring Framework, only because I find Spring's IoC/dependency injection useful and hence tend to use it everywhere, from web applications to standalone Java projects. The Spring Modules project has built code to integrate with various other popular software, and one of them is JCR. Within the springmodules-jcr project, there is support for Jackrabbit and Jeceira, another open source CMS based on the JCR specifications.

Based upon an InfoQ article "Integrating Java Content Repository and Spring", written by Costin Leau, one of the developers on the Spring Modules project, I decided to rewrite my Content Loader and Retriever implementations that I described in my blog post two weeks ago, to use JcrTemplate and JcrCallback provided by springmodules-jcr, as well as let Spring build up my Repository and other objects using dependency injection.

First, the applicationContext.xml so you know how its all set up:

 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
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:util="http://www.springframework.org/schema/util"
       xsi:schemaLocation="
       http://www.springframework.org/schema/beans 
       http://www.springframework.org/schema/beans/spring-beans-2.0.xsd
       http://www.springframework.org/schema/util 
       http://www.springframework.org/schema/util/spring-util-2.0.xsd">

  <bean id="repository" class="org.springmodules.jcr.jackrabbit.RepositoryFactoryBean">
    <property name="configuration" value="classpath:repository.xml"/>
    <property name="homeDir" value="file:/tmp/repository"/>
  </bean>
  
  <bean id="jcrSessionFactory" class="org.springmodules.jcr.JcrSessionFactory">
    <property name="repository" ref="repository"/>
    <property name="credentials">
      <bean class="javax.jcr.SimpleCredentials">
        <constructor-arg index="0" value="user"/>
        <constructor-arg index="1">
          <bean factory-bean="password" factory-method="toCharArray"/>
        </constructor-arg>
      </bean>
    </property>
  </bean>
  
  <bean id="password" class="java.lang.String">
    <constructor-arg index="0" value="password"/>
  </bean>
  
  <bean id="jcrTemplate" class="org.springmodules.jcr.JcrTemplate">
    <property name="sessionFactory" ref="jcrSessionFactory"/>
    <property name="allowCreate" value="true"/>
  </bean>

  <bean id="fileFinder" class="com.mycompany.myapp.FileFinder">
    <property name="filter" value=".xml"/>
  </bean>
  
  <bean id="someRandomDocumentParser" 
      class="com.mycompany.myapp.SomeRandomDocumentParser"/>
  
  <bean id="myRandomContentLoader" class="com.mycompany.myapp.ContentLoader2">
    <property name="fileFinder" ref="fileFinder"/>
    <property name="jcrTemplate" ref="jcrTemplate"/>
    <property name="contentSource" value="myRandomContentSource"/>
    <property name="parser" ref="someRandomDocumentParser"/>
    <property name="sourceDirectory" value="/path/to/my/random/content"/>
  </bean>
  
  <bean id="myRandomContentRetriever" class="com.mycompany.myapp.ContentRetriever2">
    <property name="jcrTemplate" ref="jcrTemplate"/>
  </bean>
    
</beans>    

The ContentLoader2.java is a version of ContentLoader.java which uses the springmodules-jcr API to work with Jackrabbit:

  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
package com.mycompany.myapp;

import java.io.File;
import java.io.IOException;
import java.util.List;

import javax.jcr.Node;
import javax.jcr.PathNotFoundException;
import javax.jcr.RepositoryException;
import javax.jcr.Session;

import org.apache.log4j.Logger;
import org.springframework.beans.factory.annotation.Required;
import org.springmodules.jcr.JcrCallback;
import org.springmodules.jcr.JcrTemplate;

public class ContentLoader2 {

  private static final Logger LOGGER = Logger.getLogger(ContentLoader2.class);
  
  private FileFinder fileFinder;
  private String sourceDirectory;
  private String contentSource;
  private IParser parser;
  private JcrTemplate jcrTemplate;
  
  @Required
  public void setFileFinder(FileFinder fileFinder) {
    this.fileFinder = fileFinder;
  }

  @Required
  public void setJcrTemplate(JcrTemplate jcrTemplate) {
    this.jcrTemplate = jcrTemplate;
  }

  @Required
  public void setContentSource(String contentSource) {
    this.contentSource = contentSource;
  }

  @Required
  public void setParser(IParser parser) {
    this.parser = parser;
  }

  @Required
  public void setSourceDirectory(String sourceDirectory) {
    this.sourceDirectory = sourceDirectory;
  }

  public void load() throws Exception {
    jcrTemplate.execute(new JcrCallback() {
      public Object doInJcr(Session session) throws IOException, RepositoryException {
        try {
          Node contentSourceNode = getFreshContentSourceNode(session, contentSource);
          List<File> filesFound = fileFinder.find(sourceDirectory);
          for (File fileFound : filesFound) {
            DataHolder dataHolder = parser.parse(fileFound);
            if (dataHolder == null) {
              continue;
            }
            LOGGER.info("Parsing file:" + fileFound);
            Node contentNode = contentSourceNode.addNode("content");
            for (String propertyKey : dataHolder.getPropertyKeys()) {
              String value = dataHolder.getProperty(propertyKey);
              LOGGER.debug("Setting property " + propertyKey + "=" + value);
              contentNode.setProperty(propertyKey, value);
            }
            session.save();
          }
        } catch (Exception e) {
          throw new IOException("Exception parsing and storing file", e);
        }
      }
    });
  }

  /**
   * Our policy is to do a fresh load each time, so we want to remove the contentSource
   * node from our repository first, then create a new one.
   * @param session the Repository Session.
   * @param contentSourceName the name of the content source.
   * @return a content source node. This is a top level element of the repository,
   * right under the repository root node.
   * @throws Exception if one is thrown.
   */
  private Node getFreshContentSourceNode(Session session, String contentSourceName) throws Exception {
    Node root = session.getRootNode();
    Node contentSourceNode = null;
    try {
      contentSourceNode = root.getNode(contentSourceName);
      if (contentSourceNode != null) {
        contentSourceNode.remove();
      }
    } catch (PathNotFoundException e) {
      LOGGER.info("Path for content source: " + contentSourceName + " not found, creating");
    }
    contentSourceNode = root.addNode(contentSourceName);
    return contentSourceNode;
  }
}

The ContentRetriever2.java, like the ContentLoader2.java, is a version of the original ContentRetriever.java file that works with Jackrabbit using the springmodules-jcr API:

 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
package com.mycompany.myapp;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

import javax.jcr.Node;
import javax.jcr.NodeIterator;
import javax.jcr.Property;
import javax.jcr.PropertyIterator;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.query.Query;
import javax.jcr.query.QueryManager;
import javax.jcr.query.QueryResult;

import org.springmodules.jcr.JcrCallback;
import org.springmodules.jcr.JcrTemplate;

public class ContentRetriever2 {

  private JcrTemplate jcrTemplate;

  public void setJcrTemplate(JcrTemplate jcrTemplate) {
    this.jcrTemplate = jcrTemplate;
  }

  @SuppressWarnings("unchecked")
  public List<DataHolder> findAllByContentSource(final String contentSource) {
    return (List<DataHolder>) jcrTemplate.execute(new JcrCallback() {
      public Object doInJcr(Session session) throws IOException, RepositoryException {
        List<DataHolder> contents = new ArrayList<DataHolder>();
        Node contentSourceNode = session.getRootNode().getNode(contentSource);
        NodeIterator ni = contentSourceNode.getNodes();
        while (ni.hasNext()) {
          Node contentNode = ni.nextNode();
          String contentId = contentNode.getProperty("contentId").getValue().getString();
          contents.add(getContent(contentSource, contentId));
        }
        return contents;
      }
    });
  }
  
  public DataHolder getContent(final String contentSource, final String contentId) {
    return (DataHolder) jcrTemplate.execute(new JcrCallback() {
      public Object doInJcr(Session session) throws IOException, RepositoryException {
        DataHolder dataHolder = new DataHolder();
        QueryManager queryManager = session.getWorkspace().getQueryManager();
        Query query = queryManager.createQuery("//" + contentSource + 
          "/content[@contentId='" + contentId + "']", Query.XPATH);
        QueryResult result = query.execute();
        NodeIterator ni = result.getNodes();
        while (ni.hasNext()) {
          Node contentNode = ni.nextNode();
          PropertyIterator pi = contentNode.getProperties();
          dataHolder.setProperty("contentSource", contentSource);
          while (pi.hasNext()) {
            Property prop = pi.nextProperty();
            dataHolder.setProperty(prop.getName(), prop.getValue().getString());  
          }
          break;
        }
        return dataHolder;
      }
    });
  }
  
  public DataHolder getContentByUrl(final String contentSource, final String url) {
    return (DataHolder) jcrTemplate.execute(new JcrCallback() {
      public Object doInJcr(Session session) throws IOException, RepositoryException {
        DataHolder dataHolder = null;
        QueryManager queryManager = session.getWorkspace().getQueryManager();
        Query query = queryManager.createQuery("//" + contentSource + 
          "/content[@cfUrl='" + url + "']", Query.XPATH);
        QueryResult result = query.execute();
        NodeIterator ni = result.getNodes();
        while (ni.hasNext()) {
          Node contentNode = ni.nextNode();
          String contentId = contentNode.getProperty("contentId").getValue().getString();
          dataHolder = getContent(contentSource, contentId);
          break;
        }
        return dataHolder;
      }
    });
  }
}

If you compared the code above to my older post, there is not much difference. The old code is now encapsulated inside of a JcrCallback anonymous inner class implementation, which is called from a JcrTemplate.execute() method. The other thing that has changed is that I no longer build my JCR Repository and Session objects in my code anymore. Also there is no Repository.login() calls in my code, because Spring already logged me in. However, one of the most important differences is the absence of checked Exceptions being thrown from the code. JcrTemplate converts the checked RepositoryException and IOException raised from the calls to JCR code into unchecked ones.

There is obviously a lot about Jackrabbit, JCR and springmodules-jcr that I don't know yet. From my limited knowledge, it looks like a product with lots of promise, even though I don't think its useful to me right now. I plan to keep looking some more, and over the next few weeks, write about the features I think will be useful to me if I ever end up setting up one in a real environment.

Monday, September 03, 2007

More Jackrabbit - using XPath queries

Last week, I posted my initial experiences with Apache Jackrabbit. I have learned a little more since then, although its still not enough for me to consider moving all our content over to it. In my last post, I built up a content repository with the following design:

1
2
3
4
5
6
7
 rootnode
    |
    +-- ${contentType}
            |
            +-- ${contentId} {
                  properties {title:"foo", etc}
                }

This design works for getting back content by contentId only, since we can simply navigate down to the contentId node directly using code such as this:

1
2
3
4
5
6
7
8
    Node contentIdNode = session.getRootNode().getNode(contentSource).getNode(contentId);
    DataHolder dataHolder = new DataHolder();
    PropertyIterator pi = contentNode.getProperties();
    while (pi.hasNext()) {
      Property prop = pi.nextProperty();
      dataHolder.setProperty(prop.getName(), prop.getValue().getString());
    }
    return dataHolder;

However, this approach breaks down completely when we want to search by some other attribute, such as URL, which is a fairly common occurence for content based applications. But not to worry ... all JCR compliant repositories, including Jackrabbit, provides an interface to query nodes using XPath, which is described in some detail in this article. In addition, Jackrabbit also provides an SQL interface, which is described here.

To allow for this kind of searching, I had to change the way I had set up my content in the repository, something like this:

1
2
3
4
5
6
7
 rootnode
    |
    +-- ${contentType}
            |
            +-- content {
                  properties {contentId:1234, title:"foo", etc}
                }

In addition, I had to set up two SearchIndex XML blocks in the repository.xml, as a nested tag within Workspace and Repository. This is because the QueryManager uses the built in Lucene index internally to pull out information from the repository. I did not include this configuration initially because I thought that this was for searching through content (such as on a content search page), rather than doing lookups within content. The delta for the repository.xml to include the SearchIndex 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
<Repository>
  ...
  <Workspace name="${wsp.name}">
    ...
    <SearchIndex class="org.apache.jackrabbit.core.query.lucene.SearchIndex">
      <param name="path" value="${wsp.home}/index"/>
      <param name="useCompoundFile" value="true"/>
      <param name="minMergeDocs" value="100"/>
      <param name="volatileIdleTime" value="3"/>
      <param name="maxMergeDocs" value="100000"/>
      <param name="mergeFactor" value="10"/>
      <param name="maxFieldLength" value="10000"/>
      <param name="bufferSize" value="10"/>
      <param name="cacheSize" value="1000"/>
      <param name="forceConsistencyCheck" value="false"/>
      <param name="autoRepair" value="true"/>
      <param name="analyzer" value="org.apache.lucene.analysis.standard.StandardAnalyzer"/>
      <param name="queryClass" value="org.apache.jackrabbit.core.query.QueryImpl"/>
      <param name="respectDocumentOrder" value="true"/>
      <param name="resultFetchSize" value="2147483647"/>
      <param name="extractorPoolSize" value="0"/>
      <param name="extractorTimeout" value="100"/>
      <param name="extractorBackLogSize" value="100"/>
    </SearchIndex>
  </Workspace>

  ...
  <SearchIndex class="org.apache.jackrabbit.core.query.lucene.SearchIndex">
    <param name="path" value="${rep.home}/repository/index"/>
  </SearchIndex>
  
</Repository>

With the new repository design, the loader changed slightly to hardcode the node name for the content node, and to include the contentId as a property of the content node. I added a getContentByUrl(String contentSource, String url) method in addition to the original getContent(String contentSource, String contentId). In fact the getContent() method also changed to use the XPath approach. 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
  public DataHolder getContent(String contentSource, String contentId) throws Exception {
    if (session == null) {
      session = repository.login(new SimpleCredentials("user", "pass".toCharArray()));
    }
    DataHolder dataHolder = new DataHolder();
    Workspace workspace = session.getWorkspace();
    QueryManager queryManager = workspace.getQueryManager();
    Query query = queryManager.createQuery("//" + contentSource + 
      "/content[@contentId='" + contentId + "']", Query.XPATH);
    QueryResult result = query.execute();
    NodeIterator ni = result.getNodes();
    while (ni.hasNext()) {
      Node contentNode = ni.nextNode();
      PropertyIterator pi = contentNode.getProperties();
      dataHolder.setProperty("contentId", contentId);
      dataHolder.setProperty("contentSource", contentSource);
      while (pi.hasNext()) {
        Property prop = pi.nextProperty();
        dataHolder.setProperty(prop.getName(), prop.getValue().getString());
      }
      break;
    }
    return dataHolder;
  }
  
  public List<DataHolder> getContentByUrl(String contentSource, String url) 
      throws Exception {
    List<DataHolder> contents = new ArrayList<DataHolder>();
    if (session == null) {
      session = repository.login(new SimpleCredentials("user", "pass".toCharArray()));
    }
    Workspace workspace = session.getWorkspace();
    QueryManager queryManager = workspace.getQueryManager();
    Query query = queryManager.createQuery("//" + contentSource + 
      "/content[@url='" + url + "']", Query.XPATH);
    QueryResult result = query.execute();
    NodeIterator ni = result.getNodes();
    while (ni.hasNext()) {
      Node childNode = ni.nextNode();
      contents.add(getContent(contentSource, childNode.getName()));
    }
    return contents;
  }

I haven't actually tried the SQL interface, but from the link to the mail, it should not be too hard to use that either. However, the SQL interface is Jackrabbit specific, so its probably not such a good idea if one wants to migrate to a different JCR compliant repository in the future.

I do plan on doing some more reading and experimenting with Jackrabbit, from the list of articles on this Jackrabbit Wiki page. If I find anything interesting, I will write about it.

Monday, August 27, 2007

Apache Jackrabbit - is it for me?

I have wanted to try out Apache Jackrabbit, the Java Content Repository (JSR-170) reference implementation, for quite some time. My objective was to evaluate it and see if I could adapt it for our own Content Management system. We already have a home grown content generation system which we use, which involves little more than building an XML parser for each new content source. The content is generated into specifically named database tables and flat files, and an intermediate file format that is fed into our Lucene indexing pipeline. Ideally, once that is done, no more work needs to be done to surface this content on the web site, although in reality, there is still some effort needed to do this at the moment, largely because of the need to maintain backward compatibility with legacy implementations.

What I was thinking of doing was to have a loader module that would allow me to plug in an XML parser for a content source and populate the Jackrabbit repository. Once in the repository, I would have a retriever module that pulled data from the repository by contentId. The nice thing about this is that the application programmer on either side would no longer need to worry about where to write the flat files or database tables. Everything would be node paths in a repository.

With that in mind, I went through the First Hops section of the Jackrabbit docs to familiarize myself with the API. After that, I decided to replace the TransientRepository with a RepositoryImpl that was driven off a repository.xml configuration file. Instead of the in-memory Apache Derby based persistence offered by TransientRepository, I chose a combination of the MySQL based PersistenceManager and a LocalFileSystem to simulate something close to my target system.

Here is my repository.xml file, adapted from the repository.xml file found in jackrabbit-core/src/main/config in the Jackrabbit source distribution. I configured my local file system as /tmp/repository and my database as a MySQL database contentdb. Note that I had to manually create the database from the MySQL client, Jackrabbit will not do that automatically.

 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
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE Repository PUBLIC "-//The Apache Software Foundation//DTD Jackrabbit 1.2//EN" "http://jackrabbit.apache.org/dtd/repository-1.2.dtd">
<Repository>

  <FileSystem class="org.apache.jackrabbit.core.fs.local.LocalFileSystem">
    <param name="path" value="${rep.home}/content"/>
  </FileSystem>

  <Security appName="Jackrabbit">
    <AccessManager class="org.apache.jackrabbit.core.security.SimpleAccessManager">
    </AccessManager>
    <LoginModule class="org.apache.jackrabbit.core.security.SimpleLoginModule">
      <param name="anonymous" value="anonymous"/>
    </LoginModule>
  </Security>

  <Workspaces rootPath="${rep.home}/workspaces" defaultWorkspace="default"/>
  
  <Workspace name="${wsp.name}">
    <FileSystem class="org.apache.jackrabbit.core.fs.local.LocalFileSystem">
      <param name="path" value="${wsp.home}"/>
    </FileSystem>
    <PersistenceManager class="org.apache.jackrabbit.core.persistence.bundle.MySqlPersistenceManager">
      <param name="driver" value="com.mysql.jdbc.Driver"/>
      <param name="url" value="jdbc:mysql://localhost:3306/contentdb"/>
      <param name="user" value="root"/>
      <param name="password" value=""/>
      <param name="schemaObjectPrefix" value="con_"/>
    </PersistenceManager>
    <!-- dont want a SearchIndex, setup for Indexing -->
  </Workspace>

  <Versioning rootPath="${rep.home}/version">
    <FileSystem class="org.apache.jackrabbit.core.fs.local.LocalFileSystem">
      <param name="path" value="${rep.home}/version"/>
    </FileSystem>
    <PersistenceManager class="org.apache.jackrabbit.core.persistence.bundle.MySqlPersistenceManager">
      <param name="driver" value="com.mysql.jdbc.Driver"/>
      <param name="url" value="jdbc:mysql://localhost:3306/contentdb"/>
      <param name="user" value="root"/>
      <param name="password" value=""/>
      <param name="schemaObjectPrefix" value="ver_"/>
    </PersistenceManager>
  </Versioning>

  <!-- Dont want SearchIndex for searching -->
</Repository>

The ContentLoader takes a reference to the source directory, a FileFinder object which traverses the source directory recursively and returns files with the specified suffix, a content source representing the content source name, an implementation of an IParser interface (described shortly) and a reference to a Repository implementation. The Repository implementation used is Jackrabbit's RepositoryImpl object which is configured using the contents of repository.xml above. All this does is parse all the files returned by the FileFinder, then store the beans in the repository under ${rootElement}/${contentSource}/${contentId}. Properties of the content identified by contentId are stored as properties of the contentId node.

 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
public class ContentLoader {

  private static final Logger LOGGER = Logger.getLogger(ContentLoader.class);
  
  private FileFinder fileFinder;
  private String sourceDirectory;
  private IParser parser;
  private Repository repository;
  private String contentSource;
  
  public void setFileFinder(FileFinder fileFinder) {
    this.fileFinder = fileFinder;
  }
  
  public void setSourceDirectory(String sourceDirectory) {
    this.sourceDirectory = sourceDirectory;
  }
  
  public void setParser(IParser parser) {
    this.parser = parser;
  }
  
  public void setRepository(Repository repository) {
    this.repository = repository;
  }
  
  public void setContentSource(String contentSource) {
    this.contentSource = contentSource;
  }
  
  public void load() throws Exception {
    Session session = repository.login(new SimpleCredentials("user", "pass".toCharArray()));
    try {
      Node contentSourceNode = getFreshContentSourceNode(session, contentSource);
      List<File> filesFound = fileFinder.find(sourceDirectory);
      LOGGER.debug("Processing # of files:" + filesFound.size());
      for (File fileFound : filesFound) {
        DataHolder dataHolder = parser.parse(fileFound);
        if (dataHolder == null) {
          continue;
        }
        LOGGER.info("Parsing file:" + fileFound);
        String contentId = dataHolder.getContentId();
        Node contentNode = contentSourceNode.addNode(contentId);
        for (String propertyKey : dataHolder.getPropertyKeys()) {
          String value = dataHolder.getProperty(propertyKey);
          contentNode.setProperty(propertyKey, value);
        }
        session.save();
      }
    } finally {
      session.logout();
      if (repository instanceof RepositoryImpl) {
        ((RepositoryImpl) repository).shutdown();
      }
    }
  }

  /**
   * Our policy is to do a fresh load each time, so we want to remove the contentSource
   * node from our repository first, then create a new one.
   * @param session the Repository Session.
   * @param contentSourceName the name of the content source.
   * @return a content source node. This is a top level element of the repository,
   * right under the repository root node.
   * @throws Exception if one is thrown.
   */
  private Node getFreshContentSourceNode(Session session, String contentSourceName) throws Exception {
    Node root = session.getRootNode();
    Node contentSourceNode = null;
    try {
      contentSourceNode = root.getNode(contentSourceName);
      if (contentSourceNode != null) {
        contentSourceNode.remove();
      }
    } catch (PathNotFoundException e) {
      LOGGER.info("Path for content source: " + contentSourceName + " not found, creating");
    }
    contentSourceNode = root.addNode(contentSourceName);
    return contentSourceNode;
  }
}

The IParser interface is a simple interface that mandates the following method signature. It takes a reference to a File and extracts its contents into a DataHolder object, which is really a Map of <String,String>.

1
2
3
public interface IParser {
  public DataHolder parse(File file) throws Exception;
}
 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
public class DataHolder {
  
  Map<String,String> data;
  
  public DataHolder() {
    data = new HashMap<String,String>();
  }
  
  public String getContentId() {
    String contentId = (String) data.get("contentId");
    if (contentId == null) {
      throw new IllegalStateException("ContentId cannot be null, check parser code");
    }
    return contentId;
  }

  public Set<String> getPropertyKeys() {
    return data.keySet();
  }
  
  public String getProperty(String key) throws Exception {
    return data.get(key);
  }
  
  public void setProperty(String key, Object value) {
    data.put(key, String.valueOf(value));
  }

  @Override
  public String toString() {
    return data.toString();
  }
}

The FileFinder recurses through the source directory looking for the files with the specified suffix. Here it is:

 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
public class FileFinder {

  private FilenameFilter filter;
  
  public void setFilter(final String filter) {
    this.filter = new FilenameFilter() {
      public boolean accept(File dir, String name) {
        return name.endsWith(filter);
      }
    };
  }
  
  public List<File> find(String sourceDirectory) throws Exception {
    if (sourceDirectory == null) {
      throw new IllegalArgumentException("sourceDirectory cannot be null");
    }
    File dir = new File(sourceDirectory);
    if ((! dir.isDirectory()) || (! dir.exists())) {
      throw new IllegalArgumentException("Directory " + sourceDirectory + 
        " does not exist or is not a directory");
    }
    List<File> files = new ArrayList<File>();
    findRecursive(sourceDirectory, files, filter);
    Collections.sort(files, new Comparator<File>() {
      public int compare(File f1, File f2) {
        return f1.getAbsolutePath().compareTo(f2.getAbsolutePath());
      }
    });
    return files;
  }

  private void findRecursive(String baseDirectory, List<File> files, 
      FilenameFilter filenameFilter) {
    File dir = new File(baseDirectory);
    String[] children = dir.list();
    if (children != null) {
      for (String child : children) {
        File f = new File(StringUtils.join(new String[] {baseDirectory, child}, File.separator));
        if (f.isDirectory()) {
          findRecursive(f.getAbsolutePath(), files, filenameFilter);
        } else if (f.isFile() && filenameFilter.accept(dir, f.getName()) == true) {
          files.add(f);
        } else {
          // just let it go
          continue;
        }
      }
    }
  }
}

For my test, I built a simple IParser implementation using JDOM, my favorite XML parsing toolkit. Granted, the XML is exceptionally well-formed, much better than a lot of formats we have worked with, but JDOM really makes it easy to write clean readable XML parsing code.

 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
public class SomeRandomDocumentParser implements IParser {
  
  @SuppressWarnings("unchecked")
  public DataHolder parse(File file) throws Exception {
    DataHolder dataHolder = new DataHolder();
    SAXBuilder builder = new SAXBuilder();
    Document doc = builder.build(file);
    Element root = doc.getRootElement();
    dataHolder.setProperty("source", file.getParent());
    dataHolder.setProperty("category", 
      FilenameUtils.getBaseName(file.getParentFile().getParent()));
    dataHolder.setProperty("contentId", root.getChildText("content-id"));
    dataHolder.setProperty("title", WordUtils.capitalizeFully(root.getChildText("title")));
    dataHolder.setProperty("summary", root.getChildText("summary"));
    Element authorGroup = root.getChild("authors");
    if (authorGroup != null) {
      List<Element> authorElements = authorGroup.getChildren("author");
      List<String> authors = new ArrayList<String>();
      for (Element authorElement : authorElements) {
        authors.add(authorElement.getTextTrim());
      }
      dataHolder.setProperty("authors", StringUtils.join(authors.iterator(), ", "));
    }
    dataHolder.setProperty("body", getBody(root.getChild("body")));
    return dataHolder;
  }

  private Object getBody(Element bodyElement) throws Exception {
    String elementName = bodyElement.getName();
    XMLOutputter outputter = new XMLOutputter();
    outputter.setFormat(Format.getCompactFormat());
    StringWriter writer = new StringWriter();
    outputter.output(bodyElement, writer);
    String result = writer.getBuffer().toString();
    result = result.replaceAll("^<" + elementName + ">", "").
      replaceAll("<\\/" + elementName + ">$", "");
    return result;
  }
}

My calling code looks like this. Although its all set up for Spring injection, I was lazy and just built up the references in the code. Obviously this would be much cleaner and more reusable with Spring configuration. Here is the calling code for the loader.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class ContentLoaderTest {

  @Test
  public void testLoading() throws Exception {
    ContentLoader loader = new ContentLoader();
    loader.setContentSource("myRandomContent");
    FileFinder fileFinder = new FileFinder();
    fileFinder.setFilter(".xml");
    loader.setFileFinder(fileFinder);
    loader.setParser(new SomeRandomDocumentParser());
    RepositoryConfig repositoryConfig = RepositoryConfig.create(
      "src/main/resources/repository.xml", "/tmp/repository");
    loader.setRepository(RepositoryImpl.create(repositoryConfig));
    loader.setSourceDirectory("/path/to/my/random/content/src");
    loader.load();
  }
}

On the content retrieval side, I built up a ContentRetriever which provides methods to pull out all the DataHolder beans for a named content source, or a particular DataHolder bean for a single piece of content identified by contentId. Again, all this does is find the appropriate Node using ${rootElement}/${contentSource} or ${rootElement}/${contentSource}/${contentId}.

 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
public class ContentRetriever {

  private static final Logger LOGGER = Logger.getLogger(ContentRetriever.class);
  
  private Repository repository;
  private Session session;
  
  public void setRepository(Repository repository) {
    this.repository = repository;
  }
  
  public List<DataHolder> findAllByContentSource(String contentSource) throws Exception {
    List<DataHolder> contents = new ArrayList<DataHolder>();
    if (session == null) {
      session = repository.login(new SimpleCredentials("user", "pass".toCharArray()));
    }
    Node contentSourceNode = session.getRootNode().getNode(contentSource);
    NodeIterator ni = contentSourceNode.getNodes();
    while (ni.hasNext()) {
      Node childNode = ni.nextNode();
      contents.add(getContent(contentSource, childNode.getName()));
    }
    return contents;
  }
  
  public DataHolder getContent(String contentSource, String contentId) throws Exception {
    if (session == null) {
      session = repository.login(new SimpleCredentials("user", "pass".toCharArray()));
    }
    DataHolder dataHolder = new DataHolder();
    try {
      Node contentNode = session.getRootNode().getNode(contentSource).getNode(contentId);
      PropertyIterator pi = contentNode.getProperties();
      dataHolder.setProperty("contentId", contentId);
      dataHolder.setProperty("contentSource", contentSource);
      while (pi.hasNext()) {
        Property prop = pi.nextProperty();
        dataHolder.setProperty(prop.getName(), prop.getValue().getString());
      }
    } catch (PathNotFoundException e) {
      LOGGER.warn("No content with contentId:[" + contentId + 
        "] for contentSource:[" + contentSource + "]");
    }
    return dataHolder;
  }
}

To call this, I use the same strategy of writing a JUnit test. Again, I should have used Spring configuration, but got lazy, so here is the calling code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public class ContentRetrieverTest {

  @Test
  public void testRetrieve() throws Exception {
    ContentRetriever retriever = new ContentRetriever();
    RepositoryConfig repositoryConfig = RepositoryConfig.create(
      "src/main/resources/repository.xml", "/tmp/repository");
    Repository repository = RepositoryImpl.create(repositoryConfig);
    retriever.setRepository(repository);
    List<DataHolder> contents = retriever.findAllByContentSource("myRandomContentSource");
    LOGGER.debug("# of content:" + contents.size());
    Assert.assertEquals(10, contents.size());
    DataHolder content = contents.get(0);
    LOGGER.debug("contentId:" + content.getProperty("contentId"));
    Assert.assertEquals("md001", content.getProperty("contentId"));
    if (repository instanceof RepositoryImpl) {
      ((RepositoryImpl) repository).shutdown();
    }
  }
}

So, to answer my original question - is Jackrabbit for me? Sadly, I don't think so. Jackrabbit allows me to generate new content in the repository by simply creating a new XML parser to parse and extract data from the content sources. Our content generation system allows me to do the same thing, except that I have to manually create a few database tables for each new content source. Because of the way Jackrabbit stores the content (as serialized blobs of data inside the database), it is less flexible than our approach, which allows us to reuse the data in different ways. While Jackrabbit's generic approach to exposing content as node paths in a repository is cool, it is probably less flexible if you want to search content using keys other than which it was built for during loading. In case of a database, we can just slap on an index and we are good to go. Jackrabbit also does not offer an easy upgrade path from existing home grown content management systems, its all or nothing.

That said, I can see it being useful for shops where there is no content management system at the moment. It offers a lot of functionality that would otherwise need to be built by programmers in-house. It also offers the promise of standards compliance, so if a shop wanted to move to a commercial CMS in the future, all it would have to worry about is that the commercial CMS was JSR-170 compliant.

Update - 2008-08-02

Based on the first comment on this post, I started trying to build and use a custom PersistenceManager. Its actually easier than he says, Jackrabbit has a DatabasePersistenceManager (and a rather basic SimpleDatabasePersistenceManager) which has hooks to override what should happen when one of SELECT, UPDATE, DELETE and INSERT actions happen. However, midway through this exercise, I realized it was pointless (at least for me) to do this. By default, Jackrabbit creates 4 tables for your content, ${PREFIX}_BINVAL to store your binary data, ${PREFIX}_NODE to store your node information, ${PREFIX}_PROP to store your node property information and ${PREFIX}_REFS to store references if you declare your one or more of your Node objects to be Referencable (has foreign keys). All the values are stored as BLOB objects because Jackrabbit uses its own (probably Java) serialization mechanism to store non primitive values as is. With a custom PersistenceManager approach, my code would have to take care of doing this, and that's actually harder than it sounds.

Jackrabbit's default schema is effectively an infinitely extendable database, because this structure can accommodate anything without any schema changes. A colleague actually used a variant of this schema with great success at a programming gig for the Israeli government. However, this effectively converts the database to being a dumb datastore, and the Jackrabbit middleware becomes a transport layer to provide a hierarchical view of the data, and all the intelligence about how different data elements relate to each other moves to the application.

This negates one of the most important (again, to me) features of having an RDBMS - the ability to use plain SQL to view and update data, and the ability to quickly generate ad-hoc reports off the database. However, Jackrabbit, like most other CMSs, has a browser-based toolset to view data, and to write Java programs to do ad-hoc reports is not terribly painful (since ad-hoc reports are never truly ad-hoc, someone is almost certainly going to ask for the exact same report 6 months from now). Once you get past this initial hump, you realize that it probably makes more sense to use Jackrabbit (or any other CMS) the way it was meant to be used, and model your data to fit into the content repository model. I found the guidelines in David's Model quite useful to do this.

So the approach I am leaning towards now is to write batch programs that read from my legacy databases, and write to a Jackrabbit instance using the JCR API. Once there, the next step is to change over the DAOs that query or update this information to use the JCR API calls. However, that's more of a big bang approach, and given the rapidly evolving nature of our legacy applications, it is going to be hard to do this cleanly. However, with this approach, you get all the other capabilities that are built into Jackrabbit for free, so there are obvious benefits in going this route.