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.

var environment = Environment.from(args);
var datasource = dataSourceFor(environment);
var greetingRepository = new GreetingRepository(datasource);
var helloWorldController =
    new HelloWorldController(greetingRepository);
Listing 1: Händisches Erzeugen eines Objektgraphen

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.

private GreetingRepository repository;

public HelloWorldController() {
    this.repository =
        ServiceLocator.INSTANCE.getGreetingRepository();
}
Listing 2: Holen von Abhängigkeiten über ServiceLocator

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.

<?xml version="1.0" encoding="UTF-8"?>
<ejb-jar ...>
    <enterprise-beans>
        <session>
            <ejb-name>HelloSessionBean</ejb-name>
            <mapped-name>ejb/HelloSessionBean</mapped-name>
            <business-local>...HelloSessionBeanLocal</business-local>
            <business-remote>...HelloSessionBeanRemote</business-remote>
            <ejb-class>...HelloSessionBean</ejb-class>
            <session-type>Stateless</session-type>
            <transaction-type>Container</transaction-type>
        </session>
    </enterprise-beans>
</ejb-jar>
Listing 3: XML-Deklaration einer Stateless EJB
public class ServiceLocator {

    static final ServiceLocator INSTANCE =
        new ServiceLocator();

    private final InitialContext context;

    private ServiceLocator() {
        try {
            var properties = new Properties();
            // ...
            this.context = new InitialContext(properties);
        } catch (NamingException e) {
            throw new IllegalStateException("Unable to create", e);
        }
    }

    public GreetingRepository getGreetingRepository() {
        return (GreetingRepository) context.lookup("JNDI_NAME");
    }
}
Listing 4: Service Locator mit JNDI Lookup

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).

/**
 * @ejb.bean name="HelloSessionBean" type="Stateless"
 */
public class HelloSessionBean implements SessionBean {
    // ...
}
Listing 5: EJB mit XDoclet-Tag
<beans>
    <bean id="greetingRepository" class="...GreetingRepository">
        <property name="datasource" ref="datasource" />
    </bean>

    <bean id="helloWorldController" class= "...HelloWorldController">
        <property name="repository" ref="greetingRepository" />
    </bean>
</beans>
Listing 6: XML-Konfiguration vom Spring-IoC-Container

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).

@Repository
public class GreetingRepository {
    // explizite mit @Autowired annotiert
    public GreetingRepository(DataSource datasource) {
        // ...
    }
}
Listing 7: Spring-Annotationen für den IoC-Container

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).

<?xml version="1.0" encoding="UTF-8"?>
<web-app version="3.0"
        xmlns="http://java.sun.com/xml/ns/javaee" ...>
    <servlet>
        <servlet-name>HelloWorldServlet</servlet-name>
        <servlet-class>com.innoq.HelloWorldServlet</servlet-class>
    </servlet>
    <servlet-mapping>
        <servlet-name>HelloWorldServlet</servlet-name>
        <url-pattern>/greeting</url-pattern>
    </servlet-mapping>
</web-app>
Listing 8: Servlet-Mapping via XML
/**
 * @web.servlet name="HelloWorldServlet"
 * @web.servlet-mapping url-pattern="/greeting"
 */
public class HelloWorldServlet extends HttpServlet {
    // ...
}
Listing 9: XDoclet-Tags für Servlet-Mapping
@WebServlet(name = "HelloWorldServlet", urlPatterns = "/ greeting")
public class HelloWorldServlet extends HttpServlet {
    // ...
}
Listing 10: Annotation für Servlet-Mapping

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).

public class HelloWorldController implements Controller {
    @Override
    public ModelAndView handleRequest(
            HttpServletRequest request,
            HttpServletResponse response) throws Exception {
        // ...
    }
}
Listing 11: Alter Spring-Controller
<beans>
    <bean id="handlerMapping" class="...SimpleUrlHandlerMapping">
        <property name="mappings">
            <props>
                <prop key="/greeting*">helloWorldController</prop>
            </props>
        </property>
    </bean>

    <bean id="helloWorldController" class="com.innoq.HelloWorldController" />
</beans>
Listing 12: XML-Mapping für Spring-Controller
@Controller
public class HelloWorldController {
    @GetMapping("/greeting")
    public ModelAndView greet() {
        // ...
    }
}
Listing 13: Spring-Controller mit Annotationen

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.

var handler = new HelloWorldController();
var router = route()
    .GET("/greeting", handler::greet)
    .build();

public class HelloWorldController {
    public Mono<ServerResponse> greet(ServerRequest request) {
        // ...
    }
}
Listing 14: Funktionale Routen mit Spring WebFlux

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.

@Entity
public class Article {
    @Id
    private Long id;

    // ...
}
Listing 15: JPA-Mapping über Annotationen
<entity-mappings>
    <entity class="com.innoq.Article" name="Article">
        <attributes>
            <id name="id"></id>
        </attributes>
    </entity>
</entity-mappings>
Listing 16: JPA-Mapping via XML

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).

@JsonInclude(Include.NON_NULL)
public class Article {
    @JsonIgnore
    public long id;

    @JsonFormat(
        shape = JsonFormat.Shape.STRING,
        pattern = "dd-MM-yyyy hh:mm:ss")
    public Date createdAt;

    @JsonProperty("name")
    public String fullName;

    // ...
}
Listing 17: Jackson Customizations mit Annotationen

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.

Conclusion

Wir haben uns hier mit Dependency Injection, Routing von HTTP-Anfragen und Mapping drei Anwendungsfälle angeschaut, in denen wir heute in der Regel auf Annotationen setzen.

Dabei haben wir, neben ein wenig historischem Beiwerk, gesehen, dass diese vor Annotationen vor allem über einen deklarativen Ansatz über ein externes Format, wie XML, erledigt wurden. Diese Art wurde dann, weil sie repetitiv und fehleranfällig ist, näher an den Code geholt. Als Zwischenschritt wurde dabei anfangs XDoclet zur Generierung der Deklaration eingesetzt und schlussendlich auf Annotationen und deren Auswertung zur Laufzeit umgestellt.

Alternativ lassen sich diese Anwendungsfälle auch rein über Code lösen. Für Dependency Injection können wir selbst den Graphen unserer Objekte aufbauen. Das Routing von HTTP-Anfragen kann heute auch über ein Objekt und die Registrierung von Methodenreferenzen erledigt werden. Und auch das Mapping von Daten auf Instanzen, oder andersherum, ließe sich mit ein wenig Code selbst erledigen.

Weitere Anwendungsfälle, bei denen wir heute vielfach Annotationen nutzen, sind Querschnittsaspekte, wie die Steuerung von Transaktionen, zur Generierung von Code, beispielsweise mittels Lombok oder für Lebenszyklusmethoden, wie @PostConstruct oder @PreDestroy. Doch auch für diese gibt es die Möglichkeit, das Problem ohne Annotation zu lösen. Durch die explizite Nutzung von Templates, wie dem TransactionTemplate, Schreiben von eigenem Code oder indem wir passende Interfaces implementieren.

Welcher Weg jetzt der bessere ist, kann so einfach nicht beantwortet werden. Annotationen, die zur Laufzeit ausgewertet werden, ermöglichen es uns, mit wenig Code auf einer deklarativen Art und Weise auszudrücken, was benötigt wird. Das hat allerdings den Nachteil, dass wir das, was dann dort passiert, nicht debuggen können und deswegen ein gutes Verständnis haben sollten, um dort keine Fehler zu produzieren. Außerdem kann auch bei zu vielen Annotation das Verständnis leiden. Das Schreiben von eigenem Code hingegen ist sehr gut zu debuggen. Allerdings kann das auch fehleranfällig sein und in der Regel muss zum Verständnis mehr Code gelesen werden. Auch erhöht sich in diesen Fällen häufig die Entfernung von verschiedenen Codeteilen. Beispielsweise kann ich dann nicht mehr an einer Klasse direkt sehen, aus welchen JSON-Feldern diese befüllt wurde.

Wie immer gilt also, dass es keine Wunderwaffe als Allheilmittel gibt. Es hilft nur, im konkreten Kontext die Optionen zu evaluieren und dann die am besten passende Lösung auszuwählen. Deswegen halte ich es für wichtig, keine der Optionen aus dogmatischen Gründen generell und für immer auszuschließen. Aber ich gebe es zu, auch mir gelingt das natürlich nicht immer.