はじめに
以前、Durable Functionsで遊んでみる記事を書いたのですが、その第2弾ということで、前回使っていなかったタイマー処理や外部イベント、永続的オーケストレーションを使って、チャット風クイズアプリを作ってみたいと思います。
前回に引き続き node で作成します。
前回の投稿:
この記事ではバックエンド部分のみを扱っています。
クライアント部分についてはクライアント編にて記載しています。
クライアント編:
ざっくりの仕様
- クイズのお題を入力してクイズ開始すると、サーバーでクイズを作成しクライアントに送信する
- クイズは選択式問題にする
- クライアントから回答が送信されたら、答え合わせしてクライアントに解答を送信する
- 回答の制限時間を設け、制限時間が過ぎたら回答を打ち切ってクライアントに解答を送信する
- 答え合わせの後、新たなクイズを作成しクライアントに送信する
- 成績の集計や表示はしない
わざわざDurable Functions にしてサーバー側で制御せずとも、クライアント側で制御した方が簡単そうですが、Durable Functionsを使うのが目的なので、サーバー側で色々やるようにします。
全体の構成
今回作ろうとしているアプリの全体構成はこんな感じです。
ここで Azure SignalR というサービスがしれっと登場していますが、Azure SignalRは、Web アプリケーションにリアルタイムの通信機能を簡単に追加できるサービスです。Azure SignalR を使うと、サーバーからクライアントにリアルタイムでデータをプッシュすることができます。SignalRという.NETのライブラリをベースにしていますが、クラウドでスケーラブルに動作します。
今回はサーバー起点でクライアントにメッセージを送るために使用します。
あと、クイズの出題と答え合わせはChatGPT先生にお願いします!
各構成要素の役割
No. | 要素 | タイプ | 役割 |
---|---|---|---|
1 | negotiate | 関数アプリ (HttpTrigger) |
クライアントが Azure SignalR へ接続するためのURLおよびアクセストークンを取得する |
2 | startQuizChat | 関数アプリ (HttpTrigger) |
クイズチャットのオーケストレーションを開始する オーケストレーション関数(orchestrateQuizChat)にアクセスするためのURL情報を返す |
3 | orchestrateQuizChat | 関数アプリ (Orchestrator) |
タイマーとクライアントからの外部イベントに応じて、以下3つのアクティビティ関数の実行を制御します
|
4 | isUserConnected | 関数アプリ (Activity) |
クライアントが Azure SignalR に接続しているかどうかはチェックする |
5 | notifyStatus | 関数アプリ (Activity) |
サーバーの状態をクライアントに通知する |
6 | talk | 関数アプリ (Activity) |
ChatGPT API を呼び出してクイズの出題依頼や、クライアントの回答に対する答え合わせを行う |
7 | Azure SignalR | Azure SignalR | サーバーからクライアントに対してメッセージを送信するための接続を保持する |
8 | ChatGPT API | ChatGPT API | talk アクティビティからのメッセージに対して返答してもらう |
必要な環境
- Node.js をインストールしておく (執筆時のバージョンは
v18.14.2
) - Azure CLI をインストールしておく
- Azure Functions Core Tools をインストールしておく
- VSCode にAzure Functions の拡張機能をインストールしておく (任意)
準備
Azure SignalR のリソースを作成する
<resource group>
、<resource name>
は実際の名前に置き換えてください。
resourceGroup=<resource group>
name=<resource name>
allowedOrigins=http://localhost:3000 # ローカルからの接続を許可する
sku=Free_F1
serviceMode=Serverless
# リソース作成
az signalr create \
--name $name \
--resource-group $resourceGroup \
--sku $sku \
--allowed-origins $allowedOrigins \
--service-mode $serviceMode
# 接続文字列確認
connectionString=$(
az signalr key list \
--name $name \
--resource-group $resourceGroup \
--query primaryConnectionString \
--output tsv
)
echo Azure SignalR connection string: $connectionString
実行すると最後に接続文字列が表示されるのでメモっておきます。(Endpoint=
以降)
Azure SignalR connection string: Endpoint=https://<resource name>.service.signalr.net;AccessKey=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx;Version=1.0;
Open AI API のアクセスキーを取得する
ChatGPT API を利用するので、アクセスキーを取得しておきます。
実装!
プロジェクトの初期化
func init --worker-runtime node --language typescript
必要なパッケージのインストール
npm i -E openai jsonwebtoken http-status-codes date-fns
npm i -D azurite @types/jsonwebtoken
ローカル設定ファイルの編集
local.setting.json に Open AI APIのアクセスキーと Azure SignalR の接続文字列を設定します。
また、Azure Storage のエミュレーター (azurite) を使用するため、AzureWebJobStorage も設定します。
更に、クライアントから接続させるためCORS設定を追加します。
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "node",
- "AzureWebJobsStorage": "",
+ "AzureWebJobsStorage": "UseDevelopmentStorage=true",
+ "OPEN_AI_API_KEY": "<取得したOpen AI API のアクセスキー>",
+ "AzureSignalRConnectionString": "<Azure SignalR リソース作成時に取得した接続文字列>"
},
+ "Host": {
+ "LocalHttpPort": 7071,
+ "CORS": "http://localhost:3000",
+ "CORSCredentials": true
+ }
}
関数アプリの作成
関数を VSCode の拡張機能か、func new
コマンドで作成していきます。
作成する関数アプリの概要は以下の通りです。
No. | 関数名 | テンプレート | 入力バインド | 出力バインド |
---|---|---|---|---|
1 | negotiate | SignalR negotiate HTTP trigger | SignalR | - |
2 | startQuizChat | Durable Functions HTTP starter | - | - |
3 | orchestrateQuizChat | Durable Functions orchestrator | - | - |
4 | isUserConnected | Durable Functions activity | - | - |
5 | notifyStatus | Durable Functions activity | - | SignalR |
6 | talk | Durable Functions activity | - | SignalR |
SignalR negotiate HTTP trigger は VSCode の拡張機能では選択できなかったので、func new
コマンドで作成する必要があります (2023/04現在)
1. negotiate
index.ts
import { AzureFunction, Context, HttpRequest, HttpResponse } from '@azure/functions'
/** SignalR に接続するためのエンドポイント、アクセストークンを返す */
const httpTrigger: AzureFunction = async function (
context: Context,
req: HttpRequest,
connectionInfo: unknown
): Promise<void> {
context.res = { body: connectionInfo } satisfies HttpResponse
}
export default httpTrigger
クライアントが Azure SignalR に接続するための情報(URL、アクセストークン)を発行するための関数です。
入力バインドで受け取った Azure SignalR の接続情報をそのまま返します。
テンプレートのままでOKです。
function.json
{
"disabled": false,
"bindings": [
{
"authLevel": "anonymous",
"type": "httpTrigger",
"direction": "in",
"methods": ["post"],
"name": "req"
},
{
"type": "http",
"direction": "out",
"name": "res"
},
{
"type": "signalRConnectionInfo",
"name": "connectionInfo",
"hubName": "serverless",
"userId": "{query.userId}",
"connectionStringSetting": "AzureSignalRConnectionString",
"direction": "in"
}
],
"scriptFile": "../dist/negotiate/index.js"
}
Azure SignalR のリソース作成時に --service-mode
を Serverless
に設定しているので、SignalR 入力バインドの hubName
を serverless
に変更します。
また、接続しているクライアントを識別できるようにするため、userId
を設定します。(userId はクエリパラメータとして取得)
【参考】Azure SignalR への接続シーケンス
以下の手順でクライアントから Azure SignalR に接続できるようになります。
negotiate のエンドポイントにアクセスすると以下の様なレスポンスが返ってきますが、この辺りはクライアントライブラリがハブへの接続確立までやってくれるので、意識する必要はありません。
(クライアントライブラリの使い方は クライアント編 にて!)
curl -X POST http://localhost:7071/api/negotiate?userId=xxxxxxxxxxxxxxxxxxxxx
{
"url": "https://<resource name>.service.signalr.net/client/?hub=serverless",
"accessToken": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
}
2. startQuizChat
index.ts
import * as df from 'durable-functions'
import { AzureFunction, Context, HttpRequest, HttpResponse } from '@azure/functions'
import { StatusCodes } from 'http-status-codes'
import { FUNCTION_NAME } from '../lib/constants'
import { OrchestrateQuizChatInput, StartChatRequestBody } from '../lib/types'
/** Quiz Chat のオーケストレーションを開始する */
const httpStart: AzureFunction = async function (
context: Context,
req: HttpRequest
): Promise<unknown> {
const { userId, quizTopic, currentInstanceId } = (req.body ?? {}) as StartChatRequestBody
if (!userId || !quizTopic) {
return {
status: StatusCodes.BAD_REQUEST,
}
}
const client = df.getClient(context)
// 起動済みのインスタンスがある場合は停止
if (currentInstanceId) {
const status = await client.getStatus(currentInstanceId)
if (status?.runtimeStatus === df.OrchestrationRuntimeStatus.Running) {
await client.terminate(currentInstanceId, 'Restart New')
}
}
// オーケストレーターを開始
const input: OrchestrateQuizChatInput = { userId, quizTopic }
const instanceId = await client.startNew(
FUNCTION_NAME.orchestrator.orchestrateQuizChat,
undefined,
input
)
// インスタンスにアクセスするためのURL情報を含んだレスポンスを返す
return client.createCheckStatusResponse(context.bindingData.req, instanceId)
}
export default httpStart
function.json
{
"bindings": [
{
"authLevel": "function",
"name": "req",
"type": "httpTrigger",
"direction": "in",
"methods": ["post", "options"]
},
{
"name": "$return",
"type": "http",
"direction": "out"
},
{
"name": "starter",
"type": "orchestrationClient",
"direction": "in"
}
],
"scriptFile": "../dist/startQuizChat/index.js"
}
クライアントからの開始操作を受けて、オーケストレーションを開始します。
2重起動を防ぐため、既に起動済みの場合は停止してから新しいオーケストレーションを開始するようにしています。
startQuizChat のエンドポイントにアクセスすると以下の様なレスポンスが返ってきます。
(レスポンスの詳細は クライアント編 にて!)
curl -X POST http://localhost:7071/api/startQuizChat -d "{\"userId\":\"xxxxxxxxxxxxxxxx\",\"quizTopic\":\"Some Topic\"}"
{
"id": "1bc5891ea1ee4a928b0cb3a631555b06",
"statusQueryGetUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06?taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
"sendEventPostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06/raiseEvent/{eventName}?taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
"terminatePostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06/terminate?reason={text}&taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
"rewindPostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06/rewind?reason={text}&taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
"purgeHistoryDeleteUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06?taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
"restartPostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06/restart?taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
"suspendPostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06/suspend?reason={text}&taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
"resumePostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06/resume?reason={text}&taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw=="
}
3. orchestrateQuizChat
index.ts
import * as df from 'durable-functions'
import { add } from 'date-fns'
import { FUNCTION_NAME } from '../lib/constants'
import { Task } from 'durable-functions/lib/src/task'
import { IOrchestrationFunctionContext } from 'durable-functions/lib/src/iorchestrationfunctioncontext'
import { ChatMessage, OrchestrateQuizChatInput, Status, StatusMessage } from '../lib/types'
/** 外部イベントとChatGPTへのメッセージ送信を制御する */
const orchestrator = df.orchestrator(function* (context) {
const input = context.df.getInput<OrchestrateQuizChatInput>()
const { userId, quizTopic } = input
// ユーザーがSignalRに接続しているかどうか確認
const isConnected = yield context.df.callActivity(FUNCTION_NAME.activity.isUserConnected, userId)
// 接続していなかったら終了
if (!isConnected) return
try {
// ChatGPTの入力中を通知
yield createNotifyStatusChangeTask(context, userId, 'Typing')
// クイズの出題依頼を送信
const requestMessage: ChatMessage = {
userId,
content: [
`${quizTopic}に関するクイズを1問出題してください。`,
'選択式問題にしてください。',
'解答は伏せてください。',
].join('\n'),
}
const question = yield createTalkTask(context, requestMessage)
// ユーザー入力を待機するタスク("NewMessage" という名前のイベントを監視する)
const messageMonitorTask = context.df.waitForExternalEvent('NewMessage')
// 回答期限(30秒)まで待機するタスク
const deadline = add(context.df.currentUtcDateTime, {
seconds: 30,
})
const deadlineTimer = context.df.createTimer(deadline)
// いずれかのタスクが完了するまで待機
const winner = yield context.df.Task.any([messageMonitorTask, deadlineTimer])
const tasks: Task[] = [
// 入力中を通知するタスク
createNotifyStatusChangeTask(context, userId, 'Typing'),
]
// ユーザーの回答を受信したら回答を送信
if (winner === messageMonitorTask) {
deadlineTimer.cancel()
const receivedMessage = messageMonitorTask.result as ChatMessage
// 回答送信タスク
const message: ChatMessage = {
...receivedMessage,
currentReply: question,
}
tasks.push(createTalkTask(context, message))
}
// 時間切れになったら降参のメッセージを送信
else {
// 時間切れを通知
yield createNotifyStatusChangeTask(context, userId, 'TimeUp')
const message: ChatMessage = {
userId,
content: '降参です。答えを教えてください。',
currentReply: question,
}
// 降参メッセージ送信タスク
tasks.push(createTalkTask(context, message))
}
// ChatGPTの入力中を通知するタスクと会話タスクを実行
yield context.df.Task.all(tasks)
} catch (error) {
context.log.error(error)
// エラーを通知
yield createNotifyStatusChangeTask(context, userId, 'Error')
return
}
// オーケストレーションを再実行
context.df.continueAsNew(input)
})
/** 会話タスクを作成 */
const createTalkTask = (context: IOrchestrationFunctionContext, message: ChatMessage): Task => {
return context.df.callActivity(FUNCTION_NAME.activity.talk, message)
}
/** ステータス変更を通知するタスクを作成 */
const createNotifyStatusChangeTask = (
context: IOrchestrationFunctionContext,
userId: string,
status: Status
): Task => {
return context.df.callActivity(FUNCTION_NAME.activity.notifyStatus, {
userId,
status,
} satisfies StatusMessage)
}
export default orchestrator
function.json
{
"bindings": [
{
"name": "context",
"type": "orchestrationTrigger",
"direction": "in"
}
],
"scriptFile": "../dist/orchestrateQuizChat/index.js"
}
クイズの出題と制限時間の制御、クライアントからの回答イベントの監視を行います。
以下の様なフローになっています。
最初にクライアントが Azure SignalR に接続しているかどうかチェックしていますが、クイズ終了操作をせずにページを閉じた場合を考慮して、接続が切れていたら終了するようにしています。
「ChatGPTの返答待ちを通知」となってる部分は、クライアント側で「入力中...」のような表示をさせるためのトリガーとなる通知です。
「A.クライアントからの回答を待機するタスク」が外部イベント、「B.回答期限 30秒 まで待機するタスク」がタイマーになります。
そして「オーケストレーションを再実行」としてループさせている部分が永続的オーケストレーションですね。
オーケストレーター関数は普通に while でループさせることも可能ですが、関数の履歴が増え続けてパフォーマンスの悪化につながるため、推奨されていないようです。
代わりに、continueAsNew
関数で再実行させることでループ処理を実現できます。
4. isUserConnected
index.ts
import { AzureFunction, Context } from '@azure/functions'
import { StatusCodes } from 'http-status-codes'
import { sign } from 'jsonwebtoken'
const API_ENDPOINT_BASE = '/api/v1/hubs/serverless/users/'
/** ユーザーが SignalR に接続しているかどうか確認する */
const activityFunction: AzureFunction = async function (
context: Context,
userId: string
): Promise<boolean> {
const apiEndpoint = `${API_ENDPOINT_BASE}${userId}`
const { url, accessToken } = createSignalRAccessToken(apiEndpoint)
try {
// Azure SignalR Service REST API にリクエスト発行
const response = await fetch(url, {
method: 'HEAD',
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
// 未接続の場合はステータスコード = 404 となる
return response.status === StatusCodes.OK
} catch (error) {
context.log.error(error)
return false
}
}
/** SignalR の接続文字列からエンドポイントとシークレットを取得する */
const parseSignalRConnectionString = () => {
const connectionString = process.env['AzureSignalRConnectionString']
if (!connectionString) throw Error("Missing env: 'AzureSignalRConnectionString'")
const [endpoint, key] = connectionString.split(';')
return {
endpoint: endpoint.replace('Endpoint=', ''),
key: key.replace('AccessKey=', ''),
}
}
/** Azure SignalR Service REST API のアクセストークンを生成する */
const createSignalRAccessToken = (endpoint: string) => {
const { endpoint: origin, key } = parseSignalRConnectionString()
const audience = `${origin}${endpoint}`
const accessToken = sign({ aud: audience }, key, { expiresIn: '1h' })
return { url: audience, accessToken }
}
export default activityFunction
function.json
{
"bindings": [
{
"name": "userId",
"type": "activityTrigger",
"direction": "in"
}
],
"scriptFile": "../dist/isUserConnected/index.js"
}
クライアントが Azure SignalR に接続しているかどうかチェックするための関数です。
Azure SignalR Service REST API に対してコネクションを取得するリクエストを投げています。
Typescript 向けのクライアントライブラリがなかったので、仕方なく自前で実装しました。
(アクセストークン生成の勉強になったのでヨシ!)
5. nofityStatus
index.ts
import { AzureFunction, Context } from '@azure/functions'
import { StatusMessage } from '../lib/types'
import { SIGNALR_MESSAGE } from '../lib/constants'
/** ステータスの変更をクライアントに通知する */
const activityFunction: AzureFunction = async function (
context: Context,
message: StatusMessage
): Promise<void> {
const { userId, status } = message
// "StatusChanged" という名前でメッセージ通知する
context.bindings.signalRMessages = [
{ userId, target: SIGNALR_MESSAGE.statusChanged, arguments: [status] },
]
}
export default activityFunction
サーバー側のステータス変更をクライアントに通知するための関数です。
SignalR 出力バインドを使用してメッセージ通知しています。
SignalR に送信するメッセージに userId
を設定しているので、該当のユーザーのみに通知されます。
userId
を設定しないと、接続中の全ユーザーにブロードキャストされます。
// "StatusChanged" という名前でメッセージ通知する
context.bindings.signalRMessages = [
{ userId, target: SIGNALR_MESSAGE.statusChanged, arguments: [status] },
]
function.json
{
"bindings": [
{
"name": "status",
"type": "activityTrigger",
"direction": "in"
},
{
"type": "signalR",
"name": "signalRMessages",
"hubName": "serverless",
"connectionStringSetting": "AzureSignalRConnectionString",
"direction": "out"
}
],
"scriptFile": "../dist/notifyStatus/index.js"
}
negotiate と同様、hubName
には serverless
を設定します。
6. talk
index.ts
import { AzureFunction, Context } from '@azure/functions'
import { ChatCompletionRequestMessage, Configuration, OpenAIApi } from 'openai'
import { ChatMessage } from '../lib/types'
import { SIGNALR_MESSAGE } from '../lib/constants'
/** ChatGPT にメッセージを送信して返答を返す */
const activityFunction: AzureFunction = async function (
context: Context,
message: ChatMessage
): Promise<string> {
const apiKey = process.env['OPEN_AI_API_KEY']
if (!apiKey) throw Error('Missing OpenAI Key')
const configuration = new Configuration({ apiKey })
const openai = new OpenAIApi(configuration)
const { userId, content, currentReply } = message
const messages: ChatCompletionRequestMessage[] = [
// AIの設定
{
role: 'system',
content: 'あなたはクイズの出題者です。日本語で返答してください。',
},
]
// 直近でChatGPTが発言した内容を設定
if (currentReply) messages.push({ role: 'assistant', content: currentReply })
// ユーザーのメッセージ
messages.push({ role: 'user', content })
// ChatGPTにメッセージ送信
const reply = await sendMessageToChatGPT(context, openai, messages)
if (!reply) throw Error('Could not retrieve reply.')
// "Reply" という名前でメッセージ通知する
context.bindings.signalRMessages = [{ userId, target: SIGNALR_MESSAGE.reply, arguments: [reply] }]
return reply
}
/** ChatGPT にメッセージ送信 */
const sendMessageToChatGPT = async (
context: Context,
openai: OpenAIApi,
messages: ChatCompletionRequestMessage[]
): Promise<string | undefined> => {
const completion = await openai.createChatCompletion(
{ model: 'gpt-3.5-turbo', max_tokens: 400, messages },
{ timeout: 120000 }
)
const reply = completion.data.choices[0].message?.content
return reply
}
export default activityFunction
ChatGPT API とメッセージのやり取りして、返答をクライアントに通知するための関数です。
notifyStatus と同様 SignalR 出力バインドを使用しています。
記事の趣旨から外れるので API の使い方については割愛しますが、クライアントからの回答内容のみを送信すると、ChatGPT 自身が直前に発言した内容を忘れて「お前何言ってんの?」状態になってしまうので、直前の発言内容を assistant
ロールとして設定しています。
function.json
{
"bindings": [
{
"name": "message",
"type": "activityTrigger",
"direction": "in"
},
{
"type": "signalR",
"name": "signalRMessages",
"hubName": "serverless",
"connectionStringSetting": "AzureSignalRConnectionString",
"direction": "out"
}
],
"scriptFile": "../dist/talk/index.js"
}
notifyStatus と同様、hubName
には serverless
を設定します。
その他
タイプ定義や定数定義
タイプ定義
/** startChat API のリクエストボディ */
type StartChatRequestBody = {
userId?: string
quizTopic?: string
currentInstanceId?: string
}
/** orchestrateQuizChat API の入力 */
type OrchestrateQuizChatInput = {
userId: string
quizTopic: string
}
/** talk API の入力 */
type ChatMessage = {
userId: string
content: string
currentReply?: string
}
/** notifyStatus API の入力 */
type StatusMessage = {
userId: string
status: Status
}
/** サーバーステータス */
type Status = 'Typing' | 'TimeUp' | 'Error'
export type { ChatMessage, OrchestrateQuizChatInput, StartChatRequestBody, StatusMessage, Status }
定数定義
/** 関数名 */
const FUNCTION_NAME = {
orchestrator: {
orchestrateQuizChat: 'orchestrateQuizChat',
},
activity: {
isUserConnected: 'isUserConnected',
talk: 'talk',
notifyStatus: 'notifyStatus',
},
} as const
/** Azure SignalR のメッセージ名 */
const SIGNALR_MESSAGE = {
reply: 'Reply',
statusChanged: 'StatusChanged',
}
export { FUNCTION_NAME, SIGNALR_MESSAGE }
最終的なディレクトリ構成
api
├── host.json
├── isUserConnected
│ ├── function.json
│ └── index.ts
├── lib
│ ├── constants.ts
│ └── types.ts
├── local.settings.json
├── negotiate
│ ├── function.json
│ └── index.ts
├── notifyStatus
│ ├── function.json
│ └── index.ts
├── orchestrateQuizChat
│ ├── function.json
│ └── index.ts
├── package-lock.json
├── package.json
├── startQuizChat
│ ├── function.json
│ └── index.ts
├── talk
│ ├── function.json
│ └── index.ts
└── tsconfig.json
起動
実行するには、Storage エミュレーター (azurite) と関数アプリを起動する必要があります。
(前回の記事と同様)
ただし、動作確認はクライアント含めて行うので、バックエンド編としてはここで終了です!
azurite の起動
npx azurite -l azurite
関数アプリの起動
npm start
まとめ
Durable Functions を使用することで、タイマー処理や外部イベント処理、永続的オーケストレーションを実装することができました。
永続的オーケストレーションは停止させるまで永遠に実行され続けるので、用途によっては終了条件を決めたり、外部から終了させる仕組みを用意しておいた方が良さそうです。
(今回は、ユーザーが未接続の場合は終了させるのと、クライアントに終了ボタンを設置する予定)
また、Azure SignalR を使用してサーバーからクライアントに対してのリアルタイム通知も実装することができました。
(しかしこのサービス、あんまり使われてないんかなぁ...)
今回はバックエンドのみで、いまいち何やってるかわかりにくかったかもしれませんが、クライアント編で画面とバックエンドを繋げて動かしてみるので、良ければ併せてご覧いただけると嬉しいです!
クライアント編: