I mit arbejde med at udvikle systemer har jeg indtil videre
håndkodet repositories, altså integration til databasen via en
klasse, der skal sørge for at afskærme resten af applikationen fra
databasevalget. Dette har som sådan fungerert OK, men det er, for
en stor del, en triviel opgave og derfor kedeligt i længden. Det
ville altså være rart kun, at skulle kode samme logik én gang,
altså følge DRY-princippet.
Jeg er i forbindelse med, at jeg er dykket ned i EF 5 og Code
First, blevet introduceret for et
generisk repository, hvilket har åbnet mine øjne for
mulighederne i Generic (som efterhånden har nogle år på bagen i
.NET). Dog har jeg ikke mulighed for at benytte EF 5 i mine gamle
projekter, dels fordi disse i høj grad baserer sig på Linq to SQL,
dels fordi disse er implementeret i .NET 3.5. Derfor jeg har søgt
efter et alternativ, som kunne bruges ifm. det miljø... og når man
søger, skal man som bekendt finde (om ikke andet inspiration),
hvilket jeg gjorde i denne artikel:
"A generic implementation of the Repository Pattern for LINQ to
SQL"
Jeg vil ikke påstå jeg forstår koden fuldt ud, men jeg kan dog
godt se de smarte i, at jeg kan smide et hvilket som helst
Entity-objekt ned i halsen på repositoryklassen og få udført de
relevante handlinger på dette. Såvidt jeg kan se, skal
forretningsobjekterne implementere et interface for at dette kan
fungere. Dette er jeg som udgangspunkt ikke specielt interesseret
i, da jeg gerne vil have adskilt mine repositories helt fra
forretningsobjekterne (som man kan med EF + mapping-klasser +
fluent API til definering af dataskema).
Derfor har jeg et alternativt forslag til, hvordan man kan opnå
dette. Det kræver dog, at man implementerer lidt ekstra kode mhp.
mapning mellem database-entities og forretningsobjekter. Jeg har
valgt at implementere denne mapning af data bag et interface, som
det generiske repository kan gøre brug af (strategy pattern). Mere
om det senere i artiklen.
Først til udgangspunktet, nemlig forretningsobjekterne.
Forretningsobjekterne
Dette er i min verden logikken omkring forretningen, dvs. her
skal ikke være nogen kendskab til hvordan objektet materialiseres
eller gemmes, men kun regler og data vedr. objektet.
Et "rent" forretningsobjekt kunne f.eks. se således ud:
public class Person
{
public int Id { get; set; }
public string Navn { get; set; }
public string Email { get; set; }
}
Det kan så diskuteres om ikke Id er en databasespecifik
egenskab, men her vælger jeg så ikke at blive alt for puristisk
(til trods for det jo var et af argumenterne for, at lave den
løsning jeg her præsenterer :-)). Id er relevant andre steder i
systemet, da det er en bekvem måde at identificere objektets data
på.
Transport af data til databasen
Jeg vil gerne have mine data gemt i en database, så jeg kan
arbejde videre med dem på et senere tidspunkt, så derfor har jeg
lavet en tabel i min database, som ser nogenlunde således ud:
person =
id int
+ navn nvarchar(50)
+ email nvarchar(250)
Når dette kværnes igennem LINQ To SQL's ORM i Visual Studio,
resulterer det i et objekt, der i korte træk ser nogenlunde således
ud:
public partial class person
{
public int id { get; set; }
public string navn { get; set; }
public string email { get; set; }
}
Nu bliver udfordringen så, at få mit forretningsobjekt
transporteret frem og tilbage til hhv. fra databasen. Dertil har
jeg mit repositoryobjekt.
Repositoryklasserne
Normalt ville jeg implementere en klasse med flg. struktur:
public class PersonRepository
{
public Person Get(Person obj) { }
public IEnumerable<Person> FindAll(
FindPersonKriterier kriterier
) {}
public IEnumerable<Person> Find(
out int total,
FindPersonKriterier kriterier
) {}
public Person Create(Person obj) { }
public Person Update(Person obj) { }
public Pesron Delete(Person obj) { }
}
Det er så her jeg udvikler min dovenskab lidt, for sådan et
skulle jeg jo i så fald lave for hvert eneste
forretningsobjekt (og har gjort det indtil videre), hvilket
som sagt er noget omstændigt.
Ud over jeg skulle implementere denne klasse for hver
forretningsobjekt, skulle jeg også implementere en
FindForretningsobjektKriterier, der skulle holde på alle mine
søgekriterier for den pågældende klasse.
Generics redder dagen
Derfor vil jeg i stedet kaste mig ud i Generics og flikke en
respositoryklasse sammen, som kan bruges i (næsten) alle
sammenhænge. Den kunne se således ud:
public class GenericRepository<TEntity, TDoamin>
{
private readonly dataContext _context;
private readonly IDomainDBMapper<TEntity, TDomain> _mapper;
public GenericRepository(
dataContext db,
IDomainDBMapper<TEntity, TDomain> mapper
)
{
_context = db;
_mapper = mapper;
}
private Table<TEntity> GetTable
{
get { return _context.GetTable<TEntity>; }
}
public TDomain Get(
Expression<Func<TEntity, bool>> idFilter
)
{
var dbo = GetTable.SingleOrDefault(idFilter);
return _mapper.DbToObject(dbo, null);
}
public IEnumerable<TDomain> FindAll(
Expression<Func<TEntity, bool>> filter,
Func<IQueryable<TEntity>,
IOrderedQueryable<TEntity>> orderBy
)
{
var total = 0;
return Find(out total, filter, orderBy, 0, 1);
}
public IEnumerable<TDomain> Find(
out int total,
Expression<Func<TEntity, bool>> filter,
Func<IQueryable<TEntity>,
IOrderedQueryable<TEntity>> orderBy
int pageSize,
int page
)
{
IQueryable<TEntity> res = GetTable;
if(filter != null)
res = res.Where(filter);
if (orderBy != null)
res = orderBy(res);
total = res.Count();
if(pageSize > 0)
{
if(page > 1)
res = res.Skip((page - 1) * pageSize);
res = res.Take(pageSize);
}
return
(
from q in res
select mapper.DBToDomain(q, null)
).ToList<TDomain>();
}
public TEntity Create(TDomain obj)
{
var dbObj = CreateEntity(obj);
GetTable.InsertOnSubmit(dbObj);
return dbObj;
}
public TEntity Update(
TDomain obj,
Expression<Func<TEntity, bool>> idFilter
)
{
var dbObj = GetTable.SingleOrDefault(idFilter);
if(dbObj == null)
throw new NullReferenceException();
mapper.ObjectToDB(obj, dbObj);
return dbObj;
}
public TEntity Delete(Expression<Func<TEntity, bool>> idFilter)
{
var dbObj = GetTable.SingleOrDefault(idFilter);
if(dbObj != null)
GetTable.DeleteOnSubmit((TEntity)dbObj);
return dbObj;
}
public TEntity CreateEntity(TDomain obj)
{
var dbObj = CreateInstance<TEntity>();
mapper.ObjectToDB(obj, dbObj);
return dbObj;
}
protected T CreateInstance<T>()
{
return Activator.CreateInstance<T>();
}
}
Klassen holder en reference til datakonteksten og kender som
sådan ikke noget til processen at oprette dette objekt. Der
foretages heller ikke nogen commit (SubmitChanges) i
repositoryklassen, da dette skal ske hos den kaldende part (f.eks.
Unit of Work). Repositoryklassen ved, som udgangspunkt, ikke,
hvornår klienten er klar til at committe sig til de foretagne
ændringer. Se senere i afsnittet om Unit of Work-klassen for at se,
hvordan det kan anvendes i praksis.
Insert/Update/Delete
Disse metoder tager sig helt basalt af, at oprette, opdatere og
slette data.
For såvidt angår Update og Delete, medsendes der, af den
kaldende part, et lambda-udtryk, som specificerer hvordan
repository-klassen skal identificerer den række der skal opdateres
hhv. slettes.
Update og Delete har brug for at kunne identificere den række
der skal opdateres hhv. slettes og dette sker ved at den kaldende
part medsender et lambda-udtryk, som angiver hvordan rækken
findes.
Get
Henter en given række i databasen baseret på rækkens id-kolonne.
Id'et findes ved at den kaldende part medsender et lambda-udtryk,
der specificerer hvordan repository-klassen skal identificere
rækken der skal hentes.
FindAll/Find
Disse to metoder finder frem til data på grundlag af et filter
(som også er et lambda-udtryk). Dette filter kan som sådan være
mere eller mindre komplekst. Pointen her er, at man filtrerer
direkte i databasen, dvs. transporten af data er minimal og dermed
godt for både netværk og hukommelsesforbruget.
Find stiller yderligere muligheden for, at paginere resultatet,
til rådighed. Dette sker ligeledes direkte i databasen og fremmer
dermed ovenstående pointe yderligere. Desuden returnerer Find også
det totale antal poster fundet ved søgningen, sammen med det
paginerede resultat. Således har man mulighed for at foretage
yderligere tilpasning ifm. præsentationen af data (f.eks. lave en
"pager").
Udover filtrering og paginering, giver begge metoder mulighed
for, at sortere resultatet. Dette sker ligeledes i databasen. På
samme måde som med filteret, sendes sorteringsbetingelserne også
med som et lambda-udtryk.
CreateEntity
Metoden benyttes til at skabe et database entity-objekt, der
afspejler forretningsobjektet. Dette kan bruges i sammenhænge, hvor
man har brug for at splitte indsættelsesprocessen op i flere trin.
Se eksempel herpå i forbindelse med Ordre + Ordrelinjer sidst i
artiklen.
Støttemetoder
Ud over det faktiske interface, er der metoder til, at oprette
en instans af forretningsobjekter og databaseobjekter
(CreateInstance), samt en metode til, at hente table-klassen for
det givne databaseobjekt (GetTable). Disse er behagelighedsmetoder
og kunne som sådan godt undværes. De gør bare resten af koden
lettere at læse.
Som det kan ses, er den generiske respositoryklasse ikke i sig
selv afskærmende i forhold til omverdenen, dvs. Linq To SQL's
entiteter kendes også af den kaldende part. Min tanke er dog, at
Unit of Work-klassen skal være denne afskærmning, således Unit of
Work-klassen kun kommunikerer forretningsobjekter i sit interface,
men internt er næd til at arbejde med og kende til
databaseobjekterne. Dette er nødvendigt for at Unit of Work-klssen
kan foretage handlinger på tværs af flere forskellige repositories
i én transaktion. Læs mere om denne konstruktion senere i
artiklen.
Udfordringen herfra er nu, at få data transporteret fra det ene
format (forretningsobjektet) over i det andet (databasenobjektet)
og til dette formål skal jeg bruge en mapper-klasse
Mapperklassen som adapter
Da der er tale om et generisk repository, kendes de typer der
arbejdes med, ikke på forhånd, så repositoryklassen kan ikke selv
stå for mapning af data (med mindre man kaster sig over
Reflection). Derfor uddelegeres dette arbejde til en mapperklasse.
Denne mapperklasser implementerer et interface, som
repositoryklassen kan få lov til at kende på compile tidspunktet,
nemlig IDomainDBMapper. Dette interface definerer to metoder, en
der kopierer data fra databaseklassen til forretningsobjektet og en
der kopierer data den anden vej. Det kunne se således ud:
public interface IDomainDBMapper<TEntity, TDomain>
{
TEntity DomainToDB(TDomain, TEntity);
TDomain DBToDomain(TEntity, TDomain);
}
Den specifikke mapperklasse implementerer, som nævnt, interfacet
ovenfor og kan se således ud:
public class PersonMapper
: IDomainDBMapper<person, Person>
{
public Person DBToDomain(
person dbObj,
Person obj
)
{
if(obj == null)
obj = Activator.CreateInstance<Person>();
obj.Id = dbObj.id;
obj.Navn = dbObj.navn;
obj.Email = dbObj.email;
return obj;
}
public void DomainToDB(
Person obj,
person dbObj
)
{
dbObj.navn = obj.Navn;
dbObj.email = obj.Email;
return dbObj;
}
}
Som det ses, er dette en relativ simpel klasse, som burde være
let at teste og som ikke burde give de store problemer at
implementere.
Herfra kunne man faktisk godt gå igang med at bruge det
generiske repository, men det ville med nogen sandsynlighed blive
et sammenfiltret system med bindinger langt ned i databaselaget
helt oppe fra GUI. Dette er ikke ønskeværdigt, så derfor bør det
overvejes, om repository-klasserne skal pakkes ind. Det kan f.eks.
gøres ved at lave et lag, som håndterer alt logik der omhandler
lagring og læsning af data. Ind fra højre kommer Unit of
Work...
Unit of Work-klassen (UoW)
Denne klasse pakker repositoryklassen (eller -klasserne) ind og
er, som sådan, et transaktionslag mellem forretningsobjekterne og
databasen via en række handlinger, der sørger for at udføre
specifikke handlinger med data. Disse handlinger kunne f.eks. være
at hente en liste af et forretningsobjekt, eller gemme en samling
af objekter af samme eller forskellig type. UoW-klassen definerer
det antal repository-klasser der er behov for, for at håndtere de
opgaver der skal løses. Det kunne f.eks. se således ud:
public class PersonUnitOfWork : IDisposable
{
private readonly dataContext _context;
private readonly GenericRepository<person,Person> _repository;
public PersonUnitOfWork()
{
_context = new dataContext();
_repository =
new GenericRepository<person,Person>(
_context,
new PersonMapper()
);
}
public void Dispose()
{
_context.Dispose();
}
public List<Person> SoegPaaNavn(string navn)
{
var lowerNavn = navn.ToLower();
return _repository.FindAll(
o => o.navn.ToLower().Contains(lowerNavn),
q => q.OrderBy(ob => ob.navn)
).ToList();
}
public Person Hent(int id)
{
return _repository.Get(o => o.id == id);
}
public void Gem(Person obj)
{
person dbObj = null;
var isNew = obj.Id == 0;
if(isNew)
dbObj = _repository.Insert(obj);
else
dbObj = _repository.Update(obj, o => o.id == obj.Id);
_context.SubmitChanges();
if(isNew)
obj.Id = dbObj.id;
}
public void Slet(Person obj)
{
_repository.Delete(o => o.id == obj.Id);
_context.SubmitChanges();
obj.Id = 0;
}
}
Ovenstående UoW-klasse er helt simpel, men den viser alligevel
nogle af de elementer man skal arbejde med i forbindelse med det
generiske respository. Desuden kan man "pakke den ned" sammen med
resten af databaselaget, da interfacet til databasen hermed er
afskærmet, idet der kun arbejdes med forretningsobjekter
udadtil.
Jeg er klar over det kan være fristende, at lægge
forretningslogik i repository-laget, men UoW-klassen skal opfattes,
som en adapter mellem databasen og forretningsobjekterne. Derfor
skal UoW-objekterne kun håndtere den logik der omhandler, at
fordele data ud i de rigtige repositories, alt andet logik skal
ligge i forretningsobjektet eller et separat serviceobjekt.
Anvendelse af UoW i praksis
UoW-klassen kan bruges således:
using(var uow = new PersonUnitOfWork())
{
var personListe = uow.SoegPaaNavn("Hansen");
repeaterPersoner.DataSource = personListe;
repeaterPersoner.DataBind();
}
Her søges der efter alle personer, hvor efternavnet Hansen er en
del af deres navn. Resultatet bindes til en repeater, der
forventes, at findes på siden.
Bemærk, at UoW implementerer IDisposable for at kunne frigive
resurser ifm. Garbage Collection. Derfor vælger jeg også at benytte
mig at using-konstruktionen ifm. brugen af UoW.
Mere komplekse anvendelsesområder for UoW
Eksemplerne i denne artikel er noget simple, men Unit of Work
kommer for alvor til sin ret, når man skal håndtere større
objekthierarkier, hvor data skal gemmes som et samlet hele (dvs. en
transaktion), idet man med den illustrerede UoW-struktur, kan
opdatere flere repositories inden SubmitChanges kaldes på
DataContext-objektet. Det er værd at bide mærke i, at
dataContext-objektet styres af UoW-klassen og at det dermed er her
beslutningen om kald af SubmitChanges tages.
Eksempelvis kunne en ordre i et shopsystem, der typisk kan
indeholde flere ordrelinjer, gemmes ved først at oprette en ordre i
ordretabellen, og derefter tilføje ordrelinjer i
ordrelinjetabellen. Til sidst kan SubmitChanges kaldes for at
effektuere selve lagringen.
public class OrdreUnitOfWork : IDisposable
{
private readonly dataContext _context;
private readonly
GenericRepository<ordre,Ordre> _ordreRepository;
private readonly
GenericRepository<ordrelinje,Ordrelinje>
_ordrelinjeRepository;
public OrdreUnitOfWork()
{
_context = new dataContext();
_ordreRepository =
new GenericRepository<ordre,Ordre>(
_context,
new OrdreMapper()
);
_ordrelinjeRepository =
new GenericRepository<ordrelinje,Ordrelinje>(
_context,
new OrdrelinjeMapper()
);
}
// øvrige metoder udeladt...
public void Gem(Ordre obj)
{
if (obj.Id == 0)
{
var dbObj = _ordreRepository.Insert(obj);
foreach(var ol in obj.Ordrelinjer)
{
dbObj.ordrelinje.Add(
_ordrelinjeRepository.CreateEntity(ol));
}
_context.SubmitChanges();
if(dbObj != null)
obj.Id = dbObj.id;
}
else
{
_repository.Update(obj, o => o.id == obj.Id);
foreach(var ol in obj.Ordrelinjer)
{
if(ol.Id == 0)
dbObj.ordrelinje.Add(
_ordrelinjeRepository.CreateEntity(ol));
else
_ordrelinjeRepository.Update(ol);
}
_context.SubmitChanges();
}
}
// øvrige metoder udeladt...
}
Som det ses, håndteres ordrelinjer specielt, da disse skal
gemmes i den rigtige rækkefølge ifht. ordren (ordren skal først
oprettes og have et id tildelt, før ordrelinjerne kan relateres til
ordren). Dette klarer Linq To SQL for os, hvis blot ordrelinjerne
tilknyttes på den korrekte måde, dvs. via Add-metoden på dbOrdre's
ordrelinje-liste.
Håndteringen af ordrelinjer kunne i princippet også håndteres i
mapperklassen, hvis der er lavet en relation mellem ordren og
ordrelinjerne i databaseskemaet (hvilket der jo nok typisk er).
Jeg håber dette kan være med til, at hjælpe dig på vej til mere
overkommeligt liv med repository-pattern og forretningsobjekter.
Smid gerne en kommentar, hvis du har forslag til forbedringer eller
sprøgsmål til implementeringen.