Implementacja domeny
Projektowanie i programowanie systemów internetowych I
wykład 7 z 15

Collegium Witelona
mgr inż. Krzysztof Rewak
Zakład Informatyki, Wydział Nauk Technicznych i Ekonomicznych
Collegium Witelona Uczelnia Państwowa
Blumilk sp. z o.o.
Agenda

  1. Domena
  2. Serwisy
  3. Repozytoria
  4. Helpery
  5. Wstrzykiwanie zależności
  6. Podsumowanie
Domena
Definicja

Mawia się, szczególnie wśród starszych stażem programistów, że nie można zbudować dobrego systemu informatycznego bez znajomości dziedziny, której tenże system dotyczy.
Ta dziedzina, ten obszar specjalizacji, to właśnie domena. Często spotkamy to określenie z przyrostkiem jako domena biznesowa.
Zawężanie definicji

Jeżeli tworzymy system informatyczny dla kancelarii podatkowej, prawdopodobnie będziemy musieli nauczyć się nie tylko programować konkretne rzeczy (bardziej skomplikowany system ról i dostępów, opóźnione powiadomienia, grupowe akcje), ale także dowiedzieć się jak działają podatki.
Zadaniem analityków lub innych osób na innych stanowiskach jest oczywiście przełożenie zawiłości domeny na "język programistów", ale naprawdę warto zagłębić się w temat. Chociażby żeby wiedzieć, co się faktycznie programuje.
Przykładowo możemy dostać zadanie: "Zaprogramuj funkcję, która obliczy należny podatek dochodowy dla podanej osoby ze znanym rocznym przychodem". Czy można byłoby ją zapisać następująco?
public int calculateTax(Person person, Year year)
{
    return (int) person.getIncome(year) * 0.12
}
Może to się po chwili skomplikować:
public int calculateTax(Person person, Year year)
{
    if(person.isQualifiedForZeroTax()) {
        return 0
    }

    if(person.getIncome(year) > TaxThreshold.Second) {
        return TaxThreshold.Second * 0.12
               + (person.getIncome(year) - TaxThreshold.Second) * 0.32
    }

    return (int) person.getIncome(year) * 0.12
}
Albo i jeszcze bardziej:
public int calculateTax(Person person, Year year)
{
    int inFirstThreshold = TaxThreshold.First

    int taxedIncome = person.getIncome(year) - TaxThreshold.First
    int inSecondThreshold = person.isQualifiedForZeroTax() ? 0
                              : (taxedIncome > TaxThreshold.Second ? TaxThreshold.Second
                                : taxedIncome)

    int overSecondThreshold = person.getIncome(year) - TaxThreshold.Second
    int inThirdThreshold = overSecondThreshold > 0 ? overSecondThreshold : 0

    return (int) (inFirstThreshold * 0
                  + inSecondThreshold * 0.12
                  + inThirdThreshold * 0.32)
}
spoiler alert: to jest jeszcze bardziej skomplikowane
Cel

Zatem widzimy już, że wypada choć trochę poznać domenę, żeby móc ją poprawnie zaimplementować. W tym celu będziemy oczywiście używali paradygmatu obiektowego i będziemy projektowali różne klasy o różnych przeznaczeniach.
Serwisy
Serwisy

Serwis to bardzo ogólna nazwa, ale w tym kontekście obejmuje ona wszelkiej maści klasy, które wprowadzają zmiany w systemie.
Na poprzednim wykładzie omawialiśmy serwis rejestrujący użytkownika w systemie. Przypomnijmy go sobie:
class UserRegistrar
{
    public UserRegistrar(Hasher hasher, EventDispatcher dispatcher, CacheManager cache)

    public User register(String email, String password) throws UserAlreadyExistsException
    {
        User user = new User()
        user.setName(email)
        user.setPassword(hasher.argon(password))

        user.createOrFail()
        user.notify(new WelcomeMessage(), Channel.Email)

        dispatcher.send(new UserRegistered())
        cache.refresh(Cache.UserStatistics | Cache.RegisterStatistics)
    }
}
Zależności

Zauważmy jak pięknie zaprojektowany jest ten rejestrator:
  • umiemy z kodu powiedzieć co tam się dzieje: hashowane jest hasło, dane są zapisywany w bazie danych, wysyłane jest powiadomienie i przebudowany jest cache...
  • ... ale nie wiemy jak to się dzieje - i bardzo dobrze!
  • widzimy na przykład, że klasa User ma metodę createOrFail(); czy na tym etapie musimy wiedzieć co tam tak naprawdę się dzieje? (nie, nie musimy!)
  • podobnie z pozostałymi funkcjami: delegowane są "dalej", a my "ufamy", że wydarzy się to, co powinno się wydarzyć
Więcej zależności

Jednocześnie możemy zauważyć, że:
  • ta rejestracja może zostać wykonana zarówno z poziomu kontrolera (obsługa formularza rejestracji, ale też tego w panelu administratora), wywołania REST API, konsolowego polecenia, cyklicznego wywołania CRON-em czy też w automatycznym teście
  • klasę tę możemy łatwo rozszerzyć, żeby zbudować np. AdministratorRegistrar poprzez dziedziczenie i ekstrakcję części głównej metody do metod chronionych
  • wszystkie systemowe zależności są przekazane przez konstruktor, a domenowe - bezpośrednio w metodzie register()
Jeszcze więcej zależności

Serwis powinien być domenowy, czyli opisywać rozwiązanie problemu domenowego. Tylko i aż tyle.
Jeżeli rejestrujemy użytkownika, nie możemy w takim serwisie przyjąć obiektu klasy HttpRequest, ponieważ zepsuje to elastyczność takiego serwisu.
Repozytoria
Repozytoria

Repozytorium to kolejna szeroka klasa rozwiązań. Gdy serwisy obsługiwały akcje, repozytoria zwracają nam dane w konkretnej formie.
Mogłoby się wydawać, że pobieranie danych to prosta sprawa... przy większych projektach może okazać się, że jest to wystarczająco skomplikowane, żeby budować wokół tego warstwę abstrakcji.
class DatabaseUsersRepository implements UsersRepositoryContract
{
    public Collection<User> get()
    {
        return (new UserQuery()).limit(50).get().mapInto(User.class)
    }
}
class CacheUsersRepository implements UsersRepositoryContract
{
    public CacheUserRepository(Client redis)

    public Collection<User> get()
    {
        return this.redis.getLatest(50).hydrate(User.class)
    }
}
class DummyUsersRepository implements UsersRepositoryContract
{
    public Collection<User> get()
    {
        // return new Collection(File.getJson("./users.json")).mapInto(User.class)
        return new Collection(for n in 50 => new User(...getRandomUserData()))
    }
}
Dane, dane, dane

Wiele systemów internetowych opiera się mocno na danych, które przedstawiane są użytkownikom w jakiejś konkretnej formie. Jeżeli kontroler obsługujący tabelę użytkowników będzie potrzebował listy użytkowników, możemy śmiało zakodować, że będziemy wykorzystywali tam UsersRepositoryContract.
Wówczas możemy uzależnić od środowiska kiedy jakie dane naprawdę dostaniem. DatabaseUsersRepository zostanie użyty na środowisku deweloperskim, CacheUsersRepository na produkcyjnym, a DummyUsersRepository - podczsa testów.
Nomenklatura

Warto jest zadać sobie pytanie jak budować repozytoria. Czy lepiej mieć jedno NewsRepository z wieloma metodami takimi jak getLatest() czy getById(). A może lepiej przekazać mnóstwo parametrów do jednej metody, np. get(limit: 50, sort: publishedAt)? A może zrobić osobne repozytoria LatestNewsRepository i NewsRepository?
Ponownie, podobnie jak przy serwisach, nie ma jednej odpowiedzi.
Agregaty

Repozytoria natomiast są z reguły klasami agregującymi obiekty innej klasy. Warto pamiętać o tym, że w różnych kontekstach być może będziemy potrzebowali inaczej zbudowanych danych.
I na przykład lista najpopularniejszych produktów będzie inaczej wyświetlana dla gościa, inaczej dla użytkownika sklepu z ustawionymi preferencjami, a jeszcze inaczej dla administratora. Nie tylko względem zawartości, ale także jej struktury.
DTO i Value object

Dobrze jest umieć poprawnie modelować obiekty, które są istotne biznesowo w systemie. Do najpopularniejszych sposobów należą podejścia DTO i Value object.
DTO (data transfer object) opisywane są przez bardzo proste klasy, które nie mają metod czy pól prywatnych. DTO służą głównie do przenoszenia danych między warstwami lub modułami systemu.
Value object to klasa, której obiekt opisuje pojedynczy element procesu biznesowego. Jej pola najczęściej będą tylko do odczytu. Często przyjmuje się, że dane wewnątrz są już zwalidowane i poprawne.
Helpery
Helpery

Helpery to klasy, które mają nam pomagać w codziennym życiu. Młodszych stażem programistów może kusić nazywanie wszystkiego helperem i używanie ich wszędzie, ale warto znać umiar.
Nie jest to reguła, ale dobrze jest projektować helpery jako małe klasy bez większych zależności oraz z jedną metodą statyczną. Jeżeli w trakcie programowania okazuje się, że trzeba je rozszerzyć - wówczas warto zastanowić się czy przypadkiem nie przepisać ich jako serwis.
final class CommonDate
{
    public static String date(Datetime datetime)
    {
        return datetime.locale("pl").timezone("UTC+1").format("Y-m-d")
    }

    public static String datetime(Datetime datetime)
    {
        return datetime.locale("pl").timezone("UTC+1").format("Y-m-d H:i:s")
    }
}
Enumy

Warto korzystać z typów wyliczeniowych, jeżeli nasz język programowania jest w nie wyposażony. Ułatwia to rozumienie czytanego kodu oraz zabezpiecza nas przed ustawieniem czegoś, co nie powinno się zdarzyć.
enum ShippingStatus
{
    case Draft
    case Pending
    case InTransit
    case OutForDelivery
    case Delivered
    case Failed
}
order.shipping.status = ShippingStatus.InTransit
order.shipping.save()
Wstrzykiwanie zależności
Wstrzykiwanie zależności

Wstrzykiwanie zależności (ang. dependency injection) to wzorzec projektowy i jedna z popularniejszych technik uelastyczniania systemów informatycznych. Jego głównym założeniem jest usuwanie bezpośrednich zależności pomiędzy klasami na rzecz przekazywania ich poprzez odwrócone sterowanie.
Wróćmy na chwilę do przykładu klasy rejestrującej użytkowników. Tak mogłaby wyglądać, gdybyśmy nie chcieli lub nie umieli wykorzystać wstrzykiwania zależności:
class UserRegistrar
{
    public User register(String email, String password) throws UserAlreadyExistsException
    {
        User user = new User()
        user.setName(email)
        user.setPassword((new Hasher(rounds: 12)).argon(password))

        user.createOrFail()
        user.notify(new WelcomeMessage(), Channel.Email)

        (new EventDispatcher()).send(new UserRegistered())

        CacheManager cache = new CacheManager(config("cache.connection_string"))
        cache.refresh(Cache.UserStatistics | Cache.RegisterStatistics)
    }
}
A przecież może wyglądać tak:
class UserRegistrar
{
    public UserRegistrar(Hasher hasher, EventDispatcher dispatcher, CacheManager cache)

    public User register(String email, String password) throws UserAlreadyExistsException
    {
        User user = new User()
        user.setName(email)
        user.setPassword(hasher.argon(password))

        user.createOrFail()
        user.notify(new WelcomeMessage(), Channel.Email)

        dispatcher.send(new UserRegistered())
        cache.refresh(Cache.UserStatistics | Cache.RegisterStatistics)
    }
}
Kontener zależności

Można byłoby zapytać: w jaki sposób wpisanie klasy lub interfejsu do konstruktora serwisu (lub kontrolera!) pozwala na wykorzystanie konkretnego obiektu? Żeby odpowiedzieć na to pytanie, musimy zrozumieć jak działa routing:
# deklaracja routingu POST /register
router.post("/register", RegisterController.class, "get")
Zauważcie, że nigdzie w całym kodzie nie używamy jawnie new RegisterController()! Jedyne, co robimy, to deklaracja, że taki adres będzie kierował pod taką metodę takiego kontrolera.
Większość frameworków MVC ma zaimplementowany tzw. kontener zależności. Żeby np. nie musieć za każdym razem podawać danych logowania do bazy danych, obiekt konektora tworzony jest raz i zapisywany w rejestrze systemu. Jeżeli jakaś klasa będzie uruchamiana przez kontener zależności, a będzie wymagała tegoż konektora, framework zamiast tworzyć nową instancje konektora, użyje tej już istniejącej.
I wracając do rejestracji użytowników, nie musimy definiować wszystkich konfigurowalnych elementów klas haszującej, wysyłającej zdarzenia oraz zarządzającej cachem, ponieważ jest to ustawione "wyżej", na poziomie konfiguracji frameworka.
class UserRegistrar
{
    public UserRegistrar(Hasher hasher, EventDispatcher dispatcher, CacheManager cache)

    public User register(String email, String password) throws UserAlreadyExistsException
    {
        User user = new User()
        user.setName(email)
        user.setPassword(hasher.argon(password))

        user.createOrFail()
        user.notify(new WelcomeMessage(), Channel.Email)

        dispatcher.send(new UserRegistered())
        cache.refresh(Cache.UserStatistics | Cache.RegisterStatistics)
    }
}
Testy!

Takie podejście maksymalnie ułatwia też testy. Dzięki wprowadzeniu abstrakcji na zależnościach, możemy je łatwo mockować, a więc i łatwiej sterować scenariuszami testów.
(new UserQuery()).truncate()

UserRegistrar registrar = new UserRegistrar(
                            new Hasher(rounds: 1),
                            new FakeEventDispatcher(),
                            new FakeCacheManager(),
                          )

registrar.register("test@example.com", "password")
assert.count((new UserQuery()).get(), 1)
Podsumowanie
Highlights

  • programista zawsze musi wiedzieć, co tak naprawdę programuje: zarówno technicznie, jak i domenowo
  • dobrze prowadzony system powinien mieć podział nie tylko na warstwy (MVC), ale również dobrze modelować domenę
  • niektórzy programiści wręcz mówią, że implementacja domeny powinna być framework-agnostic, a więc całkowicie oderwana od frameworka
  • wstrzykiwanie zależności to wielki przyjaciel programisty i naprawdę warto zrozumieć jak działa
Źródła i do dalszego poczytania

Dziękuję za uwagę