فرض کنید یک صفحهی Blazor SSR، از سه کامپوننت منوی سمت راست، محتوای اصلی صفحه و فوتر سایت که به همراه متنی است، تشکیل شدهاست. منوی سمت راست، به همراه لینکهاییاست که آمار آنها را نیز نمایش میدهد و این اطلاعات را از بانک اطلاعاتی، به کمک EF-Core دریافت میکند. فوتر صفحه، سال شروع به کار و نام برنامه را از بانک اطلاعاتی دریافت میکند و محتوای اصلی صفحه نیز از بانک اطلاعاتی دریافت میشود. پس از تکمیل این سه کامپوننت مجزا، اگر برنامه را اجرا کنید، بلافاصله با خطای زیر مواجه میشوید:
A second operation started on this context before a previous operation completed
مشکل کجاست؟! مشکل اینجاست که تنها یک نمونه از DbContext، در طول درخواست جاری رسیده، بین سه کامپوننت جاری برنامه به اشتراک گذاشته میشود (به سازندهی سرویسهای مرتبط تزریق میشود) و ... در Blazor SSR، پردازش کامپوننتهای یک صفحه، به صورت موازی و همزمان انجام میشوند؛ یعنی ترتیبی نیست. اگر ابتدا کامپوننت منو، بعد محتوای صفحه و در آخر فوتر، رندر میشدند، هیچگاه پیام فوق را مشاهده نمیکردیم؛ اما ... هر سه کامپوننت، با هم و همزمان رندر میشوند و سپس نتیجهی نهایی در Response درج خواهد شد. یعنی یک DbContext بین چندین ترد به اشتراک گذاشته میشود که چنین حالتی توسط EF-Core پشتیبانی نمیشود و مجاز نیست.
روش مواجه شدن با یک چنین حالتهایی، نمونه سازی مجزای DbContext به ازای هر کامپوننت است که نمونهای از آنرا پیشتر در مطلب «نکات ویژهی کار با EF-Core در برنامههای Blazor Server» مشاهده کردهاید. در این مطلب، راهحل دیگری برای اینکار ارائه میشود که سادهتر است و نیازی به تغییرات آنچنانی در کدهای کامپوننتها و کل برنامه ندارد.
استفاده از کلاس پایهی OwningComponentBase برای نمونه سازی مجدد DbContext بهازای هر کامپوننت
زمانیکه در برنامههای Blazor SSR از روش استاندارد زیر برای دسترسی به سرویسهای مختلف برنامه استفاده میکنیم:
@inject IHotelRoomService HotelRoomService
طول عمر دریافتی سرویس، دقیقا بر اساس طول عمر اصلی تعریف شدهی آن عمل میکند (شبیه به برنامههای ASP.NET Core). یعنی برای مثال اگر Scoped باشد، DbContext تزریق شدهی در آن هم Scoped است و این DbContext، بین تمام کامپوننتهای در حال پردازش موازی در طول یک درخواست، بهاشتراک گذاشته میشود که مطلوب ما نیست. ما میخواهیم بتوانیم به ازای هر کامپوننت مجزای صفحه، یک DbContext جدید داشته باشیم. یعنی باید بتوانیم خودمان این سرویس Scoped را نمونه سازی کنیم و نه اینکه آنرا مستقیما از سیستم تزریق وابستگیها دریافت کنیم.
بنابراین اگر بخواهیم قسمتهای مختلف برنامه را تغییر ندهیم و همان تعاریف ابتدایی services.AddDbContext و Scoped تعریف کردن سرویسهای برنامه بدون تغییر باقی بمانند (و از IDbContextFactory و موارد مشابه دیگر مطلب «نکات ویژهی کار با EF-Core در برنامههای Blazor Server» هم استفاده نکنیم)، باید جایگزینی را برای نمونه سازی سرویسها ارائه دهیم. به همین جهت در ابتدا، یک ویژگی جدیدی را به صورت زیر تعریف میکنیم:
[AttributeUsage(AttributeTargets.Property)] public sealed class InjectComponentScopedAttribute : Attribute { }
تا بتوانیم بجای:
@inject IHotelRoomService HotelRoomService
بنویسیم:
[InjectComponentScoped] internal IHotelRoomService HotelRoomService { set; get; } = null!;
مرحلهی بعد، نوبت به نمونه سازی خودکار این سرویسهای درخواستی علامتگذاری شدهی با InjectComponentScoped است. برای این منظور، تمام کامپوننتهای برنامه را از کلاس پایه و استاندارد OwningComponentBase ارثبری میکنیم. مزیت اینکار، امکان دسترسی به خاصیتی به نام ScopedServices در تمام کامپوننتهای برنامه است که توسط آن میتوان به متد ScopedServices.GetRequiredService آن دسترسی یافت. یعنی با ارثبری از کلاس پایهی OwningComponentBase به ازای هر کامپوننت، به صورت خودکار Scope جدیدی شروع میشود که توسط آن میتوان به نمونهی جدیدی از سرویس مدنظر دسترسی یافت و نه به نمونهی اشتراکی در طی درخواست جاری.
اکنون اگر از این مزیت به صورت زیر استفاده کنیم، میتوان تمام سرویسهای درخواستی مزین به InjectComponentScopedAttribute یک کامپوننت را به صورت خودکار یافته و با استفاده از ScopedServices.GetRequiredService، مقدار دهی کرد:
public class BlazorScopedComponentBase : OwningComponentBase { private static readonly ConcurrentDictionary<Type, Lazy<List<PropertyInfo>>> CachedProperties = new(); private List<PropertyInfo> InjectComponentScopedPropertiesList => CachedProperties.GetOrAdd(GetType(), type => new Lazy<List<PropertyInfo>>( () => type.GetProperties(BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) .Where(p => p.GetCustomAttribute<InjectComponentScopedAttribute>() is not null) .ToList(), LazyThreadSafetyMode.ExecutionAndPublication)).Value; protected override void OnInitialized() { foreach (var propertyInfo in InjectComponentScopedPropertiesList) { propertyInfo.SetValue(this, ScopedServices.GetRequiredService(propertyInfo.PropertyType)); } } }
این سرویس، اینبار طول عمری، محدود به کامپوننت جاری را خواهد داشت و بین سایر کامپوننتهای درحال پردازش درخواست جاری، به اشتراک گذاشته نمیشود و همچنین به صورت خودکار هم در پایان درخواست، Dispose میشود.
فعالسازی ارثبری خودکار در تمام کامپوننتهای برنامه
مرحلهی بعد، ارثبری خودکار تمام کامپوننتهای برنامه از OwningComponentBase سفارشی فوق است و در اینجا قصد نداریم تمام کامپوننتها را جهت معرفی آن، به صورت دستی تغییر دهیم. برای اینکار فقط کافی است به فایل Imports.razor_ مراجعه و یک سطر زیر را در آن درج کنیم:
@inherits BlazorScopedComponentBase
با اینکار یک ارثبری سراسری در کل برنامه رخ میدهد و تمام کامپوننتها، از BlazorScopedComponentBase مشتق خواهند شد. یعنی پس از این تغییر، اگر سرویسی را به صورت زیر معرفی و با ویژگی InjectComponentScoped علامتگذاری کردیم:
[InjectComponentScoped] internal IHotelRoomService HotelRoomService { set; get; } = null!;
به صورت خودکار یافت شده و نمونه سازی Scoped محدود به طول عمر همان کامپوننت میشود که بین سایر کامپوننتها، به اشتراک گذاشته نخواهد شد.
یک نکته: اگر کامپوننت شما متد OnInitialized را بازنویسی میکند، فراموش نکنید که در ابتدای آن باید ()base.OnInitialized را هم فراخوانی کنید تا متد OnInitialized کامپوننت پایهی BlazorScopedComponentBase نیز فراخوانی شود. البته این مورد در حین بازنویسی نمونهی async آن مهم نیست؛ چون همیشه OnInitialized غیر async در ابتدا فراخوانی میشود و سپس نمونهی async آن اجرا خواهد شد.