PizzaMVC - wprowadzenie

To właściwie powinien być pierwszy post dotyczący mojego projektu, ale najpierw musiałem zrobić małą refaktoryzację, a później znaleźć wenę. W tym poście opisuję podstawowe klasy, z których korzysta się w PizzaMVC, a także postaram się wyjaśnić czemu w taki sposób to zaimplementowałem oraz jakoś umotywować swoje decyzje projektowe. Zapraszam do lektury i ostrzegam, że będzie raczej długo.

Wprowadzenie

Celem PizzyMVC jest umożliwienie tworzenia aplikacji crudowych minimalnym nakładem kodu, ale przy zachowaniu dobrych zasad programowania, czyli uniknięciu kardynalnych błędów. Oczywiście dałoby się zrobić framework wymagający od programisty jeszcze mniej kodu do napisania, ale odbyłoby się to kosztem łamania SRP i spadkiem wydajności, a tego chciałem uniknąć.

Żeby była jasność czym jest CRUD - PizzaMVC pozwala na połączenie tabeli w bazie danych z zestawem ekranów umożliwiających wyświetlenie i edycję danych z tejże tabeli. Są to: Index (z gridem), Create, Edit i Delete. Oczywiście tabeli nie definiujemy za pomocą SQL tylko stosujemy code first. Jesteśmy w końcu programistami, a nie administratorami baz danych.

Korzystanie z PizzaMVC

Dodanie nowej tabeli oraz odpowiadających jej ekranów wymaga kilku kroków:

Model danych

Zaczynamy od zdefiniowania klasy opisującej strukturę danych w bazie. Klasa taka musi implementować interfejs IPersistenceModel. Można także skorzystać z dostarczanej przez Pizzę standardowej implementacji w postaci klasy PersistenceModelBase. Klasa ta oferuje także wersjonowanie na potrzeby optimistic concurrency oraz prosty audyt (data utworzenia, kto utworzył, data modyfikacji, kto ostatnio zmodyfikował). Jeśli zaś chcemy, aby klasa podlegała pod soft delete, wystarczy skorzystać z SoftDeletableModelBase, Ponadto, w przestrzeni nazw Pizza.Persistence.Attributes znajduje się kilka atrybutów pozwalających na zdefiniowanie sposobu mapowania właściwości na kolumny w bazie. Pozwalają one na:

  • zezwolenie na wstawianie do kolumny wartości null;
  • definiowanie rozmiaru oraz typu kolumny tekstowej;
  • unikalność wartości;
  • a także obsługę komponentów - czyli mapowanie właściwości jakiegoś typu nie jako oddzielna tabela lecz zestaw kolumn w tabeli-rodzicu.

Przykład modelu danych:

 1 public class Customer : SoftDeletableModelBase
 2 {
 3     [Unique, UnicodeString(30)]
 4     public virtual string Login { get; set; }
 5 
 6     [FixedLengthAnsiString(128)]
 7     public virtual string Password { get; set; }
 8 
 9     public virtual string FirstName { get; set; }
10     public virtual string LastName { get; set; }
11     public virtual int FingersCount { get; set; }
12     public virtual DateTime PreviousSurgeryDate { get; set; }
13     public virtual DateTime SomeDateInFuture { get; set; }
14     public virtual double HairLength { get; set; }
15     public virtual CustomerType Type { get; set; }
16 
17     [AllowNull]
18     public virtual AnimalSpecies? Animal { get; set; }
19 
20     [AllowNull, UnicodeString]
21     public virtual string Description { get; set; }
22 
23     [AllowNull, UnicodeString]
24     public virtual string Notes { get; set; }
25 
26     public virtual string FullName
27     {
28         get { return string.Format("{0} {1}", this.FirstName, this.LastName); }
29     }
30 }

Modele prezentacji

Następnym krokiem jest zdefiniowanie zestawu modeli prezentacji dla każego ekranu. Klasy te muszą implementować: IGridModelBase, ICreateModelBase, IEditModelBase oraz IDetailsModelBase. Pizza oferuje oczywiście domyślne implementacje w postaci klas: GridModelBase, CreateModelBase, EditModelBase oraz DetailsModelBase. Jeśli chodzi o manipulowanie wyświetlaniem, to obsługiwane są standardowe atrybuty z przestrzeni nazw System.ComponentModel.DataAnnotations, takie jak Display, Editable, Required, DataType. W przypadku rich text edit potrzebny jest oczywiście System.Web.Mvc.AllowHtml. Czyli wszystko tak samo, jak w standardowej aplikacji ASP.NET MVC. Przykłady:

  1 public sealed class CustomerGridModel : GridModelBase
  2 {
  3     [Display(Name = "Login")]
  4     public string Login { get; set; }
  5 
  6     [Display(Name = "First name")]
  7     public string FirstName { get; set; }
  8 
  9     [Display(Name = "Last name")]
 10     public string LastName { get; set; }
 11 
 12     [Display(Name = "Previous surgery date")]
 13     public DateTime PreviousSurgeryDate { get; set; }
 14 
 15     [Display(Name = "Fingers count")]
 16     public int FingersCount { get; set; }
 17 }
 18 
 19 public sealed class CustomerCreateModel : CreateModelBase
 20 {
 21     [Display(Name = "Login"), Required(ErrorMessage = Resources.RequiredMessage)]
 22     public string Login { get; set; }
 23 
 24     [Display(Name = "Password"), Required(ErrorMessage = Resources.RequiredMessage)]
 25     [DataType(DataType.Password)]
 26     public string Password { get; set; }
 27 
 28     [Display(Name = "First name")]
 29     public string FirstName { get; set; }
 30 
 31     [Display(Name = "Last name"), Required(ErrorMessage = Resources.RequiredMessage)]
 32     public string LastName { get; set; }
 33 
 34     [Display(Name = "Fingers count"), Required(ErrorMessage = Resources.RequiredMessage)]
 35     public int FingersCount { get; set; }
 36 
 37     [Display(Name = "Is mature"), Required(ErrorMessage = Resources.RequiredMessage)]
 38     public bool IsMature { get; set; }
 39 
 40     [Display(Name = "Previous surgery date"), Required(ErrorMessage = Resources.RequiredMessage)]
 41     public DateTime PreviousSurgeryDate { get; set; }
 42 
 43     [Display(Name = "Some other date"), Required(ErrorMessage = Resources.RequiredMessage)]
 44     public DateTime SomeDateInFuture { get; set; }
 45 
 46     [Display(Name = "Customer type"), Required(ErrorMessage = Resources.RequiredMessage)]
 47     public CustomerType Type { get; set; }
 48 
 49     [Display(Name = "Favorite animal")]
 50     public AnimalSpecies? Animal { get; set; }
 51 
 52     [Display(Name = "Long description")]
 53     [AllowHtml, DataType(DataType.Html)]
 54     public string Description { get; set; }
 55 
 56     [Display(Name = "My notes")]
 57     [AllowHtml, DataType(DataType.Html)]
 58     public string Notes { get; set; }
 59 }
 60 
 61 public sealed class CustomerEditModel : EditModelBase
 62 {
 63     [Display(Name = "Login")]
 64     [Editable(false)]
 65     public string Login { get; set; }
 66 
 67     [Display(Name = "First name")]
 68     public string FirstName { get; set; }
 69 
 70     [Display(Name = "Last name"), Required(ErrorMessage = Resources.RequiredMessage)]
 71     public string LastName { get; set; }
 72 
 73     [Display(Name = "Fingers count"), Required(ErrorMessage = Resources.RequiredMessage)]
 74     [Editable(false)]
 75     public int FingersCount { get; set; }
 76 
 77     [Display(Name = "Previous surgery date"), Required(ErrorMessage = Resources.RequiredMessage)]
 78     public DateTime PreviousSurgeryDate { get; set; }
 79 
 80     [Display(Name = "Customer type"), Required(ErrorMessage = Resources.RequiredMessage)]
 81     public CustomerType Type { get; set; }
 82 
 83     [Display(Name = "Favorite animal")]
 84     public AnimalSpecies? Animal { get; set; }
 85 
 86     [Display(Name = "Long description")]
 87     [AllowHtml, DataType(DataType.Html)]
 88     public string Description { get; set; }
 89 
 90     [Display(Name = "My notes")]
 91     [AllowHtml, DataType(DataType.Html)]
 92     public string Notes { get; set; }
 93 }
 94 
 95 public sealed class CustomerDetailsModel : DetailsModelBase
 96 {
 97     [Display(Name = "Login")]
 98     public string Login { get; set; }
 99 
100     [Display(Name = "First name")]
101     public string FirstName { get; set; }
102 
103     [Display(Name = "Last name")]
104     public string LastName { get; set; }
105 
106     [Display(Name = "Fingers count")]
107     public int FingersCount { get; set; }
108 
109     [Display(Name = "Previous surgery date")]
110     public DateTime PreviousSurgeryDate { get; set; }
111 
112     [Display(Name = "Customer type")]
113     public CustomerType Type { get; set; }
114 }

Warstwa serwisów aplkacyjnych

PizzaMVC w przeciwieństwie do podejścia znanego z mainstreamowej architektury nie ma pseudorepozytoriów będących jedynie wraperami na ORM ani logiki innej niż prezentacyjna w kontrolerach. Nie ma też żadnych nadmiarowych warstw ani obiektów - CRUD jest prosty, więc PizzaMVC też jest prosta. Ponieważ w dobrze napisanej aplikacji, cała logika aplikacyjna znajduje się w serwisach aplikacyjnych (czemu - to powinno być oczywiste, ale praktyka pokazuje, że nie jest, więc będę to musiał wyjaśnić w przyszłych postach), to o PizzaMVC oparta jest właśnie o warstwę serwisów aplikacyjnych. Główną klasą jest klasa CrudServiceBase<TPersistenceModel, TGridModel, TDetailsModel, TEditModel, TCreateModel>. Jak widać łączy ona model danych z wszystkimi modelami widoku. Klasa ta sama nie robi praktycznie nic - operacje modyfikujace dane deleguje do PersistenceModelsStore<TPersistenceModel, TEditModel, TCreateModel> natomiast odczyt danych do ViewModelsReader<TPersistenceModel, TGridModel, TDetailsModel, TEditModel, TCreateModel>. CrudServiceBase przyjmuje w zależności ISession i operuje bezpośrednio na nim.

Serwisy crudowe dla naszych własnych modeli muszą dziedziczyć z tej klasy, wygląda to na przykład tak:

1 public class CustomersCrudService 
2     : CrudServiceBase<Customer, CustomerGridModel, CustomerDetailsModel, CustomerEditModel, CustomerCreateModel>, ICustomersService
3 {
4     public CustomersCrudService(ISession session) : base(session)
5     {
6     }
7 }

Istotna uwaga - potrzebny jest także interfejs dla serwisu. W tym przypadku wygląda on tak:

1 public interface ICustomersService
2     : ICrudServiceBase<CustomerGridModel, CustomerDetailsModel, CustomerEditModel, CustomerCreateModel>
3 {
4 }

Jest on niezbędny, gdyż interfejs serwisu jest znany klienciej aplikacji webowej. Jak widać interfejs zna jedynie viewmodele. Gdyby klasa serwisu miała być znana aplikacji webowej, wówczas musiała by mieć ona referencje do asembly zawierającego kontrakty modelu danych, a to nie miałoby przecież sensu.

Kontrolery

Ostatni etap to kontroler:

 1 public class CustomersController
 2     : CrudControllerBase<ICustomersService, CustomerGridModel, CustomerDetailsModel, CustomerEditModel, CustomerCreateModel>
 3 {
 4     public CustomersController(ICustomersService service) : base(service)
 5     {
 6     }
 7 
 8     protected override Dictionary<ViewType, string> ViewNames
 9     {
10         get
11         {
12             return new Dictionary<ViewType, string> {
13                 { ViewType.Index, "Customers list" },
14                 { ViewType.Create, "New Customer form" },
15                 { ViewType.Edit, "Edit Customer form" },
16                 { ViewType.Details, "Customer details" },
17             };
18         }
19     }
20 
21     protected override GridMetamodel<CustomerGridModel> GetGridMetamodel()
22     {
23         var gridMetaModel = new GridMetamodelBuilder<CustomerGridModel>()
24             .SetCaption("Customers list")
25             .AllowNew("New Customer").AllowEdit().AllowDelete().AllowDetails()
26             .AddColumn(x => x.LastName, 200)
27             .AddColumn(x => x.FirstName, 200)
28             .AddDefaultSortColumn(x => x.FingersCount, SortMode.Descending, 150, ColumnWidthMode.Fixed, FilterOperator.Disabled)
29             .AddColumn(x => x.PreviousSurgeryDate, 150, ColumnWidthMode.Fixed, FilterOperator.DateEquals)
30             .AddColumn(x => x.Animal, 100)
31             .AddColumn(x => x.Type, 100)
32             .Build();
33 
34         return gridMetaModel;
35     }
36 }

Jak widać jest on parametryzowany interfejsem serwisu oraz czterema typami view modeli. Ponadto znajduje się w nim definicja layoutu grida, który zostanie wyświetlony w widoku Index oraz tytuły stron dla pozostałych widoków.

Efekt końcowy

Dzięki temu uzyskujemy w bazie tabelę, która wygląda tak:

Customers table

Widok listy rekordów z funkcjonalnym gridem:

Customers grid

Widok tworzenia nowego rekordu:

Customer create

Edycję istniejącego rekordu:

Customer edit

Oraz podgląd szczegółów:

Customer details

A tak wygląda komunikat przy usuwaniu rekordu:

Customer delete

Podsumowanie

I to tyle. Jak widać nie musimy bezpośrednio korzystać z ORMa, zarządzać transakcjami, nie piszemy ani linijki HTML czy JS. A co najważniejsze nie kopiujemy plików cshtml, nie tworzymy bliżniaczych kontrolerów z niemalże identyczną implementacją akcji. Wszystko to za nas robi framework, a do tego jeszcze ładnie odczytuje viewmodele odczytując z bazy tylko te właściwości, które chcemy mieć na widoku, dba o soft delete, audyt, transakcje i optimistic concurrency. A my mamy więćej czasu na tę część aplikacji, która wymaga czegoś więcej niż prostego CRUD.

Wady

Oczywiście można powiedzieć, że niepotrzebne są te wszystkie “puste” interfejsy i klasy. Oczywiśćie dałoby się zrobić automagicznie. Myślałem nad tym, ale doszedłem do wniosku, że w praktyce do takiego CrudControllera czy CrudSerwisu zawsze trzeba będzie dodać jeszcze jakieś metody, albo nadpisać te z klasy bazowej. W tym celu i tak trzeba byłoby utworzyć te klasy. Moim zdaniem nie warto pisać jakiegoś automatycznego generowania dla mniejszości przypadków.

Nie jestem też do końca przekonany, czy konfiguracja grida powinna znajdować się w kontrolerze. Możliwe, że to się zmieni.

Opublikowano: