.NET Core applications rely heavily on NuGet to resolve their dependencies, which simplifies development. Unless you’re packaging the application up as a self-contained deployment, at runtime, the resolution of assemblies is not as straightforward, however, as all the dependencies are no longer copied to the output folder.
I needed to reflect over the types defined in an external assembly in order to look for specific attributes. The first problem is how to load an assembly from the disk in .NET Core.
AssemblyLoadContext
The System.Runtime.Loader package contains a type called AssemblyLoadContext
that can be used to load assemblies from a specific path, meaning we can write code like this:
public sealed class Program
{
public static int Main(string[] args)
{
Assembly assembly =
AssemblyLoadContext.Default.LoadFromAssemblyPath(args[0]);
PrintTypes(assembly);
return 0;
}
private static void PrintTypes(Assembly assembly)
{
foreach (TypeInfo type in assembly.DefinedTypes)
{
Console.WriteLine(type.Name);
foreach (PropertyInfo property in type.DeclaredProperties)
{
string attributes = string.Join(
", ",
property.CustomAttributes.Select(a => a.AttributeType.Name));
if (!string.IsNullOrEmpty(attributes))
{
Console.WriteLine(" [{0}]", attributes);
}
Console.WriteLine(" {0} {1}", property.PropertyType.Name, property.Name);
}
}
}
}
However, as soon as an attribute that is defined in another assembly is found (e.g., from a NuGet package), the above will fail with a System.IO.FileNotFoundException
. This is because the AssemblyLoadContext
will not load any dependencies – we’ll have to do that ourselves.
Dependencies File
When you build a .NET Core application, the compiler also produces some Runtime Configuration Files, in particular the deps.json file that includes the dependencies for the application. We can hook in to this to allow us to resolve the assemblies at runtime, using additional helper classes from the System.Runtime.Loader
package to parse the file for us.
internal sealed class AssemblyResolver : IDisposable
{
private readonly ICompilationAssemblyResolver assemblyResolver;
private readonly DependencyContext dependencyContext;
private readonly AssemblyLoadContext loadContext;
public AssemblyResolver(string path)
{
this.Assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(path);
this.dependencyContext = DependencyContext.Load(this.Assembly);
this.assemblyResolver = new CompositeCompilationAssemblyResolver
(new ICompilationAssemblyResolver[]
{
new AppBaseCompilationAssemblyResolver(Path.GetDirectoryName(path)),
new ReferenceAssemblyPathResolver(),
new PackageCompilationAssemblyResolver()
});
this.loadContext = AssemblyLoadContext.GetLoadContext(this.Assembly);
this.loadContext.Resolving += OnResolving;
}
public Assembly Assembly { get; }
public void Dispose()
{
this.loadContext.Resolving -= this.OnResolving;
}
private Assembly OnResolving(AssemblyLoadContext context, AssemblyName name)
{
bool NamesMatch(RuntimeLibrary runtime)
{
return string.Equals(runtime.Name, name.Name, StringComparison.OrdinalIgnoreCase);
}
RuntimeLibrary library =
this.dependencyContext.RuntimeLibraries.FirstOrDefault(NamesMatch);
if (library != null)
{
var wrapper = new CompilationLibrary(
library.Type,
library.Name,
library.Version,
library.Hash,
library.RuntimeAssemblyGroups.SelectMany(g => g.AssetPaths),
library.Dependencies,
library.Serviceable);
var assemblies = new List<string>();
this.assemblyResolver.TryResolveAssemblyPaths(wrapper, assemblies);
if (assemblies.Count > 0)
{
return this.loadContext.LoadFromAssemblyPath(assemblies[0]);
}
}
return null;
}
}
The code works by listening to the Resolving
event of the AssemblyLoadContext
, which gives us a chance to find the assembly ourselves (the class also implements the IDisposable
interface to allow unregistering from the event, as the AssemblyLoadContext
is static
so will keep the class alive). During resolution, we find the assembly inside the dependency file (note the use of a case insensitive comparer) and wrap this up inside a CompilationLibrary
. This can actually be skipped if we set the PreserveCompilationContext
option in the csproj
file to true
, as we can then use the DependencyContext.CompileLibraries
property, however, the above code works without that setting being present. Also note that I don’t use the result of TryResolveAssemblyPaths
, as it will return true
even if it didn’t add any paths to the list.
All that’s left is to modify the main program and the code will now be able to resolve attributes in other assemblies:
public static int Main(string[] args)
{
using (var dynamicContext = new AssemblyResolver(args[0]))
{
PrintTypes(dynamicContext.Assembly);
}
return 0;
}
Filed under: CodeProject