Click here to Skip to main content
Click here to Skip to main content

Strongly typed AppSettings with MSBuild

, 18 Oct 2009 Ms-PL
Rate this:
Please Sign up or sign in to vote.
How to extend MSBuild to dynamically compile stuff during a build.

Introduction

I see that code generation is fashion out there... But there is something most developers overlook. And it's called MSBuild. What does MSBuild have to do with code generation? In fact, most of you already know that when you add the x:Name attribute to a XAML object, you can access it from the code-behind, it's like magic. And obviously, there is no magic. What if you could do that yourself easily? I'm excited to know what you will imagine with that possibility. 

My goal is to show you how cool MSBuild is with code generation. For that, I will create a strongly typed appSettings.

to:

There is no magic

I recommend the book Build Engine: Using MSBuild and Team Foundation Build, by Sayed Ibrahim Hashimi, if you are interested in MSBuild. It's a cool compilation of tips, tricks, and references in a single place. This gave me a lot of ideas. 

When you add the x:Name on a XAML element, you can access this objet in the code behind. And in fact, the build process generates a *.g.cs in the intermediate directory of your project (/obj), and links these files dynamically to your intellisense.

In fact, you can do that yourself in three lines with MSBuild. Just create a .cs (MyMagicalClass.cs) file, and put it in the same directory as your project. Then, add this at the end of your .csproj file (at the end, and that is important). Do not include the code file in your project. I just want you to copy it to the same directory as the project file.

<target name="BeforeBuild">
    <ItemGroup>
        <Compile Include="MyMagicalClass.cs" />
    </ItemGroup>
</target>

Now, compile the first time, and you'll have the intellisense on this file, even if it's not shown in your project in the Solution Explorer.

Quickly, I will explain how this is possible. When you hit "Build" in Visual studio, Visual studio calls the target called "Build" on your project file. But the build target has some dependencies which will be executed before the Build target executes. Let's see what the dependencies are in C:\Windows\Microsoft.NET\Framework\v3.5\Microsoft.Common.targets.

<PropertyGroup>
    <BuildDependsOn>
    BeforeBuild;
    CoreBuild;
    AfterBuild
    </BuildDependsOn>
</PropertyGroup>
<target name="Build" 
   condition=" '$(_InvalidConfigurationWarning)' != 'true' " 
   dependsontargets="$(BuildDependsOn)"
outputs="$(TargetPath)" />

It seems that the build depends on BeforeBuild.

<Target Name="BeforeBuild"/>

And that this target is empty.

In reality, when you add a Target called BeforeBuild at the end of your project, you tell MSBuild to override any previously defined Target called BeforeBuild (you can even override what a "Build" is).

The code we wrote earlier in the .csproj, just tells MSBuild to "add the file in the compilation". Every time you add a .cs file in your project, a new Compile item is included in your .csproj, this is why they are shown in the Solution Explorer.

However, compile items added dynamically through the invocation of a target are not shown in the Solution Explorer... Nevertheless, they are compiled, and interpreted by the intellisense.

So now, I will show you a cool thing: generate a class during compilation depending on the app.config file, and integrate the class in the compilation. Let's go...

Now you get the idea, let's try to create an AppSettings class generator...

Our goal will be to be able to reference, with a strongly typed fashion, AppSettings items in the App.config file.

I will not enter into the nasty details of the implementation of my class, the interesting point remains the integration with MSBuild. The interesting thing in this code is the WriteClass method witch generates the code with CodeDOM from the config file passed to the constructor.

public enum Language
{
    CSharp,
    VB
}
public class Generator
{
    readonly string _ConfigFile;
    public Generator(string configFile)
    {
        if(configFile == null)
            throw new ArgumentNullException("configFile");
        if(!File.Exists(configFile))
            throw new FileNotFoundException(configFile, configFile);
        _ConfigFile = configFile;
        NameSpace = "";
        ClassName = "Settings";
        Language = Language.CSharp;
    }

    public String NameSpace
    {
        get;
        set;
    }

    public String ClassName
    {
        get;
        set;
    }

    public Language Language
    {
        get;
        set;
    }
    CodeDomProvider CreateProvider()
    {
        switch(Language)
        {
            case Language.CSharp:
                return new Microsoft.CSharp.CSharpCodeProvider();
            case Language.VB:
                return new Microsoft.VisualBasic.VBCodeProvider();
            default:
                throw new NotSupportedException();
        }
    }

    public void WriteClass(TextWriter writer)
    {
        var config = 
            ConfigurationManager.OpenMappedExeConfiguration(
            new ExeConfigurationFileMap()
        {
            ExeConfigFilename = _ConfigFile
        }, ConfigurationUserLevel.None);

        CodeNamespace ns = new CodeNamespace(NameSpace);
        CodeTypeDeclaration type = new CodeTypeDeclaration(ClassName);
        ns.Types.Add(type);

        foreach(KeyValueConfigurationElement setting in config.AppSettings.Settings)
        {
            CodeMemberProperty property = new CodeMemberProperty();
            property.HasSet = false;
            property.HasGet = true;
            property.Attributes = MemberAttributes.Static | MemberAttributes.Public;
            property.Type = new CodeTypeReference(typeof(String));
            property.Name = setting.Key;

            var getSettingCall =
                new CodeIndexerExpression(
                    new CodePropertyReferenceExpression(
                        new CodeTypeReferenceExpression(typeof(ConfigurationManager)),
                        "AppSettings"),
                    new CodePrimitiveExpression(setting.Key));
            property.GetStatements.Add(new CodeMethodReturnStatement(getSettingCall));
            type.Members.Add(property);
        }

        var provider = CreateProvider();
        provider.GenerateCodeFromNamespace(ns, writer, new CodeGeneratorOptions());
    }
}

For example, this config file:

<configuration>
    <appSettings>
        <add key="val1" value="value"/>
        <add key="val2" value="value2"/>
        <add key="val3" value="value2"/>
        <add key="hello" value="value2"/>
        <add key="hello3" value="value2"/>
        <add key="hello6" value="value2"/>
    </appSettings>
</configuration>

will generate this class:

public class Settings {
    
    public static string val1 {
        get {
            return System.Configuration.ConfigurationManager.AppSettings["val1"];
        }
    }
    
    public static string val2 {
        get {
            return System.Configuration.ConfigurationManager.AppSettings["val2"];
        }
    }
    
    public static string val3 {
        get {
            return System.Configuration.ConfigurationManager.AppSettings["val3"];
        }
    }
    
    public static string hello {
        get {
            return System.Configuration.ConfigurationManager.AppSettings["hello"];
        }
    }
    
    public static string hello3 {
        get {
            return System.Configuration.ConfigurationManager.AppSettings["hello3"];
        }
    }
    
    public static string hello6 {
        get {
            return System.Configuration.ConfigurationManager.AppSettings["hello6"];
        }
    }
}

...And integrate it in the build process

First, I just need to create a custom Task in MSBuild and implement the Execute method. Every logged error will appear in the error window of Visual Studio. (To build a Task in MsBuild, you need to reference the Microsoft.Build.* assemblies.)

As you can see... it's a wrapper around my Generator:

public class ClassGeneratorTask : Task
{
    public ClassGeneratorTask()
    {
        Language = "CS";
    }
    [Required]
    public ITaskItem FilePath
    {
        get;
        set;
    }


    public String Language
    {
        get;
        set;
    }

    [Required]
    public ITaskItem ConfigFile
    {
        get;
        set;
    }

    Language? GetLanguage()
    {
        if("CS".Equals(Language, StringComparison.InvariantCultureIgnoreCase))
            return SettingsClassGenerator.Language.CSharp;
        if("VB".Equals(Language, StringComparison.InvariantCultureIgnoreCase))
            return SettingsClassGenerator.Language.VB;
        return null;
    }

    public override bool Execute()
    {
        var language = GetLanguage();
        if(language == null)
        {
            Error(Language + " is not a valid language, specify CS or VB");
            return false;
        }
        try
        {
            string text = File.ReadAllText(ConfigFile.ItemSpec);
        }
        catch(IOException ex)
        {
            Error("Error when trying to open config file : " + " " + 
                  ConfigFile.ItemSpec + " " + ex.Message);
            return false;
        }
        try
        {
            using(var generatedWriter = 
                  new StreamWriter(File.Open(FilePath.ItemSpec, FileMode.Create)))
            {
                Generator gen = new Generator(ConfigFile.ItemSpec);
                gen.Language = language.Value;
                gen.WriteClass(generatedWriter);
                generatedWriter.Flush();
            }
        }
        catch(IOException ex)
        {
            Error("Error when accessing to " + FilePath.ItemSpec + 
                  " " + ex.Message);
            return false;
        }
        return true;
    }

    public void Error(string message)
    {
        Log.LogError(message);
    }
}

ConfigFile, FilePath, and Language are properties passed when we call the task. FilePath is the path to the generated file. You can see that the type of ConfigFile and FilePath are ITaskItem, and it would work if it was just a string. However, ITaskItem has more information, like the MSBuild metadata. In reality, I don't need it, and I just could use string instead, but I feel it's a good practice to use ITaskItem. After all, it's closer to the domain of my class (which is MSBuild).

Now, we want a simple way to execute this task at the right moment on the app.config file. For that, I just need to create a .targets file which is just a classic MSBuild project file we'll import later in the csproj files.

Note: SettingsGeneratorTask.dll is the assembly containing the MSBuild task shown earlier. This assembly must be in the same directory as the .targets file.

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="3.5">
    <UsingTask AssemblyFile="SettingsGeneratorTask.dll" TaskName="ClassGeneratorTask"/>

    <ItemGroup>
        <SettingsGenerated Include="$(IntermediateOutputPath)AppSettings.g.cs">
        </SettingsGenerated>
    </ItemGroup>
    <Target Name="GenerateSetting" 
           Inputs="@(AppConfigWithTargetPath)" 
           Outputs="@(SettingsGenerated)">
        <ClassGeneratorTask Language="$(Language)" ConfigFile="@(AppConfigWithTargetPath)" 
           FilePath="@(SettingsGenerated)"></ClassGeneratorTask>
        <ItemGroup>
            <Reference Include="System.Configuration"></Reference>
            <Reference Include="System"></Reference>
            <Compile Include="@(SettingsGenerated)"></Compile>
            <FileWrites Include="@(SettingsGenerated)"/>
        </ItemGroup>
    </Target>
    <PropertyGroup>
        <ResolveReferencesDependsOn>
            GenerateSetting;
            $(ResolveReferencesDependsOn)
        </ResolveReferencesDependsOn>
    </PropertyGroup>
</Project>

As you can see here:

<PropertyGroup>
    <ResolveReferencesDependsOn>
        GenerateSetting;
        $(ResolveReferencesDependsOn)
    </ResolveReferencesDependsOn>
</PropertyGroup>

I insert my custom task as a dependency of ResolveReferences. I've done that because after investigating Microsoft.Common.targets, I've seen that this step was the only one:

  • Before the compilation.
  • After the resolution of the item AppConfigWithTargetPath that I use in my task to locate the app.config file.

Now here:

<ItemGroup>
    <Reference Include="System.Configuration"></Reference>
    <Reference Include="System"></Reference>
    <Compile Include="@(SettingsGenerated)"></Compile>
    <FileWrites Include="@(SettingsGenerated)"/>
</ItemGroup>

You can see that in my task, I add References on System and System.Configuration dynamically because the generated code depends on it. I don't want to bother the user if he forgets to add these references to the project.

So, I add two references, the generated class to compile items and the FileWrites items. FileWrites items is used to say to MSBuild that the file should be deleted when you clean the project.

Another tip:

<Target Name="GenerateSetting" Inputs="@(AppConfigWithTargetPath)" 
        Outputs="@(SettingsGenerated)">

This means that the target depends on the AppConfig file, and creates the @(SettingsGenerated) item. In fact, before executing the task, MSBuild will compare the timestamps of @(SettingsGenerated) and the config file. If @(SettingsGenerated) was modified after the config file, this mean that the config file has not changed since the last time and that the task can be skipped; this way, there is no useless overhead during the compilation.

So, what's left? Just including the targets file in your csproj, after the import of Microsoft.Common.targets.

<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Import Project="..\SettingsGeneratorTask\bin\Debug\SettingsClassGenerator.targets" />

Create your app.config with your favorite settings, and compile... you will get intellisense on the AppSettings of your config file!

Conclusion

This project is done to show you how MSBuild and code generation are powerful... As you can see, what I've done is really simple to code, and yet incredibly useful. Now that you know this, what will you imagine?

License

This article, along with any associated source code and files, is licensed under The Microsoft Public License (Ms-PL)

Share

About the Author

Nicolas Dorier
Software Developer Freelance
France France
I am a trainer and a curious developer.
 
CEO of AO-IS, we created a tool to make IaaS on Azure more easy IaaS Management Studio.
 
If you are interested for working with me, for fun coding stuff, for freelance stuff, or interested in using our cloud training infrastructure freely for a kickass presentation for the dev community ? this way Smile | :)

Comments and Discussions

 
GeneralAmazing PinmentorNick Butler19-Oct-09 10:12 
GeneralRe: Amazing PinmemberNicolas Dorier7-Nov-09 23:19 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Terms of Use | Mobile
Web01 | 2.8.141216.1 | Last Updated 18 Oct 2009
Article Copyright 2009 by Nicolas Dorier
Everything else Copyright © CodeProject, 1999-2014
Layout: fixed | fluid