AzureとLINEとVue.jsで投稿画像のHappiness数値判定Botを作ってみた

## はじめに

[エンジニアが披露宴の余興を頼まれたら](https://qiita.com/keeey999/items/7ce7c4bc342ca2ec7319)

こちらの記事に感化されて、Microsoft Azure で作成してみました。

## 動作

【LINEとランキングページ】

![line01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144897/183a92ac-8872-b36a-07a7-d01c84a41428.png)


【ダウンロード機能】

![line02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144897/01953999-146b-a528-9c90-6076e2e4f65c.png)


参加者で投稿画像を共有できるように、ダウンロード機能を設けています


【リアルタイム通信】

![line03.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144897/a57707bd-706b-f1be-851b-59c9832a1f99.gif)


上記わかりづらいですが、異なるブラウザでランキングページを表示して、スマホから更新している状態です。

ランキングページでは「新しい画像が投稿」された場合と「いいね！ボタン」が押下されたときに、

ブラウザを更新なしで、変更がリアルタイムで反映されるようにしています

ちなみに、「いいね！ボタン」はユーザ管理をしていないので、いいね！と思った分だけ押下可能です。

## アーキテクチャ図

![architecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144897/4b8b19bf-a59f-71bc-1424-0a88588f5f21.png)


LINEとの連携部分はDurable Functions を使い、Vue.js との連携部分はAzureFunctionsで使っています。

## 工夫した点

### IHttpClientFactory

LINE との連携部分では、HttpClient で通信を行っています。

* [IHttpClientFactory を使用して回復力の高い HTTP 要求を実装する](https://docs.microsoft.com/ja-jp/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests)


>このクラスでは IDisposable が実装されますが、これを using ステートメント内で宣言およびインスタンス化することはお勧めできません。その理由は、HttpClient オブジェクトが破棄されても、基になるソケットがすぐに解放されず、_ソケットの枯渇_の問題が発生する可能性があるということにあります。 

ドキュメントによるとHttpClient のインスタンスは使いまわしを行うことがベストプラクティスのため、IHttpClientFactory を DI して使うようにしました。

``` csharp:Startup.cs

using HappinessFunctionApp.Common;
using HappinessFunctionApp.Extension;
using Microsoft.Azure.Documents.Client;
using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using System;

[assembly: FunctionsStartup(typeof(HappinessFunctionApp.Startup))]
namespace HappinessFunctionApp
{
	public class Startup : FunctionsStartup
	{
		// 依存関係
		public override void Configure(IFunctionsHostBuilder builder)
		{
			// IHttpClientFactory を使用する
			builder.Services.AddHttpClient<HttpClientService>();
			builder.Services.AddSingleton(provider =>
			{
				ConnectionPolicy ConnectionPolicy = new ConnectionPolicy
				{
					ConnectionMode = ConnectionMode.Direct,
					ConnectionProtocol = Protocol.Tcp
				};

				return new DocumentClient(new Uri(AppSettings.Instance.COSMOSDB_ENDPOINT), AppSettings.Instance.COSMOSDB_KEY, ConnectionPolicy);
			});
		}
	}
}

```

* [型指定されたクライアント](https://docs.microsoft.com/ja-jp/aspnet/core/fundamentals/http-requests?view=aspnetcore-3.1#typed-clients)

> * キーとして文字列を使用する必要なしに、名前付きクライアントと同じ機能を提供します。
* クライアントを使用するときに、IntelliSense とコンパイラのヘルプが提供されます。
* 特定の HttpClient を構成してそれと対話する 1 つの場所を提供します。 たとえば、単一の型指定されたクライアントは、次のために使用 される場合があります。
 * 単一のバックエンド エンドポイント用。
 * エンドポイントを処理するすべてのロジックをカプセル化するため。
* DI に対応しており、アプリ内の必要な場所に挿入できます。


今回の用途的に「型指定されたクライアント」で実装しました。

``` csharp:HttpClientService

using HappinessFunctionApp.Common;
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace HappinessFunctionApp.Extension
{
	public class HttpClientService
	{
		private readonly HttpClient _client;

		public HttpClientService(HttpClient client)
		{
			_client = client;
		}
		public async Task<HttpResponseMessage> PostJsonAsync<T>(string requestUri, T value)
		{
			_client.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type", "application/json;");
			return await _client.PostAsJsonAsync(requestUri, value, CancellationToken.None).ConfigureAwait(false);
		}

		public async Task<HttpResponseMessage> PostLineJsonAsync<T>(string requestUri, T value)
		{
			_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AppSettings.Instance.LINE_CHANNEL_ACCESS_TOKEN);
			_client.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type", "application/json;");
			return await _client.PostAsJsonAsync(requestUri, value, CancellationToken.None).ConfigureAwait(false);
		}

		public async Task<Stream> GetStreamAsync(string requestUri)
		{
			var response = await _client.GetAsync(requestUri);
			return response.Content.ReadAsStreamAsync().Result;
		}

		public async Task<string> GetAsync(string requestUri)
		{
			var response = await _client.GetAsync(requestUri);
			return response.Content.ReadAsStringAsync().Result;
		}
	}
}


```

### DocumentClient 

* [Azure Cosmos DB と .NET のパフォーマンスに関するヒント](https://docs.microsoft.com/ja-jp/azure/cosmos-db/performance-tips#sdk-usage)

>各 DocumentClient インスタンスと CosmosClient インスタンスはスレッドセーフであり、直接モードで動作しているときには効率的な接続管理とアドレスのキャッシュが実行されます。 効率的な接続管理と SDK クライアントのパフォーマンス向上を実現するために、アプリケーションの有効期間中は、AppDomain ごとに単一のインスタンスを使用することをお勧めします。

DocumentClient インスタンスを使いまわすことが、ベストプラクティスのため、こちらも DI して使います。

``` csharp:Startup.cs

using HappinessFunctionApp.Common;
using HappinessFunctionApp.Extension;
using Microsoft.Azure.Documents.Client;
using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using System;

[assembly: FunctionsStartup(typeof(HappinessFunctionApp.Startup))]
namespace HappinessFunctionApp
{
	public class Startup : FunctionsStartup
	{
		// 依存関係
		public override void Configure(IFunctionsHostBuilder builder)
		{
			// IHttpClientFactory を使用する
			builder.Services.AddHttpClient<HttpClientService>();
			builder.Services.AddSingleton(provider =>
			{
				ConnectionPolicy ConnectionPolicy = new ConnectionPolicy
				{
					ConnectionMode = ConnectionMode.Direct,
					ConnectionProtocol = Protocol.Tcp
				};

				return new DocumentClient(new Uri(AppSettings.Instance.COSMOSDB_ENDPOINT), AppSettings.Instance.COSMOSDB_KEY, ConnectionPolicy);
			});
		}
	}
}

```

## Azure Functions Proxy

投稿された画像を保存しているストレージアカウントのエンドポイントを表示したくなかったので、
Azure Functions Proxy 経由で画像表示を行うようにした。

従量課金の AppService Plan だとコールドスタートなので、初回は画像表示に遅延が発生してしまうので、プラン変更を行うか、他の方法で実装すべきだったなと少し思っています。 

``` json:Proxies.json

{
  "$schema": "http://json.schemastore.org/proxies",
  "proxies": {
    "img": {
      "matchCondition": {
        "methods": [ "GET" ],
        "route": "/image/{filename}"
      },
      "backendUri": "https://<your Strage Account endpoint>.blob.core.windows.net/uploadimage/{filename}"
    }
  }
}

```

### Azure SignalR

今回、リアルタイムでのコンテンツ更新を行いたかったので Azure SignalR を使いました。

ランキング情報の json をそのまま配信したかったので、シンプルにデフォルトで実装しています。

``` csharp:SignalRFunction.cs

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Extensions.SignalRService;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;

namespace HappinessFunctionApp.Functions
{
    public static class SignalRFunction
    {
        [FunctionName("negotiate")]
        public static SignalRConnectionInfo GetSignalRInfo(
        [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequest req,
        [SignalRConnectionInfo(HubName = "chat")] SignalRConnectionInfo connectionInfo)
        {
            return connectionInfo;
        }

        [FunctionName("SendMessage")]
        public static async Task SendMessage(
        [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequest req,
        [SignalR(HubName = "chat")]IAsyncCollector<SignalRMessage> signalRMessages, ILogger log)
        {
            
            var data = await req.ReadAsStringAsync();

            await signalRMessages.AddAsync(new SignalRMessage
            {
                Target = "newMessage",
                Arguments = new[] { data }
            });
        }
    }
}


```

Vue側で、negotiate にリクエストを送信して接続情報を取得して、受け取った json を表示するだけ

``` js:Happiness.vue

 mounted () {
    // SignalRとコネクションを作成
    this.connection = new signalr.HubConnectionBuilder()
      .withUrl(process.env.VUE_APP_HOST)
      .configureLogging(signalr.LogLevel.Information)
      .build()
    console.log('connecting...')

    // SignalR Serviceへの接続
    this.connection
      .start()
      .then(() => console.log('connected!'))
      .catch(console.error)

    // SignalR Serviceへの接続
    this.connection.on('newMessage', (data) => {
      this.items = JSON.parse(data).images
      this.sumcnt = JSON.parse(data).images.length
      this.isLoading = false
      this.errored = false
      this.$emit('sumpicter', this.sumcnt)
      console.log(this.sumcnt)
      console.log(this.items)
    })
    // 切断
    this.connection.onclose(() => console.log('disconnected'))

```


## 動かすためにやること

### LINE Developers に登録

以下を参照して、登録を行う

* [Messaging APIを利用するには](https://developers.line.biz/ja/docs/messaging-api/getting-started/#%E3%83%81%E3%83%A3%E3%83%8D%E3%83%AB%E3%81%AE%E4%BD%9C%E6%88%90)

チャンネルの作成が完了したら「Channel secret」と「Channel access token」を控えておく

### Azureポータル上の操作

Microsoft Azure で以下を作成する

* Azure Functions
* Azure Cosmos DB
* SignalR
* Cognitive Services Face
* Storage Account

作成をしたら「キー」や「エンドポイント」を控えておく


![storage01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144897/58d8a91a-e776-f330-6891-004f0098f2e6.png)



ストレージアカウントの「BLOB」を使い、「uploadimage」というコンテナーを作成しておく


![storage02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144897/29831731-b459-8e29-5565-352f8fe23197.png)



以降の local.settings.json の項目を AzureFunctions のアプリケーション設定に登録する

その際、SIGNALR_URL は Azure 上に作成した AzureFunctions のエンドポイントを設定する


![applicationsetting.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144897/583c2dac-812e-2412-b2b6-303438f2bf47.png)


### Happiness Function (C#)

#### local.settings.json

前述の作業で控えた「キー」と「エンドポイント」を設定する

ローカル実行する場合は、localhost の記載はそのままにしておく

※HOSTの記載は変更しない
　ローカルデバック時の vue.js との連携のため

```json:local.settings.json
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "AzureSignalRConnectionString": "<your SignalR endpoint>",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet",
    "FACE_SUBSCRIPTION_KEY": "<your faceApi subscription key>",
    "FACE_ENDPOINT": "https://<your faceApi endpoint>.cognitiveservices.azure.com/",
    "STORAGE_ACCOUNT_NAME": "<your storage account name>",
    "STORAGE_ACCESS_KEY": "<your storage subscription key>",
    "BLOB_NAME": "uploadimage",
    "COSMOSDB_ENDPOINT": "https://<your cosmos db endpoint>.documents.azure.com:443/",
    "COSMOSDB_KEY": "<your cosmos db subscription key>",
    "DATABASE_ID": "LineBotDb",
    "COLLECTION_ID": "HappinessInfo",
    "LINE_CHANNEL_ACCESS_TOKEN": "<your LINE Messaging API Access Token>",
    "LINE_CHANNEL_SECRET": "<your LINE Messaging API Channel Secret>",
    "LINE_POST_LIST": "https://<your Storage Static web site endpoint>.z11.web.core.windows.net/",
    "BLOB_URL": "https://<your Strage Account endpoint>.blob.core.windows.net/uploadimage/",
    "PROXY_URL": "https://<your azure function endpoint>.azurewebsites.net/image/",
    "SIGNALR_URL": "http://localhost:7071/api/SendMessage"
  },
  "Host": {
    "LocalHttpPort": 7071,
    "CORS": "http://localhost:8080",
    "CORSCredentials": true
  }
}
```

#### Proxies.json

投稿画像を保存するストレージアカウントのエンドポイントに書き換え

```json:Proxies.json
{
  "$schema": "http://json.schemastore.org/proxies",
  "proxies": {
    "img": {
      "matchCondition": {
        "methods": [ "GET" ],
        "route": "/image/{filename}"
      },
      "backendUri": "https://<your Strage Account endpoint>.blob.core.windows.net/uploadimage/{filename}"
    }
  }
}
```

### happiness_app (Vue.js)

#### prod.env.js

VUE_APP_HOST の書き換え

```js:prod.env.js
'use strict'
module.exports = {
  NODE_ENV: '"production"',
  VUE_APP_HOST: '"https://<your azure function endpoint>.azurewebsites.net/api"'
}
```

#### ビルド方法

``` bash
# install dependencies
npm install

# serve with hot reload at localhost:8080
npm run dev

# build for production with minification
npm run build

# build for production and view the bundle analyzer report
npm run build --report
```

#### Azure Storage にデプロイ

Azure Storage コンテナー に VScode からデプロイを行う

![スクリーンショット (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144897/6bfe64c4-bf03-1292-e515-9287ec206a78.png)


## LINEにエンドポイントを設定する

Azure にデプロイした「LineBotHttpStart」のエンドポイントを設定すれば完了


![Linesetting.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144897/026e0226-63d2-d9de-5a03-3cdafc82e7e4.png)

## 所感

今回初めて、LINE Messaging API や Durable Function 、Cosmos DB 、SignalR 、Vue.js を使ってみました。

まだまだ Durable Function や vue.js の実装方法など今後も引き続き、勉強していくしかないなと感じました。

しかし、この個人プロジェクトかなり学びが多い！楽しかった！

## 参考

* [.NET Azure Functions で依存関係の挿入を使用する](https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-dotnet-dependency-injection)

* [IHttpClientFactory を使用して回復力の高い HTTP 要求を実装する](https://docs.microsoft.com/ja-jp/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests)

* [Azure Cosmos DB と .NET のパフォーマンスに関するヒント](https://docs.microsoft.com/ja-jp/azure/cosmos-db/performance-tips#sdk-usage)

* [Messaging APIを利用するには](https://developers.line.biz/ja/docs/messaging-api/getting-started/#%E3%83%81%E3%83%A3%E3%83%8D%E3%83%AB%E3%81%AE%E4%BD%9C%E6%88%90)

* [Azure SignalR サービスとは](https://docs.microsoft.com/ja-jp/azure/azure-signalr/signalr-overview)