A lightweight export container for plugin-based .NET applications. The container discovers types marked with the MEF [Export] attribute and gives access to them by contract type, contract name or URI.
dotnet add package TagBites.ComponentModel.CompositionTargets netstandard2.0. Only dependency is System.ComponentModel.Composition, used for the standard [Export] attribute.
MEF (CompositionContainer) composes full object graphs with imports, exports and lifetime policies. This library solves a smaller problem: it keeps a registry of exported types and creates instances on demand. In exchange it offers:
- Explicit lifecycle. Assemblies are loaded and unloaded on demand. Unloading an assembly removes its exports and restores the ones it replaced.
- Stable identity. Every export has a URI. The URI addresses one implementation and allows a plugin to replace an implementation from another assembly.
- Change notifications. Events report changed contract types when exports are loaded, unloaded, registered or unregistered.
- Lazy instances. Each export provides a shared instance (created on first use, held by a weak reference) or a new instance per call.
Typical use cases: plugin systems, modular desktop applications, applications with many optional assemblies and a slow cold start.
publicinterfaceIImporter{stringName{get;}}[Export(typeof(IImporter))]publicclassCsvImporter:IImporter{publicstringName=>"CSV";}[Export(typeof(IImporter))]publicclassXmlImporter:IImporter{publicstringName=>"XML";}varmanager=newExportComponentManager();manager.LoadAssembly(typeof(CsvImporter).Assembly);foreach(varimporterinmanager.GetExportInstances<IImporter>())Console.WriteLine(importer.Name);// CSV// XMLGetExportInstance returns a shared instance. The instance is created on first use and held by a weak reference, so the garbage collector may reclaim it; the next access creates a new one. CreateExportInstance returns a new instance on every call.
varshared=manager.GetExportInstances<IImporter>().First();varagain=manager.GetExportInstances<IImporter>().First();// ReferenceEquals(shared, again) == truevarfresh=manager.CreateExportInstances<IImporter>().First();// ReferenceEquals(shared, fresh) == falseA contract name separates exports of the same contract type into groups.
[Export("analytics",typeof(IImporter))]publicclassAnalyticsImporter:IImporter{publicstringName=>"Analytics";}manager.GetExportInstances<IImporter>("analytics");// AnalyticsImportermanager.GetExportInstances<IImporter>();// exports without a contract namemanager.GetManyExports<IImporter>([null,"analytics"]);// both groupsEvery export has a Location URI built from the contract type, the contract name and the implementation type: export:{contract}/{name}/{implementation}. A type is identified by its full name with assembly name, or by its [Guid] attribute when present.
varexport=manager.GetExports<IImporter>().First();Console.WriteLine(export.Location);// export:MyApp.IImporter,MyApp/MyApp.CsvImporter,MyAppvarinstance=manager.GetExportInstance<IImporter>(export.Location);// shared CsvImporter instanceA [Guid] attribute makes the identity independent from the type name and assembly. Two implementations in different assemblies with the same [Guid] share the URI, which allows one to replace the other.
When a loaded assembly contains an export whose URI is already registered, the AssemblyExportSettings assembly attribute decides the outcome:
[assembly:AssemblyExportSettings(DuplicateUriHandling=ExportDuplicateUriHandling.OverrideExisting)]| Mode | Behavior |
|---|---|
SkipCurrent | The new export is ignored. Default. |
OverrideExisting | The new export replaces the existing one for URI lookups. Both remain listed for the contract. |
RemoveExisting | The existing export is removed. Unloading the new assembly restores it. |
Components can be registered without the [Export] attribute. An instance provider supports types without a default constructor.
varcomponent=newExportComponent<IImporter>(null,typeof(IImporter),typeof(CsvImporter));manager.Register(component);manager.Unregister(component);varcomponent=newExportComponent<IImporter>(null,typeof(IImporter),typeof(DatabaseImporter),null,()=>newDatabaseImporter(connectionString),null);manager.Register(component);ExportCollectionChanged reports every change with the list of affected contract types. AddNotify subscribes to one contract type.
manager.ExportCollectionChanged+=(s,e)=>Refresh(e.ChangedContractsTypes);manager.AddNotify(typeof(IImporter),OnImportersChanged);manager.RemoveNotify(typeof(IImporter),OnImportersChanged);UnloadAssembly removes all exports of an assembly and restores exports it replaced.
manager.UnloadAssembly(pluginAssembly);LoadAssembly reflects over all types of an assembly. With many assemblies this cost dominates application startup. UseCache stores the scan result of each assembly in a JSON file, so later starts read one small file per assembly instead. The file name includes the assembly module version id, so a rebuilt assembly bypasses the stale file automatically. An unreadable or corrupted file falls back to reflection.
The serializer is provided by the caller, so the library has no serializer dependency:
varmanager=newExportComponentManager();manager.UseCache(Path.Combine(AppContext.BaseDirectory,"export-cache"),(file,type)=>JsonSerializer.Deserialize(File.ReadAllText(file),type),(file,model)=>File.WriteAllText(file,JsonSerializer.Serialize(model)));foreach(varassemblyinAppDomain.CurrentDomain.GetAssemblies())manager.LoadAssembly(assembly);// After startup, e.g. from a background taskmanager.PrepareCache();PrepareCache writes files for assemblies loaded without a cache hit and removes stale files of previous builds. The cache stores contract type names; UseCustomTypeResolver replaces the default Type.GetType resolution when needed.