Project "Caravela" 0.3 / / Caravela 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.

For instance, a logging aspect may let the user define the logging level. In the debug build, the aspect should generate code to log the beginning and success of all methods. In the release build, only failures are logged. For performance reasons, this decision must be taken at compile time (we don't want the production code to contain debugging code), so a run-time configuration file or API will not be sufficient. The aspect needs to expose a configuration mechanism.

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

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 Caravela.Framework.Aspects;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Caravela.Documentation.SampleCode.AspectFramework.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();
        }
    }
}

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 [CompileTimeOnly].

Example

using Caravela.Framework.Aspects;
using Caravela.Framework.Project;
using System;

namespace Caravela.Documentation.SampleCode.AspectFramework.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 => defaultCategory; 
            
            set
            {
                if ( this.IsReadOnly)
                {
                    throw new InvalidOperationException();
                }

                defaultCategory = value;
            }
        }
    }

    // For convenience, an extension method to access the options.
    [CompileTimeOnly]
    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 Caravela.Framework.Aspects;
using Caravela.Framework.Fabrics;
using System;
using System.Linq;

namespace Caravela.Documentation.SampleCode.AspectFramework.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.WithMembers(c => c.Types.SelectMany(t => t.Methods)).AddAspect<LogAttribute>();
        }
    }

    // Some target code.
    public class SomeClass
    {
        public void SomeMethod() { }
    }

}
using Caravela.Framework.Aspects;
using Caravela.Framework.Fabrics;
using System;
using System.Linq;

namespace Caravela.Documentation.SampleCode.AspectFramework.AspectConfiguration
{
#pragma warning disable CS0067
    // The project fabric configures the project at compile time.
    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

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

}