Idiotoodporne API klasy, czyli jakie?
W wpisie z serii „Effective Java” na moim blogu omawiałem wzorzec projektowy Builder. Była tam mowa o tym, że zazwyczaj w metodzie public Goal build()
przed zbudowaniem obiektu weryfikujemy, czy wszystkie wymagane pola zostały podane. Dla przykładowej klasy z poprzedniego wpisu wyglądało to tak:
public class Goal {
private String name;
private String description;
private List<Level> levels;
private Checklist checklist;
private LocalDate deadline;
private boolean achieved;
public static Builder builder() {
return new Builder();
}
public static final class Builder {
//...
public Goal build() {
if(name.isEmpty()){
throw new IllegalStateException("Name cannot be empty");
}
if(description.isEmpty()){
throw new IllegalStateException("Name cannot be empty");
}
if(levels.isEmpty()){
throw new IllegalStateException("Levels cannot be empty");
}
Goal goal = new Goal();
goal.deadline = this.deadline;
goal.name = this.name;
goal.checklist = this.checklist;
goal.levels = this.levels;
goal.description = this.description;
goal.achieved = this.achieved;
return goal;
}
}
}
Mamy tu dwa problemy.
- Dla każdego wymaganego pola musimy pisać
if
, który sprawdza, czy pole zostało podane. Jeśli nie, rzuca wyjątkiem, co nie jest zbyt ładnym rozwiązaniem. - Klient, który używa buildera nie ma pojęcia o tym, co jest wymagane, a co nie. Dowie się dopiero wtedy, gdy spróbuje odpalić program i dostanie wyjątek.
Możemy uprzyjemnić kod dla klienta i wyeliminować te problemy poprzez odpowiednio zaprojektowanie klasy - a z pomocą przychodzą interfejsy.
Idiotoodporne API klasy
W przykładzie z builderem będzie to działało tak, że przed zbudowaniem obiektu klient będzie musiał użyć wszystkich wymaganych metod w odpowiedniej kolejności. Jeśli tego nie zrobi, nie będzie miał nawet możliwości wywołania metody build()
. W skrócie - będzie cały czas prowadzony za rączkę.
Do klasy (w tym przypadku Goal
) będziemy musieli dodać wewnętrzne publiczne interfejsy. Wewnętrzne, bo nie ma potrzeby definiowania ich w globalnym zasięgu, przez co drzewo pakietowe może całkiem spuchnąć (takich interfejsów może być sporo). Preferuję zrobić to na końcu klasy:
public class Goal {
//fields
public static class Builder {
//builder implementation
}
public interface NeedName {
public NeedDescription name(String name);
}
public interface NeedDescription {
NeedLevels description(String description);
}
public interface NeedLevels {
NeedLevels addLevel(Level level);
CanBeBuild and();
}
public interface CanBeBuild {
CanBeBuild checklist(Checklist checklist);
CanBeBuild deadline(LocalDate deadline);
CanBeBuild achieved();
Goal build();
}
}
Jeśli klasa będzie używana tylko w tym samym pakiecie, interfejsy mogą pozostać package-private, czyli można im usunąć modyfikator dostępu public
.
Interfejsy te odwzorowują kolejne kroki w używaniu API klasy. W tym przykładzie interfejsy nazwane według konwencji Need*
określają kolejne pola, które builder musi mieć zdefiniowane oraz zwracają kolejny interfejs, który odpowiada za następny krok. Ostatni interfejs CanBeBuild
określa, że klasa jest już gotowa do wykonania finalnej metody (w tym przypadku build()
) oraz umożliwia jeszcze ustawienie opcjonalnych pól.
Teraz wystarczy sprawić, żeby builder implementował wszystkie zdefiniowane interfejsy oraz zmodyfikować metodę build()
, aby zwracała interfejs buildera reprezentujący pierwszy krok. W tym przypadku to NeedName
:
public class Goal {
//fields
public static NeedName builder() {
return new Builder();
}
public static class Builder {
//builder implementation
}
//interfaces
}
Cała klasa z builderem będzie wyglądać następująco:
public class Goal {
private String name;
private String description;
private List<Level> levels;
private Checklist checklist;
private LocalDate deadline;
private boolean achieved;
public static NeedName builder() {
return new Builder();
}
public static class Builder implements NeedName, NeedDescription, NeedLevels, CanBeBuild {
private String name;
private String description;
private List<Level> levels;
private Checklist checklist;
private LocalDate deadline;
private boolean achieved = false;
@Override
public Builder name(String name) {
this.name = name;
return this;
}
@Override
public Builder description(String description) {
this.description = description;
return this;
}
@Override
public Builder checklist(Checklist checklist) {
this.checklist = checklist;
return this;
}
@Override
public Builder deadline(LocalDate deadline) {
this.deadline = deadline;
return this;
}
@Override
public Builder achieved() {
this.achieved = true;
return this;
}
@Override
public Builder addLevel(Level level) {
levels.add(level);
return this;
}
@Override
public Builder and() {
return this;
}
public Goal build() {
Goal goal = new Goal();
goal.deadline = this.deadline;
goal.name = this.name;
goal.checklist = this.checklist;
goal.levels = this.levels;
goal.description = this.description;
goal.achieved = this.achieved;
return goal;
}
}
public interface NeedName {
public NeedDescription name(String name);
}
public interface NeedDescription {
NeedLevels description(String description);
}
public interface NeedLevels {
NeedLevels addLevel(Level level);
CanBeBuild and();
}
public interface CanBeBuild {
CanBeBuild checklist(Checklist checklist);
CanBeBuild deadline(LocalDate deadline);
CanBeBuild achieved();
Goal build();
}
}
Trochę zabiegu z tym jest, szczególnie w bardziej rozbudowanych przypadkach, ale dostajemy dzięki temu „idiotoodporną” klasę, której nie da się źle użyć. Bo jak teraz wygląda korzystanie z buildera?
Dobieramy się do instancji buildera i próbujemy wywołać metodę, a tam dostępna jest tylko opcja podania name()
:
Zwracany jest interfejs NeedDescription
i analogicznie można użyć tylko jednej metody:
Następnie zwracany jest interfejs NeedLevels
, który umożliwia dodanie dowolnej liczby poziomów do naszego celu:
i z pomocą metody and()
- przejście do ostatniego kroku, gdzie mamy już możliwość zbudowania obiektu lub jeszcze użyć jakiejś opcjonalnej metody:
Więc dodajmy jeszcze deadline:
i możemy w końcu zbudować klasę:
Jak widać, dzięki takiemu rozwiązaniu możemy w dowolny sposób manipulować tym, w jaki sposób klasa ma być używana przez klienta.
Można tak zaprojektować dowolną klasę. Jest to całkiem elastyczne i uniwersalne rozwiązanie.
Jeśli spodobał Ci się wpis, zajrzyj na bloga autora.