Dependency Injection selbst gebaut


Damit der Titel dieses Eintrags nicht für Missverständnisse sorgt, gleich vorab die Information, dass ich nicht dafür eintrete, dass nun jeder sein eigenes DI-Framework entwickeln sollte;) CDI und Konsorten leisten da schon sehr gute Dienste.

Für ein privates Projekt hatte ich die Anforderung eine kleine Desktop-Anwendung zu schreiben, die neben ihrer eigentlichen Aufgabe, Daten mit einer WebApp synchronisieren muss. Dafür sollten Service-Klassen erstellt werden, die sowohl in dem Tool als auch in der WebApp genutzt werden können. Und diese Service-Klassen sollen wie gewohnt in andere Klassen injiziert werden.

Folgendes Bild soll den Zusammenhang verdeutlichen:

Auf der einen Seite haben wir eine JavaFX Desktop Anwendung und auf der anderen Seite eine JavaEE WebApp. Beide nutzen eine gemeinsame Menge von Klassen - im Schaubild als Common bezeichnet - die in einem JAR verpackt sind und in andere Komponenten injizierbar sein sollen.

In der Java EE WebApp war CDI die erste Wahl. Der erste Ansatz war, CDI 2.0 (aktuell noch nicht in einer finalen Version vorliegend) in der JavaFX Anwendung zu nutzen. Funktionierte soweit auch problemlos, aber die CDI Implementierung kam mit 1,6Mb daher und das Tool sollte möglichst schlank daherkommen, um es auch bei geringer Datenrate übers Netz downloaden zu können und mit nur wenigen Abhängigkeiten zu versehen. Ein Blick auf andere DI-Frameworks zeigte, dass diese in ihrer Größe ebenfalls zu groß waren und zudem sollten die JavaEE Annotations wie @Inject, @Named usw. genutzt werden, um die gemeinsamen Services in der WebApp sofort mit CDI nutzen zu können.

Da für das Tool nur ein simpler Injektion-Mechanismus benötigt wurde ohne Berücksichtigungen von Lebenszyklen und dergleichen, kam der Gedanke, es einfach selbst zu tun. Das Ergebnis ist eine 11KB große Klasse.

Die Funktionsweise des DI-Mechanismus ist wie folgt:
  1. Applikation startet
  2. Auffinden aller Klassen, die mit @Singleton, @Named oder @ManagedBean annotiert sind.
  3. Jede gefundene Klasse wird instanziiert und in einer Map vorgehalten.
  4. Danach wird jede dieser Klassen auf @Inject-Annotations überprüft. Wird eine entsprechende Annotation gefunden, wird die entsprechende Instanz aus der Map dem annotierten Attribut zugewiesen. Damit ist die Injizierung abgeschlossen.
  5. Zu guter Letzt werden nochmal alle Instanzen durchlaufen und nach Methoden gesucht, die mit @PostConstruct annotiert sind. Also Methoden, die nach der Instanziierung der Klasse und nach der Injizierung der Beans ausgeführt werden sollen. Wird eine solche Methode gefunden, wird diese ausgeführt.
Das Ganze kann recht leicht mit Reflections gelöst werden und wird durch den nachfolgenden Code dokumentiert. Der nachfolgende Code ist nicht ganz die Endfassung. Da ich ihn so gewählt habe, um auf ein paar Fallstricke aufmerksam zu machen.

private static List> getClasses(String packageName) throws ClassNotFoundException, IOException {
	List> classes = new ArrayList>();

	String path = packageName.replace('.', '/');
	// MainApp.class ist die Klasse, in der sich die main-Methode der JavaFX App befindet
	final File jarFile = new File(MainApp.class.getProtectionDomain().getCodeSource().getLocation().getPath());

	if (jarFile.isFile()) { // Methode wurde aus jar heraus aufgerufen 
		final JarFile jar = new JarFile(jarFile);
		final Enumeration entries = jar.entries(); 
		while (entries.hasMoreElements()) {
			JarEntry jarEntry = entries.nextElement();
			if (!jarEntry.isDirectory() && jarEntry.getName().endsWith(".class")) {
				if (jarEntry.getName().startsWith(path + "/")) {
					String className = jarEntry.getName().replace('/', '.');
					className = className.substring(0, className.length() - ".class".length());
					classes.add(Class.forName(className));
				}
			}
		}
		jar.close();
	} else { // Methode wurde aus IDE heraus aufgerufen
		classes = getClassesIDE(packageName);
	}
	return classes;
}


private static List> getClassesIDE(String packageName) throws ClassNotFoundException, IOException {
	ClassLoader classLoader = ClassLoader.getSystemClassLoader();
	assert classLoader != null;
	String path = packageName.replace('.', '/');
	Enumeration resources = classLoader.getResources(path);
	ArrayList dirs = new ArrayList<>();
	while (resources.hasMoreElements()) {
		URL resource = (URL) resources.nextElement();
		dirs.add(new File(resource.getFile()));
	}
	List> classes = new ArrayList>();
	for (File directory : dirs) {
		classes.addAll(findClasses(directory, packageName));
	}
	return classes;
}

private static List> findClasses(File directory, String packageName) throws ClassNotFoundException {
	List> classes = new ArrayList>();
	if (!directory.exists()) {
		return classes;
	}
	File[] files = directory.listFiles();
	for (File file : files) {
		if (file.isDirectory()) {
			assert !file.getName().contains(".");
			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;
}	
Die Methode läßt sich mit List> classes = getClasses("de.mein.package"); aufrufen und liefert alle Klassen des Packages und aller Subpackages. Der Code ist an dieser Stelle noch nicht optimal, aber soll zeigen, dass es ein Unterschied macht, ob man die Anwendung aus der IDE heraus startet oder die JAR ausgeführt wird. Bei der Ausführung der JAR führen ClassLoader.getSystemClassLoader(); und Thread.currentThread().getContextClassLoader() zur Bestimmung des Ortes der Klassen nicht zum gewünschten Ergebnis. Stattdessen wird an dieser Stelle die Hauptklasse mit der main-Methode als Ausgangspunkt genommen (MainApp.class.getProtectionDomain().getCodeSource().getLocation().getPath()).
Der gesamte Code läßt sich natürlich noch weiter eindampfen und an dieser Stelle macht es auch Sinn, sich direkt nur die entsprechend annotierten Klassen zurückliefern zu lassen.

Im nächsten Schritt werden die Klassen durchlaufen und die entsprechend annotierten (@ManagedBean, @Named, @Singleton) herausgesucht. Diese werden erzeugt und in einer Map abgelegt.

import javax.annotation.ManagedBean;
import javax.inject.Named;
import javax.inject.Singleton;

private static Map, Object> managedBeans = new HashMap<>();

for (Class clazz : classes) {
	if ((clazz.isAnnotationPresent(ManagedBean.class) || clazz.isAnnotationPresent(Named.class) || clazz.isAnnotationPresent(Singleton.class)) && !managedBeans.containsKey(clazz)) {
		Object instance = clazz.newInstance();
		managedBeans.put(clazz, instance);
	}
} 
Damit haben wir alle gesuchten Klassen. Was jetzt noch fehlt ist der Injizierungsmechanismus und der Aufruf der mit @PostConstruct annotierten Methoden. Dafür wird im folgenden Code die Methode doInjections() aufgerufen. Hier werden alle Beans der Map durchlaufen und für jede wird nachgeschaut, ob es mit @Inject annotierte Methoden gibt. Hier hilft die Methode injecting, der die zu untersuchende Klasse, die Annotation nach der gesucht wird und das zu injizierende Objekt übergeben wird. Per Reflections werden dann die entsprechenden Attribute befüllt.

import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

private void doInjections() throws IllegalArgumentException, IllegalAccessException, ApplicationContextException {
	Iterator it = managedBeans.entrySet().iterator();
	while (it.hasNext()) {
		Map.Entry pair = (Map.Entry) it.next();
		injecting(pair.getValue().getClass(), Inject.class, pair.getValue());
	}
}
	
private void injecting(Class clazz, Class annotation, Object obj) throws IllegalArgumentException, IllegalAccessException, ApplicationContextException {
	while (clazz != null) {
		for (Field field : clazz.getDeclaredFields()) {
			if (field.isAnnotationPresent(annotation)) {
				field.setAccessible(true);
				Object value = field.get(obj);
				if (value == null) {
					field.set(obj, getBean(field.getType()));
				}
			}
		}
		clazz = clazz.getSuperclass();
	}
} 
Zu guter letzt sollen noch die mit @PostConstruct annotierten Methoden ausgeführt werden. Die Vorgehensweise ist dabei die gleiche wie bei der Injizierung der Objekte. Alle Klassen der Map werden durchlaufen und untersucht und eventuell vorhandene Methoden werden auf der Instanz der Klasse ausgeführt.

private void doPostConstructMethods() throws IllegalArgumentException, IllegalAccessException, ApplicationContextException, InvocationTargetException {
	Iterator it = managedBeans.entrySet().iterator();
	while (it.hasNext()) {
		Map.Entry pair = (Map.Entry) it.next();
		runPostConstructMethod(pair.getValue().getClass(), PostConstruct.class, pair.getValue());
	}
}

private void runPostConstructMethod(Class clazz, Class annotation, Object obj) throws IllegalArgumentException, IllegalAccessException, ApplicationContextException, InvocationTargetException {
	while (clazz != null) {
		for (Method method : clazz.getDeclaredMethods()) {
			if (method.isAnnotationPresent(annotation)) {
				method.setAccessible(true);
				method.invoke(obj);
			}
		}
		clazz = clazz.getSuperclass();
	}
}  
Für die wenigen Injizierungen in der JavaFX-Anwendung reicht das schon völlig aus und die gemeinsam genutzten Service-Klassen haben direkt die richtigen Annotations, um diese innerhalb der JavaEE-Anwendung im CDI-Container verwalten zu lassen.

Wenn aber nicht Anforderungen wie hier vorliegen, also die JavaFX-App muss nicht möglichst klein sein und wenig Abhängigkeiten zu dritten Bibliotheken haben, bietet sich natürlich eher CDI 2.0 an.
Tags: dependency injection, di, cdi
Guido Oelmann     Samstag, 11. Juni 2016 0    978
@
(Email Adresse wird nicht veröffentlicht)
(kein HTML erlaubt)
Bitte beantworten Sie die einfache mathematische Frage.
  


Keine Kommentare vorhanden.