ささいなことですが。

Windowsアプリテスト自動化ライブラリFriendly開発者の日記です。

リフレクションでハマったこととか

meetup app osaka@5で登壇します。そしてそれ用の資料となるブログです。(多分土曜の朝まで更新し続けると思います
meetupapp.connpass.com

TestAssistantProってツールを作っているのですが、なんだかんだでリフレクションが非常に重要になってきます。その辺りでハマったり「おっ」って思ったりしたことを書いていきます。ちなみにTestAssistantProはこれです。
youtu.be

参照しているdllがない

いきなりリフレクションと少し離れるのですが、ビルドしたときのフォルダに参照いるdllがない問題にハマりました。.NetFrameworkではこんなことはないですよね。これ何がまずいかっていうとVS拡張からdllの何かの機能を実行しようって時に関連dllが読み込めなくてエラーになる。

タイプ dllが集まる
.NetFramework(Exe)
.NetFramework(Library)
.NetStandard ×
.NetCore(Exe)
.NetCore(Library) ×
.NetCore(ユニットテスト)

どうやらライブラリの場合に集まらないみたいです。が、ライブラリでも例外的にユニットテストは集まっている。調べてみると「Microsoft.NET.Test.Sdk」が参照されている場合にはexe同様に集められているようです。なぜ「Microsoft.NET.Test.Sdk」を参照すると集まるのかはわかりませんでしたが(PropertyGroupのTestProject、IsTestProjectをtrueにしてもダメ)ないとテスト実行できないので何か特殊な仕掛けがあるのでしょう。

対象の処理を実行するには?

基本的には関連するdllを全部読み込む必要があります。exeは一か所に集まっているので簡単ですね。で、ライブラリ系の集まってないやつをどうするかというと道は二つです。

  1. deps.jsonを解析して関係するdllをPC内から探して全て読み込む
  2. 必要なdllを集めて読み込む

1はやる気が全く起きませんでした。大人しく2の方法にします。これはどうすればいいかというとpublishコマンドを使えば集まります。以下サンプルコードです。
呼び出される.NetCoreのライブラリのコード。

using Newtonsoft.Json;
using System;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;

namespace CoreLib
{
    public class Test
    {
        public static void Execute()
        {
            //jsonのシリアライズ
            //別のNewtonsoft.Json.dllを参照する必要がある
            var text = JsonConvert.SerializeObject(new Data { Value = "aaa" });
            var obj1 = JsonConvert.DeserializeObject<Data>(text);

            //BinaryFormatterのシリアライズ
            //.NetFrameworkならこのこれの解決にはAssemblyResolveが必要
            var bin = ToBinary(new Data { Value = "aaa"});
            var obj2 = ToObject<Data>(bin);
        }

        static byte[] ToBinary<T>(T obj)
        {
            var formatter = new BinaryFormatter();
            using (var mem = new MemoryStream())
            {
                formatter.Serialize(mem, obj);
                return mem.ToArray();
            }
        }

        static T ToObject<T>(byte[] bin)
        {
            var formatter = new BinaryFormatter();
            using (var mem = new MemoryStream(bin))
            {
                return (T)formatter.Deserialize(mem);
            }
        }
    }

    [Serializable]
    public class Data
    {
        public string Value { get; set; }
    }
}

リフレクションで呼び出す側のコードです。

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;

namespace CoreExe
{
    class Program
    {
        static void Main(string[] args)
        {
            var projectDir = @"C:\Reflection\CoreLib";
            var binDir = @"C:\Reflection\CoreLib\bin\Debug\netcoreapp3.1\publish";
            var dllPath = @"C:\Reflection\CoreLib\bin\Debug\netcoreapp3.1\publish\CoreLib.dll";

            //publishでビルドしてdllを集める
            var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("dotnet", "publish -c Debug")
            {
                WorkingDirectory = projectDir,
                CreateNoWindow = true,
                UseShellExecute = false
            }); ;
            process.WaitForExit();

            //全部のdllを読み込み
            foreach (var e in Directory.GetFiles(binDir, "*.dll", SearchOption.AllDirectories))
            {
                Assembly.LoadFrom(e);
            }

            //※アセンブリ解決補助
            AppDomain.CurrentDomain.AssemblyResolve += CurrentDomainAssemblyResolve;

            //実行
            var type = Assembly.LoadFrom(dllPath).GetType("CoreLib.Test");
            var method = type.GetMethod("Execute");
            method.Invoke(null, new object[0]);
        }

        //※アセンブリ解決補助
        static Assembly CurrentDomainAssemblyResolve(object sender, ResolveEventArgs args)
            => AppDomain.CurrentDomain.GetAssemblies().Where(e => e.FullName == args.Name).FirstOrDefault();
    }
}

.NetFrameworkと異なる動作

いくつかあります。全部ではないですが私が気づいた範囲で書いておきます。

AssemblyResolveの動作が異なる

これは改善された点です。先に書いたコードでAssemblyResolveを使っている箇所があります。.NetFrameworkではBinaryFormatterのシリアライズの時点でAssemblyResolveの補助がなければ解決することができませんでした。しかし.NetCoreではこれがなくても解決できます。昔からこんなんなくても自力で解決できるやろって思ってたからこれは嬉しいですね。ではなぜAssemblyResolve自体残っているのかですがサテライトアセンブリの解決に必要なようです。
docs.microsoft.com

.NetCore2.0より前はAppDomainが使えなかった

昔話です。今は使えるので問題ないです。がフルに使えるわけではありません。CreateDomainなどの機能は削除されています。とは言えリフレクション系で良くお世話になる AppDomain.CurrentDomain.GetAssemblies() は使えるので問題はないですね。いやいやロードしているアセンブリの一覧とれないとかリフレクションできへんやんって感じでした。

.NetCore2.0より前はTypeからTypeInfoが抜かれていた

懐かしのストアアプリ時代に一度設計が変わりました。それがPCLや.NetCore1.1、.NetStandard1.6まではそのまま続いていました。でもよほど不評だったのか今では旧のインターフェイスも使えるようになっています。

普通は

void Test(Type type)
{
    //いやいや普通にTypeに聞けばいいし
    var isClass = type.IsClass;
}

TypeInfoが抜かれていた暗黒の時代

void Test(Type type)
{
    //TypeIsClassがなくてGetTypeInfo()でTypeInfoを取得してそれに聞かないといけない
    var isClass = type.GetTypeInfo().IsClass;
}

Xamarin/UWP

XamarinとUWPに関しては最近全然触ってなくて(昔に実験的に触っただけ)今触りましたw。一般的なリフレクションは当然使えます。ただAppDomain触ったりdll読み込んだりは制限があります。昔はAPI自体なかったんだけど最近ではAPIはあるけど使えんかったら実行時エラーって流れになってますね。
dllの読み込みに関しては以下だけ読み込めます。UWPでは同一フォルダにあるものはビルド時に参照していなくても読み込めるようです。

タイプ Assembly.LoadFrom
UWP 同一フォルダにあるもの
Xamarin ビルド時に参照しているもの

あと、リフレクションではないのですがiOSではIL Emitはできないという有名な話があるようです。

まとめ

.NetCore2.0、.NetStandard2.0以降は改善されて冒頭のdllが集まらないこと以外は.NetFrameworkと遜色なくつかえて意外とハマらなくなってますね。

おまけ

尺が余ったとき用のリフレクションのチップスを一つ。
例えばわたって来たタイプを元にジェネリック型のインスタンスを生成したい場合に作るのはこれで良いけど、それどうやって使いますかってやつ。3つくらい考えられます。

  1. 戻ってきたやつをリフレクションで実行
  2. dynamic
  3. Type Erasure

僕は2と3を使い分けることが多いですね。Type Erasure ってのはジェネリックなどで静的な型付けが必要なクラスをジェネリックのタイプを消して動的に使うためのテクニックです。もとはC++のテンプレートで発生したテクニックのようです。

public interface IExecutor
{
    void Execute();
}

public class Executor<T> : IExecutor
{
    public void Execute()
    {
        //...何かの処理
    }
}
static void UserExecutor(Type t)
{
    //生成
    var executorType = typeof(Executor<>).MakeGenericType(t);
    var executor = Activator.CreateInstance(executorType);

    //①リフレクション
    executorType.GetMethod("Execute").Invoke(executor, new object[0]);

    //②dynamic
    dynamic executorDynamic = executor;
    executorDynamic.Execute();

    //③Type Erasure
    var iexecutor = (IExecutor)executor;
    iexecutor.Execute();
}