Ο Γιώργος έχει δίκιο. Τα αντικείμενα υπάρχουν ανεξάρτητα από το όποιο DataContext χρησιμοποιείται για να τα διαβάσεις ή να τα αποθηκεύσει, έτσι δεν υπάρχει τρόπος το αντικείμενο να ξέρει από ποιό DataContext δημιουργήθηκε ή σε ποιό είναι attach κάθε στιγμή - πολύ απλά, μπορεί να μην είναι attached πουθενά. Μία καλή αναλογία είναι να σκεφτείς το DataContext ως ένα Connection και τα αντικείμενα ως τα Datasets, Datatables που μπορεί να περάσουν από αυτή. Ένα DataTable δεν ξέρει ποτέ ποιό connection το δημιούργησε ή ποιό θα το αποθηκεύσει στο μέλλον.
Αυτό είναι ένα Καλό Πράγμα. Έτσι δεν είσαι υποχρεωμένος να κρατάς το ίδιο DataContext συνέχεια στη μνήμη και να κάνεις track αλλαγές οι οποίες μπορεί ποτέ να μην καταλήξουν στη βάση. Μπορείς άνετα να δημιουργείς ένα DataContext αντικείμενο μόνο όταν θέλεις να διαβάσεις από τη βάση ή να αποθηκεύσεις τις αλλαγές σου σε αυτή.
Θα μου πεις τώρα, και ήδη το είπες, "Δηλαδή θα πρέπει να πλημμυρίσει ο κώδικας μου με DataContexts και DeleteOnSubmit ? Εδώ είπαμε να ξεχωρίσουμε αντικείμενα από το Data Layer και εσύ μου λες, να τα ξαναμπλέξουμε ξανά ?" . Προφανώς και δεν είναι καθόλου καλή ιδέα να τα μπλέξουμε ξανά. Ευτυχώς το πρόβλημα (και η λύση του) είναι κλασσικό για τα ORMs και έχει αντιμετωπιστεί εδώ και καιρό.
Η λύση λέγεται Repository Pattern και ουσιαστικά είναι μία κλάση η οποία στην απλούστερη περίπτωση κρύβει από εσένα το DataContext και τις λεπτομέρειες του και σου δίνει ένα καθαρό interface με εντολές GetByID, Delete, Update. To Repository από πίσω αναλαμβάνει να χειριστεί το ή τα DataContext που χρειάζονται. Οι πιο προχωρημένες υλοποιήσεις διαχειρίζονται τα DataContexts και τα Transactions έτσι ώστε να υλοποιήσουν long running transactions, caching, concurrency και session management.
Κατά κανόνα μία κλάση Repository υλοποιεί κάτι σαν το παρακάτω interface:
interface IRepository<T>
{
public T GetByID(int id);
public void Save(T entity);
public void Delete(T entity);
}
Μία απλή υλοποίηση μπορεί να είναι η παρακάτω:
class CustomerRepository:IRepository<Customer>
{
#region IRepository<Customer> Members
public Customer GetByID(int id)
{
using (MyDataContext ctx = new MyDataContext())
{
return ctx.Customers.SingleOrDefault(c => c.CustomerID == id);
}
}
public void Save(Customer entity)
{
using (MyDataContext ctx = new MyDataContext())
{
if (entity.CustomerID > 0)
ctx.Customers.Attach(entity);
else
ctx.Customers.InsertOnSubmit(entity);
ctx.SubmitChanges();
}
}
public void Delete(Customer entity)
{
using (MyDataContext ctx = new MyDataContext())
{
ctx.Customers.DeleteOnSubmit(entity);
ctx.SubmitChanges();
}
}
#endregion
}
Έτσι μπορείς να γράψεις π.χ. τον παρακάτω κώδικα:
CustomerRepository repository = new CustomerRepository();
Customer customer=repository.GetByID(35);
Η κλάση CustomerRepository δεν είναι και ότι καλύτερο. Είμαι σίγουρος ότι κανείς δεν έχει όρεξη να γράφει ένα Repository για κάθε κλάση. Για να φτιαχτεί μία γενική κλάση repository χρειάζονται δύο πράγματα: να έχουμε πρόσβαση στην ctx.Customers χωρίς να την προσδιορίσουμε και κάπως, να απαλλαγούμε από την αναφορά στο CustomerID.
Η πρώτη αλλαγή είναι σχετικά εύκολη, καθώς η Customers είναι χονδρικά μία συντόμευση για την GetTable<Customer>. Η δεύτερη αλλαγή γίνεται επίσης εύκολα αν θυμηθούμε ότι μπορούμε να περάσουμε το c => c.CustomerID == id ως παράμετρ. Μπορούμε λοιπόν αντί για την GetByID να φτιάξουμε μία γενική Get:
public T Get(Func<T,bool> criteria)
{
using (MyDataContext ctx = new MyDataContext())
{
return ctx.GetTable<T>().SingleOrDefault(criteria);
}
}
Και η κλήση γίνεται:
Repository<Customer> repository = new Repository<Customer>();
Customer customer = repository.Get(c => c.CustomerID == 35);
Μας μένει όμως το MyDataContext. Μπορούμε να το ξεφορτωθούμε και αυτό αν αλλάξουμε τον ορισμό της Repository σε
class Repository<T, C>
where T:class
where C:DataContext
Έτσι η Get γίνεται:
public T Get(Func<T,bool> criteria)
{
using (MyDataContext ctx = new MyDataContext())
{
Table<T> table = ctx.GetTable<T>();
return table.SingleOrDefault(criteria);
}
}
Η Save έχει ακόμα ένα προβληματάκι, καθώς ελέγχει το CustomerID για να δει αν ένα αντικείμενο είναι νέο και θέλει Insert, ή προϋπήρχε και θέλει Attach. Θα μπορούσα να ψάξω και να βρω ποιά πεδία είναι τα Primary keys του πίνακα αλλά δεν μου αρέσει. Αντί γι αυτό, θα περάσω και αυτό το κριτήριο ως παράμετρο. Έτσι η Repository γίνεται:
class Repository<T, C>
where T:class
where C:DataContext, new()
{
protected Func<T, bool> IsNew;
public Repository(Func<T, bool> isNewCriteria)
{
IsNew = isNewCriteria;
}
#region IRepository<Customer> Members
public T Get(Func<T,bool> criteria)
{
using (C ctx = new C())
{
Table<T> table = ctx.GetTable<T>();
return table.SingleOrDefault(criteria);
}
}
public void Save(T entity)
{
using (C ctx = new C())
{
Table<T> table = ctx.GetTable<T>();
if (IsNew(entity))
table.Attach(entity);
else
table.InsertOnSubmit(entity);
ctx.SubmitChanges();
}
}
public void Delete(T entity)
{
using (C ctx = new C())
{
Table<T> table = ctx.GetTable<T>();
table.DeleteOnSubmit(entity);
ctx.SubmitChanges();
}
}
#endregion
}
Το κριτήριο για το αν ένα αντικείμενο είναι νέο το ονομάζω IsNew και το περνάω στον constructor της Repository
Τώρα, η κλήση της Get γίνεται:
Repository<Customer,MyDataContext> repository = new Repository<Customer,MyDataContext>(c=> c.CustomerID>0);
Customer customer = repository.Get(c => c.CustomerID == 35);
Αλλά πάλι, αυτός ο constructor δεν μου αρέσει. Γι αυτό θα φτιάξω μία νέα CustomerRepository ως εξής:
class CustomerRepository : Repository<Customer,MyDataContext>
{
public CustomerRepository()
{
IsNew = (c => c.CustomerID > 0);
}
}
και θα βγάλω τον Constructor από την Repository<>. Τώρα η κλήση μου γίνεται :
CustomerRepository repository = new CustomerRepository();
Customer customer = repository.Get(c => c.CustomerID == 35);
Θα μπορούσα να προσθέσω κι άλλα πράγματα. Θα μπορούσα να κάνω το ίδιο κόλπο με την IsNew και να περνάω και το κριτήριο της Get ως παράμετρο στον constructor. Θα μπορούσα να προσθέσω και κάποιες Where, First κλπ, για να κάνω αναζητήσης όπως έχω συνηθίσει με το LINQ. Αντί γι αυτό όμως θέλω να περάσω στα πιο προχωρημένα θέματα.
Αυτή τη στιγμή χρησιμοποιώ ντε και καλά ένα μόνο context τη φορά. Αυτό με βολεύει αν θέλω οι αλλαγές μου να περνάνε απευθείας στη βάση, μπορεί όμως να θέλω να υλοποιήσω διαφορετική λογική. Για παράδειγμα, μπορεί να θέλω να μαζέψω όλες τις αλλαγές που θέλω να κάνω σε ένα σύνολο αντικειμένων και να τις αποθηκεύσω όλες μαζί στο τέλος. Για να το κάνω αυτό, πρέπει το DataContext μου να παραμένει στη μνήμη, και μάλιστα να είναι διαθέσιμο για χρήση μεταξύ πολλών Repositories. Χρειάζομαι ένα τρόπο να λέω στην Repository που να βρει και πως να χρησιμοποιήσει το DataContext.
Για να το πετύχω αυτό, θα φτιάξω διάφορες κλάσεις ContextPolicy. Άλλη θα μου επιστρέφει ένα διαφορετικό context κάθε φορά και άλλη θα μου επιστρέφει το ίδιο κάθε φορά. Επειδή όμως δεν θέλω να χάσω και την ευκολία του using, τα Factories δεν θα επιστρέφουν ένα DataContext αλλά ένα ContextWrapper:
class ContextWrapper<C> : IDisposable where C : DataContext
{
public C Context { get; protected set; }
public ContextWrapper(C context)
{
Context = context;
}
#region IDisposable Members
public void Dispose()
{
Context.Dispose();
}
#endregion
}
Τα policies θα υλοποιούν το IContextPolicy interface
interface IContextPolicy<C> where C : DataContext
{
ContextWrapper<C> Get();
}
Η κλάση ContextWrapper είναι η παρακάτω:
public class ContextWrapper<C> : IDisposable where C : DataContext
{
public C Context { get; protected set; }
public ContextWrapper(C context)
{
Context = context;
}
public virtual void Dispose()
{
}
}
H Dispose είναι επίτηδες κενή, γιατί θα χρησιμοποιήσω μία άλλη κλάση, την DisposingContextWrapper για τις περιπτώσεις που θέλω να κλείνω το context κάθε φορά
class DisposingContextWrapper<C> : ContextWrapper<C> where C : DataContext
{
public DisposingContextWrapper(C context)
: base(context)
{}
#region IDisposable Members
public override void Dispose()
{
Context.Dispose();
}
#endregion
}
Και τώρα, μπορώ να φτιάξω μία ContextPolicy η οποία χρησιμοποιεί πάντα το ίδιο context:
public class SingleContextPolicy<C> : IContextPolicy<C>
where C : DataContext, new()
{
C context;
#region IContextPolicy<C> Members
public ContextWrapper<C> Get()
{
if (context==null)
context = new C();
return new ContextWrapper<C>(context);
}
#endregion
}
Ή μία ContextFactory η οποία πάντα δημιουργεί ένα νέο context:
public class MultipleContextPolicy<C> : IContextPolicy<C>
where C : DataContext, new()
{
#region IContextPolicy<C> Members
public ContextWrapper<C> Get()
{
C context = new C();
return new DisposingContextWrapper<C>(context);
}
#endregion
}
Οι αλλαγές που θα κάνω στην Repository είναι ελάχιστες
class Repository<T, C>
where T:class
where C:DataContext, new()
{
protected IContextPolicy<C> _contextPolicy;
protected Func<T, bool> IsNew;
#region IRepository<Customer> Members
public T Get(Func<T,bool> criteria)
{
using (ContextWrapper<C> ctx = _contextPolicy.Get())
{
Table<T> table = ctx.Context.GetTable<T>();
return table.SingleOrDefault(criteria);
}
}
public void Save(T entity)
{
using (ContextWrapper<C> ctx = _contextPolicy.Get())
{
Table<T> table = ctx.Context.GetTable<T>();
if (IsNew(entity))
table.Attach(entity);
else
table.InsertOnSubmit(entity);
ctx.Context.SubmitChanges();
}
}
public void Delete(T entity)
{
using (ContextWrapper<C> ctx = _contextPolicy.Get())
{
Table<T> table = ctx.Context.GetTable<T>();
table.DeleteOnSubmit(entity);
ctx.Context.SubmitChanges();
}
}
#endregion
}
Απλά, αντί για να φτιάχνω τα context με το χέρι, καλώ την Get του Context Factory.
Μπορώ τώρα να ορίσω ότι η CustomerRepository θα χρησιμοποιεί πάντα ένα context:
class CustomerRepository : Repository<Customer,MyDataContext>
{
public CustomerRepository()
{
IsNew = (c => c.CustomerID > 0);
_contextPolicy = new SingleContextPolicy<MyDataContext>();
}
}
Επόμενο βήμα τώρα. Αφού μπορώ να έχω ένα κοινό context, γιατί να μην μπορώ να μαζέψω όλες τις αλλαγές μέχρι τη στιγμή που θέλω εγώ? Και χωρίς φυσικά να κάνω hard-code αυτή τη συμπεριφορά? Η απάντηση είναι άλλο ένα σετ από Policies, τα Submission policies. Θα προσθέσω επίσης μία SaveAll στη Repository η οποία θα καλεί απευθείας την SubmitChanges. Τα Submission policies είναι τα εξής
interface ISubmissionPolicy<C> where C : DataContext
{
void Submit(C context);
}
public class AutoSubmitPolicy<C>:ISubmissionPolicy<C> where C : DataContext
{
#region ISubmissionPolicy<C> Members
public void Submit(C context)
{
context.SubmitChanges();
}
#endregion
}
public class DeferSubmitPolicy<C> : ISubmissionPolicy<C> where C : DataContext
{
#region ISubmissionPolicy<C> Members
public void Submit(C context)
{}
#endregion
}
Και η repository γίνεται
class Repository<T, C>
where T:class
where C:DataContext, new()
{
protected IContextPolicy<C> _contextPolicy;
protected ISubmissionPolicy<C> _submissionPolicy;
protected Func<T, bool> IsNew;
#region IRepository<Customer> Members
public T Get(Func<T,bool> criteria)
{
using (ContextWrapper<C> ctx = _contextPolicy.Get())
{
Table<T> table = ctx.Context.GetTable<T>();
return table.SingleOrDefault(criteria);
}
}
public void Save(T entity)
{
using (ContextWrapper<C> ctx = _contextPolicy.Get())
{
Table<T> table = ctx.Context.GetTable<T>();
if (IsNew(entity))
table.Attach(entity);
else
table.InsertOnSubmit(entity);
_submissionPolicy.Submit(ctx.Context);
}
}
public void SaveAll()
{
using (ContextWrapper<C> ctx = _contextPolicy.Get())
{
ctx.Context.SubmitChanges();
}
}
public void Delete(T entity)
{
using (ContextWrapper<C> ctx = _contextPolicy.Get())
{
Table<T> table = ctx.Context.GetTable<T>();
table.DeleteOnSubmit(entity);
_submissionPolicy.Submit(ctx.Context);
}
}
#endregion
}
Και μπορώ πλέον να ορίσω ένα CustomerRepository ως εξής:
class CustomerRepository : Repository<Customer,MyDataContext>
{
public CustomerRepository()
{
IsNew = (c => c.CustomerID > 0);
_contextPolicy = new SingleContextPolicy<MyDataContext>();
_submissionPolicy = new DeferSubmitPolicy<MyDataContext>();
}
}
Αν μάλιστα προσθέσω και ένα constructor στην Repository ο οποίος θα δέχεται τα policies ως παραμέτρους, μπορώ να ορίσω Repositories με διαφορετική συμπεριφορά σε διαφορετικά σημεία του κώδικα, για την ίδια πάντα κλάση.
Με αντίστοιχο τρόπο μπορώ να συνεχίσω για να προσθέσω concurrency, transactions, caching και ότι άλλο μπορώ να φανταστώ. Μάλιστα, μπορώ να πάω άλλο ένα βήμα παραπέρα και να χρησιμοποιήσω κάποιο dependency inversion μηχανισμό όπως το Unity, το Spring.Net ή κάποιο δικής μου κατασκευής για να ορίζω ποιά policies θα χρησιμοποιούνται μέσω configuration.
ΥΓ. Για να μην αναρωτιέται που πήγα και κατέβασα αυτές τις ιδέες, απλά αντέγραψα από ... το NHibernate. Η ιδέα των policies προέρχεται από τη C++ και τα templates και εμφανίζεται και στην C# με διάφορες παραλλαγές όπως providers, factories κλπ. Μία αναζήτηση για LINQ to SQL Repository θα επιστρέψει αρκετές υλοποιήσεις.
Παναγιώτης Καναβός, Freelancer
Twitter: http://www.twitter.com/pkanavos