Wstęp i opis
Projektując a potem implementując współczesną aplikację, każdy programista powinien pamiętać o zachowaniu głównych zasad programowania obiektowego. Tworzone przez nas klasy powinny wykonywać logikę do jakiej zostały przeznaczone i nie wykonywać innych operacji z różnych obszarów naszego projektu. Logika klas powinna być zamknięta na jakiekolwiek modyfikacje, ale z kolei otwarta na rozszerzenie funkcjonalności. Jeśli nasza klasa jest z kolei klasą dziedzicząca, to nie powinna ona znać dokładnych obiektów klasy bazowej, a tylko rozszerzyć jej funkcjonalność. Przy tworzeniu interfejsów, należy pamiętać że posiadanie większej ich ilości jest lepsze niż posiadanie jednego ogólnego. Poza tym wysokopoziomowe moduły nie powinny zależeć od żadnych modułów niskopoziomowych.
Wspomniane powyżej reguły, wchodzą w skład mnemonika SOLID:
- S - Single Responsibility Principle
- O - Open Closed Principle
- L - Liskov Substition Principle
- I - Interface Segregation Principle
- D - Dependency Inversion Principle
Więcej na ten temat można przeczytać w poniższych artykułach:
- https://blog.helion.pl/mnemonik-solid-s-single-responsibility-principle/
- https://blog.helion.pl/mnemonik-solid-o-openclosed-principle/
- https://blog.helion.pl/mnemonik-solid-l-liskov-substitution-principle/
- https://blog.helion.pl/mnemonik-solid-interface-segregation-principle/
- https://blog.helion.pl/mnemonik-solid-d-dependency-inversion-principle/
- https://www.samouczekprogramisty.pl/solid-czyli-dobre-praktyki-w-programowaniuobiektowym//
Implementacja
DTO
Na poprzednich laboratoriach, zaimplementowaliśmy logikę repozytorium, dzięki której jesteśmy w stanie pobrać dane z bazy danych. Wydawać by się mogło że wystarczyłoby napisać teraz tylko kontrolery z wystawieniem endpointów i cały serwis w zasadzie byłby gotowy. Nic bardziej mylnego. W takim przypadku narazilibyśmy się na wystawienie realnego odzwierciedlenia struktury tabel naszej bazy danych. Dodatkowo klient dostałby możliwość zobaczenia tych danych, których widzieć nie powinien. Nie bez znaczenia jest kontrolowanie wielkości wygenerowanej przez nasz serwis odpowiedzi. Aby przeciwdziałać niepożądanym skutkom oraz by osiągnąć większą kontrole nad tym co jest wysyłane, można zdefiniować obiekt transferu danych (DTO). Obiekt ten definiuje sposób w jaki dane bedą przesyłane przez sieć.
Weźmy na warsztat funkcjonalność wyświetlania wszystkich nieruchomości. Konsumentowi naszego API, kiedy pyta się o nieruchomość napewno nie chcielibyśmy przekazywać takich informacji jak data utworzenia, edycji, identyfikatorów adresu itd. Dla potrzeb zajęć załóżmy że chcielibyśmy tylko przekazywać podstawowe informacje o danej nieruchomości jak: cena najmu, liczba pokoi, metraż, piętro, czy w budynku jest winda, miasto oraz ulicę w którym znajduje się obiekt. W tym celu w projekcie ApartmentRental.Core
stwórzmy folder DTO
, a w nim klasę ApartmentBasicInformationResponseDto
o wspomnianych powyżej właściwościach.
ApartmentService oraz AddressService
Stwórzmy teraz klasę odpowiadającą za pośredniczenie danych pomiędzy warstwą bazodanową, a warstwą prezentacji. W tym samym projekcie, dodajmy katalog Services
a w nim klasę ApartmentService
oraz interfejs IApartmentService
. W interfejsie dodajmy strukturę metod które zwrócą nam wszystkie apartamenty, najtańszy apartament oraz pozwolą na dodanie nowego apartamentu do danego konta wynajmującego. Musimy pamiętać że nasza aplikacja działa asynchronicznie - na ten moment funkcje bazodanowe i obiekty które wyciągamy są objęte obiektem Task<>
. Tak samo tworzona przez nas metoda musi zwracać ten obiekt jeśli chcemy by tworzone w późniejszym czasie endpointy działały asynchronicznie. Wracając - zdefiniujmy metody:
1
2
3
Task<IEnumerable<ApartmentBasicInformationResponseDto>> GetAllApartmensBasinInfosAsync();
Task AddNewApartmentToExistingLandLordAsync (ApartmentCreationRequestDto dto);
Task<ApartmentBasicInformationResponseDto> GetTheCheapestApartmentAsync();
Pamiętajmy o tym by klasa ApartmentService
implementowała interfejs. Operacje nad encją Apartments będziemy wykonywać przy pomocy wcześniej stworzonego repozytorium. Dodajmy do serwisu odwołanie do interfejsu IApartmentsRepository
. Nie będziemy samego obiektu repozytorium inicjalizować ręcznie jako nowy obiekt w konstruktorze lub w metodach. Zamiast tego posłużymy się wstrzykiwaniem zależności ( dependency injection
). Nasza zmienna niech będzie prywatna i tylko do odczytu. Wygenerujmy również konstruktor, który prosto inicjalizuje IAparmentsRepository
. Teraz przejdźmy do klasy Program.cs
w głównym projekcie ApartmentRental.API
. W nim poniżej definicji kontekstu bazodanowego, zdefiniujemy wstrzykiwanie zależności i stworzymy powiązanie pomiędzy interfejsami a implementacjami. W tym celu wywołujemy obiekt Services z obiektu builder, a w nim metodę AddScoped<Interfejs,Implementacja>
. Metoda AddScoped<>
rejestruje serwis z określonym czasem życia przypadającym na pojedyncze zapytanie (podczas każdego nowego zapytania i wywoływania serwisu, tworzona jest jego nowa instancja. Pozwala to na nie przemieszanie się danych pomiędzy różnymi zapytaniami, więcej na temat DI w .Net - link). Konfiguracja DI, powinna wyglądać tak jak na poniższych obrazkach:
Metoda Task<IEnumerable> GetAllApartmentsBasicInfoAsync()
Jest to najprostsza metoda. Jej implementacja powinna pobrać wszystkie apartamenty z bazy danych, a następnie każdy pobrany obiekt powinien zostać przemapowany na obiekt ApartmentBacisInformationResponseDto
. Iteracje przemapowania można szybko osiągnąć przy pomocy zapytań LINQ, a dokładnie metody Select()
.
Metoda Task<ApartmentBasicInformationResponseDto?» GetTheCheapestAparmentAsync()
Implementacja metody zwracającej obiekt o najniższym czynszu, jest bardzo zbliżona do logiki z poprzedniego punktu. Na samym początku należy pobrać wszystkie apartamenty z DB. Aby znaleźć element o najniższym czynszu, ponownie wykorzystamy zapytania LINQ, a konkretnie MinBy()
. Trzeba zwrócić uwagę na to że jeśli zbiór elementów będzie pusty lub nullem, to metoda zwróci null. W takim przypadku trzeba zabezpieczyć się przed możliwym NullReferenceException
, który może wystąpić w trakcie mapowania. W tym celu zmieńmy typ zwracany implementowanej metody, a konkretniej wskażmy że zwracany obiekt może być nullem. Należy postawić znak zapytania po nazwie obiektu w typie zwracanym. Dodatkowo dodajmy prosty warunek że jeśli nie został znaleziony najmniejszy element, to zostanie zwrócony null. Implementacja powinna wyglądać jak na obrazku poniżej:
Metoda Task AddNewApartmentToExistingLandlordAsync(ApartmentCreationRequestDto dto)
Metoda dodająca nowy lokal do bieżącego konta wynajmującego nie zwraca żadnego obiektu dto, natomiast w porównaniu do poprzednich przyjmuje jako argument. Zastanówmy się z czego mógłby składać się taki obiekt i jakie dane powinny przyjść z innego mikroserwisu bądź frontend’u, tak by spełnić wymaganie. Napewno potrzebowalibyśmy wszystkich informacji o danym obiekcie, jak i informację do jakiego wynajmującego dodać nowy lokal. Stwórzmy w folderze Dto
, klasę ApartmentCreationRequestDto
z takimi właściwościami jak: wysokość czynszu, liczba pokoi, metraż lokalu, piętro, informację czy w budynku jest winda, miasto, ulica, kod pocztowy, numer lokalu, numer budynku, kraj oraz identyfikator wynajmującego.
Metoda przed dodaniem nowego lokalu, powinna również sprawdzić czy podany wynajmujący istnieje oraz czy podany adres znajduje się w naszej bazie, a jeśli nie to dodać nowy. Wynajmującego można prosto zweryfikować za pomocą sprawdzenia czy przesłany identyfikator w DTO napewno istnieje. W tym w ciele metody stwórzmy zmienną landlordId
, do której przypisywana będzie wartość z metody GetById
z repozytorium LandLord
. Przejdźmy do adresu. Aby dodać powiązanie pomiędzy obiektem a adresem w db, będziemy potrzebowali tylko i wyłącznie jego identyfikator. Na poprzednich zajęciach, zadaniem domowym było dodanie walidacji adresu w repozytorium Apartments. Przesuniemy tą logikę dla Apartamentów do serwisów (bez rzucania wyjątku). Potrzebujemy funkcjonalność, która wyszukiwała by czy podana encją adresu istnieje w bazie na podstawie takich argumentów jak kraj, miasto, kod pocztowy, ulica, numer budynku oraz numer lokalu. Jeśli encją o podanych atrybutach by istniała, zwrócilibyśmy jej identyfikator. Jeśli nie, stworzylibyśmy nową oraz zwrócili jej id. Stwórzmy więc taką funkcjonalność. W folderze Services
dodajmy interfejs IAddressService
, klasę AddressService
(serwis ma oczywiście implementować interfejs), a w głównym projekcie w klasie Program.cs
dodajmy odpowiednią rejestrację dla wstrzykiwania zależności (jeśli na ten moment nie zarejestrowałeś /zarejestrowałaś pozostałych serwisów/repozytoriów z interfejsami to zrób to teraz za pomocy metody AddScoped
). W interfejsie zdefiniujmy metodę:
1
Task<int> GetAddressIdOrCreateAsync(string country, string city, string zipCode, string street, string buildingNumber, string apartmentNumber)
i dodajmy ją do serwisu. Jako że będziemy używać repozytorium to dodajmy do serwisu właściwość
1
private readonly IAddressRepository adressRepository
i wygenerujmy konstruktor który będzie tą właściwość inicjalizował. Na ten moment zatrzymajmy się z dalszą implementacją serwisu i przejdźmy do samego repozytorium. Na potrzeby serwisu dodajmy w interfejsie IAddressRepository
metodę która zwróci nam identyfikator adresu na podstawie wspomnianych wyżej atrybutów:
1
Task <int> GetAddressIdByItsAttributesAsync(string country, string city, string zipCode, string street, string buildingNumber, string apartmentNumber)
Teraz w repozytorium napiszmy implementację tej metody. Z kontekstu bazodanowego pobierzmy pierwszą lub domyślną (domyślna dla obiektów = null) encję adresu dla której spełniony jest warunek, że wszystkie podane argumentu metody są takie same jak właściwości encji. Jeśli podana encja istnieje, zwróćmy jej identyfikator, a jeśli nie to zwróćmy zero (domyślna wartość int’a).
Stworzoną metodę wywołajmy w GetAddressIdOrCreateAsync
w klasie AddressService
i przypiszmy ją do zmiennej var id.
Jeśli zmienna jest różna od zera - zwróćmy ją, jeśli nie to potrzebujemy stworzyć nową encję adresu wraz ze zdobyciem jej identyfikatora. Ponownie dodajmy nową metodę do repozytorium adresu - CreateAndGetAsync
. Funkcja jest bardzo podobna do tworzonej na poprzednich zajęciach AddAsync
,jedyną jej różnicą jest fakt że zwracać ona będzie obiekt Address
. EF Core domyślnie podczas zapisywania zmian do db korzysta z referencji przekazywanego obiektu do stworzenia encji, więc identyfikator zostanie automatycznie przypisany do właściwości AddressId
obiektu.
Stworzoną metodę wywołajmy z serwisie, przypiszmy ją do zmiennej i zwróćmy nowo stworzony identyfikator. Metoda GetAddressIdOrCreateAsync
powinna prezentować się następująco:
Finalnie wróćmy do ApartmentService
i to metody z której wyszliśmy - AddNewApartmentToExistingLandLordAsync
. Mając już identyfikatory wynajmującego oraz adresu możemy przejść do dodania nowej encji apartamentu do db. W tym celu tylko i wyłącznie wywołajmy AddAsync
z ApartmentRepository
i jako argument przekażmy nowy obiekt Address
z właściwościami które otrzymaliśmy z argumentów metody oraz identyfikatorów. Cała logika powinna wyglądać tak jak na zrzucie niżej:
Zadanie:
- Stwórz serwis LandLordService z funkcją która będzie tworzyła nowe konto osoby wynajmującej lokal. Metoda ta powinna przyjmować jako argument nową klasę DTO (LandLordCreationRequestDto) o takich właściwościach jak: imię, nazwisko, email, numer telefonu, ulica, numer lokalu, numer budynku, miasto, kod pocztowy i kraj. Metoda powinna weryfikować czy adres osoby wynajmującej istnieje w bazie a jeśli nie to dodać nowy. Serwis powinien również zostać zarejestrowany do wstrzykiwania zależności.