2015-06-29 8 views
14

Vorrei aggirare alcune tecniche di scansione di assemblaggio classiche in un framework che sto sviluppando.Ottenere implementazioni dell'interfaccia negli assembly di riferimento con Roslyn

Quindi, dire che ho definito il seguente contratto:

public interface IModule 
{ 

} 

Questo esiste nel dire Contracts.dll.

Ora, se voglio scoprire tutte le implementazioni di questa interfaccia, ci sarebbe probabilmente fare qualcosa di simile a quanto segue:

public IEnumerable<IModule> DiscoverModules() 
{ 
    var contractType = typeof(IModule); 
    var assemblies = AppDomain.Current.GetAssemblies() // Bad but will do 
    var types = assemblies 
     .SelectMany(a => a.GetExportedTypes) 
     .Where(t => contractType.IsAssignableFrom(t)) 
     .ToList(); 

    return types.Select(t => Activator.CreateInstance(t)); 
} 

Non un grande esempio, ma lo farà.

Ora, questo tipo di tecniche di scansione dell'assieme può essere alquanto scadente, e il tutto viene eseguito in fase di esecuzione, in genere con impatto sulle prestazioni di avvio.

Nel nuovo ambiente DNX, possiamo usare ICompileModule casi come strumenti di metaprogrammazione, così si potrebbe impacchettare un'implementazione di ICompileModule nella cartella Compiler\Preprocess nel progetto e arrivare a fare qualcosa di strano.

Quello che il mio obiettivo sarebbe, è utilizzare un'implementazione ICompileModule, per eseguire il lavoro che faremmo al momento dell'esecuzione, in fase di compilazione.

  • Nei miei riferimenti (entrambe le compilation e assemblee), e la mia compilation corrente, scoprire tutte le istanze di instaniatable IModule
  • Creare una classe, consente di chiamare ModuleList con un'implementazione che produce le istanze di ciascun modulo.
public static class ModuleList 
{ 
    public static IEnumerable<IModule>() GetModules() 
    { 
     yield return new Module1(); 
     yield return new Module2(); 
    } 
} 

Con tale classe aggiunto all'unità di compilazione, potremmo invocare e ottenere un elenco statico di moduli in fase di esecuzione, invece di dover cercare attraverso tutti i gruppi allegate. Stiamo essenzialmente scaricando il lavoro sul compilatore anziché sul runtime.

Dato che è possibile ottenere l'accesso a tutti i riferimenti per una compilazione tramite la proprietà References, non riesco a vedere come ottenere informazioni utili, ad esempio l'accesso al codice byte, per caricare un assieme per la riflessione , o qualcosa di simile.

Pensieri?

+1

Avete considerato di utilizzare una classe statica globale per gestire queste istanze in runtime? (utilizzando un attributo personalizzato/piccolo snippet nella classe base) –

risposta

4

Pensieri?

Sì.

In genere in un ambiente di modulo si desidera caricare dinamicamente un modulo in base al contesto o, se applicabile, da una terza parte. Al contrario, utilizzando il framework del compilatore Roslyn, si ottiene fondamentalmente questa informazione in fase di compilazione, limitando quindi i moduli ai riferimenti statici.

Proprio ieri ho pubblicato il codice per il caricamento dinamico delle fabbriche con. attributi, aggiornamenti per il caricamento delle DLL ecc. qui: Naming convention for GoF Factory?. Da quello che capisco, è abbastanza simile a quello che stai cercando di ottenere. L'aspetto positivo di questo approccio è che è possibile caricare dinamicamente nuove DLL in fase di runtime.Se lo provi, scoprirai che è abbastanza veloce.

È inoltre possibile limitare ulteriormente gli assemblaggi elaborati. Ad esempio, se non elabori mscorlib e System.* (o forse anche tutti gli assembly GAC), funzionerà molto più velocemente, naturalmente. Tuttavia, come ho detto, non dovrebbe essere un problema; solo la scansione di tipi e attributi è un processo abbastanza veloce.


OK, un po 'più di informazioni e contesto.

Ora, potrebbe essere possibile che tu stia cercando un puzzle divertente. Posso capirlo, giocare con la tecnologia è davvero molto divertente. La risposta qui sotto (di Matthew stesso) ti darà tutte le informazioni di cui hai bisogno.

Se si desidera bilanciare i pro ei contro della generazione del codice in fase di compilazione rispetto a una soluzione runtime, ecco ulteriori informazioni dalla mia esperienza.

Alcuni anni fa, ho deciso che era una buona idea avere il mio framework di parser/generatore C# per effettuare trasformazioni AST. È abbastanza simile a quello che puoi fare con Roslyn; in pratica converte un intero progetto in un albero AST, che è quindi possibile normalizzare, generare codice, effettuare controlli extra su attività di programmazione orientate all'aspetto e aggiungere nuovi costrutti di linguaggio. Il mio obiettivo originale qui era di aggiungere il supporto per la programmazione orientata agli aspetti in C#, per il quale avevo alcune applicazioni pratiche. Ti risparmio i dettagli, ma per questo contesto è sufficiente dire che un modulo/fabbrica basato sulla generazione di codice è stata una delle cose che ho sperimentato.

Le prestazioni, la flessibilità e la quantità di codice (nella soluzione non di libreria) sono gli aspetti chiave per me per valutare la decisione tra un runtime e la decisione del tempo di compilazione. Rompiamo giù:

  • prestazioni. Questo è importante perché non posso assumere che il codice della libreria non sia sul percorso critico. Il runtime ti costerà alcuni millisecondi per istanza di appdomain. (Vedi sotto per osservazioni su come/perché).
  • Flessibilità. Sono entrambi ugualmente flessibili in termini di attributo/scansione. Tuttavia, in fase di esecuzione si hanno più possibilità in termini di modifica delle regole (ad esempio, collegare dinamicamente moduli, ecc.). A volte uso questo, soprattutto in base alla configurazione, in modo da non dover sviluppare tutto nella stessa soluzione (perché è inefficiente).
  • Quantità di codice. Come regola generale, meno codice è in genere un codice migliore. Se lo fai bene, entrambi si tradurranno in un singolo attributo di cui hai bisogno in una classe. In altre parole, entrambe le soluzioni danno lo stesso risultato qui.

Una nota sulla prestazione è in ordine però. Io uso la riflessione per qualcosa di più di semplici schemi di fabbrica nel mio codice. Fondamentalmente ho una vasta libreria qui di "strumenti" che include tutti i modelli di progettazione (e una tonnellata di altre cose). Alcuni esempi: Genero automaticamente codice in fase di esecuzione per cose come fabbriche, catena di responsabilità, decoratori, derisione, cache/proxy (e molto altro). Alcuni di questi mi hanno già richiesto di eseguire la scansione degli assiemi.

Come regola empirica, utilizzo sempre un attributo per indicare che qualcosa deve essere modificato. È possibile utilizzare ciò a proprio vantaggio: archiviando semplicemente ogni tipo con un attributo (del corretto assembly/spazio dei nomi) in un singleton/dizionario da qualche parte, è possibile rendere l'applicazione molto più veloce (perché è necessario eseguire la scansione una sola volta). Inoltre, non è molto utile per analizzare assiemi da Microsoft.Ho eseguito molti test su progetti di grandi dimensioni e ho scoperto che, nel peggiore dei casi in cui ho trovato, la scansione ha aggiunto circa 10 ms al tempo di avvio di un'applicazione. Nota che questo è solo una volta per istanziazione di un appdomain, il che significa che non lo noterai mai, mai.

L'attivazione dei tipi è davvero l'unica penalità di prestazioni "reale" che si otterrà. Questa penalità può essere ottimizzata emettendo il codice IL; non è davvero così difficile. Il risultato finale è che non farà alcuna differenza qui.

Per avvolgere in su, qui sono le mie conclusioni:

  • prestazioni: Differenza insignificante.
  • Flessibilità: Vince il runtime.
  • Quantità di codice: differenza insignificante.

Dalla mia esperienza, anche se un sacco di quadri speranza per sostenere plug and play architetture che potrebbero beneficiare di calo nelle assemblee, la realtà è che non v'è un intero carico di casi d'uso in cui questo è effettivamente applicabile.

Se non è applicabile, si potrebbe voler considerare di non utilizzare un modello di fabbrica in primo luogo. Inoltre, se è applicabile, ho mostrato che non c'è un vero svantaggio, cioè: se lo implementate correttamente. Sfortunatamente devo riconoscere che ho visto molte cattive implementazioni.

Per quanto riguarda il fatto che non è effettivamente applicabile, penso che sia solo parzialmente vero. È abbastanza comune fare il drop-in dei fornitori di dati (che segue logicamente da un'architettura a 3 livelli). Io uso anche le fabbriche per collegare cose come le API di comunicazione/WCF, i fornitori di cache e i decoratori (che logicamente deriva da un'architettura di livello superiore). In generale è usato per qualsiasi tipo di fornitore si possa pensare.

Se l'argomento è che fornisce una penalizzazione delle prestazioni, in pratica si desidera rimuovere l'intero processo di scansione del tipo. Personalmente, lo uso per una tonnellata di cose diverse, in particolare la memorizzazione nella cache, le statistiche, la registrazione e la configurazione. Inoltre, credo che il lato negativo delle prestazioni sia negliabile.

Solo i miei 2 centesimi; HTH.

+0

Grazie! Sì, questo è l'approccio di riflessione che non avrei voluto prendere. Dalla mia esperienza, anche se molti framework sperano di supportare architetture plug and play che * potrebbero * trarre vantaggio dal calo degli assembly, la realtà è che non esiste un intero carico di casi d'uso in cui ciò sia effettivamente applicabile. Il 99% delle volte, quando si desidera aggiungere un nuovo plug-in per dire, Orchard o Umbraco, si è effettivamente nella soluzione del progetto e si fa riferimento al nuovo plug-in, si ricompila e si parte. Se si sta compilando comunque, perché non si dispone di un elenco codificato, che è ancora dinamico nel senso che non si dispone di –

+0

.. è necessario cablarlo, Roslyn si prende cura di questo per voi. Ho trovato una soluzione a questo, quindi lo aggiungerò come risposta in modo da poter vedere il mio approccio. In essenza, voglio rimuovere tutto il lavoro che deve essere eseguito in runtime, eseguendolo invece in fase di compilazione. –

+0

@MatthewAbbott Ho aggiunto qualche altra informazione sulla mia esperienza specificamente mirata a questi commenti. Quello che mi manca di più è il motivo per cui lo si vuole come un costrutto in fase di compilazione così male; dalla mia esperienza non c'è un vero svantaggio sulla soluzione runtime. Forse mi manca qualcosa qui? – atlaste

4

Quindi il mio approccio con questa sfida ha significato immergersi attraverso un intero carico di sorgenti di riferimento per comprendere i diversi tipi disponibili per Roslyn.

Per anteporre la soluzione finale, permette di creare l'interfaccia del modulo, metteremo questo in Contracts.dll:

public interface IModule 
{ 
    public int Order { get; } 

    public string Name { get; } 

    public Version Version { get; } 

    IEnumerable<ServiceDescriptor> GetServices(); 
} 

public interface IModuleProvider 
{ 
    IEnumerable<IModule> GetModules(); 
} 

E mettiamoci anche definire il provider di base:

public abstract class ModuleProviderBase 
{ 
    private readonly List<IModule> _modules = new List<IModule>(); 

    protected ModuleProviderBase() 
    { 
     Setup(); 
    } 

    public IEnumerable<IModule> GetModules() 
    { 
     return _modules.OrderBy(m => m.Order); 
    } 

    protected void AddModule<T>() where T : IModule, new() 
    { 
     var module = new T(); 
     _modules.Add(module); 
    } 

    protected virtual void Setup() { } 
} 

Ora, in questo architettura, il modulo non è in realtà qualcosa di più di un descrittore, quindi non dovrebbe prendere delle dipendenze, ma esprime semplicemente i servizi che offre.

Ora un modulo di esempio potrebbe essere simile, in DefaultLogger.dll:

public class DefaultLoggerModule : ModuleBase 
{ 
    public override int Order { get { return ModuleOrder.Level3; } } 

    public override IEnumerable<ServiceDescriptor> GetServices() 
    { 
     yield return ServiceDescriptor.Instance<ILoggerFactory>(new DefaultLoggerFactory()); 
    } 
} 

ho lasciato fuori l'attuazione di ModuleBase per brevità.

Ora, nel mio progetto web, aggiungo un riferimento a Contracts.dll e DefaultLogger.dll, e quindi aggiungere la seguente implementazione del mio fornitore del modulo:

public partial class ModuleProvider : ModuleProviderBase { } 

E ora, mio ​​ICompileModule:

using T = Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree; 
using F = Microsoft.CodeAnalysis.CSharp.SyntaxFactory; 
using K = Microsoft.CodeAnalysis.CSharp.SyntaxKind; 

public class DiscoverModulesCompileModule : ICompileModule 
{ 
    private static MethodInfo GetMetadataMethodInfo = typeof(PortableExecutableReference) 
     .GetMethod("GetMetadata", BindingFlags.NonPublic | BindingFlags.Instance); 
    private static FieldInfo CachedSymbolsFieldInfo = typeof(AssemblyMetadata) 
     .GetField("CachedSymbols", BindingFlags.NonPublic | BindingFlags.Instance); 
    private ConcurrentDictionary<MetadataReference, string[]> _cache 
     = new ConcurrentDictionary<MetadataReference, string[]>(); 

    public void AfterCompile(IAfterCompileContext context) { } 

    public void BeforeCompile(IBeforeCompileContext context) 
    { 
     // Firstly, I need to resolve the namespace of the ModuleProvider instance in this current compilation. 
     string ns = GetModuleProviderNamespace(context.Compilation.SyntaxTrees); 

     // Next, get all the available modules in assembly and compilation references. 
     var modules = GetAvailableModules(context.Compilation).ToList(); 
     // Map them to a collection of statements 
     var statements = modules.Select(m => F.ParseStatement("AddModule<" + module + ">();")).ToList(); 

     // Now, I'll create the dynamic implementation as a private class. 
     var cu = F.CompilationUnit() 
      .AddMembers(
       F.NamespaceDeclaration(F.IdentifierName(ns)) 
        .AddMembers(
         F.ClassDeclaration("ModuleProvider") 
          .WithModifiers(F.TokenList(F.Token(K.PartialKeyword))) 
          .AddMembers(
           F.MethodDeclaration(F.PredefinedType(F.Token(K.VoidKeyword)), "Setup") 
            .WithModifiers(
             F.TokenList(
              F.Token(K.ProtectedKeyword), 
              F.Token(K.OverrideKeyword))) 
            .WithBody(F.Block(statements)) 
          ) 
        ) 
      ) 
      .NormalizeWhitespace(indentation("\t")); 

     var tree = T.Create(cu); 
     context.Compilation = context.Compilation.AddSyntaxTrees(tree); 
    } 

    // Rest of implementation, described below 
} 

Essenzialmente questo modulo fa alcuni passaggi;

1 - Risolve lo spazio dei nomi dell'istanza ModuleProvider nel progetto Web, ad es. SampleWeb.
2 - Individua tutti i moduli disponibili tramite i riferimenti, che vengono restituiti come una raccolta di stringhe, ad es. new [] { "SampleLogger.DefaultLoggerModule"}
3 - Conversione quelli alle dichiarazioni del genere AddModule<SampleLogger.DefaultLoggerModule>();
4 - Creare un partial implementazione di ModuleProvider che stiamo aggiungendo alla nostra classifica:

namespace SampleWeb 
{ 
    partial class ModuleProvider 
    { 
     protected override void Setup() 
     { 
      AddModule<SampleLogger.DefaultLoggerModule>(); 
     } 
    } 
} 

Quindi, come ho scoperto i moduli disponibili? Ci sono tre fasi:

1 - I complessi di riferimento (ad esempio, quelli forniti attraverso NuGet)
2 - Le compilazioni riferimento (ad esempio, i progetti riferimento nella soluzione).
3 - Le dichiarazioni del modulo nella compilation corrente.

E per ciascuna compilazione di riferimento, ripetiamo quanto sopra.

private IEnumerable<string> GetAvailableModules(Compilation compilation) 
{ 
    var list = new List<string>(); 
    string[] modules = null; 

    // Get the available references. 
    var refs = compilation.References.ToList(); 

    // Get the assembly references. 
    var assemblies = refs.OfType<PortableExecutableReference>().ToList(); 
    foreach (var assemblyRef in assemblies) 
    { 
     if (!_cache.TryGetValue(assemblyRef, out modules)) 
     { 
      modules = GetAssemblyModules(assemblyRef); 
      _cache.AddOrUpdate(assemblyRef, modules, (k, v) => modules); 
      list.AddRange(modules); 
     } 
     else 
     { 
      // We've already included this assembly. 
     } 
    } 

    // Get the compilation references 
    var compilations = refs.OfType<CompilationReference>().ToList(); 
    foreach (var compliationRef in compilations) 
    { 
     if (!_cache.TryGetValue(compilationRef, out modules)) 
     { 
      modules = GetAvailableModules(compilationRef.Compilation).ToArray(); 
      _cache.AddOrUpdate(compilationRef, modules, (k, v) => modules); 
      list.AddRange(modules); 
     } 
     else 
     { 
      // We've already included this compilation. 
     } 
    } 

    // Finally, deal with modules in the current compilation. 
    list.AddRange(GetModuleClassDeclarations(compilation)); 

    return list; 
} 

moduli Quindi, per ottenere assembly di riferimento:

private IEnumerable<string> GetAssemblyModules(PortableExecutableReference reference) 
{ 
    var metadata = GetMetadataMethodInfo.Invoke(reference, nul) as AssemblyMetadata; 
    if (metadata != null) 
    { 
     var assemblySymbol = ((IEnumerable<IAssemblySymbol>)CachedSymbolsFieldInfo.GetValue(metadata)).First(); 

     // Only consider our assemblies? Sample*? 
     if (assemblySymbol.Name.StartsWith("Sample")) 
     { 
      var types = GetTypeSymbols(assemblySymbol.GlobalNamespace).Where(t => Filter(t)); 
      return types.Select(t => GetFullMetadataName(t)).ToArray(); 
     } 
    } 

    return Enumerable.Empty<string>(); 
} 

Dobbiamo fare una piccola riflessione qui come il metodo GetMetadata non è pubblico, e più tardi, quando afferriamo i metadati, il campo CachedSymbols è anche non pubblico, quindi più riflesso lì. In termini di identificazione di ciò che è disponibile, dobbiamo prendere il IEnumerable<IAssemblySymbol> dalla proprietà CachedSymbols. Questo ci fornisce tutti i simboli memorizzati nella cache nell'assieme di riferimento. Roslyn fa questo per noi, in modo che possiamo poi abusarne:

private IEnumerable<ITypeSymbol> GetTypeSymbols(INamespaceSymbol ns) 
{ 
    foreach (var typeSymbols in ns.GetTypeMembers().Where(t => !t.Name.StartsWith("<"))) 
    { 
     yield return typeSymbol; 
    } 

    foreach (var namespaceSymbol in ns.GetNamespaceMembers()) 
    { 
     foreach (var typeSymbol in GetTypeSymbols(ns)) 
     { 
      yield return typeSymbol; 
     } 
    } 
} 

Il metodo GetTypeSymbols cammina attraverso gli spazi dei nomi e scopre tutti i tipi.Abbiamo poi catena risultato al metodo del filtro, che garantisce implementa nostra interfaccia richiesto:

private bool Filter(ITypeSymbol symbol) 
{ 
    return symbol.IsReferenceType 
     && !symbol.IsAbstract 
     && !symbol.IsAnonymousType 
     && symbol.AllInterfaces.Any(i => i.GetFullMetadataName(i) == "Sample.IModule"); 
} 

Con GetFullMetadataName essendo un metodo di utilità:

private static string GetFullMetadataName(INamespaceOrTypeSymbol symbol) 
{ 
    ISymbol s = symbol; 
    var builder = new StringBuilder(s.MetadataName); 
    var last = s; 
    while (!!IsRootNamespace(s)) 
    { 
     builder.Insert(0, '.'); 
     builder.Insert(0, s.MetadataName); 
     s = s.ContainingSymbol; 
    } 

    return builder.ToString(); 
} 

private static bool IsRootNamespace(ISymbol symbol) 
{ 
    return symbol is INamespaceSymbol && ((INamespaceSymbol)symbol).IsGlobalNamespace; 
} 

Next up, dichiarazioni di modulo nella compilazione corrente:

private IEnumerable<string> GetModuleClassDeclarations(Compilation compilation) 
{ 
    var trees = compilation.SyntaxTrees.ToArray(); 
    var models = trees.Select(compilation.GetSemanticModel(t)).ToArray(); 

    for (var i = 0; i < trees.Length; i++) 
    { 
     var tree = trees[i]; 
     var model = models[i]; 

     var types = tree.GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>().ToList(); 
     foreach (var type in types) 
     { 
      var symbol = model.GetDeclaredSymbol(type) as ITypeSymbol; 
      if (symbol != null && Filter(symbol)) 
      { 
       yield return GetFullMetadataName(symbol); 
      } 
     } 
    } 
} 

E questo è davvero! Così, ora in fase di compilazione, la mia volontà ICompileModule:

  • Scopri tutti i moduli disponibili
  • Implementare un override del mio metodo ModuleProvider.Setup con tutti i moduli di riferimento noti.

Questo significa che posso aggiungere la mia startup:

public class Startup 
{ 
    public ModuleProvider ModuleProvider = new ModuleProvider(); 

    public void ConfigureServices(IServiceCollection services) 
    { 
     var descriptors = ModuleProvider.GetModules() // Ordered 
      .SelectMany(m => m.GetServices()); 

     // Apply descriptors to services. 
    } 

    public void Configure(IApplicationBuilder app) 
    { 
     var modules = ModuleProvider.GetModules(); // Ordered. 

     // Startup code. 
    } 
} 

Massively over-ingegnerizzato, abbastanza complesso, ma un pò impressionante credo!

+0

Cool, felice che tu l'abbia risolto! :) Il problema sembrava interessante e uno che ho pensato di guardare dentro me stesso una o due volte (ma rinunciato a causa di limiti di tempo = /) – flindeberg

+0

@flindeberg Sarò felice di lanciare il codice su un succo o qualcosa del genere se tu " sei interessato a giocarci? –

Problemi correlati