himanago

Azure・C#などのMS系技術やLINE関連技術など、好きな技術について書くブログ

Durable Functions を使った Clova スキルを JavaScript で開発する方法

はじめに

Durable Functions は Azure Functions の拡張機能で、持続性のあるステートフルな処理をサーバーレスの環境で実現するものです。
普段は C# で開発しているので使ったことはなかったのですが、Durable Functions は JavaScript でも使えます。

docs.microsoft.com

今回は C# で作っていた Durable Functions を使ったサンプルスキルを JavaScript に移植してみました。
単純にいくかなーと思いきや、ちょっと大変だったのでどう実装したか残しておきます。

移植元の C# 製サンプルスキル

Clova スキル開発用 C# SDK のサンプルとして公開しているものです。

clova-extensions-kit-csharp/clova-extensions-kit-csharp-azure-functions/Durable at master · line-developer-community/clova-extensions-kit-csharp · GitHub

動きとしてはシンプルなもので、起動すると「いくつ数えますか?」と聞いてくるので、ユーザーは秒数を指定。
すると裏で関数オーケストレーションが開始し、指定秒数のカウントを行うアクティビティ関数が実行され、スキルが終了。
再度スキルを起動したとき、オーケストレーターの状態を確認し、カウントが終わっていなければ「まだ数え終わっていません」と言ってくれます。
このような、スキルを終了しても裏でステートフルな処理が動き続けていて、その状態を後からも確認できる…というサンプルです。

JavaScript への移植

先にコード全体を載せます。

Starter(HTTPトリガーの Clova スキルエンドポイント)

const df = require("durable-functions");
const clova = require('@line/clova-cek-sdk-nodejs');
const createHandler = require("azure-function-express").createHandler;
const express = require("express");

// Durable Client 用の変数
let durableClient;

const clovaClient = clova.Client
    .configureSkill()
    .onLaunchRequest(async responseHelper => {
        const status = await durableClient.getStatus(responseHelper.getUser().userId);

        if (status.runtimeStatus === 'ContinuedAsNew' ||
            status.runtimeStatus === 'Pending' ||
            status.runtimeStatus === 'Running')
        {
            responseHelper.setSimpleSpeech(
                clova.SpeechBuilder.createSpeechText('まだ数え終わっていません。')).endSession();
        }
        else
        {
            responseHelper.setSimpleSpeech(
                clova.SpeechBuilder.createSpeechText('いくつ数えますか?'));
        }
    })
    .onIntentRequest(async responseHelper => {
        const intent = responseHelper.getIntentName();

        switch (intent) {
            case 'CountIntent':
                const count = responseHelper.getSlot('count');

                // オーケストレーターを起動
                await durableClient.startNew('CountOrchestrator', responseHelper.getUser().userId, count);
            
                responseHelper.setSimpleSpeech(
                    clova.SpeechBuilder.createSpeechText('数え始めました。しばらくお待ちください。')
                ).endSession();
                break;

            case 'Clova.YesIntent':
                responseHelper.setSimpleSpeech(
                    clova.SpeechBuilder.createSpeechText('はいはい')
                );
                break;
            case 'Clova.NoIntent':
                responseHelper.setSimpleSpeech(
                    clova.SpeechBuilder.createSpeechText('いえいえ')
                );
                break;

            default:
                break;
        }
    })
    .onSessionEndedRequest(responseHelper => {
        // Do something on session end
    });

const clovaMiddleware = clova.Middleware({ applicationId: "YOUR_APPLICATION_ID" });

const app = express();

app.post('/api/ClovaEndpoint', 
    (req, res, next) => { req.body = req.rawBody; next(); },
    clovaMiddleware,
    (req, res, next) => {
        (async () => {
            // Durable Client をセット
            durableClient = df.getClient(req.context);

            // https://github.com/line/clova-cek-sdk-nodejs/blob/master/src/client.ts
            // の handle() で行っている処理
            const ctx = new clova.Context(req.body);
            const requestType = ctx.requestObject.request.type;
            const requestHandler = clovaClient.config.requestHandlers[requestType];
            await requestHandler.call(ctx,ctx);
            res.json(ctx.responseObject);
            
        })().catch(next);
    }
);

module.exports = createHandler(app);

オーケストレーター関数

アクティビティ関数を単純に呼ぶだけのもの。

const df = require("durable-functions");

module.exports = df.orchestrator(function* (context) {
    const count = context.df.getInput();
    yield context.df.callActivity('CountActivity', count);
});

アクティビティ関数

ちょっと無理やりですが指定秒数カウントする関数。カウント(=今回やりたいこと)の処理です。

module.exports = async function (context, count) {
    await (sec => new Promise(resolve => setTimeout(resolve, sec * 1000)))(count);
};

実装のポイント

公式 SDK + azure-function-express の注意点

JavaScript で Clova スキルを実装する場合は、公式の Node.js 用 SDK を使用します。

github.com

この SDK は Express に依存しているので、Azure Functions で使うためには azure-function-express というモジュールを使います。

その際、app.post の書き方に一工夫必要で、第2引数には以下のように

(req, res, next) => { req.body = req.rawBody; next(); },

と書き、Azure Functions によるリクエストボディのパースを打ち消す必要があります。1

Durable Client をハンドラー内に変数経由で引き渡す

ここがちょっと苦戦した点です。

公式SDKリポジトリ にあるサンプルなどでは

const clovaSkillHandler = clova.Client
  .configureSkill()
  .onLaunchRequest(responseHelper => {
    // (略)
  })
  .onIntentRequest(async responseHelper => {
    // (略)
  })
  .onSessionEndedRequest(responseHelper => {
    // (略)
  })
  .handle();

のように handle() して得られたハンドラーを

app.post('/clova', clovaMiddleware, clovaSkillHandler);

app.post に渡して動かしますが、これでは Durable Functions の機能が利用できません。

というのも、Durable Functions の機能を利用するためには df.getClient(req.context) といったかたちでリクエストオブジェクトをもとにして Durable Client を得る必要があるところ、Clova SDK + azure-function-express で作ると Durable Client を使いたくなる箇所である onIntentRequest などから req が参照できないからです。

そこで、(ちょっと無理やりなのですが)以下のようにローカル変数経由で Durable Functions 機能を利用できるようにしてみました。

ローカル変数 durableClient を宣言

まず最初に変数を宣言しておきます。

// Durable Client 用の変数
let durableClient;

SDK が用意した handle() を使わない

サンプルのように handle() まで行わず各種ハンドラーの定義までで止め、定数 clovaClient にセットします。

const clovaClient = clova.Client
  .configureSkill()
  .onLaunchRequest(responseHelper => {
    // (略)
  })
  .onIntentRequest(async responseHelper => {
    // (略)
  })
  .onSessionEndedRequest(responseHelper => {
    // (略)
  });

handle() の代替となる関数を自分で用意

app.post のハンドラーを渡す部分を独自の関数に置き換え、この中でローカル変数 durableClient にリクエストから取得した Durable Client をセットします。
それ以外は SDKのhandle() で行っている処理を簡略化して書くだけです。

app.post('/api/ClovaEndpoint', 
    (req, res, next) => { req.body = req.rawBody; next(); },
    clovaMiddleware,
    (req, res, next) => {
        (async () => {
            // Durable Client をセット
            durableClient = df.getClient(req.context);

            // https://github.com/line/clova-cek-sdk-nodejs/blob/master/src/client.ts
            // の handle() で行っている処理
            const ctx = new clova.Context(req.body);
            const requestType = ctx.requestObject.request.type;
            const requestHandler = clovaClient.config.requestHandlers[requestType];
            await requestHandler.call(ctx,ctx);
            res.json(ctx.responseObject);
            
        })().catch(next);
    }
);

このようにして、Azure Functions にデプロイして Starter の URL を Clova スキルに設定すると、ちゃんと動いてくれます。

まとめ

公式 SDK そのままでは Durable Functions が使えなかったのですが、SDK を読み解きつつ必要なものを引き渡していったらなんとか動かせました。せっかく JavaScript でも Durable Functions が使えるので、いろんな場面で使われてほしいな、と思います。

変数経由の受け渡しが無理やり感あるので、もうちょっといいやり方があればいいなとは思いますが、SDK の構造上しょうがないのかな…。req.rawBody で上書きしたりする必要がある点も含め、若干の使いづらさがあるので、Azure Functions で使いやすくなるように公式 SDK に PR 出してもいいのかも…。2


  1. いつも安定のかずきさんの記事を参考にしました。https://blog.okazuki.jp/entry/2018/09/09/183404

  2. 公式SDKには handle() の専用版として lambda()firebase() があるので azureFunction() があってもいいかなとか思ったり。どれだけ需要があるのかはわかりませんが。