Hinweis:
Dieser Blogartikel ist älter als 5 Jahre – die genannten Inhalte sind eventuell überholt.
Vor einer Weile hatte ich die Gelegenheit festzustellen, wie hilfreich das Builder Pattern dabei ist, gleich mehrere Aspekte des Quelltextes umfangreicher (Java-) Systeme qualitativ zu verbessern. Daher möchte ich es in einer kleinen Serie von Artikeln etwas näher beleuchten. In diesem ersten Teil stelle ich das Builder Pattern vor und gehe auf ein paar typische Anwendungsfälle ein. Teil zwei zeigt dann einige Varianten in der Umsetzung und deren Rahmenbedingungen behandeln.
Grundlegende Funktionsweise
Ursprünglich wurde das Builder Pattern von der Gang of Four beschrieben, wobei das vollständige Konzept neben dem Builder und dem von ihm erzeugten Product noch ein BuilderInterface und einen Director vorsieht, der die Erzeugung eines kompletten Product aus potentiell mehreren einzelnen Teilen zu verantworten hat. Die heute sehr stark verbreitete Form besteht in ihrer einfachen Ausprägung häufig nur aus einem Builder und seinem Product.
Grundsätzlich ist das Builder Pattern dazu gedacht, die Erzeugung von Instanzen der Klasse Product
zu unterstützen, wenn diese eine große Zahl von Attributen enthält. Ein Builder ist nun so etwas wie eine detailliert konfigurierbare Factory, die über eine Reihe von Mutator-Methoden die Attributwerte für ein Product ansammelt um schließlich eine Instanz mit eben diesen Werten zu erzeugen. Für die Mutatoren hat sich die Namenskonvention withField()
für ein Attribut field
etabliert. Die Erzeugungsmethode wird meist unabhängig vom Product mit build()
benannt und sie kann an einem Builder in der Regel beliebig oft aufgerufen werden, um eine Product
-Instanz zu erzeugen, die jeweils dem aktuellen Zustand der im Builder gesammelten Attribute entspricht.
Lebenszyklus von Objekten
Gerade bei reinen Datenobjekten, im Java-Kontext häufig als Beans bezeichnet, ist es gängig sie zunächst ohne Inhalt zu erzeugen und dann mit Attributwerten anzureichern. Allerdings sind solche Objekte unmittelbar nach ihrer initialen Erzeugung im fachlichen Sinne meist inkonsistent. Erst nach dem Abschluss der Befüllung, was ein durchaus komplexer und fehleranfälliger Prozess sein kann, ist das Objekt fertig zur Verwendung. Diese Initialisierungsphase des Products wird nun durch eine Builder-Instanz repräsentiert, die zu jedem Zeitpunkt für ihre Verwendung konsistent ist und ebenfalls immer nur vollständig initialisierte und konsistente Product-Instanzen herausgibt. Und gerade komplexe Initialisierungsprozesse werden oft in mehrere Methoden aufgeteilt, welche nun mit einem gültigen Builder aufgerufen werden, anstatt mit einem noch ungültigen Product.
Darüber hinaus muss der Lebenszyklus des Builders nicht mit der Erzeugung eines Product enden, sondern er kann weitere Instanzen erzeugen, die ggf. viele der Attributwerte gemeinsam haben sollen.
Builder vs. Factory
Eine schöne Erklärung des Unterschiedes zwischen einem Builder und einer Factory ist ein Beispiel aus der Gastronomie. Wenn man sich in einer Trattoria die Pizza des Tages bestellt, entspricht das einem Aufruf an einer Factory. Der Aufrufer spezifiziert nicht näher, was er haben möchte und die Details des erzeugten Product obliegen komplett der internen Implementierung der Factory. Bestellt man aber eine Pizza Vier-Jahreszeiten mit doppelt Käse, Knoblauch und Oliven anstatt der Artischocken, dann nimmt der Aufrufer gezielt Einfluss auf die zukünftigen Attribute des Product, bevor dieses erzeugt wird.
Implementierung
Es sind ein paar Randbedingungen zu erfüllen, um unerwartete Seiteneffekte zu vermeiden:
- Die Reihenfolge der Mutator-Aufrufe ist gängigerweise nicht bekannt und sollte nicht ohne Grund erzwungen werden.
- Der letzte Aufruf der jeweiligen Mutator-Methode definiert den letztendlich benutzten Wert des Attributs.
- Bei mehrmaliger Erzeugung von Product-Instanzen sollten diese von einander unabhängig sein.
Es gibt aber auch einige Freiheitsgrade bei der Umsetzung eines Builders, solange er die folgenden zwei zentralen Funktionen erfüllt. Er muss in seinem inneren Zustand die Attribute für die Erzeugung eines Product ansammeln können und er muss Product-Instanzen erzeugen können, wenn diese Attribute valide Werte haben.
Beispielsweise ist es sehr gängig – aber nicht prinzipiell notwendig – den Builder mit einem Fluent Interface auszustatten. Ob man den Builder lieber als Top-Level Klasse oder als innere Klasse des Product anlegt ist bis auf wenige Fälle nur Geschmackssache. Und ob man die Attribute des Product im Builder einzeln vorhält oder in einer internen Product-Instanz speichert hat wiederum verschiedene Vor- und Nachteile, die im zweiten Teil des Artikels diskutiert werden.
Für die einzelnen Funktionen gibt es verschiedene Varianten, die in bestimmten Anwendungsfällen besonders zu empfehlen sind. Ein paar dieser Fälle möchte ich nun kurz darstellen.
Telescoping Constructor
Beim Erzeugen von Datenobjekten mit vielen Attributen sind einige davon häufig optional. Um die verschiedenen Anwendungsgebiete dieser Objekte zu bedienen, ergibt sich als naiver Ansatz das Telescoping Constructor Antipattern, bei dem jeweils ein eigener überladener Konstruktor mit der gerade benötigten Teilmenge der möglichen Attribute angeboten wird. Abhängig von den Attributtypen führt dieses Vorgehen leicht zu großen Problemen bei der Auswahl des geeigneten Konstruktors und beim Lesen des Quelltextes an der Verwendungsstelle. Dies wird mit der Anzahl der (optionalen) Attribute immer gravierender.
1 2 3 |
ProcessData current = new ProcessData(17L, false, false, true, null, null, "activate", 0, 0L, 23, items); ProcessData next = new ProcessData(null, true, true, null, "item list", null, 14); |
Die Meisten dieser Probleme können durch ein Konzept wie die Named Parameters behoben werden. Da so etwas aber in Java nicht direkt unterstützt wird, bietet sich ein Builder an, wie ich schon in einem anderen Artikel beschrieben habe. Dabei werden nur diejenigen Mutatoren verwendet, deren Attribute auch tatsächlich gesetzt werden sollen. Und diese Methoden sind aussagekräftig benannt.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
ProcessDataBuilder builder = new ProcessData.Builder(); builder.withParentProcessId(17L) builder.withPropagateResults(true) builder.withStep("activate") builder.withUserId(0L) builder.withOffset(23) builder.withItems(items) ProcessData current = builder.build(); builder = new ProcessData.Builder(); builder.withAggregateSubResults(true) builder.withPropagateResults(true) builder.withEntityName("item list") builder.withOffset(14) ProcessData next = builder.build(); |
JavaBeans und Validierung
Das JavaBeans Pattern schreibt vor, dass Datenobjekte einen Default-Konstruktor haben und jedes Attribut über Mutatoren und Accessoren zugegriffen werden kann. Es wird also eine leere Instanz erzeugt und diese dann mit Attributen angereichert. Das Datenobjekt existiert dabei zwischenzeitlich in einem fachlich potenziell inkonsistenten Zustand. Gerade bei der Anreicherung mit komplexen Daten kommt es oft vor, dass solche unfertigen Objekte an andere Methoden übergeben werden, was zu Problemen führen kann. Hier sei als Beispiel eine NullPointerException
in der toString()
Methode bei der Verwendung von aspektorientierten Logging-Mechanismen genannt. Es kann bei diesem Vorgehen technisch nicht forciert werden, dass eine Validierung erfolgt ist, bevor das Objekt zugreifbar und verwendbar wird.
Ein Builder hingegen kann sehr leicht in der Erzeugermethode den aktuellen Zustand der gesammelten Attributwerte nach beliebig komplexen Vorgaben validieren und nur im Erfolgsfall eine Instanz des Product zurückliefern. Auf diese Weise kann außerhalb des Builders zu keinem Zeitpunkt ein unvollständig initialisiertes Datenobjekt existieren.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
class Builder { [...] public Product build() throws ValidationException { validateAttributes(); Product product = new Product(); [...] return product; } private void validateAttributes() throws ValidationException { [...] } } |
Telescoping Factory
Um das Problem zu entschärfen, dass unvollständig initialisierte Instanzen von Datenobjekten in Umlauf kommen, wird das JavaBeans Pattern häufig innerhalb von (meist statischen) Factory-Methoden eingesetzt. Letztlich ist dies eine Kombination aus den vorigen beiden Pattern, da diese Factory-Methoden sich völlig analog zu den Konstruktoren verhalten und – abgesehen von der Möglichkeit, sprechende Namen zu verwenden – dieselben Probleme bei vielen optionalen Attributen mit sich bringen. Auch hier kommt es zu einer Ansammlung von alternativen Methoden, um Instanzen zu erzeugen, denen jeweils eine wechselnde Teilmenge der Attribute übergeben wird.
Sowohl Problemstellung als auch Lösung ergeben sich wie beim Telescoping Constructor.
Eine Besonderheit sind eigene Methoden für die Initialisierung bestimmter Teilmengen der Attribute, die dann in mehreren Fällen wiederverwendet werden können. Diesen Methoden werden dabei wiederum unfertige Instanzen übergeben, um einen entsprechenden Teil hinzuzufügen. Für solche Fälle bietet es sich an, die jeweilige Builder-Instanz an jene Methoden zu übergeben, die dann die entsprechenden Mutatoren daran aufrufen können.
Testdaten
Wenn ein Datenobjekt im Produktivsystem schon mit wenigen Attributen konsistent erzeugt wird und dann in den Geschäftsprozessen sukzessive mit weiteren Daten befüllt wird, ist ein Builder kaum von Nutzen. Es lohnt sich aber oft, in solchen Fällen im Testcode einen Builder anzubieten, um die Unit-Tests der einzelnen Prozessphasen komfortabel mit entsprechend befüllten Instanzen durchführen zu können.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
@Test public void stepPropagatesResultToItems() { ProcessData.Builder builder = new ProcessData.Builder(); builder.withParentProcessId(17L); builder.withPropagateResults(true); builder.withStep("activate"); builder.withUserId(0L); builder.withOffset(23); step.process(builder.build()); verify(itemRepository).update(itemCaptor.capture()); assertItemsYieldProcessResult(itemCaptor.getValue()); } |
Wenn in mehreren Testfällen Product-Instanzen mit größtenteils gleichen Daten benötigt werden, empfiehlt es sich, lokale Factory-Methoden anzulegen, die eine Builder-Instanz mit prototypisch vorbelegten Attributen liefern. Diese können dann noch beliebig angepasst werden, bevor das Product erzeugt wird.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
@Test public void stepPropagatesResultToItems() { ProcessData.Builder builder = aDefaultProcessData() // get default builder.withParentProcessId(17L) // add attribute builder.withOffset(23) // override default step.process(builder.build()); // create instance verify(itemRepository).update(itemCaptor.capture()); assertItemsYieldProcessResult(itemCaptor.getValue()); } private ProcessDataBuilder aDefaultProcessData() { ProcessData.Builder builder = new ProcessData.Builder(); builder.withPropagateResults(true) builder.withStep("activate") builder.withUserId(0L) builder.withOffset(5) builder.withItems(items); return builder; } |
Wird der Builder ausschließlich im Test-Scope benötigt, bietet es sich an, solche Prototypen direkt dort als Methoden anzubieten und die verwendeten Attributwerte für die aufrufenden Tests zugreifbar zu machen. Diese können dann bei der Prüfung der resultierenden oder kommunizierten Daten zum Vergleich herangezogen werden.
Wenn es allerdings bereits einen Builder im Produktiv-Scope gibt und der Einsatz eines Test-spezifischen Builders dennoch nützlich wäre, so kann über eine Vererbungshierarchie eine teilweise Wiederverwendung erreicht werden. Hierbei ist allerdings Obacht geboten, denn die Kombination dieser Konzepte birgt einige Fallstricke. Dies habe ich in einem weiteren Artikel im Detail ausgeführt.
Invarianz
Die Invarianz von Datenobjekten, also die Unveränderlichkeit des internen Zustands nach der Erzeugung, gewinnt dieser Tage immer mehr an Bedeutung, insbesondere da sie als Lösungsansatz für viele Probleme in nebenläufigen Systemen gilt. Diese Eigenschaft in einer Klasse technisch zu forcieren führt meist dazu, dass die Initialisierung umständlicher und vor allem unflexibler wird. Es gibt keine Mutatoren, und Attribute können nur gemeinsam und vollständig per Konstruktor übergeben werden.
Ein Builder kann nun eine sehr komfortable Möglichkeit anbieten, unveränderliche Instanzen zu erzeugen, da er selbst nicht unveränderlich ist und flexibel mit Attributwerten angereichert werden kann. Dabei kann mit der Sichtbarkeit von Konstruktoren und der Builder-Klasse auch noch beeinflusst werden, welche Komponenten überhaupt Product-Instanzen erzeugen können. Gegebenenfalls muss der Builder dafür als innere Klasse des Product definiert werden.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
public final class ImmutableConfig implements Config { private final String value; public String getValue() { return value; } private ImmutableConfig(String value) { // only visible to internal Builder this.value = value; } static Builder aConfig() { // only visible to same package return new Builder(); } static final class Builder { // only visible to same package private Builder() {} // only visible to ImmutableConfig private String value; public void withValue(String value) { this.value = value; } public Config build() { return new ImmutableConfig(value); } } } |
Mehraufwand
Das größte Gegenargument für den Einsatz eines Builders ist der damit einhergehende Mehraufwand für dessen Implementierung. Letztlich muss für jede Product-Klasse eine weitere Builder-Klasse gepflegt werden, in der sämtliche Attribute streng genommen dupliziert vorliegen. Es entsteht die Notwendigkeit für mehr stupide Fleißarbeit.
Das ist tatsächlich nicht zu leugnen, allerdings gibt es die einfache Möglichkeit, diese stupide Fleißarbeit automatisiert erledigen zu lassen. Der überwiegende Teil der Implementierung eines Builders hängt direkt von der internen Struktur des Product ab und kann sehr simpel generiert werden. Die Attribute werden dem Product entsprechend nachgebildet, die Mutatoren und sonstige Namen können hergeleitet werden und die restlichen Freiheitsgrade können durch eine der Implementierungsvarianten vorgegeben werden. Entsprechend sind für die gängigen IDEs auch einige PlugIns und Generatoren für Builder verfügbar, mit denen der Mehraufwand erheblich reduziert werden kann.
Weiter geht’s
Nachdem nun die Funktionen und grundlegenden Möglichkeiten des Builder Pattern dargestellt wurden, werde ich im nächsten Teil einige gängige Varianten für die Implementierung eines Builders im Detail erläutern.
Bis dahin könnte sich ein Blick auf unser Dienstleistungsportfolio lohnen. Wir freuen uns auch auf Fragen und Anregungen im Kommentarbereich unten, per Mail an info@inovex.de oder telefonisch unter +49 721 619 021-0.