C#で型名もメソッド名も引数も文字列で指定して呼び出す

この記事は[PONOS Advent Calendar 2022](https://qiita.com/advent-calendar/2022/ponos)の2日目の記事です。
昨日は@r73ryoさんの[Unityエディタ上で循環的複雑度(CCN)を計測する](https://qiita.com/e73ryo/private/e50b7c90a072bbd615c8)でした。

---
## 目的
```csharp
namespace SampleNamespace
{
    public class SampleClass
    {
        // SampleMethod(1, 23.4) -> "(1, 23.4)"
        public static string SampleMethod(int a, float b)
        {
            return $"({a}, {b})";
        }
    }
}
```
というクラスとメソッドがあったとして
```csharp
var result = Invoker.Invoke("SampleNamespace.SampleClass", "SampleMethod", "42", "1.23");
// result == "(42, 1.23)";
```
のように呼び出せるようにするための方法です。
コンソールから入力したメソッドを呼びたいときなんかに使えると思います。

## 注意
今回の解説の実装は呼び出すメソッドが`public`かつ`static`であることが前提なのでそれ以外となると工夫が必要です。
また例外処理などは組み込んでいないため利用する際は注意してください。

## 実装
先に`Invorker`全体の実装を書いておき、要素をバラしてから解説を行います。
```csharp
public static class Invoker
{
    /// <summary>
    // 参照しているアセンブリを含めたすべての型のキャッシュ
    /// </summary>
    static readonly TypeInfo[] AllTypes = GetAllTypes();

    /// <summary>
    /// 型名、メソッド名、引数を文字列で指定して呼び込む
    /// </summary>
    /// <param name="typeName">型名。フルパスもしくは型名だけの文字列</param>
    /// <param name="methodName">メソッド名/param>
    /// <param name="args">引数に渡す文字列</param>
    /// <returns>メソッドの実行結果</returns>
    [return: System.Diagnostics.CodeAnalysis.MaybeNull]
    public static object Invoke(string typeName, string methodName, params string[] args)
    {
        var typeInfo = AllTypes.FirstOrDefault(e => e.FullName == typeName) ?? AllTypes.First(e => e.Name == typeName);

        var methods = typeInfo.GetMethods(BindingFlags.Static | BindingFlags.Public);
        var methodInfo = methods.First(e => e.Name == methodName);

        var parameterTypes = methodInfo.GetParameters().Select(e => e.ParameterType).ToArray();
        var parameters = args.Select((arg, i) =>
        {
            var converter = TypeDescriptor.GetConverter(parameterTypes[i]);
            return converter.ConvertFrom(arg);
        }).ToArray();

        return methodInfo.Invoke(null, parameters);
    }

    /// <summary>
    // 参照しているアセンブリを含めたすべての型を取得するメソッド
    /// </summary>
    /// <returns>参照しているアセンブリを含めたすべての型</returns>
    static TypeInfo[] GetAllTypes()
    {
        // 実行中のアセンブリを取得、
        // 参照先のアセンブリもすべて取得して実行中のアセンブリも合わせてコレクションにする
        // すべてのアセンブリのすべての型情報を配列化して返す
        var executingAssembly = Assembly.GetExecutingAssembly();
        var allAssemblies = executingAssembly.GetReferencedAssemblies().Select(e => Assembly.Load(e)).Append(executingAssembly);
        return allAssemblies.SelectMany(assembly => assembly.GetTypes()).Select(e => e.GetTypeInfo()).ToArray();
    }
}
```
使用例
```csharp
public static void Main()
{
    var result = Invoker.Invoke("SampleNamespace.SampleClass", "SampleMethod", "42", "1.23");
    Console.WriteLine(result);
}
```
出力
```sh
(42, 1.23)
```

## 解説
それでは解説です。

### 型情報を取得する
型名から型情報を取得しようと思うと参照可能なすべての型を取得する必要があります。
そのためには一度参照先を含めすべてのアセンブリを取得する必要があります。

#### すべてのアセンブリを取得
まず実行中のアセンブリだけであれば
```csharp
System.Reflection.Assembly.GetExecutingAssembly();
```
で取得することができます。
しかしそれでは取得したい型が別アセンブリで定義されていた場合に参照できません。
そこで下記のように実行中のアセンブリの情報から参照先のアセンブリをすべて取得して実行中のアセンブリと合わせてコレクションにします。
```csharp
// ファイルの頭でSystem.ReflectionとSystem.Linqをusingしている
var executingAssembly = Assembly.GetExecutingAssembly();
var referencedAssemblies = executingAssembly.GetReferencedAssemblies().Select(e => Assembly.Load(e));

// 実行中のアセンブリと参照しているアセンブリを合わせたコレクション
var allAssemblies = referencedAssemblies.Append(executingAssembly);
```

#### すべての型情報を取得
単一のアセンブリから型情報をすべて取得するには下記のように`GetTypes()`を呼び出すことで取得できます。
```csharp
var typeInfoArray = assembly.GetTypes();
```
`Assembly`の配列がある場合はの下記ようにすることで参照先のアセンブリを含めたすべての型情報を取得することができます。
```csharp
allAssemblies.SelectMany(assembly => assembly.GetTypes()).Select(e => e.GetTypeInfo()).ToArray();
```

`Invorker`クラスでは`GetAllType()`でこの結果を取得し`AllTypes`というstaticメンバにキャッシュしています。
`GetAllTypes()`の実装と解説のコードは異なりますが短くしているだけで結果は同じです。

### 型情報を取得する
すべての型情報を取得できているのでその中から指定した名前を持つクラスを見つけるのは`TypeInfo.FullName`もしくは`TypeInfo.Name`が一致するクラスを取得するだけで済みます。
下記の処理では`FullName`に一致する型名がなかった場合に`Name`に一致する型を取得するようにしています。
```csharp
var typeInfo = AllTypes.FirstOrDefault(e => e.FullName == typeName) ?? AllTypes.First(e => e.Name == typeName);
```
ちなみに`FullName`と`Name`の違いはいくつかありますが今回は`namespace`名を含んでいるかどうかくらいの認識で大丈夫です。

### メソッドの情報を取得する
メソッド呼び出しと引数の情報を取得するには後述する`MethodInfo`が必要なので`TypeInfo`から指定した名前の`MethodInfo`を取得します。
`GetMethods()`メソッドには`BindingFlag`を複数指定することができます。今回は`BindingFlag.Public | BindingFlag.Static`を指定して`public static`メソッドをすべて取得し、その中から名前が一致するメソッドを取得します。
```csharp
var methods = typeInfo.GetMethods(BindingFlags.Static | BindingFlags.Public);
var methodInfo = methods.First(e => e.Name == methodName);
```
**注意**
型の中に同じ名前のメソッドがある場合は最初に見つけたものがヒットします。

### メソッド呼び出し
#### MethodInfoについて
`Invoker.Invoke`の最後に使用している`MethodInfo.Invoke`についてです。
[ドキュメント](https://learn.microsoft.com/ja-jp/dotnet/api/system.reflection.methodinfo?view=net-6.0)

C#には`System.Reflection.MethodInfo`という型が存在し、この型のインスタンスを取得することでメソッドのメタデータにアクセスできるようになります。`MethodInfo.Invoke`を使用することでそのメソッドを呼び出すことが可能で、その場合メソッドの結果は`object`型で返ってきます。
`invoke`の定義は
```csharp
Invoke(Object, Object[])
```
となっており、引数には(呼び出しの対象となるオブジェクト, Invokeで呼ぶメソッドの引数の配列)を渡す必要があります。`static`メソッドの場合は第一引数は`null`で大丈夫です。
```csharp
// Invokeの例
public static class App
{
    public static int Add(int a, int b)
    {
        return a + b;
    }

    public static void Main()
    {
        var methodInfo = typeof(App).GetMethod("Add"); // App型から"Add"という名前のメソッドの情報を取得する
        var result = methodInfo.Invoke(null, new object[] { 1, 2 }); // (1, 2)を引数にAddを呼び出す
        Console.WriteLine(result); // 3が出力される
    }

```

#### 文字列から引数に渡すパラメーターに変換する
##### 引数の型情報を取得する
`MethodInfo.Invoke`でメソッド呼び出しができますが、渡してやる引数は型が正しい必要があります。そのため下記のように文字列から引数の型に変換した`object[]`の変数を生成する必要があります。
```csharp
// argsはstring[]型で{ "4", "1.23" }が格納されている
// このままだと引数の型が異なるため例外が発生する
methodInfo.Invoke(null, args);

object[] convertedArgs = /* argsをstring[]からobject[] { int(4), float(1.23) } に変換する */;
methodInfo.Invoke(null, convertedArgs);
```

それを実現するには`MethodInfo.GetParameters()`と`TypeConverter`クラスを使用します。

まずメソッドの引数の型ですが`GetParameters()`を使用することで引数の型情報のメタ情報の配列を取得することができるのでメタ情報から`ParameterType`を取得することで引数の型情報の配列を取得することができます。
```csharp
var parameterTypes = methodInfo.GetParameters().Select(e => e.ParameterType).ToArray()
```
**補足**
`MethodInfo.GetParamters()`は引数の情報をAttributeなども含めてすべて返しますが、今回必要なのは型の情報だけなので`Select`で`ParamaterType`(`TypeInfo`)のコレクションに変換しています。

##### 文字列から引数の型に変換する
`System.ComponentModel.TypeDescriptor.GetConverter(TypeInfo)`というメソッドを使用することで何らかの値を指定の型に変換するための`TypeConverter`を取得することができます。`TypeConverter.ConvertFrom(object)`を使用することで文字列を指定の型に変換することができます。
```csharp
var typeInfo = typeof(int);
var converter = TypeDescriptor.GetConverter(typeInfo);
var v = converter.ConvertFrom("42"); // vはint型で42
```
これを使用してすべての文字列をメソッドの引数の型に変換してやることで`Invoke`に渡す配列を生成することができます。
```csharp
var parameters = args.Select((arg, i) =>
{
    var converter = TypeDescriptor.GetConverter(parameterTypes[i]);
    return converter.ConvertFrom(arg);
}).ToArray();
```

#### Invoke
呼び出したいメソッドの`MethodInfo`と引数のオブジェクトの配列があるのであればメソッド呼び出しは下記のように`Invoke`を呼び出してやるだけで良いです。
```csharp
methodInfo.Invoke(null, parameters);
```

## Invoker.Invoke
ここまでのことを合わせると`Invoker.Invoke`の実装はこのようになります
```csharp
public static object Invoke(string typeName, string methodName, params string[] args)
{
    var typeInfo = AllTypes.FirstOrDefault(e => e.FullName == className) ?? typeInfo = AllTypes.First(e => e.Name == className);

    var methods = typeInfo.GetMethods(BindingFlags.Static | BindingFlags.Public);
    var methodInfo = methods.First(e => e.Name == methodName);

    var parameterTypes = methodInfo.GetParameters().Select(e => e.ParameterType).ToArray();
    var parameters = args.Select((arg, i) =>
    {
        var converter = TypeDescriptor.GetConverter(parameterTypes[i]);
        return converter.ConvertFrom(arg);
    }).ToArray();

    return methodInfo.Invoke(null, parameters);
}
```
これで型名もメソッド名も引数も文字列で指定して呼び出すことが可能になりました。

## 補足
### 参照先のアセンブリが必要になる場合(Unity)
実行中のアセンブリの型だけじゃなくて参照先のアセンブリの型が必要になることなんてあるの？という疑問がある人もいると思うので補足です。
エディタ拡張でランタイムで使用する型を取得しようと思うと`Assembly-CSharp-Editor`と`Assembly-CSharp`でアセンブリが別れているので必要になります。
実際これに気づかずに時間を無駄にしたことがあります。

## まとめ
型名もメソッド名も引数も文字列で指定して呼び出すために必要なもの
- すべての型の型情報(参照先のアセンブリも含む)
- 型情報からメソッドの情報(`MethodInfo`)を取得する
- メソッドの情報から引数の型情報を取得する
- 文字列から引数の型に変換する
- `MethodInfo.Invoke`

こういう記事を書くと複雑に思えますが知っていれば割とすんなり書けます。

---
明日は@nissy_gpさんの「TikTok APIを用いて広告レポートを自動的に取得する」です。