Angular 与 ASP.NET Core 发布物合并为单一 Docker 镜像
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/
└── *.jsfrontend 只是发布物中的一个目录,不必叫 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。dotnet 和 npm 是原生程序,失败时必须立即检查 $LASTEXITCODE;finally 则保证脚本无论在哪个阶段失败,都会回到调用前的目录。更完整的取舍见用工作目录简化 PowerShell 发布脚本。
默认不构建 Angular,适合前端产物已经准备好的情况:
.\build-package.ps1需要刷新前端产物时再显式传入参数:
.\build-package.ps1 -BuildFrontendAngular deployment 文档说明了构建与部署产物。不同 Angular 版本和 builder 的输出目录可能是 dist/<project>、dist/<project>/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。