C#でgRPC
の通信を実現するにあたって、MagicOnion
は非常に便利なライブラリです。クライアントサイドとサーバーサイドの言語を同じにできるというのは、保守性等に限らず勉強コストや人員配置コストでもいい点がたくさんありますよね。
利用例はUnityによるゲーム開発が多いですが、私は普通の業務アプリ開発での採用を現在検討しています。
使ってて特に楽しいのが1リクエストで1レスポンス返ってくるようなよくある単発のAPI(本稿ではサービスと呼びます)の実装です。MagicOnion
では以下のように普通のメソッドとして自然に書けます。
// サーバーとクライアントで共有する定義プロジェクトにISampleServiceを定義する
// このソースはサーバープロジェクトで実装
// クライアント側はISampleService経由でawait SampleCallAsync()して呼び出す
public class SampleService : ServiceBase<ISampleService>, ISampleService
{
// サービスの処理本体
// UnaryResult<T>はサービスの戻り値をとるためのTask-likeな型
// awaitが不要な場合も多々あるので、CS1998警告はプロジェクト全体で抑圧する
public async UnaryResult<string> SampleCallAsync()
{
return "Sample";
}
}
ここでよくあるのが、**別のクラスとして定義したサービスをどう呼ぶの?**という疑問です。当然、役割の違うものは別クラスとして定義したいですし、すでにサービスとして定義済みのものを呼び出したくなるケースってよくありますよね?
- ログイン用に、ユーザーIDを送信して存在確認をするサービスを定義
- フレンド登録処理で、他人のユーザーIDの存在確認処理として流用したい
- 送信データをもとに従業員の作業日報を登録するサービスを定義
- 出勤処理、退勤処理、休憩登録処理、などいろんなところで細かく自動で日報登録したい
本稿では、あるサービスから別のサービスを呼び出す方法について紹介したいと思います。
サービスの定義
説明のために適当なサービスを定義します。以下サンプルコードから名前空間の定義は省略します。
まず、呼び出すサービスをIHelloWorld
とします。
public interface IHelloWorld : IService<IHelloWorld>
{
// "Hello World! (日時)"を返すメソッド
UnaryResult<string> HelloWorldAsync();
}
呼び出されるサービスをIGetServerDateTime
とします。
public interface IGetServerDateTime : IService<IGetServerDateTime>
{
// DBサーバーの日時を返すメソッド
UnaryResult<DateTime> GetServerDateTimeAsync();
}
別サービスの呼び出し方
2つの関係は「IHelloWorld
はIGetServerDateTime
に依存する」という形になります。ということはつまり、IGetServerDateTime
をIHelloWorld
にコンストラクタインジェクションするというのがきれいな方法でしょう。
呼び出される側の具象クラスの実装
呼び出される側は普通に実装するだけです。
public class GetServerDateTime : ServiceBase<IGetServerDateTime>, IGetServerDateTime
{
public async GetServerDateTimeAsync() => MyDatabaseManager.GetDate();
}
呼び出す側の具象クラスの実装
呼び出す側には工夫が必要です。といっても、注意点は通信コンテキストを同じにするという点だけです。
public class HelloWorld : ServiceBase<IHelloWorld>, IHelloWorld
{
// コンストラクタインジェクション
private readonly IGetServerDateTime _getServerDateTime;
public HelloWorld(IGetServerDateTime getServerDateTime) : base()
{
// note: base()は自身のコンテキストにdefaultを設定しているだけ
_getServerDateTime = getServerDateTime;
}
public async UnaryResult<string> HelloWorldAsync()
{
// コンテキストを引き継ぐ
((ServiceBase<IGetServerDateTime>)_getServerDateTime).Context = this.Context;
// あとは普通に実装する
var dt = await _getServerDateTime.GetServerDateTimeAsync();
return $"Hello World! {dt}";
}
}
重要なのが_getServerDateTime
へのContext
プロパティの設定です。Context
はコンストラクタ時点ではnull
であり、このクラスでは実際のメソッドまで到達しないと設定されていません。そのためインジェクションしたものを使うタイミングでの設定が必要になります。
また、Context
はServiceBase<T>
のプロパティなのでIService<T>
からはアクセスできません。そのためキャストをしてから設定します1。
Context
が不要なら設定せずに呼び出してもエラーにはなりませんが、そもそもそのような処理は**MagicOnion
のサービスである必要がない**ので普通のクラスにするかstatic
なユーティリティメソッドとして取り回すべきでしょう。
実際の開発時ではいちいちキャストするのは面倒くさいので以下のような拡張メソッドを定義するのが楽だと思います。null
の場合にどうするかは要検討ですけども。
public static class MagicOnionExtensions
{
public static void SetContext<T>(this IService<T> src, ServiceContext context)
where T : IServiceMarker
{
if (src is null) throw new ArgumentNullException(nameof(src));
((ServiceBase<T>)src).Context = context;
}
}
DIコンテナの設定
最後にDIコンテナの設定をします。MagicOnion
のサーバーサイドをgithubに記載の手順で作っている場合、ASP .Net Core gRPCサービス
プロジェクトになっているはずです。これには標準でDIコンテナがついていますので、Startup.cs
のConfigureServices
メソッドでサービスの登録を行います。
public void ConfigureServices(IServiceCollection services)
{
services.AddGrpc();
services.AddMagicOnion();
// services.AddTranseint(毎回生成するタイプ)で具象クラスを登録
services.AddTransient<IGetServerDateTime, GetServerDateTime>();
}
ここで、利用者側であるIHelloWorld
は登録不要です。
本来、IService<T>
はクライアントから処理を呼び出したときにMagicOnion
がうまいこと具象クラスを取り出してくれます。なのでDIコンテナに内部で登録されているのかと思いましたがそうではないようです。実際に、AddTranseint
の行を削除するとHelloWorldAsync
を実行するタイミングで「IGetServerDateTime
が見つからない」という実行時例外になります。別サービスに注入されるものは明示的にDIコンテナへの登録が必要、ということを覚えておきましょう。
ただし、いちいちコンテナへの登録は面倒なのでSourceGeneratorを使ってコンテナへの登録コードを具象クラス定義時に自動実装するのが楽でよいと思います。
暴論でいうとDIコンテナを使わずにHelloWorldAsync
の中でGetServerDateTime
クラスをnew
することもできますが、密結合になるので良くない方法なのは言うまでもありません。
別のサービスを呼ぶ場合の注意点
MagicOnion
では、サービスに対して以下方法でフィルタを設定することができます。
-
Startup
時にGlobalFilter
を設定する - クラス全体に定義したフィルタを
FromTypeFilter
またはFromServiceFilter
で定義 - メソッド個別に定義したフィルタを
FromTypeFilter
またはFromServiceFilter
で定義
ただし、今回の方法を取ると通信が発生せずに直接メソッドを実行するため、それぞれの方法で定義したフィルタは実行されません。フィルタで生成した値をContext.Items
経由で使用する場合などでは適用できないので注意してください。
まとめ
あるサービスから別のサービスを利用するには以下手順を行います。
- 呼び出される側は普通に作る
- 呼び出す側はコンストラクタインジェクションし、利用時に
Context
を引き継がせる -
Startup.cs
でDIコンテナに呼び出される側のインターフェイスと具象クラスを登録する
なお、あるストリーミング処理から別のサービスを呼び出すこともできます。ServerStreamingContext
およびDuplexStreamingContext
は内部でgetter-only
なServiceContext
プロパティを公開しているので、これを使って同じ手順で呼び出すことが可能です。
ただし、ストリーミングクラスにグループへ参加するサービスを定義したからといって、ストリーミングやサービスから別のストリーミングへの参加メソッドを呼ぶようなことはしないほうがいいでしょう。素直にクライアントから参加するほうが、グループのin-outが明確になるため、システム全体の見通しが良くなります。
-
ServiceBase<T>
でDIすると今度はT
であるサービスインターフェイスに定義したGetServerDateTimeAsync()
が呼べなくなる ↩