Update July 9: Read the follow-up post for an easier way to implement.
Create and Pack Reference Assemblies
Reference Assemblies, what are they, why do I need that? Reference Assemblies are a special kind of assembly that's passed to the compiler as a reference. They do not contain any implementation and are not valid for normal assembly loading (you'll get an exception if you try outside of a reflection-only load context).
Why do you need a reference assembly?
There's two main reasons you'd use a reference assembly:
- Bait and switch assemblies. If your assembly can only have platform-specific implementations (think of a GPS implementation library), and you want portable code to reference it, you can define your common surface area in a reference assembly and provide implementations for each platform you support.
Selectively altering the public surface area due to moving types between assemblies. I recently hit this with System.Interactive (Ix). Ix provides extension methods under the
System.Linqnamespace. Two of those methods, TakeLast, and SkipLast were added to .NET Core 2.0's Enumerable type. This meant that if you referenced Ix in a .NET Core 2.0 project, you could not use either of those as an extension method. If you tried, you'd get an error:
error CS0121: The call is ambiguous between the following methods or properties: 'System.Linq.EnumerableEx.SkipLast<TSource>(System.Collections.Generic.IEnumerable<TSource>, int)' and 'System.Linq.Enumerable.SkipLast<TSource>(Sy stem.Collections.Generic.IEnumerable<TSource>, int)'.
The only way out of this is to explicitly call the method like
EnumerableEx.SkipLast(...). Not a great experience. However, we cannot simply remove those overloads from the .NET Core version since:
- It's not in .NET Standard or .NET Framework
- If you use
TakeLastfrom a .NET Standard library, then are running on .NET Core, you'd get a
The method needs to be in the runtime version, but we need to hide it from the compiler. Fortunately, we can do this with a reference assembly. We can exclude the duplicate methods from the reference on platforms where it's built-in, so those get resolved to the built-in Enumerable type, and for other platforms, they get the implementation from EnumerableEx.
Creating reference assemblies
I'm going to explore how I solved this for Ix, but the same concepts apply for the first scenario. I'm assuming you have a multi-targeted project containing your code. For Ix, it's here.
It's easiest to think of a reference assembly as a different project, with the same name, as your main project. I put mine in a refs directory, which enables some conventions that I'll come back to shortly.
The key to these projects is that the directory/project name match, so it creates the same assembly identity. If you're doing any custom versioning, be sure it applies to these as well.
There's a couple things to note:
- In the project file itself, we'll include all of the original files
TargetFrameworksshould be for what you want as reference assemblies. These do not have to match that you have an implementation for. For scenario #1 above, you'll likely only have a single
netstandard2.0target. For scenario #2, Ix, given that the surface area has to be reduced on specific platforms, it has more.
- There is a Directory.Build.props file that provides common properties and an extra set of targets these reference assembly projects need. (Ignore the bit with
NETStandardMaximumVersion, that's me cheating a bit for the future 😉)
In that props, it defines
REF_ASSMas an extra symbol, and sets
trueso the compiler generates a reference assembly.
The other key thing in there is a target we'll need to gather the reference assemblies from the main project during packing.
<Target Name="_GetReferenceAssemblies" DependsOnTargets="Build" Returns="@(ReferenceAssembliesOutput)"> <ItemGroup> <ReferenceAssembliesOutput Include="@(IntermediateRefAssembly->'%(FullPath)')" TargetFramework="$(TargetFramework)" /> <ReferenceAssembliesOutput Include="@(DocumentationProjectOutputGroupOutput->'%(FullPath)')" TargetFramework="$(TargetFramework)" /> </ItemGroup> </Target>
With these, you can use something like
#ifdef !(REF_ASSM && NETCOREAPP2.0)in your code to exclude certain methods from the reference assembly on specific platforms. Or, for the "bait and switch" scenario, you may choose to throw an
NotImplementedExceptionin some methods (don't worry, the reference assembly strips out all implementation, but it still has to compile).
You should be able to build these reference assemblies, and in the output directory, you'll see a
ref subdirectory (in
\bin\$(Configuration)\$(TargetFramework)\ref). If you open the assembly in a decompiler, you should see an assembly level: attribute
[assembly: ReferenceAssembly]. If you inspect the methods, you'll notice they're all empty.
Packing the reference assembly
In order to use the reference assembly, and NuGet/MBuild do its magic, it must be packaged correctly. This means the reference assembly has to go into the
ref/TFM directory. The library continues to go into
lib/TFM, as usual. The goal is to create a package with a structure similar to this:
The contents of the
ref folder may not exactly match the
lib, and that's okay. NuGet evaluates each independently for the intended purpose. For finding the assembly to pass as a reference to the compiler, it looks for the "best" target in
ref. For runtime, it only looks in
lib. That means it's possible you'll get a restore error if you try to use the package in an application without a supporting
dotnet pack gives us the
lib portion. Adding a Directory.Build.targets above your main libraries gives us a place to inject some code into the NuGet pack pipeline:
<Target Name="GetRefsForPackage" BeforeTargets="_GetPackageFiles" Condition=" Exists('$(MSBuildThisFileDirectory)refs\$(MSBuildProjectName)\$(MSBuildProjectName).csproj') "> <MSBuild Projects="$(MSBuildThisFileDirectory)refs\$(MSBuildProjectName)\$(MSBuildProjectName).csproj" Targets="_GetTargetFrameworksOutput"> <Output TaskParameter="TargetOutputs" ItemName="_RefTargetFrameworks" /> </MSBuild> <MSBuild Projects="$(MSBuildThisFileDirectory)refs\$(MSBuildProjectName)\$(MSBuildProjectName).csproj" Targets="_GetReferenceAssemblies" Properties="TargetFramework=%(_RefTargetFrameworks.Identity)"> <Output TaskParameter="TargetOutputs" ItemName="_refAssms" /> </MSBuild> <ItemGroup> <None Include="@(_refAssms)" PackagePath="ref/%(_refAssms.TargetFramework)" Pack="true" /> </ItemGroup> </Target>
This target gets called during the NuGet pack pipeline and calls into the reference assembly project using a convention:
$(MSBuildThisFileDirectory)refs\$(MSBuildProjectName)\$(MSBuildProjectName).csproj. It looks for a matching project in a
refs directory. If it finds it, it obtains the
TargetFrameworks it has and then gets the reference assembly for each one. It calls the
_GetReferenceAssemblies that we had in the
Directory.Build.props in the
refs directory (thus applying it to all reference assembly projects).
This will all build and pack normally using
dotnet pack, with one caveat. Because there's no
ProjectReference between the main project and the reference assembly projects, we need to build the reference assembly projects first. You can do that with
dotnet build. Then, call
dotnet pack on your regular project and it'll put it all together.