こんにちは。
テックリードのTerukiです。
今日はだいぶマニアックですがC#の特定の型を継承・実装したType型を取得したいというお話です。
なかったら詰むわけではないですが、個人的に興味深かったので備忘も込みで記事にしようと思います。
謝辞
このコード、記事を書くにあたり、DiscordのC# Japanの皆様に大変お世話になりました。ありがとうございました
モチベーション
なんでそんなことをしたいのかという話ですが、Type型を使う時はだいたいリフレクションであったりActivatorUtilitiesなどのDependency Injectionを使ったインスタンス生成などかと思います。
お仕事のコードの中にこんなものがあり、もうちょっとうまいことできないかと思ったのがやり始めたきっかけです。
Dictionary<string, Type> EventHandlers = new() {
["event1"] = typeof(Event1Handler),
["event2"] = typeof(Event2Handler),
["event3"] = typeof(Event3Handler),
};
if (EventHandlers.TryGetValue(eventId, out var handlerType)) {
var handler = (IEventHandler)ActivatorUtilities.CreateInstance(ServiceProvider, handlerType);
await handler.HandleAsync().ConfigureAwait(false);
}
これでも動くのですが、ActivatorUtilities.CreateInstanceの戻り値をキャストしているので
キャストに失敗すればクラッシュします。
しかし、極端な話をすれば下記のような指定をしてもコンパイル自体は通ってしまうわけです。
Dictionary<string, Type> EventHandlers = new() {
["event1"] = typeof(Event1Handler),
["event2"] = typeof(Event2Handler),
["event3"] = typeof(Event3Handler),
["illegal"] = typeof(object),
};
objectがIEventHandlerを実装しているわけがないので実行時に無事InvalidCastExceptionです。
こんなミスする人いないでしょと思うかもしれませんが、そういう問題ではなくこういった明らかにダメなものはコンパイルエラーにしたいですね。
いろいろやってみる
C# Japanで教えていただいたものも含めて色々やってみたので紹介していきたいと思います。結論だけ知りたい方はこちら。
関数を挟む
下記のような関数をEventHandlersの近くに置いておくパターン。
Type F<T>() where T : IEventHandler => typeof(T);
めちゃくちゃお手軽で良いですよね。
Dictionary<string, Type> EventHandlers = new() {
["event1"] = F<Event1Handler>(),
["event2"] = F<Event2Handler>(),
["event3"] = F<Event3Handler>(),
};
これなら F<object>()
とは書けないので目的は達成できてそうです。
ただ、EventHandlers内で F<T>()
の利用を強制できないのでこだわり強めな私には物足りなかったようです。
それでもオリジナルのtypeofよりはとても良いですね。
DictionaryのWrapperを自作する
Dictionary
にイベントハンドラを追加する時に型チェックができれば良さそうです。
internal class WrapDict<TBase> {
private Dictionary<string, Type> d = new();
public WrapDict<TBase> Add<T>(string key) where T : TBase {
d.Add(key, typeof(T));
return this;
}
public IReadOnlyDictionary<string, Type> Terminate() => d.ToFrozenDictionary();
}
Dictionary側で工夫するという発想はなかったので目からウロコでした。
Terminate()
でToFrozenDictionary()
しているところもおしゃれポイント高いですよね。
IReadOnlyDictionary<string, Type> EventHandlers = new WrapDict<IEventHandler>()
.Add<Event1Handler>("event1")
.Add<Event1Handler>("event2")
.Add<Event1Handler>("event3")
.Terminate();
};
この場合、EventHandlerをインスタンス化する処理はオリジナルと全く同じになりますが、EventHandlersにはIEventHandlerを実装しているTypeしか入れられないので安全度が高まってますね。
if (EventHandlers.TryGetValue(eventId, out var handlerType)) {
var handler = (IEventHandler)ActivatorUtilities.CreateInstance(ServiceProvider, handlerType);
await handler.HandleAsync().ConfigureAwait(false);
}
KeyedService
DIにEventHandlerの実装を入れてしまおうというアプローチです。
KeyedServiceというものがあるというのを初めて知りました。
static IServiceCollection AddEventHandler<T>(this IServiceCollection services,
string eventId) where T : IEventHandler {
return services.AddKeyedTransient<IEventHandler, T>(eventId);
}
StartupなどでAddEventHandlerをしてKeyにeventIdを入れれば後は簡単です。
var handler = ServiceProvider.GetKeyedService<IEventHandler>(eventId);
await handler?.HandleAsync().ConfigureAwait(false);
ActivatorUtilitiesを使っていないのがポイント高いですね。
TypeのWrapperを自作する
これは私が書いてみたものです。
readonly structなんて初めて使いましたが今回は綺麗にはまりました。
public readonly struct ConstrainedType<C> where C : class {
public Type BaseType { get; } = typeof(C);
public Type Type { get; private init; }
private ConstrainedType(Type type) {
Type = type;
}
public static ConstrainedType<C> Create<T>() where T : C {
return new ConstrainedType<C>(typeof(T));
}
}
使い方はこんな感じでCにベースクラス、Tにターゲットクラスを入れます。
Dictionary<string, ConstrainedType<IEventHandler>> EventHandlers = new() {
["event1"] = ConstrainedType<IEventHandler>.Create<Event1Handler>(),
["event2"] = ConstrainedType<IEventHandler>.Create<Event2Handler>(),
["event3"] = ConstrainedType<IEventHandler>.Create<Event3Handler>(),
};
ちょっと長くはなっていますが Create<T>
のwhere T : C
で制約を持たせられています。
ConstrainedType<C>
に下記のような関数を追加すればインスタンス化時にキャストを書かなくて済みます。
public C CreateInstance(IServiceProvider serviceProvider) {
return (C)ActivatorUtilities.CreateInstance(serviceProvider, Type);
}
if (EventHandlers.TryGetValue(eventId, out var eventHandlerType)) {
var eventHandler = eventHandlerType.CreateInstance(ServiceProvider);
await eventHandler.HandleAsync().ConfigureAwait(false);
}
個人的にはIEventHandler
でキャストを書くのではなくC
でキャストしている部分がポイント高いなと感じています。
結論
悩みましたが最終的に今回は一番最後のTypeをWrapする方法で行くことにしました。
KeyedServiceも迷ったのですが、今回のユースケースは特定の箇所の処理でしか使わないイベントハンドラであったため、それを全体で使うDIに入れるのがなんとなく違和感がありました。
また、実はEventHandlersに代入する処理がSource Generatorで実装されていたりもしていてStartupがややこしくなりそうだったというのもあります。
他のアプローチも目的はほとんど達成できているので、後は好みの部分もあると思いますがどれも有用だなと思っています。
実際に私が書いたWrapperのソースコードを置いておきますので今日がある方はこちらから↓。
全体
/// <summary>
/// 制約付きタイプを表す構造体
///
/// Type自体にはコンパイル時に特定のインターフェースやクラスを実装していることを強制する機能はないため
/// この構造体を使って間接的に指定した制約を強制することを保障する
/// </summary>
/// <typeparam name="C">被制約対象</typeparam>
public readonly struct ConstrainedType<C> where C : class {
/// <summary>
/// 基底タイプ
/// </summary>
public Type BaseType { get; } = typeof(C);
/// <summary>
/// ターゲットタイプ
/// 型はTypeだがBaseTypeを実装、継承していることが保障されている
/// </summary>
public Type Type { get; private init; }
private ConstrainedType(Type type) {
Type = type;
}
/// <summary>
/// 制約付きTypeを生成する
/// </summary>
/// <typeparam name="T">対象クラス</typeparam>
/// <returns>制約型</returns>
public static ConstrainedType<C> Create<T>() where T : C {
return new ConstrainedType<C>(typeof(T));
}
/// <summary>
/// 指定したサービスプロバイダからインスタンスを生成する
/// </summary>
/// <param name="serviceProvider">DIコンテナ</param>
/// <returns>CにキャストされたT</returns>
public C CreateInstance(IServiceProvider serviceProvider) {
return (C)ActivatorUtilities.CreateInstance(serviceProvider, Type);
}
}
おわりに
最初は公式の機能でこのようなことができないかを探していたのですが、なさそうだったので自作することになりました。
調べている段階で今まで知らなかったことを沢山知ることができたので非常に勉強になりました。
今回はだいぶニッチな内容でしたが今後も発信していけたらと思っています。
では。
Oh my teethについて
Oh my teethでは未来の歯科体験を創るために日々活動しています。
Techチームではより良いユーザー体験を提供するべく、Webフロントエンドからバックエンド、スマホアプリに機械学習モデルなど、さまざまなプロダクトを開発しています。
一緒に未来の歯科体験を創りませんか?興味がある方は是非こちらを確認してください。
カジュアル面談も可能なので気軽に応募してみてください!