Image © All Rights Reserved by Flickr member
dr_odio
Contents
Introduction
If you’ve ever used any kind of IOC framework where configuration can be performed via a config file,
you’ll know what I mean when I say that sometimes, detecting config errors that result from bad type names
can be troublesome. Because of this, where there does not exist a real need for extensibility without recompilation,
it is prudent to avoid config, and to wire type registrations for service location in code.
Sometimes though there is a genuine need for the added flexibility afforded by config, and in such cases,
whether you are hooking up modules in a composite application, or registering types in your IOC container,
it is always preferable to catch errors at compile time, rather than at runtime.
Some time ago Microsoft decoupled the build process and produced the standalone tool MSBuild.
It offers a host of opportunities to inspect projects, generate code, and perform auxiliary tasks at build-time.
If you are new to MSBuild we recommend beginning with the following resources:
Today we will look at creating a custom task that inspects app.config files to make sure that type strings are resolvable at compile time.
How It Works
The App.Config Type verifier is a custom MSBuild task. It examines your app.config file at compile time and verifies that string type names are resolvable.
To demonstrate, let’s take a look at a simple example. The following is an excerpt from an app.config file.
<configuration>
<configSections>
<section name="name1" type="Foo.BahType, Foo"/>
</configSections>
</configuration>
Here we have a section defined that refers to a type called BahType in assembly Foo. At compile time, the AppConfigVerifier will attempt to resolve the type BahType. If the type is unresolvable a build error will ensue.
Figure: Build error from missing assembly.
We are able to control how types are resolved. In a later section we will see how we can use XML comments to exclude and include type and assembly names.
Using It
In order to use the Type Verifier in a project follow the following steps:
- Copy the External Targets directory included in the download to your VS Solutions directory
- Add an Import element to your project file containing the app.config,
with the path to the Verifier.targets project specified, as demonstrated in the following excerpt.
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
-->
<Import Project="External Targets\Verifier.targets" />
-->
</Project>
And that’s it. Now when the project is built, type strings in the app.config file will be verified automatically.
Creation of a Custom MSBuild Task
The AppConfigVerifier solution contains a project called AppConfigVerifier.MSBuild. This project serves as a host for our custom task.
Figure: AppConfigVerifier.MSBuild project
By importing the file Verifier.targets into a project, we are able to trigger the execution of a custom task called AppConfigVerifier.MSBuild.Verifier. This occurs when the host project’s build occurs.
The following excerpt is taken from the Verifier.targets file:
<Project ToolsVersion="3.5" DefaultTargets="Build"
xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<UsingTask TaskName="AppConfigVerifier.MSBuild.Verifier"
AssemblyFile="AppConfigVerfier.MSBuild.dll" />
<PropertyGroup>
<RunVerifier>true</RunVerifier>
</PropertyGroup>
<PropertyGroup>
<BuildDependsOn>
$(BuildDependsOn);
VerifyAppConfig
</BuildDependsOn>
</PropertyGroup>
<Target Name="VerifyAppConfig" Condition="'$(RunVerifier)' == 'true'">
<ItemGroup>
<DefaultReferencePath Include=" @(MainAssembly->'%(FullPath)')"/>
</ItemGroup>
<Verifier References="@(ReferencePath)"
DefaultReferences="@(DefaultReferencePath)"
Config="$(MSBuildProjectDirectory)\$(AppConfig)">
</Verifier>
</Target>
</Project>
The UsingTask element identifies the type which is instanciated by the MSBuild at build time. This class AppConfigVerifier.MSBuild.Verifier extends Microsoft.Build.Utilities.Task, and overrides a method called Execute; which serves as the entry point to do our type verification.
Notice that we specify the custom task in the VerifyAppConfig using the shortened “Verifier”. This is shorthand for the full type name AppConfigVerifier.MSBuild.Verifier. If we had multiple tasks called Verifier, we would obviously have to use the full task name in our targets to avoid collisions.
The contents of the Verifier element allow us to pass information to our custom task at build time. This includes the list of reference paths, the default reference path, and the name of the config file to be verified.
The Verifier’s execute method is the entry point for our custom task and is shown in the following excerpt:
public class Verifier : Task
{
bool hasError;
public ITaskItem[] References
{
set
{
references = value.Select(item => item.ItemSpec).ToArray();
}
}
string[] references = new string[0];
public ITaskItem[] DefaultReferences
{
set
{
defaultReferences = value.Select(item => item.ItemSpec).ToArray();
}
}
string[] defaultReferences = new string[0];
[Required]
public ITaskItem Config
{
set
{
configFile = value.ItemSpec;
}
}
string configFile;
public override bool Execute()
{
if (!File.Exists(configFile))
{
LogError(configFile + " not found");
return false;
}
string content;
try
{
content = File.ReadAllText(configFile);
}
catch (SecurityException ex)
{
Log.LogError("Security error when accessing "
+ configFile + " : " + ex.Message);
return false;
}
catch (IOException ex)
{
Log.LogError("Read error when accessing "
+ configFile + " : " + ex.Message);
return false;
}
var global = new GlobalContext(content);
List listDefaultRef = defaultReferences.ToList();
listDefaultRef.Add(typeof(String).Assembly.Location); var creator = new InclusionExclusionRuleSetCreator(references, listDefaultRef.ToArray());
var scanner = new ConfigScanner();
global.AddScanner(scanner);
global.AddRuleSetCreator(creator);
global.ErrorReceived += (sender, cont, startLocation, endLocation, message) =>
{
hasError = true;
Log.LogError(null, null, null, configFile, startLocation.Line + 1,
startLocation.Column + 1, endLocation.Line + 1,
endLocation.Column + 1, message);
};
global.WarningReceived += (sender, cont, startLocation, endLocation, message)
=> Log.LogWarning(null, null, null, configFile, startLocation.Line + 1,
startLocation.Column + 1, endLocation.Line + 1, endLocation.Column + 1, message);
global.Run();
return !hasError;
}
void LogError(string message)
{
hasError = true;
Log.LogError(message);
}
}
}
Here we perform some rudimentary validation of the task data. Following this the Verifier creates a new instance of GlobalContext.
Figure: The GlobalContext and associated types.
The GlobalContext allows us to associate instances used for identifying type strings, called Scanners, and instances for creating sets of rules that are used to determine if a type string is valid, known as RuleSetCreators. The process can be summed up as follows: GlobalContext uses all associated scanners to retrieve the type tokens. Each token is then evaluated against the rules provided by the RuleSetCreators. Rules raise events that trigger the GlobalContext to report that a TypeToken is invalid.
In code it looks like this:
public void Run()
{
string content = this.content;
IEnumerable<TypeToken> tokens = scanners.SelectMany(
scanner => scanner.Scan(content)).Distinct(new TokenComparer());
var globalInfo = new GlobalInfo(this.content);
globalInfo.Log.ErrorReceived += globalInfo_ErrorReceived;
globalInfo.Log.WarningReceived += globalInfo_WarningReceived;
try
{
foreach (TypeToken token in tokens)
{
var localContext = new LocalContext(token, globalInfo);
IEnumerable<Rule> ruleSet = creators.SelectMany(
creator => creator.CreateRules(localContext));
foreach (Rule rule in ruleSet)
{
rule.Apply(localContext);
}
}
}
finally
{
globalInfo.Log.ErrorReceived -= globalInfo_ErrorReceived;
globalInfo.Log.WarningReceived -= globalInfo_WarningReceived;
}
}
When applying a rule to a TypeToken, if the TypeToken is deemed invalid then an ErrorReceived event will be raised. The GlobalContext uses the Microsoft.Build.Utilities.TaskLoggingHelper to push the build error to the user. Visual Studio listens to the logging system of MSBuild, so that when an error is logged it shows up in the Visual Studio Error List window. This can be seen in the following excerpt from the Verifier class:
global.ErrorReceived += (sender, cont, startLocation, endLocation, message) =>
{
hasError = true;
Log.LogError(null, null, null, configFile, startLocation.Line + 1,
startLocation.Column + 1, endLocation.Line + 1,
endLocation.Column + 1, message);
};
Scanning
In order to locate the type string segments in a config file we use a ConfigScanner. This class simply uses a regular expression ("type=\"([^\"]+)\"" ) to locate type strings. For each match identified a TypeToken is instanciated. TypeToken’s hold the actual type string and the location of the string in the file. This way, we are able to report to the user the line position of a verification failure.
public override IEnumerable<TypeToken> Scan(string content)
{
var matches = Regex.Matches(content, "type=\"([^\"]+)\"").OfType<Match>();
var typeTokens = matches.Select(
m => new TypeToken(Location.GetLocation(m.Groups[1].Index, content), m.Groups[1].Value.Trim()));
return typeTokens.AsEnumerable();
}
The Verifier custom task is designed with extendibility in mind. Scanners can be created in order to perform other verification tasks.
Figure: ConfigScanner find type string.
Rules
Out of the box, AppConfigVerifier comes with a single implementation of RuleSetCreator called InclusionExclusionRuleSetCreator. Its function is to look for TypeVerification tokens, in particular “Include”, “IncludeAssembly”, “Exclude”, and “ExcludeAssembly”. These tokens serve to override the default behaviour of the AppConfigVerifier by allowing us to specify overrides for types and assemblies.
Figure: InclusionExclusionRuleSetCreator
TypeVerification definitions that are placed in an app.config can be redefined, such that subsequent type strings will be validated in a different way depending on preceding TypeVerification rules in the app.config file. To achieve this, a RuleTokenComparer is used to compare the locations of RuleTokens.
Figure: Rules decide if a type string is validated.
Overriding Default Behaviour
The default behaviour of the AppConfigVerifier is to ensure that all type strings can be resolved at build time. But obviously we require the means to specify type names that, for whatever reason, are not resolvable at build time. The way we do this is to use XML comments of the format:
<!---->
When a type name string is specified as just the namespace qualified type name, it is presumed that the type resides in the app.config’s project or in mscorlib. This is mostly to avoid scanning all referenced assemblies looking for the type, which could significantly slow down the build process. In such cases where you wish or must use the short form, add a TypeVerification Exclude definition for the type as demonstrated in the following excerpt:
<!---->
The following is an excerpt from the demonstration project AppConfigVerifier.MSBuild.Test. It shows various valid and invalid type references.
<configuration>
<configSections>
<section name="name" type="System.String, mscorlib"/>
<section name="name2" type="System.Text.RegularExpressions.Regex, System"/>
<section name="name4" type="System.Text.RegularExpressions.Regex2, System"/>
-->
<section name="name3" type="Foo.BahType, Foo"/>
<section name="name5" type="System.Int32"/>
<section name="name5" type="System.Int33"/>
<section name="name5" type="AppConfigVerifier.MSBuild.Test.Class1"/>
</configSections>
</configuration>
At build time we see that those types that are unresolvable are displayed as build errors.
Figure: Types that are unable to be resolved at compile time are shown as build errors.
As with syntax errors for example, we are able to navigate directly to the line in the config file by double clicking on the error in the error list.
Resolving Types at Build Time
During a build, we create a temporary AppDomain in order to resolve the type names present in the app.config file. This is performed by the AssemblyLoader class. In the later stages of development of this project, the authors experienced some peculiar behaviour with Visual Studio shortly after a build had taken place. To resolve types without loading them into the current AppDomain it is common practice to subscribe from the AppDomain.CurrentDomain.AssemblyResolve event, and use a secondary AppDomain to resolve the types. The following excerpt demonstrates this:
public class AssemblyLoader : IDisposable
{
class CrossDomainData : MarshalByRefObject
{
public bool HasType(string path, string typeName)
{
Assembly typeAssembly = Assembly.LoadFrom(path);
return typeAssembly.GetType(typeName, false, false) != null;
}
}
readonly AppDomain subDomain;
public AssemblyLoader()
{
var appDomainSetup = new AppDomainSetup
{
ApplicationBase = Path.GetDirectoryName(
GetType().Assembly.Location)
};
subDomain = AppDomain.CreateDomain(Guid.NewGuid().ToString(), null, appDomainSetup);
AppDomain.CurrentDomain.AssemblyResolve += subDomain_AssemblyResolve;
}
Assembly subDomain_AssemblyResolve(object sender, ResolveEventArgs args)
{
return Assembly.Load(args.Name);
}
#region IDisposable Members
volatile bool disposed;
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
~AssemblyLoader()
{
Dispose(false);
}
void Dispose(bool disposing)
{
if (!disposed)
{
disposed = true;
if (!disposing)
{
}
AppDomain.Unload(subDomain);
AppDomain.CurrentDomain.AssemblyResolve -= subDomain_AssemblyResolve;
}
}
#endregion
public bool HasType(string assemblyPath, string typeName)
{
if (disposed)
{
throw new InvalidOperationException("AssemblyLoader already disposed");
}
if (typeName == null)
{
throw new ArgumentNullException("typeName");
}
var crossData = (CrossDomainData)subDomain.CreateInstanceAndUnwrap(
GetType().Assembly.FullName, typeof(CrossDomainData).FullName);
return crossData.HasType(assemblyPath, typeName);
}
}
As it turns out, by failing to unsubscribe from the AssemblyResolve event, it placed Visual Studio into a volatile state, causing it to crash unpredictably.
Unit Tests for the Custom Task
Various parts of the custom task were very amenable to unit testing, because they could be isolated with little effort. Here are the test results shown in Visual Studio.
Figure: Unit Test Results
Known Limitations
The app.config is not converted to an xml document representation, such as an XDocument. Because of this, XML comments are ignored for type names. So, even if you comment a section that includes type strings, AppConfigVerifier will still raise a build error if the types cannot be resolved.
When a type string does not contain the assembly name, then it is presumed to reside in either the app.config’s project or in mscorlib.
As mentioned in this article, it is to avoid scanning all assemblies in the bin, which could significantly slow down the build process.
Conclusion
This article described how type strings present in app.config files are able to be verified using a custom MSBuild task.
This allows us to avoid many failures that ordinarily result from unresolvable types at runtime. We have looked at creating a custom task, and how we are able to easily consume it by hosting it in its own project.
By extending the build process with custom MSBuild tasks, we are able to perform pre and post build verification of our projects. This offers us a tremendous opportunity to improve the robustness of our projects.
We hope you find this project useful. If so, then we’d appreciate it if you would rate it and/or leave feedback below.
Further Reading
History
October 2009