himanago

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

Azure Blob Storage の SAS トークンを使って LINE Bot でセキュアに画像を送る

はじめに

Messaging API では、画像や動画の送信などの際、それらが置かれたパブリックな URL を指定することで実現します。

Azure を用いて LINE Bot を開発する場合は、画像等のリソースをパブリックアクセスレベルを「コンテナー」にした Blob Storage コンテナーに置くだけでパブリックな URL が得られて便利ですが、グローバルに公開してしまうことはセキュリティ上の懸念につながります。

そこで、少しでもセキュアに Blob Storage から Messaging API へのリソース連携を行うため、SAS トークンを使ったアクセス制限をかけてみます。

SAS トークンとは

SAS とは、Shared Access Signatures の略で、Azure Storage のリソースへのアクセス制限をかける機能です。

docs.microsoft.com

これを使うと、URL に付与して署名されているかどうかを検証できる「SAS トークン」が生成できます。SAS トークンつきの URL であれば、Blob コンテナーのパブリックアクセスレベルが匿名アクセスのできない「プライベート」になっていてもアクセスできるようになります。

では、LINE Bot のバックエンドで SAS トークンを生成することで、 Messaging API での画像送信を少しセキュアにしてみましょう。

実装してみる

バックエンドのひながた

Bot のバックエンドは Azure Functions で作り、言語は C# でいくことにします。Bot 用プロジェクトのひながたはこちらの記事参照。

LINE Bot を Azure Functions (C#) で作る際のオウム返しテンプレ - himanago

今回はシンプルに、何かしらメッセージを送ると Blob Storage に置いてある特定の画像を返信する、という Bot にします。

サービス SAS を使用する

SAS にはいくつか種類がありますが、今回使うのは「サービス SAS」です。

.NET でサービス SAS 使う方法が書いてあるドキュメントはこちら。

docs.microsoft.com

BLOB 用のサービス SAS を作成

上記のドキュメントの「Create a service SAS for a blob」の部分を参考に、実装をします。

「Azure.Storage.Blobs」パッケージ(v12)を追加し、以下のようにコードを追加します。

Configuration/LineBotSettings.cs

namespace AzureSecuredImageLineBotSample.Configurations
{
    public class LineBotSettings
    {
        public string ChannelSecret { get; set; }
        public string ChannelAccessToken { get; set; }
        public string StorageConnectionString { get; set; }
        public string BlobContainerName { get; set; }
        public string StorageAccountKey => StorageConnectionString.Split("AccountKey=")[1].Split(";")[0];
    }
}

設定は面倒なので同じファイルにまとめてしまいます…。

実際の設定値はポータルのアプリケーション設定から、名前を LineBotSettings:ChannelAccessToken などの形式にして設定しておきましょう(4 つ)。

Startup.cs

using Azure.Storage.Blobs;
using AzureSecuredImageLineBotSample.Configurations;
using LineDC.Messaging;
using LineDC.Messaging.Webhooks;
using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

[assembly: FunctionsStartup(typeof(AzureSecuredImageLineBotSample.Startup))]
namespace AzureSecuredImageLineBotSample
{
    public class Startup : FunctionsStartup
    {
        public override void Configure(IFunctionsHostBuilder builder)
        {
            var config = new ConfigurationBuilder()
                .AddJsonFile("local.settings.json", true)
                .AddEnvironmentVariables()
                .Build();

            var settings = config.GetSection(nameof(LineBotSettings)).Get<LineBotSettings>();

            // Blob Storage 用に追加
            var blobServiceClient = new BlobServiceClient(settings.StorageConnectionString);
            var blobContainerClient = blobServiceClient.GetBlobContainerClient(settings.BlobContainerName);
            builder.Services.AddSingleton(blobContainerClient);

            builder.Services
                .AddSingleton(settings)
                .AddSingleton<ILineMessagingClient>(_ => LineMessagingClient.Create(settings.ChannelAccessToken))
                .AddSingleton<IWebhookApplication, LineBotApp>();
        }
    }
}

LineBotApp.cs

using Azure.Storage;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Specialized;
using Azure.Storage.Sas;
using AzureSecuredImageLineBotSample.Configurations;
using LineDC.Messaging;
using LineDC.Messaging.Messages;
using LineDC.Messaging.Webhooks;
using LineDC.Messaging.Webhooks.Events;
using Microsoft.Azure.WebJobs.Logging;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace AzureSecuredImageLineBotSample
{
    public class LineBotApp : WebhookApplication
    {
        private ILogger Logger { get; }
        private BlobContainerClient BlobContainerClient { get; }
        private LineBotSettings Settings { get; }

        public LineBotApp(ILineMessagingClient lineMessagingClient, LineBotSettings settings, ILoggerFactory loggerFactory,
            BlobContainerClient blobContainerClient)
            : base(lineMessagingClient, settings.ChannelSecret)
        {
            Logger = loggerFactory.CreateLogger(LogCategories.CreateFunctionUserCategory(nameof(WebhookEndpoint)));
            BlobContainerClient = blobContainerClient;
            Settings = settings;
        }

        protected override async Task OnMessageAsync(MessageEvent ev)
        {
            var key = new StorageSharedKeyCredential(BlobContainerClient.AccountName, Settings.StorageAccountKey);
            var originalContentUrl = GetBlobSasUri("original.png", key);
            var previewImageUrl = GetBlobSasUri("preview.png", key);

            Logger?.LogTrace($"OnMessageAsync => Type: {ev.Source.Type}, Id: {ev.Source.Id}");
            await Client.ReplyMessageAsync(ev.ReplyToken, new List<ISendMessage>
            {
                new ImageMessage(originalContentUrl, previewImageUrl)
            });
        }

        private string GetBlobSasUri(string blobName, StorageSharedKeyCredential key)
        {
            // Create a SAS token
            var sasBuilder = new BlobSasBuilder
            {
                BlobContainerName = BlobContainerClient.Name,
                BlobName = blobName,
                Resource = "b",
            };

            sasBuilder.StartsOn = DateTimeOffset.UtcNow.AddMinutes(-15);
            sasBuilder.ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(10);
            sasBuilder.SetPermissions(BlobContainerSasPermissions.Read);

            // Use the key to get the SAS token.
            var sasToken = sasBuilder.ToSasQueryParameters(key).ToString();

            return $"{BlobContainerClient.GetBlockBlobClient(blobName).Uri}?{sasToken}";
        }
    }
}

SAS トークンを含む URL は GetBlobSasUri で作成します。

ここでは、SAS トークンは有効期限を 10 分としています(開始は時刻ずれを考慮して 15 分前を設定)。

ただし、LINE Bot の画像には、送ったメッセージをユーザーが見たときにはじめてリクエストが飛ぶので、あまり短すぎるとユーザーが見る前に期限が切れてしまいます。なので、かなり遠い未来の日付を指定してあげるとよいです。1

期限が長くてもリソースごと・メッセージ送信ごとに個別のトークンが生成されるので、正しいトークンを持たないユーザーはアクセスできませんし、URL を推測して他の Blob へアクセスすることもできなくなります。

まとめ

LINE Bot で画像や動画などを扱うことは多いですが、セキュアに運用するために SAS 機能は手軽に導入でき、便利です。

Blob Storage に Bot のリソースを置く場合、特にインターネットにさらしたくないファイルがそこにある場合には、導入していくべきだと思います。


  1. 残念ながら無期限にはできないようです。

Azure Communication Services のサンプルアプリで LINE Bot とやりとりできるようにしてみた

はじめに

最近発表されて注目されている Azure Communication Services。

これは昨今の情勢でかなり使われるようになった Microsoft Teams の裏側のしくみを、Azure 上で提供してくれるサービスで、これを使うと Teams のようにチャットや Web 会議ができるシステムを独自に構築できます。

ドキュメントもわかりやすくまとまっています。

docs.microsoft.com

その中で、手軽に試せるサンプルアプリが 2 種用意されています。今回はそのうち「グループチャット」ができるサンプルアプリ(ASP.NET Core + React)を題材に、LINE Bot とやりとりができるよう機能を拡張してみます。

docs.microsoft.com

なおこのサンプル、

  • Azure Communication Services のリソースを作成
  • ソースコードを手元に clone し接続文字列を設定ファイルに追記
  • Azure に Web App として発行

だけで簡単に使えるので、そのままでも Azure Communication Services のチャット機能を体験するのにとてもおすすめです。

Azure Communication Services × LINE Bot

機能拡張の概要

Azure Communication Services の「グループチャット」サンプルアプリに、LINE Bot と 1 対多で会話できる機能を追加したサンプルアプリです。元のサンプルと同様 Azure に発行して動かすことができます。

Bot の友だち追加時に新たな会話スレッドが作成され、Web アプリのトップページでリンクが表示されるので、そこからスレッド画面に遷移すると、LINE と会話できます。

問合せ対応などのシナリオで、ユーザーが Bot を介してオペレーター数名と直接やりとりをする…というような場面を想定しています。

LINE Bot 連携機能を追加したコードのリポジトリ

コードは以下のリポジトリにあります。オリジナルのリポジトリを Fork したものにいろいろ追加してます。 github.com

スクリーンショット

サンプルアプリの Web アプリから参加した複数人と、LINE Bot のユーザーと対話できます。

f:id:himanago:20201106100042p:plain

f:id:himanago:20201106101030j:plain

変更内容

コードの変更点はコミットログを見ていただければわかるようになっていますが、いくつか重要な部分を載せておきます。

LINE Bot の Webhook エンドポイント

コントローラーを追加しています。

using LineDC.Messaging.Webhooks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using System;
using System.IO;
using System.Threading.Tasks;

namespace Chat.Controllers
{
    [Route("line/webhook")]
    [ApiController]
    public class LineBotWebhookController : ControllerBase
    {
        private IWebhookApplication App { get; }
        private ILogger Logger { get; }

        public LineBotWebhookController(IWebhookApplication app, ILogger<LineBotWebhookController> logger)
        {
            App = app;
            Logger = logger;
        }

        [HttpPost]
        public async Task<IActionResult> Post()
        {
            using var reader = new StreamReader(Request.Body);
            var body = await reader.ReadToEndAsync();
            var xLineSignature = Request.Headers["x-line-signature"];
            try
            {
                Logger?.LogTrace($"RequestBody: {body}");
                await App.RunAsync(xLineSignature, body);
            }
            catch (Exception ex)
            {
                Logger?.LogError(ex.Message);
            }
            return Ok();
        }
    }
}

Webhook のロジック部分

Messaging API C# SDKWebhookApplication を継承した LineBotApp で動きを定義していますが、ここでは友だち追加時の処理で新規スレッドを作るようにしていて、同時に LINE のユーザーID と Azure Communication Services 側で振られたユーザーID 等との関連を Store (サンプルアプリのデータ保存場所)に保存します。

Bot にテキストメッセージを送ったときの処理では、共通の Store に保存されている情報を覗いて、Azure Communication Services へのチャットメッセージ送信を行っています。

using Azure.Communication;
using Azure.Communication.Chat;
using Azure.Communication.Identity;
using LineDC.Messaging;
using LineDC.Messaging.Webhooks;
using LineDC.Messaging.Webhooks.Events;
using LineDC.Messaging.Webhooks.Messages;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System;
using System.Linq;
using System.Threading.Tasks;

namespace Chat.LineBot
{
    public class LineBotApp : WebhookApplication
    {
        private IUserTokenManager UserTokenManager { get; }
        private IChatAdminThreadStore Store { get; }
        private string ChatGatewayUrl { get; }
        private string ResourceConnectionString { get; }
        private ILogger Logger { get; }

        public LineBotApp(ILineMessagingClient client, LineBotSettings settings, ILogger<LineBotApp> logger,
            IChatAdminThreadStore store, IUserTokenManager userTokenManager, IConfiguration chatConfiguration)
            : base(client, settings.ChannelSecret)
        {
            Store = store;
            UserTokenManager = userTokenManager;
            ChatGatewayUrl = Utils.ExtractApiChatGatewayUrl(chatConfiguration["ResourceConnectionString"]);
            ResourceConnectionString = chatConfiguration["ResourceConnectionString"];
            Logger = logger;
        }

        protected override async Task OnFollowAsync(FollowEvent ev)
        {
            Logger?.LogInformation("OnFollowAsync");

            // 参加済みでなければ友だち追加時に新規スレッドを作成
            if (Store.LineUserIdentityStore.ContainsValue(ev.Source.UserId))
            {
                return;
            }

            var (userMri, token, expiresIn) = await UserTokenManager.GenerateTokenAsync(ResourceConnectionString);
            var moderator = new ContosoChatTokenModel
            {
                identity = userMri,
                token = token,
                expiresIn = expiresIn
            };

            var userCredential = new CommunicationUserCredential(moderator.token);
            var chatClient = new ChatClient(new Uri(ChatGatewayUrl), userCredential);

            // プロフィール取得
            var profile = await Client.GetUserProfileAsync(ev.Source.UserId);

            // LINEの表示名でユーザーを作成
            var chatThreadMember = new ChatThreadMember(new CommunicationUser(moderator.identity))
            {
                DisplayName = profile.DisplayName
            };

            var chatThreadClient = await chatClient.CreateChatThreadAsync(
                topic: $"{profile.DisplayName}さん", members: new[] { chatThreadMember });
            Store.Store.Add(chatThreadClient.Id, moderator);

            Logger.LogInformation($"Thread ID: {chatThreadClient.Id}");
            Logger.LogInformation($"Moderator ID: {moderator.identity}");

            // スレッドIDとLINEユーザーIDのペアを保存
            Store.LineUserIdentityStore.Add(chatThreadClient.Id, ev.Source.UserId);

            Store.UseConfigStore[moderator.identity] = new ContosoUserConfigModel { Emoji = " 👑 " };
        }

        protected override async Task OnMessageAsync(MessageEvent ev)
        {
            switch (ev.Message)
            {
                case TextEventMessage textMessage:
                    // 参加しているスレッドにメッセージを送信
                    if (Store.LineUserIdentityStore.Any(pair => pair.Value == ev.Source.UserId))
                    {
                        // スレッド・ユーザー情報を取得
                        var threadId = Store.LineUserIdentityStore.First(pair => pair.Value == ev.Source.UserId).Key;
                        var moderator = Store.Store[threadId];

                        // メッセージ送信                        
                        var userCredential = new CommunicationUserCredential(moderator.token);
                        var chatClient = new ChatClient(new Uri(ChatGatewayUrl), userCredential);
                        var chatThread = chatClient.GetChatThread(threadId);
                        var chatThreadClient = chatClient.GetChatThreadClient(threadId);
                        await chatThreadClient.SendMessageAsync(textMessage.Text);
                    }
                    break;

                case MediaEventMessage mediaMessage:
                case FileEventMessage fileMessage:
                case LocationEventMessage locationMessage:
                case StickerEventMessage stickerMessage:
                default:
                    await Client.ReplyMessageAsync(ev.ReplyToken, "そのメッセージ形式は対応していません。");
                    break;
            }
        }
    }
}

LINE へのメッセージ送信(クライアントサイド)

React を触ったのが初めてだったので、悩みながらでしたが…

sideEffect.tssendMessage 内に処理を追加しています。

const sendMessage = (messageContent: string) => async (dispatch: Dispatch, getState: () => State) => {
  let state: State = getState();
  let chatClient = state.contosoClient.chatClient;
  if (chatClient === undefined) {
    console.error('Chat Client not created yet');
    return;
  }
  let threadId = state.thread.threadId;
  if (threadId === undefined) {
    console.error('Thread Id not created yet');
    return;
  }
  let displayName = state.contosoClient.user.displayName;
  let userId = state.contosoClient.user.identity;

  let clientMessageId = (Math.floor(Math.random() * MAXIMUM_INT64) + 1).toString(); //generate a random unsigned Int64 number
  let newMessage = {
    content: messageContent,
    clientMessageId: clientMessageId,
    sender: { communicationUserId: userId },
    senderDisplayName: displayName,
    threadId: threadId,
    createdOn: undefined
  };
  let messages = getState().chat.messages;
  messages.push(newMessage);
  dispatch(setMessages(messages));
  await sendMessageHelper(
    await chatClient.getChatThreadClient(threadId),
    threadId,
    messageContent,
    displayName,
    clientMessageId,
    dispatch,
    0,
    getState
  );
  // LINE送信(以下追加)
  await fetch('/line/pushMessage', {
    method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        threadId: threadId,
        userId: state.contosoClient.user.identity,
        request: {
          content: messageContent,
          senderDisplayName: displayName
        }
      })
    });
};

LINE へのメッセージ送信(サーバー処理)

コントローラーを追加しています。メッセージ送信時にクライアントから問答無用で送られてくるので、そのスレッドに LINE Bot のユーザーが参加していれば Push するようになっています。

また、前回の記事でも紹介した Messaging API のアイコン・表示名変更を使い、Web アプリ側からの参加者のアイコン・表示名を変えてます。

Messaging APIリファレンス | LINE Developers

ただし、Web アプリ側はアイコンを絵文字で表現しているので、LINE Bot で扱うためにはこれを画像 URL に変更しなければなりません。

そこで、ちょっと無理やりですが、絵文字画像を Twemoji のリポジトリ から取得するようにしています。

using LineDC.Messaging;
using LineDC.Messaging.Messages;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Chat.Controllers
{
    [Route("line/pushMessage")]
    [ApiController]
    public class LineBotPushMessageController : ControllerBase
    {
        private ILineMessagingClient LineMessagingClient { get; }
        private ILogger Logger { get; }
        private IChatAdminThreadStore Store { get; }

        public LineBotPushMessageController(ILineMessagingClient lineMessagingClient,
            ILogger<LineBotPushMessageController> logger, IChatAdminThreadStore store)
        {
            LineMessagingClient = lineMessagingClient;
            Logger = logger;
            Store = store;
        }

        [HttpPost]
        public async Task<IActionResult> Post()
        {
            using var reader = new StreamReader(Request.Body);
            var requestBody = await reader.ReadToEndAsync();
            dynamic data = JsonConvert.DeserializeObject(requestBody);

            string threadId = data.threadId;
            string userId = data.userId;
            string messageContent = data.request.content;
            string senderDisplayName = data.request.senderDisplayName;

            // ユーザー情報が存在すればLINEへ送信
            if (Store.LineUserIdentityStore.TryGetValue(threadId, out var lineUserId))
            {
                // 絵文字画像URLを生成する
                var emoji = Store.UseConfigStore[userId].Emoji;
                var bytes = new UTF32Encoding(true, false).GetBytes(emoji);
                var result = string.Join(string.Empty, bytes.Select(b => string.Format("{0:x2}", b)));
                var emojiCode = result.Substring(result.Length - 5, 5);
                var emojiUrl = $"https://raw.githubusercontent.com/twitter/twemoji/master/assets/72x72/{emojiCode}.png";

                await LineMessagingClient.PushMessageAsync(lineUserId, new List<ISendMessage>
                {
                    new TextMessage(messageContent, null, new Sender(senderDisplayName, emojiUrl))
                });
            }
            return Ok();
        }
    }
}

既存スレッドの取得

LINE から友だち追加した際にスレッドが追加されますが、それを Web アプリ側からも確認・遷移できるよう機能追加しました。実用面では微妙ですが、サンプルなのでこれくらいで。。

以下のメソッドを ContosoApplicationController.cs に追加し、クライアントから叩いています。

/// <summary>
/// 既存スレッド情報
/// </summary>
/// <returns></returns>
[Route("existingThreadIds")]
[HttpGet]
public List<string> GetExistingThreadIds()
{
    return _store.Store.Keys.ToList();
}

まとめ

Azure Communication Services を使うとチャットアプリが簡単に作れます。そしてそこに LINE Bot を追加のインターフェースとして組み込んでいくのも割と手軽にできました。

チャット機能を自作したあと「LINE でもやりとりしたいな」と思った際にすぐつなげることができるので、Bot 上で疑似的なグループ会話を再現するのに便利な「アイコンおよび表示名の変更」機能とともに、ぜひ試してみてください。

Micronaut for Azure Functions 試してみた(2020年版)

Micronaut now supports Microsoft Azure Functions !!

去年、こんなLTをしました。

www.slideshare.net

当時はまだ Azure Functions がサポートされておらず、Web App として動かしてみたよって話だったんですが、ついに Azure Functions に正式対応されたので、動かすところまでやってみました。

プロジェクト作成

なんと、Web 上で自分好みの雛形プロジェクトを作ってダウンロードできます。これめっちゃいいです。

micronaut.io

こんなかんじで構成を選択して、最初のプロジェクトを生成することができます。

f:id:himanago:20200803122348p:plain
Azure Functions 用に生成

Azure Functions 用の雛形の作り方は、

  • Application Type を「Serverless Function」
  • Java Version を「8」
  • +FEATURES で「azure-function」を追加

でいけます。

そのほかはお好みで。今回は Groovy 縛りで行きます(Groovy / Gradle / Spock)。

PREVIEW」を押すと中のコードも確認できます。

f:id:himanago:20200803122513p:plain
プレビュー

「GENERATE PROJECT」を押すと zip が落ちてきます。

最初のとっかかりとしては非常にわかりやすく、とてもいいですね。

開いてみる

適当なフォルダに zip の中身を解凍して開いてみます。

今回は IntelliJ IDEA Ultimate を使ってみます。フォルダを開いたら起動の設定。

右上の「Add Configuration...」から f:id:himanago:20200803194210p:plain

「Templates」→ 「Gradle」で以下のように設定し、ダイアログ右上の「Create configuration」→「OK」 f:id:himanago:20200803195142p:plain

右上の▶︎ボタンから実行できるようになったのでここをクリックでローカル実行します。 f:id:himanago:20200803194448p:plain

なお、IDE を使わない場合は、Gradle コマンドを使って実行します(雛形の README にも実行手順が書いてあります)。

ところが、このまま実行すると「Cannot package functions due to error: Azure Functions entry point not found, plugin will exit.」というエラーが出ました。

なんで??と思ったんですが、エラー内容の通りで関数のエントリーポイントがないんですね。アノテーションで関数名を指定する、あれです。

こちらのドキュメントの記載を参考に、@FunctionName('echo') をメソッドに追加します。

Micronaut Azure

さらに、String req のバインドもうまくいかないようだったので、同じくドキュメントと同じ形式に修正。

ついでに

  • ログ出力の行も Groovy っぽくなかったので修正
  • return も消しつつ(Groovy では不要)body の中身をそのまま返すよう修正

しました。

Before:

package dev.himanago;
import com.microsoft.azure.functions.annotation.*
import com.microsoft.azure.functions.*
import io.micronaut.azure.function.AzureFunction

/**
 * Azure Functions with HTTP Trigger.
 */
class Function extends AzureFunction {
    String echo(
        @HttpTrigger(name = "req", methods = HttpMethod.POST, authLevel = AuthorizationLevel.ANONYMOUS)
        String req,
        ExecutionContext context) {
        context?.getLogger()?.info("Executing Function: ${getClass().getName()}");
        return String.format(req)
    }
}

After:

package dev.himanago;
import com.microsoft.azure.functions.annotation.*
import com.microsoft.azure.functions.*
import io.micronaut.azure.function.AzureFunction

/**
 * Azure Functions with HTTP Trigger.
 */
class Function extends AzureFunction {
    @FunctionName('echo')
    String echo(
        @HttpTrigger(name = "req", methods = HttpMethod.POST, authLevel = AuthorizationLevel.ANONYMOUS)
        HttpRequestMessage<Optional<String>> request,
        ExecutionContext context) {
        context?.logger?.info "Executing Function: ${this.class.name}"
        request.body.get()
    }
}

これで無事ローカル実行できました。

f:id:himanago:20200804230751p:plain
Postmanでの実行結果

<補足追記>

ローカル環境に Azure Functions Core Tools、Gradle を入れておく必要があります。
また、JDK は 8 じゃないと正しく動かないので、java -version で 1.8 が出るようにしておく必要があります。

Azure にデプロイ

今度は Azure にデプロイして動かしてみましょう。

デプロイの設定は、build.gradle に書きます。最初から書いてあるので、これを自分の環境に合わせて書き換えれば OK です。

azurefunctions {
    resourceGroup = 'java-functions-group'
    appName = 'demo'
    pricingTier = 'Consumption'
    region = 'westus'
    runtime {
      os = 'windows'
    }
    localDebug = "transport=dt_socket,server=y,suspend=n,address=5005"
}

Terminal から az loginaz account set -s <subscription id> を実行して、gradle azureFunctionsDeploy すればデプロイできます(gradle コマンドは IDE で実行しても OK)。

Function App のリソースが Azure 上にない場合は作成してくれます。

できあがった Function App をポータルから開いてテストしてみると、ちゃんと実行できました!

f:id:himanago:20200805001827p:plain
Azureポータルでの実行結果

ちょっと苦戦しましたがとりあえずいけそうです。

今後いろいろ試してみて、よさそうなら実戦投入も視野に入れていこうかなと思います!

de:code 2020 に Microsoft MVP パーソナルスポンサーについて(Azure のハンズオンを提供しました+気になったもの)

(すっごくいまさらなのですが…)

6 月 17 日 (水) ~ 7 月 17 日 (金) で開催された日本マイクロソフト主催のオンラインイベント「de:code 2020」の MVP パーソナルスポンサーとして、ハンズオン資料の提供をさせていただきました。

スポンサー | de:code (decode) 2020 | 開発者をはじめとする IT に携わるすべてのエンジニアのためのイベント

提供したもの

今回提供させていただいたのは、「Azure サーバーレステクノロジー体験ハンズオン ~イベント駆動でつなげよう~」というタイトルで、 個人的に好きで業務でも使用している Azure のサーバーレス系サービスを組み合わせて簡易勤怠管理アプリを構築するハンズオンです。

github.com

チャットツール(LINE or Teams)から、いつからいつまで働いたかを記録でき、それが Web ページにリアルタイムで反映されます。これがあればテレワーク中の時間管理が楽になるかも?といったものです。

LINE or Teams → Functions → Cosmos DB → Functions → SignalR Service → Static Web Apps リアルタイム更新のしくみを VS Code とその拡張機能を利用して開発します。

企画段階では Web ページ部分を Blob Storage の 静的 Web アプリホストの機能で作る予定だったのですが、発表されたばかりの Static Web Apps が便利すぎたので、こっちにシフト。結果的に言語は JavaScript に統一することに。

f:id:himanago:20200725113940p:plain

イベント駆動でつながっていく様子を手軽に体験できるように…というねらいのハンズオンですが、今後もこういうハンズオンをバリエーション増やしながらどんどん作っていけたら…と思っています。

ほかのパーソナルスポンサー提供のツールについて

ほかにもたくさんのツールやサンプルコードが MVP パーソナルスポンサーから公開されています。

自分は「Instance selector for Azure App Service Kudu (Google Chrome Extension)」が特にお気に入りで、業務で活用させていただいています。

AzureKuduInstanceSelector/README_ja.md at master · pnopjp/AzureKuduInstanceSelector · GitHub

ほかにもハンズオン資料では、

Surface Duo を先取り。Dual Screen 対応アプリを作ってみよう!
GitHub - TomohiroSuzuki128/XFSurfaceDuoSample2020

Bot Framework Composer + QnA Maker で作る Q&A チャットボット ハンズオン
GitHub - seosoft/BfCompQnaBot: Bot Framework Composer + QnA Maker で作る Q&A チャットボット ハンズオン

あたりが気になっているので、近日中にやってみる予定!

まとめ

de:code 2020 の開催期間は終わりましたが、セッション資料と動画が引き続き公開されています。

www.microsoft.com

もちろん、パーソナルスポンサー提供のツール&サンプルコードも公開がなくなるわけではありません!

以下の記事にすべてまとめていただいていますので、ぜひたくさんの方に見ていただけたら…と思います。

kogelog.com

de:code 2020 の期間中に楽しみきれなかった分も、引き続き時間を見つけて活用していきたいと思います。

LINE Bot を Azure Functions (C#) で作る際のオウム返しテンプレ

概要

以前 JavaScript + Azure Functions で作る LINE Bot のテンプレについて書きましたが、C# のもなかったじゃん…ということで書いていきます。

himanago.hatenablog.com

いつも横着していたロギングの部分をちゃんと DI で書いてみたので、今後はこれをテンプレとして使っていきたいと思います(よさそうなら VS のテンプレートにもしていけたら)。

プロジェクトテンプレート

Azure Functions v3 (.NET Core) を使用します。

f:id:himanago:20200602022245p:plain

必要な NuGet パッケージ

LineDC.Messaging (v1.2.0)

Messaging APIC# SDK。最新版の機能が含まれているのはこのパッケージです。
もともとあった Line.Messaging は更新が止まり、こちらに移行しているので注意が必要です。

Microsoft.Azure.Functions.Extensions (v1.0.0)

Azure Functions で DI を使うために必要なパッケージです。

このほか、パッケージとしては Microsoft.NET.Sdk.Functions (サンプルでは v3.0.7) が入っている状態です。

作成するファイル

C# のコードで作成するのは 4 つ。

  • LineBotApp.cs
  • Startup.cs
  • WebhookEndpoint.cs
  • Configurations/LineBotSettings.cs

LineBotApp.cs

WebhookApplication を継承した、LINE Bot のメイン処理を司るクラスです。

OnMessageAsync 等のイベントハンドラーをオーバーライドして処理を定義しておくことで、Webhook 時に受け取ったメッセージに応じて呼び出してくれます。
今回は、オウム返し(Echo Bot)なのでメッセージを受け取ったらそのまま返すだけの処理が書いてあります(メッセージの種類で switch)。

もう一つのポイントはコンストラクタです。
DI によって受け取る 3 つの引数を持ち、基底クラスの WebhookApplicationILineMessagingClient とチャネルシークレット(LineBotSettings オブジェクトに含まれる)を渡しながら、自身で使用するロガーを loggerFactory.CreateLogger から生成しています(このクラスは WebhookEndpoint という関数内で使われるので、その関数名を指定しています)。

using LineBotTemplate.Configurations;
using LineDC.Messaging;
using LineDC.Messaging.Webhooks;
using LineDC.Messaging.Webhooks.Events;
using LineDC.Messaging.Webhooks.Messages;
using Microsoft.Azure.WebJobs.Logging;
using Microsoft.Extensions.Logging;
using System.Threading.Tasks;

namespace LineBotTemplate
{
    public class LineBotApp : WebhookApplication
    {
        private ILogger Logger { get; }

        public LineBotApp(ILineMessagingClient lineMessagingClient, LineBotSettings settings, ILoggerFactory loggerFactory)
            : base(lineMessagingClient, settings.ChannelSecret)
        {
            Logger = loggerFactory.CreateLogger(LogCategories.CreateFunctionUserCategory(nameof(WebhookEndpoint)));
        }

        protected override async Task OnMessageAsync(MessageEvent ev)
        {
            Logger?.LogTrace($"OnMessageAsync => Type: {ev.Source.Type}, Id: {ev.Source.Id}");
            switch (ev.Message)
            {
                case TextEventMessage textMessage:
                    await Client.ReplyMessageAsync(ev.ReplyToken, textMessage.Text);
                    break;
                case MediaEventMessage mediaMessage:
                    await Client.ReplyMessageAsync(ev.ReplyToken, $"contentProvider: {mediaMessage.ContentProvider}");
                    break;
                case FileEventMessage fileMessage:
                    await Client.ReplyMessageAsync(ev.ReplyToken, $"filename: {fileMessage.FileName}");
                    break;
                case LocationEventMessage locationMessage:
                    await Client.ReplyMessageAsync(ev.ReplyToken, $"{locationMessage.Title}({locationMessage.Latitude}, {locationMessage.Longitude})");
                    break;
                case StickerEventMessage stickerMessage:
                    await Client.ReplyMessageAsync(ev.ReplyToken, $"sticker id: {stickerMessage.PackageId}-{stickerMessage.StickerId}");
                    break;
            }
        }
    }
}

Configurations/LineBotSettings.cs

LINE Bot の設定に関するクラス。

namespace LineBotTemplate.Configurations
{
    public class LineBotSettings
    {
        public string ChannelSecret { get; set; }
        public string ChannelAccessToken { get; set; }
    }
}

LineBotSettings:ChannelSecretLineBotSettings:ChannelAccessTokenといったキー名で Function App のアプリケーション設定(Azure での実行時)や local.settings.json (ローカル実行時)に追加しておくと、次に載せる Startup.cs でこのクラスのプロパティの値として使うことができます。

f:id:himanago:20200602023813p:plain

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet",
    "LineBotSettings:ChannelAccessToken": "dummy",
    "LineBotSettings:ChannelSecret": "dummy"
  }
}

どちらの値も、LINE Developers で発行・確認できる値を入れておきます。

Startup.cs

DI コンテナへの登録を行う Startup クラスです。

最初に ConfigurationBuilder を使って local.settings.json環境変数(アプリケーション設定)から値を読み取り、LineBotSettings に入れて、そのあとに LineBotSettings のオブジェクトである settingsLineMessagingClientLineBotAppAddSingleton しています。

using LineBotTemplate.Configurations;
using LineDC.Messaging;
using LineDC.Messaging.Webhooks;
using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

[assembly: FunctionsStartup(typeof(LineBotTemplate.Startup))]
namespace LineBotTemplate
{
    public class Startup : FunctionsStartup
    {
        public override void Configure(IFunctionsHostBuilder builder)
        {
            var config = new ConfigurationBuilder()
                .AddJsonFile("local.settings.json", true)
                .AddEnvironmentVariables()
                .Build();

            var settings = config.GetSection(nameof(LineBotSettings)).Get<LineBotSettings>();

            builder.Services
                .AddSingleton(settings)
                .AddSingleton<ILineMessagingClient>(_ => LineMessagingClient.Create(settings.ChannelAccessToken))
                .AddSingleton<IWebhookApplication, LineBotApp>();
        }
    }
}

LineBotApp は Webhook 応答で使用するものなのでこのサンプルでも DI で受け取って使用しています。
ILineMessagingClient はサンプルでは直接使用していませんが、何かの関数で Push したりするのに使うために、DI で受け取れるようにしておくと便利です。

ほかに必要なサービス(HttpClient や、CosmosClient)があればここで追加します。
また、DI されるインスタンスの生成方法は、DI コンテナに登録するクラスごとに必ず確認しましょう(よく間違えるので気を付けたい)。

メソッド 内容
AddTransient インジェクションごとに生成
AddScoped リクエストごとに生成
AddSingleton 一度生成されたものを使いまわす

WebhookEndpoint.cs

Azure Functions のプロジェクトテンプレートから作成したときは Function1.cs という名前で作られますが、リネームして中身を書き換えます。

DI するので static を外してコンストラクタで IWebhookApplication を受け取るようにしておきます。Startup で設定した通り、LineBotAppインスタンスが受け取れます。

関数の中の処理としては、受け取ったメッセージをもとに RunAsync を呼ぶだけで OK です(勝手に OnMessageAsync 等は実行してくれる)。

using LineDC.Messaging.Webhooks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using System;
using System.IO;
using System.Threading.Tasks;

namespace LineBotTemplate
{
    public class WebhookEndpoint
    {
        private readonly IWebhookApplication _app;

        public WebhookEndpoint(IWebhookApplication app)
        {
            _app = app;
        }

        [FunctionName(nameof(WebhookEndpoint))]
        public async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req,
            ILogger log)
        {
            try
            {
                var body = await new StreamReader(req.Body).ReadToEndAsync();
                var xLineSignature = req.Headers["x-line-signature"];

                log.LogTrace($"RequestBody: {body}");
                await _app.RunAsync(xLineSignature, body);
            }
            catch (Exception ex)
            {
                log.LogError(ex.Message);
                log.LogError(ex.StackTrace);
            }

            return new OkResult();
        }
    }
}

まとめ

あらためて、Messaging API の新しい C# SDK 便利です。

Functions でも、DI で使うサービスを整理しておくと拡張もしやすいですね。

サンプルリポジトリ

上記のコードは以下のリポジトリに置いてあります。 github.com

Azureサーバーレス&LINE API フル活用のシステム事例紹介!というタイトルで登壇しました&しゃべりきれなかった部分を補足します

はじめに

5/28(木)に、「クラウドが得意なLINE API Expert集合!サーバレス×LINEでアプリ開発してみたLT」というイベントでお話ししました。

linedevelopercommunity.connpass.com

時間 15 分で Azure サーバーレス × LINE API のおもしろさを、実例を交えてお伝えするという内容でしたが、時間も短く話したりない部分もあったので、補足込みで記事にします。

概要

「Azureサーバーレス&LINE APIフル活用のシステム事例紹介!」というタイトルでお話ししました。

ちょっと盛りすぎなタイトルですが、実際に業務で開発しているシステムの中で Azure サーバーレステクノロジーを活かしている機能がいくつかあったので、(ちょっとデフォルメして)紹介するという内容です。

Azure のサーバーレス

Azure の中でサーバーレスというと、たくさんのサービスが該当しますが、今回はこの 3 つを中心にまとめました。

f:id:himanago:20200530030545p:plain

サーバーレスとは「サーバー管理を意識する必要がない」という意味で、イベント駆動で動き、動いた分だけの課金体系という特徴を持ちます。Azure でこの特徴を持つ代表格は Azure Functions です。

Azure Functions

Azure Functions はサーバーレスなコード実行環境で、関数コードをデプロイ(または Web のポータル上で直接記述)するとすぐにイベント駆動で動作する関数が手に入ります。

さまざまな言語に対応し、デフォルトでも C# / JavaScript (TypeScript) / F# / Java / PowerShell / Python という多くの言語がサポートされているほか、(現在プレビューですが)カスタムハンドラーという仕組みを使えば、任意の言語で開発したプログラムを裏に置いて使うことができるので、事実上どんな言語でも Azure Functions を利用することができます。

Azure Functions でサポートされている言語 | Microsoft Docs

Azure Functions のカスタム ハンドラー (プレビュー) | Microsoft Docs

そのほか、豊富なトリガーと入出力バインディングが魅力的です。

HTTPトリガーや BLOB トリガーなど、イベントに応じて起動する設定がいろいろとできます。LINE API と組み合わせて使用することの多い HTTP トリガーも、Azure Functions 単体で Webhook をさばくことができるのもお手軽で使いやすい点です。

それと同時に、DB からの読み取り(入力)や DB への書き込み(出力)などの入出力バインディングを簡単に設定することができ、関数を使ってさまざまなデータのやりとりを行うことができます。

サーバーレスなコード実行環境としての Azure Functions は、通常「従量課金プラン」と呼ばれ、使われていない間は停止しています。そこから関数を呼び出して動作が始まるまでに時間がかかってしまうというコールドスタートの問題があるため、LINE Bot のリプライなど、即返信する必要のあるものでは向かないことがあります。

これに対する対策としては、常時稼働するインスタンスを用いる「App Service プラン」や、イベントに応じたスケールに対応する従量課金プラン同様のサーバーレスらしさを持ちながらコールドスタートのない「Premium プラン」を使うこともできます。料金はそれなりにかかってしまいますが、ユースケースによっては絶大な威力を発揮します。

Azure Cosmos DB

Azure における NoSQL の DB である Cosmos DB は、惑星規模のアプリケーションにも対応する大規模向けな DB として登場しましたが、さまざまな機能追加、特に今年登場した Free Tier (無料枠)の登場で小規模サービスや個人開発でも使いやすくなりました。

API (DB の形式)も選ぶことができ、とても使いやすいサービスです(自分は SQLを使ってデータ検索できるドキュメント型の「コア (SQL)」をよく使います)。

サーバーレスへの対応も進んでいて、2020年5月に「Serverless pricing」が発表されています。

Azure Cosmos DB で任意のサイズまたはスケールのアプリを構築 | Azure のブログと更新プログラム | Microsoft Azure

Azure SignalR Service

こちらはクライアントをリアルタイム更新する WebSocket のためのサービスです。出力バインディングを使うことで Functions とも簡単に統合できます。

WebSocket は、Web ページなどの更新をクライアント側の再取得操作に頼らずに、サーバー側のアクションを起点としてクライアント側の画面を更新できる技術です。もともと ASP.NET に SignalR という WebSocket 対応の機能があり、それの Azure 版といったところでしょうか。

Functions と組み合わせて使えば、簡単にサーバーレスでリアルタイムにクライアントに更新を通知できる機能を実現できます。

Azureサーバーレスフル活用のシステム事例 with LINE API

今回紹介したのは、以下の特徴をもったシステムです。

f:id:himanago:20200530030601p:plain

  • 自動応答(自然言語処理によりユーザーの意図を読み取り、数往復の対話フローで自動で処理)
  • 手動応答(運営担当者のマニュアル返信による個別トーク

の 2 つの応答モードを持ち、それをユーザーごとに切り替えることができます。

応答モード

応答モードといえば、LINE Bot / LINE 公式アカウントの標準でもサポートされています。

f:id:himanago:20200530030627p:plain

ユーザー(Bot の友だち)とチャットをするか、Bot による自動応答をさせるか選べる機能ですが、この機能では Bot / 公式アカウント単位での 切り替えしかできず、ある一人のユーザーと「チャットモード」で会話したい、と思って切り替えると、その他のユーザーが自動返信による Bot の機能が使えなくなってしまいます。

この弱点を克服するため、今回のシステムではユーザーごとの応答モードの切り替えを実装しています。

リアルタイムチャット

まず先に、マニュアル返信による手動応答(個別トーク)の実現方法を説明します。

運営・管理側には以下のような Web 画面を用意します。

f:id:himanago:20200530030656p:plain

この画面でユーザーとチャットを行いますが、チャットなので画面はリアルタイムに更新されてほしいところ。SignalR の出番です。

さらに、組み合わせるのは Cosmos DB の Change Feed と呼ばれる機能です。この機能は、Cosmos DB へのデータの変更を検知し、それを順番に次のイベントに伝えてくれる機能です。

以下の図のように、Change Feed は Cosmos DB トリガーとして Functions を起動するイベントとしてつながり、その Functions に出力バインドされた SignalR Service がクライアントにいあるタイムで更新を通知します。

f:id:himanago:20200530030719p:plain

今回のシステムでいえば、Bot からのメッセージが Cosmos DB に格納されると、Change Feed により Functions が動き、SignalR で Web サイトをリアルタイム更新します。

f:id:himanago:20200530030731p:plain

また、Web 画面から LINE へも同じ Change Feed / Cosmos DB トリガーを共通で利用しており、起動した関数から Messaging API の Push API でメッセージを送信します(Reply API ではなく Push API なのは、返信時にはリプライトークンの有効期限が切れてしまっているため)。

f:id:himanago:20200530030744p:plain

Web → LINE では、個別の担当者からのメッセージということがわかりやすくなるよう、Messaging API の icon / nickname switch API を使い、担当者のアイコンと名前を表示するようにしています。

ニュース: アイコンおよび表示名が変更できるようになりました | LINE Developers

自動応答部分

自動応答部分は自作のAzure Functions + C# の LINE Botフレームワークを使っています。bot-expressBot Framework のように会話コンテキストを記憶したうえでの対話フローを通してユーザーの課題解決を行う機能を「スキル」という単位で追加していける仕組みを作りました。

f:id:himanago:20200530030824p:plain

Azure には Bot Framework が動作するマネージドな Bot Service というのがあるのですが、今回のシステムでは Functions ベースで組みたかったので、「スキル」単位で対話フローの処理を開発していける簡易版のフレームワークを自作した、という経緯です。

ちなみに、実装がちょっと気に食わない部分があるので、個人として OSS で作り直そうかなと思っていたりします(時期など予定は全く決まっていませんが)。

Messaging API と LIFF の連携

対話フローの中で LIFF を併用したいことは多くあると思います。

チャットボットでは、通常対話を通して Bot 側に必要な情報を与えますが、チャットのやりとりなのでたくさんの種類の情報をインプットするのは一苦労です。

その点、通常の Web 画面のフォームなら、一気に入力欄を埋めて送信するだけで済むので、チャットボットにおいてもフォーム入力の併用が求められる場面は多くなります。

LINE Bot においては、LINE 内 に埋め込むことのできる Web アプリである LIFF(LINE Front-end Framework)を組み合わせることになりますが、チャットボット部分の Messaging API と LIFF をあわせて使う際、タイミングや情報の連携ために工夫が必要なときがあります。

たとえば、Messaging API で対話をしている途中で LIFF を使った入力を挟み、同じ対話フローでその LIFF の入力内容を使った対話を続けていく、というような場面です。

ユーザーID (Messaging API と LIFF では、同一プロバイダ内であれば同じ LINE ユーザー ID が振られます)をキーにして DB に登録したりしていけばよいのですが、ここを、Durable Functions を使って簡略化するアイディアを紹介します。

Durable Functions とは、「スターター(クライアント)」「オーケストレーター」「アクティビティ」といった関数を組み合わせ、サーバーレスでありながらステートフルな処理を実現する、Azure Functions の拡張機能です。

f:id:himanago:20200530030922p:plain

今回使うのは Durable Functions で利用できる「外部イベントの待機」機能です。

オーケストレーター関数は、外部イベントを待機して処理を中断することができます(関数としては実行されたままになるわけではなく、何度も再実行されてイベントの発生を監視している)。その間に、別の関数からイベントを発生させると中断していたオーケストレーターが再開します。

これを、Messaging API と LIFF の情報連携・待ち合わせに利用します。

f:id:himanago:20200530031239p:plain

  1. ユーザーから Bot に対して話しかけ、対話フローを開始
  2. HTTPトリガーの関数が起動。オーケストレーターをLINEユーザーIDをインスタンスIDとして起動し、イベントを待機
  3. 同じHTTPトリガーの関数からユーザーへLIFFアプリへのリンクを返信
  4. ユーザーはLIFFのフォームにデータを入力して送信(別のHTTPトリガーの関数にPOST)
  5. DBへの登録処理などを行い、イベントを発生させる(LINEユーザーIDとイベント名を指定)
  6. 同一LINEユーザーIDで待機していたオーケストレーターがイベントの発生を検知し、処理が再開される
  7. 再開したオーケストレーターがアクティビティ関数を呼び、LIFFの入力を前提にした処理を実行

ポイントはオーケストレーターの「インスタンスID」で、これに LINE ユーザー ID を使うことで、Messaging API と LIFF という異なる API 間での連携を容易にしている点です。

同一ユーザーであれば、LINE API では同じユーザー ID 文字列が振られているため、Durable Functions との相性がとてもいいのです。

もちろん、Durable Functions を使わなくても Messaging API と LIFF の連携は実装はできるので、無理して Durable Functions を使う必要も実はないと思います。実運用するうえでは扱いがちょっと難しいですし、関数を再実行ありきで設計するのも難しいです(何度も再実行される関数なので、意図しない再実行による二重処理などを防ぐため、冪等性を意識した関数設計が大切です。オーケストレーターは必須で、アクティビティはできれば。)。

また、CI/CD などをする場合にも、オーケストレーション実行中のデプロイには気を配る必要があります(以下のドキュメントが参考になります)。

Durable Functions のためのゼロダウンタイムのデプロイ | Microsoft Docs

なお、Durable Functions は現時点で C# / JavaScript / F# のみの対応ですが、Python のサポートがもう少しで登場しそう…?な予感です(以下参照)。

GitHub - Azure/azure-functions-durable-python: Python library for using the Durable Functions bindings.

(追記)
でました。
Durable Functions で Python がサポート対象に | Azure の更新情報 | Microsoft Azure

ユーザーごとの応答モード

ユーザーごとに応答モードを切り替える部分にも、Durable Functions を使っています。2.0 で追加された新機能、エンティティ関数(Durable Entities)です。

f:id:himanago:20200530031429p:plain

ここでも、エンティティ ID に LINE ユーザー ID を用いることで応答モードを使いやすくしています(Messaging API からでも LIFF からでも応答モードを参照・更新できる)。

セッションでは触れられませんでしたが、エンティティ関数はただ状態を持つだけではなく、それをもとにした操作も簡単に定義できるところが魅力です。

.NET での持続エンティティに関する開発者ガイド - Azure Functions | Microsoft Docs

応答モードのような単純なフラグ値なら DB に書いても別にいいのでは?と思うかもしれませんが、その状態をもとにした操作を定義したい場合など、エンティティ関数で書くと驚くほどシンプルに済むことがあります。

先に述べたように注意点も多く使い過ぎは禁物だと思いますが、Durable Functions はコードのみでステートフルな処理を実現できる点が非常に強力です。

その他

運営・管理側のチャット画面や LIFF など、静的 Web サイトを使っている部分がありますが、Azure の場合は大きく 3 つの実現方法があります。

  • Blob Storage:静的サイトホスティング機能を利用
  • Web Apps:Node.js アプリとして配備
  • Static Web Apps:2020年5月の Build で発表された新サービス。プレビューですが GitHub から簡単に静的サイトがホストできる&Functions の統合も可能

Static Web Apps が非常に強力で、今後かなり使われるようになるのではと思っています。

Azure Static Web Apps – App service | Microsoft Azure

ちなみに、今回のシステムでは通常の Web App として動かしています。

まとめ

Azure Functionsを軸にイベント駆動でサービスが簡単につながる

Cosmos DB → Functions → SignalR を例にお話ししましたが、イベント駆動がサーバーレスの醍醐味。

簡単な設定をするだけでサービスがつながって画面表示に反映されるのは感動ものです。

ステートフルな Durable Functions は LINE API と相性抜群

Durable Functions を使った機能を 2 つ紹介しましたが、LINE ユーザー ID が全 LINE API で共通であるという強みがとても活かしやすく、相性がいいです。

この特徴をうまく使えば、Azure × LINE でもっとおもしろいサービスを作ることができるのでは…と思っています。

Azure サーバーレス × LINE API はいいぞ

Azure サーバーレスと LINE API の相性はよいですし、サーバーレスも LINE API もどちらも手軽に始められるよさがあります。

小規模・個人でも始めやすく、大規模にも対応できるものなので、いろいろな人に本当におすすめです。

Azure を使うと、今回紹介したような機能を用いることで、新しいことができる可能性がたくさんあり、特におすすめです。ぜひ、触ってみてほしいなと思います。

スライドはこちら

www.slideshare.net

感想など

コロナでばたばたしててすっかり忘れてましたが、そういえば LINE API Expert になってから初登壇。

オンライン登壇も初だったのですが、オンラインのみの登壇は難しいですね。。反応がないのでわかりにくく、アドリブもしにくい。

開発者/非開発者の比率もわかっておらず、話しはじめてから準備段階でオーディエンスが意識できなかったのを思い出し、話しながら戸惑ってしまいました。アドリブを効かせていいかんじに乗り切る…ができないなと思ったので、話すことはきちんと事前に用意したほうがいいなと思いました。

おまけ

なんと、今回紹介したシステムを、Hiro さん (@mofumofu_dance) がすぐさま真似して自動応答と手動応答の Bot を作ってくれました!しかも、Power Platform で!

Power Apps で LINE とやりとりしてる…感動。

Twitter にも書いたけど、当日中にすぐ形にしてもらえるなんて、本当にうれしすぎます。。。ありがとうございます。。

その他、セッション中もたくさん Twitter で反応いただきました。

クラウドが得意なLINE API Expert集合!サーバレス×LINEでアプリ開発してみたLT まとめ #linedc - Togetter

Azure と LINE API の魅力が少しでも伝わっていたらうれしいです。ありがとうございました!

Azure Functions の HTTP トリガーで、キーが間違っていないのに 401 エラーとなってしまうケース

前置き

Azure Functions の HTTP トリガーの関数では、API キーを関数 URL のクエリストリング(code)に入れて実行しますね(承認レベルが匿名の場合は不要)。

https://example.azurewebsites.net/api/Function1?code=xxxxxxxx

この API キーを間違えたり、渡さなかったりすると 401 エラーが出るわけですが、今回はこのキーを間違えていないはずなのに 401 エラーが出てしまうケースを紹介します。

401 エラーになるコード

以下のような関数のコードを見てください。

承認レベルは Function で、関数ごとに異なる キーを渡して実行します。

これをデプロイして Function2 を実行すると、なんと正しい API キーを渡しても 401 エラーになります

public static class SampleFunctions
{
    [FunctionName(nameof(Function1))]
    public static IActionResult Function1(
        [HttpTrigger(AuthorizationLevel.Function, "get", Route = "{name}")] HttpRequest req,
        string name)
    {
        return new OkObjectResult($"Function1: {name}");
    }

    [FunctionName(nameof(Function2))]
    public static IActionResult Function2(
        [HttpTrigger(AuthorizationLevel.Function, "get", Route = null)] HttpRequest req)
    {
        return new OkObjectResult("Function2: OK");
    }
}




さて、なぜだかわかりますか???




解説

ポイントは各関数メソッドの Route = の部分です。

Route でパラメータを渡せる

Route では、関数を呼び出す URL の 「https://example.azurewebsites.net/api/」以降のパスを指定することができます。

そして、{ } でパラメータも渡すことができ、このパラメータの値は同じ名前の別の引数としてメソッド内部で利用可能です。

したがって Function1 のこの部分、

public static IActionResult Function1(
        [HttpTrigger(AuthorizationLevel.Function, "get", Route = "{name}")] HttpRequest req,
        string name)

関数 URL としては

https://example.azurewebsites.net/api/JohnDoe?code=xxxxxxxx

のようになり、好きな文字列を渡して関数内で使うことができるようになります。

Route = null

ところが、Function2 では、Route = null となっています。

Routenull の場合は、URL のパスに関数名が入ります。つまり、こんな URL になります。

https://example.azurewebsites.net/api/Function2?code=XXXXXXXX



もうおわかりですね

そう、

  • Function1 の指定 Route = {name}
  • Function2 の指定 Route = null

が、URL として完全にかぶってしまっているのです。

そして、定義として先にくる Function1 が優先され、「https://example.azurewebsites.net/api/{何かしらの文字列}」という Function1 の URL に当てはまるhttps://example.azurewebsites.net/api/Function2」という Function2 の URL も、Function1 の呼び出しだと思われてしまうため、Function2 のキーを渡しても Function1 としては間違ったキーとなるので、401 エラーになってしまう、ということになります。


ちなみに

もし Function1Function2 の名前が逆だったら、つまり

  • Function1 の指定 Route = null
  • Function2 の指定 Route = {name}

という定義であれば、/Function1 という呼び出しは定義が先の Function1 と扱ってくれるため、どちらもエラーにならず 200 が返ってきます。

しかし、この場合でも Function2 の name に「Function1」という文字列を渡そうとしたら……?

恐ろしいですね。

そんなことが起きないように

ということで、Route を指定する場合は必ず固定のパスを含んだかたちで指定するようにしましょう。

ドキュメントのサンプルを見てもきっちりここは指定されているはずです。

[FunctionName(nameof(Function1))]
public static IActionResult Function1(
    [HttpTrigger(AuthorizationLevel.Function, "get", Route = "Function1/{name}")] HttpRequest req,
    string name)
{
    return new OkObjectResult($"Function1: {name}");
}

固定のパスを指定しなくても、そしてたとえ URL がかぶっていたとしてもデプロイや起動時にエラーになってくれないので、注意が必要です。