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😭
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:
A bootstrap loader which is baked into the JVM itself (after all, ClassLoader.class
itself is a class, how does the JVM find this class which it will have to do to even have a classloader). Starting with JDK9 it loads via modules stored directly in your JDK distribution.
One instance of UrlClassLoader
, loaded by the system, with your classpath as 'list of paths' (from your jar's Class-Path
entry, if launched via -jar
. Otherwise, via the -cp
parameter and if that's not present, the CLASSPATH system property). This classloader is then used to load whatever you specified as main class, and it goes from there. NOte that the classpath has no other function. This is the one thing it does: Serve as the base for the one UrlClassLoader made by the system and used to load your main class.
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.
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 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.