Kun ilmenee tarve tehdä muutoksia toimivaan koodiin esimerkiksi suorituskyvyn tai refaktoroinnin merkeissä, usein ensimmäinen ajatus on hypätä suoraan muokkaamaan olemassa olevaa koodia. Tavoite on suorituskykyisemmän tai helpommin ylläpidettävän koodin lisäksi luonnollisesti säilyttää toimivuus. Usein toimivuuden toteaminen on kirjoitettujen testien ja mahdollisesti käyttäjätestauksen varassa. Testikattavuus on harvoin täydellinen ja refaktorointia vailla oleva koodi voi olla lähtökohtaisesti huonosti testattu. Mitä kriittisempi ominaisuus, sitä tärkeämpää on varmistaa, että uudistettu toteutus antaa toiminnallisuudeltaan samat tulokset kuin alkuperäinen.

Rinnakkaisen toteutuksen idea on suorittaa sama toiminnallisuus kahdella eri kooditoteutuksella ja verrata tuloksia. Alkuperäinen toteutus jätetään mieluiten koskemattomaksi, jolloin se toimii referenssinä, johon uuden toteutuksen tuloksia verrataan. Kutsut vanhaan toteutukseen muutetaan muotoon, jossa kutsutaankin sekä alkuperäistä että uutta toteutusta. Kutsujen jälkeen verrataan tuloksia sekä tarvittaessa esimerkiksi suoritusaikaa tai muistinkäyttöä. Tässä vaiheessa uuden toiminnallisuuden tulosta ei käytetä vielä ohjelman muussa suorituksessa.

Alkuperäisen ja uuden toteutuksen paluuarvoja verrataan keskenään. Alkuperäisen toteutuksen paluuarvo palautuu ohjelman suoritukseen ja vertailun tulokset raportoidaan.

Alkuperäisen ja uuden toteutuksen paluuarvoja verrataan keskenään. Alkuperäisen toteutuksen paluuarvo palautuu ohjelman suoritukseen ja vertailun tulokset raportoidaan.

Poikkeavuudet toiminnassa voidaan kehitysvaiheessa käsitellä vaikkapa ohjelman kaatavana poikkeuksena. Tuotannossa poikkeavuudet voidaan kirjata ylös ja analysoida myöhemmin. Rinnakkaisen toteutuksen vahvuus tulee kunnolla ilmi vasta, kun koodi on ollut tuotantokäytössä riittävän pitkään, jolloin saadaan kerättyä mahdollisimman kattavasti statistiikkaa uuden toteutuksen toimivuudesta tai virheistä. Kun ollaan varmoja uuden toteutuksen toimivuudesta, puretaan vertailukoodi ja poistetaan alkuperäisen toteutuksen koodi.

Huomioitavaa rinnakkaisista toteutuksista

  • Toteutuksien tulee olla sivuvaikutuksettomia
  • Kaksi toteutusta lisää suoritettavaa työtä, jolloin kokonaissuorituskyky voi tippua
  • Rinnakkaisten toteutuksien elinkaaren aikana myös ylläpitotarve monistuu toiminnallisuuden muuttuessa

Jotta kahta toteutusta voidaan verrata, tulee toteutuksien olla sivuvaikutuksettomia. Jos esimerkiksi verrattava toiminnallisuus tarvitsee dataa tietokannasta, ei toinen toteutus saa mennä muuttamaan tätä dataa. Ensimmäinen vaihe rinnakkaisen toteutuksen käyttöönotossa voikin olla vanhan toteutuksen muokkaus niin, että se ei vaikuta vaihtoehtoisen toteutuksen tuloksiin. Helpoiten tekniikka soveltuukin toiminnallisuuteen, jossa vain luetaan tietoa esimerkiksi tietokannasta.

Mikäli vanhaan toteutukseen ei uskalleta koskea, tai sivuvaikutuksien minimointi on liian työlästä, on tapauksesta riippuen mahdollista tehdä uusi toteutus täysin ilman sivuvaikutuksia ja suorittaa se ennen ohjelman tilaa tai dataa muuttavaa kutsua alkuperäiseen toteutukseen. Tässä on hyvä huomioida, että jotkut työkalut vaihtelevat automaattisesti verrattavien toteutuksien suoritusjärjestystä, jolloin tällainen kikka ei toimi.

Kun rinnakkainen toteutus on tehty ja julkaistu tuotantoon keräämään statistiikkaa toimivuudesta, ei ole poissuljettua, etteikö kyseiseen toiminnallisuuteen tule muutostarpeita. Jos vielä ei uskalla jättää ohjelmaa toimimaan uuden toteutuksen varaan, tulee muutos toiminnallisuuteen toteuttaa sekä alkuperäiseen että uuteen toteutukseen. Työmäärä on sitä suurempi, mitä enemmän toteutukset eroavat toisistaan. Jos toiminnallisuusmuutoksen yksityiskohdissa on joustovaraa, on punnittava suosiiko toteutuksessa tapaa, joka on helposti tehtävissä uuteen toteutukseen vai jatkaako alkuperäisen ehdoilla. Valinta voikin mielikuvan tasolla pyöräyttää uuden toteutuksen muuttuneen toiminnallisuuden referenssiksi.

Apuväline vertailuun: Scientist

GitHub on kehittänyt Ruby-ohjelmointikielelle Scientist-kirjaston helpottamaan rinnakkaisten toteutuksien suoritusta ja vertailua. Sen ominaisuuksia ovat muun muassa vertailtavan datan valinta, vertailun ehdollistaminen ja tulosten julkistaminen. Kirjastosta on tehty myös C#-kielelle pari porttausta: NScientist ja Scientist.NET, joista ensin mainittua käytetään seuraavissa esimerkeissä.

Seuraavassa listauksessa esitetään yksinkertainen esimerkki kahden rinnakkaisen toteutuksen suorittamiseen NScientist-kirjaston avulla. Esimerkissä alkuperäinen Calculate-metodi on nimetty uudelleen metodiksi CalculateOriginal ja korvattu toteutuksella, joka kutsuu sekä alkuperäistä että uutta rinnakkaista toteutusta CalculateNew:

public int Calculate(int a, int b)
{
    return Experiment
        .On(() => CalculateOriginal(a, b)) //Alkuperäinen toteutus
        .Try(() => CalculateNew(a, b)) // Uusi toteutus
        .Run(); //Palauttaa "On"-lohkon tuloksen
}

NScientist: ominaisuuksia ja käsitteitä

Peruskäsitteistö:

  • `On()`-lohkossa suoritetaan alkuperäinen toteutus, jonka tulosta kutsutaan control-arvoksi
  • `Try()`-lohkossa suoritetaan uusi toteutus, jonka tulos on trial-arvo

Ominaisuuksia:

  • `Try()`-lohkon suoritus voidaan ehdollistaa ja sen tuloksen raportointi voidaan ohittaa tunnetuissa virhetapauksissa
  • `Try()` nielee ja tallettaa mahdolliset poikkeukset suoritettavassa uudessa koodissa
  • Lohkojen suoritusjärjestys vaihtelee eri ajokerroilla
  • Lohkojen tuloksia verrataan oletuksena `object.Equals(control, trial)`, jonka voi ylikirjoittaa omalla vertailufunktiolla
  • Tulokset julkaistaan `Publish`-metodin kautta
  • Mahdollisuus ajaa molemmat toteutukset rinnakkain
  • Kummankin lohkon suoritusaika talletetaan

Tuloksien käsittely ja julkaisu

Suorituskerran tulokset tallentuvat NScientist.Results-luokan instanssiin. Publish-metodille annetaan joko Results-olion käsittelevä metodi tai NScientist.IPublisher-rajapinnnan toteutus.

Esimerkissä annetaan kokeilulle nimi Calculate, kontekstiin syötearvot sekä ohjataan tulokset käsiteltäväksi ResultLogger-metodille:

public int Calculate(int a, int b)
{
    return Experiment
        .On(() => CalculateOriginal(a, b)) //Alkuperäinen toteutus
        .Try(() => CalculateNew(a, b)) // Uusi toteutus
        
        //Kokeiltavan toiminnallisuuden nimi näkyy tuloksissa                    
        .Called("Calculate")
        
        //Kontekstiin voi lisätä tuloksen kannalta merkittäviä tietoja kuten syötteen
        .Context(() => new Dictionary<object, object> {
            { "a", a}, {"b", b} 
        })
        
        //Raportointi annetun funktion kautta
        .Publish(ResultLogger)
        
        .Run();
}

private void ResultLogger(Results results)
{
    Log("Experiment name: {0}, Control result: {1}, Trial result: {2}, Matched: {3}, context: {4}",
        results.Name, results.Control.Result, results.Trial.Result,
        results.Matched, string.Join(", ", results.Context));
}

Esimerkkituloste syötteellä a = 1 ja b = 1:

Experiment name: Calculate, Control result: 2, Trial result: 2, Matched: True, context: [a, 1], [b, 1]

Tuloksesta saa esiin myös tarkempaa tietoa erikseen sekä control– että trial-tuloksen osalta: funktion paluuarvon, suoritusajan, oliko trial-lohko suoritettu vai ohitettu sekä mahdollinen nielaistu poikkeus:

Control:
  Result: 2
  Duration: 00:00:00.0003136
  Ignored: False
  Exception:
Trial:
  Result: 2
  Duration: 00:00:00.0002266
  Ignored: False // Oliko vertailu ohitettu
  Exception: // Suorituksen aikana talletettu poikkeus

Tulokset voidaan käsitellä myös IPublisher-toteutuksella, josta kattavampi esimerkki NScientistin sivulla. Tuloksien jatkojalostus mahdollistaa esimerkiksi funktioiden suoritusaikojen ja virheiden lukumäärän graafisen havainnollistamisen.

Vertailufunktio ja muita ominaisuuksia

Oma vertailufunktio on mahdollista kirjoittaa CompareWith-lohkolla:

return Experiment
        .On(() => CalculateOriginal(a, b)) //Alkuperäinen toteutus
        .Try(() => CalculateNew(a, b)) // Uusi toteutus

        // Vertailufunktio saa alkuperäisen ja uuden toteutuksen paluuarvot
        .CompareWith((control, trial) => control == trial)
        //...

Tietyissä tapauksissa voidaan haluta ehdollistaa uuden toteutuksen suoritus esimerkiksi suorituskykysyistä pitämällä silti alkuperäinen toteutus toiminnassa. Tässä esimerkiksi uusi toteutus suoritetaan vain syötteellä a < 100:

        //...
        // Asettaa ehdon, milloin _uusi_ toteutus suoritetaan.
        // Alkuperäinen toteutus suoritetaan aina.
        .Enabled(() => a < 100)

Tiedetyt virheelliset tulokset voidaan suodattaa pois tuloksista Ignore-lohkolla. Tämä helpottaa huomion keskittämisen vielä tuntemattomiin virhetapauksiin:

        //...        
        // Tiedossa olevat virheeliset tapaukset voidaan suodattaa pois tuloksista.
        // Alkuperäinen tulos palautetaan aina.
        .Ignore((control, trial) => trial < -100 && b < -100)

Kehitysvaiheessa on usein nopeampaa puuttua virheisiin poikkeuksella:

        
        //...
        // Heittää poikkeuksen, jos tulokset eroavat. Myös Ignoren ollessa voimassa.
        .ThrowMismatches()

Oletuksena alkuperäinen ja uusi toteutus ajetaan sarjallisesti. Rinnakkaiset toteutukset on kuitenkin mahdollista suorittaa -yllättäen- myös rinnakkain!

    
        //...   
        // Molemmat toteutukset suoritetaan samanaikaisesti
        .Parallel()
        .Run();

NScientist-kirjasto näyttää olevan toistaiseksi aktiivisen kehityksen alla. Tuorein lisätty ominaisuus on SwitchToTrial, joka vaihtaa muun ohjelman suoritukseen palaavan arvon alkuperäisestä uuteen.

Kannattaa tutustua GitHubin blogipostaukseen aiheesta Scientist: Measure Twice, Cut Over Once.

KEEP CALM AND REFACTOR (http://www.keepcalm-o-matic.co.uk/p/keep-calm-and-refactor-81/)