Building strongly typed application configuration utility with Roslyn

ยท 1403 words ยท 7 minutes to read

In this post we will have a look at how, with just several lines of Roslyn code, you can build an extremely cool and powerful utility - a library allowing you to provide configuration for your application as a strongly typed C# script file.

This post was inspired by the ConfigR library, which provides this type of functionality through scriptcs (I also blogged about ConfigR before).

We will, however, deal with marshalling configuration data between the C# configuration and the parent app differently than ConfigR does.

Code based configuration ๐Ÿ”—

The idea behind code based configuration is quite simple. Instead of relying on static XML/JSON files (think web.config, app.config or some custom parsed JSONs) for your configuration, like we always have had in .NET, we can use C# script to provide any configuration settings for your application.

This is very cool for the following reasons:

  • Scripted configuration means it’s 100% valid C# code. As a result, it becomes strongly typed for free, as you are no longer constrained by the limits of serialization and deserialization. You just write your config file with C# as a C# script, against any types and assemblies (including those coming from your “main app”), and then marshal that back to the “main app”. For example if you instantiate an array in the C# configuration script, the “main app” sees the very same instance of an array. So no more error prone casting or unboxing based on magic strings.

  • Configuration is no longer just static container for data. It can now make logical decisions about configuration settings itself - as it is a fully featured C# code after all. So you can use things like switch or if statements - or any other flow control mechanisms - from within configuration. Configuration, since itโ€™s C# driven, can at runtime decide on proper config settings based on, for example, environment variables or process settings

  • At the end of the day, what you want from your configuration, is being able to edit it and have the application pick up the changes without you needing to recompile it - and this is exactly the case here. Even though we are dealing with C# code, it’s in the form of a script, so by forcing the app to reload it’s configuration (whether it’s by restarting it, or by watching for file changes or in any other way), the updated configuration script will be re-executed and the new configuration data will be exposed to the application.

Overall this gives us a brilliant use case for C# scripting.

Building a simple Roslyn based configuration provider ๐Ÿ”—

The easiest way to build this is to start off with a POCO representing some kind of configuration data for our application.

Let’s take a simple class like the one shown below:

public class AppConfiguration
{
    public int Number { get; set; }

    public string Text { get; set; }
}

You can easily imagine hydrating the above object and its Number and Text properties with data that comes from AppSettings inside web.config/app.config and is read at runtime using the good old ConfigurationManager. Of course this is also very limiting and error prone.

Let’s now use Roslyn to hydrate the same AppConfiguration object using a C# script as an alternative.

Firstly, let’s add the Roslyn C# scripting package.

install-package Microsoft.CodeAnalysis.CSharp.Scripting

Secondly, a little background.

Roslyn scripting APIs have a concept of a host type (also known as globals type). It allows us to seed the C# scripting with an instance of an object, and all of its public members are globally available in the context of the script.

This technique is used by the scriptcs project, which exposes for example a global Require method, which is effectively just defined on the host object). It’s also used by ConfigR, which exposes a global Add method.

In our case we can simply feed an instance of our configuration POCO (AppConfiguration) into the script as a host object, and let the script set the public properties - Number and Text. This makes marshalling the data between the script configuration and the “main app” extremely easy.

Since the instance of the POCO exists outside of the context of the script, once the script executes, and hydrates the properties of our configuration object, we can simply read them and consume however we wish - without any casting or any serialization.

To illustrate this, let’s write a simple class that will take care of seeding a configuration into a C# script as a host type and executing the C# script.

public class ScriptConfig
{
    private string _rootPath = AppDomain.CurrentDomain.BaseDirectory;
    private string _scriptName = "config.csx";

    private IEnumerable<Assembly> _assemblies = new[] { typeof(object).Assembly, typeof(Enumerable).Assembly };
    private IEnumerable<string> _namespaces = new[] { "System", "System.IO", "System.Linq", "System.Collections.Generic" };

    public ScriptConfig WithScript(string scriptName)
    {
        _scriptName = scriptName;
        return this;
    }

    public ScriptConfig WithRootPath(string rootPath)
    {
        _rootPath = rootPath;
        return this;
    }

    public ScriptConfig WithReferences(params Assembly[] assemblies)
    {
        _assemblies = assemblies.Union(_assemblies);
        return this;
    }

    public ScriptConfig WithNamespaces(params string[] namespaces)
    {
        _namespaces = namespaces.Union(_namespaces);
        return this;
    }

    public Task<TConfig> Create<TConfig>() where TConfig : new()
    {
        return Create(new TConfig());
    }

    public async Task<TConfig> Create<TConfig>(TConfig config)
    {
        var code = File.ReadAllText(Path.Combine(_rootPath, _scriptName));
        var opts = ScriptOptions.Default.AddImports(_namespaces).AddReferences(_assemblies).AddReferences(typeof(TConfig).Assembly);

        var script = CSharpScript.Create(code, opts, typeof (TConfig));
        var result = await script.RunAsync(config);

        return config;
    }
}

It seems like quite a bit of code, but in reality that’s the entire functionality right here, and it’s really simple. Plus a little bit of extra sugar on top too.

It’s a little builder that allows us to configure what script (by default config.csx) we want to execute, from which path should the script be executed (by default the base directory of the current app domain) and which assemblies and namespaces to import into the context of the script (again several default here for example mscorlib and System.Core.dll).

The configuration POCO is represented by TConfig which can be anything - as long the caller passes its instance in or as long as as it has a parameterless constructor.

Inside the Create method, which is how the caller will read the configuration, we will read the C# script code, create Roslyn’s ScriptOptions based on the configured settings, and invoke the C# script.

When it executes, it will get a chance to set any public members of TConfig which is then returned back to the caller, who can now use it as application configuration object.

So let’s add a C# script file, called config.csx, which I can make as part of the project, and mark as “Build Action: None” and “Copy to Output Directory: Copy Always”.

var txt = "hello";

Number = 5 * 5 + 1;
Text = txt + " foo";

As you can see I can interact with any public members of the AppConfiguration POCO and set them to whatever I wish. I can also perform calculations or any other complex operations I’d like to use C# for.

So now to use this in a real application, against our AppConfiguration, all I need is the following:

class Program
{
    static void Main(string[] args)
    {
        var scriptConfig = new ScriptConfig().Create<AppConfiguration>().Result;

        Console.WriteLine("Number: {0}", scriptConfig.Number);
        Console.WriteLine("Text: {0}", scriptConfig.Text);

        Console.ReadLine();
    }
}

Remember, I don’t even need to point it to a specific CSX file, as long the file has the default name config.csx.

The above script prints, as expected:

Number: 26
Text: hello foo

Now, to make it even more interesting, I can use some complex types. Let’s consider the following application configuration POCO:

public enum DataTarget
{
    Test,
    Production
}

public class MyAppConfig
{
    public DataTarget Target { get; set; }
    public Uri AppUrl { get; set; }
    public int CacheTime { get; set; }
}

Let’s use this configuration script:

Target = DataTarget.Production;
AppUrl = new Uri("http://localhost:8085");
CacheTime = 15;

So we are setting the relevant properties of the configuration object using strong typing - Uri, DataTarget and System.Int32.

Now in order to use this, all we need is the following in our application:

class Program
{
    static void Main(string[] args)
    {
        var scriptConfig = new ScriptConfig().
            WithScript("config2.csx"). //example of custom script name
            WithNamespaces(typeof(DataTarget).Namespace). //extra namespace so that we can use the enum in the script
            Create<MyAppConfig>().Result;

        Console.WriteLine("DataTarget: {0}", scriptConfig.Target);
        Console.WriteLine("AppUrl: {0}", scriptConfig.AppUrl);
        Console.WriteLine("CacheTime: {0}", scriptConfig.CacheTime);

        Console.ReadLine();
    }
}

And this prints the following:

DataTarget: Production
AppUrl: http://localhost:8085/
CacheTime: 15

That’s it! Pretty awesome piece of functionality for some 50 lines of Roslyn code. All the code from this post is available at Github.

About


Hi! I'm Filip W., a cloud architect from Zรผrich ๐Ÿ‡จ๐Ÿ‡ญ. I like Toronto Maple Leafs ๐Ÿ‡จ๐Ÿ‡ฆ, Rancid and quantum computing. Oh, and I love the Lowlands ๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ.

You can find me on Github and on Mastodon.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP