かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

スマートスピーカーで時間のかかる処理を呼び出す方法と実装方法

スマートスピーカーって何処のやつも大体数秒でレスポンス返さないとタイムアウトになってしまいます。 なので、ちょっと時間のかかる外部 API を呼び出したらタイムアウトしてしまいスマートスピーカーがスキルを起動できないなどの定型文を話して終了してしまいます。

対応方法としては、ぱっと思いつく方法としては以下の方法があります。

  • 起動時やデータが必要になる前のタイミングで先読みしておいてデータを取得するときはキャッシュデーターから取得する
  • データ取得のトリガーとなるやりとりではデータ取得のキックだけして、後で「〇〇と聞いてね」と返してやりすごす。ユーザーが「〇〇」と聞いたタイミングで時間のかかる処理のステータスを確認して終わってたら結果を返す

こういうのを実装しようとするとメッセージキューを用意するのと、データのキャッシュを保持するための外部ストレージが必要になります。自分で用意するとなると大変そう…。

Microsoft Azure で実装してみよう

こういう時間のかかる処理をいい感じに処理してくれるものとして Azure Functions の Durable Functions という機能があります。

docs.microsoft.com

これを使うと複数の処理が協調作業してうんぬん…というのは公式ドキュメントに書いてあるので、今回の用途に絞って言うとスマートスピーカーからの webhook をきっかけとして時間のかかる処理をトリガーして実行状態の管理と結果の取得処理とかをフレームワーク側で面倒見てくれるものが手に入ります。

しかも、それを直感的な C# のコード(JavaScript も対応してますが今回は C# でいきます)で書けます。マジで魔法のようです。

作るもの

Clova で行こうとおもいます!作るものは Clova のスキル起動時に20秒くらい時間のかかるダミー処理を実行して次のインテントが来たタイミングでステータスチェックを行い完了していたら結果を返す。そうじゃなければ時間がかかるので少し待って聞いてねっていう結果を返すような感じで行こうと思います。

インテント

なので、作るインテントは以下の 1 つかな。

  • GetResultIntent
    • 「結果を教えて」「進捗どうですか」に対応するようにしておく

関数

作る関数は以下の 3 つです。

  • Lab 関数
    • Clova の Extension サーバーの URL に設定する関数
  • LongTimeOrchestrationFunction 関数
    • Clova の Lab 関数から起動される長い処理の面倒を見るための関数
  • LongTimeActivityFunction 関数
    • LongTimeOrchestrationFunction 関数から呼ばれる実際に長い処理をやる関数

ではさくっと作っていきましょう。今回は ClovaLab という名前のプロジェクトで作ることにしました。以下のようなコマンドで下準備をします。(# の後に書いてあることは補足で、実際には打ちません)

mkdir ClovaLab
cd ClovaLab
func init   # dotnet を選択する
func new  # HttpTrigger を選択して名前は Lab にする
func extensions install -p Microsoft.Azure.WebJobs.Extensions.DurableTask -v 1.6.0 # Durable Functions をインストール

Lab.cs が作成されているので以下のように編集します。

using System.IO;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.Azure.WebJobs.Host;
using Newtonsoft.Json;
using Microsoft.Extensions.Logging;
using CEK.CSharp;
using System.Threading.Tasks;
using CEK.CSharp.Models;
using System;

namespace ClovaLab
{
    public static class LabFunctions
    {
        [FunctionName(nameof(Lab))]
        public static async Task<IActionResult> Lab(
            [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)]HttpRequest req,
            [OrchestrationClient] DurableOrchestrationClient client,
            ExecutionContext context,
            ILogger log)
        {
            var clovaClient = new ClovaClient();
            var cekRequest = await clovaClient.GetRequest(
                req.Headers["SignatureCEK"],
                req.Body);

            var cekResponse = new CEKResponse();
            switch (cekRequest.Request.Type)
            {
                case RequestType.LaunchRequest:
                    {
                        // UserId をインスタンス ID として新しい関数を実行
                        await client.StartNewAsync(nameof(LongTimeOrchestrationFunction), cekRequest.Session.User.UserId, null);
                        cekResponse.AddText("時間のかかる処理を実行しました。結果を教えてと聞くと結果を答えます。");
                        cekResponse.ShouldEndSession = false;
                        break;
                    }
                case RequestType.IntentRequest:
                    {
                        switch (cekRequest.Request.Intent.Name)
                        {
                            case "GetResultIntent":
                                // インスタンス ID が UserId の状態を取得
                                var status = await client.GetStatusAsync(cekRequest.Session.User.UserId);
                                // 状態に応じて応答を設定
                                switch (status.RuntimeStatus)
                                {
                                    case OrchestrationRuntimeStatus.Canceled:
                                        cekResponse.AddText("キャンセルされてます");
                                        break;
                                    case OrchestrationRuntimeStatus.Completed:
                                        cekResponse.AddText($"終わりました。結果は{status.Output.ToObject<string>()}です。");
                                        break;
                                    case OrchestrationRuntimeStatus.ContinuedAsNew:
                                        cekResponse.AddText("やり直してます。もうちょっと待ってね。進捗どうですかと聞いてください。");
                                        cekResponse.ShouldEndSession = false;
                                        break;
                                    case OrchestrationRuntimeStatus.Failed:
                                        cekResponse.AddText("失敗しました。");
                                        break;
                                    case OrchestrationRuntimeStatus.Pending:
                                        cekResponse.AddText("もうちょっと待ってね。進捗どうですかと聞いてください。");
                                        cekResponse.ShouldEndSession = false;
                                        break;
                                    case OrchestrationRuntimeStatus.Running:
                                        cekResponse.AddText("もうちょっと待ってね。進捗どうですかと聞いてください。");
                                        cekResponse.ShouldEndSession = false;
                                        break;
                                    case OrchestrationRuntimeStatus.Terminated:
                                        cekResponse.AddText("失敗しました。");
                                        break;
                                }
                                break;
                            default:
                                cekResponse.AddText("すみません。よくわかりませんでした。");
                                break;
                        }
                        break;
                    }
                case RequestType.SessionEndedRequest:
                    {
                        // 途中で終了されたら終わらせておく
                        await client.TerminateAsync(cekRequest.Session.User.UserId, "User canceled");
                        break;
                    }
            }

            return new OkObjectResult(cekResponse);
        }

        [FunctionName(nameof(LongTimeOrchestrationFunction))]
        public static async Task<string> LongTimeOrchestrationFunction(
            [OrchestrationTrigger] DurableOrchestrationContext context)
        {
            // 本当はここで複数個の CallActivityAsync を呼んだりできるけど、ここでは 1 個だけ
            return await context.CallActivityAsync<string>(nameof(LongTimeActivityFunction), null);
        }

        [FunctionName(nameof(LongTimeActivityFunction))]
        public static async Task<string> LongTimeActivityFunction(
            [ActivityTrigger] DurableActivityContext context)
        {
            // 20 秒待って結果を返すだけ。
            // 本当は、外部 API をここで呼び出したりする
            await Task.Delay(20000);
            return DateTimeOffset.UtcNow.ToString("UTCではyyyy年MM月dd日HH時mm分ss秒");
        }
    }
}

では、さくっと実行してみましょう。配備後最初の要求は、リクエスト検証用の証明書を取得する処理とかコールドスタートになるのでタイムアウトで失敗することがほとんどだと思います。

デプロイして実機で動かしてみました。ちゃんと動いてますね。

youtu.be

まとめ

今回はスキルの起動時に時間のかかる処理を起動してセッションが終わるタイミングで、終了してなかったらキャンセルするようにしました。 でも、Durable Functions は数日間にかかって実行されて、さらには人のワークフローが介在するような処理についても表現可能なフレームワークです。なので音声アシスタントの 1 セッションだけで終了するような処理だけではなく、数日にわたって何かしら処理が進んでいって途中経過を報告するようなものも実行可能です。

しかも、それを作るために特別なデータベースやメッセージキューを自前で用意する必要はありません。Azure Functions を作成したときに自動的に作成されるストレージアカウントに対して Durable Functions が裏でそこらへんの処理は隠蔽してやってくれます。そんな複雑なことをしてくれているのに、Durable Functions を使ったプログラムはただの Durable Functions の API を使って外部関数を呼ぶようなノリの処理を書くだけで OK です。

Google Could Function や AWS Lambda をメインで使いつつ時間のかかるワークフローは Azure Functions の Durable Functions で記載することも可能なので、こういった処理を作りたかったら是非検討してみてね!

気を付けるところとかもついでに

いいことばかり書いてきましたが以下の点に注意する必要があります。

  • コンサンプションプラン(実行しただけ課金のプラン)の場合には ActivityTrigger で実行された関数の最長実行時間は 10 分(デフォルト 5 分)です。
  • OrchestrationTrigger で実行された関数は、裏で現在の実行状況と照らしあわされながら何回も実行される処理になります。そのため DateTime.Now のように実行時間依存するような処理や、自前でスレッドを立てるような処理は書けません。詳細は以下のドキュメントにまとまっています。 docs.microsoft.com