data.mdx.frontmatter.hero_image

Odwiedzając MVC

2024-03-04 | .NET, Felietony | bd90

Gdy ponad dekadę temu stawiałem pierwsze kroki jako programista, jedną z najpopularniejszych architektur była, dość prosta w założeniach, MVC (Model - View - Controller). Dzisiaj jest już trochę zapomniana... lekko passe. Wszędzie mamy do czynienia z Heksagonami, Cebulami, Mikroserwisami... Natomiast MVC ma więcej do zaoferowania niż się wydaje na pierwszy rzut oka. Tak więc, mój drogi czytelniku rozsiądź się wygodnie, ponieważ dzisiaj... odwiedzamy MVC.

Czym jest MVC?

Historia wzorca sięga późnych lat 70 dwudziestego wieku. kiedy to w Xerox Palo Alto Research Center (PARC) Trygve Reenskaug stworzył MVC pracując nad Smalltalk-79. To już niemal 50 lat.

Jestem niemal pewny, że podczas swojej historii w IT trafiłeś/trafiłaś na jedną z wersji poniższego diagramu:

Wzorzec ten składa się odpowiednio z:

  • View (widoku): reprezentacja interfejsu użytkownika
  • Controller (kontroller): odpowiedzialny za przyjęcie inputu użytkownika i manipulację modelem
  • Model: Czym jest model w MVC? Niektórzy powiedzą, że to obiektowa reprezentacja bazy danych, inni, że warstwa dostępu do bazy danych. To pytanie pozostawię otwarte przez chwilę 😅

Cała idea tego wzorca polega na separacji, a w zasadzie dwóch:

  • separacji warstwy prezentacji od modelu
  • separacji kontrolera od warstwy prezentacji

Pierwsza separacja jest tą ważniejszą.

.NET MVC

Obecnie każdy język czy platforma posiada własną implementację framework-a, który pozwala na tworzenie aplikacji MVC. Nie inaczej jest w świecie .NET-a, gdzie mamy .NET MVC. Problem polega jednak na tym, że dokumentacja narzędzi ma za zadanie pokazania ich możliwości, a nie najlepszych praktyk. Spieszę z przykładem.

Kiedy zaczynałem przygodę z MVC bardzo często moje apliakcje wyglądały następująco: struktura projektu mocno odzwierciedlała separację wynikającą z MVC:

- MyApp\
-- Controllers\
---- HomeController.cs
-- Models\
--- User.cs
-- Views\
---- Home\
----- Index.cshtml

Oczywiście nie mogę zapomnieć o wszystkich katalogach typu Utils, Commons, które bardzo często były źródłem problemów w projekcie.

Natomiast sam kodzik, wyglądał następująco:

// controller
public class HomeController : Controller
{
	public IActionResult Action(SomeInput input)
	{
		Validate(input);
		var entity = _repository.Get(input.Id);
		entity.SomeProps = input.SomeProps;
		
		if (entity.IsValid()) {
		  _repository.Save(entity);
		  return View(entity.ToViewModel());
		}
		
		return View("error);
	}
}

// model
public class SomeEntity : BaseEntity
{
	[Key]
	public int Guid { get; set; }
	
	[SomeValidationProperties]
	[SomeORMProperties]
	[SomeRandomProperties]
	public string SomeProps { get; set; }
}

// view
@model SomeViewModel
<p>Jakiś widok z danymi @model.SomeProps</p>

Jak widać na załączonym obrazku kodzik jest prosty jak budowa cepa. Oczywiście z takim kodem może być dużo problemów, szczególnie w momencie, kiedy ilość logiki biznesowej się rozrośnie.

Jak rozwiązać problem?

Gruby model, chudy controller

Jedna z pierwszych zasad odnośnie tworzenia aplikacji zgodnych z MVC jaką otrzymałem było:

Gruby Model, Chudy Controller... Model powinien zawierać logikę biznesową, zaś kontroler tylko delegować akcję.

Kodzik z kontrolera przechodzić do modelu

//controller
public IActionResult Action(SomeInput input)
{
	Validate(input);
	var entity = _repository.Get(input.Id);
	try {
		entity.Action(input);
		_repository.Save(entity);
		return View(entity.ToViewModel());
	}
	catch (Exception e) {
		return View("error);
	}
}

// model
public class SomeEntity : BaseEntity {
// ...
public void Action(SomeInput input)
{
	SomeProps = input.SomeProps; 
	
	if (!entity.IsValid())
		throw new Exception("Invalid operation");
	}
}

Model zaczyna puchnąć, stając się "Boską Klasą", która ma wszystkie dane, zachowania, a oprócz tego 8000 linii kodu i żadnych testów...

Nadchodzą Serwajsy...

Kolejnym etapem tułaczki było dodanie warstwy pośredniczącej, tzw. serwisów, których zadaniem było przechowywanie logiki biznesowej. Serwis to najczęściej klasa bezstanowa, która... otrzymują to, co wcześniej było w kontrolerze, w taki sposób, aby wykonać akcję na modelu.


// controller
public IActionResult Action(SomeInput input)
{
	Validate(input);
	var result = _someSerwajs(input.AsDto());
	
	if (result.Success) {
		return View(result.Data);
	}
	return View("error);
}

// service
public class SomeSerwajs : ISomeSerwajs {
	public Result<SomeEntity> Action(SomeInputDto input) {
		var entity = _repository.Get(input.Id);
		try {
			entity.Action(input);
			_repository.Save(entity);
			return Result<SomeEntity>.Success(entity.ToViewModel());
		}
		catch (Exception e) {
			return Result<SomeEntity>.Error(e);
		}
	}
}

// model
public class SomeEntity : BaseEntity {
// Only props with public get and set
}

I tak powoli apliakcja MVC zmieniała się w architekturę warstwową...

Czy to rozwiązywało problemy? Częściowo owszem. Nie mieliśmy już w modelu boskich klas. Co prawda trafiały się podobne w warstwie biznesowej, ale procesy w naszej unikalnej domenie nie pozwalały aby to atrakcyjniej zakodować (🫠).

Czy można inaczej?

Ostatnio ponownie dobrałem się do książki Patterns of Enterprise Architecture Martin-a Fowler-a, która pomimo swoich lat (wydana w 2003 roku) nadal jest w większości aktualna. Właśnie w niej pojawia się tam niewielka wzmianka o MVC. Tak naprawdę to tylko 3 strony, jednak 3 konkretne zdania zainspirowały mnie do przetestowania kilku podejść, a ich efekt prezentuje właśnie w tym poście.

The model is an object that represents some infomration about the domain. It's nonvisual object containing all the data and behavior other that used for the UI. In its most pure OO form the model is an object within a Domain Model.

Uzmysłowiłem sobie, że MVC może nie tylko być architekturą typu "Encja na twarz i pchasz". Za tym może się kryć się coś więcej. Nawet sam fakt, że mamy nawiązanie do modelu domenowego jest też bardzo interesujące, skoro to mądrości jeszcze sprzed książki Evens-a.

Będąc całkowicie szczery powinienem zacytować resztę paragrafu:

You might also think of a Transaction Script as the model providing that it contains no UI machinery. Such a definition stretches the notion of model, but fits the role breakdown of MVC.

Tak więc sam Fowler wiedział, że definicja modelu jest dość "płytka", jednak sprawdźmy do czego może nas doprowadzić.

Separacja modelu

Zacznijmy od największego słonia w pokoju... i nie nie piszę o sobie 😅 Model w MVC przyzwyczaił nas, że wszystko jest publiczne, dzięki temu kontroler czy serwis mają pełną władzę na wykonywanymi akcjami w modelu. Tak więc pojawia się pytanie w jaki sposób możemy enkapsulować model aby warstwa wystawiła nam ładne api. Jednym z bardziej oczywistych wyborów, który może nam przyjść do głowy, jest wykorzystanie wzorca fasady. Tylko gdzie go umieścić?

Najpierw należy ustalić czy sam model może zawierać podział na warstwy / moduły?

Na całe szczęście i owszem (a przynajmniej moim zdaniem ). Co prawda nie jest to raczej popularne rozwiązanie, jednak nie widziałem żadnego ograniczenia mówiącego, że musi być tylko i wyłącznie jedna warstwa.

Idąc tym torem rozdzielmy model na dwie części:

  • Model domenowy enkapsulujący logikę biznesową
  • Inna warstwę, która będzie odpowiedzialna za interakcję że światem zewnętrznym

Brzmi znajomo? Być może przychodzi Ci na myśl archiketura Portów i Adapterów. Jednak pierwszej kolejności wolałbym postawić na zdecydowanie prostszy wzorzec: Functional Core, Imperative Shell.

Wtedy moglibyśmy z wizualizować tą architekturę w następujący sposób

Kodzik

Jeżeli zastanawiasz się jak wygląda to w kodzie - spieszę z przykładami. Zacznijmy od struktury projektu. Zmiany nastąpią na poziomie warstwy modelu:

- MyApp\
-- Controllers\
---- HomeController.cs
-- Model\
--- Core\
---- SomeDomainEntity.cs
--- Shell\
---- SomeFacade.cs
-- Views\
---- Home\
----- Index.cshtml

Przy tak opracowanej strukturze obowiązują dwie zasady:

  • Świat zewnętrzny, kontrolery itd. mogą wywoływać jedynie rzeczy z namespace-a MyApp.Model.Shell
  • Shell może operować na rzeczach z MyApp.Model.Core, jednak Core nie może wiedzieć o istnieniu Shell-a.

Co do samej implementacji - zaznaczyć, że jest to tylko przykładowa implementacja i istnieje ładniejszy, funkcyjny sposób, jednak aby nie komplikować przykładu zdecydowałem się na uproszczoną implementację.

W samym kontrolerze niewiele się zmieniło. Zamiast działać na module lub serwisie, działamy na fasadzie:

public IActionResult Action(SomeInput input)
{
	Validate(input);
	var result = _someFacade.Action(Command.From(input));
	
	if (result.Success) {
		return View(result.Data);
	}
	
	return View("error);
}

Sama fasada byłaby odpowiedzialna za orchiestrację całego procesu (hello application layer).

public class SomeFacade(ISomeRepository repository) : ISomeFacade
{
	public async Task Create(LicenseCommands.CreateLicense command)
	{
		var (some, params) = command;
		var license = SomeEntity.Create(some, params);
		repository.Add(license);
		await repository.Save();
	}
}

Oczywiście zamiast fasady moglibyśmy w tym miejscu użyć wzorca Command - Handler, którego osobiście jestem wielkim fanem. Jednak dla uproszczenia samej implementacji zostańmy przy fasadzie.

Natomiast w Core moglibyśmy zbudować prawdziwy model domenowy, oparty o taktyczne wzorce Domain-Driven Design (oczywiście, o ile pasują do rozwiązania problemu biznesowego).

public sealed class SomeEntity : Entity<Guid>, AggregateRoot<Guid>
{
	public enum States { State1, State2 }
	
	public States State { get; private set; } = States.State1;
	public Some Some { get; private set; }
	public Param Param { get; private set; }
	private SomeEntity(Guid id) { Id = id; }
	
	private void Apply(Events.SomeEntityCreated @event)
	{
		Some = @event.Some;
		Param = @event.Param;
	}
	
	public static SomeEntity Create(Some some, Param param)
	{
		var id = Guid.NewGuid();
		var @event = new Events.SomeEntityCreated(some, param);
		
		var someEntity = new SomeEntity(id);
		someEntity.Apply(@event);
		return someEntity;
	}
	
	public abstract record Events : DomainEvent
	{
		public record SomeEntityCreated(Some Some, Param param) : Events;
	}
}

Przypominam, że jest to tylko przykład koncepcji, nie najładniejsze dostępne rozwiązanie.

Wpływ na modularyzację

Największy problem jaki miałem z MVC opierał się na tym, że wszystko musiało być publiczne. Dodatkowo, jak były tylko 3 warstwy, ciężko było je zmodularyzować. W przypadku zastosowania wyżej wymienionego podejścia nic nie stoi na przeszkodzie aby warstwa modułu została zmodularyzowana.

Wtedy struktura naszego projektu mogłaby wyglądać następująco:

- MyApp\
-- Controllers\
---- HomeController.cs
-- Model\
--- Module-One\
---- Core\
----- SomeDomainEntity.cs 
---- Shell\
----- SomeFacade.cs
--- Module-Two\
---- Core\
----- SomeDomainEntity.cs 
---- Shell\
----- SomeFacade.cs
-- Views\
---- Home\
----- Index.cshtml

Idąc dalej, jako że elementy, które operują na naszym shell-u będą dość mocno z nim powiązane, można by nawet uznać, że mogłyby stanowić część "modułu".

Trochę zaczyna to przypominać Vertical Slice, acz nie wiem czy chciałbym iść dalej w tą stronę... Separacja pomiędzy controlerem, widokiem a modelem może być zbyt zatarta w takim przypadku, co powoduje, że ciężko będzie nazwać tą architekturę jako MVC.

Podsumowanie

Czy MVC to tylko architektura z stylu "Encja na twarz i pchasz"? Moim zdaniem nie.

Czy ta architektura może konkurować z innymi architekturami jak Modularny Monolit, czy Vertical Slice? A dlaczego miałaby? Każda architektura ma swoje wady i zalety. To naszym zadaniem jako projektantów rozwiązania jest znalezienie odpowiedniej architektury.

Jakie mogą być zalety stosowania tego rozwiązania?

  • Separacja odpowiedzialności
  • Skoncentrowanie uwagi na modelu, który nie musi wcale oznaczać "odwzorowania" tabel z bazy danych
  • Elastyczność

Natomiast jakie mogę być wady?

  • Programiści słysząc hasło MVC od razu będą mieli pewną wizję jak kod w takiej aplikacji ma wyglądać (najczęściej to będzie widziane jako architektura typu: encja na twarz i pchasz)
  • Nawet pomimo modularyzacji katalog modelu będzie nam puchnąć z czasem

Jak dla mnie jest to architektura, którą można nadal z powodzeniem stosować w aplikacjach, gdzie poziom skomplikowania nie jest na bardzo wysokim (lub nawet wysokim) poziomie. W przypadku nie dużej złożoności domeny MVC nadal może się okazać poprawnym rozwiązaniem.

Zawsze istnieje przypadek, że programiści nie zdzierżą rozbicia na warstwy i moduły... ale w takim wypadku... nie wiem... można by chociaż trochę podyskutować 😅

Jak zawsze mam nadzieje, że artykuł się podobał i był ciekawy.

Do Następnego!

Cześć! 👋

Referencje

By Bd90 | 04-03-2024 | .NET, Felietony