ASP.NET Core でホストされる Blazor WebAssembly アプリの展開レイアウト

この記事では、ダイナミックリンク ライブラリ (DLL) ファイルのダウンロードと実行がブロックされている環境でホストされている Blazor WebAssembly の展開を有効にする方法について説明します。

Note

このガイダンスでは、クライアントによる DLL のダウンロードと実行をブロックする環境について説明します。 .NET 8 以降では、Blazor は Webcil ファイル形式を使用してこの問題に対処します。 詳しくは、「ASP.NET Core Blazor WebAssembly のホストと展開」をご覧ください。 この記事で説明する試験段階の NuGet パッケージを使用したマルチパート バンドルは、.NET 8 以降の Blazor アプリではサポートされていません。 詳細については、Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle パッケージを拡張してカスタム バンドル形式 (dotnet/aspnetcore #36978) を定義する方法に関するページを参照してください。 この記事のガイダンスを使用して、.NET 8 以降用の独自のマルチパート バンドル NuGet パッケージを作成できます。

Blazor WebAssembly アプリが機能するにはダイナミックリンク ライブラリ (DLL) が必要ですが、一部の環境ではクライアントによる DLL のダウンロードと実行がブロックされています。 このような環境のサブセットでは、DLL ファイルのファイル名拡張子 (.dll) を変更するとセキュリティ制限を十分にバイパスできますが、多くの場合、セキュリティ製品では、ネットワークを通過するファイルの内容をスキャンし、DLL ファイルをブロックまたは検疫できます。 この記事では、このような環境で Blazor WebAssembly アプリを有効にする方法の 1 つについて説明します。この方法では、マルチパート バンドル ファイルをアプリの DLL から作成し、セキュリティ制限をバイパスして DLL のダウンロードをまとめてできるようにします。

ホストされた Blazor WebAssembly アプリでは、次の機能を使用して、発行されたファイルとアプリ DLL のパッケージ化をカスタマイズできます。

  • Blazor のブート プロセスをカスタマイズできる JavaScript イニシャライザー
  • 発行されたファイルのリストを変換し、 Blazor 発行拡張機能を定義する MSBuild 拡張性。 Blazor 発行拡張機能は、発行プロセスの間に定義されるファイルであり、発行された Blazor WebAssembly アプリの実行に必要なファイルのセットの代替表現を提供します。 この記事では、DLL を一緒にダウンロードできるように、アプリのすべての DLL が 1 つのファイルにパックされたマルチパート バンドルを生成する Blazor 発行拡張機能を作成します。

開発者は、この記事で示すアプローチを基にして、独自の戦略とカスタム読み込みプロセスを考案できます。

警告

セキュリティ制限を回避するために使用する方法については、セキュリティへの影響を慎重に検討する必要があります。 この記事のアプローチを採用する前に、組織のネットワーク セキュリティ プロフェッショナルと共にこの問題を調べることをお勧めします。 検討すべき代替手段としては次のようなものがあります。

  • セキュリティ アプライアンスとセキュリティ ソフトウェアを有効にして、Blazor WebAssembly アプリで必要なファイルだけをネットワーク クライアントがダウンロードして使用できるようにします。
  • Blazor WebAssembly ホスティング モデルから Blazor Server ホスティング モデルに切り替えます。このようにすると、アプリのすべての C# コードがサーバー上に保持され、DLL をクライアントにダウンロードする必要がありません。 また、Blazor Server には、C# コードがプライベートに保たれ、Blazor WebAssembly アプリでの C# コードのプライバシー用に Web API アプリを使用する必要がないという利点もあります。

実験のための NuGet パッケージとサンプル アプリ

この記事で説明するアプローチは、.NET 6 以降を対象とするアプリ向けの "試験段階の" Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle パッケージ (NuGet.org) で使われています。 このパッケージには、Blazor の発行出力をカスタマイズするための MSBuild ターゲットと、カスタム ブート リソース ローダーを使用するための JavaScript イニシャライザーが含まれています。それぞれについて、この記事で後ほど詳しく説明します。

実験のためのコード (NuGet パッケージ参照ソースと CustomPackagedApp サンプル アプリが含まれます)

警告

実験用およびプレビュー機能は、フィードバックを収集する目的で提供されており、運用環境での使用はサポートしていません。

この記事の後半の「NuGet パッケージを使用して Blazor WebAssembly の読み込みプロセスをカスタマイズする」セクションと、その 3 つのサブセクションでは、Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle パッケージの構成とコードについて詳しく説明します。 詳細な説明は、Blazor WebAssembly アプリ用の独自の戦略とカスタム読み込みプロセスを作成するときに理解していることが重要です。 ローカル デモとして、カスタマイズせずに、発行された実験のためのサポートされない NuGet パッケージを使用するには、次の手順のようにします。

  1. ホストされている既存のBlazor WebAssembly ソリューションを使用します。または、Visual Studio を使用するか、-ho|--hosted オプションdotnet new コマンド (dotnet new blazorwasm -ho) に渡すことで、Blazor WebAssembly プロジェクト テンプレートから新しいソリューションを作成します。 詳しくは、「ASP.NET Core Blazor 用のツール」をご覧ください。

  2. Client プロジェクトに、実験用の Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle パッケージを追加します。

    Note

    .NET アプリへのパッケージの追加に関するガイダンスについては、「パッケージ利用のワークフロー」 (NuGet ドキュメント) の "パッケージのインストールと管理" に関する記事を参照してください。 NuGet.org で正しいパッケージ バージョンを確認します。

  3. Server プロジェクトで、バンドル ファイル (app.bundle) を提供するためのエンドポイントを追加します。 コード例については、この記事の「ホスト サーバー アプリからバンドルを提供する」セクションで確認できます。

  4. リリース構成でアプリを発行します。

NuGet パッケージを使用して Blazor WebAssembly の読み込みプロセスをカスタマイズする

警告

このセクションと 3 つのサブセクションのガイダンスは、独自の戦略とカスタム読み込みプロセスを実装するための、ゼロからの NuGet パッケージの構築に関連しています。 .NET 6 および 7 向けの "試験段階の" Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle パッケージ (NuGet.org) は、このセクションのガイダンスに基づいています。 マルチパート バンドルのダウンロード方法のローカル デモで、提供されたパッケージを使用する場合は、このセクションのガイダンスに従う必要はありません。 提供されたパッケージを使用する方法のガイダンスについては、「実験のための NuGet パッケージとサンプル アプリ」セクションを参照してください。

Blazor アプリのリソースは、マルチパート バンドル ファイルにパックされており、カスタム JavaScript (JS) イニシャライザーを介して、ブラウザーによって読み込まれます。 JS イニシャライザーでパッケージを使用するアプリでは、要求時のバンドル ファイルの提供が必要となるのみです。 このアプローチの他のすべての部分は透過的に処理されます。

既定の発行済み Blazor アプリの読み込み方法に対して、次の 4 つのカスタマイズが必要です。

  • 発行ファイルを変換するための MSBuild タスク。
  • Blazor 発行プロセスにフックし、出力を変換し、1 つ以上の Blazor 発行拡張機能ファイル (この場合は 1 つのバンドル) を定義する、MSBuild ターゲットを含む NuGet パッケージ。
  • バンドルを読み込み、個々のファイルをアプリに提供するよう、Blazor WebAssembly リソース ローダー コールバックを更新するための JS イニシャライザー。
  • 要求に応じてバンドルがクライアントに提供されるようにするための、ホスト Server アプリ上のヘルパー。

発行されたファイルのリストをカスタマイズして新しい拡張機能を定義する MSBuild タスクを作成する

MSBuild コンパイルの一部としてインポートすることができ、ビルドと対話できる、パブリック C# クラスとして MSBuild タスクを作成します。

C# クラスには次のものが必要です。

Note

この記事の例の NuGet パッケージの名前は、Microsoft から提供されるパッケージ Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle にちなんで付けられています。 独自の NuGet パッケージの名前付けと生成に関するガイダンスについては、NuGet の次の記事を参照してください。

Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.Tasks/Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.Tasks.csproj:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>8.0</LangVersion>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Build.Framework" Version="{VERSION}" />
    <PackageReference Include="Microsoft.Build.Utilities.Core" Version="{VERSION}" />
  </ItemGroup>

</Project>

NuGet.org で {VERSION} プレースホルダーに対する最新のパッケージのバージョンを確認します。

この MSBuild タスクを作成するには、(System.Threading.Tasks.Task ではなく) Microsoft.Build.Utilities.Task を拡張するパブリック C# クラスを作成して、3 つのプロパティを宣言します。

  • PublishBlazorBootStaticWebAsset: Blazor アプリ用に発行するファイルのリスト。
  • BundlePath: バンドルが書き込まれるパス。
  • Extension: ビルドに含める新しい発行拡張機能。

さらにカスタマイズを行うときは、次の例の BundleBlazorAssets クラスを基にします。

  • Execute メソッドでは、次の 3 種類のファイルからバンドルが作成されます。
    • JavaScript ファイル (dotnet.js)
    • WASM ファイル (dotnet.wasm)
    • アプリ DLL (.dll)
  • multipart/form-data バンドルが作成されます。 各ファイルは、Content-Disposition ヘッダーContent-Type ヘッダーにより、それぞれの説明と共にバンドルに追加されます。
  • バンドルが作成されて、ファイルに書き込まれます。
  • 拡張機能用にビルドが構成されます。 次のコードにより、拡張機能項目が作成されて、Extension プロパティに追加されます。 各拡張機能項目には、3 つのデータが含まれています。
    • 拡張機能ファイルへのパス。
    • Blazor WebAssembly アプリのルートを基準にした URL パス。
    • 特定の拡張機能によって生成されたファイルをグループ化する拡張機能の名前。

前の目標を達成した後、Blazor 発行出力をカスタマイズするための MSBuild タスクが作成されます。 Blazor によって、拡張機能が収集され、発行出力フォルダー (例: bin\Release\net6.0\publish) の正しい場所に拡張機能がコピーされます。 Blazor によって他のファイルに適用されるのと同じ最適化 (圧縮など) が、JavaScript、WASM、DLL ファイルに適用されます。

Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.Tasks/BundleBlazorAssets.cs:

using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;

namespace Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.Tasks
{
    public class BundleBlazorAssets : Task
    {
        [Required]
        public ITaskItem[]? PublishBlazorBootStaticWebAsset { get; set; }

        [Required]
        public string? BundlePath { get; set; }

        [Output]
        public ITaskItem[]? Extension { get; set; }

        public override bool Execute()
        {
            var bundle = new MultipartFormDataContent(
                "--0a7e8441d64b4bf89086b85e59523b7d");

            foreach (var asset in PublishBlazorBootStaticWebAsset)
            {
                var name = Path.GetFileName(asset.GetMetadata("RelativePath"));
                var fileContents = File.OpenRead(asset.ItemSpec);
                var content = new StreamContent(fileContents);
                var disposition = new ContentDispositionHeaderValue("form-data");
                disposition.Name = name;
                disposition.FileName = name;
                content.Headers.ContentDisposition = disposition;
                var contentType = Path.GetExtension(name) switch
                {
                    ".js" => "text/javascript",
                    ".wasm" => "application/wasm",
                    _ => "application/octet-stream"
                };
                content.Headers.ContentType = 
                    MediaTypeHeaderValue.Parse(contentType);
                bundle.Add(content);
            }

            using (var output = File.Open(BundlePath, FileMode.OpenOrCreate))
            {
                output.SetLength(0);
                bundle.CopyToAsync(output).ConfigureAwait(false).GetAwaiter()
                    .GetResult();
                output.Flush(true);
            }

            var bundleItem = new TaskItem(BundlePath);
            bundleItem.SetMetadata("RelativePath", "app.bundle");
            bundleItem.SetMetadata("ExtensionName", "multipart");

            Extension = new ITaskItem[] { bundleItem };

            return true;
        }
    }
}

発行出力を自動的に変換する NuGet パッケージを作成する

パッケージが参照されると自動的に組み込まれる MSBuild ターゲットを含む NuGet パッケージを生成します。

  • 新しいRazor クラス ライブラリ (RCL) プロジェクトを作成します。
  • NuGet の規則に従って、使用する側のプロジェクトにパッケージを自動的にインポートするターゲット ファイルを作成します。 たとえば、build\net6.0\{PACKAGE ID}.targets を作成します。ここで、{PACKAGE ID} はパッケージのパッケージ識別子です。
  • MSBuild タスクが含まれるクラス ライブラリからの出力を収集し、出力が正しい場所にパックされていることを確認します。
  • Blazor パイプラインにアタッチし、MSBuild タスクを呼び出してバンドルを生成するために必要な MSBuild コードを追加します。

このセクションで説明する方法では、ターゲットとコンテンツを提供するためにだけパッケージを使用します。これは、パッケージにライブラリ DLL が含まれるほとんどのパッケージとは異なります。

警告

このセクションで説明するサンプル パッケージでは、Blazor の発行プロセスをカスタマイズする方法が示されています。 サンプルの NuGet パッケージは、ローカルでのデモンストレーション用のみに使用できます。 このパッケージの運用環境での使用はサポートされていません。

Note

この記事の例の NuGet パッケージの名前は、Microsoft から提供されるパッケージ Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle にちなんで付けられています。 独自の NuGet パッケージの名前付けと生成に関するガイダンスについては、NuGet の次の記事を参照してください。

Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle/Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.csproj:

<Project Sdk="Microsoft.NET.Sdk.Razor">

  <PropertyGroup>
    <NoWarn>NU5100</NoWarn>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <Description>
      Sample demonstration package showing how to customize the Blazor publish 
      process. Using this package in production is not supported!
    </Description>
    <IsPackable>true</IsPackable>
    <IsShipping>true</IsShipping>
    <IncludeBuildOutput>false</IncludeBuildOutput>
  </PropertyGroup>

  <ItemGroup>
    <None Update="build\**" 
          Pack="true" 
          PackagePath="%(Identity)" />
    <Content Include="_._" 
             Pack="true" 
             PackagePath="lib\net6.0\_._" />
  </ItemGroup>

  <Target Name="GetTasksOutputDlls" 
          BeforeTargets="CoreCompile">
    <MSBuild Projects="..\Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.Tasks\Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.Tasks.csproj" 
             Targets="Publish;PublishItemsOutputGroup" 
             Properties="Configuration=Release">
      <Output TaskParameter="TargetOutputs" 
              ItemName="_TasksProjectOutputs" />
    </MSBuild>
    <ItemGroup>
      <Content Include="@(_TasksProjectOutputs)" 
               Condition="'%(_TasksProjectOutputs.Extension)' == '.dll'" 
               Pack="true" 
               PackagePath="tasks\%(_TasksProjectOutputs.TargetPath)" 
               KeepMetadata="Pack;PackagePath" />
    </ItemGroup>
  </Target>

</Project>

注意

前の例の <NoWarn>NU5100</NoWarn> プロパティにより、tasks フォルダーに配置されたアセンブリに関する警告が抑制されます。 詳細については、「NuGet 警告 NU5100」を参照してください。

MSBuild タスクをビルド パイプラインに接続するための .targets ファイルを追加します。 このファイルでは、次の目標が実現されます。

  • タスクをビルド プロセスにインポートします。 DLL へのパスは、パッケージ内のファイルの最終的な場所に対する相対パスであることに注意してください。
  • ComputeBlazorExtensionsDependsOn プロパティにより、カスタム ターゲットが Blazor WebAssembly パイプラインにアタッチされます。
  • タスクの出力で Extension プロパティをキャプチャし、それを BlazorPublishExtension に追加して、拡張機能に Blazor について通知します。 ターゲットでタスクを呼び出すと、バンドルが生成されます。 発行されたファイルのリストが、Blazor WebAssembly パイプラインの PublishBlazorBootStaticWebAsset 項目グループによって提供されます。 バンドル パスは、IntermediateOutputPath を使用して定義されます (通常は obj フォルダー内)。 最終的に、バンドルは発行出力フォルダー内の適切な場所 (たとえば bin\Release\net6.0\publish) に自動的にコピーされます。

パッケージが参照されると、発行の間に Blazor ファイルのバンドルが生成されます。

Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle/build/net6.0/Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.targets:

<Project>
  <UsingTask 
    TaskName="Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.Tasks.BundleBlazorAssets" 
    AssemblyFile="$(MSBuildThisProjectFileDirectory)..\..\tasks\Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.Tasks.dll" />

  <PropertyGroup>
    <ComputeBlazorExtensionsDependsOn>
      $(ComputeBlazorExtensionsDependsOn);_BundleBlazorDlls
    </ComputeBlazorExtensionsDependsOn>
  </PropertyGroup>

  <Target Name="_BundleBlazorDlls">
    <BundleBlazorAssets
      PublishBlazorBootStaticWebAsset="@(PublishBlazorBootStaticWebAsset)"
      BundlePath="$(IntermediateOutputPath)bundle.multipart">
      <Output TaskParameter="Extension" 
              ItemName="BlazorPublishExtension"/>
    </BundleBlazorAssets>
  </Target>

</Project>

バンドルから Blazor を自動的にブートストラップする

NuGet パッケージでは、JavaScript (JS) イニシャライザーを利用して、個々の DLL ファイルを使用するのではなく、バンドルから Blazor WebAssembly アプリが自動的にブートストラップされます。 JS イニシャライザーは、Blazor ブート リソース ローダーを変更し、バンドルを使用するために使用されます。

JS イニシャライザーを作成するには、{NAME}.lib.module.js という名前の JS ファイルを、パッケージ プロジェクトの wwwroot フォルダーに追加します。{NAME} プレースホルダーはパッケージ識別子です。 たとえば、Microsoft パッケージ用のファイルは Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.lib.module.js という名前です。 エクスポートされた関数 beforeWebAssemblyStartafterWebAssemblyStarted により、読み込みが処理されます。

JS 初期化子の場合は、次のようにします。

Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle/wwwroot/Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.lib.module.js:

const resources = new Map();

export async function beforeWebAssemblyStart(options, extensions) {
  if (!extensions || !extensions.multipart) {
    return;
  }

  try {
    const integrity = extensions.multipart['app.bundle'];
    const bundleResponse = 
      await fetch('app.bundle', { integrity: integrity, cache: 'no-cache' });
    const bundleFromData = await bundleResponse.formData();
    for (let value of bundleFromData.values()) {
      resources.set(value, URL.createObjectURL(value));
    }
    options.loadBootResource = function (type, name, defaultUri, integrity) {
      return resources.get(name) ?? null;
    }
  } catch (error) {
    console.log(error);
  }
}

export async function afterWebAssemblyStarted(blazor) {
  for (const [_, url] of resources) {
    URL.revokeObjectURL(url);
  }
}

JS イニシャライザーを作成するには、{NAME}.lib.module.js という名前の JS ファイルを、パッケージ プロジェクトの wwwroot フォルダーに追加します。{NAME} プレースホルダーはパッケージ識別子です。 たとえば、Microsoft パッケージ用のファイルは Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.lib.module.js という名前です。 エクスポートされた関数 beforeStartafterStarted により、読み込みが処理されます。

JS 初期化子の場合は、次のようにします。

Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle/wwwroot/Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.lib.module.js:

const resources = new Map();

export async function beforeStart(options, extensions) {
  if (!extensions || !extensions.multipart) {
    return;
  }

  try {
    const integrity = extensions.multipart['app.bundle'];
    const bundleResponse = 
      await fetch('app.bundle', { integrity: integrity, cache: 'no-cache' });
    const bundleFromData = await bundleResponse.formData();
    for (let value of bundleFromData.values()) {
      resources.set(value, URL.createObjectURL(value));
    }
    options.loadBootResource = function (type, name, defaultUri, integrity) {
      return resources.get(name) ?? null;
    }
  } catch (error) {
    console.log(error);
  }
}

export async function afterStarted(blazor) {
  for (const [_, url] of resources) {
    URL.revokeObjectURL(url);
  }
}

ホスト サーバー アプリからバンドルを提供する

セキュリティの制限により、既定では ASP.NET Core から app.bundle ファイルは提供されません。 クライアントによって要求されたときにファイルを提供するには、要求処理ヘルパーが必要です。

Note

アプリのファイルに適用されるのと同じ最適化が、発行拡張機能に透過的に適用されるため、app.bundle.gzapp.bundle.br の圧縮されたアセット ファイルが発行時に自動的に生成されます。

バンドル ファイル (app.bundle など) の要求に応答するには、Server プロジェクトの Program.cs で、index.html にフォールバック ファイルを設定する行 (app.MapFallbackToFile("index.html");) の直前に、次の C# コードを配置します。

app.MapGet("app.bundle", (HttpContext context) =>
{
    string? contentEncoding = null;
    var contentType = 
        "multipart/form-data; boundary=\"--0a7e8441d64b4bf89086b85e59523b7d\"";
    var fileName = "app.bundle";

    var acceptEncodings = context.Request.Headers.AcceptEncoding;

    if (Microsoft.Net.Http.Headers.StringWithQualityHeaderValue
        .StringWithQualityHeaderValue
        .TryParseList(acceptEncodings, out var encodings))
    {
        if (encodings.Any(e => e.Value == "br"))
        {
            contentEncoding = "br";
            fileName += ".br";
        }
        else if (encodings.Any(e => e.Value == "gzip"))
        {
            contentEncoding = "gzip";
            fileName += ".gz";
        }
    }

    if (contentEncoding != null)
    {
        context.Response.Headers.ContentEncoding = contentEncoding;
    }

    return Results.File(
        app.Environment.WebRootFileProvider.GetFileInfo(fileName)
            .CreateReadStream(), contentType);
});

コンテンツの種類は、ビルド タスクで前に定義した種類と一致します。 エンドポイントにより、ブラウザーによって受け入れられるコンテンツのエンコードが調べられて、最適なファイル (Brotli (.br) または Gzip (.gz)) が提供されます。