Wprowadzenie
Wzorzec DAO (Data Access Object) jest jednym z częściej używanych wzorców do organizacji warstwy dostępu do danych. Dla każdego obiektu/tabeli z bazy (np. User, Product) tworzymy dedykowany interfejs / dedykowane klasy. Klasy te często nazywamy repozytoriami* (np. UserRepository, ProductRepository) i na ogół zawierają one takie same metody.
Jestem wielkim fanem szablonów T4. Lubię je wykorzystywać do automatyzacji tworzenia powtarzalnych i schematycznych elementów kodu. Właśnie klasy repozytoriów są takimi elementami, które generuję z wykorzystaniem szablonów. W tym wpisie pokażę Ci, jak to zrobić, wykorzystując bibliotekę T4 Toolbox (https://github.com/olegsych/T4Toolbox), która bardzo ułatwia tworzenie nowych szablonów.
* Używanie „Repository” w nazwach klas DAO powoduje, że część programistów myli ten wzorzec DAO z wzorcem Repository z DDD. Sam kiedyś byłem taką osobą. Jeden z wątków na stackoverflow (https://stackoverflow.com/questions/8550124/what-is-the-difference-between-dao-and-repository-patterns) pokazuje różnicę między tymi dwoma wzorcami. Zachęcam do przejrzenia, jeśli nie widzisz pomiędzy nimi różnicy.
Repozytoria
Jak implementujemy repozytoria? Sposobów jest kilka, co programista to inny. 🙂 Jednym z nich jest rozbicie aplikacji na kilka projektów:
- Models – tutaj dodajemy klasy takie jak Product lub User, które będą przenosić dane między warstwami oraz posłużą do wygenerowania bazy danych na przykład przez Entity Framework
- Logic – tutaj dodajemy interfejsy repozytoriów dla poszczególnych klas z projektu Models
- DataAccess – tutaj umieszczamy implementacje repozytoriów z wykorzystaniem jakieś biblioteki np. Entity Framework
Poniższy screen pokazuje organizacje z przykładu do wpisu (gdzie repozytoria są już generowane przesz szablony). Zaznaczone fragmenty pokazują poszczególne elementy organizacji kodu.
W przykładzie wykorzystałem kilka bazowych klas i interfejsów, których później używam w wygenerowanym kodzie. Jest wiele sposobów na implementację bazowego repozytorium. Mogą one zawierać różne metody. Poniższa implementacja to tylko przykład – i nie jest on kluczową kwestią w tym wpisie. 🙂
Klasa BaseModel zawiera kilka podstawowych właściwości, które chcę mieć w każdym ze swoich modeli i później w każdej z tabel w bazie danych. W przykładzie posłuży ona przede wszystkim po to, aby można było napisać kod repozytoriów w sposób generyczny. Właściwość ID jest kluczem głównym, którą też wykorzystamy.
public class BaseModel | |
{ | |
public BaseModel() | |
{ | |
IsActive = true; | |
} | |
public int Id { get; set; } | |
public bool IsActive { get; set; } | |
public DateTime CreatedDate { get; set; } | |
public string CreatedUser { get; set; } | |
public DateTime UpdatedDate { get; set; } | |
public string UpdatedUser { get; set; } | |
} |
Generyczny interfejs IRepository jest bazowym interfejsem dla poszczególnych interfejsów repozytoriów (np. IUserRepository, IProductRepository). Zawiera on podstawowe metody, które chcemy mieć w każdym repozytorium. Jak pisałem wcześniej, można znaleźć wiele różnych implementacji metod w repozytorium.
public interface IRepository<T> where T : BaseModel, new() | |
{ | |
void Add(T entity); | |
void Delete(T entity); | |
void Delete(int id); | |
T GetById(int id); | |
IQueryable<T> GetAllActive(); | |
IQueryable<T> GetAll(); | |
void SaveChanges(); | |
} |
Klasa Repository jest bazową implementacją IRepository i zostanie wykorzystana jako klasa bazowa dla generowanych klas repozytoriów.
public abstract class Repository<T> : IRepository<T> where T : BaseModel, new() | |
{ | |
public Repository(Lazy<DataContext> dataContext) | |
{ | |
if (dataContext == null) | |
{ | |
throw new ArgumentNullException("dataContext"); | |
} | |
_dataContext = dataContext; | |
} | |
private Lazy<DataContext> _dataContext; | |
protected DataContext DataContext | |
{ | |
get | |
{ | |
return _dataContext.Value; | |
} | |
} | |
public void Add(T entity) | |
{ | |
DataContext.Set<T>().Add(entity); | |
} | |
public void Delete(T entity) | |
{ | |
DataContext.Set<T>().Remove(entity); | |
} | |
public void Delete(int id) | |
{ | |
T entity = new T() | |
{ | |
Id = id | |
}; | |
DataContext.Entry(entity).State = EntityState.Deleted; | |
} | |
public T GetById(int id) | |
{ | |
return DataContext.Set<T>().FirstOrDefault(e => e.Id == id); | |
} | |
public IQueryable<T> GetAllActive() | |
{ | |
return DataContext.Set<T>().Where(e => e.IsActive); | |
} | |
public IQueryable<T> GetAll() | |
{ | |
return DataContext.Set<T>(); | |
} | |
public void SaveChanges() | |
{ | |
DataContext.SaveChanges(); | |
} | |
} |
Większość kodu repozytoriów będzie w tych dwóch elementach. Można by ten kod również wrzucać do generowanych klas, ale takie podejście na dłuższą metę jest mniej wygodne. Edytor szablonów T4 jest bardzo ubogi, nie ma za bardzo podpowiedzi oraz informacji, czy wynikowy kod będzie się kompilował, dlatego najlepiej jak najwięcej kodu wrzucać do takich wspólnych klas lub interfejsów, a w samym szablonie pisać jak najmniej kodu.
T4 Toolbox
Szablony T4 możemy dodać do projektu na różne sposoby. Niestety czyste szablony dostępne w Visual Studio są bardzo toporne w użyciu i warto skorzystać z czegoś, co ułatwi ich tworzenie. Jednym z takich narzędzi jest T4 Toolbox.
W pierwszej kolejności należy zainstalować dodatek z marketplace (https://marketplace.visualstudio.com/search?term=T4Toolbox&target=VS&category=All%20categories&vsVersion=&sortBy=Relevance) dla odpowiedniej wersji Visual Studio.
Po instalacji pojawi się nowa kategoria w dodaniu nowego elementu do projektu:
Do generowania kodu T4 Toolbox wykorzystuje trzy elementy:
- Template – szablon, z którego zostanie wygenerowany plik
- Generator – logika wykorzystywania szablonów
- Script – właściwy szablon, który wygeneruje kod – korzysta z generatora.
T4 Toolbox – Template
Szablon z T4 Toolbox służy do zdefiniowania struktury wygenerowanego pliku. Z jednej strony jest to zwykła klasa C#, która może zawierać to samo, co w normalnym kodzie (w przykładzie wykorzystamy pole). Z drugiej strony zawiera on specjalną metodę TransformText, która zawiera w sobie szablon generowanego pliku.
Ten plik jest najbardziej skomplikowanym ze wszystkich plików tt. Wszystko przez to, że miesza nam się w nim kod C# samej klasy szablonu z generowanym kodem C#. Z tego powodu bardzo łatwo o popełnienie błędu, szczególnie że Visual Studio za bardzo nie pomaga w edycji pliku tt. Jedynie co można tu zauważyć, to to, że kod klasy szablonu ma inne tło (szare w jasnej skórce Visual Studio) od generowanego kodu (białe tło):
W szablonach T4 sama składnia jest relatywnie prosta. Najczęściej używa się:
- <#@ [jakaś dyrektywa] #> – dyrektywy na początku pliku np. import innego szablonu, namespace itp.
- <#+ … #> – definicja klasy lub elementu klasy szablonu
- <#= …. #> – przekazanie jakiegoś napisu z klasy szablonu do generowanego pliku
- <# … #> – fragment kodu szablonu w generowanym kodzie – przydatne przy ifach lub pętlach.
IRepositoryTemplate
Przeanalizujmy teraz szablon T4 Toolbox, który wykorzystamy do generowania interfejsów dla repozytoriów (dla czytelności możesz również analizować kod szablonu z powyższego screena, gdzie jest kolorowanie z Visual Studio – niestety gist nie wyświetla dobrze szablonów plików tt):
W szablonie mamy zdefiniowaną klasę IRepositoryTemplate, która zawiera jedno pole ClassName. Posłuży nam ono do przekazania z generatora informacji, dla jakiej klasy generujemy interfejs repozytorium.
W metodzie TransformText znajduje się generowany kod. T4 Toolbox udostępnia kilka pomocniczych metod i właściwości, które możemy wykorzystać, aby tworzenie szablonu było łatwiejsze:
- DefaultNamespace – poprawny namespace uwzględniający lokalizację generowanego pliku – nie trzeba tego ręcznie ustawiać
- Identifier – zwraca poprawną nazwę uwzględniającą reguły C# dla przekazanego stringa – np. usuwa spacje.
Generowany interfejs jest prosty: w nazwie używamy przekazanej z generatora nazwy klasy, podobnie używamy tej nazwy w parametrze generycznym w IRepository.
Warto zauważyć, że generowany interfejs jest typu partial. Jest to bardzo ważne, ponieważ w generowanym kodzie nie możemy nic zmienić, a w szczególności nie możemy dodać nowych metod. Przy każdym generowaniu byłyby one nadpisane. Dlatego właśnie interfejs jest typu partial, przez co później w dodatkowym pliku możemy dodać metody specyficzne dla danego repozytorium. W przykładzie na githubie w przypadku IUserRepository jest właśnie dodany taki dodatkowy plik ze specyficzną metodą. Zachęcam do przejrzenia przykładu.
T4 Toolbox – Generator
Kolejnym elementem z T4 Toolbox jest generator. W nim wrzucamy kod, który wykorzysta wcześniej przygotowane szablony T4 Toolbox. Jeden szablon T4 Toolbox generuje jeden plik wynikowy, natomiast w generatorze możemy wykorzystać wiele różnych szablonów (raz lub wiele razy). W naszym przypadku w generatorze będziemy mieli informację, dla jakich klas chcemy wygenerować interfejsy repozytoriów. Następnie w pętli wykorzystamy IRepositoryTemplate do wygenerowania poszczególnych interfejsów.
<#@ include file="IRepositoryTemplate.tt" #> | |
<#+ | |
// <copyright file="IRepositoryGenerator.tt" company=""> | |
// Copyright © . All Rights Reserved. | |
// </copyright> | |
public class IRepositoryGenerator : Generator | |
{ | |
public IRepositoryTemplate Template = new IRepositoryTemplate(); | |
protected override void RunCore() | |
{ | |
var classes = new string[]{"User", "Product"}; | |
foreach(var className in classes){ | |
Template.ClassName = className; | |
Template.RenderToFile("I" + className + "Repository.generated.cs"); | |
} | |
} | |
} | |
#> |
W pierwszej kolejności za pomocą dyrektywy include importujemy szablon IRepositoryTemplate.tt. Musimy to zrobić dla każdego szablonu, który chcemy wykorzystać w generatorze. Dla zaimportowanego szablonu tworzymy pole w klasie generatora (może to być również zmienna w metodzie), które niżej wykorzystujemy do generowania interfejsów.
W generatorze kluczowa jest metoda RunCore. Jest ona uruchamiana podczas generowania kodu. W niej mamy tablicę, w której znajdują się nazwy klas, dla których chcemy wygenerować repozytoria. W przykładzie jest to ustawione ręcznie. Można się też pokusić o coś bardziej generycznego. Pokażę to w jednym w kolejnych wpisów.
W pętli korzystamy z wcześniej zaimportowanego szablonu. Ustawiany nazwę klasy i za pomocą metody RenderToFile generujemy interfejs dla klasy modelu. Do metody przekazujemy nazwę wygenerowanego pliku. W nazwie korzystam ze słowa „generated”. Jest to dla mnie później informacja, że ten plik został wygenerowany przez szablony i wszystkie zmiany w nim zostaną wcześniej czy później usunięte.
T4 Toolbox – Script
Ostatnim elementem całej układanki jest plik skryptu – jest to wykonywany element. Jego zapis powoduje uruchomienie szablonu T4 i wygenerowanie kodu. Zapis szablonu i generatora nie powoduje generowania. Kod w skrypcie jest bardzo prosty: importujemy plik tt generatora, który tworzy jego instancję i na końcu wywołuje metodę Run:
<#@ template language="C#" debug="True" #> | |
<#@ output extension="cs" #> | |
<#@ include file="T4Toolbox.tt" #> | |
<#@ include file="IRepositoryGenerator.tt" #> | |
<# | |
// <copyright file="IRepositoryScript.tt" company=""> | |
// Copyright © . All Rights Reserved. | |
// </copyright> | |
IRepositoryGenerator generator = new IRepositoryGenerator(); | |
generator.Run(); | |
#> |
Zapisanie pliku spowoduje wygenerowanie interfejsów repozytoriów dla klasy User oraz Product. Zrzut ekranu z początku wpisu pokazuje, jak będzie wyglądało solution po wygenerowaniu tych dwóch interfejsów. Sam wygenerowany plik dla klasy User będzie wyglądał tak:
// <autogenerated> | |
// This file was generated by T4 code generator IRepositoryScript.tt. | |
// Any changes made to this file manually will be lost next time the file is regenerated. | |
// </autogenerated> | |
using T4Repositories.Models; | |
namespace T4Repositories.Logic.Repositories | |
{ | |
public partial interface IUserRepository : IRepository<User> | |
{ | |
} | |
} |
Klasy repozytoriów
Generowanie klas repozytoriów jest bardzo podobne do generowania interfejsów. Również tworzymy szablon, generator oraz skrypt. Zawartość poszczególnych plików jest bardzo zbliżona, dlatego poniżej prezentuję ich zawartość. Nie będę ich opisywał – zachęcam do własnej analizy, 🙂 Zadaj pytanie, jeśli coś będzie niejasne.
<#+ | |
// <copyright file="RepositoryTemplate.tt" company=""> | |
// Copyright © . All Rights Reserved. | |
// </copyright> | |
public class RepositoryTemplate : CSharpTemplate | |
{ | |
public string ClassName; | |
public override string TransformText() | |
{ | |
base.TransformText(); | |
#> | |
using T4Repositories.Models; | |
using T4Repositories.Logic.Repositories; | |
using System; | |
namespace <#= DefaultNamespace #> | |
{ | |
public partial class <#= Identifier(ClassName) #>Repository : Repository<<#= Identifier(ClassName) #>>, I<#= Identifier(ClassName) #>Repository | |
{ | |
public <#= Identifier(ClassName) #>Repository(Lazy<DataContext> db) | |
: base(db) | |
{ | |
} | |
} | |
} | |
<#+ | |
return this.GenerationEnvironment.ToString(); | |
} | |
} | |
#> |
<#@ include file="RepositoryTemplate.tt" #> | |
<#@ assembly name="System" #> | |
<#+ | |
// <copyright file="RepositoryGenerator.tt" company=""> | |
// Copyright © . All Rights Reserved. | |
// </copyright> | |
public class RepositoryGenerator : Generator | |
{ | |
public RepositoryTemplate Template = new RepositoryTemplate(); | |
protected override void RunCore() | |
{ | |
var classes = new string[]{"User", "Product"}; | |
foreach(var item in classes){ | |
Template.ClassName = item; | |
Template.RenderToFile(item + "Repository.generated.cs"); | |
} | |
} | |
} | |
#> |
<#@ template language="C#" debug="True" #> | |
<#@ output extension="cs" #> | |
<#@ include file="T4Toolbox.tt" #> | |
<#@ include file="RepositoryGenerator.tt" #> | |
<# | |
// <copyright file="RepositoryScript.tt" company=""> | |
// Copyright © . All Rights Reserved. | |
// </copyright> | |
RepositoryGenerator generator = new RepositoryGenerator(); | |
generator.Run(); | |
#> |
Przykład
Tradycyjnie na githubie (https://github.com/danielplawgo/T4Repositories) jest przykład. Znajdują się w nim wszystkie szablony z wpisu. Dodatkowo stworzyłem aplikację konsolową, która pokazuje, że wygenerowane interfejsy oraz klasy faktycznie działają. Aby uruchomić aplikację, wystarczy tylko w app.config ustawić poprawny connection string do bazy (struktura bazy wygeneruje się sama podczas pierwszego uruchomienia aplikacji).
Podsumowanie
Generowanie kodu nie musi być skomplikowane. T4 Toolbox bardzo ułatwia wykorzystywanie szablonów T4 w projekcie. Dzięki podzieleniu logiki generowania kodu na trzy elementy jest to dużo łatwiejsze do ogarnięcia.
Warto się zastanowić, czy gra jest warta świeczki? Z jednej strony można zauważyć, że generowane interfejsy/klasy nie są skomplikowane i ich ręczne dodanie nie jest czasochłonne. Szczególnie w powyższej implementacji, gdy i tak trzeba zmodyfikować generator, aby dodać nową klasę do tablicy.
Z drugiej strony taka inwestycja z czasem bardzo szybko się zwraca, w szczególności gdy mamy sporo klas modeli (np. wygenerowanych na podstawie bazy danych). Wtedy takie szablony znacznie ułatwiają dodanie repozytoriów do projektu.
Dodatkowo mając taki szablon, dużo łatwiej jest w przyszłości modyfikować kod repozytoriów. Przykładowo w powyższej implementacji DataContext jest przekazywany jako Lazy w konstruktorze. Gdybyśmy z jakiegoś powodu chcieli to zmienić, to wystarczy tylko zmienić RepositoryTemplate i wygenerować od nowa wszystkie klasy. Bez wątpienia będzie to dużo szybsze rozwiązanie niż ręczna edycja każdego repozytorium.
W swoich projektach staram się jak częściej używać szablonów. Z czasem jest to już tylko kopiuj-wklej z innych projektów, gdzie po drobnej zmianie mogę korzystać z dobrodziejstw szablonów.
Ciebie również zachęcam do używania szablonów T4 w swoich projektach.
Tylko jak takie generowanie sprawdza się w momencie gdy chcemy nanieść zmianę na wybrane repozytorium? Np. dla encji użytkowników pobierać ich dodatkowo po mieście, artykuły po zawieranym tagu, produkty po kategorii. Czy może dla Ciebie takie repozytorium jest chowane potem przez serwis, który dopiero ostarcza takie API?
Cześć Adam,
Wygenerowane klasy oraz interfejsy są typu partial, czyli możemy utworzyć na nich dodatkowe pliki, które będą zawierały specyficzny kod (na przykład nowa metoda), dla jednego konkretnego repozytorium.
W przypadku, o którym piszesz, to prawdopodobnie trzeba by dodać nową metodę, które będzie miała dodatkowe parametry dla miasta, czy tagu. Tą nową metodę dodajesz wtedy do drugiego pliku, który już nie zostanie nadpisany podczas generowania.
Ewentualnie można zamienić metody w Repository na wirtualne i następnie w klasie partial je nadpisać, dodając jakaś specyficzną logikę, dla danego repozytorium (np. Include z Entity Framework).
Mam nadzieje, że udało mi się Ci pomóc. Jak coś to pytaj dalej 🙂