Thursday, November 16, 2006

Auto Discovering Hibernate Entities

We're a good portion of the way through a rewrite of an Order Entry system using spring & hibernate and all and all love the technology. There a few places, in particular with configuration, that have forced us to have repetitive code.

One of these places is the annotated class list that we provide to spring's AnnotationSessionFactoryBean in order to let Hibernate know which classes it should persist. This seems repetitive since (almost, more on that later) every class we'd like to have on this list is marked by the @Entity annotation. The session factory bean just needs a list of classes so I thought why not make a factory bean to generate this list for me. This seemed a good place to start a little cleanup so I wrote the following test:


/**
* Test that we find annotated classes given a package
*/
public void testLoadWithPackageName() throws Exception {
AnnotatedClassDiscoveryBean discoveryBean = new AnnotatedClassDiscoveryBean();
discoveryBean.setAnnotation("com.checkernet.util.testpackage.Test");
discoveryBean.setPkg("com.checkernet.util.testpackage");
Object object = discoveryBean.getObject();
assertTrue("Returned object incorrect type", object instanceof List );
List classes = (List) object;
assertEquals("Incorrect number of classes found", 3, classes.size());
}



Then I created a test package, @Test annotation, and popped in a few classes. Two of these classes used the @Test annotation. Now it was time to create an AnnotatedClassDiscoveryBean.

For starters it implemented FactoryBean. After that I knew I needed to find a way to walk through a package and get all of the classes to see if they had the annotation or not. A quick search on google lead me to a discussion on topic. Between the code on page two and spring's included AnnotationClassFilter I had everything I needed to make my test green.

With that out of the way I decided to add some more functionality. If you're in the DomainDrivenDesign camp or just don't like to organize your packages by architectural function, it would be nice to give the discovery bean a top level package and recurse/iterate through to find entity classes in sub-packages. So I added a second test:


/**
* Test that we can find annotations in packages which are descendants of the
* supplied package
*/
public void testParentPackage() throws Exception {
AnnotatedClassDiscoveryBean discoveryBean = new AnnotatedClassDiscoveryBean();
discoveryBean.setAnnotation("com.checkernet.util.testpackage.Test");
discoveryBean.setPkg("com.checkernet");
Object object = discoveryBean.getObject();
assertTrue("Returned object incorrect type", object instanceof List );
List classes = (List) object;
assertEquals("Incorrect number of classes found", 2, classes.size());
}


As you could have guessed it didn't work. Looking at the code from that thread, it was a good starting point but:

1. It wasn't that clean
2. It didn't help me pass my second test

It looked like I needed to pull the code that generated a list of class apart from the code which checked for the annotation. I did this and tried to give the code a quick pass (although it is still pretty rough) but now it passes both tests. In my session factory spring configuration the annotatedClasses property referred to a list (bean) with an id of annotated classes. It used to look like this:

<util:list id="annotatedClassList" list-class="java.util.ArrayList">
com.checkernet.model.ActiveShipmentHold
com.checkernet.model.Carrier
com.checkernet.model.CarrierMethod
com.checkernet.model.CatalogPageEntry
... (and many many more)
</util:list id="annotatedClassList" list-class="java.util.ArrayList">


so I removed that bean and replaced it with:


<bean id="annotatedClassList" class="com.checkernet.util.AnnotatedClassDiscoveryBean">
<property name="pkg" value="com.checkernet.model" />
<property name="annotation" value="javax.persistence.Entity" />
</bean>





re-ran my tests and viola no more duplication.

Here is the current (notice I didn't say final) code for the AnnotatedClassDiscoveryBean:


This code could be used for things other than hiberate.


package com.checkernet.util;

import java.io.File;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.lang.annotation.Annotation;
import java.net.URL;
import static java.net.URLDecoder.decode;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.aop.support.annotation.AnnotationClassFilter;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.beans.factory.annotation.Required;
import org.springframework.util.Assert;


/**
* Notes: A factory bean which given a package name and fully qualified annotation class name will
* Return a list of classes. Developed for use with Spring for dynamic configuration of annotated classes
* properties (like the one in AbstractSessionFactoryBean)
*
* @author Dan Thiffault
* @version 0.0.1
*/
public class AnnotatedClassDiscoveryBean implements FactoryBean {

private final Log logger = LogFactory.getLog(AnnotatedClassDiscoveryBean.class);

private Class annotation;

private String packageName;

private static final int CLASS_LIST_SIZE = 50;

private static final char PACKAGE_SEPARATOR = '.';

/**
* this is specified by ClassLoader.getResources()
*/
@SuppressWarnings({"HardcodedFileSeparator"})
private static final char PATH_SEPARATOR = '/';

/**
* Set the annotation to search for
*
* @param annotationName of the Annotation class
* @throws ClassNotFoundException if that annotation is not on the classpath
*/
@Required
public void setAnnotation(final String annotationName) throws ClassNotFoundException {
//noinspection unchecked
annotation = (Class) Thread.currentThread().getContextClassLoader()
.loadClass(annotationName);
}

/**
* Set the base package name to search through. Sub-packages will be searched
*
* @param packageName to search through
*/
@Required
public void setPkg(final String packageName) {
this.packageName = packageName;
}

/**
* Returns a list of classes which are in the specified package, or
* one of its descedant packages, that have the specified annotation.
*
* @return List<Class> of classes found
*/
public final Object getObject() {
final List classes;

List allClasses = getClassesForPackage(packageName);
classes = new ArrayList(allClasses.size());
logger.debug("Found ".concat(Integer.toString(allClasses.size())).concat(" classes in package"));
AnnotationClassFilter filter = new AnnotationClassFilter(annotation);
for (Class clazz : allClasses) {
if (filter.matches(clazz)) {
logger.debug("Discovered class: ".concat(clazz.toString()));
classes.add(clazz);
}
}


return classes;
}

/**
* Returns a list class. This is the type the calls to getObject are
* guaranteed to return.
*
* @return Class of type List
*/
public Class getObjectType() {
return List.class;
}

/**
* {@inheritDoc}
*/
public boolean isSingleton() {
return false;
}

/**
* Attempts to list all the classes in the specified package as determined
* by the context class loader
*
* @param pckgname the package name to search
* @return a list of classes that exist within that package
*/
@SuppressWarnings({"MethodWithMultipleLoops"})
private static List getClassesForPackage(String pckgname) {
String path = pckgname.replace(PACKAGE_SEPARATOR, PATH_SEPARATOR);
// This will hold a list of directories matching the pckgname. There may be more than one if a package is split over multiple jars/paths

List directories = new ArrayList(CLASS_LIST_SIZE);
List jars = new ArrayList(CLASS_LIST_SIZE);

List classFiles = new ArrayList(CLASS_LIST_SIZE);

//noinspection ProhibitedExceptionCaught
try {
ClassLoader cld = Thread.currentThread().getContextClassLoader();
if (cld == null) {
//noinspection ProhibitedExceptionThrown
throw new RuntimeException("Can't get class loader.");
}

// Ask for all resources for the path
Enumeration resources = cld.getResources(path);
//noinspection MethodCallInLoopCondition
while (resources.hasMoreElements()) {
//noinspection ObjectAllocationInLoop
String decodedPath = decode(resources.nextElement().getPath(), "UTF-8");
if (decodedPath.contains(".jar!")) {
String jarPathName = decodedPath.substring(5, decodedPath.indexOf("!"));
classFiles.addAll(getClassesInJar(new JarFile(jarPathName)));
} else {
if (!decodedPath.contains(".zip!")) {
directories.add(new File(decodedPath));
}
}
}
} catch (NullPointerException x) {
throw new IllegalArgumentException(
pckgname + " does not appear to be a valid package", x);
} catch (UnsupportedEncodingException encex) {
throw new IllegalArgumentException(
pckgname + " does not appear to be a valid package (Unsupported encoding)", encex);
} catch (IOException ioex) {
//noinspection ProhibitedExceptionThrown
throw new RuntimeException("IOException was thrown when trying to get all resources for " + pckgname, ioex);

} catch (Exception e) {
throw new RuntimeException("Something terrible happened", e);
}

// For every directory identified capture all the .class files
for (File directory : directories) {
if (directory.exists()) {
classFiles.addAll(getFilesInDirectory(directory, path));
} else {
throw new IllegalArgumentException(
pckgname + " (" + directory.getPath() + ") does not appear to be a valid package");
}
}


List classes = new ArrayList(classFiles.size());

for (String fileName : classFiles) {
try {
classes.add(Class.forName(
fileName.substring(0, fileName.length() - 6).replace(File.separatorChar, PACKAGE_SEPARATOR).replace(PATH_SEPARATOR, PACKAGE_SEPARATOR)));

} catch (ClassNotFoundException e) {
//noinspection ProhibitedExceptionThrown
throw new RuntimeException("Couldn't load class", e);
}
}

return classes;

}

private static List getFilesInDirectory(File directory, final String currentDirectory) {
Assert.isTrue(directory.isDirectory(), "File passed in was not a directory: ".concat(directory.getName()));
List files = new ArrayList(CLASS_LIST_SIZE);

for (File file : directory.listFiles()) {
String fileName = file.getName();
if (file.isFile() && fileName.endsWith(".class")) {
files.add(currentDirectory.concat(File.separator).concat(fileName));
} else if (file.isDirectory()) {
files.addAll(getFilesInDirectory(file, currentDirectory.concat(File.separator).concat(fileName)));
}
}

return files;
}

private static List getClassesInJar(JarFile jf) {
List classNames = new ArrayList(CLASS_LIST_SIZE);
Enumeration e = jf.entries();
while (e.hasMoreElements()) {
JarEntry jarEntry = e.nextElement();
if (jarEntry.getName().endsWith(".class")) {
classNames.add(jarEntry.getName());
}

}

return classNames;
}

}




Updated: Fixed to work with jars.
Update 2: Based on Joe's comment, updated the code to work on non-BSD based systems (like windows)

7 comments:

Matt Raible said...

Is it possible to scan packages in a JAR as well as directories? This might solve a problem we're having in AppFuse 2.x.

Dan Thiffault said...

I updated the code in the post so it should work with jars now.

Joe Scalise said...

I had to make some changes so that this would work on non-BSD systems.

ClassLoader.getResources(java.lang.String)
specifies that the paths must be delimited with '/', therefore:

1) where PATH_SEPARATOR = '/', change
String path = pckgname.replace(PACKAGE_SEPARATOR, File.separatorChar);
to
String path = pckgname.replace(PACKAGE_SEPARATOR, PATH_SEPARATOR);

2) change
classes.add(Class.forName(
fileName.substring(0, fileName.length() - 6).replace(File.separatorChar, PACKAGE_SEPARATOR)));

to

classes.add(Class.forName( fileName.substring(0, fileName.length() - 6) .replace(File.separatorChar, PACKAGE_SEPARATOR).replace(PATH_SEPARATOR,
PACKAGE_SEPARATOR)));

Unknown said...

Thanks for this! This was the solution we're looking for. Have you had any trouble with this since you wrote this blog entry?

Dan Thiffault said...

It has worked fine in testing, but another project has been a higher priority so we really haven't put it through its paces. I'd love to hear feedback though

Unknown said...

I notice this post is quite old already, but is there any particular reason you wrote your own code to find all class files, and did not use Spring's
PathMatchingResourcePatternResolver?

Dan Thiffault said...

That seems like a better approach. Like you said its an old post but I'll try and test it out next time I have a few free minutes. Thanks!