Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

Replacing method calls with Interceptors: Exploring the .NET 8 preview - Part 5

$
0
0

In this series I look at some of the new features coming in the .NET 8 previews. In this post, I look at a C#12 preview feature, Interceptors, show how they work and why they're useful, and discuss how the minimal API source generator from my previous post has been updated to use them!

As these posts are all using the preview builds, some of the features may change (or be removed) before .NET 8 finally ships in November 2023!

What are interceptors and why do we need them?

Interceptors are an interesting new experimental feature coming in C#12 that allow you to replace (or "intercept") a method call in your application with an alternative method. When your app is compiled, the compiler automatically "swaps out" the call to the original method with your substitute.

An obvious question here is why would you want to do that. Why not just call the substitute method directly?

The main reason is ahead-of-time compilation (AOT) which I've discussed several times in this series (not surprising given it's a key focus of .NET 8). Interceptors aren't specifically for AOT, but they are clearly designed with AOT in mind. By using interceptors you could take code which previously wasn't AOT friendly, and replace it with a source-generated version.

Customer's don't need to change their code, the source generator just automatically "upgrades" the method calls to use the source generated versions. If that sounds familiar, it's because that's exactly what the configuration source generator and existing minimal API source generator are already doing! The difference is that those generators currently rely on method specificity rules to "trick" the compiler into using the source generated method. Interceptors make the replacement explicit.

In theory, Interceptors should make it easier to build source generators like the configuration source generator or the minimal API source generator. They could even improve existing source generators like the Regex generator or the logging generator; instead of having to change your code to use the patterns the generator requires, the generator could automatically improve your existing code! Theoretically, at least.

There was a great discussion about interceptors on the .NET community standup a couple of months ago. I strongly recommend having a watch!

That's all quite abstract, so in the next section I'll show a very simple example of an interceptor, so that you can understand the mechanics of how they work.

Looking at a simple (impractical) working interceptor example

In this section I'm going to show a very impractical example of an interceptor. Interceptors are designed to be used with source generators, but they don't need to be, so for simplicity, we're just going to write a "raw" interceptor!

To start with, you'll need to be using .NET 8 preview 6 or later. Create a new app (it doesn't have to be the new AOT template), for example by running

dotnet new web

That creates an app that looks something like this:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

Next, we need to update the .csproj file to enable C#12 preview features. We also need to explicitly enable the experimental interceptors feature:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <!-- 👇 Add this to enable C#12 preview features -->
    <LangVersion>preview</LangVersion>
    <!-- 👇 Enable the interceptors feature -->
    <Features>InterceptorsPreview</Features>
  </PropertyGroup>

</Project>

Next, we need to define the [InterceptsLocation] attribute in our project. This is what drives the interceptor behaviour, but it's not included in the base class library (BCL) anywhere yet, so you need to define it yourself. That's true when you're using interceptors from source generators too.

Define the attribute somewhere in your project like this:

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
    sealed class InterceptsLocationAttribute(string filePath, int line, int column) : Attribute
    {
    }
}

If you include this definition in the same file as your source generator, you could use file-local scoping to avoid its usage "leaking".

Finally we can create our interceptor. For this demo, I'm going to intercept the call to MapGet() and create a method that logs the route pattern to the console, then calls the original method. No this isn't very useful, it's just a demo 😅

To create an interceptor you need to

  • Create a static class.
  • Define a method that has the exact same parameters and return type as your target method.
  • Decorate your interceptor with the [InterceptsLocation] attribute, specifying the location in the source of the method you wish to intercept.

For my example, I want to intercept the MapGet extension method, so I need to create a method that has the same signature as this:

public static RouteHandlerBuilder MapGet(
        this IEndpointRouteBuilder endpoints,
        [StringSyntax("Route")] string pattern,
        Delegate handler);

That's easy enough, the tricky part is setting [InterceptsLocation] to be the location of the first character of the method we want to intercept!

It's only tricky in this horrible, hacky, example. In source generators you'll have easy access to the source location, so there's no problems

Putting it all together, our interceptor looks something like this:

static class Interception
{
    // 👇 Define the file, line, and column of the method to intercept
    [InterceptsLocation(@"C:\testapp\Program.cs", line: 4, column: 5)]
    public static RouteHandlerBuilder InterceptMapGet( // 👈 The interceptor must
        this IEndpointRouteBuilder endpoints,          // have the same signature
        string pattern,                                // as the method being
        Delegate handler)                              // intercepted
    {
        Console.WriteLine($"Intercepted '{pattern}'" );

        return endpoints.MapGet(pattern, handler);
    }
}

And that's it. If we run our app, we can see that our interceptor is working!

Intercepted '/'
... 

What if we have multiple endpoints?

app.MapGet("/", () => "Hello World!");
app.MapPost("/{name}", (string name) => "Hello {name}!");

Even though it's a different HTTP verb, we can reuse our existing interceptor because it has the same parameters and return type:

static class Interception
{
    // 👇 Multiple attributes are fine
    [InterceptsLocation(@"C:\testapp\Program.cs", line: 4, column: 5)]
    [InterceptsLocation(@"C:\testapp\Program.cs", line: 5, column: 5)]
    public static RouteHandlerBuilder InterceptMapGet(
        this IEndpointRouteBuilder endpoints,
        string pattern,
        Delegate handler)
    {
        Console.WriteLine($"Intercepted '{pattern}'" );

        return endpoints.MapGet(pattern, handler);
    }
}

Just be careful with method overloads. If we try to intercept the following method call with our existing interceptor :

app.MapGet("/ping", (HttpContext context) => Task.CompletedTask);

then we'll get a compile-time exception:

error CS9144: Cannot intercept method 'EndpointRouteBuilderExtensions.MapPost
(IEndpointRouteBuilder, string, RequestDelegate)' with interceptor 'Interception.
InterceptMapGet(IEndpointRouteBuilder, string, Delegate)' because the signatures do not match.

As you can see from the error message, we're trying to intercept a method with a different parameter type to our interceptor, a RequestDelegate rather than Delegate. Again, in practice for source generators this shouldn't be a big problem as you can identify the parameter types in your generator and either ignore the method or output an appropriate interceptor.

Now you understand the toy example, we can have a look at how a real source generator makes use of interceptors!

Updating the minimal API generator to use interceptors

In my previous post I described the minimal API source generator as it ships in .NET 8 preview 6. However, shortly before publishing that post, a PR was merged which replaced the source generator implementation to use interceptors!

I was a little surprised by this at the time. Given that interceptors are going to remain experimental in .NET 8, I assumed the source generator (which will not be experimental, but fully supported) would not be able to use that feature. However, Safia Abdalla responded with a thread about how (and why) the minimal API generator will be using interceptors in .NET 8!

At the time of writing, the new source generator hasn't been released yet (it should be in .NET 8 preview 7), so the details in this section are very provisional. I'm basing my description of the changes on the snapshots used for unit testing.

It was nice to see that the ASP.NET Core team are using snapshot testing for testing source generators, as snapshots are perfect for this scenario. I described how to use snapshot testing in your own generators in a previous post, though I used the excellent Verify library by Simon Cropp, whereas it looks like Microsoft are using a much simpler (cruder) alternative.

I described the preview 6 generated code in detail in my previous post but I'll give a quick recap, so that you can see how things change with interceptors.

The minimal API generator in preview 6, without interceptors

As a reminder, without interceptors, the minimal API source generator relies on method specificity to ensure the generated method is "selected" and captures the location in source code using [CallerFilePath] and [CallerLineNumber] arguments. These caller information arguments are then used as the index key into a dictionary, which finally returns the RequestDelegateFunc containing the generated endpoint handler definition.

There's quite a few methods and types that go into the makeshift "intercepting" infrastructure:

1. An "interception via specificity" method is defined for each MapGet()/MapPost() etc call in your app. This uses a Func<> (instead of the less-specific Delegate) that matches the handler's signature. For every different signature, you will have a different version of this method, so Func<string> and Func<string, string> require different methods.

internal static RouteHandlerBuilder MapGet(
    this IEndpointRouteBuilder endpoints,
    string pattern,
    Func<string> handler,  // 👈 matches the endpoint handler definition signature
    [CallerFilePath] string filePath = "",
    [CallerLineNumber]int lineNumber = 0);

2. This method calls MapCore(), passing in all the details. MapCore() uses the provided filePath and lineNumber as a key into a dictionary:

file static class GeneratedRouteBuilderExtensionsCore
{
    internal static RouteHandlerBuilder MapCore(
        this IEndpointRouteBuilder routes,
        string pattern,
        Delegate handler,
        IEnumerable<string>? httpMethods,
        string filePath,
        int lineNumber)
    {
        // 👇Use the filePath and lineNumber as an index into the map dictionary
        var (populateMetadata, createRequestDelegate) = map[(filePath, lineNumber)];

        // Pass the functions to the minimal API internals
        return RouteHandlerServices.Map(routes, pattern, handler, httpMethods, populateMetadata, createRequestDelegate);
    }
}

3. The dictionary has an entry for every MapGet() call in your app. The value of the entry is a tuple containing the required MetadataPopulator and RequestDelegateFunc implementations.

file static class GeneratedRouteBuilderExtensionsCore
{
    private static readonly Dictionary<(string, int), (MetadataPopulator, RequestDelegateFactoryFunc)> map = new()
    {
        [(@"C:\testapp\Program.cs", 4)] = // "/", () => "Hello World!"
        (
            (methodInfo, options) => { /*   */ }, // MetadataPopulator
            (del, options, inferredMetadataResult) => { /*   */ }, // RequestDelegateFactoryFunc
        ),
        [(@"C:\testapp\Program.cs", 5)] = // "/{name}", (string name) => "Hello {name}!"
        (
            (methodInfo, options) => { /*   */ }, // MetadataPopulator
            (del, options, inferredMetadataResult) => { /*   */ }, // RequestDelegateFactoryFunc
        ),
    }
}

That's all that matters for our purposes, now let's see how things change with interceptors!

The interceptor-based minimal API generator

The code shown below is based on the snapshot results from the unit tests here.

Remember this code isn't in any previews yet, so it may well change before the final November GA release!

As before, I'm going to hide the MetadataPopulator and RequestDelegateFactoryFunc definitions, as they don't change between the implementations. The main thing that changes is that instead of 3 steps, the interceptor-based source generator only needs 1:

file static class GeneratedRouteBuilderExtensionsCore
{
    private static readonly string[] GetVerb = new[] { global::Microsoft.AspNetCore.Http.HttpMethods.Get };

    [InterceptsLocation(@"C:\testapp\Program.cs", 4, 5)]
    internal static RouteHandlerBuilder MapGet0(
        this IEndpointRouteBuilder endpoints,
        string pattern,
        Delegate handler)
    {
        MetadataPopulator populateMetadata = (methodInfo, options) => { /*   */ },
        RequestDelegateFactoryFunc createRequestDelegate = (del, options, inferredMetadataResult) => { /*   */ },

        return MapCore(endpoints, pattern, handler, GetVerb, populateMetadata, createRequestDelegate);
    }
    // ...
}

That's it! No need for (potentially) fragile "specificity" tricks, or [CallerFilePath] and dictionary lookups. The interceptor attribute allows you to neatly tie each MapGet()/MapPost() call to a generated method. Each different endpoint handler signature needs a different RequestDelegateFunc. If multiple endpoints have the same signature they can share a generated definition by adding extra [InterceptsLocation] attributes to the definition.

There's quite a few benefits to the interceptor approach:

  • Faster (single method call and no dictionary lookups)
  • Easier to understand (this code is generated in your project, and it's easier to follow as a consumer of the generator)
  • Easier to maintain (because it's simpler to understand)
  • Removes issues with method selection (because the source-generated version wasn't guaranteed to be selected; you could technically create a method with greater specificity)

The only real downside to the implementation is that it uses interceptors, which is an experimental feature and will remain so in the .NET 8 release in November. That means the minimal API AOT source generator will silently (currently) enable the interceptors feature in your project. That shouldn't cause any issues, but I just know someone is going to be salty about it 😅

Limitations to interceptors

In this post I've touched on how to use interceptors in general, and an example of a real source generator implementation that uses them, and how they make life easier for source generator authors.

However, the current interceptor design has garnered quite some criticism on the dotnet/csharplang#7009 issue. There's 100+ comments on there, so if you feel like going into a rabbit whole, enjoy! I've picked on a few obvious design limitations below, just to try and temper your expectations!

1. Methods only

The current interceptor design is focused solely on method interception. That means you can't intercept property accessors or, perhaps more importantly, constructors. The constructor point is an interesting one, as it means you could automatically intercept things like new Regex("a-z+") with a source-generated Regex implementation without the user needing to change their code. This would make things very interesting, as currently you must jump through various hoops to use the Regex or ILogger source generators.

2. Method signature must match exactly

This isn't necessarily a problem, more just something to be aware of. Even if the method arguments of a method you are intercepting could be simply cast to the parameters of your interceptor method, that's not valid. The compiler will give you a helpful error describing what you did wrong, as I showed before:

error CS9144: Cannot intercept method 'EndpointRouteBuilderExtensions.MapPost
(IEndpointRouteBuilder, string, RequestDelegate)' with interceptor 'Interception.InterceptMapGet(IEndpointRouteBuilder, string, Delegate)' because the signatures do not match.

so make sure to read these errors carefully! This may mean you need to generate more methods, depending on your use case, so it's just something to be aware of.

3. Can't intercept methods in library code

Interceptors run as part of the compilation process, just like source generators. That means you can only intercept method calls being made in the code you're directly compiling, not method calls made in some referenced library from NuGet.

To give a concrete example let's imagine your company has a set of standard minimal API endpoints that you add to all your applications. To simplify things, you create an internal NuGet package that encapsulates these calls. Users of your package just need to call:

app.AddStandardEndpoints();

and behind the scenes the AddStandardEndpoints() extension method looks something like this:

public static class StandardEndpointExtensions
{
    public static IEndpointRouteBuilder AddStandardEndpoints(this IEndpointRouteBuilder app)
    {
        app.MapGet("/healthz", () => "OK");
        app.MapGet("/metrics", () => {});
        // ... etc
    }
}

As the AddStandardEndpoints() method is defined in your NuGet package, it's already compiled. That means the minimal API source generator won't intercept those MapGet calls. The only way to support AOT for those calls is to enable the minimal API source generator when you compile the library, before distributing it on NuGet.

On the plus side, this is simpler in many ways, but it does limit the usefulness of interceptors as a way of doing "generic" aspect oriented programming (AOP).

4. Interceptors aren't a full AOP solution

This follows on somewhat from point 3, but I think it's important to call out that method interception is a common feature of AOP libraries, such as PostSharp/Metalama. However those libraries provide many more AOP features and solutions.

One of the big criticisms of the interceptor design, is that it doesn't allow "growing up" into a true AOP solution. Personally I think this is a valid concern. The current design feels very "standalone", which may be fine (especially as I don't have a big list of other AOP approaches I'm desperate for). Nevertheless, adopting a more "AOP framework" approach rather than "single standalone feature" feels like it may be a more robust long-term solution for the compiler. We'll see, there's still time for them to change given that interceptors are only experimental!

5. File paths cause problems for persisting generator output

This is just a minor one, but I like to enable persisting my source generator output to disk, so that I can include it in source control. This is particularly useful for code reviews in PRs, as you can see the impact of incidental changes, so you're reviewing all the effective code changes, including changes to the generated code.

At Datadog we even have a GitHub action that runs on every PR to confirm you've correctly committed and pushed the source generator changes.

Unfortunately, interceptors are likely going to break that approach, as they need to write the file path to the interception target as a literal string in the source code:

[InterceptsLocation(@"C:\testapp\Program.cs", 4, 5)]

The problem is that the file path will be different depending on which directory your code is checked out in, and whether it's running on Linux, macOS or Windows 🙁 I'm not really sure how to work around this to be honest, this feels like it will be fundamentally incompatible with persisting the generator code (when you have more than one person working on a project), which is a bit sad.

6. Security concerns with method replacement

I've added this one because a lot of people on the dotnet/csharplang#7009 issue bring it up. For example:

What security concerns?

  • Hijacking of methods
  • Injection of potentially malicous[sic] code for example.
  • Overriding important side effects.
  • Missing user notification that your call might get replaced completely.

However, interceptors introduce no more security concerns than existing source generators or NuGet packages.

I really want to emphasise that point; I've already shown that it's perfectly possible to "highjack" method calls without interceptors, because that's what the preview 6 minimal API and configuration binder source do! But there's no need to use source generators for this. You could achieve all of the above concerns with a malicious NuGet package that "overrides" an existing extension method, for example.

So of all your concerns with interceptors, security shouldn't be one of them. It maybe just highlights the fact that you really need to trust any NuGet packages that you add to your projects 🙂

This is a very long post already, so I'll leave with the results of a brief proof of concept for a Dapper interceptor build tool by Mark Gravell:

  • Adding the build tools improves startup time from ~95ms to ~37ms, with no significant change to subsequent reads (staying around 270-280ms)
  • Full AOT mode takes the startup time to zero and improves subsequent performance, down to ~225ms
  • And of course full AOT mode with DapperAOT has the advantage that it now works (it would fail at runtime before)

All-in-all, pretty promising!

Summary

In this post I looked at interceptors, an experimental C#12 feature. I described how they can replace a method call with a different method (typically source-generated), which can be useful for replacing existing implementations with an AOT-friendly implementation, for example.

Next I showed a very simple example of how interceptors work using the [InterceptsLocation] attribute. I then showed how the ASP.NET Core minimal API source generator has been updated to use interceptors, instead of the approach I described in my previous post.

Finally, I discussed some of the limitations and problems with interceptors. Most of these issues centre around the design choices, but the one that I find most problematic for my use case is that you likely won't be able to persist the source generator output in source control, due to file difference issues. One oft-cited concern, security, is a non-issue, as interceptors don't introduce any more risks than a third-party NuGet package does.


Viewing all articles
Browse latest Browse all 743

Trending Articles