1. 项目概述:为什么一个“群聊”值得用 Aspire + Azure Web PubSub 重做一遍?
我去年在给一家在线教育平台做实时互动模块时,被逼着把 WebSocket 服务从 SignalR 换成 Azure Web PubSub。当时心里是抵触的——不就是发个消息、推个通知?SignalR 跑得好好的,何必折腾?直到上线后第三周,我们遭遇了一次突发流量:某场免费公开课开课前5分钟,3.2万用户同时涌入聊天室,SignalR Hub 的 CPU 瞬间飙到98%,连接队列堆积,新用户连不上,老用户消息延迟超12秒。运维同事凌晨三点打电话说:“再不切,今天早上的课得直播道歉。”
那天我通宵研究 Azure Web PubSub 文档,第二天就搭出了最小可行原型。不是靠魔法,而是靠它彻底解耦了“业务逻辑”和“连接管理”——你的 ASP.NET Core 应用不再需要维护数万个 WebSocket 连接状态,也不用操心心跳、重连、断线清理这些脏活累活。Web PubSub 把连接层抽成独立的云服务,你只管写业务:谁发了什么消息、该转发给谁、要不要存数据库。Aspire 则把这套架构的部署、可观测性、依赖注入全给你兜底了。
标题里那个AddAzureWebPubSubHub方法,不是个花哨的语法糖,它是 Aspire 对 Web PubSub 的“语义封装”:它自动注册了 Hub 客户端、配置了连接字符串、绑定了默认的授权策略,并把 Hub 实例注入到 DI 容器里——你不用再手写IHttpClientFactory去调用 REST API,也不用自己拼接 SAS Token。它背后实际干的是三件事:
- 注册
IWebPubSubClient(用于服务端主动推送); - 注册
IWebPubSubServiceClient(用于管理连接、分组、权限); - 注册
IWebPubSubHubClient<T>(泛型 Hub 客户端,类型安全地发消息)。
这玩意儿适合谁?如果你正在用 .NET 做以下任何一种场景,它就不是“可选”,而是“刚需”:
- 在线协作工具(白板、文档协同);
- 实时交易看板(股票、期货、加密货币行情);
- 多人游戏状态同步(非高帧率动作类,比如棋牌、答题、投票);
- IoT 设备状态广播(传感器数据汇总推送给监控大屏);
- 客服系统坐席状态联动(坐席上线/离线/忙碌,实时同步给所有管理员)。
它不适合什么?别硬套:
- 需要毫秒级响应的高频交易指令(Web PubSub 端到端延迟通常在 50–200ms,够用但不够极致);
- 单机小项目(本地开发调试时,Web PubSub 的 Azure 依赖会拖慢启动速度,不如直接用 MemoryHub);
- 强求 P2P 通信的场景(Web PubSub 是典型的 Pub/Sub 模型,所有消息必须经由服务端中转,没有客户端直连能力)。
我见过太多团队把 WebSocket 当成“高级 HTTP”来用:前端用new WebSocket(),后端用WebSocketManager手动管理连接池,结果一上生产就掉连接、丢消息、内存泄漏。这不是技术不行,是没看清 WebSocket 的本质——它不是“更快的 HTTP”,而是一种长生命周期、双向、低开销的通道协议。它的难点从来不在“怎么连”,而在“连上了之后怎么稳、怎么扩、怎么查”。Aspire + Web PubSub 的组合,就是把“怎么稳、怎么扩、怎么查”这三座大山,直接搬进 Azure 云服务里,让你专注写业务代码。
2. 架构设计与核心思路拆解:为什么放弃 SignalR,选择 Web PubSub?
2.1 信号模型的根本差异:从“Hub 中心化”到“Broker 解耦化”
SignalR 的设计哲学是“Hub 即中心”。你在Startup.cs或Program.cs里定义一个ChatHub : Hub,所有客户端都连到这个 Hub 实例。Hub 里既有OnConnectedAsync这种连接生命周期方法,也有SendMessage这种业务方法。好处是开发快、概念直观;坏处是——它把连接状态和业务逻辑强耦合在了一起。
举个具体例子:假设你要实现“仅向同房间用户广播消息”。在 SignalR 里,你得这么写:
public class ChatHub : Hub { private readonly IHubContext<ChatHub> _hubContext; public ChatHub(IHubContext<ChatHub> hubContext) => _hubContext = hubContext; public async Task SendMessage(string room, string message) { // ❌ 错误示范:遍历所有连接,手动过滤 var connections = await _hubContext.Clients.All.GetConnections(); foreach (var conn in connections.Where(c => c.Room == room)) { await _hubContext.Clients.Client(conn.ConnectionId).SendAsync("ReceiveMessage", message); } } }这段代码的问题在哪?
GetConnections()是 SignalR 的私有 API,官方不保证兼容性,.NET 6+ 已废弃;- 即使能用,它返回的是内存中的连接列表,集群部署时(多台服务器),你只能拿到本机的连接,跨节点消息就丢了;
- 每次广播都要遍历全部连接,O(n) 复杂度,3万用户时性能断崖式下跌。
而 Web PubSub 的思路是“Broker 即规则引擎”。你不再定义一个“Hub 类”,而是定义一套“路由规则”和“事件处理器”。所有客户端连接到 Web PubSub 服务,服务根据 URL 路径、查询参数、JWT Token 自动分组。比如,你让前端连wss://your-pubsub.webpubsub.azure.com/client/hubs/chat?groupId=room123&userId=abc123,Web PubSub 就自动把这个连接加入room123分组,并关联userId=abc123元数据。
服务端发消息时,你只需调用:
await _hubClient.SendToGroupAsync("room123", new { type = "message", content = message });Web PubSub 服务内部会自动:
- 查找
room123分组下的所有活跃连接; - 过滤掉已断开的连接;
- 按 WebSocket 协议格式打包消息;
- 通过最优路径推送给每个客户端。
这个过程完全脱离你的应用进程。你的 ASP.NET Core 应用只是个“消息生产者”,不保存任何连接状态,不参与任何网络 I/O。这就意味着:
- 水平扩展无压力:加 10 台服务器,你的业务代码不用改一行,Web PubSub 自动负载均衡;
- 故障隔离强:哪怕你的 API 服务挂了,已建立的 WebSocket 连接依然畅通(因为连接在 Web PubSub 服务上);
- 可观测性好:Azure Portal 里直接看到每秒连接数、消息吞吐量、错误率,不用自己埋点。
2.2 Aspire 的价值:不是“又一个模板”,而是“生产就绪的胶水”
很多人以为 Aspire 就是个“带 UI 的 Docker Compose”,这是巨大误解。Aspire 的核心价值,在于它把分布式系统里最麻烦的三件事,变成了声明式配置:
依赖发现与连接字符串注入:
传统方式下,你得在appsettings.json里写死 Web PubSub 的连接字符串,或者用 Azure Key Vault 动态加载。Aspire 让你用一行代码声明依赖:var builder = DistributedApplication.CreateBuilder(args); var pubsub = builder.AddAzureWebPubSub("chat-pubsub"); // ✅ 自动创建资源,生成连接字符串 var api = builder.AddProject<Projects.ChatApi>("chat-api") .WithReference(pubsub); // ✅ 自动注入连接字符串到环境变量Aspire 后台会自动:
- 调用 Azure ARM API 创建 Web PubSub 实例(或复用已有实例);
- 生成具有
Client.Connect权限的 SAS Token; - 把 Token 作为
WebPubSubConnectionString环境变量注入到chat-api容器; - 在本地开发时,自动启动一个轻量级模拟器(
azdCLI 支持),无需真实 Azure 账号。
健康检查与依赖拓扑可视化:
Aspire Dashboard 不是玩具。当你访问https://localhost:14001,能看到:chat-api服务是否健康(HTTP GET/health);chat-api是否能连上chat-pubsub(尝试建立 WebSocket 连接);chat-pubsub的当前连接数、消息速率、错误日志摘要。
这比你手写一堆HealthCheck和ILogger日志清晰十倍。
配置一致性保障:
开发、测试、生产环境的 Web PubSub 连接字符串,经常因手动复制出错。Aspire 用DistributedApplicationManifest(一个 JSON 文件)统一管理所有配置。你改一处,所有环境自动同步。
所以,AddAzureWebPubSubHub不是孤立的方法,它是 Aspire 整个依赖注入体系与 Web PubSub SDK 的深度集成点。它背后绑定的是:
IWebPubSubHubClient<ChatHub>(类型安全的 Hub 客户端);IWebPubSubServiceClient(用于管理分组、用户、连接);IWebPubSubClient(用于向特定连接 ID 发送消息);IOptions<WebPubSubOptions>(包含 Hub 名称、连接字符串、重试策略等)。
这种设计,让“写业务”和“管基础设施”彻底分离。你写SendMessage方法时,脑子里想的是“我要把消息发给 room123”,而不是“我的连接字符串对不对”、“Token 过期了没”、“集群里其他实例能不能收到”。
2.3 WebSocket 原生支持:为什么不用 SignalR 客户端?
标题强调“原生 WebSocket”,这绝不是为了标新立异。SignalR 客户端(无论是 JS 还是 .NET)本质上是一个“WebSocket + Long Polling + Server-Sent Events”的兼容层。它在底层做了大量工作:
- 自动降级(当 WebSocket 不可用时,切换到 SSE 或轮询);
- 消息序列化/反序列化(JSON 格式,带方法名、参数、ID);
- 连接恢复(断线后自动重连,重放未确认消息);
- Hub 方法调用代理(
hubConnection.invoke("SendMessage", ...))。
这些功能很强大,但代价是:
- 协议不透明:你无法直接控制 WebSocket 的
subprotocol、headers、ping/pong间隔; - 调试困难:浏览器 DevTools 的 Network 标签页里,看到的是一堆
signalr的二进制帧,没法像原生 WebSocket 那样直接看到{"type":"message","content":"hello"}; - 移动端兼容性风险:某些老旧 Android WebView 对 SignalR 的 SSE 降级支持不完善,导致连接失败。
而 Web PubSub 强制要求客户端使用标准 WebSocket 协议(RFC 6455)。这意味着:
前端可以用最简代码连接:
const ws = new WebSocket("wss://your-pubsub.webpubsub.azure.com/client/hubs/chat?groupId=room123&access_token=..."); ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === "message") { console.log("收到消息:", data.content); } };后端发的消息,前端收到的就是原始 JSON 字符串,零解析成本;
所有 WebSocket 调试工具(如
wscat、Postman WebSocket 客户端)都能直接连、直接测;移动端 App(iOS Swift / Android Kotlin)可以用系统原生 WebSocket API,不用引入 SignalR SDK。
我曾帮一个金融客户做合规审计,他们要求所有实时通信协议必须是 IETF 标准,不能用任何厂商私有协议。SignalR 被否决,而 Web PubSub 因为完全基于 RFC 6455,一次过审。
3. 核心细节解析与实操要点:从零搭建一个可运行的群聊
3.1 环境准备与 Aspire 项目初始化
先明确前提:你不需要 Azure 账号就能开始。Aspire 提供了本地模拟器,足够跑通整个流程。所需工具链非常干净:
- .NET SDK 8.0+(必须,Web PubSub SDK 依赖 .NET 8 的
System.Net.WebSockets新特性); - Visual Studio 2022 17.8+ 或 VS Code + C# Dev Kit;
- Docker Desktop(Aspire 默认用容器编排,本地模拟器也基于容器);
- Azure CLI(可选,仅用于真机部署)。
第一步,创建 Aspire 解决方案:
dotnet new aspire -n ChatApp cd ChatApp这会生成三个项目:
ChatApp.AppHost:Aspire 主机,定义服务拓扑;ChatApp.ApiService:你的 ASP.NET Core Web API;ChatApp.ServiceDefaults:共享的中间件、认证、日志配置。
现在,把 Web PubSub 加进去。打开AppHost.cs,找到var builder = DistributedApplication.CreateBuilder(args);这行,在它下面添加:
// ✅ 添加 Web PubSub 服务(本地模拟器模式) var pubsub = builder.AddAzureWebPubSub("chat-hub") .WithAnnotation(new ContainerImageAnnotation { Registry = "mcr.microsoft.com", Image = "azure-webpubsub/azure-webpubsub" }) .WithHttpEndpoint(port: 8080, name: "http"); // 模拟器监听端口 // ✅ 添加 API 服务,并引用 PubSub var api = builder.AddProject<Projects.ChatApiService>("chat-api") .WithReference(pubsub) .WithExternalHttpEndpoints(); // 暴露 API 端口关键点解释:
.WithAnnotation(...)是告诉 Aspire:本地开发时,不要去 Azure 创建真实资源,而是拉取微软官方镜像mcr.microsoft.com/azure-webpubsub/azure-webpubsub启动一个轻量容器;.WithHttpEndpoint(port: 8080)是模拟器的管理端口(用于查看连接状态),不是 WebSocket 端口;WithReference(pubsub)会自动把 Web PubSub 的连接字符串注入到chat-api的环境变量WebPubSubConnectionString中。
验证是否成功:运行dotnet run --project AppHost.csproj,你会看到终端输出类似:
Building and running application... Starting container 'webpubsub'... Container 'webpubsub' is running on http://localhost:8080 Starting project 'chat-api'... chat-api is ready at https://localhost:7192此时,Web PubSub 模拟器已在http://localhost:8080运行,API 服务在https://localhost:7192。
提示:如果遇到
docker: command not found,请确认 Docker Desktop 已启动并登录。Aspire 模拟器完全依赖 Docker,没有替代方案。
3.2 API 服务集成:AddAzureWebPubSubHub的正确用法
打开ChatApiService项目,修改Program.cs。在builder.Services配置段,添加 Web PubSub Hub 注册:
// ✅ 正确注册:指定 Hub 名称为 "chat" builder.Services.AddAzureWebPubSubHub<ChatHub>(options => { options.ConnectionString = builder.Configuration.GetConnectionString("WebPubSub"); options.HubName = "chat"; // ⚠️ 必须与前端连接 URL 的 hub 名称一致 }); // ✅ 同时注册服务客户端(用于管理分组、用户) builder.Services.AddAzureWebPubSubClient(options => { options.ConnectionString = builder.Configuration.GetConnectionString("WebPubSub"); });这里有两个易错点:
HubName必须小写且无特殊字符:Web PubSub 服务强制要求 Hub 名称符合 DNS 标签规范(a-z, 0-9, -),且长度 1–63 字符。ChatHub类名可以是 PascalCase,但options.HubName必须是chat;- 连接字符串来源:
builder.Configuration.GetConnectionString("WebPubSub")会自动读取 Aspire 注入的环境变量WebPubSubConnectionString,你无需在appsettings.json里手动配置。
接着,创建ChatHub类。注意:它不是继承Hub,而是定义一个空类,仅作为泛型参数:
// ✅ ChatHub.cs - 纯标记类,无方法 public class ChatHub { }为什么这样设计?因为 Web PubSub 的“Hub”概念,是服务端的一个逻辑命名空间,不是代码里的类。IWebPubSubHubClient<ChatHub>的作用,是告诉 SDK:“我要操作名为chat的 Hub”。它比字符串"chat"更类型安全,编译期就能检查。
现在,写一个发送消息的 API Controller:
[ApiController] [Route("api/[controller]")] public class ChatController : ControllerBase { private readonly IWebPubSubHubClient<ChatHub> _hubClient; public ChatController(IWebPubSubHubClient<ChatHub> hubClient) { _hubClient = hubClient; } [HttpPost("send")] public async Task<IActionResult> SendMessage([FromBody] SendMessageRequest request) { // ✅ 向指定分组发送消息(广播) await _hubClient.SendToGroupAsync(request.GroupId, new { type = "message", sender = request.Sender, content = request.Content, timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); return Ok(new { success = true }); } } public class SendMessageRequest { public string GroupId { get; set; } = "default"; // 默认分组 public string Sender { get; set; } = "anonymous"; public string Content { get; set; } = ""; }关键参数说明:
request.GroupId:对应前端连接 URL 中的groupId参数;new { ... }:匿名对象会被 JSON 序列化后发送,Web PubSub 服务不做任何修改,原样推送给客户端;_hubClient.SendToGroupAsync:这是最常用的方法,适用于“房间聊天”场景。
注意:
SendToGroupAsync不会返回错误,即使分组不存在。Web PubSub 服务会静默丢弃消息。所以,务必在前端连接时,确保groupId参数正确传递。
3.3 前端连接与消息收发:纯原生 WebSocket 实现
前端代码必须严格匹配 Web PubSub 的连接协议。核心是构造正确的 WebSocket URL:
wss://<your-pubsub-domain>/client/hubs/<hub-name>?groupId=<group-id>&userId=<user-id>&access_token=<sas-token>在本地模拟器模式下,URL 是:
wss://localhost:8080/client/hubs/chat?groupId=room123&userId=user456&access_token=your-sas-token但access_token怎么获取?不能硬编码!Web PubSub 要求每次连接都用短期有效的 SAS Token。所以,你需要一个 API 接口,由后端生成 Token 并返回给前端:
// 在 ChatController 中添加 [HttpGet("connect-url")] public IActionResult GetConnectUrl([FromQuery] string groupId, [FromQuery] string userId) { // ✅ 使用服务客户端生成 Token var serviceClient = _serviceClientFactory.CreateClient(); var token = serviceClient.GenerateClientAccessToken( hubName: "chat", userId: userId, groupId: groupId, minutesToLive: 60); // Token 有效期 60 分钟 var url = $"wss://localhost:8080/client/hubs/chat?groupId={Uri.EscapeDataString(groupId)}&userId={Uri.EscapeDataString(userId)}&access_token={Uri.EscapeDataString(token.Token)}"; return Ok(new { url }); }前端 JavaScript(Vue 3 Composition API 示例):
import { ref, onMounted } from 'vue'; export default { setup() { const ws = ref(null); const messages = ref([]); const inputMsg = ref(''); const groupId = 'room123'; const connect = async () => { try { // ✅ 第一步:获取连接 URL const res = await fetch(`/api/chat/connect-url?groupId=${groupId}&userId=${Date.now()}`); const { url } = await res.json(); // ✅ 第二步:建立 WebSocket 连接 ws.value = new WebSocket(url); ws.value.onopen = () => { console.log('WebSocket connected'); }; ws.value.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === 'message') { messages.value.push({ sender: data.sender, content: data.content, time: new Date(data.timestamp).toLocaleTimeString() }); } }; ws.value.onerror = (error) => { console.error('WebSocket error:', error); }; ws.value.onclose = () => { console.log('WebSocket closed'); }; } catch (err) { console.error('Failed to connect:', err); } }; const sendMessage = () => { if (!ws.value || ws.value.readyState !== WebSocket.OPEN) return; ws.value.send(JSON.stringify({ type: 'message', content: inputMsg.value, sender: 'current-user' })); inputMsg.value = ''; }; onMounted(() => { connect(); }); return { messages, inputMsg, sendMessage }; } };关键细节:
ws.value.send(...)发送的是纯文本 JSON 字符串,不是 SignalR 那样的二进制帧;onmessage回调里,event.data就是原始 JSON 字符串,直接JSON.parse即可;groupId和userId必须和后端GenerateClientAccessToken时传入的一致,否则 Token 验证失败,连接被拒绝。
实操心得:我在第一次调试时,把
groupId写成了room-123(带下划线),而 Token 里传的是room123,结果 WebSocket 连接立刻关闭,状态码401。Web PubSub 的错误日志非常友好,在 Aspire Dashboard 的webpubsub服务日志里,能看到Invalid group id in token的提示。记住:前后端groupId、userId、hubName必须完全一致,包括大小写和特殊字符。
3.4 消息路由与分组管理:超越简单广播的实战技巧
群聊的核心需求从来不是“发消息”,而是“精准投递”。Web PubSub 提供了四层路由能力,按优先级从高到低:
| 路由层级 | 触发条件 | 适用场景 | SDK 方法 |
|---|---|---|---|
| 连接 ID | 指定单个 WebSocket 连接 | 私聊、系统通知(如“您的订单已支付”) | SendToConnectionAsync(connectionId, ...) |
| 用户 ID | 指定userId(Token 中设置) | 用户多设备同步(手机、PC 同时收到消息) | SendToUserAsync(userId, ...) |
| 分组 ID | 指定groupId(Token 中设置) | 房间聊天、频道订阅 | SendToGroupAsync(groupId, ...) |
| 全 Hub | 不指定任何 ID | 全局公告、系统广播 | SendToAllAsync(...) |
AddAzureWebPubSubHub默认支持SendToGroupAsync,但其他方法需要IWebPubSubServiceClient。我们来扩展一个“用户踢出”功能:
[HttpPost("kick/{userId}")] public async Task<IActionResult> KickUser(string userId, [FromQuery] string groupId) { var serviceClient = _serviceClientFactory.CreateClient(); // ✅ 从分组中移除用户(断开其所有连接) await serviceClient.RemoveUserFromGroupAsync("chat", groupId, userId); // ✅ 向该用户发送踢出通知 await serviceClient.SendToUserAsync("chat", userId, new { type = "kicked", reason = "You were removed from the group.", groupId = groupId }); return Ok(); }这里用了两个关键 API:
RemoveUserFromGroupAsync:立即断开该用户在指定分组的所有连接;SendToUserAsync:向该用户的所有在线连接(不限分组)发送消息。
这个组合,解决了“踢人”场景的终极难题:
- 传统方案:发消息让前端自己断开,但用户可能忽略或伪造连接状态;
- Web PubSub 方案:服务端直接操作连接状态,100% 可控。
另一个实用技巧:动态分组。很多群聊需要“按关键词加入房间”,比如搜索“#dotnet”就进入 .NET 技术讨论组。你可以用SendToGroupAsync的groupId参数做文章:
// 前端连接时,groupId 传入哈希值 const keyword = '#dotnet'; const groupId = btoa(keyword).replace(/=/g, ''); // base64 编码,去掉 = 号 // 结果:IzE0Lm5ldA // 后端发送时,用相同算法生成 groupId string GetGroupId(string keyword) => Convert.ToBase64String(Encoding.UTF8.GetBytes(keyword)).Replace("=", "");这样,不同关键词生成唯一groupId,Web PubSub 自动隔离流量,无需后端维护分组列表。
注意事项:
groupId最大长度 128 字符,userId最大 128 字符。避免用长文本直接做 ID,建议用 MD5 或 SHA256 哈希后截取前 32 位。
4. 实操过程与核心环节实现:从本地调试到 Azure 部署
4.1 本地调试全流程:用 Postman 和 wscat 验证每一步
在把代码交给前端之前,务必用命令行工具逐层验证。这是避免“前端连不上、后端收不到”这类问题的黄金法则。
Step 1:验证 Web PubSub 模拟器是否就绪
打开浏览器,访问http://localhost:8080。你应该看到 Web PubSub 的管理界面,显示Hubs: chat和Connections: 0。这是健康标志。
Step 2:用 wscat 测试 WebSocket 连接
安装wscat(Node.js 工具):
npm install -g wscat生成一个临时 Token(用 Azure CLI 或在线工具),然后连接:
wscat -c "wss://localhost:8080/client/hubs/chat?groupId=test&userId=testuser&access_token=your-token-here"如果连接成功,终端会显示connected (press CTRL+C to quit)。此时,回到http://localhost:8080页面,Connections数应该变成1。
Step 3:用 Postman 测试 API 发送消息
启动 API 服务(dotnet run --project ChatApiService.csproj),然后用 Postman 发送 POST 请求:
- URL:
https://localhost:7192/api/chat/send - Body (JSON):
{ "groupId": "test", "sender": "server", "content": "Hello from API!" }
如果返回200 OK,且wscat终端收到了 JSON 消息,说明后端到 Web PubSub 的链路通了。
Step 4:用浏览器 DevTools 直接连接
打开 Chrome,F12 进入 Console,粘贴以下代码:
const ws = new WebSocket('wss://localhost:8080/client/hubs/chat?groupId=test&userId=dev&access_token=your-token'); ws.onmessage = e => console.log('Received:', e.data); ws.onopen = () => ws.send(JSON.stringify({type:'ping'}));如果看到Received: {"type":"ping"},恭喜,你已经打通了“浏览器 → Web PubSub → API”全链路。
实操心得:我踩过的最大坑是 SSL。本地模拟器用的是自签名证书,Chrome 会拦截
wss://localhost:8080。解决方案有两个:
- 用
ws://localhost:8080(非加密,仅限本地开发);- 在 Chrome 地址栏输入
chrome://flags/#unsafely-treat-insecure-origin-as-secure,启用不安全源,然后访问http://localhost:8080(HTTP 管理界面)和ws://localhost:8080(WebSocket)。
生产环境必须用wss,Azure Web PubSub 自带免费 TLS 证书,无需额外配置。
4.2 Azure 真机部署:从 Aspire 到 azd 的无缝迁移
Aspire 的魔力在于,本地代码几乎不用改,就能部署到 Azure。核心是azdCLI 工具。
Step 1:安装 azd 并登录 Azure
# 下载 azd(https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd) azd loginStep 2:初始化 azd 环境
在ChatApp根目录运行:
azd init --template aspire这会生成azure.yaml配置文件,定义资源部署参数。
Step 3:修改 azure.yaml,指定 Web PubSub 实例名称
name: chat-app services: webpubsub: type: azure-webpubsub properties: sku: Free # 或 Standard_S1 hubName: chat api: type: azure-container-app properties: image: mcr.microsoft.com/dotnet/samples:aspire-chat-api env: - name: WebPubSubConnectionString value: ${{ services.webpubsub.connectionString }}Step 4:一键部署
azd upazd会自动:
- 创建 Resource Group;
- 部署 Azure Web PubSub 实例;
- 构建
ChatApiServiceDocker 镜像并推送到 Azure Container Registry; - 创建 Container App,并注入 Web PubSub 连接字符串;
- 输出公网访问 URL。
部署完成后,azd show命令会显示所有服务的 URL。你的 API 服务地址类似https://chat-api-xxxx.eastus.azurecontainerapps.io,Web PubSub 的 WebSocket 地址是wss://chat-hub-xxxx.webpubsub.azure.com/client/hubs/chat。
关键配置项说明:
sku: Free:免费层支持最多 100 个并发连接,适合测试;生产环境选Standard_S1(1000 连接/秒,100万消息/天);hubName: chat:必须和代码里options.HubName一致;value: ${{ services.webpubsub.connectionString }}:azd的变量语法,自动注入生成的连接字符串。
提示:首次部署可能耗时 5–10 分钟。
azd up会实时输出日志,看到Provisioning complete即表示成功。如果失败,azd logs可以查看详细错误。
4.3 生产环境加固:SSL、CORS、认证的必做配置
本地跑通只是开始,生产环境必须处理三座大山:HTTPS、跨域、认证。
SSL 配置(Azure 自动搞定)
Azure Web PubSub 默认启用 HTTPS,且提供免费的*.webpubsub.azure.com通配符证书。你无需上传证书或配置 TLS 版本。唯一要注意的是:前端连接 URL 必须用wss://,不能用ws://,否则浏览器会阻止。
CORS 配置(Aspire 时代已过时)
旧教程常教你配置app.UseCors(),但在 Web PubSub 场景下,CORS 由 Web PubSub 服务自身控制。你必须在 Azure Portal 的 Web PubSub 实例设置里,添加允许的 Origin:
- 进入 Azure Portal → 你的 Web PubSub 实例 → Settings → CORS;
- 添加
https://your-frontend-domain.com(支持通配符https://*.example.com); - 保存。
如果前端域名不在白名单,WebSocket 连接会直接被拒绝,状态码403。Aspire 的appsettings.json里配置CORS对 Web PubSub 无效。
认证与授权(JWT Token 最佳实践)
Web PubSub 支持两种认证方式:
- SAS Token(推荐):短期有效(最长 24 小时),由后端生成,前端只负责传递;
- JWT Token(高级):需自己实现
IWebPubSubAuthenticationProvider,验证用户身份。
SAS Token 已足够安全。关键是要避免 Token 泄露:
- 不要在前端存储 Token(如 localStorage);
- Token 只在连接时使用,连接建立后即失效;
- 后端生成 Token 时,务必设置
userId和groupId,利用 Web PubSub 的内置鉴权。
例如,一个恶意用户即使拿到了 Token,也只能连接到指定groupId,无法访问其他房间。这就是 Web PubSub 的“最小权限原则”。