教程:使用 Azure Functions 和 Azure Web PubSub 服务创建无服务器实时聊天应用

Azure Web PubSub 服务可帮助你轻松地使用 Websocket 和发布-订阅模式生成实时消息传递 Web 应用程序。 Azure Functions 是一个无服务器平台,可让你在不管理任何基础结构的情况下运行代码。 本教程介绍如何使用 Azure Web PubSub 服务和 Azure Functions 来生成具有实时消息传递和发布-订阅模式的无服务器应用程序。

在本教程中,你将了解如何:

  • 构建无服务器实时聊天应用
  • 使用 Web PubSub 函数触发器绑定和输出绑定
  • 将函数部署到 Azure 函数应用
  • 配置 Azure 身份验证
  • 配置 Web PubSub 事件处理程序以将事件和消息路由到应用程序

重要

本文中出现的原始连接字符串仅用于演示目的。

连接字符串包括应用程序访问 Azure Web PubSub 服务所需的授权信息。 连接字符串中的访问密钥类似于服务的根密码。 在生产环境中,请始终保护访问密钥。 使用 Azure 密钥保管库 安全地管理和轮换密钥,并使用 WebPubSubServiceClient 对连接进行保护

避免将访问密钥分发给其他用户、对其进行硬编码或将其以纯文本形式保存在其他人可以访问的任何位置。 如果你认为访问密钥可能已泄露,请轮换密钥。

先决条件

如果没有 Azure 帐户,请在开始前创建一个试用帐户

登录 Azure

使用 Azure 帐户登录到 https://portal.azure.cn/ 的 Azure 门户。

创建 Azure Web PubSub 服务实例

你的应用程序将连接到 Azure 中的 Web PubSub 服务实例。

  1. 选择 Azure 门户左上角的“新建”按钮。 在“新建”屏幕中,在搜索框中键入“Web PubSub”,然后按 Enter。 (还可以从 Web 类别中搜索 Azure Web PubSub。)

    在 Azure 门户中搜索 Azure Web PubSub 的屏幕截图。

  2. 在搜索结果中选择“Web PubSub”,然后选择“创建” 。

  3. 输入以下设置。

    设置 建议值 说明
    资源名称 全局唯一名称 标识新 Web PubSub 服务实例的全局唯一名称。 有效字符为 a-zA-Z0-9-
    订阅 你的订阅 创建此新 Web PubSub 服务实例所使用的 Azure 订阅。
    资源组 myResourceGroup 要在其中创建 Web PubSub 服务实例的新资源组的名称。
    位置 中国北部 3 选择你附近的区域
    定价层 免费 可以先免费试用 Azure Web PubSub 服务。 了解有关 Azure Web PubSub 服务定价层的更多详细信息
    单位计数 - 单位计数指定 Web PubSub 服务实例可接受的连接数。 每个单位最多支持 1000 个并发连接。 它只能在标准层中配置。

    屏幕截图显示在门户中创建 Azure Web PubSub 实例。

  4. 选择“创建”,开始部署 Web PubSub 服务实例。

创建函数

  1. 确保已安装 Azure Functions Core Tools。 然后为项目创建一个空目录。 在此工作目录下运行命令。

    func init --worker-runtime javascript --model V4
    
  2. 安装 Microsoft.Azure.WebJobs.Extensions.WebPubSub

    确认并将 host.json 的 extensionBundle 更新到版本 4.* 或更高版本,以获得对 Web PubSub 的支持。

    {
      "extensionBundle": {
        "id": "Microsoft.Azure.Functions.ExtensionBundle",
        "version": "[4.*, 5.0.0)"
      }
    }
    
  3. 创建 index 函数,为客户端读取和托管静态网页。

    func new -n index -t HttpTrigger
    
    • 更新 src/functions/index.js 并复制以下代码。
      const { app } = require('@azure/functions');
      const { readFile } = require('fs/promises');
      
      app.http('index', {
          methods: ['GET', 'POST'],
          authLevel: 'anonymous',
          handler: async (context) => {
              const content = await readFile('index.html', 'utf8', (err, data) => {
                  if (err) {
                      context.err(err)
                      return
                  }
              });
      
              return { 
                  status: 200,
                  headers: { 
                      'Content-Type': 'text/html'
                  }, 
                  body: content, 
              };
          }
      });
      
  4. 创建 negotiate 函数,以帮助客户端使用访问令牌获取服务连接 URL。

    func new -n negotiate -t HttpTrigger
    

    注意

    本示例使用 Microsoft Entra ID 用户标识标头 x-ms-client-principal-name 来检索 userId。 这在本地函数中不起作用。 在本地运行时,可以将其设为空,或者改用其他方式来获取或生成 userId。 例如,让客户端键入用户名,并在调用 ?user={$username} 函数以获取服务连接 URL 时将其传递到 negotiate 之类的查询中。 在 negotiate 函数中,将 userId 设置为值 {query.user}

    • 更新 src/functions/negotiate 并复制以下代码。
      const { app, input } = require('@azure/functions');
      
      const connection = input.generic({
          type: 'webPubSubConnection',
          name: 'connection',
          userId: '{headers.x-ms-client-principal-name}',
          hub: 'simplechat'
      });
      
      app.http('negotiate', {
          methods: ['GET', 'POST'],
          authLevel: 'anonymous',
          extraInputs: [connection],
          handler: async (request, context) => {
              return { body: JSON.stringify(context.extraInputs.get('connection')) };
          },
      });
      
  5. 创建 message 函数以通过服务广播客户端消息。

    func new -n message -t HttpTrigger
    
    • 更新 src/functions/message.js 并复制以下代码。
      const { app, output, trigger } = require('@azure/functions');
      
      const wpsMsg = output.generic({
          type: 'webPubSub',
          name: 'actions',
          hub: 'simplechat',
      });
      
      const wpsTrigger = trigger.generic({
          type: 'webPubSubTrigger',
          name: 'request',
          hub: 'simplechat',
          eventName: 'message',
          eventType: 'user'
      });
      
      app.generic('message', {
          trigger: wpsTrigger,
          extraOutputs: [wpsMsg],
          handler: async (request, context) => {
              context.extraOutputs.set(wpsMsg, [{
                  "actionName": "sendToAll",
                  "data": `[${context.triggerMetadata.connectionContext.userId}] ${request.data}`,
                  "dataType": request.dataType
              }]);
      
              return {
                  data: "[SYSTEM] ack.",
                  dataType: "text",
              };
          }
      });
      
  6. 在项目根文件夹中添加客户端单页 index.html 并复制内容。

    <html>
      <body>
        <h1>Azure Web PubSub Serverless Chat App</h1>
        <div id="login"></div>
        <p></p>
        <input id="message" placeholder="Type to chat..." />
        <div id="messages"></div>
        <script>
          (async function () {
            let authenticated = window.location.href.includes(
              "?authenticated=true"
            );
            if (!authenticated) {
              // auth
              let login = document.querySelector("#login");
              let link = document.createElement("a");
              link.href = `${window.location.origin}/.auth/login/aad?post_login_redirect_url=/api/index?authenticated=true`;
              link.text = "login";
              login.appendChild(link);
            } else {
              // negotiate
              let messages = document.querySelector("#messages");
              let res = await fetch(`${window.location.origin}/api/negotiate`, {
                credentials: "include",
              });
              let url = await res.json();
              // connect
              let ws = new WebSocket(url.url);
              ws.onopen = () => console.log("connected");
              ws.onmessage = (event) => {
                let m = document.createElement("p");
                m.innerText = event.data;
                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>
    

创建并部署 Azure 函数应用

在将函数代码部署到 Azure 之前,需要创建三个资源:

  • 一个资源组:相关资源的逻辑容器。
  • 一个存储帐户:用于维护有关函数的状态和其他信息。
  • 一个函数应用:提供用于执行函数代码的环境。 函数应用映射到本地函数项目,并允许你将函数分组为一个逻辑单元,以便更轻松地管理、部署和共享资源。

使用以下命令创建这些项。

  1. 请登录到 Azure(如果尚未这样做):

    az login
    
  2. 创建一个资源组,或者重用 Azure Web PubSub 服务所用的资源组以跳过此步骤:

    az group create -n WebPubSubFunction -l <REGION>
    
  3. 在资源组和区域中创建常规用途存储帐户:

    az storage account create -n <STORAGE_NAME> -l <REGION> -g WebPubSubFunction
    
  4. 在 Azure 中创建函数应用:

    az functionapp create --resource-group WebPubSubFunction --consumption-plan-location <REGION> --runtime node --runtime-version 18 --functions-version 4 --name <FUNCIONAPP_NAME> --storage-account <STORAGE_NAME>
    

    注意

    检查 Azure Functions 运行时版本文档,将 --runtime-version 参数设置为支持的值。

  5. 将函数项目部署到 Azure:

    在 Azure 中成功创建函数应用后,便可使用func azure functionapp publish命令部署本地函数项目。

    func azure functionapp publish <FUNCIONAPP_NAME>
    
  6. 为函数应用配置 WebPubSubConnectionString

    本文中出现的原始连接字符串仅用于演示目的。 在生产环境中,请始终保护访问密钥。 使用 Azure 密钥保管库 安全地管理和轮换密钥,并使用 WebPubSubServiceClient 对连接进行保护

    首先,从“Azure 门户”中找到你的 Web PubSub 资源,并复制出“密钥”下的连接字符串 。 然后,导航到 Azure 门户 中的函数应用设置 ->设置 ->环境变量。 并在“应用设置”下添加一个新项目,其名称等于 ,值为你的 Web PubSub 资源连接字符串WebPubSubConnectionString

配置 Web PubSub 服务 Event Handler

本示例使用 WebPubSubTrigger 侦听服务上游请求。 因此 Web PubSub 需要知道函数的终结点信息才能发送目标客户端请求。 此外,Azure Function App 出于安全考虑,针对扩展专用的 Webhook 方法需要使用系统密钥。 在上一步中,使用 message 函数部署函数应用后,我们可以获取系统密钥。

转到 Azure 门户 -> 找到你的函数应用资源 ->应用密钥 ->系统密钥 ->webpubsub_extension。 将该值复制为 <APP_KEY>

获取函数系统密钥的屏幕截图。

在 Azure Web PubSub 服务中设置 Event Handler。 转到 Azure 门户 -> 找到你的 Web PubSub 资源 ->设置。 将新的中心设置映射添加到当前正在使用的函数中。 将 <FUNCTIONAPP_NAME><APP_KEY> 替换为你自己的值。

  • 中心名称:simplechat
  • URL 模板:https://<FUNCTIONAPP_NAME>.chinacloudsites.cn/runtime/webhooks/webpubsub?code=<APP_KEY>
  • 用户事件模式:*
  • 系统事件:-(本示例中无需配置)

设置事件处理程序的屏幕截图。

配置以启用客户端身份验证

转到“Azure 门户”-“查找函数应用资源”->“身份验证”。 单击 Add identity provider。 将应用服务身份验证设置设置为允许未经身份验证的访问,以便匿名用户可以在重定向以进行身份验证之前访问你的客户端索引页。 然后“保存”。

此处选择Microsoft作为标识提供者,它会在x-ms-client-principal-name函数中将userId用作negotiate。 此外,可以按照链接配置其他标识提供者,并且不要忘记相应地更新userId函数中的negotiate值。

尝试运行应用程序

现在,你可以从你的函数应用中测试你的页面了:https://<FUNCTIONAPP_NAME>.azurewebsites.cn/api/index。 请参阅下面的快照。

  1. 单击 login 进行身份验证。
  2. 在输入框中键入消息进行聊天。

在消息函数中,我们会将调用方的消息广播给所有客户端,并向调用方返回消息[SYSTEM] ack。 我们可以在示例聊天快照中了解,前四条消息来自当前客户端,后两条消息来自其他客户端。

聊天示例的屏幕截图。

清理资源

如果不打算继续使用此应用,请按照以下步骤删除本文档中创建的所有资源,以免产生任何费用:

  1. 在 Azure 门户的最左侧选择“资源组”,,然后选择创建的资源组。 可以改用搜索框按名称查找资源组。

  2. 在打开的窗口中选择资源组,然后选择“删除资源组”。

  3. 在新窗口中键入要删除的资源组的名称,然后选择“删除”。

后续步骤

本快速入门介绍了如何运行无服务器聊天应用程序。 现在,可以开始构建自己的应用程序。