عنوان:

‫پیاده سازی ویژگی مشابه asp-append-version در برنامه‌های Blazor SSR


نویسنده: وحید نصیری
تاریخ: ۱۴۰۳/۰۵/۱۶ ۱۸:۵۵
آدرس: www.dntips.ir

عموما برای درج فایل‌های ثابت اسکریپت‌ها و شیوه‌نامه‌های سایت، از روش متداول زیر استفاده می‌شود:

<link rel="stylesheet" href="/css/site.css"  />    
<script src="/js/site.js"></script>    

مشکلی که به همراه این روش وجود دارد، مطلع سازی کاربران و مرورگر، از تغییرات آن‌هاست؛ چون این فایل‌های ثابت، توسط مرورگرها کش شده و با فشردن دکمه‌هایی مانند Ctrl+F5 و به‌روز شدن کش مرورگر، به نگارش جدید،‌ ارتقاء پیدا می‌کنند. برای رفع این مشکل حداقل دو روش وجود دارد:

الف) هربار نام این فایل‌ها را تغییر دهیم. برای مثال بجای نام قدیمی site.css، از نام جدید site.v.1.1.css استفاده کنیم.

ب) یک کوئری استرینگ متغیر را به نام ثابت این فایل‌ها، اضافه کنیم.

که در این بین، روش دوم متداول‌تر و معقول‌تر است. برای این منظور، ASP.NET Core به همراه ویژگی توکاری است به نام asp-append-version که اگر آن‌‌را به تگ‌های اسکریپت و link اضافه کنیم:

<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />    
<script src="~/js/site.js" asp-append-version="true"></script>    

این کوئری استرینگ را به صورت خودکار محاسبه کرده و به آدرس فایل درج شده اضافه می‌کند؛ با خروجی‌هایی شبیه به مثال زیر:

<link rel="stylesheet" href="/css/site.css?v=AAs5qCYR2ja7e8QIduN1jQ8eMcls-cPxNYUozN3TJE0" />    
<script src="/js/site.js?v=NO2z9yI9csNxHrDHIeTBBfyARw3PX_xnFa0bz3RgnE4"></script>  

ASP.NET Core در اینجا هش فایل‌های یافت شده را با استفاده از الگوریتم SHA256 محاسبه و url encode کرده و به صورت یک کوئری استرینگ، به انتهای آدرس فایل‌ها اضافه می‌کند. به این ترتیب با تغییر محتوای این فایل‌ها، این هش نیز تغییر می‌کند و مرورگر بر این اساس، همواره آخرین نگارش ارائه شده را از سرور دریافت خواهد کرد. نتیجه‌ی این محاسبات نیز به صورت خودکار کش می‌شود و همچنین با استفاده از یک File Watcher در پشت صحنه، تغییرات این فایل‌ها هم بررسی می‌شوند. یعنی اگر فایلی تغییر کرد، نیازی به ‌ری‌استارت برنامه نیست و محاسبات جدید و کش شدن مجدد آن‌ها، به صورت خودکار انجام می‌شود.

البته این ویژگی هنوز به Blazor اضافه نشده‌است؛ اما امکان استفاده‌ی از زیر ساخت ویژگی asp-append-version با کدنویسی مهیا است که در ادامه با استفاده از آن، کامپوننتی را مخصوص Blazor SSR، تهیه می‌کنیم.

دسترسی به زیر ساخت محاسباتی ویژگی asp-append-version با کدنویسی

زیرساخت محاسباتی ویژگی asp-append-version، با استفاده از سرویس توکار IFileVersionProvider به صورت زیر قابل دسترسی است:

public static class FileVersionHashProvider
{
    private static readonly string ProcessExecutableModuleVersionId =
        Assembly.GetEntryAssembly()!.ManifestModule.ModuleVersionId.ToString("N");

    public static string GetFileVersionedPath(this HttpContext httpContext, string filePath, string? defaultHash = null)
    {
        ArgumentNullException.ThrowIfNull(httpContext);

        var fileVersionedPath = httpContext.RequestServices.GetRequiredService<IFileVersionProvider>()
            .AddFileVersionToPath(httpContext.Request.PathBase, filePath);

        return IsEmbeddedOrNotFound(fileVersionedPath, filePath)
            ? QueryHelpers.AddQueryString(filePath, new Dictionary<string, string?>(StringComparer.Ordinal)
            {
                {
                    "v", defaultHash ?? ProcessExecutableModuleVersionId
                }
            })
            : fileVersionedPath;
    }

    private static bool IsEmbeddedOrNotFound(string fileVersionedPath, string filePath)
        => string.Equals(fileVersionedPath, filePath, StringComparison.Ordinal);
} 

در برنامه‌های Blazor SSR، دسترسی کاملی به HttpContext‌ وجود دارد و همانطور که مشاهده می‌کنید، این سرویس نیز به اطلاعات آن جهت محاسبه‌ی هش فایل معرفی شده‌ی به آن، نیاز دارد. در اینجا اگر هش قابل محاسبه نبود، از هش فایل اسمبلی جاری استفاده خواهد شد.

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

یک نمونه روش استفاده‌ی از متد الحاقی GetFileVersionedPath فوق را در کامپوننت DntFileVersionedJavaScriptSource.razor زیر می‌توانید مشاهده کنید:

@if (!string.IsNullOrWhiteSpace(JsFilePath))
{
    <script src="@HttpContext.GetFileVersionedPath(JsFilePath)" type="text/javascript"></script>
}

@code{
    [CascadingParameter] public HttpContext HttpContext { set; get; } = null!;

    [Parameter]
    [EditorRequired]
    public required string JsFilePath { set; get; }
}

با استفاده از HttpContext مهیای در برنامه‌های Blazor SSR، متد الحاقی GetFileVersionedPath به همراه مسیر فایل js. مدنظر، در صفحه درج می‌شود.

برای مثال یک نمونه از استفاده‌ی آن، به صورت زیر است:

<DntFileVersionedJavaScriptSource JsFilePath="/lib/quill/dist/quill.js"/>

در نهایت با اینکار، یک چنین خروجی در صفحه درج خواهد شد که با تغییر محتوای فایل quill.js، هش متناظر با آن به صورت خودکار به‌روز خواهد شد:

<scriptsrc="/lib/quill/dist/quill.js?v=5q7uUOOlr88Io5YhQk3lgYcoB_P3-5Awq1lf0rRa7-Y" type="text/javascript"></script>

شبیه به همین کار را برای شیوه‌نامه‌ها هم می‌توان تکرار کرد و کدهای آن، تفاوت آنچنانی با کامپوننت فوق ندارند.

نظرات

  • وحید نصیری در ۱۴۰۳/۰۸/۲۲ ۰۸:۵۴
    یک نکته‌ی تکمیلی: راه‌حل توکار دات‌نت 9 برای مطلع سازی کاربران از تغییرات فایل‌های ثابت

    الف) میان‌افزار قدیمی StaticFiles را حذف کنید:
    // remove the old call to use static assets
    // app.UseStaticFiles();

    ب) میان‌افزار جدید StaticAssets را اضافه کنید:
    // add this
    app.MapStaticAssets();

    ج) سپس بجای اینکه از روش متداول زیر برای معرفی شماره نگارش فایل‌های ثابت خود استفاده کنید (و یا از روش مطرح شده‌ی در مطلب جاری استفاده کنید):
    <link href="css/app.css?version=2"  rel="stylesheet"/>
    از دایرکتیو جدید Assetes@ استفاده کنید:
    <link rel="stylesheet" href="@Assets["app.css"]"/>
    که البته در پشت صحنه باز هم از همان مکانیزم تولید هش، بر اساس محتوای فایل استفاده کرده و یک چنین خروجی منحصربفردی را رندر می‌کند:
    <link rel="stylesheet" href="app.khy4lop6wu.css" />

    این قابلیت را در تمام برنامه‌های مبتنی بر ASP.NET Core هم می‌توان استفاده کرد (یعنی در برنامه‌های Razor pages و MVC هم قابل استفاده‌است). همچنین این روش، به همراه فشرده سازی خودکار این فایل‌ها نیز هست. در حین توسعه، فایل‌های ثابت را gzip کرده و ارائه می‌دهد و در حین ارائه‌ی نهایی، هر دو نگارش gzip و brotli را هم تهیه می‌کند. به علاوه‌ خروجی آن هم توسط Caching Middleware و به همراه تنظیم هدرهای مرتبط Caching ارائه می‌شود.
    • وحید نصیری در ۱۴۰۳/۰۸/۲۴ ۲۲:۱۴
      تکمیل نکته‌ی فوق در مورد تغییرات معرفی فایل‌های استاتیک در دات‌نت 9

      برنامه‌های MVC و Razor pages، نیازی به دایرکتیو جدید Assets@ ندارند؛ از این جهت که تگ‌های استانداردی مانند link، script و حتی image در این نوع برنامه‌ها، در اصل یک Tag helper هستند و کاملا تحت کنترل فریم‌ورک مدیریت می‌شوند. این نوع برنامه‌ها، فقط دو تغییر افزودن میان‌افزار جدید (MapStaticAssets) و همچنین معرفی آن به سیستم مسیریابی را به صورت زیر با فراخوانی متد WithStaticAssets نیاز دارند:
      app.MapStaticAssets();
      
      app.MapControllerRoute(
          name: "default",
          pattern: "{controller=Home}/{action=Index}/{id?}")
          .WithStaticAssets();
      در برنامه‌های Razor pages، متد جدید WithStaticAssets پس از MapRazorPages اضافه می‌شود.

      پس از آن اگر برنامه را اجرا کنیم، این تغییرات قابل مشاهده هستند:
      الف) اضافه شدن هدرهای جدید شماره نگارش (Etag) و همچنین ارائه‌ی نسخه‌ی فشرده شده‌ی (gzip) به صورت خودکار


      ب) ارائه‌ی خودکار نسخه‌ی فشرده شده در حالت publish


      کامپوننت و تگ‌هلپر جدید import map در دات‌نت 9

      اگر به قالب فایل به‌روز شده‌ی Views\Shared\_Layout.cshtml برنامه‌های MVC 9x مراجعه کنیم، یک سطر جدید importmap هم مشاهده می‌شود:
      <head>
          <!-- existing code-->
          <script type="importmap"></script>
      </head>
      این مورد مرتبط با سهولت تعریف JavaScript Modules، در نگارش‌های جدید ASP.NET Core و همچنین برنامه‌های Blazor است. در برنامه‌های MVC و Razor pages، باید این تگ‌هلپر جدید را دستی اضافه کرد (بله؛ تگ استاندارد اسکریپت هم یک Tag Helper است!). در برنامه‌های Blazor بجای اینکار باید از کامپوننت جدید ImportMap، در قسمت head صفحه در صورت نیاز (اگر ماژولی داشتید) کمک گرفت:
      <head>    
      	<!-- existing code-->
      	<ImportMap />
      </head>
      این کامپوننت جدید باید فقط یکبار و آن هم در فایل App.razor، اضافه شود.

      اما ... داستان ماژول‌ها چیست؟!
      ماژول‌ها در جاوااسکریپت، یک چنین فایل‌هایی هستند:
      // JS/alert.js
      
      export class AlertHelper {    
      	async showAlert(message, type = 'info', options = {}) {            
      		// details omitted
      	}
      }
      
      export default new AlertHelper();
      که روش فراخوانی آن‌ها در برنامه‌های Blazor WASM به صورت زیر است:
      @code {    
      	async Task ShowAlert(string message)
      	{
      		var module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/alert.js");
      		await module.InvokeVoidAsync("default.showAlert", message, "success");
      	}    
      }
      مشکل اینجاست که با اعمال تغییری در فایل ماژول فوق، چون هدرهای کش شدن و یا شماره نگارش خاصی مشخص نشده، باز هم همان فایل قبلی کش شده‌ی توسط مرورگر، بازگشت داده می‌شود. اما با وجود کامپوننت جدید ImportMap، اینبار خروجی زیر به صورت خودکار رندر خواهد شد (فریم‌ورک تمام ماژول‌ها را به صورت خودکار یافته و نگاشت آن‌ها را تکمیل می‌کند):
      <script type="importmap">{
      	"imports": {
      		// other imports
      		"./js/alert.js": "./js/alert.voa2fr9sx9.js",
      	}
      }</script>
      در اینجا مفهوم import map بهتر مشخص می‌شود. زمانیکه برنامه js/alert.js را درخواست می‌کند، به سمت نمونه‌ی جدید دارای شماره نگارش، فشرده شده و همچنین دارای تگ‌های هدر کش شدن، هدایت خواهد شد.

      یک نکته: وجود "script type="importmap و مداخل مرتبط با آن، مطلقا سبب دریافت و بارگذاری فایل‌های آن‌ها نمی‌شود. چون فریم‌ورک این قسمت را به صورت خودکار تکمیل می‌کند، ممکن است اینطور به‌نظر برسد که چون تعداد زیادی فایل در اینجا ذکر شده‌اند، بارگذاری اولیه‌ی صفحه کند خواهد شد؛ اما ... مرورگر فقط در یکی از دو حالت فراخوانی import (مانند قطعه کد فوق) و یا وجود چنین مدخلی:
      <script type="module" src="..." />
      یک JavaScript Module را بارگذاری و پردازش می‌کند.
      • وحید نصیری در ۱۴۰۳/۰۹/۰۹ ۱۹:۲۸
        یک نکته‌ی تکمیلی: افزایش زمان Build و اجرا با فعالسازی قابلیت جدید StaticAssets دات‌نت 9

        اگر نکاتی را که تا به اینجا در مورد فعالسازی قابلیت جدید StaticAssets در دات‌نت 9 عنوان شد، انجام دهید و ... پروژه‌ی شما متشکل از چندهزار آیکن bootstrap در فرمت‌های مختلف و چندهزار فایل جاواسکریپتی متداول باشد(!)، نه‌تنها سرعت Build برنامه کاهش پیدا می‌کند، بلکه در ابتدای اجرای برنامه هم متوجه خواهید شد که زمان راه‌اندازی وب‌سرور برنامه در مقایسه با دات‌نت 8، چند دقیقه‌ای بیشتر شده‌است! علت اینجاست که این قابلیت جدید، در حین راه‌اندازی اولیه‌ی برنامه، تمام فایل‌های استاتیک را فشرده هم می‌کند که آن‌ها را می‌توانید در مسیر obj\Debug\net9.0\compressed پیدا کنید. برای اینکه هم از این قابلیت استفاده کنیم و هم زمان Build و اجرا در حین دیباگ برنامه افزایش پیدا نکند، می‌توان این فشرده سازی و تنظیمات را برای حالت دیباگ، در فایل csproj. غیرفعال کرد:
        <PropertyGroup Condition="'$(Configuration)' != 'Release'">
                <StaticWebAssetFingerprintingEnabled>false</StaticWebAssetFingerprintingEnabled>
                <StaticWebAssetsFingerprintContent>false</StaticWebAssetsFingerprintContent>
                <EnableDefaultCompressedItems>false</EnableDefaultCompressedItems>
            </PropertyGroup>
        با این تنظیمات، تهیه‌ی امضای فایل‌های استاتیک و همچنین فشرده سازی تمام آن‌ها در حین اجرای برنامه در حالت دیباگ، غیرفعال می‌شود.
        • وحید نصیری در ۱۴۰۳/۰۹/۳۰ ۱۰:۳۰
          Disable build time compression
          <PropertyGroup>
            <DisableBuildCompression>true</DisableBuildCompression>
          </PropertyGroup>

          Disable compression altogether
          <PropertyGroup>
            <CompressionEnabled>false</CompressionEnabled>
          </PropertyGroup>

          Disable compression for common content-types
          <PropertyGroup>
            <EnableDefaultCompressedItems>false</EnableDefaultCompressedItems>
          </PropertyGroup>