教程:使用 Azure Web PubSub 服务创建聊天应用

发布和订阅消息教程介绍了使用 Azure Web PubSub 发布和订阅消息的基础知识。 在本教程中,你将了解 Azure Web PubSub 的事件系统,并使用它来生成具有实时通信功能的完整 Web 应用程序。

本教程介绍如何执行下列操作:

  • 创建 Web PubSub 服务实例
  • 配置 Azure Web PubSub 的事件处理程序设置
  • 处理应用服务器中的事件并构建实时聊天应用

如果没有 Azure 试用版订阅,请在开始前创建 Azure 试用版订阅

先决条件

  • 如需在本地运行 CLI 参考命令,请安装 Azure CLI。 如果在 Windows 或 macOS 上运行,请考虑在 Docker 容器中运行 Azure CLI。 有关详细信息,请参阅如何在 Docker 容器中运行 Azure CLI

    • 如果使用的是本地安装,请使用 az login 命令登录到 Azure CLI。 若要完成身份验证过程,请遵循终端中显示的步骤。 有关其他登录选项,请参阅使用 Azure CLI 登录

    • 出现提示时,请在首次使用时安装 Azure CLI 扩展。 有关扩展详细信息,请参阅使用 Azure CLI 的扩展

    • 运行 az version 以查找安装的版本和依赖库。 若要升级到最新版本,请运行 az upgrade

  • 本设置需要 Azure CLI 版本 2.22.0 或更高版本。

创建 Azure Web PubSub 实例

创建资源组

资源组是在其中部署和管理 Azure 资源的逻辑容器。 使用 az group create 命令在 chinaeast 位置创建名为 myResourceGroup 的资源组。

az group create --name myResourceGroup --location ChinaEast

创建 Web PubSub 实例

运行 az extension add,安装 webpubsub 扩展或将其升级到当前版本。

az extension add --upgrade --name webpubsub

使用 Azure CLI az webpubsub create 命令在已创建的资源组中创建 Web PubSub。 以下命令在 ChinaEast 的资源组 myResourceGroup 下创建一个免费的 Web PubSub 资源:

重要

每个 Web PubSub 资源必须具有唯一名称。 在以下示例中,将 <your-unique-resource-name> 替换为 Web PubSub 的名称。

az webpubsub create --name "<your-unique-resource-name>" --resource-group "myResourceGroup" --location "ChinaEast" --sku Free_F1

此命令的输出会显示新建的资源的属性。 请记下下面列出的两个属性:

  • 资源名称:为上面的 --name 参数提供的名称。
  • 主机名:在本例中,主机名为 <your-unique-resource-name>.webpubsub.azure.cn/

目前,只有你的 Azure 帐户才有权对这个新资源执行任何操作。

获取 ConnectionString 以供将来使用

重要

连接字符串包括应用程序访问 Azure Web PubSub 服务所需的授权信息。 连接字符串中的访问密钥类似于服务的根密码。 在生产环境中,请始终小心保护访问密钥。 使用 Azure 密钥保管库安全地管理和轮换密钥。 避免将访问密钥分发给其他用户、对其进行硬编码或将其以纯文本形式保存在其他人可以访问的任何位置。 如果你认为访问密钥可能已泄露,请轮换密钥。

使用 Azure CLI az webpubsub key 命令获取服务的 ConnectionString。 将 <your-unique-resource-name> 占位符替换为 Azure Web PubSub 实例的名称。

az webpubsub key show --resource-group myResourceGroup --name <your-unique-resource-name> --query primaryConnectionString --output tsv

复制主连接字符串以供稍后使用。

复制提取的 ConnectionString 并将其设置到环境变量 WebPubSubConnectionString 中,教程稍后将读取该变量。 将下面的 <connection-string> 替换为提取的 ConnectionString

export WebPubSubConnectionString="<connection-string>"
SET WebPubSubConnectionString=<connection-string>

设置项目

先决条件

创建应用程序

Azure Web PubSub 中有两个角色:服务器和客户端。 此概念类似于 Web 应用程序中的服务器和客户端角色。 服务器负责管理客户端、侦听,以及响应客户端消息。 客户端负责通过服务器发送和接收用户的消息,并将它们可视化给最终用户。

在本教程中,我们将生成一个实时聊天 Web 应用程序。 在真实的 Web 应用程序中,服务器的职责还包括对客户端进行身份验证,以及为应用程序 UI 提供静态网页。

我们将使用 ASP.NET Core 8 来托管网页并处理传入的请求。

首先,让我们在 chatapp 文件夹中创建一个 ASP.NET Core Web 应用。

  1. 创建新的 Web 应用。

    mkdir chatapp
    cd chatapp
    dotnet new web
    
  2. 添加 app.UseStaticFiles() Program.cs 以支持托管静态网页操作。

    var builder = WebApplication.CreateBuilder(args);
    var app = builder.Build();
    
    app.UseStaticFiles();
    
    app.Run();
    
  3. 创建一个 HTML 文件并将其保存为 wwwroot/index.html,我们稍后会在聊天应用的 UI 中使用它。

    <html>
      <body>
        <h1>Azure Web PubSub Chat</h1>
      </body>
    </html>
    

可以通过运行 dotnet run --urls http://localhost:8080 来测试服务器,并在浏览器中访问 http://localhost:8080/index.html

添加协商终结点

发布和订阅消息教程中,订阅者直接使用连接字符串。 在现实世界的应用程序中,与任何客户端共享连接字符串是不安全的,因为连接字符串具有对服务执行任何操作的高权限。 现在,让服务器使用连接字符串,并公开一个 negotiate 终结点,以便客户端获取带有访问令牌的完整 URL。 这样,服务器就可以在 negotiate 终结点之前添加身份验证中间件,以防止未经授权的访问。

首先安装依赖项。

dotnet add package Microsoft.Azure.WebPubSub.AspNetCore

现在,我们添加一个 /negotiate 终结点,供客户端调用以生成令牌。

using Azure.Core;
using Microsoft.Azure.WebPubSub.AspNetCore;
using Microsoft.Azure.WebPubSub.Common;
using Microsoft.Extensions.Primitives;

// Read connection string from environment
var connectionString = Environment.GetEnvironmentVariable("WebPubSubConnectionString");
if (connectionString == null)
{
    throw new ArgumentNullException(nameof(connectionString));
}

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddWebPubSub(o => o.ServiceEndpoint = new WebPubSubServiceEndpoint(connectionString))
    .AddWebPubSubServiceClient<Sample_ChatApp>();
var app = builder.Build();

app.UseStaticFiles();

// return the Client Access URL with negotiate endpoint
app.MapGet("/negotiate", (WebPubSubServiceClient<Sample_ChatApp> service, HttpContext context) =>
{
    var id = context.Request.Query["id"];
    if (StringValues.IsNullOrEmpty(id))
    {
        context.Response.StatusCode = 400;
        return null;
    }
    return new
    {
        url = service.GetClientAccessUri(userId: id).AbsoluteUri
    };
});
app.Run();

sealed class Sample_ChatApp : WebPubSubHub
{
}

AddWebPubSubServiceClient<THub>() 用于注入服务客户端 WebPubSubServiceClient<THub>,我们可以在协商步骤中使用它来生成客户端连接令牌,并在触发中心事件时,在中心方法中使用它来调用服务 REST API。 此令牌生成代码类似于我们在发布和订阅消息教程中使用的代码,其不同之处在于,我们在生成令牌时多传递了一个参数 (userId)。 用户 ID 可用于识别客户端的身份,因此,当你收到消息时,你知道消息来自何处。

该代码从我们在上一步设置的环境变量 WebPubSubConnectionString 中读取连接字符串。

使用 dotnet run --urls http://localhost:8080 重新运行服务器。

你可以通过访问 http://localhost:8080/negotiate?id=user1 来测试此 API,它将为你提供 Azure Web PubSub 的完整 URL 和访问令牌。

处理事件

在 Azure Web PubSub 中,当客户端发生某些活动时(例如客户端正在进行连接、已连接、已断开连接或客户端正在发送消息),服务会向服务器发送通知,以便服务器对这些事件做出响应。

事件以 Webhook 的形式传送到服务器。 Webhook 由应用程序服务器提供和公开,并在 Azure Web PubSub 服务端注册。 每当发生事件时,服务都会调用 Webhook。

Azure Web PubSub 遵循 CloudEvents 来描述事件数据。

下面,我们在客户端已连接的情况下处理 connected 系统事件,并在客户端发送消息以构建聊天应用时处理 message 用户事件。

我们在上一步安装的适用于 AspNetCore Microsoft.Azure.WebPubSub.AspNetCore 的 Web PubSub SDK 也可以帮助解析和处理 CloudEvents 请求。

首先,在 app.Run() 之前添加事件处理程序。 指定事件的终结点路径,比如 /eventhandler

app.MapWebPubSubHub<Sample_ChatApp>("/eventhandler/{*path}");
app.Run();

现在,在上一步创建的 Sample_ChatApp 类中添加一个构造函数,让其与用来调用 Web PubSub 服务的 WebPubSubServiceClient<Sample_ChatApp> 配合使用。 OnConnectedAsync() 用于在触发 connected 事件时进行响应,OnMessageReceivedAsync() 用于处理来自客户端的消息。

sealed class Sample_ChatApp : WebPubSubHub
{
    private readonly WebPubSubServiceClient<Sample_ChatApp> _serviceClient;

    public Sample_ChatApp(WebPubSubServiceClient<Sample_ChatApp> serviceClient)
    {
        _serviceClient = serviceClient;
    }

    public override async Task OnConnectedAsync(ConnectedEventRequest request)
    {
        Console.WriteLine($"[SYSTEM] {request.ConnectionContext.UserId} joined.");
    }

    public override async ValueTask<UserEventResponse> OnMessageReceivedAsync(UserEventRequest request, CancellationToken cancellationToken)
    {
        await _serviceClient.SendToAllAsync(RequestContent.Create(
        new
        {
            from = request.ConnectionContext.UserId,
            message = request.Data.ToString()
        }),
        ContentType.ApplicationJson);

        return new UserEventResponse();
    }
}

在上面的代码中,我们使用服务客户端以 JSON 格式向所有通过 SendToAllAsync 加入的人广播一条通知消息。

更新网页

现在,让我们更新 index.html 以添加用于进行连接、发送消息以及在页面中显示收到的消息的逻辑。

<html>
  <body>
    <h1>Azure Web PubSub Chat</h1>
    <input id="message" placeholder="Type to chat...">
    <div id="messages"></div>
    <script>
      (async function () {
        let id = prompt('Please input your user name');
        let res = await fetch(`/negotiate?id=${id}`);
        let data = await res.json();
        let ws = new WebSocket(data.url);
        ws.onopen = () => console.log('connected');

        let messages = document.querySelector('#messages');

        ws.onmessage = event => {
          let m = document.createElement('p');
          let data = JSON.parse(event.data);
          m.innerText = `[${data.type || ''}${data.from || ''}] ${data.message}`;
          messages.appendChild(m);
        };

        let message = document.querySelector('#message');
        message.addEventListener('keypress', e => {
          if (e.charCode !== 13) return;
          ws.send(message.value);
          message.value = '';
        });
      })();
    </script>
  </body>

</html>

你可以在上面的代码中看到,我们使用浏览器中的本机 WebSocket API 进行连接,使用 WebSocket.send() 发送消息,使用 WebSocket.onmessage 侦听接收到的消息。

还可以使用客户端 SDK 连接到该服务,这样就可以完成自动重新连接、错误处理等操作。

现在还差一步就可以进行聊天了。 让我们在 Web PubSub 服务中配置我们关心的事件以及将事件发送到的位置。

设置事件处理程序

我们在 Web PubSub 服务中设置事件处理程序来告诉服务将事件发送到哪里。

当 Web 服务器在本地运行时,如果没有允许从 Internet 进行访问的终结点,Web PubSub 服务如何调用 localhost? 通常有两种方法。 一种方法是使用一些通用隧道工具向公众公开 localhost,另一种方法是使用 awps-tunnel 通过该工具将流量从 Web PubSub 服务以隧道传输方式传输到本地服务器。

在本部分,我们使用 Azure CLI 设置事件处理程序并使用 awps-tunnel 将流量路由到 localhost。

配置中心设置

我们将 URL 模板设置为使用 tunnel 方案,以便 Web PubSub 通过 awps-tunnel 的隧道连接来路由消息。 事件处理程序可以按本文所述通过门户或 CLI 设置,在这里我们通过 CLI 设置。 由于我们按照上一步的设置侦听路径 /eventhandler 中的事件,因此我们将 URL 模板设置为 tunnel:///eventhandler

使用 Azure CLI az webpubsub hub create 命令为 Sample_ChatApp 中心创建事件处理程序设置。

重要

将 <your-unique-resource-name> 替换为在前面的步骤中创建的 Web PubSub 资源的名称。

az webpubsub hub create -n "<your-unique-resource-name>" -g "myResourceGroup" --hub-name "Sample_ChatApp" --event-handler url-template="tunnel:///eventhandler" user-event-pattern="*" system-event="connected"

在本地运行 awps-tunnel

下载并安装 awps-tunnel

该工具在 Node.js 16 或更高版本上运行。

npm install -g @azure/web-pubsub-tunnel-tool

使用服务连接字符串并运行

export WebPubSubConnectionString="<your connection string>"
awps-tunnel run --hub Sample_ChatApp --upstream http://localhost:8080

运行 Web 服务器

现在,一切都已设置好。 让我们运行 Web 服务器并体验聊天应用。

现在使用 dotnet run --urls http://localhost:8080 运行服务器。

可以在此处找到本教程的完整代码示例。

打开 http://localhost:8080/index.html。 可以输入用户名并开始聊天了。

使用 connect 事件处理程序进行延迟身份验证

在前面的部分中,我们演示如何使用协商终结点返回 Web PubSub 服务 URL 和 JWT 访问令牌,以便客户端连接 Web PubSub 服务。 在某些情况下,例如资源有限的边缘设备,客户端可能更愿意直接连接 Web PubSub 资源。 在这些情况下,可以将 connect 事件处理程序配置为对客户端进行延迟身份验证、将用户 ID 分配给客户端、指定客户端连接后加入的组、配置客户端拥有的权限以及 WebSocket 子协议作为对客户端的 WebSocket 响应等。有关详细信息,请参阅连接事件处理程序规范

现在,让我们使用 connect 事件处理程序来实现与协商部分类似的功能。

更新中心设置

首先,让我们更新中心设置以包括 connect 事件处理程序,我们还需要允许匿名连接,使没有 JWT 访问令牌的客户端可以连接到此服务。

使用 Azure CLI az webpubsub hub update 命令为 Sample_ChatApp 中心创建事件处理程序设置。

重要

将 <your-unique-resource-name> 替换为在前面的步骤中创建的 Web PubSub 资源的名称。

az webpubsub hub update -n "<your-unique-resource-name>" -g "myResourceGroup" --hub-name "Sample_ChatApp" --allow-anonymous true --event-handler url-template="tunnel:///eventhandler" user-event-pattern="*" system-event="connected" system-event="connect"

更新上游逻辑以处理连接事件

现在,让我们更新上游逻辑以处理连接事件。 我们现在还可以移除协商终结点。

与我们出于演示目的在协商终结点中执行的操作类似,我们还从查询参数中读取 ID。 在连接事件中,原始客户端查询会保留在连接事件请求正文中。

在类 Sample_ChatApp 中,替代 OnConnectAsync() 来处理 connect 事件:

sealed class Sample_ChatApp : WebPubSubHub
{
    private readonly WebPubSubServiceClient<Sample_ChatApp> _serviceClient;

    public Sample_ChatApp(WebPubSubServiceClient<Sample_ChatApp> serviceClient)
    {
        _serviceClient = serviceClient;
    }

    public override ValueTask<ConnectEventResponse> OnConnectAsync(ConnectEventRequest request, CancellationToken cancellationToken)
    {
        if (request.Query.TryGetValue("id", out var id))
        {
            return new ValueTask<ConnectEventResponse>(request.CreateResponse(userId: id.FirstOrDefault(), null, null, null));
        }

        // The SDK catches this exception and returns 401 to the caller
        throw new UnauthorizedAccessException("Request missing id");
    }

    public override async Task OnConnectedAsync(ConnectedEventRequest request)
    {
        Console.WriteLine($"[SYSTEM] {request.ConnectionContext.UserId} joined.");
    }

    public override async ValueTask<UserEventResponse> OnMessageReceivedAsync(UserEventRequest request, CancellationToken cancellationToken)
    {
        await _serviceClient.SendToAllAsync(RequestContent.Create(
        new
        {
            from = request.ConnectionContext.UserId,
            message = request.Data.ToString()
        }),
        ContentType.ApplicationJson);

        return new UserEventResponse();
    }
}

更新 index.html 以直接连接

现在,让我们更新网页以直接连接到 Web PubSub 服务。 需要说明的是,为了演示的目的,Web PubSub 服务终结点是硬编码到客户端代码中的,请将下面 html 中的服务主机名 <the host name of your service> 更新为你自己服务的值。 从服务器提取 Web PubSub 服务终结点值可能仍然有用,因为这样可以更灵活地控制客户端连接到哪里。

<html>
  <body>
    <h1>Azure Web PubSub Chat</h1>
    <input id="message" placeholder="Type to chat...">
    <div id="messages"></div>
    <script>
      (async function () {
        // sample host: mock.webpubsub.azure.com
        let hostname = "<the host name of your service>";
        let id = prompt('Please input your user name');
        let ws = new WebSocket(`wss://${hostname}/client/hubs/Sample_ChatApp?id=${id}`);
        ws.onopen = () => console.log('connected');

        let messages = document.querySelector('#messages');

        ws.onmessage = event => {
          let m = document.createElement('p');
          let data = JSON.parse(event.data);
          m.innerText = `[${data.type || ''}${data.from || ''}] ${data.message}`;
          messages.appendChild(m);
        };

        let message = document.querySelector('#message');
        message.addEventListener('keypress', e => {
          if (e.charCode !== 13) return;
          ws.send(message.value);
          message.value = '';
        });
      })();
    </script>
  </body>

</html>

重新运行服务器

现在,重新运行服务器,并按照之前的说明访问网页。 如果已停止 awps-tunnel,请重新运行隧道工具

后续步骤

本教程大致介绍了事件系统在 Azure Web PubSub 服务中的工作原理。

查看其他教程,进一步深入了解如何使用该服务。