前回までに、リフレクション、外部アセンブリの読み込み、属性を見てきました。
今回はそれらを組み合わせて、プラグインで拡張できるアプリケーション の構造を考えます。
ここで大切なのは、単に DLL を読み込むことではありません。
アプリ本体と拡張機能の境界をどこに置くか、どの情報を契約として共有するか、失敗したプラグインをどう扱うか、という設計が重要です。
拡張可能なアプリケーションの基本構造
プラグイン方式では、アプリケーションをいくつかの役割に分けます。
ExtensibleApp/
AppHost アプリ本体
PluginContracts 共通のインターフェイスや属性
Plugins/
CsvPlugin 追加機能
JsonPlugin 追加機能
重要なのは、アプリ本体とプラグインが直接お互いの実装に依存しないことです。
両者は、共通契約だけを共有します。
共通契約を定義する
まず、プラグインが満たすべきインターフェイスを定義します。
namespace PluginContracts;
public interface IAppPlugin
{
string Id { get; }
string DisplayName { get; }
Task ExecuteAsync(PluginContext context, CancellationToken token);
}
実行時に渡す情報も、専用のコンテキストとしてまとめます。
namespace PluginContracts;
public sealed class PluginContext
{
public PluginContext(string workingDirectory)
{
WorkingDirectory = workingDirectory;
}
public string WorkingDirectory { get; }
public void Log(string message)
{
Console.WriteLine($"[Plugin] {message}");
}
}
プラグインにアプリ本体の全機能を渡すのではなく、必要な操作だけを PluginContext に公開します。
この考え方が、拡張性と安全性の境界になります。
属性でプラグイン情報を表す
プラグインの表示名、カテゴリ、順序などは属性で表現できます。
namespace PluginContracts;
[AttributeUsage(AttributeTargets.Class)]
public sealed class AppPluginAttribute : Attribute
{
public AppPluginAttribute(string id, string displayName)
{
Id = id;
DisplayName = displayName;
}
public string Id { get; }
public string DisplayName { get; }
public string Category { get; set; } = "General";
public int Order { get; set; }
}
インターフェイスは「実行できること」を表し、属性は「見つけるための説明」を表します。
プラグインを実装する
プラグイン側では、共通契約を参照して実装します。
using PluginContracts;
[AppPlugin("csv.import", "CSV 取り込み", Category = "Import", Order = 10)]
public sealed class CsvImportPlugin : IAppPlugin
{
public string Id => "csv.import";
public string DisplayName => "CSV 取り込み";
public async Task ExecuteAsync(
PluginContext context,
CancellationToken token)
{
context.Log("CSV 取り込みを開始します。");
await Task.Delay(500, token);
context.Log("CSV 取り込みが完了しました。");
}
}
このプラグインは、アプリ本体を直接参照していません。
共通契約だけを参照しているため、アプリ本体とは独立してビルド・配置できます。
プラグイン情報を表すモデル
アプリ本体では、読み込んだプラグインを扱うためのモデルを用意します。
using PluginContracts;
public sealed class PluginDescriptor
{
public PluginDescriptor(
AppPluginAttribute metadata,
Type implementationType)
{
Metadata = metadata;
ImplementationType = implementationType;
}
public AppPluginAttribute Metadata { get; }
public Type ImplementationType { get; }
}
型そのものと属性情報をセットで持っておくと、一覧表示や実行に使いやすくなります。
プラグインを発見する
アセンブリ内から、条件に合う型を探します。
using System.Reflection;
using PluginContracts;
static IReadOnlyList<PluginDescriptor> DiscoverPlugins(Assembly assembly)
{
var plugins = new List<PluginDescriptor>();
foreach (Type type in assembly.GetTypes())
{
if (!typeof(IAppPlugin).IsAssignableFrom(type))
{
continue;
}
if (!type.IsClass || type.IsAbstract)
{
continue;
}
AppPluginAttribute? metadata =
type.GetCustomAttribute<AppPluginAttribute>();
if (metadata == null)
{
continue;
}
plugins.Add(new PluginDescriptor(metadata, type));
}
return plugins
.OrderBy(plugin => plugin.Metadata.Order)
.ToList();
}
ここでは、次の 3 つを条件にしています。
- 共通インターフェイスを実装している
- インスタンス化できる具象クラスである
- プラグイン属性が付いている
この条件により、偶然存在するクラスを誤ってプラグインとして扱う可能性を減らせます。
フォルダー内のプラグインを読み込む
プラグインフォルダー内の DLL を読み込んで探索します。
using System.Reflection;
static IReadOnlyList<PluginDescriptor> LoadPluginsFromDirectory(string pluginDirectory)
{
var result = new List<PluginDescriptor>();
foreach (string file in Directory.GetFiles(pluginDirectory, "*.dll"))
{
try
{
Assembly assembly = Assembly.LoadFrom(file);
result.AddRange(DiscoverPlugins(assembly));
}
catch (Exception ex)
{
Console.WriteLine($"読み込み失敗: {file}");
Console.WriteLine(ex.Message);
}
}
return result;
}
本格的には AssemblyLoadContext を使って、依存関係やアンロードも設計します。
ただし、まずは「フォルダーから DLL を読み込んで機能を発見する」流れを押さえることが大切です。
プラグインを実行する
発見した型からインスタンスを作り、共通インターフェイスとして実行します。
using PluginContracts;
static async Task RunPluginAsync(
PluginDescriptor descriptor,
PluginContext context,
CancellationToken token)
{
object? instance = Activator.CreateInstance(descriptor.ImplementationType);
if (instance is not IAppPlugin plugin)
{
Console.WriteLine("プラグインを作成できませんでした。");
return;
}
await plugin.ExecuteAsync(context, token);
}
呼び出し側です。
string pluginDirectory = Path.Combine(AppContext.BaseDirectory, "Plugins");
IReadOnlyList<PluginDescriptor> plugins = LoadPluginsFromDirectory(pluginDirectory);
foreach (PluginDescriptor plugin in plugins)
{
Console.WriteLine($"{plugin.Metadata.Id}: {plugin.Metadata.DisplayName}");
}
PluginContext context = new PluginContext(AppContext.BaseDirectory);
foreach (PluginDescriptor plugin in plugins)
{
try
{
await RunPluginAsync(plugin, context, CancellationToken.None);
}
catch (Exception ex)
{
Console.WriteLine($"実行失敗: {plugin.Metadata.DisplayName}");
Console.WriteLine(ex.Message);
}
}
プラグインは外部コードなので、読み込みと実行の両方で例外処理を入れます。
アプリケーション構造として考える
プラグイン方式を設計するときは、次の点を最初に決めるとよいです。
何を拡張可能にするか
すべてを自由に拡張可能にすると、アプリ全体が不安定になります。
「取り込み処理だけ」「出力形式だけ」「通知方法だけ」のように、拡張点を絞ると設計しやすくなります。
契約を小さく保つ
共通インターフェイスが大きくなると、プラグイン実装者の負担が増えます。
最初は小さく始め、必要に応じてバージョンを上げる方が安全です。
プラグインに渡す権限を制限する
PluginContext に何でも入れると、プラグインがアプリ本体を壊しやすくなります。
ログ、設定、作業ディレクトリ、限定されたサービスなど、必要なものだけを渡します。
失敗しても本体を止めない
1 つのプラグインの失敗でアプリ全体が落ちると、拡張機能がリスクになります。
読み込み失敗、初期化失敗、実行失敗は個別に捕まえ、ログに残せるようにします。
バージョン互換性を考える
共通契約を変更すると、既存プラグインが動かなくなることがあります。
新しいメソッドを追加したい場合は、新しいインターフェイスを追加するなど、互換性を壊しにくい設計を考えます。
public interface IAppPlugin
{
string Id { get; }
string DisplayName { get; }
Task ExecuteAsync(PluginContext context, CancellationToken token);
}
public interface IConfigurablePlugin : IAppPlugin
{
void Configure(IReadOnlyDictionary<string, string> settings);
}
このように追加機能を別インターフェイスに分けると、古いプラグインも動かしやすくなります。
まとめ
拡張可能なアプリケーションでは、リフレクションや属性は「機能を発見するための道具」になります。
ただし、本当に大切なのは、共通契約、拡張点、エラー分離、バージョン互換性を設計することです。
プラグイン構造は、アプリ本体を小さく保ちながら、機能を後から追加するための強力なパターンです。
次回は、dynamic と動的な呼び出しを使って、遅延バインディングをもう少し簡潔に書く方法を見ていきます。