https://d226lax1qjow5r.cloudfront.net/blog/blogposts/builder-pattern-with-inheritance-in-java/builder-pattern.png

Builder-Muster mit Vererbung in Java

Zuletzt aktualisiert am August 3, 2022

Lesedauer: 6 Minuten

Dieser Artikel wurde im April 2025 aktualisiert.

Einführung

In der Regel werden Objekte über einen Konstruktor instanziiert, wobei die erforderlichen Parameter direkt übergeben werden. Der Einfachheit halber können wir verwandte Parameter in einer Klasse zusammenfassen und eine Instanz dieser Klasse separat konstruieren, die wir dem Hauptobjekt als Parameter übergeben.

Dies hat den Vorteil, dass die Anzahl der Parameter, die für die Konstruktion eines Objekts erforderlich sind, reduziert wird, da die Konstruktion in mehrere Schritte unterteilt wird. Die Verwendung eines Konstruktors hat jedoch auch einige Nachteile. Zum einen ist die Reihenfolge, in der die Parameter übergeben werden, von Bedeutung, was bei vielen Parametern entmutigend sein kann, und noch fehleranfälliger, wenn diese Parameter vom gleichen Typ sind (z. B. String).

Es kann auch optionale Parameter geben. Dies kann zwar durch mehrere Konstruktoren mit unterschiedlichen Signaturen umgangen werden, doch führt dies zu einer großen Menge an Textbausteinen und einer hohen kognitiven Belastung für Benutzer und Entwickler. Außerdem wird es undurchführbar, wenn mehrere optionale Parameter vom gleichen Typ sind.

Das Builder-Muster ist ein bekanntes Entwurfsmuster in objektorientierten Sprachen zur Steuerung der Objektkonstruktion. Wie viele (vielleicht die meisten) Entwurfsmuster dient es dazu, einen Mangel in der Sprache zu beheben. In Sprachen mit benannten Parametern (wie Kotlin, Scala, Python, C#, Ruby und vielen anderen) ist der Bedarf an Buildern in vielen Fällen geringer.

Die Verwendung des Builder-Patterns ist eine Empfehlung in Effective Java, aber für Sprachen mit benannten Argumenten wird sein Wert fraglich (siehe z.B. diesen Artikel, der seinen Nutzen in Kotlin untersucht).

Eine Diskussion darüber, inwieweit optionale Parameter das Builder-Muster überflüssig machen, würde den Rahmen dieses Artikels sprengen. Stattdessen sollen die Gründe für eine scheinbar komplizierte Anwendung des Builder-Musters in der kürzlich veröffentlichten Messages API-Implementierung im Vonage Java SDK.

MessageRequest Klassen

Zur Modellierung der verschiedenen Arten von Nachrichten, die über die Messages APIzu modellieren, wird ein objektorientierter Ansatz verwendet, bei dem für jede gültige Kombination von Nachrichtentyp und Dienst eine Klasse erstellt wird. Es gibt eine dreistufige Vererbungshierarchie. Nehmen Sie das folgende Beispiel:

  • MessageRequest

    • MmsRequest

      • MmsVcardRequest

MessageRequest und MmsRequest sind abstrakte Klassen, und MmsVcardRequest ist die Klasse, die die Kombination des Versands einer vCard über MMS darstellt.

Die Basisklasse MessageRequest nimmt in ihrem Konstruktor den Kanal und den Nachrichtentyp als Argumente entgegen, die von den Unterklassen festgelegt werden. Sie nimmt auch einen Builder als Parameter, in dem die wichtigsten Details der Nachricht festgelegt werden. Einige Parameter sind optional, wie z. B. clientRefund alle Nachrichten haben einen Absender und einen Empfänger, die daher in der Basisklasse MessageRequest.

Diese Vererbungshierarchie ist jedoch für den Benutzer transparent, wenn er eine MmsVcardRequestdie wie folgt aussieht:

MmsVcardRequest message = MmsVcardRequest.builder()
        .from("447900090000").to("447900090001")
        .url("https://www.example.com/contact.vcf")
        .clientRef("vCard-msg-#1")
        .build();

Vollständige Beispiele finden Sie in der code samples repo.

Aus der Sicht des Benutzers erscheint dies recht elegant, da es in Java keine benannten und optionalen Methoden-/Konstruktorparameter gibt. Hinter den Kulissen erfordert die Ermöglichung des obigen Codes mit einer Vererbungshierarchie jedoch ein Design, das aus Sicht des SDK-Entwicklers nicht sofort offensichtlich oder intuitiv ist.

Die Komplikationen beziehen sich auf die verschachtelten Builder-Klassen, die mit jeder Unterklasse von MessageRequest. Betrachten wir MmsRequest als Beispiel. Es ist immer noch abstrakt, da wir nur die Channel.

Aber was ist mit dieser einschüchternden Builder Erklärung? Der Rest dieses Artikels wird Ihnen helfen, dies zu verstehen:

protected abstract static class Builder<M extends MmsRequest, B extends Builder<? extends M, ? extends B>> extends MessageRequest.Builder<M, B>

Die<M> Parameter

Die Builder-Basisklasse in MessageRequest nimmt als Parameter den Typ von MessageRequest der konstruiert werden soll, und den Typ von Builder.

Die erste (M) ist leicht zu erklären: public abstract M build() In MessageRequest.Builder ist das, was der Benutzer aufruft, nachdem er die Parameter gesetzt hat, und gibt ihm die entsprechende konkrete MessageRequest Unterklasse zurück.

Natürlich könnte dies weggelassen werden, da die Java-Vererbung kovariante Rückgabetypen unterstützt. Das heißt, wir könnten M weglassen und das gleiche Ergebnis aus der Sicht des Benutzers erzielen, wenn wir das wollten. Dann, MessageRequest.Builder wie folgt aus:

public abstract static class Builder<B extends Builder<? extends B>> {
    protected String from, to, clientRef;

    public B from(String from) {
        this.from = from;
        return (B) this;
    }

    public B to(String to) {
        this.to = to;
        return (B) this;
    }

    public B clientRef(String clientRef) {
        this.clientRef = clientRef;
        return (B) this;
    }

    public abstract MessageRequest build();
}

**

Wir müssen dann daran denken, dies in Unterklassen zu überschreiben, idealerweise sogar in abstrakten Klassen wie MmsRequest.Builderzu überschreiben, etwa so:

protected abstract static class Builder<B extends Builder<? extends B>> extends MessageRequest.Builder<B> {
    String url;

    protected B url(String url) {
        this.url = url;
        return (B) this;
    }

    @Override
    public abstract MmsRequest build();
}

Die konkrete Klasse MmsVcardRequest.Builder würde genauso aussehen, da wir dort den Typ deklarieren:

public static final class Builder extends MmsRequest.Builder<Builder> {
    Builder() {}

    public Builder url(String url) {
        return super.url(url);
    }

    @Override
    public MmsVcardRequest build() {
        return new MmsVcardRequest(this);
    }
}

Warum also fügen wir diesen scheinbar überflüssigen Parameter hinzu, wenn wir kovariante Rückgabetypen verwenden können? Er soll einfach sicherstellen, dass wir nicht vergessen, den Rückgabetyp zu überschreiben.

Durch die Generierung des Rückgabetyps stellt der Compiler sicher, dass die build() Methode die richtige Signatur hat. Wäre dies nicht der Fall, wäre die folgende Version von MmsVcardRequest.Builder ebenfalls gültig, aber ungenau:

public static final class Builder extends MmsRequest.Builder<Builder> {
    Builder() {}

    public Builder url(String url) {
        return super.url(url);
    }

    @Override
    public MmsRequest build() {
        return new MmsVcardRequest(this);
    }
}

Da wir daran gedacht haben, den Rückgabetyp von MmsRequest.Builder#build()zu überschreiben, wird der Compiler das Folgende abfangen build() Signatur:

@Override
public MessageRequest build() {
    return new MmsVcardRequest(this);
}

mit Fehlermeldung:

'build()' in 'com.vonage.client.messages.mms.MmsVcardRequest.Builder' kollidiert mit 'build()' in 'com.vonage.client.messages.mms.MmsRequest.Builder'; Versuch, einen inkompatiblen Rückgabetyp zu verwenden.

Das liegt nur daran, dass wir daran gedacht haben, die Signatur der Methode in MmsRequest.Builder. Hätten wir das nicht getan, würde es keinen Fehler geben. Durch die Parametrisierung des Rückgabetyps sind wir gezwungen, den richtigen Typ zu deklarieren, und wir müssen die build() Methode in Unterklassen von MessageRequest - Der Compiler erledigt das für uns.

Die<B> Parameter

Kehren wir zu dem letztgenannten Builder-Parameter zurück - B. Wenn Sie mit dem Builder-Muster vertraut sind, werden Sie wissen, dass jeder Methodenaufruf des Builders den Builder selbst zurückgibt, so dass Sie Methodenaufrufe zum Setzen von Parametern problemlos verketten können.

Das funktioniert gut, wenn es keine Vererbung gibt, aber wir wollen, dass der Benutzer die Parameter in beliebiger Reihenfolge setzen kann - ist das nicht einer der Hauptgründe für die Verwendung des Builder-Musters? Daher müssen wir sicherstellen, dass die spezifischste konkrete Builder-Klasse zurückgegeben wird, unabhängig davon, welche Methoden zuerst aufgerufen werden.

Andernfalls verlieren wir die Möglichkeit, Methodenaufrufe zu verketten, und müssten jedes Mal auf das Casting des Rückgabewerts zurückgreifen - was den flüssigen Ablauf, den wir durch die Verwendung eines Builders zu erreichen versuchen, zunichte macht.

Um dies zu verdeutlichen, betrachten wir den Fall, dass wir naiv den aktuellen Bauherrn zurückgeben:

public abstract static class Builder<M extends MessageRequest> {
    protected String from, to clientRef;

    public Builder<M> from(String from) {
        this.from = from;
        return this;
    }

    public Builder<M> to(String to) {
        this.to = to;
        return this;
    }

    public Builder<M> clientRef(String clientRef) {
        this.clientRef = clientRef;
        return this;
    }

    public abstract M build();
}

Das kompiliert gut und ist brauchbar. Dann wird MmsRequestwird der Builder wie folgt:

protected abstract static class Builder<M extends MmsRequest> extends MessageRequest.Builder<M> {
    String url;

    protected Builder<M> url(String url) {
        this.url = url;
        return this;
    }
}

Schließlich wird die konkrete Unterklasse (unter Beibehaltung des MmsVcardRequest Beispiel) wie folgt aus:

public static final class Builder extends MmsRequest.Builder<MmsVcardRequest> {
    Builder() {}

    /**
    * (REQUIRED)
    * Sets the URL of the vCard attachment. Supports only <code>.vcf</code> file extension.
    *
    * @param url The URL as a string.
    * @return This builder.
    */
    public Builder url(String url) {
        return (Builder) super.url(url);
    }

    @Override
    public MmsVcardRequest build() {
        return new MmsVcardRequest(this);
    }
}

Beachten Sie, dass wir den Rückgabetyp von super.url(url) auf diesen Builder übertragen, da der Rückgabetyp der Supermethode com.vonage.messages.mms.MmsRequest.Builderist, nicht com.vonage.messages.mms.MmsVcardRequest.Builder.

Beachten Sie, dass wir diese Methode nur überschrieben haben, um ihr Javadocs hinzuzufügen, nicht um ihre Funktionalität zu ändern. Dies verdeutlicht jedoch perfekt das Problem, das der parametrisierte Builder-Typ zu lösen versucht. Zur Veranschaulichung wollen wir versuchen, diesen Builder zu verwenden:

MmsVcardRequest message = MmsVcardRequest.builder()
    .from("447900090000").to("447900090001")
    .url("https://www.example.com/path/to/contact.vcf")
    .build();

Der Compiler gibt einen Fehler aus: Cannot resolve method 'url' in 'Builder'. Im Gegensatz dazu funktioniert das Folgende:

MmsVcardRequest message = MmsVcardRequest.builder()
    .url("https://www.example.com/path/to/contact.vcf")
    .from("447900090000").to("447900090001")
    .build();

Was ergibt das? Es sind genau die gleichen Informationen, aber die Methoden werden in einer anderen Reihenfolge aufgerufen. Ist es nicht der Sinn eines Builders, Flexibilität bei der Reihenfolge der Methodenaufrufe zu ermöglichen? Für ein gutes Benutzererlebnis ist es notwendig, diese Situationen zu berücksichtigen.

Der Benutzer sollte sich nicht darum kümmern müssen, welche Klassen welche Eigenschaften beisteuern - dies sind interne Implementierungsdetails. Eine ausführliche Lösung ist das Überschreiben jeder Methode in der konkreten Unterklasse von MessageRequest.Builder. Zum Beispiel, in MmsVcardRequesthätten wir dann:

public static final class Builder extends MmsRequest.Builder<MmsVcardRequest> {
    Builder() {}

    public Builder from(String from) {
        return (Builder) super.from(from);
    }

    public Builder to(String to) {
        return (Builder) super.to(to);
    }

    public Builder clientRef(String clientRef) {
        return (Builder) super.clientRef(clientRef);
    }

    public Builder url(String url) {
        return (Builder) super.url(url);
    }

    @Override
    public MmsVcardRequest build() {
        return new MmsVcardRequest(this);
    }
}

Aber das macht den Sinn der Vererbung zunichte, da wir Informationen wiederholen! Aus diesem Grund parametrisieren wir den Builder-Typ. Der Compiler sorgt dafür, dass immer der konkreteste Subtyp zurückgegeben wird.

Da unsere Builder-Klassen jedoch erweitert werden können, müssen wir dies auch in den Parameterdeklarationen kodieren, daher die Bindung B extends Builder<? extends M, ? extends B> im Gegensatz zu B extends Builder<M, B>.

Leider müssen wir immer noch den Rückgabetyp des Builders auf B jedes Mal, wenn wir return thisaufrufen, aber soweit ich weiß, ist das eine Einschränkung des Compilers. Zum Glück muss das Casting nur in den abstrakten Builder-Klassen erfolgen, nicht in den konkreten Typen.

Schlussfolgerung

Ich hoffe, Sie haben in diesem Artikel ein einigermaßen nützliches (wenn auch vielleicht etwas verworrenes) Muster für die Verwendung des Builder-Musters kennen gelernt, wenn abstrakte Klassen und Vererbung im Spiel sind. Vielleicht werden solche Muster eines Tages obsolet, wenn die Sprache bessere Möglichkeiten zur Instanziierung von Objekten bietet. Bis dahin helfen uns zumindest die Generics, so entmutigend die Arbeit mit ihnen manchmal auch sein mag!

Wir freuen uns immer über die Beteiligung der Gemeinschaft. Sie können sich uns gerne auf dem Vonage Community Slack oder senden Sie uns eine Nachricht auf Twitter. Wenn Sie Vorschläge für Verbesserungen oder Erweiterungen haben oder einen Fehler entdecken, zögern Sie nicht, einen Fehler auf GitHub.


Teilen Sie:

https://a.storyblok.com/f/270183/400x400/46a3751f47/sina-madani.png
Sina MadaniVonage Ehemaliges Teammitglied

Sina ist Java Developer Advocate bei Vonage. Er hat einen akademischen Hintergrund und ist generell neugierig auf alles, was mit Autos, Computern, Programmierung, Technologie und der menschlichen Natur zu tun hat. In seiner Freizeit geht er gerne spazieren oder spielt Videospiele.