Understanding Dependency Injection by writing a DI Container - from scratch! (Part 3)

Martin Häusler - Feb 11 '20 - - Dev Community

This is the third part of my "DI From Scratch" series. In the previous article, we built a basic DI container. Now, we want to take it yet another step further and automatically discover the available service classes.

DI Stage 7: Auto-detecting Services

Find the source code of this section on github

Our current state represents (a strongly simplified, yet functional) version of libraries such as Google Guice. However, if you are familiar with Spring Boot, it goes one step further. Do you think that it's annoying that we have to specify the service classes explicitly in a set? Wouldn't it be nice if there was a way to auto-detect service classes? Let's find them!

public class ClassPathScanner {

    // this code is very much simplified; it works, but do not use it in production!
    public static Set<Class<?>> getAllClassesInPackage(String packageName) throws Exception {
        ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
        String path = packageName.replace('.', '/');
        Enumeration<URL> resources = classLoader.getResources(path);
        List<File> dirs = new ArrayList<>();
        while (resources.hasMoreElements()) {
            URL resource = resources.nextElement();
            dirs.add(new File(resource.getFile()));
        }
        Set<Class<?>> classes = new HashSet<>();
        for (File directory : dirs) {
            classes.addAll(findClasses(directory, packageName));
        }
        return classes;
    }

    private static List<Class<?>> findClasses(File directory, String packageName) throws Exception {
        List<Class<?>> classes = new ArrayList<>();
        if (!directory.exists()) {
            return classes;
        }
        File[] files = directory.listFiles();
        for (File file : files) {
            if (file.isDirectory()) {
                classes.addAll(findClasses(file, packageName + "." + file.getName()));
            } else if (file.getName().endsWith(".class")) {
                classes.add(Class.forName(packageName + '.' + file.getName().substring(0, file.getName().length() - 6)));
            }
        }
        return classes;
    }

}

Here we have to deal with the ClassLoader API. This particular API is quite old and dates back to the earliest days of Java, but it still works. We start with a packageName to scan for. Each Thread in the JVM has a contextClassLoader assigned, from which the Class objects are being loaded. Since the classloader operates on files, we need to convert the package name into a file path (replacing '.' by '/'). Then, we ask the classloader for all resources in this path, and convert them into Files one by one. In practice, there will be only one resource here: our package, represented as a directory.

From there, we recursively iterate over the file tree of our directory package, loking for files ending in .class. We convert every class file we encounter into a class name (cutting off the trailing .class) to end up with our class name. Then, we finally call Class.forName(...) on it to retrieve the class.

So we have a way to retrieve all classes in our base package. How do we use it? Let's add a static factory method to our DIContext class that produces a DIContext for a given base package:

    public static DIContext createContextForPackage(String rootPackageName) throws Exception {
        Set<Class<?>> allClassesInPackage = ClassPathScanner.getAllClassesInPackage(rootPackageName);
        Set<Class<?>> serviceClasses = new HashSet<>();
        for(Class<?> aClass : allClassesInPackage){   
            serviceClasses.add(aClass);
        }
        return new DIContext(serviceClasses);
    }

Finally, we need to make use of this new factory method in our createContext() method:

    private static DIContext createContext() throws Exception {
        String rootPackageName = Main.class.getPackage().getName();
        return DIContext.createContextForPackage(rootPackageName);
    }

We retrieve the base package name from the Main class (the class I've used to contain my main() method).

But wait! We have a problem. Our classpath scanner will detect all classes, whether they are services or not. We need to tell the algorithm which ones we want with - you guessed it - an annotation:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Service {

}

Let's annotate our services with it:

@Service
public class ServiceAImpl implements ServiceA { ... }

@Service
public class ServiceBImpl implements ServiceB { ... }

... and filter our classes accordingly:

    public static DIContext createContextForPackage(String rootPackageName) throws Exception {
        Set<Class<?>> allClassesInPackage = ClassPathScanner.getAllClassesInPackage(rootPackageName);
        Set<Class<?>> serviceClasses = new HashSet<>();
        for(Class<?> aClass : allClassesInPackage){
            if(aClass.isAnnotationPresent(Service.class)){
                serviceClasses.add(aClass);
            }
        }
        return new DIContext(serviceClasses);
    }

We are done... are we?

And there you have it - a minimalistic, poorly optimized, yet fully functional DI container. But hold on, why do you need several megabytes worth of library code if the core is so simple? Well...

  • Our classpath scanner is very wonky. It certainly doesn't cover all cases, nesting depths, inner classes etc. Libraries like Guava's ClassPath do a much better job at this.

  • A big advantage of DI is that we can hook into the lifecycle of a service. For example, we might want to do something once the service has been created (@PostConstruct). We might want to inject dependencies via setters, not fields. We might want to use constructor injection, as we did in the beginning. We might want to wrap our services in proxies to have code executed before and after each method (e.g. @Transactional). All of those "bells and whistles" are provided e.g. by Spring.

  • Our wiring algorithm doesn't respect base classes (and their fields) at all.

  • Our getServiceInstance(...) method is very poorly optimized, as it linearly scans for the matching instance every time.

  • You will certainly want to have different contexts for testing and production. If you are interested in that, have a look at Spring Profiles.

  • We only have one way of defining services; some might require additional configuration. See Springs @Configuration and @Bean annotations for details on that.

  • Many other small bits and pieces.

Summary

We have created a very simple DI container which:

  • encapsulates the creation of a service network
  • creates the services and wires them together
  • is capable of scanning the classpath for service classes
  • showcases the use of reflection and annotations

We also discussed the reasoning for our choices:

  • First, we replaced static references by objects and constructors.
  • Then, we introduced interfaces to further decouple the objects.
  • We discovered that cyclic dependencies are a problem, so we introduced setters.
  • We observed that calling all setters for building the service network manually is error-prone. We resorted to reflection to automate this process.
  • Finally, we added classpath scanning to auto-detect service classes.

If you came this far, thanks for reading along! I hope you enjoyed the read.

. . . . . . . . . . . . . . . . . . . . . .