HoloLensでSignalR Service(Serverless)でつなげてみる

# HoloLensでSignalRを使う。

HoloLensでSignalRを使うのは割と多いかもしれません。SignalRはリアルタイムで双方向通信を実現することができるライブラリでAzure上でServerlessのサービスとして利用できます。Serverlessの場合はライブラリを使って自分でWebサービスを公開する手間がかからないので、ちょっとしたデータ配信システムを作るのに向いてます。
また、Azure IoTとMixed Realityの組み合わせではSignalRが以前からよく出てきてます。
SignalRを使う理由としては情報配信という点でサービスに接続すればどういうシステム、デバイスでも同報通信できるというところがあるのだろ思います。

https://learn.microsoft.com/ja-jp/azure/architecture/example-scenario/iot/smart-places

https://learn.microsoft.com/ja-jp/training/paths/build-mixed-reality-azure-digital-twins-unity/


# 過去いろいろHoloLens × SignalR関連の話は出てきます。

以前からこういったSignalRを活用すためのTipsはあります。なので、参考にすれば普通に開発できると思いきや、 **SignalR Serviceに直接つなぐ**方法を調べてみると意外と少なく、今回はその情報を紹介しようと思います。

https://blog.jbs.co.jp/entry/2022/10/03/144133

https://bluebirdofoz.hatenablog.com/entry/2021/06/29/031616

https://blog.hololab.co.jp/entry/2018/06/15/234507

# 開発環境

今回はできるだけ最小限に構成するために以下の構成をとりました。なお今回の手法は、Meta Quest Proなどにもデプロイ可能です。

* Mixed Reality Feature Tool(Microsoft OpenXR Plugin導入用)
* Unity 2021.3.11f1
    * Mixed Reality OpenXR Plugin(from Mixed Reality Feature Tool)
    * XR Plugin Management
    * OpenXR Plugin
* Visual Studio 2022
    * 以下のWindows SDKのいずれか
       * Windows SDK 10.0.18362.0以降
       * Windows 11 SDK
    * USB Device Connectivity (required to deploy/debug to HoloLens over USB)
    * C++ (v142) Universal Windows Platform tools (required when using Unity)

* SignalR 関連
    *  Microsoft.AspNetCore.SignalR.Client 5.0.17(最新は7.0.1なのですが少し古めをあえて使用)
    *  System.IdentityModel.Tokens.Jwt(接続時のToken生成用)

# 手順

コードの一部は以下のGithubのコードを参考にしています。

https://github.com/aspnet/AzureSignalR-samples

## 必要なライブラリの収集

まずはSignalRのクライアント用のライブラリを収集します。まずはNuget.exeが必要なのでサイトにアクセスしてダウンロードします。バージョンは推奨のもので。

https://www.nuget.org/downloads

次にnuget.exeを任意のフォルダで実行しライブラリを取得します。今回はSignalRと接続時に使用するトークン生成のためのライブラリを取得します。なお、SignalRは最新バージョンではないです。少し試していた時にうまくいかなかったこともありあえてバージョンを下げています。
パッケージのダウンロードは以下のコマンドを入力します。

```powershell
ps D:\> .\nuget.exe install Microsoft.AspNetCore.SignalR.Client -version 5.0.17 -OutputDirectory [任意のパス]
```

次に **.netstandard2.0**　用のライブラリを集約します。各ライブラリの配下には.NETプラットフォームごとにDLLが格納されています。.netstadard2.0用のものを使うことでMeta QuestなどのAndroidでも利用可能になります。

```powershell
& {
(Get-ChildItem .\[任意のパス]\ -Include *netstandard2.0* -Recurse).FullName | Select-string -Pattern '.*lib.*' | foreach {(Get-ChildItem $_.Matches.Value -Include *.dll -Recurse).FullName}| Copy-Item -Destination [保存先:任意のパス]
}
```

次に、Unityで利用するlink.xmlを作成します。これはバイトコードストリップの機能によって Microsoft.AspNetCore.SignalR 内の型が展開されない問題が発生します。SignalRなどの一部の.NET系ライブラリはDIなどを利用する関係で型名で制御している部分がありここを最適化されると処理ができません。link.xmlは以下のコマンドを実行することで先ほど集めたDLLに対して実施可能です。

```powershell
& {
'<linker>'
(Get-childItem [保存先:任意のパス] -Include Microsoft*dll*).Name|foreach {[string]::Concat('  <assembly fullname="',$_,'" preserve="all"/>')}
'</linker>'
} > [保存先:任意のパス]/link.xml
```

以上で、必要なモジュールの収集は完了です。

## SignalR Service(Serverless)を構築する

次にAzure上にSignalRのサーバレスの環境を構築します。
Azureポータルを開きリソースとしてSignalR Serviceを追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/e3b62f8d-cea0-76ca-fc92-8383bdf5a772.png)


特に特別な設定はないのですが、気軽に使える無料枠でServerlessで設定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/b75d0001-fb76-375d-2c83-37a6386cad3e.png)

次にSignalRをServerlessにクライアントから送信するためのAzure Functionsを追加します。

## Azure Functions（Serverless）サービスを追加する

次にAzure Functionsサービスを追加します。SignalR ServiceをServerlessで動作させた場合もSignalR上でHubが必要になります。Webサービスとして公開する場合はVMなどがあるためコードを直接デプロイするのですが、Serverlessの場合はAzure Functionsを利用します。興味がある方は以下のサイトなどのサーバレスでの運用についての情報があります。

https://learn.microsoft.com/ja-jp/azure/azure-signalr/signalr-concept-azure-functions

では早速サービスを構築していきましょう。
まず、Azure PortalからAzure Functions（関数アプリ）をリソースとして追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/d95eac9f-652a-6b1a-38d9-3bb02522a4e3.png)

各種設定は以下のような形で設定します。こちらもServerlessで構成しました。

* ランタイムスタック : .NET
* バージョン : 6
* プランの種類 : 消費量(サーバレス) 

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/eab2f57c-117d-7b6f-e2f7-ec4a4861791d.png)

次にVisual StudioでSignalR Service用のAzure Functionsの実装を行います。

新規にAzure Functionsのプロジェクトを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/54b63266-6454-9a9f-e622-102e133f10cf.png)

任意のプロジェクト名を入力してください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/53ff652f-50fb-8a40-b223-5771b95ca807.png)

次に関数アプリの構成についての設定を行います。ここは先ほど作ったサービスに合わせて設定してください。

* Function worker : .NET 6.0(長期的なサポート)
* Function : SignalR
* Authorization level : Function
* Hub name : 任意のHub名

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/10eb2675-dffe-8692-0b6d-370d3fdc8583.png)

次に依存関係のあるサービスの接続を設定します。この設定をしておくとAzure FunctionsからSignalR Serviceへの接続時の情報を連携して設定することが可能になります。
今回は先ほど作ったSignalRのインスタンス(qiitaadventcalendar)を選択しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/b4417663-c164-4aa5-80d0-f6bf2dce69dc.png)

次に進むと自動的に接続文字列を定数として定義してくれます。便利ですね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/b79c0a91-5317-5139-aaf5-8b8a148b5fc2.png)

最後に設定に対する概要が出てきます。完了を押してプロジェクトの作成を進めます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/d8dcf064-13f4-d1cf-08f1-894b721e5547.png)

プロジェクトが開いてしばらくするとFunction1.csファイルができていると思います。とりあえずこれは消してしまって、以下のクラスを追加してください。Azure Functions上にSignalR ServiceのHubを構築するコードです。注意点として**Hub名＝クラス名**にしておかないと正しく動作しないです。

```csharp
// // Copyright (c) 2022 Takahiro Miyaura
// // Released under the MIT license
// // http://opensource.org/licenses/mit-license.php

using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Extensions.SignalRService;
using Microsoft.Extensions.Logging;

namespace QiitaAdventCalendar;

public class [任意のHub名] : ServerlessHub
{
    [FunctionName("negotiate")]
    public static SignalRConnectionInfo GetSignalRInfo(
        [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req,
        [SignalRConnectionInfo(HubName = "[任意のHub名]")] SignalRConnectionInfo connectionInfo)
    {
        return connectionInfo;
    }

    [FunctionName(nameof(UpdatePos))]
    public async Task UpdatePos([SignalRTrigger] InvocationContext invocationContext, string message, ILogger logger)
    {
        await Clients.All.SendAsync("UpdatePos", message);
    }
}
```

実装が終わったら、Azure Funxtionsに関数を登録します。先ほど作ったAzure Functionsのプロジェクトのメニューから[発行]を行います。発行先はAzureなので[Azure]を選択して次へ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/6be99fdf-7269-347d-6854-f0b48f7606da.png)

ホストのタイプを選択します。これはすでに作っているAzure Functionsと同じものを選択してください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/7eacff84-7c1b-fc9d-979c-e275ffa359a4.png)

インスタンスの選択画面が出てくるので先ほど作成したAzure Functionsサービスを選択し完了します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/4f14f3f6-22e0-44f6-46e9-dfc601bbe0af.png)

最後に発行ボタンを押してAzureにデプロイしましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/4bfc5dfb-9582-b688-bd25-1134da30df63.png)

デプロイが正常に完了したら、Azure Portalに接続し、デプロイした関数のキーを控えておきます（後でつかう）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/3d706a22-fde8-2caa-1b81-f3fe087fd86d.png)


## Unityで適当なアプリを作る

では、早速UnityでHoloLensなアプリを作成したいと思います。今回はHoloLens上で表示されるオブジェクトの動きをUnity Editor上のコントローラから制御するような実装を試してみたいと思います。

### 空のUnityプロジェクトを作成する
まずは空のUnityプロジェクトを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/ccd8dc67-2790-2a32-d280-7bfdf076212e.png)

### [Mixed Reality Feature Tool]を起動し Mixed Reality OpenXR Pluginをプロジェクトに適用する

次にHoloLens向けのOpenXR Puluginを適用します。

https://learn.microsoft.com/ja-jp/windows/mixed-reality/develop/unity/welcome-to-mr-feature-tool

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/37bed632-d8d8-16ed-1315-f71dced74a26.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/a848165a-22da-e9af-f398-249ab0bb8945.png)

### 必要なパッケージを導入する

[Window]-[Package Manager]を開いて必要なパッケージを導入します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/9978fc53-ced8-106d-aadc-b450355d0224.png)

    * XR Plugin Management
    * XR Interaction Toolkit
    * OpenXR Plugin

### SignalRのライブラリをコピーする

先ほどの手順で作った.netstandard2.0向けのSignalRのライブラリとlink.xmlをAssetsフォルダ内にコピーします。
Editorを開きっぱなしでやるとエラーが出るかもしれないですが特に問題はないです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/d1208206-feda-9894-0a96-c3b5b447930c.png)

### スクリプトを作成する

次にSignalRを使うためのコードを使用します。
SignalRに接続するためにはトークンの生成が必要になります。この実装については以下のServiceUtils.csがそのまま利用可能できます。

https://github.com/aspnet/AzureSignalR-samples/blob/main/samples/Serverless/ServiceUtils.cs

次に、SignalRの接続と受信についてのコードを書きます。

```csharp
// // Copyright (c) 2022 Takahiro Miyaura
// // Released under the MIT license
// // http://opensource.org/licenses/mit-license.php

using System;
using System.Net.Http;
using Microsoft.AspNetCore.SignalR.Client;
using Newtonsoft.Json;
using UnityEngine;

public class SignalRClient : MonoBehaviour
{

    private const string MethodName = "UpdatePos";

    private const string FunctionsURL =
        "https://qiitaadventcalendar.azurewebsites.net/api?code=[関数キー];


    private Rootobject _jsonData;

    private Vector3 _updatePos;

    private bool isSending;

    public Transform LinkObj;

    private HubConnection Connection { get; set; }

    // Start is called before the first frame update
    private void Start()
    {
        try
        {
            Connection = new HubConnectionBuilder().WithUrl(FunctionsURL).Build();

            Connection.On<string>(MethodName, data =>
            {
                _jsonData = JsonConvert.DeserializeObject<Rootobject>(data);
                _updatePos = new Vector3(_jsonData.x, _jsonData.y, _jsonData.z);
            });
        }
        catch (Exception e)
        {
            Debug.Log(e.ToString());
        }

        if (LinkObj != null) _updatePos = LinkObj.position;
        Connect();
    }

    // Update is called once per frame
    private void Update()
    {
        if (LinkObj != null) LinkObj.position = _updatePos;
    }

    public async void Connect()
    {
        if (Connection.State is HubConnectionState.Connected or HubConnectionState.Connecting) return;

        try
        {
            await Connection.StartAsync(); // '/negotiate' から接続情報を取得して接続
            Debug.Log("Connected!");
        }
        catch (Exception e)
        {
            Debug.Log(e.ToString());
        }
    }

    public async void SendData(Transform transform)
    {
        if (isSending || Connection.State != HubConnectionState.Connected) return;
        isSending = true;
        var serializeObject = JsonConvert.SerializeObject(new Rootobject
            { x = transform.position.x, y = transform.position.y, z = transform.position.z });
        await Connection.InvokeAsync<string>(MethodName, serializeObject);
        isSending = false;

        Debug.Log("SendData" + serializeObject);
    }


    public class Rootobject
    {
        [JsonProperty("x")]
        public float x;

        [JsonProperty("y")]
        public float y;

        [JsonProperty("z")]
        public float z;
    }
}
```

SignalRに情報を送信するためのスクリプトも追加しましょう。
こちらは時間でオブジェクトを円運動するように仕込み、一定間隔でSignalR Serviceに円運動する自分の位置を送信します。

```csharp
// Copyright (c) 2022 Takahiro Miyaura
// Released under the MIT license
// http://opensource.org/licenses/mit-license.php
using UnityEngine;

public class MoveAndSend : MonoBehaviour
{
    //初期位置 
    private Vector3 _initialPos;

    //タイマー
    private float _timer;

    //SignalR クライアント
    public SignalRClient Client;

    //半径
    public float radius = 1f;

    //SignalRに送信する間隔
    public float SendInterval = 0.5f;

    //移動スピード
    public float speed = 5f;

    private void Start()
    {
        _initialPos = transform.position;
    }

    private void Update()
    {
        var x = radius * Mathf.Sin(Time.time * speed);
        var y = radius * Mathf.Cos(Time.time * speed);

        transform.position = new Vector3(x + _initialPos.x, y + _initialPos.y, _initialPos.z);

        _timer += Time.deltaTime;
        if (_timer > SendInterval)
        {
            _timer = 0f;
            Client?.SendData(transform);
            Debug.Log("SendData"+ transform.position);
        }
    }
}
```

### シーンを作成する（送信用）

次に送信用のシーンを作成します。送信用のシーンはUnity Editorでとりあえず動かすことを想定しています。
まず空のシーンを作成します。Main CameraのPositionを(0,0,0)に設定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/d30696ab-6984-c059-1c68-54069b9ce685.png)

空のオブジェクトを用意し、SignalRClientをドラッグ＆ドロップでコンポーネントとして追加します。
各パラメータの設定は不要です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/96c3b46d-2500-3475-4ada-51ecfdad758a.png)

次にCubeを追加します。positionを(0,0,0.7),Scale(0.2,0.2,0.2)で設定し、MoveAndSendをドラッグ＆ドロップでコンポーネントとして追加します。パラメータには先ほど作ったSignalRClientを設定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/c5fbc70c-880f-7891-99db-ea74149b65a3.png)

この時点で一度実行します。接続が成功し、データが送信できていることを確認してください。うまくいかない場合は接続文字列を確認してください。

### シーンを作成する（HoloLens用）

次に送信用のシーンを作成します。送信用のシーンはUnity Editorでとりあえず動かすことを想定しています。
まず空のシーンを作成します。Main CameraのPositionを(0,0,0)に設定します。また、[Clear Flags]を[Solid Color]、[Background]を(0,0,0,0)にします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/d30696ab-6984-c059-1c68-54069b9ce685.png)

さらに、[Tracked Pose Driver]を追加して[Position Input],[Rotation Input]にHMDに入力を紐づけます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/f363f574-8659-f36a-585a-aada8044025f.png)


次にCubeを追加します。positionを(0,0,0.7),Scale(0.2,0.2,0.2)で設定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/95b6c4d1-1c31-672d-a1a5-0f09b3976417.png)



次に空オブジェクトを作成しSignalRClientをドラッグ＆ドロップでコンポーネントとして追加します。
パラメータには先ほど作成したCubeを設定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/f249afa3-66ba-9a7c-754f-2cf775edd662.png)


最後にCapabilitiesの設定を行います。[Edit]-[Project Settings...]を開き[Player]-[Publish Settings]-[Capabilities]のネット接続関連にチェックを入れます。

* InternetClient
* InternetClientServer
* PrivateNetworkClientServer 

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/cf0b553e-f02e-d1ca-2c7c-9a7438d731fd.png)

後はHoloLensにデプロイしてアプリを実行し、Unity Editorで送信用のシーンを実行すると時々Cubeが動くのを確認できると思います。

# まとめ

今回はSignalR ServiceをサーバレスでHoloLensで使用する方法を紹介しました。ポイントはSignalRのライブラリを「.netstandard2.0にしておくこと」、「link.xmlによってバイトコードストリップの機能によって Microsoft.AspNetCore.SignalR 内の型が展開されない問題する」です。これによって、Android機でもSiagnalRを活用できるので、HoloLensとMeta Questで同期したコンテンツを作ったいといったちょっとおもしろいことも可能になります。これはほかのライブラリにも応用が利く手段ですのでぜひいろいろな開発に役立ててみてください。