W świecie programistycznym istnieje wiele wskazówek jak tworzyć dobre oprogramowanie. Jedną z nich jest znany (i zapewne lubiany) mnemonik SOLID, o którym powstało wiele artykułów. Opisuje on podstawowe zasady jakich powinno się przestrzegać podczas programowania. Jeśli, więc oprze się swój kod o SOLID to będzie on charakteryzował się wysoką jakością. Wśród tak popularnych wytycznych ciężko dostrzec te mniej znane. Właśnie jednym z nich jest GRASP, który zawiera naprawdę ciekawy zbiór reguł dla programowania obiektowego. W tym artykule przyjrzymy się mu bliżej.

Czym jest GRASP?

Rozwinięciem skrótu GRASP jest General Responsibility Assignment Software Patterns, czyli w wolnym tłumaczeniu - ogólne wzorce przypisania odpowiedzialności w oprogramowaniu. Brzmi to dosyć enigmatycznie, nieprawdaż? Czym właściwie jest odpowiedzialność? W Internecie znajdziemy takie oto wyjaśnienia:

  • nie powinno być więcej niż jednego powodu do istnienia klasy bądź metody
  • jednostka jest zobligowana do wykonywania określonego zadania albo przechowywania informacji

Dalej może być to niejasne, ale już mniej więcej wiemy, że dane twory mają określone zadanie do wykonania. Nie powinno być ich jednak więcej niż jedno. No dobrze, to teraz przejdźmy do tego co składa się na GRASP. Jest to lista dziewięciu zasad, którymi warto podążać.

  • Information Expert - Ekspert Informacji
  • Creator - Kreator
  • Controller - Kontroler
  • Low Coupling - Niskie sprzężenie
  • High Cohesion - Wysoka spójność
  • Indirection - Niebezpośredniość
  • Polymorphism - Polimorfizm
  • Pure Fabrication - Czysta fabrykacja
  • Protected Variations - Chroniona zmienność

Starałem się te zasady przetłumaczyć na polski, ale nawet wtedy nie oddają idei kryjącej się za nimi. Z tego powodu przejdziemy sobie teraz po kolei po każdej z nich patrząc co właściwie chcą nam one przekazać.

Information Expert - Ekspert Informacji

Ta reguła mówi nam, że powinno się przypisać daną odpowiedzialność do obiektu jeśli posiada on informacje niezbędne do jej spełnienia. Mogą one znajdować się w tym obiekcie lub innych obiektach z nim współpracujących. Dobrym przykładem jest koszyk zakupowy, który ma w sobie listę zakupów. Możemy dać mu odpowiedzialność do wyliczenia kosztu całkowitego zakupów, ponieważ ma on wszelkie niezbędne do tego informacje.

1
2
3
4
5
6
7
8
9
10
11
12
public class Cart {

  private final List<Order> orderList;

  // ...

  public Money totalCost() {
    return orderList.stream()
        .map(Order::price)
        .reduce(Money.ZERO, Money::add);
  }
}

Jak widzisz jest to dosyć prosta zasada. Jeśli obiekt nie ma niezbędnych informacji, których potrzebujemy, to nie można mu przypisać danej odpowiedzialności.

Creator - Kreator

W niektórych przypadkach obiekty są odpowiedzialne za tworzenie innych obiektów. Ponownie pojawiło się słowo odpowiedzialność. W zasadzie Kreator chodzi o to, że wybrany obiekt może tworzyć obiekty, które zawiera. W ten sposób ukrywa przed światem zewnętrznym ten szczegół implementacyjny. Załóżmy, że prowadzimy wypożyczalnię wideo. Oczywiste jest, że jej biznes polega na pożyczaniu klientom filmów wideo za opłatą. Projektując system postanowiliśmy, że to klient będzie przechowywał w sobie listę wypożyczeń. Staję się on, więc ich agregatem.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Client {

  private final ClientId id;
  private final List<Rental> rentals;
  private final EventsBus eventsBus;
  
  // ...

  public void addRental(RentalId rentalId, int days) {
    Rental rental = new Rental(rentalId, days);

    if (rentals.size() >= 5) {
      throw new BusinessRuleValidationException("Client cannot have more than 5 rentals");
    }
    if (days >= 14) {
      throw new BusinessRuleValidationException("Rental cannot be longer than 14 days");
    }
    
    rentals.add(rental);
    
    eventsBus.publish(new RentalAddedEvent(id, rentalId, days));
  }
}

Klasa Client tworzy wewnątrz metody addRental obiekt klasy Rental, ponieważ jest on mu niezbędny do weryfikacji reguł biznesowych. Tak naprawdę klasa Rental mogłaby być klasą pakietową. Powstała ona tylko na potrzeby lepszej czytelności kodu i świat zewnętrzny nie musi o niej nic wiedzieć.

Controller - Kontroler

Każdy system musi mieć swojego wewnętrznego zarządcę, który będzie delegował zadania do innych obiektów. Stanowi on zewnętrzny interfejs naszego systemu przez, który przekazuje dalej jakie biznesowe zadanie jest do wykonania. Dobrym przykładem są kontrolery np. Springa. Wystawiają one endpointy HTTP do naszego systemu i agregują w sobie różne serwisy, którym zlecają pracę do wykonania w zależności od odebranego żądania.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController
@RequestMapping("/shelter")
class ShelterController {

  private final ShelterCommandHandler shelterCommandHandler;

  // ...
    
  @PostMapping
  ResponseEntity<Result> acceptAnimalIntoShelter(AcceptAnimalRequest request) {
    shelterCommandHandler.handle(new AcceptAnimalCommand(AnimalId.create(), request.name(), request.species(), request.age()));
    return ResponseEntity.ok(Result.success());
  }
}

Powyżej przedstawiam kod jako potwierdzenie tego co opisałem wcześniej. Istnieje klasa ShelterController, która wystawia enpoint POST ‘/shelter’ niezbędny do akceptowania zwierząt do schroniska. Następnie deleguje zadanie do wykonania do ShelterService, a dokładniej do jej metody accept. To jest cała odpowiedzialność naszego kontrolera.

Low Coupling - Niskie sprzężenie

Coupling, czyli po polsku sprzężenie, jest miarą zależności dwóch elementów do siebie. Im wyższa wartość tym większe uzależnienie jednej klasy od drugiej. Natomiast w drugą stronę, niski coupling oznacza, że dwa elementy są od siebie niezależne i dobrze odizolowane. Ten drugi stan jest pożądany, ponieważ wtedy nie musimy się martwić, że zmieniając coś w jednym miejscu nie zepsujemy czegoś w kompletnie niepowiązanym miejscu.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class ParcelLocker {

  private final EventsBus eventsBus;
  private final List<Parcel> parcels;

  ParcelLocker(final EventsBus eventsBus) {
    this.eventsBus = eventsBus;
    this.parcels = new ArrayList<>();
  }
  
  public void addParcel(ParcelId parcelId, int weight, String desc) {
    if (parcels.size() >= 30) {
      throw new BusinessRuleValidationException("Cannot place more than 30 parcels");
    }
    
    parcels.add(new Parcel(parcelId, weight, desc));
    
    eventsBus.publish(new ParcelAddedEvent(parcelId));
  }
}

W tym przypadku mamy dużą zależność klasy ParcelLocker od Parcel. Co w przypadku, gdyby do klasy Parcel doszły następne pola? Musielibyśmy również zrobić odpowiednie zmiany w ParcelLocker. Lepszym podejściem byłoby mieć tylko listę id paczek zamiast wszystkich informacji o nich. W ten sposób unikamy niepotrzebnego splątania tych dwóch pojęć ze sobą. Jeśli doszłoby pole z ceną do paczki to nie musielibyśmy dotykać w ogóle ParcelLocker. Unikamy zbędnej odpowiedzialności, która byłaby niezdrowa dla naszego kodu. Wysoki coupling może objawiać się również rozszerzaniem klas przez siebie, zbyt dużą ilością pól do zarządzania czy agregacją do konkretnej klasy zamiast interfejsu.

High Cohesion - Wysoka spójność

Kohezja jest kolejną miarą. Odpowiada nam ona na pytanie jak mocno odpowiedzialności danego elementu są ze sobą powiązane. W jakim stopniu części tego obiektu zależą od siebie.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class GmailValidator {

  private final EmailSender emailSender;

  EmailValidator(final EmailSender emailSender) {
    this.emailSender = emailSender;
  }

  public boolean validate(Email email) {
    return email.value().endsWith("@gmail.com");
  }

  public void send(Email email) {
    emailSender.send(email);
  }
}

Ta klasa charakteryzuje się niską kohezją, ponieważ ma za dużo odpowiedzialności na sobie. Jej nazwa wskazuje, że powinna ona walidować emaile natomiast ktoś dodał jeszcze jej zadanie wysyłki wiadomości. Powinno się wyodrębnić osobną klasę specjalnie do tego dedykowaną, a walidacja powinna pozostać nienaruszona.

1
2
3
4
5
6
class GmailValidator {

  public boolean validate(Email email) {
    return email.value().endsWith("@gmail.com");
  }
}

Indirection - Niebezpośredniość

Reguła niebezpośredniości mówi nam w jaki sposób uniknąć bezpośredniej zależności pomiędzy dwoma lub więcej elementami. Należy, więc wprowadzić dodatkową jednostkę, która będzie pośrednikiem pomiędzy innymi obiektami. Efektem ubocznym zastosowania zasady Indirection jest zmniejszenie couplingu. Powstało wiele wzorców projektowych, które pomagają tą regułę spełnić np. Adapter, Fasada, Obserwator czy Mediator.

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
class EmployeeService {

  private final EmployeeSalaryManager employeeSalaryManager;

  // ...

  public void raiseSalary(final EmployeeId employeeId, final int raise) {
    if (raise <= 0) {
      throw new BusinessRuleValidationException("Raise cannot be negative");
    }

    employeeSalaryManager.register(employeeId, raise);
  }
}

class EmployeeSalaryManager {

  private final EmployeeRepository employeeRepository;
  private final BossNotifier bossNotifier;

  // ...

  void register(final EmployeeId employeeId, final int raise) {
    employeeRepository.updateSalary(employeeId, raise);
    
    bossNotifier.notify(new EmployeeGotRaiseEvent(employeeId, raise));
  }
}

class BossNotifier {

  private final EmailSender emailSender;

  // ...

  void notify(EmployeeGotRaiseEvent event) {
    emailSender.send(new Email(event.employeeId(), event.raise()));
  }
}

Mamy tutaj liniową zależność. EmployeeService zależy od EmployeeSalaryManager, a EmployeeSalaryManager zależy od BossNotifier. Nie jest to najlepszy pomysł, ponieważ przy zmianie biznesowej tego procesu będziemy musieli się sporo napracować. Lepiej jest wprowadzić dodatkową abstrakcję, która będzie zarządzała całym zadaniem zwiększenia pensji pracownikowi. Wykorzystamy w tym przypadku wzorzec Mediatora, który będzie orkiestratorem całego procesu.

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
44
45
46
47
class EmployeeMediator {

  private final RaiseValidator raiseSalary;
  private final EmployeeSalaryManager employeeSalaryManager;
  private final BossNotifier bossNotifier;

  // ...

  public void raiseSalary(final EmployeeId employeeId, final int raise) {
    raiseSalary.validate(raise);
    
    employeeSalaryManager.register(employeeId, raise);

    bossNotifier.notify(new EmployeeGotRaiseEvent(employeeId, raise));
  }
}

class RaiseValidator {

  public void validate(final int raise) {
    if (raise <= 0) {
      throw new BusinessRuleValidationException("Raise cannot be negative");
    }
  }
}

class EmployeeSalaryManager {

  private final EmployeeRepository employeeRepository;

  // ...

  void register(final EmployeeId employeeId, final int raise) {
    employeeRepository.updateSalary(employeeId, raise);
  }
}

class BossNotifier {

  private final EmailSender emailSender;

  // ...

  void notify(EmployeeGotRaiseEvent event) {
    emailSender.send(new Email(event.employeeId(), event.raise()));
  }
}

Teraz otrzymaliśmy większą elastyczność oraz przy okazji zauważyliśmy, że klasa EmployeeService nie robiła nic innego niż walidowanie wartości podwyżki. Z tego powodu warto było zmienić jej nazwę na RaiseValidator. Proces stał się klarowniejszy i jest o wiele prostszy w zarządzaniu. Indirection również ograniczyła odpowiedzialności poszczególnych klas. EmployeeService tak naprawdę nie powinien zapisywać informacji o podwyżce.

Polymorphism - Polimorfizm

Jedną z fundamentalnych zasad programowania obiektowego jest polimorfizm. Dzięki niemu nie musimy być uzależnieni od konkretnej implementacji. Możemy dostarczyć różne rozwiązania, a kod nawet tego nie zauważy. Dla niego to nie będzie istotne. Ważne tylko, aby został spełniony kontrakt zawarty pomiędzy naszą klasą a dostarczoną abstrakcją.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class PostOffice {

  private final ParcelValidator parcelValidator;
  private final ParcelRepository parcelRepository;

  PostOffice(final ParcelValidator parcelValidator, final ParcelRepository parcelRepository) {
    this.parcelValidator = parcelValidator;
    this.parcelRepository = parcelRepository;
  }

  public void accept(final Parcel parcel) {
    parcelValidator.isValid(parcel)
        .ifPresent(ruleViolation -> { throw new BusinessRuleValidationException(ruleViolation.message()); });
    
    parcelRepository.accept(parcel);
  }
}

interface ParcelValidator {
  Optional<RuleViolation> isValid(Parcel parcel);
}

Dla klasy PostOffice nie jest ważne w jaki sposób będzie walidowana przesyłka. Ona tylko wykorzystuje kontrakt pomiędzy konkretnymi implementacjami implementującymi interfejs ParcelValidator, które zostają dostarczone do niej przez konstruktor (wzorzec Strategii). W ten sposób polimorfizm zwalnia dany obiekt z niepotrzebnej odpowiedzialności.

Pure Fabrication - Czysta fabrykacja

Zdarza się czasami, że ciężko jest dopasować daną odpowiedzialność zgodnie z wcześniejszymi zasadami. Z tego powodu powstała koncepcja “sztucznej” klasy posiadającej zbiór powiązanych ze sobą odpowiedzialności, które nie znalazły miejsca w konkretnym obiekcie. W ten sposób dostarczamy rozwiązanie o wysokiej kohezji i niskim couplingu. Dodatkowo w łatwy sposób można ponownie wykorzystać taką klasę. Zaraz zobaczymy przykład wyjaśniający o co dokładnie chodzi w zasadzie Pure Fabrication.

Załóżmy, że musimy zapisywać informacje o pacjentach w bazie danych. Można byłoby dodać obiekt łączący się z bazą danych w klasie Patient. Jednak takie rozwiązanie ma minusy w postaci otworzenia wielu połączeń do bazy danych oraz przeniesienie wielu odpowiedzialności do klasy Patient. Lepszym sposobem byłoby stworzenie sztucznego tworu w postaci PatientRepository. Nie jest to wprawdzie żaden obiekt domenowy jak Patient, ale taka abstrakcja jest lepsza pod względem infrastrukturalnym. Co więcej jest ona spójna (tylko operacje na bazie danych) i niezależna (może być interfejsem). Oczywiście można też ją wykorzystywać bez problemu w wielu miejscach.

1
2
3
4
5
6
7
8
9
10
11
12
interface PatientRepository {

  List<Patient> getAll();

  Optional<Patient> getBy(PatientId patientId);

  void save(Patient patient);

  void update(Patient patient);

  void delete(PatientId patientId);
}

Protected Variations - Chroniona zmienność

W dzisiejszych czasach jedyną pewną rzeczą jest zmiana. Z tego powodu wytwarzany kod musi być łatwy do przyszłych przekształceń. To nie jest opcjonalna właściwość oprogramowania tylko powinno być to odpowiedzialnością deweloperów, aby zapewnić elastyczność swojego rozwiązania. Zasada Protected Variations wydaje się być w tym wypadku najważniejszą zasadą. Jest to kwintesencja wszystkich poprzednich reguł. Nie będziemy już patrzeć na przykładowy kod. Stosując polimorfizm, enkapsulację danych czy interfejsy, oczywiście w odpowiedni sposób, ochronimy nasze oprogramowanie przed nieoczekiwanymi zmianami.

Podsumowanie

Mam nadzieję, że przybliżyłem Ci inne wskazówki wytwarzania dobrego software niż te zawarte w SOLID. Naprawdę warto się im przyjrzeć i spróbować zaadoptować w swoim kodzie. Oczywiście stosowanie zasad GRASP nie zwalnia nas z myślenia warto, więc się zastanowić czy nie lepiej jest złamać jedną z przedstawionych wyżej reguł, aby kod lepiej odzwierciedlał procesy biznesowe.

Źródła: