Checked Builder Pattern in Java

Keine Kommentare

Bei der Verwendung des Builder Pattern gibt es immer wieder die Herausforderung,
dass man bei der Erzeugung der finalen Instanz die Gültigkeit aller vorangegangenen
Schritte überprüfen muss. Anders formuliert: Wurde eine gültige Kombination der
Methoden, die die Attribute setzen, verwendet? Dazu sehen wir uns das nachfolgende Beispiel einmal genauer an.
Wir haben zum einen die Klasse, von der Instanzen erzeugt werden sollen. Hier wurde auch gleich ein passender Builder generiert.

public class DataHolder {
 
  public int a;
  public int b;
  public int c;
 
  private DataHolder(Builder builder) {
    this.a = builder.a;
    this.b = builder.b;
    this.c = builder.c;
  }
 
  public static Builder newBuilder() {
    return new Builder();
  }
 
  public static final class Builder {
    private int a;
    private int b;
    private int c;
 
    private Builder() {
    }
 
    public Builder withA(int a) {
      this.a = a;
      return this;
    }
 
    public Builder withB(int b) {
      this.b = b;
      return this;
    }
 
    public Builder withC(int c) {
      this.c = c;
      return this;
    }
 
    public DataHolder build() {
      return new DataHolder(this);
    }
  }
}

Dazu schreiben wir nun noch einen trivialen Validator.

@FunctionalInterface
public interface Validator {
  boolean checkCombination(T dataHolder);
}
 
public class NotZeroValidator implements Validator {
  @Override
  public boolean checkCombination(DataHolder dataHolder) {
    final boolean a = dataHolder.a != 0;
    final boolean b = dataHolder.b != 0;
    final boolean c = dataHolder.c != 0;
    return (a && b && c);
  }
}

Hier soll lediglich sichergestellt sein, dass die Values nicht alle gleichzeitig 0 sein werden. (Über den Sinn kann man natürlich beliebig lange diskutieren. 😉 )

Die Anwendung kann dann wie folgt aussehen.

public class Main {
  public static void main(String[] args) {
 
    final DataHolder build = DataHolder.newBuilder()
        .withA(1).withB(1).withC(1).build();
    final boolean b = new NotZeroValidator().checkCombination(build);
    System.out.println("b = " + b);
  }
}

Allerdings hat dies einige Schwächen. Hier muss man davon ausgehen, dass jeder beteiligte Entwickler es auch kennt und machen wird. Da wir über einen Builder verfügen, liegt es nahe, dies in die Methode build() zu verlegen.
Hierzu modifizieren wir den Builder.

public class DataHolder {
 
//SNIPP
 
  public static final class Builder {
 
//SNIPP
 
    public Optional build() {
      final DataHolder dataHolder = new DataHolder(this);
      final boolean b = new NotZeroValidator().checkCombination(dataHolder);
      if (b) {
        return Optional.of(dataHolder);
      } else {
        return Optional.empty();
      }
    }
  }
}

Ich ändere hier den Rückgabetyp in eine Instanz der Klasse Optional um, um ein null zu vermeiden und nicht im Fehlerfall eine Exception werfen zu müssen.

Die Verwendung ändert sich damit nur geringfügig.

public class Main {
  public static void main(String[] args) {
    final Optional holderOptional = DataHolder.newBuilder()
        .withA(1).withB(1).withC(1).build();
    System.out.println("holderOptional.isPresent() = " 
        + holderOptional.isPresent());
  }
}

Nun wird es sicherlich nicht nur eine Regel geben, die es zu beachten gilt. Also ist der nächste Schritt, eine Menge von Validatoren zu verwenden. Erzeugen wir uns deshalb einen zweiten Validator und fügen diesen der Methode build() hinzu.

public class BusinessRule01Validator implements Validator {
  @Override
  public boolean checkCombination(DataHolder dataHolder) {
    return dataHolder.a + dataHolder.b + dataHolder.c == 3;
  }
}
 
public class DataHolder {
 
// SNIPP
 
    public static final class Builder {
 
// SNIPP
 
    public Optional build() {
      DataHolder dataHolder = new DataHolder(this);
      boolean b = new NotZeroValidator().checkCombination(dataHolder);
      boolean c = new BusinessRule01Validator().checkCombination(dataHolder);
      if (b && c) {
        return Optional.of(dataHolder);
      } else {
        return Optional.empty();
      }
    }
  }
}

Da es sich allerdings um eine größere Menge von Validatoren handeln kann, ist hier eine Liste von Validatoren sinnvoller. Die Liste der Validatoren halten wir in dem jeweiligen Builder vor. Zusätzlich bekommt man die Möglichkeit, zur Laufzeit Validatoren hinzuzufügen und zu entfernen.

public class DataHolder {
 
  //SNIPP
 
  public static final class Builder {
 
  //SNIPP
 
    //add manually - start
    private List<Validator> validatorList = new ArrayList<>();
    public Builder addValidator(Validator validator){
      validatorList.add(validator);
      return this;
    }
 
    public Optional build() {
      final DataHolder dataHolder = new DataHolder(this);
      return validatorList.stream()
          .filter(v->!v.checkCombination(dataHolder))
          .map(v->Optional.empty()) //check false
          .findFirst()
          .orElse(Optional.of(dataHolder));
    }
    //add manually - stop
 
  }
}

Da es sich bei dem Interface Validator um ein FunctionalInterface handelt, kann man natürlich auch mit Lamdas arbeiten.

//classic
    final DataHolder.Builder builder = DataHolder.newBuilder();
 
    final Optional holderOptional = builder
        .withA(1).withB(1).withC(1).build();
    System.out.println(".isPresent() = " + holderOptional.isPresent());
 
    //wrong, but no Validator added
    System.out.println(".isPresent() = " + builder.withC(2).build().isPresent());
 
 
    builder
        .addValidator(new NotZeroValidator())
        .addValidator(new BusinessRule01Validator());
    System.out.println(".isPresent() = " + builder.withC(2).build().isPresent());
    System.out.println(".isPresent() = " + builder.withC(1).build().isPresent());
 
//lamdas
    final DataHolder.Builder builder = DataHolder.newBuilder();
 
    final Optional holderOptional = builder
        .withA(1).withB(1).withC(1).build();
    System.out.println(".isPresent() = " + holderOptional.isPresent());
 
    //wrong, but no Validator added
    System.out.println(".isPresent() = " + builder.withC(2).build().isPresent());
 
    builder
        .addValidator(dataHolder -> {
          final boolean a = dataHolder.a != 0;
          final boolean b = dataHolder.b != 0;
          final boolean c = dataHolder.c != 0;
          return (a && b && c);
        })
        .addValidator(dataHolder -> dataHolder.a + dataHolder.b + dataHolder.c == 3);
    System.out.println(".isPresent() = " + builder.withC(2).build().isPresent());
    System.out.println(".isPresent() = " + builder.withC(1).build().isPresent());

Der nächste Schritt besteht nun darin, die Implementierung generischer zu gestalten.
Nennen wir die generische Builder-Implementierung CheckedBuilder.

public class CheckedBuilder<B, T> {
 
  protected List<Validator> validatorList = new ArrayList<>();
 
  public B addValidator(Validator validator) {
    validatorList.add(validator);
    return (B) this;
  }
 
  protected Optional checkAndGet(T value) {
    return validatorList.stream()
        .filter(v -> !v.checkCombination(value))
        .map(v -> Optional.empty()) //check false
        .findFirst()
        .orElse(Optional.of(value));
  }
}

Damit muss die generierte spezielle Builder-Implementierung nun nur noch minimal
angepasst werden. Der Builder muss von CheckedBuilder erben und in der Methode
build() die Methode checkAndGet(T value) aufrufen.

public class DataHolder {
 
//SNIPP
 
  //extend from CheckedBuilder
  public static final class Builder extends CheckedBuilder<Builder, DataHolder> {
 
//SNIPP
 
    //add manually - start
    public Optional build() {
      final DataHolder dataHolder = new DataHolder(this);
      return checkAndGet(dataHolder);
    }
    //add manually - stop
  }
}

Die Verwendung der Instanz der Klasse Builder erfolgt dann genauso wie vorher.

Only one more thing
Wenn man nicht die Methode build() in dieser Form editieren möchte, kann man auch einen etwas anderen Weg gehen.
Die Methode build() kann auch in den CheckedBuilder verlegt werden, so dass man sie in dem generierten Builder löschen muss. Der CheckedBuilder wird abstract deklariert und bekommt die Methoden protected abstract T createInstance();

public abstract class CheckedBuilder<B, T> {
 
  protected List<Validator> validatorList = new ArrayList<>();
 
  public B addValidator(Validator validator) {
    validatorList.add(validator);
    return (B) this;
  }
 
  private Optional checkAndGet(T value) {
    return validatorList.stream()
        .filter(v -> !v.checkCombination(value))
        .map(v -> Optional.empty()) //check false
        .findFirst()
        .orElse(Optional.of(value));
  }
 
  protected abstract T createInstance();
 
  public Optional build() {
    return checkAndGet(this.createInstance());
  }
}

Damit verändert sich der Builder in der Form, dass man das Ganze sehr leicht in bestehende Templates einbauen kann. Die notwendigen Informationen zum Zeitpunkt des Generierens liegen vollständig vor. Nur leider nicht in der Form, dass man mittels Reflection innerhalb des CheckedBuilder darauf zugreifen kann.

public class DataHolder {
 
 //SNIPP
 
  //extend from CheckedBuilder
  public static final class Builder extends CheckedBuilder<Builder, DataHolder> {
 
 //SNIPP
 
    //implement
    @Override
    public DataHolder createInstance() {
      return new DataHolder(this);
    }
 
    //add manually - start
    //delete build() method
    //add manually - stop
  }
}

Tags

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.