How does Spring work II?

11 Sep

Last time we experimented with a simple, self-cooked DI solution which work’s a bit like spring. Let’s continue this idea and talk about how to implement DI by using Annotations instead of configuration files. Again, I prepared a small Project on github. I will reference the important files one by one. So let’s start.

Step 1: The Annotations

First, we define two annotations. One (@Bean) marks a variable as a property which will receive a bean, and the other (@Resource) marks a class as a Resource which we will inject. Here are the two annotations:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Bean {
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Resource {
}

Nothing unexpected here. If you never worked with annotations before: RetentionPolicy.RUNTIME tells the java compiler to not remove the annotation on compile time (we could do this if we want to generate some documentation, but we need it later!). The ElementType simple tells java where the annotation can be used.

Step 2: The Test Setup

As the next step, we define a simple integration test (this is really not enough to cover the whole project, but we want to keep things simple here):

public class Test1 {
    @Test
    public void beansAreFound() {
        final DIContext context = new DIContext(
                "de.jowisoftware.myDI.integrationtests.test1", "org.hamcrest");
        context.getBean(Class1.class).greet();
        context.getBean(Class2.class).getBean().greet();
    }
}

 And these are our beans:

@Resource
public class Class1 {
    public void greet() {
        System.err.println("YEHS!");
    }
}
     
@Resource
public class Class2 {
    @Bean
    private Class4 subBean;
    public Class4 getBean() {
        return subBean;
    }
}
 
@Resource
public class Class4 {
    public void greet() {
        System.err.println("Greeting from class 4");
    }
}

Okay, this seems simple. We tell the DIContext class which packages (and subpackages) contain our beans.

Step 3: The package Scan

We now have a bunch of marked classes, but how do we find them? This step is often called a “package scan” and there is a big drawback: java cannot help us here. There is no way to enumerate classes in a package. As classes can be generated on the fly or downloaded from http servers using some custom classloaders (which don’t know which other classes exist on the remote side) this seems somehow logical.

What can we do? If we take a look on how it is solved in other frameworks, we’ll be surprised: they do it by searching class files. This forces us to give different solutions for class files in the file system, in jar files, in war files, etc. But we are not the only one with this problem. Although there are some solutions, we build our own – as we want to learn something here – and promise to never use this one in production code wink

We start with a class that scans a directory for class files in specific packages that are annotated with our @Resource annotation.

public class FileScanner {
    private final File rootFile;
    private final String[] packages;
     
    public FileScanner(final File rootFile, final String[] packages) {
        this.rootFile = rootFile;
        this.packages = packages;
    }
     
    public void scan(final List<Class<?>> classes) {
        for (final String packageName : packages) {
            final File packageDir = new File(rootFile, packageName.replace('.',
                    File.separatorChar));
     
            if (packageDir.exists()) {
                recursiveScanDir(packageDir, packageName, classes);
            }
        }
    }
     
    private void recursiveScanDir(final File packageDir,
            final String packageName, final List<Class<?>> classes) {
        for (final File file : packageDir.listFiles()) {
            final String filename = file.getName();
     
            if (file.isDirectory()) {
                recursiveScanDir(file, packageName + "." + filename, classes);
            } else {
                if (file.isFile() && filename.endsWith(".class")) {
                    final String fqcn = packageName + "."
                            + filename.substring(0, filename.length() - 6);
                    analyzeClass(classes, fqcn);
                }
            }
        }
    }
 
    private void analyzeClass(final List<Class<?>> classes, final String fqcn) {
        try {
            final Class<?> clazz = Class.forName(fqcn);
  
            if (clazz.getAnnotation(Resource.class) != null) {
                classes.add(clazz);
            }
        } catch (final ClassNotFoundException e) {
            throw new RuntimeException(e);
        }
    }
}

 The class is initialized with a File (which represents a directory in the java class path) and a bunch of packages. The scan method collects all classes and adds them into a provided list. Here is what it does:

First it creates a path for every package. If such a directory exists in the filesystem, it recursively scans the directory for .class-Files. Since there is a direct relation between namespaces and directories (the class “a.b.MyClass” will resist in “a/b/MyClass.class”) we can transfer the filename into a full qualified class name (fqcn).

So far, we have a list of class-files in a list of packages. The last thing we have to do is to check whether it has the required annotation. This is done in analyzeClass (sorry, the name is not chosen well): it loads the class via reflection. We only need the fqcn – the existing classloaders will do the rest. Then we check the Annotations of the class by using Class#getAnnotation. We receive the concrete annotation object, but we haven’t defined any members, so we just check if it exists. If the class has the annotation, we add it to the result list (see why ananlyzeClass is not telling the truth about the class and therefor is a bad name?).

The second implementation will look into jar files. This is even simpler as we don’t need to work recursively here:

public class JarScanner {
    private final File rootFile;
    private final String[] packages;
     
    public JarScanner(final File rootFile, final String[] packages) {
        this.rootFile = rootFile;
        final List<String> zipPathes = new LinkedList<>();
   
        for (final String packageName : packages) {
            zipPathes.add(packageName.replaceAll("\\.", "/") + "/");
        }
     
        this.packages = zipPathes.toArray(new String[zipPathes.size()]);
    }
     
    public void scan(final List<Class<?>> classes) {
        try (ZipFile jar = new ZipFile(rootFile)) {
            final Enumeration<? extends ZipEntry> zipEntries = jar.entries();
     
            for (ZipEntry entry = zipEntries.nextElement(); zipEntries
                    .hasMoreElements(); entry = zipEntries.nextElement()) {
                for (final String prefix : packages) {
                    final String className = entry.getName();
     
                    if (className.startsWith(prefix)
                            && className.endsWith(".class")) {
                        addFile(className, classes);
                        break;
                    }
                }
            }
        } catch (final IOException e) {
            throw new RuntimeException(e);
        }
    }
     
    private void addFile(final String name, final List<Class<?>> classes) {
        final String className = name.substring(0, name.length() - 6)
                .replaceAll("/", ".");
        final Class<?> clazz;
     
        try {
            clazz = Class.forName(className);
        } catch (final ClassNotFoundException e) {
            throw new RuntimeException(e);
        }
     
        if (clazz.getAnnotation(Resource.class) != null) {
            classes.add(clazz);
        }
    }
}

 The structure is similar. It is even similar enough to introduce a common parent class. Especially addFile and analyseClass could be merged. Go, do it! But for the moment, the scan method is the important one: it iterates over all entries in the jar file. There are no directories; the directory name is simply part of the filename. We simple check if an entry ends with “.class”. To get the fqdn, we replace the file separator, which is a slash in jar files, through a dot. The rest is identical to our FileScanner.

Step 4: The DIContext class

Now we need to wire things up. We are able to find classes which are annotated with @Resource. Next, we have to load them and store them in a list (see my last post for more details). If they contains fields with an @Bean annotation, they must also be initialized. Let’s start with the constructor:

public class DIContext {
    private final List<Object> beans;
     
    public DIContext(final String... packages) {
        final List<Class<?>> classes = new LinkedList<>();
        final String[] roots = System.getProperty("java.class.path", ".")
                .split(File.pathSeparator);
     
        for (final String root : roots) {
            scanRoot(root, packages, classes);
        }
     
        beans = initializeBeans(classes);
    }

 The “java.class.path” property contains all class path elements. Typically they are provided by the “-cp” argument to java. A class path could look like this: “/tmp/my/classes;/tmp/otherClasses.jar”. After they are split, we scan these “class path roots” for our classes. After this, we initialize the beans. Let’s start with the scan:

    private void scanRoot(final String root, final String[] packages,
            final List<Class<?>> classes) {
        final File rootFile = new File(root);
 
        if (rootFile.isDirectory()) {
            new FileScanner(rootFile, packages).scan(classes);
        } else if (rootFile.isFile() && rootFile.getName().endsWith(".jar")) {
            new JarScanner(rootFile, packages).scan(classes);
        } else {
            System.err.println("Warning: cannot scan " + rootFile);
        }
    }

We already implemented this! All we have to do is check the type of file in the class path. Is it a directory? Use a FileScanner! Is it a jar file? Use our JarScanner. We could implement more things here (like .war-files, e.g.).

The more important part is the initializeBeans:

    private List<Object> initializeBeans(final List<Class<?>> classes) {
        final List<Object> result = firstPassInit(classes);
        secondPassInit(result);
        return result;
    }
     
    private List<Object> firstPassInit(final List<Class<?>> classes) {
        final List<Object> result = new LinkedList<>();
        for (final Class<?> clazz : classes) {
            try {
                result.add(clazz.newInstance());
            } catch (InstantiationException | IllegalAccessException e) {
                throw new RuntimeException(e);
            }
        }
        return result;
    }

As we already found out in the first experiment, we can create beans by using Class#newInstance. We put them into a list here; they are just plain java objects and we did not initialize them yet. Initialization happens in secondPassInit:

    private void secondPassInit(final List<Object> preInitBeans) {
        for (final Object bean : preInitBeans) {
            for (final Field field : bean.getClass().getDeclaredFields()) {
                if (field.getAnnotation(Bean.class) != null) {
                    insertBean(field, bean, preInitBeans);
                }
            }
        }
    }
 
    private void insertBean(final Field field, final Object bean,
            final List<Object> preInitBeans) {
        final Class<?> targetType = field.getType();
        final Object targetObject = getBean(targetType, preInitBeans);
        field.setAccessible(true);
 
        try {
            field.set(bean, targetObject);
        } catch (IllegalArgumentException | IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }

We talk about getBean in a minute, but let’s just assume that it finds the right bean for us. We take the list of uninitialized beans and iterate over them. Again, we’re using reflections to find all fields inside the object. For every field we check if a @Bean annotation is present.

If this is the case, we call insertBean. We provide the field, the bean and the list of available beans. Note that we have to give both: the field and the bean. We have to do this, because the field is not bound to a concrete object but to the class. If we want to set a field, we have to provide the object too. This is why we hand both to insertBean. We extract the field’s type and search for a bean which supports this type. If the field is private, we have to make it accessible. After that, we can inject the bean into the field.

The final step is to find the bean. We also called getBean in our integration test and we reused it here. Its implementation is simple:

    public <T> T getBean(final Class<? extends T> clazz) {
        return getBean(clazz, beans);
    }
     
    private <T> T getBean(final Class<? extends T> clazz,
            final List<Object> beanList) {
        for (final Object bean : beanList) {
            if (clazz.isInstance(bean)) {
                @SuppressWarnings("unchecked")
                final T result = (T) bean;
                return result;
            }
        }
     
        return null;
    }
}

 In the first experiment we used a Map. This method shows the reason why we use a list: it is possible that we need a superclass of the bean. We solve this by using the Class#isInstance method. It allows us to check if a bean can be casted to the required type. If it matches, we return it.

That’s it, folks. To wrap things up:

  1. We defined two annotations and a test which uses the annotations
  2. To scan packages for java classes we traverse the file system and jar files and pick up the class files
  3. We use reflections to search for @Resource annotations
  4. We create an instance per annotated class
  5. After this, we initialize one instance per bean
  6. We again use reflections to search for fields which are annotated with @Bean; we inject beans which have a matching type.

This code is really just a simple proof of concept example. E.g. we do no error checking here. We also don’t call custom init method like the last time. But the idea would be the same so you can build your own mechanism for that. Just keep playing and learning!

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert