SignalR Service 調査メモ(2)

# SignalR Service 調査メモ(2)

[前回記事](https://qiita.com/yossy6954/items/8461217c40b164b893f1) で調べると書き残して〆た Azure Functions から SignalR Service を使って通知を送信する部分を調べました。

[RealtimeSignIn](https://github.com/aspnet/AzureSignalR-samples/tree/master/samples/RealtimeSignIn) の README によると動作の概要はこんな感じ。

* index.html を開くとサインインを行い、Functions → ブラウザ に SignalR Serviceのエンドポイントとアクセストークン、統計情報を返す
* Functionsではサインインが行われる度にテーブルを更新し、サインインの統計情報を SignalR Service を通じてブロードキャストする
* ブラウザはサインイン時に受け取ったエンドポイント・アクセストークンを使って JavaScript で SignalR Service に接続し、受信した統計情報を元に画面を更新する。

## Azure Functions と SignalR Service の接点

Azure Functions と SignalR Service の接点は

* 統計情報のブロードキャスト
* サインイン時のブラウザ用エンドポイント・アクセストークンの生成

の二箇所になります。


### 統計情報のブロードキャスト

```cs:AzureSignalR.cs
private static void ParseConnectionString(string connectionString, out string endpoint, out string accessKey)
{
    var dict = connectionString.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Split(new[] { '=' }, 2)).ToDictionary(p => p[0].Trim().ToLower(), p => p[1].Trim());
    if (!dict.TryGetValue("endpoint", out endpoint)) throw new ArgumentException("Invalid connection string, missing endpoint.");
    if (!dict.TryGetValue("accesskey", out accessKey)) throw new ArgumentException("Invalid connection string, missing access key.");
}

private string GenerateJwtBearer(string issuer, string audience, ClaimsIdentity subject, DateTime? expires, string signingKey)
{
    SigningCredentials credentials = null;
    if (!string.IsNullOrEmpty(signingKey))
    {
        var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey));
        credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
    }

    var token = jwtTokenHandler.CreateJwtSecurityToken(
        issuer: issuer,
        audience: audience,
        subject: subject,
        expires: expires,
        signingCredentials: credentials);
    return jwtTokenHandler.WriteToken(token);
}

private Task<HttpResponseMessage> PostJsonAsync(string url, object body, string bearer)
{
    var request = new HttpRequestMessage
    {
        Method = HttpMethod.Post,
        RequestUri = new Uri(url)
    };

    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
    request.Headers.Accept.Clear();
    request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
    request.Headers.AcceptCharset.Clear();
    request.Headers.AcceptCharset.Add(new StringWithQualityHeaderValue("UTF-8"));

    var content = JsonConvert.SerializeObject(body);
    request.Content = new StringContent(content, Encoding.UTF8, "application/json");
    return httpClient.SendAsync(request);
}

public async Task SendAsync(string hubName, string methodName, params object[] args)
{
    var payload = new PayloadMessage()
    {
        Target = methodName,
        Arguments = args
    };
    var url = $"{endpoint}:5002/api/v1-preview/hub/{hubName}";
    var bearer = GenerateJwtBearer(null, url, null, DateTime.UtcNow.AddMinutes(30), accessKey);
    await PostJsonAsync(url, payload, bearer);
}

```

流れは

1. SignalR Service の接続文字列をパースしてエンドポイント・アクセスキーを抜き出す
2. アクセスキーからJWTを生成する
3. エンドポイント・ハブ名から SignalR Service 送信用URLを作る
4. 送信用URL・JWTを使用して送信したいデータをPOSTする

こんな感じですね。SDK等が提供されているわけではありませんが、このままで単純な送信は対応可能です。


### ブラウザ用エンドポイント・アクセストークンの生成

```cs:SignInFunction.cs
[FunctionName("signin")]
public static async Task<HttpResponseMessage> Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")]HttpRequestMessage req, TraceWriter log)
{
    ...
    return req.CreateResponse(HttpStatusCode.OK, new
    {
        authInfo = new
        {
            serviceUrl = signalR.GetClientHubUrl(HubName),
            accessToken = signalR.GenerateAccessToken(HubName)
        },
        stats = stats
    }, "application/json");
```

```cs:AzureSignalR.cs
public string GetClientHubUrl(string hubName)
{
    return $"{endpoint}:5001/client/?hub={hubName}";
}

public string GenerateAccessToken(string hubName)
{
    return GenerateJwtBearer(null, GetClientHubUrl(hubName), null, DateTime.UtcNow.AddMinutes(30), accessKey);
}

```

signin Function を叩かれたタイミングでClientHubのURLとアクセストークンを生成し、ブラウザ側に返す所の抜粋。
前項と同じく接続文字列から抜き出したエンドポイント・アクセスキーからClient用HubのURLとアクセストークンを生成して返しています。


### まとめ

AzureSignalR.cs を組み込んで Azure Functions から SignalR Service へのデータ送信は簡単にできそうです。
しかしChatRoomアプリのような「ブラウザ→SignalR Service→サーバ(Azure Functions)」のフローはどう実現するのか謎なままです。
まあそこは普通にPOSTで代用してもいいのかもしれませんが、GAする頃には Azure Functions側に SignalR Service Trigger的なものが追加されているかもしれません。


# おまけ(Serverless版ChatRoomを作ってみる)

というわけで ChatRoom・RealtimeSignIn をニコイチして改造し、Azure Functions/SignalR Service/Storage の3サービスだけで動く ServerlessChatRoom サンプルを作ってみました。

## Functions側実装

### Join

```cs：Join.cs
[FunctionName("Join")]
public static IActionResult Run(
    [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)]HttpRequestMessage req,
    TraceWriter log) {
    try {
        var signalR = new AzureSignalR(Environment.GetEnvironmentVariable("AzureSignalRConnectionString"));
        return new OkObjectResult(new {
            authInfo = new {
                serviceUrl = signalR.GetClientHubUrl(HubName),
                accessToken = signalR.GenerateAccessToken(HubName)
            }
        }
        );
    } catch(Exception ex) {
        log.Error(ex.Message);
        log.Error(ex.StackTrace);
        throw;
    }
}
```

チャットルーム参加時に呼び出すHttpTriggerです。
ブラウザからSignalR Serviceに接続するためのエンドポイントURL・アクセストークンを返します。

### BroadcastMessage

```cs:BroadcastMessage.cs
[FunctionName("BroadcastMessage")]
public static async Task<IActionResult> Run(
    [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)]HttpRequest req,
    TraceWriter log) {
    var signalR = new AzureSignalR(Environment.GetEnvironmentVariable("AzureSignalRConnectionString"));
    string name = req.Query["name"];
    string message = req.Query["message"];

    // broadcast through SignalR
    await signalR.SendAsync(HubName, "broadcastMessage", name, message);

    return new OkObjectResult($"OK");
}
```

メッセージ送信用のHttpTriggerです。
発言者・メッセージをそのまま AzureSignalR を介してブロードキャスト送信します。

## JavaScriptアプリ側実装

こちらは ChatRoom のindex.htmlを改造します。

### SignalR Service 接続

```javascript
function startConnection(url, accessToken) {
    var connection = new signalR.HubConnectionBuilder()
        .withUrl(url, { accessTokenFactory: () => accessToken })
        .build();
    bindConnectionMessage(connection);
    connection.start()
        .then(function () {
            onConnected(connection);
        })
        .catch(function (error) {
            console.error(error.message);
        });
}
$.getJSON('/Join').done(result => {
    startConnection(result.authInfo.serviceUrl, result.authInfo.accessToken);
});
```

Join HttpTrigger を呼び出し、返ってきたエンドポイントURL・アクセストークンを使用して SignalR の接続を作るように変更します。

### BroadcastMessage送信

```javascript
function broadcastMessage(name, message) {
    $.ajax({
        url: '/BroadcastMessage',
        type: 'GET',
        data: {
            name: name,
            message: message
        },
        timeout: 10000,
    }).done(function (data) {
    }).fail(function (XMLHttpRequest, textStatus, errorThrown) {
    });
}

...

//connection.send('broadcastMessage', '_SYSTEM_', username + ' JOINED');
broadcastMessage('_SYSTEM_', username + ' JOINED')

```

オリジナルだとSignalRで送信していた所を BroadcastMessage HttpTrigger を呼び出し、Azure Functions からメッセージを送信する形にします。


完成したソースを以下に置いておきます。
StorageにWebページを配置、Azure Functions をデプロイしてアプリケーション設定の"AzureSignalRConnectionString", "BlobHost"をお使いの SignalRServiceの接続文字列・Storageのホストに変更すれば動きます。

[ソースコード](https://github.com/yossy6954/ServerlessChatRoom/tree/master/ServerlessChatRoom)