xin9le.net

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

gRPC / MagicOnion 入門 (15) - Unary 通信中にプッシュ配信

gRPC におけるサーバーからの Push 通知は Server Streaming 通信を用いて行う、というのを以前解説しました。

ですが、この方法では Server Streaming 専用の API を呼び出さないと Push 通知が行われません。一般的には、何か単発の API (gRPC だと Unary) 呼び出しを起点として Push 通知したいというケースがほとんどです。このようなことをしようとした場合、素の gRPC だと結構大変な実装をしなければならないのですが、MagicOnion は簡単に実現するための機能を搭載しているので安心です。

今回はそんな Unary 通信中に Push 配信するための実装方法について見ていきます。

サーバー側

チャット風なものを作る想定で、API の定義を以下のような感じにします。参加 / 退出 / 送信 / 受信の API があるイメージですね。

using System.Threading.Tasks;
using MagicOnion;
using MessagePack;

namespace MagicOnionSample.ServiceDefinition
{
    public interface IChatApi : IService<IChatApi>
    {
        UnaryResult<Nil> Join();  // チャットルームへの参加
        UnaryResult<Nil> Unjoin();  // チャットルームから退出
        UnaryResult<Nil> Send(string message);  // メッセージ送信
        Task<ServerStreamingResult<string>> OnReceive();  // push 配信によるメッセージ受信
    }
}

下はその実装例です。短い割に結構複雑...な印象かもしれません。

using System;
using System.Threading.Tasks;
using MagicOnion;
using MagicOnion.Server;
using MagicOnionSample.ServiceDefinition;
using MessagePack;

namespace MagicOnionSample.Service
{
    public class ChatApi : ServiceBase<IChatApi>, IChatApi
    {
        //--- static に ServerStreamingContext をキャッシュ
        private static StreamingContextRepository<IChatApi> cache;

        public async UnaryResult<Nil> Join()
        {
            //--- ストリーミング情報をキャッシュする入れ物を作る
            var context = this.GetConnectionContext();
            cache = new StreamingContextRepository<IChatApi>(context);

            Console.WriteLine($"{context.ConnectionId} is joined.");
            return Nil.Default;
        }

        public async UnaryResult<Nil> Unjoin()
        {
            //--- ストリーミングを切断
            cache.Dispose();
            cache = null;

            //--- ここはメッセージを出したいだけなので重要じゃない
            var context = this.GetConnectionContext();
            Console.WriteLine($"{context.ConnectionId} is unjoined.");

            return Nil.Default;
        }

        public async UnaryResult<Nil> Send(string text)
        {
            //--- キャッシュしているストリーミング情報 (OnReceive) を使って push 配信
            Console.WriteLine($"ChatApi.Send : {text}");
            await cache.WriteAsync(x => x.OnReceive, text);
            return Nil.Default;
        }

        public async Task<ServerStreamingResult<string>> OnReceive()
        {
            //--- このメソッド (OnReceive) のストリーミング情報を登録
            Console.WriteLine($"Start receiving...");
            var result = await cache.RegisterStreamingMethod(this, this.OnReceive);

            //--- ここは cache.Dispose() されるまで通りません!
            Console.WriteLine($"Stop receiving...");
            return result;
        }
    }
}

ここで重要なのが StreamingContextRepository<TService> という静的キャッシュ機構です。なぜこのようなキャッシュが必要かというと、そもそも gRPC の Server Streaming 接続は Server Streaming API (今回の例でいうと OnReceive) が終わるまでの間 push 配信が可能だからです。裏を返せば OnReceive メソッドが終了したら push 配信はできなくなります

という基本/前提があるので、別 API から push 配信をするためには以下の 2 点を守る必要が出てきます。

  • ストリーミング API (OnReceive) を終了させないで待機させる
  • ストリーミング接続情報を静的キャッシュして、別 API (Send) からアクセスできるようにする

そして、これらの要件をサポートしているのが StreamingContextRepository<TService> です。先の OnReceive メソッド内に記述されている RegisterStreamingMethod でストリーミング接続を登録/記憶させます。このメソッドは StreamingContextRepository.Dispose (今回の例でいうと Unjoin) が呼び出されるまで待機し続けるため、ひとつ目の要件を満たすことができます。こうやって待機させている間に、静的キャッシュを介してサーバー push を行うというわけです。

クライアント側

難しいのはサーバー側だけで、クライアント側の実装は簡単です。サーバー側の API を叩く順番だけ気を付けましょう。

using System;
using System.Threading.Tasks;
using Grpc.Core;
using MagicOnion;
using MagicOnion.Client;
using MagicOnionSample.ServiceDefinition;

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

        static async Task MainAsync()
        {
            //--- いつもの前準備
            Console.Title = "MagicOnionSample.Client";
            var channel = new Channel("localhost", 12345, ChannelCredentials.Insecure);
            var context = new ChannelContext(channel, () => "xin9le");
            await context.WaitConnectComplete();
            var client = context.CreateClient<IChatApi>();

            //--- チャットルームに参加
            Console.WriteLine("Step.1 : Join");
            await client.Join();

            //--- メッセージ受信の登録
            Console.WriteLine("Step.2 : Register receiving");
            var streaming = await client.OnReceive();
            var receiveTask = streaming.ResponseStream.ForEachAsync(x => Console.WriteLine($"Received : {x}"));

            //--- メッセージを投げてみる
            Console.WriteLine("Step.3 : Send 1st message");
            await client.Send("あいうえお");

            Console.WriteLine("Step.4 : Send 2nd message");
            await client.Send("ABCDE");

            //--- チャットルームから退出
            Console.WriteLine("Step.5 : Unjoin");
            await client.Unjoin();

            //--- ちゃんと終わるの待ちましょう
            Console.WriteLine("Step.6 : Waiting for completion");
            await receiveTask;

            Console.ReadLine();
        }
    }
}

実行してみる

実装したものを実行してみると、以下のようなログが表示されます。呼び出される順序を確認してみてください。

f:id:xin9le:20180319013751p:plain

私がこれを勉強したとき、腑に落ちるまで結構時間がかかりました...!けれど、gRPC の Server Streaming の特性とそれをサポートする StreamingContextRepository を押さえられれば大丈夫です。

gRPC / MagicOnion 入門 (14) - 接続ユーザーを特定する

gRPC で通信を行う際、サーバー側でアクセスしてきているユーザーを特定したいケースは多々あります。これを実現する最も基本的で素朴な方法が、gRPC / MagicOnion 入門 (10) - ヘッダーの利用 で解説したヘッダーにユーザー固有の ID を埋め込むことです。

そのままやろうとすると結構手間な実装になりますが、MagicOnion はこれを簡単に実現できるよう ConnectionId の概念をサポートしてくれています。今回はその機能について見ていきます。

クライアントから ConnectionId を送信する

クライアントから ConnectionId を送信する場合、これまで紹介してきた方法と若干違う方法で初期化する必要があります。以下のように ChannelContext でラップしつつ、通信を行います。

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);

            //--- ChannelContext でチャンネルとユーザー固有の ID をラップ
            var connectionId = "なにかしらユーザー固有のID";
            var context = new ChannelContext(channel, () => connectionId);
            await context.WaitConnectComplete();  // 接続待ち

            //--- API クライアントを生成して通信
            var client = context.CreateClient<ISampleApi>();
            var result = await client.Sample();
            Console.ReadLine();
        }
    }
}

サーバー側で ConnectionId を取り出す

サーバー側で ConnectionId を取り出すのは非常に簡単で、以下のようにします。

using MagicOnion;
using MagicOnion.Server;
using MagicOnionSample.ServiceDefinition;
using MessagePack;

namespace MagicOnionSample.Service
{
    public class SampleApi : ServiceBase<ISampleApi>, ISampleApi
    {
        public async UnaryResult<Nil> Sample()
        {
            //--- こんな感じで取り出せます
            //--- たったこれだけ!
            var connectionId = this.GetConnectionContext().ConnectionId;
            return Nil.Default;
        }
    }
}

この ConnectionId の仕組みも、実はヘッダーを利用して行われています。MagicOnion が面倒な部分を上手くラップしてくれていることもあり、簡単に使えるのでオススメです。

ImageMagick でお手軽 TGA → PNG 変換

業務で .tga で納品される大量のファイルを一括して .png に変換したいということがあったので、ImageMagick Converter を使ってチャチャっとやってみた系のメモです。

ImageMagick をダウンロード

下記サイトからダウンロードできます。各々の OS 環境に合わせてダウンロードしてください。Windows 環境の場合は portable 版を落とすのがお手軽でよさそう。

f:id:xin9le:20180313004617p:plain

バッチ処理で一括変換

ダウンロードした ImageMagick の .zip ファイルを解凍して、中にある convert.exe を利用しましょう。特定フォルダに含まれる .tga ファイルを一括で .png にする場合、例えば以下のようなバッチファイルになるでしょう*1

@echo off

set CONVERTER=ImageMagick-7.0.7-26-portable-Q16-x64\convert.exe
set SOURCE=<tgaのあるフォルダパス>
set TARGET=<pngの出力先フォルダパス>

for /r %SOURCE% %%A in (*.tga) do (
    echo %%A
    %CONVERTER% %%A %TARGET%\%%~nA.png
)

一度作ってしまえば、あとはフォルダに入れて叩くだけ!また、この手法を使えば別の拡張子間での変換もお手軽にできるはずです。

*1:パスやフォルダは適宜読み替えてください

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 が異なってしまうという不具合