The conclusion of the analysis:
C# Wasm AOT still
has a long way to become a general and performant client side web
programming platform. Note: The usage of the Uno.Wasm.Bootstrap
toolchain may have affected the performance of some of the benchmarks.
Thus, this analysis should not be regarded as a benchmark of the
finalized product. However, note that the Uno platform is using ".NET 6
WebAssembly Mixed mode AOT/Interpreter" (source).
در فضایی که همواره هیچ تضمینی وجود ندارد که درخواست ارسال شدهی به یک 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
Automapper به بنیاد NET. پیوست.
AutoMapper has been a popular library in the .NET open source community for a long time. As their site says:
AutoMapper is a simple little library built to solve a deceptively complex problem - getting rid of code that mapped one object to another. This type of code is rather dreary and boring to write, so why not invent a tool to do it for us?
تفاوت بین یک کلاس استاتیک، متدی استاتیک و یا متغیر عضو استاتیک چیست؟ چه زمانی باید از آنها استفاده کرد و لزوم بودن آنها چیست؟
برای پاسخ دادن به این سؤالات باید از نحوهی تقسیم بندی حافظه شروع کرد.
RAM برای هر نوع پروسهای که در آن بارگذاری میشود به سه قسمت تقسیم میگردد: Stack ، Heap و Static (استاتیک در دات نت در حقیقت قسمتی از Heap است که به آن High Frequency Heap نیز گفته میشود).
این قسمت استاتیک حافظه، محل نگهداری متدها و متغیرهای استاتیک است. آن متدها و یا متغیرهایی که نیاز به وهلهای از کلاس برای ایجاد ندارند، به صورت استاتیک ایجاد میگردند. در سی شارپ از واژه کلیدی static برای معرفی آنها کمک گرفته میشود. برای مثال:
class MyClass
{
public static int a;
public static void DoSomething();
}
MyClass.DoSomething(); // and not -> new MyClass().DoSomething();
به مثال زیر دقت نمائید:
class MyClass
{
// non-static instance member variable
private int a;
//static member variable
private static int b;
//static method
public static void DoSomething()
{
//this will result in compilation error as “a” has no memory
a = a + 1;
//this works fine since “b” is static
b = b + 1;
}
}
بر این اساس کامپایلر نیز از کامپایل شدن این کد جلوگیری کرده و خطای لازم را گوشزد خواهد کرد.
اکنون تعریف یک کلاس به صورت استاتیک چه اثری را خواهد داشت؟
با تعریف یک کلاس به صورت استاتیک مشخص خواهیم کرد که این کلاس تنها حاوی متدها و متغیرهای استاتیک میباشد. امکان ایجاد یک وهله از آنها وجود نداشته و نیازی نیز به این امر ندارند. این کلاسها امکان داشتن instance variables را نداشته و به صورت پیش فرض از نوع sealed به حساب خواهند آمد و امکان ارث بری از آنها نیز وجود ندارد. علت این امر هم این است که یک کلاس static هیچ نوع رفتاری را تعریف نمیکند.
پس با این تفاسیر چرا نیاز به یک کلاس static ممکن است وجود داشته باشد؟
همانطور که عنوان شد یک کلاس استاتیک هیچ نوع رفتاری را تعریف نمیکند بنابراین بهترین مکان است برای تعریف متدهای کمکی که به سایر اعضای کلاسهای ما وابستگی نداشته، عمومی بوده، مستقل و متکی به خود هستند. عموما متدهای کمکی در یک برنامه به صورت مکرر فراخوانی شده و نیاز است تا به سرعت در دسترس قرار داشته باشند و حداقل یک مرحله ایجاد وهله کلاس در اینجا برای راندمان بیشتر حذف گردد.
برای مثال متدی را در نظر بگیرید که بجز اعداد، سایر حروف یک رشته را حذف میکند. این متد عمومی است، وابستگی به سایر اعضای یک کلاس یا کلاسهای دیگر ندارد. بنابراین در گروه متدهای کمکی قرار میگیرد. اگر از افزونهی ReSharper استفاده نمائید، این نوع متدها را به صورت خودکار تشخیص داده و راهنمایی لازم را جهت تبدیل آنها به متدهای استاتیک ارائه خواهد داد.
با کلاسهای استاتیک نیز همانند سایر کلاسهای یک برنامه توسط JIT compiler رفتار میشود، اما با یک تفاوت. کلاسهای استاتیک فقط یکبار هنگام اولین دسترسی به آنها ساخته شده و در قسمت High Frequency Heap حافظه قرار میگیرند. این قسمت از حافظه تا پایان کار برنامه از دست garbage collector در امان است (بر خلاف garbage-collected heap یا object heap که جهت instance classes مورد استفاده قرار میگیرد)
نکته:
در برنامههای ASP.Net از بکارگیری متغیرهای عمومی استاتیک برحذر باشید (از static fields و نه static methods). این متغیرها بین تمامی کاربران همزمان یک برنامه به اشتراک گذاشته شده و همچنین باید مباحث قفلگذاری و امثال آنرا در محیطهای چند ریسمانی هنگام کار با آنها رعایت کرد (thread safe نیستند).
As anyone in the .NET community who hasn't been living under a rock will know, there's a lot of exciting things happening with .NET at the moment with the announcement of the open source, cross platform, .NET Core. However, partly due to the very open nature of its evolution, there's been a whole host of names associated with its development - vNext, ASP.NET 5, ASP.NET Core, .NET generations etc.
In this post I'm going to try and clarify some of the naming and terminology surrounding the evolution of the .NET framework. I'll discuss some of the challenges the latest iteration is attempting to deal with and how the latest developments aim to address these.
This is really for those that have seen some of the big announcements but aren't sure about the intricacies of this new framework and how it relates to the existing ecosystem, which was my situation before I really started digging into it all properly!
Hopefully by the end of this article you'll have a clearer grasp of the latest in .NET!
تغییرات SQL Server 2022
Microsoft is working on integrating Rust, a programming language that emphasizes performance, type safety, and concurrency, into the Windows 11 kernel. According to David Weston, Vice President, Enterprise and OS Security at Microsoft, the company plans to boot Windows 11 with Rust code inside in the near future.
This session will cover the strategic decisions you have to make as a developer when targeting multiples devices in your application. We will explore the tools and technologies that you have available in Visual Studio 2013 for both web and native applications that target Windows, iOS and Android devices, as well as best practices to reuse code and skills across them.
بهبود صفحهی بارگذاری اولیه در Blazor WASM
اگر یک برنامهی جدید blazor wasm را در دات نت 7، برای مثال با دستور dotnet new blazorwasm --hosted ایجاد کنیم، با اجرای آن، یک progress-bar حلقوی نمایش میزان درصد بارگذاری اولیهی برنامه ظاهر میشود که به نوعی پیاده سازی توکار نکات مطلب جاری است. این پیاده سازی از اجزای زیر تشکیل شدهاست:
الف) تغییرات فایل index.html برنامه
برای این منظور، فایل Client\wwwroot\index.html به صورت زیر تغییر کردهاست:
<body> <div id="app"> <svg class="loading-progress"> <circle r="40%" cx="50%" cy="50%" /> <circle r="40%" cx="50%" cy="50%" /> </svg> <div class="loading-progress-text"></div> </div>
ب) تغییرات فایل app.css برنامه
کلاسهای progress-bar را به این صورت در فایل Client\wwwroot\css\app.css اضافه کردهاند:
.loading-progress { position: relative; display: block; width: 8rem; height: 8rem; margin: 20vh auto 1rem auto; } .loading-progress circle { fill: none; stroke: #e0e0e0; stroke-width: 0.6rem; transform-origin: 50% 50%; transform: rotate(-90deg); } .loading-progress circle:last-child { stroke: #1b6ec2; stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; transition: stroke-dasharray 0.05s ease-in-out; } .loading-progress-text { position: absolute; text-align: center; font-weight: bold; inset: calc(20vh + 3.25rem) 0 auto 0.2rem; } .loading-progress-text:after { content: var(--blazor-load-percentage-text, "Loading"); }
روش سفارشی سازی این progress-bar بر اساس دو CSS variable فوق صورت میگیرد:
--blazor-load-percentage: درصد بارگذاری جاری را مقدار دهی میکند.
--blazor-load-percentage-text: متن Loading نمایش داده شده را مشخص میکند.
برای مثال اگر علاقمند باشیم بجای SVG پیشفرض از progress-bar توکار خود فریمورک بوتاسترپ استفاده کنیم، روش کار به صورت زیر خواهد بود:
<body> <div id="app"> <div class="progress"> <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" aria-valuenow="75" aria-valuemin="0" aria-valuemax="100" style="width: var(--blazor-load-percentage, 0%)"> <div class="loading-text"></div> </div> </div>
.loading-text:after { content: var(--blazor-load-percentage-text, "Loading"); }