你当前正在访问 Microsoft Azure Global Edition 技术文档网站。 如果需要访问由世纪互联运营的 Microsoft Azure 中国技术文档网站,请访问 https://docs.azure.cn

教程:在无服务器模式下使用 Azure 函数构建聊天应用(预览版)

本教程介绍如何在无服务器模式下为 Socket.IO 服务创建 Web PubSub,并构建一个与 Azure 函数集成的聊天应用。

查找本教程中使用的完整代码示例:

重要说明

默认模式需要使用持久性服务器,因此无法在默认模式下将适用于 Socket.IO 的 Web PubSub 与 Azure 函数集成。

重要说明

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

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

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

先决条件

在无服务器模式下为 Socket.IO 资源创建 Web PubSub

若要为 Socket.IO 创建 Web PubSub,可以使用以下 Azure CLI 命令:

az webpubsub create -g <resource-group> -n <resource-name>--kind socketio --service-mode serverless --sku Premium_P1

在本地创建 Azure Function 项目

应按照以下步骤启动本地 Azure 函数项目。

  1. 按照步骤安装最新的 Azure Function Core 工具

  2. 在终端窗口中或通过命令提示符,运行以下命令在 SocketIOProject 文件夹中创建项目:

    func init SocketIOProject --worker-runtime javascript --model V4
    

    此命令创建 JavaScript 项目。 然后输入文件夹 SocketIOProject,运行以下命令。

  3. 目前,函数捆绑包不包括 Socket.IO 函数绑定,因此需要手动添加该包。

    1. 要消除函数捆绑包引用,请编辑 host.json 文件并删除以下几行。

      "extensionBundle": {
          "id": "Microsoft.Azure.Functions.ExtensionBundle",
          "version": "[4.*, 5.0.0)"
      }
      
    2. 运行以下命令:

      func extensions install -p Microsoft.Azure.WebJobs.Extensions.WebPubSubForSocketIO -v 1.0.0-beta.4
      
  4. 创建用于协商的函数。 协商函数用于生成端点和令牌,以便客户端访问服务。

    func new --template "Http Trigger" --name negotiate
    

    打开 src/functions/negotiate.js 中的文件,并替换为以下代码:

    const { app, input } = require('@azure/functions');
    
    const socketIONegotiate = input.generic({
        type: 'socketionegotiation',
        direction: 'in',
        name: 'result',
        hub: 'hub'
    });
    
    async function negotiate(request, context) {
        let result = context.extraInputs.get(socketIONegotiate);
        return { jsonBody: result };
    };
    
    // Negotiation
    app.http('negotiate', {
        methods: ['GET', 'POST'],
        authLevel: 'anonymous',
        extraInputs: [socketIONegotiate],
        handler: negotiate
    });
    

    此步骤会创建一个带有 HTTP 触发器和 negotiate 输出绑定的函数 SocketIONegotiation,这意味着可以使用 HTTP 调用来触发函数,并返回由 SocketIONegotiation 绑定生成的协商结果。

  5. 创建用于处理消息的函数。

    func new --template "Http Trigger" --name message
    

    打开文件 src/functions/message.js,替换为以下代码:

    const { app, output, trigger } = require('@azure/functions');
    
    const socketio = output.generic({
    type: 'socketio',
    hub: 'hub',
    })
    
    async function chat(request, context) {
        context.extraOutputs.set(socketio, {
        actionName: 'sendToNamespace',
        namespace: '/',
        eventName: 'new message',
        parameters: [
            context.triggerMetadata.socketId,
            context.triggerMetadata.message
        ],
        });
    }
    
    // Trigger for new message
    app.generic('chat', {
        trigger: trigger.generic({
            type: 'socketiotrigger',
            hub: 'hub',
            eventName: 'chat',
            parameterNames: ['message'],
        }),
        extraOutputs: [socketio],
        handler: chat
    });
    

    这使用 SocketIOTrigger 由 Socket.IO 客户端消息触发,并使用 SocketIO 绑定将消息广播到命名空间。

  6. 创建一个函数,以便返回供访问的索引 html。

    1. public 下创建文件夹 src/

    2. 创建一个包含以下内容的 HTML 文件 index.html

      <html>
      
      <body>
      <h1>Socket.IO Serverless Sample</h1>
      <div id="chatPage" class="chat-container">
          <div class="chat-input">
              <input type="text" id="chatInput" placeholder="Type your message here...">
              <button onclick="sendMessage()">Send</button>
          </div>
          <div id="chatMessages" class="chat-messages"></div>
      </div>
      <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
      <script>
          function appendMessage(message) {
          const chatMessages = document.getElementById('chatMessages');
          const messageElement = document.createElement('div');
          messageElement.innerText = message;
          chatMessages.appendChild(messageElement);
          hatMessages.scrollTop = chatMessages.scrollHeight;
          }
      
          function sendMessage() {
          const message = document.getElementById('chatInput').value;
          if (message) {
              document.getElementById('chatInput').value = '';
              socket.emit('chat', message);
          }
          }
      
          async function initializeSocket() {
          const negotiateResponse = await fetch(`/api/negotiate`);
          if (!negotiateResponse.ok) {
              console.log("Failed to negotiate, status code =", negotiateResponse.status);
              return;
          }
          const negotiateJson = await negotiateResponse.json();
          socket = io(negotiateJson.endpoint, {
              path: negotiateJson.path,
              query: { access_token: negotiateJson.token }
          });
      
          socket.on('new message', (socketId, message) => {
              appendMessage(`${socketId.substring(0,5)}: ${message}`);
          })
          }
      
          initializeSocket();
      </script>
      </body>
      
      </html>
      
    3. 若要返回 HTML 页面,请创建一个函数并复制代码:

      func new --template "Http Trigger" --name index
      
    4. 打开文件 src/functions/index.js,替换为以下代码:

      const { app } = require('@azure/functions');
      
      const fs = require('fs').promises;
      const path = require('path')
      
      async function index(request, context) {
          try {
              context.log(`HTTP function processed request for url "${request.url}"`);
      
              const filePath = path.join(__dirname,'../public/index.html');
              const html = await fs.readFile(filePath);
              return {
                  body: html,
                  headers: {
                      'Content-Type': 'text/html'
                  }
              };
          } catch (error) {
              context.log(error);
              return {
                  status: 500,
                  jsonBody: error
              }
          }
      };
      
      app.http('index', {
          methods: ['GET', 'POST'],
          authLevel: 'anonymous',
          handler: index
      });
      
      

如何在本地运行应用

准备好代码后,按照说明运行示例。

为 Azure Function 设置 Azure 存储

Azure Functions 需要一个存储帐户才能运行,即使是在本地运行。 选择以下两个选项之一:

  • 运行免费的 Azurite 模拟器
  • 使用 Azure 存储服务。 如果继续使用它,可能会产生费用。
  1. 安装 Azurite
npm install -g azurite
  1. 启动 Azurite 存储模拟器:
azurite -l azurite -d azurite\debug.log
  1. 确保 AzureWebJobsStorage 中的 设置为 UseDevelopmentStorage=true

为 Socket.IO 设置 Web PubSub 的配置

  1. 在函数 APP 中添加连接字符串:
func settings add WebPubSubForSocketIOConnectionString "<connection string>"
  1. 将中心设置添加到 Web PubSub for Socket.IO
az webpubsub hub create -n <resource name> -g <resource group> --hub-name hub --event-handler url-template="tunnel:///runtime/webhooks/socketio" user-event-pattern="*"

连接字符串可通过 Azure CLI 命令来获取

az webpubsub key show -g <resource group> -n <resource name>

输出包含 primaryConnectionStringsecondaryConnectionString,任选其一。

设置隧道

在无服务器模式下,服务使用 Webhook 来触发函数。 尝试在本地运行应用时,一个关键问题是让服务能够访问你的本地函数终结点。

最简单的方法是使用隧道工具

  1. 安装隧道工具:

    npm install -g @azure/web-pubsub-tunnel-tool
    
  2. 运行隧道

    awps-tunnel run --hub hub --connection "<connection string>" --upstream http://127.0.0.1:7071
    

    --upstream 是本地 Azure 函数公开的 URL。 端口可能有所不同,可以在下一步启动函数时检查输出。

运行示例应用

运行隧道工具后,可以在本地运行函数应用:

func start

并于 http://localhost:7071/api/index 访问网页。

无服务器聊天应用的屏幕截图。

后续步骤

接下来,可以尝试使用 Bicep 通过基于标识的身份验证在线部署应用: