This article is also available in English
Dieses Jahr feiern im Spring-Universum gleich zwei Projekte einen runden Geburtstag. Das Spring Framework wird 20 und Spring Boot 10 Jahre alt. Vor allem das Framework hat in dieser Zeit, neben zahlreichen Firmen als Arbeitgeber für das Kernteam, viel gesehen, mitgemacht und auch selbst beeinflusst.
Beispielsweise sind die sehr langen – und für Menschen, welche die Konzepte kennen, auch präzisen – Klassennamen, wie AbstractAnnotationConfigDispatcherServletInitializer, so bekannt, dass vermutlich jeder darüber schon mal einen Witz gehört hat. Es gibt gar ganze Webseiten, wie https://projects.haykranen.nl/java/, die sich diesem Thema annehmen.
Das Zweite, woran wohl jede denkt, die mit Spring zu tun hat, sind Annotationen. Wobei sich diese heutzutage nicht auf Spring beschränken, sondern im Java-Universum quasi überall zu finden sind. Seitdem diese mit JDK 5.0 vor 19 Jahren eingeführt wurden, haben sie sich immer mehr zum Mittel der Wahl für viele Dinge entwickelt.
Wenn es nun aber Annotationen seit 19 und das Spring Framework seit 20 Jahren gibt und diese ein zentrales Mittel von Spring sind, wie funktionierte denn dann alles am Anfang, als es diese noch nicht gab? Genau das wollen wir uns im Folgenden anschauen.
Dependency Injection
Die, schon immer, zentrale Funktionalität, die uns das Spring Framework bietet, besteht darin, das Muster Dependency Injection zu implementieren. Hierzu wird der IoC-Container (Inversion of Control) eingesetzt. An diesem lassen sich Klassen registrieren, zu denen der Container uns dann Instanzen, die Spring Beans genannt werden, erzeugen kann. Bei der Erzeugung einer solchen Bean werden auch die Abhängigkeiten von dieser erzeugt und in der Instanz, heute in der Regel über den Konstruktor, gesetzt.
Vor dem Spring Framework gab es zwei Möglichkeiten für Dependency Injection. Zum einen gab, und gibt es immer noch, die Möglichkeit, diese händisch zu implementieren. Dazu, siehe Listing 1, erzeugen wir alle Instanzen an den passenden Stellen selbst und sorgen somit dafür, dass unser gesamter Objektgraph korrekt initialisiert ist.
Solange alle diese Instanzen Singletons sind, das heißt, es reicht aus, eine einzelne Instanz für die gesamte Anwendung zu haben, funktioniert das sehr gut. Hierzu erzeugen wir einmal am Anfang alle Instanzen in der korrekten Reihenfolge und sind fertig. Komplizierter wird es, wenn wir für einzelne Instanzen einen anderen Scope brauchen. So wird beispielsweise die aktuelle Transaktion oder der angemeldete Nutzer gerne an den Thread, der gerade die Anfrage abarbeitet, gehängt. Dementsprechend können wir eine solche „Abhängigkeit“ nicht einmal am Anfang instanziieren. Hierfür können wir allerdings eine „Hilfsklasse“ injizieren, auf der wir dann eine Methode aufrufen können, um an die benötigte Instanz zu gelangen, ohne die Details zu kennen.
Da es jedoch bereits in den Anfängen von Jakarta EE, damals noch J2EE, üblich war, dass die bereitgestellte Infrastruktur Instanzen unserer Klassen erzeugen musste und deswegen nur der öffentliche, argumentlose Konstruktor aufgerufen wurde, verbreitete sich das Muster Service Locator (s. Listing 2). Hierbei gibt es eine Klasse, über die man jederzeit an alle relevanten anderen Objekte kommen kann. Da hierzu ein globaler Zugriff notwendig ist, gibt es entweder eine statische Instanz dieser Klasse oder die Methoden, um andere Objekte zu erhalten, sind statisch.
Mit der Verbreitung von Enterprise Java Beans beziehungsweise Jakarta Enterprise Beans wurde der Einsatz eines Containers, der zur Laufzeit die Instanzen unserer Klassen verwaltet, üblich. Damit dieser unsere Klassen kennt, wurde vor allem auf die Deklaration über XML (s. Listing 3) gesetzt. Beim Start liest der EJB-Container diese aus und kann Instanzen unserer Klassen erzeugen. Um an eine solche Instanz zu gelangen, wurde meist auf das Java Naming and Directory Interface gesetzt und dieser Code wurde in der Regel auch in einem Service Locator, siehe Listing 4, gekapselt.
Da die Deklaration in XML sehr repetitiv und fehleranfällig ist, wurde schnell nach einer Vereinfachung gesucht. Die erste Lösung war die Nutzung von speziellen Doclets. Doclets erlauben es, JavaDoc-Kommentare zu parsen und zu verarbeiten. Das „Standard“-Doclet generiert daraus die uns allen bekannte Java-Doc-API-Dokumentation. Mit XDoclet entstand dabei ein Tool, das es ermöglicht, die XML-Deklaration, siehe Listing 5, zu erzeugen. Hierzu nutzen wir spezifische JavaDoc-Tags, wie @ejb.bean
, über die sich die Erzeugung der XML-Datei parametrisieren lassen. Außerdem war es so auch möglich, die zur EJB gehörenden Local- und Homeinterfaces generieren zu lassen. Und, als Kind seiner Zeit, startete auch das Spring Framework mit einer Deklaration seiner Spring Beans in XML (s. Listing 6).
Im Gegensatz zu den damals verbreiteteren EJB-Containern gab es hier jedoch direkt die Möglichkeit, sich andere Objekte injizieren zu lassen. Somit war die Verwendung des Musters Service Locator nicht mehr notwendig. Allerdings galt auch hier, dass das Schreiben der XML-Dateien repetitiv und fehleranfällig ist. Und somit gab es auch schnell XDoclet-Support für das Spring Framework.
Erst die in JDK 5.0 eingeführten Annotationen verschoben dann langsam, aber sicher die Deklaration von separaten XML-Dateien direkt in unseren Code (s. Listing 7).
Routing von HTTP-Anfragen
Webanwendungen nutzen als Protokoll HTTP und bieten somit unter verschiedenen Pfaden, je nach genutzter HTTP-Methode und manchmal auch basierenden auf verschiedenen HTTP-Headern, unterschiedliche Funktionalitäten an. So kann ein GET /pets
beispielsweise eine Liste von Kuscheltieren zurückgeben, während ein POST /pets
ein neues anlegt. Somit müssen wir in unserer Anwendung, um diese zu beantworten, irgendwo ein Stück Code haben, welches basierend auf der konkreten Anfrage entscheidet, welcher Code ausgeführt werden muss. Dies wird im Allgemeinen als Routing bezeichnet.
Traditionell wird in Java die Servlet API genutzt, um serverseitig auf HTTP-Anfragen zu reagieren. Da diese Programmierschnittstelle ebenfalls aus einer annotationsfreien Zeit stammt, wurde auch hier auf XML, über eine Datei web.xml, zurückgegriffen (s. Listing 8). Diese Konfiguration besteht dabei aus zwei Teilen. Zuerst müssen wir, ähnlich wie im Spring-IoC-Container, unsere Servlets mit einem Namen registrieren. Im zweiten Schritt können wir dann ein oder mehrere URIs auf ein vorher registriertes Servlet mappen. Ähnlich wie bei Dependency Injection ging auch hier der Weg über XDoclets (s. Listing 9) zu Annotationen (s. Listing 10).
Und auch Spring MVC, der Webteil im Spring Framework, begann zu Anfang mit einer Servlet ähnlichen API (s. Listing 11) und einem explizit zu definierenden Mapping via XML (s. Listing 12). Erst mit Spring Framework 2.5 wurde dieses um die Annotationen ergänzt, die wir heute nutzen. Dabei ersparen uns diese Annotation nicht nur das explizite Eintragen von Mappings in einer XML-Datei, sondern mit dem Umstieg auf diese wurden auch die Möglichkeiten des Mappings erweitert. War es vorher nur möglich, URI-Muster auf eine einzelne Klasse zu mappen, können wir nun HTTP-Anfragen anhand weiterer Eigenschaften wie der HTTP-Methode oder anhand spezifischer HTTP-Header auf spezifische Methoden innerhalb unserer Klasse mappen (s. Listing 13).
Aber auch für Routing benötigt man nicht zwingend Annotationen. Spring selbst, wenn wir Spring WebFlux nutzen, besitzt hierfür mittlerweile eine Möglichkeit. Hierzu, siehe Listing 14, werden die Routen an einem Router über Builder-Methoden registriert. Jede Route erhält dabei als Ziel eine Methodenreferenz, welche einer bestimmten Signatur, nämlich einem Argument vom Typ ServerRequest
und Mono<ServerResponse>
als Rückgabetyp, entsprechen muss. Leider gibt es bisher hierfür kein Äquivalent für den auf Servlets basierenden, klassischen Weg. Aber wir sehen, wie auch für die beiden ersten Bereiche, es ist auch für das Routing von HTTP-Anfragen eine Welt ohne Annotationen möglich.
Mapping
Auch für das Mappen von Daten auf Instanzen einer Klasse verwenden wir heute vielfach Annotationen. Viele der Bibliotheken oder Frameworks nutzen hier zwar heute sinnvolle Defaults, welche die Anzahl der Annotationen deutlich reduziert, aber hier und da müssen wir dann doch eine Annotation verwenden.
Beispiele für ein solches Mapping sind Jakarta Persistence (JPA), Jackson und MapStruct. JPA, siehe Listing 15, benötigt beispielsweise mindestens die Information, dass die Klasse eine JPA-Entität sein soll und welchem Feld die ID dieser Entität entspricht. Auch hier ist es möglich, diese Informationen nicht per Annotation, sondern über XML (s. Listing 16) zu definieren.
Die Alternative, welche dann nicht mehr mit JPA kompatibel ist, wäre es, das Mapping zwischen unseren Tabellen und unseren Klassen selbst, beispielsweise mit Java Database Connectivity (JDBC), zu implementieren.
Neben dem Mappen von Klassen auf relationale Tabellen ist auch für die Serialisierung oder Deserialisierung von Klassen auf Datenformate, wie JSON oder XML, der Einsatz von Annotationen üblich. Bibliotheken wie Jackson kommen zwar, mittlerweile, im besten Fall komplett ohne aus, aber sobald wir speziellere Anforderungen haben, benötigen wir diese dann doch, um diese auszudrücken (s. Listing 17).
Auch hier besteht die Alternative darin, das Mapping von Hand zu schreiben und dabei eine der zahlreichen Bibliotheken zu nutzen, mit denen wir JSON parsen oder generieren können.