Metalama / / Conceptual documentation / Creating aspects / Exposing configuration
Open sandbox

Exposing configuration

Some complex and widely-used aspects need a central, project-wide way to configure their compile-time behavior.

There are two complementary configuration mechanisms: MSBuild properties and the configuration API.

Benefits

  • Central options of aspects. When you provide a configuration API, the whole project can be configured from a single place. Without a configuration API, the aspect user must supply the configuration whenever a custom attribute is used.

  • Debug/Release-aware options. Without a configuration API, setting options according to the Debug/Release build configuration can be impractical.

  • Run-time performance. When decisions are taken at compile time and optimal run-time code is generated accordingly, the run-time execution of your app is faster.

Consuming MSBuild properties

The simplest way for an aspect to accept a configuration property is to read an MSBuild property using the IProject.TryGetProperty method. MSBuild properties are not visible to aspects by default: you must instruct MSBuild to pass it to the compiler using the CompilerVisibleProperty item.

We recommend the following approach to consume a configuration property:

  1. Create a file named YourProject.targets (the actual name of the file does not matter but the extension is important):

    <Project>
        <ItemGroup>
            <CompilerVisibleProperty Include="YourProperty" />
        </ItemGroup>
    </Project>
    
  2. Include YourProject.targets in your project and mark it for inclusion under the build directory of your NuGet package. This ensures that the property will be visible to the aspect for all projects referencing your package. Your csproj file should look like this:

    <Project Sdk="Microsoft.NET.Sdk">
        <!-- ... -->
        <ItemGroup>
            <None Include="YourProject.targets">
                <Pack>true</Pack>
                <PackagePath>build</PackagePath>
            </None>
        </ItemGroup>
        <!-- ... -->
    </Project>
    
  3. To configure the aspect, users should set this property in the csproj file, like in the following snippet:

    <Project Sdk="Microsoft.NET.Sdk">
        <!-- ... -->
        <PropertyGroup>
            <YourProperty>TheValue</YourProperty>
        </PropertyGroup>
        <!-- ... -->
    </Project>
    
    Warning

    Neither line breaks nor semicolons are allowed in values of compiler-visible properties: they will cause your aspect to receive an incorrect value.

Example

In the following example, the Log aspect reads the default category from the MSBuild property.

using Metalama.Framework.Aspects;
using System;

namespace Doc.ConsumingProperty
{
    public class Log : OverrideMethodAspect
    {
        public string? Category { get; set; }

        public override dynamic? OverrideMethod()
        {
            if ( !meta.Target.Project.TryGetProperty( "DefaultLogCategory", out var defaultCategory ) )
            {
                defaultCategory = "Default";
            }

            Console.WriteLine( $"{this.Category ?? defaultCategory}: Executing {meta.Target.Method}." );

            return meta.Proceed();
        }
    }
}
using Metalama.Framework.Aspects;
using System;

namespace Doc.ConsumingProperty
{

#pragma warning disable CS0067, CS8618, CS0162, CS0169, CS0414, CA1822, CA1823, IDE0051, IDE0052
    public class Log : OverrideMethodAspect
    {
        public string? Category { get; set; }

        public override dynamic? OverrideMethod() => throw new System.NotSupportedException("Compile-time-only code cannot be called at run-time.");

    }

#pragma warning restore CS0067, CS8618, CS0162, CS0169, CS0414, CA1822, CA1823, IDE0051, IDE0052

}

Exposing a configuration API

For more complex aspects, a set of MSBuild properties may not be sufficient. Instead, you can build a configuration API that your users will call from their project fabrics.

To create a configuration API:

  1. Create a class that derives from ProjectExtension and has a default constructor.
  2. Optionally, override the Initialize method, which receives the IProject.
  3. In your aspect code, call the IProject.Extension<T>() method, where T is your configuration class, to get the configuration object.
  4. Optionally, create an extension method to the IProject type to expose your configuration API, so that it is more discoverable. The class must be annotated with [CompileTime].
  5. To configure your aspect, users should implement a project fabric and access your configuration API using this extension method.

Example

using Metalama.Framework.Aspects;
using System;

namespace Doc.AspectConfiguration
{
    // The aspect itself, consuming the configuration.
    public class LogAttribute : OverrideMethodAspect
    {
        public string? Category { get; set; }

        public override dynamic? OverrideMethod()
        {
            var defaultCategory = meta.Target.Project.LoggingOptions().DefaultCategory;

            Console.WriteLine( $"{this.Category ?? defaultCategory}: Executing {meta.Target.Method}." );

            return meta.Proceed();
        }
    }
}
using Metalama.Framework.Fabrics;
using System.Linq;

namespace Doc.AspectConfiguration
{
    // The project fabric configures the project at compile time.
    public class Fabric : ProjectFabric
    {
        public override void AmendProject( IProjectAmender amender )
        {
            amender.Project.LoggingOptions().DefaultCategory = "MyCategory";

            // Adds the aspect to all members.
            amender.Outbound
                .SelectMany( c => c.Types.SelectMany( t => t.Methods ) )
                .AddAspectIfEligible<LogAttribute>();
        }
    }

}
namespace Doc.AspectConfiguration
{
    // Some target code.
    public class SomeClass
    {
        public void SomeMethod() { }
    }
}
using System;

namespace Doc.AspectConfiguration
{
    // Some target code.
    public class SomeClass
    {
        public void SomeMethod()
        {
            Console.WriteLine("MyCategory: Executing SomeClass.SomeMethod().");
            return;
        }
    }
}