ASP.NET Core 托管的 Blazor WebAssembly 应用程序的部署布局

注释

此版本不是本文的最新版本。 有关当前版本,请参阅 本文的 .NET 9 版本

警告

此版本的 ASP.NET Core 不再受支持。 有关详细信息,请参阅 .NET 和 .NET Core 支持策略。 有关当前版本,请参阅 本文的 .NET 9 版本

重要

此信息与预发布产品相关,相应产品在商业发布之前可能会进行重大修改。 Microsoft对此处提供的信息不作任何明示或暗示的保证。

有关当前版本,请参阅 本文的 .NET 9 版本

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

注释

本指南介绍阻止客户端下载和执行 DLL 的环境。 在 .NET 8 或更高版本中,Blazor 使用 Webcil 文件格式解决此问题。 有关详细信息,请参阅 主机和部署 ASP.NET Core Blazor WebAssembly。 在 .NET 8 或更高版本中,Blazor 应用程序不支持使用本文所述的实验 NuGet 包进行多部分捆绑。 可以使用本文中的指南为 .NET 8 或更高版本创建自己的多部分捆绑 NuGet 包。

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

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

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

  • JavaScript 初始值设定项 允许定制Blazor启动过程。
  • MSBuild 扩展性,用于转换已发布文件列表并定义 Blazor 发布扩展。 Blazor 发布扩展是在发布过程中定义的文件,提供运行已发布 Blazor WebAssembly 应用所需的文件集的替代表示形式。 在本文中,将创建一个Blazor发布扩展模块,用于生成一个将应用程序的所有 DLL 打包到单个文件中的多部分捆绑包,以便能够一起下载。

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

警告

必须仔细考虑任何规避安全限制的方法,以考虑其安全隐患。 建议在采用本文中的方法之前,与组织的网络安全专业人员进一步探讨该主题。 要考虑的替代方法包括:

  • 启用安全设备和安全软件,以允许网络客户端下载和使用应用所需的 Blazor WebAssembly 确切文件。
  • 从 Blazor WebAssembly 托管模型切换到 Blazor Server 托管模型,该模型维护服务器上的所有应用的 C# 代码,并且不需要将 DLL 下载到客户端。 Blazor Server 还提供一种无需通过使用 Web API 应用程序,即可保持 C# 代码私密的优势,与 Blazor WebAssembly 应用程序一起确保 C# 代码的隐私保护。

试验 NuGet 包和示例应用

本文中所述的方法由 试验Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle 包(NuGet.org) 用于面向 .NET 6 或更高版本的应用。 该包包含 MSBuild 目标,用于自定义 Blazor 发布输出和 JavaScript 初始值设定项以使用自定义 启动资源加载程序,本文随后将详细介绍每个内容。

警告

实验功能和预览版功能用于收集反馈,不支持用于生产用途。

本文稍后在 通过 NuGet 包及其三个子部分自定义 Blazor WebAssembly 加载过程 部分,对 Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle 包中的配置和代码进行了详细说明。 在为 Blazor WebAssembly 应用创建自己的策略和自定义加载过程时,请务必了解详细说明。 若要将实验性、已发布的、不受支持的 NuGet 包作为 本地演示,请执行以下步骤:

  1. 使用现有托管Blazor WebAssembly解决方案,或通过 Visual Studio 或将Blazor WebAssembly选项传递给-ho|--hosted),从dotnet new项目模板创建新解决方案dotnet new blazorwasm -ho。 有关详细信息,请参阅 ASP.NET Core Blazor的工具

  2. Client 项目中,添加实验 Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle 包。

    注释

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

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

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

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

警告

本部分中的指南及其三个子部分涉及从头开始构建 NuGet 包,以实现自己的策略和自定义加载过程。 .NET 6 和 7 的 实验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有关扩展的信息。 调用目标中的任务将生成捆绑包。 已发布文件的列表由 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) 初始值设定项 从捆绑包自动启动 Blazor WebAssembly 应用,而不是使用单个 DLL 文件。 JS 初始化器用于更改 Blazor启动资源加载程序 并使用该捆绑包。

若要创建JS初始值设定项,请在包项目的JS文件夹中添加一个名为{NAME}.lib.module.jswwwroot文件,其中{NAME}占位符是包标识符。 例如,Microsoft包的文件被命名为Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.lib.module.js。 导出的函数 beforeWebAssemblyStartafterWebAssemblyStarted 处理加载。

初始化器JS:

  • 通过检查 extensions.multipart,即在 ExtensionName 部分中提供的扩展名 ,来检测发布扩展是否可用。
  • 下载捆绑包,并使用生成的对象 URL 将内容分析到资源映射中。
  • 使用自定义函数更新 启动资源加载程序(options.loadBootResource), 该函数使用对象 URL 解析资源。
  • 应用启动后,撤销对象 URL 以释放函数中的 afterWebAssemblyStarted 内存。

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初始值设定项,请在包项目的JS文件夹中添加一个名为{NAME}.lib.module.jswwwroot文件,其中{NAME}占位符是包标识符。 例如,Microsoft包的文件被命名为Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.lib.module.js。 导出的函数 beforeStartafterStarted 处理加载。

初始化器JS:

  • 通过检查 extensions.multipart,即在 ExtensionName 部分中提供的扩展名 ,来检测发布扩展是否可用。
  • 下载捆绑包,并使用生成的对象 URL 将内容分析到资源映射中。
  • 使用自定义函数更新 启动资源加载程序(options.loadBootResource), 该函数使用对象 URL 解析资源。
  • 应用启动后,撤销对象 URL 以释放函数中的 afterStarted 内存。

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# 代码放置在 Program.cs 项目中设置回退文件为 Serverindex.html)的行之前,以便响应捆绑文件的请求(例如,app.MapFallbackToFile("index.html");):

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