Entity-FrameWork-DbContext-And-Dependency-Injection-And-DbContextScopeReviewed by کارشناسان.نت on April 22Rating:5

یکی از متداول ترین الگوهای امروزی برای رفع وابستگی به یک سرویس در یک Object وابسته به آن سرویس الگوی Dependency Injection می باشد . در این الگو , وابستگی به سایر کلاس ها , به صورت اتوماتیک به داخل سرویس مورد نظر تزریق می شود و نیاز به نمونه گیری و ساخت Object از ان وابستگی به صورت Explicit نیست . این الگو در مهندسی نرم افزار فوایدیی (Refactoring , کنترل LifeTime اشیاء , ...) دارد که مرتبط با موضوع این مقاله نیست , اما نحوه صحیح نمونه گیریی از DbContext با الگوی Dependency Injection در انواع برنامه ها اعم از وب یا دسکتاپ موضوع اصلی این مقاله می باشد. 

Terminology : در هر جای این مقاله که از سرویس یاد شده است منظور متدهای موجود در یک Class یا Controller یا هر چیز دیگری می باشد

با مقداری جستجو در IOC های موجود در اینترنت , 99% انها از روش تزریق مستقیم DbContext با طول عمر PerWebRequest ستفاده کرده اند , اگر چه در نگاه اول و در بسیاری از برنامه های ساده این روش جواب می دهد و بدون هیچ مشکلی اجرا خواهد شد اما با نگاهی عمیق تر و دقیق تر , بالخصوص در برنامه های بزرگتر و پیچیده تر , این روش ناکارامد می باشد .

 

public class UserService : IUserService  
{
    private readonly IUserRepository _userRepository;

    public UserService(IUserRepository userRepository)
    {
        if (userRepository == null) throw new ArgumentNullException("userRepository");
        _userRepository = userRepository;
    }

    public void MarkUserAsPremium(Guid userId)
    {
        var user = _userRepository.Get(context, userId);
        user.IsPremiumUser = true;
    }
}

public class UserRepository : IUserRepository  
{
    private readonly MyDbContext _context;

    public UserRepository(MyDbContext context)
    {
        if (context == null) throw new ArgumentNullException("context");
        _context = context;
    }

    public User Get(Guid userId)
    {
        return _context.Set<User>().Find(userId);
    }
}

 روش ذکر شده از یک پیش فرض اشتباه استنباط شده است و آن پیش فرض عبارت است از

"A 1 Web Request = A 1 Bussines Transaction"

  اما در بسیاری از موارد این پیش فرض صحیح نمی باشد .

این پیش فرض غلط باعث بروز 3 مشکل اساسی می شود :

1 - با توجه به مشترک بودن نمونه DbContext در سراسر یک Request , اگر در ان Request چند Transaction وجود داشته باشد , متد SaveChanges کجا صدا زده می شود , کدام Transaction اول به پایان میر سد و کدام دیرتر . 

2 - اگر شما از Multi Thread استفاده کرده باشین , یک نمونه مشترک از DbContext را در بین چند Thread به اشتراک می گذارید و همانطور که مطلع هستین DbContext هیچ گارانتی برای Thread Safe بودن ندارد , شاید بگویید در برنامه من از Thread استفاده نمی شود , اما به یاد داشته باشین , HttpModule و HttpHandler خودشان نوعی Multi Thread می باشند .

3 - اگر شما از Async Programing استفاده کرده باشین و همچنین از Multi Threading , هر استفاده از Wait , تنها در Thread خود شناخته می شود و در نتیجه گویا شما 2 کوئری Async همزمان را صدا زده باشین و در نتیجه پیام خطای معروف زیر را دریافت خواهید کرد .

A second operation started on this context before a previous asynchronous operation completed. Use 'await' to ensure that any asynchronous operations have completed before calling another method on this context. Any instance members are not guaranteed to be thread safe

شما می توانید به موارد بالا Paralel Programing را هم اضافه کنید .

اما راه حل مشکلات ذکر شده و راه بهتر و Felxible تر و اسان تر برای مدیریت DbContext , کامپوننتی است با نام "DbContextScope" .  سورس این کامپوننت در GitHub موجود می باشد .

اگر شما با TransactionScope اشنا باشید , روش استفاده از DbContextScope را بلد خواهید بود . تنها تفاوت این است که  DbContextScope  , نمونه DbContext را می سازد و مدیریت می کند اما TransactionScope  با نمونه Transaction دیتابیس کار می کند ولی مشابه DbContextScope , TransactionScope می تواند رفتار Nested داشته باشد و یا این رفتارش را غیر فعال کرد و یا به صورت Async از ان استفاده کرد .

اینترفیس DbContext به شکل زیر است :

public interface IDbContextScope : IDisposable  
{
    void SaveChanges();
    Task SaveChangesAsync();

    void RefreshEntitiesInParentScope(IEnumerable entities);
    Task RefreshEntitiesInParentScopeAsync(IEnumerable entities);

    IDbContextCollection DbContexts { get; }
}

وظیفه اصلی DbContextScpe مدیریت نمونه DbContext در یک بلوک کد می باشد. شما می توانید مستقیما" از DbContext نمونه بگیرید یا از IDbContextScopeFactory برای ساخت نمونه DbContext با استفاده از تنظیمات متداول استفاده کنید .

public interface IDbContextScopeFactory  
{
    IDbContextScope Create(DbContextScopeOption joiningOption = DbContextScopeOption.JoinExisting);
    IDbContextReadOnlyScope CreateReadOnly(DbContextScopeOption joiningOption = DbContextScopeOption.JoinExisting);

    IDbContextScope CreateWithTransaction(IsolationLevel isolationLevel);
    IDbContextReadOnlyScope CreateReadOnlyWithTransaction(IsolationLevel isolationLevel);

    IDisposable SuppressAmbientContext();
}

نحوه استفاده :

یک متد متداول سرویس شما به هنگام استفاده از DbContext به شکل زیر می باشد :

public void MarkUserAsPremium(Guid userId)  
{
    using (var dbContextScope = _dbContextScopeFactory.Create())
    {
        var user = _userRepository.Get(userId);
        user.IsPremiumUser = true;
        dbContextScope.SaveChanges();
    }
}

داخل DbContextScope شما به دو روش می توانید به نمونه DbContext ایی که توسط DbContextScope مدیریت می شود دست پیدا کنید , یا با استفاده از DbContextScope .DbContext به ان دست یابید

public void SomeServiceMethod(Guid userId)  
{
    using (var dbContextScope = _dbContextScopeFactory.Create())
    {
        var user = dbContextScope.DbContexts.Get<MyDbContext>.Set<User>.Find(userId);
        [...]
        dbContextScope.SaveChanges();
    }
}

اما این روش تنها در متدی میسر است که DbContextScope را می سازد , اگر شما می خواهید از نمونه DbContext تان در هر جای دیگری (مثلا در کلاس Repository )خود استفاده کنید , شما باید از IAmbientDbContextLocator کمک بگیرید

public class UserRepository : IUserRepository  
{
    private readonly IAmbientDbContextLocator _contextLocator;

    public UserRepository(IAmbientDbContextLocator contextLocator)
    {
        if (contextLocator == null) throw new ArgumentNullException("contextLocator");
        _contextLocator = contextLocator;
    }

    public User Get(Guid userId)
    {
        return _contextLocator.Get<MyDbContext>.Set<User>().Find(userId);
    }
}

DbContext به صورت Lazy ساخته می شود و DbcontextScope مراقب است تا در Scope تنها یک نمونه از DbContext موجود باشد .

Scope های تو در تو

DbContextScope می تواند به صورت تو در تو باشد در مثال زیر یک متد یک کاربر را به عنوان کاربر Premium ذخیره می کند :

public void MarkUserAsPremium(Guid userId)  
{
    using (var dbContextScope = _dbContextScopeFactory.Create())
    {
        var user = _userRepository.Get(userId);
        user.IsPremiumUser = true;
        dbContextScope.SaveChanges();
    }
}

حال اگر نیاز باشد یک گروه از کاربران به کاربران Premium تبدیل شوند , می توان از روش زیر استفاده کرد :

public void MarkGroupOfUsersAsPremium(IEnumerable<Guid> userIds)  
{
    using (var dbContextScope = _dbContextScopeFactory.Create())
    {
        foreach (var userId in userIds)
        {
            // The child scope created by MarkUserAsPremium() will
            // join our scope. So it will re-use our DbContext instance(s)
            // and the call to SaveChanges() made in the child scope will
            // have no effect.
       // تغییرات در این قسمت ذخیره نمی شود
         MarkUserAsPremium(userId);
        }

        // Changes will only be saved here, in the top-level scope,
        // ensuring that all the changes are either committed or
        // rolled-back atomically.
    // تغییرات در این قسمت ذخیره می شود.
     dbContextScope.SaveChanges();
    }
}

این متد فقط برای نمایش چگونگی کارکرد DbContextScope می باشد و صد البته متد تمرینی است .

Read Only Scope

فرض کنید متد سرویس شما ReadOnly یاشد , در نتیجه هرچند صدا زدن ()SaveChanges کار دلپذیریی نمی باشد , اما صدا نکردن ان به مراتب بدتر است بنابر دلایل زیر :

1 - Code Review و پشتیبانی برنامه بسیار مشکل می شود (شما فراخونی ()SaveChanges را عمدا" انجام نداده اید یا فراموش کرده اید ؟!)

2 - اگر شما یک Transaction دیتابیسی را فراخونی کرده باشین , صدا نزدن ()SaveChanges باعث Roll Back شدن Transaction می گردد و صد البته سیستم های مانیتورینگ دیتابیس , RollBack را به عنوان خطای Application فرض می کنند , در نتیجه داشتن RollBack جعلی کار درستی نمی باشد .

کلاس DbContextReadOnlyScope برای رفع این مشکل می باشد. اینتر فیس ان به شکل زیر می باشد :

public interface IDbContextReadOnlyScope : IDisposable  
{
    IDbContextCollection DbContexts { get; }
}

و طرز استفاده از آن :

public int NumberPremiumUsers()  
{
    using (_dbContextScopeFactory.CreateReadOnly())
    {
        return _userRepository.GetNumberOfPremiumUsers();
    }
}

پشتیبانی از Async

DbContextScope به همان نحوی که شما انتظار دارید با Async کار می کند :

public async Task RandomServiceMethodAsync(Guid userId)  
{
    using (var dbContextScope = _dbContextScopeFactory.Create())
    {
        var user = await _userRepository.GetAsync(userId);
        var orders = await _orderRepository.GetOrdersForUserAsync(userId);

        [...]

        await dbContextScope.SaveChangesAsync();
    }
}

در مثال بالا متد () OrderRepository.GetOrderForUserAsync قابلیت دیدن و دسترسی به همان نمونه DbContext ایی را دارد که DbContextScope در یک Thread جداگانه ساخته است .

شما برای جزئیات بیشتر می توانید به مطلب با عنوان Managing DbContext the right way with Entity Framework 6: an in-depth guide که توسط جناب مهندس مهدی قادری نوشته شده است بپردازین .