Metalama 1.0 / / Metalama Documentation / Conceptual Documentation / Creating Aspects / Exposing Configuration

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 configuration API.

Benefits

  • Central options of aspects. When you provide a configuration API, the whole project can be configured at once. Without a configuration API, the user of the aspect needs to supply the configuration every time a custom attribute is used.

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

  • 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 have to 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 does)

    <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 ensure that the property will be visible by 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. Instruct the user of your aspect to set this property in their own csproj file, like this:

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

    The value of compiler-visible properties must not contain line breaks or semicolons. Otherwise, your aspect will receive an empty or incorrect value.

Example

In the following example, the Log aspect reads the default category from the MSBuild project. It assumes the property has been exposed using the approach described above.

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 properties may not be convenient enough. Instead, you can build a configuration API that your users will call from project fabrics.

To create a configuration API:

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

Example

using Metalama.Framework.Aspects;
using Metalama.Framework.Project;
using System;

namespace Doc.AspectConfiguration
{
    // Options for the [Log] aspects.
    public class LoggingOptions : ProjectExtension
    {
        private string _defaultCategory = "Default";

        public override void Initialize( IProject project, bool isReadOnly )
        {
            base.Initialize( project, isReadOnly );

            // Optionally, we can initialize the configuration object from properties passed from MSBuild.
            if ( project.TryGetProperty( "DefaultLogProperty", out var propertyValue ) )
            {
                this._defaultCategory = propertyValue;
            }
        }

        public string DefaultCategory
        {
            get => this._defaultCategory;

            set
            {
                if ( this.IsReadOnly )
                {
                    throw new InvalidOperationException();
                }

                this._defaultCategory = value;
            }
        }
    }

    // For convenience, an extension method to access the options.
    [CompileTime]
    public static class LoggingProjectExtensions
    {
        public static LoggingOptions LoggingOptions( this IProject project ) => project.Extension<LoggingOptions>();
    }

    // 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.With( c => c.Types.SelectMany( t => t.Methods ) ).AddAspect<LogAttribute>();
        }
    }

    // Some target code.
    public class SomeClass
    {
        public void SomeMethod() { }
    }
}
using Metalama.Framework.Fabrics;
using System;
using System.Linq;

namespace Doc.AspectConfiguration
{
    // The project fabric configures the project at compile time.

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

    public class Fabric : ProjectFabric
    {
        public override void AmendProject(IProjectAmender amender) => 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


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