Autodiscovery of Hibernate Mapping Files in Spring

I’m a big fan of autodiscovery – I dislike having to specify information that can be inferred programmatically. So along those lines, here’s a subclass of Spring’s LocalSessionFactoryBean (AutodiscoverSessionFactoryBean) that finds Hibernate hbm.xml mapping files in your classpath.

Once your application is “done”, this feature is unnecessary, but during development, or if your application will be extended with new Hibernate persistence classes, it’s very convenient to not have to worry about updating Spring’s applicationContext.xml each time you add, remove, or move a mapped class.

There is a small amount of configuration – the search needs to be bootstrapped to avoid searching the entire classpath. Originally I wrote this for an application that was self-contained and the search was a lot simpler, but then I reworked an application that uses shared libraries containing mapping files and things got a little more complicated. But it’s still simple – just specify one mapped class (the class name itself, not the mapping file) per class path location (e.g. jar or class folder). The code finds the location of the specified classes and searches only those locations for mapping files.

Typically, you’ll configure a ‘sessionFactory’ bean in applicationContext.xml with a property listing all known mapping files, e.g.

<bean id='sessionFactory' class='org.springframework.orm.hibernate3.LocalSessionFactoryBean'>
...
  <property name='mappingResources'>
    <list>
      <value>com/foo/bar/My1stPersistentClass.hbm.xml</value>
      <value>com/foo/bar/My2ndPersistentClass.hbm.xml</value>
...
      <value>com/foo/bar/MyNthPersistentClass.hbm.xml</value>
    </list>
  </property>
</bean>

and this list can be very long if you have a lot of mapped entities.

Let’s say you have a web application that’s deployed with classes and mapping files in WEB-INF/classes and it uses two library jar files that contain mapping files. Using AutodiscoverSessionFactoryBean, you’d only need to specify one mapped class per location, one each for the jars and one in the web app classes, e.g.

<bean id='sessionFactory' class='com.myapp.spring.AutodiscoverSessionFactoryBean'>

  <property name='dataSource' ref='dataSource' />
  <property name='hibernateProperties'>
    <props>
      <prop key='hibernate.dialect'>${hibernate.dialect}</prop>
      <prop key='hibernate.max_fetch_depth'>${hibernate.max_fetch_depth}</prop>
      <prop key='hibernate.use_outer_join'>${hibernate.use_outer_join}</prop>
    </props>
  </property>
  <property name='classNames'>
    <list>
      <value>com.lib1.model.User</value>
      <value>com.lib2.model.Product</value>
      <value>com.myapp.model.ShoppingCart</value>
    </list>
  </property>
</bean>

Pretty simple. For the more likely case where all mapping files are in your application classes, the list would only need to contain a single class.

So here’s the Java source:

package com.myapp.spring;

import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import org.apache.log4j.Logger;
import org.hibernate.HibernateException;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.orm.hibernate3.LocalSessionFactoryBean;

/**
 * Override standard factory bean to search classpath instead
 * of explicitly enumerating all hbm files.
 *
 * @author Burt
 */
public class AutodiscoverSessionFactoryBean
       extends LocalSessionFactoryBean {

  private final Logger _log = Logger.getLogger(getClass());

  private String[] _classNames;

  @Override
  public void afterPropertiesSet()
      throws IllegalArgumentException, HibernateException, IOException {

    if (_classNames == null) {
      _log.warn("no class names specified");
      return;
    }

    try {
      String[] hbmFilesAndJars = findHbmFiles(_classNames);
      if (hbmFilesAndJars.length == 0) {
        _log.warn("no mapping files found for classes " +
            Arrays.toString(_classNames));
        return;
      }

      List<resource> jars = new ArrayList<resource>();
      List<string> hbmFiles = new ArrayList<string>();
      for (String path : hbmFilesAndJars) {
        if (path.toLowerCase().endsWith(".jar")) {
          // Hibernate will find all mapping files in specified jars
          jars.add(new FileSystemResource(path));
        }
        else {
          hbmFiles.add(path);
        }
      }

      setMappingJarLocations(jars.toArray(new FileSystemResource[jars.size()]));
      setMappingResources(hbmFiles.toArray(new String[hbmFiles.size()]));
    }
    catch (URISyntaxException e) {
      // unrecoverable, just punt
      throw new RuntimeException(e);
    }
    catch (ClassNotFoundException e) {
      // unrecoverable, just punt
      throw new RuntimeException(e);
    }

    super.afterPropertiesSet();
  }

  /**
   * Find Hibernate mapping files in the class path;
   * will include hbm xml files and jars that contain
   * hbm xml files.
   *
   * @param classNames
   * @return the mapping file paths and/or jar file paths
   * @throws URISyntaxException
   * @throws ClassNotFoundException
   */
  public static String[] findHbmFiles(final String[] classNames)
        throws URISyntaxException, ClassNotFoundException {

    if (classNames == null || classNames.length == 0) {
      return new String[0];
    }

    List<string> fileNames = new ArrayList<string>();

    for (String className : classNames) {
      Class clazz = Class.forName(className);
      File root = findRoot(clazz);
      if (root.isFile()) {
        if (root.getName().toLowerCase().endsWith(".class")) {
          // find root directory containing this class
          root = findRootFromClass(clazz, root);
          // recurse
          findHbmFiles(fileNames, root, root);
        }
        else {
          // jar file; no need to look inside; Hibernate will do that
          fileNames.add(root.getPath());
        }
      }
      else {
        // recurse
        findHbmFiles(fileNames, root, root);
      }
    }

    return fileNames.toArray(new String[fileNames.size()]);
  }

  /**
   * The root directory of the classpath.
   * @param clazz  a class in the classpath
   * @return  the directory
   * @throws URISyntaxException
   */
  private static File findRoot(final Class clazz)
        throws URISyntaxException {
    URI uri = clazz.getProtectionDomain().getCodeSource().getLocation().toURI();
    String path = uri.toString();
    if (path.startsWith("jar:file:")) {
      // e.g. 'jar:file:/path/to/jar/myjar.jar!/com/myapp/foo/Thing.class',
      // extract what's between 'jar:file:' and !
      int index = path.indexOf('!');
      return new File(path.substring(9, index));
    }
    return new File(uri);
  }

  private static File findRootFromClass(
      final Class clazz, final File root) {

    // e.g. class name = com.myapp.model.Thing,
    // root is /usr/local/tomcat/webapps/myapp/WEB-INF/classes/com/myapp/model/Thing.class,
    // return '/usr/local/tomcat/webapps/myapp/WEB-INF/classes'
    return new File(root.getPath().substring(0,
            root.getPath().length() - clazz.getName().length() - 6));
  }

  private static void findHbmFiles(
      final List<string> fileNames,
      final File dir,
      final File rootDir) {

    File[] files = dir.listFiles();
    if (files == null) {
      return;
    }

    for (File file : files) {
      if (file.isDirectory()) {
        findHbmFiles(fileNames, file, rootDir);
      }
      else if (file.getName().endsWith(".hbm.xml")) {
        fileNames.add(getRelativeFile(file.getPath(), rootDir.getPath()));
      }
    }
  }

  private static String getRelativeFile(
       final String fileName,
       final String rootDir) {
    String fileNameFixed = fileName.replaceAll("\\\\", "/");
    String rootDirFixed = rootDir.replaceAll("\\\\", "/");
    if (!rootDirFixed.endsWith("/")) {
      rootDirFixed += '/';
    }

    if (!fileNameFixed.toLowerCase().startsWith(rootDirFixed.toLowerCase())) {
      return fileName;
    }

    String fixed = fileNameFixed.substring(rootDirFixed.length());
    if (fixed.startsWith("/")) {
      return fixed.substring(1);
    }

    return fixed;
  }

  /**
   * Set classes to search for.
   * @param classNames
   */
  @DependencyInjection
  public void setClassNames(final String[] classNames) {
    _classNames = classNames;
  }
}

The list of class names is a dependency injection, specified in applicationContext.xml. To mark the setter method as a DI method and not callable from application code, I use this marker annotation:

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * Marker annotation that flags a method as one called to
 * inject a dependency, but not ordinarily callable by
 * application code.
 *
 * @author Burt
 */
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.METHOD)
public @interface DependencyInjection {
   // empty; marker annotation only
}

3 Responses to “Autodiscovery of Hibernate Mapping Files in Spring”

  1. Jorg Heymans says:

    Just curious, how is your solution different from specifying the mappingLocations property like this:

  2. Burt says:

    Sorry, your question got clipped. But in case I understand where you’re headed, in the above example I specify one class name per classpath location. I could specify locations, but then that limits me to cases where I know what the classpath is at runtime. An app that’s reused as a jar might run from a classes directory when it’s run standalone; likewise, running in an IDE will likely have one or more classes directories versus jars in a web or standalone app. The tradeoff I’m taking is the assumption that there’s one mapped class per location that’s always there, and there may be more and the others are free to change.

  3. Jorg Heymans says:

    Just to make sure we’re on the same level here: the syntax that got clipped was a property called mappingLocations with a value of classpath*:**/*.hbm.xml

    This works for hbm detection in most cases, classes/jars/both.

    The only place where it doesn’t work for me is when deployed as an EAR on a managed WLS server instance. On a standalone (ie ‘admin’) server instance it also works fine. I guess your solution is more resilient to the current classloader environment.

    Cheers
    Jorg

Creative Commons License
This work is licensed under a Creative Commons Attribution 3.0 License.