C# 7.0 custom task-like の正しいフレームワークでの利用法

例年、この頃はMVP更新が云々とかなのですが、今年からシステムが変わって更新時期に変動があるんで何もありませんが、一応まだ継続しています。それはともかくとしてVisual Studio 2017が出ました。会社でも全プロジェクトがVS2017に移行完了を果たして、代わり映えしないようで、タプル記法のデコンストラクションとか工夫すると結構便利だな、とか使い始めると色々発見があります。タプル記法やデコンストラクションの工夫に関しては、弊社エンジニアリングブログのC# 7.0 が使えるようになったので ValueTuple を活用してみたをどうぞ。

そんな中で、私がはよ来てくれ……と願っていたC# 7.0の新機能は、task-likeです。Proposal: arbitrary task-like types returned from async methodsで延々と議論されていたようですが、これは何かというと、asyncでTask以外の型が返せるようになります。もともとC# 7.0からValueTaskが入って、async ValueTask<T> を返せるようになる必要があったついでに搭載されたみたいなものですが、色々何か出来そうですよね!?

というわけで、早速有効に使えるシチュエーションを用意しました。というか早速投下しています。

task-likeがない場合の苦痛

現在、私はMagicOnionというgRPCをベースにしたフレームワークを作っています。シリアライザはこないだ公開したエクストリーム速くて軽量なMessagePack for C#です。と、そういう細かいことはどうでもいいとして、MagicOnionではこんな風に書きます。

// 定義を用意して
public interface IMyFirstSerivce : IService<IMyFirstSerivce>
{
    UnaryResult<int> Sum(int x, int y);
}

public class MyFirstSerivce : ServiceBase<IMyFirstSerivce>, IMyFirstSerivce
{
    // これがサーバーで呼び出される実装になる
    public UnaryResult<int> Sum(int x, int y)
    {
        var sum = x + y;
        return UnaryResult(x + y);
    }
}

static async Task Run()
{
    var channel = new Channel("localhost:1111", ChannelCredentials.Insecure);

    // インターフェースで動的にクライアントを自動生成する
    var client = MagicOnionClient.Create<IMyFirstSerivce>(channel);

    // 自然な感じでサーバー - クライアント通信で受け取れる
    var result = await client.Sum(10, 20);

    Console.WriteLine(result);
}

まぁまぁ自然な感じでいいじゃん?ってところですが、面倒くさいのは UnaryResult<T> を返さなければならないところ。そのため UnaryResuylt() というヘルパー関数を読んで包んだのをリターンする羽目になってます。これが地味に面倒くさい。return x + y; って書きたいじゃん、って。

で、MagicOnionがUnaryResultを強制するには理由があって、多くの場合は戻り値そのものだけで良いんですが、場合によってはレスポンスヘッダを取りたいとかステータスコードを取りたいとか、そういうのに対応する必要があるんですね。

// awaitしない
var response = client.Sum(10, 20);

// headerを取るとか
var header = await response.ResponseHeadersAsync;

// statusを取るとかしたかったりする
var trailer = response.GetStatus();

// 結果を取る場合。 await response はこれのショートカットでしかなかったりする
var result = await response.ResponseAsync;

APIの触り心地に関してはものすごく考えたんですが、最終的にこの辺が妥協点になってくるかな、と。しょうがないね。さて、ではasyncになるとどうでしょう?

public interface IMyFirstSerivce : IService<IMyFirstSerivce>
{
    Task<UnaryResult<string>> EchoAsync(string message);
}

public class MyFirstSerivce : ServiceBase<IMyFirstSerivce>, IMyFirstSerivce
{
    // サーバー側の書き味は普通、なんですが……
    public async Task<UnaryResult<string>> EchoAsync(string message)
    {
        await Task.Delay(TimeSpan.FromSeconds(10));

        return UnaryResult(message);
    }
}

static async Task Run()
{
    var channel = new Channel("localhost:1111", ChannelCredentials.Insecure);
    var client = MagicOnionClient.Create<IMyFirstSerivce>(channel);

    // await await !!!
    var result = await await client.EchoAsync("hogehoge");

    // というのも、await一発でUnaryResultの取得になる
    var response = await client.EchoAsync("takotako");

    //  ようするにこれのショートカットはawait awaitになってしまうのだ……
    var result2 = await response.ResponseAsync;
}

注目はawait awaitです。なんと、await awaitという世にも奇っ怪な記述が合法として出てくるのであった、最悪……。

task-likeがある場合

そこでC# 7.0 task-likeですよ!

// SyncもAsyncも共にUnaryResultとして定義
public interface IMyFirstSerivce : IService<IMyFirstSerivce>
{
    UnaryResult<int> SumAsync(int x, int y);
    UnaryResult<string> EchoAsync(string message);
}

public class MyFirstSerivce : ServiceBase<IMyFirstSerivce>, IMyFirstSerivce
{
    public async UnaryResult<int> SumAsync(int x, int y)
    {
        // UnaryResult()で囲む必要なし!やったー!
        return x + y;
    }

    public async UnaryResult<string> EchoAsync(string message)
    {
        // 勿論awaitする場合も普通に
        await Task.Delay(TimeSpan.FromSeconds(3));
        return message;
    }
}

static async Task Run()
{
    var channel = new Channel("localhost:1111", ChannelCredentials.Insecure);
    var client = MagicOnionClient.Create<IMyFirstSerivce>(channel);

    // 自然に扱える!
    var result1 = await client.SumAsync(1, 100);
    var result2 = await client.EchoAsync("hogehoge");
}

UnaryResult()でのラップもawait awaitも不要です。非常に綺麗にすっきりと扱えるようになりました。あってヨカッタtask-like。かなり有意義に使えてると思いますです。

これは何をやっているかというと、async UnaryResult の場合に独自のコード生成が入って、UnaryResult()の呼び出しを自動で行ってくれるようになってます。UnaryResult()でのラップやawait awaitもダルいのですが、地味に辛いのがTask<UnaryResult<T>>という、ジェネリクスが二階層になっているところですね。継承の連鎖が悪で少ないに越したことはないのと同様に、ジェネリクスのネストも、書き味的にも読み味的にも、少ないに越したことはないのです(ところでかんすーがたげんごの人は型をネストさせまくることの可読性低下にあまりにも無頓着すぎる気がとってもしてます、よくないね)。

警告を無視する

ところで、asyncでawaitなしだと警告がでます。CS1998 Async method lacks 'await' operators and will run synchronously というあれ。お薦めは、ガン無視することです。プロジェクト設定のほうで1998は警告「しない」にしちゃうのがいいでしょう。

image

ずっと会社でasyncまみれになってン年間過ごして思ったのは、この警告いらないわ。別に。抵抗感あるかもとは思いますが、それでもなお無視したほうが幸せ度上がると思います。

task-likeの作り方

適当にやりました。いや、だってよくわからんし。なんで適当にAsyncTaskMethodBuilderに丸投げです。まぁこれはValueTaskのtask-likeと一緒です。ノリが同じなのでそれで動くと思ってたし、実際それで動いた。超絶手間なくtask-like対応できたわー。

// 対象の型にAsyncMethodBuilder属性をつける
[AsyncMethodBuilder(typeof(AsyncUnaryResultMethodBuilder<>))]
public struct UnaryResult<TResponse>
{
}

// こちらがその中身。基本AsyncTaskMethodBuilderに丸投げです。
public struct AsyncUnaryResultMethodBuilder<T>
{
    private AsyncTaskMethodBuilder<T> methodBuilder;
    private T result;
    private bool haveResult;
    private bool useBuilder;

    public static AsyncUnaryResultMethodBuilder<T> Create()
    {
        return new AsyncUnaryResultMethodBuilder<T>() { methodBuilder = AsyncTaskMethodBuilder<T>.Create() };
    }

    public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
    {
        methodBuilder.Start(ref stateMachine);
    }

    public void SetStateMachine(IAsyncStateMachine stateMachine)
    {
        methodBuilder.SetStateMachine(stateMachine);
    }

    public void SetResult(T result)
    {
        if (useBuilder)
        {
            methodBuilder.SetResult(result);
        }
        else
        {
            this.result = result;
            haveResult = true;
        }
    }

    public void SetException(Exception exception)
    {
        methodBuilder.SetException(exception);
    }

    public UnaryResult<T> Task
    {
        get
        {
            if (haveResult)
            {
                return new UnaryResult<T>(result);
            }
            else
            {
                useBuilder = true;
                return new UnaryResult<T>(methodBuilder.Task);
            }
        }
    }

    public void AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion
        where TStateMachine : IAsyncStateMachine
    {
        useBuilder = true;
        methodBuilder.AwaitOnCompleted(ref awaiter, ref stateMachine);
    }

    [SecuritySafeCritical]
    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : ICriticalNotifyCompletion
        where TStateMachine : IAsyncStateMachine
    {
        useBuilder = true;
        methodBuilder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
    }
}

まぁ細かいことはいいんです、どうでも。

まとめ

C# 7.0は良い。というかMagicOnionはもはやC# 7.0が前提みたいな新世代フレームワークと化してますとかかんとか。MagicOnionは現在CM放送中(!)の黒騎士と白の魔王でも全面採用しています。黒騎士ではHTTP/1 Web APIはほぼ使われてないのです。クライアント-サーバー間もサーバー-サーバー間も全てgRPC。時代はHTTP/2。圧倒的な次世代。gRPCも、Unityでも動くようにgRPCにかなりの魔改造を施したカスタム仕様で、かなりアグレッシブな感じです。

その一端はUnite 2017でお話するつもりなので是非是非来てくださいな。もちろん、UniteはUnityのイベントなのでクライアントサイド中心の話なのでサーバー側(gRPC/MagicOnion)の話は少なめになりますが、近いうちに他のイベントでサーバー側でもお話できればな、と思ってます。ちょうど5月6月はクラウド系の大規模カンファレンスがラッシュでありますしね。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive