Plugin System

Plugin System

Die Software kann noch so gut sein, aber ohne die Möglichkeit einer Plugin-Schnittstelle ist der Anwender immer dazu gezwungen, sich bei der Erweiterung um neue Features auf den Entwickler zu verlassen.
Kurz gesagt, nur mit einer anständigen Plugin-Schnittstelle kann Software auch vom Kunden beliebig erweitert werden.
Wie genau das funktioniert möchte ich euch in diesem Artikel erklären.
Dazu habe ich einen einfachen Text-Editor erstellt, der dann über Erweiterungen die eigentlichen Funktionieren implementiert.

Damit ihr möglichst einfach Schritt für Schritt folgen könnt, habe ich euch die fertigen Projekte hoch geladen.
Einmal den Editor im Rohformat, d.h. ohne jegliche Vorarbeiten sowie das fertige Projekt (falls ihr vorher kurz sehen wollt, was genau euch als Resultat erwartet).

Fangen wir also an..

1. Projektmappe
Ladet euch zuerst einmal am Ende dieser Seite die Projektmappe Editor herunter, entpackt diese und öffnet das Projekt in VisualStudio.
Ihr werdet darin eine herkömmliche Windows Forms Anwendung finden, die lediglich ein Formular beinhaltet.
Öffnet das FormMain und geht in die Methode InitializePlugins.
Diese Methode soll alle gefunden Erweiterungen laden und für jede Erweiterung einen Menüeintrag erstellen, der dann die Erweiterung ausführt.
Als Erweiterung erwarte ich hier eine einfache Methode, die eine Aktion ausführt und den Text im Editor verändert.
In diesen Tutorial schreiben wir nun eine Ersetzen Erweiterung.

2. Plugin-Schnittstelle
Damit der Editor mit der Erweiterung kommunizieren kann, muss natürlich zuerst einmal eine einheitliche und vordefinierte Struktur erzeugt werden.
D.h. die Erweiterung muss wissen, wie sie sich verhalten muss, damit der Editor damit zurecht kommt.
Hierzu verwendet man Schnittstellen, welche einen Vertrag zwischen zwei Stellen darstellen.
Eine Schnittstelle ist also nix weiter als eine Vorlage von Methoden und Eigenschaften, die implementiert werden müssen, damit dieser Vertrag zu Stande kommt.
Allerdings möchte ich dies hier nicht im Detail erklären, da dass den Rahmen dieses Artikels sprengen würde.
Ein gutes Nachschlagewerk hierfür finden ihr z.B. hier.
Was wir nun tun ist also folgendes, wir fügen der Projektmappe nun ein neues Projekt vom Typ Klassenbibliothek hinzu und implementieren eine Schnittstelle, die wie folgt aussieht.
using System.Windows.Forms;

namespace EditorPluginSystem
{
    public interface IEditorPlugin
    {
        string Name { get; }

        void Initialize(Form host);
        string PluginAction(string text);
    }
}

Wir haben eine Eigenschaft, die den Name der Erweiterung angibt und zwei Methoden.
Die Methode Initialize wird aufgerufen wenn die Erweiterung geladen wird und die Methode PluginAction wird ausgeführt wenn der Menüeintrag der Erweiterung vom Benutzer angeklickt wurde.

Die Klassenbibliothek erfordert noch eine Referenz zu System.Windows.Forms.
Zudem müssen wir die neue Klassenbibliothek noch im Editor als Projektreferenz hinzufügen.

Außerdem würde ich den Ausgabeordner für die compilierte DLL-Datei auf den Plugins Ordner ändern.
Dann könnt ihr mit F5 direkt debuggen und müsst nicht immer umständlich die DLL kopieren.

Geht also auf die Eigentschaften vom Projekt EditorPluginSystem und navigiert zum Tab Erstellen.
Ändert dort den Ausgabepfad die folgt:


3. InitializePlugins implementieren
Nachdem wir nun die Schnittstelle definiert haben, können wir auch schon damit loslegen die Erweiterungen im Editor zu laden.
Dazu gehen wir wieder zurück in die Code-Ansicht des FormMain Formulars und gehen in die Methode InitializePlugins.
Dort suchen wir nun als erstes in einem vorgegebenen Verzeichnis nach allen Dateien mit der Erweiterung *.dll.
DLL steht für Dynamic Link Library und stellt eine Bibliothek mit Funktionen zur Verfügung die dann vom Anwendungen genutzt werden können.
Genau so eine DLL-Datei wird erstellt, wenn eine Klassenbibliothek compiliert wird.

Der Code für die Methode InitializePlugins schaut also wie folgt aus:
string pluginDirectory = Path.Combine(Application.StartupPath, "Plugins");
foreach (string path in Directory.GetFiles(pluginDirectory, "*.dll"))
{
    // todo: Erweiterung laden ..
}

Wie ihr sehen könnt, gehe ich davon aus, dass alle Erweiterungen im Unterverzeichnis Plugins liegen.

Als nächstes nutzen wir die Reflection Technik von .NET aus, um die Assembly (das ist einfach ausgedrückt, eine .NET Anwendung) zu laden und nach unserer soeben definierten Schnittstelle zu durchsuchen.

Das geht wie folgt:
Assembly pluginAssembly = Assembly.LoadFile(path);
foreach (Type pluginType in pluginAssembly.GetTypes())
{
    if (pluginType.IsPublic && !pluginType.IsAbstract)
    {
        Type pluginInterface = pluginType.GetInterface("EditorPluginSystem.IEditorPlugin", false);
        if (pluginInterface != null)
        {
            object pluginInstance = Activator.CreateInstance(pluginAssembly.GetType(pluginType.ToString()));
            if (pluginInstance != null)
            {
                IEditorPlugin plugin = (IEditorPlugin)pluginInstance;
                plugin.Initialize(this);

                ToolStripMenuItem miPlugin = new ToolStripMenuItem(plugin.Name);
                miPlugin.Tag = plugin;
                miPlugin.Click += miPlugin_Click;
                mtPlugins.DropDownItems.Add(miPlugin);
            }
        }
    }
}

Ich erkläre mal Zeile für Zeile, da dieser Code schon etwas komplizierter ist.
Zuerst laden wir die DLL-Datei (Im Code heißt das dafür vorhandene Objekt Assembly), was eigentlich nix weiter als eine .NET Anwendung darstellt.
Wenn wir diese erstellt haben, gehen wir alle darin enthaltenen Typen durch, also Schnittstellen, Klassen usw. und überprüfen bei jeder ob diese als Public und nicht Abstract gekennzeichnet wurde, also im wesentlichen genauso wie unsere Schnittstelle.
Danach Versuchen wir ein Type-Object unserer Schnittstelle zu erhalten.
Wenn auch das funktioniert hat, können wir von der eigentlichen Klasse die unsere Schnittstelle implementiert hat eine Instanz erzeugen.
Nun casten wir noch zu Typ IEditorPlugin und rufen die darin enthaltene Methode Initialize auf und übergeben unser Formular.
Zuletzt wird noch ein Menüeintrag erstellt, der dann später die PluginAction Methode bei einen Klick darauf ausführt.

4. Eventhandler für Menüeinträge implementieren
Der Event-Handler für die Menüeinträge schaut nun wie folgt aus:
private void miPlugin_Click(object sender, EventArgs e)
{
    IEditorPlugin plugin = (IEditorPlugin)((ToolStripMenuItem)sender).Tag;

    string result = plugin.PluginAction(tbxText.Text);
    if (result != tbxText.Text)
    {
        tbxText.Text = result;

        m_changed = true;
        SetTitle(m_filepath);
    }
}

Hier holen wir uns aus dem sender (welcher dem Menüeintrag entspricht) über die darin enthaltene Eigenschaft Tag die eigentliche Instanz der Erweiterung und rufen im nächsten Schritt die PluginAction Methode aus.
Sollte diese Aktion unseren Text verändert haben, aktualisieren wir das EditControl und markieren den Text als geändert.

5. Erweiterung erstellen
Da wir nun die eigentliche Plugin-Schnittstelle im Editor implementiert haben, können wir damit starten die erste Erweiterung zu erstellen.
Wie bereits gesagt, möchte ich in diesem Beispiel die Ersetzen Funktion als Erweiterung umsetzen.

Bitte macht nun folgendes:
1. Fügt der Projektmappe ein neues Projekt vom Typ Klassenbibliothek hinzu.
2. Fügt Referenzen zu System.Windows.Forms und der Plugin-DLL (EditorPluginSystem.dll) hinzu.
3. Erstellt eine neue Klasse, welche die Schnittstelle IEditorPlugin implementiert.

Nachdem ihr das getan habt, können wir damit beginnen die eigentliche Logik der Erweiterung zu implementieren.

Zuerst legen wir den Name der Erweiterung in der Eigenschaft Name fest:
public string Name
{
    get { return "Ersetzen"; }
}

Damit wir überall auf das Formular zugreifen können, erstellen wir eine Klassenvariable und weisen diese in der Initialize Methode zu:
private Form m_formHost;

public void Initialize(Form host)
{
    m_formHost = host;
}

Im nächsten Schritt erstellen wir ein Formular für die Ersetzen Funktion.
Ich habe dazu folgendes Layout definiert:



Im Code habe ich dann noch zwei Eigenschaften festgelegt, um auf die eingegebenen Texte zuzugreifen.

Der ganze Code des Formulars sieht wie folgt aus:
using System.Windows.Forms;

namespace Replace
{
    public partial class FormReplace : Form
    {
        #region Constructor
        public FormReplace()
        {
            InitializeComponent();
        }
        #endregion

        #region Properties
        public string Replace
        {
            get { return tbxReplace.Text; }
        }

        public string ReplaceValue
        {
            get { return tbxReplaceValue.Text; }
        }
        #endregion
    }
}

Nun müssen wir nur noch die eigentliche PluginAction Methode implementieren.
Diese sieht wie folgt aus:
public string PluginAction(string text)
{
    using (FormReplace dialog = new FormReplace())
    {
        if (dialog.ShowDialog(m_formHost) == DialogResult.OK)
        {
            text = text.Replace(dialog.Replace, dialog.ReplaceValue);
        }
    }

    return text;
}

Der hier enthaltene Code sollte wohl selbsterklärend sein.
Wir öffnen den soeben erstellten Dialog und falls dieser mit OK bestätigt wurde, ersetzen wir den Text mit den dafür vorgegebenen Werten.

Damit sind wir auch schon am Ende angelangt. smile
Ihr seht, es ist gar nicht so schwer, aber der Gewinn durch so eine Schnittstelle ist enorm.
Probiert doch mal aus, die Schnittstelle noch ein wenig zu erweitern.
Über Fragen, Feedback oder Kritik würde ich mich natürlich freuen.

Hier nun noch die Projekte zum herunterladen:
Editor Projekt
Fertige Implementierung
Um einen Kommentar zu hinterlassen, ist eine Anmeldung erforderlich.