Check | Code | Description |
Is Null | if(variable is null) return true; |
|
Is Not Null | if(variable is { }) return false |
|
Is Not Null | if(variable is object) return false |
|
Is Null | if(variable == null) return true |
|
Is Not Null | if(variable != null) return false |
|
تقریبا تمام اعمال کار با شبکه در Silverlight از مدل asynchronous programming پیروی میکنند؛ از فراخوانی یک متد وب سرویس تا دریافت اطلاعات از وب و غیره. اگر در سایر فناوریهای موجود در دات نت فریم ورک برای مثال جهت کار با یک وب سرویس هر دو متد همزمان و غیرهمزمان در اختیار برنامه نویس هستند اما اینجا خیر. اینجا فقط روشهای غیرهمزمان مرسوم هستند و بس. خیلی هم خوب. یک چارچوب کاری خوب باید روش استفادهی صحیح از کتابخانههای موجود را نیز ترویج کند و این مورد حداقل در Silverlight اتفاق افتاده است.
برای مثال فراخوانیهای زیر را در نظر بگیرید:
private int n1, n2;
private void FirstCall()
{
Service.GetRandomNumber(10, SecondCall);
}
private void SecondCall(int number)
{
n1 = number;
Service.GetRandomNumber(n1, ThirdCall);
}
private void ThirdCall(int number)
{
n2 = number;
// etc
}
private void FetchNumbers()
{
int n1 = Service.GetRandomNumber(10);
int n2 = Service.GetRandomNumber(n1);
}
private void FetchNumbers()
{
int n1, n2;
Service.GetRandomNumber(10, result =>
{
n1 = result;
Service.GetRandomNumber(n1, secondResult =>
{
n2 = secondResult;
});
});
}
به عبارتی میخواهیم کل اعمال انجام شده در متد FetchNumbers هنوز Async باشند (ترد اصلی برنامه را قفل نکنند) اما پی در پی انجام شوند تا مدیریت آنها سادهتر شوند (هر لحظه دقیقا بدانیم که کجا هستیم) و همچنین کدهای تولیدی نیز خواناتر باشند.
روش استانداری که توسط الگوهای برنامه نویسی برای حل این مساله پیشنهاد میشود، استفاده از الگوی coroutines است. توسط این الگو میتوان چندین متد Async را در حالت معلق قرار داده و سپس در هر زمانی که نیاز به آنها بود عملیات آنها را از سر گرفت.
دات نت فریم ورک حالت ویژهای از coroutines را توسط Iterators پشتیبانی میکند (از C# 2.0 به بعد) که در ابتدا نیاز است از دیدگاه این مساله مروری بر آنها داشته باشیم. مثال بعد یک enumerator را به همراه yield return ارائه داده است:
using System;
using System.Collections.Generic;
using System.Threading;
namespace CoroutinesSample
{
class Program
{
static void printAll()
{
foreach (int x in integerList())
{
Console.WriteLine(x);
}
}
static IEnumerable<int> integerList()
{
yield return 1;
Thread.Sleep(1000);
yield return 2;
yield return 3;
}
static void Main()
{
printAll();
}
}
}
کامپایلر سی شارپ در عمل یک state machine را برای پیاده سازی این عملیات به صورت خودکار تولید خواهد کرد:
private bool MoveNext()
{
switch (this.<>1__state)
{
case 0:
this.<>1__state = -1;
this.<>2__current = 1;
this.<>1__state = 1;
return true;
case 1:
this.<>1__state = -1;
Thread.Sleep(0x3e8);
this.<>2__current = 2;
this.<>1__state = 2;
return true;
case 2:
this.<>1__state = -1;
this.<>2__current = 3;
this.<>1__state = 3;
return true;
case 3:
this.<>1__state = -1;
break;
}
return false;
}
در حین استفاده از یک IEnumerator ابتدا در وضعیت شیء Current آن قرار خواهیم داشت و تا زمانیکه متد MoveNext آن فراخوانی نشود هیچ اتفاق دیگری رخ نخواهد داد. هر بار که متد MoveNext این enumerator فرخوانی گردد (برای مثال توسط یک حلقهی foreach) اجرای متد integerList ادامه خواهد یافت تا به yield return بعدی برسیم (سایر اعمال تعریف شده در حالت تعلیق قرار دارند) و همینطور الی آخر.
از همین قابلیت جهت مدیریت اعمال Async پی در پی نیز میتوان استفاده کرد. State machine فوق تا پایان اولین عملیات تعریف شده صبر میکند تا به yield return برسد. سپس با فراخوانی متد MoveNext به عملیات بعدی رهنمون خواهیم شد. به این صورت دیدگاهی پی در پی از یک سلسه عملیات غیرهمزمان حاصل میگردد.
خوب ما الان نیاز به یک کلاس داریم که بتواند enumerator ایی از این دست را به صورت خودکار مرحله به مرحله آن هم پس از پایان واقعی عملیات Async قبلی (یا مرحلهی قبلی)، اجرا کند. قبل از اختراع چرخ باید متذکر شد که دیگران اینکار را انجام دادهاند و کتابخانههای رایگان و یا سورس بازی برای این منظور موجود است.
ادامه دارد ...
معرفی عملگر Hat
برای دسترسی به آخرین عضو یک آرایه عموما از روش زیر استفاده میشود:
var integerArray = new int[3]; var lastItem = integerArray[integerArray.Length - 1];
var integerList = integerArray.ToList(); integerList.Last();
var secondToLast = integerArray[integerArray.Length - 2];
این شمردنهای از آخر در C# 8.0 توسط ارائهی عملگر hat یا همان ^ که پیشتر کار xor را انجام میداد (و البته هنوز هم در جای خودش همین مفهوم را دارد)، میسر شدهاست:
var lastItem = integerArray[^1];
نکتهی مهم: کسانیکه شروع به آموزش برنامه نویسی میکنند، مدتی طول میکشد تا عادت کنند که اولین ایندکس یک آرایه از صفر شروع میشود. در اینجا باید درنظر داشت که با بکارگیری «عملگر کلاه»، آخرین ایندکس یک آرایه از «یک» شروع میشود و نه از صفر. برای نمونه در مثال زیر به خوبی تفاوت بین ایندکس از ابتدا و ایندکس از انتها را میتوانید مشاهده کنید:
var words = new string[] { // index from start index from end "The", // 0 ^9 "quick", // 1 ^8 "brown", // 2 ^7 "fox", // 3 ^6 "jumped", // 4 ^5 "over", // 5 ^4 "the", // 6 ^3 "lazy", // 7 ^2 "dog" // 8 ^1 }; // 9 (or words.Length) ^0
در حالت کلی ایندکس n^ معادل sequence.Length - n است. بنابراین sequence[^0] به معنای sequence[sequence.Length] است و هر دو مورد یک index out of range exception را صادر میکنند.
IDE نیز با فعال سازی C# 8.0، زمانیکه به قطعه کد زیر میرسد، زیر words.Length - 1 خط کشیده و پیشنهاد میدهد که بهتر است از 1^ استفاده کنید:
Console.WriteLine($"The last word is {words[words.Length - 1]}");
معرفی نوع جدید Index
در C# 8.0 زمانیکه مینویسم 1^، در حقیقت قطعه کد زیر را ایجاد کردهایم:
var index = new Index(value: 1, fromEnd: true); Index indexStruct = ^1; var indexShortHand = ^1;
در سطر اول، پارامتر fromEnd نیز قابل تعریف است. این fromEnd با مقدار true، همان عملگر ^ در اینجا است و عدم ذکر این عملگر به معنای false بودن آن است:
int[] a = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; Console.WriteLine(a[a.Length – 2]); // will write 8 on console. Console.WriteLine(a[^2]); // will write 8 on console. Index i5 = 5; Console.WriteLine(a[i5]); //will write 5 on console. Index i2fromEnd = ^2; Console.WriteLine(a[i2fromEnd]); // will write 8 on console.
روش دسترسی به بازهای از اعضای یک آرایه تا پیش از C# 8.0
فرض کنید آرایهای از اعداد بین 1 تا 10 را به صورت زیر ایجاد کردهاید:
var numbers = Enumerable.Range(1, 10).ToArray();
var (start, end) = (1, 7); var length = end - start; // Using LINQ var subset1 = numbers.Skip(start).Take(length); // Or using Array.Copy var subset2 = new int[length]; Array.Copy(numbers, start, subset2, 0, length);
روش دسترسی به بازهای از اعضای یک آرایه در C# 8.0
در C# 8.0 برای دسترسی به بازهای از عناصر یک آرایه میتوان از range expression که به صورت x..y نوشته میشود، استفاده کرد. در ادامه، مثالهایی را از کاربردهای عبارت .. ملاحظه میکنید:
var myArray = new string[] { "Item1", "Item2", "Item3", "Item4", "Item5" };
var fromIndexToX = myArray[1..3]; // = [Item2, Item3]
var fromIndexToXFromTheEnd = myArray[1..^1]; // = [ "Item2", "Item3", "Item4" ]
var fromAnIndexToTheEnd = myArray[1..]; // = [ "Item2", "Item3", "Item4", "Item5" ]
var fromTheStartToAnIndex = myArray[..3]; // = [ "Item1", "Item2", "Item3" ]
var entireRange = myArray[..]; // = [ "Item1", "Item2", "Item3", "Item4", "Item5" ]
مثالی دیگر: بازنویسی یک حلقهی for با foreach
حلقهی for زیر را
var myArray = new string[] { "Item1", "Item2", "Item3", "Item4", "Item5" }; for (int i = 1; i <= 3; i++) { Console.WriteLine(myArray[i]); }
foreach (var item in myArray[1..4]) // = [ "Item2", "Item3", "Item4" ] { Console.WriteLine(item); }
یعنی ابتدای آن inclusive است و انتهای آن exclusive
چند مثال کاربردی و متداول از بازهها
using System; using System.Linq; namespace ConsoleApp { class Program { private static readonly int[] _numbers = Enumerable.Range(1, 10).ToArray(); static void Main() { var skip2CharactersAndTake2Characters = _numbers[2..4]; // صرفنظر کردن از دو عنصر اول و سپس انتخاب دو عنصر var skipFirstAndLastCharacter = _numbers[1..^1]; // صرفنظر کردن از دو عنصر اول و آخر var last3Characters = _numbers[^3..]; // انتخاب بازهای شامل سه عنصر آخر var first4Characters = _numbers[0..4]; // دریافت بازهای از 4 عنصر اول var rangeStartFrom2 = _numbers[2..]; // دریافت بازهای شروع شده از المان دوم تا آخر var skipLast3Characters = _numbers[..^3]; // صرفنظر کردن از سه المان آخر var rangeAll = _numbers[..]; // انتخاب کل بازه } } }
معرفی نوع جدید Range
در C# 8.0 زمانیکه مینویسم 4..1، در حقیقت قطعه کد زیر را ایجاد کردهایم:
var range = new Range(1, 4); Range rangeStruct = 1..4; var rangeShortHand = 1..4;
یک مثال: استفاده از نوع جدید Range به عنوان پارامتر یک متد
using System; using System.Linq; namespace ConsoleApp { class Program { private static readonly int[] _numbers = Enumerable.Range(1, 10).ToArray(); static void Print(Range range) => Console.WriteLine($"{range} => {string.Join(", ", _numbers[range])}"); static void Main() { Print(1..3); // 1..3 => 2, 3 Print(..3); // 0..3 => 1, 2, 3 Print(3..); // 3..^0 => 4, 5, 6, 7, 8, 9, 10 Print(1..^1); // 1..^1 => 2, 3, 4, 5, 6, 7, 8, 9 Print(^2..^1); // ^2..^1 => 9 } } }
مثالی دیگر: استفاده از Range به عنوان جایگزینی برای متد String.Substring
از Range میتوان برای کار بر روی رشتهها و انتخاب قسمتی از آنها نیز استفاده کرد:
Console.WriteLine("123456789"[1..4]); // Would output 234
var helloWorldStr = "Hello, World!"; var hello = helloWorldStr[..5]; Console.WriteLine(hello); // Output: Hello var world = helloWorldStr[7..]; Console.WriteLine(world); // Output: World! var world2 = helloWorldStr[^6..]; // Take the last 6 characters Console.WriteLine(world); // Output: World!
سؤال: زمانیکه بازهای از یک آرایه را انتخاب میکنیم، آیا یک آرایهی جدید ایجاد میشود، یا هنوز به همان آرایهی قبلی اشاره میکند؟
پاسخ: یک آرایهی جدید ایجاد میشود؛ اما میتوان با فراخوانی متد ()array.AsSpan پیش از انتخاب یک بازه، بازهای را تولید کرد که دقیقا به همان آرایهی اصلی اشاره میکند و یک کپی جدید نیست:
var arr = (new[] { 1, 4, 8, 11, 19, 31 }).AsSpan(); var range = arr[2..5]; ref int elt1 = ref range[1]; elt1 = -1; int copiedElement = range[2]; copiedElement = -2; Console.WriteLine($"range[1]: {range[1]}, range[2]: {range[2]}"); // output: range[1]: -1, range[2]: 19 Console.WriteLine($"arr[3]: {arr[3]}, arr[4]: {arr[4]}"); // output: arr[3]: -1, arr[4]: 19
همچنین مزیت دیگر آن، انتقال سادهتر کدهای جاوا به سیشارپ است؛ از این لحاظ که ویژگی مشابهی در زبان جاوا تحت عنوان «Default Methods» سالها است که وجود دارد.
یک مثال از ویژگی «پیاده سازیهای پیشفرض در اینترفیسها»
interface ILogger { void Log(string message); } class ConsoleLogger : ILogger { public void Log(string message) { Console.WriteLine(message); } }
مدتی بعد بر اساس نیازمندیهای مشخصی به این نتیجه خواهید رسید که بهتر است overload دیگری را برای متد Log در اینترفیس ILogger، درنظر بگیریم. مشکلی که این تغییر به همراه دارد، کامپایل نشدن کلاس ConsoleLogger در یک برنامهی ثالث است و این کلاس باید الزاما این overload جدید را پیاده سازی کند؛ در غیراینصورت قادر به کامپایل برنامهی خود نخواهد شد. اکنون در C# 8.0 میتوان برای این نوع تغییرات، در همان اینترفیس اصلی، یک پیاده سازی پیشفرض را نیز قرار داد:
interface ILogger { void Log(string message); void Log(Exception exception) => Console.WriteLine(exception); }
ویژگی «پیاده سازیهای پیشفرض در اینترفیسها» چگونه پیاده سازی شدهاست؟
واقعیت این است که امکان پیاده سازی این ویژگی، سالها است که در سطح کدهای IL دات نت وجود داشته (از زمان دات نت 2) و اکنون از طریق کدهای برنامه با بهبود کامپایلر آن، قابل دسترسی شدهاست.
تاثیر زمینهی کاری بر روی دسترسی به پیاده سازیهای پیشفرض
مثال زیر را درنظر بگیرید:
interface IDeveloper { void LearnNewLanguage(string language, DateTime dueDate); void LearnNewLanguage(string language) { // default implementation LearnNewLanguage(language, DateTime.Now.AddMonths(6)); } } class BackendDev : IDeveloper // compiles OK { public void LearnNewLanguage(string language, DateTime dueDate) { // Learning new language... } }
سؤال: به نظر شما اکنون کدامیک از کاربردهای زیر از کلاس BackendDev، کامپایل میشود و کدامیک خیر؟
IDeveloper dev1 = new BackendDev(); dev1.LearnNewLanguage("Rust"); var dev2 = new BackendDev(); dev2.LearnNewLanguage("Rust");
There is no argument given that corresponds to the required formal parameter 'dueDate' of 'BackendDev.LearnNewLanguage(string, DateTime)' (CS7036) [ConsoleApp]
ارثبری چندگانه چطور؟
احتمالا حدس زدهاید که این قابلیت ممکن است ارثبری چندگانه را که در سیشارپ ممنوع است، میسر کند. تا C# 8.0، یک کلاس تنها از یک کلاس دیگر میتواند مشتق شود؛ اما این محدودیت در مورد اینترفیسها وجود ندارد. به علاوه تاکنون اینترفیسها مانند کلاسها، امکان تعریف پیاده سازی خاصی را نداشتند و صرفا یک قرارداد بیشتر نبودند. بنابراین اکنون این سؤال مطرح میشود که آیا میتوان با ارائهی پیاده سازی پیشفرض متدها در اینترفیسها، ارثبری چندگانه را در سیشارپ پیاده سازی کرد؛ مانند مثال زیر؟!
using System; namespace ConsoleApp { public interface IDev { void LearnNewLanguage(string language) => Console.Write($"Learning {language} in a default way."); } public interface IBackendDev : IDev { void LearnNewLanguage(string language) => Console.Write($"Learning {language} in a backend way."); } public interface IFrontendDev : IDev { void LearnNewLanguage(string language) => Console.Write($"Learning {language} in a frontend way."); } public interface IFullStackDev : IBackendDev, IFrontendDev { } public class Dev : IFullStackDev { } }
IFullStackDev dev = new Dev(); dev.LearnNewLanguage("TypeScript");
The call is ambiguous between the following methods or properties: 'IBackendDev.LearnNewLanguage(string)' and 'IFrontendDev.LearnNewLanguage(string)' (CS0121)
تفاوت امکانات کلاسهای Abstract با متدهای پیشفرض اینترفیسها چیست؟
اینترفیسها هنوز نمیتوانند مانند کلاسها، سازندهای را تعریف کنند. نمیتوانند متغیرها/فیلدهایی را در سطح اینترفیس داشته باشند. همچنین در اینترفیسها همهچیز public است و امکان تعریف سطح دسترسی دیگری وجود ندارد.
بنابراین باید بخاطر داشت که هدف از تعریف اینترفیسها، ارائهی «یک رفتار» است و هدف از تعریف کلاسها، ارائه «یک حالت».
یک نکته: در نگارشهای پیش از C# 8.0 هم میتوان ویژگی «متدهای پیشفرض» را شبیه سازی کرد
واقعیت این است که توسط ویژگی «متدهای الحاقی»، سالها است که امکان افزودن «متدهای پیشفرضی» به اینترفیسها در زبان سیشارپ وجود دارد:
namespace MyNamespace { public interface IMyInterface { IList<int> Values { get; set; } } public static class MyInterfaceExtensions { public static int CountGreaterThan(this IMyInterface myInterface, int threshold) { return myInterface.Values?.Where(p => p > threshold).Count() ?? 0; } } }
var myImplementation = new MyInterfaceImplementation(); // Note that there's no typecast to IMyInterface required var countGreaterThanFive = myImplementation.CountGreaterThan(5);
بررسی یک مثال: تهیه یک برنامهی Blazor 8x برای نمایش لیست محصولات، به همراه جزئیات آنها
به لطف وجود SSR در Blazor 8x، میتوان HTML نهایی کامپوننتها و صفحات Blazor را همانند صفحات MVC و یا Razor pages، در سمت سرور تهیه و بازگشت داد. این خروجی در نهایت یک static HTML بیشتر نیست و گاهی از اوقات ما به بیش از یک خروجی ساده HTML ای نیاز داریم.
در این مثال که بر اساس قالب dotnet new blazor --interactivity Server تهیه میشود، قصد داریم موارد زیر را پیاده سازی کنیم:
- صفحهای که یک لیست محصولات فرضی را نمایش میدهد : بر اساس SSR
- صفحهای که جزئیات یک محصول را نمایش میدهد: بر اساس SSR
- دکمهای در ذیل قسمت نمایش جزئیات یک محصول، برای دریافت و نمایش لیست محصولات مشابه و مرتبط: بر اساس Blazor server islands
یعنی تا جائیکه ممکن است قصد نداریم تمام صفحات و تمام قسمتهای برنامه را با فعالسازی سراسری حالت تعاملی Blazor server که در قسمتهای قبل در مورد آن توضیح داده شد، پیاده سازی کنیم. میخواهیم فقط قسمت کوچکی از این سناریو را که واقعا نیاز به یک چنین قابلیتی را دارد، توسط یک جزیرهی تعاملی Blazor server واقع شدهی در قسمتی از یک صفحهی استاتیک SSR، مدیریت کنیم.
مدل برنامه: رکوردی برای ذخیره سازی اطلاعات یک محصول
namespace BlazorDemoApp.Models; public record Product { public int Id { get; set; } public required string Title { get; set; } public required string Description { get; set; } public decimal Price { get; set; } public List<int> Related { get; set; } = new(); }
سرویس برنامه: سرویسی برای بازگشت لیست محصولات
چون Blazor Server و SSR هر دو بر روی سرور اجرا میشوند، از لحاظ دسترسی به اطلاعات و کار با سرویسها، هماهنگی کاملی وجود داشته و میتوان کدهای یکسان و یکدستی را در اینجا بکار گرفت.
در ادامه کدهای کامل سرویس Services\ProductStore.cs را مشاهده میکنید:
using BlazorDemoApp.Models; namespace BlazorDemoApp.Services; public interface IProductStore { IList<Product> GetAllProducts(); Product GetProduct(int id); IList<Product> GetRelatedProducts(int productId); } public class ProductStore : IProductStore { private static readonly List<Product> ProductsDataSource = new() { new Product { Id = 1, Title = "Smart speaker", Price = 22m, Description = "This smart speaker delivers excellent sound quality and comes with built-in voice control, offering an impressive music listening experience.", Related = new List<int> { 2, 3 }, }, new Product { Id = 2, Title = "Regular speaker", Price = 89m, Description = "Enjoy room-filling sound with this regular speaker. With its slick design, it perfectly fits into any room in your house.", Related = new List<int> { 1, 3 }, }, new Product { Id = 3, Title = "Speaker cable", Price = 12m, Description = "This high-quality speaker cable ensures a reliable and clear audio connection for your sound system.", }, }; public IList<Product> GetAllProducts() => ProductsDataSource; public Product GetProduct(int id) => ProductsDataSource.Single(p => p.Id == id); public IList<Product> GetRelatedProducts(int productId) { var product = ProductsDataSource.Single(x => x.Id == productId); return ProductsDataSource.Where(p => product.Related.Contains(p.Id)).ToList(); } }
این سرویس را باید در فایل Program.cs برنامه به صورت زیر معرفی کرد تا در فایلهای razor برنامهی جاری قابل دسترسی شود:
builder.Services.AddScoped<IProductStore, ProductStore>();
تکمیل صفحهی نمایش لیست محصولات
قصد داریم زمانیکه کاربر برای مثال به آدرس فرضی http://localhost:5136/products مراجعه کرد، با تصویر لیستی از محصولات مواجه شود:
کدهای این صفحه را که در فایل Components\Pages\Store\ProductsList.razor قرار میگیرند، در ادامه مشاهده میکنید:
@page "/Products" @using BlazorDemoApp.Models @using BlazorDemoApp.Services @inject IProductStore Store @attribute [StreamRendering] <h3>Products</h3> @if (_products == null) { <p>Loading...</p> } else { @foreach (var item in _products) { <a href="/ProductDetails/@item.Id"> <div> <div> <h5>@item.Title</h5> </div> <div> <h5>@item.Price.ToString("c")</h5> </div> </div> </a> } } @code { private IList<Product>? _products; protected override Task OnInitializedAsync() => GetProductsAsync(); private async Task GetProductsAsync() { await Task.Delay(1000); // Simulates asynchronous loading to demonstrate streaming rendering _products = Store.GetAllProducts(); } }
- جهت دسترسی به سرویس لیست محصولات، ابتدا سرویس IProductStore به این صفحه تزریق شدهاست.
- سپس در روال رویدادگردان آغازین OnInitializedAsync، کار دریافت اطلاعات و انتساب آن به لیستی، صورت گرفتهاست.
- در این متد جهت شبیه سازی یک عملیات async از یک Task.Delay استفاده شدهاست.
- چون این صفحه، یک صفحهی SSR عادی است، بدون تعریف ویژگی StreamRendering در آن، پس از اجرای برنامه، هیچگاه قسمت loading که در حالت products == null_ قرار است ظاهر شود، نمایش داده نمیشود؛ چون در این حالت (حذف نوع رندر)، صفحهی نهایی که به کاربر ارائه خواهد شد، یک صفحهی استاتیک کاملا رندر شدهی در سمت سرور است و کاربر باید تا زمان پایان این رندر در سمت سرور، منتظر بماند و سپس صفحهی نهایی را دریافت و مشاهده کند. در حالت Streaming rendering، ابتدا میتوان یک قالب HTML ای را بازگشت داد و سپس مابقی محتوای آنرا به محض آماده شدن در طی چند مرحله بازگشت داد.
- لینکهای نمایش داده شدهی در اینجا، به صفحهی ProductDetails اشاره میکنند که در آن، جزئیات محصول انتخابی نمایش داده میشوند.
تکمیل صفحهی نمایش جزئیات یک محصول
در صفحهی کامپوننت Components\Pages\Store\ProductDetails.razor، کار نمایش جزئیات محصول انتخابی صورت میگیرد:
@page "/ProductDetails/{ProductId}" @using BlazorDemoApp.Models @using BlazorDemoApp.Services @inject IProductStore Store @attribute [StreamRendering] @if (_product == null) { <p>Loading...</p> } else { <div> <div> <h5> @_product.Title (@_product.Price.ToString("C")) </h5> <p> @_product.Description </p> </div> @if (_product.Related.Count > 0) { <div> <RelatedProducts ProductId="Convert.ToInt32(ProductId)" /> </div> } </div> <NavLink href="/Products">Back</NavLink> } @code { private Product? _product; [Parameter] public string? ProductId { get; set; } protected override Task OnInitializedAsync() => GetProductAsync(); private async Task GetProductAsync() { await Task.Delay(1000); // Simulates asynchronous loading to demonstrate streaming rendering _product = Store.GetProduct(Convert.ToInt32(ProductId)); } }
- باتوجه به نحوهی تعریف مسیریابی این صفحه، پارامتر ProductId از طریق آدرسی مانند http://localhost:5136/ProductDetails/1 دریافت میشود.
- سپس این ProductId را در روال رخدادگردان OnInitializedAsync، برای یافتن جزئیات محصول انتخابی از سرویس تزریقی IProductStore، بکار میگیریم.
- در اینجا نیز از Task.Delay برای شبیه سازی یک عملیات طولانی async مانند دریافت اطلاعات از یک بانک اطلاعاتی، کمک گرفته شدهاست.
- همچنین برای نمایش قسمت loading صفحه در حالت SSR، بازهم از StreamRendering استفاده کردهایم.
- اگر دقت کرده باشید، ذیل تصویر اطلاعات محصول، دکمهای نیز جهت بارگذاری اطلاعات محصولات مشابه، قرار دارد که ProductId محصول انتخابی را دریافت میکند:
<RelatedProducts ProductId="Convert.ToInt32(ProductId)" />
تکمیل کامپوننت نمایش لیست محصولات مشابه و مرتبط
در فایل Components\Pages\Store\RelatedProducts.razor، کار نمایش یک دکمه و سپس نمایش لیستی از محصولات مشابه، صورت میگیرد:
@using BlazorDemoApp.Models @using BlazorDemoApp.Services @inject IProductStore Store <button @onclick="LoadRelatedProducts">Related products</button> @if (_loadRelatedProducts) { @if (_relatedProducts == null) { <p>Loading...</p> } else { <div> @foreach (var item in _relatedProducts) { <a href="/ProductDetails/@item.Id"> <div> <h5>@item.Title (@item.Price.ToString("C"))</h5> </div> </a> } </div> } } @code{ private IList<Product>? _relatedProducts; private bool _loadRelatedProducts; [Parameter] public int ProductId { get; set; } private async Task LoadRelatedProducts() { _loadRelatedProducts = true; await Task.Delay(1000); // Simulates asynchronous loading to demonstrate InteractiveServer mode _relatedProducts = Store.GetRelatedProducts(ProductId); } }
تعاملی کردن کامپوننت نمایش لیست محصولات مشابه
مشکل! اگر در این حالت برنامه را اجرا کرده و بر روی دکمهی related products کلیک کنیم، هیچ اتفاقی رخ نمیدهد! یعنی روال رویدادگران LoadRelatedProducts اصلا اجرا نمیشود. علت اینجا است که صفحات SSR، در نهایت یک static HTML بیشتر نیستند و فاقد قابلیتهای تعاملی، مانند واکنش نشان دادن به کلیک بر روی یک دکمه هستند.
محدودیتی که به همراه صفحات SSR وجود دارد این است: این نوع کامپوننتها و صفحات فقط یکبار رندر میشوند و نه بیشتر. بله میتوان بر روی آنها دهها دکمه، نوارهای لغزان، دراپداون و غیره را قرار داد، اما ... نمیتوان هیچگونه تعاملی را با آنها داشت. کامپوننت نهایی رندر شده و نمایش داده شده، دیگر در هیچجائی اجرا نمیشود. در این حالت است که میتوان تصمیم گرفت که نیاز است قسمتی از این صفحه، تعاملی شود.
به همین جهت باید نحوهی رندر کامپوننت RelatedProducts را به صورت یک جزیرهی تعاملی Blazor server درآورد تا رویداد منتسب به دکمهی related products موجود در آن، پردازش شود. بنابراین به صفحهی ProductDetails.razor مراجعه کرده و rendermode@ این کامپوننت را به صورت زیر به حالت InteractiveServer تغییر میدهیم:
<RelatedProducts ProductId="Convert.ToInt32(ProductId)" @rendermode="@InteractiveServer"/>
نحوهی پردازش پشت صحنهی این نوع صفحات هم جالب است. برای اینکار به برگهی network مخصوص developer tools مرورگر مراجعه کرده و مراحل رسیدن به صفحهی نمایش جزئیات محصول را طی میکنیم:
- اگر دقت کنید، جابجایی بین صفحات، با استفاده از fetch انجام شده؛ یعنی با اینکه این صفحات در اصل static HTML خالص هستند، اما ... کار full reload صفحه مانند ASP.NET Web forms قدیمی انجام نمیشود (و یا حتی برنامههای MVC و Razor pages) و نمایش صفحات، Ajax ای است و با fetch استاندارد آن صورت میگیرد تا هنوز هم حس و حال SPA بودن برنامه حفظ شود. همچنین اطلاعات DOM کل صفحه را هم بهروز رسانی نمیکند؛ فقط موارد تغییر یافته در اینجا به روز رسانی خواهند شد.
این موارد توسط فایل blazor.web.js درج شدهی در کامپوننت آغازین App.razor، به صورت خودکار مدیریت میشوند:
<script src="_framework/blazor.web.js"></script>
به علاوه در این حالت ایجکسی fetch، کار دریافت مجدد فایلهای استاتیک مرتبط یک صفحه، مانند فایلهای js.، css.، تصاویر و غیره، مجددا انجام نمیشود که این مورد خود مزیتی است نسبت به حالت متداول برنامههای ASP.NET Core MVC و یا Razor pages. در حالت Blazor 8x SSR، فقط یک partial update از نوع Ajax ای انجام میشود.
به این قابلیت، enhanced navigation هم گفته میشود. برای مثال زمانیکه یک فرم SSR را در Blazor 8x به سمت سرور ارسال میکنیم، موقعیت scroll به صورت خودکار ذخیره و بازیابی میشود تا کاربر با یک full post back مواجه نشده و موقعیت جاری خود را در صفحه از دست ندهد (چنین ایدهای، یک زمانی در برنامههای ASP.NET Web forms هم برقرار بود و هست! به نظر مایکروسافت هنوز دلتنگ طراحی قدیمی ASP.NET Web forms است!).
- همچنین به محض نمایش صفحهی جزئیات محصول، پس از پایان کار نمایش آن، یک اتصال وبسوکت هم برقرار شده که مرتبط با جزیرهی تعاملی Blazor server تعریف شده، یا همان کامپوننت RelatedProducts است.
- یک disconnect را هم در اینجا مشاهده میکنید. اگر به یک صفحهی تعاملی مراجعه کنیم، همانطور که مشخص است، یک اتصال SignalR برقرار میشود (که به آن در اینجا circuit هم میگویند). اما اگر از این صفحه به سمت یک صفحهی SSR حرکت کنیم، پس از نمایش آن صفحه، اتصال SignalR قبلی که دیگر نیازی به آن نیست، بسته خواهد شد تا منابع سمت سرور، رها شوند.
در حین disconnect، شماره ID اتصال SignalR ای که دیگر به آن نیازی نیست، به برنامه ارسال میشود تا به صورت خودکار در سمت سرور بسته شود. تمام این موارد توسط blazor.web.js فریمورک، مدیریت میشوند.
در این تصویر ابتدا به آدرس http://localhost:5136/ProductDetails/1 مراجعه کردهایم که سبب برقراری اتصال یک وبسوکت شدهاست. سپس با کلیک بر روی دکمهی back، به صفحهی SSR مشاهدهی لیست محصولات برگشتهایم. در این حالت، دستور قطع اتصال SignalR قبلی صادر شدهاست.
نحوهی مدیریت Pre-rendering در جزایر تعاملی Blazor 8x
به صورت پیشفرض زمانیکه از حالت رندر InteractiveServer استفاده میکنیم، قابلیت pre-rendering آن نیز فعال است. یعنی ابتدا حداقل قالب و قسمتهای ثابت کامپوننت، در سمت سرور پردازش و رندر شده و سپس به سمت کلاینت ارسال میشوند. در این حالت کاربر، تجربهی کاربری روانتری را شاهد خواهد بود؛ چون برای مدتی نباید منتظر آماده شدن کل UI مرتبط باشد و حداقل، قسمتهایی از صفحه که تعاملی نیستند، قابل دسترسی و مشاهده هستند.
اگر به هر دلیلی نیاز به غیرفعال کردن این قابلیت را دارید، باید به صورت زیر عمل کرد:
<RelatedProducts ProductId="Convert.ToInt32(ProductId)" @rendermode="@(new InteractiveServerRenderMode(false))"/>
نحوهی تعریف خواص استاتیک InteractiveServer بکار گرفته شده و یا کلاس InteractiveServerRenderMode را در ادامه مشاهده میکنید. جهت سهولت تعریف این موارد، سطر زیر که یک using static است، به فایل Imports.razor_ اضافه شدهاست:
@using static Microsoft.AspNetCore.Components.Web.RenderMode
public static class RenderMode { public static InteractiveServerRenderMode InteractiveServer { get; } = new InteractiveServerRenderMode(); public static InteractiveWebAssemblyRenderMode InteractiveWebAssembly { get; } = new InteractiveWebAssemblyRenderMode(); public static InteractiveAutoRenderMode InteractiveAuto { get; } = new InteractiveAutoRenderMode(); } public class InteractiveServerRenderMode : IComponentRenderMode { public InteractiveServerRenderMode() : this(true) { } public InteractiveServerRenderMode(bool prerender) => this.Prerender = prerender; public bool Prerender { get; } }
کدهای کامل این مثال را از اینجا میتوانید دریافت کنید: Blazor8x-Server-Normal.zip
معرفی برنامهی Blazor WASM این مطلب
در این مطلب قصد داریم دقیقا قسمت جزیرهی تعاملی Blazor Server همان برنامهی مطلب قبل را توسط یک جزیرهی تعاملی Blazor WASM بازنویسی کنیم و با نکات و تفاوتهای ویژهی آن آشنا شویم. یعنی زمانیکه صفحهی SSR نمایش جزئیات یک محصول ظاهر میشود، نحوهی رندر و پردازش کامپوننت نمایش محصولات مرتبط و مشابه، اینبار یک جزیرهی تعاملی Blazor WASM باشد. بنابراین قسمت عمدهای از کدهای این دو قسمت یکی است؛ فقط نحوهی دسترسی به سرویسها و محل قرارگیری تعدادی از فایلها، متفاوت خواهد بود.
ایجاد یک پروژهی جدید Blazor WASM تعاملی در دات نت 8
بنابراین در ادامه، در ابتدای کار نیاز است یک پوشهی جدید را برای این پروژه، ایجاد کرده و بجای انتخاب interactivity از نوع Server:
dotnet new blazor --interactivity Server
dotnet new blazor --interactivity WebAssembly
الف) یک پروژهی سمت سرور (برای تامین backend و API و سرویسهای مرتبط)
ب) یک پروژهی سمت کلاینت (برای اجرای مستقیم درون مرورگر کاربر؛ بدون داشتن وابستگی مستقیمی به اجزای برنامهی سمت سرور)
این ساختار، خیلی شبیه به ساختار پروژههای نگارش قبلی Blazor از نوع Hosted Blazor WASM است که در آن، یک پروژهی ASP.NET Core هاست کنندهی پروژهی Blazor WASM وجود دارد و یکی از کارهای اصلی آن، فراهم ساختن Web API مورد استفادهی در پروژهی WASM است.
در حالتیکه نوع تعاملی بودن پروژه را Server انتخاب کنیم (مانند مثال قسمت پنجم)، فایل Program.cs آن به همراه دو تعریف مهم زیر است که امکان تعریف کامپوننتهای تعاملی سمت سرور را میسر میکنند:
// ... builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); // ... app.MapRazorComponents<App>() .AddInteractiveServerRenderMode();
این تعاریف در فایل Program.cs (پروژهی سمت سرور) قالب جدید Blazor WASM به صورت زیر تغییر میکنند تا امکان تعریف کامپوننتهای تعاملی سمت کلاینت از نوع وباسمبلی، میسر شود:
// ... builder.Services.AddRazorComponents() .AddInteractiveWebAssemblyComponents(); // ... app.MapRazorComponents<App>() .AddInteractiveWebAssemblyRenderMode() .AddAdditionalAssemblies(typeof(Counter).Assembly);
نیاز به تغییر معماری برنامه جهت کار با جزایر Blazor WASM
همانطور که در قسمت پنجم مشاهده کردیم، تبدیل کردن یک کامپوننت Blazor، به کامپوننتی تعاملی برای اجرای در سمت سرور، بسیار سادهاست؛ فقط کافی است rendermode@ آنرا به InteractiveServer تغییر دهیم تا ... کار کند. اما تبدیل همان کامپوننت نمایش محصولات مرتبط، به یک جزیرهی وباسمبلی، نیاز به تغییرات قابل ملاحظهای را دارد؛ از این لحاظ که اینبار این قسمت قرار است بر روی مرورگر کاربر اجرا شود و نه بر روی سرور. در این حالت دیگر کامپوننت ما دسترسی مستقیمی را به سرویسهای سمت سرور ندارد و برای رسیدن به این مقصود باید از یک Web API در سمت سرور کمک بگیرد و برای کار کردن با آن API در سمت کلاینت، از سرویس HttpClient استفاده کند. به همین جهت، پیاده سازی معماری این روش، نیاز به کار بیشتری را دارد:
همانطور که ملاحظه میکنید، برای فعالسازی یک جزیرهی تعاملی وباسمبلی، نمیتوان کامپوننت RelatedProducts آنرا مستقیما در پروژهی سمت سرور قرار داد و باید آنرا به پروژهی سمت کلاینت منتقل کرد. در ادامه پیاده سازی کامل این پروژه را با توجه به این تغییرات بررسی میکنیم.
مدل برنامه: رکوردی برای ذخیره سازی اطلاعات یک محصول
از این جهت که مدل برنامه (که در قسمت پنجم معرفی شد) در دو پروژهی Client و سرور قابل استفادهاست، به همین جهت مرسوم است یک پروژهی سوم Shared را نیز به جمع دو پروژهی جاری solution اضافه کرد و فایل این مدل را در آن قرار داد. بنابراین این فایل را از پوشهی Models پروژهی سرور به پوشهی Models پروژهی جدید BlazorDemoApp.Shared در مسیر جدید BlazorDemoApp.Shared\Models\Product.cs منتقل میکنیم. مابقی کدهای آن با قسمت پنجم تفاوتی ندارد.
سپس به فایل csproj. پروژهی کلاینت مراجعه کرده و ارجاعی را به پروژهی جدید BlazorDemoApp.Shared اضافه میکنیم:
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly"> <PropertyGroup> <TargetFramework>net8.0</TargetFramework> </PropertyGroup> <ItemGroup> <ProjectReference Include="..\BlazorDemoApp.Shared\BlazorDemoApp.Shared.csproj" /> </ItemGroup> </Project>
سرویس برنامه: سرویسی برای بازگشت لیست محصولات
چون Blazor Server و صفحات SSR آن هر دو بر روی سرور اجرا میشوند، از لحاظ دسترسی به اطلاعات و کار با سرویسها، هماهنگی کاملی وجود داشته و میتوان کدهای یکسان و یکدستی را در اینجا بکار گرفت. یعنی هنوز هم همان مسیر قبلی سرویس Services\ProductStore.cs در این پروژهی سمت سرور نیز برقرار است و نیازی به تغییر مسیر آن نیست. البته بدیهی است چون این پروژه جدید است، باید این سرویس را در فایل Program.cs برنامهی سمت سرور به صورت زیر معرفی کرد تا در فایل razor برنامهی آن قابل دسترسی شود:
builder.Services.AddScoped<IProductStore, ProductStore>();
تکمیل فایل Imports.razor_ پروژهی سمت سرور
جهت سهولت کار با برنامه، یک سری مسیر و using را نیاز است به فایل Imports.razor_ پروژهی سمت سرور اضافه کرد:
@using static Microsoft.AspNetCore.Components.Web.RenderMode // ... @using BlazorDemoApp.Client.Components.Store @using BlazorDemoApp.Client.Components
تکمیل صفحهی نمایش لیست محصولات
کدها و مسیر کامپوننت ProductsList.razor، با قسمت پنجم دقیقا یکی است. این صفحه، یک صفحهی SSR بوده و در همان سمت سرور اجرا میشود و دسترسی آن به سرویسهای سمت سرور نیز ساده بوده و همانند قبل است.
تکمیل صفحهی نمایش جزئیات یک محصول
کدها و مسیر کامپوننت ProductDetails.razor، با قسمت پنجم دقیقا یکی است. این صفحه، یک صفحهی SSR بوده و در همان سمت سرور اجرا میشود و دسترسی آن به سرویسهای سمت سرور نیز ساده بوده و همانند قبل است ... البته بجز یک تغییر کوچک:
<RelatedProducts ProductId="ProductId" @rendermode="@InteractiveWebAssembly"/>
تکمیل کامپوننت نمایش لیست محصولات مشابه و مرتبط
پس از این توضیحات، به اصل موضوع این قسمت رسیدیم! کامپوننت سمت سرور RelatedProducts.razor قسمت پنجم ، از آنجا cut شده و به مسیر جدید BlazorDemoApp.Client\Components\Store\RelatedProducts.razor منتقل میشود. یعنی کاملا به پروژهی وباسمبلی منتقل میشود. بنابراین کدهای آن دیگر دسترسی مستقیمی به سرویس دریافت اطلاعات محصولات ندارند و برای اینکار نیاز است در سمت سرور، یک Web API Controller را تدارک ببینیم:
using BlazorDemoApp.Services; using Microsoft.AspNetCore.Mvc; namespace BlazorDemoApp.Controllers; [ApiController] [Route("/api/[controller]")] public class ProductsController : ControllerBase { private readonly IProductStore _store; public ProductsController(IProductStore store) => _store = store; [HttpGet("[action]")] public IActionResult Related([FromQuery] int productId) => Ok(_store.GetRelatedProducts(productId)); }
برای اینکه مسیریابی این کنترلر کار کند، باید به فایل Program.cs برنامه، مراجعه و سطرهای زیر را اضافه کرد:
builder.Services.AddControllers(); // ... app.MapControllers();
یک نکته: همانطور که مشاهده میکنید، در Blazor 8x، امکان استفاده از دو نوع مسیریابی یکپارچه، در یک پروژه وجود دارد؛ یعنی Blazor routing و ASP.NET Core endpoint routing. بنابراین در این پروژهی سمت سرور، هم میتوان صفحات SSR و یا Blazor Server ای داشت که مسیریابی آنها با page@ مشخص میشوند و همزمان کنترلرهای Web API ای را داشت که بر اساس سیستم مسیریابی ASP.NET Core کار میکنند.
بر این اساس در پروژهی سمت کلاینت، کامپوننت RelatedProducts.razor باید با استفاده از سرویس HttpClient، اطلاعات درخواستی را از Web API فوق دریافت و همانند قبل نمایش دهد که تغییرات آن به صورت زیر است:
@using BlazorDemoApp.Shared.Models @inject HttpClient Http <button class="btn btn-outline-secondary" @onclick="LoadRelatedProducts">Related products</button> @if (_loadRelatedProducts) { @if (_relatedProducts == null) { <p>Loading...</p> } else { <div class="mt-3"> @foreach (var item in _relatedProducts) { <a href="/ProductDetails/@item.Id"> <div class="col-sm"> <h5 class="mt-0">@item.Title (@item.Price.ToString("C"))</h5> </div> </a> } </div> } } @code{ private IList<Product>? _relatedProducts; private bool _loadRelatedProducts; [Parameter] public int ProductId { get; set; } private async Task LoadRelatedProducts() { _loadRelatedProducts = true; var uri = $"/api/products/related?productId={ProductId}"; _relatedProducts = await Http.GetFromJsonAsync<IList<Product>>(uri); } }
در ادامه یکبار برنامه را اجرا میکنیم و ... بلافاصله پس از انتخاب صفحهی نمایش جزئیات یک محصول، با خطای زیر مواجه خواهیم شد!
System.InvalidOperationException: Cannot provide a value for property 'Http' on type 'RelatedProducts'. There is no registered service of type 'System.Net.Http.HttpClient'.
اهمیت درنظر داشتن pre-rendering در حالت جزیرههای وباسمبلی
استثنائی را که مشاهده میکنید، به علت pre-rendering سمت سرور این کامپوننت، رخدادهاست.
زمانیکه کامپوننتی را به این نحو رندر میکنیم:
<RelatedProducts ProductId="ProductId" @rendermode="@InteractiveWebAssembly"/>
الف) یکبار در سمت سرور تا HTML حداقل قالب آن، به همراه سایر قسمتهای صفحهی SSR جاری به سمت مرورگر کاربر ارسال شود.
ب) یکبار هم در سمت کلاینت، زمانیکه Blazor WASM بارگذاری شده و فعال میشود.
استثنائی را که مشاهده میکنیم، مربوط به حالت الف است. یعنی زمانیکه برنامهی ASP.NET Core هاست برنامه، سعی میکند کامپوننت RelatedProducts را در سمت سرور رندر کند، اما ... ما سرویس HttpClient را در آن ثبت و فعالسازی نکردهایم. به همین جهت است که عنوان میکند این سرویس را پیدا نکردهاست. برای رفع این مشکل، چندین راهحل وجود دارند که در ادامه آنها را بررسی میکنیم.
راهحل اول: ثبت سرویس HttpClient در سمت سرور
یک راهحل مواجه شدن با مشکل فوق، ثبت سرویس HttpClient در فایل Program.cs برنامهی سمت سرور به صورت زیر است:
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("http://localhost/") });
راهحل دوم: استفاده از polymorphism یا چندریختی
برای اینکار اینترفیسی را طراحی میکنیم که قرارداد نحوهی تامین اطلاعات مورد نیاز کامپوننت RelatedProducts را ارائه میکند. سپس یک پیاده سازی سمت سرور را از آن خواهیم داشت که مستقیما به بانک اطلاعاتی رجوع میکند و همچنین یک پیاده سازی سمت کلاینت را که از HttpClient جهت کار با Web API استفاده خواهد کرد.
از آنجائیکه این قرارداد نیاز است توسط هر دو پروژهی سمت سرور و سمت کلاینت استفاده شود، باید آنرا در پروژهی Shared قرار داد تا بتوان ارجاعاتی از آنرا به هر دو پروژه اضافه کرد؛ برای مثال در فایل BlazorDemoApp.Shared\Data\IProductStore.cs به صورت زیر:
using BlazorDemoApp.Shared.Models; namespace BlazorDemoApp.Shared.Data; public interface IProductStore { IList<Product> GetAllProducts(); Product GetProduct(int id); Task<IList<Product>?> GetRelatedProducts(int productId); }
پیاده سازی سمت سرور این اینترفیس، کاملا مهیا است و فقط نیاز به تغییر زیر را دارد تا با خروجی Task دار هماهنگ شود:
public Task<IList<Product>?> GetRelatedProducts(int productId) { var product = ProductsDataSource.Single(x => x.Id == productId); return Task.FromResult<IList<Product>?>(ProductsDataSource.Where(p => product.Related.Contains(p.Id)) .ToList()); }
[HttpGet("[action]")] public async Task<IActionResult> Related([FromQuery] int productId) => Ok(await _store.GetRelatedProducts(productId));
در ادامه نیاز است یک پیاده سازی سمت کلاینت را نیز از آن تهیه کنیم که در فایل BlazorDemoApp.Client\Data\ClientProductStore.cs درج خواهد شد:
public class ClientProductStore : IProductStore { private readonly HttpClient _httpClient; public ClientProductStore(HttpClient httpClient) => _httpClient = httpClient; public IList<Product> GetAllProducts() => throw new NotImplementedException(); public Product GetProduct(int id) => throw new NotImplementedException(); public Task<IList<Product>?> GetRelatedProducts(int productId) => _httpClient.GetFromJsonAsync<IList<Product>>($"/api/products/related?productId={productId}"); }
builder.Services.AddScoped<IProductStore, ClientProductStore>(); builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
الف) معرفی سرویس IProductStore بجای HttpClient قبلی
@inject IProductStore ProductStore
private async Task LoadRelatedProducts() { _loadRelatedProducts = true; _relatedProducts = await ProductStore.GetRelatedProducts(ProductId); }
اکنون اگر برنامه را اجرا کنیم، پس از مشاهدهی جزئیات یک محصول، بارگذاری کامپوننت Blazor WASM آن در developer tools مرورگر کاملا مشخص است:
راهحل سوم: استفاده از سرویس PersistentComponentState
با استفاده از سرویس PersistentComponentState میتوان اطلاعات دریافتی از بانکاطلاعاتی را در حین pre-rendering در سمت سرور، به جزایر تعاملی انتقال داد و این روشی است که مایکروسافت برای پیاده سازی مباحث اعتبارسنجی و احراز هویت در Blazor 8x در پیشگرفتهاست. این راهحل را در قسمت بعد بررسی میکنیم.
کدهای کامل این مثال را از اینجا میتوانید دریافت کنید: Blazor8x-WebAssembly-Normal.zip
معرفی قالبهای جدید شروع پروژههای Blazor در دات نت 8
پس از نصب SDK دات نت 8، دیگر خبری از قالبهای قدیمی پروژههای blazor server و blazor wasm نیست! در اینجا در ابتدا باید مشخص کرد که سطح تعاملی برنامه در چه حدی است. در ادامه 4 روش شروع پروژههای Blazor 8x را مشاهده میکنید که توسط پرچم interactivity--، نوع رندر برنامه در آنها مشخص شدهاست:
اجرای قسمتهای تعاملی برنامه بر روی سرور:
dotnet new blazor --interactivity Server
اجرای قسمتهای تعاملی برنامه در مرورگر، توسط فناوری وباسمبلی:
dotnet new blazor --interactivity WebAssembly
برای اجرای قسمتهای تعاملی برنامه، ابتدا حالت Server فعالسازی میشود تا فایلهای WebAssembly دریافت شوند، سپس فقط از WebAssembly استفاده میکند:
dotnet new blazor --interactivity Auto
فقط از حالت SSR یا همان static server rendering استفاده میشود (این نوع برنامهها تعاملی نیستند):
dotnet new blazor --interactivity None
سایر گزینهها را با اجرای دستور dotnet new blazor --help میتوانید مشاهده کنید.
نکتهی مهم! در قالبهای آمادهی Blazor 8x، حالت SSR، پیشفرض است.
هرچند در تمام پروژههای فوق، انتخاب حالتهای مختلف رندر را مشاهده میکنید، اما این انتخابها صرفا دو مقصود مهم را دنبال میکنند:
الف) تنظیم فایل Program.cs برنامه جهت افزودن وابستگیهای مورد نیاز، به صورت خودکار.
ب) ایجاد پروژهی کلاینت (علاوه بر پروژهی سرور)، در صورت نیاز. برای مثال حالتهای وباسمبلی و Auto، هر دو به همراه یک پروژهی کلاینت وباسمبلی هم هستند؛ اما حالتهای Server و None، خیر.
در تمام این پروژهها هر صفحه و یا کامپوننتی که ایجاد میشود، به صورت پیشفرض بر اساس SSR رندر و نمایش داده خواهد شد؛ مگر اینکه به صورت صریحی این نحوهی رندر را بازنویسی کنیم. برای مثال مشخص کنیم که قرار است بر اساس Blazor Server اجرا شود و یا وباسمبلی و یا حالت Auto.
بررسی حالت Server side rendering
برای بررسی این حالت یک پوشهی جدید را ایجاد کرده و توسط خط فرمان، دستور dotnet new blazor --interactivity Server را در ریشهی آن اجرا میکنیم. پس از ایجاد ساختار ابتدایی پروژه بر اساس این قالب انتخابی، فایل Program.cs جدید آن، چنین شکلی را دارد:
var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); var app = builder.Build(); if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseHsts(); } app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseAntiforgery(); app.MapRazorComponents<App>().AddInteractiveServerRenderMode(); app.Run();
server-side rendering به این معنا است که برنامهی سمت سرور، کل DOM و HTML نهایی را تولید کرده و به مرورگر کاربر ارائه میکند. مرورگر هم این DOM را نمایش میدهد. فقط همین! در اینجا هیچ خبری از اتصال دائم SignalR نیست و محتوای ارائه شده، یک محتوای استاتیک است. این حالت رندر، برای ارائهی محتواهای فقط خواندنی غیرتعاملی، فوق العادهاست؛ امکان از لحظهای که نیاز به کلیک بر روی دکمهای باشد، دیگر پاسخگو نیست. به همین جهت در اینجا امکان تعاملی کردن تعدادی از کامپوننتهای ویژه و مدنظر نیز پیشبینی شدهاند تا بتوان به ترکیبی از server-side rendering و client-side rendering رسید.
حالت پیشفرض در اینجا، ارائهی محتوای استاتیک است. بنابراین هر کامپوننتی در اینجا ابتدا بر روی سرور رندر شده (HTML ابتدایی آن آماده شده) و به سمت مرورگر کاربر ارسال میشود. اگر کامپوننتی نیاز به امکانات تعاملی داشت باید آنرا دقیقا توسط ویژگی InteractiveXYZ مشخص کند؛ مانند مثال زیر:
@page "/counter" @rendermode InteractiveServer <PageTitle>Counter</PageTitle> <h1>Counter</h1> <p role="status">Current count: @currentCount</p> <button class="btn btn-primary" @onclick="IncrementCount">Click me</button> @code { private int currentCount = 0; private void IncrementCount() { currentCount++; } }
در ادامه، مجددا سطر کامنت شده را به حالت عادی برگردانید و سپس برنامه را اجرا کنید. پیش از باز کردن صفحهی Counter، ابتدا developer tools مرورگر خود را گشوده و برگهی network آنرا انتخاب و سپس صفحهی Counter را باز کنید. در این لحظهاست که مشاهده میکنید یک اتصال وبسوکت برقرار شد. این اتصال است که قابلیتهای تعاملی صفحه را برقرار کرده و مدیریت میکند (این اتصال دائم SignalR است که این صفحه را همانند برنامههای Blazor Web Server پیشین مدیریت میکند).
یک نکته: در برنامههای Blazor Server سنتی، امکان فعالسازی قابلیتی به نام prerender نیز وجود دارد. یعنی سرور، ابتدا صفحه را رندر کرده و محتوای استاتیک آنرا به سمت مرورگر کاربر ارسال میکند و سپس اتصال SignalR برقرار میشود. در دات نت 8، این حالت، حالت پیشفرض است. اگر آنرا نمیخواهید باید به نحو زیر غیرفعالش کنید:
@rendermode InteractiveServerRenderModeWithoutPrerendering @code{ static readonly IComponentRenderMode InteractiveServerRenderModeWithoutPrerendering = new InteractiveServerRenderMode(false); }
روشی ساده برای تعاملی کردن کل برنامه
اگر میخواهید رفتار برنامه را همانند Blazor Server سابق کنید و نمیخواهید به ازای هر کامپوننت، نحوهی رندر آنرا به صورت سفارشی انتخاب کنید، فقط کافی است فایل App.razor را باز کرده:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <base href="/" /> <link rel="stylesheet" href="bootstrap/bootstrap.min.css" /> <link rel="stylesheet" href="app.css" /> <link rel="stylesheet" href="MyApp.styles.css" /> <link rel="icon" type="image/png" href="favicon.png" /> <HeadOutlet /> </head> <body> <Routes /> <script src="_framework/blazor.web.js"></script> </body> </html>
<HeadOutlet @rendermode="@InteractiveServer" /> ... <Routes @rendermode="@InteractiveServer" />
در این حالت دیگر نیازی نیست تا به ازای هر کامپوننت و صفحه، نحوهی رندر را مشخص کنیم؛ چون این نحوه، از بالاترین سطح، به تمام زیرکامپوننتها به ارث میرسد (دربارهی این نکته در قسمت قبل، توضیحاتی ارائه شد).
بررسی حالت Streaming Rendering
در اینجا مثال پیشفرض Weather.razor قالب پیشفرض مورد استفادهی جاری را کمی تغییر دادهایم که کدهای نهایی آن به صورت زیر است (2 قسمت forecasts.AddRange_ را اضافهتر دارد):
@page "/weather" @attribute [StreamRendering(prerender: true)] <PageTitle>Weather</PageTitle> <h1>Weather</h1> <p>This component demonstrates showing data.</p> @if (_forecasts == null) { <p> <em>Loading...</em> </p> } else { <table class="table"> <thead> <tr> <th>Date</th> <th>Temp. (C)</th> <th>Temp. (F)</th> <th>Summary</th> </tr> </thead> <tbody> @foreach (var forecast in _forecasts) { <tr> <td>@forecast.Date.ToShortDateString()</td> <td>@forecast.TemperatureC</td> <td>@forecast.TemperatureF</td> <td>@forecast.Summary</td> </tr> } </tbody> </table> } @code { private List<WeatherForecast>? _forecasts; protected override async Task OnInitializedAsync() { // Simulate asynchronous loading to demonstrate streaming rendering await Task.Delay(500); var startDate = DateOnly.FromDateTime(DateTime.Now); var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching", }; _forecasts = GetWeatherForecasts(startDate, summaries).ToList(); StateHasChanged(); // Simulate asynchronous loading to demonstrate streaming rendering await Task.Delay(1000); _forecasts.AddRange(GetWeatherForecasts(startDate, summaries)); StateHasChanged(); await Task.Delay(1000); _forecasts.AddRange(GetWeatherForecasts(startDate, summaries)); } private static IEnumerable<WeatherForecast> GetWeatherForecasts(DateOnly startDate, string[] summaries) { return Enumerable.Range(1, 5) .Select(index => new WeatherForecast { Date = startDate.AddDays(index), TemperatureC = Random.Shared.Next(-20, 55), Summary = summaries[Random.Shared.Next(summaries.Length)], }); } private class WeatherForecast { public DateOnly Date { get; set; } public int TemperatureC { get; set; } public string? Summary { get; set; } public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); } }
در ادامه مجددا سطر ویژگی StreamRendering را به حالت قبلی برگردانید و برنامه را اجرا کنید. در این حالت ابتدا قسمت loading ظاهر میشود و سپس در طی چند مرحله با توجه به Task.Delayهای قرار داده شده، صفحه رندر شده و تکمیل میشود.
اتفاقی که در اینجا رخ میدهد، استفاده از فناوری HTML Streaming است که مختص به مایکروسافت هم نیست. در حالت Streaming، هربار قطعهای از HTML ای که قرار است به کاربر ارائه شود، به صورت جریانی به سمت مرورگر ارسال میشود و مرورگر این قطعهی جدید را بلافاصله نمایش میدهد. نکتهی جالب این روش، عدم نیاز به اتصال SignalR و یا اجرای WASM درون مرورگر است.
Streaming rendering حالت بینابین رندر کامل در سمت سرور و رندر کامل در سمت کلاینت است. در حالت رندر سمت سرور، کل HTML صفحه ابتدا توسط سرور تهیه و بازگشت داده میشود و کاربر باید تا پایان عملیات تهیهی این HTML نهایی، منتظر باقی بماند و در این بین چیزی را مشاهده نخواهد کرد. در حالت Streaming rendering، هنوز هم همان حالت تهیهی HTML استاتیک سمت سرور برقرار است؛ به همراه تعدادی محل جایگذاری اطلاعات جدید. به محض پایان یک عمل async سمت سرور که سه نمونهی آن را در مثال فوق مشاهده میکنید، برنامه، جریان قطعهای از اطلاعات استاتیک را به سمت مرورگر کاربر ارسال میکند تا در مکانهایی از پیش تعیین شده، درج شوند.
در حالت SSR، فقط یکبار شانس ارسال کل اطلاعات به سمت مرورگر کاربر وجود دارد؛ اما در حالت Streaming rendering، ابتدا میتوان یک قالب HTML ای را بازگشت داد و سپس مابقی محتوای آنرا به محض آماده شدن در طی چند مرحله بازگشت داد. در این حالت نمایش گزارشی از اطلاعاتی که ممکن است با تاخیر در سمت سرور تهیه شوند، سادهتر میشود. یعنی میتوان هربار قسمتی را که تهیه شده، برای نمایش بازگشت داد و کاربر تا مدت زیادی منتظر نمایش کل صفحه باقی نخواهد ماند.
روش نهایی معرفی نحوهی رندر صفحات
بجای استفاده از ویژگیهای RenderModeXyz جهت معرفی نحوهی رندر کامپوننتها و صفحات (که تا پیش از نگارش RTM معرفی شده بودند و چندبار هم تغییر کردند)، میتوان از دایرکتیو جدیدی به نام rendermode@ با سه مقدار InteractiveServer، InteractiveWebAssembly و InteractiveAuto استفاده کرد. برای سهولت تعریف این موارد باید سطر ذیل را به فایل Imports.razor_ اضافه نمود:
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@attribute [RenderModeInteractiveServer]
@rendermode InteractiveServer
اگر هم قصد سفارشی سازی آنها را دارید، برای مثال میخواهید prerender را در آنها false کنید، روش کار به صورت زیر است:
@rendermode renderMode @code { static IComponentRenderMode renderMode = new InteractiveWebAssemblyRenderMode(prerender: false); }
در فضایی که همواره هیچ تضمینی وجود ندارد که درخواست ارسال شدهی به یک API، همواره مسیر خود را همانطور که انتظار میرود طی کرده و پاسخ مورد نظر را در اختیار ما قرار میدهد، بیشک تلاش مجدد برای پردازش درخواست مورد نظر، به دلیل خطاهای گذرا، یکی از راهکارهای مورد استفاده خواهد بود. تصور کنید قصد طراحی یک مجموعه API عمومی را دارید، بهنحوی که مصرف کنندگان بدون نگرانی از ایجاد خرابی یا تغییرات ناخواسته، امکان تلاش مجدد در سناریوهای مختلف مشکل در ارتباط با سرور را داشته باشند. حتما توجه کنید که برخی از متدهای HTTP مانند GET، به اصطلاح Idempotent هستند و در طراحی آنها همواره باید این موضوع مدنظر قرار بگیرد و خروجی مشابهی برای درخواستهای تکراری همانند، مهیا کنید.
در تصویر بالا، حالتی که درخواست، توسط کلاینت ارسال شده و در آن لحظه ارتباط قطع شدهاست یا با یک خطای گذرا در سرور مواجه شدهاست و همچنین سناریویی که درخواست توسط سرور دریافت و پردازش شدهاست ولی کلاینت پاسخی را دریافت نکردهاست، قابل مشاهدهاست.
نکته: Idempotence یکی از ویژگی های پایهای عملیاتی در ریاضیات و علوم کامپیوتر است و فارغ از اینکه چندین بار اجرا شوند، نتیجه یکسانی را برای آرگومانهای همسان، خروجی خواهند داد. این خصوصیت در کانتکستهای مختلفی از جمله سیستمهای پایگاه داده و وب سرویسها قابل توجه میباشد.
Idempotent and Safe HTTP Methods
طبق HTTP RFC، متدهایی که پاسخ یکسانی را برای درخواستهای همسان مهیا میکنند، به اصطلاح Idempotent هستند. همچنین متدهایی که باعث نشوند تغییری در وضعیت سیستم در سمت سرور ایجاد شود، به اصطلاح Safe در نظر گرفته خواهند شد. برای هر دو خصوصیت عنوان شده، سناریوهای استثناء و قابل بحثی وجود دارند؛ بهعنوان مثال در مورد خصوصیت Safe بودن، درخواست GET ای را تصور کنید که یکسری لاگ آماری هم ثبت میکند یا عملیات بازنشانی کش را نیز انجام میدهد که در خیلی از موارد به عنوان یک قابلیت شناسایی خواهد شد. در این سناریوها و طبق RFC، باتوجه به اینکه هدف مصرف کننده، ایجاد Side-effect نبودهاست، هیچ مسئولیتی در قبال این تغییرات نخواهد داشت. لیست زیر شامل متدهای مختلف HTTP به همراه دو خصوصیت ذکر شده می باشد:
HTTP Method | Safe | Idempotent |
GET | Yes | Yes |
HEAD | Yes | Yes |
OPTIONS | Yes | Yes |
TRACE | Yes | Yes |
PUT | No | Yes |
DELETE | No | Yes |
POST | No | No |
PATCH | No | No |
Request Identifier as a Solution
راهکاری که عموما مورد استفاده قرار میگیرد، استفاده از یک شناسهی یکتا برای درخواست ارسالی و ارسال آن به سرور از طریق هدر HTTP می باشد. تصویر زیر از کتاب API Design Patterns، روش استفاده و مراحل جلوگیری از پردازش درخواست تکراری با شناسهای همسان را نشان میدهد:
در اینجا ابتدا مصرف کننده درخواستی با شناسه «۱» را برای پردازش به سرور ارسال میکند. سپس سرور که لیستی از شناسههای پردازش شدهی قبلی را نگهداری کردهاست، تشخیص میدهد که این درخواست قبلا دریافت شدهاست یا خیر. پس از آن، عملیات درخواستی انجام شده و شناسهی درخواست، به همراه پاسخ ارسالی به کلاینت، در فضایی ذخیره سازی میشود. در ادامه اگر همان درخواست مجددا به سمت سرور ارسال شود، بدون پردازش مجدد، پاسخ پردازش شدهی قبلی، به کلاینت تحویل داده می شود.
Implementation in .NET
ممکن است پیادهسازیهای مختلفی را از این الگوی طراحی در اینترنت مشاهده کنید که به پیاده سازی یک Middleware بسنده کردهاند و صرفا بررسی این مورد که درخواست جاری قبلا دریافت شدهاست یا خیر را جواب می دهند که ناقص است. برای اینکه اطمینان حاصل کنیم درخواست مورد نظر دریافت و پردازش شدهاست، باید در منطق عملیات مورد نظر دست برده و تغییراتی را اعمال کنیم. برای این منظور فرض کنید در بستری هستیم که می توانیم از مزایای خصوصیات ACID دیتابیس رابطهای مانند SQLite استفاده کنیم. ایده به این شکل است که شناسه درخواست دریافتی را در تراکنش مشترک با عملیات اصلی ذخیره کنیم و در صورت بروز هر گونه خطا در اصل عملیات، کل تغییرات برگشت خورده و کلاینت امکان تلاش مجدد با شناسهی مورد نظر را داشته باشد. برای این منظور مدل زیر را در نظر بگیرید:
public class IdempotentId(string id, DateTime time) { public string Id { get; private init; } = id; public DateTime Time { get; private init; } = time; }
هدف از این موجودیت ثبت و نگهداری شناسههای درخواستهای دریافتی میباشد. در ادامه واسط IIdempotencyStorage را برای مدیریت نحوه ذخیره سازی و پاکسازی شناسههای دریافتی خواهیم داشت:
public interface IIdempotencyStorage { Task<bool> TryPersist(string idempotentId, CancellationToken cancellationToken); Task CleanupOutdated(CancellationToken cancellationToken); bool IsKnownException(Exception ex); }
در اینجا متد TryPersist سعی میکند با شناسه دریافتی یک رکورد را ثبت کند و اگر تکراری باشد، خروجی false خواهد داشت. متد CleanupOutdated برای پاکسازی شناسههایی که زمان مشخصی (مثلا ۱۲ ساعت) از دریافت آنها گذشته است، استفاده خواهد شد که توسط یک وظیفهی زمانبندی شده می تواند اجرا شود؛ به این صورت، امکان استفادهی مجدد از آن شناسهها برای کلاینتها مهیا خواهد شد. پیاده سازی واسط تعریف شده، به شکل زیر خواهد بود:
/// <summary> /// To prevent from race-condition, this default implementation relies on primary key constraints. /// </summary> file sealed class IdempotencyStorage( AppDbContext dbContext, TimeProvider dateTime, ILogger<IdempotencyStorage> logger) : IIdempotencyStorage { private const string ConstraintName = "PK_IdempotentId"; public Task CleanupOutdated(CancellationToken cancellationToken) { throw new NotImplementedException(); //TODO: cleanup the outdated ids based on configurable duration } public bool IsKnownException(Exception ex) { return ex is UniqueConstraintException e && e.ConstraintName.Contains(ConstraintName); } // To tackle race-condition issue, the implementation relies on storage capabilities, such as primary constraint for given IdempotentId. public async Task<bool> TryPersist(string idempotentId, CancellationToken cancellationToken) { try { dbContext.Add(new IdempotentId(idempotentId, dateTime.GetUtcNow().UtcDateTime)); await dbContext.SaveChangesAsync(cancellationToken); return true; } catch (UniqueConstraintException e) when (e.ConstraintName.Contains(ConstraintName)) { logger.LogInformation(e, "The given idempotentId [{IdempotentId}] already exists in the storage.", idempotentId); return false; } } }
همانطور که مشخص است در اینجا سعی شدهاست تا با شناسهی دریافتی، یک رکورد جدید ثبت شود که در صورت بروز خطای UniqueConstraint، خروجی با مقدار false را خروجی خواهد داد که می توان از آن نتیجه گرفت که این درخواست قبلا دریافت و پردازش شدهاست (در ادامه نحوهی استفاده از آن را خواهیم دید).
در این پیاده سازی از کتابخانه MediatR استفاده می کنیم؛ در همین راستا برای مدیریت تراکنش ها به صورت زیر می توان TransactionBehavior را پیاده سازی کرد:
internal sealed class TransactionBehavior<TRequest, TResponse>( AppDbContext dbContext, ILogger<TransactionBehavior<TRequest, TResponse>> logger) : IPipelineBehavior<TRequest, TResponse> where TRequest : IBaseCommand where TResponse : IErrorOr { public async Task<TResponse> Handle( TRequest command, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken) { string commandName = typeof(TRequest).Name; await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.ReadCommitted, cancellationToken); TResponse? result; try { logger.LogInformation("Begin transaction {TransactionId} for handling {CommandName} ({@Command})", transaction.TransactionId, commandName, command); result = await next(); if (result.IsError) { await transaction.RollbackAsync(cancellationToken); logger.LogInformation("Rollback transaction {TransactionId} for handling {CommandName} ({@Command}) due to failure result.", transaction.TransactionId, commandName, command); return result; } await transaction.CommitAsync(cancellationToken); logger.LogInformation("Commit transaction {TransactionId} for handling {CommandName} ({@Command})", transaction.TransactionId, commandName, command); } catch (Exception ex) { await transaction.RollbackAsync(cancellationToken); logger.LogError(ex, "An exception occured within transaction {TransactionId} for handling {CommandName} ({@Command})", transaction.TransactionId, commandName, command); throw; } return result; } }
در اینجا مستقیما AppDbContext تزریق شده و با استفاده از خصوصیت Database آن، کار مدیریت تراکنش انجام شدهاست. همچنین باتوجه به اینکه برای مدیریت خطاها از کتابخانهی ErrorOr استفاده می کنیم و خروجی همهی Command های سیستم، حتما یک وهله از کلاس ErrorOr است که واسط IErrorOr را پیاده سازی کردهاست، یک محدودیت روی تایپ جنریک اعمال کردیم که این رفتار، فقط برروی IBaseCommand ها اجرا شود. تعریف واسط IBaseCommand به شکل زیر میباشد:
/// <summary> /// This is marker interface which is used as a constraint of behaviors. /// </summary> public interface IBaseCommand { } public interface ICommand : IBaseCommand, IRequest<ErrorOr<Unit>> { } public interface ICommand<T> : IBaseCommand, IRequest<ErrorOr<T>> { } public interface ICommandHandler<in TCommand> : IRequestHandler<TCommand, ErrorOr<Unit>> where TCommand : ICommand { Task<ErrorOr<Unit>> IRequestHandler<TCommand, ErrorOr<Unit>>.Handle(TCommand request, CancellationToken cancellationToken) { return Handle(request, cancellationToken); } new Task<ErrorOr<Unit>> Handle(TCommand command, CancellationToken cancellationToken); } public interface ICommandHandler<in TCommand, T> : IRequestHandler<TCommand, ErrorOr<T>> where TCommand : ICommand<T> { Task<ErrorOr<T>> IRequestHandler<TCommand, ErrorOr<T>>.Handle(TCommand request, CancellationToken cancellationToken) { return Handle(request, cancellationToken); } new Task<ErrorOr<T>> Handle(TCommand command, CancellationToken cancellationToken); }
در ادامه برای پیادهسازی IdempotencyBehavior و محدود کردن آن، واسط IIdempotentCommand را به شکل زیر خواهیم داشت:
/// <summary> /// This is marker interface which is used as a constraint of behaviors. /// </summary> public interface IIdempotentCommand { string IdempotentId { get; } } public abstract class IdempotentCommand : ICommand, IIdempotentCommand { public string IdempotentId { get; init; } = string.Empty; } public abstract class IdempotentCommand<T> : ICommand<T>, IIdempotentCommand { public string IdempotentId { get; init; } = string.Empty; }
در اینجا یک پراپرتی، برای نگهداری شناسهی درخواست دریافتی با نام IdempotentId در نظر گرفته شدهاست. این پراپرتی باید از طریق مقداری که از هدر درخواست HTTP دریافت میکنیم مقداردهی شود. به عنوان مثال برای ثبت کاربر جدید، به شکل زیر باید عمل کرد:
[HttpPost] public async Task<ActionResult<long>> Register( [FromBody] RegisterUserCommand command, [FromIdempotencyToken] string idempotentId, CancellationToken cancellationToken) { command.IdempotentId = idempotentId; var result = await sender.Send(command, cancellationToken); return result.ToActionResult(); }
در اینجا از همان Command به عنوان DTO ورودی استفاده شدهاست که وابسته به سطح Backward compatibility مورد نیاز، می توان از DTO مجزایی هم استفاده کرد. سپس از طریق FromIdempotencyToken سفارشی، شناسهی درخواست، دریافت شده و بر روی command مورد نظر، تنظیم شدهاست.
رفتار سفارشی IdempotencyBehavior از ۲ بخش تشکیل شدهاست؛ در قسمت اول سعی می شود، قبل از اجرای هندلر مربوط به command مورد نظر، شناسهی دریافتی را در storage تعبیه شده ثبت کند:
internal sealed class IdempotencyBehavior<TRequest, TResponse>( IIdempotencyStorage storage, ILogger<IdempotencyBehavior<TRequest, TResponse>> logger) : IPipelineBehavior<TRequest, TResponse> where TRequest : IIdempotentCommand where TResponse : IErrorOr { public async Task<TResponse> Handle( TRequest command, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken) { string commandName = typeof(TRequest).Name; if (string.IsNullOrWhiteSpace(command.IdempotentId)) { logger.LogWarning( "The given command [{CommandName}] ({@Command}) marked as idempotent but has empty IdempotentId", commandName, command); return await next(); } if (await storage.TryPersist(command.IdempotentId, cancellationToken) == false) { return (dynamic)Error.Conflict( $"The given command [{commandName}] with idempotent-id [{command.IdempotentId}] has already been received and processed."); } return await next(); } }
در اینجا IIdempotencyStorage تزریق شده و در صورتی که امکان ذخیره سازی وجود نداشته باشد، خطای Confilict که بهخطای 409 ترجمه خواهد شد، برگشت داده میشود. در غیر این صورت ادامهی عملیات اصلی باید اجرا شود. پس از آن اگر به هر دلیلی در زمان پردازش عملیات اصلی، درخواست همزمانی با همان شناسه، توسط سرور دریافت شده و پردازش شود، عملیات جاری با خطای UniqueConstaint برروی PK_IdempotentId در زمان نهایی سازی تراکنش جاری، مواجه خواهد شد. برای این منظور بخش دوم این رفتار به شکل زیر خواهد بود:
internal sealed class IdempotencyExceptionBehavior<TRequest, TResponse>(IIdempotencyStorage storage) : IPipelineBehavior<TRequest, TResponse> where TRequest : IIdempotentCommand where TResponse : IErrorOr { public async Task<TResponse> Handle( TRequest command, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(command.IdempotentId)) return await next(); string commandName = typeof(TRequest).Name; try { return await next(); } catch (Exception ex) when (storage.IsKnownException(ex)) { return (dynamic)Error.Conflict( $"The given command [{commandName}] with idempotent-id [{command.IdempotentId}] has already been received and processed."); } } }
در اینجا عملیات اصلی در بدنه try اجرا شده و در صورت بروز خطایی مرتبط با Idempotency، خروجی Confilict برگشت داده خواهد شد. باید توجه داشت که نحوه ثبت رفتارهای تعریف شده تا اینجا باید به ترتیب زیر انجام شود:
services.AddMediatR(config => { config.RegisterServicesFromAssemblyContaining(typeof(DependencyInjection)); // maintaining the order of below behaviors is crucial. config.AddOpenBehavior(typeof(LoggingBehavior<,>)); config.AddOpenBehavior(typeof(IdempotencyExceptionBehavior<,>)); config.AddOpenBehavior(typeof(TransactionBehavior<,>)); config.AddOpenBehavior(typeof(IdempotencyBehavior<,>)); });
به این ترتیب بدنه اصلی هندلرهای موجود در سیستم هیچ تغییری نخواهند داشت و به صورت ضمنی و انتخابی، امکان تعیین command هایی که نیاز است به صورت Idempotent اجرا شوند را خواهیم داشت.
References
https://www.mscharhag.com/p/rest-api-design
Primary Constructors چیست؟
Primary Constructors، قابلیتی است که به C# 12 اضافه شدهاست تا توسط آن بتوان خواص را مستقیما توسط پارامترهای سازندهی یک کلاس تعریف و همچنین مقدار دهی کرد. هدف از آن، کاهش قابل ملاحظهی یکسری کدهای تکراری و مشخص است تا به کلاسهایی زیباتر، کمحجمتر و خواناتر برسیم. برای مثال کلاس متداول زیر را درنظر بگیرید:
public class Employee { public string FirstName { get; set; } public string LastName { get; set; } public DateTime HireDate { get; set; } public decimal Salary { get; set; } public Employee(string firstName, string lastName, DateTime hireDate, decimal salary) { FirstName = firstName; LastName = lastName; HireDate = hireDate; Salary = salary; } }
اکنون اگر بخواهیم همین کلاس را با استفاده از ویژگی Primary Constructor اضافه شده به C# 12.0 بازنویسی کنیم، به قطعه کد زیر میرسیم:
public class Employee(string firstName, string lastName, DateTime hireDate, decimal salary) { public string FirstName { get; set; } = firstName; public string LastName { get; set; } = lastName; public DateTime HireDate { get; set; } = hireDate; public decimal Salary { get; set; } = salary; }
var employee = new Employee("John", "Doe", new DateTime(2020, 1, 1), 50000);
یک نکته: اگر از Rider و یا ReSharper استفاده میکنید، یک چنین Refactoring توکاری جهت سهولت کار، به آنها اضافه شدهاست و به سرعت میتوان این تبدیلات را توسط آنها انجام داد.
توضیحات:
- متد سازنده در این حالت، به ظاهر حذف شده و به قسمت تعریف کلاس منتقل شدهاست.
- تمام مقدار دهیهای آغازین موجود در متد سازندهی پیشین نیز حذف شدهاند و مستقیما به قسمت تعریف خواص، منتقل شدهاند.
در نتیجه از یک کلاس 15 سطری، به کلاسی 7 سطری رسیدهایم که کاهش حجم قابل ملاحظهای را پیدا کردهاست.
نکته 1: هیچ ضرورتی وجود ندارد که به همراه یک primary constructor، خواصی هم مانند مثال فوق ارائه شوند؛ چون پارامترهای آن در تمام اعضای این کلاس، به همین شکل، قابل دسترسی هستند. در این مثال صرفا جهت بازسازی کد قبلی، این خواص اضافی را مشاهده میکنید. یعنی اگر تنها قرار بود، کار تزریق وابستگیها صورت گیرد که عموما به همراه تعریف فیلدهایی جهت انتساب پارامترهای متد سازنده به آنها است، استفاده از یک primary constructor، کدهای فوق را بیش از این هم فشردهتر میکرد و ... یک سطری میشد.
نکته 2: استفاده از پارامترهای سازندهی اولیه، صرفا جهت مقدار دهی خواص عمومی یک کلاس، یک code smell هستند! چون میتوان یک چنین کارهایی را به نحو شکیلتری توسط required properties معرفی شدهی در C# 11، پیاده سازی کرد.
بررسی تاریخچهی primary constructors
همانطور که در مقدمهی بحث نیز عنوان شد، primary constructors قابلیت جدیدی نیست و برای نمونه به همراه C# 9 و مفهوم جدید رکوردهای آن، ارائه شد:
public record class Book(string Title, string Publisher);
پس از آن در C# 10، این توسعه ادامه یافت و به امکان تعریف record structها، بسط یافت که در اینجا هم قابلیت تعریف primary constructors وجود دارد:
public record struct Color(int R, int G, int B);
پس از این مقدمات، اکنون در C# 12 نیز میتوان primary constructors را به تمام کلاسها و structهای معمولی هم اعمال کرد؛ با این تفاوت که در اینجا برخلاف رکوردها، کدهای خواصهای متناظر، به صورت خودکار تولید نمیشوند و اگر به آنها نیاز دارید، باید آنها را همانند مثال ابتدای بحث، خودتان به صورت دستی تعریف کنید.
primary constructors کلاسها و structهای معمولی، با primary constructors رکوردها یکی نیست
در C# 12 و به همراه معرفی primary constructors مخصوص کلاسها و structهای معمولی آن، از روش متفاوتی برای دسترسی به پارامترهای تعریف شده، استفاده میکند که به آن capturing semantics هم میگویند. در این حالت پارامترهای تعریف شدهی در یک primary constructor، توسط هر عضوی از آن کلاس قابل استفادهاست که یکی از کاربردهای آن، ساده کردن تعاریف تزریق وابستگیها است. در این حالت دیگر نیازی نیست تا ابتدا یک فیلد را برای انتساب به پارامتر تزریق شده تعریف کرد و سپس از آن فیلد، استفاده نمود؛ مستقیما میتوان با همان پارامتر تعریف شده، در متدها و اعضای کلاس، کار کرد.
برای مثال سرویس زیر را که از تزریق وابستگیها، در سازندهی خود استفاده میکند، درنظر بگیرید:
public class MyService { private readonly IDepedent _dependent; public MyService(IDependent dependent) { _dependent = dependent; } public void Do() { _dependent.DoWork(); } }
public class MyService(IDependent dependent) { public void Do() { dependent.DoWork(); } }
البته مفهوم Captures هم در زبان #C جدید نیست و در ابتدا به همراه anonymous methods و بعدها به همراه lambda expressions، معرفی و بکار گرفته شد. برای مثال درون یک lambda expression، اگر از متغیری خارج از آن lambda expressions استفاده شود، کامپایلر یک capture از آن متغیر را تهیه کرده و استفاده میکند.
بنابراین به صورت خلاصه primary constructors در رکوردها، با هدف تعریف خواص عمومی فقط خواندنی، ارائه شدند؛ اما primary constructors ارائه شدهی در C# 12 که اینبار قابل اعمال به کلاسها و structs معمولی است، بیشتر هدف ساده سازی تعریف کدهای تکراری private fields را دنبال میکند. برای نمونه این کدی است که کامپایلر برای primary constructor مثال ابتدای بحث تولید میکند و در اینجا نحوهی تولید خودکار این فیلدهای خصوصی را مشاهده میکنید:
using System; using System.Diagnostics; using System.Runtime.CompilerServices; namespace CS8Tests { [NullableContext(1)] [Nullable(0)] public class Employee { [CompilerGenerated] [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string <FirstName>k__BackingField; [CompilerGenerated] [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string <LastName>k__BackingField; [CompilerGenerated] [DebuggerBrowsable(DebuggerBrowsableState.Never)] private DateTime <HireDate>k__BackingField; [CompilerGenerated] [DebuggerBrowsable(DebuggerBrowsableState.Never)] private Decimal <Salary>k__BackingField; public Employee(string firstName, string lastName, DateTime hireDate, Decimal salary) { this.<FirstName>k__BackingField = firstName; this.<LastName>k__BackingField = lastName; this.<HireDate>k__BackingField = hireDate; this.<Salary>k__BackingField = salary; base..ctor(); } public string FirstName { [CompilerGenerated] get { return this.<FirstName>k__BackingField; } [CompilerGenerated] set { this.<FirstName>k__BackingField = value; } } public string LastName { [CompilerGenerated] get { return this.<LastName>k__BackingField; } [CompilerGenerated] set { this.<LastName>k__BackingField = value; } } public DateTime HireDate { [CompilerGenerated] get { return this.<HireDate>k__BackingField; } [CompilerGenerated] set { this.<HireDate>k__BackingField = value; } } public Decimal Salary { [CompilerGenerated] get { return this.<Salary>k__BackingField; } [CompilerGenerated] set { this.<Salary>k__BackingField = value; } } } }
یک نکته: برای مشاهدهی یک چنین کدهایی میتوانید از منوی Tools->IL Viewer برنامهی Rider استفاده کرده و در برگهی ظاهر شده، گزینهی #Low-Level C آنرا انتخاب نمائید.
امکان تعریف سازندههای دیگر، به همراه سازندهی اولیه
اگر به کدهای #Low-Level C تولیدی فوق دقت کنید، این کلاس، به همراه یک سازندهی خالی بدون پارامتر (parameter less constructor) نیست و سازندهی پیشفرضی (default constructor) برای آن درنظر گرفته نشدهاست ... اما اگر کلاسی به همراه یک primary constructor تعریف شد، میتوان با استفاده از واژهی کلیدی this، سازندهی ثانویهای را هم برای آن تعریف کرد:
public class Person(string firstName, string lastName) { public Person() : this("John", "Smith") { } public Person(string firstName) : this(firstName, "Smith") { } public string FullName => $"{firstName} {lastName}"; }
امکان ارثبری و تعریف سازندهی اولیه
مثال زیر را درنظر بگیرید که در آن کلاس مشتق شدهی از کلاس User، یک سازندهی اولیه را تعریف کرده:
public class User { public User(string firstName, string lastName) { } } public class Editor(string firstName, string lastName) : User { }
البته این محدودیت با structها وجود ندارد؛ چون structها، value type هستند و همواره به صورت پیشفرض، به همراه یک سازندهی پیش فرض بدون پارامتر، تولید میشوند.
یک مثال: قطعه کد متداول ارثبری زیر را درنظر بگیرید که در آن، کلاس مشتق شده به کمک واژهی کلید base، امکان تعریف سازندهی جدیدی را یافته و یکی از پارامترهای سازندهی کلاس پایه را مقدار دهی میکند:
public class Automobile { public Automobile(int wheels, int seats) { Wheels = wheels; Seats = seats; } public int Wheels { get; } public int Seats { get; } } public class Car : Automobile { public Car(int seats) : base(4, seats) { } }
public class Automobile(int wheels, int seats) { public int Wheels { get; } = wheels; public int Seats { get; } = seats; } public class Car(int seats) : Automobile(4, seats);
و یا یک نمونه مثال دیگر آن به صورت زیر است که در آن، ذکر بدنهی کلاس در C# 12، الزامی ندارد:
public class MyBaseClass(string s); // no body required public class Derived(int i, string s, bool b) : MyBaseClass(s) { public int I { get; set; } = i; public string B => b.ToString(); }
توصیه به پرهیز از double capturing
با مفهوم capture در این مطلب آشنا شدیم. در مثال زیر دوبار از پارامتر سازندهی age، در دو قسمت عمومی شده، استفاده شدهاست:
public class Human(int age) { // initialization public int Age { get; set; } = age; // capture public string Bio => $"My age is {age}!"; }
var p = new Human(42); Console.WriteLine(p.Age); // Output: 42 Console.WriteLine(p.Bio); // Output: My age is 42! p.Age++; Console.WriteLine(p.Age); // Output: 43 Console.WriteLine(p.Bio); // Output: My age is 42! // !
درک بهتر آن، نیاز به #Low-Level C کلاس Human را دارد:
using System.Diagnostics; using System.Runtime.CompilerServices; namespace CS8Tests { [NullableContext(1)] [Nullable(0)] public class Human { [CompilerGenerated] [DebuggerBrowsable(DebuggerBrowsableState.Never)] private int <age>P; [CompilerGenerated] [DebuggerBrowsable(DebuggerBrowsableState.Never)] private int <Age>k__BackingField; public Human(int age) { this.<age>P = age; this.<Age>k__BackingField = this.<age>P; base..ctor(); } public int Age { [CompilerGenerated] get { return this.<Age>k__BackingField; } [CompilerGenerated] set { this.<Age>k__BackingField = value; } } public string Bio { get { DefaultInterpolatedStringHandler interpolatedStringHandler = new DefaultInterpolatedStringHandler(11, 1); interpolatedStringHandler.AppendLiteral("My age is "); interpolatedStringHandler.AppendFormatted<int>(this.<age>P); interpolatedStringHandler.AppendLiteral("!"); return interpolatedStringHandler.ToStringAndClear(); } } } }
public Human(int age) { this.<age>P = age; this.<Age>k__BackingField = this.<age>P; base..ctor(); }
امکان تعریف alias، قابلیت جدیدی نیست!
در نگارشهای پیشین زبان #C نیز میتوان برای نوعهای نامدار داتنت، alias/«نام مستعار» تعریف کرد؛ برای مثال:
using MyConsole = System.Console; MyConsole.WriteLine("Test console");
بنابراین دو حالت تعریف Namespace alias برای کوتاه سازی فضاهای نام طولانی و یا تعریف Type alias برای معرفی یک نام مستعار جدید برای نوعی مشخص، میسر است:
// Namespace alias using SuperJSON = System.Text.Json; var document = SuperJSON.JsonSerializer.Serialize("{}"); // Type alias using SuperJSON = System.Text.Json.JsonSerializer; var document = SuperJSON.Serialize("{}");
تنها کارکرد نامهای مستعار، کوتاه و زیبا سازی نامهای طولانی نیستند. برای مثال گاهی از اوقات ممکن است که بین نام نوعهای موجود در usingهای جاری، تداخل حاصل شود و برنامه کامپایل نشود. برای مثال فرض کنید که دو using زیر را تعریف کردهاید:
using UnityEngine; using System; Random rnd = new Random();
var rnd = new System.Random();
using Random = System.Random;
امکان تعریف alias برای هر نوعی در C# 12.0
محدودیت امکان تعریف alias برای نوعهای نامدار داتنت در C# 12.0 برطرف شده و اکنون میتوان برای انواع و اقسام نوعها مانند آرایهها، tuples و غیره نیز alias تعریف کرد:
using Ints = int[]; using DatabaseInt = int?; using OptionalFloat = float?; using Grade= decimal; using Point3D = (int, int, int); using Person = (string name, int age, string country); using unsafe P = char*; using Matrix = int[][]; Matrix aMatrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];
یک نکته: امکان تعریف alias برای nullable reverence types وجود ندارد.
بررسی یک مثال C# 12.0
در اینجا محتویات یک فایل Program.cs یک برنامهی کنسول داتنت 8 را مشاهده میکنید:
using MyConsole = System.Console; using Person = (string name, int age, string country); Person person = new("User 1", 33, "Iran"); Console.WriteLine(person); PrintPerson(person); MyConsole.WriteLine("Test console"); static void PrintPerson(Person person) { MyConsole.WriteLine($"{person.name}, {person.age}, {person.country}"); }
(User 1, 33, Iran) User 1, 33, Iran Test console
چه زمانی بهتر است از قابلیت تعریف نامهای مستعار نوعها و یا فضاهای نام استفاده شود؟
اگر یک نام طولانی را بتوان به این صورت خلاصه کرد، مفید هستند؛ برای مثال ساده سازی تعریف یک لیست طولانی به صورت زیر:
using Companies = System.Collections.Generic.List<Company>; Companies GetCompanies() { // logic here } class Company { public string Name; public int Id; }
using EventHandlers = System.Collections.Generic.IEnumerable<System.Func<System.Threading.Tasks.Task>>;
و یا اگر بتوانند رفع تداخلی را حاصل کنند، بکارگیری آنها ضروری است (مانند مثال شیء Random ابتدای بحث) و یا اگر بتوانند از تکرار تعریف یک tuple جلوگیری کنند، ذکر آنها یک refactoring مثبت بهشمار میرود؛ مانند مثال زیر که در آن از تعریف نوع tuple ای، دوبار استفاده شدهاست:
using Country = (string Abbreviation, string Name); Country GetCountry(string abbreviation) { // Logic here } List<Country> GetCountries() { // Logic here }
using Ints = int[]; using DatabaseInt = int?; using OptionalFloat = float?;
میدان دید نامهای مستعار
به صورت پیشفرض، تمام نامهای مستعار تنها در داخل همان فایلی که تعریف شدهاند، قابل استفاده میباشند. از زمان C# 10.0 ، میتوان پیش از واژهی کلیدی using از واژهی کلیدی global نیز استفاده کرد تا تعریف آنها فقط در پروژهی جاری به صورت سراسری قابل دسترسی شود.
به همین جهت اگر نوعی قرار است در سایر پروژهها استفاده شود، بهتر است از global using استفاده نشده و از همان روشهای متداول تعریف records و یا classes استفاده شود.