Angular 与 ASP.NET Core 发布物合并为单一 Docker 镜像

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处

Angular 和 ASP.NET Core 分别部署很常见:一个域名提供前端静态文件,另一个域名运行 API。这样做没有问题,但对中小型应用来说,两套入口也会带来额外工作:需要维护两个域名和两份反向代理配置,前端还要保存 API 的完整地址,并处理跨域请求。

如果希望它们共用一个域名和一个容器,不必把 Angular 源码搬进 ASP.NET Core 项目,也不必在 Docker 中重新构建。更简单的做法是保留各自的构建过程,只在发布阶段组装产物,再让 ASP.NET Core 托管 Angular 生成的文件。

合并的是发布物,不是项目

前后端仍然独立构建:

Angular source ── ng build ────────┐
                                   ├── publish/ ── Docker image
ASP.NET Core ── dotnet publish ────┘

最终目录可以约定为:

publish/
├── WebHost.dll
├── WebHost.deps.json
├── WebHost.runtimeconfig.json
├── appsettings.json
└── frontend/
    ├── index.html
    ├── assets/
    └── *.js

frontend 只是发布物中的一个目录,不必叫 wwwroot。ASP.NET Core 的静态文件中间件支持通过 PhysicalFileProvider 指定 Web Root 之外的物理目录。

这种目录约定还有一个实际好处:打开发布目录就能判断打包是否完整。后端程序集和前端入口文件缺少任何一项,都不应该继续构建镜像。

在 ASP.NET Core 中托管独立的 SPA 目录

下面以 .NET 8 的最小托管模型为例。假设 Angular 产物位于应用内容根目录下的 frontend

using Microsoft.Extensions.FileProviders;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

var app = builder.Build();

var frontendRoot = Path.Combine(
    app.Environment.ContentRootPath,
    "frontend");

if (!Directory.Exists(frontendRoot))
{
    throw new DirectoryNotFoundException(
        $"Angular publish directory was not found: {frontendRoot}");
}

var frontendFiles = new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(frontendRoot)
};

app.UseStaticFiles(frontendFiles);

app.UseRouting();

app.MapControllers();

app.MapGet("/api/health", () => Results.Ok(new { status = "ok" }));

// 未匹配到控制器的 API 必须保持 404,不能落入 SPA。
app.Map("/api/{**path}", () => Results.NotFound());

// 只有不带文件名、且未被其他终结点处理的 GET/HEAD 请求才返回 index.html。
app.MapFallbackToFile(
    "{*path:nonfile}",
    "index.html",
    frontendFiles);

app.Run();

这里有三层行为:

  • UseStaticFiles 直接返回真实存在的 JavaScript、CSS、图片和 index.html
  • 控制器处理实际存在的 API;
  • MapFallbackToFile 最后接管 Angular Router 使用的客户端路由。

MapFallbackToFile API注册的是最低优先级终结点,适合放在 API 和其他服务终结点之后。{*path:nonfile} 还会排除看起来像文件的路径,所以请求一个不存在的 app.js 会得到 404,而不是一份内容类型错误的 index.html

为什么还要保留 API 的 404 边界

仅仅添加 SPA fallback 还不够。假设前端误写了一个接口地址:

GET /api/orderss

如果没有 /api/{**path} 这条兜底规则,未命中的 API 也可能落到 index.html。HTTP 状态码变成 200,响应体却是 HTML。前端通常只会报告 JSON 解析失败,真正的路由错误反而被藏了起来。

因此,请求边界应当明确:

/api/...          -> API;未匹配时返回 404
/assets/...       -> 静态资源;文件不存在时返回 404
其他非文件路径    -> Angular index.html

如果项目还有健康检查、SignalR Hub、Swagger 或下载接口,也要先映射这些终结点,再注册 SPA fallback。

让 Angular 使用同源 API

合并发布后,前端不应继续保存旧 API 域名。浏览器已经从同一个站点加载页面,API 地址使用相对路径即可:

{
  "apiBaseUrl": "/api",
  "appBaseUrl": "/"
}

这样请求会自然发往当前页面所在的协议、域名和端口。部署地址变化时,不需要重新写入另一个绝对域名,常规请求也不再需要 CORS。

Angular 部署在域名根路径时,页面中的 base URL 应保持为:

<base href="/">

如果应用以后改到 /admin/ 之类的子路径,Angular 的 base href、静态文件的 RequestPath 和服务端 fallback 范围必须一起调整。只改其中一处,刷新客户端路由或加载延迟模块时就会出现 404。

用脚本组装发布目录

脚本的职责只是依次进入前后端目录、执行构建并复制产物。下面的目录名都是示例,应替换为实际项目结构:

param(
    [switch]$BuildFrontend
)

$ErrorActionPreference = "Stop"

$rootDirectory = $PSScriptRoot
$publishDirectory = Join-Path $rootDirectory "dist/publish"

Push-Location $rootDirectory

try
{
    Remove-Item $publishDirectory -Recurse -Force -ErrorAction Ignore

    Set-Location "src/Server"
    dotnet publish -c Release -o $publishDirectory -t:Rebuild
    if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed: $LASTEXITCODE" }

    Set-Location "$rootDirectory/src/Web"

    if ($BuildFrontend)
    {
        npm ci
        if ($LASTEXITCODE -ne 0) { throw "npm ci failed: $LASTEXITCODE" }

        npm run build
        if ($LASTEXITCODE -ne 0) { throw "npm run build failed: $LASTEXITCODE" }
    }

    if (-not (Test-Path "dist/browser/index.html" -PathType Leaf))
    {
        throw "Angular build output was not found: dist/browser/index.html"
    }

    $frontendDirectory = Join-Path $publishDirectory "frontend"
    New-Item $frontendDirectory -ItemType Directory -Force | Out-Null
    Copy-Item "dist/browser/*" $frontendDirectory -Recurse -Force
}
finally
{
    Pop-Location
}

这里不能只检查 PowerShell Cmdlet。dotnetnpm 是原生程序,失败时必须立即检查 $LASTEXITCODEfinally 则保证脚本无论在哪个阶段失败,都会回到调用前的目录。更完整的取舍见用工作目录简化 PowerShell 发布脚本

默认不构建 Angular,适合前端产物已经准备好的情况:

.\build-package.ps1

需要刷新前端产物时再显式传入参数:

.\build-package.ps1 -BuildFrontend

Angular deployment 文档说明了构建与部署产物。不同 Angular 版本和 builder 的输出目录可能是 dist/&lt;project&gt;dist/&lt;project&gt;/browser 或自定义路径,复制前应以 angular.json 中的实际配置为准,不要照搬示例目录。

Dockerfile 只复制产物

构建上下文只需要包含已经组装好的 publish 目录和 Dockerfile:

FROM mcr.microsoft.com/dotnet/aspnet:8.0

WORKDIR /app
COPY publish/ ./

ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080

ENTRYPOINT ["dotnet", "WebHost.dll"]

这里没有 Node.js SDK,也没有 dotnet publish。Docker 构建只负责把可运行的发布物装进与目标 .NET 版本相匹配的 ASP.NET Core Runtime 镜像:

docker build -t example/web-host:latest .

这种方式要求发布环境和容器目标一致。用于 Linux 容器时,后端发布物必须能够在 Linux 上运行;项目若包含本机动态库或依赖特定平台的组件,需要另外验证。纯托管程序集通常不需要指定 win-x64 一类 Windows RID。

发布后怎样检查

先把容器的 8080 端口映射到本机:

docker run -d --rm --name web-host -p 8080:8080 example/web-host:latest

不必先配置域名、HTTPS 或反向代理。在另一个终端直接请求本地端口,就能检查路由边界:

curl -i http://localhost:8080/
curl -i http://localhost:8080/users/list
curl -i http://localhost:8080/api/health
curl -i http://localhost:8080/api/not-exists
curl -i http://localhost:8080/assets/not-exists.js

结果应当满足:

  • / 返回 Angular 的 index.html
  • /users/list 即使是客户端路由,也返回 index.html
  • 已存在的 API 返回它自己的状态码和内容类型;
  • 不存在的 API 返回 404,响应体不是 HTML;
  • 不存在的静态资源返回 404,不由 SPA fallback 接管。

检查完成后停止测试容器:

docker stop web-host

还应直接检查镜像内的目录,确认前端文件确实进入最终产物:

docker run --rm --entrypoint sh example/web-host:latest -c "find /app/frontend -maxdepth 2 -type f | head"

几个容易混淆的地方

放进 wwwroot 不是必要条件

wwwroot 是 ASP.NET Core 的默认 Web Root,使用它确实最省配置,但并非唯一选择。已经有独立前端产物目录时,用 PhysicalFileProvider 明确暴露该目录,发布结构反而更容易辨认。

不要把 FileProvider 指向整个应用目录。只暴露 Angular 产物所在的目录,也不要为了处理少见扩展名随意开启 ServeUnknownFileTypes

一个镜像不等于一个构建过程

前端和后端可以由不同流水线生成,最后再进入制品组装阶段。把所有编译命令写进 Dockerfile 是另一种方案,但不是合并运行入口的前提。

一个域名不代表所有请求都交给 SPA

SPA fallback 只负责客户端路由。API、健康检查、实时连接和静态资源仍然需要清楚的服务端边界。判断实现是否正确,不能只看首页能否打开,还要检查错误 API 和错误资源是否确实返回 404。