.NET Assembly Loader

.NET Assembly Loader

Back in the day, I remember creating new AppDomain instances to control the scope of loaded assemblies. If I wanted to load some assemblies, then release them, that was the trick to getting that to work.

Enter .NET Core, and that approach no longer works. From what I understand, Microsoft has no plans to add that capability to .NET Core anytime soon.

So, is it still possible to load, and then unload, .NET assemblies, at runtime, using .NET Core? The short answer is yes! But, Microsoft left parts of the answer as an exercise for the rest of us to figure out. Not a problem though, we’ll do that together.

Here is the listing for the assembly loader I came up. I’ll walk through the code below the listing.

public class AssemblyLoader : AssemblyLoadContext
{
    private string _baseDirectory;
    private string _dynamicDirectory;
    private string _relativeSearchDirectory;
    private string _applicationDirectory;
    private AssemblyDependencyResolver _resolver;

    public AssemblyLoader(
        string baseDirectory,
        string assemblyContextName,
        bool isCollectible
        ) : base(assemblyContextName, isCollectible)
    {
        Guard.Instance().ThrowIfNullOrEmpty(
            baseDirectory,
            nameof(baseDirectory)
            ).ThrowIfInvalidFolderPath(
                baseDirectory,
                nameof(baseDirectory)
                );

        _baseDirectory = baseDirectory;
        _dynamicDirectory = AppDomain.CurrentDomain.DynamicDirectory;
        _relativeSearchDirectory = AppDomain.CurrentDomain.RelativeSearchPath;
        _applicationDirectory = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;

        _resolver = new AssemblyDependencyResolver(
            baseDirectory
            );
    }

    public AssemblyLoader(
        string assemblyContextName,
        bool isCollectible
        ) : base(assemblyContextName, isCollectible)
    {
        _baseDirectory = AppDomain.CurrentDomain.BaseDirectory;
        _dynamicDirectory = AppDomain.CurrentDomain.DynamicDirectory;
        _relativeSearchDirectory = AppDomain.CurrentDomain.RelativeSearchPath;
        _applicationDirectory = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;

        _resolver = new AssemblyDependencyResolver(
            _baseDirectory
            );
    }

    public AssemblyLoader(
        bool isCollectible
        ) : base(isCollectible)
    {
        _baseDirectory = AppDomain.CurrentDomain.BaseDirectory;
        _dynamicDirectory = AppDomain.CurrentDomain.DynamicDirectory;
        _relativeSearchDirectory = AppDomain.CurrentDomain.RelativeSearchPath;
        _applicationDirectory = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;

        _resolver = new AssemblyDependencyResolver(
            _baseDirectory
            );
    }

    public AssemblyLoader()
    {
        _baseDirectory = AppDomain.CurrentDomain.BaseDirectory;
        _dynamicDirectory = AppDomain.CurrentDomain.DynamicDirectory;
        _relativeSearchDirectory = AppDomain.CurrentDomain.RelativeSearchPath;
        _applicationDirectory = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;

        _resolver = new AssemblyDependencyResolver(
            _baseDirectory
            );
    }

    protected override Assembly Load(
        AssemblyName assemblyName
        )
    {
        Guard.Instance().ThrowIfNull(
            assemblyName,
            nameof(assemblyName)
            );

        var assemblyPath = _resolver.ResolveAssemblyToPath(
            assemblyName
            );

        if (false == string.IsNullOrEmpty(assemblyPath))
        {
            return LoadFromAssemblyPath(
                assemblyPath
                );
        }

        if (false == string.IsNullOrEmpty(_baseDirectory))
        {
            var completePath = Path.Combine(
                _baseDirectory,
                $"{assemblyName.Name}.dll"
                );

            if (File.Exists(completePath))
            {
                return Assembly.LoadFrom(completePath);
            }
        }

        if (false == string.IsNullOrEmpty(_applicationDirectory))
        {
            var completePath = Path.Combine(
                _applicationDirectory,
                $"{assemblyName.Name}.dll"
                );

            if (File.Exists(completePath))
            {
                return Assembly.LoadFrom(completePath);
            }
        }

        if (false == string.IsNullOrEmpty(_relativeSearchDirectory))
        {
            var completePath = Path.Combine(
                _relativeSearchDirectory,
                $"{assemblyName.Name}.dll"
                );

            if (File.Exists(completePath))
            {
                return Assembly.LoadFrom(completePath);
            }
        }

        if (false == string.IsNullOrEmpty(_dynamicDirectory))
        {
            var completePath = Path.Combine(
                _dynamicDirectory,
                $"{assemblyName.Name}.dll"
                );

            if (File.Exists(completePath))
            {
                return Assembly.LoadFrom(completePath);
            }
        }

        return null;
    }

    protected override IntPtr LoadUnmanagedDll(
        string unmanagedDllName
        )
    {
        Guard.Instance().ThrowIfNullOrEmpty(
            unmanagedDllName,
            nameof(unmanagedDllName)
            );

        var libraryPath = _resolver.ResolveUnmanagedDllToPath(
            unmanagedDllName
            );

        if (false == string.IsNullOrEmpty(libraryPath))
        {
            return LoadUnmanagedDllFromPath(
                libraryPath
                );
        }

        return IntPtr.Zero;
    }
}

The class derives from the AssemblyLoadContext class, which is a Microsoft class from the System.Runtime.Loader NUGET package. That class is abstract, which is why we have to derive from it and add our own code to make everything work.

The AssemblyLoadContext class has three constructors, so I went ahead and exposed those on my concrete class. Each one of my constructors passed parameters to the base ctor and the, also initializes the variables I’ve added to my class. _dynamicDirectory, _relativeSearchDirectory, and _applicationDirectory are all copied from the current AppDomain, whereas _baseDirectory is copied from the ctor argument. assemblyContextName is used by the AssemblyLoadContext class to create a named context, for loaded assemblies. isCollectible is a flag that tells the AssemblyLoadContext class whether we need to be able to unload assemblies, or not.

All the constructors eventually create an instance of an AssemblyDepdencyResolver object, which is stored in the _resolver field of my class. We’ll need that object when we start loading assemblies.

The Load method accepts an AssemblyName object as an argument. We start by passing that assembly name to our AssemblyDependencyResolver instance, to try to convert the name to a physical file path. By doing that, we’re essentially using standard .NET to try to resolve the location of the named assembly. Luckily, most of the time, that works just fine. When it does, we use the resulting file path to load the assembly, using a call to LoadAssemblyFromPathmethod, which is part of the base class.

If we fail to convert the assembly name to a file path, using the AssemblyDependencyResolver instance, then we need to get creative. In that case, the first thing we do is use whatever path is in the _baseDirectory field, to try to locate the assembly file.

If that fails, we then move on to use whatever path is in the _applicationDirectory field. If that fails, we then try to use whatever path is in the _relativeSearchDirectory field. If that also fails, we then try to use whatever path is in the _dynamicDirectory field.

If all those paths fail, and we still can’t locate the assembly, then we give up and return NULL. At that point, your private .NET assembly is hidden too well and we simply can’t find it.

The only other method on the AssemblyLoader class is called LoadUnmanagedDll. This method does exactly what it claims to, loading non-.NET assemblies by name. To be candid, I don’t really use this functionality, since I don’t work with many non-.NET, from C# projects. Still, the feature is there, if you need it.

Using the AssemblyLoader class couldn’t be easier. Here is a quick example:

var loader = new AssembylLoader("path to my private assemblies");
var asm = loader.LoadAssemblyByName("myassembly.dll");

// In case you also need to unload the assembly, later ...
loader.Unload(); 

The AssemblyLoader class is part of my CG.Runtime NUGET package. Go HERE to find the source. Go HERE to find the package.

Thanks for reading!

Photo by Markus Spiske on Unsplash