rksoftware

Visual Studio とか C# とかが好きです

アドオン機能を持ったアプリを作る

C# でアプリに対してアドオン機能として、ユーザーが機能を追加できるようにしたいことが良くあります。

注意

今回の例は大いなる力をアドオン機能作成者の与えます。不用意なアドオン機能がアプリを簡単に壊すことができます。

大いなる力には大いなる責任が伴う。

■ サンプルプロジェクト

今回作ったソリューションは GitHub に置いてあります。

■ 概要

特定のディレクトリにアドオン機能として作った .dll を置いておくことで、アプリに機能追加したり機能変更したりできる様にする技術要素を考えてみます。
次のような感じです。

/アプリのインストールディレクトリ
├MyApp.exe
└/addons
  └ (ここにアドオンとして作った .dll を置く)

■ アドオン .dll 仕様

では作っていきましょう。
アドオンの .dll を作ってくれる人に求める仕様を決めます。

  • とあるインタフェースを実装すること

これだけです。ただし、実装クラスはふたつ用意してもらうことにします。アドオンで実現する機能を実装したクラスとそのクラスをインスタンス化するファクトリクラスです。理由はいくつかのインスタンス化の速さを雑に比較した次の記事です。

そこまで気にするほどの速さが必要なのかは分かりませんが、インスタンスメンバーが必要なアドオンの場合はファクトリクラスでインスタンス化する様にしておくほうが意識が高そうです。

※ファクトリクラスは Activator でインスタンス化することになりますが、それは一回だけで済むので。

■ 今回実装してもらうインタフェース

新しいクラスライブラリプロジェクトを作成し次のインタフェースを定義します。

ファクトリクラス用

ublic interface ITextDecorateAddOnFactory
    : IFactory<ITextDecorateAddOnFeature, string>
{
}

機能クラス用

public interface ITextDecorateAddOnFeature
{
    string Decorate(string text);
}

今回のアドオンはとある文字列に対して編集を行う機能 ( 例えば文頭や文末に文字列を追加するような ) を自由に実装することを提供することにしました。そのため、機能クラスには string を受け取って string を返す Decorate メソッドを定義しています。

■ ファクトリクラスのインタフェースが依存しているインタフェース

IFactory<ITextDecorateAddOnFeature, string> の部分ですね。

public interface IFactory<IFeature, Arg>
{
    IFeature Create(Arg arg);
}

機能クラスのインスタンスを生成する Create メソッドを定義しています。このインタフェースを使って、アドオンを管理するクラスがアドオンのクラスを管理します。

■ アドオンの管理 (読み込み、インスタンス化)

■ 読み込み機能

とあるディレクトリの中に置かれている .dll を読み込む処理です。読み込むディレクトリはアプリごとに変えやすいように、引数で受け取ることにしました。

 public class LoadAddOns
{
    public int LoadFiles(DirectoryInfo directory)
    {
        var files = directory.GetFiles("*.dll");

        foreach (var file in files) Assembly.LoadFile(file.FullName);
            return files.Length;
    }
}

■ ファクトリクラスをインスタンス化する機能

.dll ファイルを読み込んだらファクトリクラスをインスタンス化し、保持します。
ここで使っている現在読み込まれているアセンブリからと、あるインタフェースを実装しているクラスの Type を取り出すことに関しては別途記事を書いています。

Factory[] factories;

public void Init()
{
    factories =
        AppDomain.CurrentDomain.GetAssemblies()
        .SelectMany(asm => asm.GetTypes())
        .Where(t => t.GetInterfaces().Contains(typeof(Factory)))
        .Select(t => (Factory)Activator.CreateInstance(t))
        .ToArray() ?? new Factory[0];
}

機能の生成を行います。

public IEnumerable<Feature> Create(Arg arg) =>
    factories?.Select(factory => factory.Create(arg))
    .ToArray() ?? Enumerable.Empty<Feature>();

ふたつのメソッドは一つのクラスに実行し、次のようになりました。

public class CallAddOn<Feature, Factory, Arg>
    : where Factory : IFactory<Feature, Arg>
{
    Factory[] factories;

    public void Init()
    {
        factories =
            AppDomain.CurrentDomain.GetAssemblies()
            .SelectMany(asm => asm.GetTypes())
            .Where(t => t.GetInterfaces().Contains(typeof(Factory)))
            .Select(t => (Factory)Activator.CreateInstance(t))
            .ToArray() ?? new Factory[0];
    }

    public IEnumerable<Feature> Create(Arg arg) =>
        factories?.Select(factory => factory.Create(arg))
        .ToArray() ?? Enumerable.Empty<Feature>();
    }
}

■ アプリケーションを実装

ここまでの実装を組み込んでアプリケーションを実装してみます。
コンソールアプリケーションで、コンソールから読み取ったテキストをアドオン機能によって加工してコンソールに出力するシンプルなアプリです。

アドオン機能がインスタンス化されるときの引数は (ファクトリメソッド Create への引数) "Hello, " となっています。

class Program
{
    static void Main(string[] args)
    {
        var loadAddOns = new LoadAddOns();
        var callAddOn = new CallAddOn<ITextDecorateAddOnFeature, ITextDecorateAddOnFactory, string>();

        loadAddOns.LoadFiles(new DirectoryInfo("addons"));
        callAddOn.Init();

        var addOnFeatures = callAddOn.Create("hello, ");

        var text = Console.ReadLine();

        var result = addOnFeatures.Aggregate(text, (text, feature) => feature.Decorate(text));

        Console.WriteLine(result);
    }
}

■ 実行 (まずはアドオンなし)

アドオンなしで実行すると、コンソールに打ち込んだテキストは加工されずにおうむ返しにコンソールに出力されます。

次の例では、World を打ち込んだので World と出力されています。

>ExpandableApp1.exe
World
World

■ アドオンの実装

前述のインタフェースを実行してアドオンを実装します。サンプルコードの例では MyAddOn1MyAddOn2 のふたつの機能を実装しています。違いは文末に追加される文字が !? かだけです。

インスタンス化されるときに引数で受け取った文字列を先頭に追加するとともに、末尾に固定の文字列 (ここでは !) を追加します。

■ ファクトリの実装

public class MyTextDecorateAddOnFactory : ITextDecorateAddOnFactory
{
    public ITextDecorateAddOnFeature Create(string arg)
        => new MyTextDecorateAddOnFeature(arg);
}

■ 機能の実装

public class MyTextDecorateAddOnFeature : ITextDecorateAddOnFeature
{
    readonly string prefix;

    public MyTextDecorateAddOnFeature(string arg)
    {
        prefix = arg;
    }

    public string Decorate(string text) => $"{prefix}{text}!";
}

■ 実行 (アドオンあり)

アプリケーションの実行ファイルの横に、addons ディレクトリを作ってその中に、MyAddOn1MyAddOn2 のふたつのプロジェクトのアセンブリ (.dll) を置きます。
そしてアプリケーションを実行します。

次の例では、World を打ち込んだので World に対し、それぞれのアドオンが

  • 先頭に "hello, " を追加
  • 末尾に "!" または "?" を追加

をした hello, hello, World!? と出力されています。

>ExpandableApp1.exe
World
hello, hello, World!?

これで何とか拡張性のあるアプリケーションを実装することができました。