Resolving Assemblies in .NET Core

.NET Core applications rely heavily on NuGet to resolve their dependencies, which is 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 coppied 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;
}
Advertisements

Nesting files in a project

As part of the code generation work, I wanted to group the generated code file with the associated source. This was provided for free under project.json if you kept the filename the same and simply appended a new extension, however, this has been lost in the conversion to MSBuild files.

Solution Explorer

Having said that, the project file format for .NET core projects in Visual Studio 2017 has been greatly simplified, for example, you no longer have an entry for each file in the project. There also exists a nice Microsoft.Build NuGet package that simplifies working with the project file and, as a bonus, it also works for .NET Core applications.

Implementation

The way to get Visual Studio to group an item is by adding a Compile item with the DependentUpon set to the parent item, i.e.:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    ...
  </PropertyGroup>

  <ItemGroup>
    <Compile Update="Child.cs">
      <AutoGen>True</AutoGen>
      <DependentUpon>Parent.txt</DependentUpon>
    </Compile>
  </ItemGroup>
</Project>

This can be done in code quite easily by the following code:

string project = "Example.csproj";
string childName = "Example.txt.cs";
string parentName = "Example.txt";

// Load the project file
var root = ProjectRootElement.Open(
    project,
    ProjectCollection.GlobalProjectCollection,
    preserveFormatting: true);

// We need to put the Compile in an ItemGroup
ProjectItemGroupElement group = root.AddItemGroup();

// MUST set the Update attribute before adding it to the group
ProjectItemElement compile = root.CreateItemElement("Compile");
compile.Update = childName;
group.AppendChild(compile);

// MUST be in the group before we can add metadata
compile.AddMetadata("AutoGen", "True");
compile.AddMetadata("DependentUpon", parentName);

// Save changes
root.Save();

As you can see, the code follows the XML structure and can be easily expanded to re-use the same ItemGroup for all the dependent items. Also worth noting is that I’m preserving the formatting of the project file by specifying true for the preserveFormatting parameter in the call to the Open method, as the project files can now be edited manually from within Visual Studio it would annoy me if another tool had overwritten my hand-crafted changes 🙂

Adding a tool to a .NET Core build

I need a command line tool to run over some files in a project and generate some code that can then be built with the rest of the project’s source code. However, the documentation to get this to be an automatic step of the build is a bit lacking – there are plenty of articles on creating a .NET Core CLI tool, however, these have to be manually invoked at the command prompt.

Background

.NET Core projects files (the Visual Studio 2017 csproj type) allow the NuGet packages to be specified in the same file as any other project/assembly reference. It also allows a special type of NuGet package that was created as a DotNetCliTool type to be pulled into a project and integrated with the rest of the dotnet tooling, for example:

dotnet restore
dotnet my-tool

The tool is just a standard .NET Core console application that runs in the projects directory and can receive command line arguments as any other console program can (i.e. dotnet my-tool argument would pass ‘argument’ to the entry point of the program).

Creating a tool

Creating a tool is simple: create a new .NET Core console app (MUST be a .NET Core App 1.0, not 1.1) and edit the project file to set the package type to be a .NET CLI tool (at the moment there’s no UI for this in Visual Studio, so you’ll have to edit the csproj file, but that can be done from Visual Studio now by right clicking on the project and then there will be an Edit option in the context menu).

Here’s an example project file:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp1.0</TargetFramework>
    <PackageType>DotNetCliTool</PackageType>
  </PropertyGroup>
</Project>

Another thing that we need to do is ensure the application is prefixed with dotnet- in order for it to work with dotnet. This can be done via the UI (the Assembly name in the properties) or by editing the csproj:

<PropertyGroup>
  ...
  <AssemblyName>dotnet-example-tool</AssemblyName>
</PropertyGroup>

You can now build that and then create a NuGet package by running the dotnet pack command in the project’s folder.

Using the tool

For testing purposes, it’s probably easier to put the tool on your local machine and setting up Visual Studio to use the local package source:

NuGet Source

In another project, you can now reference the tool as a NuGet package. Unfortunately, at the time of writing the Visual Studio NuGet UI throws an error of “Package ‘ExampleTool’ has a package type ‘DotNetCliTool’ that is not supported by project ‘ExampleConsole’”. This means you’ll need to edit the csproj file to include it manually:

<ItemGroup>
  <DotNetCliToolReference Include="ExampleTool" Version="1.0.0" />
</ItemGroup>

To test this works, open the command prompt in the project’s directory and run the following:

dotnet restore
dotnet example-tool

You should see “Hello World!” displayed (or whatever you made your program do). We’re nearly there, however, I wanted to the tool to run before each build so it can generate some source files that get swept up in the build – at the moment it’s a manual step.

Integration with the build

Although the project.json was nice and compact, one advantage of moving back to MSBuild is we can now take advantage of the exec task to run our tool during the build. To do this, edit the csproj file of the project consuming the tool to add the following:

<Target Name="RunExampleTool" BeforeTargets="Build">
  <Exec Command="dotnet example-tool" WorkingDirectory="$(ProjectDir)" />
</Target>

Now when you build the solution, if you check the build output you should see “Hello World!” in there. Success!

Analysing the above, you can see we’ve created a target that runs before the built in Build task (note you used to be able to just name your task BeforeBuild, however, this no longer works – I’m guessing it’s a result of no longer including the common targets?). Next the task the target will run is an exec task, which invokes our tool (via the dotnet command). Here the important thing to note is that we’re setting the working directory to be the project’s directory, which is achieved using the MSBuild macro $(ProjectDir) – dotnet will only automatically pick up the tool from the NuGet packages is we’re in that directory.

That’s it, quite simple but took me a while to piece everything together as the documentation is missing in some areas and I guess it’s not a common thing I’m trying to achieve.