Einar bedriver svartekunst [Luke 3, 2012]


mandag 3. desember 2012 C# Julekalender CIL AOP

Einar W. Høst (@einarwh) er en hyggelig utvikler jeg møtte første gang på ROOTS-konferansen i Bergen. Han var også en av tre helsprø Computas-utviklere som blant annet holdt på å skade en stakkars publikummer på en av NDC 2012's mest underholdende forelesninger. Jeg visste jeg ville få et solid og spennende bidrag da jeg spurte Einar om han ville bidra til julekalenderen i år.

einarwh-600x600

Hvem er du?
Eventyrlysten akademiker som utforsker den praktiske utviklerverdenen.

Hva er jobben din?
Overingeniør og leder for Software Engineering-fagnettverket i Computas.

Hva kan du?
Programmering som håndverk og vitenskap.

Hva liker du best med yrket ditt?
Oscilleringen mellom å føle seg som en idiot og et geni.

Her følger Einars post om IL-manipulering...


IL Tempo Gigante

Lurer du på hvor lang tid de ulike enhetstestene dine bruker på å kjøre? Ikke? Vel, la oss i så fall late som om du gjør det. Vi kan til og med tenke oss at du bruker et enhetstestrammeverk til å kjøre integrasjonstestene dine, og da begynner det jo faktisk å bli litt interessant å vite hvor lang tid ting tar. La gå at integrasjonstester ikke er ytelsestester, men vi kan jo bruke dem som det likevel.

Så hvis vi antar at det er interessant å få vite hvor lang tid testene bruker på å kjøre, hvordan kan vi gjøre det på en enkel og ikke-invaderende måte? I denne bloggposten skal vi gjøre det fullstendig ortogonalt og i det skjulte - vi skal faktisk ikke røre koden i det hele tatt. Det vil si, det skal vi så klart - men ikke kildekoden. I stedet skal vi bruke bytekodemanipulasjon for å trylle litt. Bytekodemanipulasjon vil si at vi gjør ting på IL-nivå, etter at testene er kompilert.

Alle .NET-programmerere vet at C# og VB.NET kompileres til IL, som videre kompileres til maskinkode ved behov, såkalt JIT-kompilering. Sånn sett er IL er det egentlige .NET-språket! Likevel er det få som noensinne kikker på IL-koden, og enda færre som finner på å endre på den. Men det er synd, for det er både enkelt og nyttig (for eksempel når man ønsker å gjøre akkurat det samme ørten forskjellige steder i koden uten å skitne til kildekoden med masse repetisjon). Og nå som C# går en usikker fremtid i møte, er det en glimrende anledning til å lære seg litt IL. Jeg vet ikke med deg, men jeg skriver heller IL for hånd enn å gå over til VB.NET!

IL er ikke vanskelig, men det er naturligvis annerledes enn C#. Det første man må venne seg til er at det er et stack-basert språk. En stack er det vi på godt norsk kaller en stabel. Det er to ting man kan gjøre med en stabel: legge en ny ting på toppen, eller ta av den øverste tingen som allerede er der. (Man kan forsåvidt også i mange tilfeller studere den øverste tingen uten å fjerne den.) Yrkesskadd som jeg er, har jeg det med å tenke "stack" hver gang jeg kommer inn i kantinen og får se stablene med tallerkner. Jeg tenker sågar "throw new StackEmptyException()" dersom en av stablene er tomme, men det er ikke min skyld - det skjer helt av seg selv!

Uansett, IL definerer et sett med bytekodeinstruksjoner som gjør ting med stacken. Generelt sett vil en bytekodeinstruksjon "poppe" y elementer fra stacken, gjøre noe med dem, og "pushe" y elementer tilbake på stacken. Et eksempel er add-instruksjonen som "popper" to verdier fra stacken, legger dem sammen, og "pusher" resultatet tilbake på stacken. Sånn sett fungerer stack-baserte språk ved at man gjør ting i postfix rekkefølge, det vil si at operasjonen kommer etter verdiene det skal opereres på. Det er verdt å merke seg at IL ikke er det eneste stack-baserte språket i verden. Det finnes, hva skal vi si... sluttbrukerorienterte språk som f.eks. Forth og Factor som fungerer på tilsvarende måte.

Når man skal starte et bytekodemanipuleringsprosjekt er det viktig å ha to kodeeksempler i C# tilgjengelig. For det første må man naturligvis ha et utgangspunkt: et enkelt, minimalistisk eksempel på opprinnelige, urørte koden. For det andre må man ha et målbilde: koden slik den ville sett ut dersom vi gjort endringene vi ønsker for hånd - altså uten bytekodemanipulering, bare ved å endre på C#-koden.

I vårt tilfelle er utgangspunktet en meget enkel enhetstest. Her bruker jeg MSTest, men det er naturligvis enkelt å gjøre tilsvarende ting for NUnit eller andre rammeverk. Som du ser har vi en veldig enkel test-klasse med to enhetstester. Det eneste de gjør er å sove.

[TestClass]
public class UnitTest1
{
    [TestMethod]
    public void TestMethod1()
    {
        Thread.Sleep(4300);
    }

    [TestMethod]
    public void TestMethod2()
    {
        Thread.Sleep(2400);
    }
}

Hvordan skal så målbildet vårt se ut? Vel, vi trenger jo å ta tiden. Til det trenger vi en stoppeklokke. Vi må starte stoppeklokken før enhetstesten kjører, og stoppe den etter at testen har kjørt. Vi kunne brukt stoppeklokke direkte i test-metoden, men jeg tror vi heller bruker test-rammeverkets støtte for setup- og teardown-metoder. I MSTest heter det henholdsvis TestInitialize- og TestCleanup-metoder. Vi må altså legge til to metoder og annotere dem med hvert sitt custom attribute. Siden begge disse metodene må ha tilgang til samme stoppeklokke, trenger vi også et felt for selve klokken. Når testen er ferdig må vi på en eller annen måte rapportere hvor lang tid testen brukte på å kjøre. For enkelhets skyld kommer vi bare til å skrive til Trace.

Og så en litt subtil ting: vi må vite navnet på testen vi kjører! Det blir vi nesten nødt til å fange opp mens selve test-metoden kjører og lagre i et felt, slik at vi får det med når vi rapporterer. Men hvordan gjør man egentlig det? Vi er faktisk nødt til å ta i bruk en liten knivsodd reflection. Slik kan det se ut:

[TestClass]
public class UnitTest1
{
    private Stopwatch _stopwatch;
    private string _method;

    [TestInitialize]
    public void StartSecretUnitTestPerformanceTimer()
    {
        _stopwatch = new Stopwatch();
        _stopwatch.Start();
    }

    [TestCleanup]
    public void StopSecretUnitTestPerformanceTimer()
    {
        _stopwatch.Stop();
        var msg = string.Format("{0}.{1}: {2} ms.", 
            GetType().FullName, _method, _stopwatch.ElapsedMilliseconds);
        Trace.WriteLine(msg);
    }

    [TestMethod]
    public void TestMethod1()
    {
        _method = new StackFrame().GetMethod().Name;
        Thread.Sleep(4300);
    }

    [TestMethod]
    public void TestMethod2()
    {
        _method = new StackFrame().GetMethod().Name;
        Thread.Sleep(2400);
    }
}

Så: hvordan går vi nå fram for å transformere det første kodeeksemplet til det andre? (Merk at vi egentlig ikke skal gjøre en C#-til-C#-transformasjon, men en IL-til-IL-transformasjon. Dvs. vi skal ta IL-koden vi får når vi kompilerer det første eksemplet og transformere den til å bli lik IL-koden vi får når vi kompilerer det andre eksemplet. Ingen tukling på C#-nivå.)

For å få til alt dette trenger vi verktøy. Det første verktøyet heter ILSpy, og brukes til å dekompilere .NET-assemblies. Tidligere brukte jeg Lutz Roeders .NET Reflector, men det begynte jo plutselig å koste penger. ILSpy er gratis og fungerer helt fint. Det kan dekompilere både til "disassembled" IL (IL i tekstformat) og til C#. I første omgang er vi mest interessert i tekstlig IL. Ved å studere IL'en som blir generert når de to kodeeksemplene blir kompilert kan vi finne ulikhetene som vi må kompensere for når vi gjør bytekodemanipulasjonen. Senere kan vi dekompilere vår bytekodemanipulerte assembly til C# for å verifisere at vi virkelig har laget kode som er helt lik originalen.

.method public hidebysig 
	instance void StartSecretUnitTestPerformanceTimer () cil managed 
{
	.custom instance void [Microsoft.VisualStudio.QualityTools.UnitTestFramework]Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute::.ctor() = (
		01 00 00 00
	)
	// Method begins at RVA 0x20a0
	// Code size 23 (0x17)
	.maxstack 2

	IL_0000: ldarg.0
	IL_0001: newobj instance void [System]System.Diagnostics.Stopwatch::.ctor()
	IL_0006: stfld class [System]System.Diagnostics.Stopwatch Insoluble.Test.UnitTest1::_secretStopwatchField
	IL_000b: ldarg.0
	IL_000c: ldfld class [System]System.Diagnostics.Stopwatch Insoluble.Test.UnitTest1::_secretStopwatchField
	IL_0011: callvirt instance void [System]System.Diagnostics.Stopwatch::Start()
	IL_0016: ret
} // end of method UnitTest1::StartSecretUnitTestPerformanceTimer

Kodeeksemplet viser den ene av metodene vi må lage: TestInitialize-metoden. Jeg har kalt den StartSecretUnitTestPerformanceTimer, bare for at det skal være usannsynlig at testen allerede har en annen metode med det samme navnet.

Hvis dette er første gang du ser IL: ikke få panikk! Dette er egentlig ganske enkle saker. Det burde være mulig å kjenne igjen metodesignaturen fra det andre kodeeksemplet. (Ikke bry deg om hidebysig, det er en teknisk detalj som ikke har noen betydning for oss.) Den tingen som begynner med .custom er TestInitialize-attributtet. Dybden på stacken er angitt av .maxstack; heller ikke det er spesielt skummelt. Det betyr bare at vi ber .NET om å la oss ha inntil to ting i stabelen vår. Og så kommer det morsomme: selve bytekodeinstruksjonene. Det at det står IL0000 og sånn er bare merkelapper, og ikke spesielt interessant i dette tilfellet. De er mest interessante når man skal lage forgreninger i koden, med andre ord if-setninger og løkker. Vi skal ikke gjøre noe så fancy.

Selve IL-koden er egentlig ganske grei å lese, så lenge man husker på at vi primært bruker stacken snarere enn lokale variable til å holde på verdier. Instruksjonen ldarg.0 betyr at vi "pusher" en referanse til "this" på stacken, altså testklasseinstansen. Videre kaller vi konstruktøren til Stopwatch-typen med newobj-instruksjonen. Da har vi både "this" og en referanse til en Stopwatch-instans på stacken. Når vi så kaller stfld med navnet på et felt, lagrer vi Stopwatch-referansen i feltet. Denne operasjonen "popper" begge verdiene vi hadde på stacken. Deretter "pusher" vi "this" på stacken igjen, leser feltet vi nettopp skrev til med ldfld, og kaller en metode på Stopwatch-instansen med callvirt-instruksjonen. Til slutt gjør vi en eksplitt retur fra metoden med ret.

Ikke spesielt vanskelig, altså. Men hvordan i all verden går man fram for å dytte all denne koden inn i DLL'en vi fikk da vi kompilerte det første eksemplet? Det ville vært en uoverstigelig kjip oppgave om det ikke hadde vært for to supre verktøy. Det viktigste av dem heter Mono.Cecil og er skrevet av en fyr som heter Jb Evain. Mono.Cecil gir deg et API for å manipulere .NET-assemblies. Det andre verktøyet er mer valgfritt, men gjør det enklere å bruke Mono.Cecil integrert i Visual Studio. Det heter Fody, og tilbyr en addin-modell for å gjøre bytekodemanipulasjon med Mono.Cecil enklere. Fody sørger for at bytekodemanipulasjonen din blir kjørt når Visual Studio bygger prosjektet ditt, at Visual Studio ikke låser assembly-filen m.m. Tro meg, alt blir så mye lettere! Når man bruker Fody, følger man en konvensjon der man lager en ModuleWeaver-klasse med bestemte properties og en metode som heter Execute. Fody tar ansvar for å gi inn verdier til propertiene og å kalle Execute.

public class ModuleWeaver
{
    private const string TestClassAttribute = "TestClassAttribute";

    public ModuleDefinition ModuleDefinition { get; set; }
        
    public IAssemblyResolver AssemblyResolver { get; set; }
        
    public void Execute()
    {
        var typeFinder = new TypeFinder(AssemblyResolver, ModuleDefinition);

        var testTypeDefs =
            ModuleDefinition.GetTypes().Where(
                t => t.CustomAttributes.Any(
                    a => a.AttributeType.Name == TestClassAttribute));

        foreach (var typeDef in testTypeDefs)
        {
            new TypeWeaver(typeDef, typeFinder).Execute();
        }
    }
}

Her ser du min ModuleWeaver-klasse. Den gjør ikke så mye, egentlig. Det eneste som skjer, er at jeg plukker ut alle klasser som er annotert med TestClass-attributtet, og itererer over dem. Den virkelige jobben håndteres av en klasse jeg har kalt TypeWeaver. Den bruker også hjelpeklassen TypeFinder for å lette oppslag av typereferanser når vi skal ta i bruk Mono.Cecil litt mer på ordentlig.

TypeWeaver-klassen er stedet hvor IL-manipulasjonen finner sted. Etter mønster fra ModuleWeaver-klassen har den sin egen Execute-metode.

public void Execute()
{
    AddStopWatchField();
    AddTestNameField();
    AddStartStopwatchSetupMethod();
    AddStopStopwatchTeardownMethod();

    foreach (var methodDef in _typeDef.Methods.Where(
                 m => m.CustomAttributes.Any(
                     a => a.AttributeType.Name == TestMethodAttribute)))
    {
        InjectCodeToStoreTestName(methodDef);
    }
}

Som du kan se gjør vi her de ulike tingene vi fant ut at vi måtte gjøre da vi definerte målbildet vårt. Det blir litt i overkant å gjennomgå all koden i detalj, men la oss se på hvordan vi lager TestInitialize-metoden som vi så på tidligere.

private MethodDefinition CreateStartStopwatchSetupMethod()
{
    var methodDef = new MethodDefinition(
        StartTimingMethodName, 
        MethodAttributes.Public | MethodAttributes.HideBySig, 
        _types.Void)
    {
        Body = { MaxStackSize = 8, InitLocals = true }
    };

    var initMethodRef = new MethodReference(".ctor", _types.Void, TestInitializeAttributeTypeRef) 
    { 
        HasThis = true 
    };
    methodDef.CustomAttributes.Add(new CustomAttribute(initMethodRef));

    var il = methodDef.Body.GetILProcessor();
    Action<opcode> op = x => il.Append(il.Create(x));
    Action<opcode   , fieldreference> fop = (x, f) => il.Append(il.Create(x, f));
    Action<opcode   , methodreference> mop = (x, m) => il.Append(il.Create(x, m));

    // Push 'this' reference onto stack.
    op(OpCodes.Ldarg_0);

    // Create StopWatch object and push onto stack.
    mop(OpCodes.Newobj, new MethodReference(".ctor", _types.Void, StopWatchTypeRef) 
    { 
        HasThis = true 
    });

    // Store reference to StopWatch object in field.
    fop(OpCodes.Stfld, _stopWatchFieldRef);

    // Push 'this' reference onto stack.
    op(OpCodes.Ldarg_0);

    // Load reference to StopWatch object from field.
    fop(OpCodes.Ldfld, _stopWatchFieldRef);

    // Invoke Start method on StopWatch.
    mop(OpCodes.Callvirt, new MethodReference("Start", _types.Void, StopWatchTypeRef) 
    { 
        HasThis = true 
    });

    // Return from method.            
    op(OpCodes.Ret);

    return methodDef;
}

Jeg starter med å lage en MethodDefinition-instans, med signatur som matcher den vi så i den dekompilerte IL-koden. Deretter legger vi til annoteringen med TestInitialize, slik at MSTest vil kjøre metoden vår før testen blir kjørt. For å legge til bytekodeinstruksjoner til metodekroppen, bruker vi noe som kalles en ILProcessor. Jeg synes det er litt mye støy for å opprette og legge til instruksjonene, så jeg lager meg en håndfull actions for å gjøre det litt mer kortfattet. Deretter legger jeg bare til de bytekodeinstruksjonene en etter en: legge "this" på stacken, opprette Stopwatch-instans, skrive til felt, legge "this" på stacken igjen, lese fra felt, kalle Start-metoden, ferdig. Man må naturligvis gjøre seg litt kjent med API'et til Mono.Cecil for å få det til, men det er ikke spesielt vanskelig.

Resten av koden er egentlig ganske lik. Bytekodemanipulering er ikke vanskelig når man først har blitt kjent med verktøyene, og åpner opp for en del unike muligheter og løsninger som er vanskelig å få til på andre måter. Dessuten får man den berusende følelsen av å drive med svartekunst, mens man egentlig gjør ganske enkle ting. Dersom du vil se et litt mer ambisiøst eksempel på bytekodemanipulering kan du ta en titt på Silver.Needle, et prosjekt jeg har laget for å lette livet til Silverlight-utviklere som blir sprø av å implementere INotifyPropertyChanged.

Men ja! Funket egentlig dette her? Du får nesten laste ned kildekoden fra https://bitbucket.org/einarwh/tempo og prøve! Innimellom litt øvrig støy i Output-vinduet i Visual Studio dukket følgende linjer opp hos meg:

Tempo.Test.UnitTest1.TestMethod1: 4298 ms
Tempo.Test.UnitTest1.TestMethod2: 2401 ms

Godt nok for meg!


Om du synes dette var spennende så finer du mer av samme kaliber på Einars posterous-blogg. Og følg med i morgen for en ny luke!


comments powered by Disqus