はじめに
担当: 守矢 ![]()
Blazor アプリで CSS や JavaScript を更新したのに、利用者のブラウザにすぐ反映されないことがあります。
いわゆる ブラウザキャッシュ問題です。
ASP.NET Core の MVC / Razor Pages では asp-append-version を使うことで、
静的ファイルの更新を簡単に反映できます。
<link rel="stylesheet" href="site.css" asp-append-version="true" />
しかし Blazor コンポーネントでは TagHelper が使えないため、この仕組みがそのまま使えないケースがあります。
今回のプロジェクトは .NET 8 / Blazor 環境であり、
asp-append-version がそのまま利用できなかったため、
既存環境への影響を抑えつつキャッシュバスティングを自前実装しました。
この記事ではその実装方法と考え方を紹介します。
前提
この記事は .NET 8 / Blazor 環境を前提としています。
.NET 9 以降では静的アセット管理が強化されており、
フィンガープリント付きアセット配信の仕組みが用意されています。
Microsoft Docs: Blazor の静的ファイル
本記事では .NET 9 以降の公式機能の詳細には踏み込まず、
既存の .NET 8 環境で実運用しやすい対応例に絞って紹介します。
今回やりたかったこと
目的はシンプルです。
- CSS / JavaScript を更新したら
- 利用者のブラウザに 自動で新しいファイルを読ませる
一般的な方法は URLにバージョンを付けることです。
例:
site.css
↓
site.css?v=xxxxx
ファイル内容が変わるとURLも変わるため、
ブラウザキャッシュの影響を受けにくくなります。
実装方針
今回の実装では次の方針を取りました。
- ファイル内容からハッシュを生成する
-
?v=を付与したURLを返す - 計算結果だけをメモリキャッシュする
- キャッシュキーはプレフィックスを付けて管理する
ポイントは
ファイル内容が変わったときだけURLが変わる
という仕組みにすることです。
レイアウト側の呼び出し
まず、レイアウト側では静的ファイルを直接指定するのではなく、
バージョン付きパスを返すメソッドを通すようにしました。
@inject FileVersionService FileVersionService
@functions {
string AppendVersion(string path)
=> FileVersionService.GetVersionedPath(path);
}
<link rel="stylesheet" href="@AppendVersion("app.css")" />
<link rel="stylesheet" href="@AppendVersion("site.css")" />
<script src="@AppendVersion("app.js")"></script>
<script src="@AppendVersion("common.js")"></script>
呼び出し側はかなりシンプルになります。
サービス実装
実際のサービスは次のような形です。
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Caching.Memory;
using System.Security.Cryptography;
public class FileVersionService
{
private readonly IWebHostEnvironment _env;
private readonly IMemoryCache _memoryCache;
public FileVersionService(IWebHostEnvironment env, IMemoryCache memoryCache)
{
_env = env;
_memoryCache = memoryCache;
}
public string GetVersionedPath(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
return relativePath;
var cacheKey = $"FileVersion:{relativePath}";
if (_memoryCache.TryGetValue(cacheKey, out string? cached))
return cached ?? relativePath;
var fileInfo = _env.WebRootFileProvider.GetFileInfo(relativePath);
if (!fileInfo.Exists)
return relativePath;
using var stream = fileInfo.CreateReadStream();
using var sha = SHA256.Create();
var hash = sha.ComputeHash(stream);
var encoded = WebEncoders.Base64UrlEncode(hash);
var versionedPath = $"{relativePath}?v={encoded}";
_memoryCache.Set(cacheKey, versionedPath);
return versionedPath;
}
}
実装ポイント
ファイル内容ベースでハッシュを作る
タイムスタンプではなく ファイル内容そのもの からハッシュを作っています。
そのため
- 内容が変わらなければURLは同じ
- 内容が変わればURLが変わる
という挙動になります。
キャッシュしているのは「計算結果」
今回キャッシュしているのは ファイル本体ではなく、計算済みのURL です。
_memoryCache.Set(cacheKey, versionedPath);
つまり
- ファイル内容をメモリに持つわけではない
- ハッシュ計算結果だけを保持する
という設計です。
※本実装はデプロイ時にアプリが再起動される運用を前提としており、プロセスを維持したまま静的ファイルを差し替える場合は再計算タイミングの設計が必要です。
キャッシュキーにプレフィックスを付ける
var cacheKey = $"FileVersion:{relativePath}";
プレフィックスを付けることで
- 何のキャッシュか分かりやすい
- 他用途のキーと混ざりにくい
というメリットがあります。
この対応を入れた理由
今回この仕組みを入れた理由は
利用者にブラウザキャッシュ削除をお願いしにくい環境だったためです。
マルチテナント型のシステムでは
- 利用者が多い
- 個別に連絡できない
- 問い合わせが来た時には既に影響が出ている
ということがあります。
そのため、デプロイ後に自動で新しいファイルを参照させる仕組みを入れておく方が運用しやすいケースもあります。
まとめ
今すぐ公式機能へ寄せるのが難しい場合は、
ファイル内容ベースのハッシュを ?v= に付与して配信する方法は十分に有効です。
計算結果だけをキャッシュする形にしておけば、運用負荷を抑えながら更新反映を安定させやすくなります。
まずはこの方法で運用を安定化し、移行可能なタイミングで .NET 9 以降の Static Web Assets / Fingerprinting へ寄せていくという進め方が、既存環境では現実的だと思います。