DEV Community

Martin Häusler
Martin Häusler

Posted on

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

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.

Top comments (8)

Collapse
 
chrislaforet profile image
Chris Laforet

Thanks for this very nice article on DI. It was great working stepwise through the refinement procedure which helped make the subject really come to life.

Collapse
 
chrislaforet profile image
Chris Laforet

FYI, I translated the exercise code (as parallel as possible) into C# in .net core and was amazed at how, once again, your stepwise approach made it so much easier to reason out. The code is on github.com/ChrisLaforet/Understand... if you are interested. Thanks again :)

Collapse
 
tee12345 profile image
Babatunde Raimi Lawal

@martin Hausler, thanks for this post. Please how can I make this program accept configuration files (XML or text files)? Thanks for your time.

Collapse
 
martinhaeusler profile image
Martin Häusler

Hi! Sorry for the late response, somehow your "@" mention didn't work and I didn't get notified about your answer.

The XML/Text files you refer to would replace the classpath scan. Instead of scanning all classes, you'll read a predefined list of service class names from a file, typically XML. Let's simplify it a bit and use plain text, for example:

(left is interface, right is implementation)

org.example.api.MyServiceA, org.example.impl.MyServiceAImpl
org.example.api.MyServiceB, org.example.impl.MyServiceBImpl
Enter fullscreen mode Exit fullscreen mode

This would tell you that the interface "MyServiceA" is implemented by a class "MyServiceAImpl". To find the classes, you'd simply have to use Class.forName(className). Use those interface/class pairs instead of the classpath scan.

Collapse
 
tee12345 profile image
Babatunde Raimi Lawal

Thanks a lot, I'm very grateful.

Collapse
 
lgrzesiak profile image
Łukasz Grzesiak

Very informative and flawlessly written!

Collapse
 
martinhaeusler profile image
Martin Häusler

Thanks, glad you enjoyed it!

Collapse
 
mathphysicscomputer profile image
math-physics-computer

Finally I can grasp the picture. This is the best explanation I've ever seen. Thank you so much :)