Paweł Łukasiewicz
2016-01-23
Paweł Łukasiewicz
2016-01-23
Udostępnij Udostępnij Kontakt
Wprowadzenie

W artykule tym postaramy się rozumieć podstawy Repository (repozytorium) oraz Unit of Work (jednostki pracy) a także przygotujemy przykładową aplikację w technologii ASP.NET MVC, która pozwoli nam na implementację tych wzorców.

W czasach .NET 1.1 należało spędzić niezwykle dużo czasu na napisanie kodu dostępu do danych. Charakter pisanego kodu był bardzo podobny, jednakże różnice w schematach bazy danych wymuszały pisanie odrębnej warstwy dostępu do danych. W najnowszych wersjach platformy .NET możemy używać ORM’ów dzięki czemu nie spędzamy tak dużo czasu nad pisaniem kodu dostępu do danych.

Od kiedy dostęp do danych przy użyciu ORM jest tak prosty, istnieje możliwość łączenia się z bazą danych z różnych miejsc naszej aplikacji. Dla przykładu, każdy kontroler posiadający instancję obiektu ObjectContext może wykonywać operacje dostępu do danych.

Repozytorium oraz jednosta pracy zapewniają czytelny dostęp do danych, cała logika jest przechowywana w jednym miejscu dzięki czemu jest dużo łatwiej zarządzać testami naszej aplikacji. Zamiast bardziej zagłębiać się w szczegóły skupimy się na przykładzie, który w zdecydowanie lepszy sposób pokże jak używać tych wzorców.


Aplikacja

W naszym przykładzie posłużymy się niejedokrotnie używaną już bazą danych Adventure Works. Dokonamy mapowania jednej tabeli, na której będziemy wykonywać operacje CRUD.

W pierwszej kolejności dodajemy nowy projekt ASP.NET MVC. Następnie dodajemy do niego ADO.NET Entity Data Model - model tworzymy na podstawie istniejącej bazy danych. Jako tabelę do mapowania wybieramy Person. Tak przedstawia się mapowanie tej tabeli przy użyciu Entity Framework:

Entity Framework - definicja tabeli Person


Dostęp do danych

Entity Framework jest gotowe do użycia w naszej aplikacji. Dodajmy teraz kontroler, który pozwali nam na wykonywanie operacji CRUD:

Entity Framework - dostęp do danych z bazy danych

A tak prezentuje się automatycznie wygenerowany kod dla tego kontrolera:

using System.Data.Entity;
using System.Linq;
using System.Net;
using System.Web.Mvc;
namespace EF_Repository_UoW.Controllers
{
    public class PersonController : Controller
    {
        private AdventureWorks2012_DataEntities db = new AdventureWorks2012_DataEntities();
        // GET: People
        public ActionResult Index()
        {
            return View(db.Person.ToList());
        }
        // GET: People/Details/5
        public ActionResult Details(int? id)
        {
            if (id == null)
            {
                return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
            }
            Person person = db.Person.Find(id);
            if (person == null)
            {
                return HttpNotFound();
            }
            return View(person);
        }
        // GET: People/Create
        public ActionResult Create()
        {
            return View();
        }
        // POST: People/Create
        // To protect from overposting attacks, please enable the specific properties you want to bind to, for 
        // more details see http://go.microsoft.com/fwlink/?LinkId=317598.
        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult Create([Bind(Include = "BusinessEntityID,PersonType,NameStyle,Title,FirstName,MiddleName,LastName,Suffix,EmailPromotion,AdditionalContactInfo,Demographics,rowguid,ModifiedDate")] Person person)
        {
            if (ModelState.IsValid)
            {
                db.Person.Add(person);
                db.SaveChanges();
                return RedirectToAction("Index");
            }
            return View(person);
        }
        // GET: People/Edit/5
        public ActionResult Edit(int? id)
        {
            if (id == null)
            {
                return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
            }
            Person person = db.Person.Find(id);
            if (person == null)
            {
                return HttpNotFound();
            }
            return View(person);
        }
        // POST: People/Edit/5
        // To protect from overposting attacks, please enable the specific properties you want to bind to, for 
        // more details see http://go.microsoft.com/fwlink/?LinkId=317598.
        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult Edit([Bind(Include = "BusinessEntityID,PersonType,NameStyle,Title,FirstName,MiddleName,LastName,Suffix,EmailPromotion,AdditionalContactInfo,Demographics,rowguid,ModifiedDate")] Person person)
        {
            if (ModelState.IsValid)
            {
                db.Entry(person).State = EntityState.Modified;
                db.SaveChanges();
                return RedirectToAction("Index");
            }
            return View(person);
        }
        // GET: People/Delete/5
        public ActionResult Delete(int? id)
        {
            if (id == null)
            {
                return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
            }
            Person person = db.Person.Find(id);
            if (person == null)
            {
                return HttpNotFound();
            }
            return View(person);
        }
        // POST: People/Delete/5
        [HttpPost, ActionName("Delete")]
        [ValidateAntiForgeryToken]
        public ActionResult DeleteConfirmed(int id)
        {
            Person person = db.Person.Find(id);
            db.Person.Remove(person);
            db.SaveChanges();
            return RedirectToAction("Index");
        }
        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                db.Dispose();
            }
            base.Dispose(disposing);
        }
    }
}
Kiedy uruchomimy naszą aplikację będziemy w stanie wykonywac różne operacje na tabeli Person.

Z perspektywy kodu nie ma niczego złego w takim podejściu. Pojawiają się jednak dwa problemy:

  1. Kod dostępu jest rozproszony po całej aplikacji (kontrolerach) przez co zarządzaniem staje się badzo utrudnione.
  2. Akcja w kontrolerze tworzy Context tylko dla siebie. Powoduje to, że testowanie tej metody przy użyciu fikcyjnych danych jest niemożliwe i nie będziemy w stanie zweryfikować poprawności wyników. W takim przypadku należy używać danych testowych.


Tworzenie repozytorium

Jak zatem rozwiązać pierwszy z powyższych problemów? Możemy przenieść cały kod dostępu do danych do jednego miejsca. Utworzymy klasę, która będzie zawierała wszystkie element logiki dostępu do danych dla tabeli Person.

Zanim jednak przejdziemy do utworzenia klasy, zastanówmy się nad drugim problemem. Jeżeli stworzymy prosty interfejs dla tabeli Person a następnie dokonamy implemetancji tego interfejsu w naszej klasy odniesiemy istotną korzyść. Możemy przygotować inną klasę implementującą ten interfejs, która tym razem będzie korzystała z fikcyjnych danych. Dzięki temu rozwiązaniu nasz projekt testowy może przekazwać fikcyjne dane do kontrolera, który nie będzie miał z tym żadnego problemu.

W pierwszej kolejności przygotujemy interfejs repozytorium:

using System;
using System.Collections.Generic;
namespace EF_Repository_UoW.Repository
{
    // Przygotowany interfejs zawiera jedynie deklaracje metod
    // Klasa implementująca ten interfejs będzie określała definicję tych metod
    public interface IRepository<T> where T : class
    {
        IEnumerable<T> GetOverview(Func<T, bool> predicate = null);
        T GetDetail(Func<T, bool> predicate);
        void Add(T entity);
        void Delete(T entity);
    }
}
W kolejnym kroku dodajemy klasę impelementującą ten interfejs, która pozwoli na wykonywanie operacji CRUD na tabeli Person:
using System;
using System.Collections.Generic;
using System.Linq;
namespace EF_Repository_UoW.Repository
{
    public class ContactRepository : IRepository<Person>
    {
        private AdventureWorks2012_DataEntities db = new AdventureWorks2012_DataEntities();
        public void Add(Person entity)
        {
            db.Person.Add(entity);
        }
        public void Delete(Person entity)
        {
            db.Person.Remove(entity);
        }
        public Person GetDetail(Func<Person, bool> predicate)
        {
            return db.Person.FirstOrDefault(predicate);
        }
        public IEnumerable<Person> GetOverview(Func<Person, bool> predicate = null)
        {
            if (predicate != null)
                return db.Person.Where(predicate);
            return db.Person;
        }
        internal void SaveChanges()
        {
            db.SaveChanges();
        }
    }
}
Następnie należy przygotować klasę, która będzie używała przedstawionego powyżej repozytorium:
using EF_Repository_UoW.Repository;
using System.Linq;
using System.Web.Mvc;
namespace EF_Repository_UoW.Controllers
{
    public class PersonRepoController : Controller
    {
        private PersonRepository repo = new PersonRepository();
        // GET: PeopleRepo
        public ActionResult Index()
        {
            return View(repo.GetOverview().ToList());
        }
        public ActionResult Details(int id)
        {
            Person person = repo.GetDetail(p => p.BusinessEntityID == id);
            if (person == null)
                return HttpNotFound();
            return View(person);
        }
        public ActionResult Create()
        {
            return View();
        }
        [HttpPost]
        public ActionResult Create(Person person)
        {
            if (ModelState.IsValid)
            {
                repo.Add(person);
                repo.SaveChanges();
                return RedirectToAction("Index");
            }
            return View(person);
        }
        public ActionResult Delete(int id)
        {
            Person person = repo.GetDetail(p => p.BusinessEntityID == id);
            if (person == null)
                return HttpNotFound();
            return View(person);
        }
        [HttpPost, ActionName("Delete")]
        public ActionResult DeleteConfirmed(int id)
        {
            Person person = repo.GetDetail(p => p.BusinessEntityID == id);
            repo.Delete(person);
            repo.SaveChanges();
            return RedirectToAction("Index");
        }
    }
}
Zaletą takiego podejścia jest to, iż warstwa dostępu do bazy danych nie jest rozsiana po wszystkich kontrolerach a znajduje się w jednym miejscu. Jest ona owinięta (wrapped) wewnątrz repozytorium klasy.


Wiele repozytoriów

Wyobraźmy sobie teraz sytuację w której mamy wiele tabel w bazie danych. Następnie musielibyśmy utworzyć wiele repozytoriów w celu mapowania modelu na tabele bazy danych. Wiele klas repozytorium wiąże się z problemami.

Problem związany jest z obiektem ObjectContext. Jeżeli utworzymy wiele repozytoriów, powinny zawierać obiekt ObjectContext osobno? Wiemy, że jednoczesne używaniu kilku instancji obiektu ObjectContext jest problematyczne, może więc należy doprowadzić do sytuacji w której każde repozytorium ma swoją instancję ObjectContext?

Jest rozwiązanie tego problemu. Możemy pozwolić, aby każde repozytorium miało swoją własnę instancję ObjectContext. Dodatkowo, stworzymy instancję obiektu ObjectContext w jednej, centralnej lokalizacji a następnie będziemy ją przekazywać do repozytorium kiedy to zostanie zainicjowane. Ta nowa klasa będzie się nazywała UnitOfWork i będzie odpowiedzialna za tworzenie instancji obiektu ObjectContext i przekazywanie instancji repozytoriów do kontolerów.


Unit of Work

Utworzymy osobne repozytorium, które będzie używane poprzez klasę UnitOfWork oraz obiekt ObjectContext będzie przekazywany do tej klasy z zewnątrz.

using System;
using System.Collections.Generic;
using System.Linq;
namespace EF_Repository_UoW.Repository
{
    public class PersonRepositoryWithUoW
    {
        private AdventureWorks2012_DataEntities db = null;
        public PersonRepositoryWithUoW(AdventureWorks2012_DataEntities _db)
        {
            this.db = _db;
        }
        public void Add(Person entity)
        {
            db.Person.Add(entity);
        }
        public void Delete(Person entity)
        {
            db.Person.Remove(entity);
        }
        public Person GetDetail(Func<Person, bool> predicate)
        {
            return db.Person.FirstOrDefault(predicate);
        }
        public IEnumerable<Person> GetOverview(Func<Person, bool> predicate = null)
        {
            if (predicate != null)
                return db.Person.Where(predicate);
            return db.Person;
        }
    }
}
Powyższe repozytorium pobiera obiekt ObjectContext z zewnątrz (gdziekolwiek zostanie on utworzony).

Zobaczmy teraz jak klasa UnitOfWork tworzy repozytorium oraz przekazuje je do kontrolera.
using EF_Repository_UoW.Repository;
using System;
namespace EF_Repository_UoW.Unit_of_Work
{
    public class UnitOfWork : IDisposable
    {
        private AdventureWorks2012_DataEntities db = null;
        public UnitOfWork()
        {
            this.db = new AdventureWorks2012_DataEntities();
        }
        // Obsługę każdego repozytorium dodajemy tutaj
        IRepository<Person> personRepository = null;
        // Gettery dla każdego repozytorium dodajemy tutaj
        public IRepository<Person> PersonRepository
        {
            get
            {
                if (personRepository == null)
                    personRepository = new PersonRepositoryWithUoW(db);
                return personRepository;
            }
        }
        public void SaveChanges()
        {
            db.SaveChanges();
        }
        private bool disposed = false;
        protected virtual void Dispose(bool disposing)
        {
            if (!this.disposed)
            {
                if (disposing)
                    db.Dispose();
            }
            disposed=true;
        }
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }
    }
}
Ostatni krok to utworzenie kontolera PersonUoWController, który będzie używał jednostki pracy do wykonywania operacji CRUD na tabeli Person.
using EF_Repository_UoW.Unit_of_Work;
using System.Linq;
using System.Web.Mvc;
namespace EF_Repository_UoW.Controllers
{
    public class PersonUoWController : Controller
    {
        private UnitOfWork uow = null;
        public PersonUoWController()
        {
            uow = new UnitOfWork();
        }
        public PersonUoWController(UnitOfWork _uow)
        {
            this.uow = _uow;
        }
        // GET: PersonUoW
        public ActionResult Index()
        {
            return View(uow.PersonRepository.GetOverview().ToList());
        }
        public ActionResult Details(int id)
        {
            Person person = uow.PersonRepository.GetDetail(p => p.BusinessEntityID == id);
            if (person == null)
                return HttpNotFound();
            return View(person);
        }
        public ActionResult Create()
        {
            return View();
        }
        [HttpPost]
        public ActionResult Create(Person person)
        {
            if (ModelState.IsValid)
            {
                uow.PersonRepository.Add(person);
                uow.SaveChanges();
                return RedirectToAction("Index");
            }
            return View(person);
        }
        public ActionResult Delete(int id)
        {
            Person person = uow.PersonRepository.GetDetail(p => p.BusinessEntityID == id);
            if (person == null)
                return HttpNotFound();
            return View(person);
        }
        [HttpPost, ActionName("Delete")]
        public ActionResult DeleteConfirmed(int id)
        {
            Person person = uow.PersonRepository.GetDetail(p => p.BusinessEntityID == id);
            uow.PersonRepository.Delete(person);
            uow.SaveChanges();
            return RedirectToAction("Index");
        }
    }
}
Takim podejściem zachowaliśmy testowalność naszej aplikacji poprzez przygotowanie kombinacji domyślnego oraz parametryzowanego kontolera. Ponadto, kod dostępu do danych znajduje się w jednym miejscu co pozwala nam na inicjowanie wielu klas repozytorium w tym samym czasie.

Adnotacja: Istnieją co najmniej 2 przeciążenia metody Where:
public static IQueryable<TSource> Where<TSource>(this IQueryable<TSource> source, Expression<Func<TSource, bool>> predicate);
public static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate);
Kiedy używamy
Func<T, bool>
nasze zapytanie używa wersji z IEnumerable. W tym wypadku w pierwszej kolejności zwracane są wszystkie rekordy z bazy danych a następnie, przy pomocy predykata, zwracany jest wynik końcowy. Aby tego dowieść należy sprawdzić wygenerowane zapytanie SQL - nie ma w nim klauzuli Where.

Aby uniknać tego problemu należy zmienić Func na Expression Func:
Expression<Func<T, bool>> predicate
W tym wypadku zostanie użyta wersja z IQueryable wraz z klauzulą Where. Po więcej odsyłam do artykułu -> IEnumerable vs IQueryable


Generyczne repozytorium i jednostka pracy

Repozytorium i jednostka pracy zostały przygotowane. Jest jeden haczyk, co jeśli baza danych zawiera wiele tabel a my dla każdej z nich musimy utworzyć klasę repozytorium a klasa jednostki pracy musi mieć właściwości dostępu do każdego z tych repozytoriów?

Czy nie lepiej byłoby przygotować repozytorium i jednostke pracy jako generyczne, które wspołpracowałyby z każdym modelem? Przejdźmy zatem do utworzenia generycznej klasy repozytorium:

using System;
using System.Collections.Generic;
using System.Data.Entity;
using System.Linq;
using System.Linq.Expressions;
namespace EF_Repository_UoW.Repository
{
    public class GenericRepository<T> : IRepository<T> where T : class
    {
        private AdventureWorks2012_DataEntities db = null;
        // obiekt reprezentuje kolekcję wszystkich encji w danym kontekście
        // lub może być wynikiem zapytania z bazy danych
        IDbSet<T> _objectSet;
        public GenericRepository(AdventureWorks2012_DataEntities _db)
        {
            db = _db;
            _objectSet = db.Set<T>();
        }
        public void Add(T entity)
        {
            _objectSet.Add(entity);
        }
        public void Delete(T entity)
        {
            _objectSet.Remove(entity);
        }
        public T GetDetail(Expression<Func<T, bool>> predicate)
        {
            return _objectSet.First(predicate);
        }

        public IEnumerable<T> GetOverview(Expression<Func<T, bool>> predicate = null)
        {
            if (predicate != null)
                return _objectSet.Where(predicate);
            return _objectSet.AsEnumerable();
        }
    }
}
Kolejnym krokiem będzie utworzenie generycznej klasy UnitOfWork. Klasa będzie sprawdzała czy klasa repozytorium dla danego typu została już utworzona – jeżeli tak, zostanie zwrócona ta sama instancja, jeżeli nie, zostanie zwrócona nowa instancja.
using EF_Repository_UoW.Repository;
using System;
using System.Collections.Generic;
using System.Linq;
namespace EF_Repository_UoW.Unit_of_Work
{
    public class GenericUnitOfWork : IDisposable
    {
        private AdventureWorks2012_DataEntities db = null;
        public GenericUnitOfWork()
        {
            db = new AdventureWorks2012_DataEntities();
        }
        // Słownik będzie używany do sprawdzania instancji repozytoriów
        public Dictionary<Type, object> repositories = new Dictionary<Type, object>();
        public IRepository<T> Repository<T>() where T : class
        {
            // Jeżeli instancja danego repozytorium istnieje - zostanie zwrócona
            if (repositories.Keys.Contains(typeof(T)) == true)
                return repositories[typeof(T)] as IRepository<T>;
            // Jeżeli nie, zostanie utworzona nowa i dodana do słownika
            IRepository<T> repo = new GenericRepository<T>(db);
            repositories.Add(typeof(T), repo);
            return repo;
        }
        public void SaveChanges()
        {
            db.SaveChanges();
        }
        private bool disposed = false;
        protected virtual void Dispose(bool disposing)
        {
            if(!this.disposed)
            {
                if (disposing)
                    db.Dispose();
            }
            this.disposed = true;
        }
        public void Dispose()
        {
            throw new NotImplementedException();
        }
    }
}
Ostatnim krokiem jest utworzenie kontrolera GenericPersonController, który będzie używał klasy GenericUnitOfWork, aby wykonywać operacje CRUD na tabeli People
using EF_Repository_UoW.Unit_of_Work;
using System.Linq;
using System.Web.Mvc;
namespace EF_Repository_UoW.Controllers
{
    public class GenericPersonController : Controller
    {
        private GenericUnitOfWork uow = null;
        public GenericPersonController()
        {
            uow = new GenericUnitOfWork();
        }
        public GenericPersonController(GenericUnitOfWork _uow)
        {
            this.uow = _uow;
        }
        // GET: GenericPerson
        public ActionResult Index()
        {
            return View(uow.Repository<Person>().GetOverview().ToList());
        }
        public ActionResult Details(int id)
        {
            Person person = uow.Repository<Person>().GetDetail(p => p.BusinessEntityID == id);
            if (person == null)
                return HttpNotFound();
            return View(person);
        }
        public ActionResult Create()
        {
            return View();
        }
        [HttpPost]
        public ActionResult Create(Person person)
        {
            if (ModelState.IsValid)
            {
                uow.Repository<Person>().Add(person);
                uow.SaveChanges();
                return RedirectToAction("Index");
            }
            return View(person);
        }
        public ActionResult Delete(int id)
        {
            Person person = uow.Repository<Person>().GetDetail(p => p.BusinessEntityID == id);
            if (person == null)
                return HttpNotFound();
            return View(person);
        }
        [HttpPost, ActionName("Delete")]
        public ActionResult DeleteConfirmed(int id)
        {
            Person person = uow.Repository<Person>().GetDetail(p => p.BusinessEntityID == id);
            uow.Repository<Person>().Delete(person);
            uow.SaveChanges();
            return RedirectToAction("Index");
        }
    }
}


Podsumowanie

W artykule zobaczyliśmy czym jest i jak używać repozytorium oraz jednostki pracy. Zobaczyliśmy również podstawową implementację tych wzorców w technologii ASP.NET MVC przy użyciu Entity Framework. W kolejnym kroku przygotowaliśmy generyczną klasę repozytorium, którą możemy pobrać z klasy GenericUnitOfWork - wyklucza to konieczność tworzenia wielu klas repozytoriów.