Liigu peamise sisu juurde

Builder

Sissejuhatus

Seni oleme käsitlenud mustreid, mille eesmärk on otsustada, millist klassi luua. Builder muster erineb nendest, keskendudes pigem sellele, kuidas üht objekti looma peaks. Seda täpsemalt siis olukorras, kus loodaval objektil on palju välju ning millest enamik võivad valikulised olla.

Konstruktor on kõige lihtsam viis objekti loomiseks, kuid sellel omad piirangud: kui parameetreid on liiga palju - eriti valikulisi - muutub see kiiresti ebapraktiliseks.

Probleemist lähemalt

Vaatleme probleemi läbi HTTP päringute. HTTP-päringul on mõned kohustuslikud väljad - URL ja meetod (GET/POST jne.) - ning pikk nimekiri valikulistest: päised (headers), päringu sisu (body), parameetrid, ajapiirang, kordustearv, ümbersuunamine jne.

Koodis see tähendaks, et kuidagi peaks päringu loomise hetkel saama neid määrata. Naiivne lahendus oleks kõik ühe suure konstruktori kaudu ära teha:

public HttpRequest(String url, String method,
Map<String, String> headers,
String body, int timeoutMs,
int retries, boolean followRedirects) {
// ...
}

Selline lähenemine omakorda tekitaks koodi, mida on raske lugeda:

HttpRequest req = new HttpRequest("https://api.example.com/users", "GET",
null, null, 0, 0, false);

Näiteks, mida antud olukorras 0-id tähendavad? Mida tähistab null? Sellest ei ole võimalik aru saada ilma lähtekoodi vaatamata.

Alternatiiv sellele oleks pakkuda mitu konstruktorit erinevate parameetrikombinatsioonidega, mida tuntakse telescoping constructor antipattern nime all:

public HttpRequest(String url, String method) { ... }
public HttpRequest(String url, String method, Map<String, String> headers) { ... }
public HttpRequest(String url, String method, Map<String, String> headers,
String body) { ... }
// ... and so on

Ka seegi lähenemine oleks ebamõistlik, nagu nimigi ütleb (antipattern...). Iga uus valikuline väli kahekordistab võimalike konstruktorite arvu ning kutsuv kood peab endiselt meeles pidama parameetrite täpset järjekorda.

Builder muster

Builder muster eraldab objekti koostamise sellest, mida objekt peaks sisaldama. Selle asemel, et kõik väärtused korraga konstruktorisse anda, seab kasutaja väljad samm-sammult abiklassi abil ning lõpuks palub sellel saadud andmete põhjal valmis objekt tagastada.

Näiteks:

HttpRequest request = HttpRequest.builder()
.url("https://api.example.com/users")
.method("POST")
.header("Content-Type", "application/json")
.header("Authorization", "Bearer abc123")
.body("{\"name\":\"Alice\"}")
.timeout(5000)
.build();

Nüüd käitub kood kui kirjeldusena: igal väljal on nimi, järjekord ei ole oluline ning valikulised väljad võib lihtsalt välja jätta.

Teostus ise koosneb kahest osast: HttpRequest klassist endast ja selle sees olevast Builder klassist, mis kogub väljad kokku.

public class HttpRequest {
private final String url;
private final String method;
private final Map<String, String> headers;
private final String body;
private final int timeoutMs;

// The constructor is private - the only way to create an HttpRequest
// is through the Builder.
private HttpRequest(Builder builder) {
this.url = builder.url;
this.method = builder.method;
this.headers = Map.copyOf(builder.headers);
this.body = builder.body;
this.timeoutMs = builder.timeoutMs;
}

public static Builder builder() {
return new Builder();
}

public static class Builder {
private String url;
private String method;
private final Map<String, String> headers = new HashMap<>();
private String body;
private int timeoutMs = 30_000; // sensible default

public Builder url(String url) {
this.url = url;
return this;
}

public Builder method(String method) {
this.method = method;
return this;
}

public Builder header(String key, String value) {
this.headers.put(key, value);
return this;
}

public Builder body(String body) {
this.body = body;
return this;
}

public Builder timeout(int timeoutMs) {
this.timeoutMs = timeoutMs;
return this;
}

public HttpRequest build() {
if (url == null || url.isBlank()) {
throw new IllegalStateException("URL is required");
}
if (method == null || method.isBlank()) {
throw new IllegalStateException("HTTP method is required");
}
return new HttpRequest(this);
}
}
}

Pange tähele:

  • Iga setter tagastab this. See võimaldab aheldatud süntaksit (.url(...).method(...).build()). Sellist koodistiili nimetatakse fluent interface'iks.
  • header(...) kogub väärtusi, mitte ei asenda neid. Iga väljakutse lisab uue kirje, mis võimaldab lisada suvalise arvu päiseid.
  • HttpRequest konstruktor on privaatne. Objekti ei saa luua ilma builder'ita ega poolikus olekus.
  • build() vastutab valideerimise eest. Kohustuslikud väljad kontrollitakse siin - viga ilmneb objekti loomisel, mitte hiljem kasutamisel.
  • HttpRequest on muutumatu (immutable). Setterid puuduvad, builder võimaldab keerulist objekti koostada nii, et valmis objekt jääb muutumatuks.

Kohustuslikud ja valikulised väljad

Builder'ite puhul on kaks levinud stiili väljadega tegelemiseks:

  1. Kõik väljad käivad läbi builder'i, ning build() kontrollib, et kohustuslikud väljad on määratud. Seda stiili kasutasime ka eelnevas näites.

  2. Kohustuslikud väljad antakse builder'i konstruktorisse, ning ainult valikulised väljad seadistatakse setter'itega:

    HttpRequest.builder("https://api.example.com/users", "GET")
    .timeout(5000)
    .build();

Esimene lähenemine on ühtlasem ja loetavam. Teine samas välistab võimaluse, et kohustuslik väli ununeb määramata, kuid toob kaasa konstruktorid, mis kasvavad iga uue kohustusliku väljaga. Mõlemad variandid on täiesti sobivad - valik sõltub konkreetsest olukorrast.

Kasutusjuhud

Builder'it tasub kasutada, kui:

  • Klassil on palju välju, eriti kui enamik neist on valikulised
  • Soovid, et loodav objekt oleks muutumatu.
  • Väljade seadistamise järjekord ei tohiks kasutaja jaoks oluline olla.

See on üleliigne väikeste klasside puhul, millel on vaid kaks või kolm välja - sellisel juhul on tavaline konstruktor selgem ja praktilisem.