TagBites.ComponentModel.Composition
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.
Install
Section titled “Install”dotnet add package TagBites.ComponentModel.CompositionTargets netstandard2.0. Only dependency is System.ComponentModel.Composition, used for the standard [Export] attribute.
Why TagBites.ComponentModel.Composition?
Section titled “Why TagBites.ComponentModel.Composition?”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.
Discover exports
Section titled “Discover exports”public interface IImporter{ string Name { get; }}
[Export(typeof(IImporter))]public class CsvImporter : IImporter{ public string Name => "CSV";}
[Export(typeof(IImporter))]public class XmlImporter : IImporter{ public string Name => "XML";}var manager = new ExportComponentManager();manager.LoadAssembly(typeof(CsvImporter).Assembly);
foreach (var importer in manager.GetExportInstances<IImporter>()) Console.WriteLine(importer.Name);
// CSV// XMLShared and new instances
Section titled “Shared and new instances”GetExportInstance 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.
var shared = manager.GetExportInstances<IImporter>().First();var again = manager.GetExportInstances<IImporter>().First();// ReferenceEquals(shared, again) == true
var fresh = manager.CreateExportInstances<IImporter>().First();// ReferenceEquals(shared, fresh) == falseContract names
Section titled “Contract names”A contract name separates exports of the same contract type into groups.
[Export("analytics", typeof(IImporter))]public class AnalyticsImporter : IImporter{ public string Name => "Analytics";}manager.GetExportInstances<IImporter>("analytics"); // AnalyticsImportermanager.GetExportInstances<IImporter>(); // exports without a contract namemanager.GetManyExports<IImporter>([null, "analytics"]); // both groupsExport URI
Section titled “Export URI”Every 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.
var export = manager.GetExports<IImporter>().First();Console.WriteLine(export.Location);// export:MyApp.IImporter,MyApp/MyApp.CsvImporter,MyApp
var instance = 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.
Duplicate URIs
Section titled “Duplicate URIs”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. |
Manual registration
Section titled “Manual registration”Components can be registered without the [Export] attribute. An instance provider supports types without a default constructor.
var component = new ExportComponent<IImporter>(null, typeof(IImporter), typeof(CsvImporter));manager.Register(component);manager.Unregister(component);var component = new ExportComponent<IImporter>( null, typeof(IImporter), typeof(DatabaseImporter), null, () => new DatabaseImporter(connectionString), null);manager.Register(component);Change notifications
Section titled “Change notifications”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);Unloading
Section titled “Unloading”UnloadAssembly removes all exports of an assembly and restores exports it replaced.
manager.UnloadAssembly(pluginAssembly);Startup cache
Section titled “Startup cache”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:
var manager = new ExportComponentManager();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 (var assembly in AppDomain.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.