Probleme mit Lombok
Nahezu jeder Java-Entwickler kennt Project Lombok. Es ist eine der meistgenutzten Bibliotheken überhaupt und taucht immer wieder irgendwo auf. Die Vorteile sind dabei offensichtlich und werden auf der Projektseite sehr anschaulich dargestellt. Trotzdem ist nicht alles, was Lombok macht, gut und die Verwendung birgt einige Tücken. Ich möchte hier ein wenig auf die Probleme eingehen, die ich in der Verwendung von Lombok sehe. Dabei geht es mir nicht um die Vermeidung, sondern eher um das Bewusstsein der Schwächen und Fallstricke.
Was ist Lombok
Als Erstes möchte ich kurz darauf eingehen, was Lombok überhaupt ist, wie es funktioniert und warum es entstanden ist.
Lombok ist vor allem eine Library, die uns Entwicklern viel Schreibarbeit abnehmen kann. Man könnte zwar argumentieren, dass dies ein Ziel der meisten Bibliotheken ist, aber Lombok ist ein sehr spezieller Fall. Es hilft uns nämlich hauptsächlich dabei Boilerplate-Code zu reduzieren und uns auf die Anwendung zu konzentrieren.
Boilerplate-Code ist Code, der Notwendig ist, um überhaupt ein lauffähiges Programm zu entwickeln. Das heißt Code, der nicht unmittelbar zu unserem Anwendungsfall beiträgt, aber dennoch nötig ist. Ein Beispiel hierfür ist die Main-Methode, die in Java-Anwendungen immer nötig ist, in manchen anderen Sprachen aber nicht. |
Um zu verdeutlichen, wie Lombok uns hilft, folgt hier ein Beispiel mit Lombok-Annotation und eines ohne diese Vereinfachungen.
@Data
class Product {
private final String name;
private BigDecimal price;
}
class Product {
private final String name;
private BigDecimal price;
public Product(String name) {
this.name = name;
}
public String getName() {
return name;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
@Override
public String toString() {
return "Product(" + name + ", " + price + ")";
}
@Override
public boolean equals(Object obj) {
if (obj == this) return true;
if (!(obj instanceof Product)) return false;
Product other = (Product)obj;
return name.equals(other.name) && Object.equals(price, other.price);
}
@Override
public int hashCode() {
Object.hash(name, price);
}
}
Wie man sieht, ist der Aufwand um eine vergleichbare Klasse ohne Lombok zu erhalten riesig. Es wird uns also in der Tat viel Arbeit abgenommen und Lombok kann sein Versprechen anscheinend halten.
Die Krux liegt im Detail
Auf den ersten Blick sieht all das, was Lombok hier macht sehr gut aus, aber es kaschiert auch viel und wirft so Fragen auf.
Wie genau verhalten sich
equals()
undhashCode()
?Was passiert, wenn ich
Comparable
implementieren will?Gibt es Einschränkungen bei der Nutzung anderer Techniken?
Welche Alternativen gäbe es?
Neben diesen Fragen gibt es aber noch ein anderes Problem, nämlich die Einfachheit. Durch die extrem vereinfachte Schreibweise wird nämlich suggeriert, dass der Code einfach sei, obwohl er das gar nicht ist. Wie gesagt, Lombok kaschiert lediglich die Komplexität lässt sie aber nicht magisch verschwinden. Dabei bleiben alle Besonderheiten und Fallstricke von Java erhalten. Um dies zu verdeutlichen, zeige ich gerne das folgende Beispiel:
@Data
class Example {
private final UUID id;
private String value;
public static void main(String[] args) {
Example example = new Example(UUID.randomUUID());
example.setValue("foo");
Set<Example> exampleSet = new HashSet<>();
exampleSet.put(example);
assert exampleSet.size() == 1;
assert exampleSet.iterator().next().equals(example);
assert exampleSet.contains(example); (1)
example.setValue("bar");
assert exampleSet.size() == 1;
assert exampleSet.iterator().next().equals(example);
assert exampleSet.cotains(example); (2)
}
}
1 | Alle Prüfungen funktionieren. |
2 | In dieser Zeile schlägt die Prüfung auf einmal fehl! |
Der Grund für den Fehler findet sich in der Implementierung der Methode hashCode()
.
Diese hat Lombok für uns überschrieben und berechnet einen Hash aus id
und value
.
Nur wollen wir das hier ja gar nicht, denn wir haben bereits eine eindeutige ID, nämlich die id
.
Entsprechend sollte für den Hash auch nur dieses Feld herangezogen werden.
Damit Lombok sich hier korrekt verhält, müssen wir Lombok mitteilen, dass wir nur id
berücksichtigen wollen.
Dadurch verändern sich die Klasse wie folgt:
@Getter
@Setter
@ToString
@EqualsAndHashCode
@RequiredArgsConstructor
class Example {
private final UUID id;
@EqualsAndHashCode.Exclude
private String value;
}
Wie wir sehen ist nur durch diese kleine Änderung unser Bedarf an Annotationen explodiert. Das ist aber leider nur die offensichtliche Verschlechterung, viel schwerer wiegen die implizierten Auswirkungen. Denn das weitaus größere Problem ist das Wissen, welches man mitbringen muss, um diese Situation korrekt zu handhaben. Um diesen Umbau nachvollziehen zu können muss man:
Verstehen, wie equals und hashCode funktionieren.
Wissen, was die ganzen Annotationen machen.
Wissen, wie der generierte Code etwa aussehen wird.
Überhaupt all die Annotationen erstmal kennen.
In der Summe bewahrt Lombok uns hier also nicht davor uns doch mit den Interna beschäftigen zu müssen. Es macht lediglich den Code etwas übersichtlicher, aber nicht leichter zu verstehen.
Standardinterfaces im Angesicht von Lombok
Die Entscheidung für Lombok kann aber noch andere Auswirkungen haben.
So ist zum Beispiel die Methode compareTo()
aus dem Comparable
-Interface sehr eng verwand mit der Methode equals()
.
It is strongly recommended, but not strictly required that
(x.compareTo(y)==0) == (x.equals(y))
.
Version 15 API Specification
Wenn ich also Comparable
implementiere und @EqualsAndHashCode
annotiere, kann ich sehr einfach einen Konflikt erzeugen.
Einen Konflikt, den ich nicht einfach nachvollziehen kann, da der Code ja nicht zu sehen ist.
Für Standardinterfaces, aber natürlich auch alle anderen, gilt darum unbedingt die Dokumentation zu beachten, um doofe Fehler zu vermeiden.
Dieser Ratschlag gilt übrigens auch für solche Dinge wie JPA und andere weit verbreitete und wichtige Techniken. Hibernate schreibt zum Beispiel einen leeren Konstruktor vor, dieser kann allerdings mit Lombok auch recht einfach untergehen. Ich dagegen bin ein großer Freund statischer Fabriken oder des Builder-Pattern umd Entitäten zu bauen.
Glücklicherweise bietet uns Lombok für das Builder-Pattern auch eine handliche Hilfe an:
@Builder
@Entity
class Example {
@Id private UUID id;
private String value;
public static void main(String[] args) {
Example example = Example.builder()
.id(UUID.randomUUID())
.value("foo")
.build();
}
}
Diese Schreibweise ist wieder sehr einfach und geht schnell von der Hand. Allerdings beschwert sich nun Hibernate, dass kein passender Konstruktor gefunden wird. Und auch hier beginnt die Suche mit etwas Unverständnis, denn Lombok geniert hier einen Konstruktor, wie delombok offenbart. Allerdings sucht Hibernate, wie erwähnt, nicht irgendeinen Konstruktor, sondern einen leeren. Kurzum wir müssen noch annotieren, dass ein leerer Konstruktor benötigt wird, sodass sich die Klasse wie folgt darstellt:
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
class Example {
@Id private UUID id;
private String value;
}
Das Problem und Alternativen
Dem Einen oder Anderen ist bestimmt schon aufgefallen, dass die angesprochenen Probleme auch verallgemeinert werden können. Es handelt sich nämlich häufig gar nicht im Probleme durch Lombok selbst, sondern um State-Handling-Probleme mit Lombok.
Und hier liegt auch schon mein hauptsächlicher Kritikpunkt an der Verwendung von Lombok. Denn häufig scheint die Verwendung dem berühmten Nagelsprichwort zu folgen, statt wirklich notwendig zu sein. Oder anders gesagt, Lombok ist häufig einfach das falsche Werkzeug, oder löst Probleme, die gar nicht existieren.
Wer nur einen Hammer hat, wird jedes Problem für einen Nagel halten.
Aphorismus aus dem englischen
Nun, welche Alternativen hätten wir statt der Verwendung von Lombok? Ich denke wir müssen nicht auf Lombok verzichten, aber sollten Alternativen suchen und finden, die ihre Aufgabe besser erfüllen. Das Framework Auto ist zum Beispiel einfach konsequenter als Lombok und steckt klar seine Grenzen ab. Für Value-objects bietet sich dabei AutoValue geradezu an und ist wesentlich präziser als Lombok.
@AutoValue
abstract class Example {
abstract UUID id();
abstract String value();
static Example of(UUID id, String value) {
return new AutoValue_Example(id, value);
}
}
Ein Value-object ist keine Entität! |
Außerdem entwickelt sich unser Umfeld stetig weiter, wodurch uns weitere Sprachen wie Scala oder Kotlin zur Verfügung stehen. Diese Sprachen adressieren zum Teil die gleichen Probleme wie Lombok, aber mit genauer definierten Barrieren. Durch diese genaueren Definitionen ist es einfacher besseren Code zu schreiben und nicht so einfach in Fallen zu laufen. Allerdings muss man nicht einmal auf andere Sprachen schielen, sondern kann sich auch die Entwicklung von Java ansehen. Ab Version 15 können wir das Beispiel nämlich auch mittels eines Records programmieren. Diese Fortschritte betreffen aber stets nur einen Teilaspekt von Lombok, weshalb wir nicht alles ersetzen können, geschweige denn sollten.
record Example(UUID id, String value) {}
Fazit
Lombok ist eines der meist verbreiteten Werkzeuge in der Java-Welt und wird es auch noch eine Weile bleiben. Trotzdem lohnt sich ein Blick auf andere Lösungen, da diese in ihre Aufgaben häufig sehr gut und genau lösen. Außerdem entbindet Lombok den Entwickler in keiner Weise davon sich Gedanken über seinen Code zu machen. Ganz im Gegenteil, ich halte es sogar für gefährlich Lombok einzusetzen, ohne zu Wissen wie der gleiche Code ohne es aussähe. Dennoch hat Lombok auch seine Daseinsberechtigung und beschleunigt manchmal enorm die Entwicklung. Schlussendlich freue ich mich aber immer, wenn ich sehe, dass ein Projekt auf Lombok verzichtet. Dennoch arbeite ich gerne mit Lombok, weil es manchmal einfach die Entwicklung beschleunigt. Schlussendlich freue ich mich immer, wenn ich Projekte sehe, die auf Lombok verzichten.