Entity Framework – aplikacja trójwarstwowa

Autor: Piotr Zieliński

Wymagane oprogramowanie

Aby uruchomić program dołączony do artykułu, należy zainstalować następujące oprogramowanie:

  • Visual Studio 2010 RC,
  • SQL Server 2008 express,
  • Microsoft Entity Framework Feature CTP 3.

Wprowadzenie

Entity Framework jest doskonałym narzędziem typu ORM (ang*. Object RelationalMapping*), przeznaczonym do budowania aplikacji zarówno dwuwarstwowych, jak i trójwarstwowych. W artykule opisano sposób budowania aplikacji składającej się z trzech warstw. Warstwa pośrednia oparta jest na technologii Windows Communication Foundation, natomiast warstwa prezentacji − na Windows Presentation Foundation. Aplikacja kliencka łączy się zatem bezpośrednio z usługą sieciową (WCF), a nie z bazą danych. Dlaczego zatem warto korzystać z trójwarstwowego modelu aplikacji?

Przede wszystkim oprogramowanie o budowie trójwarstwowej jest znacznie bardziej skalowalne. Łatwo zaimplementować tzw. mechanizm równoważenia obciążenia (ang. loadbalancing), który rozdziela ruch do konkretnej bazy danych (lub innej usługi sieciowej) w zależności od natężenia. Gdyby ruch okazał się naprawdę intensywny lub system musiał działać nawet w przypadku awarii jakieś bazy danych, można by wprowadzić rozproszone bazy danych. Warto podkreślić, że wszystkie te udoskonalenia dałoby się wprowadzić bez ingerencji w kod aplikacji klienckiej, użytkownik korzystałby zatem ciągle z tej samej aplikacji.

Umieszczenie logiki biznesowej w osobnej warstwie znacząco ułatwia testy integracyjne, które mają na celu sprawdzenie połączeń między modułami aplikacji. W takich testach niezbędne jest zastąpienie realnych obiektów tzw. obiektami mock – klasami, które symulują obliczenia i dostarczają tak naprawdę z góry zdefiniowany wynik. Posiadając system podzielony na warstwy, dużo łatwiej określić połączenia między modułami i wprowadzić obiekty mock.

Ponadto w usłudze sieciowej może być zastosowany zaawansowany model autoryzacji i uwierzytelniania użytkownika. Dostęp do metod usługi sieciowej jest wtedy uzależniony od pozwoleń użytkownika. Na przykład każdy zalogowany użytkownik ma prawo odczytywania danych, a przywilej zapisywania przysługuje wyłącznie użytkownikom o rozszerzonych uprawnieniach. Fakt, że zmiana reguł dostępu do danych następuje niezależnie od aktualizacji aplikacji klienckiej, jest bardzo istotny. Dzięki temu bowiem, gdy okaże się, że któryś użytkownik ma zbyt wysokie uprawnienia, można w każdej chwili zmienić reguły dostępu do danych.

Rysunek 1. Architektura aplikacji.

Do zademonstrowania opisywanych zagadnień wykorzystano bazę danych modelującą bardzo prosty system sprzedaży:

Rysunek 2. Struktura bazy danych.

W artykule pominięto opis generowania modelu encji na podstawie danych. Łatwo go jednak stworzyć na podstawie bazy danych za pomocą gotowego kreatora w Visual Studio (wystarczy wybrać ADO .NET Entity Model, kliknąć na generate from database i postępować zgodnie z instrukcjami kreatora).

Drugim sposobem jest stworzenie modelu encji i pozwolenie, aby Entity Framework wygenerował stosowną strukturę tabel w bazie danych.

Prawidłowy diagram encji powinien po zmapowaniu wyglądać następująco:

Rysunek 3. Model encji prostego systemu sprzedaży.

Aplikacja składa się z czterech podprojektów:

  • SalesSystemModel – zawiera model Entity Framework oraz tzw. warstwę persystencji,
  • SalesSystemEntities –  zawiera specjalne encje wygenerowane przez szablony T4,
  • SalesSystemService – eksponuje usługę sieciową WCF,
  • WpfClient – aplikacja kliencka napisana w WPF prezentuje możliwości usługi sieciowej.

Czym jest Microsoft Entity Framework Feature CTP 3

Entity Framework Feature to zestaw rozszerzeń do podstawowej wersji EF. Jednym z tych ulepszeń jest wsparcie dla aplikacji trójwarstwowych, opartych na Windows Communication Foundation.

Wsparcie dla WCF jest realizowane za pomocą silnika do automatycznego generowania kodu, a konkretnie TextTemplateTransformation Toolkit (T4). Szablony T4 tworzy się w specjalnym języku dyrektyw, przypominającym w składni ASP .NET. Szablon ma za zadanie wygenerować zbiór klas dla konkretnego języka (np. c#). Proces generowania kodu wyjściowego składa się z kilku etapów:

Rysunek 4. Proces generowania kodu przez T4.

Po kompilacji szablonu powstaje klasa GeneratedTextTransformation, która ma przeładowaną metodę TransformText. To ona właśnie w momencie zapisywania kodu wyjściowego zostaje wywołana i zwraca wygenerowany tekst.

Przykładowy szablon może wyglądać następująco:

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ output extension=".cs" #>
<#Write("class HelloClass\n{");#>   
    public void HelloWorld()
    {
            
    }
}

a tekst (kod) wyjściowy:

class HelloClass
{   
    public void HelloWorld()
    {
            
    }
}

Model encji oraz warstwa persystencji dla aplikacji trójwarstwowej

Encje wygenerowane przez standardowy kreator EF nie nadają się bezpośrednio do użycia w serwisie WCF, ponieważ nie umożliwiają przesyłania informacji o stanie konkretnej encji między klientem a WCF. Dlatego Self-TrackingEntities uzupełnia podstawowe encje z EF o dodatkowe funkcje:

  • każda encja może znajdować się w jednym z następujących stanów: Added, Deleted, Modified, or Unchanged,
  • stan kolekcji – wszystkie operacje na listach są śledzone,
  • śledzenie wartości klucza obcego w przypadku relacji.

Należy zatem wygenerować specjalne encje za pomocą szablonów T4 znajdujących się w Entity Framework Feature CTP 3. Na tym etapie czytelnik powinien mieć już utworzony podstawowy model encji, przedstawiony na rysunku 3. Zapytania SQL, generujące tabele oraz kompletny kod źródłowy aplikacji, zostały dołączone do artykułu.

W celu uzyskania encji przystosowanych dla WCF za pomocą T4 wykonujemy następujące kroki:

  1. Otwieramy model encji (plik SalesSystemModel.edmx).

  2. Klikamy prawym przyciskiem myszy na obszar roboczy, a następnie wybieramy AddCodeGenerationItem.

    Rysunek 5. Dodanie szablonu T4 generującego encje.

  3. Następnie wybieramy ADO .NET Self-TrackingEntity Generator i podajemy nazwę SalesSystem.

    Rysunek 6. Wstawianie szablonu T4.

  4. Do projektu zostały dodane dwa szablony T4: SalesSystem.Context.tt oraz SalesSystem.Types.tt. Pierwszy stanowi warstwę persystencji – kodu wykonującego fizyczny zapis danych w konkretnej bazie. Drugi posiada encje przystosowane do współpracy z WCF.

W oknie Solution Explorer szablony w węzłach podrzędnych zawierają wygenerowane pliki klas. Przyjrzyjmy się bliżej klasie DiscountedProduct, która została utworzona przez SalesSystem.Types.tt:

[DataContract(IsReference = true)]
public partial class DiscountedProduct : Product, 
                                IObjectWithChangeTracker,       INotifyPropertyChanged
{
        #region Primitive Properties
    
        [DataMember]
        public byte Discount
        {
            get { return _discount; }
            set
            {
                if (_discount != value)
                {
                    _discount = value;
                    OnPropertyChanged("Discount");
                }
            }
        }
        private byte _discount;

        #endregion
        #region ChangeTracking
    
        protected override void ClearNavigationProperties()
        {
            base.ClearNavigationProperties();
        }

        #endregion
}

Warto zauważyć, że klasa opatrzona jest atrybutem DataContract, a wszystkie metody − DataMember. Programiści WCF z pewnością rozpoznają te atrybuty, używa się ich bowiem w obiektach przesyłanych przez sieć, np. między klientem a usługą sieciową. Serializacja odbywa się za pomocą klasy DataContractSerializer, natomiast właściwość IsReference umożliwia serializację obiektów zawierających graf (np. relacje rodzic − dziecko).

W klasach encji trzeba również zwrócić uwagę na właściwość ChangeTracker zwracającą obiekt ObjectChangeTracker, który śledzi wszelkie zmiany stanu. Wszelkie kolekcje danych zostały zastąpione TrackableCollection – specjalną listą danych wspierającą mechanizm śledzenia zmian. Gdy jakaś właściwość zostanie zmieniona, ChangeTracker.State ustawiany jest na wartość Modified. W przypadku usunięcia encji pole przyjmuje wartość Deleted. W podobny sposób realizowany jest mechanizm śledzenia zmian w kolekcjach. TrackableCollection to w rzeczywistości kolekcja dziedzicząca po ObservableCollection. Zdarzenia ObservableCollection pozwalają określić, kiedy jakiś element zostaje dodany lub usunięty. W TrackableCollection dodano po prostu dwa rozszerzenia: zapobieganie duplikacji elementów podczas wstawiania nowych encji oraz powiadamianie o usuwaniu każdego elementu podczas wywoływania metody ClearItems.

public class TrackableCollection<T> : ObservableCollection<T>
    {
        protected override void ClearItems()
        {
            new List<T>(this).ForEach(t => Remove(t));
        }
    
        protected override void InsertItem(int index, T item)
        {
            if (!this.Contains(item))
            {
                base.InsertItem(index, item);
            }
        }
    }

Tworzenie projektu SalesSystemEntities

Aby odseparować kod encji od reszty projektu, należy umieścić je w osobnej bibliotece. W tym celu trzeba utworzyć nową bibliotekę dll (projekt Class Library) i nazwać ją SalesSystemEntities.

Rysunek 7. Tworzenie nowej biblioteki.

Po utworzeniu biblioteki usuwamy zbędną, automatycznie dodaną klasę Class1. Następnym zadaniem jest przeniesienie encji wygenerowanych przez T4 w projekcie SalesSystemModel do nowo utworzonej biblioteki SalesSystemEntities. Wykonujemy zatem następujące czynności:

  1. Klikamy na pliku SalesSystem.Types.tt i z menu kontekstowego wybieramy właściwości. W otwartym oknie kasujemy zawartość pola CustomTool, aby szablon T4 przestał generować encje w projekcie SalesSystemModel (generowanie chcemy przenieść do osobnej biblioteki).

    Rysunek 8. Pole CustomTool trzeba wykasować.

  2. Kasujemy wszystkie automatycznie utworzone encje w projekcie SalesSystemModel.

    Rysunek 9. Automatycznie wygenerowane encje należy usunąć.

    Podobnie postępujemy z plikiem SalesSystem.Context.tt – kasujemy wygenerowane już klasy oraz wartość właściwości CustomTool.

  3. Do projektu SalesSystemEntities (biblioteka dll) dodajemy plik SalesSystem.Types.tt. W tym celu z menu kontekstowego w Solution Explorer wybieramy Add->ExistingItem. Przechodzimy do katalogu, w którym znajduje się plik SalesSystem.Types.tt, klikamy na przycisku Add i wskazujemy Add as Link.

  4. Na końcu dodajemy bibliotekę System.Runtime.Serialization do projektu, ponieważ wygenerowane encje wymagają dostępu do atrybutów WCF (DataContract, DataMember). W tym celu w Solution Explorer klikamy na węźle References, a następnie z menu kontekstowego wybieramy Add Reference.

    Rysunek 10. Biblioteka System.Runtime.Serialization jest niezbędna do skompilowania projektu.

Tworzenie oraz konfiguracja projektu Windows Communication Foundation

Kolejnym krokiem jest utworzenie usługi sieciowej SalesSystemService:

  1. W menu głównym klikamy na File->New->Project.

  2. Wybieramy szablon o nazwie WCF Service Application.

    Rysunek 11. Tworzenie projektu WCF Service Application.

  3. By zachować nazewnictwo, zmieniamy domyślną nazwę Service1 oraz IService1 na SalesSystemService i ISalesSystemService.

  4. Usługa sieciowa potrzebuje dostępu do encji (projekt SalesSystemEntities), metadanych (SalesSystemModel) oraz kontekstu (plik SalesSystem.Context.tt). Zarówno encje, metadane, jak i kontekst dodajemy podobnie jak w poprzednich krokach. W celu dodania encji oraz metadanych klikamy na Add Reference i wskazujemy skompilowane biblioteki SalesSystemEntities i SalesSystemModel. Kontekst dodajemy, klikając na AddExistingItem w menu kontekstowym Solution Browser, a następnie, po wskazaniu pliku SalesSystem.Context.tt, wybieramy Add as Link.

  5. Encje oraz kontekst generowane są w osobnych przestrzeniach nazw (SalesSystemEntities i SalesSystemSevice). Należy ustawić jedną wspólną przestrzeń nazw (SalesSystemEntities), ponieważ inaczej kontekst będzie się odwoływał do nieistniejących encji (domyślnie kontekst szuka encji w swojej przestrzeni nazw). Klikamy więc na pliku SalesSystem.Context.tt i w oknie właściwości ustawiamy pole Customtoolnamespace na SalesSystemEntities.

    Rysunek 12. Ustawienie domyślnej przestrzeni nazw.

  6. W pliku web.config dodajemy connection string:

<connectionStrings>
    <add name="EF3Entities" connectionString="metadata=res://*/SalesSystemModel.csdl|res://*/SalesSystemModel.ssdl|res://*/SalesSystemModel.msl;provider=System.Data.SqlClient;provider connection string=&quot;Data Source=piotr-pc;Initial Catalog=EF3;Integrated Security=True;MultipleActiveResultSets=True&quot;" providerName="System.Data.EntityClient" />

</connectionStrings>
  1. Następnym krokiem jest dodanie do projektu bibliotek System.Data, System.Data.Entity oraz System.Data.DataSetExtensions.

    Rysunek 13. Dodawanie referencji do wymaganych bibliotek.

Metody usługi sieciowej

Po niezbędnej konfiguracji można przejść do implementacji metod usługi sieciowej. W przykładowym kodzie będą to podstawowe metody tworzenia, aktualizacji, usuwania oraz selekcji rekordów – żadnych skomplikowanych reguł biznesowych. Najpierw definiujmy kontrakt (interfejs), czyli plik ISalesSystemService:

[ServiceContract]
    public interface ISalesSystemService
    {
        [OperationContract]
        int AddProduct(Product product);        
        [OperationContract]
        void RemoveProduct(int productId);
        [OperationContract]
        void UpdateProduct(Product product);
        [OperationContract]
        Product GetProductById(int productId);
        [OperationContract]
        IEnumerable<Product> GetAllProducts();
        [OperationContract]
        IEnumerable<ProductWithInvoice> GetAllProductsWithInvoice();
        [OperationContract]
        IEnumerable<Product>  FindProductByBarCode(string barCode);


        [OperationContract]
        int AddInvoice(Invoice invoice);
        [OperationContract]
        void RemoveInvoice(int invoiceId);
        [OperationContract]
        void UpdateInvoice(Invoice invoice);
        [OperationContract]
        Invoice GetInvoiceById(int invoiceId);
        [OperationContract]
        IEnumerable<Invoice> GetAllInvoices();
        [OperationContract]
        IEnumerable<Invoice> FindInvoicesByReceiverName(string receiverName);
    }

Implementacja metod jest bardzo podobna do wykorzystania zwykłego Entity Framework. Aby dodać produkt, wystarczy stworzyć obiekty klasy dziedziczącej po ObjectContext:

public int AddProduct(SalesSystemEntities.Product product)
        {
            using (SalesSystemEntities.EF3Entities context = new SalesSystemEntities.EF3Entities())
            {
                context.Products.AddObject(product);
                context.SaveChanges();
                return product.ID_PRODUCT;
            }
        }

Podobnie przebiega wyszukiwanie produktów za pomocą procedury składowej, zwracającej typ złożony:

public IEnumerable<SalesSystemEntities.ProductWithInvoice> GetAllProductsWithInvoice()
        {
            using (SalesSystemEntities.EF3Entities context = new SalesSystemEntities.EF3Entities())
            {
                return context.GetProductsWithInvoice().ToArray();
            }
        }

Konfiguracja klienta

Po utworzeniu aplikacji WPF należy dodać niezbędne referencje, a dokładniej SalesSystemEntities (encje) oraz SalesSystemService (usługa sieciowa).

W celu dodania referencji do biblioteki encji, klikamy na węźle References i wybieramy Add Reference w menu kontekstowym.

Rysunek 14. Dodawanie referencji do encji.

Aby klient mógł korzystać z usługi sieciowej, trzeba najpierw dodać do niej referencję:

  1. Klikamy na węzeł References (okno Solution Browser) i z menu kontekstowego wybieramy Add Service Reference.

  2. Klikamy na przycisk Discovery, a następnie zaznaczamy usługę SalesSystemService.

Rysunek 15. Okno dodawania referencji do usługi WCF.

Po zatwierdzeniu automatycznie zostaną wygenerowane potrzebne obiekty (proxy, klient).

Wykorzystanie usługi sieciowej w aplikacji klienckiej

 W celu wywołania jakiejkolwiek metody usługi sieciowej musimy najpierw utworzyć obiekt pośredniczący między klientem a zdalną usługą sieciową. W poprzednim kroku automatycznie została wygenerowana klasa, która umożliwia wykonanie operacji na WCF. Wystarczy więc stworzyć instancję tej klasy:

ServiceReference1.SalesSystemServiceClient serviceClient = new ServiceReference1.SalesSystemServiceClient();

Następnie można wykonywać operacje na usłudze sieciowej, tak jakbyśmy operowali na zwykłej, lokalnej klasie:

serviceClient.AddProduct(ProductModel);
serviceClient.RemoveProduct(4);

Ponadto wszystkie encje posiadają mechanizm śledzenia zmian, który można wykorzystać również w aplikacji klienckiej. Wyobraźmy sobie, że mamy okno, w którym użytkownik dodaje nową fakturę bądź modyfikuje już istniejącą. Klasa okna zawiera dwa konstruktory:

public Invoice()
{
}
public Invoice( SalesSystemEntities.Invoice invoice)
{
}

Użytkownik kodu, chcąc stworzyć okno dodające nową fakturę, wywołuje pierwszy konstruktor. Jeśli zamierza zmodyfikować już istniejącą, wywołuje drugi konstruktor i przekazuje referencję na załadowaną fakturę.

Jak więc za pomocą jednej metody przeprowadzić prawidłową operację (aktualizacji lub wstawienia)? Jeśli użytkownik nie wprowadził żadnych zmian do faktury, nie powinno się wykonywać aktualizacji. Odpowiedź na postawione wyżej pytanie brzmi: oczywiście zastosować mechanizm śledzenia zmian oraz właściwość ChangeTracker. Można więc napisać następujący kod:

if (Invoice.ChangeTracker.State == SalesSystemEntities.ObjectState.Added)
       // wstawienie obiektu
else if (Invoice.ChangeTracker.State == ObjectState.Modified)
      // aktualizacja obiektu

Domyślnie każda encja zwrócona przez WCF będzie miała uaktywniony mechanizm śledzenia zmian. Jednak gdy sami tworzymy nową encję (za pomocą słowa kluczowego „new”), musimy włączyć śledzenie zmian za pomocą właściwości ChangeTracker.ChangeTrackingEnabled bądź wykorzystując metodę rozszerzającą StartTracking:

Invoice.ChangeTracker.ChangeTrackingEnabled = true;
Invoice.StartTracking();

W przypadku metody rozszerzającej należy pamiętać o zadeklarowaniu przestrzeni nazw, w której się ona znajduje:

usingSalesSystemEntities;

Zakończenie

Microsoft Entity Framework Feature umożliwia budowanie aplikacji opartych na WCF w sposób bardzo podobny jak w programach dwuwarstwowych.

Self-TrackingEntities wspierają również typy złożone oraz mechanizm dziedziczenia. W dołączonym przykładzie można te mechanizmy zobaczyć w encji DiscountedProduct (dziedziczenie po Product) oraz w ProductWithInvoice (typ złożony). W przykładzie pokazano również, że możliwe jest budowanie trójwarstwowych aplikacji wykorzystujących mapowanie procedur składowanych (GetAllProductsWithInvoice, FindInvoicesByReceiverName).