Introduction
C# Source Generators made their first appearance around the release of .NET 5 and now ship as part of the .NET Compiler Platform (“Roslyn”) SDK. They allow developers to inspect user code as it is being compiled and even create new C# source files on the fly and add them to the compilation.
A source generator is a .NET Standard 2.0 assembly that is loaded by the compiler. It’s usable in environments where .NET Standard components can be loaded and run, including .NET/.NET Core, and .NET Framework 4.6.1+.
There are lots of use cases for source code generators, some common ones include:
- Automate boilerplate/template code.
- Shifting various runtime reflection tasks to compile-time.
- Code analysers (styling, bugs, vulnerabilities, etc).
My interest was as a means of producing different C# implant builds based on a provided configuration. Imagine a C# implant that you want to embed an HTTP traffic profile into, or specify different post-ex behaviours.
Creating a Code Generator
To get started with your first code generator, create a new solution made of a Console Application and .NET Standard 2.0 Class Library. The library project needs to have the Microsoft.CodeAnalysis.CSharp
& Microsoft.CodeAnalysis.Analyzers
packages installed, and EnforceExtendedAnalyzerRules
in the .csproj
set to true
.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.6.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
</ItemGroup>
</Project>
Then add a reference to the console app, pointing at the library project. Ensure the OutputItemType
is set to Analyzer
and ReferenceOutputAssembly
to false
.
<ItemGroup>
<ProjectReference Include="..\CodeGenerator\CodeGenerator.csproj">
<OutputItemType>Analyzer</OutputItemType>
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
</ProjectReference>
</ItemGroup>
To create the generator functionality, create a new class with the Generator
attribute and have it inherit from a generator interface, such as ISourceGenerator
.
using Microsoft.CodeAnalysis;
namespace CodeGenerator;
[Generator]
public sealed class ExampleGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
}
public void Execute(GeneratorExecutionContext context)
{
}
}
We don’t need to do anything in the Initialize
method, so we’ll focus on Execute
. Adding some new code to the compilation can be as simple as:
public void Execute(GeneratorExecutionContext context)
{
var sourceCode = SourceText.From("""
namespace ConsoleApp;
public static class GeneratedCode
{
public static string GeneratedMessage = "Hello from Generated Code";
}
""", Encoding.UTF8);
context.AddSource("GeneratedCode.g.cs", sourceCode);
}
If the code generator project is built now, the new source file will appear in the ConsoleApp project under References > Source Generators
and can be used in the ConsoleApp code.
using System;
namespace ConsoleApp;
internal static class Program
{
public static void Main(string[] args)
{
Console.WriteLine(GeneratedCode.GeneratedMessage);
}
}
.\ConsoleApp.exe
Hello from Generated Code
Polymorphism
The above example obviously has limited utility. A more useful implementation could be leveraged using abstract classes and overrides. This could allow you to provide a default behaviour and override it from the code generator.
In this example, we have an abstract BaseClass
and a partial ExampleClass
that inherits from it.
namespace ConsoleApp;
public partial class ExampleClass : BaseClass { }
public abstract class BaseClass
{
public virtual string GetMessage()
{
return "Hello World";
}
}
If we instantiate a new instance of ExampleClass
and call GetMessage
we’ll see “Hello World!” (nothing special there).
using System;
namespace ConsoleApp;
internal static class Program
{
public static void Main(string[] args)
{
var example = new ExampleClass();
Console.WriteLine(example.GetMessage());
}
}
.\ConsoleApp.exe
Hello World
To override the implementation from our source generator, we could do:
public void Execute(GeneratorExecutionContext context)
{
var sourceCode = SourceText.From("""
namespace ConsoleApp;
public partial class ExampleClass
{
public override string GetMessage()
{
return "Hello from overridden code";
}
}
""", Encoding.UTF8);
context.AddSource("ExampleClass.g.cs", sourceCode);
}
.\ConsoleApp.exe
Hello from overridden code
Configuration File
It would be nice if we could pass some sort of configuration file to the code generator to dictate its behaviour. Create a JSON file in the ConsoleApp project and add the following to replicate (at least part of) an “HTTP traffic profile”.
{
"ConnectAddress": "localhost",
"ConnectPort": 8080,
"Uris": [
"/home",
"/news"
]
}
Change the properties of the file in the project so that it’s an “AdditionalFile”.
<ItemGroup>
<AdditionalFiles Include="config.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</AdditionalFiles>
</ItemGroup>
Then create a POCO in the code generator to represent that JSON.
public sealed class Config
{
public string ConnectAddress { get; set; }
public int ConnectPort { get; set; }
public string[] Uris { get; set; }
}
Inside the Execute
method, we can read the content of the JSON file and deserialize it into a Config
class that we can handle nicely.
var json = context.AdditionalFiles
.Single(t => t.Path.Contains("config.json"))
.GetText()
.ToString();
var config = JsonSerializer.Deserialize<Config>(json);
Then use the content to create a new source file, effectively translating the configuration into a new static class.
var sourceCode = SourceText.From($$"""
namespace ConsoleApp;
public static class HttpProfile
{
public static string ConnectAddress { get; } = "{{config.ConnectAddress}}";
public static int ConnectPort { get; } = {{config.ConnectPort}};
public static string[] Uris { get; } = { {{string.Join(", ", config.Uris.Select(u => $"\"{u}\""))}} };
}
""", Encoding.UTF8);
context.AddSource("HttpProfile.g.cs", sourceCode);
As before, we can then reference this generated code class in the ConsoleApp.
public static void Main(string[] args)
{
Console.WriteLine($"Connect Address : {HttpProfile.ConnectAddress}");
Console.WriteLine($"Connect Port : {HttpProfile.ConnectPort}");
Console.WriteLine($"URIs : {string.Join(", ", HttpProfile.Uris)}");
}
.\ConsoleApp.exe
Connect Address : localhost
Connect Port : 8080
URIs : /home, /news
Closing
C# source generators open a lot of interesting potential for producing customised builds. I’ve barely scratched the surface here. Please let me know if you have any source generator tricks that you’d like to share.
原文始发于Rasta Mouse:C# Source Generators