Witaj na Zine.net online Zaloguj się | Rejestracja | Pomoc

Jakub Binkowski - dot or not

Blog programisty C#

Co z tym DataContext?

Chyba trudno znaleźć programistę, który po dłuższej pracy z LINQ-to-SQL nie uznałby tej technologii za przełomową pod względem wygody i szybkości tworzenia aplikacji w porównaniu do tego, co wcześniej oferował sam .NET: DataSets oraz czyste DbConnections i spółka. Jednakże używanie tego rozwiązania na dłużą metę nie jest wolne od kilku wyzwań (i bardzo dobrze).

W Linq2Sql klasą dającą dostęp do bazy danych jest DataContext (lub dziedzicząca po nim, dla silnie typowanych kontekstów). Jednakże nie trudno zadać sobie pytanie - jak skutecznie zarządzać obiektem DataContext, aby zapewnić maksimum wygody i wydajności. W tym wpisie chciałbym właśnie przedstawić rozwiązanie, którego ja używam i które do tej pory u mnie się sprawdza.

Od razu zaznaczam, że nie chodzi mi o kwestie czasu życia samego kontekstu (czy jeden na metodę, wątek, aplikację, itd.), bo zgodnie z MSDN:

In general, a DataContext instance is designed to last for one "unit of work" however your application defines that term. A DataContext is lightweight and is not expensive to create. A typical LINQ to SQL application creates DataContext instances at method scope or as a member of short-lived classes that represent a logical set of related database operations.

Ja przyjąłem zasadę jeden DataContext per metoda.

Problemy z DataContext

Z początku wydawać by się mogło, że goły DataContext nie stwarza żadnych wyzwań. W końcu piszemy:

using (var dc = new MyDbDataContext())
{
    //operacje na bazie
}

i wszystko pięknie działa. Problemy widać już lepiej w poniższym kodzie:

   1: public class App
   2: {
   3:     public static Owner GetOwner(int ownerId)
   4:     {
   5:         using (var dataContext = new MyDbDataContext())
   6:         {
   7:             var loadOptions = new DataLoadOptions();
   8:             loadOptions.LoadWith<Dog>(d => d.Breed);
   9:             dataContext.LoadOptions = loadOptions;
  10:  
  11:             return (from o in dataContext.Owners
  12:                     where o.OwnerId == ownerId
  13:                     select o)
  14:                 .FirstOrDefault();
  15:         }
  16:     }
  17:  
  18:     public static bool CanBeBreeder(int ownerId, string breed)
  19:     {
  20:         using (var dataContext = new MyDbDataContext())
  21:         {
  22:             var loadOptions = new DataLoadOptions();
  23:             loadOptions.LoadWith<Dog>(d => d.Breed);
  24:             dataContext.LoadOptions = loadOptions;
  25:  
  26:             return (from d in dataContext.Dogs
  27:                     where d.OwnerId == ownerId && d.Breed.Name == breed
  28:                     select d).Count() >= 2;
  29:         }
  30:     }
  31:  
  32:     public static void MakeBreeder(int ownerId)
  33:     {
  34:         using (var dataContext = new MyDbDataContext())
  35:         {
  36:             var loadOptions = new DataLoadOptions();
  37:             loadOptions.LoadWith<Dog>(d => d.Breed);
  38:             loadOptions.LoadWith<Dog>(d => d.Owner);
  39:             dataContext.LoadOptions = loadOptions;
  40:             
  41:             var owner = dataContext.Owners.First(o => o.OwnerId == ownerId);
  42:             owner.IsBreeder = true;
  43:             dataContext.SubmitChanges();
  44:         }
  45:     }
  46:  
  47:     static void Main(string[] argv)
  48:     {
  49:         int ownerId = 1;
  50:         using (var ts = new TransactionScope())
  51:         {
  52:             var owner = GetOwner(ownerId);
  53:             if (!owner.IsBreeder)
  54:             {
  55:                 if (CanBeBreeder(ownerId, "Labrador"))
  56:                     MakeBreeder(ownerId);
  57:             }
  58:             ts.Complete();
  59:         }
  60:     }
  61: }

Otóż, powyższy kod:

  • wykorzystuje 3 niezależne połączenia do tej samej bazy (z tym samym ConnectionString),
  • tworzy ciężką i kosztowną transakcję rozproszoną (wymaga włączonej usługi Distibuted Transaction Coordinator),
  • w każdej metodzie definiuje takie same LoadOptions dla DataContext (ale czasami rozszerza ten zbiór o dodatkowe ustawienia),

Innymi słowy - jest długi, mało wydajny i trudny do utrzymania. Od razu widać 2 problemy, z jakimi przyjdzie nam się zmagać:

  1. Jak zarządać połączeniem do bazy danych, aby ograniczyć ich ilość do minimum (a przy okazji unikać transakcji rozproszonych)?
    Idealnym rozwiązaniem byłoby, aby wszystkie operacje w ramach jednego wątku i takiego samego DataContextu odbywały się na tym jednym połączeniu.
  2. Jak zdefiniować domyślne Load Options dla DataContextu, które można rozszerzać?
    Właśnie kwestia rozszerzalności jest najbardziej problematyczna. O ile silnie typowany DataContext jest klasą partial i ma metodę partial OnCreated, w której możemy utworzyć domyślne DataLoadOptions, o tyle już po przypisaniu tych opcji do kontekstu, nie ma możliwości ich zmiany (bez tworzenia DataLoadOptions od nowa).

Propozycja rozwiązania

Moja propozycja opiera się na opakowaniu DataContext przez inną klasę. Zadaniem tej klasy jest zarządzanie połączeniem i Load Options oraz tworzenie odpowiednio skonfigurowanego DataContext. Oto jej kod:

/// <summary>
/// Creates a "DbConnection scope", DataContext and manages its LoadOptions.
/// </summary>
public class MyDbDatabase : IDisposable
{
    #region Private fields
 
    [ThreadStatic]
    private static MyDbDatabase CurrentContext;
    private readonly DataLoadOptionsBuilder loadOptionsBuilder = new DataLoadOptionsBuilder();
    private readonly ConnectionMode mode;
    private readonly MyDbDatabase parent;
    private DbConnection connection;
    private MyDbDataContext context;
    private bool createdConnection;
 
    #endregion
 
    #region Properties
 
    /// <summary>
    /// Gets the current connection.
    /// </summary>
    /// <value>The connection.</value>
    public DbConnection Connection
    {
        get
        {
            if (connection == null)
            {
                if (mode == ConnectionMode.UseExisting)
                {
                    if (parent != null)
                    {
                        connection = parent.Connection;
                    }
                    else
                    {
                        connection = CreateConnection();
                        connection.Open();
                        createdConnection = true;
                    }
                }
                else
                {
                    connection = CreateConnection();
                    createdConnection = true;
                }
            }
            return connection;
        }
    }
 
    /// <summary>
    /// Gets the current DataContext.
    /// </summary>
    /// <value>The DataContext.</value>
    public MyDbDataContext DataContext
    {
        get
        {
            if (context == null)
            {
                context = new MyDbDataContext(Connection)
                              {
                                  LoadOptions = loadOptionsBuilder.BuildDataLoadOptions()
                              };
            }
 
            return context;
        }
    }
 
    #endregion
 
    #region Constructors
 
    /// <summary>
    /// Initializes a new instance of the <see cref="MyDbDatabase"/> class.
    /// </summary>
    /// <param name="mode">The mode.</param>
    public MyDbDatabase(ConnectionMode mode)
    {
        this.mode = mode;
        parent = CurrentContext;
        CurrentContext = this;
        CreateDefaultDataLoadOptions();
    }
 
    /// <summary>
    /// Initializes a new instance of the <see cref="MyDbDatabase"/> class.
    /// </summary>
    public MyDbDatabase()
        : this(ConnectionMode.UseExisting)
    {
    }
 
    /// <summary>
    /// Initializes a new instance of the <see cref="MyDbDatabase"/> class.
    /// </summary>
    /// <param name="connection">The connection to use.</param>
    /// <remarks>This method uses ConnectionMode.CreateNew mode.</remarks>
    public MyDbDatabase(DbConnection connection)
        : this(ConnectionMode.CreateNew)
    {
        if (connection == null)
            throw new ArgumentNullException("connection");
 
        this.connection = connection;
        createdConnection = false;
    }
 
    #endregion Constructors  
 
    #region LoadOptions Methods
 
    /// <summary>
    /// Adds LoadWith option to DataLoadOptions.
    /// </summary>
    /// <param name="expr">The expression.</param>
    public void LoadWith(LambdaExpression expr)
    {
        if (context != null)
            throw new InvalidOperationException(
                "LoadOptions for DataContext are frozen and cannot be modified. Methods LoadWith, LoadWith<T>, AssociateWith, AssociateWith<T>, ClearLoadOptions cannot be used after DataContext property of EFSDatabase class has been accessed for the first time.");
        loadOptionsBuilder.Add(new LoadWith(expr));
    }
 
    /// <summary>
    /// Adds LoadWith option to DataLoadOptions.
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="expr">The expression.</param>
    public void LoadWith<T>(Expression<Func<T, object>> expr)
    {
        if (context != null)
            throw new InvalidOperationException(
                "LoadOptions for DataContext are frozen and cannot be modified. Methods LoadWith, LoadWith<T>, AssociateWith, AssociateWith<T>, ClearLoadOptions cannot be used after DataContext property of EFSDatabase class has been accessed for the first time.");
        loadOptionsBuilder.Add(new LoadWith<T>(expr));
    }
 
    /// <summary>
    /// Adds AssociateWith option to DataLoadOptions.
    /// </summary>
    /// <param name="expr">The expression.</param>
    public void AssociateWith(LambdaExpression expr)
    {
        if (context != null)
            throw new InvalidOperationException(
                "LoadOptions for DataContext are frozen and cannot be modified. Methods LoadWith, LoadWith<T>, AssociateWith, AssociateWith<T>, ClearLoadOptions cannot be used after DataContext property of EFSDatabase class has been accessed for the first time.");
        loadOptionsBuilder.Add(new AssociateWith(expr));
    }
 
    /// <summary>
    /// Adds AssociateWith option to DataLoadOptions.
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="expr">The expression.</param>
    public void AssociateWith<T>(Expression<Func<T, object>> expr)
    {
        if (context != null)
            throw new InvalidOperationException(
                "LoadOptions for DataContext are frozen and cannot be modified. Methods LoadWith, LoadWith<T>, AssociateWith, AssociateWith<T>, ClearLoadOptions cannot be used after DataContext property of EFSDatabase class has been accessed for the first time.");
        loadOptionsBuilder.Add(new AssociateWith<T>(expr));
    }
 
    /// <summary>
    /// Clears all currently set load options.
    /// </summary>
    public void ClearLoadOptions()
    {
        if (context != null)
            throw new InvalidOperationException(
                "LoadOptions for DataContext are frozen and cannot be modified. Methods LoadWith, LoadWith<T>, AssociateWith, AssociateWith<T>, ClearLoadOptions cannot be used after DataContext property of EFSDatabase class has been accessed for the first time.");
 
        loadOptionsBuilder.Clear();
    }
 
    #endregion
 
    #region IDisposable Members
 
    public void Dispose()
    {
        if (context != null)
            context.Dispose();
 
        if (CurrentContext != this)
            throw new InvalidOperationException(
                "MyDbDatabase object is disposed before dependant MyDbDatabase object(s). This indicates a programming error.");
        CurrentContext = parent;
        if (createdConnection && connection != null)
            connection.Dispose();
    }
 
    #endregion
 
    #region Custom methods
 
    private const string DefaultConnectionStringName = "ConsoleApplication1.Properties.Settings.DbConnectionString";
 
    /// <summary>
    /// Creates the connection.
    /// </summary>
    /// <returns>New connection.</returns>
    public DbConnection CreateConnection()
    {
        //connection is created here
        return new SqlConnection(
            ConfigurationManager.ConnectionStrings[
                DefaultConnectionStringName].ConnectionString);
    }
 
    /// <summary>
    /// Creates the default data load options.
    /// </summary>
    public void CreateDefaultDataLoadOptions()
    {
        //default DataLoadOptions go here
        loadOptionsBuilder.Add(new LoadWith<Dog>(d => d.Breed));
    }