Das Java-Framework Lombok

26.10.2012

Einleitung

In diesem Artikel wird kurz beschrieben was es mit dem Java-Framework Lombok auf sich hat und wie man es am Besten einsetzen kann, um Quellcode lesbarer zu machen und sich gleichzeitig unnötige Tipparbeit erspart. Dazu wird in einem kleinen Beispiel eine Klasse Person mit einigen Member-Variablen erstellt und eine Testklasse, die die Objekte vom Type Person erstellt und auf verschiedene Weisen benutzt.

Wer hat sich nicht schon oft geärgert, dass er getter und setter erstellen musste? Klar. Inzwischen kann man sie mit IDEs, wie Eclipse oder NetBeans generieren lassen. Aber warum? Normalerweise sollen sie keine Businesslogik enthalten, sondern nur zum Setzen und Leser von Werten verwendet werden. Das ist jedem Entwickler klar und deshalb macht es eigentlich wenig Sinn, dass sie Teil des Quellcodes sind, an dem der Programmierer arbeit. Andere Sprachen, wie Objective-C, lassen solchen Code schon lange weg und sind dadurch erheblich kompakter und lesbarer. In der Java-Welt ist es aber so, dass auch diese Methoden Teil des Quellcodes sind. Wenn es ganz schlimm läuft, dann verlangt ein Software-Architekt, dass man die Methoden mit JavaDoc versehen soll, weil ein Tool, wie Sonar dies empfiehlt. Ein weiterer Nachteil ist, dass durch diesen Boilerplate Code der Text, den der Programmierer immer wieder lesen muss, mehr wird und er dadurch von den wichtigen Textstellen ablenkt und ihr dadurch ausbremst. Die gettern und settern sind nur ein Beispiel für solche Codefragmente. Es gibt zahlreiche weitere, die im Folgenden aufgelistet sind:

  • Konstruktoren die alle Membervariablen als Parametern enthalten
  • Prüfung auf not null im Konstruktor
  • toString()-Methode überschreiben
  • hashcode()-Methode überschreiben
  • equals()-Methode überschreiben
  • Logging initialisieren.
  • Variablendeklarationen mit Typ für lokale Variablen
  • IO-Streams schließen
  • Ein try-catch-Block, der eine Exception nur fängt, um eine Fehlermeldung auszugeben.

Es wäre doch schön, wenn es ein Framework gäbe, welches einem diese Routinetätigkeiten abnimmt und den notwendigen Code einfach zur Compilezeit generiert, so dass er beim Programmieren und Lesen des Codes nicht ablenkt.

Das Java-Framework Lombok erledigt genau das.

In dem folgenden Beispiel werden diese Annotationen verwendet:

  • @Getter
  • @Setter
  • @ToString
  • @EqualsAndHashCode
  • @AllArgsConstructor
  • @NonNull
  • @Log4j
  • @Cleanup
  • @SneakyThrows

Und das Schlüsselwort val durch das beim Deklarieren von Variablen kein Typ mehr angegeben werden muss.

Ein vollständige Liste mit allen Annotation und passenden Beispielen findet man unter Lombok Features.

Installation von Lombok in Eclipse

Als erstes muss das Lombok-Framework von der Seite Project-Lombok heruntergeladen werden. Anschließend öffnet man ein Konsolenfenster und führt das Kommando java -jar lombok.jar aus. Daraufhin öffnet sich ein Installer-Fenster in dem im Normalfall das installierte Eclipse erkannt wird. Falls dies nicht der Fall ist, muss der Installationspfad von Eclipse angegeben werden.

Create New Project

Nach der erfolgreichen Installation wird folgender Dialog angezeigt. Nun muss Eclipse noch neu gestartet werden, damit Lombok verwendet werden kann.

Create New Project

Hinweis


Wenn schon kompilierten Quellcode in einem Eclipse-Projekt vorhanden war, der Lombok-Annotationen verwendet, dann muss ggf. Project->Clean... aufgerufen werden, damit die Klassen neu übersetzt werden.

Beispiel ohne Lombok

Im Folgenden wird an einem einfachen Beispiel erklärt, wie man mit Hilfe von Lombok eine Menge Quellcode einsparen kann. Dazu implementieren wir als erstes ein Pojo Person in der herkömmlichen Weise, also ohne Lombok.

Der Klasse Person

Die Klasse Person soll folgende Werte speichern: Nachname, Vorname, Geburtstag, Adresse, Stadt und Postleitzahl. Außerdem werden getter und setter benötigt. Zusätzlich brauchen wir einen default-Konstruktor ohne Parameter und einen Konstruktor über den alle Werte gesetzt werden können. Nebenbedingung dabei ist, dass Name (lastname) und Vorname (firstname) nicht null sind. Zusätzlich müssen die Methode toString(), equals() und hashcode() überschrieben werden. Bei den letzten beiden Methoden gilt die Nebenbedingung, dass die Werte Vorname (firstname, Adresse (address) und Geburtstag (birthday) nicht berücksichtigt werden.

Der daraus resultierende Quellcode könnte so aussehen. Wegen der Übersichtlichkeit habe ich die JavaDoc weggelassen .

package org.hameister.lombok;

import java.io.Serializable;
import java.util.Date;

public class Person implements Serializable {

	private static final long serialVersionUID = 1L;

	private String lastname;
	private String firstname;

	private Date birthday;
	private String address;
	private String city;
	private String zip;

	public Person() {
	}

	public Person(String lastname, String firstname, Date birthday,
			String addresse, String city, String zip) {
		if (lastname == null)
			throw new NullPointerException("Lastname must not null.");
		if (firstname == null)
			throw new NullPointerException("Firstname must not null");

		this.lastname = lastname;
		this.firstname = firstname;
		this.birthday = birthday;
		this.address = addresse;
		this.city = city;
		this.zip = zip;
	}

	@Override
	public String toString() {
		return "Person(lastname=" + lastname + ", firstname=" + firstname
				+ ", birthday=" + birthday + ", address=" + address + ", city="
				+ city + ", zip=" + zip + ")";
	}

	@Override
	public boolean equals(Object o) {
		if (o == this)
			return true;
		if (!(o instanceof Person))
			return false;
		Person p = (Person) o;
		if (!lastname.equals(p.getLastname()))
			return false;
		if (!city.equals(p.getCity()))
			return false;
		if (!zip.equals(p.getZip()))
			return false;
		return true;
	}

	@Override
	public int hashCode() {
		final int PRIME = 31;
		int result = 1;
		result = (result * PRIME) + (this.lastname == null ? 0 : this.lastname.hashCode());
		result = (result * PRIME) + (this.city == null ? 0 : this.city.hashCode());
		result = (result * PRIME) + (this.zip == null ? 0 : this.zip.hashCode());
		return result;
	}

	public String getLastname() {
		return lastname;
	}

	public void setLastname(String lastname) {
		this.lastname = lastname;
	}

	public String getFirstname() {
		return firstname;
	}

	public void setFirstname(String firstname) {
		this.firstname = firstname;
	}

	public Date getBirthday() {
		return birthday;
	}

	public void setBirthday(Date birthday) {
		this.birthday = birthday;
	}

	public String getAddress() {
		return address;
	}

	public void setAddress(String address) {
		this.address = address;
	}

	public String getCity() {
		return city;
	}

	public void setCity(String city) {
		this.city = city;
	}

	public String getZip() {
		return zip;
	}

	public void setZip(String zip) {
		this.zip = zip;
	}

}

Testklasse für Person

Zum Testen der Klasse Person erstellen wir eine weitere Klasse mit einer main-Funktion. In der Funktion erledigen wir folgende Dinge:

  • Logger für Log4J initialisieren
  • Ein Personen-Objekt erstellen und mit den settern die Werte setzten (im Beispiel: Donald Duck, Entenstrasse 1, 1234 Entenhausen)
  • Nachname abfragen und ausgeben
  • Test der Methode toString()
  • Eine ArrayList mit einer Person erstellen
  • Ein weiteres Personen-Objekt mit dem Konstruktor mit allen Parametern aufrufen
  • equals() und hashcode() mit beiden Personen ausprobieren
  • Die NullPointerException provozieren
  • Eine Person in eine Datei serialisieren und anschließend wieder deserialisieren.

Anmerkung: Normalerweise würde man solche Test mit JUnit durchführen. Hier soll aber gezeigt werden, wie die Klasse Person im normalen Programmfluss verwendet wird.

package org.hameister.lombok;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

import org.apache.log4j.Logger;

public class PersonTester {

	private static final Logger log = Logger.getLogger(PersonTester.class);

	/**
	 * @param args
	 */
	public static void main(String[] args) {
		Person person = new Person();

		// Getter and setter
		person.setFirstname("Donald");
		person.setLastname("Duck");
		person.setAddress("Entenstrasse 5");
		person.setCity("Entenhausen");
		person.setZip("1234");

		System.out.println("Lastname: " + person.getLastname());

		// Logging and toString
		log.debug(person.toString());

		// Array with persons
		List<Person> persons = new ArrayList<Person>();
		persons.add(person);

		// Constructor
		Person person2 = new Person("Duck", "Dagobert", null, "Entenstrasse 1", "Entenhausen", "1234");

		// equals and hashcode
		log.debug("Same values except firstname and address: " + person.equals(person2));
		log.debug("Hashcode " + person.getFirstname() + ": " + person.hashCode());
		log.debug("Hashcode " + person2.getFirstname() + ": " + person2.hashCode());

		// NotNull values in constructor
		try {
			new Person("Duck", null, new Date(), "Entenstrasse 5", "Entenhausen", "1234");
		} catch (NullPointerException exception) {
			log.debug("Expected NPE.");
		}

		// Close open streams
		try {

			FileOutputStream fos = new FileOutputStream("DonaldDuck.ser");
			try {
				ObjectOutputStream out = new ObjectOutputStream(fos);
				try {
					out.writeObject(person);
				} finally {
					if (out != null) {
						out.close();
					}
				}
			} finally {
				if (fos != null) {
					fos.close();
				}
			}

			FileInputStream fis = new FileInputStream("DonaldDuck.ser");
			try {
				ObjectInputStream in = new ObjectInputStream(fis);
				try {
					Person person4 = (Person) in.readObject();
					log.debug("Serialized Person: " + person4);
				} finally {
					if (in != null) {
						in.close();
					}
				}
			} finally {
				if (fis != null) {
					fis.close();
				}
			}
		} catch (FileNotFoundException e) {
			log.error("DonaldDuck.ser not found.", e);
		} catch (IOException e) {
			log.error("Cannot read stream.", e);
		} catch (ClassNotFoundException e) {
			log.error("Cannot find Person class.", e);
		}

	}

}

Beispiel mit Lombok

Nun wird das gleiche Beispiel mit Hilfe des Java-Frameworks Lombok erstellt, um zu zeigen, wie übersichtlich und lesbar der Quellcode werden kann, wenn man das Framework einsetzt. Dazu wird eine Menge Quellcode gelöscht und durch die oben aufgelisteten Annotationen ersetzt.

Die Klasse Person

Der folgende Beispielcode zeigt die Umsetzung mit Lombok. Mit Hilfe von Annotationen, werden verschiedene Funktionen zur Compilezeit generiert.

Was sofort auffällt ist, dass die Klasse erheblich kürzer und vor allen Dingen auch lesbarer ist. Selbst die fehlende JavaDoc ist aus meiner Sicht kein Problem.

package org.hameister.lombok;

import java.io.Serializable;
import java.util.Date;

import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NonNull;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
@EqualsAndHashCode(exclude={"firstname", "address", "birthday"})
@AllArgsConstructor
public class Person implements Serializable {

	private static final long serialVersionUID = 1L;
	
	@NonNull private String lastname;
	@NonNull private String firstname;
	
	private Date birthday;
	private String address;
	private String city;
	private String zip;
	
	public Person() {}
}

Die Annotationen @Getter und @Setter sorgen für das Generieren der Zugriffsmethoden. Mit der Annotation @ToString wird die Methode toString() erstellt, wobei alle Membervariablen beim Aufruf verwendet werden. Beim Generieren von hashcode() und equals() wird durch die Annotation EqualsAndHashCode und die exclude-Anweisung dafür gesorgt, dass nur lastname, zip, city verwendet werden. Außerdem wird mit der Annotation @AllArgsConstructor ein Konstruktor mit allen Parametern generiert.

Testklasse für Person mit Lombok

Auch bei der Testklasse ist die Reduzierung des Quellendes durch den Einsatz von Lombok deutlich sichtbar.

package org.hameister.lombok;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.ArrayList;
import java.util.Date;

import lombok.Cleanup;
import lombok.SneakyThrows;
import lombok.val;
import lombok.extern.log4j.Log4j;

//Logging
@Log4j
public class PersonTester {

	// Catch FileNotFoundException, IOException, ClassNotFoundException
	@SneakyThrows
	public static void main(String[] args) {
		Person person = new Person();

		// Getter and setter
		person.setFirstname("Donald");
		person.setLastname("Duck");
		person.setAddress("Entenstrasse 5");
		person.setCity("Entenhausen");
		person.setZip("1234");

		System.out.println("Lastname: " + person.getLastname());

		// Logging and toString
		log.debug(person.toString());

		// val
		val persons = new ArrayList<Person>();
		persons.add(person);

		// Constructor
		Person person2 = new Person("Duck", "Dagobert", null, "Entenstrasse 1", "Entenhausen", "1234");

		// equals and hashcode
		log.debug("Same values except firstname and address: " + person.equals(person2));
		log.debug("Hashcode " + person.getFirstname() + ": " + person.hashCode());
		log.debug("Hashcode " + person2.getFirstname() + ": " + person2.hashCode());

		// NotNull values in constructor
		try {
			new Person("Duck", null, new Date(), "Entenstrasse 5", "Entenhausen", "1234");
		} catch (NullPointerException exception) {
			log.debug("Expected NPE.");
		}

		// Close open streams
		@Cleanup
		FileOutputStream fos = new FileOutputStream("DonaldDuck.ser");
		@Cleanup
		ObjectOutputStream out = new ObjectOutputStream(fos);
		out.writeObject(person);

		@Cleanup
		FileInputStream fis = new FileInputStream("DonaldDuck.ser");
		@Cleanup
		ObjectInputStream in = new ObjectInputStream(fis);
		Person person4 = (Person) in.readObject();
		log.debug("Serialized Person: " + person4);
	}
}

Die Variable log wird einfach durch die Annotation @Log4J integriert und kann danach verwendet werden. Ein weiteres Feature von Lombok ist das Schlüsselwort val. Damit kann eine lokale Variable ohne Typ definiert werden. In dem Beispiel oben spart man sich dadurch die Definition von List<Person>. Eine weitere nützliche Annotation ist @Cleanup. Damit werden die IO-Streams automatisch geschlossen, d.h. es wird der Boilerplate-Code generiert, der im letzten Beispiel noch per Hand geschrieben wurde. Die Annotation @SneakyThrows sorgt dafür, dass wir uns nicht selbst um die Exceptions kümmern müssen, d.h. der try-catch-Block wird generiert. Das hat den Vorteil, dass Exceptions, die im Normalfall nicht auftreten im Quellcode auch nicht vom Entwickler behandelt werden müssen. Ein Beispiel dafür ist Thread.sleep(1000);. Es soll einfach eine Sekunde gewartet werden, aber die InterruptedException-Exception, um die man sich normalerweise kümmern müsste, ist eher unwahrscheinlich. Also kann durch die Annotation @SneakyThrows das Exception-Handling weggelassen werden und der Quellcode wird übersichtlicher.

Fazit

Wie man leicht sieht, lässt sich die Menge des Quellcodes durch den Einsatz von Lombok erheblich reduzieren und er wird durch das Weglassen des Boilerplate-Code erheblich lesbarer.

In Zahlen bedeutet dies:

Lombok Klasse Person Testklasse Gesamt
nein ca. 117 Zeilen ca. 101 Zeilen ca. 218 Zeilen
ja ca. 31 Zeilen ca. 69 Zeilen ca. 100 Zeilen

Sicherlich ist das Beipsiel sehr "konstruktiert", aber trotzdem verdeutlicht es, wie übersichtlich Quellcode werden kann.

Bei den Tests, die ich mit dem Framework gemacht habe, hat es problemlos funktioniert. Was ich nicht getestet habe, wie Tools, wie Sonar, auf den generierten Code reagieren.

Ich denke, dass das Lombok-Framework gerade für Pojos sehr gut geeignet ist. Diese werden erheblich kompakter. Bei Klassen, die Businesslogik enthalten, muss man meiner Meinung nach gut abwägen, ob der Quellcode lesbarer und wartbarer wird. Um dazu eine belastbare Aussage zu machen, muss das Framework wohl in einem realen Kundenprojekt eingesetzt werden...