ASP.NET Core Blazor WebAssembly 应用的部署布局

本文介绍如何在阻止下载和执行动态链接库 (DLL) 文件的环境中启用 Blazor WebAssembly 部署。

Blazor WebAssembly 应用需要动态链接库 (DLL) 才能运行,但某些环境会阻止客户端下载和执行 DLL。 在这些环境中的某些环境中,更改 DLL 文件的文件扩展名 (.dll) 足以绕过安全限制,但安全产品通常能够扫描通过网络传输的文件内容并阻止或隔离 DLL 文件。 本文介绍一种用于在这些环境中启用 Blazor WebAssembly 应用的方法,其中从应用的 DLL 创建多部分捆绑包文件,以便可以绕过安全限制一起下载 DLL。

托管的 Blazor WebAssembly 应用可使用以下功能自定义其发布的文件和应用 DLL 包:

  • JavaScript 初始化表达式,可允许自定义 Blazor 启动进程。
  • MSBuild 扩展性,用于转换已发布文件列表和定义 Blazor 发布扩展。 Blazor 发布扩展是在发布过程中定义的文件,可为运行已发布的 Blazor WebAssembly 应用所需的一组文件提供替代表示形式。 在本文中,创建了一个 Blazor 发布扩展,该扩展可生成一个多部分捆绑包并将应用的所有 DLL 打包到单个文件中,以便可以一起下载这些 DLL。

本文中演示的方法可作为开发人员设计自己的策略和自定义加载过程的起点。

警告

对于任何用于规避安全限制的方法,都必须仔细考虑其安全影响。 建议在采用本文中的方法之前,与你的组织的网络安全专家进一步探讨该主题。 要考虑的替代方式包括:

  • 启用安全设备和安全软件,以允许网络客户端下载和使用 Blazor WebAssembly 应用所需的确切文件。
  • 从 Blazor WebAssembly 托管模型切换到 Blazor Server 托管模型,后者将在服务器上维护应用的所有 C# 代码,并且不需要将 DLL 下载到客户端。 Blazor Server 还提供了保持 C# 代码专用性的好处,而无需使用 Web API 应用来保护 Blazor WebAssembly 应用的 C# 代码隐私。

试验性 NuGet 包和示例应用

试验性 Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle 包 (NuGet.org) 使用了本文中所述的方法。 此包包含用于自定义 Blazor 发布输出的 MSBuild 目标和用于使用自定义启动资源加载程序JavaScript 初始化表达式,本文稍后将详细介绍这些内容。

试验性代码(包括 NuGet 包参考源和 CustomPackagedApp 示例应用)

警告

提供实验性和预览功能是为了收集反馈,不支持用于生产。 有关详细信息以及如何向 ASP.NET Core 产品单位提供反馈,请参阅考虑发布受支持的 Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle 版本 (dotnet/aspnetcore #36978)

本文后面的通过 NuGet 包自定义 Blazor WebAssembly 加载过程部分及其三个小节详细说明了 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 包。

    注意

    有关将包添加到 .NET 应用的指南,请参阅包使用工作流(NuGet 文档)中“安装和管理包”下的文章。 在 NuGet.org 中确认正确的包版本。

  3. Server 项目中,添加用于提供捆绑包文件 (app.bundle) 的终结点。 示例代码可在本文的从主机服务器应用提供捆绑包部分中找到。

  4. 在发布配置中发布应用。

通过 NuGet 包自定义 Blazor WebAssembly 加载过程

警告

本部分及三个小节中的指南介绍如何从头开始生成 NuGet 包以实现你自己的策略和自定义加载过程。 试验性 Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle 包 (NuGet.org) 基于本部分中的指南。 在多部分捆绑包下载方法的本地演示中使用提供的包时,无需遵循本部分中的指南。 有关如何使用提供的包的指南,请参阅试验性 NuGet 包和示例应用部分。

Blazor 应用资源被打包到多部分捆绑包文件中,并由浏览器通过自定义 JavaScript (JS) 初始化表达式加载。 对于通过 JS 初始化表达式使用包的应用,该应用只要求在请求时提供捆绑包文件。 此方法的所有其他方面都是透明处理的。

默认的已发布 Blazor 应用的加载方式需要四项自定义:

  • MSBuild 任务,用于转换发布文件。
  • 包含 MSBuild 目标的 NuGet 包,可连接到 Blazor 发布过程、转换输出并定义一个或多个 Blazor 发布扩展文件(在本例中为一个捆绑包)。
  • JS 初始化表达式,用于更新 Blazor WebAssembly 资源加载程序回叫,以便加载捆绑包并为应用提供各个文件。
  • 主机 Server 应用上的帮助程序,可确保根据请求向客户端提供捆绑包。

创建 MSBuild 任务,以自定义已发布文件列表和定义新扩展

将 MSBuild 任务创建为公共 C# 类,该类可作为 MSBuild 编译的一部分导入且可与生成交互。

C# 类需要以下项:

注意

本文中示例的 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 任务,请创建一个扩展 Microsoft.Build.Utilities.Task(而非 System.Threading.Tasks.Task)的公共 C# 类并声明三个属性:

  • PublishBlazorBootStaticWebAsset:要为 Blazor 应用发布的文件列表。
  • BundlePath:写入捆绑包的路径。
  • Extension:要包含在生成中的新发布扩展。

以下示例 BundleBlazorAssets 类是进一步自定义的起点:

  • Execute 方法中,捆绑包是通过以下三种文件类型创建的:
    • JavaScript 文件 (dotnet.js)
    • WASM 文件 (dotnet.wasm)
    • 应用 DLL (*.dll)
  • 已创建 multipart/form-data 捆绑包。 每个文件都通过 Content-Disposition 标头Content-Type 标头添加到捆绑包中,并具有各自的说明。
  • 创建捆绑包后,捆绑包会写入到文件中。
  • 生成是为扩展配置的。 以下代码会创建一个扩展项并将其添加到 Extension 属性。 每个扩展项都包含三个数据:
    • 扩展文件的路径。
    • 对应于 Blazor WebAssembly 应用根目录的 URL 路径。
    • 扩展名,它对由给定扩展名生成的文件进行分组。

完成上述目标后,将创建 MSBuild 任务以自定义 Blazor 发布输出。 Blazor 负责收集扩展并确保将扩展复制到发布输出文件夹中的正确位置(例如 bin\Release\net6.0\publish)。 相同的优化(例如压缩)会应用于 JavaScript、WASM 和 DLL 文件,这与 Blazor 应用于其他文件一样。

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 任务的类库中收集输出,并确认已将输出打包到正确位置。
  • 添加必要的 MSBuild 代码以附加到 Blazor 管道,并调用 MSBuild 任务以生成捆绑包。

本部分中所介绍的方法仅使用包来交付目标和内容,这与其中包含库 DLL 的大多数包不同。

警告

本部分中所述的示例包演示了如何自定义 Blazor 发布过程。 示例 NuGet 包仅用于本地演示。 不支持在生产环境中使用此包。

注意

本文中示例的 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

添加 .targets 文件以将 MSBuild 任务连接到生成管道。 在此文件中,实现了以下目标:

  • 将任务导入到生成过程中。 请注意,DLL 的路径与文件在包中的最终位置有关。
  • ComputeBlazorExtensionsDependsOn 属性将自定义目标附加到 Blazor WebAssembly 管道。
  • 捕获任务输出上的 Extension 属性,并将其添加到 BlazorPublishExtension 以告知 Blazor 扩展。 调用目标中的任务将生成捆绑包。 已发布文件的列表由 PublishBlazorBootStaticWebAsset 项组中的 Blazor WebAssembly 管道提供。 捆绑包路径是使用 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) 初始化表达式从捆绑包中自动启动 Blazor WebAssembly 应用,而不是使用单个 DLL 文件。 JS 初始化表达式用于更改 Blazor启动资源加载程序和使用捆绑包。

若要创建 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 文件。 如果客户端请求文件,需要请求处理帮助程序才能提供文件。

注意

由于相同的优化透明地应用于应用于发布扩展(该扩展已应用于应用的文件),因此在发布时会自动生成 app.bundle.gzapp.bundle.br 压缩资产文件。

将 C# 代码放在 Server 项目的 Program.cs 中并位于将回退文件设置为 index.html (app.MapFallbackToFile("index.html");) 的行之前,以响应对捆绑包文件(例如 app.bundle)的请求:

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)。