Framework Internals: Dependency Injection
Introduction
If you've been a software engineer for a little while you must've come across excellent advice like: "Do Not Repeat Yourself", "Do Not Re-Invent the Wheel", and so on. Several opensource libraries and frameworks often have battle-tested code that solves your problems. For example, why build a new Java-based backend framework when Springboot already solves your problem?
For a while I blindly took this advice and just solved the business needs of my employers using the frameworks and tools available. But, being a mechanical engineer before pivoting to software engineering, the engineer in me wouldn't stop to worry about the "internals". How does it work behind the scenes I'd ask myself. Then it dawned on me that contrary to the popular opinion "to not reinvent the wheel", the fastest and most rewarding way to actually understand what goes on behind the scenes is to...."REINVENT THE WHEEL"!
In this article, I try to reinvent a Dependency Injection container. I do not touch on the whole aspects a production grade dependency injection container would have, but I explore the basics such that the inner workings become clear.
Dependency Injection in Action
Consider the code snippet below from a Springboot application, the UserController class depends on the UserService. I often find myself wondering how does Springboot know to inject an instance of the UserService class into the userService field of the UserController class? I can understand creating a new instance with the new keyword, but what's this sorcery?
@Controller
public class UserController {
@Autowired
private final UserService userService;
// ... other class code
}
Enter Annotations
Springboot uses a lot of annotations: @Entity, @Controller, @Repository, @Service, etc. These annotations provide springboot with additional information. For example, the @Controller annotation makes it known that a class is a controller. These annotations do not change the way the program is compiled. Instead, they provide some additional information for a tool that processes your code. In other words, a. you have a set of annotations you have defined, b. you have a tool that can make sense of your annotations and do something useful with them.
In the code snippet below I show an annotation I have defined named Autowired. I also define a Service and a Controller annotation, though not shown here, they can be found in the code repo.
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.CONSTRUCTOR})
@Retention(RetentionPolicy.RUNTIME)
public @interface Autowired {
}
Processing Annotations
So we have defined some annotations that look like Springboot annotations. How do we processess them? What we intend to do is this, on application start up, find these annotations and do something with them. In our case, this "something" is to create instances of our dependencies and then load them into a registry and into class fields. Earlier, I alluded to needing a "tool" to process the annotations. There are several libraries that can process annotations, I use the asm library for this project. The dependency is added to the pom.xml file.
The code snippet below defines a classScanner class that extends the ClassVisitor class from the asm library. I override the visitAnnotation and visitField methods, these methods scan our classes and the fields in our classes for annotations. I define the classAnnotations and fieldAnnotation fields to hold the annotations discovered. The classAnnotations field stores the annotations found on classes, while the fieldAnnotations stores fields found on class fields.
public class ClassScanner extends ClassVisitor {
public List classAnnotations = new ArrayList<>();
public List fieldAnnotations = new ArrayList<>();
public ClassScanner() {
super(Opcodes.ASM9);
}
@Override
public AnnotationVisitor visitAnnotation(String descriptor, boolean visible){
classAnnotations.add(descriptor);
return super.visitAnnotation(descriptor, visible);
}
@Override
public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value){
return new FieldVisitor(Opcodes.ASM9) {
@Override
public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
fieldAnnotations.add(descriptor);
return super.visitAnnotation(descriptor, visible);
}
};
}
public List getClassAnnotations() {
return classAnnotations;
}
public List getFieldAnnotations() {
return fieldAnnotations;
}
}
Scanning the code
Next I define an ApplicationContext class. The function of this class is to scan all our files, create instances of our dependencies and store them. The class also runs our application server. The class has three fields. The classes field stores the list of classes that we find in the path we scan. The applicationAnnotations field, for each class found, holds lists of class and field annotations on it. The beanRegistry field which is a HashMap to store our dependencies, mapping the classes to their created instances.
public class ApplicationContext implements IApplicationContext{
private List classes;
private final Map>> applicationAnnotations = new HashMap<>();
private final Map beanRegistry = new HashMap<>();
public ApplicationContext(String basePath) {
this.loadClasses(basePath);
}
// other class code
}
The constructor of the class takes a string which is a path to where the classes can be found. In a production environment this would be within your packaged jar file. But for development purposes I point to the classes directory within the target directory. Remember that you must run the package command to have the target directory generated.
The constructor calls the loadClasses method which inturn calls the Helper class' getClasses method. This helper class defines the base path where we read our compiled classes. It's private method _getClasses recursively reads through the base directory populating the list passed to it (our classes field), with all the classes found. We exclude the annotation folder since it contains the annotation that we defined and not application code. See the Helper Class below.
public class Helper {
private static final String ANNOTATION_DIR_EXCLUDE = "annotation";
private static final String CLASS_EXT = ".class";
static String BASE_PATH = "C:\\code-library\\dependency-injection-container-java\\target\\classes";
public static List getClasses(String baseDir){
List classes = new ArrayList<>();
_getClasses(baseDir, classes);
return classes;
}
public static String getClassName(String absoluteFileName){
return absoluteFileName
.replace(BASE_PATH, "")
.replace("\\", ".")
.replace(CLASS_EXT, "")
.replaceFirst(".", "");
}
private static void _getClasses(String baseDir, List classes){
try {
File dir = new File(baseDir); ;
for (File file: Objects.requireNonNull(dir.listFiles())){
if(file.isDirectory() && !file.getName().equals(ANNOTATION_DIR_EXCLUDE)){
_getClasses(file.getAbsolutePath(), classes);
}else{
if(file.getName().endsWith(CLASS_EXT)){
classes.add(file.getAbsolutePath());
}
}
}
}catch(NullPointerException exception){
System.out.println("looks like a directory passed is not valid");
}
}
}
With the list of all of our classes we can now use the ClassScanner we defined earlier to identify the classes and fields that have annotations of interest to us. The snippet below shows the loadPackages method that handles this.
public void loadPackages() {
try {
for (String clazz: classes){
ClassScanner classScanner = new ClassScanner();
FileInputStream fileInputStream = new FileInputStream(clazz);
ClassReader classReader = new ClassReader(fileInputStream);
classReader.accept(classScanner, 0);
if (!classScanner.getClassAnnotations().isEmpty()){
applicationAnnotations
.computeIfAbsent(clazz, k -> new HashMap<>())
.put(
ApplicationAnnotationTypes.CLASS_ANNOTATIONS.toString(),
classScanner.getClassAnnotations());
}
if (!classScanner.getFieldAnnotations().isEmpty()){
applicationAnnotations
.computeIfAbsent(clazz, k -> new HashMap<>())
.put(
ApplicationAnnotationTypes.FIELD_ANNOTATIONS.toString(),
classScanner.getFieldAnnotations());
}
}
}catch(Exception exception){
System.out.println(exception.toString());
System.exit(1);
}
}
So far we have, defined our annotations, packaged our code, scanned our compiled classes and have saved them to a list called classes in the ApplicationContext class. We have also saved the classes with field and(or) class annotations to the applicationAnnotations hashmap.
Now for each class we know the class and field annotations they have. We can now check these annotations for each class and find the ones of interest to us. E.g when we come across a class with an @Controller annotation, we know we have to create an instance of this class; when we come across a field with the @Autowired annotation we know we have to inject a given class instance into this field. So how do we create instances of these classes of interest? This is where the Reflections API come in!!!
Enter the Reflections API
Reflection, a feature of the Java Programming language, allows a running program to inspect itself and manipulate inner properties of the program. You can find a quick example here if you're not familiar with Java Reflection.
So how does Java Reflection help us here? Once we get the classes with annotations we are interested in, we can use the reflection API to create instances of thse classes and save them to the beanRegistry field. The createBeans method below handles this.
public void createBeans() {
for (Map.Entry>> content : applicationAnnotations.entrySet()) {
var classAnnotations = content
.getValue()
.get(ApplicationAnnotationTypes.CLASS_ANNOTATIONS.toString());
if(classAnnotations != null){
classAnnotations.stream()
.filter(annotation -> annotation.contains("Controller") || annotation.contains("Service"))
.forEach(_n -> {
String className = Helper.getClassName(content.getKey());
Class> clazz;
try {
clazz = Class.forName(className);
Object instance = clazz.getDeclaredConstructor().newInstance();
beanRegistry.put(className, instance);
} catch (ClassNotFoundException | InvocationTargetException | InstantiationException |
IllegalAccessException | NoSuchMethodException e) {
throw new RuntimeException(e);
}
});
}
}
}
So we get the class annotations, we filter for classes with the @Service or @Controller annotation. We get the class name for each of these classes. The getClassName helper function helps with this. (Note that the classname includes the packages where the class is located so that we can accurately instantiate it.). With the class name within the try block we create an instance of the class and store it in the beanRegistry.
Since we have the list of all the classes in our application, we can loop through this list checking each field on the class to see if it has the @Autowired annotation. If it does, we find out the instance type of this field, grab the instance from the registry, set the value for the field. The injectBeans method below handles this.
public void injectBeans() throws ClassNotFoundException, IllegalAccessException {
for (String className : classes) {
Class> clazz = Class.forName(Helper.getClassName(className));
for (Field field : clazz.getDeclaredFields()) {
if (field.isAnnotationPresent(Autowired.class)){
// this field is an instance of what class?
Class> fieldType = field.getType();
field.setAccessible(true);
Object classInstance = getBean(Helper.getClassName(className));
field.set(classInstance, getBean(fieldType.getName()));
System.out.println("injected " +
fieldType.getName() + " into " +
Helper.getClassName(className));
}
}
}
}
Testing the Application
There's no point loading dependencies into our classes only to have the application exit immediately. To test our dependency injection we need a way to keep the application running. In a springboot application your Tomcat or Jetty server keeps the application running. For this application, I wrote a tiny inefficient server and a tiny client to send some bytes to the server.
The ApplicationServer class has a runServer method that creates a server socket, runs a loop forever, accepts connections from the client and calls the private handleMessage method. The ApplicationServer class also has a UserController instance to be autowired. It is this controller that we shall use to in the handle message method.
public class ApplicationServer {
@Autowired
private static UserController userController;
public ApplicationServer() {
}
private static void handleMessage(String message){
switch(message){
case "controller":
userController.greet();
break;
case "service":
userController.getUserGreeting();
break;
default:
System.out.println("Invalid request");
}
}
public static void runServer(int port) throws IOException {
try(ServerSocket serverSocket = new ServerSocket(port)){
System.out.println("Application listening on port " + port);
while(true){
Socket clientSocket = serverSocket.accept();
InputStream inputStream = clientSocket.getInputStream();
byte[] dataReceived = inputStream.readAllBytes();
handleMessage(new String(dataReceived).toLowerCase());
clientSocket.close();
}
}catch(IOException e){
throw new RuntimeException("Couldn't start application Server: " + e.getMessage());
}
}
}
In the handleMessage method, if the message passed in is "controller", the UserController's greet method is called. If the message "service" is passed, the getUserGreeting method is called. The UserController also depends on the UserService , so in the getUserGreeting method we call the UserService's greet method. See the UserController and UserService code below.
@Controller
public class UserController {
@Autowired
private UserService userService;
public UserController() {
}
public void greet(){
System.out.println("Hello from the user controller");
}
public void getUserGreeting(){
System.out.println(userService.greet());
}
@Override
public String toString() {
return "UserController";
}
}
@Service
public class UserService {
public UserService(){
}
@Override
public String toString() {
return "UserService";
}
public String greet() {
return "Greetings from the UserService";
}
}
Finally we can run the application, and then in a seperate terminal run the client code. See the client code below, it simply sends a message to the server. You can switch the message from "service" to "controller" to see the dependency injection at work! And that's it, I ramble no more.
public class Client {
public static void main(String[] args) {
try {
Socket server = new Socket("127.0.0.1", 8080);
System.out.println("connected to server at "
+ server.getInetAddress().getHostName());
OutputStream outputStream = server.getOutputStream();
outputStream.write("controller".getBytes());
server.close();
}catch (Exception e){
System.out.println(e.getMessage());
}
}
}
References
- Java the Complete Reference. 11th Edition. Herbert Schildt
- Core Java Vol II. Cay S. Horstmann
- Using Java Reflection Glen McCluskey