🔥

C#の非同期なジェネリックメソッドをリフレクションで実行して結果を取得する

2022/07/29に公開約8,800字2件のコメント

CTOの高丘です。最近は継承を減らしてインターフェースとジェネリックを活用し、シンプルにプログラムをまとめるリファクタリングを行っています。C# には豊富なリフレクション機能が用意されていて、複雑な機能をリフレクションを使用して実行できます。リフレクションを使用することにより、多くのクラスやメソッドの機能を一箇所にまとめることができます。今回は備忘も兼ねて、今回直面した問題と対策を書いておきます。

TL;DR

C#の非同期なジェネリックメソッドをリフレクションで実行して結果を取得したいときは、

tldr.cs
    var result = await (dynamic)methodInfo.Invoke(
        _executor, 
        new object?[] { commandObject, null });

上記の書き方で簡単に取れました。

直面した問題

今回のケースでは、以下の状況でした。

1. 対象のメソッドが非同期ジェネリックメソッドである

IAggregateCommandExecuter.cs
public interface IAggregateCommandExecutor
{
    Task<ExecutorResponse<TContents, TCommand>> 
    ExecCommandAsync<TAggregate, TContents, TCommand>(C command)
        where TAggregate : AggregateBase<TContents> 
        where TContents : IAggregateContents, new() 
        where TCommand : CommandBase<TAggregate>;
}

ちょっと簡略化していますが、上記はコマンドを実行するインターフェースのメソッドです。ExecCommandAsyncコマンドは、コマンドを引数として取るのですが、メソッドはジェネリックとなっており対象の集約のクラスの型、集約の内部のコンテンツの型、またコマンドの型を指定することにより、メソッドの内部で適切な処理を行えるようになっています。

2.呼び出し元のWebAPIアクションが、複数のジェネリックに対応して、リフレクションを用いる形である

少し長いですが、以下のコードをご覧ください。

SekibanCommandController.cs
[ApiController]
[Route("api/command")]
public class SekibanCommandAccessController : ControllerBase
{
    private readonly IAggregateCommandExecutor _executor;
    private readonly ISekibanControllerItems _sekibanControllerItems;
    public SekibanCommandAccessController(
        IAggregateCommandExecutor executor, 
        ISekibanControllerItems sekibanControllerItems)
    {
        _executor = executor;
        _sekibanControllerItems = sekibanControllerItems;
    }
    
    [HttpPost]
    [Route("{aggregateName}/{commandName}")]
    public async Task<IActionResult> CreateCommandExecuterAsync(
        string aggregateName, 
        string commandName, 
        [FromBody] dynamic command)
    {
        // 対応するコマンドは内部で持っている、配列にはタプルで必要な型を記録している
        foreach (var (
            interfaceType, 
            aggregateType, 
            commandType, 
            aggregateContentsType, 
            implementationType) 
                in _sekibanControllerItems.SekibanCommands)
        {
            // セキュリティ上、対応するコマンドに一致する名前しか受け付けない
            if (interfaceType?.Name == 
                typeof(IAggregateCommandHandler<,>).Name)
            {
                // コマンドのオブジェクトを指定したコマンド型に変換
                dynamic? commandObject = SekibanCommandHelper.getCommand(
                    command, 
                    commandType);
                if (commandObject is null) 
                { 
                    return Problem(
                    "Aggregate Command could not" +
                    $"serialize to {commandType.Name}"); 
                }

                // 実行するメソッドを取得(まだリフレクション型を指定していないもの)
                var createMethod = _executor
                    .GetType()
                    .GetMethod(
                        nameof(IAggregateCommandExecutor.ExecCommandAsync));
                // 型をリフレクションで指定して、実際の実行メソッドを作成する
                var methodInfo = createMethod?.MakeGenericMethod(
                    aggregateType, 
                    aggregateContentsType, 
                    commandType);
                if (methodInfo is null) 
                { 
                    return Problem("Aggregate Command can not execute."); 
                }

                // ここでリフレクションで、非同期なメソッドを実行して、型を取得したい
                // 以下のコードは、このように本当はシンプルに書きたい理想系
                //(これはコンパイルが通らない)
                var result = await methodInfo.Invoke(
                    _executor, 
                    new object?[] { commandObject, null });
                return Ok(result);
            }
        }
        await Task.CompletedTask;
        return Problem("Aggregate Command not found");
    }
}

呼び出したいメソッドに3つの型を渡さないといけないため、対応した型リストの中からAPIで取得した名称に合うものクラスの型を取得しています。そして実際に呼び出すメソッドも、MakeGenericMethod(type, type, type) を使用して、生成しています。ここまでで、非同期で、ジェネリックなメソッドをリフレクションを利用して、実行する準備ができました。しかし、以下の書き方ではメソッドの実行ができませんでした。

    // ここでリフレクションで、非同期なメソッドを実行して、型を取得したい
    // 以下のコードは、このように本当はシンプルに書きたい理想系
    //(これはコンパイルが通らない)
    var result = await methodInfo.Invoke(
        _executor, 
        new object?[] { commandObject, null });
    return Ok(result);

上記で書いているように、メソッドをInvokeで実行して、awaitでタスクを実行し、その結果をWebAPIからクライアントに返すというコードを書きたいのですが、上記のようにシンプルに書くことはできませんでした。

ダメだった例

InvokeAsync メソッドは存在しない

MethodInfoクラスに関しては、以下のMicrosoftのドキュメントを見ると、Invokeはあるのですが、InvokeAsyncメソッドは存在しません。

https://docs.microsoft.com/ja-jp/dotnet/api/system.reflection.methodinfo?view=net-6.0

ただ、await 処理をかけないとしても、戻り値としては、Task<ExecutorResponse<TContents, TCommand>> 型で返ってきているはずなので、そのかたちにキャストできるのではないかと考えて、色々試してみました。戻り値にもジェネリックが入っているので、簡単にTask<int>のように型を決めて書くことができません。

Task<dynamic>のような書き方だと、実行時にキャストエラーとなる

    var responseTask = (Task<dynamic>?)methodInfo.Invoke(
        _executor, 
        new object?[] { commandObject, null });
    var result = await responseTask;
    return Ok(result);

上記のように、Task<dynamic>Task<object>などの型にキャストして、それをawaitしたら取得できるのではないかと考えてみたのですが、以下のようなエラーとなり、キャストに失敗しました。

System.InvalidCastException: Unable to cast object of type 

'AsyncStateMachineBox`1[Sekiban.EventSourcing.AggregateCommands.AggregateCommandExecutorResponse`2[CustomerDomainContext.Aggregates.Branches.BranchContents,CustomerDomainContext.Aggregates.Branches.Commands.CreateBranch],Sekiban.EventSourcing.AggregateCommands.AggregateCommandExecutor+<ExecCreateCommandAsync>d__7`3[CustomerDomainContext.Aggregates.Branches.Branch,CustomerDomainContext.Aggregates.Branches.BranchContents,CustomerDomainContext.Aggregates.Branches.Commands.CreateBranch]]' 

to type 'System.Threading.Tasks.Task`1[System.Object]'.

   at Sekiban.EventSourcing.WebHelper.Controllers.SekibanCommandAccessController.CreateCommandExecuterAsync(String aggregateName, String commandName, Object command) in SekibanCommandAccessController.cs:line 46

どうも、MethodInfo.Invokeで非同期処理を実行するときに、Task<object>をそのまま返しているのではなく、AsyncStateMachineBox<AggregateCommandExecutorResponse<BranchContents, CreateBranch>, ExecCommandAsync<Branch, BranchContents,CreateBranch>>のような型が使用されているようです。

そのため、単純にTask<dynamic>Task<dynamic>?などにキャストできないという問題となっていました。

成功した方法

AsyncStateMachineBoxもTask型を継承したクラスで、非同期処理できるはずと考え、自力でうまくいかなかったため、色々ググってみました。methodinfo invoke async genericの検索結果で、以下のStackOverflowの記事がヒットしました。

https://stackoverflow.com/questions/39674988/how-to-call-a-generic-async-method-using-reflection

この質問の答えを参考にして書き直した結果が以下のものです。

Task 型で取得して、非同期実行後に Resultを取得する

ExecuteGenericAsync.cs
    var responseTask = (Task?)methodInfo.Invoke(
        _executor, 
        new object?[] { commandObject, null });
    if (responseTask is null) 
    { 
        return Problem("Aggregate Command can not execute."); 
    }
    await responseTask;
    var resultProperty = responseTask
        .GetType().GetProperty("Result");
    if (resultProperty is null) 
    { 
        return Problem("Aggregate Command can not execute."); 
    }
    var result = resultProperty.GetValue(responseTask);
    return Ok(result);

このコードは、まずTask型で取得し、awaitで非同期実行し、Task型で取得したオブジェクトに対してResultのメソッドプロパティをリフレクションで取得し、そのプロパティを実行することにより、結果を取得するという方法です。

今までTask型は値を返さない非同期処理と考えていたのですが、実際には、実行結果があったときにResultプロパティに値をとっているため、その値を取得できるということが分かり、勉強になりました。

この記事を書いているときに、stackoverflowの他の回答を見ていて、もう少し簡単に書ける方法があり試してみたところ、成功しました。以下の方法です。

dynamic 型に対して、await をする

awaitableDynamic.cs
    dynamic awaitableTask = methodInfo.Invoke(
        _executor, 
        new object?[] { commandObject, null });
    if (awaitableTask is null) 
    {
        return Problem("Aggregate Command could not execute.");
    }
    var result = await awaitableTask;
    return Ok(result);

Task<dynamic>として受けるのではなく、dynamicとして受け、それに対して、awaitしてあげればいいのですね。この方法を試してみたら無事に値を取得できました。この方法であれば、自分でも思い浮かぶことができたはずと思い、ちょっと悔しかったです。

さらにシンプルに (dynamic)を挿入するだけで記述できる

さらにシンプルにすると以下の形となります。

awaitableDynamic.cs
    var result = await (dynamic) methodInfo.Invoke(
        _executor, 
        new object?[] { commandObject, null });
    return Ok(result);

この方法も成功し、これは元々最初に書きたかった書き方に (dynamic)を挟んだだけという結果となりました。

結論

今回は簡単な記事でしたが、C#の非同期、ジェネリック、リフレクションなどを使うと、シンプルなインターフェースで、処理を羅列することなく、多くの機能に対応するプログラムを書くことができます。システムを開発するすべてのプログラマーが使う必要はない言語の機能かもしれませんが、共通機能の中でこれらの機能を使い、シンプルなインターフェースで記述できるように設計することにより、フレームワークを使用するプログラマができるだけシンプルに問題解決ができるようにしていきたいと思います。

少しコードにも出ていますが、今作成している、イベントソーシングを使用したSekibanというプロダクトは、集約(AggregateRoot)に対してコマンドを定義すると、この記事で記述したように、コマンドに対応したAPIを自動生成することにより、ビジネスロジックを定義するプログラマがロジックにできるだけ集中できるフレームワークとなるように作成中です。

https://zenn.dev/jtechjapan/articles/d47c31fa7e3180

実際にはこの記事で説明したコードは使用されずに、コントローラー自体をジェネリックにして各コマンドごとにコントローラーをジェネリックで生成して起動時にダイナミックに登録する(ジェネリックコントローラーを継承するクラスを記述しなくてもよい)方法を採用することにより、OpenAPIを使って、それぞれのコマンド定義をTypeScriptで型付けできるかたちにすることにしました。その方法についても、また記事にできればと思います。

Discussion

結果的に IActionResult が必要なようなので dynamic を使う代わりに Ok までの呼び出しを切り出しておいて

private async Task<IActionResult> ExecuteCommandAsync<TAggregate, TContents, TCommand>(object command)
{
    var result = await _executor.ExecuteAsync<TAggregate, TContents, TCommand>((TCommand)command);
    return Ok(result);
}

これを呼び出すという手もありますね。

var methodInfo = createMethod.MakeGenericMethod(aggregateType, aggregateContentsType, commandType);
return await (Task<IActionResult>)methodInfo.Invoke(this, new object?[] { commandObject, null });
ログインするとコメントできます