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

Using source generators to find all routable components in a Blazor WebAssembly app

$
0
0

In the previous post I described a way to find all the "routable" components in a Blazor app using reflection. In this post I show an alternative approach using a source generator.

I'm not going to discuss source generators in this post. For an introduction, see this introductory post from the .NET team or this jump start by Khalid Abuhakmeh, as well as the example samples.

Finding a routable component in Blazor

In my previous post, I discussed the difference between a "routable" component, such as the Counter or FetchData component in the default Blazor templates, and a "standard" component.

The only difference, is that routable components, when compiled to their C# format, are decorated with a [RouteAttribute]; unrouteable components are not:

[RouteAttribute("/test")] // <-- only present for routable copmonents
public partial class TestComponent1 : Microsoft.AspNetCore.Components.ComponentBase
{
    protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder __builder)
    {
        __builder.AddMarkupContent(0, "<h1>Test</h1>");
    }
}

In the previous post, I showed how you could use reflection to identify classes decorated in this way. In that post, I was finding these classes in a unit test, for the purposes of statically prerendering each of the routes. The fact that reflection can be somewhat slow isn't really an issue, as this is happening at build time.

However, what if you want to find all these routes at runtime, from within a Blazor WebAssembly app? Lets say, for some reason, you want to display a list of routes in your app:

Example of displaying all routes in an app

There's a few different approaches you could take:

  • Use reflection, in exactly the same way, to find all the [Route] attributes on components at runtime.
  • Build a JSON file listing the routes at the same time as prerendering the HTML pages, and load that at runtime. (Various similar approaches are possible)
  • Use a source generator to embed the list of the routes into the application at build time.

In this post I'm going to use the last approach, creating a source generator, to solve the problem!

What we need to generate

Let's start by defining the final output that we're going to try and generate:

public static class AppRoutes
{
    public static ReadOnlyCollection<string> Routes { get; } = 
        new ReadOnlyCollection<string>(
            new List<string>
            {
                "/counter",
                "/fetchdata",
                "/",
            }
        );
}

This simple AppRoutes class exposes the list of routes as a static property in our Blazor app. The only data we need so that we can build this class is the list of routable components, and the route template in the [Route] attribute that decorates them:

[RouteAttribute("/counter")]
public partial class CounterComponent : Microsoft.AspNetCore.Components.ComponentBase
{
}

Now we know what we need, lets build the source generator to create it.

Creating the source generator

We'll start by creating the project. I created a simple class library using dotnet new classlib -n SourceGenerators and updated the project file to:

  • Enable C#9 and nullable references types
  • Add the required restore project sources
  • Add the required necessary NuGet packages

The project now looks like the following:

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

    <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
        <LangVersion>latest</LangVersion>
        <Nullable>enable</Nullable>
    </PropertyGroup>

    <PropertyGroup>
        <RestoreAdditionalProjectSources>https://dotnet.myget.org/F/roslyn/api/v3/index.json ;$(RestoreAdditionalProjectSources)</RestoreAdditionalProjectSources>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.8.0" PrivateAssets="all" />
        <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.2" PrivateAssets="all" />
    </ItemGroup>

</Project>

Next, we'll create a simple helper class which will generate the AppRoutes class in our Blazor app. You provide it a list of routes, and it returns a string that will be added to the compilation:

using System.Collections.Generic;
using System.Text;

namespace SourceGenerators
{
    internal static class Templates
    {
        public static string AppRoutes(IEnumerable<string> allRoutes)
        {
            // hard code the namespace for now
            var sb = new StringBuilder(@"
using System.Collections.Generic;
using System.Collections.ObjectModel;

namespace BlazorApp1
{
    public class AppRoutes
    {
        public ReadOnlyCollection<string> Routes { get; }

        public AppRoutes()
        {
            Routes = new ReadOnlyCollection<string>(
                new List<string>
                {
");
            foreach (var route in allRoutes)
            {
                sb.AppendLine($"\"{route}\",");
            }
            sb.Append(@"
                }
            );
        }
    }
}");
            return sb.ToString();
        }
    }
}

Finally, we have the source generator itself. This is a bit more complex, so I'll provide the full code, and then explain it in detail below.

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Text;

namespace SourceGenerators
{
    [Generator]
    public class AppRoutesGenerator : ISourceGenerator
    {
        private const string RouteAttributeName = "Microsoft.AspNetCore.Components.RouteAttribute";
        
        public void Initialize(GeneratorInitializationContext context) { }

        public void Execute(GeneratorExecutionContext context)
        {
            try
            {
                var allRoutePaths = GetRouteTemplates(context.Compilation);

                SourceText dictSource = SourceText.From(Templates.AppRoutes(allRoutePaths), Encoding.UTF8);
                context.AddSource("AppRoutes", dictSource);
            }
            catch (Exception)
            {
                Debugger.Launch();
            }
        }

        private static ImmutableArray<string> GetRouteTemplates(Compilation compilation)
        {
            IEnumerable<SyntaxNode> allNodes = compilation.SyntaxTrees.SelectMany(s => s.GetRoot().DescendantNodes());
            IEnumerable<ClassDeclarationSyntax> allClasses = allNodes
                .Where(d => d.IsKind(SyntaxKind.ClassDeclaration))
                .OfType<ClassDeclarationSyntax>();
            
            return allClasses
                .Select(component => GetRoutePath(compilation, component))
                .Where(route => route is not null)
                .Cast<string>()// stops the nullable lies
                .ToImmutableArray();
        }
        
        private static string? GetRoutePath(Compilation compilation, ClassDeclarationSyntax component)
        {
            var routeAttribute = component.AttributeLists
                .SelectMany(x => x.Attributes)
                .FirstOrDefault(attr => attr.Name.ToString() == RouteAttributeName);
                
            if (routeAttribute?.ArgumentList?.Arguments.Count != 1)
            {
                // no route path
                return null;
            }
                
            var semanticModel = compilation.GetSemanticModel(component.SyntaxTree);

            var routeArg = routeAttribute.ArgumentList.Arguments[0];
            var routeExpr = routeArg.Expression;
            return semanticModel.GetConstantValue(routeExpr).ToString();
        }
    }
}

Our source generator implements the ISourceGenerator interface, which requires implementing the Initialize() function. This is called once when the source generator is first initialized: in this case, there's no initialization we need to do, so the method is empty.

The Execute() method is called whenever we need to evaluate the compilation and generate the AppRoutes class.

public void Execute(GeneratorExecutionContext context)
{
    try
    {
        var allRoutePaths = GetRouteTemplates(context.Compilation);

        SourceText dictSource = SourceText.From(Templates.AppRoutes(allRoutePaths), Encoding.UTF8);
        context.AddSource("AppRoutes", dictSource);
    }
    catch (Exception)
    {
        Debugger.Launch();
    }
}

First we call the GetRouteTemplates() method, which is where we do the bulk of the work. In this method we find all the classes decorated with the [Route] attribute, extract the route template from the attribute, and return them as a collection.

Once we have the allRoutePaths, we can build the string representing the AppRoutes class using our Templates helper, create a SourceText instance, and add it to the application as an additional source file. This source file is included when we compile the Blazor application, making it available to other components in the project.

I added a Debugger.Launch() call in a catch block to assist in debugging the source generator when I was creating it.

Now let's take a look at the GetRouteTemplates() method:

private static ImmutableArray<string> GetRouteTemplates(Compilation compilation)
{
    IEnumerable<SyntaxNode> allNodes = compilation.SyntaxTrees.SelectMany(s => s.GetRoot().DescendantNodes());
    IEnumerable<ClassDeclarationSyntax> allClasses = allNodes
        .Where(d => d.IsKind(SyntaxKind.ClassDeclaration))
        .OfType<ClassDeclarationSyntax>();
    
    return allClasses
        .Select(component => GetRoutePath(compilation, component))
        .Where(route => route is not null)
        .Cast<string>()// stops the nullable lies
        .ToImmutableArray();
}

We start by finding all nodes in the Compilation (the project's source code), and then filter that down to only the class declarations. Finally we use the GetRoutePath() helper method to try to find the [Route] attribute and extract the route template:

private static string? GetRoutePath(Compilation compilation, ClassDeclarationSyntax component)
{
    var routeAttribute = component.AttributeLists
        .SelectMany(x => x.Attributes)
        .FirstOrDefault(attr => attr.Name.ToString() == RouteAttributeName);
        
    if (routeAttribute?.ArgumentList?.Arguments.Count != 1)
    {
        // no route path
        return null;
    }
        
    SemanticModel? semanticModel = compilation.GetSemanticModel(component.SyntaxTree);

    var routeArg = routeAttribute.ArgumentList.Arguments[0];
    var routeExpr = routeArg.Expression;
    return semanticModel.GetConstantValue(routeExpr).ToString();
}

We start by checking if the class declaration has an attribute with the correct name (defined in the RouteAttributeName constant), and try and get the first argument from the attribute, i.e. the route template.

To do that, we need to use the SemanticModel to evaluate the component, and convert the value in the route attribute to the final string value. That's the string route template we're looking for.

All that remains is to reference the SourceGenerators project from our Blazor project. You can do this using a standard <ProjectReference>, but by also setting OutputItemType="Analyzer" and ReferenceOutputAssembly="false" (so that the Blazor app doesn't include the source generator project in the publish output).

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

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="5.0.1" />
    <PackageReference Include="System.Net.Http.Json" Version="5.0.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\SourceGenerators\SourceGenerators.csproj" ReferenceOutputAssembly="false" OutputItemType="Analyzer" />
  </ItemGroup>

</Project>

With that all done, we can now reference the AppRoutes class in our Blazor app.

Using the generated file

The AppRoutes file isn't actually written to disk anywhere, but it's still included in the compilation, so you can reference it as though it's available. For example, this lists the available routes:

@page "/"

This app has the following routes:

<ul>
@foreach (var route in AppRoutes.Routes)
{
    <li>
        <code>@route</code>
    </li>
}
</ul>

Build and run the application, and voila:

Example of displaying all routes in an app

In this source generator we didn't bother to filter to only components that derive from ComponentBase which we could do if necessary. The source generator also gives us the option of extracting other details from the components at compile time if that's useful.

As well as using the AppRoutes class inside the Blazor app, we could also use it as I described in my previous post, to statically prerender all the pages in the Blazor WebAssembly app.

Summary

In this post I described how you could use a source generator to locate all routable components in a Blazor app. The source generator looks for all the ClassSyntaxDefinitions in the Blazor app, checks for a [Route] attribute, and extracts the route templates. In this case we're just displaying this list of routes on the home page. In the next post, we'll take this a step further, using a similar approach to dynamically generate the NavMenu component.


Viewing all articles
Browse latest Browse all 743

Trending Articles