0
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

MagicOnionでサービスから別のサービスを呼び出す

Last updated at Posted at 2021-12-24

C#でgRPCの通信を実現するにあたって、MagicOnionは非常に便利なライブラリです。クライアントサイドとサーバーサイドの言語を同じにできるというのは、保守性等に限らず勉強コストや人員配置コストでもいい点がたくさんありますよね。
利用例はUnityによるゲーム開発が多いですが、私は普通の業務アプリ開発での採用を現在検討しています。

使ってて特に楽しいのが1リクエストで1レスポンス返ってくるようなよくある単発のAPI(本稿ではサービスと呼びます)の実装です。MagicOnionでは以下のように普通のメソッドとして自然に書けます。

SampleService.cs
// サーバーとクライアントで共有する定義プロジェクトに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とします。

IHelloWorld.cs
public interface IHelloWorld : IService<IHelloWorld>
{
    // "Hello World! (日時)"を返すメソッド
    UnaryResult<string> HelloWorldAsync();
}

呼び出されるサービスをIGetServerDateTimeとします。

IGetServerDateTime.cs
public interface IGetServerDateTime : IService<IGetServerDateTime>
{
    // DBサーバーの日時を返すメソッド
    UnaryResult<DateTime> GetServerDateTimeAsync();
}

別サービスの呼び出し方

2つの関係は「IHelloWorldIGetServerDateTime依存する」という形になります。ということはつまり、IGetServerDateTimeIHelloWorldにコンストラクタインジェクションするというのがきれいな方法でしょう。

呼び出される側の具象クラスの実装

呼び出される側は普通に実装するだけです。

GetServerDateTime.cs
public class GetServerDateTime : ServiceBase<IGetServerDateTime>, IGetServerDateTime
{
    public async GetServerDateTimeAsync() => MyDatabaseManager.GetDate();
}

呼び出す側の具象クラスの実装

呼び出す側には工夫が必要です。といっても、注意点は通信コンテキストを同じにするという点だけです。

GetServerDateTime.cs
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であり、このクラスでは実際のメソッドまで到達しないと設定されていません。そのためインジェクションしたものを使うタイミングでの設定が必要になります。
また、ContextServiceBase<T>のプロパティなのでIService<T>からはアクセスできません。そのためキャストをしてから設定します1

Contextが不要なら設定せずに呼び出してもエラーにはなりませんが、そもそもそのような処理は**MagicOnionのサービスである必要がない**ので普通のクラスにするかstaticなユーティリティメソッドとして取り回すべきでしょう。

実際の開発時ではいちいちキャストするのは面倒くさいので以下のような拡張メソッドを定義するのが楽だと思います。nullの場合にどうするかは要検討ですけども。

MyMagicOnionExtension.cs
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.csConfigureServicesメソッドでサービスの登録を行います。

Startup.cs
    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経由で使用する場合などでは適用できないので注意してください。

まとめ

あるサービスから別のサービスを利用するには以下手順を行います。

  1. 呼び出される側は普通に作る
  2. 呼び出す側はコンストラクタインジェクションし、利用時にContextを引き継がせる
  3. Startup.csでDIコンテナに呼び出される側のインターフェイスと具象クラスを登録する

なお、あるストリーミング処理から別のサービスを呼び出すこともできます。ServerStreamingContextおよびDuplexStreamingContextは内部でgetter-onlyServiceContextプロパティを公開しているので、これを使って同じ手順で呼び出すことが可能です。
ただし、ストリーミングクラスにグループへ参加するサービスを定義したからといって、ストリーミングやサービスから別のストリーミングへの参加メソッドを呼ぶようなことはしないほうがいいでしょう。素直にクライアントから参加するほうが、グループのin-outが明確になるため、システム全体の見通しが良くなります。

  1. ServiceBase<T>でDIすると今度はTであるサービスインターフェイスに定義したGetServerDateTimeAsync()が呼べなくなる

0
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?