Zev Spitz
Zev Spitz

Reputation: 15385

Specify build output location of PackageReference dependency DLLs

I have a project with some NuGet dependencies, using PackageReference:

<ItemGroup>
    <PackageReference Include="Microsoft.CSharp" Version="4.7.0" />
    <PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.19" />
</ItemGroup>

I don't want the dependencies to be installed directly in the bin\${buildConfiguration}\${framework} folder (files in parentheses ( )):

bin
    Debug
        net472
            (MyLibrary.dll)
            (Microsoft.CSharp.dll)
            (Microsoft.Xaml.Behaviors.Wpf.dll)

Rather, I want each build's dependencies in a deeper subfolder, like this:

bin
    Debug
        net472
            (MyLibrary.dll)
            MyLibrary
                (Microsoft.CSharp.dll)
                (Microsoft.Xaml.Behaviors.Wpf.dll)

I know I can use a nuget.config file to control where the solution's packages should be downloaded to, but the build output of the NuGet dependencies remains the same -- the bin/release/framework folder.

Note that I want more than just to move the files, which could be accomplished with a post-build step, and wouldn't be very useful. I need that the reference to the dependent DLL should change while building to refer the subfolder instead of the root folder; so I can copy the entire contents of the root folder as a whole to a different location, and still have it work.

MyLibrary.dll is constructed using an SDK-format project, which uses PackageReference; it can be either .NET Framework, .NET Core, or .NET Standard.

How can I do this?


Some background

I've authored a Visual Studio debugging visualizer for expressions. Debugging visualizers are single DLLs that are copied by hand -- along with their dependencies -- to a specific subfolder under Documents -- e.g. Visual Studio 2019\Visualizers -- or to the Visualizers subfolder of the VS install folder.

If there are two visualizers that depend on different versions of the same library, one is liable to break. Deleting a visualizer is a hit-and-miss affair of removing unneeded dependencies.

This is compounded by the need to create multiple DLLs when writing a visualizer for .NET Core or .NET Standard; each of those DLLs might have their own dependencies.

If it were possible to output dependencies to a subfolder with the same name, that would be a step in the right direction.

(Developer community feature request and (now-closed) request to document better solutions to this problem)

Upvotes: 3

Views: 3664

Answers (3)

Willster419
Willster419

Reputation: 122

I had a similar problem (this question was the closest result from searching), but I wanted to copy the nuget dll files out to a custom directory so I could embed them later into my application (For dynamic assembly loading). Taking a modified approach by @zivkan in his answer:

  <!-- After nuget resolves the dependencies, get the list of all dlls and copy them to a custom location -->
  <!-- https://stackoverflow.com/questions/62001105/specify-build-output-location-of-packagereference-dependency-dlls -->
  <!-- https://learn.microsoft.com/en-us/visualstudio/msbuild/how-to-extend-the-visual-studio-build-process?view=vs-2019 -->
  <Target Name="CopyPackageAssembliesToResourcesForEmbedding" AfterTargets="ResolveReferences">
    <Message Text="Copying dlls to Resources directory" />
    <!-- Create a property of the dll output directory -->
    <PropertyGroup>
      <DllOutputDir>Resources\DLL</DllOutputDir>
    </PropertyGroup>
    <!-- Create an item group that will only include dll locations of nuget packages -->
    <ItemGroup>
      <!-- This will force the nuget dlls into a subdir called 'libs'. 
           Usefull for if you want to test dynamic assembly loading but don't want to 
           change each nuget package reference node to explicitly not copy to the output directory -->
      <NugetPkgs Include="@(ReferenceCopyLocalPaths)" Condition=" '%(ReferenceCopyLocalPaths.NuGetPackageId)' != '' " />
      <NugetDlls Include="@(NugetPkgs)" Condition=" '%(NugetPkgs.Extension)' == '.dll' " />
    </ItemGroup>
    <!-- For debugging -->
    <!-- <Message Text="ReferenceCopyLocalPaths: @(ReferenceCopyLocalPaths)" /> -->
    <!-- <Message Text="NugetPkgs: @(NugetPkgs)" /> -->
    <!-- <Message Text="NugetDlls: @(NugetDlls)" /> -->
    <!-- https://learn.microsoft.com/en-us/visualstudio/msbuild/makedir-task?view=vs-2019 -->
    <MakeDir Directories="$(DllOutputDir)" />
    <!-- https://learn.microsoft.com/en-us/visualstudio/msbuild/copy-task?view=vs-2019 -->
    <Copy SourceFiles="@(NugetDlls)" DestinationFolder="$(DllOutputDir)" />
  </Target>

I hope this helps someone else in a similar situation

Upvotes: 0

Zev Spitz
Zev Spitz

Reputation: 15385

There are two parts to doing this:

  1. During build, place dependencies in a subfolder; the technique described by @zivkan in this answer works very well.
  2. The main assembly needs to find the dependency assemblies, either at compile time or at runtime. I'm not sure it's possible to do this at compile time, but I've chosen to do it at runtime, with the following code (taken in part from this answer:
//using System;
//using System.Linq;
//using System.Reflection;
//using static System.IO.Path;
//using static System.StringComparison;
//using static System.Reflection.Assembly;
//using System.IO;

public static class SubfolderAssemblyResolver {
    public static void Hook(string subfolderKey) {
        if (string.IsNullOrWhiteSpace(subfolderKey)) { return; }
        if (!string.IsNullOrWhiteSpace(subfolderPath)) { return; }

        subfolderPath = Combine(
            GetDirectoryName(GetCallingAssembly().Location),
            subfolderKey
        );
        basePath = GetDirectoryName(subfolderPath);

        AppDomain.CurrentDomain.AssemblyResolve += resolver;
    }

    private static bool EndsWithAny(this string s, StringComparison comparisonType, params string[] testStrings) => testStrings.Any(x => s.EndsWith(x, comparisonType));

    private static readonly string[] exclusions = new[] { ".xmlserializers", ".resources" };
    private static readonly string[] patterns = new[] { "*.dll", "*.exe" };

    private static string? basePath;
    private static string? subfolderPath;

    private static Assembly? resolver(object sender, ResolveEventArgs e) {
        var loadedAssembly = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.FullName == e.Name);
        if (loadedAssembly is { }) { return loadedAssembly; }

        var n = new AssemblyName(e.Name);
        if (n.Name.EndsWithAny(OrdinalIgnoreCase, exclusions)) { return null; }

        // search in basePath first, as it's probably the better dependency
        var assemblyPath =
            resolveFromFolder(basePath!) ??
            resolveFromFolder(subfolderPath!) ??
            null;

        if (assemblyPath is null) { return null; }

        return LoadFrom(assemblyPath);

        string resolveFromFolder(string folder) =>
            patterns
                .SelectMany(pattern => Directory.EnumerateFiles(folder, pattern))
                .FirstOrDefault(filePath => {
                    try {
                        return n.Name.Equals(AssemblyName.GetAssemblyName(filePath).Name, OrdinalIgnoreCase);
                    } catch {
                        return false;
                    }
                });
    }
}

and it's called like this:

SubfolderAssemblyResolver.Hook("SubfolderName");

This code runs on .NET Framework, .NET Core (tested on 3.1) and .NET Standard 2.0.

Upvotes: 0

zivkan
zivkan

Reputation: 15092

I only tested this in the most basic scenario, so maybe a multi-targeting project will need modifications to this, and maybe non-sdk style projects work differently to sdk style projects, but:

investigating

The single most important thing you need to know about investigating anything MSBuild is the binary log output, which is viewed with the MSBuild Structed Log Viewer.

So, I ran dotnet new console, and dotnet add package NuGet.Versioning, because I really need to do SemVer2 comparisons in a console app. Now, I run dotnet build -bl and start msbuild.binlog.

In the MSBuild log viewer, search for an assembly name from a package, and the word copy. In my case I searched for "copy nuget.versioning.dll" and it finds one result. Clicking on it, I see the message was output by a task named "Copy", which ran in a target named "_CopyFilesMarkedCopyLocal". Clicking the Task Copy in the tree, it opens the text view of Microsoft.Common.CurrentVersion.targets, on the line that runs the Copy task, and I see this:

    <Copy
        SourceFiles="@(ReferenceCopyLocalPaths)"
        DestinationFiles="@(ReferenceCopyLocalPaths->'$(OutDir)%(DestinationSubDirectory)%(Filename)%(Extension)')"
        SkipUnchangedFiles="$(SkipCopyUnchangedFiles)"
        OverwriteReadOnlyFiles="$(OverwriteReadOnlyFiles)"
        Retries="$(CopyRetryCount)"
        RetryDelayMilliseconds="$(CopyRetryDelayMilliseconds)"
        UseHardlinksIfPossible="$(CreateHardLinksForCopyLocalIfPossible)"
        UseSymboliclinksIfPossible="$(CreateSymbolicLinksForCopyLocalIfPossible)"
        Condition="'$(UseCommonOutputDirectory)' != 'true'"
            >

Notice the destination DestinationFiles="@(ReferenceCopyLocalPaths->'$(OutDir)%(DestinationSubDirectory)%(Filename)%(Extension)')". Ok, DestinationSubDirectory sounds promising. Checking the copy task's parameters, the DestinationFiles item doesn't have any DestinationSubDirectory set, so it appears I can just set it to whatever relative path I want.

Let's search for where the ReferenceCopyLocalPaths items are defined. I see two search results for "AddItem ReferenceCopyLocalPaths", but checking the "call stack" of both of them, I see they're both under a target named "ResolveReferences".

Last thing, since this question is about assemblies coming from PackageReference, I want to be extra careful, so I look at the ReferenceCopyLocalPaths item, and notice that it has a metadata item named NuGetPackageId.

So now:

  • I want to run my own target after the ResolveReferences target
  • I want it to update ReferenceCopyLocalPaths items
  • where NuGetPackageId metadata is defined
  • set DestinationSubDirectory to some path

solution

Add this target into your csproj:

  <Target Name="CopyPackageAssembliesToSubFolder" AfterTargets="ResolveReferences">
    <ItemGroup>
      <ReferenceCopyLocalPaths Condition=" '%(ReferenceCopyLocalPaths.NuGetPackageId)' != '' "
        Update="%(ReferenceCopyLocalPaths)"
        DestinationSubDirectory="libs\" />
    </ItemGroup>
  </Target>

Now when I run dotnet clean ; dotnet build, I see the bin directory has a libs/ folder with NuGet.Versioning.dll.

Upvotes: 10

Related Questions