Załóżmy, że przychodzisz do projektu, w którym widzisz jedną wielką encję Hibernate połączoną z innymi encjami przy pomocy adnotacji @OneToMany
, @ManyToOne
czy @OneToOne
. Na początku myślisz, że to fajne rozwiązanie, bo wszystkie dane masz pod ręką. Jednak gdy przychodzi do napisania testu to łapiesz się za głowę. Myślisz sobie: “Jak ja mam to zrobić? Przecież muszę przygotować tak wiele niepotrzebnych danych w kontekście mojego testu!”. I faktycznie, gdy zestawiasz tylko niezbędne informacje to przy uruchomieniu testu dostajesz NullPointerException
. Próbujesz więc dodawać kolejne i kolejne dane, tylko po to aby test w ogóle przeszedł do sekcji asercji. Następnego dnia, pełen frustracji, poruszyłeś ten temat w zespole i udało Ci się przekonać kolegów i koleżanki, aby coś z tym zrobić. Zdecydowaliście się na rozbicie tej wielkiej encji! Powstało natomiast następujące pytanie: “Jak to zrobić?”.
Taki mniej więcej scenariusz spotkał mnie w obecnej pracy. W tym wpisie przedstawię Ci jeden ze sposobów walki z Goliatem, z jakiego skorzystaliśmy. Przejdziemy krok po kroku po przepisie wykorzystującym do tego celu kompozycję.
Krok 0. - Przedstawienie problemu
Załóżmy, że w projekcie zaprojektowano encję Client
, która ma wiele zamówień w postaci listy Order
. Są one połączone ze sobą dwustronnie przez adnotacje @OneToMany
i @ManyToOne
. Dzięki temu bez problemu dostaniemy się do listy zamówień korzystając z klasy Client
, jaki i pozyskamy klienta z każdej klasy Order
. Istny Eden!
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
48
49
50
51
52
@Entity
public class Client {
@Id
@GeneratedValue
private Long id;
@OneToMany(mappedBy = "client")
private List<Order> orders = new ArrayList<>();
//...
public void addOrder(Order order) {
order.setClient(client);
orders.add(order);
}
public ClientId getClientId() {
return ClientId.of(id);
}
public List<Order> getOrders() {
return orders;
}
}
@Entity
public class Order {
@Id
@GeneratedValue
private Long id;
@ManyToOne
private Client client;
//...
public void setClient(Client client) {
this.client = client;
}
public OrderId getOrderId() {
return OrderId.of(id);
}
public Client getClient() {
return client;
}
}
Dysponujemy jednym, centralnym repozytorium ClientRepository
, które uzyskaliśmy za darmo od Spring Data.
1
2
3
4
5
6
7
8
9
public interface ClientRepository extends CrudRepository<Client, Long> {
default Client findByClientIdForce(ClientId clientId) {
return findById(clientId.toLong())
.orElseThrow(() -> new IllegalStateException(
"client cannot be found for id: " + clientId.toLong()))
}
}
W celu wykonania akcji na konkretnym zamówieniu musimy:
- wyciągnąć klienta po id
- wyciągnąć konkretne zamówienie z pobranego klienta
- wykonać wybraną akcje na zamówieniu
Rozglądając się po naszym wyimaginowanym kodzie znajdziemy gdzieniegdzie właśnie takie wykorzystanie repozytorium.
1
2
3
4
5
6
7
Client client = clientRepository.findByClientIdForce(clientId);
Set<ProductCode> uniqueProductCodes = client.getOrders()
.stream()
.map(Order::getProducts)
.flatMap(Collection::stream)
.map(Product::getProductCode)
.collect(Collectors.toUnmodifiableSet());
Na szczęście ktoś pomyślał, aby zaprojektować metodę add(Order)
w klasie Client
. Można było przecież skorzystać z takiego zapisu - .getOrders().add(order)
…
1
2
Client client = clientRepository.findByClientIdForce(clientId);
client.add(order);
Krok 1. - Projektowanie
Zawsze trzeba pamiętać jaki problem rozwiązujemy. Przypominam, że chcemy rozbić dwie encje, aby mogły żyć własnym życiem. Na poziomie bazy danych dalej będzie pomiędzy nimi takie samo powiązanie w postaci klucza obcego (brak konieczności migracji danych). W kodzie natomiast pozbędziemy się wzajemnej świadomości encji. Wszystko to z powodu lepszej testowalności aplikacji.
Jeśli Order
ma żyć swoim życiem musimy stworzyć dla niego własne repozytorium. Cel w postaci ClientRepository
prezentuje się następująco.
1
2
3
4
5
public interface OrderRepository extends CrudRepository<Order, Long> {
Set<Order> findAllByClientId(ClientId clientId);
}
Jednak aby do niego dotrzeć, trzeba wykonać kilka kroków pośrednich. Pierwszym z nich będzie zlokalizowanie miejsc w kodzie, gdzie wykonywane są operacje na Order
. Jest to dosyć proste zadanie o ile korzystamy z odpowiedniego IDE jakim jest na przykład IntelliJ. Teraz warto zaprojektować przejściową wersję naszego repozytorium. Chcielibyśmy przenieść wszystkie sposoby zapisywania i pobierania Order
w jedno miejsce. Tutaj możemy skorzystać z wcześniej wspomnianej kompozycji. Sztuczka polega na opakowaniu encji Client
, aby na jej podstawie wystawić API naszego nowego repozytorium. Może to wyglądać w następujący sposób.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class OrderRepository {
private final Client client;
public OrderRepository(Client client) {
this.client = client;
}
public Set<Order> findAllByClientId(ClientId clientId) {
return client.getOrders();
}
public Order save(Order order) {
client.add(order);
return order;
}
}
Tak przygotowani możemy przejść dalej. Wykorzystajmy nasz wrapper w kodzie produkcyjnym.
Krok 2. - Wykorzystanie
Spójrzmy jeszcze raz na pierwszy przypadek wykorzystania, który widzieliśmy wcześniej.
1
2
3
4
5
6
7
Client client = clientRepository.findByClientIdForce(clientId);
Set<ProductCode> uniqueProductCodes = client.getOrders()
.stream()
.map(Order::getProducts)
.flatMap(Collection::stream)
.map(Product::getProductCode)
.collect(Collectors.toUnmodifiableSet());
Podepnijmy pod niego nasze nowe repozytorium.
1
2
3
4
5
6
7
8
9
Client client = clientRepository.findByClientIdForce(clientId);
OrderRepository orderRepository = new OrderRepository(client);
Set<ProductCode> uniqueProductCodes = orderRepository.findAllByClientId(clientId)
.stream()
.map(Order::getProducts)
.flatMap(Collection::stream)
.map(Product::getProductCode)
.collect(Collectors.toUnmodifiableSet());
O dziwo kod wygląda gorzej, co nie? Jednak aby wszystko było wykonane bezpiecznie, musimy “zabrudzić” istniejące rozwiązanie. Jednak możemy być pewni, że ten kod działa! Z tego powodu można zastosować to rozwiązanie, zrobić merge do master
i puścić na produkcję!
💡 Przekaż WSZYSTKIM kolegom i koleżankom o stosowaniu tego rozwiązania. Inaczej znowu zostaniecie w kodzie z wywołaniami typu client.getOrders()
czy client.add(order)
, rozsianymi wszędzie!
Spójrzmy na drugi przypadek.
1
2
Client client = clientRepository.findByClientIdForce(clientId);
client.add(order);
Teraz i tutaj użyjmy OrderRepository
.
1
2
3
4
Client client = clientRepository.findByClientIdForce(clientId);
OrderRepository orderRepository = new OrderRepository(client);
orderRepository.save(order);
Było znowu tak prosto, a ja specjalnie utrudniam… Poczekajmy jednak na finalny efekt.
Krok 3. - Usuwanie
Pora na czyszczenie naszego kodziku. Na start usuńmy powiązania @ManyToOne
i @OneToMany
w naszych encjach.
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
@Entity
public class Client {
@Id
@GeneratedValue
private Long id;
//...
public ClientId getClientId() {
return ClientId.of(id);
}
}
@Entity
public class Order {
@Id
@GeneratedValue
private Long id;
@Embedded
@AttributeOverride(name = "value", column = @Column("client_id"))
private ClientId clientId;
//...
public OrderId getOrderId() {
return OrderId.of(id);
}
public ClientId getClientId() {
return clientId;
}
}
Pozbyliśmy się dwustronnych powiązań! Hmm… Kod się teraz nie kompiluje… Spróbujmy to naprawić. OrderRepository
może stać się bez problemu interfejsem i rozszerzać CrudRepository
. Przekonajmy się co można zrobić z kodem odpowiedzialnym za pobieranie unikatowych kodów produktów.
1
2
3
4
5
6
7
8
9
Client client = clientRepository.findByClientIdForce(clientId);
OrderRepository orderRepository = new OrderRepository(client);
Set<ProductCode> uniqueProductCodes = orderRepository.findAllByClientId(clientId)
.stream()
.map(Order::getProducts)
.flatMap(Collection::stream)
.map(Product::getProductCode)
.collect(Collectors.toUnmodifiableSet());
Po zmianie OrderRepository
stanie się polem wstrzykiwanym do danej klasy, repozytorium ClientRepository
stanie się zbędne, a po kliencie pozostanie nam tylko wspomnienie… i clientId
.
1
2
3
4
5
6
Set<ProductCode> uniqueProductCodes = orderRepository.findAllByClientId(clientId)
.stream()
.map(Order::getProducts)
.flatMap(Collection::stream)
.map(Product::getProductCode)
.collect(Collectors.toUnmodifiableSet());
Posprzątane! Wygląda to o niebo lepiej. Jeszcze przypisywanie nowego zamówienia dla klienta. Zobaczmy sam efekt finalny.
1
orderRepository.save(order);
W tym przypadku jedna linijka robi już całą robotę. Order
ma w sobie referencję do ClientId
. Wychodzi na to, że zadanie wykonane! Zweryfikujmy to poprzez napisanie testu. Zobaczymy czy faktycznie lepiej testuje się naszą aplikację.
Krok 4. - Testowanie
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
class OrderSpec extends Specification {
ClientRepository clientRepository = new InMemoryClientRepository()
@Subject
OrderService orderService = new OrderService(clientRepository)
def "get unique products code for all client orders"() {
given:
Product abcProduct = new Product(ProductCode.ABC)
Product xyzProduct = new Product(ProductCode.XYZ)
and:
Order firstOrder = new Order(1, ...)
firstOrder.addProduct(abcProduct, 3)
and:
Order secondOrder = new Order(2, ...)
secondOrder.addProduct(abcProduct, 2)
secondOrder.addProduct(xyzProduct, 4)
and:
Client client = new Client(1, ...)
client.add(firstOrder)
client.add(secondOrder)
and:
clientRepository.save(client)
when:
Set<ProductCode> uniqueProductCodes = orderService.getAllUniqueProductCodes(client.getClientId())
then:
uniqueProductCodes == Set.of(ProductCode.XYZ, ProductCode.ABC)
}
}
Przykładowy test dla starego rozwiązania mógłby wyglądać w powyższy sposób. Zasadność zależności pomiędzy Product
i Order
pozostawiam Tobie. Przyjmijmy, że aktualnie to nie stanowi dla nas problemu. Z punktu widzenia testu musieliśmy utworzyć instancję klasy Client
, chociaż biznesowo nie jest ona nam do niczego potrzebna. Chcemy pobrać tylko zamówienia klienta na podstawie jego id. Zobaczmy więc jak będzie to wyglądało w przypadku nowego podejścia.
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
class OrderSpec extends Specification {
OrderRepository orderRepository = new InMemoryOrderRepository()
@Subject
OrderService orderService = new OrderService(orderRepository)
def "get unique products code for all client orders"() {
given:
ClientId clientId = ClientId.of(0)
and:
Product abcProduct = new Product(ProductCode.ABC)
Product xyzProduct = new Product(ProductCode.XYZ)
and:
Order firstOrder = new Order(1, clientId, ...)
firstOrder.addProduct(abcProduct, 3)
and:
Order secondOrder = new Order(2, clientId, ...)
secondOrder.addProduct(abcProduct, 2)
secondOrder.addProduct(xyzProduct, 4)
and:
orderRepository.saveAll(List.of(firstOrder, secondOrder))
and:
when:
Set<ProductCode> uniqueProductCodes = orderService.getAllUniqueProductCodes(clientId)
then:
uniqueProductCodes == Set.of(ProductCode.XYZ, ProductCode.ABC)
}
}
Nieznacznie, ale test uległ uproszczeniu. Jeśli klasa Client
posiadałaby wiele zależności to na pewno tym ruchem oszczędziliśmy sobie sporo pracy. W przypadku gdy zajdzie zmiana w tej encji to test "get unique products code for all client orders"
na tym nie ucierpi. Nie będziemy musieli do niego wchodzić i w razie czego dostosowywać do nieistotnej, z jego punktu widzenia, zmiany.
Podsumowanie
Mam nadzieję, że ten przypadek “z placu boju” był dla Ciebie ciekawy oraz przydatny. Oczywiście w kodzie produkcyjnym nie wyglądało to tak pięknie. Trzeba było jeszcze przenieść przysłowiowy OrderRepository
do odpowiedniego modułu. Z racji łatwego dostępu do metody getOrders()
była ona wykorzystywana w wielu miejscach poza pakietem order
… Powstaje, więc pytanie - “w jaki sposób moduły mogą wymieniać ze sobą informacje poza wywołaniem metod takich jak getOrders
”? Ja znalazłem 4 takie sposoby, ale domyślam się, że może być ich więcej. Chcesz się o nich dowiedzieć? Daj znać w komentarzu.
Na koniec dodam, że w przypadku naszego projektu ważne było zwiększenie testowalności, ponieważ powstawało za dużo błędów. Ta sztuczka bardzo nam w tym pomogła. Jeśli u was też jest to tak istotne to może warto ją rozważyć? Warto do tego dołożyć jeszcze zasadę ‘First Class Collections’ z Object Calisthenics. Zachęcam mocno do spróbowania!