تا
قسمت قبل، روشی را که برای کار با EF-Core درنظر گرفتیم، روش متداول کار با آن، در برنامههای ASP.NET Core Web API بود؛ یعنی این روش با برنامههای مبتنی بر Blazor WASM که از دو قسمت مجزای Web API سمت سرور و Web Assembly سمت کلاینت تشکیل شدهاند، به خوبی جواب میدهد؛ اما ... با Blazor Server یکپارچه که تمام قسمتهای مدیریتی آن سمت سرور رخ میدهند، خیر! در این مطلب، دلایل این موضوع را به همراه ارائه راهحلی، بررسی خواهیم کرد.
طول عمر سرویسها، در برنامههای Blazor Server متفاوت هستند
هنگامیکه با یک ASP.NET Core Web API متداول کار میکنیم، درخواستهای HTTP رسیده، از میانافزارهای موجود رد شده و پردازش میشوند. اما هنگامیکه با Blazor Server کار میکنیم، به علت وجود یک اتصال دائم SignalR که عموما از نوع Web socket است، دیگر درخواست HTTP وجود ندارد. تمام رفت و برگشتهای برنامه به سرور و پاسخهای دریافتی، از طریق Web socket منتقل میشوند و نه درخواستها و پاسخهای متداول HTTP.
این روش پردازشی، اولین تاثیری را که بر روی رفتار یک برنامه میگذارد، تغییر طول عمر سرویسهای آن است. برای مثال در برنامههای Web API، طول عمر درخواستها، از نوع Scoped هستند و با شروع پردازش یک درخواست، سرویسهای مورد نیاز وهله سازی شده و در پایان درخواست، رها میشوند.
این مساله در حین کار با EF-Core نیز بسیار مهم است؛ از این جهت که در برنامههای Web API نیز EF-Core و DbContext آن، به صورت سرویسهایی با طول عمر Scoped تعریف میشوند. برای مثال زمانیکه یک چنین تعریفی را در برنامه داریم:
services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(connectionString));
امضای واقعی متد AddDbContext مورد استفادهی فوق، به صورت زیر است:
public static IServiceCollection AddDbContext<TContext>(
[NotNullAttribute] this IServiceCollection serviceCollection,
[CanBeNullAttribute] Action<DbContextOptionsBuilder> optionsAction = null,
ServiceLifetime contextLifetime = ServiceLifetime.Scoped,
ServiceLifetime optionsLifetime = ServiceLifetime.Scoped) where TContext : DbContext;
همانطور که مشاهده میکنید، طول عمرهای پیشفرض تعریف شدهی در اینجا، از نوع Scoped هستند. یعنی زمانیکه سرویسهای ApplicationDbContext را از طریق سیستم تزریق وابستگیهای برنامه دریافت میکنیم، در ابتدای یک درخواست Web API، به صورت خودکار وهله سازی شده و در پایان درخواست رها میشوند. به این ترتیب به ازای هر درخواست رسیده، وهلهی متفاوتی از DbContex را دریافت میکنیم که با وهلهی استفاده شدهی در درخواست قبلی، یکی نیست.
اما زمانیکه مانند یک برنامهی مبتنی بر Blazor Server، دیگر HTTP Requests متداولی را نداریم، چطور؟ در این حالت زمانیکه یک اتصال SignalR برقرار شد، وهلهای از DbContext که در اختیار برنامهی Blazor Server قرار میگیرد، تا زمانیکه کاربر این اتصال را به نحوی قطع نکرده (مانند بستن کامل مرورگر و یا ریفرش صفحه)، ثابت باقی خواهد ماند. یعنی به ازای هر اتصال SignalR، طول عمر ServiceLifetime.Scoped پیشفرض تعریف شده، همانند یک وهلهی با طول عمر Singleton عمل میکند. در این حالت تمام صفحات و کامپوننتهای یک برنامهی Blazor Server، از یک تک وهلهی مشخص DbContext که در ابتدای کار دریافت کردهاند، کار میکنند و از آنجائیکه DbContext به صورت thread-safe کار نمیکند، این تک وهله مشکلات زیادی را ایجاد خواهد کرد که یک نمونه از آنرا در عمل، در پایان
قسمت قبل مشاهده کردید:
«اگر برنامه را اجرا کرده و سعی در حذف یک ردیف کنیم، به خطای زیر میرسیم و یا حتی اگر کاربر شروع کند به کلیک کردن سریع در قسمتهای مختلف برنامه، باز هم این خطا مشاهده میشود:
An exception occurred while iterating over the results of a query for context type 'BlazorServer.DataAccess.ApplicationDbContext'.
System.InvalidOperationException: A second operation was started on this context before a previous operation completed.
This is usually caused by different threads concurrently using the same instance of DbContext.
For more information on how to avoid threading issues with DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913.
عنوان میکند که متد OnConfirmDeleteRoomClicked، بر روی ترد دیگری نسبت به ترد اولیهای که DbContext بر روی آن ایجاد شده، در حال اجرا است و چون DbContext برای یک چنین سناریوهایی، thread-safe نیست، اجازهی استفادهی از آنرا نمیدهد.»
هر درخواست Web API نیز بر روی یک ترد جداگانه اجرا میشود؛ اما چون ابتدا و انتهای درخواستها مشخص است، طول عمر Scoped، در ابتدای درخواست شروع شده و در پایان آن رها سازی میشود. به همین جهت استثنائی را که در اینجا مشاهده میکنید، در برنامههای Web API شاید هیچگاه مشاهده نشود.
معرفی DbContextFactory در EF Core 5x
همواره باید طول عمر DbContext را تا جای ممکن، کوتاه نگه داشت. مشکل فعلی ما، Singleton رفتار کردن DbContextها (داشتن طول عمر طولانی) در برنامههای Blazor Server هستند. یک چنین رفتاری را شاید در برنامههای دسکتاپ هم پیشتر مشاهده کرده باشید. برای مثال در برنامههای دسکتاپ WPF، تا زمانیکه یک فرم باز است، Context ایجاد شدهی در آن هم برقرار است و Dispose نمیشود. در یک چنین حالتهایی، عموما Context را در زمان نیاز، ایجاد کرده و پس از پایان آن کار کوتاه، Context را رها میکنند. به همین جهت نیاز به DbContext Factory ای وجود دارد که بتواند یک چنین پیاده سازیهایی را میسر کند و خوشبختانه از زمان EF Core 5x، یک چنین امکانی خصوصا برای برنامههای Blazor Server تحت عنوان DbContextFactory
ارائه شدهاست که به عنوان راه حل استاندارد دسترسی به DbContext در اینگونه برنامهها مورد استفاده قرار میگیرد.
برای کار با DbContextFactory، اینبار در فایل BlazorServer.App\Startup.cs، بجای استفاده از services.AddDbContext، از متد AddDbContextFactory استفاده میشود:
public void ConfigureServices(IServiceCollection services)
{
var connectionString = Configuration.GetConnectionString("DefaultConnection");
//services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(connectionString));
services.AddDbContextFactory<ApplicationDbContext>(options => options.UseSqlServer(connectionString));
سپس باید دقت داشت که روش استفادهی از آن، نسبت به کار مستقیم با ApplicationDbContext، کاملا متفاوت است. هدف از DbContextFactory، ساخت دستی Context در زمان نیاز و سپس Dispose صریح آن است. بنابراین طول عمر Context دریافت شدهی توسط آن باید توسط برنامه نویس مدیریت شود و به صورت خودکار توسط IoC Container برنامه مدیریت نخواهد شد. در این حالت دو روش استفادهی از آن در کامپوننتهای برنامههای Blazor Server، پیشنهاد میشود.
روش اول کار با DbContextFactory در کامپوننتهای Blazor Server : وهله سازی از نو، به ازای هر متد
در این روش پس از ثبت AddDbContextFactory در فایل Startup برنامه مانند مثال فوق، ابتدا سرویس IDbContextFactory که به ApplicationDbContext اشاره میکند به ابتدای کامپوننت تزریق میشود:
@inject IDbContextFactory<ApplicationDbContext> DbFactory
سپس در هر جائی که نیاز به وهلهای از ApplicationDbContext است، آنرا به صورت دستی وهله سازی کرده و همانجا هم Dispose میکنیم:
private async Task DeleteImageAsync()
{
using var context = DbFactory.CreateDbContext();
var image = await context.HotelRoomImages.FindAsync(1);
// ...
}
در اینجا یکی متدهای یک کامپوننت فرضی را مشاهده میکند که از DbFactory تزریق شده استفاده کرد و سپس با استفاده از متد ()CreateDbContext، وهلهی جدیدی از ApplicationDbContext را ایجاد میکند. همچنین در همان سطر، وجود عبارت using نیز مشاهده میشود. یعنی در پایان کار این متد، context ایجاد شده حتما Dispose شده و طول عمر کوتاهی خواهد داشت.
روش دوم کار با DbContextFactory در کامپوننتهای Blazor Server : یکبار وهله سازی Context به ازای هر کامپوننت
در این روش میتوان طول عمر Context را معادل طول عمر کامپوننت تعریف کرد که مزیت استفادهی از Change tracking موجود در EF-Core را به همراه خواهد داشت. در این حالت کامپوننتهای Blazor Server، شبیه به فرمهای برنامههای دسکتاپ عمل میکنند:
@implements IDisposable
@inject IDbContextFactory<ApplicationDbContext> DbFactory
@code
{
private ApplicationDbContext Context;
protected override async Task OnInitializedAsync()
{
Context = DbFactory.CreateDbContext();
await base.OnInitializedAsync();
}
private async Task DeleteImageAsync()
{
var image = await Context.HotelRoomImages.FindAsync(1);
// ...
}
public void Dispose()
{
Context.Dispose();
}
}
- در اینجا همانند روش اول، کار با تزریق IDbContextFactory شروع میشود
- اما بجای اینکه به ازای هر متد، کار فراخوانی DbFactory.CreateDbContext صورت گیرد، یکبار در آغاز کار کامپوننت و در روال رویدادگردان OnInitializedAsync، کار وهله سازی Context کامپوننت انجام شده و از این تک Context در تمام متدهای کامپوننت استفاده خواهد شد.
- در این حالت کار Dispose خودکار این Context به متد Dispose نهایی کل کامپوننت واگذار شدهاست. برای اینکه این متد فراخوانی شود، نیاز است در ابتدای تعاریف کامپوننت، از دایرکتیو implements IDisposable@ استفاده کرد.
سؤال: اگر سرویسی از ApplicationDbContext تزریق شدهی در سازندهی خود استفاده میکند، چکار باید کرد؟
برای نمونه سرویسهای از پیش تعریف شدهی ASP.NET Core Identity، در سازندهی خود از ApplicationDbContext استفاده میکنند و نه از IDbContextFactory. در این حالت برای تامین ApplicationDbContextهای تزریق شده، فقط کافی است از روش زیر استفاده کنیم:
services.AddScoped<ApplicationDbContext>(serviceProvider =>
serviceProvider.GetRequiredService<IDbContextFactory<ApplicationDbContext>>().CreateDbContext());
در این حالت به ازای هر Scope تعریف شدهی در برنامه، جهت دسترسی به ApplicationDbContext از طریق سیستم تزریق وابستگیها، کار فراخوانی DbFactory.CreateDbContext به صورت خودکار انجام خواهد شد.
سؤال: روش پیاده سازی سرویسهای یک برنامه Blazor Server به چه صورتی باید تغییر کند؟
تا اینجا روشهایی که برای استفاده از IDbContextFactory معرفی شدند (که
روشهای رسمی و توصیه شدهی اینکار نیز هستند)، فرض را بر این گذاشتهاند که ما قرار است تمام منطق تجاری کار با بانک اطلاعاتی را داخل همان متدهای کامپوننتها انجام دهیم (این روش برنامه نویسی، بسیار مورد علاقهی مایکروسافت است و در تمام مثالهای رسمی آن به صورت ضمنی توصیه میشود!). اما اگر همانند مثالی که تاکنون در این سری بررسی کردیم، نخواهیم اینکار را انجام دهیم و علاقمند باشیم تا این منطق تجاری را به سرویسهای مجزایی، با مسئولیتهای مشخصی انتقال دهیم، روش استفادهی از IDbContextFactory چگونه خواهد بود؟
در این حالت از ترکیب روش دوم مطرح شدهی استفاده از IDbContextFactory که به همراه مزیت دسترسی کامل به Change Tracking توکار EF-Core و پیاده سازی الگوی واحد کار است و وهله سازی خودکار ApplicationDbContext که معرفی شد، استفاده خواهیم کرد؛ به این صورت:
الف) تمام سرویسهای EF-Core یک برنامهی Blazor Server باید اینترفیس IDisposable را پیاده سازی کنند.
این مورد برای سرویسهای پروژههای Web API، ضروری نیست؛ چون طول عمر Context آنها توسط خود IoC Container مدیریت میشود؛ اما در برنامههای Blazor Server، مطابق توضیحاتی که ارائه شد، خودمان باید این طول عمر را مدیریت کنیم.
بنابراین به پروژهی سرویسهای برنامه مراجعه کرده و هر سرویسی که ApplicationDbContext تزریق شدهای را در سازندهی خود میپذیرد، یافته و تعریف اینترفیس آنرا به صورت زیر تغییر میدهیم:
public interface IHotelRoomService : IDisposable
{
// ...
}
public interface IHotelRoomImageService : IDisposable
{
// ...
}
سپس باید اینترفیسهای IDisposable را پیاده سازی کرد که روش مورد پذیرش code analyzerها در این زمینه، رعایت الگوی زیر، دقیقا به همین شکل است و باید از دو متد تشکیل شود:
public class HotelRoomService : IHotelRoomService
{
private bool _isDisposed;
// ...
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (!_isDisposed)
{
try
{
if (disposing)
{
_dbContext.Dispose();
}
}
finally
{
_isDisposed = true;
}
}
}
}
این الگو را به همین شکل برای سرویس HotelRoomImageService نیز پیاده سازی میکنیم.
ب) Dispose دستی تمام سرویسها، در کامپوننتهای مرتبط
در ادامه تمام کامپوننتهایی را که از سرویسهای فوق استفاده میکنند یافته و ابتدا دایرکتیو implements IDisposable@ را به ابتدای آنها اضافه میکنیم. سپس متد Dispose آنها را جهت فراخوانی متد Dispose سرویسهای فوق، تکمیل خواهیم کرد:
بنابراین ابتدا به فایل BlazorServer\BlazorServer.App\Pages\HotelRoom\HotelRoomUpsert.razor مراجعه کرده و تغییرات زیر را اعمال میکنیم:
@page "/hotel-room/create"
@page "/hotel-room/edit/{Id:int}"
@implements IDisposable
// ...
@code
{
// ...
public void Dispose()
{
HotelRoomImageService.Dispose();
HotelRoomService.Dispose();
}
}
و همچنین به کامپوننت BlazorServer\BlazorServer.App\Pages\HotelRoom\HotelRoomList.razor مراجعه کرده و آنرا به صورت زیر جهت Dispose دستی سرویسها، تکمیل میکنیم:
@page "/hotel-room"
@implements IDisposable
// ...
@code
{
// ...
public void Dispose()
{
HotelRoomService.Dispose();
}
}
مشکل! اینبار خطای dispose شدن context را دریافت میکنیم! System.ObjectDisposedException: Cannot access a disposed context instance.
A common cause of this error is disposing a context instance that was resolved from dependency injection and then
later trying to use the same context instance elsewhere in your application. This may occur if you are calling 'Dispose'
on the context instance, or wrapping it in a using statement. If you are using dependency injection, you should let the
dependency injection container take care of disposing context instances.
Object name: 'ApplicationDbContext'.
هم برنامههای Blazor WASM و هم برنامههای Blazor Server از مفهوم طول عمرهای تنظیم شدهی سرویسها پشتیبانی نمیکنند! در هر دوی اینها اگر سرویسی را با طول عمر Scoped تنظیم کردیم، رفتار آن همانند سرویسهای Singleton خواهد بود. تنها زمانی رفتارهای Scoped و یا Transient پشتیبانی میشوند که درخواست HTTP ای رخ داده باشد که این مورد خارج است از طول عمر یک برنامهی Blazor WASM و همچنین اتصال SignalR برنامههای Blazor Server. فقط قسمتهایی از برنامهی Blazor Server که با مدل قبلی Razor pages طراحی شدهاند، چون سبب شروع یک درخواست HTTP معمولی میشوند، همانند برنامههای متداول ASP.NET Core رفتار میکنند و در این حالت طول عمرهای غیر Singleton مفهوم پیدا میکنند.
مشکلی که در اینجا رخ داده این است که سرویسهایی را داریم با طول عمر به ظاهر Scoped که یکی از وابستگیهای آنها را به صورت دستی Dispose کردهایم. چون طول عمر Scoped در اینجا وجود ندارد و طول عمرها در اصل Singleton هستند، هربار که سرویس مدنظر مجددا درخواست شود، همان وهلهی ابتدایی که اکنون یکی از وابستگیهای آن Dispose شده، در اختیار برنامه قرار میگیرد.
پس از این تغییرات، اولین باری که برنامه را اجرا میکنیم، لیست اتاقها به خوبی نمایش داده میشوند و مشکلی نیست. بعد در همین حال و در همین صفحه، اگر بر روی دکمهی افزودن یک اتاق جدید کلیک کنیم، اتفاقی که رخ میدهد، فراخوانی متد Dispose کامپوننت لیست اتاقها است (بر روی آن یک break-point قرار دهید). بنابراین متد Dispose یک کامپوننت، با هدایت به یک مسیر دیگر، به صورت خودکار فراخوانی میشود. در این حالت Context برنامه Dispose شده و در کامپوننت ثبت یک اتاق جدید دیگر، در دسترس نخواهد بود؛ چون IHotelRoomService مورد استفاده مجددا وهله سازی نمیشود و از همان وهلهای که بار اول ایجاد شده، استفاده خواهد شد.
بنابراین سؤال اینجا است که چگونه میتوان سیستم تزریق وابستگیها را وادار کرد تا تمام سرویسهای تزریق شدهی به سازندههای سرویسهای HotelRoomService و HotelRoomImageService را مجددا وهله سازی کند و سعی نکند از همان وهلههای قبلی استفاده کند؟
پاسخ: یک روش این است که IHotelRoomImageService را خودمان به ازای هر کامپوننت به صورت دستی در روال رویدادگردان OnInitializedAsync وهله سازی کرده و DbFactory.CreateDbContext جدیدی را مستقیما به سازندهی آن ارسال کنیم. در این حالت مطمئن خواهیم شد که این وهله، جای دیگری به اشتراک گذاشته نمیشود:
@code
{
private IHotelRoomImageService HotelRoomImageService;
protected override async Task OnInitializedAsync()
{
HotelRoomImageService = new HotelRoomImageService(DbFactory.CreateDbContext(), mapper);
await base.OnInitializedAsync();
}
private async Task DeleteImageAsync()
{
await HotelRoomImageService.DeleteAsync(1);
// ...
}
public void Dispose()
{
HotelRoomImageService.Dispose();
}
}
هرچند این روش کار میکند، اما در زمان استفاده از IoC Containerها قرار نیست کار انجام newها را خودمان به صورت دستی انجام دهیم و بهتر است مدیریت این مساله به آنها واگذار شود.
وادار کردن Blazor Server به وهله سازی مجدد سرویسهای کامپوننتها
بنابراین مشکل ما Singleton رفتار کردن سرویسها، در برنامههای Blazor است. برای مثال در برنامههای Blazor Server، تا زمانیکه اتصال SignalR برنامه برقرار است (مرورگر بسته نشده، برگهی جاری بسته نشده و یا کاربر صفحه را ریفرش نکرده)، هیچ سرویسی دوباره وهله سازی نمیشود.
برای رفع این مشکل، امکان Scoped رفتار کردن سرویسهای یک کامپوننت نیز در نظر گرفته شدهاند. برای نمونه کدهای کامپوننت HotelRoomList.razor را به صورت زیر تغییر میدهیم:
@page "/hotel-room"
@*@implements IDisposable*@
@*@inject IHotelRoomService HotelRoomService*@
@inherits OwningComponentBase<IHotelRoomService>
با استفاده از
دایرکتیو جدید inherits OwningComponentBase@ میتوان میدان دید یک سرویس را به طول عمر کامپوننت جاری محدود کرد. هربار که این کامپوننت نمایش داده میشود، وهله سازی شده و هربار که به کامپوننت دیگری هدایت میشویم، به صورت خودکار سرویس مورد استفاده را Dispose میکند. بنابراین در اینجا دیگر نیازی به ذکر دایرکتیو implements IDisposable@ نیست.
چند نکته:
- فقط یکبار به ازای هر کامپوننت میتوان از دایرکتیو inherits استفاده کرد.
- زمانیکه طول عمر سرویسی را توسط OwningComponentBase مدیریت میکنیم، در حقیقت یک کلاس پایه را برای آن کامپوننت درنظر گرفتهایم که به همراه یک خاصیت عمومی ویژه، به نام Service و از نوع سرویس مدنظر ما است. در این حالت یا میتوان از خاصیت Service به صورت مستقیم استفاده کرد و یا میتوان به صورت زیر، همان کدهای قبلی را داشت و هربار که نیازی به HotelRoomService بود، آنرا به خاصیت عمومی Service هدایت کرد:
@code
{
private IHotelRoomService HotelRoomService => Service;
- اگر نیاز به بیش از یک سرویس وجود داشت، چون نمیتوان بیش از یک inherits را تعریف کرد، میتوان از نمونهی غیرجنریک OwningComponentBase استفاده کرد:
@page "/preferences"
@using Microsoft.Extensions.DependencyInjection
@inherits OwningComponentBase
@code {
private IHotelRoomService HotelRoomService { get; set; }
private IHotelRoomImageService HotelRoomImageService { get; set; }
protected override void OnInitialized()
{
HotelRoomService = ScopedServices.GetRequiredService<IHotelRoomService>();
HotelRoomImageService = ScopedServices.GetRequiredService<IHotelRoomImageService>();
}
}
در این حالت کلاس پایهی OwningComponentBase، به همراه خاصیت جدید ScopedServices است که با فراخوانی متد GetRequiredService در روال رویدادگردان OnInitialized بر روی آن، سبب وهله سازی Scoped سرویس مدنظر خواهد شد. نمونهی جنریک آن، تمام این موارد را در پشت صحنه انجام میدهد و کار کردن با آن سادهتر و خلاصهتر است.
خلاصهی بحث جاری در مورد روش مدیریت DbContext برنامههای Blazor Server:
- بجای services.AddDbContext متداول، باید از AddDbContextFactory استفاده کرد:
services.AddDbContextFactory<ApplicationDbContext>(options => options.UseSqlServer(connectionString));
services.AddScoped<ApplicationDbContext>(serviceProvider =>
serviceProvider.GetRequiredService<IDbContextFactory<ApplicationDbContext>>().CreateDbContext());
- تمام سرویسهایی که از ApplicationDbContext استفاده میکنند، باید به همراه پیاده سازی Dispose آن نیز باشند؛ چون Scope یک سرویس، معادل طول عمر اتصال SignalR برنامه است و مدام وهله سازی نمیشود. در این حالت باید وهله سازی و Dispose آنرا دستی مدیریت کرد.
- کامپوننتهای برنامه، سرویسهایی را که باید Scoped عمل کنند، دیگر نباید از طریق تزریق مستقیم آنها دریافت کنند؛ چون در این حالت همواره به همان وهلهای که در ابتدای کار ایجاد شده، میرسیم:
@inject IHotelRoomService HotelRoomService
این دریافت باید با استفاده از کلاس پایه OwningComponentBase صورت گیرد:
@inherits OwningComponentBase<IHotelRoomService>
تا عملیات فراخوانی خودکار ScopedServices.GetRequiredService (دریافت وهلهی جدید Scoped) و همچنین Dispose خودکار آنها را به ازای هر کامپوننت مجزا، مدیریت کند.
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید: Blazor-5x-Part-19.zip