Entity Framework – aplikacja dwuwarstwowa

Autor: Piotr Zieliński

Wymagane oprogramowanie

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

  • Visual Studio 2010,
  • SQL Server 2008 express.

Wprowadzenie

Entity Framework jest rozbudowanym narzędziem ORM (ang*.Object Relational Mapping*), dołączonym do Visual Studio. Dzięki zaawansowanemu IDE tworzenie modelu danych jest bardzo łatwe i w pełni zautomatyzowane.

W artykule przedstawiony został przykład wykorzystania Entity Framework w aplikacji dwuwarstwowej:

Rysunek 1. Architektura aplikacji.
Rysunek 1. Architektura aplikacji.

W celu zademonstrowania możliwości EF posłużono się prostą bazą danych:

Rysunek 2. Projekt bazy danych.
Rysunek 2. Projekt bazy danych.

Jak widać, baza danych nie jest skomplikowana i zawiera tabele przeznaczone dla faktur, produktów oraz produktów w promocji. Ponadto w projekcie wykorzystano pięć procedur składowanych (DeleteInvoice, InsertInvoice, UpdateInvoice, FindInvoicesByReceiverName, GetProductsWithInvoice).

W pliku dołączonym do artykułu znajduje się kod SQL generujący tę bazę danych. Należy go po prostu uruchomić w SQL Management Studio.

Mapowanie tabel na klasy w C#

Pierwszym zadaniem jest wygenerowanie modelu klas dla utworzonej bazy danych. Na tym etapie większość czynności (takich jak stworzenie pliku mapowań) zrealizuje za nas kreator.

Po stworzeniu projektu WPF wykonujemy następujące czynności:

1. W Solution Explorer z kontekstowego menu wybieramy Add->New Item i przechodzimy do zakładki Data. Wybieramy ADO .NET Entity Data Model, podajemy nazwę oraz zatwierdzamy ją, klikając w przycisk Add.

Rysunek 3. Dodawanie Entity Data Model.
Rysunek 3. Dodawanie Entity Data Model.

2. Po uruchomieniu kreatora wybieramy opcję Generate From Database, ponieważ chcemy wygenerować klasy na podstawie już istniejącej bazy danych.

3. Klikamy w New Connection w celu skonfigurowania bazy danych. Podajemy niezbędne dane (adres oraz nazwę bazy danych) i przechodzimy dalej, naciskając Next.

4. Wybieramy elementy, które chcemy zmapować. W naszym przypadku są to tabele: Invoices, Products, DiscountedProducts, OrdeItems, oraz procedury: DeleteInvoice, InsertInvoice, UpdateInvoice, FindInvoicesByReceiverName, GetProductsWithInvoice.

Rysunek 4. Wybieranie tabel, widoków oraz procedur do zaimportowania.
Rysunek 4. Wybieranie tabel, widoków oraz procedur do zaimportowania.

5. Po naciśnięciu przycisku Finish zostanie wygenerowany następujący model encji:

Rysunek 5. Wstępny model encji.
Rysunek 5. Wstępny model encji.

Na tym etapie mamy już wstępny model encji, który umożliwi nam manipulacje danymi w bazie danych za pomocą klas c#. Kolejnym krokiem będzie zamodelowanie dziedziczenia między klasami DiscountedProduct i Product.

Modelowanie dziedziczenia

Entity Framework ma pełne wsparcie dla dziedziczenia. W omawianym przypadku produkt w promocyjnej cenie (DiscountedProduct) dziedziczy po produkcie (Product). W języku c# taką strukturę przedstawilibyśmy następująco:

class Product
    {
        // właściwości podstawowego produktu
    }
    class DiscountedProduct : Product
    {
        // właściwości zniżkowego produktu
    }

Pozostaje pytanie, jak zamodelować dziedziczenie w relacyjnej bazie danych?

Wyróżnia się trzy wzorce projektowe, służące do zamodelowania mechanizmu dziedziczenia w bazie danych:

  • TPH (Table per Hierarchy),
  • TPC (Table per Concrete Type),
  • TPT (Table per Type).

Najprostszym wzorcem jest TPH, polegający na stworzeniu pojedynczej tabeli zawierającej informację o wszystkich klasach w hierarchii dziedziczenia. Wyobraźmy sobie, że posiadamy klasę Product oraz szereg podklas, np. Cpu, Gpu oraz DiscountedProduct. Zgodnie z wzorcem w bazie danych będziemy mieć tylko jedną tabelę, ale za to z kolumnami odpowiadającymi wszystkim właściwościom wymienionych klas. Aby rozpoznawać, który wiersz należy do której klasy, trzeba wprowadzić dodatkową kolumnę, np. ProductType (0-Monitor, 1- DiscountedProduct, 2-Cpu).

Rysunek 6. Przykład mapowania Table per Hierarchy.
Rysunek 6. Przykład mapowania Table per Hierarchy.

Kolejnym wzorcem jest TPC, w którym każda klasa w hierarchii dziedziczenia odpowiada osobnej tabeli w bazie danych. Ponadto tabele zawierają kolumny odpowiadające zarówno właściwościom klasy potomnej, jak i klasy bazowej.

Rysunek 7. Przykład mapowania Table for Conrete Type.
Rysunek 7. Przykład mapowania Table for Conrete Type.

Ostatnie podejście (TPT) polega na stworzeniu tabeli dla każdej klasy, nawet abstrakcyjnej. W przeciwieństwie do wzorca TPC, w tym rozwiązaniu nie tworzymy w każdej tabeli nadmiarowych kolumn (dla wszystkich właściwości klasy bazowej).

Rysunek 8. Przykład mapowania Table Per Type.
Rysunek 8. Przykład mapowania Table Per Type.

W artykule używany jest wzorzec Table Per Type. Aby nie komplikować kodu, zrezygnowano z części klas przedstawionych na rysunku powyżej.

Entity Framework a wzorzec projektowy TPT

 

W Entity Framework bardzo łatwo wykorzystać TPT. Na tym etapie powinniśmy mieć już wstępny model encji wygenerowany na podstawie bazy danych. Musimy teraz usunąć asocjacje między encją DiscountedProduct a Product i wstawić relację dziedziczenia. W tym celu wykonujemy następujące kroki:

1. klikamy w asocjację łączącą encję DiscountedProduct z encją Product;

2. naciskamy klawisz Delete;

3. klikamy prawym przyciskiem w obszar roboczy i wybieramy Add->Inheritance;

4. w nowo otwartym oknie jako klasę bazową wybieramy Product, a jako klasę pochodną – DiscountedProduct;

Rysunek 9. Okienko dodawania relacji dziedziczenia.
Rysunek 9. Okienko dodawania relacji dziedziczenia.

5.       klikamy we właściwości ID_PRODUCT encji DiscountedProduct, a następnie naciskamy Delete w celu usunięcia tej właściwości;

6.       klikamy w Mapping details encji DiscountedProduct. Powinno wyświetlić się okienko:

Rysunek 10. Okienko Mapping Details przed ustawieniem odziedziczonej właściwości ID_PRODUCT.
Rysunek 10. Okienko Mapping Details przed ustawieniem odziedziczonej właściwości ID_PRODUCT.

7.       klikamy w puste pole i wybieramy ID_PRODUCT:

Rysunek 11. Okienko Mapping Details po ustawieniu właściwości ID_PRODUCT.
Rysunek 11. Okienko Mapping Details po ustawieniu właściwości ID_PRODUCT.

W tej chwili mamy już odwzorowaną relację dziedziczenia. Następnym krokiem będzie zapoznanie się z podstawowymi operacjami typu CRUD (Create, Read, Update, Delete).

Rysunek 12. Ostateczny model encji.
Rysunek 12. Ostateczny model encji.

Wstawianie nowego obiektu do bazy danych

Kiedy dysponujemy modelem encji, wstawianie danych do bazy jest bardzo proste i nie różni się niczym od dodawania obiektu do zwykłej kolekcji danych. Kluczem do sukcesu jest tzw. ObjectContext, obiekt pośredniczący między bazą danych a światem c#. Dzięki niemu programista nie musi bezpośrednio korzystać z zapytań SQL. Korzystanie z wygenerowanego interfejsu jest bardzo intuicyjne. Jeśli wygenerowany model ma nazwę EFSampleEntities, dodanie nowej faktury wygląda następująco:

EFSampleEntitiesdataContext = new EFSampleEntities();
dataContext.AddToInvoices(invoice);
dataContext.SaveChanges();

Wywołanie metody SaveChanges powoduje fizyczny zapis danych do bazy.

Aktualizacja obiektu

ObjectContext śledzi wszystkie zmiany dokonywane na encjach. Zmienianie właściwości jest identyczne z modyfikacją zwykłych obiektów w c#. Wszystkie modyfikacje zatwierdzamy podobnie – metodą SaveChanges.

Przykład:

EFSampleEntities dataContext = new EFSampleEntities();
Entities.Product product=  dataContext.Products.First();
product.Name = "nowa nazwa";
dataContext.SaveChanges();

Usuwanie obiektów

W celu usunięcia obiektu z bazy danych należy wywołać metodę DeleteObject i przekazać encję, którą chcemy usunąć:

dataContext.DeleteObject(invoice);
dataContext.SaveChanges();

Selekcja obiektów za pomocą LINQ**

Jak już wspomniano, użytkownik Entity Framework nie wykorzystuje bezpośrednio zapytań SQL specyficznych dla konkretnej bazy danych. Zamiast tego korzysta z API, które ma ogólny interfejs umożliwiający wyłuskiwanie danych niezależnie od ich źródła. Jednym z wygodniejszych sposobów jest oczywiście język LINQ. Entity Framework wspiera LINQ i możemy filtrować dane z bazy, tak jakbyśmy filtrowali zwykłą kolekcję. Rozważmy prosty przykład: wyszukiwanie produktu na podstawie kodu kreskowego:

IEnumerable<Entities.Product> products = from product in m_DataContext.Products where product.BarCode == barCodeFilter.BarCode select product;

Należy podkreślić, że zapytania LINQ nie są zwykłym tekstem i ewentualne błędy składniowe zostaną wykryte już na etapie kompilacji. To wielka zaleta.

Opóźnione ładowanie (lazy loading) oraz zachłanne ładowanie (eager loading)

Opóźnione ładowanie jest mechanizmem umożliwiającym pobieranie pewnych danych dopiero w momencie, kiedy są potrzebne. Ważne, że pobieranie następuje w sposób niewidoczny dla programisty. Rozważmy przykład faktury, która zawiera kolekcję sprzedanych produktów. Wszelkie kolekcje oraz referencje na inne encje domyślnie podlegają opóźnionemu ładowaniu w Entity Framework. Zatem po odpytaniu bazy danych (np. za pomocą LINQ) tak naprawdę zostaną zwrócone tylko czyste faktury, bez kolekcji sprzedanych produktów. Kolekcje zostaną pobrane dopiero w momencie, gdy użytkownik będzie chciał uzyskać do nich dostęp. Jeśli programista wykona np. pętle foreach po liście produktów, zostanie wysłane drugie zapytanie SQL do serwera bazy danych w celu zwrócenia listy sprzedanych produktów dla konkretnej faktury.

Opóźnione ładowanie zwykle jest pożądanym zjawiskiem, ponieważ rzadko chcemy uzyskać natychmiastowy dostęp do wszystkich referencji w danej encji. Może to jednak spowodować poważne problemy z wydajnością. Rozważmy przykład, w którym mamy okienko przedstawiające zarówno podstawowe dane faktury, jak i jej pozycje (sprzedane produkty). Potrzebujemy zatem wyświetlić również kolekcję OrderItems, która domyślnie jest ładowana za pomocą dodatkowego zapytania SQL. W tym przypadku powinniśmy skorzystać z tzw. zachłannego ładowania, które w jednym zapytaniu do serwera bazodanowego zwróci nam zarówno encję Invoice, jak i kolekcję OrderItems.

W Entity Framework w celu wykonania zachłannego ładowania należy skorzystać z metody Include podczas wykonywania zapytania, np.:

IEnumerable<Entities.Invoice> invoices= m_DataContext.Invoices.Include("OrderItems");

Dzięki temu zapytaniu dostaniemy od razu i encję Invoice, i kolekcję OrderItems, bez wysyłania dodatkowego zapytania.

Mapowanie składowanych procedur

 

a.      Typ zwracany jako istniejąca już encja

Entity Framework wspiera również mapowanie składowanych procedur na zwykłe metody c#(lub vb).

Na początku artykułu dodaliśmy już procedury składowane do modelu, w tej chwili musimy więc je tylko zmapować na zwykłe metody:

1.       Z zakładki Model Browser wybieramy procedurę FindInvoicesByReceiverName, która znajduje się w węźle Store->StoredProcedures, a następnie w kontekstowym menu klikamy w Create Function Import.

2.       Pojawi się okienko konfiguracyjne, w którym wybieramy Invoice jako typ zwracany przez procedurę. Klikamy w przycisk OK, aby zatwierdzić zmiany.

Rysunek 13. Okno konfiguracyjne składowanej procedury.
Rysunek 13. Okno konfiguracyjne składowanej procedury.

Korzystanie z procedury niczym się nie różni od wywołania zwykłej metody:

IEnumerable<Entities.Invoice>
Invoices = m_DataContext.FindInvoicesByReceiverName("nazwa");

Jak widzimy, użytkownik nie musi kłopotać się ręcznym wykonaniem procedury składowanej, a parametry przekazuje się również jak do zwykłej metody.

b.      Typ zwracany jako nowa, złożona encja

Czasami procedura zwraca typ, który nie pokrywa się dokładnie z posiadaną już encją. Przykładowo zaimportowana procedura GetProductsWithInvoice zwraca produkt wraz z fakturą, w której został sprzedany. Entity Framework umożliwia wygenerowanie tzw. typu złożonego, składającego się z dowolnych pól. Spróbujmy więc prawidłowo zmapować wspomnianą procedurę:

1.       Podobnie jak w poprzednich zadaniach, klikamy w składowaną procedurę i z kontekstowego menu wybieramy Add Function Import.

2.       W nowo otwartym oknie konfiguracyjny klikamy w przycisk Get Column information, aby pobrać informacje dotyczące zwracanego typu. Po wykonaniu operacji powinna się pojawić lista kolumn zwracanych przez GetProductsWithInvoice.

3. W celu dynamicznego stworzenia typu złożonego wybieramy „Create New Complex Type”. Jako nazwę typu złożonego podajemy np. ProductWithInvoice.

Rysunek 14. Tworzenie typu złożonego.
Rysunek 14. Tworzenie typu złożonego.

Operacje wstawiania, aktualizacji oraz usuwania wiersza jako procedury składowane

Domyślnie, po wygenerowaniu modelu encji, wszelkie aktualizacje i operacje usuwania wierszy wykonywane są za pomocą zwykłych zapytań SQL. Przykładowo, wywołując metodę DeleteObject, Entity Framework wykona zapytanie SQL typu „delete FROM TABLE_BALE WHERE {WARUNKI}”. W większości przypadków to wystarcza. Wyobraźmy sobie jednak, że każda modyfikacja wiersza powinna wykonywać pewien wpis (log) w innej tabeli. Możemy kod odpowiedzialny za wykonywanie logów umieścić w procedurze i powiedzieć EntityFramework, aby ręcznie nie generował zapytań SQL, lecz skorzystał z napisanej przez nas procedury.

W tym celu stwórzmy najpierw trzy procedury, które służą do wstawiania nowych wierszy, ich aktualizacji oraz usuwania:

CREATE PROCEDURE [dbo].[InsertInvoice]
    
    @IssuerName nvarchar(50),
    @ReceiverName nvarchar(50),
    @IssueDate datetime2(7)
AS
BEGIN

    INSERT INTO [dbo].[Invoices]    
           ([IssuerName]
           ,[ReceiverName]
           ,[IssueDate])
     VALUES
           (@IssuerName,
           @ReceiverName,
           @IssueDate)
           
     select SCOPE_IDENTITY() as ID_INVOICE;   
END
CREATE PROCEDURE [dbo].[UpdateInvoice]
    @ID_INVOICE int,
    @IssuerName nvarchar(50),
    @ReceiverName nvarchar(50),
    @IssueDate datetime2(7)
AS
BEGIN

    UPDATE [dbo].[Invoices] 
    SET [IssuerName] = @IssuerName
       ,[ReceiverName] = @ReceiverName
       ,[IssueDate] = @IssueDate
    WHERE ID_INVOICE=@ID_INVOICE;

END
CREATE PROCEDURE [dbo].[DeleteInvoice]
    @ID_INVOICE int
AS
BEGIN
    delete from Invoices where ID_INVOICE=@ID_INVOICE;
END

Zwróćmy uwagę, że w procedurze InsertInvoice znajduje się zapytanie o identyfikator właśnie wstawionego wiersza. Dzięki temu po wykonaniu operacji wstawiania nowego wiersza, encja zostanie automatycznie zaaktualizowana z nowo nadanym identyfikatorem.

Następnie musimy ustawić w EF utworzone procedury:

1.       przechodzimy do zakładki Model Browser,

2.       klikamy w encję Invoice, a następnie w przycisk Mapping Details,

3.       wybieramy Map Entity To Function,

4.       ustawiamy kolejno procedury Insert, Update oraz Delete,

5.       dla procedury wstawiającej wiersze ustawiamy wartość wyjściową (nadany identyfikator) za pomocą węzła Result Column Binding.

Rysunek 15. Okno konfiguracyjne procedur do wstawiania, aktualizacji oraz usuwania wiersza.
Rysunek 15. Okno konfiguracyjne procedur do wstawiania, aktualizacji oraz usuwania wiersza.

Walidacja danych

Weryfikacja przetwarzanych danych powinna być wykonywana w każdej warstwie systemu. W Entity Framework możemy walidować dane, tworząc klasę częściową (partial). W niej mamy pełną swobodę – możemy tworzyć nowe metody, ustawiać wartości domyślne, weryfikować dane. W klasach, które wspierają walidację danych, często wykorzystuje się interfejs System.ComponentModel.IDataErrorInfo. Takie rozwiązanie jest szczególnie przydatne, ponieważ WPF potrafi współpracować z tym interfejsem i zmieniać interfejs w zależności od jego stanu (np. kontrolki ze złymi danymi będą zaznaczone na czerwono).

Rozważmy klasę Product:

partial class Product : System.ComponentModel.IDataErrorInfo
    {
        private Dictionary<string, string> m_ValidationErrors = new Dictionary<string, string>();
        public Product()
        {
            this.Name = this.Description = this.BarCode = "";
        }

        partial void OnNameChanged()
        {
            if (string.IsNullOrEmpty(Name))
                AddError("Name", "Nazwa produktu jest obowiązkowa!");
            else
                RemoveError("Name");
        }
        private void AddError(string columnName, string msg)
        {
            if (!m_ValidationErrors.ContainsKey(columnName))
            {
                m_ValidationErrors.Add(columnName, msg);
            }

        }
        private void RemoveError(string columnName)
        {
            if (m_ValidationErrors.ContainsKey(columnName))
            {
                m_ValidationErrors.Remove(columnName);
            }
        }

        public string Error
        {
            get
            {
                if (m_ValidationErrors.Count > 0)
                {
                    return m_ValidationErrors.ElementAt(0).Value;
                }
                else return null;
            }
        }

        public string this[string columnName]
        {
            get
            {
                if (m_ValidationErrors.ContainsKey(columnName))
                {
                    return m_ValidationErrors[columnName];
                }
                else return null;
            }
        }
    }

Interfejs wymusza implementację właściwości Error oraz this[string columnName]. Walidację przeprowadza się w metodach częściowych (partial methods), np. OnNameChanged. Warto zauważyć, że w konstruktorze można przypisać dowolną wartość domyślną każdej właściwości. Co prawda IDE wspiera nadawanie wartości domyślnej właściwościom encji, ale nie można ustawić wartości, która jest wynikiem jakieś funkcji, np. DateTime.Now czy Guid.NewGuid().

Zakończenie

Entity Framework stanowi doskonałą alternatywę uproszczonego ORM, jakim jest LINQ to SQL. Zaletami narzędzia na pewno są: ogromne możliwości (m.in. obsługa LINQ, dziedziczenia, relacji wiele do wielu), rozbudowane wsparcie ze strony Visual Studio oraz bardzo wygodne, automatyczne odwzorowywanie tabel. Warto także wspomnieć, że Entity Framework umożliwia korzystanie z czystych klas, czyli tzw. obiektów POCO (Plain Old CLR Object). Dzięki temu można podpiąć już istniejący model domeny i Entity Framework traktować jako zwykły mechanizm persystencji. Więcej informacji na ten temat można znaleźć w linkach do materiałów dodatkowych.

Dodatkowe materiały: