Azure SignalR Service と Functions を使って、Blob ストレージへのファイルアップロード通知をみんなに送る

##1. はじめに
Blob ストレージにファイルがアップロードされたらすぐにでも知りたいですよね。(Functions で十分？)
というわけで試しに Azure SignalR Service を使って Blob ストレージのアップロードを知らせる Push 通知の仕組みを作ってみました。
Azure SignalR Service って聞いたこともないよくわからないサービス名ですが、チャットやオンラインゲーム、株価の表示などのリアルタイムの通信を必要とするアプリを簡単に実現できる結構便利なサービスらしいです。
元々は ASP.NET の一つとして提供されていますが、サーバーレスで簡単に使用できるよう Azure 上でサービスとして提供されるようになったとか。


>Azure SignalR サービスは、リアルタイムの Web 機能を HTTP 経由でアプリケーションに追加するプロセスを簡略化します。 このリアルタイム機能は、サービスが、接続されているクライアントにシングル ページ Web やモバイル アプリケーションなどのコンテンツの更新をプッシュできるようにします。 その結果、クライアントは、サーバーをポーリングしたり更新プログラムについて新しい HTTP 要求を送信したりしなくても更新されます。
https://docs.microsoft.com/ja-jp/azure/azure-signalr/signalr-overview

ある SignalR にメッセージを送信すると、SignalR が接続相手のすべてのクライアントにメッセージを送信する、という感じです。
一人に通知するだけなら Functions で十分だと思いますが、複数人に同時に Push 通知をしたいという場合には SignalR が向いています。

##2. 作ったもの
Blob ストレージへのファイルアップロードをトリガーとして Functions で SignalR に POST すると、SignalR が接続相手のクライアントに通知する仕組みです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320008/58bcff53-5cf9-06f7-10b1-f4760d60cc54.png)

blobTrigger というのがアップロード通知です。チャットアプリのサンプルを流用したのでチャット機能もついてます。とても便利！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320008/7456c6da-4a2b-b901-3f42-04bcda5921a5.png)




##3. 用意するもの
- Azure サブスクリプション
- Visuasl Studio Code

##4. 作ってみる
#####1. Azure ポータルから SignalR を作成する
Pricing tier は Free で、ServiceMode は Serverless にしておく。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320008/a4285e86-28f4-65d2-98f7-8b7961bb9392.png)

#####2. GitHub から Function App のサンプルコードをダウンロードする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320008/225da4b5-6e75-0ea1-5277-b0286685b10c.png)

```PowerShell
git clone https://github.com/Azure-Samples/signalr-service-quickstart-serverless-chat.git
code ./signalr-service-quickstart-serverless-chat\src\chat\csharp
```


```local.settings.json
{
  "IsEncrypted": false,
  "Values": {
    "FUNCTIONS_WORKER_RUNTIME": "dotnet",
    "AzureSignalRConnectionString": "Endpoint=https://<リソース名>.service.signalr.net;AccessKey=<Access Key>;Version=1.0;"
  },
  "Host": {
    "LocalHttpPort": 7071,
    "CORS": "http://localhost:8080,https://azure-samples.github.io",
    "CORSCredentials": true
  }
}
```

##### 3. 試しにデバッグ実行
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320008/418eb6cd-9a9f-76bf-6496-d741b0e69984.png)

```PowerShell
npm install -g azure-functions-core-tools
```

npm コマンドが認識されない場合は、Node.js がインストールされていない
インストール後に VS Code を再起動
https://nodejs.org/ja/


>func : このシステムではスクリプトの実行が無効になっているため、ファイル C:\Users\riech\AppData\Roaming\npm\func.ps1 を読み込むことができません。詳細につ
いては、「about_Execution_Policies」(https://go.microsoft.com/fwlink/?LinkID=135170) を参照してください。
発生場所 行:1 文字:1
+ func host start
+ ~~~~
    + CategoryInfo          : セキュリティ エラー: (: ) []、PSSecurityException
    + FullyQualifiedErrorId : UnauthorizedAccess

func.ps1 という Function App をホストするための PowerShell スクリプトの実行が許可されていない場合には、このようなエラーが出る。管理者として PowerShell を起動して以下のようなコマンドでポリシーを変更する必要がある。

```PowerShell
Set-ExecutionPolicy RemoteSigned
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320008/3ee872aa-72fe-064e-093f-7fef86794089.png)

VS Code を再起動して、再度デバッグ実行
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320008/27ed5f82-975e-55c9-60c9-413a318ad224.png)


コマンドプロンプトを起動して、以下のコマンドを実行する。以下のような URL が表示されれば起動成功

```PowerShell
cd signalr-service-quickstart-serverless-chat\docs\demo\chat-v2 # サンプルを配置したフォルダーから移動
npm install http-server -g # index.html をホストするための Web サーバーをインストール
http-server # Web サーバーを起動 (カレントディレクトリのファイルをホスト)
```
https://www.npmjs.com/package/http-server

ブラウザで http://localhost:8080/index.html にアクセス
Function App の URL とユーザー名を入力後、チャット画面が表示される。
もう一つブラウザを起動して一方からメッセージを送信すると
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320008/2c0f286e-2729-e39e-e1d8-e3fa74499fca.png)

両方の画面に同じメッセージが表示される。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320008/03e7b168-e82d-4fe0-8ad1-ecdc0d330544.png)

##### 4. ソースコードに変更を加える
Blob ストレージの更新をトリガーにできるよう、Nuget パッケージを追加してソースコードを変更する。

```PowerShell
dotnet add package Microsoft.Azure.WebJobs.Extensions.Storage
```

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

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

        [FunctionName("messages")]
        public static Task SendMessage(
            [HttpTrigger(AuthorizationLevel.Anonymous, "post")] object message,
            [SignalR(HubName = "chat")] IAsyncCollector<SignalRMessage> signalRMessages, ILogger log)
        {
            log.LogInformation($"{message}");
            return signalRMessages.AddAsync(
                new SignalRMessage
                {
                    Target = "newMessage",
                    Arguments = new[] { message }
                });
        }

        [FunctionName("BlobTriggerCSharp")]        
        public static async Task Run([BlobTrigger("samples-workitems/{name}")] Stream myBlob, string name, [SignalR(HubName = "chat")] IAsyncCollector<SignalRMessage> signalRMessages, ILogger log)
        {
            var jsonArgs = new SignalRMessageArguments();
            jsonArgs.Sender = "blobTrigger";
            jsonArgs.Text = name;

            log.LogInformation($"C# Blob trigger function Processed blob\n Name:{name} \n Size: {myBlob.Length} Bytes");
              await signalRMessages.AddAsync(
                new SignalRMessage
                {
                    Target = "newMessage",
                    Arguments = new[] { jsonArgs } //OK {"Target":"newMessage","Arguments":[{"sender":"blob","text":"from blob"}]} 
                }); 
        }
    }

    [JsonObject("Arguments")]
    public class SignalRMessageArguments
    {
        [JsonProperty("sender")]
        public string Sender { get; set; }
        [JsonProperty("text")]
        public string Text { get; set; }
    }    
}
```

##### 5. できた！
ローカルで実行して Blob ストレージにファイルをアップロードすると通知が表示された。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320008/92a58c99-d886-6bf9-fc8f-3b646d9e1755.png)




# (おまけ) クライアントの動作
Fiddler で HTTP 通信のログを取り POST だけ見てみる。(OPTIONS は一旦おいておく)
Blob ストレージから Functions への通信ログは取れないので、サンプルのままチャットとして利用した場合のを参考までに。。

#####1.クライアントから Function App (/api/negotiate) に POST して Azure SignalR Service の URL と accessToken を受け取る
リクエスト
> POST http://localhost:7071/api/negotiate HTTP/1.1
Origin: http://localhost:8080
Referer: http://localhost:8080/index.html
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.18362
Accept: */*
Accept-Language: ja-JP
Content-Type: text/plain;charset=UTF-8
X-Requested-With: XMLHttpRequest
Accept-Encoding: gzip, deflate
Content-Length: 0
Host: localhost:7071
Connection: Keep-Alive
Pragma: no-cache

レスポンス

>HTTP/1.1 200 OK
Date: Mon, 25 Nov 2019 17:31:28 GMT
Content-Type: application/json; charset=utf-8
Server: Kestrel
Content-Length: 339
Vary: Origin
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: http://localhost:8080

>{"url":"https://<リソース名>.service.signalr.net/client/?hub=chat","accessToken":"eyJhbGciOi..."}

#####2. SignalR との接続を確立 (1 で受け取った URL と accessToken を使う)
リクエスト
> POST https://<リソース名>.service.signalr.net/client/negotiate?hub=chat HTTP/1.1
Origin: http://localhost:8080
Referer: http://localhost:8080/index.html
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.18362
Accept: */*
Accept-Language: ja-JP
Authorization: Bearer eyJhbGciOi***
Content-Type: text/plain;charset=UTF-8
X-Requested-With: XMLHttpRequest
Accept-Encoding: gzip, deflate, br
Host: <リソース名>.service.signalr.net
Content-Length: 0
Connection: Keep-Alive
Cache-Control: no-cache

レスポンス
> HTTP/1.1 200 OK
Server: nginx
Date: Mon, 25 Nov 2019 17:31:29 GMT
Content-Type: application/json
Connection: keep-alive
Vary: Accept-Encoding
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: http://localhost:8080
Content-Length: 261

> {"connectionId":"vMlJoNHoJCl*****3352521","availableTransports":[{"transport":"WebSockets","transferFormats":["Text","Binary"]},{"transport":"ServerSentEvents","transferFormats":["Text"]},{"transport":"LongPolling","transferFormats":["Text","Binary"]}]}


#####4. WebSocket に upgrade
リクエスト
> GET https://<リソース名>.service.signalr.net/client/?hub=chat&id=vMlJoNHoJClg7TOwPz2tqQ013352521&access_token=eyJhbGciOiJIU*** HTTP/1.1
Origin: http://localhost:8080
Sec-WebSocket-Key: UjayPyXz8CUt9UQ13PhzXg==
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.18362
Host: <リソース名>.service.signalr.net
Cache-Control: no-cache

レスポンス
> HTTP/1.1 101 Switching Protocols
Server: nginx
Date: Mon, 25 Nov 2019 17:31:30 GMT
Connection: upgrade
Upgrade: websocket
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: http://localhost:8080
Sec-WebSocket-Accept: w9uFWUH0OWqInqq5mQrnmRtkzu4=
EndTime: 23:39:38.968
ReceivedBytes: 4743
SentBytes: 2395





#####5. メッセージの送受信 
Function App の /api/messages に POST してメッセージを送信 

リクエスト

> POST http://localhost:7071/api/messages HTTP/1.1
Origin: http://localhost:8080
Referer: http://localhost:8080/index.html
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.18362
Accept: application/json, text/plain, */*
Accept-Language: ja-JP
Content-Type: application/json;charset=utf-8
Accept-Encoding: gzip, deflate
Content-Length: 28
Host: localhost:7071
Connection: Keep-Alive
Pragma: no-cache

>{"sender":"abc","text":"hi"}

レスポンス

>HTTP/1.1 204 No Content
Date: Mon, 25 Nov 2019 17:31:33 GMT
Server: Kestrel
Content-Length: 0
Vary: Origin
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: http://localhost:8080


#####6. Function App から SignalR にメッセージ送信 (SignalR と接続を確立しているすべてのクライアントがメッセージを受信する)
> POST https://<リソース名>.service.signalr.net/api/v1/hubs/chat HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs***
Accept: application/json
Accept-Charset: UTF-8
Asrs-User-Agent: Microsoft.Azure.WebJobs.Extensions.SignalRService/1.0.0-rtm (.NET Core 4.6.28008.02; Microsoft Windows 10.0.18362; X64)
Content-Type: application/json; charset=utf-8
Content-Length: 66
Host: conversationmonitor.service.signalr.net

> {"Target":"newMessage","Arguments":[{"sender":"abc","text":"hi"}]}

レスポンス (フロントは nginx らしい)

> HTTP/1.1 202 Accepted
Server: nginx
Date: Mon, 25 Nov 2019 17:31:33 GMT
Content-Length: 0
Connection: keep-alive





# ご参考
[Azure Functions における Azure Blob Storage のバインド](https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-bindings-storage-blob?tabs=csharp)

[Azure SignalR Service の内部](https://docs.microsoft.com/ja-jp/azure/azure-signalr/signalr-concept-internals)

[(Ebent Grid との組み合わせのご参考) Azure SignalR Service now supports Event Grid!](https://devblogs.microsoft.com/aspnet/azure-signalr-service-now-supports-event-grid/)