2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Azure Durable Functions で遊んでみた Part2 - チャット風クイズアプリを作る(バックエンド編)

Last updated at Posted at 2023-04-19

はじめに

以前、Durable Functionsで遊んでみる記事を書いたのですが、その第2弾ということで、前回使っていなかったタイマー処理や外部イベント、永続的オーケストレーションを使って、チャット風クイズアプリを作ってみたいと思います。
前回に引き続き node で作成します。

前回の投稿:

この記事ではバックエンド部分のみを扱っています。
クライアント部分についてはクライアント編にて記載しています。

クライアント編:

ざっくりの仕様

  • クイズのお題を入力してクイズ開始すると、サーバーでクイズを作成しクライアントに送信する
    • クイズは選択式問題にする
  • クライアントから回答が送信されたら、答え合わせしてクライアントに解答を送信する
  • 回答の制限時間を設け、制限時間が過ぎたら回答を打ち切ってクライアントに解答を送信する
  • 答え合わせの後、新たなクイズを作成しクライアントに送信する
  • 成績の集計や表示はしない

わざわざDurable Functions にしてサーバー側で制御せずとも、クライアント側で制御した方が簡単そうですが、Durable Functionsを使うのが目的なので、サーバー側で色々やるようにします。

全体の構成

今回作ろうとしているアプリの全体構成はこんな感じです。

architecture.png

ここで 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つのアクティビティ関数の実行を制御します
  • isUserConnected
  • notifyStatus
  • talk
4 isUserConnected 関数アプリ
(Activity)
クライアントが Azure SignalR に接続しているかどうかはチェックする
5 notifyStatus 関数アプリ
(Activity)
サーバーの状態をクライアントに通知する
6 talk 関数アプリ
(Activity)
ChatGPT API を呼び出してクイズの出題依頼や、クライアントの回答に対する答え合わせを行う
7 Azure SignalR Azure SignalR サーバーからクライアントに対してメッセージを送信するための接続を保持する
8 ChatGPT API ChatGPT API talk アクティビティからのメッセージに対して返答してもらう

必要な環境

準備

Azure SignalR のリソースを作成する

<resource group><resource name> は実際の名前に置き換えてください。

bash
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 を利用するので、アクセスキーを取得しておきます。

実装!

プロジェクトの初期化

bash
func init --worker-runtime node --language typescript

必要なパッケージのインストール

bash
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設定を追加します。

local.settings.json
{
  "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
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
negotiate/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-modeServerless に設定しているので、SignalR 入力バインドの hubNameserverless に変更します。
また、接続しているクライアントを識別できるようにするため、userId を設定します。(userId はクエリパラメータとして取得)

【参考】Azure SignalR への接続シーケンス

以下の手順でクライアントから Azure SignalR に接続できるようになります。

negotiate のエンドポイントにアクセスすると以下の様なレスポンスが返ってきますが、この辺りはクライアントライブラリがハブへの接続確立までやってくれるので、意識する必要はありません。
(クライアントライブラリの使い方は クライアント編 にて!)

bash
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
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
startQuizChat/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 のエンドポイントにアクセスすると以下の様なレスポンスが返ってきます。
(レスポンスの詳細は クライアント編 にて!)

bash
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
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
orchestrateQuizChat/function.ts
{
  "bindings": [
    {
      "name": "context",
      "type": "orchestrationTrigger",
      "direction": "in"
    }
  ],
  "scriptFile": "../dist/orchestrateQuizChat/index.js"
}

クイズの出題と制限時間の制御、クライアントからの回答イベントの監視を行います。
以下の様なフローになっています。

最初にクライアントが Azure SignalR に接続しているかどうかチェックしていますが、クイズ終了操作をせずにページを閉じた場合を考慮して、接続が切れていたら終了するようにしています。

「ChatGPTの返答待ちを通知」となってる部分は、クライアント側で「入力中...」のような表示をさせるためのトリガーとなる通知です。

「A.クライアントからの回答を待機するタスク」が外部イベント、「B.回答期限 30秒 まで待機するタスク」がタイマーになります。

そして「オーケストレーションを再実行」としてループさせている部分が永続的オーケストレーションですね。
オーケストレーター関数は普通に while でループさせることも可能ですが、関数の履歴が増え続けてパフォーマンスの悪化につながるため、推奨されていないようです。
代わりに、continueAsNew 関数で再実行させることでループ処理を実現できます。

4. isUserConnected

index.ts
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
isUserConnected/function.ts
{
  "bindings": [
    {
      "name": "userId",
      "type": "activityTrigger",
      "direction": "in"
    }
  ],
  "scriptFile": "../dist/isUserConnected/index.js"
}

クライアントが Azure SignalR に接続しているかどうかチェックするための関数です。
Azure SignalR Service REST API に対してコネクションを取得するリクエストを投げています。
Typescript 向けのクライアントライブラリがなかったので、仕方なく自前で実装しました。
(アクセストークン生成の勉強になったのでヨシ!)

5. nofityStatus

index.ts
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
nofityStatus/function.ts
{
  "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
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
talk/function.ts
{
  "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 を設定します。

その他

タイプ定義や定数定義

タイプ定義

lib/types.ts
/** 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 }

定数定義

libs/constants.ts
/** 関数名 */
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 の起動

bash
npx azurite -l azurite

関数アプリの起動

bash
npm start

まとめ

Durable Functions を使用することで、タイマー処理や外部イベント処理、永続的オーケストレーションを実装することができました。
永続的オーケストレーションは停止させるまで永遠に実行され続けるので、用途によっては終了条件を決めたり、外部から終了させる仕組みを用意しておいた方が良さそうです。
(今回は、ユーザーが未接続の場合は終了させるのと、クライアントに終了ボタンを設置する予定)

また、Azure SignalR を使用してサーバーからクライアントに対してのリアルタイム通知も実装することができました。
(しかしこのサービス、あんまり使われてないんかなぁ...)

今回はバックエンドのみで、いまいち何やってるかわかりにくかったかもしれませんが、クライアント編で画面とバックエンドを繋げて動かしてみるので、良ければ併せてご覧いただけると嬉しいです!

クライアント編:

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?