مطالب
دسترسی به فیلد های Static در XAML
MarkupExtension ها قبلا در اینجا توضیح داده شده اند. یکی از MarkupExtension‌های از پیش تعریف شده x:Static است که برای مقداردهی یک خصوصیت در XAML با یک مقدار استاتیک استفاده می‌شود.اگر بخواهید از یک ثابت(constant)،یک خصوصیت استاتیک(static property)، یا یک مقدار از یک enumeration، برای مقداردهی یک خصوصیت در XAML استفاده کنید باید از این MarkupExtension استفاده کنید.
 برای مثال برای یک استفاده از یک خصوصیت استایک به صورت زیر عمل می‌کنیم:
 namespace Test
 { 
   public class Constants
   {
      public static readonly string ConstantString = "Test"; 
   }
 }
 توجه داشته باشید که برای استفاده از این ثابت باید ابتدا فضای نام مربوط به آن را تعریف کنید.
xmlns:test="clr-namespace:ItemTest "
<Label Content="{x:Static test:Constants.ConstantString}" />
و یا برای مقدار دهی از طریق یک Enumeration 
namespace Test
{
  public enum VisiblityEnum
     {
         Collapse,
         Hidden,
         Visible
     };
}
و در فایل XAML:
 xmlns:test="clr-namespace:Test"
<Label Content="{x:Static test:VisiblityEnum.Collapse}" />
برای استفاده از یک ثابت نیز به همین صورت عمل می‌کنیم. 
مطالب
خروجی Excel با حجم بالا در برنامه‌های ‌ASP.NET Core با استفاده از MiniExcel

امکان خروجی اکسل از گزارشات سیستم، یکی از بایدهای بیشتر سیستم‌های اطلاعاتی می‌باشد؛ یکی از چالش‌های اصلی در تولید این نوع خروجی، افزایش مصرف حافظه متناسب با افزایش حجم دیتا می‌باشد. از آنجایی‌که بیشتر راهکارهای موجود از جمله ClosedXml یا Epplus کل ساختار را ابتدا تولید کرده و اصطلاحا خروجی مورد نظر را بافر می‌کنند، برای حجم بالای اطلاعات مناسب نخواهند بود. راهکار برای خروجی CSV به عنوان مثال خیلی سرراست می‌باشد و می‌توان با چند خط کد، به نتیجه دلخواه از طریق مکانیزم Streaming رسید؛ ولی ساختار Excel به سادگی فرمت CSV نیست و برای مثال فرمت Excel Workbook با پسوند xlsx یک بسته Zip شده‌ای از فایل‌های XML می‌باشد.

معرفی MiniExcel

MiniExcel یک کتابخانه سورس باز با هدف به حداقل رساندن مصرف حافظه در زمان پردازش فایل‌های Excel در دات نت می‌باشد. در مقایسه با Aspose از منظر امکانات شاید حرفی برای گفتن نداشته باشد، ولی از جهت خواندن اطلاعات فایل‌های Excel با قابلیت پشتیبانی از ‌LINQ و Deferred Execution در کنار مصرف کم حافظه و جلوگیری از مشکل OOM خیلی خوب عمل می‌کند. در تصویر زیر مشخص است که برای عمده عملیات پیاده‌سازی شده، از استریم‌ها بهره برده شده است.

همچنین در زیر مقایسه‌ای روی خروجی ۱ میلیون رکورد با تعداد ۱۰ ستون در هر ردیف انجام شده‌است که قابل توجه می‌باشد:

Logic : create a total of 10,000,000 "HelloWorld" excel
LibraryMethodMax Memory UsageMean
MiniExcel'MiniExcel Create Xlsx'15 MB11.53181 sec
Epplus'Epplus Create Xlsx'1,204 MB22.50971 sec
OpenXmlSdk'OpenXmlSdk Create Xlsx'2,621 MB42.47399 sec
ClosedXml'ClosedXml Create Xlsx'7,141 MB140.93992 sec

به شدت API خوش دستی برای استفاده دارد و شاید مطالعه سورس کد آن از جهت طراحی نیز درس آموزی داشته باشد. در ادامه چند مثال از مستندات آن را می‌توانید ملاحظه کنید:

var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xlsx");
MiniExcel.SaveAs(path, new[] {
    new { Column1 = "MiniExcel", Column2 = 1 },
    new { Column1 = "Github", Column2 = 2}
});

// DataReader export multiple sheets (recommand by Dapper ExecuteReader)

using (var cnn = Connection)
{
    cnn.Open();
    var sheets = new Dictionary<string,object>();
    sheets.Add("sheet1", cnn.ExecuteReader("select 1 id"));
    sheets.Add("sheet2", cnn.ExecuteReader("select 2 id"));
    MiniExcel.SaveAs("Demo.xlsx", sheets);
}

طراحی یک ActionResult سفارشی برای استفاده از MiniExcel

برای این منظور نیاز است تا Stream مربوط به Response درخواست جاری را در اختیار این کتابخانه قرار دهیم و از سمت دیگر دیتای مورد نیاز را به نحوی که بافر نشود و از طریق مکانیزم Streaming در EF (استفاده از Deferred Execution و Enumerableها) مهیا کنیم. برای امکان تعویض پذیری (این سناریو در پروژه واقعی و باتوجه به جهت وابستگی‌ها می‌تواند ضروری باشد) از دو واسط زیر استفاده خواهیم کرد:

public interface IExcelDocumentFactory
{
    ILargeExcelDocument CreateLargeDocument(IEnumerable<ExcelColumn> headers, Stream stream);
}


public interface ILargeExcelDocument : IAsyncDisposable, IDisposable
{
    Task Write<T>(
        PaginatedEnumerable<T> items,
        int count,
        int sizeLimit,
        CancellationToken cancellationToken = default) where T : notnull;
}

متد CreateLargeDocument یک وهله از ILargeExcelDocument را در اختیار مصرف کننده قرار می‌دهد که قابلیت نوشتن روی آن از طریق متد Write را خواهد داشت. روش واکشی دیتا از طریق Delegate تعریف شده با نام PaginatedEnumerable به مصرف کننده محول شده‌است که در ادامه امضای آن را می‌توانید مشاهده کنید:

public delegate IEnumerable<T> PaginatedEnumerable<out T>(int page, int pageSize);

در ادامه پیاده‌سازی واسط ILargeExcelDocument برای MiniExcel به شکل زیر خواهد بود:

internal sealed class MiniExcelDocument(Stream stream, IEnumerable<ExcelColumn> columns) : ILargeExcelDocument
{
    private const int SheetLimit = 1_048_576;
    private bool _disposedValue;

    public async Task Write<T>(
        PaginatedEnumerable<T> items,
        int count,
        int sizeLimit,
        CancellationToken cancellationToken = default)
        where T : notnull
    {
        ThrowIfDisposed();
        
        // TODO: apply sizeLimit
        var properties = FastReflection.Instance.GetProperties(typeof(T))
            .ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase);

        var sheets = new Dictionary<string, object>();
        var index = 1;
        while (count > 0)
        {
            cancellationToken.ThrowIfCancellationRequested();

            IEnumerable<Dictionary<string, object>> reader = items(index, SheetLimit)
                .Select(item =>
                {
                    cancellationToken.ThrowIfCancellationRequested();
                    return columns.ToDictionary(h => h.Title, h => ValueOf(item, h.Name, properties));
                });

            sheets.Add($"sheet_{index}", reader);
            count -= SheetLimit;
            index++;
        }

        // This part is forward-only, and we are pretty sure that streaming will happen without buffering.
        await stream.SaveAsAsync(sheets, cancellationToken: cancellationToken);
    }

    private void Dispose(bool disposing)
    {
        if (!_disposedValue)
        {
            if (disposing)
            {
                // TODO: dispose managed state (managed objects)
            }

            // TODO: free unmanaged resources (unmanaged objects) and override finalizer
            // TODO: set large fields to null
            _disposedValue = true;
        }
    }

    ~MiniExcelDocument()
    {
        Dispose(disposing: false);
    }

    public void Dispose()
    {
        // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }

    public async ValueTask DisposeAsync()
    {
        Dispose();
        await ValueTask.CompletedTask;
    }

    private void ThrowIfDisposed()
    {
        if (!_disposedValue) return;
        
        throw new ObjectDisposedException(nameof(MiniExcelDocument));
    }
    private static object ValueOf<T>(T record, string prop, IDictionary<string, FastPropertyInfo> properties)
        where T : notnull
    {
        var property = properties[prop] ??
                       throw new InvalidOperationException($"There is no property with given name [{prop}]");

        return NormalizeValue(property.GetValue?.Invoke(record));
    }

    private static object NormalizeValue(object? value)
    {
        if (value == null) return null!;

        return value switch
        {
            DateTime dateTime => dateTime.ToShortPersianDateTimeString(),
            TimeSpan time => time.ToString(@"hh\:mm\:ss"),
            DateOnly dateTime => dateTime.ToShortPersianDateString(false),
            TimeOnly time => time.ToString(@"hh\:mm\:ss"),
            bool boolean => boolean ? "بلی" : "خیر",
            IEnumerable<object> values => string.Join(',', values.Select(NormalizeValue).ToList()),
            Enum enumField => enumField.GetEnumStringValue(),
            _ => value
        };
    }
}

در بدنه متد Write باتوجه به تعداد کل رکوردها، یک کوئری برای هر شیت از طریق فراخوانی متد منتسب به پارامتر items اجرا خواهد شد؛ توجه کنید که اجرای این کوئری مشخصا به تعویق افتاده و تا زمان اولین MoveNext، اجرایی صورت نخواهد گرفت (مفهوم Deferred Execution). به این ترتیب باقی کارها از جمله فرمت کردن مقادیر در سمت برنامه و از طریق Linq To Object انجام خواهد شد. همچنین پیاده‌سازی Factory مرتبط با آن به شکل زیر خواهد بود:

internal sealed class ExcelDocumentFactory : IExcelDocumentFactory
{
    public ILargeExcelDocument CreateLargeDocument(IEnumerable<ExcelColumn> columns, Stream stream)
    {
        return new MiniExcelDocument(stream, columns);
    }
}

در ادامه ActionResult سفارشی برای گرفتن خروجی اکسل را به شکل زیر می توان پیاده‌سازی کرد:

public class ExcelExportResult<T>(PaginatedEnumerable<T> items, int count, ExportMetadata metadata) : ActionResult
    where T : notnull
{
    private const string ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
    private const string Extension = ".xlsx";
    private const int SizeLimit = int.MaxValue;

    private readonly IReadOnlyList<FastPropertyInfo> _properties = FastReflection.Instance.GetProperties(typeof(T));

    public override async Task ExecuteResultAsync(ActionContext context)
    {
        var sp = context.HttpContext.RequestServices;
        var factory = sp.GetRequiredService<IExcelDocumentFactory>();

        var disposition = new ContentDispositionHeaderValue(DispositionTypeNames.Attachment);
        disposition.SetHttpFileName(MakeFilename());

        context.HttpContext.Response.Headers[HeaderNames.ContentDisposition] = disposition.ToString();
        context.HttpContext.Response.Headers.Append(HeaderNames.ContentType, ContentType);
        context.HttpContext.Response.StatusCode = StatusCodes.Status200OK;

        //TODO: deal with exception, because our global exception handling cannot take into account while the response is started.

        await using var bodyStream = context.HttpContext.Response.BodyWriter.AsStream();
        await context.HttpContext.Response.StartAsync(context.HttpContext.RequestAborted);
        await using (var document = factory.CreateLargeDocument(MakeColumns(), bodyStream))
        {
            await document.Write(items, count, SizeLimit, context.HttpContext.RequestAborted);
        }

        await context.HttpContext.Response.CompleteAsync();
    }

    private string MakeFilename()
    {
        return
            $"{metadata.Title} - {DateTime.UtcNow.ToEpochSeconds()}{Extension}";
    }

    private IEnumerable<ExcelColumn> MakeColumns()
    {
        var types = _properties.ToDictionary(p => p.Name, p => p.PropertyType, StringComparer.OrdinalIgnoreCase);
        return metadata.Fields.Select(f =>
        {
            var type = types[f.Name];

            type = Nullable.GetUnderlyingType(type) ?? type;

            if (type.IsEnum ||
                type == typeof(DateOnly) ||
                type == typeof(TimeOnly) ||
                type == typeof(bool) ||
                type == typeof(TimeSpan) ||
                type == typeof(DateTime))
            {
                type = typeof(string);
            }

            return new ExcelColumn(f.Name, f.Title, type);
        });
    }
}

در اینجا از طریق ExportMetadata که از سمت کاربر تعیین می‌شود، مشخص خواهد شد که کدام فیلدها در فایل نهایی حضور داشته باشند. در بدنه متد ExecuteResultAsync یکسری هدر مرتبط با کار با فایل‌ها تنظیم شده‌است و سپس از طریق BodyWriter و متد AsStream به استریم مورد نظر دست یافته و در اختیار متد Write مربوط به document ایجاد شده، قرار داده‌ایم. یک نمونه استفاده از آن برای موجودیت فرضی مشتری می تواند به شکل زیر باشد:

[ApiController, Route("api/customers")]
public class CustomersController(IDbContext dbContext) : ControllerBase
{
    [HttpGet("export")]
    public async Task<ActionResult> ExportCustomers([FromQuery] ExportMetadata metadata,
        CancellationToken cancellationToken)
    {
        var count = await dbContext.Set<Customer>().CountAsync(cancellationToken);
        return this.Export(
            (page, pageSize) => dbContext.Set<Customer>()
                .OrderBy(c => c.Id)
                .Skip((page - 1) * pageSize)
                .Take(pageSize)
                .AsNoTracking()
                .AsEnumerable(), // Enable streaming instead of buffering through deferred execution
            count,
            metadata);
    }
}

در اینجا از طریق Extension Method مهیا شده روش کوئری کردن برای هر شیت را مشخص کرده‌ایم؛ نکته مهم در ایجاد استفاده از ‌متد AsEnumerable می باشد که در عمل یک Type Casting انجام می دهد که باقی متدهای استفاده شده روی خروجی، از طریق Linq To Object اعمال شود و همچنین نیاز به استفاده از ToList و یا موارد مشابه را نخواهیم داشت. نمونه درخواست GET برای این API می تواند به شکل زیر باشد:

http://localhost:5118/api/customers/export?Title=Test&Fields[0].Name=FirstName&Fields[0].Title=First name&Fields[1].Name=LastName&Fields[1].Title=Last name&Fields[2].Name=BirthDate&Fields[2].Title=BirthDate

سورس کد مثال قابل اجرا از طریق مخزن زیر قابل دسترس می باشد:

https://github.com/rabbal/large-excel-streaming

در این مثال در زمان آغاز برنامه، ۱۰ میلیون رکورد در جدول Customer ثبت خواهد شد که در ادامه می توان از آن خروجی Excel تهیه کرد.

نکته مهم: توجه داشته باشید که استفاده از این روش قابلیت از سرگیری مجدد برای دانلود را نخواهد داشت و شاید بهتر است این فرآیند را از طریق یک Job انجام داده و با استفاده از قابلیت‌های Multipart Upload مربوط به یک BlobStroage مانند Minio، خروجی مورد نظر از قبل ذخیره کرده و لینک دانلودی را در اختیار کاربر قرار دهید.

مطالب
سری بررسی SQL Smell در EF Core - ایجاد روابط Polymorphic - بخش اول
سناریویی را در نظر بگیرید که برای هر کدام از مدلهای Article, Video, Event می‌خواهیم قابلیت کامنت‌گذاری جداگانه‌ای را داشته باشیم. چندین روش برای پیاده‌سازی این سناریو وجود دارد که در ادامه به آنها خواهیم پرداخت. 

Polymorphic association  
در این روش بجای تعریف چند کلید خارجی، تنها یک فیلد جنریک را تعریف خواهیم کرد که می‌تواند همزمان یک ارجاع را به مدل‌های مطرح شده داشته باشد. برای تعیین نوع کلید هم نیاز به یک فیلد دیگر جهت تعیین نوع ارجاع خواهیم داشت. در واقع با کمک آن می‌توانیم تشخیص دهیم که ارجاع موردنظر به کدام موجودیت اشاره دارد: 


public enum CommentType
{
    Article,
    Video,
    Event
}

public class Comment
{
    public int Id { get; set; }
    public string CommentText { get; set; }
    public string User { get; set; }
    public int? TypeId { get; set; }
    public CommentType CommentType { get; set; }
}

public class Article
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Slug { get; set; }
    public string Description { get; set; }
}

public class Video
{
    public int Id { get; set; }
    public string Url { get; set; }
    public string Description { get; set; }
}

public class Event
{
    public int Id { get; set; }
    public string Name { get; set; }
    public DateTimeOffset? Start { get; set; }
    public DateTimeOffset? End { get; set; }
}

public class MyDbContext : DbContext
{
    public DbSet<Article> Articles { get; set; }
    public DbSet<Video> Videos { get; set; }
    public DbSet<Event> Events { get; set; }
    public DbSet<Comment> Comments { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlite("Data Source=polymorphic.db");
}

این روش در واقع به عنوان یک Anti Pattern و SQL Smell شناخته می‌شود؛ زیرا امکان کوئری گرفتن از دیتابیس را دشوار خواهد کرد. اکثر فریم‌ورک‌های غیر دات‌نتی به صورت توکار قابلیت پیاده‌سازی این نوع ارتباط را ارائه می‌دهند. اما در Entity Framework باید به صورت دستی تنظیمات انجام شوند و همچنین به دلیل نداشتن ارجاع مستقیم (کلید خارجی) درون جدول Comments با مشکل data integrity مواجه خواهیم شد. یکی دیگر از مشکلات آن امکان درج orphaned record است؛ زیرا هیچ Constraintی بر روی Polymorphic Key تعریف نشده‌است. در این روش مدیریت واکشی اطلاعات سخت خواهد بود و در حین کوئری گرفتن دیتا باید CommentType را نیز به همراه TypeId به صورت صریحی قید کنیم:
var articleComments = dbContext.Comments
                .Where(x => x.CommentType == CommentType.Article && x.TypeId.Value == 1);
foreach (var articleComment in articleComments)
{
    Console.WriteLine(articleComment.CommentText);
}

Join Table Per Relationship Type
 یک روش دیگر ایجاد Join Table به ازای هر ارتباط است:


public class Comment
{
    public int Id { get; set; }
    public string CommentText { get; set; }
    public string User { get; set; }
    
    public virtual ICollection<ArticleComment> ArticleComments { get; set; }
    public virtual ICollection<VideoComment> VideoComments { get; set; }
    public virtual ICollection<EventComment> EventComments { get; set; }
}

public class Article
{
    public Article()
    {
        ArticleComments = new HashSet<ArticleComment>();
    }
    
    public int Id { get; set; }
    public string Title { get; set; }
    public string Slug { get; set; }
    public string Description { get; set; }
    
    public virtual ICollection<ArticleComment> ArticleComments { get; set; }

}

public class Video
{
    public Video()
    {
        VideoComments = new HashSet<VideoComment>();
    }
    
    public int Id { get; set; }
    public string Url { get; set; }
    public string Description { get; set; }
    
    public virtual ICollection<VideoComment> VideoComments { get; set; }
}

public class Event
{
    public Event()
    {
        EventComments = new HashSet<EventComment>();
    }
    
    public int Id { get; set; }
    public string Name { get; set; }
    public DateTimeOffset? Start { get; set; }
    public DateTimeOffset? End { get; set; }
    
    public virtual ICollection<EventComment> EventComments { get; set; }
}

public class MyDbContext : DbContext
{
    public DbSet<Article> Articles { get; set; }
    public DbSet<ArticleComment> ArticleComments { get; set; }
    public DbSet<Video> Videos { get; set; }
    public DbSet<VideoComment> VideoComments { get; set; }
    public DbSet<Event> Events { get; set; }
    public DbSet<EventComment> EventComments { get; set; }
    public DbSet<Comment> Comments { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlite("Data Source=polymorphic.db");

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<ArticleComment>(entity =>
        {
            entity.HasKey(e => new { e.CommentId, e.ArticleId })
                .HasName("PK_dbo.ArticleComments");

            entity.HasIndex(e => e.ArticleId)
                .HasName("IX_ArticleId");

            entity.HasIndex(e => e.CommentId)
                .HasName("IX_ArticleCommentId");

            entity.HasOne(d => d.Article)
                .WithMany(p => p.ArticleComments)
                .HasForeignKey(d => d.ArticleId)
                .HasConstraintName("FK_dbo.ArticleComments_dbo.Articles_ArticleId");

            entity.HasOne(d => d.Comment)
                .WithMany(p => p.ArticleComments)
                .HasForeignKey(d => d.CommentId)
                .HasConstraintName("FK_dbo.ArticleComments_dbo.Comments_CommentId");
        });
        
        modelBuilder.Entity<VideoComment>(entity =>
        {
            entity.HasKey(e => new { e.CommentId, e.VideoId })
                .HasName("PK_dbo.VideoComments");

            entity.HasIndex(e => e.VideoId)
                .HasName("IX_VideoId");

            entity.HasIndex(e => e.CommentId)
                .HasName("IX_VideoCommentId");

            entity.HasOne(d => d.Video)
                .WithMany(p => p.VideoComments)
                .HasForeignKey(d => d.VideoId)
                .HasConstraintName("FK_dbo.VideoComments_dbo.Videos_VideoId");

            entity.HasOne(d => d.Comment)
                .WithMany(p => p.VideoComments)
                .HasForeignKey(d => d.CommentId)
                .HasConstraintName("FK_dbo.VideoComments_dbo.Comments_CommentId");
        });
        
        modelBuilder.Entity<EventComment>(entity =>
        {
            entity.HasKey(e => new { e.CommentId, e.EventId })
                .HasName("PK_dbo.EventComments");

            entity.HasIndex(e => e.EventId)
                .HasName("IX_EventId");

            entity.HasIndex(e => e.CommentId)
                .HasName("IX_EventCommentId");

            entity.HasOne(d => d.Event)
                .WithMany(p => p.EventComments)
                .HasForeignKey(d => d.EventId)
                .HasConstraintName("FK_dbo.EventComments_dbo.Events_EventId");

            entity.HasOne(d => d.Comment)
                .WithMany(p => p.EventComments)
                .HasForeignKey(d => d.CommentId)
                .HasConstraintName("FK_dbo.EventComments_dbo.Comments_CommentId");
        });
    }
}


همانطور که مشاهده میکنید روش فوق نیاز به اضافه کردن مدلهای بیشتری دارد و همچنین تمام روابط چند به چند نیز نیاز است به صورت کامل تنظیم شوند. مزیت این روش داشتن Constraint برای تمامی کلیدهای خارجی است؛ بنابراین می‌توانیم از صحت دیتا مطمئن شویم:
var article = new Article
{
    Title = "Article A",
    Slug = "article_a",
    Description = "No Description"
};
var comment = new Comment
{
    CommentText = "It's great",
    User = "Sirwan"
};
dbContext.ArticleComments.Add(new ArticleComment
{
    Article = article,
    Comment = comment
});

dbContext.SaveChanges();

var articleOne = dbContext.Articles
    .Include(article => article.ArticleComments)
    .ThenInclude(comment => comment.Comment)
    .First(article => article.Id == 1);
var article1Comments = articleOne.ArticleComments.Select(x => x.Comment);
Console.WriteLine(article1Comments.Count());

Exclusive Belongs To  
یک روش دیگر، اضافه کردن ارجاعی به ازای هر کدام از مدلهای عنوان شده، درون موجودیت Comment می‌باشد که به صورت nullable خواهند بود. بنابراین اگر به عنوان مثال بخواهیم برای یک Article یک کامنت داشته باشیم، کلید رکورد ذخیره شده را به عنوان کلید خارجی در جدول Comments اضافه خواهیم کرد:


public class Comment
{
    public int Id { get; set; }
    public string CommentText { get; set; }
    public string User { get; set; }
    
    // Article
    public virtual Article Article { get; set; }
    public int? ArticleId { get; set; }
    
    // Video
    public virtual Video Video { get; set; }
    public int? VideoId { get; set; }
    
    // Event
    public virtual Event Event { get; set; }
    public int? EventId { get; set; }
}
public class Article
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Slug { get; set; }
    public string Description { get; set; }
    public virtual ICollection<Comment> Comments { get; set; }
}

public class Video
{
    public int Id { get; set; }
    public string Url { get; set; }
    public string Description { get; set; }
    public virtual ICollection<Comment> Comments { get; set; }
}

public class Event
{
    public int Id { get; set; }
    public string Name { get; set; }
    public DateTimeOffset? Start { get; set; }
    public DateTimeOffset? End { get; set; }
    public virtual ICollection<Comment> Comments { get; set; }
}

public class MyDbContext : DbContext
{
    public DbSet<Article> Articles { get; set; }
    public DbSet<Video> Videos { get; set; }
    public DbSet<Event> Events { get; set; }
    public DbSet<Comment> Comments { get; set; }
    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlite("Data Source=polymorphic.db");
}


این روش از لحاظ منطقی و طراحی دیتابیس بدون اشکال است؛ زیرا مقدار نامعتبری را نمی‌توانیم برای کلیدهای خارجی درج کنیم. چون برای کلیدهای تعریف شده درون جدول Comment یکسری Constraint تعریف شده‌اند که صحت دیتای ورودی را بررسی خواهند کرد. حتی در صورت نیاز نیز می‌توانیم یک Constraint ترکیبی را جهت مطمئن شدن از خالی نبودن همزمان ستون‌های FK اضافه کنیم. البته SQLite Provider از HasCheckConstraint پشتیبانی نمی‌کند، ولی اگر به عنوان مثال از MySQL استفاده می‌کنید می‌توانید Constraint موردنظر را اینگونه اضافه کنید: 
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Comment>(entity =>
        entity.HasCheckConstraint("CHECK_FKs", 
            "(`ArticleId`  IS NOT NULL) AND (`VideoId`  IS NOT NULL) AND (`EventId`  IS NOT NULL)"));
}

با طراحی فوق می‌توانیم مطمئن شویم که orphaned record نخواهیم داشت. اما اگر تعداد مدل‌ها بیشتر شوند، باید به ازای هر مدل جدید، یک ارجاع به آن را به جدول Comment اضافه کنیم که در نهایت با تعداد زیادی کلیدهای خارجی مواجه خواهیم شد که در آن واحد فقط یکی از آنها مقدار دارند و بقیه NULL خواهند شد. در مقابل، مزیت این روش، امکان کوئری نویسی ساده‌ی آن است:
var articles = dbContext.Articles
                .Include(x => x.Comments).Where(x => x.Id == 1);
foreach (var article in articles)
{
    Console.WriteLine($"{article.Title} - Comments: {article.Comments.Count}");
}
var comment = dbContext.Comments.Include(x => x.Article)
    .FirstOrDefault(x => x.Id == 1);
Console.WriteLine(comment?.Article.Title);

کدهای مطلب جاری را می‌توانید از اینجا دریافت کنید (هر مثال بر روی برنچی جدا قرار دارد)
مطالب
استفاده از مسیرهای مطلق در حین import ماژول‌ها در برنامه‌های مبتنی بر TypeScript
در حین import ماژول‌های TypeScript ایی پس از مدتی به یک چنین کدهایی خواهیم رسید:
import { SpecialCollection } from "../../special";
import { LoginComponent } from "../login";
import { TextUtils } from ".../../utils/text";
import { Router } from "../../../core/router";
در این حالت، در یک پوشه برای import ماژولی مشخص، چنین import ایی را خواهیم داشت:
import { Data } from '../data';
و در پوشه‌ی تو در توی دیگری، این تعریف به صورت زیر تغییر می‌کند:
import { Data } from '../../../data';
و در آخر برنامه پر می‌شود از مسیرهای نسبی ‘../../../..’ مانند. به این ترتیب جابجا کردن فایل‌ها و Refactoring آن‌ها، مشکل می‌شود.
خوشبختانه کامپایلر TypeScript به همراه تنظیمات baseUrl و paths است که توسط آن‌ها می‌توان این مسیرهای نسبی را به مسیرهای مطلق تبدیل کرد و در این حالت اهمیتی ندارد که ماژول مدنظر از چه سطحی و درون چه پوشه‌ی تو در تویی فراخوانی می‌شود، این مسیر import همواره ثابت خواهد بود.


تنظیمات فایل tsconfig.json برای معرفی مسیرهای مطلق ماژول‌ها

فرض کنید می‌خواهید از یکی از سرویس‌های Core Module استفاده کنید:


بسته به عمق پوشه‌ی استفاده کننده، به یک چنین تعریفی خواهید رسید:
import { BrowserStorageService } from "./../../core/browser-storage.service";
برای بهبود این وضعیت، فایل tsconfig.json و یا همان تنظیمات کامپایلر TypeScript را به نحو ذیل تکمیل می‌کنیم:
{
  "compilerOptions": {
    "baseUrl": "src",
    "paths": {
      "@app/*": [
        "app/*"
      ],
      "@app/core/*": [
        "app/core/*"
      ],
      "@app/shared/*": [
        "app/shared/*"
      ],
      "@env/*": [
        "environments/*"
      ]
    }
  }
}
در اینجا baseUrl به پوشه‌ی src برنامه اشاره می‌کند و مسیرهای بعدی بر این اساس محاسبه می‌شوند. در ادامه در قسمت paths، ابتدا یک نام مستعار ذکر می‌شود و سپس مسیری که ارائه دهنده‌ی آن است. ذکر @ در اینجا اختیاری است؛ اما ذکر */‌ها اجباری است.
پس از این تغییرات، اکنون افزونه‌ی پیشنهاد دهنده‌ی imports، هر دو حالت استفاده‌ی از مسیر مطلق بر اساس نام مستعار تعریف شده:
 import { BrowserStorageService } from "@app/core/browser-storage.service";
و یا استفاده‌ی از مسیر نسبی را نیز پیشنهاد می‌دهد:
 import { BrowserStorageService } from "./../../core/browser-storage.service";


برای مثال اگر دقت کرده باشید، روش import اجزای خود Angular به صورت زیر است:
 import { Component } from '@angular/core';
علت اینجا است که Angular از تعریف مشابهی به صورت زیر برای نگاشت پوشه‌ی node_modules آن به angular@ استفاده می‌کند:
"paths": {
    "@angular/*": ["node_modules/@angular/*"]
},
و ذکر @ اختیاری هم از اینجا اقتباس شده‌است.


یک نکته‌ی مهم: تنظیمات فوق بدون تنظیمات معادل webpack ناقص هستند

اگر از برنامه‌ی Angular CLI استفاده می‌کنید، تنظیمات ذکر شده، تا همینجا به پایان می‌رسند؛ چون webpack جزئی از Angular CLI است و تنظیمات پیش فرض آن، قسمت baseUrl و paths فایل tsconfig.json را به صورت خودکار پردازش می‌کند. اما اگر از TypeScript در محیط‌های دیگری استفاده می‌کنید که از webpack به صورت مجزایی استفاده می‌کنند، نیاز است قسمت resolve.alias فایل webpack.config.js را نیز جهت معرفی این تغییرات، اصلاح کنید. از این جهت که کامپایلر TypeScript این مسیرهای مطلق را در حین تولید فایل‌های نهایی JavaScript ایی معادل، به مسیرهای نسبی بازنویسی نمی‌کند و در این حالت webpack نمی‌داند که چطور باید این ماژول‌ها را یافته و یکی کند.
resolve: {
  extensions: ['*', '.js', '.ts'],
  modules: [
    rootDir,
    path.join(rootDir, 'node_modules')
  ],
  alias: {
    '@app': 'src/app'
  }
},


کوتاه کردن مسیرهای مطلق با معرفی فایل ویژه‌ی index.ts

تا اینجا بجای ذکر مسیر
import { BrowserStorageService } from "./../../core/browser-storage.service";
به مسیر مطلق زیر رسیدیم (صرفنظر از محل قرارگیری ماژولی که قرار است آن‌را import کند):
import { BrowserStorageService } from "@app/core/browser-storage.service";
این را هم می‌خواهیم به صورت زیر کوتاه‌تر کنیم:
import { BrowserStorageService } from "@app/core";
یعنی فقط app/core@ را ذکر کنیم.

برای اینکار نیاز است فایل ویژه‌ای را به نام index.ts، در ریشه‌ی پوشه‌ی core ایجاد کنیم (src\app\core\index.ts)، با این محتوا:
export * from "./browser-storage.service";
export * from "./app-config.service";
export * from "./seo-service";
در اینجا تمام ماژول‌هایی که توسط Core Module ارائه می‌شوند را یکبار export می‌کنیم.
برای نمونه اگر به پوشه‌ی node_modules\@angular خود مجموعه‌ی Angular هم مراجعه کنید، هر پوشه‌ی src آن به همراه یک فایل index.d.ts شبیه تعاریف فوق نیز هست.

پس از افزودن فایل index.ts به ریشه‌ی پوشه‌ی مدنظر، اکنون در لیست پیشنهادات، ذکر app/core@ تنها نیز ظاهر شده و استفاده‌ی از آن مجاز است:

مطالب
بررسی تغییرات Blazor 8x - قسمت هفتم - امکان تعریف جزیره‌های تعاملی Blazor WASM
در قسمت‌های قبل، نحوه‌ی تعریف جزیره‌های تعاملی Blazor Server را به همراه نکات مرتبط با آن‌ها بررسی کردیم. برای مثال مشاهده کردیم که چون Blazor Server و SSR هر دو بر روی سرور اجرا می‌شوند، از لحاظ دسترسی به اطلاعات و کار با سرویس‌ها، هماهنگی کاملی دارند و می‌توان کدهای یکسان و یکدستی را در اینجا بکار گرفت. در Blazor 8x، امکان تعریف جزیره‌های تعاملی Blazor WASM نیز وجود دارد که به همراه تعدادی نکته‌ی ویژه، در مورد نحوه‌ی مدیریت سرویس‌های مورد استفاده‌ی در این کامپوننت‌ها است.


معرفی برنامه‌ی Blazor WASM این مطلب

در این مطلب قصد داریم دقیقا قسمت جزیره‌ی تعاملی Blazor Server همان برنامه‌ی مطلب قبل را توسط یک جزیره‌ی تعاملی Blazor WASM بازنویسی کنیم و با نکات و تفاوت‌های ویژه‌ی آن آشنا شویم. یعنی زمانیکه صفحه‌ی SSR نمایش جزئیات یک محصول ظاهر می‌شود، نحوه‌ی رندر و پردازش کامپوننت نمایش محصولات مرتبط و مشابه، اینبار یک جزیره‌ی تعاملی Blazor WASM باشد. بنابراین قسمت عمده‌ای از کدهای این دو قسمت یکی است؛ فقط نحوه‌ی دسترسی به سرویس‌ها و محل قرارگیری تعدادی از فایل‌ها، متفاوت خواهد بود.


ایجاد یک پروژه‌ی جدید Blazor WASM تعاملی در دات نت 8

بنابراین در ادامه، در ابتدای کار نیاز است یک پوشه‌ی جدید را برای این پروژه، ایجاد کرده و بجای انتخاب interactivity از نوع Server:
dotnet new blazor --interactivity Server
اینبار برای اجرای در مرورگر توسط فناوری وب‌اسمبلی، نوع WebAssembly را انتخاب کنیم:
dotnet new blazor --interactivity WebAssembly
در این حالت، Solution ای که ایجاد می‌شود، به همراه دو پروژه‌‌است (برخلاف پروژه‌های Blazor Server تعاملی که فقط شامل یک پروژه‌ی سمت سرور هستند):
الف) یک پروژه‌ی سمت سرور (برای تامین 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();
مهم‌ترین قسمت‌های آن، متدهای AddInteractiveServerComponents و AddInteractiveServerRenderMode هستند که server-side rendering را به همراه امکان داشتن کامپوننت‌های تعاملی، ممکن می‌کنند.

این تعاریف در فایل 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>
نیازی نیست تا اینکار را برای پروژه‌ی سرور نیز تکرار کنیم؛ از این جهت که ارجاعی به پروژه‌ی کلاینت، در پروژه‌ی سرور وجود دارد که سبب دسترسی به این پروژه‌ی Shared هم می‌شود.


سرویس برنامه: سرویسی برای بازگشت لیست محصولات

چون 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
سطر اول سبب می‌شود تا بتوان به سادگی به اعضای کلاس استاتیک RenderMode، در برنامه‌ی سمت سرور دسترسی یافت. دو using جدید دیگر سبب سهولت دسترسی به کامپوننت‌های قرارگرفته‌ی در این مسیرها در صفحات SSR برنامه‌ی سمت سرور می‌شوند.


تکمیل صفحه‌ی نمایش لیست محصولات

کدها و مسیر کامپوننت ProductsList.razor، با قسمت پنجم دقیقا یکی است. این صفحه، یک صفحه‌ی SSR بوده و در همان سمت سرور اجرا می‌شود و دسترسی آن به سرویس‌های سمت سرور نیز ساده بوده و همانند قبل است.


تکمیل صفحه‌ی نمایش جزئیات یک محصول

کدها و مسیر کامپوننت ProductDetails.razor، با قسمت پنجم دقیقا یکی است. این صفحه، یک صفحه‌ی SSR بوده و در همان سمت سرور اجرا می‌شود و دسترسی آن به سرویس‌های سمت سرور نیز ساده بوده و همانند قبل است ... البته بجز یک تغییر کوچک:
<RelatedProducts ProductId="ProductId" @rendermode="@InteractiveWebAssembly"/>
در اینجا حالت رندر این کامپوننت، به InteractiveWebAssembly تغییر می‌کند. یعنی اینبار قرار است تبدیل به یک جزیره‌ی وب‌اسمبلی شود و نه یک جزیره‌ی Blazor Server که آن‌را در قسمت پنجم بررسی کردیم.


تکمیل کامپوننت نمایش لیست محصولات مشابه و مرتبط

پس از این توضیحات، به اصل موضوع این قسمت رسیدیم! کامپوننت سمت سرور 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));
}
این کلاس در مسیر Controllers\ProductsController.cs پروژه‌ی سمت سرور قرار می‌گیرد و کار آن، بازگشت اطلاعات محصولات مشابه یک محصول مشخص است.
برای اینکه مسیریابی این کنترلر کار کند، باید به فایل 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);
    }

}
و ... همین! اکنون برنامه قابل اجرا است و به محض نمایش صفحه‌ی جزئیات یک محصول انتخابی، کامپوننت RelatedProducts، در حالت وب‌اسمبلی جزیره‌ای اجرا شده و لیست این محصولات مرتبط را نمایش می‌دهد.
در ادامه یکبار برنامه را اجرا می‌کنیم و ... بلافاصله پس از انتخاب صفحه‌ی نمایش جزئیات یک محصول، با خطای زیر مواجه خواهیم شد!
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"/>
به صورت پیش‌فرض در آن pre-rendering نیز فعال است؛ یعنی این کامپوننت دوبار رندر می‌شود:
الف) یکبار در سمت سرور تا HTML حداقل قالب آن، به همراه سایر قسمت‌های صفحه‌ی SSR جاری به سمت مرورگر کاربر ارسال شود.
ب) یکبار هم در سمت کلاینت، زمانیکه Blazor WASM بارگذاری شده و فعال می‌شود.

استثنائی را که مشاهده می‌کنیم، مربوط به حالت الف است. یعنی زمانیکه برنامه‌ی ASP.NET Core هاست برنامه، سعی می‌کند کامپوننت RelatedProducts را در سمت سرور رندر کند، اما ... ما سرویس HttpClient را در آن ثبت و فعالسازی نکرده‌ایم. به همین جهت است که عنوان می‌کند این سرویس را پیدا نکرده‌است. برای رفع این مشکل، چندین راه‌حل وجود دارند که در ادامه آن‌ها را بررسی می‌کنیم.


راه‌حل اول: ثبت سرویس HttpClient در سمت سرور

یک راه‌حل مواجه شدن با مشکل فوق، ثبت سرویس HttpClient در فایل Program.cs برنامه‌ی سمت سرور به صورت زیر است:
builder.Services.AddScoped(sp => new HttpClient 
{ 
    BaseAddress = new Uri("http://localhost/") 
});
پس از این تعریف، کامپوننت RelatedProducts، در حالت prerendering ابتدایی سمت سرور هم کار می‌کند و برنامه با استثنائی مواجه نخواهد شد.


راه‌حل دوم: استفاده از 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);
}
این همان اینترفیسی است که پیشتر در فایل ProductStore.cs سمت سرور تعریف کرده بودیم؛ با یک تفاوت: متد GetRelatedProducts آن async تعریف شده‌است که نمونه‌ی سمت کلاینت آن باید با متد GetFromJsonAsync کار کند که async است.
پیاده سازی سمت سرور این اینترفیس، کاملا مهیا است و فقط نیاز به تغییر زیر را دارد تا با خروجی 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());
    }
و اکشن متد متناظر هم باید به صورت زیر await دار شود تا خروجی صحیحی را ارائه دهد:
[HttpGet("[action]")]
public async Task<IActionResult> Related([FromQuery] int productId) =>
        Ok(await _store.GetRelatedProducts(productId));
همچنین پیشتر سرویس آن در فایل Program.cs برنامه‌ی سمت سرور، ثبت شده‌است و نیاز به نکته‌ی خاصی ندارد.

در ادامه نیاز است یک پیاده سازی سمت کلاینت را نیز از آن تهیه کنیم که در فایل 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}");
}
در این بین بر اساس نیاز کامپوننت نمایش لیست محصولات مشابه، فقط به متد GetRelatedProducts نیاز داریم؛ بنابراین فقط همین مورد در اینجا پیاده سازی شده‌است. پس از این تعریف، نیاز است سرویس فوق را در فایل Program.cs برنامه‌ی کلاینت هم ثبت کرد (به همراه سرویس HttpClient ای که در سازنده‌ی آن تزریق می‌شود):
builder.Services.AddScoped<IProductStore, ClientProductStore>();
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
به این ترتیب این سرویس در کامپوننت RelatedProducts قابل دسترسی می‌شود و جایگزین سرویس HttpClient تزریقی قبلی خواهد شد. به همین جهت به فایل کامپوننت ProductStore مراجعه کرده و فقط 2 سطر آن‌را تغییر می‌دهیم:
الف) معرفی سرویس IProductStore بجای HttpClient قبلی
@inject IProductStore ProductStore
ب) استفاده از متد GetRelatedProducts این سرویس:
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
مطالب
چطور مسیریابی‌های ASP.NET MVC را دیباگ کنیم؟
سؤال: من برای تهیه sitemap برنامه، یک route سفارشی نوشته‌ام تا یک فایل xml ایی را که در وب سرور، وجود خارجی ندارد، در آدرس‌های سایت قابل دسترسی کند. برای مثال:
            routes.MapRoute(
                "SiteMap_route", // Route name
                "sitemap.xml", // URL with parameters
                new { controller = "Sitemap", action = "index", name = UrlParameter.Optional, area = "" } // Parameter defaults
            );
با استفاده از این مسیریابی خاص، قرار است هر زمانیکه آدرس http://site/sitemap.xml در مرورگر وارد شد، برنامه در پشت صحنه، به صورت خودکار به کنترلر sitemap و اکشن متد index آن مراجعه کرده و یک محتوای پویای XML ایی را تولید کند و بازگشت دهد. اما ... کار نمی‌کند! یعنی آدرس یاد شده اصلا پاسخ نمی‌دهد. چرا؟ نحوه‌ی ثبت مسیریابی سفارشی تعریف شده نیز به صورت زیر است:
        public static void RegisterRoutes(RouteCollection routes)
        {
            routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

            routes.MapRoute(
                name: "Default",
                url: "{controller}/{action}/{id}",
                defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
            );

            routes.MapRoute(
                "SiteMap_route", // Route name
                "sitemap.xml", // URL with parameters
                new { controller = "Sitemap", action = "index", name = UrlParameter.Optional, area = "" } // Parameter defaults
            );
        }
پاسخ: اگر با تقدم و تاخر و معنای مسیریابی‌های تعریف شده آشنایی داشته باشید، شاید بلافاصله بتوانید مشکل را حدس بزنید. اما اگر تعداد مسیریابی‌های سفارشی تعریف شده زیاد باشد، اینکار ساده نیست و حتما نیاز به ابزار دیباگ دارد تا بتوان تشخیص داد که در صفحه جاری کدامیک از مسیریابی‌های تعریف شده کار را تمام کرده‌اند و نوبت به دیگری نرسیده است.

برای این منظور می‌توان از افزونه‌ای به نام RouteDebug نوشته یکی از اعضای سابق تیم ASP.NET MVC استفاده کرد:
کار کردن با آن نیز بسیار ساده است.
الف) ارجاعی را به اسمبلی RouteDebug.dll (حاصل از کامپایل پروژه فوق) به پروژه جاری ASP.NET MVC خود اضافه کنید.
ب) سپس به فایل Global.asax.cs خود مراجعه و در سطر آخر متد Application_Start آن، فراخوانی ذیل را اضافه نمائید:
 RouteDebug.RouteDebugger.RewriteRoutesForTesting(RouteTable.Routes);
اکنون هر صفحه و آدرسی را که باز کنید، بجای محتوای اصلی صفحه، مسیریابی‌های فعال و برنده آن‌را مشاهده خواهید کرد. برای مثال در صفحه اول برنامه داریم:


نکته مهمی که در این تصویر باید به آن دقت داشت، اولین True سبز رنگی است که نمایش می‌دهد. یعنی اولین مسیریابی که کار هدایت و نمایش صفحه جاری را برعهده دارد. در اجرای عادی ASP.NET MVC، همینجا کار پردازش سیستم مسیریابی صفحه جاری خاتمه خواهد یافت و نوبت به سایرین نخواهد رسید.
در مورد صفحه sitemap.xml چطور؟ اگر این آدرس را در مرورگر، بدون فعال سازی افزونه RouteDebug وارد کنیم، پیام 404 را دریافت می‌کنیم. اگر افزونه را فعال کنیم، اینبار به صفحه زیر خواهیم رسید:


بله. همانطور که مشاهده می‌کنید، مسیریابی پیش فرض، اینبار نیز برنده بوده است و اولین تطابق صورت گرفته با آن صورت می‌گیرد. بنابراین اصلا کار به استفاده از مسیریابی سفارشی تعریف شده توسط ما نخواهد رسید.
بنابراین محل تعریف این مسیریابی را اکنون به پیش از مسیریابی پیش فرض انتقال می‌دهیم:
        public static void RegisterRoutes(RouteCollection routes)
        {
            routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

            routes.MapRoute(
                "SiteMap_route", // Route name
                "sitemap.xml", // URL with parameters
                new { controller = "Sitemap", action = "index", name = UrlParameter.Optional, area = "" } // Parameter defaults
            );

            routes.MapRoute(
                name: "Default",
                url: "{controller}/{action}/{id}",
                defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
            );      
        }
در ادامه اگر مجددا مسیر sitemap.xml را درخواست کنیم، به تصویر ذیل خواهیم رسید:


بله. با این تنظیم صورت گرفته، اینبار دیگر سیستم مسیریابی، برای تفسیر مسیر سفارشی تعریف شده، به سراغ مسیریابی پیش فرض نخواهد رفت و کار همینجا خاتمه می‌یابد.

سؤال: آیا اینکار تداخلی در عملکرد اصلی برنامه ایجاد نمی‌کند؟ مثلا اگر به مسیر index کنترلر home مراجعه کنیم، مشکلی نخواهد بود؟
پاسخ: خیر. برای آزمایش آن باز هم به افزونه RouteDebug مراجعه خواهیم کرد:


همانطور که مشخص است، مسیریابی برنده در این حالت، همان مسیریابی پیش فرض است و نه مسیریابی سفارشی آدرس خاص sitemap.xml سایت.


یک نکته تکمیلی
افزونه گلیمپس نیز امکان دیباگ Routeها را دارد؛ اما توانایی بررسی مشکلات Routing یک خطای 404 مانند مثال فوق را حداقل تا زمان نگارش این مطلب ندارد و همان افزونه RouteDebug یاد شده، بهتر عمل می‌کند.
نظرات مطالب
React 16x - قسمت 16 - مسیریابی - بخش 2 - پارامترهای مسیریابی
با سلام؛ دلیل اینکه وقتی یکی از روت‌هایی که با روش زیر تنظیم شدن رو تو مرورگر مستقیم واردکنیم و درخواست بدیم کل قالب بهم میریزه و انگار css‌های فایل index.html رو نمیشناسه و یا اینکه پردازش نمیکنه و آیا راهکاری داره که این اتفاق نیفته؟
  <Route path="/admin/users" component={Users} />
          <Route path="/admin/posts" component={Posts} />
نظرات مطالب
ارتقاء به ASP.NET Core 1.0 - قسمت 7 - کار با فایل‌های config
من با این صورت فایل رو آپدیت می‌کنم
        public void UpdateSettings(SiteSettings siteSettings)
        {
            var path = System.IO.Path.Combine(_hostingEnvironment.WebRootPath, "appsettings.json");
            System.IO.StreamWriter streamWriter = new System.IO.StreamWriter(path, false);
            streamWriter.Write(Newtonsoft.Json.JsonConvert.SerializeObject(siteSettings));
            streamWriter.Close();
        }
ولی نه در فایل تغییری ایجاد می‌شه و نه در مقادیر :
        private readonly IOptionsSnapshot<SiteSettings> _siteOptions;
برنامه خطای دسترسی هم نمی‌دهد!
نظرات مطالب
دریافت و نمایش فایل‌های PDF در برنامه‌های Blazor WASM
یک نکته‌ی تکمیلی: چاپ گزارش در WASM با Microsoft RDLC

با توجه به  آموزش‌های قبلی  در زمینه تولید پی دی اف که  در سایت داده شده است از توضیحات اضافی خودداری و بصورت خلاصه نکات تهیه گزارش چاپی با RDLC آورده شده است.
1- دریافت بسته از نیوگت Install-Package ReportViewerCore.NETCore -Version 15.1.16 
2- دریافت Microsoft RDLC Report Designer از Marketplace  چنانچه از قبل دریافت نشده باشد جهت تولید فایل گزارش
3 -اضافه کردن دیتاست DataSet  در پروژه و ایجاد فایل گزارش  Report.rdlcدر مسیر ریشه و تنظیم و طراحی خواص گزارش بر اساس دیتاست طراحی شده.
4-ایجاد کنترولر جهت تولید و چاپ گزارش.
نکته قابل توجه
1- مقدار ReportDataSource   چه در قسمت سربرگ و پابرگ و  محتوا ی ارسالی به گزارش حتما از نوع لیستی باید باشد.FirstOrDefault ,Single باعث بروز استثناء میشود.

 report.DataSources.Add(new ReportDataSource("Header", "از نوع لیست باشد"));

کدهای کامل این مطلب را از اینجا می‌توانید دریافت کنید  :BlazorReportRDLC-077977be954549dab99b73a6802bf3ea.zip

اشتراک‌ها
کار با Api های Git در NET. توسط کتابخانه LibGit2Sharp

Git + .NET/Mono = ❤  http://libgit2.github.com

LibGit2Sharp brings all the might and speed of libgit2, a native Git implementation, to the managed world of .NET and Mono. 


$ git init /d/temp/rooted/path
string rootedPath = Repository.Init(@"D:\temp\rooted\path");
Console.WriteLine(rootedPath);

$ git add --all .
using (var repo = new Repository("path/to/your/repo"))
{
    Commands.Stage(repo, "*");
}

$ git checkout <branch>
using (var repo = new Repository("path/to/your/repo"))
{
    var branch = repo .Branches[branchName];
    Branch currentBranch = Commands.Checkout(repo , branch);
}
کار با Api های Git در NET. توسط کتابخانه LibGit2Sharp