この記事はC# Advent Calendar 2017の16日目の記事となります。
今回は、C#でのRPC(Remote Procedure Call)のインターフェース表現の一例ということで、
C#のプリプロセッサ ディレクティブとSharedProjectを利用してRPCのインターフェースを表現してみる方法とその実装について書いていきます。
##前書き
私は所属している組織の都合でWebApiの開発にSOAPを利用しているため、SOAPを切り口として書き始めたいと思います。
SOAPはサーバに公開したXML情報からクライアントのコードを生成することが可能なRPCとなります。
(正確にはXML情報からwsdlの中間定義ファイルを生成し、そこからコードを生成する。)
クライアントに定義を作ることから次のような問題が発生していました。
####WebApiの変更を検出できない
コードを再生成しない限り、クライアント側からはサーバ側の変更をコンパイラで検出することが出来ないため、処理を実行しエラーとなることで変更に気づくことになります。また、実行時にエラーが出るのであればまだマシな方で、最悪の場合テストケースをパスしてしまう可能性があります。(実行しても引数にはデフォルト値(参照であればnull)となるため)
####処理が机上で追い難い
クライアントとサーバのコードが物理的に分かれてしまっているので、サーバ側のどのコードに飛ぶのか追い難いのも問題点の一つです。クラス名やメソッド名である程度推測はできるかもしれませんが、迷子になりやすいといえます。
####管理対象のファイルなのか分かり難い
C#のコードほかに中間ファイルやその他色々なファイルを生成します。
SOAPを使った開発に慣れていない開発者からは、コミットするときにどう扱っていいか困ったという話を聞きました。
##問題を課題へ
問題を課題に整理してみましょう。
処理を実行するまでエラーが分からなかったり、あるいは実行しても分からなかったりといった状況は好ましくありません。「サーバ側の変更をコンパイラで判断したい」できれば「VisualStudioでリアルタイムに検出したい」という課題が出てきました。また、IDEの機能(例えばVisualStudioのShift+F12やコードレンズなど)をフル活用し「実行しなくても処理を追えるようにしたい」と思います。
ファイルが多いほど構造が複雑になるため、「不要なファイルは作らないようにしたい」というのも課題となりました。
##インターフェースを使った解決
本題になります。
上記の課題を解決するためにまずクライアント・サーバを共通のインターフェースで縛るやり方を考えました。
が、同じ定義のインターフェースを使えば良いのかというと、そうではないパターンが出てきました。
サーバ側は非同期IOや並列処理を必要としなければ、次のようなインターフェースで良いはずです。
int Tally(int a, int b);
クライアント側はどうでしょう?通信で非同期前提なのを考えるとTaskが戻り値になりますね。
また、クライアントとサーバでは欲しいパラメータが違うことがあります。例えば、通信するにあたりパラメータにCancellationTokenを指定したいなどです。(HttpClientでもCancellationTokenを指定できますね。)
そのため、次のようなインターフェースになるでしょう。
Task<int> TallyAsync(int a, int b, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)
シグネチャが異なるため同じインターフェースを使うことができません。困りましたね。
同じ定義のインターフェースは欲しいけれど、「クライアントとサーバのそれぞれの事情を吸収できるような仕組みだと良い」。
ムチャクチャ言ってますね。
この課題を解決するために、頭をひねって出てきたのが、こんなインターフェースです。
//サーバ側のコンパイラスイッチには___server___の定数を指定
#if !___server___
Task<
#endif
int
#if !___server___
>
#endif
#if !___server___
TallyAsync
#else
Tally
#endif
(int a, int b
#if !___server___
, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)
#endif
);
C#の#ifを使ってクライアントとサーバのそれぞれの事情のところだけ分ける感じですね。
あとはインターフェースをSharedProjectに参照させて、SharedProjectをクライアント側・サーバ側の両方のプロジェクトに参照させます。サーバ側のプロジェクトではコンパイラスイッチに#ifで指定した定数を定義しておけばOKです。
さすがに見難いのと、C#7.0からValueTask追加されているので、最終的な妥協点として以下のようなインターフェースとなりました。
// 戻り値ありはValueTask<T>
ValueTask<int> TallyAsync(int a, int b
#if !___server___
, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)
#endif
);
//戻り値なしはTask
Task MethodAsync(int a, int b
#if !___server___
, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)
#endif
);
後はそれぞれでインターフェースを実装するという流れになります。
##実行時コード生成での解決
不要なファイルは作らないようにクライアントの処理を作る方法は、動的にILを生成する方法やC#のソースコードを生成する選択肢がありましたが、クライアント・サーバ共にWindowsが使える環境でしたので、ILを生成するのがお手軽で良さそうでした。
動的IL生成は@neueccさんのIntroduction to the pragmatic IL via C#
ソースコード生成は@skitoy4321さんのC#で半自動ソースコード生成を行う
でちょうど触れられてますね。
##実装したライブラリ
さて、これまでつらつらと書いてきましたが、こんなこと本当にできるのでしょうか?
論より証拠、やってみようということで作ってみた実装がNetStitchというライブラリになります。
NetStitchは.NetFramework/.NetCoreの上に載せたHttp RPC フレームワークです。
大きくNetStitch.ServerとNetStitch.Clientの二つで構成されています。
詳しいところはgithubを参照していただくとして、使い方の例を以下に記載します。
####インターフェース
//SharedProjectに参照させる。
//RPCで呼び出すインターフェースを識別するためにContractのインターフェースを指定
public interface Interface : INetStitchContract
{
ValueTask<int> TallyAsync
(int a, int b
#if !___server___
, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)
#endif
);
}
####クライアント
//普通にインスタンス生成 オーバーロードにHttpClientの引数もあるため、認証周りはそちらで指定
var client = new NetStitchClient(url);
//SharedProjectに定義したインターフェースをGenericの型に指定する
await client.Create<Interface>.TallyAsync(100, 4);
####サーバ
// ASP.Net Core で使ういつものやつ
public class Startup
{
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
//クラスからDLLを参照し、DLLに含まれるINetStitchContractを継承した
//メソッドを呼び出せるようにする。
app.UseNetStitch(typeof(Startup));
}
}
//SharedProjectに定義したインターフェースを実装するだけ
public class MyClass : Interface
{
public async ValueTask<int> TallyAsync(int a, int b)
{
return a + b;
}
}
##最後に
C#でRPC(Remote Procedure Call)のインターフェースを表現することでいくつかの課題が解決しました。
-
(事情の異なる)同じインターフェースを参照しているため、クライアント側からでもサーバの変更をVisualStudioでリアルタイムに変更を検出することができるようになった
-
Shift+F12での検索やコードレンズによる参照を使えるようになった
-
C#のインターフェースだけ用意すれば良いため、必要なファイルが分かりやすくなった
実装となるライブラリも作ってはみましたが、間違いなくオレオレライブラリになりますので、参考程度に見ていただければ幸いです。
ライブラリを作るにあたっては、@neueccさんのLightNodeを大変参考にさせていただきました。
また、NetStitchのシリアライザにはMessagePack for C#を使わせて頂きました。本当に頭が下がる思いです。
C# Advent Calendar 2017も残り10日を切りました。最後まで頑張って完走しましょう!