Package scanner and its tests

The package scanner is a helper that can list and load classes from a
package, optionally recursing into sub-packages.
This commit is contained in:
Emmanuel BENOîT 2015-09-13 11:09:43 +02:00
parent 97c8e1274a
commit ae5a3d5d1a
6 changed files with 394 additions and 0 deletions

View file

@ -0,0 +1,279 @@
package info.ebenoit.ebul.reflection;
import java.io.File;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.function.Predicate;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
/**
* This class scan a package to find the classes it contains.
*
* <p>
* <b><u>Warning</u>: not thread safe</b>
*
* @author <a href="mailto:ebenoit@ebenoit.info">E. Benoît</a>
*/
public class PackageScanner
{
/** The name of the package */
private final String name;
/** <code>true</code> if classes in sub-packages will be listed as well */
private final boolean recursive;
/** The class loader to use */
private final ClassLoader loader;
/** The cached results */
private ArrayList< String > classes;
/**
* Initialises the scanner using the current thread's default class loader.
*
* @param name
* the name of the package to scan
* @param recursive
* <code>true</code> if sub-packages are to be listed, <code>false</code> if they are to be ignored
*/
public PackageScanner( final String name , final boolean recursive )
{
this( name , recursive , Thread.currentThread( ).getContextClassLoader( ) );
}
/**
* Initialises the scanner using a custom class loader.
*
* @param name
* the name of the package to scan
* @param recursive
* <code>true</code> if sub-packages are to be listed, <code>false</code> if they are to be ignored
* @param loader
* the class loader to use when scanning and loading classes
*/
public PackageScanner( final String name , final boolean recursive , final ClassLoader loader )
{
this.name = name;
this.recursive = recursive;
this.loader = loader;
}
/**
* @return the name of the package to scan
*/
public String getPackageName( )
{
return this.name;
}
/**
* @return <code>true</code> if sub-packages are listed, <code>false</code> if they are ignored
*/
public boolean isRecursive( )
{
return this.recursive;
}
/**
* Clear the cached results.
*
* @return the package scanner
*/
public PackageScanner clearCache( )
{
this.classes = null;
return this;
}
/**
* Gets the list of class names. If the method had already been called, the cached results will be returned.
*
* @return the list of canonical class names
* @throws IOException
* if an I/O error occurs while scanning the packages
*/
public ArrayList< String > getClassNames( )
throws IOException
{
if ( this.classes == null ) {
this.findAllClasses( );
}
return this.classes;
}
/**
* Gets the list of classes. The names will be retrieves using {@link #getClassNames()} and loaded using the
* scanner's configured class loader.
*
* @return the classes
* @throws IOException
* if an I/O error occurs while scanning the packages
* @throws ClassNotFoundException
* if one of the classes cannot be loaded.
*/
public ArrayList< Class< ? > > loadClasses( )
throws IOException , ClassNotFoundException
{
final ArrayList< Class< ? > > classes = new ArrayList< >( );
for ( final String name : this.getClassNames( ) ) {
classes.add( this.loader.loadClass( name ) );
}
return classes;
}
/**
* Finds classes matching a specific filter.
*
* @param filter
* the predicate to use as a filter
* @return the list of matching classes
* @throws IOException
* if an I/O error occurs while scanning the packages
* @throws ClassNotFoundException
* if one of the classes cannot be loaded.
*/
public ArrayList< Class< ? > > findClasses( final Predicate< Class< ? > > filter )
throws IOException , ClassNotFoundException
{
final ArrayList< Class< ? > > classes = new ArrayList< >( );
for ( final String name : this.getClassNames( ) ) {
final Class< ? > loadedClass = this.loader.loadClass( name );
if ( filter.test( loadedClass ) ) {
classes.add( loadedClass );
}
}
return classes;
}
/**
* Internal method that list classes from the package
*
* @throws IOException
* if an I/O error occurs while scanning the packages
*/
private void findAllClasses( )
throws IOException
{
this.classes = new ArrayList< >( );
final String packagePath = this.name.replace( '.' , '/' );
final HashSet< String > uniqueNames = new HashSet< >( );
final Enumeration< URL > en = this.loader.getResources( packagePath );
final HashSet< URL > urls = new HashSet< >( );
while ( en.hasMoreElements( ) ) {
final URL url = en.nextElement( );
if ( !urls.add( url ) ) {
continue;
}
if ( url.getProtocol( ).equals( "jar" ) ) {
this.scanPackageJar( packagePath , url , uniqueNames );
} else {
try {
this.scanPackageFiles( new File( url.toURI( ).getPath( ) ) , this.name , uniqueNames );
} catch ( final URISyntaxException e ) {
throw new RuntimeException( "internal error" , e );
}
}
}
}
/**
* Scan a JAR file for matching classes
*
* @param packagePath
* path matching the package
* @param url
* URL of the JAR file
* @param names
* set of class names that were already found
* @throws IOException
* if decoding the URL or opening the JAR file fails
*/
private void scanPackageJar( String packagePath , final URL url , final HashSet< String > names )
throws IOException
{
String jarFileName = URLDecoder.decode( url.getFile( ) , "UTF-8" );
jarFileName = jarFileName.substring( 5 , jarFileName.indexOf( "!" ) );
packagePath += "/";
final JarFile jf = new JarFile( jarFileName );
try {
final Enumeration< JarEntry > jarEntries = jf.entries( );
while ( jarEntries.hasMoreElements( ) ) {
String entryName = jarEntries.nextElement( ).getName( );
if ( ! ( entryName.startsWith( packagePath ) && entryName.endsWith( ".class" ) ) ) {
continue;
}
entryName = entryName.substring( packagePath.length( ) , entryName.lastIndexOf( '.' ) );
if ( entryName.endsWith( "/package-info" ) || !this.recursive && entryName.indexOf( '/' ) != -1 ) {
continue;
}
entryName = entryName.replace( '/' , '.' );
if ( names.add( entryName ) ) {
this.classes.add( entryName );
}
}
} finally {
jf.close( );
}
}
/**
* Scan a directory for matching classes
*
* @param directory
* directory to scan
* @param pack
* name of the package the directory corresponds to
* @param names
* set of class names that were already found
*/
private void scanPackageFiles( final File directory , final String pack , final HashSet< String > names )
{
for ( final File actual : directory.listFiles( ) ) {
String entryName = actual.getName( );
if ( actual.isDirectory( ) ) {
if ( this.recursive ) {
this.scanPackageFiles( actual , pack + "." + entryName , names );
}
continue;
}
if ( !entryName.endsWith( ".class" ) ) {
continue;
}
entryName = entryName.substring( 0 , entryName.lastIndexOf( '.' ) );
if ( "package-info".equals( entryName ) ) {
continue;
}
entryName = pack + "." + entryName;
if ( names.add( entryName ) ) {
this.classes.add( entryName );
}
}
}
}

View file

@ -0,0 +1,84 @@
package info.ebenoit.ebul.reflection;
import static org.junit.Assert.*;
import java.io.Serializable;
import java.util.ArrayList;
import org.junit.Test;
/**
* Tests for the {@link PackageScanner} class
*
* @author <a href="mailto:ebenoit@ebenoit.info">E. Benoît</a>
*/
public class TestPackageScanner
{
/** Test: list the class names in the test package, without recursion */
@Test
public void testGetClassNamesNoRecursion( )
throws Exception
{
PackageScanner scanner = new PackageScanner( "test" , false );
ArrayList< String > result = scanner.getClassNames( );
ArrayList< String > expected = new ArrayList< >( );
expected.add( "test.PSTest1" );
assertEquals( expected , result );
}
/** Test: list the class names in the test package, with recursion into test.sub */
@Test
public void testGetClassNamesRecursion( )
throws Exception
{
PackageScanner scanner = new PackageScanner( "test" , true );
ArrayList< String > result = scanner.getClassNames( );
result.sort( ( String a , String b ) -> a.compareTo( b ) );
ArrayList< String > expected = new ArrayList< >( );
expected.add( "test.PSTest1" );
expected.add( "test.sub.PSTest2" );
assertEquals( expected , result );
}
/** Test: load all classes in test and test.sub */
@Test
public void testLoadClasses( )
throws Exception
{
PackageScanner scanner = new PackageScanner( "test" , true );
ArrayList< Class< ? > > result = scanner.loadClasses( );
result.sort( ( Class< ? > a , Class< ? > b ) -> a.getCanonicalName( ).compareTo( b.getCanonicalName( ) ) );
ArrayList< Class< ? > > expected = new ArrayList< >( );
expected.add( test.PSTest1.class );
expected.add( test.sub.PSTest2.class );
assertEquals( expected , result );
}
/** Test: find classes in test and test.sub that implement {@link Serializable} */
@Test
public void testFindClasses( )
throws Exception
{
PackageScanner scanner = new PackageScanner( "test" , true );
ArrayList< Class< ? > > result = scanner.findClasses( a -> Serializable.class.isAssignableFrom( a ) );
ArrayList< Class< ? > > expected = new ArrayList< >( );
expected.add( test.sub.PSTest2.class );
assertEquals( expected , result );
}
}

View file

@ -0,0 +1,6 @@
package test;
public class PSTest1
{
}

View file

@ -0,0 +1,6 @@
/**
* Package used for the package scanner test.
*
* @author <a href="mailto:ebenoit@ebenoit.info">E. Benoît</a>
*/
package test;

View file

@ -0,0 +1,13 @@
package test.sub;
import java.io.Serializable;
@SuppressWarnings( "serial" )
public class PSTest2
implements Serializable
{
}

View file

@ -0,0 +1,6 @@
/**
* Sub-package used for the package scanner test.
*
* @author <a href="mailto:ebenoit@ebenoit.info">E. Benoît</a>
*/
package test.sub;