javaannotationsreflections

Java Reflections and Meta Annotation Scanning


I am working on a somewhat like spring DI framework, and I faced an issue scanning all classes annotated with a certain annotation.

Here are the annotations:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Component {}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Component
public @interface View {}

@View itself is annotated with @Component so that ComponentScanner can also scan @View.

And Here is the ComponentScanner:

public class ComponentScanner {

    public static Set<Class<?>> scan(String basePackage) {
        Reflections reflections = new Reflections(basePackage);
        return reflections.getTypesAnnotatedWith(Component.class)
                .stream().filter(component -> !component.isAnnotation())
                .collect(Collectors.toSet());
    }
}

ComponentScanner.scan() is called when the ApplicationContext is initialized:

public class ApplicationContext {

    private final BeanContainer beanContainer = new BeanContainer();

    public ApplicationContext(String basePackage) {

        // scan components and inject dependencies
        Set<Class<?>> components = ComponentScanner.scan(basePackage);
        beanContainer.registerComponents(components);
        for (Class<?> componentClass : components)
            System.out.println("ComponentScanner found: "+componentClass.getName());
    }

    public <T> T getBean(Class<T> tClass) {
        return beanContainer.getBean(tClass);
    }
}

The issue is that this ComponentScanner.scan() works fine for test codes but not when in actual use...

Use case:

public class App {

    public static void run(Class<?> mainClass, String[] args) {
        final String BASE_PACKAGE = mainClass.getPackageName();
        ApplicationContext context = new ApplicationContext(BASE_PACKAGE);
        // other setting codes...
    }
}
package com.example.test;

public class Main {
    public static void main(String[] args) {
        App.run(Main.class, args);
    }
}

@Component
public class MyComponent {}

@View
public class MyView {}

If I run the main method, App.run() is called and it initializes ApplicationContext so that all Components are automatically scanned. But when I checked the log, MyComponent was scanned but not MyView...

ComponentScanner found: com.example.test.MyComponent

This is my test code:

public class WebTest {

    static final String BASE_PACKAGE = "com.johndoe.myproject";
    static final String TEMPLATE_ROOT = "src/test/resources/templates";
    static ApplicationContext context = new ApplicationContext(BASE_PACKAGE);

    @Test
    public void testView() throws IOException {
        TestView view = context.getBean(TestView.class);
        Assertions.assertNotNull(view);
        Assertions.assertEquals("hello", view.hello());

    }
}

This test actually passed. So, I believe this indicates that ComponentScanner.scan() actually scanned the View below:

@View
public class TestView {
    public String hello() {
        return "hello";
    }
}

TestView is under the same package with WebTest.

I tried all the steps Open AIs told me to do and they all failed. So my question is; why does my test code work but not the actual code?

I am really sorry for the load of code blocks but I really wanted to explain the full situation. I tried my best to simplify the code so that you can only see the essentials😭


Solution

  • Reflections doesn't actually work; the entire model is completely broken. Reflection relies on unspecified implementation behaviours.

    Java's "find a resource" system is called a ClassLoader. It's a class; you can extend it. It's a pluggable model: You can make your own classloaders. The default classloader that the system uses is actually 2 of em:

    Even though the name is ClassLoader, it can load any kind of resource. class files are files that are created during compilation, they do not change, and are an inherent part of your app's distribution: If they aren't there your app cannot work. Hence, it's the right place for all files that have that property. For example, if you're a desktop app with a custom save icon, that icon's png fits that description just as well as a class file would, and should therefore be stored in the same place your class files are, and should be loaded via a classloader.

    Now we get to the crucial reason Reflections doesn't actually work:

    ClassLoader as a concept does not have a list directive. It simply does not exist. The only 2 methods it has is 'make a URL that represents this resource string' and 'give me an inputstream that provides the data contained in the resource identified by this resource string'. Some classloaders will allow you to ask for a path that identifies a 'directory' but the spec does not require this or even explain how that would work. In addition, when you ask a classloader to make a URL, it is plausible that this URL contains sufficient data to 'recreate' its process. For example, the UrlClassLoader, if you ask it to make a URL, will get you something like:

    jar:/abs/path/to/the/lib.jar!relative/path/inside/the/jar/YourFile.png
    

    With some string manipulation you can extract /abs/path/to/the/lib.jar, you can then open that with JarInputStream and friends, and that class does have a list directive.

    That's how the Reflections library does its job. However, if it sees a URL that it either does not know about, or which does not contain the information required to 'recreate' how the classloader loads, then it cannot do its job.

    Given the few things a ClassLoader must actually implement, you can make a classloader that loads classes from a database. Or downloads them on the fly. Or even creates them on the spot, or introduces randomness. A URL for such things might be db:psql:localhost:1234/dbName/someUnid which Reflections cannot do anything with.

    This is, presumably, what you're running into. Point is, there is no spec, so you'd have to disassemble the entire chain of everything you're doing, and debug reflections, in order to ever fix this. The model is broken.

    The fix - SPI

    As one might expect, if the ClassLoader spec itself does not actually allow 'list your contents', then either the OpenJDK never needed it, or found another solution. Indeed, it has found another solution. The OpenJDK does do things just like you are: There are JDBC drivers it wants to load (and in case all you've followed is 30 year old tutorials, no, you don't need to Class.forName("org.foo.jdbc.Driver") first), charset providers to list, and it has a pluggable security infrastructure.

    The solution is SPI.

    SPI gets around the problem of 'you cannot list any files' by having the build process itself list all relevant files, and to put that listing into a file with a hardcoded name. Now, to 'list files', you just load that hardcoded name which is a thing ClassLoader instances can do, and now you have a list of names to ask classloaders for.

    The file is by convention located in META-INF/services/com.foo.fully.qualified.name.of.a.SuperType and the contents of it are 1 class per line, each line a fully qualified type name. The JDK has a baked in way to retrieve these: ServiceLoader

    You can ask every classloader for a resource and go through all of them which is why this system 'works'. You can for example write a java-based photo editor app that allows plugins, and all you'd have to do to 'install' a plugin is to ensure it is on the classpath. The code in your java app can do:

    // Actually, you'd use ServiceLoader,
    // but that class is plain jane java code, and it works based on:
    classLoader.findResources("META-INF/services/com.standingash.photolicious.plugins.PhotoPlugin")
    

    go through each resource that returns, read it all in as a string, loop through every line, and classloader.loadClass every one of them, newInstance what comes out. Voila - a pluggable system that can dynamically find classes with a certain property and load them, without the need to ask any classloader to 'list' things.

    If you're not interested in 'pluggably finding classes' but want to find other files, the exact same mechanism can be used. You don't have to use ServiceLoader, after all, you can simply use findResources instead.

    The final missing piece - annotation processing

    The one downside to the above process is that somebody is going to have to maintain that services file. Which is annoying. And doesn't match what you want, which is to 'find all classes annotated with X'.

    The solution is annotation processors. You can run, as part of the build, a processor that sees all those @View annotations and generates that services file as part of the build. Just like how your MyCode.java file requires compilation and that results in MyCode.class existing, the same process results in META-INF/services/com.foo.my-list-of-classes-annotated-with-view.

    Specifically for the SPI concept (the pluggable part being: Classes with a public no-args constructor that implement a 'plugin' base type) there are a few existing projects out there that are such an AP (it'd be a weekend job to write one at best, it's not all that complicated), but it sounds like you might have to write your own.

    The downside is, you have to re-engineer this part rather thoroughly: Away from the concept of 'At runtime, give me a list of all types that have this annotation' and into the combination of 'witness all types that have this annotation during compilation and list them out into a text file' + 'at runtime, read the text file'.

    You're going to have to do that. Or, rely on a concept that relies on unspecified implementation details, which means 'debugging' your issue requires pretty much a complete copy of your entire PC to debug, the details you provided in your question probably aren't enough to actually figure it out. It can depend on your JDK vendor, platform, architecture, JDK version, classloader situation, and so on.