🍄

F#のコードに対してC#からリフレクションを使う際の注意事項と対策

2024/08/03に公開

株式会社ジェイテックジャパン CTOの高丘 @tomohisaです。最近は関数型的にどのようにC#で実装できるかいろいろ探求しています。

C#が動作する基盤のフレームワークである .NET は複数の言語に対応しています。そのうち1つがF#という、関数型にフォーカスした言語となっています。私たちはイベントソーシング・CQRSフレームワークであるSekibanをC#で開発していますが、.NETフレームワークで動作するF#でも使えるのではないかと以前からいろいろ試していました。

https://github.com/J-Tech-Japan/Sekiban

AsyncEnumerableを使ったものや、static interface methodの対応をF#で以前トライした時には僕の知識不足によりうまく実行できるところまでできませんでした。しかし、今回はまず、C# のSekiban側でEnumerable、AsyncEnumerableを使わない形でもコマンドを記述できる機能を追加しました。これはC#でも関数的な実行や記述方式ができるとともに、設定などをシンプルにするために行ったので、F#で使えるために行ったわけではなかったのですが、結果としてF#でも書けるようになったのではないかと考えて、F#でのドメイン記述に再挑戦してみました。

F#で記述するドメインコード

結果できたコードがこちら。以下のコードではクライアントを作成するドメインコマンドとなっています。

  • 指定したBranchIdが存在するかを確認
  • 指定したメールでアカウントが作られていないことを確認
  • ClientCreatedのイベントを登録
    というコードが以下のものとなっています。
type CreateClient =
    {
      [<Required>]Name: string
      [<Required>]Email: string
      [<Required>]BranchId: Guid }

    interface ICommandWithHandlerAsync<Client, CreateClient> with
        member this.GetAggregateId() = Guid.NewGuid()

        static member HandleCommandAsync(command, context) =
            context
                .ExecuteQueryAsync(BranchExistsQuery(command.BranchId))
                .Verify(fun exists ->
                    if exists then
                        ExceptionOrNone.None
                    else
                        ExceptionOrNone.FromException(InvalidDataException("Branch not exists")))
                .Conveyor(fun () -> context.ExecuteQueryAsync(ClientEmailExistsNextQuery(command.Email)))
                .Verify(fun exists ->
                    if exists then
                        ExceptionOrNone.FromException(InvalidDataException("Email not exists"))
                    else
                        ExceptionOrNone.None)
                .Conveyor(fun () ->
                    context.AppendEvent(ClientCreated(command.Name, command.Email, command.BranchId)))

これと同じことをC#で行うコードが以下のものです。

public record CreateClientR(
    [property: Required]
    Guid BranchId,
    [property: Required]
    string ClientName,
    [property: Required]
    string ClientEmail) : ICommandWithHandlerAsync<Client, CreateClientR>
{
    public Guid GetAggregateId() => Guid.NewGuid();

    public static Task<ResultBox<UnitValue>>
        HandleCommandAsync(CreateClientR command, ICommandContext<Client> context) => context
        .ExecuteQueryAsync(new BranchExistsQueryN(command.BranchId))
        .Verify(exists => exists ? ExceptionOrNone.None : new InvalidDataException("Branch not exists"))
        .Conveyor(_ => context.ExecuteQueryAsync(new ClientEmailExistQueryNext(command.ClientEmail)))
        .Verify(exists => exists ? new InvalidDataException("Email not exists") : ExceptionOrNone.None)
        .Conveyor(
            _ => context.AppendEvent(new ClientCreated(command.BranchId, command.ClientName, command.ClientEmail)));
}

元々C#で書くために作っているライブラリを使っていて、ほぼC#とF#のコードが同じであることがわかると思います。実際には、もっとF#らしい書き方ができるようなんですが、現在勉強中です。

その他のコードを含んだドメインコードはこちら。

https://github.com/J-Tech-Japan/Sekiban/blob/main/internalUsages/fsharp/Domain.fs

F#のドメインコードを実行する時にC#側でエラーになった原因

上記のコードを、C#のライブラリを読み込んで実行してみたのですが、うまく動かないという問題が発生しました。原因は、リフレクションを使用してメソッドを取得するコードにF#のクラスを渡した時に起きました。F#側の定義はこのようになっています。

type Branch =
    { Name: string }

    interface IAggregatePayload<Branch> with
        static member CreateInitialPayload(_: Branch) : Branch = { Name = "" }

これをC#側でリフレクションを使用して、CreateInitialPayloadのメソッド情報を取得する以下のコードを実行しました。

 var method = typeof(TAggregatePayload).GetMethod(
                nameof(IAggregatePayloadGeneratable<SnapshotManager>.CreateInitialPayload),
                BindingFlags.Static | BindingFlags.Public);

これをC#で書いたクラスに対して実行すると、"CreateInitialPayload"というメソッド名が見つかり、実行できるのですが、上記のF#のクラスに対して実行すると、method が nullで見つからずに帰ってくるという問題が発生しました。
なぜかというと、F#の BranchのTypeクラスを見るとメソッド名が以下の文字列で登録されていました。

Sekiban.Core.Aggregate.IAggregatePayloadGeneratable<fsCustomer.Domain.Branch>.CreateInitialPayload

つまり、メソッド名がインターフェース名.メソッド名となっています。

Sekibanの中ではところどころリフレクションを使っているので、ちょっと悩みました。

F#のクラスに対するリフレクションのGetMethodの問題を解決する

解決方法について考えてみたところ、GetMethods()で全部のメソッドは取得できるので、CreateInitialPayloadのメソッドを探すのはできそうだと感じました。また、C#側に問題となる解決策にはしたくなかったので、まず通常通りGetMethodで探した後に、なかったらGetMethodsで全関数を取得し、かつ、ドットが入り、メソッド名で終わるのを利用して、以下の拡張関数を作りました。

    public static MethodInfo? GetMethodFlex(
        this Type type, 
        string name, 
        BindingFlags bindingAttr = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public)
    {
        return type.GetMethod(name,bindingAttr) ?? type.GetMethods().FirstOrDefault(m => m.Name.EndsWith($".{name}"));
    }

こうすることにより、type.GetMethod(type.GetMethodFlex(に書き換えることによってC#のケースもF#のケースも取得してくれるようになりました。

幸い、メソッドを取得した後は同じコードで実行まで問題なくできたので、その他の部分は変えずに、F#でイベントソーシングを記述して、実行することが無事できました。

まとめ

F#は関数型的に書くことができ、代数的データ型にも対応しています。

最近C#でも将来の言語変更のプロポーザルとして、Union Typeに関するプロポーザルが提出されました。

https://github.com/dotnet/csharplang/blob/18a527bcc1f0bdaf542d8b9a189c50068615b439/proposals/TypeUnions.md

これが実現すれば、C#でも代数的データ型的なアプローチができるようになり、switchで全てのケースがカバーされている時にdefaultの記述が不要になり、新しく項目を追加した時に、足りない項目があることをコンパイラがエラーを出してくれることになるため、とても期待しているのですが、まだ仕様の議論の段階で、実際にdotnetに導入されるのは来年以降と思います。

それまで、F#も勉強しながら、関数的にドメインを記述する方法についてもより良い方法を実現していきたいと思います。

よろしければC#でもF#でも、Sekibanを使ってみていただければと思います。F#に対応した、0.20.6のバージョンもリリースされています。

https://github.com/J-Tech-Japan/Sekiban

ジェイテックジャパンブログ

Discussion