xin9le.net

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

ReactiveSignalR を ASP.NET Core SignalR に対応させました

ASP.NET SignalR 時代に作り、ASP.NET Core SignalR が出てからも長らく放置していた ReactiveSignalRASP.NET Core SignalR 向けに作り直しました!ということで、晴れて v1.0 をリリース!バージョン番号なんて特に意味はないんですが...。

ASP.NET SignalR の頃にはクライアント向けサーバー向けのライブラリを用意していたのですが、今回の ASP.NET Core SignalR 対応でサーバー側は削っています。理由はいくつかあるのですが、作ったくせに自分でも使わなかった (!) というあたりに必要性を感じなくなったというのが大きいです。

Available Now!!

すでに NuGet にも放流してあるので、今すぐ利用することができます。

PM> Install-Package ReactiveSignalR

提供する機能

現状以下のふたつだけです。超シンプル!

  • HubConnection.On メソッドを IObservable として返す
  • HubConnection にあるイベントを IObservable として返す

特に前者が大切で、Rx.NET のオペレーターにそのまま接続できるようになるのが大変強いです。

使い方

ASP.NET Core SignalR 標準だとデリゲートを使ったコールバックでしか記述できませんが、ReactiveSignalR を導入すると以下のような感じで書けるようになります。.On と書くだけで捗る!簡単!

// こんな感じでコネクションを作り
var url = "http://localhost:5000/chathub";
var connection = new HubConnectionBuilder().WithUrl(url).Build();

// Rx を使って書く
var subscription
    = connection
    .On<string>("Receive")  // ReactiveSignalR はこの部分を提供
    .Where(x => ...)  // あんなことも
    .Select(x => ...)  // こんなことも
    .ObserveOn(SynchronizationContext.Current)  // スレッドを戻すことも
    .Subscribe(x => ...);  // できます

SignalR ♡ Rx

SignalR のようなサーバー側から Push 配信されるものは Rx と非常に相性が良いです。またサーバーの負荷対策にも Rx を使うことができるので、SignalR を採用するときは Rx のことを少しだけ思い出してあげてください。

(このドキュメントは ASP.NET SignalR 時代のものですが、考え方は共通です)

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

前回 (というか昨夜)「3 秒押し続けたらイベント発火するボタン」を作ってみたのですが、朝起きてみたら早速以下のようなバグが発見されていました...(oh

  • MouseDown 中に「Alt + Tab」で画面切り替える
  • MouseDown 中にボタン外にマウスカーソルを移動して MouseUp する

どちらもイレギュラーな挙動ですが、こういうユーザー操作は簡単に起こり得るものなので対処せねばならぬ...!ということで、少しだけ手直ししてみました。

Click イベントを利用する

Windows Forms / WPF のようなデスクトップアプリ向けの逃げですが、軽いお遊び程度なのでそこはご容赦いただくとして...。標準の Click イベントはボタン外で MouseUp しても発火しませんし、MouseDown 中に「Alt + Tab」をしても問題が起こりません。とても優秀な出来!これをありがたく利用することにして、事前にボタンを押している時間が長いことだけを判定できれば OK というようにしてみます。

前回同様 Rx ベースで書きたいので、以下のように Click イベントをシーケンス化しておきます。

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<EventArgs> ClickAsObservable(this Control control)
        => Observable.FromEvent<EventHandler, EventArgs>
        (
            handler => (sender, e) => handler(e),
            handler => control.Click += handler,
            handler => control.Click -= handler
        );
}

次に MouseDownClick の経過時間を算出します。前回は Zip メソッドを利用していたのですが、今回は CombineLatest に変更します。Zip だと一旦 MouseDown したら次の MouseUp が来るまで待ってしまうのですが、CombineLatest なら最新の値でペアリングするようになります。このとき場合によっては経過時間が負の値になってしまうことがありますが、「一定時間以上経過していること」として判定するので問題になりません。

public static class ControlExtensions
{
    public static IObservable<Unit> ClickIf(this Control control, TimeSpan threshold)
    {
        var down = control.MouseDownAsObservable().Select(_ => DateTimeOffset.Now);
        var click = control.ClickAsObservable().Select(_ => DateTimeOffset.Now);
        return down
            .CombineLatest(click, (downTime, clickTime) => clickTime - downTime)  // 経過時間を算出
            .Where(x => x >= threshold)  // 一定時間を超えていたら発火
            .Select(_ => Unit.Default);
    }
}

ちゃんと動くよ!

内部実装の変更だけなので、前回同様、以下のような感じで利用できます。今度こそ (?) めでたし!(のはず...

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

        this.button
            .ClickIf(TimeSpan.FromSeconds(3))  // 3 秒押し続けたら
            .Subscribe(_ => Debug.WriteLine("CHACCA!!"));  // お前のハートに火をつけろ!
    }
}

おまけ

まったく内容とは関係ないですが、僕の友人が最近 CHACCA というサービスをはじめました。音楽アーティスト支援をこれまでと違った形で行う面白いアイディアなので、気が向いたら見てあげてください :)

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