教程:使用 Azure Functions 和 Azure Web PubSub 服务创建无服务器实时聊天应用
Azure Web PubSub 服务可帮助你轻松地使用 Websocket 和发布-订阅模式生成实时消息传递 Web 应用程序。 Azure Functions 是一个无服务器平台,可让你在不管理任何基础结构的情况下运行代码。 本教程介绍如何使用 Azure Web PubSub 服务和 Azure Functions 来生成具有实时消息传递和发布-订阅模式的无服务器应用程序。
在本教程中,你将了解如何:
- 构建无服务器实时聊天应用
- 使用 Web PubSub 函数触发器绑定和输出绑定
- 将函数部署到 Azure 函数应用
- 配置 Azure 身份验证
- 配置 Web PubSub 事件处理程序以将事件和消息路由到应用程序
先决条件
如果没有 Azure 试用版订阅,请在开始前创建 Azure 试用版订阅。
登录 Azure
使用 Azure 帐户登录到 https://portal.azure.cn/ 的 Azure 门户。
创建 Azure Web PubSub 服务实例
你的应用程序将连接到 Azure 中的 Web PubSub 服务实例。
选择 Azure 门户左上角的“新建”按钮。 在“新建”屏幕中,在搜索框中键入“Web PubSub”,然后按 Enter。 (还可以从 Web
类别中搜索 Azure Web PubSub。)
在搜索结果中选择“Web PubSub”,然后选择“创建” 。
输入以下设置。
设置 |
建议值 |
说明 |
资源名称 |
全局唯一名称 |
标识新 Web PubSub 服务实例的全局唯一名称。 有效字符为 a-z 、A-Z 、0-9 和 - 。 |
订阅 |
你的订阅 |
在其下创建此新的 Web PubSub 服务实例的 Azure 订阅。 |
资源组 |
myResourceGroup |
要在其中创建 Web PubSub 服务实例的新资源组的名称。 |
位置 |
中国北部 2 |
选择你附近的区域。 |
定价层 |
免费 |
可以先免费试用 Azure Web PubSub 服务。 了解有关 Azure Web PubSub 服务定价层的更多详细信息 |
单位计数 |
- |
单位计数指定 Web PubSub 服务实例可接受的连接数。 每个单位最多支持 1000 个并发连接。 它只能在标准层中配置。 |
选择“创建”,开始部署 Web PubSub 服务实例。
创建函数
确保已安装 Azure Functions Core Tools。 然后为项目创建一个空目录。 在此工作目录下运行命令。
func init --worker-runtime javascript --model V4
func init --worker-runtime javascript --model V3
func init --worker-runtime dotnet
func init --worker-runtime dotnet-isolated
func init --worker-runtime python --model V1
安装 Microsoft.Azure.WebJobs.Extensions.WebPubSub
。
确认并更新 host.json
的 extensionBundle 到版本 4.* 或更高版本,以获取 Web PubSub 支持。
{
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[4.*, 5.0.0)"
}
}
确认并更新 host.json
的 extensionBundle 到版本 3.3.0 或更高版本,以获取 Web PubSub 支持。
{
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[3.3.*, 4.0.0)"
}
}
dotnet add package Microsoft.Azure.WebJobs.Extensions.WebPubSub
dotnet add package Microsoft.Azure.Functions.Worker.Extensions.WebPubSub --prerelease
{
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[3.3.*, 4.0.0)"
}
创建 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,
};
}
});
更新 index/function.json
并复制以下 json 代码。
{
"bindings": [
{
"authLevel": "anonymous",
"type": "httpTrigger",
"direction": "in",
"name": "req",
"methods": ["get", "post"]
},
{
"type": "http",
"direction": "out",
"name": "res"
}
]
}
更新 index/index.js
并复制以下代码。
var fs = require("fs");
var path = require("path");
module.exports = function (context, req) {
var index =
context.executionContext.functionDirectory + "/../index.html";
context.log("index.html path: " + index);
fs.readFile(index, "utf8", function (err, data) {
if (err) {
console.log(err);
context.done(err);
}
context.res = {
status: 200,
headers: {
"Content-Type": "text/html",
},
body: data,
};
context.done();
});
};
- 更新
index.cs
并将 Run
函数替换为以下代码。
[FunctionName("index")]
public static IActionResult Run([HttpTrigger(AuthorizationLevel.Anonymous)] HttpRequest req, ExecutionContext context, ILogger log)
{
var indexFile = Path.Combine(context.FunctionAppDirectory, "index.html");
log.LogInformation($"index.html path: {indexFile}.");
return new ContentResult
{
Content = File.ReadAllText(indexFile),
ContentType = "text/html",
};
}
更新 index.cs
并将 Run
函数替换为以下代码。
[Function("index")]
public HttpResponseData Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req, FunctionContext context)
{
var path = Path.Combine(context.FunctionDefinition.PathToAssembly, "../index.html");
_logger.LogInformation($"index.html path: {path}.");
var response = req.CreateResponse();
response.WriteString(File.ReadAllText(path));
response.Headers.Add("Content-Type", "text/html");
return response;
}
更新 index/function.json
并复制以下 json 代码。
{
"bindings": [
{
"authLevel": "anonymous",
"type": "httpTrigger",
"direction": "in",
"name": "req",
"methods": ["get", "post"]
},
{
"type": "http",
"direction": "out",
"name": "$return"
}
]
}
更新 __init__.py
并将 main
函数替换为以下代码。
import os
import azure.functions as func
def main(req: func.HttpRequest) -> func.HttpResponse:
f = open(os.path.dirname(os.path.realpath(__file__)) + "/../index.html")
return func.HttpResponse(f.read(), mimetype="text/html")
创建 negotiate
函数,以帮助客户端使用访问令牌获取服务连接 URL。
func new -n negotiate -t HttpTrigger
注意
本示例使用 Microsoft Entra ID 用户标识标头 x-ms-client-principal-name
来检索 userId
。 这在本地函数中不起作用。 在本地运行时,可以将其清空或改为使用其他方式获取或生成 userId
。 例如,让客户端键入用户名,并在调用 negotiate
函数以获取服务连接 URL 时将其传递到 ?user={$username}
之类的查询中。 在 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')) };
},
});
- 更新
negotiate/function.json
并复制以下 json 代码。
{
"bindings": [
{
"authLevel": "anonymous",
"type": "httpTrigger",
"direction": "in",
"name": "req"
},
{
"type": "http",
"direction": "out",
"name": "res"
},
{
"type": "webPubSubConnection",
"name": "connection",
"hub": "simplechat",
"userId": "{headers.x-ms-client-principal-name}",
"direction": "in"
}
]
}
- 更新
negotiate/index.js
并复制以下代码。
module.exports = function (context, req, connection) {
context.res = { body: connection };
context.done();
};
- 更新
negotiate.cs
并将 Run
函数替换为以下代码。
[FunctionName("negotiate")]
public static WebPubSubConnection Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req,
[WebPubSubConnection(Hub = "simplechat", UserId = "{headers.x-ms-client-principal-name}")] WebPubSubConnection connection,
ILogger log)
{
log.LogInformation("Connecting...");
return connection;
}
- 在标头中添加
using
语句来解析所需的依赖项。
using Microsoft.Azure.WebJobs.Extensions.WebPubSub;
- 更新
negotiate.cs
并将 Run
函数替换为以下代码。
[Function("negotiate")]
public HttpResponseData Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req,
[WebPubSubConnectionInput(Hub = "simplechat", UserId = "{headers.x-ms-client-principal-name}")] WebPubSubConnection connectionInfo)
{
var response = req.CreateResponse(HttpStatusCode.OK);
response.WriteAsJsonAsync(connectionInfo);
return response;
}
更新 negotiate/function.json
并复制以下 json 代码。
{
"bindings": [
{
"authLevel": "anonymous",
"type": "httpTrigger",
"direction": "in",
"name": "req"
},
{
"type": "http",
"direction": "out",
"name": "$return"
},
{
"type": "webPubSubConnection",
"name": "connection",
"hub": "simplechat",
"userId": "{headers.x-ms-client-principal-name}",
"direction": "in"
}
]
}
更新 negotiate/__init__.py
并复制以下代码。
import azure.functions as func
def main(req: func.HttpRequest, connection) -> func.HttpResponse:
return func.HttpResponse(connection)
创建 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",
};
}
});
- 更新
message/function.json
并复制以下 json 代码。
{
"bindings": [
{
"type": "webPubSubTrigger",
"direction": "in",
"name": "data",
"hub": "simplechat",
"eventName": "message",
"eventType": "user"
},
{
"type": "webPubSub",
"name": "actions",
"hub": "simplechat",
"direction": "out"
}
]
}
- 更新
message/index.js
并复制以下代码。
module.exports = async function (context, data) {
context.bindings.actions = {
actionName: "sendToAll",
data: `[${context.bindingData.request.connectionContext.userId}] ${data}`,
dataType: context.bindingData.dataType,
};
// UserEventResponse directly return to caller
var response = {
data: "[SYSTEM] ack.",
dataType: "text",
};
return response;
};
- 更新
message.cs
并将 Run
函数替换为以下代码。
[FunctionName("message")]
public static async Task<UserEventResponse> Run(
[WebPubSubTrigger("simplechat", WebPubSubEventType.User, "message")] UserEventRequest request,
BinaryData data,
WebPubSubDataType dataType,
[WebPubSub(Hub = "simplechat")] IAsyncCollector<WebPubSubAction> actions)
{
await actions.AddAsync(WebPubSubAction.CreateSendToAllAction(
BinaryData.FromString($"[{request.ConnectionContext.UserId}] {data.ToString()}"),
dataType));
return new UserEventResponse
{
Data = BinaryData.FromString("[SYSTEM] ack"),
DataType = WebPubSubDataType.Text
};
}
- 在标头中添加
using
语句来解析所需的依赖项。
using System.Threading.Tasks;
using Microsoft.Azure.WebJobs.Extensions.WebPubSub;
using Microsoft.Azure.WebPubSub.Common;
- 更新
message.cs
并将 Run
函数替换为以下代码。
[Function("message")]
[WebPubSubOutput(Hub = "simplechat")]
public SendToAllAction Run(
[WebPubSubTrigger("simplechat", WebPubSubEventType.User, "message")] UserEventRequest request)
{
return new SendToAllAction
{
Data = BinaryData.FromString($"[{request.ConnectionContext.UserId}] {request.Data.ToString()}"),
DataType = request.DataType
};
}
- 更新
message/function.json
并复制以下 json 代码。
{
"bindings": [
{
"type": "webPubSubTrigger",
"direction": "in",
"name": "request",
"hub": "simplechat",
"eventName": "message",
"eventType": "user"
},
{
"type": "webPubSub",
"name": "actions",
"hub": "simplechat",
"direction": "out"
}
]
}
- 更新
message/__init__.py
并复制以下代码。
import json
import azure.functions as func
def main(request, actions: func.Out[str]) -> None:
req_json = json.loads(request)
actions.set(
json.dumps(
{
"actionName": "sendToAll",
"data": f'[{req_json["connectionContext"]["userId"]}] {req_json["data"]}',
"dataType": req_json["dataType"],
}
)
)
在项目根文件夹中添加客户端单页 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>
由于 C# 项目将文件编译到不同的输出文件夹,因此你需要更新 *.csproj
,使内容页与其保持一致。
<ItemGroup>
<None Update="index.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
由于 C# 项目将文件编译到不同的输出文件夹,因此需要更新*.csproj
,使内容页与其保持一致。
<ItemGroup>
<None Update="index.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
创建并部署 Azure 函数应用
在将函数代码部署到 Azure 之前,需要创建三个资源:
- 一个资源组:相关资源的逻辑容器。
- 一个存储帐户:用于维护有关函数的状态和其他信息。
- 一个函数应用:提供用于执行函数代码的环境。 函数应用映射到本地函数项目,并允许你将函数分组为一个逻辑单元,以便更轻松地管理、部署和共享资源。
使用以下命令创建这些项。
请登录到 Azure(如果尚未这样做):
az cloud set -n AzureChinaCloud
az login
# az cloud set -n AzureCloud //means return to Public Azure.
创建资源组,或者可重用某个 Azure Web PubSub 服务来跳过此步骤:
az group create -n WebPubSubFunction -l <REGION>
在资源组和区域中创建常规用途存储帐户:
az storage account create -n <STORAGE_NAME> -l <REGION> -g WebPubSubFunction
在 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>
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>
az functionapp create --resource-group WebPubSubFunction --consumption-plan-location <REGION> --runtime dotnet --functions-version 4 --name <FUNCIONAPP_NAME> --storage-account <STORAGE_NAME>
az functionapp create --resource-group WebPubSubFunction --consumption-plan-location <REGION> --runtime dotnet-isolated --functions-version 4 --name <FUNCIONAPP_NAME> --storage-account <STORAGE_NAME>
az functionapp create --resource-group WebPubSubFunction --consumption-plan-location <REGION> --runtime python --runtime-version 3.9 --functions-version 4 --name <FUNCIONAPP_NAME> --os-type linux --storage-account <STORAGE_NAME>
将函数项目部署到 Azure:
在 Azure 中成功创建函数应用后,便可使用func azure functionapp publish命令部署本地函数项目。
func azure functionapp publish <FUNCIONAPP_NAME>
配置函数应用的 WebPubSubConnectionString
:
首先,从“Azure 门户”中找到你的 Web PubSub 资源,并复制出“密钥”下的连接字符串 。 然后,导航到“Azure 门户”->“设置”->“配置”中的函数应用设置。 并在“应用程序设置”下添加一个新项目,其名称等于 WebPubSubConnectionString
,值为你的 Web PubSub 资源连接字符串。
本示例使用 WebPubSubTrigger
侦听服务上游请求。 因此 Web PubSub 需要知道函数的终结点信息才能发送目标客户端请求。 并且 Azure 函数应用需要一个系统密钥,以确保有关特定于扩展的 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
作为标识提供者,它会在negotiate
函数中将x-ms-client-principal-name
用作userId
。 此外,可以按照链接配置其他标识提供者,并且不要忘记相应地更新negotiate
函数中的userId
值。
尝试运行应用程序
现在,可以从你的函数应用测试页面:https://<FUNCTIONAPP_NAME>.azurewebsites.cn/api/index
。 请参阅下面的快照。
- 单击
login
进行身份验证。
- 在输入框中键入消息进行聊天。
在消息函数中,我们会将调用方的消息广播给所有客户端,并向调用方返回消息[SYSTEM] ack
。 我们可以在示例聊天快照中了解,前四条消息来自当前客户端,后两条消息来自其他客户端。
清理资源
如果不打算继续使用此应用,请按照以下步骤删除本文档中创建的所有资源,以免产生任何费用:
在 Azure 门户的最左侧选择“资源组”,,然后选择创建的资源组。 可以改用搜索框按名称查找资源组。
在打开的窗口中选择资源组,然后选择“删除资源组”。
在新窗口中键入要删除的资源组的名称,然后选择“删除”。
后续步骤
本快速入门介绍了如何运行无服务器聊天应用程序。 现在,可以开始构建自己的应用程序。