xin9le.net

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

ASP.NET Core SignalR を試す

これまで ASP.NET Core には SignalR が提供されていませんでしたが、最近ようやく利用できるようになってきたということで実際に動かして試してみました。今回は、空プロジェクトから簡易チャット機能を動作させるところまでを紹介します。

以下に公式ブログに getting started がありますが、空プロジェクトからの開始ではないのでよりシンプルにしてみます。

Step.1 : 環境構築

まず、以下のセットアップを行いましょう。

Step.2 : プロジェクトの準備

次に Visual Studio で ASP.NET Core テンプレートから空プロジェクトを作成します。

f:id:xin9le:20180312011235p:plain

f:id:xin9le:20180312011620p:plain

続いて、npm (Node Package Manager) を用いてクライアント実装で利用する JavaScript ライブラリを取得します。以下のコマンドを入力しましょう。

npm install @aspnet/signalr 

上記コマンド実行によってインストールされた .js ファイル群を wwwroot 配下にコピーします。手コピが面倒ですが、そういうものということにしましょう。

コピー フォルダパス
C:\Users\<ユーザー名>\node_modules\@aspnet\signalr\dist\browser
<プロジェクトフォルダ>\wwwroot\libs\signalr

最終的なプロジェクト構造は以下のようになる想定です。

f:id:xin9le:20180312040050p:plain

Step.3 : Hub (サーバー側) を作成

チャット機能のサーバー側実装として Hub を作成します。Hub は MVC でいうところの Controller に当たる部分です。クライアントからのリクエストを処理し、接続されているユーザーにデータを返します。今回は簡易チャット機能なので、以下のような実装をします。

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;

namespace SignalRSample.Hubs
{
    public class ChatHub : Hub
    {
        public Task Broadcast(string message)
        {
            //--- 接続されている全ユーザーにメッセージを配信
            var timestamp = DateTime.Now.ToString();
            return this.Clients.All.SendAsync("Receive", message, timestamp);
        }
    }
}

続いて、ASP.NET Core アプリケーションで SignalR の機能を有効にします。Startup.cs に以下のようなコードを記述します。

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using SignalRSample.Hubs;

namespace SignalRSample
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddSignalR();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseDefaultFiles();
            app.UseStaticFiles();
            app.UseSignalR(x => x.MapHub<ChatHub>("/chat"));
        }
    }
}

Step 4 : クライアント側を作成

メインとなるページ (index.html) を wwwroot 直下に作成します。中身は以下のような感じにします。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>SignalR Sample</title>
</head>
<body>
    <form>
        <input type="text" id="message" />
        <button type="submit" id="button">Send</button>
    </form>
    <div id="messages"></div>

    <script src="libs/signalr/signalr.js"></script>
    <script src="scripts/chat.js"></script>
</body>
</html>

次に、サーバーと接続してデータのやりとりをする処理を実装します。ここでは wwwroot\scripts\chat.js として作成します。

//--- ChatHub とのコネクションを生成
const connection = new signalR.HubConnection('/chat');

//--- 受信したときの処理
connection.on('Receive', (message, timestamp) => {
    const item = document.createElement('li');
    item.innerHTML = "<div>" + timestamp + " - " + message + "</div>";
    document.getElementById('messages').appendChild(item);
});

//--- ボタンをクリックしたらデータを送信
document.getElementById('button').addEventListener('click', event => {
    const message = document.getElementById('message').value;
    connection.invoke('Broadcast', message).catch(e => console.log(e));
    event.preventDefault();
});

//--- 接続を確立
connection.start().catch(e => console.log(e));

Step.5 : 実行

実装が完了したので実行してみましょう。複数のブラウザ間でメッセージのやり取りができることが確認できると思います。

f:id:xin9le:20180312035722p:plain

細かい説明は省略していますが、とりあえず Hello World ができれば大きな一歩!

4 色オセロ対決 again - 仙台 IT 文化祭 2017

少し遅くなってしまいましたが...!10/28 (土) ~ 10/29 (日) にかけて仙台 IT 文化祭というイベントに参加/登壇してきました。人生初めての仙台、人生初めての東北。新しいところに踏み入れるのは不思議な緊張感があっていいものですね!

ドキドキ・ライブコーディング対決 出張編

いつもは北陸の勉強会で行っていて、若干恒例になりつつある C# 大好き MVP によるドキドキ・ライブコーディング対決 !!。プログラミングを見て/聞いて/一緒に考えて楽しむエンターテイメントとして行っています。今回は出張編と題して、年始に BuriKaigi 2017 in Toyama で行ったのと同じ 4 色オセロ対決 をメインに行いました。お品書きはこんな感じ。

  • ライブコーディング対決
    • FizzBuzz
    • 閏年の判定
    • 2017/10 のカレンダー表示
  • 4 色オセロ対決
    • 各自のアルゴリズム紹介
    • 実際に勝負

ライブコーディング対決って言っても、問題が簡単だと思うじゃないですか!でも案外瞬間的には出てこないもので、出題されて 2、3 分で答えを出すのって案外難しいんです。出題される問題を本当に知らないので、本当に毎回テンパります。でもきっとそれが面白く映るんだと思います。実際ほとんどの方が笑ってくださっていましたし、所変えてもポジティブな感想や反響は非常に多かったです。セッションを聞いて、プログラミングを始めるために Visual Studio をインストールした!という方が出たくらい!

4 色オセロのアルゴリズム

さて、今回のメインだった 4 色オセロ対決。前回の対決での僕はと言えば、4 色オセロの強い AI を作ることができず、結果魂を悪魔に売って強いと思われる人の AI を勝手に使うという手法に出ました...w これは正直全然良くないアプローチで、確実に勝てるとは限りませんでした (実際負けた)。

なので今回は考えを大きく改めて、勝ち筋を再現するというアプローチを取ることにしました。これは以下のような発想で作ったものです。

  • 自分の手を乱数で決定
  • 自分の手はすべて履歴として記録
  • 他人のアルゴリズムと事前に 1000 回ほど戦っておく
    (このぐらいやると大体数十回は勝つ)
  • 勝った手筋のうち、最終的に一番自分の石が多く盤面に残っている手筋を選択
  • 実際の対決の際には、選択された手筋をそのままトレースして打つ

この手法は、比較的弱いサンプル AI に対して以下のような大勝することもできました。

f:id:xin9le:20171104032018p:plain

f:id:xin9le:20171104032026p:plain

ただし、これには相手が自分の手に対して必ず同じ答えを返してくるという前提が必要です。もう少し別の言い方をすれば、相手が乱数で決まるアルゴリズムを使ってこないことが条件となります。しかし、これはエンジニア心理としてほとんど発生しないと踏みました。この手のアルゴリズムを考える場合、大抵のエンジニアは自分の手を神に委ねるような真似をしません。「お前がそう打つなら、俺はこう打ってやるぜ」を地で行きます。乱数のないアルゴリズムであれば自分の手に対して毎回同じ答えを返すので、事前に評価された結果と同じ未来が訪れます。あとはランダムに打つ自分のアルゴリズムが試行回数内に勝つことを祈るのみですw

本番では別のところに予期せぬ問題 *1 が発生して上手くいきませんでしたが、イベント終了後に不整合を修正したら僕の AI が勝ちました!

我々が勝手に考えたルールの 4 色オセロなので、定石なんてものはありません。また、総当たりに近い計算をしてしまうと (計算量が多くなってしまうために) 現実的な時間で結果を出せなくなってしまいます。そういったところに対して、勝率と実装コストのバランス両方を考慮したアルゴリズムを考えられたと思います。

セッション資料

関連記事

東北関連の与太話

ずいぶんと話を戻せば、僕がプログラミング関連の勉強を頑張るようになったのは東北大震災が大きなキッカケのひとつでした。当時の僕は Windows のデスクトップアプリしか作ることができず、Web/クラウドの知識も一切なかったので、エンジニアであるにも関わらずプログラムを使った即効性のある支援が一切できませんでした。それがとっても悔しくって Web/クラウド関連に触れるようになりました。それから数年経って東北の地に行って、何もできなかったあの頃を懐かしいなぁと思うのと同時に、もうちょっと頑張らなきゃなって少しヤル気が出ました。

*1:@AILight さんの実装との相性問題があり、事前評価で使う AI と実行時の AI が異なってしまうという不具合

gRPC / MagicOnion 入門 (13) - 送受信されているデータを可視化する

gRPC に限った話ではありませんが、ネットワークを介して送受信しているデータをダンプして目視確認したいことは頻繁にあります。このようなとき、たいてい以下のような方法が採られます。

用途 実装
個別 ダンプしたい箇所にデバッグコードを仕込む
全体 全 API の入口/出口となる箇所にデバッグコードを仕込む

MagicOnion には後者 (全通信内容をダンプする) の機能が標準で用意されているので、今回はこれを利用してみます。

ダンプ用のロガーを仕込む

方法は非常に簡単で、MagicOnionEngine の生成時にロガーを仕込んだオプションを設定するだけです。

using System;
using Grpc.Core;
using Grpc.Core.Logging;
using MagicOnion.Server;

namespace MagicOnionSample.Service
{
    class Program
    {
        static void Main()
        {
            //--- gRPC のログをコンソールに出力
            GrpcEnvironment.SetLogger(new ConsoleLogger());

            //--- MagicOnion 側のログを gRPC のログに流し込む
            //--- そのとき名前付き (JSON 形式) でデータをダンプ
            var logger = new MagicOnionLogToGrpcLoggerWithNamedDataDump();
            var options = new MagicOnionOptions(true){ MagicOnionLogger = logger };
            var service = MagicOnionEngine.BuildServerServiceDefinition(options);

            //--- いつも通りの起動
            var port = new ServerPort("localhost", 12345, ServerCredentials.Insecure);
            var server = new Server(){ Services = { service }, Ports = { port } };
            server.Start();  // launch gRPC server
            Console.ReadLine();
        }
    }
}

実行してみる

これでダンプの準備は整いました。では、以下のような API で通信してみましょう。

using MagicOnion;

namespace MagicOnionSample.ServiceDefinition
{
    public interface ISampleApi : IService<ISampleApi>
    {
        UnaryResult<double> Sample(Vector2 value);
    }
}
using System;
using MagicOnion;
using MagicOnion.Server;
using MagicOnionSample.ServiceDefinition;

namespace MagicOnionSample.Service
{
    public class SampleApi : ServiceBase<ISampleApi>, ISampleApi
    {
        public async UnaryResult<double> Sample(Vector2 value)
        {
            //--- テキトーにピタゴラスでも
            var x2 = value.X * value.X;
            var y2 = value.Y * value.Y;
            return Math.Sqrt(x2 + y2);
        }
    }
}
using System;
using System.Threading.Tasks;
using Grpc.Core;
using MagicOnion.Client;
using MagicOnionSample.ServiceDefinition;

namespace MagicOnionSample.Client
{
    class Program
    {
        static void Main() => MainAsync().Wait();

        static async Task MainAsync()
        {
            var channel = new Channel("localhost", 12345, ChannelCredentials.Insecure);
            var client = MagicOnionClient.Create<ISampleApi>(channel);

            //--- こんな感じのデータを投げてみる
            var v = new Vector2(3, 4);
            var result = await client.Sample(v);

            Console.ReadLine();
        }
    }
}

実行すると以下のようなログが出力されます。

D0911 13:58:21.959994 BeginInvokeMethod type:Unary method:/ISampleApi/Sample size:11 dump:{"X":3,"Y":4}
D0911 13:58:21.979015 EndInvokeMethod type:Unary  method:/ISampleApi/Sample size:9 elapsed:86.2561  dump:5

たった 1 行 2 行の簡単なログですが、ここから以下のことが読み取れます。

  • 通信した時刻
  • API に入ったときか出て行くときか
  • 通信方法は何か (Unary / ServerStreaming / ClientStreaming / DuplexStreaming)
  • どの API にアクセスしたか
  • データサイズはどれだけか
  • API の処理時間
  • データをダンプした結果

デバッグ時に非常に役立つと思うので、ぜひ使ってみてください :)

gRPC / MagicOnion 入門 (12) - ログを出力する

アプリケーション開発においてログの出力は非常に重要です。どこで、どのようなことが起こったのかを知る手がかりになります。gRPC には標準でログを出力するための機構が備わっているので、今回はそれを使ってログ出力をしてみます。

コンソールにログを出力する

とりあえず、最も簡単なコンソール画面へのログ出力をしてみましょう。まず準備として、以下のようにロガーを設定します。

using System;
using Grpc.Core;
using Grpc.Core.Logging;
using MagicOnion.Server;

namespace MagicOnionSample.Service
{
    class Program
    {
        static void Main()
        {
            //--- コンソール画面へログ出力をするように設定
            GrpcEnvironment.SetLogger(new ConsoleLogger());

            //--- いつもの gRPC サーバー起動
            var service = MagicOnionEngine.BuildServerServiceDefinition();
            var port = new ServerPort("localhost", 12345, ServerCredentials.Insecure);
            var server = new Server(){ Services = { service }, Ports = { port } };
            server.Start();
            Console.ReadLine();
        }
    }
}

ConsoleLogger は gRPC にビルトインされているので、すぐに利用することができます。

次にログを出力してみましょう。以下のように Debug / Info / Warning / Error で分類して出力することができます。

using System;
using Grpc.Core;
using MagicOnion;
using MagicOnion.Server;
using MagicOnionSample.ServiceDefinition;
using MessagePack;

namespace MagicOnionSample.Service
{
    public class SampleApi : ServiceBase<ISampleApi>, ISampleApi
    {
        public async UnaryResult<Nil> Sample()
        {
            Console.WriteLine("--------------------");
            GrpcEnvironment.Logger.Debug("Debug");
            GrpcEnvironment.Logger.Info("Info");
            GrpcEnvironment.Logger.Warning("Warning");
            GrpcEnvironment.Logger.Error("Error");

            return Nil.Default;
        }
    }
}

実行すると以下のような出力がされます。標準の ConsoleLogger の出力では、先頭文字が 4 種類のメソッドの頭文字になっていることが分かりますね。

f:id:xin9le:20170906002333p:plain

独自のロガーを利用する

GrpcEnvironment.SetLogger には ILogger インターフェースを実装した型を差し込むことができます。独自のロガーを作成すれば、ロガーの差し替えだけで出力先 / 出力形式を差し替えられるので便利です。ここではその一例として、ファイルに対してログ出力するものを実装してみましょう。例えば以下のようになります。

using System;
using System.IO;
using Grpc.Core.Logging;

namespace MagicOnionSample.Service
{
    public class FileLogger : ILogger
    {
        private TextWriter Writer { get; }

        public FileLogger()
        {
            var desktop = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
            var path = Path.Combine(desktop, $"{DateTime.Now:yyyy-MM-dd HH-mm-ss}.log");
            this.Writer = TextWriter.Synchronized(new StreamWriter(path));
        }

        #region ILogger implementations
        public ILogger ForType<T>() => this;
        public void Debug(string message) => this.Log("D", message);
        public void Debug(string format, params object[] formatArgs) => this.Debug(string.Format(format, formatArgs));
        public void Info(string message) => this.Log("I", message);
        public void Info(string format, params object[] formatArgs) => this.Info(string.Format(format, formatArgs));
        public void Warning(string message) => this.Log("W", message);
        public void Warning(string format, params object[] formatArgs) => this.Warning(string.Format(format, formatArgs));
        public void Warning(Exception exception, string message) => this.Warning(message + " " + exception);
        public void Error(string message) => this.Log("E", message);
        public void Error(string format, params object[] formatArgs) => this.Error(string.Format(format, formatArgs));
        public void Error(Exception exception, string message) => this.Error(message + " " + exception);
        #endregion

        private void Log(string severity, string message)
        {
            var text = $"{severity} - {DateTime.Now:yyyy/MM/dd HH:mm:ss.ffffff} | {message}";
            this.Writer.WriteLine(text);
        }
    }
}

これでデスクトップに以下のような結果が書き込まれたファイルが出力されます。

D - 2017/09/06 00:40:03.214088 | Attempting to load native library "C:\Users\t.suzuki\documents\visual studio 2017\Projects\MagicOnionSample\MagicOnionSample.Service\bin\Debug\grpc_csharp_ext.x86.dll"
D - 2017/09/06 00:40:03.236148 | gRPC native library loaded successfully.
D - 2017/09/06 00:40:08.379669 | Debug
I - 2017/09/06 00:40:08.379669 | Info
W - 2017/09/06 00:40:08.379669 | Warning
E - 2017/09/06 00:40:08.379669 | Error

複数のロガーをまとめる

GrpcEnvironment.SetLogger の API シグネチャからも分かりますが、gRPC に設定できるロガーはひとつだけとなっています。しかし、実際にはコンソールに出力しつつ BigQuery にもログを飛ばす、などの運用をしたくなると思います。もちろんそのような独自ロガーを作成しても良いですが、「コンソール出力」と「BigQuery への出力」を分離して実装したいというのが人情でしょう。

そういう要求に応えるため、MagicOnion には CompositeLogger が提供されています。以下のように簡単に利用できます。

GrpcEnvironment.SetLogger(new CompositeLogger
(
    new ConsoleLogger(),
    new FileLogger()
));

単機能の独自ロガーを量産しても大丈夫ですね :)

gRPC / MagicOnion 入門 (11) - 通信可能なデータ量を変更する

gRPC でデータを送受信するにあたり、どこまでのサイズが許容されるのか。今回はそのあたりの制限と、その緩和について見ていきます。

既定の通信データ量の制限

gRPC のフレームワークは 1 度の通信で送受信できるデータ容量に制限を設けています。既定では送信/受信ともに 4MB (= 4194304 bytes) となっています。容量を超えるデータを送信すると、例えば以下のような例外が飛んできます。(= Unity で発生したケース)

例外がスローされました: 'Grpc.Core.RpcException' (Assembly-CSharp.dll の中)
OnAnchor.OnError
Grpc.Core.RpcException: Status(StatusCode=InvalidArgument, Detail="Received message larger than max (10347353 vs. 4194304)")
   at UniRx.Stubs.<>c.<.cctor>b__3_1(Exception ex)
   at UniRx.Operators.DoObservable`1.Do.OnError(Exception error)
 
(Filename: C:/buildslave/unity/build/artifacts/generated/Metro/runtime/DebugBindings.gen.cpp Line: 51)

通信データ量制約の緩和

4MB を超えるデータのやりとりはそう多くはないですが、場合によっては超えるときもあるでしょう。そういうときは ChannelOptions で設定をオーバーライドしてあげましょう。以下のような感じで変更します。

//--- サーバー側
var server = new Server(new []
{
    //--- 送信 / 受信それぞれ個別に設定できます
    new ChannelOption(ChannelOptions.MaxReceiveMessageLength, int.MaxValue),
    new ChannelOption(ChannelOptions.MaxSendMessageLength, int.MaxValue)
});

//--- クライアント側
var channel = new Channel("localhost", 12345, ChannelCredentials.Insecure, new []
{
    //--- こっちも同じ
    new ChannelOption(ChannelOptions.MaxReceiveMessageLength, int.MaxValue),
    new ChannelOption(ChannelOptions.MaxSendMessageLength, int.MaxValue),
});

ポイントとしてはサーバー側もクライアント側も両方設定する必要があるということです。それはそうですよね。サーバー側が 4MB 以上のデータを送信できたとしても、クライアント側で受け付けない設定になっていれば意味がありません。各アプリケーションや通信のシナリオに応じて、適切に設定してあげましょう!