In the previous chapter, we've seen how our C# code gets converted to the Common Intermediate Language (CIL), often called Intermediate Language (IL). I promised we'd use the advantage of the fact that IL exists as a step between our C# code and the machine code interpreted by our computer. So, let's do it, and let's take a look at the technique behind it called "IL weaving".
IL Weaving
IL weaving is a technique used in .NET programming that allows us to modify the IL code of an application during or after compilation. You can think of it as "stitching" new behaviors into the existing code.
Imagine you're writing a book. You've written all the chapters, but then you decide to add footnotes throughout the book. Instead of manually going through every chapter and inserting footnotes, you use a tool that automatically adds them wherever needed.
IL weaving works similarly. You have your application, which is a set of instructions (the IL code) that our Just-In-Time (JIT) compiler understands, and you decide you want to add some additional behaviors (like logging, error handling, etc.). Instead of going through your application code and manually adding these behaviors wherever necessary, you can use IL weaving to automatically "weave" these additional behaviors into your existing code.
How it's done?
The high-level step-by-step process would look like this:
- Identify the additional behaviors: First, you must identify the additional behaviors you want to add to your application. As previously mentioned, these could be anything like logging, error handling, performance monitoring, etc.
- Write the additional behaviors: You then write these additional behaviors as separate methods or classes. These are called aspects.
- Choose an IL Weaving tool: While you can do it manually by injecting commands into
.il
files you get by disassembling yourDLL
assemblies, I don't recommend doing this. Several higher-level tools are available for IL weaving, like Fody or PostSharp, allowing you to indicate where IL weaving should happen in your C# code without messing around with.il
files manually. Therefore I'd recommend you look into those if you want to apply this technique. - Configure the tool: Most IL weaving tools require some configuration. You need to tell the tool where your aspects are and where and when you want these aspects to be applied.
- Weave the aspects into your code: You run the IL weaving tool once everything is set up. The tool will take your compiled IL code and "weave" your aspects into it. This could happen during the build process (compile-time weaving) or when the application runs (run-time weaving).
- Test your application: Finally, you must test your application to ensure that the aspects are correctly woven into your code and working as expected.
Manual IL Weaving
Even though I don't recommend doing it this way because it's too complex, requires you to know IL commands very well, and is not very practical, I have to show you how it can be done using just tools available in the .NET SDK.
- Compile your code normally: Compile your C# (or other .NET language) code into a .NET assembly (either
DLL
orEXE
) using the standard .NET compiler. - Disassemble the assembly: Use the IL Disassembler (ILDASM) tool to convert the assembly into IL code. You should be familiar with ILDASM from the previous chapter. If not, take a look there. The following command disassembles an assembly:
ildasm /all /out:MyProgram.il MyProgram.dll
- Modify the IL code: After disassembling, you will have a
.il
file to edit using any text editor. This is where you can inject your own IL instructions. - Reassemble the modified IL code: After injecting your additional code into the IL, you can then reassemble the IL code back into an assembly. This can be done using the IL Assembler (ILASM) tool, which also comes with the .NET SDK:
ilasm /dll /output:MyProgram.dll MyProgram.il
That basically sums up the manual process of doing this. If you're considering not using any of the libraries I've mentioned earlier, I'd advise you to at least consider libraries like Mono.Cecil or dnlib which offer a more programmable way to manipulate IL code. These libraries provide an API to read and write IL code, allowing you to automate the process of weaving code into your assemblies rather than manually disassembling and assembling them back again after introducing changes. That being said, let's see Mono.Cecil
in action.
The Demo
Let's say we have a simple console application in the Dotnet.Interpreter
project that contains a single static method that calculates the sum of numbers in the given range with the boundaries included.
namespace Dotnet.Interpreter;
public static class Program
{
public static void Main()
{
int sum = SumInRange(2, 7);
Console.WriteLine($"The sum is {sum}");
}
/// <summary>
/// Sums up numbers in the range. Range boundaries inclusive.
/// </summary>
/// <param name="from">Range start.</param>
/// <param name="to">Range end.</param>
/// <returns>Sum of the numbers in range.</returns>
public static int SumInRange(int from, int to)
=> Enumerable.Range(from, to - from + 1).Sum();
}
As expected, it just sums up that hardcoded range of numbers and outputs the calculated sum on the console standard output.
Now, let's say we want to inject something really simple as the output on the console when the method execution starts and when it finishes without modifying our original code.
To do so, we'll create a separate project in our solution called Dotnet.Iljector
containing the custom MS Build task definition in the ILWeaverTask
which we can attach to the solution AfterBuild
event to avoid executing the IL injection logic manually after the solution has been built each time.
In its .csproj
, we'll add a reference to Microsoft.Build.Utilities.Code
package that contains all we need for a custom MS Build task definition. In addition to it, we'll add the Mono.Cecil
package that will help us programmatically manipulate IL code using C# syntax.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Build.Utilities.Core" Version="17.6.3" />
<PackageReference Include="Mono.Cecil" Version="0.11.5" />
</ItemGroup>
<Target Name="AfterBuild" AfterTargets="Build">
<Copy
SourceFiles="$(NuGetPackageRoot)\mono.cecil\0.11.5\lib\netstandard2.0\Mono.Cecil.dll"
DestinationFolder="$(OutDir)"
SkipUnchangedFiles="true" />
</Target>
</Project>
The next thing is our custom MS Build task definition, which must inherit from the Task
coming from the Microsoft.Build.Utilities
, not to be confused with System.Threading.Tasks.Task
. It's definition is as follows:
namespace Dotnet.Iljector
{
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using Mono.Cecil;
using Mono.Cecil.Cil;
public class ILWeavingTask : Task
{
[Required]
public string AssemblyPath { get; set; }
public override bool Execute()
{
var assembly = ReadAssembly(AssemblyPath);
var programType = assembly.MainModule.GetType("Dotnet.Interpreter.Program");
if (programType == null)
{
Log.LogError("Failed to find type 'Program'");
return false;
}
var sumInRangeMethod = programType.Methods.Single(m => m.Name == "SumInRange");
if (sumInRangeMethod == null)
{
Log.LogError("Failed to find method 'SumInRange' in type 'Program'");
return false;
}
var consoleWriteLineRef = ImportConsoleWriteLineMethod(assembly);
InsertInstructions(
sumInRangeMethod,
consoleWriteLineRef,
"Starting SumInRange method execution...",
"SumInRange method execution finished!");
assembly.Write(AssemblyPath);
return true;
}
private AssemblyDefinition ReadAssembly(string assemblyPath)
{
var resolver = new DefaultAssemblyResolver();
resolver.AddSearchDirectory(Path.GetDirectoryName(assemblyPath));
resolver.AddSearchDirectory(Path.GetDirectoryName(typeof(object).Assembly.Location));
var parameters = new ReaderParameters { AssemblyResolver = resolver };
return AssemblyDefinition.ReadAssembly(assemblyPath, parameters);
}
private MethodReference ImportConsoleWriteLineMethod(AssemblyDefinition assembly)
{
var consoleType = assembly.MainModule.ImportReference(typeof(Console));
var consoleWriteLineMethod = new MethodReference(
"WriteLine",
assembly.MainModule.TypeSystem.Void,
consoleType)
{
HasThis = false,
CallingConvention = MethodCallingConvention.Default,
Parameters = { new ParameterDefinition(assembly.MainModule.TypeSystem.String) },
};
return assembly.MainModule.ImportReference(consoleWriteLineMethod);
}
private void InsertInstructions(
MethodDefinition method,
MethodReference methodRef,
string startMessage,
string endMessage)
{
var processor = method.Body.GetILProcessor();
var startInstructions = new List<Instruction>
{
processor.Create(OpCodes.Ldstr, startMessage),
processor.Create(OpCodes.Call, methodRef)
};
var endInstructions = new List<Instruction>
{
processor.Create(OpCodes.Ldstr, endMessage),
processor.Create(OpCodes.Call, methodRef)
};
var firstInstruction = method.Body.Instructions.First();
foreach (var instruction in startInstructions)
{
processor.InsertBefore(firstInstruction, instruction);
}
var lastInstruction = method.Body.Instructions.Last();
foreach (var instruction in endInstructions)
{
processor.InsertBefore(lastInstruction, instruction);
}
}
}
}
I won't explain it line by line, but it essentially loads the Dotnet.Interceptor
, searches for the SumInRange
method we want to enrich, and adds Console.WriteLine
statement at the method start and the method end.
Finally, we need to modify the original Dotnet.Interpreter
project to use this ILWeavingTask
and execute its logic after the solution has been built to modify the IL code in the compiled DLL
files.
<UsingTask
TaskName="Dotnet.Iljector.ILWeavingTask"
AssemblyFile="..\Dotnet.Iljector\bin\Debug\netstandard2.0\Dotnet.Iljector.dll" />
<Target Name="AfterBuild" AfterTargets="Build">
<ILWeavingTask AssemblyPath="$(TargetPath)" />
</Target>
All we have to do now is to rebuild our solution! The picture below shows the IL code before and after our custom injection. You can clearly see our custom console output added at the method execution start and end.
And the output on the console windows is displayed in the picture below:
The method execution starts and finishes, and then we proceed with the rest of the Main
method, which outputs the sum on the standard output as it used to.
Conclusion
Even though this sample was heavily coupled with our simple calculation method implementation, it's possible to write the generic code similarly, which could be reusable and applicable to any targeted method in any other project. However, I wanted to keep it simple as possible.
IL weaving is a powerful tool and the main engine behind aspect-oriented programming in C#, but it should be used wisely, like any tool. While it can help keep your code clean and DRY (Don't Repeat Yourself), it can also add a level of complexity and opacity to your code, making it harder to understand and debug. Therefore, I recommend using IL weaving reasonably and always in combination with good testing practices.
I haven't seen it utilized on many projects I've worked on, but I did encounter it, mostly in combination with the PostSharp library and mostly for logging and caching mechanisms, but there are other ways to deal with the same problem in many different ways. One of the ways to tackle the logging around your method execution without changing an implementation in different classes is through a proxy pattern, which I described in one of the previous chapters.
Anyway, libraries like PostSharp or Fody operate on a way higher level than what we dealt with in this example and will make your life much easier if you ever encounter an aspect-oriented programming approach to the project you're working on.
I hope you found this information useful. If something is unclear, feel free to leave the comments below.