xin9le.net

Microsoft の製品/技術が大好きな Microsoft MVP な管理人の技術ブログです。

3 秒間押し続けたらイベント発火するボタンを作る

Twitter で @amay077 さんが以下のような内容を呟いていたのを見て、頭の体操と思って Rx を使って解決してみました。

ボタンのイベントが取れれば動作確認できるので、今回は雑に Windows Forms で試してみます。WPF でも Xamarin でも同様なことはできるでしょう。

追記

以下の実装でバグ報告があったので修正しました...w

ざっくり実装

まずマウスイベントを Rx 化します。イベントをハンドラのまま扱おうとするとだいぶ面倒ですが、シーケンスとして扱えるようにすると一気に楽になれます。

public static class ControlExtensions
{
    public static IObservable<MouseEventArgs> MouseDownAsObservable(this Control control)
        => Observable.FromEvent<MouseEventHandler, MouseEventArgs>
        (
            handler => (sender, e) => handler(e),
            handler => control.MouseDown += handler,
            handler => control.MouseDown -= handler
        );

    public static IObservable<MouseEventArgs> MouseUpAsObservable(this Control control)
        => Observable.FromEvent<MouseEventHandler, MouseEventArgs>
        (
            handler => (sender, e) => handler(e),
            handler => control.MouseUp += handler,
            handler => control.MouseUp -= handler
        );
}

この MouseDownMouseUp のイベントを Zip メソッドで対になるようにし、それぞれのイベントが発火された時間の差分を取ります。これが一定時間を超えていれば OK ですね!ちょっと汎用化して拡張メソッドとして切り出すと以下のような感じになるでしょう。

// メソッド名がこんなのでいいかはさておき...
public static IObservable<Unit> ClickIf(this Control control, TimeSpan threshold)
{
    var down = control.MouseDownAsObservable().Select(_ => DateTimeOffset.Now);
    var up = control.MouseUpAsObservable().Select(_ => DateTimeOffset.Now);
    return down
        .Zip(up, (downTime, upTime) => upTime - downTime)  // 経過時間を算出
        .Where(x => x >= threshold)  // 一定時間を超えていたら発火
        .Select(_ => Unit.Default);
}

使ってみる

上記のように実装しておけば、こんなに簡単に実装できます。Rx を使えば押し続けないと発火しないボタンも簡単に作れちゃいますね!

public partial class MainForm : Form
{
    public MainForm()
    {
        this.InitializeComponent();

        this.button
            .ClickIf(TimeSpan.FromSeconds(3))  // 3 秒押し続けたら
            .Subscribe(_ => Debug.WriteLine("fire!!"));  // 発火!
    }
}

Unity で ASP.NET Core SignalR を利用する

前回 に引き続き今回も SignalR ネタです。今回は Unity で ASP.NET Core SignalR を動かしてみようと思います。「そんなことできるんだっけ?」ともしかしたら思われるかもしれませんが、実は以下の理由によりできてしまいます!

  • Unity 2018.1 以降は .NET Standard 2.0 に対応している
  • ASP.NET Core SignalR は .NET Standard 2.0 でできている

実は ASP.NET Core SignalR は .NET Core 依存ではない、というのが特にポイントが高いところです。これにより (基本的には) Plugins フォルダに対象となる dll を入れるだけで利用できるようになります。

動かすのに必要な dll

以下の dll を Plugins フォルダに追加すれば OK です。

- Microsoft.AspNetCore.Connections.Abstractions.dll
- Microsoft.AspNetCore.Http.Connections.Client.dll
- Microsoft.AspNetCore.Http.Connections.Common.dll
- Microsoft.AspNetCore.Http.Features.dll
- Microsoft.AspNetCore.SignalR.Client.Core.dll
- Microsoft.AspNetCore.SignalR.Client.dll
- Microsoft.AspNetCore.SignalR.Common.dll
- Microsoft.AspNetCore.SignalR.Protocols.Json.dll
- Microsoft.Extensions.DependencyInjection.Abstractions.dll
- Microsoft.Extensions.DependencyInjection.dll
- Microsoft.Extensions.Logging.Abstractions.dll
- Microsoft.Extensions.Logging.dll
- Microsoft.Extensions.Options.dll
- Microsoft.Extensions.Primitives.dll
- Newtonsoft.Json.dll
- System.Buffers.dll
- System.IO.Pipelines.dll
- System.Memory.dll
- System.Runtime.CompilerServices.Unsafe.dll
- System.Threading.Channels.dll
- System.Threading.Tasks.Extensions.dll

標準の Unity では NuGet から dll を引っ張ってくる方法がありません。なので地道に必要な dll を探して追加していくことになります。地味ですがこればっかりは仕方ありません。僕は最も重要そうな dll (今回の場合 Microsoft.AspNetCore.SignalR.Client.dll) をまず追加してみて、Console に出るエラーを見ながら必要な dll をひとつずつ追加していく方法で解決していきました。

(もしかしたら NuGetForUnity という非公式のパッケージ管理システムを使えば一発解決かもしれませんが、試したことはないです...)

実際に動かしてみる

実際に動作させてみると以下のようになります。ちゃんと通知が飛んでいますね!

f:id:xin9le:20190503215724g:plain

Unity 側への通知が遅れているように見えますが、WPF アプリを手元の PC 環境で、Unity を Azure 上の VM で動作させている (= Remote Desktop で繋いでいる) ためです。実際には手元で動かすとほぼ同じタイミングで通知されるので安心してください。

IL2CPP 環境下で利用する

最近の Unity は UWP アプリを開発しようとすると「今後は IL2CPP しかサポートしないから気をつけろよ」のような警告が出ます。カジュアルに (?) こんなことを言ってきますが IL2CPP ビルドには結構ハマりポイントがある ので注意が必要です。最たるものとして IL2CPP ビルドには バイトコードストリップ という大きな特徴があります。要は静的構文解析の結果として利用されていない型は C++ コードとして展開されないというものです。

  • 明示的に型を利用しない限り消える
  • リフレクション経由でインスタンス化されているものは型を「利用していない」判定される

つまり実際には利用している型も条件次第で C++ コードとして展開されない場合があるということです。こうなると実行時エラーとなるため非常に厄介です。そしてこれは ASP.NET Core SignalR を利用するときも影響して例外ではなく、例えば実行時に以下のようなエラーが出ます。

InvalidOperationException: A suitable constructor for type 'Microsoft.AspNetCore.SignalR.Client.HttpConnectionFactory' could not be located. Ensure the type is concrete and services are registered for all parameters of a public constructor.
  at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateConstructorCallSite (System.Type serviceType, System.Type implementationType, Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteChain callSiteChain) [0x00000] in <00000000000000000000000000000000>:0 
  at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.TryCreateExact (Microsoft.Extensions.DependencyInjection.ServiceDescriptor descriptor, System.Type serviceType, Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteChain callSiteChain) [0x00000] in <00000000000000000000000000000000>:0 
  at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.TryCreateExact (System.Type serviceType, Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteChain callSiteChain) [0x00000] in <00000000000000000000000000000000>:0 
  at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateCallSite (System.Type serviceType, Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteChain callSiteChain) [0x00000] in <00000000000000000000000000000000>:0 
  at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngine.CreateServiceAccessor (System.Type serviceType) [0x00000] in <00000000000000000000000000000000>:0 
  at System.Func`2[T,TResult].Invoke (T arg) [0x00000] in <00000000000000000000000000000000>:0 
  at System.Collections.Concurrent.ConcurrentDictionary`2[TKey,TValue].GetOrAdd (TKey key, System.Func`2[T,TResult] valueFactory) [0x00000] in <00000000000000000000000000000000>:0 
  at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngine.GetService (System.Type serviceType, Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope serviceProviderEngineScope) [0x00000] in <00000000000000000000000000000000>:0 
  at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngine.GetService (System.Type serviceType) [0x00000] in <00000000000000000000000000000000>:0 
  at Microsoft.Extensions.DependencyInjection.ServiceProvider.GetService (System.Type serviceType) [0x00000] in <00000000000000000000000000000000>:0 
  at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetService[T] (System.IServiceProvider provider) [0x00000] in <00000000000000000000000000000000>:0 
  at Microsoft.AspNetCore.SignalR.Client.HubConnectionBuilder.Build () [0x00000] in <00000000000000000000000000000000>:0 

ちょっと分かりにくいかもしれませんが、IL2CPP によって型情報が欠落したため「適切なコンストラクタなくない?」と言われています。これを回避するためにはちょっとした小細工が必要です。要は IL2CPP に「その型はコンパイル時に消さないで」と指示できれば良いのですが、主に 2 種類の方法があります。

  • 特に利用しなくても良いので new でコンストラクタを呼び出しておく
  • Linker.xml で特別扱いするものをホワイトリストとして明示する

今回の ASP.NET Core SignalR の場合であれば以下の型を除外できれば OK です。こうすれば HoloLens のような IL2CPP 環境下でも ASP.NET Core SignalR を動作させることができるようになります。

- Microsoft.AspNetCore.SignalR.Client.HubConnection
- Microsoft.AspNetCore.SignalR.Client.HttpConnectionFactory
- Microsoft.AspNetCore.SignalR.Protocol.JsonHubProtocol
- Microsoft.Extensions.Logging.LoggerFactory
- Microsoft.Extensions.Options.OptionsFactory<Microsoft.AspNetCore.Http.Connections.Client.HttpConnectionOptions>
- Microsoft.Extensions.Options.OptionsManager<Microsoft.AspNetCore.Http.Connections.Client.HttpConnectionOptions>
- Microsoft.Extensions.Options.OptionsMonitor<Microsoft.Extensions.Logging.LoggerFilterOptions>

ASP.NET Core SignalR でバイトコードストリップが発生するのかと言うと、ASP.NET Core の内部で DI (= Dependency Injection) が利用されているためです。つまりリフレクション経由でインスタンス生成をしているからなのですが、これが IL2CPP と非常に相性が悪いです。なので DI を使っているようなライブラリを利用するときはバイトコードストリップに十分注意を払う必要があります。

Azure Functions + Azure SignalR Service でメッセージを Push 配信する

リアルタイムな双方向通信フレームワークである SignalR にスケーラブルな接続管理を提供する Azure SignalR ServiceGA されて半年ほどが経ちました。これと Azure Functions を利用し、いわゆるサーバーレスアーキテクチャの構成でクライアントにリアルタイムにメッセージを Push 配信することができるようになりました。この構成を作るまでの手順を残します。

以下の公式サンプルが超参考になるのでオススメです。

作りたい構成

全体像としてはザックリと下図のようなものを想定しています。Kinect とか HoloLens などでクライアント側を仮に表現していますが、これは作りたいものによって変えていただければ OK なので、チャットのようなものを想定していただいても良いかと思います。

f:id:xin9le:20190428105723p:plain

クラウド側の作り方

Step.1 : Azure Functions のインスタンスを作る

Azure Functions のインスタンスを作成します。何ということはなく、ただ作るだけです。唯一あるとすればランタイムスタックを「.NET」にすることくらいでしょうか。(ちなみに Linux 環境下では試したことがないので、できるのか確信はありません)

f:id:xin9le:20190428111517p:plain

Step.2 : Azure SignalR Service のインスタンスを作る

次に Azure SignalR Service のインスタンスを作成します。東日本にもすでに来ているのが嬉しいところですね。

f:id:xin9le:20190428112332p:plain

f:id:xin9le:20190428112428p:plain

大事なポイントは Service Mode を Serverless にする ことです。Service Mode についてはしばやんの記事が詳しいのでそっちに譲ります。

Step.3 : Azure SignalR Service の接続文字列を Azure Functions に設定する

Azure Functions から Azure SignalR Service に Push 配信したいメッセージを投げる必要があるわけですが、どの Azure SignalR Service に対して投げるべきなのか、そのエンドポイントを知る必要があります。ということで Azure SignalR Service への接続文字列を Azure Functions に設定してあげます。以下のような感じです。キー名は AzureSignalRConnectionString で固定で、値に接続文字列を入れるのがポイント!

f:id:xin9le:20190428114149p:plain

f:id:xin9le:20190428114729p:plain

ここまでで Azure 側での設定はおしまいです。簡単ですね!

Step.4 : SignalR Service Binding の利用準備

ここからは Azure Functions の実装をしていきます。Visual Studio を開き、Azure Functions の .NET Core テンプレートから開始します。

f:id:xin9le:20190428131836p:plain

Azure Functions と Azure SignalR Service の間は SignalR Service Binding を用いて行います。これは Azure Functions の拡張機能である「入出力バインディング」で実現されており、GitHub にてオープンソースとして公開されています。この NuGet Package をインストールしましょう。

f:id:xin9le:20190428134512p:plain

PM> Install-Package Microsoft.Azure.WebJobs.Extensions.SignalRService

Step.5 : Azure SignalR Service のエンドポイントを問い合わせるメソッドを実装

Push 配信でメッセージを受信したいクライアント (冒頭の図だと HoloLens なので、ここでは HoloLens として話を進めます) は、事前に Azure SignalR Service との間に Connection を張っておく必要があります。ですが、HoloLens は Azure SignalR Service のエンドポイントを知りません。なので、すでにエンドポイントを知っている Azure Functions (Step.3 で接続文字列を指定しましたよね!) に「どこに繋ぎに行ったらいいんだい?」というのを聞くことにします。以下のような感じで実装します。

[FunctionName("negotiate")]  // 'negotiate' で固定
public static SignalRConnectionInfo Negotiate
(
    [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequest _,  // POST で受ける
    [SignalRConnectionInfo(HubName = "sample")] SignalRConnectionInfo info  // 接続先情報を injection してもらう
) => info;  // それを返す

こんな短い実装ですが、ポイントがいくつかあります。これは SignalR の Client ライブラリは POST メソッドで /negotiate という固定の URL で接続先情報を取得するように実装されているためです。

  1. FunctionName 属性に negotiate を指定したメソッドを作る
  2. POST で動作する [HttpTrigger] な関数とする
  3. SignalRConnectionInfo を返す

SignalRConnectionInfo は SignalR Service Binding が提供するもので、メソッドの引数に属性を与えておくとメソッドが呼び出し時に injection してくれます。なのでそれを返すだけの実装で OK です。

Step.6 : メッセージを Push 配信する

最後に Azure Functions で受け取り、それを Azure SignalR Service に投げてメッセージを Push 配信する API を実装します。例えば以下のような感じです。

[FunctionName("broadcast")]  // 任意
public static async Task BroadcastAsync
(
    [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequest request,  // POST で投げることにする
    [SignalR(HubName = "sample")] IAsyncCollector<SignalRMessage> messages
)
{
    var data = await request.ReadAsStringAsync();  // 受け取ったデータを取り出す
    await messages.AddAsync(new SignalRMessage  // 出力バインディングを使って SignalR Service に投げ込む
    {
        Target = "Receive",  // 配信先の 'Receive' を呼び出す
        Arguments = new[] { data },  // Push 配信するデータ
    });
}

重要なポイントは IAsyncCollector<SignalRMessage> の部分で、このインスタンスに AddAsync することで (出力バインディング経由で) SignalR Service にデータを投げ込むことができます。

ここまでできたら Azure Functions にデプロイ!以上でクラウド側の実装もすべておしまいです。簡単ですね :)

クライアント側の作り方

クライアント側は、相手がサーバーレスアーキテクチャだからと言って特別何か難しいことをする必要はありません。今回はサンプルとして WPF で実装してみます。

<Window x:Class="SignalRTestApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        mc:Ignorable="d"
        Title="SignalRTestApp" Height="200" Width="500">
    <Grid Margin="5">
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>

        <Label Name="StatusText" Grid.Row="0" BorderBrush="Red" BorderThickness="1" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" FontSize="30" />
        <StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,5,0,0">
            <Button Content="Connect" Width="75" Click="OnConnectClick" />
            <Button Content="Disconnect" Width="75" Margin="5,0" Click="OnDisconnectClick" />
            <Button Content="Broadcast" Width="75" Click="OnBroadcastClick" />
        </StackPanel>
    </Grid>
</Window>
using System;
using System.Text;
using System.Net.Http;
using System.Windows;
using Microsoft.AspNetCore.SignalR.Client;

namespace SignalRTestApp
{
    public partial class MainWindow : Window
    {
        private const string RootUrl = "https://signalr-test-api.azurewebsites.net";  // 作った Azure Functions の URL
        private HttpClient HttpClient { get; } = new HttpClient();
        private HubConnection Connection { get; }

        public MainWindow()
        {
            this.InitializeComponent();
            this.Connection = new HubConnectionBuilder().WithUrl($"{RootUrl}/api").Build();

            // Azure SignalR Service から Push されてきたメッセージを受信して表示
            // 最初の図の HoloLens 側
            this.Connection.On<string>("Receive", data =>
            {
                this.Dispatcher.Invoke(() => this.StatusText.Content = data);
            });
        }

        private async void OnConnectClick(object sender, RoutedEventArgs e)
        {
            // 最初の図の HoloLens 側
            await this.Connection.StartAsync();  // '/negotiate' から接続情報を取得して接続
            this.StatusText.Content = "Connected!";
        }

        private async void OnDisconnectClick(object sender, RoutedEventArgs e)
        {
            // 最初の図の HoloLens 側
            await this.Connection.StopAsync();  // 切断
            this.StatusText.Content = "Disconnected!";
        }

        private async void OnBroadcastClick(object sender, RoutedEventArgs e)
        {
            // 日付文字列を POST で Azure Functions に投げ込む
            // 最初の図の Kinect 側
            var url = $"{RootUrl}/api/broadcast";
            var now = DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss.fff");
            using (var content = new StringContent(now, Encoding.UTF8))
                await this.HttpClient.PostAsync(url, content);
        }
    }
}

これを実行すると以下のような感じになるはずです。パッと動かす分には全然難しくないですね!

f:id:xin9le:20190429235248g:plain

CloudStructures v2.1.0 released!!

前回に引き続き今回も CloudStructures ネタです。.NET Standard 対応を行ってから来たフィードバックにお応えしたのと、反省点の修正 (?) を行いました。変更点は大きく 3 点あります。

  • RedisLock 型の追加
  • 非同期メソッドとして提供しているコマンドに Async 接尾詞をつける
  • IRedisStructures から DefaultExpiry プロパティを切り出す

RedisLock 型の追加

Grani 時代に同僚のみっちぃ (@mitchydeath) から「DistributedLock を作れない」と言われました。完全に見落としてました...。

ということで、それを可能にするためのコマンド群を RedisLock として追加しました。いい感じにラップすれば C# 8.0 で提供されそうな IAsyncDisposable にも対応できるんじゃないかと思います。

命名規則の変更

これまで CloudStructures は Redis コマンド関連のメソッドに Async の接尾詞をつけていませんでした。非同期メソッドだけど!これは昔の @neuecc さんの blog に書いてありますが、「悩んだけど消した」という歴史的な理由に依ります。

ですが「やっぱり付けた方がいいよね」ということで、破壊的変更 MAX ではありますが付けて回りました。非同期メソッドには Async を付けましょう!

IRedisStructureWithExpiry を新設

RedisStringRedisLock のようなコマンド型は基底インターフェースとして IRedisStructure を持っています。これまでそこに DefaultExpiry プロパティを持っていたのですが、RedisLockRedisLua のような型では DefaultExpiry プロパティを利用していないという現実がありました。これはプロパティを持っているだけ無駄なので、DefaultExpiry プロパティを持っているインターフェースと持っていないインターフェースに分離しました。

public interface IRedisStructure
{
    RedisConnection Connection { get; }
    RedisKey Key { get; }
}

public interface IRedisStructureWithExpiry : IRedisStructure
{
    TimeSpan? DefaultExpiry { get }
}

public readonly struct RedisLock<T> : IRedisStructure{}
public readonly struct RedisString<T> : IRedisStructureWithExpiry{}

これも十分に破壊的変更ですが、あるべき姿により近づくと思って導入しました。

CloudStructures now supports .NET Standard!!

Grani 時代から Redis 操作ライブラリとして長く愛用してきた CloudStructures ですが、残念ながらこれまで .NET Core に対応していませんでした。ただ、こればっかりはさすがの @neuecc 先生もあれもこれもメンテするのは難しいので仕方ない!.NET Core 対応済みの王道ライブラリは StackExchange.Redis だと思いますが、余りにプリミティブ過ぎて使い勝手が良いとは言えません。もはや CloudStructures がないと生きていけない体にされてしまった...(罪深い

f:id:xin9le:20190228223150p:plain

と言うことで意を決して .NET Core 対応をしようと思い立ち、2018 年 6 月頃から勝手に Fork (コピー) してヒッソリと作業を始めました。半年以上プライベートで使っていたのですが、.NET Core 3.0 のリリースも近づいている今、より盛り上げるためにも本家に入れてもらえるように @neuecc 先生に話をして承認いただき、ついに正式に .NET Standard 版となりました!

主な変更点

使用感としてはそこまで変わらないのですが、以前のバージョンと比べるとかなりの破壊的変更が加わっています。リリース当時から比べると Managed な Redis でクラスター環境が提供されるようになったり、依存していたライブラリやフレームワークの環境も相当に変化していて、そういった補完していた部分だったりを今風に適応/アレンジしました。また、近年は C#/.NET もパフォーマンス最適化が命題となってきているので、その辺りにも最大限気を配ってみました。

リファクタリング系

  • RedisSettings / RedisGroupRedisConnection に一本化
  • RedisString に含まれていた Bit 関連のコマンドを RedisBit として切り出し
  • Geo 関連コマンドを RedisGeo として新設
  • RedisClass を削除
    • Grani の中でもほとんど使われてなかった
  • RedisSubject を削除
    • これは元々別パッケージとして提供されていたので一旦削除
    • 今後対応を考えるかも
  • IServerSelector を削除
    • 自前でロードバランスするのではなく Cluster 化された Managed Redis を使う方が時代に即してそう
  • TraceHelper を削除
  • app.config / web.config からの設定読み込み機能を削除
  • Glimpse サポートを削除
    • Glimpse が .NET Core に対応していないため

パフォーマンス改善系

  • RedisString などを class から struct に変更
    • ほとんどのケースでインスタンスの生存期間が非常に短くスコープも狭いため、ヒープアロケーションを避けるべきと判断
  • ラムダ式の変数キャプチャを完全に排除
  • ボックス化を完全に排除
    • 特に ValueConverter 周りの仕組みを思い切り改善

まとめ

CloudStructures を隅から隅まで全部読んで書き換えたこともあって、だいぶ理解が深まりました。パフォーマンス改善関連もすでに世の中で実現されているテクニックのいくつかを真似しているだけですが、そういったところに細心の注意を払うってすごく地道だし大変って実感しました。ほんと、すごく勉強になりました。

あとライブラリの Contributor にしていただいたので、何かあれば Pull Request などいただければ!.NET で Redis を扱うときには是非 CloudStructures を使ってみてくださいね!