A single .NET Core project to rule them all - Luca Bolognese

A single .NET Core project to rule them all

Luca -

☕ 5 min. read

Abstract

The code is here.

Thanks to Mike for re­view­ing this.

I have al­ways been mildly ir­ri­tated by how many .net pro­jects I need to cre­ate in my stan­dard work­flow.

Usually I start with an idea for a li­brary; I then want to test it with a sim­ple ex­e­cutable; write some XUnit tests for it and fi­nally bench­mark some key sce­nar­ios. So I end up with at least four pro­jects to man­age.

Sure, I can find ways to au­to­mat­i­cally gen­er­ate those pro­jects, but I have al­ways been weary of code­gen to solve com­plex­ity is­sues. It al­ways ends up com­ing back to bite you. For those of you as old as I am, think MFC

So what is my ideal world then? Well, let’s try this:

  1. One sin­gle pro­ject for the li­brary and re­lated ar­ti­facts (i.e. test, bench­marks, etc…).
  2. Distinguish the li­brary code from the test code from the bench­mark code by some con­ven­tion (i.e. name scheme).
  3. Generate each ar­ti­fact (i.e. li­brary, tests, bench­marks, ex­e­cutable) by pass­ing dif­fer­ent op­tions to dotnet build and dotnet run.
  4. Create a new pro­ject by us­ing the stan­dard dotnet new syn­tax.
  5. Have in­tel­lisense work­ing nor­mally in each file for my cho­sen ed­i­tor (VSCode).
  6. Work with dotnet watch so that one can au­to­mat­i­cally run tests when any­thing changes.

Disclaimer

What fol­lows, de­spite work­ing fine, is not the stan­dard way .net tools are used. It is not in the golden path’. That is prob­lem­atic for pro­duc­tion us­age as:

  1. It might not work in your par­tic­u­lar con­fig­u­ra­tion.
  2. It might not work with other tools that rely on the pres­ence of mul­ti­ple pro­jects (i.e. code cov­er­age? …).
  3. It might work now in all sce­nar­ios, but get bro­ken in the fu­ture as you up­date to a new frame­work, sdk, ed­i­tor.
  4. It might ex­pose bugs in the tools, now or later, which aren’t go­ing to be fixed, as you are not us­ing the tools as in­tended.
  5. It might up­set your cowork­ers that are used to a more stan­dard setup.

I need to write a blog post about the con­cept of the golden path’ and the per­ils, mostly hid­den, of get­ting away from it. The sum­mary, it is a bad idea.

Having said all of that, for the dar­ing souls, here is one way to achieve most of the above. It also works out as a tu­to­r­ial on how the dif­fer­ent com­po­nents of the .NET Core build sys­tem in­ter­acts.

How to use it

Here are the steps:

  1. Type dotnet new -i Lucabol.SingleSourceProject.
  2. Create a di­rec­tory for your pro­ject and cd to it.
  3. Type dotnet new lsingleproject and op­tion­ally --standardVersion <netstandardXX> --appVersion <netcoreappXX>.
  4. Either mod­ify the Library.cs, Main.cs, Test.cs, Bench.cs files or cre­ate your own with this con­ven­tion:
    • Code for the ex­e­cutable goes in po­ten­tially mul­ti­ple files named XXXMain.cs (i.e. MyLibrary.Main.cs).
    • Code for the tests goes into files named XXXTest.cs (i.e. MyLibrary.Test.cs).
    • Code for the bench­marks goes into files named XXXBench.cs (i.e. MyLibrary.Bench.cs).
    • Any .cs file not fol­low­ing the above con­ven­tions is com­piled into the dll.
  5. Type:
    • dotnet build or dotnet build -c release to build debug or release ver­sion of your dll. This does­n’t in­clude any of the main, test or bench code.
    • dotnet build -c main or dotnet build -c main_release and the cor­re­spond­ing dotnet run -c .. build and run the exe.
    • dotnet build -c test, dotnet build -c test_release and dotnet test -c test build and run the tests.
    • dotnet build -c bench, dotnet run -c bench build and run the bench­mark.

How it all works

The var­i­ous steps above are im­ple­mented as fol­lows:

dotnet new -i ... in­stall a cus­tom tem­plate that I have cre­ated and pushed on NuGet.

The cus­tom tem­plate is com­posed of the fol­low­ing files:

Code files

There is one file for each kind of ar­ti­fact that the pro­ject can gen­er­ate: li­brary, pro­gram, tests and bench­mark. The files fol­low the name ter­mi­nat­ing con­ven­tions, as de­scribed above.

Project file

The pro­ject file is iden­ti­cal to any other pro­ject file gen­er­ated by dotnet new ex­cept that there is one ad­di­tional line ap­pended at the end:

<Import Project="Base.targets" />

This in­struct msbuild to in­clude the Base.targets file. That file has most of the mag­ick. I have sep­a­rated it out so that you can use it un­changed in your own pro­jects.

Base.targets

We start by re­mov­ing all the file from com­pi­la­tion ex­cept the ones that are used to build the li­brary.

  <ItemGroup>
<Compile Remove="**/*Bench.cs;**/*Test.cs;**/*Main.cs" />
</ItemGroup>

We then con­di­tion­ally in­clude the cor­rect ones de­pend­ing on which con­fig­u­ra­tion is cho­sen. Please no­tice the last line, which in­struct dotnet watch to watch all the .cs files. By de­fault it just watches the ones in the debug con­fig­u­ra­tion.

  <ItemGroup>
<Compile Include="**/*Test.cs" Condition="'$(Configuration)'=='Test'"/>
<Compile Include="**/*Test.cs" Condition="'$(Configuration)'=='Test_Release'"/>
<Compile Include="**/*Bench.cs" Condition="'$(Configuration)'=='Bench'"/>
<Compile Include="**/*Main.cs" Condition="'$(Configuration)'=='Main'"/>
<Compile Include="**/*Main.cs" Condition="'$(Configuration)'=='Main_Release'"/>
<Watch Include="**\*.cs" />
</ItemGroup>

Then we need to de­fine the ref­er­ences. Depending on what you are build­ing you need to in­clude ref­er­ences to the cor­rect NuGet pack­ages (i.e. if you are build­ing test you need the xunit pack­ages). This is done be­low:

  <ItemGroup  Condition="'$(Configuration)'=='Bench' OR '$(Configuration)'=='Debug'">
<PackageReference Include="BenchmarkDotNet" Version="0.11.3" />
</ItemGroup>

<ItemGroup Condition="'$(Configuration)'=='Test' OR '$(Configuration)'=='Test_Release' OR '$(Configuration)'=='Debug'">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.9.0" />
<PackageReference Include="xunit" Version="2.4.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.0" />
</ItemGroup>

One thing to no­tice is that most ref­er­ences are also in­cluded in the debug con­fig­u­ra­tion. This is not a good thing, but it is the only way to get VSCode Intellisense to work for all the files in the so­lu­tion. Apparently, IntelliSense uses what­ever ref­er­ence are de­fined for the debug build in VsCode. So debug is spe­cial, if you wish …

But that’s not enough. When you cre­ate your own MsBuild con­fig­u­ra­tions, you also have to repli­cate the prop­er­ties and con­stants that are set in the debug and release con­fig­u­ra­tions. You would like a way to in­herit them, but I don’t think it is pos­si­ble.

It is par­tic­u­larly im­por­tant to set the TargetFramework prop­erty, as it needs to be set to netcoreappXXX for the main, test and bench­mark con­fig­u­ra­tions. I give an ex­am­ple of the Test and Test_release con­fig­u­ra­tions be­low. The rest is sim­i­lar:

  <PropertyGroup Condition="'$(Configuration)'=='Test'">
<TargetFramework>netcoreapp2.1</TargetFramework>
<DefineConstants>$(DefineConstants);DEBUG;TRACE;TEST</DefineConstants>
<DebugSymbols>true</DebugSymbols>
<DebugType>portable</DebugType>
<Optimize>false</Optimize>
</PropertyGroup>

<PropertyGroup Condition="'$(Configuration)'=='Test_Release'">
<TargetFramework>netcoreapp2.1</TargetFramework>
<DefineConstants>$(DefineConstants);RELEASE;TRACE;TEST</DefineConstants>
<DebugSymbols>false</DebugSymbols>
<DebugType>portable</DebugType>
<Optimize>true</Optimize>
</PropertyGroup>

The .template.config/template.json file

This is nec­es­sary to cre­ate a dotnet new cus­tom tem­plate. The only thing to no­tice is the two pa­ra­me­ters standardVersion and appVersion that gives the user a way to in­di­cate which ver­sion of the .NET Standard to use for the li­brary and which ver­sion of the ap­pli­ca­tion frame­work to use for Main, Test and Bench.

{
"$schema": "http://json.schemastore.org/template",
"author": "Luca Bolognese",
"classifications": [ "Classlib", "Console", "XUnit" ],
"identity": "Lucabol.SingleSourceProject",
"name": "One single Project",
"description": "One single Project for DLL, XUnit, Benchmark & Main, using configurations to decide what to compile",
"shortName": "oneproject",
"tags": {
"language": "C#",
"type": "project"
},
"preferNameDirectory": true,
"sourceName": "SingleSourceProject",
"symbols":{
"standardVersion": {
"type": "parameter",
"defaultValue": "netstandard2.0",
"replaces":"netstandard2.0"
},
"appVersion": {
"type": "parameter",
"defaultValue": "netcoreapp2.1",
"replaces":"netcoreapp2.1"
}
}
}

Conclusion

Now that you know how it all works, you can make an in­formed de­ci­sion if to use it or not. As for me …

0 Webmentions

These are webmentions via the IndieWeb and webmention.io.