Il supporto ai plugin è sicuramente una funzionalità fondamentale per molte applicazioni, dai sistemi di gestione di forum e blog fino ai CMS.
Un plugin (letteralmente "inserisci") è una estensione di un programma che ne espande le funzionalità di base. In un blog, ad esempio, una estensione potrebbe aggiungere il supporto ad una nuova tipologia di feed, modificare come vengono visualizzati i messaggi, etc.
In questo articolo vedremo come creare un sistema di gestione dei plugin servendoci di ASP.NET e del namespace System.Reflection, che fornisce gli strumenti per esplorare la struttura degli assembly e dei tipi contenuti al loro interno, come classi, interfacce, etc.
Per rendere la spiegazione più chiara, implementiamo un plugin molto semplice. Potremmo trovare alcuni limiti a questa applicazione ma via via li evidenzieremo e sicuramente ci forniranno spunti per successivi sviluppi.
Struttura dell'applicazione
Il cuore della nostra applicazione è composto principalmente da un'interfaccia, IPlugin
ed una classe, PluginManager
. Nel realizzare il progetto di esempio, ho preferito inserire queste due classi in una DLL a se stante, da inserire quindi nella cartella Bin
del sito.
IPlugin
è l'interfaccia che una classe dovrà implementare per essere considerata un plugin
. In questo modo potremo, grazie alla reflection, navigare fra le classi caricando soltanto quelle contrassegnate come estensione.
All'interno dell'interfaccia sono definite le proprietà e i metodi che tutti i plugin dovranno implementare, fornendo in questo modo anche un punto di accesso alle funzionalità dell'estensione, sconosciute all'applicazione.
Nel nostro esempio IPlugin è molto semplice:
public interface IPlugin { string Name { get; } void DoAction(); }
Abbiamo definito la proprietà Name
, che rappresenta il nome del plugin, e il metodo DoAction()
che rappresenta il punto di accesso. Una volta che l'estensione sarà caricata potremo richiamarne le funzionalità proprio attraverso questo metodo.
In una implementazione più completa, altre proprietà tipiche potrebbero essere il nome dell'autore, il sito Web di riferimento, una breve descrizione del plugin, etc.
Un punto fondamentale nella nostra architettura è aver dichiarato questa interfaccia come pubblica, rendendola quindi accessibile da altri assembly. In questo modo potremo creare un file DLL per ogni plugin, rendendo di fatto più semplice aggiungere, aggiornare ed eliminare le estensioni caricate nella nostra applicazione.
La classe statica PluginManager
si occupa invece del caricamento e della gestione dei plugin. In particolare offre:
- una lista statica (
List
) contenente i plugin caricati - una funzione
InitializePlugins()
che si occupa di esplorare la cartellaBin/Plugins
del sito alla ricerca di tutti i file ".dll" contenuti al suo interno (vedremo fra poco perchè la scelta è caduta proprio su questa cartella) - un'altra funzione,
LoadPlugin(string assemblyName)
, che, dato il nome di un assembly, si occupa di caricare fisicamente un singolo plugin
Cominciamo la nostra analisi proprio da quest'ultima funzione, che rappresenta il cuore di tutto il sistema e mostra l'utilizzo di alcuni metodi di System.Reflection
:
private static IPlugin LoadPlugin(string assemblyName) { IPlugin result = null; Assembly assembly = Assembly.LoadFile(assemblyName); Type[] types = assembly.GetTypes(); // cicla tra tutti i tipi definiti nell'assembly foreach (Type t in types) { // Carica soltanto le classi pubbliche che derivano da IPlugin if (t.IsPublic && t.GetInterface("IPlugin") != null) { // Questa classe è un plugin, la carica e si ferma result = (IPlugin)Activator.CreateInstance(t); break; } } if (result == null) throw new Exception("Nessun plugin trovato: " + assembly); return result; }
Dopo aver caricato l'assembly (Assembly.LoadFile()
), cominciamo l'esplorazione dei tipi contenuti al suo interno. Riceviamo quindi la lista completa attraverso l'istruzione assembly.GetTypes()
e con un ciclo foreach
la scorriamo alla ricerca dei plugin, contrassegnati, come già detto, dall'interfaccia IPlugin
. Per sapere se una classe implementa un'interfaccia ci serviamo della funzione GetInterface
propria dell'oggetto Type
. Se il test ha risultato positivo allora creiamo una nuova istanza della classe, usciamo dal ciclo e, dopo aver controllato che il plugin sia stato effettivamente caricato, lo utilizziamo come valore di ritorno.
Prima di continuare è importante soffermarsi sull'altra condizione presente nel blocco if
. Oltre a ricercare l'interfaccia controlliamo anche che la classe che stiamo analizzando sia pubblica, per due motivi: primo perché una classe non pubblica non è accessibile dall'esterno e potrebbe quindi creare qualche problema in seguito, secondo perché i meccanismi di protezione di ASP.NET impongono dei limiti, in determinate condizioni, all'utilizzo della reflection.
In particolare, negli spazi Web condivisi è molto probabile imbattersi in politiche di protezione più restrittive, soprattuto basate sulla regola Medium Trust
, che limita, tra le altre cose, l'utilizzo della reflection su tipi non pubblici. Se non effettuiamo controlli appropriati potremmo quindi incorrere nell'eccezione SecurityException
. Per maggiori informazioni è utile riferirsi alla documentazione su MSDN.
Un possibile sviluppo potrebbe consistere nell'espandere questo metodo aggiungendo il supporto ad assembly che contengono più plugin, infatti la ricerca si interrompe una volta trovato il primo.
Vediamo ora la funzione InitializePlugins():
public static void InitializePlugins() { string[] files = Directory.GetFiles(HttpContext.Current.Server.MapPath("~/Bin/Plugins"), "*.dll"); foreach (string file in files) { LoadedPlugins.Add(LoadPlugin(file)); } }
Riceviamo la lista dei file attraverso il metodo Directory.GetFiles
, specificando come parametri l'indirizzo fisico della cartella e come pattern di ricerca *.dll
che seleziona solo i file ".dll".
Per ogni file trovato, eseguiamo la funzione LoadPlugin()
vista in precedenza e aggiungiamo il risultato alla lista dei plugin caricati.
Plugin di esempio
Completata l'infrastruttura di base, passiamo alla creazione di una prima estensione molto semplice, CiaoMondoPlugin
, che offre la funzionalità più classica della programmazione, scrivendo a video la stringa "Ciao Mondo!".
Visto che ogni estensione occupa un assembly a se stante creiamo un nuovo progetto in Visual Studio o Visual C# Express Edition, scegliendo il template Libreria di Classi (Class Library in inglese). Dopo aver aggiunto le dipendenze necessarie, siamo pronti a creare il plugin vero e proprio:
public class CiaoMondoPlugin : IPlugin { #region IPlugin Members public string Name { get { return "Plugin Ciao Mondo"; } } public void DoAction() { HttpContext.Current.Response.Write("Ciao Mondo!"); } #endregion }
Il codice non presenta difficoltà di comprensione: abbiamo semplicemente implementato l'interfaccia IPlugin. Soffermiamoci invece sul contenuto del metodo DoAction()
:
HttpContext.Current.Response.Write("Ciao Mondo!");
A livello di codice questa riga è molto semplice, ma a livello concettuale c'è bisogno di una precisazione: HttpContext.Current
restituisce l'istanza corrente dell'oggetto HttpContext
, proprio di un determinato AppDomain
e non valido al di fuori di questo.
Gli AppDomain sono spazi chiusi in cui il framework .NET fa girare le applicazioni, siano queste Web o desktop. Ne deriva che ogni dominio avrà una propria istanza, nel caso ovviamente di una applicazione Web, dell'oggetto HttpContext
. Nel nostro caso siamo sicuri di fare riferimento all'istanza corretta perché abbiamo caricato l'assembly contenente il plugin all'interno dello stesso AppDomain
in cui gira l'applicazione Web.so da implementare, consiste nel creare un nuovo AppDomain specifico per i plugin, passando di volta in volta dal dominio dell'applicazione Web
In realtà questo approccio presenta diversi problemi tra cui:
- scarsa sicurezza: i plugin hanno in questo modo accesso ad ogni funzionalità dell'applicazione Web, comprese eventuali funzioni di aggiornamento del database: un plugin malevolo potrebbe quindi fare seri danni
- scarsa affidabilità: se il plugin genera una eccezione questa pregiudica l'esecuzione di tutta l'applicazione
Un approccio più corretto e sicuro, ma anche molto più complesso da implementare, consiste nel creare un nuovo AppDomain
specifico per i plugin, passando di volta in volta dal dominio dell'applicazione Web al plugin soltanto gli oggetti strettamente necessari, ad esempio attraverso l'utilizzo di una classe "ospite":
[Serializable] public class PluginContext { private HttpResponse response; public HttpResponse Response { get { return response; } } public PluginContext(HttpResponse response) { this.response = response; } }
È importante che la classe sia serializzabile in quanto il passaggio da un dominio ad un altro prevede la serializzazione dell'oggetto interessato.
Il metodo DoAction()
diventerebbe quindi:
public void DoAction(PluginContext context) { context.Response.Write("Ciao Mondo!"); }
a cui andrebbe passato l'oggetto PluginContext
corretto ad ogni invocazione.
Nella scrittura dell'articolo ho scelto di non seguire questa strada per motivi di complessità di realizzazione, ma anche perché l'approccio con diversi domini è inutilizzabile in scenari con politica Medium Trust
. Questo livello di protezione non permette infatti la creazione e la gestione di domini diversi da quello dell'applicazione stessa.
Mettiamo tutto assieme
Dunque, abbiamo creato l'infrastruttura ed un plugin di esempio, occupiamoci ora del sito Web che ospiterà queste funzionalità. Per prima cosa creiamo il file Global.asax
ed in particolare inseriamo nella funzione Application_Start()
il seguente codice:
void Application_Start(object sender, EventArgs e)
{
// Carico i plugin
PluginManager.InitializePlugins();
}
In questo modo i plugin saranno caricati all'avvio dell'applicazione Web. Essendo la lista dei plugin caricati definita come statica, avremo bisogno di svolgere questo lavoro soltanto una volta avendo allo stesso tempo la certezza che i plugin rimangano in memoria per tutto il ciclo di vita dell'applicazione.
Apriamo quindi il file Web.config
ad aggiungiamo direttamente nella sezione <configuration>
(attenzione: non in <system.web>
dove vanno solitamente le altre impostazioni).
<runtime> <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> <probing privatePath="BinPlugins" /> </assemblyBinding> </runtime>
Queste righe istruiscono ASP.NET ad aggiungere il percorso Bin/Plugins
fra quelli utilizzati nella ricerca degli assembly. Normalmente ASP.NET esegue la ricerca soltanto nella cartella Bin
, senza occuparsi delle sotto cartelle.
E siamo finalmente arrivati a spiegare il perché nella scelta di questa cartella in particolare: ASP.NET abilita di default nella cartella Bin
la shadow copy, un meccanismo che consente di caricare un assembly lasciandolo però allo stesso tempo libero da blocchi di scrittura. Le DLL, nella cartella Bin
, possono essere modificate o cancellate senza dover prima chiudere il server Web che ospita l'applicazione.
Fin qui niente di nuovo, la cosa interessante è che il framework applica lo stesso meccanismo anche ai file contenuti nelle sotto-cartelle di Bin
, nel nostro caso a Bin/Plugins
. In questo modo evitiamo che vengano applicati blocchi alle DLL che contengono i plugin, e ne rendiamo possibile l'aggiornamento e l'eliminazione.
Se avessimo utilizzato una cartella diversa sarebbe stato impossibile eseguire queste operazione senza aver prima spento il server Web su cui gira l'applicazione (soluzione improponibile nella maggior parte dei casi).
Questo accorgimento diventa inutile nel caso in cui avessimo utilizzato un AppDomain
diverso.
La pagina di test
Per testare il nostro sistema creiamo una pagina di prova.
public partial class _Default : System.Web.UI.Page { protected void Page_Load(object sender, EventArgs e) { Response.Write("<b>Eseguo tutti i plugin caricati</b><br /><br />"); foreach (IPlugin p in PluginManager.LoadedPlugins) { Response.Write("<b>Nome:</b> " + p.Name + "<br />"); Response.Write("<b>Risultato esecuzione</b><br />"); p.DoAction(); Response.Write("<br /><br /><br />"); }; } }
La parte centrale del codice è un ciclo che esegue per ogni plugin caricato la funzione DoAction()
.
Conclusioni
Il namespace System.Reflection
è molto potente e realizzare un sistema di gestione dei plugin con ASP.NET può risultare molto utile. Ci siamo soffermati sui problemi di implementazione, sulle possibili soluzioni e fornendo anche una panoramica su soluzioni alternative. Non ci siamo invece occupati del modo in cui è possibile richiamare l'esecuzione di un plugin dall'applicazione (magari attraverso una gestione tramite eventi o in risposta all'input dell'utente), preferendo fornire una soluzione più semplice ed immediata.