MVC - dublowanie modeli

0

Chciałbym zaprosić do tego wątku @somekind .

Generalnie z MVC jest tak, że z jednej strony "encja na twarz i pchasz", co ponoć jest złym podejściem, a z drugiej strony mamy zdublowane modele. Chciałbym rozpocząć konstruktywną dyskusję na ten temat - kiedy tak, kiedy inaczej i dlaczego.

Ogólnie rzecz biorąc (zaznaczam - przypadek OGÓLNY) uważam, że nie ma sensu duplikować modeli. No bo co. Załóżmy, że mamy model:

public class Client: DbItem
{
    public string Name {get;set;}
    public string EmailAddress {get;set;}
    public Address Address {get; set;}
    public string PassHash {get;set;}
}

//DbItem to jakaś klasa trzymająca dane wszystkich modeli będących w bazie danych, np. pole Id.
//Przyjmijmy, że model nie jest anemiczny.

Teraz, jeśli chcemy zmodyfikować go, wystarczyłoby ten obiekt wysłać do widoku i widok ładnie sobie wszystko poogarnia.
Możemy stworzyć coś w rodzaju ClientViewModel:

public class ClientViewModel
{
    [Required(MinLength=5)]
    public string Name {get;set;}

    [Required]
    [EmailAddress]
    public string EmailAddress {get;set;}
    public Address Address {get; set;}
}

Ale to mi wygląda już na duplikowanie kodu - a więc łamiemy zasadę DRY (zaznaczam - mowa o przypadku ogólnym).
A jeśli widok jest nieco bardziej skomplikowany, możemy skończyć z czymś takim:

public RegisterUserViewModel
{
    public ClientViewModel Client {get;set;}
    public RegistratorViewModel Reg {get;set;}
    public InnyViewModel VM {get;set;}

//inne dane
}

Więc czemu technika powielania modeli miałaby być lepsza? Jeśli zmieni się coś w modelu głównym, wtedy zmiany w kodzie mogą być w wielu miejscach. Rozumiem potrzebę zdublowania modelu, jeśli chodzi o Api. W takich przypadkach prawdopodobnie powinniśmy tworzyć DTO. Ale co w sytuacji widoków?

2

No nie wiem. Jak dla mnie przykład z userem to jest wlasnie ten gdzie powinnien być oddzielony od widoku. Raczej slabo, żeby do widoku dawac pole PassHash. Albo oznaczać jakimiś atrybutami (używanymi na widoku) model db. A jak encja db nie jest anemiczna, prywatne settery to jak chcesz to pogodzić z widokiem?
A oprócz DRY mamy też SRP

0

No jeśli chodzi o model db, to jeśli używasz adnotacji, to np. required jest wspólne. Chociaż ja staram się nie używać adnotacji w ORMach. Lubię mieć czysty model.

6

Przeciez już w twoim przykładzie widać problem -> co zrobisz z tym hasłem? :) Będziesz je usuwać przed zwróceniem JSONa na front? I analogicznie będziesz sprawdzać czy jak ktoś wyśle z frontu update to czy czasami nie uwzględnił tego pola, zebyś go przypadkiem nie updatował w bazie? A co jeśli takich pól jest o wiele więcej? Już nawet nie mówie jakie cyrki potrafią się dziaś jak to wszystko spina magiczny ORM, bo w Javie taki Hibernate, o ile sesja ORMa jest nadal aktywna i nie zrobiłeś obiektu detached to zapisze ci automatycznie zmianę w bazie. Więc ty usuwasz pass hash przed wysłaniem na front, a ORM zapisuje to w bazie :D (tak, widziałem taką sytuacje na produkcji kiedyś...)

Poza jakimiś generic crudami (gdzie encja na twarz i pchasz się nawet sprawdza) model trwałych danych często nijak się ma do modelu domenowego i nijak się to wszystko ma do modelu wyjściowego. Banalny przykład:

Masz tego swojego Clienta ale w twojej aplikacji nie trzymasz żadnego pass hasha ani nic takiego bo jest inny serwis security który tym zarządza, a twój serwis trzyma takie rzeczy jak np. historia transakcji klienta, bo prowadzisz sklep. Nagle w twoim modelu domenowym klient moze być "wzbogacony" o informacje o jakichś ACLach czy rolach, mimo ze twoja baza danych w ogóle takich informacji nie przechowuje! Czyli twój model danych nie jest wcale 1:1 do modelu domenowego. A teraz co prezenujesz na wyjściu klientowi też może być zupełnie inne. Może np. prezentujesz jakieś "zbiorcze" podsumowania które wcale nie przypominają rekordów które masz zapisane w bazie, tylko są na ich podstawie wyliczane.

Na przykładzie generic CRUDa który nie ma żadnej logiki i jest hello worldem, to można udowodnić praktycznie wszystko.

8
Juhas napisał(a):

Ale to mi wygląda już na duplikowanie kodu - a więc łamiemy zasadę DRY (zaznaczam - mowa o przypadku ogólnym).

To nie jest duplikowanie kodu, bo to są dwa modele o różnych zastosowaniach. I nawet jeśli mają dokładnie te same właściwości (niezwykle rzadki przypadek), to to nadal nie jest to problem, bo problem z DRY dotyczy kopiowania logiki, a nie definicji struktur danych.

Więc czemu technika powielania modeli miałaby być lepsza? Jeśli zmieni się coś w modelu głównym, wtedy zmiany w kodzie mogą być w wielu miejscach. Rozumiem potrzebę zdublowania modelu, jeśli chodzi o Api. W takich przypadkach prawdopodobnie powinniśmy tworzyć DTO. Ale co w sytuacji widoków?

Problemy z widokami są dokładnie takie same jak z API. Jeśli mamy jeden model, to:

  1. Ktoś dodaje nową właściwość do modelu (bo trzeba coś przechowywać w bazie) -> nagle w wyniku widzimy więcej danych (nieważne, czy to jakiś grid, czy API). Trzeba to naprawić - dla jednego widoku/endpointa część tych danych ukrywać, dla innego nie, jeśli ta sama encja ma być pokazywana w kilkudziesięciu widokach, to robi się z tego masa ifologii.
  2. Ktoś dowiązuje nową tabelę (np. łączy klienta z fakturami) -> potem okazuje się, że wejście w profil pobiera pół bazy danych.
  3. Jeśli model w bazie ma 100 pól, a w jakimś gridzie potrzebujemy tylko 5, to pobieranie pozostałych 95 jest marnotrawstwem zasobów.
  4. Trzeba pokazać coś dynamicznie obliczonego, czego nie ma w bazie (np. wartość dotychczasowych zamówień klienta) -> potrzebna kolejna ifologia/atrybuty, żeby to pole się nie mapowało.

To wszystko sprawia, że kod takich "uniwersalnych" modeli jest upstrzony atrybutami, do tego potrzebny jeszcze jest dodatkowy kod gdzieś w aplikacji, no i efekt jest prawie taki jakbyśmy pisali w dynamicznie typowanym języku, zamiast korzystać z typów musimy kombinować. (Do tego zazwyczaj jak powstają takie problemy, to korporacje zamiast rozwiązać je architekturą, rozwiązują wewnątrzfirmowym frameworkiem, który ma rozwiązać problemy z ukrywaniem zbędnych danych, ale w efekcie generuje jeszcze więcej błędów.)
A jeśli mamy podział na warstwy, to nie tracimy czasu na rozwiązywanie powyższych problemów. Jeśli ViewModel/DTO nie zawiera pola, to na pewno nigdzie przypadkiem nie zostanie pokazane ani pobrane z bazy. Jeśli mamy oddzielną klasę do każdego widoku, to nie ma ryzyka, że ktoś zmieniając jedną z nich popsuje inny widok. Jeśli mamy odseparowaną warstwę składowania od widoku, to nie ma ryzyka, że dodając nowe pole w bazie złamiemy kontrakt API/popsujemy widok.
Koszt napisania dwóch warstw modeli klas jest znikomy w porównaniu z ilością kodu do napisania, przetestowania i utrzymania w przypadku próby załatwienia wszystkiego jednym zestawem modeli.

Jeden model może zadziałać tylko jeśli mamy do czynienia z jakimś małym pomocniczym narzędziem, w którym:

  • mamy czystego CRUDa, w którym faktycznie mapowanie 1:1 jest rzeczywiste i nie przechowujemy dużo danych;
  • nie jest to publicznie dostępna aplikacja (bez ryzyka ujawnienia);
  • jesteśmy jedynym developerem i znamy wszystkie ficzery (nie ma ryzyka, że zmieniając ten model zepsujemy jakiś ficzer w zupełnie innym module aplikacji, o którym nawet nie mamy pojęcia);

Na przykład może to być edytor jakieś konfiguracji dla innej aplikacji.

0
somekind napisał(a):
  1. Jeśli model w bazie ma 100 pól, a w jakimś gridzie potrzebujemy tylko 5, to pobieranie pozostałych 95 jest marnotrawstwem zasobów.

Wiem, że wątek nie jest o DDD, ale pozwolę sobie tu o coś zapytać. No właśnie co w przypadku DDD? I tak trzeba pobrać cały model z bazy, a potem go ociosać do 5 pól? Oszczędzenie zasobów to może sieciowych i po stronie UI, ale na backendzie raczej bez różnicy. Czy dobrze myślę?

Btw: jakiś błąd w Coyote, bo ten punkt w oryginalnym poście to 3., a nie 2. Zacytowało mi 1, 2, 2, 2.

2

@maszrum:

Oszczędzenie zasobów to może sieciowych i po stronie UI, ale na backendzie raczej bez różnicy. Czy dobrze myślę?

Nie wiem jak w DDD więc się nie wypowiem, ale generalnie tak samo pobierając z bazy jak i pchając z backendu na front, IMO nie ma po co wyciągać nadmiarowych danych. Każda baza danych ma jakiś mechanizm projekcji, który pozwoli na wyciągnięcie tylko tego co potrzebujesz, choćby coś jak SELECT firstName, lastName FROM clients.

2
maszrum napisał(a):

Wiem, że wątek nie jest o DDD, ale pozwolę sobie tu o coś zapytać. No właśnie co w przypadku DDD? I tak trzeba pobrać cały model z bazy, a potem go ociosać do 5 pól?

Ale dlaczego? W DDD przecież model będzie miał tyle pól, ile wynika z domeny, więc niczego ciosać nie trzeba. Ewentualną projekcją powinno zająć się repozytorium w taki sposób, aby była ona wykonana przez źródło danych.

Oszczędzenie zasobów to może sieciowych i po stronie UI, ale na backendzie raczej bez różnicy. Czy dobrze myślę?

Backend z bazy przecież też po sieci czyta. A jak do tego masz bazę płatną od megabajta transferu, to można sobie niezłych kosztów narobić.
Ja po prostu nie widzę sensu w czytaniu danych, które nie są potrzebne.

Btw: jakiś błąd w Coyote, bo ten punkt w oryginalnym poście to 3., a nie 2. Zacytowało mi 1, 2, 2, 2.

To nie jest błąd w Coyote, tak działa Markdown. Nieważne jakie cyferki wpiszesz, lista numerowana wyświetli się prawidłowo.

0
somekind napisał(a):

Ale dlaczego? W DDD przecież model będzie miał tyle pól, ile wynika z domeny, więc niczego ciosać nie trzeba. Ewentualną projekcją powinno zająć się repozytorium w taki sposób, aby była ona wykonana przez źródło danych.

Pokażę na przykładzie o co mi chodzi: W e-commerce mam model produktu ze 100 polami, ale czasami chciałbym wyciągnąć tylko podstawowe informacje np. na stronę główną (nazwę, cenę, url zdjęcia). I w tym momencie muszę i tak pobrać cały model produktu, żeby zwrócić te podstawowe informacje?

Backend z bazy przecież też po sieci czyta. A jak do tego masz bazę płatną od megabajta transferu, to można sobie niezłych kosztów narobić.

Chodziło mi o oszczędzenie zasobów sieciowych backend - UI.

3
maszrum napisał(a):

Pokażę na przykładzie o co mi chodzi: W e-commerce mam model produktu ze 100 polami, ale czasami chciałbym wyciągnąć tylko podstawowe informacje np. na stronę główną (nazwę, cenę, url zdjęcia). I w tym momencie muszę i tak pobrać cały model produktu, żeby zwrócić te podstawowe informacje?

Wyświetlanie danych na stronie to nie jest DDD. DDD jest od procesów biznesowych.

W tym wypadku po prostu robisz sobie viewmodel/DTO i jakiś query handler za pomocą ORMa robi projekcję tych wartości, które są potrzebne.

4

Pokażę na przykładzie o co mi chodzi: W e-commerce mam model produktu ze 100 polami, ale czasami chciałbym wyciągnąć tylko podstawowe informacje np. na stronę główną (nazwę, cenę, url zdjęcia). I w tym momencie muszę i tak pobrać cały model produktu, żeby zwrócić te podstawowe informacje?

W ogólności nie musi tak być, to zależy od skali. Możesz wszystko oprzeć o jedną tabelkę, ale rodzi to szereg problemów przy skalowaniu ruchu, a nawet developmentu (10 zespołów wprowadza zmiany do tego samego modelu danych).

Musisz rozróżnić model do zapisu, którego zadaniem jest ochrona warunków biznesowych (tu robisz sobie np. dedede) od modelu do odczytu, który zawiera zagregowane, często nawet nieaktualne, dane pod konkretny widok. To jest absolutna podstawa. Dzięki temu możesz mieć Produkt rozbity po różnych usługach, ale jeden agregat „Lista produktów” czy „Szczegóły zakupu” pod konkretne widoki.

0

A co w przypadku złożonego ViewModelu, np:

class MyPageViewModel
{
    public UserViewModel {get; set;}
    public DataViewModel {get;set;}
    public Data2ViewModel {get;set;}
    public Data3ViewModel {get;set;}
}

Czy coś takiego powinno zapalić lampkę? Czy to jest jak najbardziej normalne stosowanie?

0

Ja tam staram się mieć viewmodele możliwe płaskie.

0

ViewModel != DTO != Model != DBModel

ViewModel - służy do prezentacji danych na widoku. Zawiera tylko to co pokazuje, ale może pochodzić z wielu encji.
DTO - służy do transferu danych jak sama nazwa mówi, Transferuje dane z modelu, ale tylko te niezbędne, by innych nie popsuć, nie targać po sieci powodując problemy wydajnościowe, nie niszczyć dysków etc. Brak DTO może powodować wyciek danych lub zniszczenie danych co Shalom wspominał.
Model - klasa zawierająca dane, na których są przeprowadzane operacje biznesowe.
DBModel - model który służy do napisu w bazie. Ostatecznie wcale nie musi istnieć. Model może być zasilany z pliku, generowany, pobierany przez REST, TCP, przez rurki IPC, przez Bluetooth z czipa jak ktoś zaszczepiony.

Taki podział zapewnia separacje warstw - jak ktoś doda coś do modelu DBModel, co nie trzeba się martwić co na widoku się stanie. Jest zachowana zasada SRP. DRY nie jest złamane - przecież to są zupełnie inne byty. Jeśli ktoś nie chce tracić czasu na przepisywanie "ręczne" i robienie mapperów to zapraszam do zapoznania się z czymś takim jak AutoMapper. Mappowanie można zautomatyzować - pisania ifów nie bardzo.

1 użytkowników online, w tym zalogowanych: 0, gości: 1