Vue komponenttipuun visualisaatio (plugin)

Vue komponenttipuun visualisaatio

Vueviz osana kehitystyötä kuva 2

Kehitin alkuvuodesta Vue pluginin erääseen tärkeään tarpeeseen. Monissa yhteyksissä oli tarve nähdä Vue-appia kehitettäessä paitsi komponenttipuun rakenne, myös komponenttipuun diff, eli mitkä komponentit ovat juuri päivittyneet tai tulleet luoduiksi.

Tällä tavalla on helpompi varmistaa, että kun klikkaan vaikka nappulaa “Avaa tuotehierarkia”, suunnilleen oikeat komponentit päivittyvät ja oikea setti uusia komponentteja ilmestyy puuhun.

Tarvetta varten kehitin Vueviz pluginin. Se kuuntelee koko appin komponenttipuussa tapahtuvia muutoksia, laskee diffin edelliseen puuversioon nähden, ja printtaa ruudulle tiedot muutoksesta. Tiedot muutoksista kommunikoidaan värikoodeilla; sininen merkkaa päivittynyttä komponenttia, vihreä on uusi komponentti.

Vueviz osana kehitystyötä

Käännä kutsujärjestys mixinin avulla

Vue and Mixins (osa 2): Vaihda kutsujärjestys

Viime viikolla käsittelin blogipostauksessa Vuen mixineitä. Ne mahdollistavat hienoja asioita, kuten useaa komponenttia koskevan koodin abstraktoinnin ikäänkuin yläluokkaan.

Vue mixinin yksi hienoimmista ominaisuuksista on lifecycleen liittyvien hooks-kutsujen kutominen; komponentin oman hookin lisäksi Vuen moottori kutsuu myös mixinin saman nimisen hookin (mikäli sellainen on määritelty).

Lähtökohtaisesti kutsujärjestys on mixin ensin, komponentti sitten.

Tämä tarkoittaa siis, että useaa komponenttia koskeva yhteinen koodi ajetaan ensin, ja komponentin oma spesifi koodi ajetaan jälkeen.

Valittu järjestys on yhtäältä looginen, toisaalta epälooginen.

Yhtäältä järjestys on äärimmäisen looginen. Ajamalla mixinin hooksin ensin saamme tehtyä vakioalustuksen ensin; mikäli komponentti on tyytyväinen vakioalustukseen, all is fine ja komponentin oman hooksin ei tarvitse tehdä mitään. Mikäli komponentti puolestaan tarvitsee “ylhäältä määrätyn” vakioalustuksen lisäksi jotain omaa, se voi turvallisesti määrittää omat data-muuttujansa luottaen siihen, että mahdolliset nimi-konfliktit ratkotaan komponentin omien määritysten hyväksi!

Tämä luottamus toimii juuri siksi, että komponentin oma hook ajetaan viimeisenä, jolloin komponentti voi vapaasti ylikirjoittaa haluamansa data-muuttujan.

Vastaava periaate on keskiössä kaikissa OOP-ohjelmointikielissä. Periaatteen ydinajatus on, että mitä ylemmällä abstraktion tasolla jokin luokkamuuttuja on määritelty, sitä alempi sen prioriteetti on. Eri tasojen eri prioriteetit varmistavat sen, että konfliktit on aina mahdollista ratkaista.

Toisaalta järjestys on epälooginen. Entä jos haluamme luoda mixinin, joka loggaa komponentin alkutilan? Koska mixinin created-hook ajetaan ensin, ei mixinillä ole mitään logattavaa; komponentin oma created-hook ei ole vielä ehtinyt alustaa data-muuttujia haluttuihin alkuarvoihin, joten koko loggaus on yhtä tyhjän kanssa.

Jotta loggaus toimisi oikein, on meidän saatava mixinin hook ajetuksi komponentin hookin jälkeen, ei ennen. Tällä tavalla mixinin created-hook näkee komponentin tilamuuttujan juuri sellaisina kuin mihin komponentin oma created-hook ne alusti.

Ongelmaan on ratkaisu:

Vaihda kutsujärjestystä -mixin


// CreationLogger.js

function privCreated() {
    console.log(JSON.stringify(this.$data)); 
}

export default {
  created: function () {
    setTimeout(privCreated.bind(this), 0);
  }
}

Ratkaisu perustuu siihen, että varsinainen toiminnallisuus siirretään pois mixinin created-hookista. Sen sijaan created-hook - joka ajetaan ennen komponentin created-hookia - kutsuu mixinin privaattia funktiota, joka sisältää varsinaisen business-koodin. Ja oleellista on, että kutsu ajastetaan tehtäväksi Javascriptin event-loopin seuraavalla pyörähdyksellä (tick); tällä tavoin komponentin oman created-hook ehtii ajaa välissä.

Kutsujärjestys ajon aikana siis on:

  1. Mixinin created()
  2. Komponentin created()
  3. Mixinin privCreated()

Mixinin käyttöönotto tapahtuu normaaliin tapaan:


import CreationLogger from '@/mixins/CreationLogger'
import Gen from '@/chess/PositionGenerator'

export default {
  name: 'ChessGame',
  props: ['players'],
  mixins: [CreationLogger],
  data() {
    return {
      
      position: '',
      startingTimes: null
    }
  },
  created() {

    this.position = Gen.getRandomStartPosition();
    this.startingTimes = [15, 15] // Minuuttia
    // ...
  }
}

Tärkeintä tässäkin ratkaisussa on, että itse komponentin ei tarvitse tietää hölkäsen pöläystä mixinin poikkeavasta toimintatavasta.

Loppuhuomautus: idea koodinajon siirtämisestä seuraavalle event-loopin pyörähdykselle on erittäin monikäyttöinen. Se on itse asiassa yksi tärkeimmistä (lexical scoping:in ja prototyyppi-ketjujen ohella) tekijöistä, jotka tekevät Javascriptista Javascriptin.

Tapahtumakuuntelu mixinin kautta

Moni Vue-komponentti tarvitsee elinkaarensa aikana kyvyn reagoida muiden komponenttien tapahtumiin. Mikäli tapahtumiin reagoiva komponentti ja tapahtumia tuottava komponentti eivät ole suorassa parent-child -suhteessa, paras tapa välittää tietoa on erillisen Vue instanssin kautta, joka toimii keskitettynä viestikeskuksena, siis ikäänkuin radiomastona.

Aiemmassa Vue-blogauksessani annoin esimerkin komponentista (Palolaitos), joka kuuntelee viestikeskuksesta tulevia viestejä, ja komponentista (Puukerrostalo), joka ampuu viestejä viestikeskukseen.

Tuossa esimerkissä viestinvälitysmekanismi oli koodattu suoraan komponenttien sisälle. Mutta suuressa web-applikaatiossa vastaavaa viestittelymekanismia joudutaan käyttämään useiden eri komponenttien kohdalla.

Eli mikäli toinen komponentti haluaa ottaa vastaavan ratkaisun käyttöön, täytyy vastaava mekanismi koodata myös sinne.

Ongelma on, että kyseessä on puhdas duplikaatio; sama koodi ripotellaan kopioina eri puolelle koodipohjaa.

Yksi ohjelmoinnin kaikkein fundamentaalisimmista säännöistä on: älä duplikoi koodia ilman hyvää syytä.

Lähes kaikki ohjelmointikielet tarjoavat ratkaisun duplikaation torjumiseen, ja yleisin ratkaisu on funktionaalinen abstraktio; koodi laitetaan funktion sisään, ja koodin sijasta ripotellaan funktiokutsuja pitkin poikin koodipohjaa. Vue tarjoaa tähän lisämausteen mixin-konseptin avulla.

Perusidealtaan Vuen mixin on hiukka samankaltainen kuin vaikkapa PHP:n puolella konsepti trait. Molemmat mahdollistavat koodin abstraktoinnin komponentin ulkopuolelle siten, että myös muut komponentit (tai luokat) voivat koodia hyödyntää.

Yksi ero on, että siinä missä PHP:ssä trait on yksi vaihtoehto – joskus hyvä, joskus huono, useimmiten neutraali - perimiselle yläluokasta, Vuen puolella mixin on kutakuinkin ainoa järkevä tapa abstraktoida komponentin lifecycleen liittyvä toiminta ulos komponentista. Vuen moottori nimittäin osaa käsitellä mixineiksi merkityn koodin spesiaalilla tavalla, ja ikäänkuin kutoa sen yhteen komponentin oman koodin kanssa.

Teknisesti Vue yksinkertaisesti tunnistaa, että mixiniä käytetään, ja ajaa mixinin määrittelemät lifecycle-kuuntelijat (esim. created)juuri ennen komponentin omia lifecycle-kuuntelijoita. Huomionarvoista on, että komponentin oma kuuntelija ajetaan mixin-kuuntelijan jälkeen.

Tämä “kutominen” mahdollistaa ratkaisun, joka ei yksinkertaisesti ole mahdollinen PHP:n traittia käyttäen; mixin voi määritellä metodin “created”, ja komponentti voi määritellä saman nimisen metodin “created”, ja kun komponentti ottaa mixinin käyttöön, Vue kutoo kaksi metodia yhteen ja ajaa metodikutsun seurauksena molempien metodien koodit.

Tämä on fundamentaalisti eri asia kuin useimpien ohjelmointikielten (ml. Javascript, jonka päälle Vue rakentuu!) tapauksessa kun runtime-engine etsii ajettavaa metodia metodinimen perusteella. Esimerkiksi PHP:n puolella luokka ja yläluokka voivat määritellä saman nimisen metodin, mutta koodinajon aikana vain yhden metodin sisältämä koodi ajetaan. Yksi metodi voi tietenkin kutsua toista metodia – tämä on mahdollista jopa silloin kun metodien nimet ovat tismalleen samat (esim. parent::__construct) - ,mutta tämä kutsuminen on erikseen kirjoitettava koodiin. Vuen hienous on, että Vuen moottori tekee kutsun automaattisesti lifecycle-kuuntelijoiden kohdalla.

Yhtäkaikki, mixin näyttää tältä:


// mixins/eventListeningMixin.js

import {EventBus} from '@/services/eventbus'
import _ from 'lodash'

export default {

  data() {
    return {
      // vakiona tyhjä objekti -> ei kuuntelijoita
      eventBusListeners: {}
    }
  },

  beforeDestroy() {
    // Komponentti tuhoutumassa, poista kuuntelijat.
    _.forOwn(this.eventBusListeners, function(fun, eventName) {
      EventBus.$off(eventName, fun);
    });  
    
  },
  created() {
    // Komponenttia alustetaan, aseta kuuntelijat
    _.forOwn(this.eventBusListeners, function(fun, eventName) {
      EventBus.$on(eventName, fun);
    })
  }
}

Itse EventBus - joka on hienosti wrapattu mixinin sisälle piiloon - on yksinkertaisesti erillinen Vue-instanssi:


// services/eventbus.js

import Vue from 'vue';
export const EventBus = new Vue();

Kun olemme luoneet mixinin, mikä tahansa komponentti voi ottaa tuon mixinin käyttöönsä. Käyttöönotto on helppoa; rekisteröi mixinin ja asettaa halutun eventBusListeners-objektin, joka kytkee tapahtumanimet kuuntelijametodeihin. Oleellista on, että mixin hoitaa kaiken koordinnoin taustajärjestelmien kanssa - komponentin sisällä voimme koodata deklaratiivisesti eli meidän ei tarvitse huolehtia algoritmeista, joita tapahtumakuuntelu käyttää.

Esimerkkinä komponentti Dashboard.vue:


// components/Dashboard.vue

import API from '@/api'
import eventListening from '@/mixins/eventListeningMixin'

export default {

  name: 'Dashboard',
  mixins: [
    eventListening
  ],  
  data() {
    return {        

      // Komponentin oma data
      leads: null,
      

      // Tapahtumakuuntelijat mixiniä varten
      // Tämä ylikirjoittaa mixinin oman kuuntelijaobjektin (defaulttina tyhjä objekti)
      eventBusListeners: {
        open_lead_acquired: this.eventReloadOpenLeads.bind(this),
        own_lead_closed: this.eventReloadOwnLeads.bind(this),        
      }, 

    }
  },
  created() {
    console.log("Dashboard luotu");
    return API.leads.fetch('include=events,reminders,calls,emails')
    .then((leads) => {
      this.leads = leads;
    });

    //...jne
  },
  methods: {
    eventReloadOpenLeads() {
      // Lataa avoimet liidit uusiksi rajapinnasta
    },

    eventReloadOwnLeads() {
      //... lataa omat liidit uusiksi rajapinnasta
    },

  }
}  

Parasta tässä on, että kiitos Vuen kutomisen, viestikuuntelija-mixiniä käyttävän komponentin ei tarvitse pelätä, että mixin vaikuttaisi itse komponentin omien lifecycle-hooksien toimintaan. Ne toimivat tismalleen samoin kuin ilman mixiniä.

Esimerkissä emme määrittäneet varsinaisia business-metodeja mixinin sisään. Toisin kuin lifecycle-kuuntelijoiden tapauksessa, Vue ei “kudo” mixinin ja komponentin saman nimisiä business-metodeja yhteen. Sen sijaan mixinin metodi yksinkertaisesti kipataan roskiin, ja komponentin metodi jää käyttöön.

Tämä koskee siis vain tilanteita, joissa komponentin ja mixinin määrittämällä metodilla on tismalleen sama nimi.

Komponenttipuun kontrollointi URL:n timestampilla

Männä päivänä syntyi tarve ladata Vue:n reitittimeen kytketty Vue komponentti uusiksi ilman, että reitittimen toimintaa ohjaava route muuttuu.

Löyhä määritelmä: route on kytkös URL:n eli selaimen www-osoitteen ja käyttöliittymässä aktiivisena olevan näkymän välillä.

Tyypillisestihän Vuen reititin (router) automaattisesti päivittää komponentti-puun ajan tasalle mätsäämään sen hetkistä URL-rakennetta.

Esimerkiksi:

www.appi.fi/#/sahkopostit/{id}/liitteet tuottaa komponenttipuun: App.vue > Sahkoposti.vue > Liitelista.vue

App.vue on ylimmän tason container-tyyppinen komponentti, jonka sisään applikaation business-näkymät rakentuvat. Kaikki mahdolliset komponenttipuut sisältävät App-komponentin esi-isänään.

Kun nyt käyttäjä klikkaa applikaation menuvalikosta painiketta “Profiili”, URL päivittyy:

www.appi.fi/#/profiili, joka tuottaa komponenttipuun App.vue > Profiili.vue.

Kun URL päivittyy, Vue automaattisesti hoitaa lifecycle-kontrollin poistuville ja ilmestyville komponenteille. Esimerkin tapauksessa poistuvia komponentteja ovat Sahkoposti ja Liitelista, ja ilmestyvä komponentti on Profiili. Osana tätä lifecycle-kontrollia komponenttien lifecycle-hooksit ajetaan, mikä mahdollistaa uuden komponentin populoinnin esimerkiksi palvelimelta haetulla datalla.


// Sahkoposti.vue

<template>
  <div v-if="email">
    <h1>{{email.subject}}</h1>
    <span>{{email.content}}</span>
  </div>
  <h3 v-else>"Ladataan..."</h3>
</template>

<script>

import API from '@/api'

export default {
  
  name: 'Sahkoposti',
  props: ['id'],
  data() {
    email: null,
  },
  created() {
    return API.emails.single(id)
    .then((email) => {
      this.email = email;
    });
  }
}
</script>

Ylläoleva soveltuu hyvin datalle, joka ei koskaan muutu. Tällöin komponentin alustuksen aikana tehty hakureissu serverille riittää populoimaan komponentin sen koko elinkaaren ajaksi.

Mutta entä jos meillä on seuraavanlainen URL-reitti ja siihen kytketty komponentti?

www.osakeseuranta.fi/#/osakekurssit/{id}


// Osakekurssi.vue

<template>
  <div v-if="osakekurssi !== null">
    <h1>Kurssi: {{osakekurssi}}</h1>
  </div>
  <h3 v-else>"Ladataan..."</h3>
</template>

<script>

import API from '@/api'

export default {
  
  name: 'Osakekurssi',
  props: ['id'],
  data() {
    osakekurssi: null,
  },
  created() {
    return API.osakekurssit.single(id)
    .then((osakekurssi) => {
      this.osakekurssi = osakekurssi;
    });
  }
}
</script>

Haemme jälleen osakekurssidatan komponentin alustuksen yhteydessä, mutta ongelmana on, että osakekurssilla on tapana muuttua. Varsin nopeasti ja usein. Haettu osakekurssi on tarpeeksi hyvä approksimaatio oikeasta osakekurssista ehkä muutaman sekunnin ajan; sen jälkeen se on vanhentunutta tietoa. Vanhentunut tieto on arvotonta tietoa.

Yksi tapa on tehdä jonkinlainen push-notifikaatioihin perustuva järjestelmä, jossa uusin osakekurssi ammutaan fronttiin aina parin sekunnin välein:

Ratkaisu 1: Push-notifikaatiot


<script>

import PusherWrapper from '@/inbound/PusherWrapper'

export default {
  
  name: 'Osakekurssi',
  props: ['id'],
  data() {
    osakekurssi: null,
    cb: null
  },
  created() {
    this.cb = (viimeisinKurssi) => {
      this.osakekurssi = viimeisinKurssi;
    }.bind(this);

    return PusherWrapper.subscribe('osakkeet', this.id, this.cb);
  },
  beforeDestroy() {
    PusherWrapper.unsubscribe(this.cb);
  }
}
</script>

Toimiakseen komponentti vaatii tietenkin taustajärjestelmien olemassaolon, kuten (esimerkin tapauksessa) Pusher-tilin. Myös osakekurssidatan sisältävän palvelimen täytyy muuttua; sen täytyy ampua tietoa Pusherin suuntaan harvasen sekunti.

Ratkaisu on varsin monimutkainen. Mikäli esimerkiksi viiden sekunnin viive datan saannissa on OK, seuraava ratkaisu on parempi:

Ratkaisu 2: Polling API


<template></template>

<script>

import API from '@/api'

export default {
  
  name: 'Osakekurssi',
  props: ['id'],
  data() {
    osakekurssi: null,
    // Polling-järjestelmän apumuuttujat
    poller: null,
    // Viimeisimpänä lähteneen requestin järjestysnumero
    requestNum: 0,
    // Suurin järjestysnumero jolle saatu vastaus palvelimelta.
    latestResponse: 0
  },
  created() {
    // Ensihaku
    this.haeData(this.requestNum++);
    // Luo looppi joka hakee dataa 5 sek välein
    this.poller = setInterval(() => {
      this.haeData(this.requestNum++); 
    }, 5000);
  },
  methods: {
    haeData(currentRequestNum) {
      API.osakekurssit.single(this.id)
      .then((osakekurssi) => {
        if (this.latestResponse > currentRequestNum) {
          // Responsella kesti liian pitkään saapua. 
          // Tuoreempaa dataa on jo ehtinyt saapua, joten
          // tällä datalla emme tee yhtikäs mitään.
          return;
        }
        this.latestResponse = currentRequestNum;
        this.osakekurssi = osakekurssi;
      });
    }
  },
  beforeDestroy() {
    clearInterval(this.poller);
    this.poller = null;
  }
}
</script>

Ylläoleva ratkaisu on siitä hyvä, että se ei vaadi muutoksia palvelimen puolelle eikä push-notifikaatioita. Yksinkertaisesti häiriköimme rajapintaa viiden sekunnin välein.

Huono puoli on, että viiden sekunnin intervalli on arbitraali, ja ei anna käyttäjälle mahdollisuutta vaikuttaa päivitystahtiin. Osakekurssien tapauksessa kustomoitua päivitys-kontrollia ei juuri tarvita (koska osakekurssit tuppaavat päivittymään orjallisen tasaisesti), mutta muissa yhteyksissä päivitystahdin parempi kontrollointi voi olla tarpeen.

Lisäksi kiveen hakattu päivitystahti on suorastaan käyttäjän ehdollistamista avuttomuuteen. Kuin Albert Camuksen novellissa, käyttäjästä tulee passiivinen seuraaja, joka ei voi vaikuttaa mihinkään.

Sidenote: vastakohta Albert Camuksen masennuskeskeiselle maailmankuvalle on Viktor Hugo, jonka novelleissa päähenkilöt omaavat vapaan tahdon, ja voivat aktiivisesti vaikuttaa oman elämänsä kulkuun.

Ratkaisu 3: Datan uudelleenlataus nappia painamalla

Tämä ratkaisu sisältää vihdoin otsikon mukaisen ongelman. Esimerkin tapauksessa voimme tehdä nappia painamalla refreshin seuraavasti:


<template>
  <div v-if="osakekurssi !== null">
    <h1>Kurssi: {{osakekurssi}}</h1>
    <button v-on:click="haeData">Päivitä</button>
  </div>
  <h3 v-else>"Ladataan..."</h3>
</template>

<script>

import API from '@/api'

export default {
  
  name: 'Osakekurssi',
  props: ['id'],
  data() {
    osakekurssi: null,
  },
  created() {
    this.haeData();
  },
  methods: {
    haeData() {
      API.osakekurssit.single(this.id)
      .then((osakekurssi) => {
        this.osakekurssi = osakekurssi;
      });
    }
  }
}
</script>

On kuitenkin huomattava, että käyttämämme esimerkki on nk. toy example. Oikeassa applikaatiossa päivitettävä komponentti saattaa sisältää valtavan alipuun täynnä muita komponentteja. Lisäksi päivitysnappula voi sijaita ihan toisaalla kuin itse osakekurssi-komponentin sisällä.

Se mitä haluaisimme tehdä on hyödyntää olemassaolevaa Vue:n reititintä. Se tietää tismalleen mitä tehdä; kun saavumme esimerkiksi osoitteeseen www.osakeappi.fi/#/osakekurssit/5, reititin osaa rakentaa koko komponenttipuun osakekurssille, jolla on ID 5.

Yleisemmin: koska meillä on jo tapa rakentaa koko komponenttipuu ja populoida se reititintä käyttäen, olisi fiksua tehdä myös puun uudelleenrakennus + uudelleenpopulointi reititintä käyttäen.

Törmäämme kuitenkin ongelmaan: kun vapaan tahdon omaava käyttäjä klikkaa osakekurssi-painiketta menuvalikossa, Vue oikeaoppisesti siirtyy uuteen URL:iin ja rakentaa uuden komponenttipuun. Mutta kun käyttäjä klikkaa samaa valikkopainiketta uudestaan, mitään ei tapahdu.

Ongelma on, että Vuen reititin päivittää komponenttipuun vain mikäli reitittimen havaitsema route muuttuu. Käyttäjän klikatessa valikkonappulaa toisen kerran route ei muutu. Käyttäjän tavoitteena on saada samalle osakekurssille tuore kurssidata. Tuore data ja vanha data käyttävät kuitenkin samaa URL:iä, joten reititin ei ymmärrä päivittää komponenttipuuta, eikä täten created-hookit tule kutsutuksi.

Yksi ratkaisu on kutsua $vm.forceUpdate() jossain kohtaa, mutta näin toimiessa olemme matkalla kohti spagettikoodia; Vue reititin seuraa URL:ia, ja forceUpdate-ratkaisu sivuuttaa reitittimen toimintamekanismin. Oikeaoppisessa web-applikaatiossa URL toimii käyttöliittymän näkymää kontrolloivana tilamuuttujana, ja mieluiten ainoana näkymää kontrolloivana tilamuuttujana.

Kyseessä on filosofinen ero datan ja näkymän dataan välillä. Ero on vastaava kuin vaikkapa tähden ja kaukoputken välillä. Matemaattisesti ero on vastaava kuin X:n ja F(X):n välillä; ensimmäinen on objekti, jälkimmäinen on transformaatio eli funktio.

Oikeaoppisessa web-applikaatiossa URL on tilamuuttuja sille mitä/miten dataa näytetään. URL on harvoin tilamuuttuja itse datalle. Ihan jo siksi, että suuren määrän dataa mahduttaminen selaimen osoiteriville on mahdotonta.

Itse datan tilamuuttujana toimii useimmiten joko palvelin-rajapinta, lokaali tietovarasto (esim. local storage) tai yksinkertaisesti globaali Javascript-objekti.

Tämän periaatteen sivuuttaminen saattaa johtaa ongelmiin pitkässä juoksussa. Tai sitten ei johda. Mutta meillä on parempikin tapa, joten…

Ratkaisu 4: Komponenttipuun kontrollointi URL:n timestampilla

Paras(?) ratkaisu ongelmaan on muuttaa URL:ia. Mutta miten? Emme voi muuttaa täysin arbitraalisti, koska osakekurssidatan sisältävä komponentti näkyy vain URL:in www.osakeappi.fi/#/osakekurssit/{id} alaisuudessa. Mitä siis voimme muuttaa?

Entä jos teemme näin:

www.osakeappi.fi/#/osakekurssit/{id}?ts=772272727

Lisäämällä query stringin URL:iin saamme muutettua URL:ia ilman, että itse komponenttipuun rakennetta ohjaava osuus URL:ista muuttuu. Nyt sitten ylätasolla (App.vue) teemme seuraavasti:


// App.vue

<template>
  <router-view :key="$route.fullPath"></router-view>
</template>

<script>/*...*/</script>


// Menu.vue

<template>
  <ul v-for="osake in osakkeet">
    <li v-on:click="openOsake(osake.id)">{{osake.name}}</li>
  </ul>
</template>

<script>
  export default {
    //...
    methods: {
      openOsake(id) {
        // Generoimme timestampin URL:in mukaan!
        // Koska timestamp muuttuu millisekunnin välein,
        // URL ei ole koskaan sama!
        this.$router.replace({ 
          name: "osakekurssi", 
          params: {id: id}, 
          query: {ts: Date.now()} 
        })
      }
    }
  }
</script>

Yllä oleelliset osuudet ovat App-komponentin :key=$route.fullPath ja Menu-komponentin query: {ts: Date.now()}.

Ensin mainittu kytkee router-viewin kuuntelemaan URL:n muutoksia. Joka kerta kun URL mukaan lukien query string muuttuu, komponentin key-attribuutti muuttuu, ja se pakottaa komponentin uudelleenlatauksen.

Jälkimmäinen huolehtii, että joka kerta kun valikkopainiketta klikataan, generoitava URL on uniikki.

Yksi hyvä puoli ratkaisussa on, että nyt voimme automaattisesti kontrolloida päivitystahtia suoraan URL:n tasolla! Tämä on suorastaan upeaa, fantastista.

Ylläolevassa ratkaisussa URL muuttuu millisekunnin välein (koska Date.now palauttaa aikamääreen millisekunnin tarkkuudella). Mutta voimme yhtä hyvin tehdä URL muutoksen yhden sekunnin välein. Tällä tavalla käyttäjä ei voi ampua kuin maksimissaan yhden HTTP-requestin per sekunti vaikka kuinka näpyttelisi hurmiossa samaa valikkolinkkiä.

Toteutimme kuin vahingossa automaattisen rate limitin, siis.


// Menu.vue

<template>
  <ul v-for="osake in osakkeet">
    <li v-on:click="openOsake(osake.id)">{{osake.name}}</li>
  </ul>
</template>

<script>
  export default {
    //...
    methods: {
      generateTs() {
        var now = Date.now();
        // Pyöristä sekunnin tarkkuuteen -> URL muuttuu keskimäärin
        // sekunnin välein vaikka kuinka naputtaisi li-elementtiä.
        return Math.round(now / 1000);
      },
      openOsake(id) {
        this.$router.replace({ 
          name: "osakekurssi", 
          params: {id: id}, 
          query: {ts: this.generateTs()} 
        })
      }
    }
  }
</script>

Tässä saavumme ihanteelliseen kompromissiin monen asian suhteen. Ensinnäkin käyttäjä olettaa, että kun hän kerran klikkaa osakkeen nimeä ja kurssi päivittyy, myöhemmät klikkaukset tekevät samoin. Tämä antaa käyttäjälle kontrollin. Samaan aikaan saamme kuitenkin toteutettua rate limiitin; koska kurssidataa ei ole järkeä hakea useammin kuin kerran sekunnissa, voimme URL:n koostumusta kontrolloimalla kontrolloida päivitystahtia. Kolmanneksi… koska URL:n muutos uudelleengeneroi koko router-viewin alaisen komponenttipuun, kaikki alikomponentit ladataan puhtaalta pöydältä. Tämä helpottaa synkronisaatio-ongelmia ison komponenttipuun eri haarojen välillä; kaikki haarat generoidaan uusiksi, ja vanha data häviää bittiavaruuteen.

Kaiken kaikkiaan toimintamalli muistuttaa jumalallista RAII-patternia C++:n puolelta.

RAAI = Resource acquisation is initialization

Opetus: pidä komponenttipuun rakenne ja päivityssykli riippuvaisena URL:ista, käyttäen tarvittaessa query stringiä generoimaan uniikki URL.

Laravel ja välimuistin testaus

Alkusanat: tässä artikkelissa ei käsitellä HTTP-protokollan headereihin perustuvaa välimuistin kontrollointia. Frontti-välimuisti tämän artikkelin yhteydessä tarkoittaa Javascriptin päälle rakennettavaa tietovarastoa.

Hyvä katsaus HTTP:n välimuistikäyttöön löytyy mm.: https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching

Moderni Laravel-pohjainen weppi-appi käyttää usein hyväkseen välimuistia (cache). Välimuistia hyödyntämällä vältetään joko turhat HTTP-kutsut (frontend-välimuisti) tai turhat tietokantahaut (backend-välimuisti). Tällä tavalla applikaation suorituskyky paranee, toivottavasti.

Frontti-välimuisti

Fronttipuolella välimuisti liittyy rajapintakutsujen välttämiseen. Toimintamalli tällöin on, että kun tietty rajapintakutsu on tehty, sen tulos tallennetaan lokaalisti, ja tulevaisuudessa rajapintakutsun sijasta käytetään tallennettua tulosta.

Fronttipuolen välimuistilla on käyttönsä, mutta HTTP-kutsun skippaamisella on varjopuolensa; on vaikea tietää hetkeä, jolloin lokaali välimuisti on vanhentunut. Eli hetkeä, jolloin täytyy tehdä uusi HTTP-kutsu ja päivittää välimuistin sisältö tuoreella datalla.

Yksi keino on käyttää jonkinlaista subscriber-systeemiä, jossa backend puskee komennon tyhjentää välimuisti fronttiin. Komento voidaan toimittaa vaikka Pusherin kaltaisen järjestelmän kautta. Toimintamalli on kuitenkin varsin monimutkainen saavutettavaan hyötyyn nähden.

Fronttipuolella välimuisti soveltuu parhaiten tapauksiin, joissa palvelimelta haettava data muuntuu aniharvoin jos koskaan. Tällöin voidaan esim. kirjautumisen yhteydessä tehdä yksi HTTP-kutsu, ja tämän jälkeen tallettaa saatu data välimuistiin kirjautumissession ajaksi.

Tyypillisesti paras vaihtoehto on yksinkertaisesti välttää frontti-välimuistin käyttöä kokonaan, ja tehdä HTTP-kutsu palvelimelle joka kerta kun dataa tarvitaan.

Backend-välimuisti

Erillinen välimuisti-layeri

Backendin puolella yksi mahdollisuus on käyttää jonkinlaista erillistä välimuisti-layeriä. Tälläinen layer on kokonaan erillisellä palvelimella, ja toimii täysin erillään varsinaisesta business-backendistä. Esimerkiksi Varnish tarjoaa ratkaisun tähän.

Erillisen välimuistipalvelimen voi valjastaa myös muihin käyttötarkoituksiin, esimerkiksi kuorman tasaukseen.

Erillisen välimuisti-layerin käyttö törmää samaan ongelmaan - joskin hiukan helpommassa muodossa - kuin frontti-välimuistin; kuinka tyhjentää välimuisti ja pakottaa tuoreen datan haku.

Applikaatiosta riippuen voimme skipata erillisen tyhjennyskomennon kokonaan, ja tyytyä aikaperusteiseen tyhjennykseen. Aikaperusteisessa tyhjennyksessä välimuisti tyhjentyy esimerkiksi viiden minuutin välein itsestään. Tällöin loppukäyttäjä saa haltuunsa pahimmillaan 5 minuuttia vanhaa dataa.

Toinen vaihtoehto on turvautua Laravellin omaan välimuisti-ratkaisuun.

Laravel Cache

Laravellin oma välimuistiratkaisu siirtää välimuistin samalle palvelimelle (default-asetuksilla, tätäkin voi toki kustomoida!) itse business-koodin kanssa. Loppuosa artikkelista tutkii tätä toimintamallia.

Käyttö

Laravellin oman välimuistiratkaisun käyttö onnistuu - oman kokemukseni perusteella - parhaiten suoraan HTTP-layeriltä käsin, eli siis Controller:eista.

Tässä toimintamallissa välimuistiin talletetaan HTTP-endpointtien palauttama sisältö.

Toinen vaihtoehto on toteuttaa välimuisti syvemmälle applikaation sisuksiin, esimerkiksi suoraan yksittäisten domain-objektien sisälle. Tällöin välimuistiin talletetaan tietokannasta saatu data jonkinlaisessa tekstimuodossa.

Ero nk. domain-välimuistin ja Controller-välimuistin välillä on hienojakoisuudessa; Controllereiden hallitsema välimuisti tallentaa endpointin lopullisen palautusarvon (joka usein sisältää useamman domain-objektin sekä lisäksi mahdolliset transformaatiot, joita domain-objekteille on tehty). Domain-välimuisti taas tallettaa yksittäisen domain-objektin kerrallaan sellaisena kuin se tietokannasta pötkähtää ulos.

Domain-välimuisti on teoriassa suorituskykyisempi ja konseptuaalisesti “oikeampi” malli, mutta myös vaikeampi toteuttaa. Huonosti toteutettuna domain-välimuisti on ensiaskel tiellä kohti BBoM*-helvettiä. Käytännössä se on valtava overkill.

Alla esimerkki Controllerista, joka hyödyntää Controller-välimuistia:


class VihannesController extends Controller {
  
  public function all() {
  
    if (Cache::has('vihannekset')) {
      return Cache::get('vihannekset');
    }
    // Ei vihanneksia välimuistissa -> haetaan tietokannasta.
    $vihannekset = Vihannes::all();

    // Ajetaan fractalin transformaatiot, jotka rakentavat meille
    // lopullisen vastaus-objektin palautettavaksi HTTP-vastauksena.
    $responseData = $this->transformCollection($vihannekset, new VihannesTransformer);
    
    // Lisätään välimuistiin 10 minuutiksi.
    Cache::put('vihannekset', $responseData, 10);
    // Palautetaan kutsujalle
    return $responseData;
  }

  protected function transformCollection($collection, $transformer) {
    //... muunna objektit front-endin odottamaan formaattiin
  }
}

Ylläoleva Controller lisää vihannekset välimuistiin 10 minuutin ajaksi. Eli 10 minuutin ajan tietokantaan ei tarvitse tehdä hakuja vihannesten osalta. Tämä säästää kivasti tietokannan hermoja.

Mutta entä jos ennen 10 minuutin aikarajan umpeutumista joku lisää uuden vihanneksen tietokantaan?

Laravel tarjoaa toiminnon tyhjentää (“forget”) välimuisti halutun avain-arvon osalta. Tällä tavalla voimme pakottaa uuden vihannekset-haun tietokannasta joka kerta, kun uusi vihannes lisätään.


class Vihannes extends Model {
  
  public static function flushCache() {
    Cache::forget('vihannekset');
  }

  public static function create(array $data = []) {
    // Uusi vihannes lisätään tietokantaan, 
    // tyhjennä välimuisti lisäyksen jälkeen.
    $model = parent::create($data);
    static::flushCache();
    return $model;

  }

}

Huom! Ylläoleva koodi ei toimi Laravel 5.4 tai tuoreemmilla versioilla, koska create-metodia on muutettu frameworkin konepellin alla.

Yllä teemme overriden Eloquentin create-metodille. Overriden sisällä kutsumme Eloquent-metodia, joka lisää vihanneksen tietokantaan, ja kutsun jälkeen tyhjennämme välimuistin.

Yllä on tehty välimuistin tyhjennys vain create-metodin kohdalle. Vastaavat overridet tarvitaan myös metodeille, jotka muuttavat vihanneksen dataa tai tuhoavat vihanneksia.

Metodien overraidaamisen sijaan voisimme käyttää tapahtumakuuntelijaa (model listener), jolla kuuntelisimme esimerkiksi saved-eventtejä vihannesten osalta. Tällöin saamme enkapsuloitua välimuistin tyhjennyksen yhteen paikkaan, eli tapahtumakuuntelijan sisälle.

Valinta näiden kahden vaihtoehdon välillä on ensisijaisesti makukysymys. Itse suosin eksplisiittistä koodia ja usecase-arkkitehtuuria (josta lisää seuraavassa kappaleessa), ja siksi vältän sekä tapahtumakuuntelijoita että välimuisti-kontrollin sijoittamista domain-luokkien (kuten Vihannes) sisälle.

Ylläoleva koodi toimii (tietenkin), mutta olen itse päätynyt viimeisimmässä applikaatiossani malliin, jota kutsun “get from controller, flush from usecase” -malliksi.

Get from Controller, flush from Usecase

Mallin nimi kertoo kaiken oleellisen siitä, mistä koodipohjan osasta käsin kukin operaatio suoritetaan. Controller-puolen käsittelimme jo. Mutta mitä tarkoittaa “flush from usecase”?

Usecase-arkkitehtuurissa kukin applikaatiolle suoritettava toimenpide muodostaa erillisen usecase-luokan. Tämä usecase-luokka kontrolloi toimenpiteen suorittamista, ja tarjoaa oivallisen sijainnin kaikelle toimenpiteen oheis-koodille. Tälläistä oheiskoodia on mm. virhehallintakoodi sekä tässä käsiteltävä välimuistin kontrolloimiseen liittyvä koodi.

Usecase-luokan instanssi on tyypillinen manager-objekti; sen ydintehtävä on koordinoida toimenpiteen suorittaminen, ei niinkään suorittaa itse toimenpidettä. Usecase on siis työnjohtaja, joka valvoo työtehtävien suoritusta. Ero on hienovarainen, mutta konseptina hyödyllinen.

Välimuistin tyhjennykselle usecase on mainio paikka, koska mikäli applikaatio rakennetaan oikein, yksikään muutos tietokantaan ei tapahdu ilman usecase-objektin antamaa käskyä. Alla esimerkki usecasesta, joka tekee vihannessopan sille annetuista vapaavalintaisista vihanneksista (Vihannes-luokan instanssit):


class ValmistaSoppa extends Usecase {

  public function execute($soppaVihannekset) {
    // Soppavihannekset koostuu Vihannes-objekteista, jotka
    // käytetään sopan valmistukseen. 

    // Aloitetaan sopan valmistus, mieluiten transaktion sisällä
    // jotta emme töpeksi tietokantaa mikäli jotain menee päin hönkiä.
    DB::transaction(function() use ($soppaVihannekset) {
      
      // Luodaan soppakattila
      $soppaKattila = new SoppaKattila;
      // Siirretään vihannekset yksi kerrallaan kattilaan
      $ainesosat = $soppaVihannekset->each(function($vihannes) use ($soppaKattila) {
        // Siirrä vihannes kattilaan
        $soppaKattila->lisaaKattilaan($vihannes);
        // Vihannes on nyt käytetty, tuhotaan se tietokannasta.
        $vihannes->delete();
      });

    });

    // Vihanneksia on poistettu tietokannasta, joten välimuisti tyhjennettävä.
    Cache::forget('vihannekset');

    // Kiehauta ja suolaa
    $soppaKattila->kiehauta();
    $soppaKattila->lisaaSuola();

    // Soppa on valmis, palauta kutsujalle joka voi
    // kaataa liemen lautasille ym.
    return $soppaKattila;

  }
  
}

Ylläolevassa usecasessa valmistamme vihannessopan. Koko usecasen koodi on selkeästi step-by-step -muodossa; tee näin, sitten tee näin, sitten tee näin. Tämä hienosti tarjoaa meille selkeän paikan, jonne tunkea välimuistin tyhjennys. Vasta kun vihannekset on poistettu tietokannasta - ja poisto tapahtuu vain mikäli transaktio onnistuu -, tyhjennämme välimuistin.

Disclaimer: Välimuistin toteuttaminen Controller-layerille ei ole hopealuoti. Yksi merkittävä haaste on, että palvelupyynnön query stringin mukana tulevat include-komennot vaativat erillisen käsittelyn. Ongelman ydin on se, että yksi palvelupyyntö voi haluta includeerata jotain mitä toinen palvelupyyntö ei tarvitse. Mikäli laitamme yhden palvelupyynnön tuottaman responsen välimuistiin, toinen palvelupyyntö saa puutteellisen datan käyttöönsä.

Yksi ratkaisu on käyttää koko URL-stringiä (myös Query-osuutta!) välimuistin avaimena. Mutta tämä vie välimuistin kontrolloimista hienojakoisempaan suuntaan kuin mitä haluamme. Välimuistin ja query-parametrien välinen riippuvuus kuulostaa yksinkertaiselta huonolta idealta (omakohtaista kokemusta asiasta minulla ei ole).

Toinen ratkaisu on usecase-luokkien sisällä suorittaa kaikki tarvittavat välimuistityhjennykset kaikille niille objektiluokille, joihin operaatio vaikutti. Tämä on hiukka sotkuista mikäli objektien väliset relaatiot ovat runsaslukuisia, mutta hyvä puoli on toimintatavan eksplisiittisyys. Usecase-luokkaa tarkastelemalla voi kerralla havaita mitkä välimuistit nollaantuvat operaation seurauksena.

Välimuistin testaamisesta kehityksen aikana

Vihdoin otsikon aiheeseen, eli kuinka testata välimuistin kontrolloimista devauksen aikana.

Ensinnäkin ilmiselvä fakta: välimuistin käytön merkitys korostuu applikaatioissa, jotka ovat joko datamäärältään tai käyttäjämäärältään suuria.

Kehitystyön aikana voi olla vaikea simuloida tarpeeksi suurta data-/käyttäjämäärää, jotta välimuistin tuomasta performanssi-hyödystä pääsee jyvälle. Siksi olen päätynyt seuraavanlaiseen toimintatapaan: joka kerta kun datahaku palvelimelle menee välimuistin ohi, palvelupyynnön suoritusaikaan lisätään 5 sekuntia luppoaikaa.

Tällä tavalla on applikaatiota testatessa esim. fronttiappista käsin helppo omin silmin erotella välimuisti-hitit (cache hit) ja välimuisti-missit (cache miss) toisistaan.

Ohessa muokattu Controllerin koodi:


class VihannesController extends Controller {
  
  public function all() {
  
    if (Cache::has('vihannekset')) {
      return Cache::get('vihannekset');
    }

    if (env('APP_ENV') === 'development') {
      // Välimuisti ohitettu! Simuloidaan tietokannan hitautta
      // odottamalla viisi sekuntia.
      sleep(5);     
    }

    // Ei vihanneksia välimuistissa -> haetaan tietokannasta.
    $vihannekset = Vihannes::all();

    // Ajetaan fractalin transformaatiot, jotka rakentavat meille
    // lopullisen vastaus-objektin palautettavaksi HTTP-vastauksena.
    $responseData = $this->transformCollection($vihannekset, new VihannesTransformer);
    
    // Lisätään välimuistiin 10 minuutiksi.
    Cache::put('vihannekset', $responseData, 10);
    // Palautetaan kutsujalle
    return $responseData;
  }

  protected function transformCollection($collection, $transformer) {
    //...
  }
}

Odotamme siis 5 sekuntia mikäli dataa ei löydy välimuistista. Tällä tavalla simuloimme tilannetta, jossa kutsu ylikuormitettuun tietokantaan kestää pienen ikuisuuden. Luonnollisesti haluamme tehdä simulaation vain kehitysympäristössä.

Suuressa applikaatiossa odottelu-koodi kannattaa enkapsuloida joko traitin sisään, tai siirtää yläluokkaan. Esimerkiksi:


class VihannesController extends CacheController {
  
  public function all() {
  
    if (Cache::has('vihannekset')) {
      return Cache::get('vihannekset');
    }

    static::cacheMiss();

    // jne...
}


class CacheController extends Controller {
  
  protected static function cacheMiss() {
    if (env('APP_ENV') === 'development') {
      // Välimuisti ohitettu! Simuloidaan tietokannan hitautta
      // odottamalla viisi sekuntia.
      sleep(5);     
    }    
  }
}

Viiden sekunnin odottelu voi jossain kohtaa kehitystyötä alkaa rasittaa. Toisaalta haluamme silti tietää, milloin välimuistiin on osuttu ja milloin ei. Voimme palauttaa tiedon headerissa, ja fronttiappi voi lukea headerin ja ilmoittaa välimuistiosuman/-ohituksen devaajalle visuaalisesti:


class CacheController extends Controller {
  
  protected static function cacheMiss() {
    if (env('APP_ENV') === 'development') {
      // Välimuisti ohitettu! Lisätään tieto headeriin.
      Response::header('cache-miss-occurred', 1);   
    }    
  }
}

Ja frontissa jotain tähän tyyliin:


axios.get(baseUrl + '/vihannekset')
.then((response) => {
  if (response.headers['cache-miss-occurred']) {
    // Hienovaraisesti ilmoita käyttäjälle
    setInterval(() => { alert('Cache miss!')}, 1);
  }

  return response;
})
.then(/*...*/);

Summa summarum: harkitse välimuistin toteuttamista sanahirviön get from controller, flush from usecase eli GCFU-mallin mukaisesti. Devauksen aikana kehitä käyttöliittymää siten, että välimuistiin osumisen/missauksen seuraukset näkee saman tien.

  • * BBoM = Big Ball of Mud = hirveä sekasotku *

Komponentin datahaku alustuksen aikana

Männä päivänä syntyi seuraavanlainen tarve Vue-käyttöliittymää ohjelmoidessa; yhden komponentin tuli alustuksensa (created-hook) aikana saada informaatiota toiselta komponentilta, joka ei ollut suora esi-isä alustettavalle komponentille.

Ongelma ei kuulosta erityisen vaikealta - eikä sitä olekaan - mutta ohjelmoijan pääkoppa alkaa herkästi yliratkomaan ongelmaa.

Tyypillisestihän Vue-komponenttien välinen kommunikointi tapahtuu jommalla kummalla kahdesta seuraavasta tavasta:

1. Emit/props

Mikäli toinen komponentti on toisen suora jälkeläinen, kommunikointi tapahtuu luontevasti joko käyttäen propseja (alaspäin kommunikoidessa!) tai emittoimalla eventtejä (ylöspäin kommunikoidessa!). Tämä on luonteva tapa kommunikoida jos komponenttipuussa liikutaan vain vertikaalisesti (isä-poika), ei horisontaalisesti (sisar-veli). Ohessa esimerkki eventtien käytöstä:


// Parent.js


<template>
  <h3>Parent component</h3>
  <Child @viesti="viestiAlhaalta"></Child>
</template>

<script>

import Child from './Child'

export default {
  methods: {
    viestiAlhaalta(viestinSisalto) {
      console.log("Viesti alhaalta: " + viestinSisalto)
      
    }
  }
}

</script>


// Child.js

<template>
  <h3>Child component</h3>
  <button v-on:click="lahetaViesti">Lähetä</button>
</template>

<script>

export default {
  methods: {
    lahetaViesti() {
      this.$emit('viesti', 'Hei vain, isäpappa');
      
    }
  }
}

</script>

2. Erillinen Vue-instanssi

Mikäli kumpikaan komponentti ei ole toisen suora jälkeläinen, kommunikointi voi tapahtua joko 1) yhteistä ylätason komponenttia käyttäen, joka ottaa vastaan viestin yhdestä alipuusta ja ampuu sen alas toiseen alipuuhun, tai 2) erillistä observer-järjestelmää käyttäen.

Jälkimmäinen on suositeltava ratkaisu. Ensimmäinen ratkaisu toki toimii, mutta on isossa puurakenteessa tuhoisan sotkuinen toteuttaa ja ylläpitää.

Ohjelmoinnin kultainen sääntöhän on, että kaikkea on mahdollista tehdä, mutta mitään ei ole järkevää tehdä. Tai ainakaan lähes tulkoon mitään.

Eli observer-ratkaisu on parempi. Observer-radiomastona toimii luontevasti koko erillinen Vue-instanssi:


// services/Radiomasto.js

export default new Vue({});


// Palolaitos.js


<script>

import Radiomasto from './services/Radiomasto';

export default {
  data() {
    observerCb: null
  },
  name: 'Palolaitos',
  created() {
    // Ilmoita halustasi kuunnella tiettyjä viestejä
    this.observerCb = this.halytys.bind(this);
    Radiomasto.$on('tulipalo', this.observerCb);  
  },
  beforeDestroy() {
    // Lopeta kuuntelu
    Radiomasto.$off('tulipalo', this.observerCb);
  },
  methods: {
    halytys(osoite) {
     // Lähetä palomiehet annettuun osoitteeseen
      console.log("Palomiehet paikalle!");
    }
  }
}
</script>


// Puukerrostalo.js


<script>

import Radiomasto from './services/Radiomasto';

export default {
  name: 'Puukerrostalo',
  methods: {
    tulipaloHavaittu() {
      // Ilmoita palosta.
      Radiomasto.$emit('tulipalo', 'Koivukuja 2');
    }
  }
}
</script>

Ylläolevan ratkaisun saa tarvittaessa vieläpä siirrettyä mixiniin, jolloin sitä on helppo käyttää milloin tarve vaatii.

Mutta alkuperäinen ongelmani oli saada yhdeltä komponentilta informaatiota toisen komponentin alustuksen aikana!

Yksikään ylläolevista vaihtoehdoista ei sovellu erityisen hyvin tämän vaatimuksen täyttämiseen.

Ylläolevassa #2 esimerkissä viestin lähetys on tuottaja-lähtöistä; viestin luoja lähettää viestin haluamanaan ajanhetkenä. Mutta alkuperäisessä ongelmassa viestittely on kuluttaja-lähtöistä; viestin vastaanottaja määrittää ajanhetken, jolloin hän tarvitsee informaatiota käyttöönsä. Tästä syystä tarvitsemme toisen lähestymistavan.

Yksinkertaisin ratkaisu on suorastaan hupaisan… yksinkertainen. Käytetään yhteistä globaalia tietovarastoa, jonne kaikilla komponenteilla on yhteys! Joka kerta kun tuottaja-komponentti havaitsee muutoksen datassa, hän päivittää tietovaraston. Kuluttaja-komponentti voi sitten hakea haluamansa datan sopivalla hetkellä, tässä tapauksessa alustuksen aikana.

Globaali tietovarasto


// Tietovarasto.js

export default {
  muumitKpl: 0
}


// Muumimamma.js


<template>
  <button v-on:click="lisaaMuumi">Lisää</button>
  <button v-on:click="lisaaTuutikki">Lisää tuutikki</button>
</template>

<script>

import Tietovarasto from 'services/Tietovarasto'
import Muumi from 'entities/Muumi'
import Tuutikki from 'entities/communists/Tuutikki'

export default {
  name: 'Tuottaja',
  data() {
    olennot: [],
  },
  methods: {
    lisaaMuumi() {
      this.olennot(new Muumi());
      // Muumien määrä muuttui
      // Laske ja päivitä globaali tieto muumien määrästä
      Tietovarasto.muumitKpl = this.olennot.filter((olento) => {
        return !!olento.valkoinenJaPullea;
      }).length;
    },
    lisaaTuutikki() {
      this.olennot(new Tuutikki());

      // Muumien määrä ei muuttunut

    }
  }
}
</script>


// MuumitInfotaulu.js


<template>
  <h3>Muumeja on {{kpl}}</h3>
  <button v-on:click="paivitaMuumimaara">Päivitä</button>
</template>
<script>

import Tietovarasto from 'services/Tietovarasto'

export default {
  name: 'MuumitInfotaulu',
  data() {
    kpl: 0
  },
  methods: {
    paivitaMuumimaara() {
      // Käy hakemassa viimeisin lukumäärä
      // globaalista tietovarastosta.
      this.kpl = Tietovarasto.muumitKpl;
    }
  },
  created() {
    // Alustus
    //
    // Haetaan muumimäärä.
    this.paivitaMuumimaara();
  }
}
</script>

Yleisemmin: ylläoleva ratkaisu antaa mille tahansa komponentille pääsyn minkä tahansa komponentin tietoihin haluamallaan ajanhetkellä. Datan tuottajalta silti vaaditaan hiukka suostuvaisuutta; tuottajan täytyy puskea muutokset globaaliin tietovarastoon.

Tietovaraston käytön voi haluttaessa yhdistää tavanomaiseen observer-järjestelmään. Tällöin kuluttaja-komponentti hakee viimeisimmän datatiedon alustuksensa aikana, ja tämän jälkeen jää kuuntelemaan päivityksiä dataan observer-järjestelmää hyödyntäen. Tämä ratkaisu on varsin toimiva monissa yhteyksissä.

Esimerkki yhdistetystä haku + observer -ratkaisusta on vaikkapa chat-palikka, joka liitetään VueJS-sivustolle. Kun käyttäjä avaa chatin, on pulikan haettava keskusteluhistoria, jotta käyttäjä pääsee kärryille mistä keskustellaan. Avauksen jälkeen puolestaan on tarve saada live-päivityksiä, jotka kertovat uusien chat-viestien saapumisesta. Eli alustuksen aikana haku, alustuksen jälkeen kuuntelu. Tämä on erittäin yleinen toimintamalli.

Globaali tietovarasto on esimerkissämme rakennettu erilliseen javascript-moduuliin. Toinen vaihtoehto on rakentaa se Vuen sisälle, esimerkiksi pluginin päälle. Kumpikin tapa saavuttaa kutakuinkin saman lopputuleman.

Age of Empires - moninpelin arkkitehtuuri

Age of Empires 2 on yksi lempipeleistäni. Etenkin sen online-multiplayer. Vuonna 1999 ilmestynyt AoE2 sisältää jopa kahdeksan pelaajan online-pelimuodon, jossa yli tuhat eri pelaajien kontrolloimaa pelihahmoa käy massiivisia taisteluja.

Ohessa video hektisestä kahdeksan pelaajan multiplayer-pelistä: https://youtu.be/BBsyHerdpuI?t=50m3s

Sattumalta googlasin männä päivänä tietoja siitä, kuinka Aoe2 on rakentanut jo 90-luvulla näin mahtavan online-pelikokemuksen.

Ennen googlettelua oletin, että multiplayer tapahtuu client-server-mallin pohjalta; yksi palvelin (joka mahdollisesti sijaitsee yhden pelaajista, nk. host-pelaajan tietokoneella!) pitää globaalia pelitilaa yllä, ja jakaa sitä N kertaa sekunnissa pelaajille.

Pelaajat puolestaan lähettävät palvelimelle komentoja; palvelin reagoi kuhunkin komentoon, päivittää yhteisen pelitilanteen, ja lähettää päivitetyn tilan pelaajille. Yksinkertaista.

Mutta eihän se näin mennytkään; AoE2:n online-arkkitehtuuri perustuu peer-to-peer -malliin.

Peer-to-peer

Peer-to-peer -mallissa ei ole keskitettyä palvelinta, joka toimisi “single source of truth”-keskuksena pelin aikana.

Missä sitten sijaitsee tieto siitä, miltä pelimaailma näyttää kullakin ajanhetkellä? Vastaus: kullakin pelaajalla on tuo tieto erikseen.

Jotta koko hommassa olisi mitään järkeä, kullakin pelaajalla on oltava identtinen käsitys sen hetkisestä pelitilanteesta. Muuten koko pelissä ei olisi mitään mieltä.

Kuvittele esimerkiksi shakkipeli, jossa valkea pelaaja näkee laudan nappulat eri ruuduissa kuin musta pelaaja. Shakin pelaaminen olisi aika tuskallista.

Yksi tapa huolehtia siitä, että kullakin pelaajalla on sama identtinen pelitilanne tietyllä ajanhetkellä, on seuraava algoritmi:

Pelaajan algoritmi (ajetaan kunkin pelaajan tietokoneella):

  1. Suorita pelaajan tekemä pelisiirto lokaalisti ja laske uusi pelitila.
  2. Lähetä uusi lokaalisti laskettu pelitila kaikille muille pelin pelaajille.
  3. Vastaanota muiden pelaajien vastaavalla tavalla laskettu uusi pelitila.
  4. Yhdistä eri pelaajien pelitilat yhteen, ja laske niistä uusi yhdistetty pelitila.
  5. Renderöi yhdistetty pelitila ruudulle, ja jää odottamaan uutta pelaajan komentoa/pelisiirtoa.

Ongelmana tässä algoritmissä on kohta 4, joka saattaa – pelistä riippuen – olla joko mielipuolisen vaikea tai suorastaan mahdoton suorittaa. On helppo kuvitella tilanne, jossa kahden eri pelaajan tekemät pelisiirrot ovat lokaalisti (siis yksittäin tarkasteltuna) laillisia, mutta niiden yhdistelmä on laiton.

RTS === vuoropohjainen?

Ratkaisu tähän “lokaalisti laillinen – globaalisti laiton” -ongelmaan on pakottaa pelaajat tekemään siirrot vuorotellen.

Tai, ellei teknisesti ihan vuorotellen, niin ainakin vuoroja hyödyntäen.

Tämä kuulostaa liian tiukalta vaatimukselta monelle pelityypille, esimerkiksi AoE2:n kaltaiselle real-time-strategy (RTS)-pelille. Koko RTS:n pointti kun on olla real-time; vuoropohjaisten pelien ystäville on jo Civilization-saaga.

On kuitenkin huomattava, että on kaksi eri asiaa olla aidosti real-time versus näennäisesti real-time.

Age of Empiresin kaltainen RTS-peli käyttää konepellin alla itseasiassa diskreettejä pelivuoroja, mutta vuorojen varsinainen pituus on varsin lyhyt, ja muutamaa kikkaa hyödyntäen niiden pituus saadaan vaikuttamaan kuin vuoroja ei olisi lainkaan.

Homma toimii näin. Pelin kulku koostuu pelivuoroista, joiden aikana kukin pelaaja voi tehdä N määrän pelisiirtoja. Erona Civilization-peliin on lähinnä se, että eri pelaajat tekevät siirtonsa saman pelivuoron aikana. Siinä missä Civilizationissa kullakin pelaajalla on oma pelivuoronsa, jonka aikana muut pelaajat kiltisti odottavat, Aoe2-pelissä kaikki pelaajat jakavat yhden globaalin pelivuoron kerrallaan.

Lisäksi AoE2:n pelivuoro on siitä ikävä, että se ei odota pelaajaa (toisin kuin aidoissa vuoropohjaisissa peleissä); jos pelaaja ei ehdi tekemään pelisiirtoa pelivuoron aikana, se on pelaajan oma ongelma.

AoE2:n pelivuorolla on nimittäin ajallinen pituus, joka on vakiona 200 millisekuntia. Näin lyhyt siirtovuoro on tarpeen, jotta peli saa luotua illuusion reaaliaikaisuudesta.

Kahdensadan millisekunnin pituus on luonnollisesti muutettavissa riippuen pelaajien nettiyhteyksien nopeudesta. Arvoa voi skaalata suuntaan tai toiseen jopa yksittäisen pelin ollessa käynnissä. Toimintaperiaate muistuttaa TCP-protokollan flow-kontrollia.

Mutta hetkinen, 200 ms on siltikin järjettömän pitkä aika tietokonepelin kontekstissa. Jos itse peli pyörisi 200 millisekunnin render-loopilla, pelin ruudunpäivitystahti (FPS) olisi viisi.

Siis 5 ruudunpäivitystä sekunnissa. Eli puhdas slideshow.

Mikä siis lopulta pyörii 200 millisekunnin vauhdilla?

Pelisiirrot vs. pelilogiikka

Ainoastaan pelivuorot. Pelilogiikan sisältävä game-loop pyörii 30 FPS:n nopeudella.

Homma toimii suunnilleen näin: kukin pelaaja tekee annetun pelivuoron (200ms) aikana niin monta pelisiirtoa kuin ehtii. Kun pelivuoro päättyy, tehdyt siirrot talletetaan listaksi ja lähetetään kaikille muille pelaajille. Vastaavasti pelaaja vastaanottaa kaikkien muiden pelaajien pelisiirrot.

Kun tämä valtava – kukin pelaaja lähettää omat siirtonsa kullekin toiselle pelaajalle – lähetysoperaatio on tehty, kullakin pelaajalla on nyt identtinen lista pelivuoron aikana globaalisti tehdyistä pelisiirroista. Nyt seuraa paras kohta; kukin pelaaja lokaalisti päivittää oman pelitilansa annettujen pelisiirtojen perusteella.

Ja koska kaikilla pelaajilla on identtinen lista siirtoja ja identtinen pelitila ennen päivitystä, päätyvät kaikki pelaajat identtiseen pelitilaan siirtopäivitysten jälkeen mikäli pelilogiikka toimii 100% deterministisesti.

Asian voi havainnollistaa shakkipelillä: pelaajat A ja B aloittavat shakkipelin. Pelaaja A tekee siirron ja lähettää sen B:lle. Tarvitseeko A:n lähettää siirron mukana myös uusi peliasema? Ei, sillä shakkipeli on täysin deterministinen. Ja shakkipelin alkuasema on kirjoitettu shakin sääntöihin, joten se on identtinen ja molempien pelaajien tiedossa.

Shakin deterministisyys mahdollistaa mielenkiintoisia pelimuotoja, jotka eivät ole mahdollisia esimerkiksi Afrikan Tähdessä. Kaksi vahvaa shakinpelaajaa voi pelata shakkipelin ilman lautaa ja nappuloita; he sanovat vuorotellen siirrot toisilleen. Tämä on sokkoshakkia. Hurjimmat pelaavat sokkoshakkia vaikka kesken tennisottelun.

AoE2:n puolella pelin alkutilanne ei ole osa pelin sääntöjä (eikä täten identtinen pelikerrasta toiseen), joten pelin alkaessa alkuasema täytyy synkronoida kaikkien pelaajien kesken. Tämä on ainoa hetki, jolloin online-moninpelin pelaajat päivittävät pelitilansa globaalia tilamuuttujaa hyödyntäen. Globaalina tilamuuttujana voi toimia joko moninpelialusta (esim. Steam tai Voobly?) tai joku yksittäinen pelaaja, joka hetkellisesti ottaa host-roolin.

Tätä mallia kutsutaan nimellä “deterministic lockstep”-malli. Mallilla on vankka teoreettinen pohja, ja se toimii kuin junan vessa.

Back to the earth – käytännön haasteet

Toimii kuin junan vessa teoriassa, siis.

Käytännössä mallin saaminen toimimaan vaatii pelistä riippuen joko vähän töitä tai aivan saatanasti töitä. Shakki on esimerkki ensin mainitusta, AoE2 jälkimmäisestä. Jo pelkästään AoE2 pelivideota katsomalla huomaa, että pelissä tapahtuu valtavasti asioita.

Jotta deterministic lockstep toimii, täytyy koko pelimekaniikan olla deterministinen. Tämä tarkoittaa, että jokaikisen saksanhirven (AI-ohjattu) liikeradan, jokaisen keihään lentoradan, jokaisen läpi sokkeloisen metsäpolun lasketun kulkuradan (unit pathing)… kaikkien on toimittava identtisesti kaikilla kahdeksalla pelaajalla.

Satunnaislukugeneraattori lentää ensimmäisenä roskakoriin, sillä jos yksikin osa pelimekaniikasta perustuu sattumaan, koko moninpeli on pilalla. Tilalle tulee pseudo-satunnaislukugeneraattori, joka alustetaan pelin alussa seedillä. Kaikilla pelaajilla on luonnollisesti oltava sama seed, jotta generaattorin tuottamat “satunnaisluvut” ovat ei-satunnaisia, eli samat kullakin pelaajalla.

Mallin edut

Yksinpeli vai moninpeli - who cares?

Koko pelin deterministisyyden varmistaminen on pirullisen moninmutkainen ongelma. Mutta jos ongelma ratkotaan, moni muu asia tulee ikäänkuin ilmaiseksi.

Esimerkiksi online-moninpeli typistyy lopulta lokaaliksi moninpeliksi tai yksinpeliksi isoa joukkoa AI-pelaajia vastaan, sillä AoE2-peli-instanssin ei tarvitse välittää mistä lähteestä pelisiirrot tulevat. Kaikki pelimuodot toimivat pelimoottorin näkökulmasta identtisesti; pelimoottori ottaa vastaan siirtoja, ja thats it. Siirtojen alkuperä ei pelimoottoria kiinnosta.

Halpa tiedonsiirto

Deterministic lockstep -mallin toinen valtava etu on, että internet-yhteyden yli siirrettävä tietomäärä on verrattaen vähäinen.

Vahva AoE2 pelaaja ehtii yhden siirtovuoron (sanotaan vaikka tuo 200 millisekuntia) aikana tekemään ehkä 3-4 siirtoa mikäli pelitilanne on oikein hektinen. Jokainen näistä siirroista on komento, joka sisältää ainoastaan tarvittavan tiedon komennon suorittamiseksi kaikkien online-pelin pelaajien tietokoneilla. Yksinkertaisimmillaan komento voisi siis olla:

{
  type: “move”,
  unit: “knight_8282”,
  to: {x: 672, y: 992}
}

Komento sisältää kaiken tarvittavan tiedon; ritarihahmo, jonka ID on knight_8282, siirtykööt lokaatioon 672,992. Tämän tiedon perusteella kukin pelaaja voi päivittää pelitilansa; kunkin pelaajan AoE2-peli laskee unit path-algoritmin avulla reitin ritarin nykyisestä lokaatiosta uuteen lokaatioon.

Ja koska kaikki AoE2-peli-instanssit käyttävät luonnollisesti samaa unit path-algoritmia, on koko laskettu reitti identtinen kaikilla pelaajilla.

Komennon koko JSON-tekstinä (auttamattoman kookas dataformaatti) on hädin tuskin 50 tavua.

Kyseessä on siis todella suorituskykyinen multiplayer-arkkitehtuuri. Hyvä niin, sillä internet-yhteydet vuonna 1999 eivät olleet kummoisia.

Deterministic lockstep-mallin onnistunut käyttö AoE2-pelissä vaatii taustamekaniikkaa, ja tässä blogikirjoituksessa raapaistiin vain pintaa. AoE2-pelin arkkitehtuurin ydinajatukset löytyvät täältä: https://www.gamasutra.com/view/feature/131503/1500_archers_on_a_288network.php

Promise yli netin

Promise on hieno keksintö. Se mahdollistaa asynkronoidun operaation odottamisen yli yksittäisen Javascript-tapahtumaloopin pyörähdyksen (tick), ja tekee mm. virhetilanteiden hallinnasta helppoa.

Useimmissa tilanteissa Promise hoitaa kaiken koordinoinnin automaattisesti ohjelmoijan puolesta; ohjelmoijalle riittää kirjoittaa Promise-kutsu ja haluttu koodi, joka ajetaan Promisen täytyttyä.


import Promise from 'bluebird'

Promise.resolve("Kutsuttava async-operaatio")
.then(function() {
	console.log("Ajettava koodi")	
})

Mutta jotta ylläoleva toimisi ja tarjoaisi helppokäyttöisen API:n ohjelmoijalle, täytyy pinnan alla tapahtua aika paljon. Promise-objektin täytyy sisällään koordinoita sille annettujen callback-funktioiden kutsumista.

Entä jos Promisen suorittama asynkronoitu operaatio suoritetaan internet-yhteyden yli, siis osana operaatiota otetaan yhteys johonkin toiseen tietokoneeseen. Esimerkkinä seuraavan internet-moninpeli-applikaation koodinpätkä:


// Applikaatio kuvaa kaksinpeliä, jossa pelaajat
// tekevät vuorotellen siirtoja.

// Pelin business-logiikka.	
var game = new Game();

var loopMoves = function(player1, player2) {
	var askForMove = function(player) {
		// Palauttaa Promisen, joka odottaa pelaajan tekevän siirron.
		return player.makeMove()
		.then(function(move) {
			// Tee siirto ja vahvista sen laillisuus
			var legal = game.applyMove(move);

			if (!legal || game.gameOver()) {
				throw new GameOver();
			}
		});
	}
	// Pelaajan 1 siirtovuoro
	return askForMove(player1)
	// Pelaajan 2 siirtovuoro
	.then(askForMove.bind(null, player2))
	// Jos peli ei päättynyt, looppaa takaisin
	// jotta pelaajat voivat tehdä uudet siirrot.
	.then(loopMoves.bind(null, player1, player2);
}

// Player1 ja player2 tulevat ulkoa.
loopMoves(player1, player2)
.catch(GameOver, function(gameOver) {
	console.log("Game over");
})

Ylläolevan kaltainen koodi tekee game-loopin kirjoittamisesta helppoa online-multiplayer-pelille. Kaiken ytimessä on kutsu player.makeMove(), joka palauttaa Promisen, joka puolestaan täyttyy pelaajan antamalla siirrolla.

Mutta miltä tuo makeMove-funktio näyttää? Ongelmana on, että makeMove-funktion tulee ottaa yhteys yli internetin siihen pelaajaan, jonka siirtovuoro on kyseessä. Tyypillisessä arkkitehtuurissa tuo yhteys on TCP-yhteyden välityksellä, web-applikaatioissa lähes poikkeuksetta WebSocket-protokollan avulla.

WebSocketin käyttö osana siirtovuoro-Promisea vaatii jonkin verran koordinointia. Tarvitsemme tavan yhdistää pelaajalle lähetetty pyyntö (“tee siirto”) myöhempään sisääntulevaan vastaukseen (“tässä siirtoni”). Ongelmana on, että pelaaja voi saada näiden kahden ajanhetken välillä useita eri viestejä palvelimelta, ja kaikki viestit välitetään samalla WebSocket-yhteydellä.

Tästä syystä meidän täytyy jotenkin tallentaa palvelimen päässä tieto lähetetystä siirtovuoro-pyynnöstä, ja myöhemmin osata yhdistää sisääntullut vastaus aiempaan pyyntöön, jotta voimme täyttää siirtovuoro-Promisen (joka makeMove-metodista palautetaan):


function Player(webSocket) {
	// Esim. socket.ion tuottama socket-objekti.
	this.webSocket = webSocket;

	this.init = function() {
		// Ohjaa socketista tulevat siirtoviestit omaan receive-metodiimme.
		this.webSocket.on('answerToMakeMove', this.receiveMoveFromClient.bind(this));
	}

	// Tämä objekti pitää kirjaa pelaajan suuntaan lähetetyistä pyynnöistä,
	// joihin pelaaja ei ole vielä antanut vastausta.	
	this.pendingMoveRequests = {};	

	this.makeMove = function() {
		var moveRequestId = generateUUID(); 

		return new Promise(function(resolve, reject) {
			// Talleta resolve-callback, jotta voimme myöhemmin
			// löytää sen ja palauttaa pelaajalta saadun vastauksen
			// alkuperäiselle kutsujalle.
			this.pendingMoveRequests[moveRequestId] = resolve;

			// Lähetä tieto pelaajalle 
			this.webSocket.emit('makeMove', {
				answerId: moveRequestId
			});
		}.bind(this))
	}

	this.receiveMoveFromClient = function(moveMsg) {
		var answerTo = moveMsg.moveRequestId;
		var move = moveMsg.move;

		// Etsi resolver hyödyntäen clientin mukana kuljettamaa moveRequestId-arvoa.
		if (this.pendingMoveRequests[answerTo]) {
			var resolver = this.pendingMoveRequests[answerTo];
			delete this.pendingMoveRequests[answerTo];

			// Tämä täyttää Promisen, joka aikaa sitten palautettiin makeMove-metodista.
			resolver(move);
		}
	}
}

Ylläoleva vaatii clientin puolella sen, että client käyttää saamaansa moveRequestId-tunnistetta antaessaan vastauksen takaisin palvelimen suuntaan. Jos client tämän muistaa tehdä, voimme palvelimen puolella helposti matchata lähetetyn siirtopyynnön ja sisääntulleen siirtovastauksen toisiinsa.

Itse ylimmällä tasolla voimme laittaa pelin käyntiin esim. seuraavasti:


var p1;
var p2;
var game = new Game();

// Socket.io odottaa sisääntulevia yhteyksiä
socketio.on('connect', function(socket) {
	// Aseta disconnect-handler.
	socket.on('disconnect', function() {
		// Client on sulkenut yhteyden
		if (game.running()) {
			game.end();
		}
	});

	if (!p1) {
		// Ensimmäinen pelaaja
		p1 = new Player(socket);
		return;
	}

	// Toinen pelaaja
	p2 = new Player(socket);

	game.startGame();
  p1.init();
  p2.init();

	// Molemmat pelaajat paikalla, aloita siirtojen looppaus.
	loopMoves(p1, p2)
	.catch(GameOver, function() {
		// Peli päättynyt, disconnectoi pelaajat
		p1.webSocket.disconnect();
		p2.webSocket.disconnect();
	});
});


Ohjelmistoprojektin koordinointi ja psykologia (osa 1)

Vaativan ohjelmistokehitys on mentaalisesti raskasta ja kuluttavaa puuhaa. Tyypillinen ohjelmistoprojekti koostuu tuhansista ja tuhansista riveistä koodia. Abstraktion tasosta ja applikaation luonteesta riippuen koodin pystyy jaottelemaan suurempiin paloihin - ja tällä tavoin hahmottamaan kehitysprosessin abstraktioiden yhdistelynä ja muovaamisena - mutta abstraktoiminen ja “black box” -ajattelu ovat lähinnä optimisaatioita, eivät ratkaisuja.

Mitä suuremmaksi ja vaativammaksi ohjelmistoprojekti paisuu, sitä enemmän se sisältää liikkuvia osia kaikilla abstraktion tasoilla.

Yksittäisten funktioiden määrä kasvaa kasvamistaan, mutta tämä kasvu on ongelmista pienin, sillä suurin osa funktioista elää kiltisti jonkin ylemmän tason abstraktion sisällä.

Suurempi ongelma on, että abstraktion ylimmällä tasolla komponentit yhä enemmän kytkeytyvät toisiinsa. Ne siis entistä tiiviimmin kiinnittävät limaiset lonkeronsa toistensa sisuskaluihin.

Tämä on seurausta kahdesta erillisestä ilmiöstä:

Ajallinen ulottuvuus (a.k.a “hyvätkin ideat tuppaavat unohtumaan”)

Ohjelmistoprojektin alkuvaiheessa kokonaisarkkitehtuuri on tuoreena mielessä, ja koodin määrä on vähäinen, joten arkkitehtuurillisesti kauniit/järkevät ratkaisut ovat helppoja. Mitä pidempään projekti jatkuu, sitä häilyvämmäksi applikaation arkkitehtuuri muuntuu ohjelmoijan pään sisällä. Alunperin kirkkaana ollut idea pikkuhiljaa häviää harmaan sumuverhon taakse.

Psykologinen ulottuvuus (a.k.a “kuka idiootti tämänkin on kirjoittanut”)

Vaativan ohjelmistoprojektin hieno asia on, että se kehittää ohjelmoijaa aivan helvetisti. Kuusi kuukautta projektin aloituksen jälkeen ohjelmoija katsoo koodiaan, jonka on itse kirjoittanut kuusi kuukautta aiemmin, ja naurahtaa: ei jumalauta, olinpa uskomaton amatööri.

Tämä on tietenkin hieno tunne, mutta psykologisesti sillä on ikävä seuraus; ohjelmoija alkaa alitajuntaisesti halveksua aiempaa, amatöörimäistä koodiaan ja haluaa pysyä siitä erossa. Mutta koska projekti jatkuu ja vaatii lisäkehitystä, ohjelmoijan täytyy elää oman menneisyytensä kanssa. Tämä on psykologisesti yllättävän raskasta. Kun uusi ja parempi ratkaisu on materialisoitunut ohjelmoijan pääkoppaan, on lähes mahdoton jättää vanha, huonon ratkaisun sisältävä koodi rauhaan.

Tämä psykologinen inho omaa koodiaan kohtaan johtaa siihen, että ohjelmoija ei jaksa nähdä vaivaa sen eteen. Hän olettaa, että ennemmin tai myöhemmin hän uudelleenkirjoittaa koko koodin. Pienten parannusten tekeminen on turhaa, sillä uudelleenkirjoitus nollaa parannukset kuitenkin. Ohjelmoija ryhtyy oikomaan mutkia, sillä ratkaisujen tekeminen oikeaoppisesti on turhaa työtä; parempi tehdä ratkaisut oikeaoppisesti sitten, kun koko koodi laitetaan kerralla uusiksi.

Perimmäinen syy ilmiöön numero 1 on ihmisen pitkäkestoisen muistin toiminta. Ilmiön 2 taustalla taas on kaikille kunnianhimoisille ihmisille tyypillinen perfektionismi yhdistettynä pakonomaiseen ajankäytön optimointiin ja ylianalysointiin.

Ilmiö 2 on kenties toiseksi suurin yksittäinen syy siihen, miksi fiksut ihmiset tuppaavat saamaan niin vähän aikaan työurallaan.

Suurin yksittäinen syy siihen, että fiksut ihmiset eivät saa ikinä mitään aikaan on tietenkin sosiaalinen media.

Mutta ei siitä sen enempää. Keskitytään ilmiöön 1.

Abstraktion eri tasot ja työmuisti

Työmuistin rajallinen koko aiheuttaa sen, että ohjelmoija joutuu kaikilla abstraktion tasoilla “paloittelemaan maailman” kouralliseen yksittäisiä konsepteja.

Mitä tarkoitan tällä?

Sitä, että työmuistiin on aina mahduttava koko tarkastelun alaisena oleva maailma kerrallaan.

Komennot (alin taso)

Alimmalla abstraktion tasolla huomiokyky (ja työmuistin sisältö) on keskittynyt asettelemaan yksittäiset koodikomennot järkevästi ja siten, että ne toimivat. Epävirallisesti voimme sanoa, että yksittäiset koodikomennot ovat palasia, joista funktiot ja metodit koostuvat. Tällä tasolla ohjelmointi on lähinnä komentojen syöttämistä mikroprosessorille, ja tarkastelun alaisena oleva maailma on yksittäisen komennon suorittaminen.

Funktiot

Ylöspäin mentäessä seuraavalla abstraktion tasolla ohjelmoija käsittelee funktioita. Jo tällä tasolla siirrytään pois raudan parista, ja käytetään näkökulmaa “mitä halutaan saavuttaa”, ei “miten halutaan saavuttaa”. Web-ohjelmoinnin piirissä tämä on käytännössä alin taso.

Web-ohjelmoija ei kerro tietokoneelle, miten HTML-elementti asetellaan ruudulle, vaan minne HTML-elementti asetellaan. Tietokone sitten ratkoo kaikki käytännön ongelmat, kuten yksittäisten pikseleiden värittämisen.

Tällä tasolla tarkastelun alaisena oleva maailma on esimerkiksi animaation pyöritys osana tietokonepeliä. Tyypillinen animaatio on kokoelma osa-animaatioita. Sanotaan vaikka, että meillä on animaatio nimeltä “vieteriukon ilmestyminen”. Tuon animaation osa-animaatiot ovat seuraavat: “avaa laatikon yläkansi, pompauta vieteriukko ulos”.

Kumpikin noista osa-animaatioista voi puolestaan koostua alemman tason osa-animaatioista. Jossain kohtaa sitten tullaan osa-animaatioon, joka kirjaimellisesti värittää näyttöpäätteen pikseleitä 60 kertaa sekunnissa, mutta oleellista on, että ylimmällä tasolla (“vieteriukon ilmestyminen”) emme välitä pikseleistä pätkän vertaa.

Ja koska emme välitä, eivät pikselit ja niiden värityksestä huolehtiminen rasita työmuistiamme.

Tämä on kaiken ohjelmoinnin perusta; tietyllä abstraktion tasolla emme välitä alemman tason toiminnoista. Otamme ne vastaan annettuina, ja sokeasti luotamme, että ne toimivat. Maaginen ohjelmointiguru Gerald Sussman (SICP, Scheme, ym.) kutsuu tätä termillä wishful thinking.

Moduulit ja komponentit

Ylöspäin mentäessä siirrytään joko moduuleiden (löyhästi kokoelma toisiinsa liittyviä funktioita) tai komponenttien (löyhästi erillinen palikka, joka kykenee itsenäisesti suorittamaan vaativia tehtäviä, esim. sähköpostin lähetyksen) tasolle. Tällä tasolla syntyy ensimmäistä kertaa kokonaiskuva (osa-)applikaatiosta, jota ollaan rakentamassa. Applikaatio koostuu komponenteista, jotka vuorovaikuttavat toistensa kanssa. Yhdistelemällä komponentteja ja rakentamalla informaatioväyliä komponenttien välille saavutetaan applikaatio.

Komponentin ja moduulin ero on tärkeä ymmärtää; moduuli on staattinen kokoelma koodia, jolla on jokin yhteinen tarkoitus olla olemassa. Komponentti on dynaaminen palikka, joka elää ohjelman ajon aikana ja suorittaa vastuulleen kuuluvia velvollisuuksia. Komponentti on siis ohjelman ajon aikana elävä asia; moduuli puolestaan on kasa koodia, joka “elää koodieditorissa”.

Ero on sama kuin Pythagoraan lauseella ja Kheopsin pyramidilla; Pythagoraan lause ei ole olemassa muuten kuin abstraktina sääntönä, jonka perusteella voidaan käsin kosketeltavia asioita (kuten pyramidit) rakentaa.

Osa-applikaatiot, rajapinnat ja palvelu-arkkitehtuuri

Abstraktion ylimmällä tasolla komponentit muodostavat kokonaisuuksia, joita voi kutsua “osa-applikaatioiksi”. Web-applikaatioissa esim. frontend vs. backend -jaottelu on tyypillinen esimerkki osa-applikaatioista; frontend on yksi applikaatio, backend on toinen, ja yhdessä ne muodostavat halutun “kokonaisapplikaation”, joka toivottavasti täyttää jonkin oikean maailman tarpeen. Useimmiten nämä osa-applikaatiot keskustelevat vastaavalla tavalla kuin me ihmisetkin; ne rimpauttavat toisilleen HTTP-protokollan (tai jonkin alemman, kuten TCP-protokollan) avulla ja kertovat kuulumisensa. Jokainen osa-applikaatio tarjoaa rajapinnan, johon muut osa-applikaatiot voivat soitella.

  • Osa 2 - Jatkuu huomenna… *

Kahden näytön työ-setup

Ammattimaisen koodaamisen perusedellytys on, että tukitoiminnot ja työkalut varsinaista koodin kirjoittamista ajatellen ovat kunnossa. Koodarin tärkein työkalu on luonnollisesti laitteisto, jolla koodia kirjoitetaan. Siis fyysinen tietokone ja jonkin sortin näppäimistö.

Oma työkaluni on vanha kunnon pöytäkone, joka hurisee hiljaa työpöydän alla. Koneen speksit eivät ole tärkeät, etenkään web-koodauksen puolella. Vanhakin prosessori riittää oikein hyvin, ja näytönohjain tarvitaan lähinnä usean näytön tukea varten (useimmissa web-sovelluksissa itse graafiikka on yksinkertaista eikä vaadi näytönohjaimilta suuremmin tehoja).

Tärkein osa laitteistokokonaisuutta on näyttöpäätteet, ja niiden konfigurointi maksimaalista tuottavuutta ajatellen. Seuraavassa oma ratkaisuni.

Kaksi fyysistä näyttöpäätettä, kahdeksan virtuaalista näyttöä

Työpöytäni näyttää tältä:

Työpöytä

Kaksi näyttöä vierekkäin, joista toinen on perinteinen vaakasuuntainen, toinen käännetty pystyyn.

Miksi toinen on vaaka-asennossa, toinen pystyasennossa? Näytöt palvelevat eri tarpeita. Vaakasuuntainen näyttö sisältää kivasti vaakasuuntaista tilaa, joten siihen sopii hyvin selainikkuna, tarvittaessa vaikka kaksi vierekkäin.

Pystysuuntainen näyttö taas sisältää rutkasti tilaa pystysuunnassa. Koodieditori soveltuu tälle näytölle mainiosti, sillä koodia kirjoittaessa on tärkeämpää nähdä monta koodiriviä kerrallaan kuin nähdä yhden pitkän koodirivin koko teksti.

Toisin sanoen, koodieditori puolella vertikaalinen tila on tärkeämpää kuin horisontaalinen. Pystynäytöllä saa nopeasti kokonaiskuvan isosta palasesta koodia, ja esimerkiksi moni yksittäinen kooditiedosto mahtuu näyttöruudulle kokonaisuudessaan, jolloin ei tarvitse skrollata. Horisontaalisesti tilaa on vähemmän, mutta koodirivit tuppaavat olemaan horisontaalisesti lyhyitä, joten tämä ei ole ongelma.

Näyttöpäätteiden tarjoama tila puolestaan jakautuu seuraavasti (per näyttöpääte):

Näyttöjen jaottelu

Koodieditori valtaa kokonaan pystynäytön. Vaakanäytöllä puolestaan on niin paljon horisontaalista tilaa, että olen laittanut vasempaan reunaan komentorivikehoitteen (siis terminaalin), ja oikealle laidalle selainikkunan.

Kuvasta asiaa ei näe, mutta itse asiassa selainikkuna jakautuu vielä kahteen osaan: itse varsinaiseen työskentelyalueeseen (“webbisivu-näkymään”) ja työkalupalkkiin (Chrome Dev Tools). Tämäkin jaottelu on horisontaalinen.

Tällä tavoin saan kahden näytön turvin luotua setupin, jossa pystyn näkemään koodieditorin ja koodattavan applikaation yhtäaikaisesti. Editori vasemmalla näytöllä, applikaatio oikealla näytöllä.

Mutta tämä on vasta alkua, sillä useimmat applikaatiot koostuvat sekä frontend-koodipohjasta että backend-koodipohjasta. Nämä kaksi koodipohjaa ovat erilliset, eivätkä millään mahdu yhteen koodieditoriin. Mikä avuksi?

Virtuaaliset näytöt (workspaces)

Ubuntussa on kiva konsepti nimeltä “workspace”.

Ubuntun help-sivuston kuvaus workspacesta: Workspaces refer to the grouping of windows on your desktop. You can create multiple workspaces, which act like virtual desktops. Workspaces are meant to reduce clutter and make the desktop easier to navigate.

Useamman kuin yhden Workspacen käyttö mahdollista ikäänkuin fyysisten näyttöjen monistamisen virtuaalisiksi näytöiksi.

Toistaiseksi olemme olettaneet, että käytössä on yksi workspace. Mutta Ubuntu sallii jopa neljän workspacen käytön. Tälläisessä tilanteessa meillä on kahdeksan virtuaalisen näyttöpäätteen verran tilaa.

Näyttöjen jaottelu

Vertauskuvallisesti voimme ajatella, että saamme kolme uutta kopiota koko työpöydästä (siis siitä puisesta työpöydästä, jolla fyysiset näyttöpäätteet seisovat) käyttöömme.

Tämä mahdollistaa asetelman, jossa yhden applikaation jokainen “osa-applikaatio” elää omassa workspacessaan. Ohjelmoija voi sitten pomppia workspacejen välillä nopeasti Ctrl+Alt+nuolinäppäin -komennolla.

Esimerkkinä oma tyypillinen workspace-struktuurini, kun kehitän vaativaa web-applikaatiota:

Kahdeksan virtuaalinäyttöä

Yksi virtuaalinen näyttöpari on varattu backend-koodille ja tietokantanäkymälle (esim. phpmyadmin). Toinen on varattu frontend-koodin käyttöön. Kolmas on varattu Slackille (mikäli koodaus vaatii muiden koodareiden kanssa kommunikointia; muussa tapauksessa koko workspace on tyhjä). Neljäs on varattu kaikelle ylimääräiselle hölynpölylle, kuten Youtube-näkymälle, josta kuunnella - fiiliksestä riippuen - vaikka huuhkajan huhuilua tai ammattilaiskäyttöön soveltuvaa koodausmusiikkia.

PaperJS: What does applyMatrix do?

PaperJs is great library for building scene hierarchies and virtual worlds (e.g. game worlds). It is somewhat beginner-friendly; the documentation could be better, but for the most part, PaperJS library simply does what is expected.

However, there is one big gotcha that tripped me over when I started using PaperJs; behaviour of applyMatrix -attribute.

Lets start with an example. I want to build a christmas-themed scene.

This scene is pretty simple; it has one single room, with nicely decorated Christmas tree standing in the middle of the room.

Something like this should achieve our setup of the scene:

  // Our room is equivalent to PaperJs global project coordinate system.
  // In other words, top-left corner of the room is point [0,0] in global space.

  // Lets create scene.
  // Start by creating a Group that holds all objects for our Christmas tree.
  var xmasTree = new paper.Group({});

  // Place the xmasTree Group to the middle of the room.
  xmasTree.position({x: 0.5 * roomMaxX, y: 0.5 * roomMaxY});

  // Create a tree 
  var tree = new paper.Path.Rectangle(
    // Tree's relative position within xmasTree group
    new paper.Point(0, 0),
    // Tree trunks size
    new paper.Size(20, 80)
  );
  tree.fillColor = 'green';

  // Add tree as a child of our xmasTree group
  xmasTree.addChild(tree);

  // Create tree decorations, and add to xmasTree group.
  // ...

Code above looks like it gets the job done. What we are doing above is:

  1. Create xmas tree group that’ll logically group together all individual objects (actual tree, christmas balls, candles, etc.) of the xmas tree.
  2. Place the group into the middle of the room.
  3. Add a tree to the group, and place it to relative (to the group!) position of {0,0}.
  4. Add decorations (not shown in the code)

Logically that should do it, but what you’ll see in the screen is something quite else.

Xmas tree NOT in the middle of the room

The actual tree (green rectangle) is of correct size, but it is not in the middle on the room!

What happened? We clearly specified that our Group object (xmasTree) is placed to middle of the room. Then we created child object for that group, and placed it to position {0,0} relative to the Group.

Or is it relative to the Group? If you look at the code closely, we specify tree’s position BEFORE specifying the tree is a child of the xmasTree group. Maybe you could solve the issue by setting tree’s position AFTER its group membership:


  // Start by creating a Group that holds all objects for our Christmas tree.
  var xmasTree = new paper.Group({});

  // Place the xmasTree Group to the middle of the room.
  xmasTree.position({x: 0.5 * roomMaxX, y: 0.5 * roomMaxY});

  // Create a tree 
  var tree = new paper.Path.Rectangle(
    // Tree's relative position within xmasTree group
    new paper.Point(0, 0),
    // Tree trunks size
    new paper.Size(20, 80)
  );
  tree.fillColor = 'green';

  // Add tree as a child of our xmasTree group
  xmasTree.addChild(tree);

  // NEW! Now that tree is a child or xmasTree, lets re-set tree's position!
  tree.position = {x: 0, y: 0};

  // Create tree decorations, and add to xmasTree group.
  // ...

Does this help? No. Nothing changes. Our green tree rectangle is still not in the middle of the room.

Xmas tree still NOT in the middle of the room

Next we might think: “hmm, what if we also re-set group’s position AFTER adding tree as its child”:

  // Start by creating a Group that holds all objects for our Christmas tree.
  var xmasTree = new paper.Group({});

  // Place the xmasTree Group to the middle of the room.
  xmasTree.position({x: 0.5 * roomMaxX, y: 0.5 * roomMaxY});

  // Create a tree 
  var tree = new paper.Path.Rectangle(
    // Tree's relative position within xmasTree group
    new paper.Point(0, 0),
    // Tree trunks size
    new paper.Size(20, 80)
  );
  tree.fillColor = 'green';

  // Add tree as a child of our xmasTree group
  xmasTree.addChild(tree);

  // NEW! Now that tree is a child of xmasTree, lets re-set tree's position!
  tree.position = {x: 0, y: 0};

  // MORE NEW! Now that tree is child of xmasTree, lets re-set group's position!
  xmasTree.position({x: 0.5 * roomMaxX, y: 0.5 * roomMaxY});

  // Create tree decorations, and add to xmasTree group.
  // ...

Does this help? Yes! Now the tree is in the middle of the room.

Xmas tree NOT in the middle of the room

So the problem was that our group’s global position got set too early; when we later added a tree (the green rectangle) as xmasTree’s child, group’s position did not propagate to its new child object. Thus, the tree-object got position relative to the global project space. Thats why it was right next to the screen edge in the first screeshot.

We - of course - want it to be positioned in terms of the xmasTree group; that is, we want xmasTree to create its own local coordinate space, and we want all child objects to be positioned relative to that space!

Understanding the difference between global coordinate space versus local coordinate space(s) is absolutely crucial; you can not work with PaperJs without ability to transform one space to another. Of course, all the calculations are being performed by PaperJS, but you should at least understand why local coordinate spaces are needed.

Think about our real world, and how it forms a hierarchy of local coordinate spaces. You have latitudes and longitudes, and those help you find - for example - a route to Tokyo. But when you are in the Tokyo, it is much more convenient to use some local coordinate space that is relevant only inside Tokyo. That coordinate space is probably arranged using street names etc.

Then, you go into a restaurant in Tokyo. Inside the restaurant you won’t use street names anymore. When a waiter gives you directions to restaurant’s toilet, she will talk in terms of restaurant’s local coordinate space: “take the stairs down and turn left, you’ll find our restroom there”.

So lets get to it. How do we create a local coordinate space that actually stays alive for more than a single function call?

applyMatrix = false

The name of game is this: paperJs Group-objects have an attribute named applyMatrix, which controls the lifetime of group’s local coordinate space!

In our code example, we did not care about applyMatrix-attribute, allowing paperJs to set it to whatever value it wants. And, perhaps bit questionably, paperJS uses applyMatrix = true as a default value (for Groups).

Setting applyMatrix to true means this: whenever we do some transform operation on the Group-level, that operation is instantly applied to Group’s children.

We have been using positioning as an example of more general concept called transform/translate operation. Positioning is not the only one; there are other transform/translate operations like scaling, rotating, skewing etc. Importantly, exactly same rules apply to all transform operations! All these individual operations combine into a concept called transformation matrix, and each PaperJS object has its own transformation matrix. This matrix is - very informally - a set of mirrors, lenses and magnifying glasses that define how the actual object looks from a particular point of view.

This means that if we set Group’s position to - lets say - {x: 20, y: 30}, what we are actually doing is setting the origin of the Group’s local coordinate space to global coordinate space point {x: 20, y: 30}.

Notice that this is exactly what we want! We want to define our group’s position in relative to the global space. However, with applyMatrix === true, *this new position is not stored anywhere in the Group object*; instead, for each child a new global position is calculated and object is rerendered when the position of the group is being set.

Now think about this - what happens if you set a new position for a Group with no children?

It is a no-op! Literally. Nothing happens. Because the group tries to calculate new position of each of its children, but there are none - thus there is nothing to calculate.

When you later add a child to the group, you might expect its position to be relative to the position of the group you previously set. But it can not be so. Because… applyMatrix is true means that the group does not store its own position in its own transformation matrix.

Its exactly like telling an Alzheimer’s patient to remember numbers 3 and 5. Later, we ask that same patient to sum up the two numbers he was told earlier with a number 2. What will he answer? 10? Nope. He will answer 2.

Taking all this into account, we come to a solution:


  var xmasTree = new paper.Group({});

  // Important!!! 
  // ApplyMatrix must be set false before setting position of the Group!
  xmasTree.applyMatrix = false;

  // Place the xmasTree Group to the middle of the room.
  xmasTree.position({x: 0.5 * roomMaxX, y: 0.5 * roomMaxY});

  // Create a tree 
  var tree = new paper.Path.Rectangle(
    // Tree's relative position within xmasTree group
    new paper.Point(0, 0),
    // Tree trunks size
    new paper.Size(20, 80)
  );
  tree.fillColor = 'green';

  // Add tree as a child of our xmasTree group
  xmasTree.addChild(tree);

  // Create tree decorations, and add to xmasTree group.
  // ...

Now everything works correctly and, importantly, does not depend on the order of setting group position versus child position. Whenever you add new child objects (Christmas balls, tree candles, presents under the tree, etc.) to our xmasTree group, they will get automatically positioned correctly.

Xmas tree NOT in the middle of the room

And more importantly, if you ever reposition our xmasTree object, all its children will “get carried” with the group. This is then just what we want.


  // Woman of the household decides xmasTree should be moved to the corner of the room   
  xmasTree.position({x: 0, y: 0});

  // Whole xmasTree is now correctly moved to the corner.

Quiz

Lets take a test.

Take a look of the following code snippets, and determine what is the position (in terms of the global space!) of the tree object.

1


  var xmasTree = new paper.Group({});
  // Group global position set to {x: 100, y: 0}, right?
  xmasTree.position = {x: 100, y: 0}

  var tree = new paper.Path.Rectangle(
    // Local (or is it?) position is {x: 0, y: 0}
    new paper.Point(0, 0), 
    // Size is irrelevant, lets say 1x1.
    new paper.Size(1, 1)
  );

  xmasTree.addChild(tree);

  // Whats the global x-coordinate offset of tree: 0 or 100?

2


  var xmasTree = new paper.Group({});
  
  var tree = new paper.Path.Rectangle(
    // Local (or is it?) position is {x: 0, y: 0}
    new paper.Point(0, 0), 
    // Size is irrelevant, lets say 1x1.
    new paper.Size(1, 1)
  );

  xmasTree.addChild(tree);

  // Group global position set to {x: 100, y: 0}, right?
  xmasTree.position = {x: 100, y: 0}

  // Whats the global x-coordinate offset of tree: 0 or 100?

3


  var xmasTree = new paper.Group({});

  // Group global position set to {x: 100, y: 0}, right?
  xmasTree.position = {x: 100, y: 0}

  xmasTree.applyMatrix = false;

  var tree = new paper.Path.Rectangle(
    // Local (or is it?) position is {x: 0, y: 0}
    new paper.Point(0, 0), 
    // Size is irrelevant, lets say 1x1.
    new paper.Size(1, 1)
  );

  xmasTree.addChild(tree);

  // Whats the global x-coordinate offset of tree: 0 or 100?

4


  var xmasTree = new paper.Group({});

  xmasTree.applyMatrix = false;

  // Group global position set to {x: 100, y: 0}, right?
  xmasTree.position = {x: 100, y: 0}

  var tree = new paper.Path.Rectangle(
    // Local (or is it?) position is {x: 0, y: 0}
    new paper.Point(0, 0), 
    // Size is irrelevant, lets say 1x1.
    new paper.Size(1, 1)
  );

  xmasTree.addChild(tree);

  // Whats the global x-coordinate offset of tree: 0 or 100?

Answers below…

… bit more…

Answers:

1: 0

Reason: applyMatrix = true, setting Group position too early is no-op!

2: 100

Reason: applyMatrix = true, setting Group position after adding child.

3: 0

Reason: applyMatrix = false, but it is set false AFTER group position setup.

4: 100

Reason: applyMatrix = false, and set false before anything else.

Tietokanta per asiakas

Tyypillinen pieni/keskisuuri Laravel-applikaatio rakentuu yhden tietokannan päälle. Tuo yksi tietokanta sisältää kaiken datan, jota Laravel-sovellus tallentaa/käyttää.

Tyypillinen web-applikaatio kuitenkin tarjoaa käyttöoikeuden usealle erilliselle käyttäjälle/loppuasiakkaalle. Varsin yleinen tapaus vieläpä on, että kunkin loppuasiakkaan data elää täysin erillään muiden asiakkaiden datasta. Tällöin jokainen asiakas muodostaa oman universuminsa tietokannan sisälle; useimmiten tämä “privaatti maailma” rakennetaan käyttämällä avokätisesti viiteavaimia (foreign key).

Näitä viiteavaimia sitten ripotellaan ympäri tietokannan rakennetta; lähes jokainen tietokantataulu sisältää sarakkeen, jossa viiteavain määrittelee kenen asiakkaan universumiin kyseinen tietue (rivi) kuuluu.

Toinen vaihtoehto on tehdä asiat konseptuaalisesti yksinkertaisemmin; annetaan jokaiselle asiakkaalle oma tietokanta!

Tällöin viiteavaimia ei tarvita, sillä yksittäisessä tietokannassa on aina vain yhden asiakkaan data.

Tietokannan luominen jokaiselle asiakkaalle erikseen sisältää paljon hyviä puolia. Mutta kuten aina, trade-off on olemassa. Hyvä kokonaiskatsaus näihin kahteen eriävään strategiaan löytyy esim.: https://stackoverflow.com/questions/18910519/pros-cons-using-multiple-databases-vs-using-single-database

Tutkitaan seuraavaksi, miten Laravel-applikaatio voidaan rakentaa käyttämään yhtä tietokantaa per asiakas.

Tietokanta === subdomain

Yksi erinomainen tapa mahdollistaa usean tietokannan käyttö järkevästi on kytkeä looginen yhtäläisyys tietokannan ja alidomainin välille.

Tämä tarkoittaa, että esimerkiksi domain nokia.app.fi valitsee käyttöönsä Nokia-tietokannan, ja atria.app.fi valitsee käyttöönsä Atria-tietokannan. Molemmat asiakkaat (Nokia ja Atria) jakavat yhteisen Laravel-applikaatiopalvelimen, ja mahdollisesti myös fyysisen tietokantapalvelimen, mutta Laravel valitsee kunkin sisääntulevan palvelupyynnön yhteydessä sopivan tietokannan dynaamisesti.

Koodirajapinnan tasolla tämä voisi näyttää kutakuinkin tältä:


// routes/api.php

Route::group(['domain' => '{company}.' . ENV('APP_DOMAIN')], function() {
	
	Route::get('/users', 'UserController@all');

}); 

Route-tiedostomme siis ottaa alidomainin sisään dynaamisena muuttujana. Tuota muuttujaa voidaan käyttää Controllerin puolella:

// Controller/UserController.php

class UserController extends Controller
{

    public function index(Request $request, $company) {

    	if ($company === 'nokia') {
    		// Käytä Nokian tietokantaa
    	} else if ($company === 'atria') {
    		// Käytä Atrian tietokantaa.
    	}

    	// Tässä kohtaa Eloquent on kytketty oikeaan tietokantaan.

    	return User::all();
    }


 }

Ikävää ylläolevassa on tietenkin se, että meidän tarvitsee jokaikisessä Controllerissa tehdä tietokannan valinta. Helpompaa on siirtää tietokannan dynaaminen valinta middlewareen:


// Http/Kernel.php

class Kernel extends HttpKernel
{
	//... muita asetuksia...

    protected $middlewareGroups = [
        'api' => [
            \App\Http\Middleware\ValitseTietokanta::class, 
            'throttle:60,1',
            'bindings',
        ]
    ]; 

    // ... muita asetuksia...
}


// Middleware/ValitseTietokanta.php

class ValitseTietokanta
{

    public function handle($request, Closure $next)
    {
        $company = $request->route('company');

    	// Määritämme globaalin vakion, jota voidaan käyttää
    	// missä tahansa applikaatiokoodissa. Tällä tavoin
    	// mikä tahansa funktio saa tarvittaessa tietoonsa minkä
    	// asiakkaan kontekstissa se suoritetaan.
        if (!defined('COMPANY_SUBDOMAIN')) {
            define('COMPANY_SUBDOMAIN', $company);
        }

        // Ylikirjoita default-config.
        \Config::set('database.connections.mysql.database', 'appi_db_' . $company);
        // Ota uusi tietokantayhteys
        \DB::reconnect('mysql');

        return $next($request);
    }
}

Ylläoleva tekee tietokannan valinnan jokaiselle API-routelle. Se ei tee suuremmin virhetilanteiden hallintaa. On mahdollista, että tietokantaa ei ole olemassa. Tällöin myöskään alidomainia ei pitäisi olla olemassa, eli ympäröivän www-palvelimen tulisi estää sisääntuleva yhteys.

Ylläoleva tarvitsee vielä config-tiedostoon lisäyksen.

// config/database.php

return [

	// muita asetuksia

    'connections' => [


        'mysql' => [
            'driver' => 'mysql',
            'host' => env('DB_HOST', 'localhost'),
            'port' => env('DB_PORT', '3306'),
            // Tämä attribuutti korvataan middlewaressa.
            'database' => env('DB_DATABASE', 'appi_db_default'),
            'username' => env('DB_USERNAME', 'forge'),
            'password' => env('DB_PASSWORD', ''),
            'charset' => 'utf8',
            'collation' => 'utf8_unicode_ci',
            'prefix' => '',
            'strict' => true,
            'engine' => null,
        ],
];

Homma toimii siten, että middlewaressa ylikirjoitamme database-attribuutin mysql-configista. Ylikirjoituksen jälkeen kutsumme DB::reconnect(), joka lataa (muunnetun) configin uusiksi ja ottaa uuden tietokantayhteyden.

Ylläoleva koodiesimerkki tekee ikävän oletuksen siitä, että kaikki asiakkaat käyttävät tietokannassa samaa salasanaa, tunnusta ja hostia. Tämä estää tietokannan siirtämisen ulkoiselle palvelimelle, esimerkiksi asiakasyrityksen omalle palvelimelle.

Äärimmäinen dynaamisuus on saavutettavissa siten, että luomme erillisen taulun “_asiakkaat”, jonne tallennamme tiedot kunkin asiakkaan tietokannasta. Tämän jälkeen middlewaressa asetamme kaikki mysql-configin attribuutit asiakastietokannan asetusten mukaisiksi.

Mutta minne luomme “_asiakkaat”-taulun? Nokian vai Atrian tietokantaan? Ei kumpaankaan. Loogisin paikka on erillinen admin-tietokanta, joka on rakenteeltaan erilainen kuin asiakkaiden tietokannat. Toinen vaihtoehto on käyttää .env-tiedostoa, ja tunkea kaikkien asiakkaiden tietokantatiedot sinne. Tärkeintä on, että asiakkaiden tietoja ei päästetä versiohallinnan piiriin, eli config/database -tiedostoon niitä EI saa laittaa.

Beware JS accumulating math inaccuracies

One of the fun things about programming is that math operations on floating point values are inherently inaccurate. This can be seen in Javascript:


var b = 0.362 * 100;

console.log(b); // 36.199999999999996

Math operation above should produce 36.2, but instead it spews out something else. It is not a large inaccurary, but it is an inaccuracy nevertheless.

Of course, what is “large” is relative.

Most of the time those small inaccuracies do not cause any troubles; after all, Javascript is not meant to be used in high-precision scientific computing. Javascript is a scripting language for the Web.

However, as always, there is a big gotcha to watch out for: accumulating inaccuracies during render loop.

Small inaccurary turns into a big one

Here is an example how things can quickly go haywire:


var c = 0.362 * 100 - 35.2; // Should produce value 1

var frames = 60 * 60; // One minute at 60 FPS

while (frames--) {
  // 1 * 1 should be 1, thus c should never change!
  c = c*c;
}

// c should be 1, but...
console.log("Eventual c: " + c); // 0


In the code above we are running a simulated game loop. Every loop run simply multiplies c by itself. As this is supposed to be game loop, it spins approximately 60 times a second.

What happens is that originally small and meaningless inaccuracy quickly accumulates itself into a devastating error. At the end of the loop, variable c contains value zero.

This is a type of bug that will certainly cause troubles within your program. First of all, it is pretty hard to find in testing because of its accumulating nature.

Like multithreading bugs, likelihood of the bug appearing increases with the duration of the program has been running.

But again, above still seems pretty theoretical example. Does this bug really cause troubles in practice?

Yes. I had this bug happen in my Javascript game. I was using PaperJs library, and this bug periodically messed up scales of my PaperJS objects. Code causing troubles was (loosely) like this:


var paperObject = new paper.Circle(...);

// This gets called on every render frame.
function setScaleToObject(newScale) {
	paperObject.scaling = {
		x: newScale,
		y: newScale
    };
}

Setting scale-values right into paperJS object caused problems. Because, for example, if I expected newScale to be 1 but it instead was 0.999999999, PaperJs would store 0.999999999 to its internal data structures. And then somehow that value got repeatedly multiplied until suddenly object just disappeared from the screen.

Sudden disappearance is due the fact that the inaccuracy grows slowly at first, but eventually it reaches “critical mass” and starts to grow exponentially.

For example: 0.99999 ^ 2 is still pretty close to 0.99999, but 0.9 ^ 2 is clearly different (0.9 vs 0.81).

If you think about this in terms of pixels, 0.99999 ^ 2 multiplied by 1000 pixels still rounds to 1000 pixels. But 0.9 ^ 2 multiplied by 1000 pixels is only 810 pixels. A huge difference.

What happened is this: PaperJs internal scale value hit zero. This was extremely strange because I could always be certain that newScale was not zero. Thus I was explicitly setting object’s scale to non-Zero value.

But setting scaling-attribute did not reset actual matrix scale.

Instead, somehow, setting that scaling-attribute directly caused underlying PaperJs matrix object to become instable, and slowly to drift away from the wanted value (newScale).

The fix I used to avoid accumulating errors was to introduce auto-correction to the code. And stop setting scale-value directly to paperJS object:


var paperObject = new paper.Circle(...);

function scaleObject(newScale) {
	
	// We use objects current scale to auto-adjust our scale change.
	var currentScale = paperObject.getScaling().x;

	// We know currentScale and newScale; now we can calculate how much to scale
	// to achieve newScale given currentScale.
	// This achieves auto-correction!!
	var change = newScale / currentScale;

	paperObject.scale(change);
}

The code above is auto-correcting; meaning that if currentScale starts to drift away from expected exact value (e.g. 0.99999 vs 1), our change calculation will take it into account. This saves the day.

Kaikki tapahtumat vievät try-catchiin

Tyypillinen UI-lähtöinen web-applikaatio perustuu nk. event-driven paradigmaan. Tämä tarkoittaa, että applikaation oleelliset toiminnallisuudet suoritetaan tapahtumien (events) seurauksena.

Esimerkkinä: kun käyttäjä klikkaa hiirellä ikonia, syntyy tapahtuma. Tuo tapahtuma aiheuttaa jonkin toiminnallisuuden suorittamisen applikaation sisällä. Kun toiminnallisuus on suoritettu, applikaatio menee horrostilaan odottamaan seuraavaa tapahtumaa.

Tapahtumakeskeiset applikaatiot tupataan koodaamaan tapahtumakuuntelijoiden ympärille. Tyypillinen UI-applikaatio on pohjimmiltaan pelkkä kasa kuuntelijoita, jotka suorittavat toimintoja. Tyypillinen ylätason arkkitehtuuri on seuraavanlainen:


// App contains all the business code and logic.
var app = new App();

var listeners = {

	onEventX: app.api.doSomething,
	onEventY: app.api.doSomethingElse,
	onEventZ: app.api.doThirdThing,
	//...
}

// Bind listeners to device, allowing User to interact 
// with our App by pressing buttons on the device etc.
device.registerListeners(listeners);

Ylläoleva on karkea kuvaus siitä, miten käytännössä kaikki graafisen käyttöliittymän omaavat applikaatiot toimivat.

Entä miltä näyttää tuollaisen applikaation suoritus-/ajohistoria? Tapahtumia odottaville applikaatiolle on nyrkkisääntönä tyypillistä, että ne kirjaimellisesti odottavat valtaosan ajasta. Tämä johtuu siitä, että tyypillinen applikaatio käsittelee sisääntulleen tapahtuman silmänräpäyksessä.

Esimerkiksi tyypillinen tekstieditori - sanotaan vaikka Microsoftin Notepad - istuu ja odottaa vähintään 99% elinkaarestaan toimettomana. Joka kerta kun tekstieditorin käyttäjä - siis ruudun edessä istuva ihminen - painaa näppäimistöllä nappulaa, tekstieditori herää ruususen unestaan ja suorittaa toimenpiteen. Tekstieditorin tapauksessa toimenpide on useimmiten käyttäjän näppäimistöllä painaman kirjaimen tallentaminen keskusmuistiin ja piirtäminen ruudulle. Aikaa tuohon kuluu ehkä parisenkymmentä *mikro*sekuntia (sekunnin miljoonasosa!), jonka jälkeen tekstieditori siirtyy takaisin unten maille.

Ajohistorian toinen hauska piirre on, että kaikki suoritusajot lähtevät liikkeelle tapahtumahallinnasta. Tämä on väistämätöntä, sillä juuri tapahtumahallinta vastaanottaa sisääntulleen tapahtuman ja kutsuu applikaation varsinaisen bisneslogiikan sisältämiä funktioita.

Tämä “tapahtumalähtöisyys” antaa mainion tavan organisoida loki- ja virhehallinta! Koska kaikki suoritusajot lähtevät liikkeelle tapahtumien kautta, voi näppärä koodari luoda putken, jonne kaikki tapahtumat ajetaan.

Putken toisessa päässä odottaa itse applikaatio. Kun putkeen työntää tapahtuman, se hetkeä myöhemmin tömähtää toisesta päästä ulos ja herättää horrokseen vaipuneen applikaation.

Ensimmäistä koodiesimerkkiä muokkaamalla:


////////////////////////
//// EVENTS.JS /////////
////////////////////////

// App contains all the business code and logic.
var app = new App();

var listeners = {

	onEventX: app.eventBus.bind(app, 'eventX'),
	onEventY: app.eventBus.bind(app, 'eventY'),
	onEventZ: app.eventBus.bind(app, 'eventZ'),
	//...
}

// Bind listeners to device, allowing User to interact 
// with our App by pressing buttons on the device etc.
device.registerListeners(listeners);


////////////////////////
/////// APP.JS /////////
////////////////////////

function App() {

	this.api = new Api();
	
	this.eventBus = function(eventTag) {
		// eventTag on joko eventX, eventY tai eventZ.

		if (eventTaget === 'eventX') {
			this.api.doSomething();
		} else if (eventTaget === 'eventY') {
			this.api.doSomethingElse();
		} else if (eventTaget === 'eventZ') {
			this.api.doThirdThing();
		}
	}
}

Ylläolevan ero verrattuna ensimmäiseen koodiesimerkkiin on, että nyt kaikki tapahtumat saapuvat yhden linkkipisteen kautta. Tuo linkkipiste on eventBus-metodi.

Tämä on käytännössä ainoa ero näiden kahden koodiesimerkin välillä; applikaatiota ajaessa ne toimivat tismalleen samoin. Miksi siis luoda yksittäinen linkkipiste?

Periaate on sama kuin vaikkapa Suomen rajalla. Sen sijaan, että ulkomaalaisten annettaisiin hyppiä Suomen maaperälle mistä kohdin tahansa, kaikki maahantulot ohjataan raja-asemalle. Tuolla raja-asemalla voidaan keskitetysti suorittaa tietyt toimenpiteet, kuten passin tarkastus.

Siirtämällä esimerkkiapplikaatiomme käyttämään keskitettyä linkkipistettä, mekin voimme nyt suorittaa keskitetysti avustavia toimenpiteitä.


function App() {

	this.api = new Api();
	
	this.eventBus = function(eventTag, event) {
		// eventTag on joko eventX, eventY tai eventZ.

		// Kirjaa lokitietoihin uuden tapahtuman käsittely
		this.log('Tapahtuma ' + eventTag + ' saapunut'); 

		if (eventTaget === 'eventX') {
			this.api.doSomething();
		} else if (eventTaget === 'eventY') {
			this.api.doSomethingElse();
		} else if (eventTaget === 'eventZ') {
			this.api.doThirdThing();
		}
	}
}

Yllä kirjasimme lokiin tiedon tapahtuman saapumisesta. Koska kaikki tapahtumat tulevat sisään eventBus-metodin kautta, kaikki tapahtumat myös tulevat kirjatuksi lokiin!

Lisäsimme myös eventBus-metodiin toisen parametrin nimeltä event. Applikaatiosta riippuen tätä parametriä tarvitaan tai ei tarvita. Se sisältää itse tapahtuman, jonka applikaation alta löytyvä laitteisto synnytti. Ensimmäinen parametri (eventTag) sisältää vain tiedon minkälainen tapahtuma on kyseessä; toinen parametri sisältää itse tapahtuman. Kuten sanottua, joskus (usein) riittää tietää millainen tapahtuma on kyseessä; tällöin itse tapahtuma-objektia ei tarvita lainkaan.

Silloin kun tapahtuma-objekti tarvitaan, se sisältää kaiken tapahtumaan liittyvän informaation. Esimerkiksi klikatessa hiirellä ikonia tuo parametri event sisältää tiedon siitä, mitä ikonia klikattiin. Tai vaihtoehtoisesti se voi sisältää tietokoneen näyttöpäätteen koordinaatit (x/y), jossa klikkaus tapahtui.

Ylläoleva on ihan kiva, mutta todellinen hyöty syntyy virhehallinnan puolella. Kuten useaan otteeseen todettu, tyypillisessä UI-applikaatiossa kaikki toimenpiteet lähtevät liikkeelle tapahtumahallinnasta. Sama hiukka teknisemmin todettuna: yksittäinen suoritusajo muodostaa itsenäisen call stackin, jossa ylimpänä funktiokutsuna on tapahtumahallinta, meidän esimerkin tapauksessa eventBus.

Esimerkkinä applikaation call stack, joka muodostuu vaikkapa Photoshopissa kun käyttäjä klikkaa hiirellä työkalupalkista “Pensseli-työkalua”.

	eventBus
	  api.handleClick
	    drawTools.handleClick
	      drawTools.setPensseliAsNewTool
	  

Kun käyttäjä painaa Photoshopin teksti-objektin ollessa valittuna näppäintä “s”, syntyy puolestaan seuraava call stack:

	eventBus
	  api.handleKeyPress
	    canvas.handleKeyPress
	      textObject.handleKeyPress
	        textObject.updateText
	  

Ylläolevan sisäkkäisten funktiokutsujen sarjan perusteella Photoshop päivittää teksti-objektin sisältämän tekstin. Jos aiemmin ruudulla luki “Kaamo”, nyt siinä lukee “Kaamos”.

Yhteistä kahdelle edeltävälle call stackille on, että eventBus on molempien lähtöpiste. Tämä antaa mahdollisuuden seuraavanlaiseen virhehallintaan.


function App() {

	this.api = new Api();
	
	this.eventBus = function(eventTag, event) {
		// eventTag on joko eventX, eventY tai eventZ.

		// Kirjaa lokitietoihin uuden tapahtuman käsittely
		this.log('Tapahtuma ' + eventTag + ' saapunut'); 

		try {
			if (eventTag === 'eventX') {
				this.api.doSomething();
			} else if (eventTag === 'eventY') {
				this.api.doSomethingElse();
			} else if (eventTag === 'eventZ') {
				this.api.doThirdThing();
			}
		}

		catch (e) {
			// Jotain meni pieleen tapahtumaa käsitellessä/suorittaessa

		}

	}
}

Wrappasimme koko event-dispatchin (tuon ison if-else-lausekkeen) try-catchin sisälle. Tämä tarkoittaa, että kaikki virheet, jotka tapahtuvat alempana call stackissa, napataan viimeistään eventBus-metodin sisällä kiinni. Tämä on keskitettyä virheiden hallintaa parhaimmillaan.

Myös virheiden raportointia on helppo kehittää:


function App() {

	this.api = new Api();
	
	this.eventBus = function(eventTag, event) {
		// eventTag on joko eventX, eventY tai eventZ.

		// Kirjaa lokitietoihin uuden tapahtuman käsittely
		this.log('Tapahtuma ' + eventTag + ' saapunut'); 

		try {
			if (eventTag === 'eventX') {
				this.api.doSomething();
			} else if (eventTag === 'eventY') {
				this.api.doSomethingElse();
			} else if (eventTag === 'eventZ') {
				this.api.doThirdThing();
			}
		}

		catch (e) {
			// Jotain meni pieleen tapahtumaa käsitellessä/suorittaessa
			
			// Lähetetään virheilmoitus Bugsnag-palveluun.

			// Kerrotaan ensin minkä tapahtuman käsittelyssä virhe syntyi...
			Bugsnag.leaveBreadcrumb("Virhe syntyi käsitellessä tapahtumaa " + eventTag);
			// ...sitten lisätään lähetyspakettiin itse Exception-objekti.
			Bugsnag.notifyException(e);

			// Kehittäjien ja sidosryhmien informointi on suoritettu!

			// Yritetään korjata tilanne palauttamalla aiempi tila. Tällä tavoin
			// käyttäjän on mahdollista jatkaa ohjelman käyttämistä virheestä huolimatta.
			this.resetPreviousState();
		}

	}
}

Yllä lähetämme virheilmoituksen mainioon Bugsnag-palveluun. Tuon palvelun kautta ilmoitus päätyy applikaation kehittäjille, parhaimmillaan jopa reaaliajassa.

Tämän lisäksi yritämme palauttaa applikaation aiempaan, varmuudella toimivaan tilaan. Yksi ikävä piirre virhetilanteissa noin yleensä on, että ne sotkevat applikaation sisäiset tilamuuttujat. Näin ei ole pakko tapahtua; on vallan mahdollista, että virhe tapahtuu turvallisesti, jolloin se jättää jälkeensä siistin, toimivan applikaation. Mutta monet ennakoimattomat virheet tapahtuvat nk. kriittisellä hetkellä, jolloin ne sotkevat applikaation.

Tilanne on vähän vastaava kuin vaikka laskiessa säästöpossun kolikoita. Jos kesken laskusuorituksen menet yhtäkkiä laskuissa sekaisin (= aivojesi virhetilanne), ei sinulla ole muuta vaihtoehtoa kuin aloittaa alusta. Virhe tapahtui kriittisellä hetkellä, tässä tapauksessa laskennan ollessa käynnissä.

Ei-kriittinen virhetilanne syntyy jos kesken laskutoimituksen vahingossa pudotat kädessä olevan kolikon lattialle. Tämä on ilmiselvä käsiesi virhetilanne; et varmastikaan tarkoittanut pudottaa kolikkoa. Mutta kyseessä on ei-kriittinen virhe siksi, että voit nostaa kolikon lattialta ja jatkaa laskutoimitusta siitä mihin jäit. No harm done.

Metodikutsumme resetPreviousState antaa applikaatiolle käskyn palauttaa aiempi, toimivaksi todettu tila. Tämän toiminnallisuuden toteuttaminen olisi toisen postauksen aihe; tässä kohtaa riittää, että oletamme aiemman tilan palauttamisen olevan mahdollista.

Koodia voi vielä hiukan siistiä siirtämällä varsinaisen dispatch-osuuden erikseen avustavista toimenpiteistä (raportointi, recovery-toimenpiteet):


function App() {

	this.api = new Api();
	
	this.eventBus = function(eventTag, event) {
		// eventTag on joko eventX, eventY tai eventZ.

		// Kirjaa lokitietoihin uuden tapahtuman käsittely
		this.log('Tapahtuma ' + eventTag + ' saapunut'); 

		try {
			this.handleEvent(eventTag, event);
		}

		catch (e) {
			// Jotain meni pieleen tapahtumaa käsitellessä/suorittaessa
			
			// Lähetetään virheilmoitus Bugsnag-palveluun.

			// Kerrotaan ensin minkä tapahtuman käsittelyssä virhe syntyi...
			Bugsnag.leaveBreadcrumb("Virhe syntyi käsitellessä tapahtumaa " + eventTag);
			// ...sitten lisätään lähetyspakettiin itse Exception-objekti.
			Bugsnag.notifyException(e);

			// Kehittäjien ja sidosryhmien informointi on suoritettu!

			// Yritetään korjata tilanne palauttamalla aiempi tila. Tällä tavoin
			// käyttäjän on mahdollista jatkaa ohjelman käyttämistä virheestä huolimatta.
			this.resetPreviousState();
		}

	}

	// HandleEvent-metodi keskittyy yksinomaan valitsemaan oikean toimenpiteen saamansa
	// tapahtuman (tai tapahtumatagin) perusteella.
	this.handleEvent = function(eventTag, event) {
		if (eventTag === 'eventX') {
			this.api.doSomething();
		} else if (eventTag === 'eventY') {
			this.api.doSomethingElse();
		} else if (eventTag === 'eventZ') {
			this.api.doThirdThing();
		}		
	}
}

Thats it! Koodi näyttää selkeältä, ja eri vastuualueet on selkeän visuaalisesti erillään koodipohjassa.

Vue.js reactivity gotcha

PaperJs object violated by Vue

Vue is great framework. However, one must be careful when using it in apps requiring usage of animation loop (requestAnimationFrame).

Lately I’ve been using Vue with Paper.js. There is some great synergy between these two when building games or game-like javascript apps. Vue specializes in handling typical UI interactions, while Paper.js takes care of high-speed rendering and animations to canvas.

In application I am building, Paper.js takes care of running the game (and game loop) and Vue provides HTML elements used to control gameplay. This works well, but there is a big gotcha.

Beware Vue’s reactivity octopus

Lets say we want to build a very simple HTML canvas based game. It is a game where some monster sprites (or whatever) move on the canvas. And then there are HTML buttons above canvas; one button for each monster. Clicking the button deletes the monster on the canvas. Each monster has its own button.

Creating buttons from dynamically changing arrays is something Vue is very good at, so we naturally use v-for directive to keep monsters and buttons in sync.

Now, one could build it like this:


<template>
	<div>
		<!-- Render delete buttons for game objects above canvas -->
		<button 
			v-for="monster in monsters" 
			v-on:click="deleteMonster(monster.id)"
			:key="monster.id"
		>Delete {{monster.id}}</button>
		<!-- Canvas paper.js uses to draw game stuff -->
		<canvas id="forpaper"></canvas>
	</div>
</template>

<script>

import _ from 'lodash';
import paper from 'paper';

function Monster(paper) {
	
	this.id = /* generate random id*/

	this.paperObject = new paper.Circle(/*settings*/);

	this.moveTo = function(x, y) {
		// Delegate to Paper object which will takes care
		// of updating and drawing to the screen.
		this.paperObject.position = {x: x, y: y};
	} 

	//... etc
}

export default {

	data: function() {
		return {
			monsters: []
		}
	},

	mounted: function() {

		// Init Paper to our canvas (not implemented here)

		// Create 4 monsters to start with
		_.times(4, this.createMonster.bind(this));
	},

	methods: {
		createMonster: function() {
			var monster = new Monster(paper);
			// This push will cause button to be inserted to DOM 
			// for the monster.
			this.monsters.push(monster);
		},
		deleteMonster: function(id) {

			// First we remove our wrapping object, which causes 
			// corresponding button to disappear.
			var removedPlayers = _.remove(monsters, function(p) { 
				return p.id === id
			});
			var removedPlayer = removedPlayers[0];
			// ...then actual Paper.js object.
			removedPlayer.paperObject.remove();

		}
	}

}

</script>

Vue component above looks nice. All of the monster-related Paper.js stuff is nicely encapsulated inside Monster. We can freely design any API we want for Monster object, and Monster then internally calls Paper.js methods.

There is deep performance issue, however.

First of all, notice that we are pushing Monster objects to monsters-array that is used to render HTML buttons. This monsters-array is component’s data member, giving us all the reactivity magic Vue is so good at. But at what price?

Consider what happens when we call createMonster method:

  1. We call new Monster().
  2. Monster’s constructor builds up PaperJs object and saves it locally to a property.
  3. Newly-created Monster is pushed to an array.
  4. Vue notices this and binds get/set listeners to our Monster object’s properties.
  5. Virtual Dom is recreated and real DOM updated (new button shown on the screen).

Fourth step is problematic, because Vue binds reactivity listeners recursively. That is, it traverses Monster object’s all normal properties and plunges right in if one of them happens to be Object or Array.

And Monster.paperObject is an Object.

Thus what happens it that Vue ends up binding ALL the internal properties of Paper.js Circle object!

This means that every time any internal property of our Circle object changes, Vue’s reactivity listener gets called. That might not sound that terrible, but consider this; our Circle represents a particular graphical object on the screen which is (by default) updated 60 times a second via browser’s own animation loop.

If the circle is constantly being animated (which it probably is… we are after all building a game), we end up calling Vue’s reactivity listener 60 times per second.

And that is for one object, and for its one property that is mutated somewhere deep down in the heart of PaperJS code.

Now imagine we have 100 Monster objects. That would cause 6000 totally unnecessary calls per second per property.

That is still vast underestimate. Most likely one update call to a Circle will mutate many of its properties. Position, rotation, size,… etc.

You can see this quickly gets out of hand. A massive slow-down ensues.

This is something I experienced first-hand. Simply pushing one object that had internal Paper.js linkage somewhere deep down to an array Vue controls caused massive performance drop. This was hard to notice at first, because I was developing with PC happily running FPS 60. That is, each frame still got processed in under 17 ms so there was no visual feedback.

When I started using the app on mobile device, performance issues became apparent. Doing even the most elementary PaperJS stuff (like drawing a simple rectange over and over again) caused FPS to drop around 30-40.

I call this gotcha Vue the Kraken, because its feels like Vue deliberately tries to hunt down my Paper.js object with its long slimy tentacles. No matter how deep you hide your linkage to Paper, Vue will find it and fuck up everything.

Of course, Vue is just doing its job to make the reactivity system work as expected. There is no way Vue could know which data-bound objects to walk through and which not. But still. Kraken Vue.

So beware. Keep Vue and PaperJs separate. They are still great match for building HTML5 games with nice UIs, but you must introduce some impenetrable layer between them.

Onko ohjelmasi puu vai graafi?

Tässä kätevä ajatusmalli: ennenkuin ohjelmoit riviäkään koodia, päätä onko ohjelmasi (tai ohjelmasi osa!) puu vai graafi!

Ai mikä ihmeen “puu vai graafi”?

Puumalli ja graafi ovat tapoja organisoida objektit, joilla on liitoksia muihin objekteihin. Puumallin spesialiteetti on, että objektit on organisoitu kuusipuun näköiseksi struktuuriksi. Kuusipuun keskiossä on runko, josta lähtee oksia. Jokainen oksa haarautuun pienempiin oksiin, ja jokainen pienempi oksa haarautuu vielä pienempiin oksiin.

Puumallin ydinominaisuus on, että jokaisella lapsi-oksalla on tasan yksi äiti-oksa. Oikeassakin kuusipuussa jokainen uusi oksa haarautuu tasan yhdestä oksasta.

Graafi puolestaan organisoi objektit vailla em. äiti-lapsi-hierarkiaa. Esimerkiksi Helsingin tieristeykset noudattavat graafi-mallia. Kuhunkin risteykseen yhtyy useampi tie, ja yksikään risteys ei ole äiti jollekin toiselle risteykselle.

Ohjelmoinnissa näiden kahden mallin ero näkyy esim. Laravellin Model-layerin ja Vuen view-layerin välissä.

Laravel ja Vue ovat vain esimerkkiteknologioita. Fundamentaalisemmin voisi sanoa, että ero näkyy domain-driven-design -periaatteen mukaisen Domain-layerin ja XML-pohjaisen elementtihierarkian välillä.

Laravellin Model-layer saattaa näyttää esim. tältä.

[Kuva tähän]

Vuen view-layer puolestaan saattaa näyttää tältä.

[Kuva tähän]

Ylläolevat kaksi erilaista tapaa strukturoida applikaatio vaativat erilaiset ratkaisut. Esimerkiksi Vue:n ratkaisussa (= puumalli) huomaamme, että jos tuhoamme objektin nimeltä Profiili (kts. kuva), objektit Tallenna-nappi ja Profiilin kentät putoavat “tyhjyyteen”. Ne ovat erillään jäljellejäävästä Vue-puusta. Mitä tälläisille erakoille tulisi tehdä? Käytännössä kaksi vaihtoehtoa; joko liitämme ne takaisin puuhun, tai tuhoamme ne.

Takaisin puuhun liittäminen on vaikeaa, sillä mistä tiedämme mihin nuo kaksi erakkoa liitämme? Yksi looginen ajatus olisi liittää ne siihen objektiin, joka oli äskettäin tuhoamamme Profiilikentät-objektit äiti.

Tätä mallia käytetään paljon. Monissa käyttötarkoituksissa tämä on tismalleen oikea tapa toimia.

Mutta monissa muissa käyttötarkoituksissa tuo ei ole oikea tapa toimia.

Ajatellaan vaikkapa tavanomaista sukupuuta, jossa suvun viimeisin jäsen on ylimpänä (root, juuri). Tämä käännetty sukupuu on binaaripuu; jokaisella objektilla on tasan kaksi lapsi-objektia. Ironisesti, nuo kaksi lapsi-objektia ovat objektin kuvaaman henkilön vanhemmat.

Jos tästä puusta poistetaan yksi objekti, niin meidän on pakko poistaa kaikki hänen aiemmat esi-isänsäkin. Muuten puu ei enää olisi luotettava.

Puumallin ohjelmoinnissa on muutamia muitakin erityisseikkoja:

  1. Puumalli on helppo käydä läpi. Lähtee juuresta liikkeelle, ja kiertää koko puun. Puumallin hienous on, että kun loogisesti seuraa liitoksiä yksi kerrallaan esim. vasemmalta oikealle, ei koskaan saavu samaan objektiin kahdesti.

  2. Objekteilla on selkeä hierarkia. Juuri tästä syystä puumalli sopii niin hyvin esimerkiksi käyttöliittymän taustalla olevaksi datastruktuuriksi. Tyypillinen käyttöliittymä on pohjimmiltaan pelkkä iso pino sisäkkäisiä suorakulmioita. Koska suorakulmiot ovat sisäkkäisiä, ne sopivat mainiosti puumalliin. Yhdellä suorakulmiolla on aina tasan yksi äiti; suorakulmio ei voi olla yhtäaikaisesti kahden eri suorakulmion sisällä siten, että nuo kaksi muuta suorakulmiota eivät ole keskenään äiti-lapsi -hierarkiassa.

  3. Hierarkia tekee objektien välisestä kommunikaatiosta helpompaa. Puumallin hieno ominaisuus on, että pohjalta lähtiessä ylöspäin päätyy aina juuri-objektiin. Tämäkin seikka on mukava käyttöliittymän kannalta. Moni käyttöliittymä reagoi eventteihin (tapahtumat) siinä objektissa, missä ne alunperin tapahtuvat. Esimerkkinä vaikka hiiren klikkaus. Kun käyttäjä klikkaa hiirellä “Tallenna-nappia” (kts. aiempi puumalli-kuva), klikkaus rekisteröidään vastaanotetuksi “Tallenna-nappi”-objektissa.

Mutta entä jos haluamme tietää ylimmällä tasolla (juuri-objektissa), että nappulaa on klikattu? Monissa käyttöliittymissä juuri-objekti edustaa ikkunaa.

Usein haluamme klikkauksen - tapahtui se klikkaus missä kohtaa ikkunaa tahansa - seurauksena aktivoida ikkunan. Tämä aktivointi tehdään ikkuna-objektissa. Mutta itse klikkaus voi tapahtua missä tahansa objektissa, vaikka kuinka “syvällä” puumallin pohjamudissa tahansa. Miten ikkuna-objekti saa tiedon klikkauksesta? Helposti, sillä puumallin ominaisuus on, että liikkumalla puussa ylöspäin päätyy ennen pitkään väistämättä juureen. Tässä tapauksessa siis ikkuna-objektiin.

Voit testata saman lähimetsässä. Valitse iso puu. Valitse satunnaisesti mikä tahansa sen oksa. Kiipeä valitsemaltasi oksalta ylöspäin. Ennen pitkään saavut puun latvaan. Maagisinta on, että saavut samaan latvaan riippumatta siitä, miltä oksalta kiipeämisesi aloitit.

Tätä eventin liikuttelua kohti juurta kutsutaan nimellä “event bubbling”.

[Graafi tähän]

Jatkuu huomenna

Yksi tunniste, monta käyttöä

Yksi erinomainen tapa kytkeä front-end applikaatio rajapintaan, joka vaatii kirjautumisen/tunnistautumisen, on käyttää nk. API-avainta.

API-avain on vähän vastaava asia kuin ranneke kesäfestivaaleilla. Kun festivaalien vierailija ensi kertaa astuu festivaalialueelle, häneltä kysytään lippua, mahdollisesti myös henkilökorttia. Lipun antaessaan vierailijalle lätkäistään käteen ranneke. Jos vierailija myöhemmin poistuu festivaalialueelta, hän voi palata sinne takaisin ranneketta (API-avaimen) näyttämällä. Jos rannekkeessa on RFID-siru, rannekkeella voidaan yksilöidä kävijä helposti. Myös API-avain yksilöi käyttäjänsä. Käyttäjän tarkka yksilöinti on valinnainen “lisäpalvelu”; joissain käyttötarkoituksissa riittää tietää, että kävijällä on oikeus nähdä tiedot ilman tarvetta tietää kuka haluaa tiedot nähdä. Useimmiten API-avain kuitenkin yksilöi käyttäjän.

API-avaimen saa antamalla rajapinnalle validin tunnus+salasana-yhdistelmän. Tällä tavoin rajapinta tietää, että API-avaimen vastaanottava taho on ihan oikea poika palveluun rekisteröitynyt käyttäjä.

API-avain on yleensä voimassa siihen asti, kunnes käyttäjä erikseen kirjautuu ulos palvelusta (rajapinnasta). Vaihtoehtoisesti tunniste voi olla voimassa vain tietyn ajan.

Tyypillisessä arkkitehtuurissa rajapinnasta saatu API-avain talletetaan käyttäjän tietokoneen kovalevylle talteen. Tällä tavoin käyttäjä pysyy automaattisesti kirjautuneena rajapintaan, vaikka sulkisi tietokoneen välillä.

Automaattisesti kirjautuneena pysyminen tässä kohtaa tarkoittaa, että frontend-applikaatio hoitaa kovalevyltä ladatun API-avaimen avulla tunnistautumisen; ihmiskäyttäjän ei tarvitse syöttää salasanaa. Oikeasti käyttäjä ei pysy kirjautuneena yhtään mihinkään. Pinnan alla joka ikisen rajapintakutsun yhteydessä kirjautuminen suoritetaan uusiksi juurikin API-avaimen avulla. Ihmiskäyttäjä ei tätä prosessia näe.

API-avaimen ominaisuuksiin myös kuuluu useimmiten, että jos käyttäjä tarjoaa validin tunnus+salasana-yhdistelmän vaikka hänellä on (tai pitäisi olla!) hallussaan API-avain, rajapinta generoi uuden API-avaimen. Vanha API-avain lentää roskakoriin.

Tämä malli toimii erinomaisesti. Jos kovalevyltä ei API-avainta löydy, käyttäjän on pakko syöttää salasana. Salasanan (mieluiten oikean) syötettyään käyttäjä saa API-avaimen, jonka voi tallettaa kovalevylleen.

Useimpien web-applikaatioiden yhteydessä ‘kovalevy’ on synonyymi web-selaimen localStorage:lle.

Yksi monen puolesta

Mutta entä jos yhtä rajapintaa käyttää kaksi erillistä web-applikaatiota? Tälläinen tilanne syntyy herkästi nk. micro service -arkkitehtuurissa sovellettuna fronttipuolelle. Yksi rajapinta tarjoaa palvelut monelle web-applikaatiolle, jotka yhdessä muodostavat tuoteperheen.

Esimerkkinä vaikkapa applikaatiokokonaisuus, jossa yksi web-app huolehtii lomakedatan käsittelystä, ja toinen web-app huolehtii lomakkeiden luonnista (lomake-editori). Molemmat web-appit ovat osa samaa kokonaisuutta, jota kutsuttakoon vaikka “liidien hallinnaksi”.

Kutsutaan applikaatioita vaikka nimillä “Lotus Lomakekäsittely” ja “Lotus Lomake-editori”.

On luontevaa, että applikaatiokokonaisuuden tilaava taho saa käyttöön yhdet admin-tunnukset, joilla kirjautua molempiin applikaatioihin sisään.

Mutta jos orjallisesti seuraamme yllä kuvattua API-avaimen käyttömallia, olemme pian dilemman edessä.

Dilemma

Ongelmaksi muodostuu kysymys siitä, minne tallennamme käyttäjän API-avaimen? Se siis tallennetaan käyttäjän laitteelle. Mutta kumman applikaation alaisuuteen?

Jos tallennamme API-avaimen Lotus Lomakekäsittelyn alaisuuteen, Lomake-editori ei pääse siihen käsiksi.

Jos tallennamme API-avaimen Lotus Lomake-editorin alaisuuteen, Lomakekäsittely ei pääse siihen käsiksi.

Syy siihen mikseivät eri web-applikaatiot (teknisesti eri web-domainien alaisuudessa elävät verkkosivut) näe toistensa API-avaimia on tietoturva. Rajoitus estää yhtä web-applikaatio näkemästä dataa, jota joku toinen web-applikaatio tallentanut käyttäjänsä päätelaitteelle.

Tässä kohtaa saattaa nousta ihmetys, että miksi molempien tarvitseekaan päästä yhteen ja samaan API-avaimeen käsiksi? Kuten aiemmin jo mainittua, uuden API-avaimen saa rajapinnasta pyytämällä.

Ongelman ydin on siinä, että kun Lomake-editori pyytää uuden API-avaimen, rajapinta resetoi nykyisen API-avaimen. Lomake-editori ei ole moksiskaan; se halusi uuden tokenin ja sai sen.

Mutta Lotus Lomakekäsittelylle tilanne on pirullisempi. Sen API-avain on nyt väärä. Siis vanhentunut. Vielä hetki sitten sillä oli hallussaan täysin käyttökelpoinen API-avain. Mutta sitten Lomake-editori meni pyytämään itselleen uutta avainta, ja näin toimiessaan rajapinta resetoi ja generoi uuden API-avaimen.

Lotus Lomakekäsittelyn avain on siis väärä, joten mitä se tekee? Se tietenkin hakee itse uuden API-avaimen rajapinnasta. Näin toimiessaan Lotus Lomakekäsittely puolestaan aiheuttaa invalidoinnin Lotus Lomake-editorin juuri saadulle API-avaimelle.

Lotus Lomakekäsittelyn ja Lotus Lomake-editorin siirtyvät pelaamaan API-pingistä. Kumpikin vuorollaan invalidoi toisen API-avaimen. Ikuinen noidankehä on valmis.

Mikä avuksi?

Ratkaisut

Ongelmaan on monta ratkaisua.

Ratkaisu 1

Yksi ilmiselvä ratkaisu on välttää ongelma kokonaan laittamalla eri applikaatiot saman domainin alle. Jos sekä Lotus Lomake-editori että Lotus Lomakekäsittely elävät samassa valtakunnassa, ne voivat jakaa yhden ja saman API-avaimen. Tällöin jokainen API-avain on yhteinen. Yksi osapuoli hakee, ja palatessaan kiltisti jakaa saadun aarteen toisen osapuolen kanssa.

Ratkaisun ongelma on siinä, että mikäli web-applikaatioiden lähdekoodi elää eri palvelimilla, voi olla ikävän työlästä saada ne saman domainin alaisuuteen.

Ratkaisu 2

Toinen ratkaisu on tallentaa rajapintaan useampi API-avain. Jos API-avaimia on yksi per applikaatio, ei eri applikaatioiden tarvitse keskenään tapella avaimen herruudesta. Tämä on varsin OK vaihtoehto, mutta loogisesti hiukka luonnottoman tuntuinen. Jos eri web-applikaatioiden käyttöoikeus on selkeästi yhden käyttäjätilin (admin) alaisuudessa, niin loogista olisi, että yksi API-avain kävisi kaikkialle.

Toinen ongelma on, että jos admin haluaa kirjautua kaikista tuoteperheen applikaatioista ulos, hänen täytyy käydä suorittamassa kirjautumiset yksitellen. Ellei sitten rajapinta sisällä toiminnallisuutta, jolla kaikki API-avaimet voi resetoida kerralla. Niin tai näin, menetelmä tuntuu fundamentaalisesti väärältä.

Ratkaisu 3 (paras?)

Kolmas ratkaisu on luoda isäntä-renki -hierarkia eri web-applikaatioiden välille. Yksi applikaatio on isäntä, muut renkejä.

Pointti on, että ainoastaan isäntä-applikaatio voi resetoida olemassaolevan API-avaimen. Renki-applikaatiot voivat hakea API-avaimen, mutta eivät resetoida. Tämä ratkoo aiemmin mainitun noidankehän. Kun Lotus Lomakekäsittely (“isäntä”) hakee uuden API-avaimen, se samalla resetoi Lotus Lomake-editorin käyttämän API-avaimen. Tämän seurauksena Lomake-editori hakee uuden avaimen. Mutta Lomake-editorin haku ei generoi uutta API-avainta. Rajapinta yksinkertaisesti palauttaa aiemmin isäntä-applikaation toimesta generoidun avaimen. Noidankehän katkeaa; molemmat applikaatiot käyttävät samaa, käyttökelpoista avainta.

Ratkaisu kolme on mielestäni paras käyttötarkoituksiin, joissa valtaosan ajasta käytetään yhtä applikaatio (isäntä), mutta aina välillä on tarve käydä tekemässä jotain avustavia toimenpiteitä tuoteperheen muissa applikaatioissa (rengit).

Loppukaneetti

Noheva lukija saattaa nyt miettiä, että eikö koko ruljanssin voisi välttää yksinkertaisesti pitämällä API-avain aina samana. Tällöin ei tarvita isäntä-renki -hierarkiaa, sillä kaikki web-applikaatiot ovat renkejä; yksikään ei voi pyytää rajapintaa generoimaan uutta API-avainta.

Yksi ongelma on, että mitä uloskirjautuminen tarkoittaa tapauksessa, jossa API-avain on ikuinen ja koskematon? Uudelleen generoitavan API-avaimen tapauksessa uloskirjautuminen tuhoaa sen hetkisen API-avaimen. Uloskirjautumisen aikana käyttäjällä ei ole lainkaan API-avainta. Kun seuraavan kerran käyttäjä haluaa kirjautua sisään, hänen on pakko syöttää tunnus+salasana.

Tämä on eri tilanne kuin aiemmin mainitussa kahden web-applikaation noidankehässä. API-noidankehässä yksi applikaatio tuhoaa API-avaimen, mutta rajapinta generoi samantien uuden avaimen. Konseptuaalisesti käyttäjällä on siis joka hetkellä aktiivinen API-avain olemassa.

Mutta jos API-avainta ei koskaan tuhottaisi, niin miten käyttäjä voisi koskaan kirjautua ulos?

Toinen, huomattavasti vakavampi ongelma tässä skenaariossa on, että jos API-avain edes yhden kerran päätyy vääriin käsiin, admin-tunnarit ovat pysyvästi mennyttä. Niihin ei voi enää luottaa. Tämä on valtava tietoturvariski. Siksi API-avaimet resetoidaan jokaisen uloskirjautumisen yhteydessä. Jos hakkeri saa sinun API-avaimen käsiins, riittää että menet pää yhtenä jalkana web-applikaation kirjautumissivulle syöttämään oman tunnus+salasana -yhdistelmän. Yhdistelmän syöttäminen regeneroi uuden API-avaimen, samalla tuhoten hakkerin haltuunsa saaman avaimen.

Loppukaneetti 2

API-avainten käyttö on joidenkin mielestä täysin väärin. He suosivat hienompia lähestymistapoja, kuten OAuth. Samat tahot kuluttavat moottoritiet piloille laittamalla nastat alle heti kun ensimmäinen koivunlehti varisee konepellille.

API-avain on yksinkertaisuudessaan ylivertainen ratkaisu, ja maalaisjärkeä käyttämällä varsin tietoturvallinen. Tärkein elementti API-avaimen ja tietoturvan kannalta on SSL-yhteyden käyttö web-applikaation ja rajapinnan välisessä yhteydenpidossa.

Amazonin kartoitus #1: Polly

Tämä bloggaus aloittaa Nollaversio IT:n blogiin uuden artikkelisarjan, jossa käyn lävitse yksi kerrallaan Amazon AWS-ekosysteemin tarjoamia palveluita. Keskityn artikkelisarjassa palveluiden hyödyntämiseen osana web-palveluiden rakentamista.

Amazon Polly

Amazon Polly on yksi AWS-tuoteperheen uusimmista lisäyksistä. Polly täyttää varsin konkreettisen tarpeen; se mahdollistaa tekstin kääntämisen puheeksi.

Pollyn vastapari AWS-perheessä on Amazon Lex, joka kääntää puheen tekstiksi. Tutustutaan Lexiin myöhemmin.

Polly siis ottaa vastaan tekstiä ja puhuu suunsa puhtaaksi - aivan kuten tavallinen ihminen lukisi pätkän tekstiä mikrofoniin. Pollyn tapauksessa puhumisen hoitaa tietokone-algoritmi. Algoritmipuhe tuottaa äänitiedoston, esim. mp3-tiedoston.

Polly on suoranainen kielivirtuoosi. Se höpöttää englannin lisäksi ainakin ruotsia, venäjää ja saksaa. Valitettavasti suomi ei ole joukossa mukana, ainakaan vielä.

Soveltuvuus

Pollyn kaltaisen palvelun sisällyttäminen osaksi web-applikaatiota vaatii hiukka järkeilyä.

Mitä lisäarvoa puhuttu puhe tuottaa verrattuna näyttöpäätteeltä luettuun tekstiin? Valtaosassa web-applikaatioita ei yhtään mitään - kirjoitettu teksti on helppokäyttöisempää kuin luettu puhe.

Yksi selkeä käyttötarkoitus on applikaatioissa, joissa käyttäjä ei ole näyttöpäätteen äärellä jatkuvasti. Applikaatio voi tällöin muuntaa esim. sisääntulevan viestin puheeksi, joka soitetaan käyttäjän kaiuttimista. Tällä tavoin viesti saadaan ihmiskäyttäjälle perille vaikka hän ei olisi läsnä näyttöpäätteen äärellä. Riittää, että hän on kaiuttimien äänen kantaman saavutettavissa.

Tämä ensimmäinen käyttötarkoitus perustuu ajatukseen siitä, että ääniviestiä on vaikeampi olla huomaamatta kuin visuaalista viestiä.

Toinen käyttötarkoitus on muuntaa tekstidokumentteja puheeksi esim. matkakuuntelua varten.

Ilmiselvä käyttötarve on esim. kirjan muuntaminen mp3-muotoon ja audiomuodossa matkalle mukaan ottaminen.

Toinen, vähemmän ilmiselvä käyttöpotentiaali, löytyy sähköpostiviestien käsittelystä. Auton ratissa on mahdoton käyttää silmiä sähköpostiviestien yms. dokumenttien lukemiseen. Tämä on fakta, jota moni on yrittänyt uhmata henkensä hinnalla.

Mutta entä jos sisääntuleva sähköpostiviesti luettaisiin ääneen auton kaiuttimista?

Arkkitehtuuri voisi toimia seuraavasti.

Malli-arkkitehtuuri: sähköpostit auton kaiuttimista.

Arkkitehtuurin hardware vaatii älypuhelimen 3G- ja Bluetooth-yhteyksillä sekä autosoittimen Bluetooth-yhteydellä. Lähes kaikki modernit älypuhelimet tukevat 3G + Bluetooth -yhdistelmää, ja valtaosa uusista autoista sisältää Bluetooth-soittimien sisäänrakennettuna auton audiojärjestelmään.

Automatkan alkaessa kuski avaa applikaation (joko web-appi tai mobiiliappi) “EmailitPuheeksi”. Applikaatiosta hän valitsee audiokytkennän auton audiojärjestelmään.

Applikaatio kysyy käyttäjän sähköpostitilin tietoja. Tiedot syötettyään applikaatio jää kuuntelemaan sähköpostiliikennettä; aina kun email lävähtää käyttäjän sähköpostilaatikkoon, EmailitPuheeksi-appi saa siitä kopion käyttöönsä.

Tämän email-kopion applikaatiomme lähettää Amazonin rajapintaan. Amazon herättää Pollyn kauneusunilta, ja käännös “teksti -> puhe” suoritetaan. Käännöksen suoritus saattaa kestää useita sekunteja, joten Amazon ampuu käännetyn email-puhetiedoston AWS:n omaan jonopalveluun.

Amazonin ei tarvitse toimia oikean ihmisen tavoin, eli lukea tekstiä sana kerrallaan. Koska puheenmuodostus tapahtuu algoritmisesti, on puhe mahdollista tuottaa paralleelisti - alkuperäinen teksti pätkikään osiin ja kukin osa käännetään puheeksi erikseen.

Jonopalvelusta applikaatiomme sitten käy nappaamassa puhetiedoston, ja sen saatuaan soittaa tiedoston. Koska applikaation ääni-output on kytketty auton audiojärjestelmään, sähköposti luetaan ääneen auton kaiuttimista.

Koodiesimerkki

Pollyn käyttö on helppoa. AWS tarjoaa rajapintapalvelunsa API Gatewayn liitettäväksi Pollyn kylkeen; tällöin HTTP-pyyntö voidaan lähettää rajapintaan, joka sitten parsii siitä tarvittavat tiedot (hyödyntäen AWS Lambda-funktiota!) ja lähettää ne Pollyn luettavaksi.

Polly edustaa teknologisen kehityksen terävintä kärkeä. Lambdan hyödyntäminen Pollyn käytössä edustaa tuon kehityksen terävimmän kärjen ylintä atomia. Vielä kuukausi sitten - joulukuussa 2016 - Lambdaa ei oltu päivitetty sisältämään rajapintatoimintoja Pollyn suuntaan. Nyt (11.01.17) päivitys on tehty, joskin sen deploymentti läpi AWS valtavan infrastruktuurin on vielä kesken. Lisätietoja täältä.

Polly on sen verran uusi palvelu, että netistä ei löydy käytännössä lainkaan esimerkkejä sen käytöstä. Mutta joltain tämänkaltaiselta se näyttää:


// Tuodaan HTTP-kirjasto käyttöön
var request = require('request');
// Tuodaan jokin soitin, ei tarkemmin määritelty.
var soitin = require('mp3soitin');

// Kaikki authentikaatio on skipattu.

// Tämä käännetään äänitiedostoksi Pollyn avulla.
var text = "Translate this!";

var options = {
  uri: 'http://aws.polly.com/v1/speech',
  method: 'POST',
  json: {
   	"OutputFormat": "mp3",
   	"Text": text,
   	"TextType": "text",
   	"VoiceId": "Emma"
  }
};

// Tehdään kutsu AWS:n Polly-rajapintaan.

request(options, function (error, response, body) {
  // Virheiden tarkistus tähän..

  // Napataan audio.
  var audio = response.AudioStream;

  // Lähetetään audio johonkin soittimeen
  soitin.play(audio);

});

Amazonin suuntaan lähtevän HTTP POST-kutsun tärkein osa on tämä:


 {
   	"OutputFormat": "mp3",
   	"Text": text,
   	"TextType": "text",
   	"VoiceId": "Emma"
  }

Tuossa objektissa määritämme mm. luettavan tekstin, lukijaäänen (ihana Emma, jolla on brittiaksentti) ja ääniformaatin. Muitakin asetuksia on laitettavissa, mm. samplaus-rate.

Hinta

Polly on hinnoiteltu - kuten käytännössä kaikki AWS:n palvelut - naurettavan halvaksi.

Esimerkiksi 24 tunnin kestoinen puhe maksaa neljä dollaria.

Tuntipalkkaa Polly perii siis huimat n. 20 senttiä. Mikä pahinta, Polly-parka kituuttaa nollasopimuksella; kk-maksuja ei ole lainkaan ja kaikki veloitus menee suoraan käytön mukaan.

Summarum

Amazon Polly tarjoaa tekstin kääntämisen puheeksi mukavan kivuttomasti. Palvelu on upouusi, joten käyttökokemukset siitä ovat vähissä. Edelläkävijälle palvelu tarjoaa spesifiin käyttötarpeeseen optimaalisen täsmäratkaisun, joka yksinkertaisesti toimii. Tai ainakin lupaa toimivansa.

Huonona puolena on, että - vielä toistaiseksi - suomen kieltä ei ole mukana.

Laravel: seuraa datan muutoksia

Tänään törmäsin mielenkiintoiseen kysymykseen Laravellin englanninkielisellä keskustelupalstalla Laracast.com:ssa.

Kysymys meni näin:

I have a classic create() function to create elements, but changes I wish to save in a separate table, like history. There is table: element_changes and also model created named ElementChange, but in my ElementController, how can I tell to save it in a separate table?

Vapaasti suomennettuna siis:

Minulla on tyypillinen luontifunktio, joka luo uusia malleja. Mutta haluaisin erilliseen tietokantatauluun kirjata ylös luontihistorian. Eli kun luon uuden objektin mallin pohjalta (tai muutan olemassaolevaa mallia), järjestelmä kirjaa lokitiedon asiasta erilliseen tauluun. Kuinka saavuttaa tämä?

Hyvä kysymys. Olen itse tarvinnut vastaavaa.

Miksi tuollainen lokihistoria sisältäen muutokset on hyödyllinen? Selkeä käyttötarkoitus on järjestelmissä, joille vallitseva laki asettaa vaatimuksia. Yksi yleinen vaatimus on, että järjestelmän tulee pitää tarkkaa kirjaa kaikista järjestelmän sisällä tapahtuvista muutoksista.

Tälläinen kirjanpito on järkevä hoitaa lokihistorian avulla, jonne kirjaa lyhyen tiedoksiannon jokaisesta muutoksesta.

Otetaan esimerkkinä ydinvoimalan hallintajärjestelmä. Siellä tuollainen muutos - jonka haluamme kirjata ylös - voisi olla reaktorin polttoainesauvan liikuttaminen.

Kun järjestelmän ylläpitäjä antaa järjestelmälle komennon siirtää polttoainesauvaa kolme senttiä ylöspäin, järjestelmän on syytä kirjata lokitieto asiasta.

Sillä jos jotain menee pieleen, poliitikot haluavat tietää tismalleen mitä ja miksi meni pieleen! Lokihistoria auttaa.

Toteutus

Jälleen kerran Laravel tekee lokihistorian pitämisen laittoman helpoksi. Käytännössä homma toimii näin; määrität kullekin malliluokalle muutaman metodin, joita Laravel-kehys kutsuu aina tietokantaa päivittäessään. Näiden metodien sisällä pusket lokitiedon lokihistoria-tauluun.

Otetaan hypoteettisena esimerkkinä tuo ydinvoimala.

Meillä on malliluokka nimeltä “Polttoainesauva”, joka on tämän näköinen:


class Polttoainesauva extends Model {
	
  public function nostaYlos() {//...}
  public function laskeAlas() {//...}
}

Malliluokkamme on varsin yksinkertainen; sille on määritelty ohjelmoijan toimesta vain kaksi metodia.

Ensimmäinen metodi nostaa sauvan ylös, toinen laskee sen takaisin alas. Metodien tarkemmat määritykset eivät ole oleellisia.

Oletamme, että sauvojen asento/sijainti on kunakin hetkellä tallennettuna tietokantaan. Oikeassa maailmassa “tietokantana” toimisi ydinreaktori, mutta tämä on web-applikaatio, joka simuloi oikeaa maailmaa.

Jossain kohtaa applikaatiota meillä on seuraava koodinpätkä:


$polttoainesauva->nostaYlos();

Ylläolevaa koodinpätkää voi ydinlaitoksen huoltoteknikko kutsua jonkinlaisen rajapinnan kautta.

Ydinkysymys: miten saamme järjestettyä siten, että polttoainesauvan nostosta jää yksiselitteinen lokitieto järjestelmän historiaan?

Annoin vastauksen jo tämän kappaleen alkupuolella. Tutkitaan kuitenkin ensin pari huonoa tapaa hoitaa homma.

Tapa 1

Yksi tapa on muokata ylläolevaa koodinkutsua seuraavanlaiseksi:


$polttoainesauva->nostaYlos();
// Kirjaa lokiin
Loki::write('Polttoainesauva nostettu');

Ratkaisu on yleisellä tasolla huono, sillä entä jos useampi rajapintafunktio nostelee sauvaa? Tällöin lokikirjauksen tekeminen tulisi muistaa tehdä kaikkialle erikseen!

Tämä on vaarallista ihan siksi, että ennemmin tai myöhemmin joku puolikätinen ohjelmoija pöllähtää paikalle ja muokkaa rajapintaa unohtaen lokikirjauksen lisäyksen!

Tapa 2

Huomattavasti parempi tapa on siirtää lokikirjaus suoraan Polttoainesauva-luokan metodien oheen:


class Polttoainesauva extends Model {
	
  public function nostaYlos() {
    // Tee nosto
    Loki::write('Polttoainesauva nostettu');


  }

  public function laskeAlas() {
    // Tee lasku
    Loki::write('Polttoainesauva laskettu');  

  }
}

Nyt voimme olla varmoja, että sauvoja ei nosteta/lasketa ilman lokikirjausta.

Vai voimmeko? Entä jos koodarimme menee typeryyspäissään kirjoittamaan uuden rajapintafunktion tyyliin:


function vedenPintaKriittisenAlhaalla() {
  // Kiireellä sauva pois matalasta vedestä!
  // (Disclaimer: en tiedä lainkaan toimisiko tälläinen
  // varotoimenpide oikeassa elämässä...dont try at home!)
  $polttoainesauva->asento = 'ylös';
  $polttoainesauva->save();

  // Unohtuiko jotain...?
}

Kirjataanko tuossa mitään lokiin? Ei, sillä uusi noviisiohjelmoija meni muuttamaan sauvan asentoa ohitse meidän nostaYlos-metodimme. Siispä lokikirjausta ei tehty.

No, ydinvoimalat eivät palkkaisi diplomi-insinöörejä, joten ylläolevaa ei pääse tapahtumaan. Mutta on hyvä tiedostaa riskit.

Eikä siinä vielä kaikki. Tuossa lokikirjausten tekemisessä Polttoainesauva-luokkaan on toinenkin ongelma: entä jos meillä on sadoittain vastaavia malliluokkia ympäri applikaatiotamme?

Meidän tulisi jokaikiseen kirjata jokaikisen tietokantaa muokkaavan metodin kohdalle lokikirjaus! Helvetinmoinen urakka, muuten.

Tapa 3

Paras keino on luottaa Trait-konseptin* voimaan.

Lisäämällä kirjaustoiminnot sisältävä Trait kunkin malliluokan oheen, meidän ei tarvitse huolehtia juuri mistään muusta! Laravel-kehys huolehtii siitä, että Traitin sisältämät kuuntelijafunktiot kutsutaan aina kun tietokantaa muokataan.

Huono puoli tässäkin on - meidän tulee edelleen muistaa sisällyttää tuon Trait jokaisen malliluokan oheen. Mutta ainakaan meidän ei tarvitse enää huolehtia yksittäisistä metodeista. Yksi lisäys per malliluokka riittää.

Ja mikä parasta, yksi ja sama Trait kelpaa kaikkiin malliluokkiin.

Tämä viimeisin pointti on tärkeä; vaikka meillä olisi tuhat malliluokkaa, yksi Trait edelleen riittäisi.

Traitin avulla jokainen malliluokan metodi tulee automaattisesti “suojelluksi” - tarkoittaen, että tietokannan muokkaus mistä ikinä metodista tulee kirjatuksi lokiin.

Miltä tuo Trait näyttää? Tältä:


trait Trackable {
  // Laravel kutsuu tätä metodia osana käynnistys-ajoaan.
  public static function bootTrackable() {

    static::creating(function ($model) {
      // Kirjataan tieto objektin luonnista
      Loki::write('Luonti: ' . get_class($model));
    });

    static::updating(function ($model) {
      // Kirjataan tieto objektin muokkauksesta!
      // HUOM! Emme tiedä millainen muokkaus on kyseessä, 
      // mutta objekti itse tietää!
      Loki::write('Muokkaus: ' . get_class($model) . $model->printData());
    });

    static::deleting(function ($model) {
      // Kirjataan tieto objektin kuolemasta!
      Loki::write('Kuolema: ' . get_class($model));
    });
  }
}

Ylläolevaa traittia voimme käyttää missä tahansa malliluokassa seuraavasti:


class Polttoainesauva extends Model {
  use Trackable;
  // jne..
}

class Reaktori extends Model {
  use Trackable;
  // jne..
}

class Vesiallas extends Model {
  use Trackable;
  // jne..
}

class Lampomittari extends Model {
  use Trackable;
  // jne..
}

Muuta ei tarvita! Laravel-kehys hoitaa loput. Se pitää huolen, että aina kun tietokantaa muokataan jonkun em. malleista osalta, lokiin kirjataan tieto.

Onko suojaus nyt täydellinen, täysin diplomi-insinööri-proof? Ei. Jos tietokantaa muokataan suoraan SQL-koodilla, lokikirjaus jää edelleen tekemättä. Mutta ainakin ohjelmoijilla on nyt vain yksi elinehto: älä ohita Laravel-kehyksen omaa tietokanta-abstraktiota.

*Perusidea on, että traitin sisältö copypastataan sellaisenaan siihen kohtaan koodipohjaa, jossa traitia käytetään (use).

Lambda-pohjainen arkkitehtuuri

Kokonaisarkkitehtuuri Lambdan avulla

Amazonilla on palvelu nimeltä AWS Lambda. Tuo palvelu suorittaa datan prosessoinnin pilvipalvelun muodossa.

Käytännössä se toimii siten, että ulkopuolinen ohjelmisto kutsuu Amazonin rajapintaa. Tuo rajapinta on Amazonin hallintapaneelissa (tms.) kytketty haluttuun Lambda-funktioon. Rajapinnan kutsu tällä tavoin laukaisee Lambda-funktion suorittamisen.

Oleellinen myyntiargumentti Lambdan kohdalla on, että loppukäyttäjän ei tarvitse välittää tuon taivaallista palvelinten ylläpidosta. Ei edes virtuaalipalvelinten.

Loppukäyttäjä vain kutsuu Amazonin rajapintaa, ja Amazon hoitaa loput. Käytännössä Amazon valitsee valtavasta rauta-arsenaalistaan sopivan palvelimen, jonka suoritettavaksi loppukäyttäjän työvaihe annetaan.

Loppukäyttäjältä - eli web-palveluiden ohjelmoijalta, tyypillisesti - jää täten yksi huolenaihe vähemmän. Hänen ei tarvitse pelätä palvelimen kaatumista jouluyönä kello 3.00, sillä ei ole mitään palvelinta, joka voisi kaatua.

Mihin käyttötarkoituksiin Lambda soveltuu?

Lambda-funktio noudattaa fire-and-forget-mallia. Jokainen Lambda-funktion kutsu on erillinen - yksi kutsu ei pysty jättämään post-it-lappuja toiselle kutsulle muuten kuin tietokannan tai vastaavan ulkoisen kiintopisteen kautta.

Tämän rajoitteen (ominaisuuden?) vuoksi Lambda soveltuu huonosti esimerkiksi moninpelipalvelimeksi, sillä moninpelipalvelimen luonteeseen kuuluu, että palvelin ylläpitää pelitilaa yksittäisten siirtojen/kutsujen välillä.

Lambda ei voi ylläpitää pelitilaa keskusmuistissaan, sillä yksittäisen Lambda-kutsun maksimisuoritus aika on muutamia minuutteja.

Käytännössä loppukäyttäjä voi ajatella Lambda-palvelua ikäänkuin palvelimena, joka kaatuilee parin minuutin välein. Jos toiminto vaatii yli parin minuutin suoritusajan tai tilamuuttajan ylläpidon, Lambda ei sovellu tarkoitukseen.

Se mihin Lambda soveltuu erinomaisesti on dataa sisään -> dataa ulos -tyylisten itsenäisten työvaiheiden suorittamiseen.

Hyvä esimerkki on vaikkapa tekstidokumentin kääntäminen suomesta englanniksi. Tälläinen operaatio on luonteeltaan itsenäinen; tarkoittaen, että operaatio ottaa vastaan dataa, ajaa tietyn pätkän koodia, ja palauttaa ulos uutta dataa.

Malliarkkitehtuuri

Seuraavassa kokonaisvaltainen korkean tason arkkitehtuuri, joka hyödyntää Lambdaa.

Oletetaan dokumenttien kääntämiseen erikoistunut web-palvelu. Tyypillinen käyttötarkoitus on, että asiakas antaa web-palvelulle kasan asiakirjoja, jotka haluaa käännettäväksi suomesta englanniksi. Web-palvelu kääntää dokumentit omalla ajallaan, ja kun kaikki käännökset ovat valmiita, asiakkaalle lähetetään sähköpostilla tiedoksianto.

Heti alkuun nähdään, että kokonaisarkkitehtuurissa käännökset suorittava ohjelma on järkevä eristää dokumentit asiakkaalta vastaanottavasta ohjelmasta. Ne siis ovat kaksi erillistä palapelin palasta osana kokonaisarkkitehtuuria.

Käännösohjelma

Käännöksien suorittamisesta vastaava ohjelma ajetaan Amazonin Lambda-palvelussa. Miksi? Koska sen käyttötarkoitus soveltuu mainiosti Lambdan päälle.

Toinen syy on, että on luontevaa suorittaa käännökset dokumentti kerrallaan, mutta samanaikaisesti. Tällä tarkoitan, että yksi Lambda-funktion kutsu ottaa käännettäväkseen tasan yhden dokumentin, mutta kullakin ajanhetkellä useampi Lambda-funktio tekee käännöstyötään.

Periaate on sama kuin pankissa - kukin pankkivirkailija palvelee tasan yhtä asiakasta kerrallaan, mutta useita pankkivirkailijoita on yhtäaikaisesti töissä.

Sanotaan esimerkin vuoksi, että asiakas syöttää web-palveluumme 1000 kpl asiakirjoja. Yhden dokumentin kääntäminen tekoälyn turvin vie 10 sekuntia. Tuhannen dokumentin kääntäminen perätysten veisi 1000 * 10 sekuntia, eli noin kolme tuntia.

Mutta jos ajamme samanaikaisesti 1000 kpl Lambda-funktioita, koko urakka kestää 10 sekuntia.

Käännösohjelman kannalta valitsemamme samanaikaisesti x määrää dokumentteja kääntävä palvelumme ei aiheuta ongelmia, sillä kuten mainittua, käännösohjelman koodi vastaanottaa vain yhden dokumentin. Koodia ajetaan tuhannella eri palvelimella samanaikaisesti, mutta koodi ei välitä - se huolehtii vain yhden dokumentin kääntämisestä.

Samanaikaisuus aiheuttaa hienoisia vaikeuksia arkkitehtuurimme toisessa palasessa, mutta probleemat ovat ratkottavissa.

Dokumenttien vastaanotto -ohjelma

Vastaanotto-ohjelman tehtävä on ottaa dokumentit käyttäjältä vastaan. Käytännössä tämä tarkoittaa jonkinlaista www-sivua, jossa on lomake, jota käyttäen loppuasiakas lataa dokumentit sisään. Tuhannen asiakirjan upload saattaa toki kestää hetken, mutta ei takerruta siihen (loppuasiakas voi lähettää zip-paketin joka sisältää kaikki asiakirjat).

Vastaanotto-ohjelma pyörii tuikitavallisella web-palvelimella. Se ei siis pyöri Lambdan päällä ihan siksi, että se joutuu pitämään kirjaa käännetyistä dokumenteista.

Käytännössä asiakirjojen vastaanotto loppuasiakkaalta toimii näin:

  1. Web-rajapinta vastaanottaa zip-paketin ja purkaa sen.
  2. Kukin asiakirja kirjataan saapuneeksi. Palvelinohjelmisto tällä tavoin tietää, montako asiakirjaa lähetys sisälsi.
  3. Kukin asiakirja lähetetään Amazonin rajapintaan.

Amazonin puolella kukin asiakirja kääntyy pikkuhiljaa itsestään. Mutta miten Amazon saa palautettua tulokset takaisin vastaanotto-ohjelmallemme?

Yksi todella huono tapa olisi se, että vastaanotto-ohjelma lähettää asiakirjan Amazonille HTTP-kutsuna, ja jää odottamaan tuon kutsun vastausta. Ongelmaksi muodostuu se, että jos käännös kestää vaikka 60 sekuntia, HTTP-yhteys Amazonin suuntaan on 60 sekuntia auki. Tämä ei ole ideaaliratkaisu.

Parempi ratkaisu on, että vastaanotto-ohjelma ampuu asiakirjan Amazonin suuntaan HTTP-kutsulla, ja Amazon vastaa HTTP-kutsuun välittömästi. Amazonin antama vastaus ei sisällä käännöstä, vaan kuittauksen tyyliin käännöstyö vastaanotettu, ilmoitamme erikseen kun käännös on valmiina.

Keskustelun voi kuvata näin:

(yhteys aukeaa)

Vastaanotto-ohjelma: hei Amazon, tässä sinulle työtehtävä…

Amazon: selvä pyy, ilmoitan sitten kun on valmista!

(yhteys sulkeutuu)

Entä miten Amazon palauttaa vastauksen takaisin vastaanotto-ohjelmalle? Se ottaa itsenäisesti uuden HTTP-yhteyden! Tämä on mahdollista suorittaa suoraan Lambda-funktion sisältä. Keskustelu jatkuu kutakuinkin näin:

(yhteys aukeaa)

Amazon(Lambda): hei kaveri, muistatko antamasi työtehtävän? Tässä tulokset siitä!

Vastaanotto-ohjelma: kiitos, otan talteen!

(yhteys sulkeutuu)

Tässä kohtaa vastaanotto-ohjelma on saanut yhden käännöstuloksen takaisin. Käännöksiä lähti alunperin liikkeelle 1000 kpl, joten tämä yksi on vasta alkua. Käytännössä seuraavat pari minuuttia (tai sinnepäin) vastaanotto-ohjelma saa 999 uutta yhteydenottoa:

(yhteys aukeaa)

Amazon(Lambda): tässä tulokset…

Vastaanotto-ohjelma: kiitos, otan talteen!

(yhteys sulkeutuu)


(toinen yhteys aukeaa)

Amazon(Lambda) #2: tässä tulokset…

Vastaanotto-ohjelma: kiitos, otan talteen!

(toinen yhteys sulkeutuu)


kolmas yhteys aukeaa)

Amazon(Lambda) #3: tässä tulokset…

Vastaanotto-ohjelma: kiitos, otan talteen!

(kolmas yhteys sulkeutuu)



999s yhteys aukeaa)

Amazon(Lambda) #999: tässä tulokset…

Vastaanotto-ohjelma: kiitos, otan talteen!

(999s yhteys sulkeutuu)

Kun kaikki 1000 käännöstä ovat saapuneet, koko urakka on vihdoin valmis! Mutta ennen sitä on syytä miettiä seuraavaa: Amazonilla saattaa olla kullakin ajan hetkellä usean eri loppuasiakkaan käännösurakat pyörimässä.

Eli edellinen keskustelu olikin VALTAVA yksinkertaistus, sillä siinä oletettiin, että kaikki käännöstulokset kuuluivat yhdelle ja samalla ihmisasiakkaalle. Oikeasti keskustelu näyttää tältä:

(yhteys aukeaa)

Amazon(Lambda) #3829: tässä käännös Matin dokumenttiin nro 12…

Vastaanotto-ohjelma: kiitos, otan talteen!

(yhteys sulkeutuu)


(yhteys aukeaa)

Amazon(Lambda) #115: tässä käännös Pirkon dokumenttiin nro 821…

Vastaanotto-ohjelma: kiitos, otan talteen!

(yhteys sulkeutuu)


(yhteys aukeaa)

Amazon(Lambda) #8008: tässä käännös Pirkon dokumenttiin nro 822…

Vastaanotto-ohjelma: kiitos, otan talteen!

(yhteys sulkeutuu)


//jne. jne

Yllä näemme toisen tärkeän konseptin; kukin dokumentti on yksilöity järjestysnumerolla. Tämä järjestysnumero mahdollistaa sen, että lähtevä suomenkielinen dokumentti voidaan myöhemmin mätsätä eli yhdistää sisääntulevaan englanninkieliseen käännökseen.

Tällä tavoin tiedämme, mitkä dokumentit on käännetty ja mitkä ovat vielä prosessoitavana.

Käännökset saapuneet, yksi urakka valmis!

Kun vastaanotto-ohjelma on saanut kaikki käännökset haltuunsa, se voi vihdoin lähettää tiedon ja käännökset ihmiskäyttäjälle. Ensin 1000 kpl käännöksiä pakataan zip-pakettiin. Sen jälkeen vastaanotto-ohjelma (joka tässä vaiheessa toimii enemmänkin “lähetysohjelmana”) ottaa yhteyden SMTP-rajapintaan.

Tuonne rajapintaan pusketaan zip-paketti ja ihmiskäyttäjän email-osoite. SMTP-palvelin hoitaa loput, ja hetken kuluttua ihmiskäyttäjän sähköpostilaatikko kilahtaa.

Entä jos vastaanotto-ohjelma kaatuu kesken käännösten odottelun?

Mietitäänpä seuraavaa tilannetta. Matti lähettää 1000 kpl dokumentteja web-palveluumme. Vastaanotto-ohjelma lähettää ne kaikki Amazonin suuntaan. Amazon ehtii kääntämään ja palauttamaan 500 kpl, kunnes jotain menee pieleen: vastaanotto-ohjelmamme kaatuu.

Näin voi käydä esimerkiksi siinä tapauksessa, että fyysinen palvelin simahtaa pois päältä. Ehkä palvelinsalin siivooja sattui kippaamaan Fairyt tuuletinaukosta sisään.

Muista, että vastaanotto-ohjelma pyörii ihan tavallisella palvelimella. Ainoastaan Amazonin pääty pyörii ulkoistetun pilvipalvelun varassa.

Jos Amazonin päädyssä yksittäinen palvelin sattuu tekemään itsemurhan, Amazon hoitaa korjaustoimenpiteet osana palvelulupaustaan. Jos vastaanotto-ohjelman päädyssä palvelin posahtaa, se on ohjelmoijan ongelma. Eli siis minun ongelma, joka ylläpidän käännöspalvelua.

Niin tai näin, koko vastaanotto-ohjelman keskusmuistitila nollaantuu palvelimen käynnistyessä uudestaan. Tämä nollaantuminen on hiukan ongelmallista, sillä vastaanotto-ohjelma piti keskusmuistissaan kirjaa dokumenteista, jotka olivat parhaillaan prosessoitavina Amazonin päädyssä.

Auts. Se siitä kirjanpidosta. Mites nyt suu pannaan?

Kovalevy avuksi

Ongelmaan on helppo ratkaisu. Vastaanotto-ohjelma pitää kirjanpitoa keskusmuistin sijaan kovalevylle. Kovalevyn hyvä puoli on, että palvelimen sipatessa tieto ei katoa mihinkään. Kun palvelin buuttaa itsensä ja vastaanotto-ohjelma palaa linjoille, se voi kovalevyltä tarkistaa kirjanpidon. Ongelma ratkaistu!

Mutta valitettavasti kirjanpidon pöllähtäminen taivaan tuuliin ei ollut ainoa ongelmamme. Sillä mietipä seuraavaa. Sanotaan, että vastaanotto-ohjelmamme kaatuu kahdeksi minuutiksi (tuon ajan fyysisellä palvelimella kestää buutata itsensä). Tällä välin Amazonin pääty on saanut käännöksen valmiiksi. Miltä keskustelu näyttää?

(yhteyttä muodostetaan…)

Amazon(Lambda) #3829: tässä Matin käännös dokumentti nro 12…

Amazon(Lambda) #3829: haloo, onko ketään kotona…?

Ongelman ydin on yksinkertainen: vastaanotto-ohjelma on poissa langoilta, joten Amazon ei saa siihen yhteyttä!

Ongelma on pirullinen ratkaista. Naivi, ihanan sinisilmäinen ratkaisuehdotus on pakottaa Amazonin Lambda-funktio odottamaan kunnes vastaanotto-ohjelma on taas takaisin elävien kirjoissa.

Tämä “ratkaisu” on erittäin huono. Sen surkeuden voi paljastaa yhdellä kysymyksellä: entä jos vastaanotto-ohjelma ei ehdi palaamaan linjoille ennen Lambda-funktion elinajan ylittymistä?

Muistutetaan mieliimme, että Lambda-kutsulla on maksimiaika, jonka aikana työtehtävä tulee suorittaa. Jos aika ei riitä niin huonompi homma.

Tälläisessä tilanteessa käännöstyön tulokset häviävät pysyvästi bittiavaruuteen.

Kolmas osapalanen

Paras ratkaisu on lisätä kokonaisarkkitehtuuriimme kolmas elementti: käännöstöiden tulokset vastaanottava jono.

Tämä jono on esimerkiksi Amazonin SQS jonopalvelu. Jonon ydinidea on, että se ei ole koskaan poissa linjoilta. Voimme siis luottaa, että Amazonin Lambda saa aina yhteyden Amazonin jonoon.

Jonon toinen ydinidea on, että se pitää tuloksia hallussaan siihen asti, kunnes vastaanotto-ohjelma käy ne hakemassa itselleen.

Tällä tavoin ongelma ratkeaa. Vastaanotto-ohjelman ollessa alhaalla Amazonin pääty lähettää tulokset jonoon. Kun vastaanotto-ohjelma sitten joskus herää kuolleista, se käy hakemassa tulokset tuolta samasta jonosta.

Itse asiassa jono mahdollistaa vielä paremman yksinkertaistuksen: Amazon Lambda lähettää käännösten tulokset jonoon riippumatta siitä onko vastaanotto-ohjelma elossa vai ei! Tällä tavoin Lambdan ei tarvitse milloinkaan ottaa suoraa yhteyttä vastaanotto-ohjelmaan.

Tässä uudessa, parannellussa mallissamme keskustelun kulku menee kutakuinkin näin. Käydään keskustelu yhden käännettävän dokumentin näkökulmasta:

(yhteys aukeaa)

Vastaanotto-ohjelma: hei Amazon, tässä sinulle työtehtävä…

Amazon: selvä pyy.

(yhteys sulkeutuu)


(yhteys aukeaa)

Amazon Lambda: hei jono, tässäpä tulokset…

Jono: kiitos, pistän talteen

(yhteys sulkeutuu)


(yhteys aukeaa)

Vastaanotto-ohjelma: hei jono, onko mitään uutta?

Jono: kyllä on, tässä uudet tulokset!

(yhteys sulkeutuu)

On tärkeä ymmärtää syyt miksi tämä kolmen osapuolen arkkitehtuuri on valtava parannus alkuperäiseen kahden osapuolen arkkitehtuuriin verrattuna. Kerrataan siis:

Alkuperäisessä mallissa vastaanotto-ohjelmalla oli kaksi(!) vastuualuetta mitä tulee tulosten vastaanottamiseen:

  1. Vastaanottaa tulokset (“ai tosiaanko!”)
  2. Pysyä hengissä

Listan kakkoskohta saattaa kuulostaa hupaisalta, mutta datan katoamisessa bittiavaruuteen ei ole mitään hupaisaa.

Uudessä, kolmen osapuolen arkkitehtuurissa vastaanotto-ohjelmalla on vain yksi vastuualue:

  1. Hakea tulokset jonosta

Kyseessä on valtava yksinkertaistus ihan siitä syystä, että palvelinohjelmiston ylläpitäminen 100% luotettavuudella pystyssä on helvetinmoinen haaste. Sen lisäksi että sähköt saattavat katketa, käytännössä kaikki ohjelmistot sisältävät bugeja.

Hyvä nyrkkisääntö palvelinpuolen koodauksessa onkin seuraava:

Ennemmin tai myöhemmin jokainen palvelinohjelmisto kaatuu bugin seurauksena.

Ja mitä monimutkaisempi ohjelma, sitä todennäköisemmin se pölläyttää savut pihalle. Tässä mielessä yksi vastuualue on parempi kuin kaksi.

Joko vihdoin olemme kuivilla vesillä kokonaisarkkitehtuurin suhteen?

Entä jos vastaanotto-ohjelma kaatuu otettuaan jonosta tulokset?

Palvelinohjelmistojen ohjelmointi on saatanallista ongelmanratkontaa. Emme suinkaan ole vielä paratiisin ovilla. Seuraava ratkaistava ongelma on tämä:

Entä jos vastaanotto-ohjelma kaatuu heti sen jälkeen, kun se on hakenut uusimmat tulokset jonosta?

Se siis hakee uusimmat tulokset jonosta, joka luonnollisesti unohtaa nuo tulokset. Mutta ennenkuin vastaanotto-ohjelma ehtii lähettää tulokset ihmiskäyttäjälle, palvelin kohtaa sähkökatkon.

Tulokset eivät ole enää jonossa, mutta ne eivät ole enää vastaanotto-ohjelman keskusmuistissakaan - ohjelma kun kaatui. Bittiavaruus ja niin edelleen.

Ratkaisuehdotus #1

No, ratkaisuhan on ilmiselvä? Kun vastaanotto-ohjelma saa tulokset jonosta itselleen, se ensitöikseen tallentaa ne kovalevylle. Ratkaisu on siis sama kuin aiemmassa ongelmassamme käännöstöiden kirjanpidon suhteen.

Paitsi että pieleen meni. Sillä entä jos vastaanotto-ohjelma kaatuu juuri ennenkuin se ehtii kirjata tulokset kovalevylle? Se siitä, bittiavaruus kohtalona jälleen.

Ratkaisuehdotus #2

Oikea ratkaisu on hoitaa asia niin, että jono unohtaa tulokset vasta kun sille annetaan lupa. Keskustelu vastaanotto-ohjelman ja jonon kanssa näyttää tältä:

(yhteys aukeaa)

Vastaanotto-ohjelma: hei jono, onko mitään uutta?

Jono: kyllä on, tässä uudet tulokset!

Vastaanotto-ohjelma: ok, kiva, odotapa pojka hetki…

Vastaanotto-ohjelma: voit unohtaa nuo antamasi tulokset!

Jono: gone and gone! ensi kertaan!

(yhteys sulkeutuu)

(Teknisesti tuota viestinvaihto ei käydä yhden ja saman yhteyden - ei varsinkaan HTTP-yhteyden - sisällä, mutta yksinkertaistus sallittakoon…)

Maali

Nyt olemme saaneet ratkaistua suurimmat ongelmamme. Muutamia vielä jäin, joihin en jaksa puuttua kuin lyhyesti ja summittaisesti:

  1. Entä jos vastaanotto-ohjelma kaatuu juuri kun ihmiskäyttäjä on lähettänyt zip-paketin?
  2. Entä jos Amazonin Lambda-funktio jostain syystä ei saa suoritettua käännöstä (kenties teksti on liian sotkuista)? Kelle se ilmoittaa epäonnistumisestaan?
  3. Entä jos asteroidi syöksää ihmiskunnan kivikaudelle?

Nopeat vastaukset:

  1. Vastaanotto-ohjelma ensitöikseen tallentaa zip-paketin kovalevylle.
  2. Ehkä Lambdan ei tarvitse ilmoittaa kellekään. Jos käännöstä ei saada tehtyä, sitä ei saada tehtyä, ja sillä selvä. Vastaanotto-ohjelman puolella voi olla jokin aikamääre määriteltynä, jonka sisällä kukin käännöstyö tulee saada valmiiksi. Jos käännös ei valmistu aikamääreen sisällä, se katsotaan epäonnistuneeksi, ja hylätään. Lopullinen, ulos lähtevä zip-paketti on tällöin pienempi kuin sisääntullut zip-paketti.
  3. “Päivitä Windows 10 uusimpaan versioon”.

Laravel: viivyttelyn taito

Laravel-kehyksen yksi sisäänrakennetuista ominaisuuksista on jono. Laravel mahdollistaa tehtävien puskemisen jonoon, ja suorittamisen erillisessä käyttöjärjestelmän prosessissa.

Tällä tavoin käyttäjän palvelupyyntöä käsittelevä prosessi pääsee helpommalla. Sen ei tarvitse hoitaa kuin tehtävien assignointi, ei itse tehtävien suoritusta.

Jonotuksen perusteet löytyvät parhaiten aiemmasta postauksestani täältä

Tässä postauksessa keskitymme erityisesti delay()-metodin käyttöön jonotuksen yhteydessä.

Lähtökohtaisesti jonoon työnnetyt tehtävät suoritetaan niin pian kuin mahdollista. Useimmiten tämä tarkoittaa, että tehtävä viettää jonossa aikaa vain muutaman sekunnin murto-osan.

On kuitenkin käyttötapauksia, joissa on ihanteellista pakottaa tehtävä jonottamaan vähän pidempään.

Ajastetut tehtävät jonon kautta

Yksi yleinen toimenpide on ajastaa sarja tehtäviä suoritettavaksi myöhempänä ajankohtana. Usein vieläpä nuo tehtävät tulee ajastaa siten, että tehtäväsuoritusten välillä kuluu tietty aika.

Otetaan esimerkki.

Lämpötilan mittaus tunnin välein - etukäteen ajastettuna!

Oletetaan, että meillä on applikaatio, joka mittaa ulkolämpötilaa. Se miten varsinainen mittaus suoritetaan ei ole oleellista - esimerkin kannalta oleellista on se, miten mittaukset ajastetaan.

On täysin mahdollista mitata lämpötila joka sekunti. Ulkolämpötila ei kuitenkaan mainittavasti nouse/laske sekunnin välein, joten kovin järkevää tuo ei ole. Sen sijaan mitatkaamme lämpötila kerran tunnissa.

Järjestelmän hieno ominaisuus on, että se ei mittaa lämpötiloja omin päin. Sen sijaan käyttäjä joutuu pyytämään lämpötilan mittaussarjan aloittamista. Pyynnön yhteydessä käyttäjä myös ilmoittaa montako mittaustapahtumaa hän haluaa suorittaa. Mittaustapahtumien määrä vastaa tuntien määrää, sillä mittauksia tehdään yksi tunnissa.

Kätevimmin ylläolevan kaltainen toiminnallisuus onnistuu juuri ajastetun jonotuksen avulla.

// App\Execute.php

// Se miten käyttäjältä kysytään mittaustapahtumien määrä ei ole oleellista.
// Oletetaan että kysyminen on suoritettu *jotenkin*.
$mittaustenMaara = 10;

// Carbon on erinomainen ajanhallintaan erikoistuva lisäosa!
$now = Carbon::now();

// Luodaan ja jonotetaan mittaukset
for($i=0; $i < $mittaustenMaara; $i++){
  // dispatch siirtää tehtävän jonoon
  // Huomionarvoista on *delay()*-metodin käyttö. Se 
  // antaa meille tilaisuuden määrittää ajankohdan
  // jolloin tehtävä aikaisintaan voidaan suorittaa!

  // Delay-metodin avulla voimme täten siirtää tehtävän suorituksen
  // haluttuun hetkeen tulevaisuuteen. Kullekin tehtävälle annamme
  // odotusajaksi kasvavan tuntimäärän $i.
  dispatch(new MittaaLampotila()->delay($now->addHours($i)));   
}

Tarvitsemme vielä tuon MittaaLampotila-luokan.


// App\Jobs\MittaaLampotila.php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;

use App\Models\Mittaustulos;

class MittaaLampotila implements ShouldQueue
{
    use InteractsWithQueue, Queueable

    public function handle(LampotilaRajapinta $rajapinta)
    {
    	// $rajapinta tulee DI-konttanerin...kontaaninerin... kautta

    	// Suoritetaan mittaus kutsumalla injektoitua rajapintaa.
        $celsius = LampotilaRajapinta->mittaa();

        // Meillä on olemassa 'Mittaustulos' Active Record-malli,
        // joka hoitaa tuloksen puskemisen tietokantaan.
        $mittaustulos = new Mittaustulos($celsius);
        $mittaustulos->save();

    }
}

Kun koko jono on lopulta (esimerkin tapauksessa 9 tunnin kuluttua) tyhjentynyt, tietokanta näyttää appatiarallaa tältä.


| celsius    | ajankohta |
| ---------- | --------- |
| 12         | 16.00     |
| 13         | 17.00     |
| 13         | 18.00     | 
| 10         | 19.00     | 

// jne. jne.

Laravellin delay()-metodi mahdollistaa helpon tavan siirtää tehtävä kauas tulevaisuuteen. Sen lisäksi, että tehtävä ajetaan erillisessä prosessissa (ns. prosessi-isolaatio), tehtävä ajetaan myös ajallisesti erillään (ns. ajallinen isolaatio).

Toinen hyvä käyttötarkoitus tälle portaalliselle ajastukselle on tehdä kutsuja johonkin rajapintaan. Sanotaan, että meillä on 1000 kpl HTTP-kutsuja tehtävänä. Jos kaikki kutsut ammutaan parin sekunnin sisällä, vastaanottava pää on käärmeissään (koska DoS-hyökkäys).

Jos taas ajastamme kutsut lähtemään aina 10 sekunnin välein, vastaanottaja on tyytyväinen.

Regex ja URL

Tarvitsin tänään Laravel-projektia koodatessani toiminnallisuutta, joka tsekkaa onko annettu tekstijono validi www-osoite.

Laravel itsessään tarjoaa tälläisen tsekkauksen, mutta ikäväksekseni Laravel on varsin tiukkapipoinen: se ei hyväksy osoitetta www.nokia.fi, sillä osoitteen alusta puuttuu “http://“-alkuliite. Omassa projektissani en halua kiusata käyttäjiä mokoman http-alkuosan kirjoituspakolla, joten jouduin hylkäämään Laravellin tsekkarin.

Netistä löytyi varsin kiva regex (regular expression) hoitamaan URL:n tarkistus:

/^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/;

Niin että mitäs tuo sotku tarkoittaakaan? Itselläni ei ole juuri mitään hajua. Tai ei ollut ennen tätä päivää. Olen aina suosiolla ulkoistanut Regex-lauseiden muodostamisen Stack Overflown kaltaisille nettipalveluille.

Nyt kuitenkin selvitin asiaa, vaikka vain tätä blogipostausta varten. Ja toisaalta onhan se hyvä osata jotain.

Miten tuo tekstihirviö tarkistaa URL-osoitteen?

Ylläoleva regex tosiaan varmistaa, että sille annettu tekstijono on toimiva www-osoite eli URL. Miten ihmeessä? Tarkastellaan tekstimonsteria pala kerrallaan.

Koko monsteri oli siis:

/^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/

Ensimmäinen kenoviiva

/

Ensimmäinen kenoviiva avaa regex-ekspressionin.

Http-alkuliitteen tarkistus

^(https?:\/\/)?

Tässä päästään itse asiaa. Tämä osuus tarkistaa, että URL-osoittessa joko on http://-alkuliite, https://-alkuliite, tai ei alkuliitettä ollenkaan. Jokin noista kolmesta vaihtoehdosta tulee olla voimassa; muussa tapauksessa kyseessä ei ole URL ja regex loppuu siihen.

Hiukka merkistöstä ylläolevan regex-palasen suhteen.

  1. Sulut ympäröivät tarkistettavan konseptin.
  2. Kysymysmerkki merkitsee, että sitä edeltävä konsepti esiintyy joko kerran tai ei lainkaan. Esimerkiksi s? tarkoittaa, että osuuden http jälkeen tulee kirjain s joko kerran tai ei kertaakaan.

Mennään eteenpäin.

Host-nimen tarkistus

Seuraava palanen tarkistaa, että domainin host-osuus sisältää laillisia merkkejä. Host-osuus on domainissa se nimi, joka tulee ennen maatunnusta. Esimerkiksi domainissa www.nokia.fi, host-nimi on nokia.

([\da-z\.-]+)

Ylläoleva siis tarkistaa, että host-nimi sisältää numeroita (\d) ja/tai laillisia kirjaimia (a-z). Ääkkösiä ei saa sisältää, sillä a-z sisältää vain englannin kielen kirjaimet.

Lisäksi a-z tarkoittaa, että vain pieniä kirjaimia saa olla mukana. Isot kirjaimet eivät käy.

Tämä jälkeen tulee kohta ‘\.-’, joka tarkoittaa, että host-nimi saa sisältää myös pisteitä ja väliviivoja. Muut merkit eivät ole sallittuja.

Mitä nuo hakasulut tekevät tuossa? En tiedä. Jotain kapturoinnista internet-haun mukaan, mutta en täysin ymmärtänyt mitä kapturoinnilla (siis “kiinniotolla” suomeksi) tarkoitetaan tässä kontekstissa.

Tärkeä sen sijaan on plus-merkki juuri ennen viimeistä sulkua. Se tarkoittaa, että koko aiempi litanja voi laillisesti toistua yhden tai useamman kerran. Ei siis nolla kertaa - vähintään yksi kerta tarvitaan.

Tämä tarkoittaa, että seuraavat host-nimet ovat laillisia:

  1. ‘nokia’
  2. ‘nokia-puhelin007’
  3. ‘nokia.puhelin007.ollila’

Ylläolevat noudattavat sääntöjämme. Sen sijaan seuraavat host-nimet ovat laittomia:

  1. ‘Nokia’ (iso kirjain on laiton!)
  2. ‘huhtamäki’ (ääkkönen on laiton!)
  3. ’ ‘ (tyhjä merkkijono on laiton!)

Mennään eteenpäin.

Pakollinen piste

\.

Tämä on hyvin yksinkertainen palanen; vaadimme, että host-nimen jälkeen tulee yksi piste. Tämä piste vastaa pistettä host-nimen ja maatunnuksen välissä, esim. “nokia.fi”.

Maatunnus min. 2 merkkiä, max. 6 merkkiä

Seuraavana tulee maatunnus, eli siis se com, fi, org tjms.

([a-z\.]{2,6})

Ylläoleva vaatimus määrittää, että maatunnus voi sisältää vain a-z -kirjaimet. Se siis EI voi sisältää numeroita. Ja sitten tulee mielenkiintoinen: {2,6} tarkoittaa, että maatunnuksen pituus voi olla 2-6 merkkiä.

Eli fi menee alarajalta nipin napin läpi, se kun on kaksi merkkiä. Maatunnus finland ei menisi läpi, koska se on 7 merkkiä pitkä.

Loppuosuus eli mahdolliset URI-päätteet

Loppuosuus on aika sotku.

([\/\w \.-]*)*\/?$/

Ylläoleva on tarkoitettu varmistamaan, ettei URL-osoitteen hakemistopolku sisällä laittomuuksia. Hakemistopolku on siis se loppuosuus, joka määrittää tarkan resurssin, joka haetaan.

Esimerkiksi URL-tekstijonossa www.nokia.fi/mobiili/ollila.jpg, tuo hakemistopolun osuus on /mobiili/ollila.jpg.

Ylläoleva regex aluksi varmistaa, että loppuosuus alkaa kenoviivalla.

Sen jälkeen tulee merkki \w, joka on mielenkiintoinen. Tuo tarkoittaa, että mikä tahansa alfanumeerinen merkki kelpaa. Eli siis pienet kirjaimet, isot kirjaimet ja numerot, ja vielä erikoismerkki _ (alaviiva).

Sitten tulee merkki *. Se tarkoittaa, että koko aiempi litanja - joka on hakasulkujen sisällä - toistuu joko nolla kertaa, yhden kerran tai useammin. Eli siis kuinka monesti tahansa - kaikki käy.

Loppuosuus *\/?$/ merkkaa yksinkertaisesti, että syöte päättyy. Dollarimerkki käskyttää regex-moottoria ymmärtämään, että tekstijonon tulisi olla loppu tässä kohtaa.

Aika monimutkaista.

AWS ja harva indeksi

Amazonin Dynamo-tietokantaa käytettäessä törmäsin tänään mielenkiintoiseen patterniin. Tarvitsin taululle indeksin attribuuttia varten, joka harvoin saa yhtään mitään arvoa.

Tälläisessä tapauksessa on ikävää joutua luomaan uusi, täysimittainen indeksitaulu.

Häh, miksi tuo on niin ikävää muka? Koska jos 99% talletettavista objekteista ei hyödy indeksistä lainkaan, niiden roikottaminen mukana indeksitaulussa on tilanhukkaa.

Otetaan konkreettinen esimerkki. Sanotaan huvin vuoksi, että meillä on seuraavanlainen tietokantataulu:


| nimi       | ikä | maailmanmestari |
| ---------- | --- | --------------- |
| Matti M    | 62  |        -        |
| Pekka J    | 11  |        -        |    
| Ismo P     | 16  |        -        | 
| Kimi R     | 37  |    Formula 1    | 

// jne. jne.

Ylläoleva taulu sisältää kaikista suomalaisista kolme tietoa; nimi, ikä, ja minkä urheilulajin maailmanmestaruuden henkilö on voittanut.

Sanotaan että nimi-attribuutti muodostaa ns. pääavain-indeksin. Sen tulee siis olla uniikki - täyskaimoja tietokantamme ei salli.

Käytännössä emme tietenkään käyttäisi nimeä pääavaimena, vaan pääavain olisi henkilötunnus. En valitettavasti satu tietämään Räikkösen Kimin hetua joten esimerkki toimii paremmin näin.

Nyt voimme luoda kaksi indeksitaulua varsinaisen taulun oheen. Yksi indeksi iälle, toinen maailmanmestaruudelle.

Tällä tavoin nopeutamme merkittävästi hakuja, joissa ikää tai maailmanmestaruutta käytetään hakukriteerinä.

Esimerkki ikä-attribuuttia hakukriteerinä käyttävästä hausta: palauta kaikki henkilöt, joiden ikä on 60 ja 65 välillä

Esimerkki maailmanmestari-attribuuttia käyttävästä hausta: palauta kaikki henkilöt, jotka ovat voittaneet keihäänheiton MM-kultaa

Kaikki hyvin. On kuitenkin huomattava, että ikä-indeksitaulu sisältää viisi miljoonaa riviä. Tämä ihan siksi, että alkuperäinen taulu sisältää myös viisi miljoonaa riviä, ja jokainen henkilö tulee indeksoida iän perusteella, jotta ikä-indeksi toimii oikein.

Mutta kuinka moni suomalainen on voittanut MM-kultaa yhtään missään?

Datan indeksointi ikä-attribuutin suhteen on siis varsin järkevä idea.

Jokainen henkilö kun on jonkin ikäinen.

Vaan kuinka moni on voittanut jonkin lajin maailmanmestaruuden?

Ydinkysymys on tämä: kuinka suuri on maailmanmestareiden osuus on verrattuna koko väestöön?

Sanotaan esimerkin vuoksi, että mestareiden lukumäärä on 1000 henkilöä. Eli koko kansasta 0.02%. Tästä herää pieni suorituskyvyllinen ongelma: jos luomme indeksin maailmanmestari-attribuutille, 99.98% indeksitaulun jäsenistä on siellä ihan turhaan.

He eivät ole voittaneet mestaruutta, joten ei heitä tarvitse indeksoida. Ei ole mitään mitä indeksoida! Sama kuin yrittäisi indeksoida sosiaalidemokraattien itsekunnioitusta.

Tälläinen tuhlaus kuulostaa hirveältä: 0.02% takia 99.98% joutuu kärsimään. Siis kärsimään siinä mielessä, että heille luodaan oma turhanpäiväinen rivi indeksitauluun.

Harva indeksi - jätä luuserit pois alunperinkin

Harva indeksi tulee apuun. Ydinpointti on tässä: miksi emme loisi maailmanmestari-indeksiä siten, että se sisältää ainoastaan maailmanmestarit?

Ajatus on varsin luonteva, ja vain ohjelmistosuunnittelija voi ilakoida sen hoksaamisella. Mutta kuitenkin - harva indeksi on pätevä ratkaisu ongelmaamme.

Käytännössä luomme siis “eliitti-indeksin” - vain maailmanmestarit kelpuutetaan mukaan listaukseen. Indeksi toimii ikäänkuin urheilumaailman “Kuka kukin on”-oppaana.

Harvan indeksin avulla saamme pudotettua 5 miljoonan rivin kokoisen indeksin vaivaiseksi 1000 rivin indeksiksi. Tilaa säästyy valtava määrä.

Amazon tekee harvan indeksin ohjelmoijan puolesta

Amazonin DynamoDB:ssä harvan indeksin luonti on helppoa. Jopa niin helppoa, että se tapahtuu täysin automaattisesti järjestelmän toimesta. Ihan totta, kirjaimellisesti ohjelmoijan ei tarvitse tehdä yhtään mitään.

Teknisesti AWS:n toteutus toimii siten, että aina kun uutta objektia lisättäessä tuon objektin indeksoidun attribuutin jättää tyhjäksi, objektia ei indeksoida lainkaan. Henkilötaulun esimerkki yllä on suoraan siirrettävissä DynamoDB:n puolelle - lisätessämme uusia henkilöitä tietokantaan riittää, että jätämme maailmanmestari-kentän tyhjäksi.

Jos emme jätä sitä tyhjäksi, henkilö on voittanut maailmanmestaruuden, ja Amazonin taustajärjestelmä indeksoi hänet oikeaoppisesti.

Kätevää.

Miten MySQL toimii indeksoitavan kentän jäädessä tyhjäksi? Tämän linkin mukaan Mysql osaa ottaa asian huomioon jos asettaa kentän eksplisiittisesti arvoon NULL. En muista kokeilleeni asiaa käytännössä.

Yksi tunniste, monta käyttöä

Yksi erinomainen tapa kytkeä front-end applikaatio rajapintaan, joka vaatii kirjautumisen/tunnistautumisen, on käyttää nk. API Tokenia.

API Token on vähän vastaava asia kuin ranneke kesäfestivaaleilla. Kun festivaalien vierailija ensi kertaa astuu festivaalialueelle, häneltä kysytään lippua, mahdollisesti myös henkilökorttia. Lipun antaessaan vierailijalle lätkäistään käteen ranneke. Jos vierailija myöhemmin poistuu festivaalialueelta, hän voi palata sinne takaisin ranneketta (API Token) näyttämällä. Jos rannekkeessa on RFID-siru, rannekkeella voidaan yksilöidä kävijä helposti. Myös API Token yksilöi käyttäjänsä. Käyttäjän tarkka yksilöinti on valinnainen “lisäpalvelu”; joissain käyttötarkoituksissa riittää tietää, että kävijällä on oikeus nähdä tiedot ilman tarvetta tietää kuka haluaa tiedot nähdä. Useimmiten API Token kuitenkin yksilöi käyttäjän.

API Tokenin saa antamalla rajapinnalle validin tunnus+salasana-yhdistelmän. Tällä tavoin rajapinta tietää, että API Token vastaanottava taho on ihan oikea poika palveluun rekisteröitynyt käyttäjä.

API Token on yleensä voimassa siihen asti, kunnes käyttäjä erikseen kirjautuu ulos palvelusta (rajapinnasta). Vaihtoehtoisesti tunniste voi olla voimassa vain tietyn ajan.

Tyypillisessä arkkitehtuurissa rajapinnasta saatu API Token talletetaan käyttäjän tietokoneen kovalevylle talteen. Tällä tavoin käyttäjä pysyy automaattisesti kirjautuneena rajapintaan, vaikka sulkisi tietokoneen välillä.

Automaattisesti kirjautuneena pysyminen tässä kohtaa tarkoittaa, että frontend-applikaatio hoitaa kovalevyltä ladatun API Token avulla tunnistautumisen; ihmiskäyttäjän ei tarvitse syöttää salasanaa. Oikeasti käyttäjä ei pysy kirjautuneena yhtään mihinkään. Pinnan alla joka ikisen rajapintakutsun yhteydessä kirjautuminen suoritetaan uusiksi juurikin API Tokenin avulla. Ihmiskäyttäjä ei tätä prosessia näe.

API Tokenin ominaisuuksiin myös kuuluu useimmiten, että jos käyttäjä tarjoaa validin tunnus+salasana-yhdistelmän vaikka hänellä on (tai pitäisi olla!) hallussaan API Token, rajapinta generoi uuden API Tokenin. Vanha API Token lentää roskakoriin. Näin on pakko olla; muuten käyttäjä hävittäessään API Tokeninsa ei enää koskaan pääsisi sisälle rajapintaan. Ja kuinka API Token voi hävitä? Esimerkiksi tyhjentämällä web-selaimen välimuistin.

Tämä malli toimii erinomaisesti. Jos kovalevyltä ei API Tokenia löydy, käyttäjän on pakko syöttää salasana. Salasanan (mieluiten oikean) syötettyään käyttäjä saa API Tokenin, jonka voi tallettaa kovalevylleen.

Useimpien web-applikaatioiden yhteydessä ‘kovalevy’ on synonyymi web-selaimen localStorage:lle.

##Yksi monen puolesta

Mutta entä jos yhtä rajapintaa käyttää kaksi erillistä web-applikaatiota? Tälläinen tilanne syntyy herkästi nk. micro service -arkkitehtuurissa sovellettuna fronttipuolelle. Yksi rajapinta tarjoaa palvelut monelle web-applikaatiolle, jotka yhdessä muodostavat tuoteperheen.

Esimerkkinä vaikkapa applikaatiokokonaisuus, jossa yksi web-app huolehtii lomakedatan käsittelystä, ja toinen web-app huolehtii lomakkeiden luonnista (lomake-editori). Molemmat web-appit ovat osa samaa kokonaisuutta, jota kutsuttakoon vaikka “liidien hallinnaksi”.

Kutsutaan applikaatioita vaikka nimillä “Lotus Lomakekäsittely” ja “Lotus Lomake-editori”.

On luontevaa, että applikaatiokokonaisuuden tilaava taho saa käyttöön yhdet admin-tunnukset, joilla kirjautua molempiin applikaatioihin sisään.

Mutta jos orjallisesti seuraamme yllä kuvattua API Tokenin käyttömallia, olemme pian dilemman edessä.

(jatkuu huomenna…)

Lodash: template

Javascriptillä populoitavien mallipohjien (template) käyttö on etenkin frontend-koodauksessa varsin yleistä. Tyypillinen tarve mallipohjalle syntyy silloin, kun DOM-puuhun pitäisi lisätä uusi DOM-elementti, ja tuo elementti on rakennettava dynaamisesti.

DOM-puu tulee sanoista “Document Object Model tree”. DOM-puu kaikessa yksinkertaisuudessaan kuvaa hierarkisessa muodossa kaiken sen mitä nettisivu sisältää. Nettisivun tekstit, kuvat, videoelementit kaikki ovat osa tuota puurakennetta.

Elementtiä dynaamisesti rakennettaessa oleellista on, että pystymme injektoimaan haluttuun mallipohjaan sopivia tekstinpätkiä. Mallipohja sisältää näitä injektioita varten erikseen määritellyt “replace here”-kohdat.

Dynaamisen elementin rakentaminen mallipohjan pohjalta on konseptiltaan sama kuin sanaristikon täyttäminen. Ristikkointoilija täyttää ennaltamääriteltyihin laatikoihin kirjaimia. Vihjekuvat ovat aina samat - ne ovat osa mallipohjaa, tässä tapauksessa ristikkoa.

Omalla kohdallani perinteinen tapa toteuttaa HTML-mallipohjien käyttö on ollut turvautua Handlebars-kirjastoon. Tuo kirjasto hoitaa homman asiallisesti. Mutta jokunen aika sitten kävi ilmi, että myös Lodash-kirjasto hoitaa homman.

Ja koska käytän Lodashia muutenkin kovin runsaasti, oli suora motivaatio siirtyä heidän pariin tässäkin asiassa.

template()-funktion käyttö

Lodashin template() apumetodi mahdollistaa tekstijonon tuottamisen toisen tekstijonon pohjalta seuraavaan tyyliin:


var vieras1 = "Jaakko";
var vieras2 = "Kalle";

var pohja = _.template('Hei vain <%= nimi %>');

// Luodaan pohjan perusteella uusia tekstijonoja, joissa 
// nimi on dynaamisesti korvattu uudella tekstijonolla.

pohja({nimi: vieras1}); // "Hei vain Jaakko"
pohja({nimi: vieras2}); // "Hei vain Kalle"

Käyttö on tismalleen noin yksinkertaista. Ylläolevassa esimerkissä mallipohjan käytön hyöty ei ole merkittävä - yhtä hyvin voisimme tehdä seuraavalla tavalla:


// Luodaan kumpikin tervehdys liimamalla tekstijonoja yhteen käsin.

"Hei vain " + vieras1; // "Hei vain Jaakko"
"Hei vain " + vieras2; // "Hei vain Kalle"

Tilanne muuttuu, kun mallipohjana toimiva tekstijono on pitkä, ja siihen on tehtävä useita tekstikorvauksia. Tällöin leikkaaminen + liimaaminen käsipelillä vie rutosti aikaa (ohjelmoijan aikaa, ei CPU-aikaa).

Lodashin template-metodi sisältää paljon ominaisuuksia. Se pystyy mm. tunnistamaan HTML-merkistön ja tekemään asianmukaiset merkistökoodaukset (“escape”).

Lisätietoa löytyy doc-sivuilta: https://lodash.com/docs/4.16.4#template

Jonotettu työvaihe ja debuggaus

Yksi Laravellin monista hienoista ominaisuuksista on kyky jonottaa. Siis laittaa työtehtäviä jonoon myöhemmin suoritettavaksi.

Laravel tarjoaa kaikki tarvittavat komponentit jonotuksen toteuttamiseksi ns. “out-of-the-box”. Kaikki vain toimii.

Itse jonotuksen saloista olen puhunut aiemminkin täällä, mutta yksi hauska twisti jonon kautta ajetulla koodilla on.

Se on tämä: koska jonotettu koodinpätkä ajetaan erillisessä prosessissa, se ei voi palauttaa selaimelle debug-tekstiä ohjelmoijan tarkasteltavaksi.

Kun PHP-koodi ajetaan tavanomaisesti osana selaimelta lähtöisin olevaan kutsua, PHP voi aina palauttaa tarvittavan tekstijonon ohjelmoijan käyttöön. PHP-koodin puolella tämä onnistuu esimerkiksi komennoilla echo tai var_dump.

Tuo palautettu tekstijono printataan selaimen toimesta suoraan näyttöpäätteelle.

Mutta kun PHP-koodi ajetaan jonotetun työvaiheen kautta, ei ole mitään selainta jolle palauttaa mitään! Jonotettu työvaihe ajetaan nimittäin jono-managerin toimesta, joka siis käskyttää erillistä käyttöjärjestelmän prosessia ajamaan PHP-koodin. Tuo jono-manageri ei saa yhteyttä selaimeen.

Joten miten debugata jonotetun työvaiheen sisällä ajettavaa koodia?

En tiedä oikeaa vastausta itsekään. Pitäisi varmaan kysellä. Yksi ok tapa on logata debug-viestejä Laravellin lokiin. Jonotetulla prosessilla on luonnollisesti kyky kirjoittaa lokitiedostoihin, joten tämä onnistuu.

Jonotettu työvaihe ajetaan irrallaan perinteisestä selain->palvelin->selain -viestienvaihdosta. Tämä on koko jonotuksen ydinpointti (selain saa vastauksen nopeasti, ja raskas työvaihe voidaan jonottaa myöhempään ajankohtaan), mutta sen heikkous on, että debuggaus hiukan monimutkaistuu.

Yksi varteenotettava ratkaisu on debugata kirjoittamalla Laravellin omiin lokitiedostoihin, esim. komennolla \Log::info(‘debug-viesti’);

Laravel ja pehmeä tuho

Laravel tarjoaa ohjelmoijan käyttöön konseptin nimeltä “soft delete”. Suomennan tuo “pehmeäksi tuhoksi”, koska termi on niin hauska.

Pehmeä tuho tarkoittaa seuraavaa: kun tietue poistetaan tietokannasta, sitä ei oikeasti poistetakaan, se vain merkitään näkymättömäksi.

Vastakohtana on tietenkin “kova tuho” - eli siis tuikitavallinen poisto-operaatio, jossa tietue ihan aidosti poistetaan tietokannasta.

Miksi pehmeä tuho?

Herää kysymys, että mitä järkeä koko pehmeän tuhon konseptissa on? Poistamme tietueen, mutta emme poistakaan sitä. Häh? Miksi halusimme alunperinkään poistaa, jos emme sitten halunneetkaan.

Kaiken ytimessä on ajatus siitä, että applikaation tasolla tietue on saavuttamattomissa. Applikaatio siis elää käsityksessä, että tietue on ihan oikeasti tuhottu. Samaan aikaan kuitenkin yrityksen muut komponentit - esim. Business Intelligence - haluaa, että tietue on visusti tallessa.

Tämä eri komponenttien erilainen tarve tietueen olemassaololle johtuu komponenttien eriävistä vaatimuksista:

Applikaation ydinkoodille on ensisijaisen tärkeää, että poistetut tietueet ovat poissa. Eli että ne eivät väärään aikaan yhtäkkiä hyppää silmille.

Business Intelligence väelle taas on tärkeää, että jos jokin tietue on kerran asuttanut applikaation tietokantaa, on tuosta tietueesta ikuinen jälki jossain. Tällä tavoin mitään informaatiota ei huku bittiavaruuteen; jokainen tietue on ikuisesti tallessa.

Oleellista on, että yksi ja sama tietokanta voi pehmeää tuhoa hyväksikäyttäen tarjota soveltuvat toiminnallisuudet sekä applikaatiokoodille että Business Intelligence väelle!

Tämä onnistuu yksinkertaisesti siten, että kaikki applikaatiokoodin haut tietokantaan ajetaan yhdessä kontekstissa, ja kaikki Business Intelligencen haut ajetaan toisessa kontekstissa.

Yksinkertaisemmin: applikaatiokoodin haut jättävät huomioimatta pehmeästi tuhotut tietueet, kun taas Business Intelligence sisällyttää kaikki tietueet.

Toteutus Laravellissa

Laravellin puolella pehmeän tuhon käyttö on helppoa. Käytännössä käyttöönotossa on vain kaksi vaihetta:

  1. Käytettävään tietokantatauluun lisätään “deleted_at”-sarake.
  2. Käytettävä malli ottaa käyttöön SoftDeletes-toiminnallisuuden.
  3. Käytettävän mallin tulee sisältää dates-attribuutin.

Kas, näin:


// App\Models\Pankkitili.php

class Pankkitili extends Model
{
  // Vaatimus 2.
  use SoftDeletes;
  // Vaatimus 3.
  protected $dates = ['deleted_at'];

  // jne. muut mallin normimetodit
}

Vaatimuksen nro 1 täyttämiseksi meidän tulee luoda taulu, jossa on deleted_at-sarake. Esimerkiksi:


// Taulu: pankkitilit


id | tilinumero | omistaja    | created_at | deleted_at
-- | ---------- | ----------- | ---------- | ----------
1  | FI23932118 | 070278-262M | 2016-10-01 | 2016-10-03
2  | FI88001921 | 261188-771S | 2015-02-27 | NULL

Ylläolevassa taulussa sarake deleted_at kertoo milloin tietue on “tuhottu”, eli siis pehmeästi tuhottu.

Jos sarakkeen arvo on NULL, tietue on vielä olemassa. Tällöin siis sekä Business Intelligence että applikaatiokoodi näkevät tietueen.

Applikaatiokoodin puolella Laravel huolehtii siitä, että pehmeästi tuhotut mallit eivät tule mukaan hakutuloksiin.

// Koska vain malli ID #2 on applikaation näkökulmasta olemassa,
// seuraava haku palauttaa lukumääräksi 1.
Pankkitili::all()->count(); // 1

Soft Delete-toiminnallisuus mahdollistaa helposti append-only-tyylisen tiedonhallintaratkaisun luomisen. Append-only-ratkaisussa mitään tietoa ei koskaan poisteta; vanhentunut tieto yksinkertaisesti merkitään jollain ruksilla (deleted_at), joka kertoo, että tietoa ei pidä sisällytettävän applikaation tietokantahakuihin.

Yksi taulu, useampi objekti (part 2)

(jatkoa edelliselle postauksella)

Eli kysymys siis on: Miten yhdistämme usean eri luokan yhteen tauluun ja miksi haluamme niin tehdä?

Yksi tapa vetää mutkat suoriksi on tehdä yksinkertainen taulu, joka sisältää objekti-ID:n ja sitten tekstimuodossa valinnaisen datan, joka kuvaa objektia:


id | data
-- | ----------------------------------
1  | {name: 'jaakko', osoite: '...'}
2  | {pankki: 'OP', puhelin: '...'}
3  | {yhteys: 'SQLDriver', args: '...'}

Tällä tavalla on helppo saada eri tyyppiset objektit menemään samaan tauluun. Riittää, että objektin sisältö kyetään mahduttamaan data-kenttään, ja avot.

Mutta hetkinen, jotain puuttuu. Miten erotamme eri tyyppiset objektit toisistaan? Tarvitsemme uuden sarakkeen:


id | tyyppi  | data
-- | ------- | ----------------------------------
1  | Henkilo | {name: 'jaakko', osoite: '...'}
2  | Pankki  | {pankki: 'OP', puhelin: '...'}
3  | Ajuri   | {yhteys: 'SQLDriver', args: '...'}

Nyt meidän tyyppi-sarake kertoo millainen objekti kyseiselle riville on tallennettu. Teoriassa tuon objektin tyypin olisi voinut tallentaa osaksi data-attribuuttia, mutta parempi näin. Sillä nyt pystymme tekemään hakuja tyyppi-attribuuttia hyödyntäen.

Muokataan ylläolevaa meidän kommunikaatioesimerkkiä varten:


// Taulu: 'kommunikaatiot'

id | tyyppi     | data
-- | ---------- | --------------------------
1  | Savumerkki | {savunvari: 'harmaa', ...}
2  | Valomerkki | {aallonpituus: '30', ...}
3  | Puhelin    | {numero: 0409351405, ...}

Jokainen rivi sisältää tiedon siitä millainen konkreettinen kommunikaatiotapa on kyseessä, ja tarvittavan lisäinfon tuon tavan käyttämiseksi applikaatiokoodissa.

Miten sitten applikaatiokoodi tietää luoda oikeanlaisen objektin kunkin rivin pohjalta?

Muistutetaan mieleen, että tämä oli koko “yhden taulun periytyvuuden”-lähtökohta; kyky luoda eri objekteja saman taulun tietueista. Olemme kivasti onnistuneet koodaamaan tietuetyypin osaksi riviä (“tyyppi”-sarake!), mutta kuinka luoda objekti tuon sarakkeen avulla?

Laravellissa homma onnistuu laittoman helposti; voimme nätisti korvata vakioluontimetodin omalla metodillamme, joka tarkastaa tyyppi-sarakkeen ja valitsee oikean objektiluokan sarakkeen arvon perusteella!

Kas, näin:


use App\Models\Puhelin;
use App\Models\Valomerkki;
use App\Models\Savumerkki;

class Kommunikaatio extends Model {
	
  public function newFromBuilder($attributes = array(), $connection = null) {

    $m;

    $tyyppi = $attributes->tyyppi;

    // Voisimme myös instantoida suoraan "tyyppi"-attribuuttia käyttäen:
    // $m = new $tyyppi($attributes->data);
    // Tällöin emme tarvitsisi if-lausekkeita lainkaan!

    if ($tyyppi === 'Puhelin') {
      $m = new Puhelin($attributes->data);
    } 
    else if ($tyyppi === 'Savumerkki') {
      $m = new Savumerkki($attributes->data);
    } 
    else if ($tyyppi === 'Valomerkki') {
      $m = new Valomerkki($attributes->data);
    }     	
    else {
      throw new \Exception('Missing type: ' . $tyyppi);
    }

    return $m;
  }
}

Nyt meidän abstrakti konseptimallimme Kommunikaatio - joka on suoraan kytketty kommunikaatiot tietokantatauluun - tekee päätöksen lopullisesta konkreettisesta objektiluokasta, jonka perusteella objekti luodaan!

Tämän päätöksen Kommunikaatio tekee tarkastelemalla tyyppi-attribuuttia, ja valitsemalla sopivan mallin. Tuon sopivan mallin pohjalta luotu uusi objekti sitten palautetaan ulos metodista.

Kaiken hienous on siinä, että metodia kutsutaan Laravellin itsensä toimesta. Eli kun applikaatiokoodini hakee tietyn kokoelman kommunikaatioita tietokannasta, kukin kommunikaatio rakennetaan ylläolevan newFromBuilder-metodin kautta!


// Esimerkki
Kommunikaatio::all(); // [Puhelin, Valomerkki, Valomerkki, Puhelin, ...]

Toisin sanoen pystyn yhdellä ylätason kutsulla Kommunikaatio::all() luomaan kokoelman, joka sisältää eri objekteja. Tämä on aika hienoa. Koska nyt voin käsitellä noita eri objekteja miten haluan. Niin kauan kuin ne kaikki noudattavat jotain kommunikaatiokanaville yhteistä käyttöliittymää, ei ongelmia synny.


// Esimerkki
$kommunikaatiot = Kommunikaatio::all();

$kommunikaatiot->each(function($komm) {
  // Tässä on hienous! Voimme polymorfisesti kutsua
  // tiettyä metodia tietämättä lainkaan mikä konkreettinen
  // objekti "$komm" itse asiassa on!

  // Puhelin, Valomerkki, Savumerkki kaikki tarjoavat "send"-metodin.
  $komm->send('Haloo!');
});

Single-table inheritance - yhden taulun periytyvyys - antaa mahdollisuuden tallentaa yhteen ja samaan tauluun eri tyyppisiä objekteja. Mikä parasta, Laravellin avulla voimme luoda kokoelmia, jotka sisältävät noita eri tyyppisiä objekteja. Kaiken huippuna voimme käsitellä kokoelmia ilman, että tiedämme mitä tyyppiä kukin objekti on. Riittää, että kukin objekti tarjoaa tietyn yhteisen käyttöliittymän (interface).

Yksi taulu, useampi objekti

Tietokantapohjaisissa web-applikaatioissa tulee käyttöön aina välillä kätevä konsepti nimeltä “Single table inheritance”, eli “yhden taulun periytyvyys”.

Konsepti mahdollistaa useamman eri datatyypin objektin tallennettavan yhteen tietokantatauluun.

Lähtökohtaisesti useamman eri objektin tallennuksessa samaan tauluun ei ole mitään järkeä. Active Record-pohjaisissa järjestelmissä kukin ns. malliobjekti on kytketty pinnan alla yhteen tauluun, ja jos kaksi objektia kytkeytyy samaan tauluun, täytyy niillä olla samanmoiset attribuutit. Tämä siksi, että kukin tietokantataulu sisältää tietyn määrän attribuutteja (sarakkeita), ja tauluun menevän objektin tulee mukauttaa itsensä noihin attribuutteihin.

Esimerkiksi objektiluokan “Hevonen” ja “Tilisiirto” kytkeminen osaksi samaa tietokantataulua kuulostaa aika järjettömältä. Hevonen on elävä eläin, Tilisiirto on abstrakti konsepti liittyen pankkitoimintaan. Kovin paljoa yhteistä ei noilla kahdella objektilla ole.

Tilanne on vähän sama kuin jos yrittäisit valmistaa kulkuneuvon, joka liikkuu sekä ilmojen halki että vetten alla sukelluksissa. Ehkä saisit sellaisen aikaan, mutta kovin käytännöllinen tuo vehje ei varmasti ole.

Mutta entä jos meillä on jokin abstrakti konsepti, josta on mahdollista tuottaa konkreettisia objekteja?

Esimerkkinä vaikka “Kommunikaatio”. Kommunikaatio on abstrakti konsepti; se kuvaa motiivin vaihtaa informaatiota, mutta ei määrittele miten informaatiota vaihdetaan.

“Puhelin” puolestaan on konkreettinen objekti, joka menettelee miten informaatiota vaihdetaan.

Samoin on “Savumerkki”. Samoin on “Valomerkki”. Kaikki nuo tarjoavat menetelmän suorittaa käytännön maailmassa konsepti “Kommunikaatio”.

Kuvitellaan sitten, että meillä on Kommunikaatio-niminen luokka. Tuohon luokkaan on kytketty tietokantataulu “kommunikaatiot”.

Nyt suuri kysymys: miten saamme järkevästi kommunikaatiot-tauluun talletettua erilaisia kommunikaatiovälineitä?

Toinen suuri kysymys: miksi haluaisimme tehdä niin? Miksi emme vain loisi uutta tietokantataulua jokaista kommunikaatiovälinettä varten? Esim. “Puhelin” objektia varten taulu “puhelimet”. Savumerkkiä varten taulu “savumerkit”.

Eli: Miten yhdistämme usean eri luokan yhteen tauluun ja miksi haluamme niin tehdä?

(Esimerkki jatkuu huomenna)

Älä kuole ääneti

Monet ohjelmointikielet sisältävät tärkeän konseptin nimeltä *garbage collection”, suomeksi siis roskienkeruu. Tuo konsepti tarkoittaa yksinkertaisesti sitä, että ohjelmointiympäristö automaattisesti huolehtii ohjelman ajon aikana luotujen objektien tuhoamisesta.

Alimmalla raudan tasolla tämä tuhoamisesta huolehtiminen tarkoittaa sitä, että keskusmuistista vapautetaan tilaa uusia objekteja varten.

Myös Javascript noudattaa garbage collection-periaatetta. Kun tietystä objektista tulee tarpeeton, Javascriptin runtime-ympäristö hoksaa vapauttaa objektin varaamaan muistitilan. Se kuinka tuo hoksaaminen käytännössä tapahtuu ei ole oleellista ohjelmoijan kannalta; oleellista on vain se, että ohjelmoijan ei tarvitse asiasta välittää. Ohjelmointikielen taustalla pyörivä runtime-alusta toimii roskakuskina.

Niille jotka ovat kiinnostuneita roskienkeruun teknisestä toteutuksesta, seuraava linkki auttaa: http://stackoverflow.com/questions/10112670/when-are-javascript-objects-destroyed

Asiassa on kuitenkin yksi mutta.

Entä jos roskakoriin päätyvä objekti on varannut olemassaolonsa ajaksi käyttöönsä jonkin ulkoisen resurssin? Kun Javascript objekti tulee elinkaarensa päähän, runtime-alusta viskaa sen roskakoriin. Mutta miten käy tuon objektin omistaman resurssin?

Tosimaailman esimerkki selventää.

Tosimaailman esimerkki

Kuvitellaan, että varaan liput teatteriesitykseen huomisillalle. Ikäväkseni kuitenkin käy niin, että saan kohtalokkaan sydänkohtauksen tänä iltana, ja siirryn ajasta ikuisuuteen.

Vielä tämän päivän puolella eloton ruumiini käydään noukkimassa ruumishuoneelle (“roskien keruu”).

Vaan miten käy teatterilippujeni? Olen varannut liput huomisen esitykseen. Se, että menin kuolemaan tässä välissä, ei automaattisesti peruuta varaustani huomisen teatteriesitykseen.

Kuolleena en valitettavasti pääse paikalle teatteriin, mutta teatteri ei myöskään voi antaa paikkaa kellekään toiselle, sillä teatteri ei tiedä kuolemastani.

Ongelman ydin on siinä, että kuollessani kukaan ei peruuta paikkavaraustani.

Mutta entä jos toimisin seuraavasti; vielä kun olen elävien kirjoissa, raapustan post-it-lapulle tekstin “peruuta paikkavaraus teatteriin mikäli olen kuollut”. Asetan lapun lompakkooni ajokortin oheen.

Kuka ikinä elottoman ruumiini löytää, löytää myös tuon lapun. Hän voi toimia lapun ohjeiden mukaan. Teatteri saa tiedon siitä, etten pääse paikalle esitystä seuraamaan. Täten teatteri voi myydä paikkani jollekin toiselle.

Esimerkki applikoituna ohjelmoinnin maailmaan

Ylläolevan esimerkin logiikkaa seuraten voimme myös toteuttaa resurssin vapautuksen resurssia hallinnoivan objektin kuollessa. Vai voimmeko? Riippuu ohjelmointikielestä.

C++ -kielessä on konsepti nimeltä “destructor”, joka mahdollistaa juurikin tuollaisen post-it-lapun luomisen. Objektin destructor kutsutaan juuri ennen objektin kuolemaa. Tällä tavoin destructor-metodi voi ajaa tarvittavan koodin, jolla huolehditaan että objekti ei jätä keskeneräisiä velvoitteita peräänsä kuollessaan.

Esimerkiksi teatteriesityksen tapauksessa:

(HUOM! C++ koodia)


class Katsoja {
public:
   Katsoja(char* nimi, Teatteriesitys *esitys); 
   ~Katsoja();
private:
  char *nimi;
  Teatteriesitys *esitys;
};

Katsoja::~Katsoja() {
  // Ilmoitetaan teatterille, että tämä katsoja
  // ei pääse paikalle; hän kun on kuolemaisillaan.
  esitys->vapautaPaikka(this);
}

// jne. muut metodit

Ylläolevassa koodissa objekti ilmoittaa kaikille kiinnostuneille osapuolille että hän on kuolemassa. Tämän ilmoituksen hän tekee juuri ennen kupsahtamistaan.

HUOM! C++ suorittaa automaattisen roskien keruun ainoastaan ns. lokaaleille objekteille. Tälläisiä objekteja ovat ne, jotka luodaan suoraan funktion sisälle lokaaliin käyttöön (ns. “stäkkimuuttujat”).

C++:n puolella ylläoleva konsepti “kerro omasta kuolemastasi juuri ennen kuin kuolet” toimii erinomaisesti. Konseptin ja stäkkimuuttujien automaattisen destruktion varaan on rakennettu erittäin vahva patteri nimeltä *RAII (“resource acquisation is initialization”).

Mutta Javascriptin puolella konsepti ei toimi, sillä Javascript ei tunne destructorin käsitettä lainkaan.

Tämä destructorin puute on ongelmallista. Kun roskakuski nappaa turhaksi käyneen objektin, objekti ei voi ilmoittaa viimeisenä äännähdyksenään muulle maailmalle että “hei, se on menoa nyt!”.

Eritoten Javascript-objekti ei kuolemansa hetkellä voi ajaa koodia, joka vapauttaa objektin omistamat resurssit (esim. teatterivarauksen).

Käytännössä tämä tarkoittaa, että koodarin täytyy vastaava logiikka ohjelmoida itse ja huolehtia visusti, että objekti tapetaan eksplisiittisesti; ts. objekti tapetaan ohjelmoijan kirjoittaman koodin toimesta.

Myöhempi automaattinen roskien keruu on typistyy kuolleen ruumiin siivoamiseksi pois kadulta.


function Katsoja(esitys) {
  
  this.kuole = function() {
    // Kerro teatterille että kuolema iski päälle.
    esitys.vapautaPaikka(this);
  }	

  // jne...
}

var esitys = new TeatteriEsitys('Mielensäpahoittajan paluu');
var katsoja = new Katsoja(esitys);

// jne...

katsoja.kuole();

// Muuttuja "katsoja" kerätään roskiin kunhan se menee out-of-scope.

Yllä Javascript-koodissa määritämme kuole-metodin. Metodi on pitkälti vastaava kuin C++:n ~Katsoja-metodi.

Merkittävä ero on, että C++:ssa tuo metodi kutsutaan automaattisesti, Javascriptissä meidän tulee kutsua metodia itse!

Resurssien hallinta on tärkeä osa ohjelmointia. Hallinta pohjimmiltaan typistyy kysymykseen: “kuinka varmistua siitä, ettei kuollut objekti vahingossa unohda vapauttaa omistamaansa resurssia”.

Mikäli objektit unohtavat vapautuksen, järjestelmä pikkuhiljaa syö kaikki resurssit. Tosimaailmassa vastaava ilmiö tapahtuisi mikäli kuolleet ihmiset eivät menettäisi omistusoikeuttaan esim. kiinteistöihinsä kuolemansa hetkellä.

Koska kuolleet eivät voi niitä myydä (kuolleelta on pirun vaikea saada allekirjoitusta kauppakirjaan), ne olisivat kuolleiden omistuksessa ikuisesti.

Ajan mittaan Suomen kaikki rakennukset olisivat kuolleiden ihmisten omistuksessa. Tälläistä ilmiötä kutsutaan ohjelmoinnin parissa nimellä “resource depletion”. Yksi jos toinenkin (päin mäntyjä ohjelmoitu) applikaatio kärsii ongelmasta.

Älä kuole ääneti

Monet ohjelmointikielet sisältävät tärkeän konseptin nimeltä *garbage collection”, suomeksi siis roskienkeruu. Tuo konsepti tarkoittaa yksinkertaisesti sitä, että ohjelmointiympäristö automaattisesti huolehtii ohjelman ajon aikana luotujen objektien tuhoamisesta.

Alimmalla raudan tasolla tämä tuhoamisesta huolehtiminen tarkoittaa sitä, että keskusmuistista vapautetaan tilaa uusia objekteja varten.

Myös Javascript noudattaa garbage collection-periaatetta. Kun tietystä objektista tulee tarpeeton, Javascriptin runtime-ympäristö hoksaa vapauttaa objektin varaamaan muistitilan. Se kuinka tuo hoksaaminen käytännössä tapahtuu ei ole oleellista ohjelmoijan kannalta; oleellista on vain se, että ohjelmoijan ei tarvitse asiasta välittää. Ohjelmointikielen taustalla pyörivä runtime-alusta toimii roskakuskina.

Niille jotka ovat kiinnostuneita roskienkeruun teknisestä toteutuksesta, seuraava linkki auttaa: http://stackoverflow.com/questions/10112670/when-are-javascript-objects-destroyed

Asiassa on kuitenkin yksi mutta.

Entä jos roskakoriin päätyvä objekti on varannut olemassaolonsa ajaksi käyttöönsä jonkin ulkoisen resurssin? Kun Javascript objekti tulee elinkaarensa päähän, runtime-alusta viskaa sen roskakoriin. Mutta miten käy tuon objektin omistaman resurssin?

Tosimaailman esimerkki selventää.

Kuvitellaan, että varaan liput teatteriesitykseen huomisillalle. Ikäväkseni kuitenkin käy niin, että saan kohtalokkaan sydänkohtauksen tänä iltana, ja siirryn ajasta ikuisuuteen.

Vielä tämän päivän puolella eloton ruumiini käydään noukkimassa ruumishuoneelle (“roskien keruu”).

Vaan miten käy teatterilippujeni? Olen varannut liput huomisen esitykseen. Se, että menin kuolemaan tässä välissä, ei automaattisesti peruuta varaustani huomisen teatteriesitykseen.

Kuolleena en valitettavasti pääse paikalle teatteriin, mutta teatteri ei myöskään voi antaa paikkaa kellekään toiselle, sillä teatteri ei tiedä kuolemastani.

Ongelman ydin on siinä, että kuollessani kukaan ei peruuta paikkavaraustani.

Mutta entä jos toimisin seuraavasti; vielä kun olen elävien kirjoissa, raapustan post-it-lapulle tekstin “peruuta paikkavaraus teatteriin mikäli olen kuollut”. Asetan lapun lompakkooni ajokortin oheen.

Kuka ikinä elottoman ruumiini löytää, löytää myös tuon lapun. Hän voi toimia lapun ohjeiden mukaan. Teatteri saa tiedon siitä, etten pääse paikalle esitystä seuraamaan. Täten teatteri voi myydä paikkani jollekin toiselle.

Ylläolevan esimerkin logiikkaa seuraten voimme myös toteuttaa resurssin vapautuksen resurssia hallinnoivan objektin kuollessa. Vai voimmeko? Riippuu ohjelmointikielestä.

C++ -kielessä on konsepti nimeltä “destructor”, joka mahdollistaa juurikin tuollaisen post-it-lapun luomisen. Objektin destructor kutsutaan juuri ennen objektin kuolemaa. Tällä tavoin destructor-metodi voi ajaa tarvittavan koodin, jolla huolehditaan että objekti ei jätä keskeneräisiä velvoitteita peräänsä kuollessaan.

Esimerkiksi teatteriesityksen tapauksessa:

(HUOM! C++ koodia)


class Katsoja {
public:
   Katsoja(char* nimi, Teatteriesitys *esitys); 
   ~Katsoja();
private:
  char *nimi;
  Teatteriesitys *esitys;
};

Katsoja::~Katsoja() {
  // Ilmoitetaan teatterille, että tämä katsoja
  // ei pääse paikalle; hän kun on kuolemaisillaan.
  esitys->vapautaPaikka(this);
}

// jne. muut metodit

Ylläolevassa koodissa objekti ilmoittaa kaikille kiinnostuneille osapuolille että hän on kuolemassa. Tämän ilmoituksen hän tekee juuri ennen kupsahtamistaan.

HUOM! C++ suorittaa automaattisen roskien keruun ainoastaan ns. lokaaleille objekteille. Tälläisiä objekteja ovat ne, jotka luodaan suoraan funktion sisälle lokaaliin käyttöön (ns. “stäkkimuuttujat”).

C++:n puolella ylläoleva konsepti “kerro omasta kuolemastasi juuri ennen kuin kuolet” toimii erinomaisesti. Konseptin ja stäkkimuuttujien automaattisen destruktion varaan on rakennettu erittäin vahva patteri nimeltä *RAII (“resource acquisation is initialization”).

Mutta Javascriptin puolella konsepti ei toimi, sillä Javascript ei tunne destructorin käsitettä lainkaan.

Tämä destructorin puute on ongelmallista. Kun roskakuski nappaa turhaksi käyneen objektin, objekti ei voi ilmoittaa viimeisenä äännähdyksenään muulle maailmalle että “hei, se on menoa nyt!”.

Eritoten Javascript-objekti ei kuolemansa hetkellä ajaa koodia, joka vapauttaa objektin omistamat resurssit (esim. teatterivarauksen).

Käytännössä tämä tarkoittaa, että koodarin täytyy vastaava logiikka ohjelmoida itse ja huolehtia visusti, että objekti tapetaan eksplisiittisesti; ts. objekti tapetaan ohjelmoijan kirjoittaman koodin toimesta.

Myöhempi automaattinen roskien keruu on typistyy kuolleen ruumiin siivoamiseksi pois kadulta.


function Katsoja(esitys) {
  
  this.kuole = function() {
    // Kerro teatterille että kuolema iski päälle.
    esitys.vapautaPaikka(this);
  }	

  // jne...
}

var esitys = new TeatteriEsitys('Mielensäpahoittajan paluu');
var katsoja = new Katsoja(esitys);

// jne...

katsoja->kuole();

// Muuttuja "katsoja" kerätään roskiin kunhan se menee out-of-scope.

Yllä Javascript-koodissa määritämme kuole-metodin. Metodi on pitkälti vastaava kuin C++:n ~Katsoja-metodi.

Merkittävä ero on, että C++:ssa tuo metodi kutsutaan automaattisesti, Javascriptissä meidän tulee kutsua metodia itse!

Resurssien hallinta on tärkeä osa ohjelmointia. Hallinta pohjimmiltaan typistyy kysymykseen: “kuinka varmistua siitä, ettei kuollut objekti vahingossa unohda vapauttaa omistamaansa resurssia”.

Mikäli objektit unohtavat vapautuksen, järjestelmä pikkuhiljaa syö kaikki resurssit. Tosimaailmassa vastaava ilmiö tapahtuisi mikäli kuolleet ihmiset eivät menettäisi omistusoikeuttaan esim. kiinteistöihinsä kuolemansa hetkellä.

Koska kuolleet eivät voi niitä myydä (kuolleelta on pirun vaikea saada allekirjoitusta kauppakirjaan), ne olisivat kuolleiden omistuksessa ikuisesti.

Ajan mittaan Suomen kaikki rakennukset olisivat kuolleiden ihmisten omistuksessa. Tälläistä ilmiötä kutsutaan ohjelmoinnin parissa nimellä “resource depletion”. Yksi jos toinenkin (päin mäntyjä ohjelmoitu) applikaatio kärsii ongelmasta.

Laravel jonottaa puolestasi

Yksinkertaiset PHP-applikaatiot toimivat seuraavanlaisesti:

  1. Nettisurffaaja lähettää HTTP-pyynnön.
  2. Palvelin ajaa PHP-koodin, joka käsittelee tuon pyynnön.
  3. Koodinajon päätteeksi PHP-koodin luoma vastaus palautetaan surffaajalle.

Ylläoleva toimintamalli on ns. request-response -paradigman ytimessä. Yksi osapuoli tekee pyyntöjä (request), toinen osapuoli vastaan niihin pyyntöihin (response).

Huomionarvoista on, että palvelin ei pysty tekemään pyyntöjä loppukäyttäjän suuntaan - se kun ei tiedä satunnaisen loppukäyttäjän IP-osoitetta. Satunnainen loppukäyttäjä sen sijaan tietää palvelimen IP-osoitteen.

Loppukäyttäjän web-selain saa IP-osoitteen tietoonsa luonnollisesti domain-nimen kautta. Nettiselain huolehtii esim. “www.iltasanomat.fi”-osoitteen muuntamisesta IP-osoitteeksi. Ihmiskäyttäjän ei tarvitse asialla vaivata päätään.

Request-response -malli sopii erinomaisesti tyypilliseen tietokantapohjaiseen web-applikaatioon.

Yksi PHP:lle ominainen ongelma kuitenkin nostaa päätään request-response -mallin yhteydessä. Koska vastaus käyttäjälle palautetaan vasta kun PHP-koodi on ajanut itsensä läpi, pitkäkestoinen koodinajo tarkoittaa pitkää odotusaikaa loppukäyttäjän päässä.

Eli jos koodi suorittaa raskaan operaation, joka kestää viisi sekuntia, ei loppukäyttäjä saa vastausta takaisin kuin aikaisintaan viiden sekunnin kuluttua.

Ylläoleva on hienoinen yksinkertaistus. Teknisesti on mahdollista kikkailla flush()-tyylisillä PHP-funktioilla, mutta tuollainen kikkailu on turhan sotkuista ja tuppaa aiheuttamaan ylläpidollisia ongelmia koodipohjalle pitkällä aikavälillä.

Jonotus pelastaan päivän

Onneksi apunamme on Laravel-kehyksen erinomainen Queue-toiminnallisuus. Käytännössä jonotustoiminnon avulla voimme saavuttaa seuraavanlaisen tavan käsitellä sisääntuleva pyyntö.

  1. Palvelupyyntö loppukäyttäjältä tulee sisään.
  2. PHP-koodi puskee työvaiheen jonoon.
  3. Palvelupyynnön vastaus palautetaan loppukäyttäjälle.
  4. PHP-koodi aloittaa työvaiheen erillisessä prosessissa.
  5. …(aikaa kuluu, työvaihe on hidas suorittaa)
  6. Työvaihe valmis.

Ylläoleva mahdollistaa juurikin raskaiden ja hitaiden työvaiheiden siirtämisen erillisen käyttöjärjestelmän prosessin suoritettavaksi. Tällä tavoin työvaiheen suoritus ei hidasta vastauksen palauttamista loppukäyttäjälle.

Periaate on sama kuin loistohotellien concierge-palvelussa. Hotelliasiakas voi antaa conciergen hoidettavaksi vaikkapa varauksen suorittamisen illan teatteriesitykseen.

Tässä tapauksessa asiakas tekee requestin concierge-palvelijan suuntaan. Palvelija ottaa pyynnön vastaan ja palauttaa responsen välittömästi asiakkaalle. Itse pyynnön toteutuksen - tässä tapauksessa lippujen hankkimisen teatteriin - palvelija hoitaa myöhempänä ajankohtana.

Tärkeintä asiakaspalvelun laadun kannalta on se, että hotelliasiakkaan ei tarvitse toljottaa tyhjän panttina odottamassa että concierge saa teatteriliput ostettua. Sen sijaan hotelliasiakas voi vaikka käydä olusella teatterilippuja odotellessaan.

Vertaa ylläolevaa viiden kohdan listaa vanhaan malliin, jossa jonotusta ei käytetty:

Vanha malli:

  1. Palvelupyyntö loppukäyttäjältä tulee sisään.
  2. PHP-koodi aloittaa työvaiheen samassa prosessissa.
  3. …(aikaa kuluu, työvaihe on hidas suorittaa)
  4. Työvaihe valmis.
  5. Palvelupyynnön vastaus palautetaan loppukäyttäjälle.

Käytännön toteutus

Laravel tekee kaikesta liian helppoa. Myös jonottamisesta. Mistä tahansa koodin osasta voimme yksinkertaisesti kutsua globaalia apufunktiota dispatch, joka siirtää halutun työvaiheen jonoon:


// Controllers/TilausController.php

public function vastaanotaTilaus(Tilaus $tilaus) {
  
  Log::log("Tilaus vastaanotettu järjestelmään: " . $tilaus->id);
  // Pusketaan uusi työvaihe jonoon.
  dispatch(new IlmoitaTavaranToimittajille($tilaus));

  // Palautetaan vastaus loppukäyttäjälle välittömästi.
  return "Tilaus vastaanotettu - käsittelemme sen piakkoin.";

}

Tarvitsemme luonnollisesti IlmoitaTavaranToimittajille-luokan. Tämän luokan luoma objekti on lopulta se, joka erillisessä prosessissa ajetaan sitten joskus myöhemmin.


// Jobs/IlmoitaTavaranToimittajille.php

class IlmoitaTavaranToimittajille implemets ShouldQueue {

  // Lisätoiminnallisuuksia jotka vaaditaan jonotusta varten.
  // Näistä ei koodarin tarvitse suuremmin välittää, kehys hoitaa.
  use InteractsWithQueue, Queueable, SerializesModels;	

  protected $tilaus;

  public function __construct(Tilaus $tilaus) {
    $this->tilaus = $tilaus;
  }
  // Handle-metodi kutsutaan kehyksen toimesta kun suoritus alkaa!
  public function handle() {
    $tilaus->tavarat->each(function($tavara) {
      $toimittaja = Tavaratoimittaja::haeToimittaja($tavara);
      try {
        $toimittaja->varaaYksiKappale($tavara);
      } catch (EiVarastossa $e) {
      	// Tilausta ei voida täyttää. Tee jotain.
      }
    });

    $tilaus->tavaratVahvistettu();
  }

}

Kaiken tämän lisäksi tarvitaan vielä käyttöjärjestelmän prosessi huolehtimaan jonon pyörittämisestä. Jonon käynnistys onnistuu suoraan komentoriviltä:

php artisan queue:work

Ja siinäpä se onkin. Jonoprosessi automaattisesti monitoroi jonoa, suorittaen sinne lisätyt työvaiheet sopivana ajanhetkenä.

Likainen lippu - vältä turhaa työtä

Törmäsin patterniin nimeltä “dirty flag”. Tuo patterni on ollut käytössä itselläni useissa applikaatioissa, mutta vasta nyt tajusin että sille on annettu tarkka nimikin.

Minkä ongelman dirty flag ratkoo?

Kuvitellaan applikaatio, joka analysoi shakkiasemia reaaliajassa. Applikaatio pitää kirjaa tietyn shakkipelin - jota kaksi ihmispelaajaa pelaa - siirroista. Applikaation kautta katsojat voivat seurata tuota peliä. Lisämausteena applikaatio tarjoaa analysointipalvelun, jonka kautta katsojat saavat tietokonearvion kulloisestakin peliasemasta.

Shakkipeliaseman tietokonearvio on aika raskas laskenta suorittaa. Luotettavan arvio tuottaminen tekoälyn turvin vie rutosti CPU-aikaa. Täten analysointi suoritetaan vain kun tarve vaatii.

Jos esimerkiksi peliä ei tietyllä ajanhetkellä seuraa yhtään katsojaa, on laskentatehon väärinkäyttöä tuottaa analysointipalvelua. Reaaliaikaisesta analysoinnista ei ole hyötyä jos kukaan ei ole sitä näkemässä.

Toinen huomioonotettava seikka on, että kukin asema on järkevää analysoida vain kerran. Kun analysointi tietylle asemalle on suoritettu, analysoinnin tulos talletetaan välimuistiin.

Jälkimmäinen vaatimus antaa hyvän syyn käyttää likaista lippua. Kun katsojalta tulee pyyntö saada tuorein analysointitulos käyttöönsä, seuraava algoritmi ajetaan:

  1. Jos likainen lippu olemassa, hae analysointitulos välimuistista.
  2. Jos likaista lippua ei olemassa, hae tuore asema tietokannasta. Aloita sen analysointi. Aseta muuttuja ilmoittamaan analysoinnin käynnissäolo.
  3. Kun analysointi valmis, talleta tulos välimuistiin ja aseta likainen lippu.

Kun taas uusi peliasema saapuu, toimimme yksinkertaisesti seuraavasti:

  1. Talleta peliasema applikaation tietokantaan. Älä aloita analysointia.
  2. Jos likainen lippu olemassa, tuhoa se.

Upouuden aseman saapuessa siis tuhoamme (mahdollisen) vanhan likaisen lipun. Tällä tavalla seuraavan kerran kun joku katsojista pyytää viimeisintä analyysiä käyttöönsä, applikaatio osaa hakea tuoreimman aseman tietokannasta ja aloittaa sen analysoinnin.

Kun joku toinen katsoja tämän jälkeen pyytää analyysiä, likainen lippu on jo olemassa ja analysointi ei käynnisty. Sen sijaan viimeisin analysointitulos palautetaan välittömästi välimuistista.

Toisin sanoen likainen lippu kertoo vastauksen seuraavaan kysymykseen: onko analysointi tuoreimmalle peliasemalle jo kertaalleen suoritettu?.

Jos on, palauta tulos välimuistista.

Jos ei, aloita analysointi ja analysoinnin päätyttyä aseta likainen lippu.

Ja uuden aseman saapuminen luonnollisesti tuhoaa likaisen lipun; muussa tapauksessa yksi ja sama analysointitulos palautettaisiin uudestaan ja uudestaan riippumatta peliasemasta. Koska kukin analysointitulos on järkevä vain yhden ja tietyn peliaseman yhteydessä, täytyy analysointi suorittaa erikseen jokaiselle peliasemalle.

Yllämainitun arkkitehtuurin suuri vahvuus on, että mikäli hetkellisesti shakkipeliä ei seuraa yhtään ainutta katsojaa, ei myöskään analysointia ajeta. Tämä johtuu siitä tosiasiasta, että analysointi käynnistyy vain katsojan pyytäessä tuoreinta analyysitulosta. Jos yksikään katsoja ei ole paikalle pyyntöjä tekemässä, analyysi jää suorittamatta.

Tällä tavoin vältetään turhaa työtä.

Dirty flag -patternin ydinajatus on välttää turhaa työtä. Ajatus on vastaava kuin inventaariota tehdessä ruokakaupassa. Inventaarion tekeminen on valtava urakka. Kun se on kerran tehty, sitä ei ole järkeä tehdä uudestaan ennenkuin vähintään yksi tuote on saapunut/poistunut hyllyistä. Kahden inventaarion tekeminen perätysten on järjetöntä ajanhaaskausta; ne kun tuottavat saman tuloksen. Parempi tehdä yksi inventaario, asettaa dirty flag, ja tehdä seuraava inventaario vasta kun tarpeeksi paljon tuotteita on liikkunut kaupasta ulos ja sisään.

Forge ja koodin käyttöönotto

Laravellin ekosysteemiin kuuluu oleellisena osana palvelu nimeltä Forge. Tuo palvelu mahdollistaa Laravel-applikaatioiden devops-ylläpidon helposti suoraan esim. Linoden pilvipalvelinten päällä.

Erityisesti Forge mahdollistaa erään nykyaikaisen ohjelmistokehityksen kulmakivenä toimivan konseptin; koodin jatkuvan käyttöönoton.

Oma kone -> Github -> Tuotantopalvelin

Homma toimii näin yksinkertaisesti.

Sanotaan esimerkin vuoksi, että Laravel-applikaatio vaatii bugikorjauksen. Ammattimaisella kehittäjällä on kaikista Laravel-applikaatiostaan ajan tasaiset kopiot omalla työkoneellaan, joten voin lähteä saman tien bugia korjaamaan.

Korjaan bugin työkoneella olevaan Laravel-applikaatioon muutamassa minuutissa. Testaan applikaation toiminnan (yksikkötestaus + nopea smoke test riittävät, integraatiotestaus yleensä ajan tuhlausta pienissä applikaatioissa) ja kaikki toimii odotetusti.

Seuraavaksi tuo uusi versio applikaatiosta tulee saada tuotantopalvelimelle. Eli tuotantopalvelimella tällä hetkellä pyörivä buginen versio tulee korvatuksi tällä uudella, ei-bugisella versiolla.

Kuinka homma onnistuu?

Minun näkökulmasta toimenpide on naurettavan yksinkertainen. Pusken yksinkertaisesti uuden koodipohjan Githubiin projektipuuhun.

Tämä onnistuu luonnollisesti yhdellä komennolla:

git push origin master

Pinnan alla Forge ja Github automaattisesti hoitavat loput. Kas näin:

  1. Pusken siis uuden koodipohjan Githubiin (koodi liikkuu työkoneeltani -> pilveen).
  2. Github ilmoittaa Forgelle, että uutta koodia on tarjolla.
  3. Forge ottaa homman haltuun ja siirtää Githubista uuden koodin tuotantopalvelimelle.
  4. Siirron jälkeen Forge ajaa tarvittavat asennukset, skriptit, tietokanta-migraatiot yms.
  5. Tuotantopalvelimella pyörii uusin versio applikaatiosta.

Syytä huomata siis, että minun vastuuni loppuu listan kohtaan #1. Kaikki muu osa-alueet hoituvat automaattisesti.

Tätä on moderni PHP-ohjelmistokehitys.

Forge on kätevä työkalu Laravel-applikaation pyöritykseen tuotantopalvelimella. Forge itsessään ei tarjoa palvelintilaa tai -ohjelmistoja, vaan se toimii ikäänkuin kapellimestarina; Forge käskyttää tuotantopalvelinta ja toimii yhteistyössä Githubin rajapinnan kanssa hakeakseen uusimman koodipohjan aina kun sellainen on saatavilla.

Fasaadin feikkaus

Laravel hyödyntää runsaasti konseptia / design patternia nimeltä “Facade”. Kehys tarjoaa kehittäjän käyttöön tarttumapinnan moniin aputoiminnallisuuksiin juurikin fasaadien kautta, esim. applikaation oman välimuistin käsittely käy helposti Cache-fasaadin avulla:


// Cache-fasaadi tarjoaa meille globaalin tarttumapinnan 
// Laravellin omaan välimuistiin.
$nimi = Cache::get('pelaajan_nimi');

Fasaadin käytössä on myös heikkoutensa. Pääasiallinen heikkous on, että fasaadin kutsuminen on staattinen kutsu; toisin sanoen, kutsuttava luokka on määritelty suoraan koodiin.

Toinen vaihtoehtohan on olla määrittämättä kutsuttavaa luokkaa suoraan koodiin. Miten ihmeessä se on mahdollista? Käyttämällä konseptia nimeltä dependency injection, eli riippuvuuksien injektointi.

Vertaa näitä kahta tapaa:

Fasaadin käyttö


public function tallennaNimi() {
  // Cache-fasaadi tarjoaa meille globaalin tarttumapinnan välimuistiin.
  Cache::set('pelaajan_nimi', Auth::user()->name);	
}

Riippuuvuuden injektointi


public function tallennaNimi(ICache $valimuisti) {
  // ICache-rajapintaa noudattava objektin ei tarvitse olla Cache-luokasta,
  // vaan se voi olla *mikä tahansa* objekti joka implementoi ICachen.
  $valimuisti->set('pelaajan_nimi', Auth::user()->name);	
}

Kahden ylläolevan esimerkin välinen ero on juurikin siinä, että ensimmäisessä versiossa kutsumme staattisesti Cache-luokan metodia. Jälkimmäisessä puolestaan kutsumme dynaamisesti sisäänsaadun objektin metodia.

Jälkimmäistä kutsua kutsumme nimeltä polymorphinen kutsu. Tämä tarkoittaa, että koodia kirjoitettaessa meillä ei ole varmaa tietoa siitä, mikä pätkä koodia lopulta tulee ajetuksi kun metodikutsu $valimuisti->set() suoritetaan.

Mitä hyötyä tuollaisesta polymorphisesta kutsusta on? Se, että voimme ulkoakäsin määritellä millainen ICache-rajapintaa noudattava objekti halutaan käyttöön.


public function tallennaNimi(ICache $valimuisti) {
  // ICache-rajapintaa noudattava objektin ei tarvitse olla Cache-luokasta,
  // vaan se voi olla *mikä tahansa* objekti joka vain implementoi ICachen.
  $valimuisti->set('pelaajan_nimi', Auth::user()->name);	
}

// Vaihtoehto #1, Laravellin default-välimuisti
tallennaNimi(new Cache());
// Vaihtoehto #2, käytetään lokaalia tekstitiedostoa
tallennaNimi(new Loki('pelaajat.txt'));
// Vaihtoehto #3, käytetään Googlen nettilevyä
tallennaNimi(new HTTPCache('http://www.docs.google.com/jrk5u5emsdmk'));

Riippuvuuden injektointi on siis joustavampi kuin fasaadin käyttö.

Fasaadin feikkaus

Mutta.

Laravel 5.3 kehyksessä fasaadia käyttävän kutsun voi myös muuttaa polymorphiseksi. Muutos vain täytyy tehdä koko applikaatiolle kerrallaan.

Tärkeä huomio: yksittäistä fasaadikutsua ei voi muuttaa polymorphiseksi, mutta koko fasaadin voi.

Tämä tarkoittaa, että kun defaulttina Cache-fasaadi johtaa Laravellin omaan välimuistiin, on mahdollista asettaa Cache-fasaadi johtamaan johonkin muuhun luokkaan. Muutos koskee koko applikaatiota.

Laravel 5.3 tarjoaa sisäänrakennetun korvausmekanismin. Kullekin fasaadille on määritelty fake-metodi, joka mahdollistaa korvata fasaadiin kytketty vakioluokka jollain muulla luokalla.

Otetaan esimerkkinä tuo Cache-fasaadi. Haluamme että Cache-fasaadi tallentaa välimuistitiedot Dropboxiin:


class Cache extends Facade {

  public static function fake() {
    // Korvaamme vakiotoiminnot tarjoavat luokan jollain toisella luokalla.
    // Tässä siis kytketään fasaadi siten, että missä ikinä
    // käytämmekään *Cache*-fasaadia, se vie meidät 
    // *NettiLevyValimuisti*-luokan metodeihin.
    static::swap(new NettiLevyValimuisti('dropbox.com/j53jySD'));
  }
	

}

Ylläoleva ei vielä ihan riitä. Meidän täytyy jotenkin ilmaista Laravel-kehykselle, että haluamme tuon swappauksen tehdä, eli haluamme ottaa nettilevyn käyttöön. Ilmoitus tehdään yksinkertaisesti:


// Swapataan.
Cache::fake();

Tästä eteenpäin voimme Cache-fasaadin kautta tallettaa tietoja suoraan Dropboxiin.


// Swapataan.
Cache::fake();

// Swappaus suoritettu.
// Pinnan alla HTTP-kutsu lähtee matkaan kohti Dropboxin palvelinta.
Cache::set('pelaajan_nimi', 'Jussi'); 

Milloin fasaadin korvaus, milloin injektointi?

Yllä näimme kaksi tapaa järjestää rajapintakutsu. Ensimmäinen tapa turvaa fasaadin käyttöön. Toinen tapa turvaa sopivan objektin injektointiin ja sen objektin metodikutsuun.

On tärkeä huomata, että vaikka fasaadin “vakio-ohjaus” voidaan pinnan alla korvata kustomoidulla ohjauksella, on injektointi edelleenkin joustavampi tapa. Tämä johtuu siitä, että fasaadin tapauksessa korvaus on aina globaali. Tietty fasaadi johtaa aina tiettyyn implementaatioon.

Injektointi taas mahdollistaa lokaalin korvauksen. Injektoinnin avulla kukin injektoidun objekti voi johtaa eri toiminnallisuuksiin:


public function tallennaNimi(ICache $valimuisti) {
  $valimuisti->set('pelaajan_nimi', Auth::user()->name);	
}

// Eri toiminnallisuuksia voi olla rajaton määrä...
tallennaNimi(new Cache());
tallennaNimi(new Loki('pelaajat.txt'));
tallennaNimi(new HTTPCache('http://www.docs.google.com/jrk5u5emsdmk'));
tallennaNimi(new CDLevy());
tallennaNimi(new SaviTaulu());

// jne jne...

Fasaadia käytettäessä korvaus voidaan tehdä vain kerran.


public function tallennaNimi() {
  Cache->set('pelaajan_nimi', Auth::user()->name);	
}

// Vakiotoiminnallisuuden voi korvata vain kerran.

tallennaNimi(); // Tallentaa vakio-välimuistiin.
tallennaNimi(); // Tallentaa vakio-välimuistiin.
tallennaNimi(); // Tallentaa vakio-välimuistiin.
Cache::fake(); // Suoritetaan korvaus
tallennaNimi(); // Tallentaa nettilevylle;
tallennaNimi(); // Tallentaa nettilevylle;
tallennaNimi(); // Tallentaa nettilevylle;
// jne jne...

Injektointi on suositeltava tapa silloin kun on syytä dynaamisesti kesken business-koodin kyetä muuttamaan metodikutsun määränpäätä. Fasaadien käyttö on täysin ok jos tälläistä kykyä ei tarvitse. Testauksen kannalta molemmat ovat ok - testejä ajettaessa riittää, että esimerkiksi välimuisti korvataan feikkivälimuistilla globaalisti.

Sisäinen eheys vs. ulkoinen eheys

Sain yhden perustavanlaatuisimmista oivalluksistani liittyen Domain-Driven Designiin pdf-dokumentista Domain-Driven Design Reference: Definitions and Pattern Summaries. Tuossa Eric Evansin (se “sinisen kirjan” guru) rustaamassa dokkarissa on elintärkeä lause piilotettuna tekstin joukkoon:

Within an aggregate boundary, apply consistency rules synchronously. Across boundaries, handle updates asynchronously.

Tummennukset allekirjoittaneen.

Vapaasti suomennettuna ja hieman yksinkertaistettuna lausahdus menee muotoon:

yhden aggregaatin sisäinen eheys hoidetaan transaktioiden avulla, useamman eri aggregaatin ulkoinen (tai “välinen”) eheys hoidetaan muulla tavoin.

Aggregaatti? Sisäinen eheys vs. ulkoinen eheys?

Ensiksi määritetään aggregaatti. Aggregaatti on entiteetti, joka on jaettavissa pienempiin osiin. Mutta nuo pienemmät osat ovat nähtävissä vain sisältä käsin; ulkoa katsottuna aggregaatti on eheä ja atominen palanen.

Esimerkiksi lentokone voidaan nähdä aggregaattina. Ulkoapäin katsottuna lentokone näyttää yksittäiseltä objektilta. Kun minä katson Espoon Vanttilan yli pyyhältävää Finnairin matkustajajettiä, näen yksittäinen objektin.

Minun näkökulmastani katsottuna tuo kilometrin korkeudessa pyyhältävä lentokone on eheä kokonaisuus, joka ei ole jaettavissa pienempiin osiin.

Lentokoneen sisällä reissatessa taas huomaa selvästi, että lentokone on jaettavissa pienempiin osiin. Penkit, ovet, ruuma, cockpit, suihkumoottorit - tästä sisäisestä näkökulmasta asiaa tarkastellessa huomaa, että lentokone on aggregaatti; objekti, joka koostuu valtavasta määrästä muita objekteja.

Jatketaan esimerkkiä. Sanotaan, että tehtävämme on kehittää tietojärjestelmä, joka mallintaa lentokoneiden liikennöintiä Helsinki-Vantaan ilmatilassa. Järjestelmä mallintaa koneiden toimintaa mahdollisimman yksityiskohtaisella tasolla, esim. yksittäisen lentokoneen suihkumoottoreiden toiminta mallinnetaan.

Tämä järjestelmä koostuu ilmiselvästi objekteista - tai paremminkin entiteeteistä - jotka ovat tyyppiä “lentokone”. Jokainen lentokone on järjestelmän sisällä itsenäinen entiteetti.

Samaan aikaan jokainen lentokone on myös aggregaatti, joka koostuu siivistä, suihkumoottoreista, navigointilaitteista, yms.

Sisäinen eheys

Nyt tässä kontekstissa sisäinen eheys tarkoittaa, että kukin lentokone on kunakin ajan hetkenä sisäisesti eheässä tilassa. Toisin sanoen, jokainen lentokoneen omat alikomponentit ovat keskenään johdonmukaisessa tilassa.

Millainen olisi sisäisesti ei-johdonmukainen tila? Esimerkiksi sellainen, jossa lentokoneen kerosiinitankki olisi typötyhjä, mutta polttoainemittari näyttäisi 100%.

Tai sellainen, jossa koneen laskeutumistelineet olisivat visusti ylhäällä, mutta cockpitin infonäyttö näyttäisi niiden olevan alhaalla.

Sanomattakin selvää, että yllämainitun kaltaiset epäjohdonmukaisuustilat ovat hengenvaarallisia lentoturvallisuuden suhteen. Siksi on elintärkeää, että lentokone ei koskaan päädy niihin. Lentokoneen tulee siis olla sisäisesti johdonmukaisessa tilassa kaikkina ajan hetkinä. Jos löpömittari näyttää 100%, tankissa on oltava polttoainetta piri pintaan asti.

Samaan aikaan kun jokainen lentokone on sisäisesti johdonmukaisessa tilassa, tulee järjestelmän olla kokonaisuutena johdonmukainen.

Tämä tarkoittaa, että eri lentokoneiden tulee olla toisiinsa nähden johdonmukaisessa tilassa.

Ulkoinen eheys

Millainen olisi ulkoisesti epäjohdonmukainen tila?

Esimerkiksi sellainen, jossa kaksi lentokonetta laskeutuisi yhdelle samalle kiitoradalle tismalleen samaan aikaan. Järjestelmän oikean toiminnan kannalta on elintärkeää, että yhdelle kiitoradalle laskeutuu vain yksi lentokone kerrallaan.

Sisäinen eheys on siis lentokoneen sisäisen tilan johdonmukaisuus.

Ulkoinen eheys on eri lentokoneiden johdonmukaisuus toisiinsa nähden.

Järjestelmän toiminta ja eri eheyksien varmistaminen?

Palataan postauksen alun kultaiseen lausahdukseen:

Within an aggregate boundary, apply consistency rules synchronously. Across boundaries, handle updates asynchronously.

Esimerkissämme lentokone on “aggregate boundary”. Lausahduksen mukaan meidän tulee lentokoneen sisäinen eheys varmistaa synkronoidusti. Synkronoitu tarkoittaa tässä tapauksessa sitä, että muun järjestelmän kannalta lentokoneen tulee olla kaikkina ajanhetkinä sisäisesti eheässä tilassa.

Tämä onnistuu transaktioita käyttämällä. Kun lentokone laskee laskutelineensä, tarvitsemme transaktion, joka huolehtii että laskutelineiden laskeminen ja cockpitin telinemittarin päivitys joko onnistuvat tai epäonnistuvat yhdessä.

Toisin sanoen, missään välissä ei saa olla tilannetta, jossa laskutelineiden asento ja laskutelinemittariston väittämä asento eivät täsmäisi.

Transaktion tehtävä on huolehtia, että tuollaista epäjohdonmukaisuutta ei pääse syntymään.

Sitten siirrytään huomattavasti mielenkiintoisempaan kakkosvaatimukseen:

Across boundaries, handle updates asynchronously.

Palataan laskeutumisesimerkkiin. Helsinki-Vantaan ilmatilaan on saapumassa Air Francen Airbus. Samaan aikaan Finnairin DC-10 on parhaillaan kiitoradan #1 alkupäässä odottamassa nousulupaa.

Lennonjohto päättää, että Airbus saa välittömän laskeutumisluvan kiitoradalle #1, ja että DC-10 käyttäköön kiitorataa #2. Mutta DC-10 on iso kone, ja sillä kestää pari minuuttia poistua kiitoradalta #1.

Nyt jos järjestelmä vaatisi eri lentokoneiden välille (“across boundaries”) synkronoitua eheyttä, ei missään välissä saisi tulla tilannetta, jossa Airbus yrittäisi laskeutua kiitoradalle, jolla on toinen lentokone. Toisin sanoen, synkronoidun eheys vaatimus vaatii, että lennonjohto ensin varmistaa kiitoradan #1 olevan typötyhjä, ja sitten antaa Airbus-koneelle laskeutumisluvan.

Asynkronoidun eheys tapauksessa teemme löysennyksen ylläolevaan: sallimme, että hetkellisesti järjestelmä voi olla epäjohdonmukaisessa tilassa.

Esimerkkimme tapauksessa se tarkoittaa, että Airbus saa laskeutumisluvan kiitoradalle #1 vaikka tuolla kiitoradalla seisoo DC-10 odottamassa nousulupaa. Tämä tilanne aiheuttaa sen, että järjestelmä on hetkellisesti ristiriitaisessa tai epäjohdonmukaisessa tilassa; järjestelmän perussääntö on, että kaksi lentokonetta ei voi käyttää samaa kiitorataa samanaikaisesti.

Huomionarvoista on termi “hetkellinen”. Järjestelmän on huolehdittava, että epäjohdonmukaisuus on väliaikainen. Toisin sanoen lennonjohdon on pidettävä huoli, että DC-10 poistuu kiitoradalta ennenkuin Airbus laskeutuu sille.

Asynkronoitu tuo siis mukaan ajallisen ulottuvuuden. Kaksi lentokonetta voi olla toisiinsa nähden epäjohdonmukaisessa tilassa jos a) tuo epäjohdonmukaisuus kestää vain hetken ja b) tuon hetken aikana ei ehdi tapahtua mitään katastrofaalista.

Oikean elämän lennonjohto toimii juurikin asynkronoituun johdonmukaisuuteen perustuen. Kaksi lentokonetta voi olla hetkellisesti suoralla törmäyskurssilla toisiinsa nähden. Riittää, että lennonjohto muuttaa jomman kumman koneen kurssia hyvissä ajoin ennen törmäystä.

Mitä seurauksia tekniseen toteutukseen?

Asynkronoidun ja synkronoidun johdonmukaisuuksien erottaminen toisistaan antaa meille lisämahdollisuuksia järjestelmän teknisen toteutuksen kannalta.

Synkronoitu johdonmukaisuus täytyy kyetä hoitamaan yhden ja saman transaktion sisällä. Käytännössä tämä tarkoittaa, että transaktion tulee elää yksittäisen tietokoneen (siis ihan fyysisen palvelinraudan) sisällä.

Asynkronoitu johdonmukaisuus sallii tilanteen, että järjestelmä on hetkellisesti epäjohdonmukaisessa tilassa. Riittää, että ennen pitkään järjestelmä tila palaa johdonmukaiseksi. Tämä sääntökevennys sallii viestittelyn esim. tietoverkkoa pitkin. Järjestelmän yksi osanen voi tehdä omaan tietokantaansa muutoksen, lähettää sen jälkeen viestin järjestelmän toiselle osaselle, joka tekee vastaavan muutoksen omaan tietokantaansa.

Viestin liikkuminen tietoverkon lävitse kestää hetken aikaa; tuon hetken ajan järjestelmä on epäjohdonmukaisessa tilassa. Kun viesti lopulta saapuu vastaanottavaan osaseen, järjestelmä palautuu johdonmukaiseen tilaan.

Asynkronoidun johdonmukaisuuden vaatimus on löysempi kuin synkronoidun johdonmukaisuuden vaatimus. Synkronoidusti johdonmukainen järjestelmä ei voi olla hetkeäkään epäjohdonmukaisessa tilassa (esim. tilassa, jossa kaksi laskeutuvaa lentokonetta suuntaa kohti samaa kiitorataa). Asynkronoidusti johdonmukainen järjestelmä voi olla hetkellisesti epäjohdonmukaisessa tilassa; riittää, että epäjohdonmukaisuus poistuu ennenkuin mitään peruuttamatonta vahinkoa ehtii syntymään.

CQRS ja Laravel

CQRS (Command Query Responsibility Separation) on vahva keino selkeyttää vastuunjakoa ohjelma-arkkitehtuurissa.

Sen perusidea on datan haun ja datan muokkauksen erottaminen toisistaan. Tämä tarkoittaa pohjimmiltaan sitä, että tietty operaatio joko hakee dataa tai muokkaa dataa, mutta ei koskaan molempia yhtaikaa.

Kun operaatio on joko hakubisneksessä tai muokkausbisneksessä, mutta ei ikinä molemmissa, voi operaatio optimoida itsensä valitun “bisneksen” mukaan. Esimerkiksi hakuoperaatio voidaan optimoida käyttämään datalähdettä, jossa data on valmiiksi käsitelty helposti haettavaan muotoon. Muokkausoperaatio puolestaan voi käyttää datalähdettä, jossa data on käsitelty helposti muokattavaan muotoon.

Useimmiten ylläoleva tarkoittaa, että datasta on kaksi kopiota; yksi hakua varten, toinen muokkausta varten. Kopiot pidetään ajan tasalla toisiinsa nähden esimerkiksi rakentamalla hakukopio puhtaalta pöydältä aina kun muokkauskopioon tulee päivitys (=dataa muokataan).

CQRS ei itsessään vaadi datakopioiden olemassaoloa. Haku- ja muokkausoperaatioiden erottelu voidaan suorittaa siten, että molemmat operaatiot käyttävät samaa datalähdettä, mutta vaatimukset esim. virhetilanteiden käsittelylle ovat erilaiset.

Hakuoperaatio (Query)

Hakuoperaation luonteeseen kuuluu, että haku ei voi mennä kriittisellä tavalla pieleen. Kriittisellä tarkoitan tässä, että jos operaatio epäonnistuu, datalähde ei ole moksiskaan. Operaation epäonnistuminen rajoittuu operaatioon itseensä; ympäröivä järjestelmä ei kärsi vaurioita.

Miksi näin? Luonnollisesti ihan siksi, että hakuoperaatio - nimensä mukaisesti - hakee tietoa. Tuo haku joko onnistuu tai epäonnistuu. Riippumatta operaation lopputulemasta, datalähde pysyy intaktina.

Muokkausoperaatio (Command)

Muokkausoperaation luonteeseen taas kuuluu, että operaatio muokkaa datalähdettä. Esimerkiksi puhelinnumeron muokkaus Facebookin profiilissa on selkeä muokkausoperaatio; uusi puhelinnumero tulee tallentaa jonnekin. Uuden datan tallennus (tai vanhan muokkaus) on operaatio, joka ei jätä datalähdettä intaktiin tilaan.

Mitä haku vs. muokkaus tarkoittaa koodin tasolla?

Koska hakuoperaatio ei voi edes teoriassa sotkea datalähdettä, tuo operaatio voidaan suorittaa varsin “vapaamielisesti”. Toisin sanoen vailla huolen häivää.

Itse tuppaan suorittamaan hakuoperaatiot suoraan Controllerista käsin. Controller on siis perinteisessä web-MVC-arkkitehtuurissa se osanen, joka vastaa sisääntulevan palvelupyynnön käsittelystä ja vastauksen (response) muodostamisesta.

Ihannearkkitehtuurissa Controller ei ole se paikka, josta tehdään tietokantakutsuja, mutta mikään laki ei estä tietokantakutsuja suorittamasta. Ja koska hakuoperaation kohdalla vaatimukset tietokantakutsuille ovat niin löyhät, voi tuollaisia kutsuja suorittaa huoletta.


// Controller/LainausController.php

public class LainausController {
	
  public function list() {

    // Tietokantakutsu käyttäen Kirja-mallia.
    $kirjat = Kirja::all();


    return view('kirjat.lista', compact('kirjat'));
  }
}

Muokkausoperaation kohdalla en lähtökohtaisesti tee tietokantakutsuja Controllerista käsin. Miksi? Koska muokkausoperaation epäonnistuminen voi pahimmillaan tuhota koko tietokannan eheyden. Siksi on tärkeää, että muokkausoperaatio suoritetaan johdonmukaisesti ja turvatoimenpiteet huomioiden.

Turvatoimenpiteellä tarkoitan lähinnä sitä, että moniosainen muokkaus tehdään tietokantatransaktion sisällä.

Koska tietty muokkausoperaatio on varsin mahdollista suorittaa useammasta eri Controllerista käsin, on syytä abstraktoida muokkausoperaatio erilliseen apuluokkaan:


// Usecases/Lainaakirja.php

public class LainaaKirja {
	
  public function suorita(User $user, $koodi) {
    // Kirjan lainaus muokkaa sekä kirjan tietoja että lainaajan tietoja.
    // Muokkaukset on syytä tehdä transaktion sisällä jotta ne molemmat
    // joko onnistuvat tai epäonnistuvat. 

    // Missään tapauksessa ei saa käydä niin, että käyttäjä rekisteröi 
    // lainauksen, mutta kirja ei rekisteröi lainaajaa.

    $kirja = Kirja::findOrFail($koodi);
    // Onko kirja saatavilla?
    if ($kirja->parhaillaanLainassa()) {
      throw new KirjaJoLainassa($koodi);
    }

    // Aloitetaan transaktio.
    // Huomattavaa on, että joku toinen saattaa 
    // juuri tässä kohtaa lainata kirjan. Jos näin käy,
    // transaktio epäonnistuu rivillä '$kirja->rekisteroiLainaaja($user)'
    
    DB::transaction(function () use ($user, $kirja) {
      // Jos jompi kumpi epäonnistuu, molemmat epäonnistuvat.
      $user->rekisteroiLainaus($kirja);
      $kirja->rekisteroiLainaaja($user);
    });
  }
}


// Controller/LainausController.php

public class LainausController {
	
  public function lainaaKirja($kirjaKoodi) {
    $user = Auth::user();
    (new LainaaKirja)->suorita($user, $kirjaKoodi);

  }
}

Ylläolevassa koodissa Controllerin tehtäväksi jää kutsua apuluokkaa, joka suorittaa varsinaisen muokkausoperaation. Tuo apuluokka yksinkertaisesti enkapsuloi sisäänsä tarvittavan logiikan, jonka avulla lainaus suoritetaan.

Ero hakuoperaation ja muokkausoperaation välillä on selkeä: hakuoperaatio suoritetaan suoraan Controllerista käsin, muokkausoperaatio delegoidaan apuluokalle, joka huolehtii tarvittavista lisätoimenpiteistä (kuten transaktion luonti).

Loppukaneetti: Controllerista käsin tietokantakutsujen tekeminen on useimpien mielestä kyseenalaista. Höpsis. Jos tietokantakutsu on turvallinen ja yksinkertainen, ei ole mitään syytä lähteä abstraktoimaan sitä sen enempää. Kunhan vain kutsut tietokantaa ja sillä sipuli.

Muokkausoperaation kohdalla tilanne on toinen. Vaativissa applikaatioissa muokkausoperaatiot voivat olla erittäin monimutkaisia ja sisältää monta askelta. Tällöin on tärkeää, että mahdolliset virhetilanteet käsitellään asianmukaisesti. Muokkausoperaation voi suorittaa Controllerista käsin, mutta applikaation rakenteen kannalta on selkeämpää, että elintärkeä ja mutkikas muokkaus eristetään omaksi apuluokakseen. Tämä eristys myös mahdollistaa, että useampi eri Controller voi uudelleenkäyttää tuota muokkauslogiikkaa mikäli tarve niin vaatii.

Laravel 5.3: ilmoitukset

Laravellin uusin versio (5.3) tekee web-ohjelmoinnista taas laittoman helppoa. Ikäänkuin se ei olisi jo sitä ollut.

Uusi versio tuo mukanaan ilmoituksen (engl. notification) konseptin, jonka avulla ns. domain-koodista pystyy ampumaan ilmoituksia suoraan domain-objektien suuntaan. Laravel-kehys sitten hoitaa loput.

Tyypillinen tapa ilmoitttaa jotain on ampua ilmoitus User-objektin suuntaan. Homma toimii äärimmäisen yksinkertaisesti:


$matti->notify(new LaskuEraantynyt());

Ylläoleva koodi kertoo Matille, että hänen laskunsa on erääntynyt.

Pinnan alla tapahtuu ylläolevan koodinajon jälkeen vielä hiukka asioita. Ensiksi tarvitsemme User-luokkaan ($matti on User-luokan objekti) metodin nimeltä routeNotificationForSlack.

Tämä routeNotificationForSlack-metodi määrittelee mihin “postilaatikkoon” lähetämme laskun erääntymisestä kertovan ilmoituksen. Se ei tee itse ilmoitusta, vaan ainoastaan kertoo mihin tuo ilmoitus ohjataan.


// User.php

public function routeNotificationForSlack() {
  // Tässä määritetään Matin Slack-tilin endpoint joka vastaanottaa viestit.
  // Oletetaan että Matti on rekisteröinnin yhteydessä antanut endpoint-URL:n.
  // Tuo Slack-URL on sitten tallennettu osaksi Matin käyttäjätietoja tietokantaan.
  return $this->slack_url;	
}

Lisäksi tarvitsemme vielä LaskuEraantynyt-viestiluokan. Koska Laravel 5.3 vakiona tukee Slackkia, voimme luoda tuon luokan helposti.

Tarvitsemme ensinnäkin via-metodin, joka määrittää mitä ilmoitustapaa käytämme. Voimme käyttää esim. SMS-viestiä tai email-viestiä. Tässä esimerkissä tyydymme Slackin käyttöön.

Lisäksi tarvitsemme toSlack-metodin, joka luo Slackia varten uuden viestin. Tätä metodia tarvitsemme ainoastaan lähettäessämme ilmoituksen Slackiin.

Jos lähettäisimme ilmoituksen emaililla, käyttäisimme metodia toMail. Koska lähetämme Slackiin, käytämme metodia toSlack. Suorastaan johdonmukaista.


// Notifications/LaskuEraantynyt.php

class LaskuEraantynyt extends Notification {

  public function via($notifiable) {
    // Laskuilmoitukset lähetetään asiakkaiden Slack-kanaviin.
    return ['slack'];	
  }

  public function toSlack($notifiable) {
    // Kehys kutsuu tätä metodia kun Slack-viestiä luodaan/lähetetään.
    // SlackMessage on Laravellin sisäinen apuluokka.
    return (new SlackMessage)->content('Maksa heti!');

  }
	
}

Muuta ei tarvita (paitsi Guzzle, lue loppukaneetti).

On syytä nopeasti katsoa miten Laravel-kehys hoitaa lähetyksen pinnan alla:

  1. Kutsumme domain-koodissa User-objektin notify-metodia. Parametrinä sisään pyyhältää uunituore LaskuEraantynyt-objekti.
  2. Laravel selvittää LaskuEraantynyt-objektin via-metodilla, että haluttu viestiväylä on Slack.
  3. LaskuEraantynyt-objektin toSlack-metodi palauttaa SlackMessage-viestiobjektin.
  4. SlackMessage-viestiobjekti ohjataan User-objektin routeNotificationForSlack-metodin palauttamaan URL-osoitteeseen. Teknisesti tuon ohjauksen hoitaa Guzzle, joka kutsuu Slackin rajapintaa HTTP POST-pyynnön turvin.

Loppukaneetti: Slack-viestin lähettäminen vaatii Guzzle-lisäosaa, joka ottaa yhteyden Slackin HTTP-rajapintaan.

Lodash: toPairs + sortBy

Löysin kivan patternin tallentaa objektin attribuuttien keskinäinen järjestys osaksi objektia.

Sanotaan esimerkkinä, että meillä on asukasluettelo. Tuo luettelo on objekti, jossa avaimena toimii asukkaan nimi, ja arvona asukkaan iän kertova objekti:


var asukasLuettelo = {
  'Matti' : {ika: 16},
  'Pekka' : {ika: 28},
  'Pirjo' : {ika: 35},
  'Lauri' : {ika: 21},
  // jne.	
}

Haluamme muuntaa asukasluettelon muotoon, jossa jokaisen iän yhteydeen on kirjattu kuinka mones nousevassa ikäjärjestyksessä tuo asukas on.

Eli haluamme lopputuloksen:


var asukasLuettelo = {
  'Matti' : {ika: 16, jarj: 1},
  'Pekka' : {ika: 28, jarj: 3},
  'Pirjo' : {ika: 35, jarj: 4},
  'Lauri' : {ika: 21, jarj: 2},
  // jne.	
}

Kuinka tehdä tuo muutos helposti? Yksinkertainen pätkä ketjutettuja Lodash-funktiokutsuja riittää:


_.chain(asukasLuettelo)
// Muunna objekti listaksi.
.toPairs()
// Lajittele asukkaat ikäjärjestykseen.
.sortBy(function(asukasL) { return asukasL[1].ika})
// Asukkaat nyt ikäjärjestyksessä.
// Talletetaan kunkin asukkaan kohdalle tieto hänen järj.numerostaan.
.each(function(asukasL, idx) { asukasL[1].jarj = idx+1})
// Pakotetaan Lodash evaluoimaan kutsuketju
.value()

Koska teemme muutoksen suoraan asukas-objektiin, meidän ei tarvitse tallentaa funktioketjun paluuarvoa mihinkään.

Nyt jokaisen asukkaan yhteyteen on tallennettu hänen ikäjärjestysnumeronsa.

Loppukaneetti: ylläolevan kutsuketjun lopussa kutsumme apufunktiota value(). Tämä kutsu on syytä suorittaa vaikka emme tarvitsekaan palautusarvoa mihinkään! Tämä siksi, että Lodash käyttää konseptia nimeltä lazy evaluation kun se kohtaa tuollaisen kutsuketjun.

Laiskana miehenä Lodash ei tee yhtään mitään ennenkuin se näkee value()-kutsun - tuon nähdessään se käy läpi koko kutsuketjun, ajaen tarpeelliset funktiot järjestyksessä loppuun saakka.

Arkkitehtuuri: ohjaa pelaajat eteenpäin

Esittelen lyhyesti arkkitehtuurin, joka sopii mainiosti Laravel + Node.js -yhteisarkkitehtuureihin.

Tälläinen yhteisarkkitehtuuri tyypillisesti jakautuu vastuualueisiin siten, että Node.js hoitaa reaaliaikapuolen ja Laravel hoitaa admin-toiminnot ja pitkäaikaisvarastoinnin. Node.js on erinomainen ratkaisu reaaliaikaisesta tiedonvaihdosta huolehtimiseen. PHP ja Laravel taas loistavat perinteisten ei-reaaliaikaisten web-käyttöliittymien kohdalla. Yhdessä Node.js ja Laravel tekevät ihmeitä.

Rakensin viime syksynä kokonaisarkkitehtuurin reaaliaikaisten tietovisojen luomiseen ja pelaamiseen. Palvelun kautta pelaajat voivat pelata reaaliajassa toisiaan vastaan tietovisoja. Tuon järjestelmän kokonaisarkkitehtuuri on seuraavalainen:

  1. Laravel-applikaatio tarjoaa admin-käyttöliittymän, jonka kautta luoda/muokata/hallita tietovisoja.

  2. Node.js-applikaatio hakee tasaisin väliajoin pian alkavat tietovisat Laravellista ja hoitaa niiden pyörityksen, mm. socket-yhteydet pelaajiin ja pelilogiikan etenemisen.

  3. Tietovisan päätyttyä Node.js-puoli kutsuu Laravellin “tulospalvelurajapintaa”, jonne syöttää tietovisan tulokset pitkäaikaistallennukseen. Tässä jälleen Laravel ja Laravellin erinomainen ORM loistavat. Pelaajat voivat jälkikäteen tarkastella tuloksia Laravellin puolella.

Kokonaisarkkitehtuuri perustuu lisäksi vielä ajatukseen, että järjestelmän pyörittämisestä vastaa yksi Laravel-applikaatio ja useampi Node.js-palvelin. Miksi näin? Node.js-palvelimen tehtävänä - kuten yllä kuvattiin - on hoitaa kaikki reaaliaikainen tiedonvaihto tietovisan pelaajien suuntaan. Tämä vastuualue vaatii poweria palvelinraudalta - kutakin pelaajaa varten täytyy varata samanaikainen Websocket-yhteys ja viestiliikenne pelaajamäärältään suuressa tietovisassa on suuri.

Laravel-puoli taas on lähinnä tietovisojen luontia ja tulospalvelun ylläpitoa varten. Kumpikaan näistä ei vaadi millisekuntien latenssia. Lisäksi tietovisoja luo huomattavasti pienempi määrä käyttäjiä kuin niitä pelaa.

Usea peliserveri - kuinka pelaaja löytää oikean?

Kuvitellaan, että meillä on yksi Laravel-palvelin ja viisi Node.js-palvelinta. Kukin tietovisa pyörii yhdellä palvelimella. Tietovisat pyritään jakamaan tasaisesti palvelinten kesken, jotta kuormitus jakautuu mahdollisimman tasaisesti.

Loppukäyttäjän eli tietovisan osallistujan kannalta viisi palvelinta on hiukka ongelmallista - kuinka loppukäyttäjä tietää mihin palvelimeen ottaa yhteys tietovisan pelaamista varten?

Ratkaisu on, että pelaaja ottaa ensin yhteyden Laravel-palvelimeen, joka kertoo pelaajalle hänen valitsemansa tietovisan Node.js-palvelimen IP-osoitteen.

Koska Laravel-palvelimia on kokonaisjärjestelmässä vain yksi kappale, sen osoite on aina tiedossa. Tai paremminkin - tietty domain johtaa suoraan Laravel-applikaatioon.

Homma toimii siis kutakuinkin näin:

  1. Ihmiskäyttäjä haluaa pelata tietovisan.
  2. Hän menee osoitteeseen www.visamestari.fi. Tämä osoite ohjaa hänet järjestelmän Laravel-osioon.
  3. Laravel-osiosta hän valitsee haluamansa piakkoin alkavan tietovisan, ja klikkaa “Osallistu”.
  4. Laravel tarkistaa tietokannasta, mille Node.js-palvelimelle tuo tietovisa on allokoitu, ja palauttaa tuon palvelimen IP-osoitteen.
  5. Käyttäjän selain ottaa yhteyden saatuun IP-osoitteeseen, täten ilmoittaen olemassaolostaan Node.js-palvelimelle.
  6. Node.js-palvelimen ja käyttäjän välille luodaan Websocket-yhteys reaaliaikaista tiedonvaihtoa varten.

Yllä vaihe #4 edellyttää, että Laravel on etukäteen tallentanut tietokantaansa tietovisan pyörityksestä huolehtivan palvelimen IP-osoitteen. Miten ja missä vaiheessa tämä tallennus tapahtuu?

Homma menee kutakuinkin näin.

Jokainen viidestä Node.js-palvelimesta pyytää tasaisin väliajoin pian alkavia tietovisoja Laravel-palvelimelta. Yksittäisen Node.js-palvelimen kannalta pyyntö etenee seuraavasti:

  1. Palvelin kysyy Laravellilta ‘onko uusia tietovisoja, joita voisin pyörittää?’.

  2. Palvelin tarkistaa tietokannasta ja vastaa joko: ‘ei’ tai ‘kyllä on, tässä tietovisan pyöritykseen vaadittavat tiedot’

Mitä tapahtuu Laravellin päässä kun Laravel antaa tietovisan pyörityksen tietyn Node.js-palvelimen kontolle? Laravel tietää IP-osoitteen, josta Node.js-palvelin otti yhteyttä. Joten pyöritysvastuun antamisen yhteydessä Laravel voi tallettaa tuon IP-osoitteen tietokantaan.

Kun myöhemmin loppukäyttäjä saapuu Laravel-puolella ja valitsee sieltä osallistumisen tuohon tietovisaan, Laravellilla on tietokannassaan tallessa Node.js-palvelimen IP-osoite. Se voi vain palauttaa tuon IP-osoitteen loppukäyttäjälle.

Node.js:n puolella ohjelmisto vastaanottaa piakkoin alkavat tietovisan tiedot. Näiden pohjalta se luo Tietovisa-objektin, joka jää odottamaan rekisteröitymisiä. Tietyllä kellonlyömällä Node.js sitten käynnistää tietovisan, lähettäen jokaiselle siihen mennessä rekisteröityneelle käyttäjälle “tietovisa alkaa”-viestin Websocketin kautta.

Tietovisan päätyttyä Node.js lähettää tulokset Laravellille. Koska Laravel-palvelimia on kokonaisarkkitehtuurissa vain yksi kappale, ei Node.js-palvelimen tarvitse huolehtia Laravel-palvelimen IP-osoitteen selvittämisestä. Tuo IP-osoite on yksinkertaisesti tallennettu Node.js:n konfiguraatiotiedostoon.

Loppukaneetti: ylläoleva arkkitehtuuri perustuu pohjimmiltaan ajatukseen, että X määrä työläisiä kysyy tasaisin väliajoin lisätyötä. Työnantajana toimii Laravel-keskuspalvelin. Oleellista on, että Laravel on täysin passiivinen; se ei ikinä ota yhteyttä Node.js-palvelimiin, vaan odottaa sinnikkäästi Node.js-palvelinten yhteydenottoja, ja jakaa työtehtäviä noiden yhteydenottojen pohjalta.

Monikielisyys Laravel-kehyksen turvin

Tyypillinen web-applikaatio tarjoaa käyttäjilleen HTML-sivuista koostuvan käyttöliittymän. Tuo käyttöliittymä sisältää luonnollisesti tekstiä. Monet pienemmät ohjelmistot sisältävät tekstin ainoastaan ensisijaisen käyttäjäryhmän äidinkielellä, mutta suuremmat ohjelmistot ovat lähes poikkeuksetta monikielisiä.

Monikielisyys toteutetaan loppukäyttäjän kannalta usein niin, että käyttöliittymän yläpalkissa (tai vastaavassa) on valikko, josta kielivalinnan voi määrittää.

Laravel tekee kielivalintojen käytöstä helppoa. Monikielisyys pohjaa kahteen toimenpiteeseen:

  1. Määritä kullekin kielivalinnalle oma kielihakemisto, joka sisältää käännökset (joko yhdessä tai useammassa tiedostossa) kaikkiin käyttöliittymässä esiintyviin tekstipätkiin. Oleellista on, että kunkin kielihakemiston sisäinen tiedostorakenne on samankaltainen muiden kielihakemistojen kanssa.

  2. Koodaa käyttöliittymä siten, että kaikkialla viitataan tiettyyn käännöstiedoston nimeen. Ei siis tiettyyn käännöstiedostoon (eli tiedoston täydelliseen tiedostopolkuun), vaan ainoastaan tiedostonimeen. Missään ei aktiivisesti viitata tiettyyn kielihakemistoon. Kielihakemiston valinnan hoitaa Laravel pinnan alla.

Käytännössä siis kullekin kielelle luodaan ensin oma hakemisto. Tuonne hakemistoon luodaan kielitiedostot.


// resources/lang/en/tervehdykset.php

return [
  'tervetuloviesti' => 'Hi and Welcome!'
];


// resources/lang/fi/tervehdykset.php

return [
  'tervetuloviesti' => 'Tervetuloa!'
];


Nyt kaikkialla applikaation käyttöliittymän koodipohjassa viittamme tuohon lista-indeksiin tervetuloviesti. Pinnan alla Laravel osaa tällä tavoin hakea oikean tekstin riippuen siitä, mikä kieli on kulloinkin valittuna.


// resources/views/etusivu.blade.php

<h1>{{trans('tervehdykset.tervetuloviesti')}}</h1>

Ylläoleva tuottaa loppukäyttäjän näkyville joko h1-tagilla ympäröidyn tekstin Hi and Welcome! (mikäli englanti on valittuna), tai Tervetuloa! (mikäli suomi valittuna).

Huomaa funktiokutsu trans(), joka suorittaa käännöksen.

Miten Laravel päättää mikä kieli on kulloinkin valittuna?

Yllä oletimme, että Laravel on valinnut tietyn kielen käyttöönsä, ja sen valinnan perusteella käy hakemassa oikeasta hakemistosta tarvittavan käännöstekstin.

Vakiokielivalinnan voi kertoa Laravellille helposti suoraan config-tiedostossa:


// config/app.php

// Muut asetukset...

// Applikaation vakiokieli
'fallback_locale' => 'en'

Asettamalla config-tiedoston vakiokieleksi englannin (en), Laravel osaa käyttää englannin käännöksiä ellei sitä toisin ohjeisteta.

Nyt sitten kysymys kuuluukin, kuinka ohjeistaa Laravellia toisin? Entä jos haluamme suomen käännökset käyttöön?

Kätevin tapa lienee koodata tieto käyttäjän kielitoiveesta suoraan osaksi URL-osoitetta:


// routes.php

Route::group([
  'prefix' => 'app/{kielivalinta}', 
  'middleware' => 'asetaKieli'], 
  function() {
    Route::get('front', function() {/* ... */});
    // jne.
  }
)


// app/Http/Middleware/Asetakieli.php

// Tämä middleware pitää muistaa rekisteröidä Laravellin käyttöön.

namespace App\Http\Middleware;

use Closure;

class AsetaKieli {

  public function handle($request, Closure $next) {
    // Aseta kielivalinta
    \App::setLocale($request->route('kielivalinta'));
    return next($request);
  }

}

Ylläolevan ansiosta voimme helposti määrittää haluamamme kielen osana URL-osoitetta:

Suomi:

http://www.testiohjelma.fi/app/fi/front

Englanti:

http://www.testiohjelma.fi/app/en/front

Loppukaneetti: koodissa viittasimme muuttujaan/lista-indeksiin nimeltä “tervetuloviesti”. Tämä muuttujan nimi on siis koodissa suomeksi. Pitäisikö myös tälle olla käännös? Ei, sillä loppukäyttäjä ei koskaan näe koodin sisällä käytettäviä muuttujien nimiä.

Nyrkkisääntönä on, että koodin muuttujien nimet määritetään englanniksi, koska valtaosa ohjelmoijista käyttää englantia työkielenään. Mikään pakko näin ei ole toimia tietenkään.

Laravel injektoi mallin puolestasi

Laravel 5.2 -kehys toi mukanaan uuden ominaisuuden nimeltä implicit model binding. Suomennos on vaikea; “automaattinen mallin injektointi” kuvaa mielestäni parhaiten tuota konseptia.

Sillä konseptin avulla voi pistää Laravellin tekemän raskas työ ja etsimään sopiva malliluokka, luomaan sen pohjalta uusi objekti, ja tarjoamaan objekti ohjelmoijan käyttöön.

Ero implisiittisen mallin injektoinnin ja ns. tavanomaisen koodiratkaisun välillä on seuraava:

Vanha tapa (ei injektointia)


//routes.php

Route::get('sanomalehdet/{id}', function($id) {
  // Luodaan *eksplisiittisesti* Sanomalehti-objekti käyttäen id-parametriä.
  $lehti = Sanomalehti::findOrFail($id);

  return $lehti->sarjakuvat();

});

Uusi tapa (injektointi käytössä)


//routes.php

Route::get('sanomalehdet/{id}', function(Sanomalehti $lehti) {
  // Meillä on käytössämme Sanomalehti-luokasta luotu $lehti-objekti.
  // $lehti luotiin automaattisesti id-parametrin perusteella.	

  return $lehti->sarjakuvat();

});

Huomaamme eron: vanhassa ratkaisussa erikseen haemme objektin tietokannasta Sanomalehti::find($id)-kutsulla. Uudessa ratkaisussa Laravel-kehys hakee objektin tietokannasta meidän puolestamme.

Kumpikin ratkaisu toimii loppukäyttäjälle samalla tavalla - kutsumme URL-endpointia tyyliin:


http://www.lehtiapp.fi/sanomalehdet/1

Objekti siis molemmissa tapauksissa haetaan tietokannasta - ero on vain siinä kuka hakee.

Tarkastalleen vaihe vaiheelta mitä oikeasti pinnan alla tapahtuu tuon URL-endpointin kutsun aikana:

  1. Käyttäjä kirjoittaa URL:n osoiterivilleen.
  2. Kutsu saapuu Laravel-applikaation HTTP-rajapintaan.
  3. Laravel ohjaa kutsun määrittämäämme Route::get(‘sanomalehdet/{id}’) callbackiin.
  4. Pinnan alla Laravel tutkii tuon callbackin parametrilistan ja havaitsee tutkinnan seurauksena, että callback ottaa parametrikseen $lehti-objektin luokkatyyppiä Sanomalehti.
  5. Laravel laskee yksi yhteen ja hoksaa, että URL:n sisällä tullut id-parametri on sama kuin callbackin parametriksi tulevan Sanomalehti-objektin id-attribuutti.
  6. Laravel käy hakemassa sopivan Sanomalehti-objektin tietokannasta edelliseen päättelyyn pohjaten. Laravel siis tekee haun tyyliin:

Sanomalehti::where('id', $id)->first()

Kaiken tuon Laravel päättelee sen muutaman millisekunnin aikana, joka HTTP-kutsun vastaanottoon kuluu. Laravellilla on aika nopsat hoksottimet.

ID-parametrin korvaaminen toisella attribuutilla

Entä jos haluamme, että voimme osoiteriville kirjoittaa seuraavanlaisen URL-lausekkeen:


http://www.lehtiapp.fi/sanomalehdet/ristiinalainen

Koska termi ‘ristiinalainen’ ei ole id-attribuutti, Laravel-kehys ei löydä oikeaa lehteä sen avulla. Ellemme sitten kerro Laravellille, että haluamme lehden nimen (esim. ‘ristiinalainen’) toimivan hakuattribuuttina.

Tämä on mahdollista määrittämällä uusi metodi Sanomalehti-malliin:


// Sanomalehti.php

class Sanomalehti extends Eloquent {
	
	// Määritetään injektointiattribuutti, jota Laravel käyttää 
	// etsiäkseen oikean objektin tietokannasta.
	public function getRouteKeyName() {
		return 'nimi';
	}

}


//routes.php

Route::get('sanomalehdet/{nimi}', function(Sanomalehti $lehti) {
  // Meillä on käytössämme Sanomalehti-luokasta luotu $lehti-objekti.
  // $lehti luotiin automaattisesti nimi-parametrin perusteella.	

  return $lehti->sarjakuvat();

});

Ylläoleva mahdollistaa meidän kutsuvan HTTP-endpointia tyyliin:


http://www.lehtiapp.fi/sanomalehdet/ristiinalainen

Tämä on selkeä loppukäyttäjää helpottava parannus verrattuna aiempaan kutsuumme, jossa tietty lehti eriteltiin id-attribuutin avulla. Nyt lehdet eritellään niiden nimen avulla. Loppukäyttäjä ei osaa yhdistää id-numeroa tiettyyn lehteen. Lehden nimi taas heti kertoo mistä lehdestä on kyse.

Bluebird: Catch + Translate

Lupausketjuihin perustuvissa arkkitehtuureissa virhetilanteiden hallinta on helppoa. Useimmiten riittää, että asettaa sopivaan kohtaan lupausketjua catch-handlerin. Tuo handleri nappaa kiinni ketjun aiempien suoritusvaiheiden tuottamat virheet.

Bluebird tekee catch-handlerin käytöstä vieläkin kätevämpää tarjoamalla ikäänkuin automaattisen virheiden ohjauksen juuri oikeaan handleriin. Esim. seuraavasti:


var jaateloKioski = /* luo */
var asiakas = /* luo */;

Promise.try(function() {
  return asiakas.valitseMaku();
})
.then(function(maku) {
  // Saattaa heittää virheen 'JaateloMakuLoppunut'
  return jaateloKioski.rakennaAnnos(maku)
})
.tap(function() {
  // Pyydä maksu
  // Saattaa heittää virheen 'EiRahaa'
  jaateloKioski.pyydaMaksu(asiakas);
})
.then(function(annos) {
  return asiakas.vastaanotaJaatelo(annos);
})
// Käsitellään virheet, kukin virhe yksitellen.
.catch(JaateloMakuLoppunut, function() {/* ...*/})
.catch(EiRahaa, function() {/* ...*/})

Ylläolevassa koodissa on mahdollista syntyä kaksi eri virhetyyppiä. Joko jäätelömaku on kiskalta toistaiseksi loppunut, tai asiakas havaitsee yllättäen, että hän on persaukinen.

Nämä kaksi eri virhettä käsitellään erikseen omissaan catch-handlereissa.

Mutta aina tilanne ei ole yhtä valoisa. Joskus tulee vastaan skenaario, jossa kaksi eri loogista virhetyyppiä käyttävät saman tyypin virheobjektia.

Esimerkki:


// Yksittäisen siirron maksimiaika
var maksimiSiirtoaika = 2000; // 2 sek
// Koko peliin (=pelaajaan kaikkiin siirtoihin) varattu maksimiaika
var peliAikaaJaljella = 180000; // 3 min

Promise.try(function() {
  return pelaaja.teeSiirtosi()
  .timeout(maksimiSiirtoaika)
  .timeout(peliAikaaJaljella)
})
.then(/* käsittele siirto ja vähennä peliaikaa */)
.catch(Promise.TimeoutError, function() {
  // Pelaaja ei tehnyt siirtoaan ajoissa.
  // Mutta kumpi timeout laukesi?
	
})

Ylläoleva esimerkki on melko suoraan koodistani. Osana peliserveriäni lupausketjun tulee tietää onko pelaaja ylittänyt siirtokohtaisen aikansa vai pelikohtaisen aikansa.

Ongelmana on, että molemmat ylityksen heittävät identtisen virheobjektin. Itse asiassa Bluebird-kirjasto tekee tuon heiton, joten sitä ei ole helppo edes kontrolloida.

Ratkaisu: Muunna geneerinen virhetyyppi domain-spesifiksi virhetyypiksi

Mutta voimme aina napata toisen heiton ja muuntaa (translate) sen toiseksi virhetyypiksi. Riittää, että asetamme ylimääräisen catch-handlerin sopivaan kohtaan.


// Yksittäisen siirron maksimiaika
var maksimiSiirtoaika = 2000; // 2 sek
// Koko peliin (=pelaajaan kaikkiin siirtoihin) varattu maksimiaika
var peliAikaaJaljella = 180000; // 3 min

Promise.try(function() {
  return pelaaja.teeSiirtosi()
  .timeout(maksimiSiirtoaika)
  .catch(Promise.TimeoutError, function() {
    throw new MaksimiSiirtoAikaYlitetty();
  })
  .timeout(peliAikaaJaljella)
})
.then(/* käsittele siirto ja vähennä peliaikaa */)
.catch(Promise.TimeoutError, function() {
  // Pelaajan kokonaispeliaika umpeutui!	
})
.catch(MaksimiSiirtoAikaYlitetty, function() {
  // Pelaajan siirtokohtainen aika umpeutui!	
})

Yltä huomaamme, että nappaamme ensimmäisen mahdollisen TimeoutErrorin kiinni juuri sopivasti ennen toista kutsua, joka tuottaa myös TimeoutErrorin. Nappaamalla ensimmäisen virheen kiinni ja muuntamalla sen toiseen muotoon - eli toiseen virhetyyppiin - meidän ei tarvitse myöhemmin vaivata päätämme sen suhteen, mistä virhe lähti alunperin liikkeelle!

Tämä on siis catch + translate -patterni. Virhe napataan ja muunnetaan eri muotoon, ja muunnoksen jälkeen palautetaan takaisin “putkeen”.

Bluebird tarjoaa peräti juuri tätä catch+translate -tarkoitusta varten erillisen apumetodin: catchThrow(). Ylläoleva koodi menee muotoon:


// Yksittäisen siirron maksimiaika
var maksimiSiirtoaika = 2000; // 2 sek
// Koko peliin (=pelaajaan kaikkiin siirtoihin) varattu maksimiaika
var peliAikaaJaljella = 180000; // 3 min

Promise.try(function() {
  return pelaaja.teeSiirtosi()
  .timeout(maksimiSiirtoaika)
  .catchThrow(Promise.TimeoutError, new MaksimiSiirtoAikaYlitetty())
  .timeout(peliAikaaJaljella)
})
.then(/* käsittele siirto ja vähennä peliaikaa */)
.catch(Promise.TimeoutError, function() {
  // Pelaajan kokonaispeliaika umpeutui!	
})
.catch(MaksimiSiirtoAikaYlitetty, function() {
  // Pelaajan siirtokohtainen aika umpeutui!	
})

Loppukaneetti: Ihannearkkitehtuurissa myös siirtokohtaisen ajan ylitys muunnettaisiin domain-spesifiin virhetyyppiin. Tällöin emme lupausketjun lopussa nappaisi kiinni geneeristä TimeoutErroria lainkaan, vaan esim. KokonaisPeliAikaYlitetty-virheen.

Ketjutettava rajapinta

Ohjelmoinnin puolella on olemassa kätevä konsepti nimeltä “Fluent interface”. Paras suomennos tuolle lienee “ketjutettava rajapinta”, joten käytän sitä.

Mikä tai millainen on ketjutettava rajapinta? Se on yksinkertaisesti rajapinta, joka mahdollistaa rajapintakutsujen ketjutuksen.

Otetaan esimerkkinä rajapinnasta, joka ei ole ketjutettava:


// Rajapintaluokan 'Valot' kautta voi hallita talon valokatkaisijoita
class Valot {

  // Kukin toggle-metodi sytyttää valot jos ovat pois päältä,
  // ja sammuttaa valot jos ovat päällä.

  public function toggleVessa() {/*...*/}
  public function toggleKeittio() {/*...*/}
  public function toggleOlohuone() {/*...*/}
  public function toggleMakuuhuone() {/*...*/}
  public function toggleParveke() {/*...*/}
}


$valot = new Valot();

// Kutsutaan metodeja
$valot->toggleKeittio();
$valot->toggleVessa();
$valot->toggleMakuuhuone();

Yllä meillä on tavallinen rajapinta. Kutsumme sitä metodi kerrallaan. Koska yksikään metodikutsu ei palauta mitään palautusarvoa - tai ainakaan emme mitään palautusarvoa ota vastaan - voimme olettaa, että kukin metodikutsu suorittaa jonkin ulkoisen muutoksen (engl. side effect). Jos metodikutsut eivät tuota ulkoisia muutoksia, koko rajapinnan käyttö on yksinkertaisesti turhaa.

Mikäli metodikutsu ei palauta mitään eikä muokkaa yhdenkään ulkoisen tilamuuttujan arvoa, kyseessä on täysin tarpeeton metodikutsu. Sillä KAIKKI metodikutsut tehdään jomman kumman syyn takia; joko ne 1) palauttavat jonkin arvon, tai ne 2) muokkaavat jotakin ulkoista tilamuuttujaa.

Mitään kolmatta vaihtoehtoa ei ole olemassa, esimerkiksi tulosteen kirjoittaminen käyttäjän nähtäville on versio vaihtoehdosta #2 - siinä tietokoneen näyttöpäätteen tilamuuttujaa (= RAM-keskusmuistin sitä muistialuetta, johon kunkin pikselin tila on tallennettu) muokataan siten, että ihmiskäyttäjä näkee lukea jotain.

Nyt voimme muuntaa ylläolevan koodin fluent interface:ksi eli ketjutettavaksi rajapinnaksi hyvin yksinkertaisesti.


class Valot {

  public function toggleVessa() {
    //...
    // Palautusarvo on oleellista ketjutuksen kannalta. 
    // Palauttamalla kutsuttavan objektin voimme samantien kutsua sen
    // jotain metodia (vaikka tätä toggleVessa-metodia!) heti uudestaan.
    return $this;
  }
  public function toggleKeittio() {
    //...
    return $this;
  }
  public function toggleOlohuone() {
    //...
    return $this;
  }
  public function toggleMakuuhuone() {
    //...
    return $this;
  }
  public function toggleParveke() {
    //...
    return $this;
  }
}



$valot = new Valot();

// Kutsutaan metodeja
$valot->toggleKeittio()->toggleVessa()->toggleMakuuhuone();

Yltä näemme mitä ketjutus tismalleen tarkoittaa; voimme kunkin metodikutsun palautusarvon kierrättää ja kutsua sen metodia. Ja koska kunkin Valot-luokan metodikutsun palautusarvo on Valot-objekti itse, ketjutus johtaa identtiseen lopputulemaan alkuperäisen esimerkin kanssa.

Itseasiassa seuraavat kolme koodipätkää johtavat kaikki identtiseen lopputulemaan:


// Tapa 1
$valot->toggleKeittio()->toggleParveke()->toggleKeittio();

// Tapa 2
$valot->toggleKeittio();
$valot->toggleParveke();
$valot->toggleKeittio();

// Tapa 3
// Tämä on huonoin tapa mitä tulee koodin selkeyteen, mutta toimii yhtäkaikki.
$valotKopio1 = $valot->toggleKeittio();
$valotKopio2 = $valotKopio1->toggleParveke();
$valotKopio3 = $valotKopio2->toggleKeittio();

Mitä etua ketjutus sitten tuo? Niin. Ei oikein mitään. Siinä säästää muutaman hassun merkin kun ei tarvitse toistaa ‘$valot’-sanaa uudestaan ja uudestaan. Ei kovin merkittävä hyöty.

Jonkun mielestä ketjutus tekee koodista nätimpää tai helpommin luettavaa. Olen samaa mieltä, mutta kyseessä on ihan puhdas mielipidekysymys.

Huomio! Tässä ketjutettava rajapinta esiteltiin PHP-kielen kautta. Ketjutuksen konsepti ei ole sidonnainen PHP-kieleen, vaan pätee kutakuinkin kaikissa funktiokutsuja tukevissa ohjelmointikielissä.

Slack and Laravel

Uuden Laravel 5.3 ohjelmistokehyksen avulla web-applikaation integrointi Slackin kanssa on naurettavan helppoa. Otetaan esimerkkinä tapaus, jossa haluamme lähettää tiedoksiantoja Slackin suuntaan.

Sanotaan vaikka, että meillä on Slack-käyttäjänä bisnespersoona Jari Sarasvuo. Applikaatiomme haravoi internettiä etsien blogimainintoja hänen firmastaan Trainer’s House. Aina kun joku bloggari kirjoittaa blogiinsa postauksen, jossa termi ‘Trainer’s House’ mainitaan, applikaatiomme tuottaa Slack-viestin ja lähettää sen Sarasvuon Slack-tilille.

Ylimmällä tasolla applikaatiomme toimii esim. näin:


// BlogiController.php

use App\Notifications\SlackViesti;

class BlogiController extends Controller {

	protected $blogs; // lista blogeja, täytetään jotenkin

	// Tätä metodia kutsutaan jonkin ulkoisen skriptin toimesta
	// esim. kerran minuutissa, tällä tavoin blogit tulee tarkistetuksi
	// minuutin välein.

	// Ulkoisen skriptin ei tarvitse olla PHP-skripti, vaan se voi hoitaa
	// kutsun HTTP-endpointin kautta. Saapuva HTTP-kutsu sitten ohjautuu tähän metodiin.
	public function tarkistaBlogit(Request $_request) {
	  
	  $maininnat = $this->blogs->map(function(blogi) {
	    // Tsekkaa blogi-objektia käyttäen jos uusi maininta havaittu
	    if ($blogi->uusiMainintaHavaittu()) return $blogi->haeMaininta();
	    return null;   	
	  })->filter(function($maininta) {
	    // Filteröi nullit pois
	    return $maininta !== null;
	  });

	  // Haetaan tietokannasta Sarasvuon käyttäjä-objekti.
	  $sarasvuo = User::where('nimi', 'Jari Sarasvuo')->first();

	  // Ilmoitetaan Sarasvuon Slack-tilille.
	  $sarasvuo->notify(new SlackViesti($maininnat));

	}	

}



Ylimmällä tasolla Slack-viestin lähettäminen on juurikin noin helppoa kuin yllä. Toki tarvitsemme vielä lisäksi pari luokkaa:


// App\Notifications\SlackViesti.php

class SlackViesti {

  protected $maininnat;

  public function __construct($maininnat) {
    // Talletetaan maininnat jotta voidaan käyttää sitä myöhemmin
    $this->maininnat = maininnat;
  }

  // Kehys kutsuu tätä metodia kun tiedoksianto luodaan ja lähetetään
  // Parametrinä sisään tulee tässä tapauksessa Sarasvuon käyttäjä-objekti.	
  public function via($sarasvuo) {
    // Täällä päätämme mitä tiedoksiantokanavaa haluamme käyttää.
    return ['slack'];

  }

  public function toSlack($user) {
    // Hoidetaan Slack-viestin luonti.
    // Kehys hoitaa loput.

    $mainintaTeksti = $this->maininnat->reduce(function($teksti, $maininta) {
      return $teksti . $maininta->url . ", ";
    }, 'Blogimaininnat: ')

    // SlackMessage on Laravel-kehyksen sisäinen apuluokka.
    return (new SlackMessage)
      ->line('Uusia Trainers House mainintoja')
      ->line('Firmasi mainittiin blogeissa ' . $mainintaTeksti);
  }


}


// User.php

class User extends Authenticatable {

  // Mahdollistaa tiedoksiantojen lähetyksen käyttäjälle.	
  use Notifiable;

  // Mahdollistaa Slack-viestien lähettämisen tiettyyn Slack-endpointiin.
  public function routeNotificationForSlack() {
    // Sarasvuo on luonut itselleen HTTP-endpointin Slack-appin puolella.
    // Tässä tapauksessa kirjoitetaan testi-endpoint suoraan lähdekoodiin.
    // Oikeassa applikaatiossa haluamme tallentaa tuo endpointin tietokantaan.

    return 'https://hooks.slack.com/services/T00000000/B00000000/1234abcd';
  }
}

Ylläolevan koodin kautta Sarasvuo saa suoraan Slackiin ilmoituksia tyyliin:

Uusia Trainers House mainintoja

Firmasi mainittiin blogeissa: http://www.kakkumaakari.fi, http://nollaversio.fi

Nuo ilmoitukset siis menevät suoraan Slackin palvelimelle, josta ne sitten jaetaan Jarille. Hyvä puoli tässä on, että Slack tarjoaa appinsa niin työpöytäkoneeseen, läppäriin kuin mobiilikännykkäänkin. En ihmettelisi ellei pian olisi Slack-appi Teslan monitoiminäyttöönkin.

Meidän applikaatiomme ei siis tarvitse huolehtia siitä mitä päätelaitetta Sarasvuo käyttää. Riittää, että kutsumme Slack-endpointia.

Toimii kuin unelma. Laravel tekee tässäkin tapauksessa koodarin elämästä lähes laittoman helppoa. Ja Slack hoitaa loput.

Konsolitulosteen väritys

Löysin muutama kuukausi sitten Node.js-lisäosan nimeltä chalk. Tämä chalk-kirjasto tarjoaa kivan rajapinnan värittää komentorivillä näkyvät console.log-tekstit. Värityksestä on paljon hyötyä tapauksissa, joissa Node.js-skripti printtaa runsaasti tekstiä komentoriville.

Käyttö on helppoa - riittää, että työntää merkkijonon chalk-kirjaston metodikutsun sisälle:


console.log(chalk.cyan("beforeMove cb"))

tuottaa seuraavanlaisen lopputuleman:

Turkoosin värinen merkkijono

Käytän eri värejä simuloimaan eri käyttäjien kommunikaatiota Node.js-serverin kanssa. Sanotaan esimerkiksi, että meillä on kolme käyttäjää A, B ja C. Nuo kaikki kolme saavat viestejä applikaatiolta. Tuotantokäytössä nuo viestit luonnollisesti menisivät kunkin käyttäjän www-selaimeen, mutta testivaiheessa on helpompaa vain printata kunkin käyttäjän saama viesti komentoriville. Ongelmaksi muodostuu, että komentoriviltä on visuaalisesti vaikea hahmottaa mikä viesti kuuluu millekin käyttäjälle:

Kaikkien käyttäjien kommunikaatio palvelimen kanssa printataan testiajossa komentoriville

Chalk-kirjaston avulla voimme assignoida kullekin käyttäjälle oman värin, jolloin on visuaalisesti helppo erottaa eri käyttäjien viestit toisistaan:


// Participant.js

var chalk = require('chalk');

// Määritä kullekin testikäyttäjälle oma väritysfunktio
var consoleColorers = {
  'A': chalk.bgGreen,
  'B': chalk.bgYellow,
  'C': chalk.bgBlue
}

function Participant(id, communicator) {
  // Unique among all participants
  this.id = id;
  // communicator is probably Socket-object, can be mocked.
  this.communicator = communicator;

  this.msg = function(msg) {
    // Väritä tämän käyttäjän saama viesti hänen omalla värillään
    // ja printtaa viesti komentoriville.
    var text = this.id + ': ' + msg.msg;
    console.log(consoleColorers[this.id](text));
  }

  // ... muut metodit

}

module.exports = Participant;


// test.js

var _ = require('lodash'); // _.map-funktiota varten
var Participant = require('./Participant');

// Luo testipelaajia kolme kpl.
var players = [new Participant('A', {}), new Participant('B', {}), new Participant('C', {})];

// Lähetä kullekin pelaajalle viesti kerran sekunnissa
setInterval(function() {
  _.map(players, function(player) {
    player.msg({topic: 'testi', msg: 'Sinulle on postia'});
  })
}, 1000);


Ylläoleva tuottaa kauniin lopputuloksen komentoriville kun test.js-tiedosto suoritetaan:

Kullakin käyttäjällä on oma värinsä komentoriville

Pohjimmiltaan värien käyttö on tietenkin makukysymys, mutta ainakin itselläni se helpottaa testitulosteen lukemista huomattavasti.

Require vs Include

PHP:ssa on mahdollisuus sisällyttää yhden tiedoston koodipätkä toisen tiedoston sisälle skriptiä ajettaessa. Tämä sisällytys onnistuu joko require tai include komennoilla:


// Auto.php

require 'Ratti.php';

class Auto {
  public function __construct(Ratti $ratti, $autoMerkki) {...}
  //...
}

$volvo = new Auto(new Ratti, 'Volvo');


// Ratti.php

class Ratti {
	//...
}

tai


// Auto.php

include 'Ratti.php';

class Auto {
  public function __construct(Ratti $ratti, $autoMerkki) {...}
  //...
}

$volvo = new Auto(new Ratti, 'Volvo');


// Ratti.php

class Ratti {
	//...
}

Yllä koodiesimerkit eroavat toisistaan vain yhden rivin suhteen; ensimmäinen esimerkki turvautuu PHP:n komentosanaan require, jälkimmäinen esimerkki käyttää termiä include. Mitä eroa näillä kahdella on?

Require vs. include

On ensin syytä ymmärtää näiden kahden termin yhtäläisyys; molemmat tuovat ulkoisen tiedoston sisältämän koodin osaksi sitä tiedostoa, jossa termi sijaitsee.

Ne siis käytännössä copypastaavat palan koodia tismalleen siihen kohtaan, jossa require/include-termiä käytetään.

Kahden termin välinen ero on yksikertainen.

Require vaatii, että copypastattava tiedosto on olemassa.

Include EI vaadi copypastattavan tiedoston olemassaoloa.

Require on siis hiukka tiukkapipoisempi versio include-käskystä. Mutta mitä tarkoittaa “vaatia tiedoston olemassaolo”?

Se tarkoittaa yksinkertaisesti sitä, että jos require yrittää sisällyttää olemattoman tiedoston, PHP-skripti räjähtää käsiin. Teknisesti tarkempi termi tälle posahtamiselle on keskeyttää skriptin suoritus virhekoodilla “Fatal error”. Yhtäkaikki, asiat menevät päin honkia.

Jos puolestaan include yrittää sisällyttää olemattoman tiedoston, PHP-skripti ei räjähdä käsiin, vaan jatkaa suoritustaan kuin mitään ei olisi tapahtunut.

Tästä kaikesta herää kysymys; jos haluamme sisällyttää yhden kooditiedoston sisältämän koodin osaksi toista tiedostoa, kaipa me vaadimme tuon tiedoston olemassaolon?

Asia ei aina välttämättä ole näin. Esimerkkinä tilanne, jossa meillä on tietyt vakioasetukset PHP-skriptillemme. Nuo vakioasetukset määritetään koko applikaation elinkaaren ensihetkillä.

Vakioasetukset voidaan kuitenkin ylikirjoittaa erillisen asetustiedoston avulla. Jos asetustiedosto on olemassa, sen sisältämä koodi korvaa vakioasetukset omilla asetuksillaan.

Jos asetustiedostoa ei ole olemassa, vakioasetukset jäävät voimaan.

Ylläolevan esimerkin mukaisen rakenteen voi toteuttaa include-käskyllä näin:


// applikaatio.php

// Vakioasetukset
$tcpPortti = "8080";
$tcpTimeout = 5000;

// Tuodaan sisään korvaavat asetukset sisältävä tiedosto
// HUOM! Jos tiedosto ei ole olemassa, mitään ei tapahdu
// ja vakioasetukset jäävät voimaan!
include "kayttajan_asetukset.php";

// ... rakenna applikaatio yms. käyttäen yllämääriteltyjä asetuksia


// kayttajan_asetukset.php

// Käyttäjän erilliset, korvaavat asetukset
$tcpPortti = "3000";
$tcpTimeout = 12000;

Jos kayttajan_asetukset.php-tiedostoa ei ole olemassa, vakioasetukset jäävät voimaan. Jos tuo tiedosto on olemassa, käyttäjän omat asetukset korvaavat (muuttujat alustetaan uusiin arvoihin!) vakioasetukset.

Include-käsky on toimiva tapauksissa, joissa sisällytettävä koodi tuo valinnaisia lisäominaisuuksia ympäröivän koodin käyttöön.

Require-käsky on asianmukainen tapauksissa, joissa sisällytettävä koodi on elintärkeä applikaation toiminnan kannalta, ja tiedoston puuttuminen on syytä nähdä virhetilanteena.

Yksikkötestaus ja tietokanta-transaktio

Yksikkötestaus (engl. Unit Testing) on tehty Laravellissa helpoksi. Ei muuta kuin määrittää testiluokan, ja pinnan alla testiajuri hoitaa loput.

Tähän tyyliin:


class LentokoneTesti extends TestCase {
	
  public function lentokoneella_on_kaksi_siipea() {
    // Oletetaan, että meillä on Lentokone-malli olemassa.
    $lentokone = new LentoKone()

    // Varmistetaan, että siipien lkm on kaksi.
    $this->assertEquals($lentokone->siivet->count(), 2);

  }
}

Kaikki hyvin yllä. Luomme Eloquent-mallin pohjalta objektin nimeltä lentokone, ja tarkistamme, että tuolla lentsikalla on kaksi kpl siipiä.

Huomionarvoista on, että ylläolevassa testissä emme käytä tietokantaa lainkaan. Tämä on ihanteellista. Mutta joissain testeissä on kovin vaikea välttää tietokannan käyttöä:


class LentokenttaTesti extends TestCase {
	
public function kentta_evaa_laskeutumisluvan_jos_kiitoradat_taynna() {

  $helsinkiVantaa = Lentokentta::create(['kiitoradat' => [
    'itäinen', 'läntinen', 'pohjoinen'
  ]]);

  // Luodaan neljä kappaletta lentokoneita
  // Laravellin factory-apumetodi auttaa.
  factory(Lentokone::class, 4)->create();

  // Lentokoneet ja lentokenttä on lisätty tietokantaan! 
  // Toisin sanoen, meidän on käytettävä tietokantaa suorittaaksemme testin loppuosan.

  // Varmistetaan, että lentokoneet tosiaan ovat tietokannassa.
  $koneet = Lentokone::all();

  // Lentokoneita tulisi siis olla neljä kpl
  $this->assertEquals($koneet->count(), 4);

  // Assignoidaan kullekin koneelle yksi kiitorata laskeutumiseen.
  $helsinkiVantaa->assignoiKiitorata($koneet[0]);
  $helsinkiVantaa->assignoiKiitorata($koneet[1]);
  $helsinkiVantaa->assignoiKiitorata($koneet[2]);

  // Nyt Helsinki-Vantaan kaikki kolme kiitorataa ovat käytössä, joten
  // viimeinen kone EI voi saada omaa kiitorataansa.

  // Varmistetaan, että lentokenttä ei sisällä vapaita kiitoratoja.
  $this->assertEquals($helsinkiVantaa->vapaatKiitoradat()->count(), 0);

  // Varmistetaan, että yritys assignoida olematon kiitorata johtaa virhetilanteeseen!
  // (En ole itsekään ihan varma miten tämä toteutetaan, mutta jotenkin seuraavasti...)
  $this->expectException(EiVapaitaKiitoratoja::class);

  $helsinkiVantaa->assignoiKiitorata($koneet[3]);

  // Nyt äskettäin asetetun exception handlerin tulisi olla lauennut.

  }
}

Ylläoleva testi käyttää tietokantaa. Ensin se luo tietokantaan yhden lentokentän ja neljä lentokonetta. Sen jälkeen testi suorittaa testilogiikan tietokantaan turvautuen.

Ylläolevan ongelma on, että kun testi on valmis, testin aikana luodut objektit jäävät lojumaan tietokantaan. Tämä on epämieluisa tilanne. Parhaimmillaan se on pelkkä suorituskykyongelma, pahimmillaan se johtaa tilanteisiin, joissa testi menee pieleen koska tietokanta sisältää ennalta-arvaamatonta roskaa.

Use DatabaseTransactions

Tietokannan resetointi testin jälkeen on helppoa. Suorastaan laittoman helppoa. Lisätään vain yksi rivi koodia:


class LentokenttaTesti extends TestCase {

  // Uusi rivi
  use DatabaseTransactions;
	
  public function kentta_evaa_laskeutumisluvan_jos_kiitoradat_taynna() {

    // Kuten aiemmin

  }
}

Lisäämällä rivin use DatabaseTransactions Laravel-kehys huolehtii omatoimisesti tietokannan putsaamisesta testin päätteeksi.

DatabaseTransactions on siis Trait, joka käytännössä copypastaa LentokenttaTesti-luokkaan sopivat putsaustoiminnot. Testi suorituu nyt näin:


class LentokenttaTesti extends TestCase {

  use DatabaseTransactions;
	
  public function kentta_evaa_laskeutumisluvan_jos_kiitoradat_taynna() {

    // Puhdas tietokanta

    // Kuten aiemmin, luodaan objekteja tietokantaan.
    // Sitten testataan, testataan niin pirusti.

    // Tyhjennä tietokanta

  }
}

Varsin kätevää.

Tietokannan resetointi alkuperäiseen tilaan noudattaa nk. “same world”-periaatetta. Periaate tarkoittaa, että tietty testi ajetaan aina vakioidussa ympäristössä. Tässä tapauksessa tuo vakioympäristö on tyhjä tietokanta.

Emailin lähetys Laravellista

Moni web-applikaatio joutuu lähettämään sähköposteja. Tyypillinen tarve sähköpostin lähetykselle syntyy käyttäjän rekisteröityessä applikaatioon; jonkinlainen tervetuloviesti olisi mukava lähettää käyttäjän suuntaan, jotta hän tuntisi olonsa tervetulleeksi.

Laravel tekee emailin puskemisesta eetteriin erittäin helppoa. Otetaan esimerkiksi lottoapplikaatio, joka arpoo kerran viikossa lottovoittajan kaikkien osallistujien joukosta. (Tässä esimerkissä ei siis arvota numeroita, vaan valitaan satunnaisesti yksi voittaja suuresta määrästä osallistujia).


// Lotto.php

public function valitseVoittaja(array $osallistujat) {
  // Arvo voittaja satunnaisesti
  $voittaja = $osallistujat->random();
  // Lähetä voittajalle onnittelu-sähköposti
  Mail::raw('Olet voittanut jotain!', function($email) use ($voittaja) {
    $email->from('lotto@veikkaus.fi', 'Veikkaus');
    // Voittajan email-osoite on tallennettu osaksi käyttäjä-objektia
    $email->to($voittaja->email);
  });
	
}

Ylläoleva koodinpätkä arpoo voittajan, ja lähettää hänelle onnitteluviestin käyttäen Mail::raw()-metodia. Mail::raw() yksinkertaisesti lähettää email-viestin pelkkänä leipätekstinä. Viestin voi lähettää myös HTML-muotoilun kera:


// Lotto.php

public function valitseVoittaja(array $osallistujat) {
  // Arvo voittaja satunnaisesti
  $voittaja = $osallistujat->random();
  // Lähetä voittajalle onnittelu-sähköposti
  Mail::send('emails.voitto', ['voittaja' => $voittaja] function($email) use ($voittaja) {
    $email->from('lotto@veikkaus.fi', 'Veikkaus');
    // Voittajan email-osoite on tallennettu osaksi käyttäjä-objektia
    $email->to($voittaja->email);
  });
	
}


// Views/emails/voitto.blade.php

<h1>Olet voittanut jättipotin!</h1>
<p>Onnittelut {{$voittaja->etunimi}}, olet juuri rikastunut oikein urakalla.</p>


Tukitoimenpide vs. ydintoimenpide

Ylläoleva koodaustyyli, jossa emailin lähetys suoritetaan suoraan arvontametodin sisältä, on ihan toimiva. Mutta on syytä tehdä pesäero ydintoimenpiteen ja tukitoimenpiteen välille.

Lottovoittajan arvonta on ydintoimenpide. Ilman voittajan arvontaa koko lottoapplikaatio olisi aika turha.

Sähköpostin lähettäminen voittajalle taas voidaan nähdä joko ydintoimenpiteenä tai tukitoimenpiteenä. Minä näkisin sen tukitoimenpiteenä. Ensinnäkin lottovoittaja tuskin on kiinnostunut siitä tavasta, jolla hänelle ilmoitetaan voitosta. Emailin lähettäminen on tässä mielessä toissijaista - oleellista on, että tieto jotenkin tavoittaa tulevan miljonäärimme.

Ylläolevat ratkaisumme emailin lähettämiseen noudattivat kutakuinkin seuraavaa kaavaa:

Ydinmetodi

– ydintoimenpide

– tukitoimenpide

Toisin sanoen, tukitoimenpiteet on yllä ripoteltu ydintoimenpiteiden sekaan.

Toinenkin vaihtoehto on olemassa:

Ydinmetodi

– ydintoimenpide

Tukimetodi

– tukitoimenpide

Jälkimmäisessä ratkaisussa ydintoimenpiteet - kuten arvonta, jonka suorittaminen oikeaoppisesti on ensiarvoisen tärkeää koko lottoapplikaation toiminnan kannalta - on eroteltu tukitoimenpiteistä. Kysymykseksi jää nyt, miten ydinmetodi saa kutsuttua/ilmoitettua tukimetodille, että tietty tukitoimenpide (tässä tapauksessa sähköpostin lähetys) on syytä suorittaa.

Paras tapa lienee eristää tukitoimenpiteet Event Listener-objektin sisälle:


/////////////////////////////
// App/Events/ArvontaSuoritettu.php

class ArvontaSuoritettu extends Event
{

    public $voittaja;

    public function __construct(User $voittaja)
    {
        $this->voittaja = $voittaja;
    }
}


/////////////////////////////
// App/Listeners/LahetaTietoVoittajalle.php

class LahetaTietoVoittajalle
{

    public function __construct()
    {

    }

    public function handle(ArvontaSuoritettu $arvontaInfo)
    {
      $voittaja = $arvontaInfo->voittaja;	
      // Lähetetään sähköposti voittajalle
      Mail::raw('Olet voittanut jotain!', function($email) use ($voittaja) {
        $email->from('lotto@veikkaus.fi', 'Veikkaus');
        // Voittajan email-osoite on tallennettu osaksi User-objektia
        $email->to($voittaja->email);
      });
        
    }
}


/////////////////////////////
// Lotto.php

public function valitseVoittaja(array $osallistujat) {
  // Arvo voittaja satunnaisesti
  $voittaja = $osallistujat->random();

  // Ilmoita muulle applikaatiolle, että voittaja on valittu!
  // HUOM! Tämä metodi ei välitä siitä, lähetetäänkö voittajalle
  // sähköposti, kirje vai vaikka savumerkki. Tämän metodin 
  // ainoa vastuualue on ilmoittaa, että voittaja on valittu.

  // Joku muu huolehtii voittajalle ilmoittamisesta.

  // Luo event ja ammu se eetteriin.
  event(new ArvontaSuoritettu($voittaja));

}

Ylläoleva ratkaisu on hyvin erilainen alkuperäiseen verrattuna. Se näyttää monimutkaisemmalta, mutta ei ole. Se on yksinkertaisempi, sillä vastuualueet elävät nyt omissa kivoissa lokeroissaan. Lottoarvonnan suorittava valitseVoittaja-metodi ei räpellä sähköpostien kanssa - sen sijaan se yksinkertaisesti luo ohjelmistokehyksen sisäisen tiedoksiannon.

Tuo tiedoksianto kulkeutuu LahetaTietoVoittajalle-kuuntelijan korviin, joka tiedoksiantoon perustuen luo ja lähettää sähköpostin.

Uusi jaottelu on täten selvä; ydinmetodi huolehtii ydintoimenpiteistä, ja tukimetodi (LahetaTietoVoittajalle::handle) huolehtii tukitoimenpiteistä.

Loppukaneetti: ydintoimenpiteiden ja tukitoimenpiteiden erottelu on usein järkevä tapa selkeyttää applikaation koodia.

Vaan kuinka hyödyllistä tuo jaottelu lopulta on?

Tilanne on sama kuin yritysmaailmassa. Nollaversio IT:n kaltaisessa pienessä nakkipuljussa yksi mies voi hoitaa niin markkinoinnin, ohjelmoinnin kuin laskutuksenkin. Suuressa pörssiyhtiössä yksi henkilö ei millään kykene hoitamaan kaikkia arkirutiineja, vaan vastuualueet on jaettava usean työntekijän kesken. Yksi toteuttaa asiakasprojektit (=ydintoimenpide), toinen pyörittää lakiosastoa (=tukitoimenpide), kolmas luuttuaa toimiston lattiat (=tukitoimenpide).

Eli mitä monimutkaisempi web-applikaatio on kyseessä, sitä tärkeämpää on tehdä pesäero ydintoimintojen ja tukitoimintojen välille.

Poikkeuksen väärinkäyttö?

Yksi suht usein tarvittava algoritmi on tietyn arvon etsiminen binaaripuusta. Etsinnän voi suorittaa esimerkiksi näin:


function etsiArvoBinaaripuusta(puu, arvo) {
  var loytynyt = false;	

  function etsiAlipuu(juuri, arvo) {
    if (juuri.arvo === arvo) loytynyt = true;
    // Käy läpi vasemman ja oikeanpuoliset alipuut rekursiivisesti
    etsiAlipuu(juuri.vasenHaara, arvo);
    etsiAlipuu(juuri.oikeaHaara, arvo);
  }
  // Aloita rekursio kutsumalla alipuun etsintäfunktiota.
  etsiAlipuu(puu, arvo);

  return loytynyt;

}

// Kutsutaan etsintäfunktiota
var binaaripuu = /* rakenna puu, ei oleellista etsinnän kannalta */
var tulos = etsiArvoBinaaripuusta(binaaripuu, 'hauki');
console.log(tulos); // true tai false

Ylläoleva toimii. Mutta etsintä käy koko puun rekursiivisesti läpi kaikissa tapauksissa, ml. siinä erikoistapauksessa, että arvo löytyy heti koko puun juuresta.

Arvokas huomio funktion tehokkuuden kannalta onkin huomata, että heti kun arvo on löytynyt, ei jäljellä olevan puun läpikäyminen ole järkevää. Se on vain ajanhukkaa.

Asia on korvattavissa pitämällä huolen, että arvon löytyessä puuetsintää ei jatketa ko. oksan kohdalta alaspäin:


function etsiArvoBinaaripuusta(puu, arvo) {
  var loytynyt = false;	

  function etsiAlipuu(juuri, arvo) {
    if (!juuri) return;
    if (juuri.arvo === arvo) loytynyt = true;
    // Käy läpi vasemman ja oikeanpuoliset alipuut rekursiivisesti,
    // mutta vain jos arvoa ei löytynyt!
    else {
      etsiAlipuu(juuri.vasenHaara, arvo);
      etsiAlipuu(juuri.oikeaHaara, arvo);   	
    }

  }
  // Aloita rekursio kutsumalla alipuun etsintäfunktiota.
  etsiAlipuu(puu, arvo);

  return loytynyt;

}

Ylläoleva on himpun verran parempi tapa hoitaa etsintä. Mutta edelleenkin etsintä jatkuu tarpeettoman kauan. Asian voi tarkistaa seuraavalla ajatuskokeella; puu jaetaan kahteen haaraan, vasen ja oikea. Kumpikin haara etsitään erikseen. Jos arvo löytyy heti vasemman haaran alkupäästä, ainoastaan vasemman haaran etsintä stoppaa. Oikean haaran etsintä joutuu yhä käymään läpi koko oikean puolen puun.

Tämä huomio johtaa meidät pieneen ongelmaan. Binaaripuulle on ominaista suorittaa etsintä binaarisesti - eli jakamalla jäljellä oleva puu aina kahteen osaan. Kumpikin osa saa oman “etsintäpartionsa”.

Mutta optimaalisinta olisi jos nuo kaksi etsintäpartiota voisivat kommunikoida keskenään. Näin ei kummassakaan ylläolevassa ratkaisussa ole. Kommunikaatio ei ole mahdollista - vasen partio ja oikea partio rämpivät täysin toisistaan erillään ja itsenäisesti.

Haluamme saavuttaa tilanteen, jossa heti kun oikean puolen etsintäpartio löytää arvon, se viestittää tiedon vasemman puolen partiolle.

Kuinka saavuttaa tälläinen kommunikaatio?

Poikkeus apuun

Käytännössä kaikki yleisimmät ohjelmointikielet tarjoavat konseptin nimeltä poikkeus (engl. exception). Poikkeus on tarkoitettu ohjelman ajon aikana tapahtuvien virhetilanteiden hallintaan. Jos esimerkiksi yrität jakaa nollalla, ohjelma heittää poikkeuksen, joka kertoo että metsään mentiin.

Mikään laki ei estä käyttämästä poikkeuksia myös muihin tarkoituksiin kuin ns. aitojen virhetilanteiden käsittelyyn.

Voimme luoda keinotekoisen virhetilanteen, joka heittää poikkeuksen. Tuollainen keinotekoinen “virhe” voi olla esimerkiksi halutun arvon löytyminen binaaripuusta:


function etsiArvoBinaaripuusta(puu, arvo) {
  var loytynyt = false;	

  function etsiAlipuu(juuri, arvo) {
    if (!juuri) return;
    if (juuri.arvo === arvo) throw new Error("Löytyi!");
    // Käy läpi vasemman ja oikeanpuoliset alipuut rekursiivisesti,
    etsiAlipuu(juuri.vasenHaara, arvo);
    etsiAlipuu(juuri.oikeaHaara, arvo);   	
    
  }
  // Aloita rekursio kutsumalla alipuun etsintäfunktiota.
  try {
    etsiAlipuu(puu, arvo);
  } catch (e) {
    loytynyt = true;
  }

  return loytynyt;

}

Ylläoleva koodi toimii halutusti. Mutta mikä parasta, ylläolevassa koodissa kaikki etsintäpartiot heittävät hanskat tiskiin heti kun arvo on löytynyt. Miksi näin? Koska heittämällä poikkeuksen - heti kun arvo löytyy - koodinajo rullaa itsensä suoraan catch-komentoon.

Toisin sanoen, heti kun arvo löytyy, hyödynnämme Javascriptin sisäänrakennettua poikkeusten hallintaa ja luomme keinotekoisen virhetilanteen. Tuo virhetilanne abortoi kaiken käynnissä olevan etsinnän ja siirtää koodinajon catch-komennon riville:

// Heti kun arvo on löytynyt, koodi pomppaa tänne
catch (e) {
  loytynyt = true;
}

Catch-komennon sisällä yksinkertaisesti merkkaamme arvon löydetyksi. Tämän jälkeen koodinajo jatkaa catch-komentoa seuraavalta riviltä.

Ylläolevaa koodia voi vielä hiukan parantaa. Ei ole mikään pakko heittää geneeristä poikkeusta, vaan luokaamme suosiolla sopivasti nimetty spesiaalipoikkeus:


// Spesiaalipoikkeuksen määritys
// Huom! Spesiaalipoikkeuksen täytyy ekstentoida Error-objektia.
function ArvoLoytyi() {};
ArvoLoytyi.prototype = new Error();

function etsiArvoBinaaripuusta(puu, arvo) {
  var loytynyt = false;	

  function etsiAlipuu(juuri, arvo) {
    if (!juuri) return;
    if (juuri.arvo === arvo) throw new ArvoLoytyi("Löytyi!");
    // Käy läpi vasemman ja oikeanpuoliset alipuut rekursiivisesti,
    etsiAlipuu(juuri.vasenHaara, arvo);
    etsiAlipuu(juuri.oikeaHaara, arvo);   	
    
  }
  // Aloita rekursio kutsumalla alipuun etsintäfunktiota.
  try {
    etsiAlipuu(puu, arvo);
  } catch (err) {
    if (err instanceof ArvoLoytyi) {
    	// Arvo on löytynyt
    	loytynyt = true;
    } else {
    	// Jotain muuta meni pieleen, arvo ei löytynyt.
    	// Heitä poikkeus uudelleen, joku muu huolehtikoot...
    	throw err;
    }
    
  }

  return loytynyt;

}

Loppukaneetti: monet tahot suhtautuvat erittäin epäilevästi poikkeusten väärinkäyttöön ylläolevan esimerkin tavoin. Epäilevässä suhtautumisessa on perusteensa - poikkeukset on luotu ohjelman ajon aikana tapahtuvien virheiden käsittelyyn, ja valtaosa ohjelmoijista lähtee tästä oletuksesta liikkeelle. Mikäli poikkeusta käyttää muuhun tarkoitukseen, on asia syytä selkeästi ilmaista lähdekoodin kommenteissa - tällä tavalla (ehkä, kenties) vältytään väärinkäsityksiltä.

Asynkronoidun koodin testaus (Mocha)

Rakensin eilen PromiseMonopoly-nimistä ohjelmistokehystäni jälleen hiukan eteenpäin. Kehys on siinä pisteessä, että on syytä kirjoittaa muutamia yksinkertaisia automatisoituja testejä sille.

Piskuiseksi ongelmaksi muodostui, että koska kutsut kehyksen sisälle ovat asynkronoituja - eli palauttavat lupauksen -, testaaminen täytyy myös tehdä asynkronoidusti.

Maanmainio Mocha tuli tässä kohtaa apuun.

Async-testi Mochalla

Kirjoitin kehykselleni allaolevan testin:


describe('Phase', function() {
  describe('onEnter + onExit', function() {
    it('Phase with empty subphases goes correctly', function(done) {
      var tracking = [];
      var testiphase = new Phase('testi', {loop: false}, []);
      testiphase.onEnter = function() {
        tracking.push("START");
      }

      testiphase.onExit = function() {
        tracking.push("STOP");
      }
      testiphase.__initialize({}, [new Player(whiteUser), new Player(blackUser)]);

      testiphase.__start()
      .then(function() {
        expect(tracking).to.deep.equal(['START', 'STOP']);
        done();
      })
    })
  })
})	

Ylläoleva testi varmistaa, että onEnter- ja onExit-kutsufunktiot tulevat kutsutuksi kehyksen toimesta oikeassa järjestyksessä. Eli kutsuessamme testiphase.__start(), myöhemmin meillä on tracking-listassa viestit “START” ja “STOP” peräkkäin.

expect(tracking).to.deep.equal(['START', 'STOP']);

Asynkronoidun testauksen ytimessä Mochalla on tämä koodirivi:


 it('Phase with empty subphases goes correctly', function(done) {

Oleellista ylläolevassa rivissä on done-parametri, jonka testiajon suorittava funktio ottaa vastaan. Mikä tuo mystinen done sitten on? Se on parametri on funktio, jota kutsumalla testi julistetaan suoritetuksi.

Toinen tärkeä on tämä rivi:


 expect(tracking).to.deep.equal(['START', 'STOP']);

Expect-kutsulla suoritamme varsinaisen testin, eli varmistamme, että ohjelma-ajon tuottama tulos on haluttu.

On syytä huomata, missä tämä expect-kutsu sijaitsee; se on lupausketjun viimeisen then-metodin sisällä! Tämä tarkoittaa, että varsinainen testaus suoritetaan vasta kun lupausketju on siirtynyt viimeiseen vaiheeseensa. Muita vaihtoehtoja suorittaa testaus ei ole, sillä testauksen kannalta relevantit operaatiot suoritetaan lupausketjun aiemmissa vaiheissa.

Seuraava esimerkillinen testi EI toimi kuten haluamme:


// Ei toimi, async ja sync sekoitettuna!

describe('Matikka', function() {
  describe('Yhteenlaskut', function() {
    it('2+2=4', function() {
      var summa = laskeAsync(2, 2);
      expect(4).to.equal(summa);
    })
  })
})	

Ylläoleva ei toimi juuri siksi, että laskeAsync on (nimensä mukaisesti) asynkronoitu funktio. Se ei voi palauttaa haluttua lukua, sillä asynkronoidut funktiokutsut eivät tiedä lopputulosta ajoissa. Tässä tapauksessa oletamme, että laskeAsync suorittaa yhteenlaskun vaikkapa kysymällä Googlen serveriltä lopputulosta. Tuo lopputulos saapuu sitten joskus, riippuen nettiyhteyden nopeudesta.

Eli ongelma on, että muuttuja summa ei ole ajoissa tiedossa.

Ongelma on helppo korjata, ja muuntaa testaus asynkronoituun muotoon:


// Toimii, 100% async!

describe('Matikka', function() {
  describe('Yhteenlaskut', function() {
    it('2+2=4', function(done) {
      laskeAsync(2, 2).then(function(summa) {
        expect(4).to.equal(summa);
        done();
      })
			
    })
  })
})	

Nyt homma pelittää virheettömästi. Kutsumme laskeAsync-funktiota, jota palauttaa lupauksen. Kun tuo lupaus täyttyy (then()), meillä on haluamamme summa saatavilla ja voimme varmistaa expect-kutsun avulla, että tuo summa on neljä.

Suoritettuamme expect-testin kutsumme funktiota done, joka ilmoittaa Mochalle, että testaus on tältä osalta valmis. Miksi tuota done-funktiota pitää erikseen kutsua?

Synkronoidussa versiossa ei tarvitse. Tämä siksi, että Mocha voi olettaa testauksen olevan valmis heti kun kooditiedosto on ajettu kerralla loppuun. Eli siis ollaan saavuttu viimeiselle koodiriville.

Mutta asynkronoidussa versiossa Mocha ei voi tehdä tuollaisia rämäpäisiä oletuksia. Osa testauskoodista saattaa odottaa vuoroaan. Meidän esimerkissämme näin tekee Googlen palvelimelta yhteenlaskun tulosta odottava koodipätkä. Tällöin Mocha ei voi vain julistaa testejä suoritetuksi heti kun testitiedoston viimeinen koodirivi on nähty ja ajettu; testit ajetaan myöhemmin ja on syytä jäätä odottamaan testien tuloksia. Done-funktion käyttö mahdollistaa odotuksen - kukin yksittäinen testi ilmoittaa oman done-funktionsa kautta milloin se on valmis.

Rekursiivinen lupausketju ajurina? (osa 2)

(Tämä on jatkoa postaukselle Rekursiivinen lupausketju ajurina? (osa 1))

Rakennan parhaillaan ohjelmakehystä (työ)nimeltään PromiseMonopoly. Tuon kehyksen tarkoitus on valmistuessaan mahdollistaa keskuspalvelimen kautta toimivien vuoropohjaisten pelien helpompi toteuttaminen.

Kehys abstraktoi vastuulleen yhteyksien hallinnan ja ns. game-loopin pyörittämisen. Jälkimmäinen vastuualue on keskeinen osa mitä tahansa vuoropohjaista peliä. Ajatellaan vaikka Monopolia; meillä on viisi pelaajaa, jotka kukin tekevät siirtonsa vuorollaan. Siirtovuoro kiertää ympyrää kullakin hetkellä pelissä mukana olevien pelaajien kesken kunnes lopulta jäljellä on vain yksi pelaaja. Tämä viimeinen mohikaani on pelin voittaja.

Vastaava ympyrää kiertävä siirtovuorojärjestys on ominainen käytännössä kaikille vuoropohjaisille peleille. Ainoa mikä vaihtelee on pelaajien määrä.

Esimerkiksi shakissa siirtovuoro hyppii kahden pelaajan välillä. Shakkipeli päättyy heti kun toinen pelaajista ei enää kykene tekemään siirtoa (eli laudalla on matti tai patti).

Rakennusvaiheessa oleva ohjelmistokehykseni abstraktoi siirtovuorojen hallinnan seuraavalla tavalla:


var peliTila = new Peli();

var SIIRTO_MAX_AIKA = 5000; // Siirtoaika max. viisi sekuntia.

function aloitaPeli(pelaajat) {
    // Pyydä kutakin pelaajaa yksitellen tekemään siirtonsa  
	return siirtoKierros(pelaajat)
	// Pelaajat, jotka eivät jatka seuraavalle siirtokierrokselle saivat 
	// palautusarvonaan "null", joten heidät voi filteröidä pois.
	.then(_.compact)
	.then(function(mukanaOlevatPelaajat) {

	  if (mukanaOlevaPelaajat.length <= 1) {
		// Vain yksi tai nolla pelaajaa enää mukana, lopeta peli.
		throw new LopetaPeli();
	  }

	  // Peli jatkuu, aloita uusi siirtoKierros 
	  // Vain yhä mukana olevat pelaajat pääsevät mukaan
	  // uudelle siirtokierrokselle.
	  return siirtoKierros(mukanaOlevatPelaajat);
	})
	.catch(LopetaPeli, function() {
		// Peli on päättynyt
		// Älä rekursoi
		console.log("Peli päättynyt");
	})
}

function siirtoKierros(pelaajat) {
  return Promise.mapSeries(pelaajat, function(pelaaja) {
    if (pelaaja.hasDisconnected()) return null;
    return __pyydaSiirtoa(pelaaja);
  });	
}

function __pyydaSiirtoa(pelaaja) {
  // pelaaja.teeSiirto() lähettää pelaajalle pyynnön tehdä siirto.
  // .timeout() määrittää maksimiajan jonka puitteissa tuo siirto on tehtävä.
  return pelaaja.teeSiirto().timeout(SIIRTO_MAX_AIKA)
  .tap(function(siirto) {
    // Throws "Laitonsiirto"-Error jos kyseessä laiton siirto.
    return tarkistaSiirronLaillisuus(siirto);
  })
  .then(function(siirto) {
    // Jos pääsemme tänne, siirto on ollut laillinen
    // Muokkaamme pelin tämän hetkistä tilaa siirron pohjalta.
    // Pelitila yksinkertaisesti tarkoittaa pelin tämän hetkistä pelitilannetta, esim.
    // shakkipelissä pelitila tarkoittaa laudalla olevaa asemaa.
    var uusiPelitila = toteutaSiirto(siirto);
    // Ilmoitamme uuden tilapäivityksen kaikille pelin osanottajille.
    // (jotta he pysyvät kärryillä pelin etenemisestä).

    viestiPelaajille(pelaajat, {
      aihe: 'uusi_siirto_tehty',
      siirto: siirto,
      pelitila: uusiPelitila
    });
    // Palautamme pelaajan sillä hän jatkaa mukana pelissä.
    return pelaaja;

    // 
  })
  .catch(Laitonsiirto, function() {
    // Pelaaja yritti tehdä laittoman siirron.
    // Palauta vuoro pelaajalle ja pyydä tekemään laillinen siirto.
    // Kutsumme rekursiivisesti tätä funktiota uudestaan.
    return this.__pyydaSiirtoa(pelaaja);

  })
  .catch(TimeoutError, function() {
    // .timeout(aika) metodimme heitti virheen, eli
    // pelaaja ei ehtinyt tekemään siirtoaan ajoissa.

    // Vakioasetuksena pelaaja häviää pelin, eli palautamme arvon null.
    return null;
  })

	
}

function toteutaSiirto(siirto) {
  // Muokkaa pelitilaa siirron pohjalta jotenkin ja palauta muokattu pelitila.
  // Muokkaaminen on pelikohtaista ja kehyksen käyttäjä määrittää muokkausfunktion.

  return peliTila;
}

function viestiPelaajille(pelaajat, viesti) {
  // Kutsu kunkin pelaajan "lahetaViesti"-metodia, joka
  // hoitaa kommunikoinnin pelaajan suuntaan.
  _.map(pelaajat, function(pelaaja) {
    pelaaja.lahetaViesti(viesti);
  });
}

Yllä on yksinkertaistettu versio asynkronoidusta game-loopista, joka pyytää vuorotellen pelaajia tekemään siirtojaan kunnes lopulta vain yksi pelaaja on jäljellä.

Koko loopin keskiössä on Promise.mapSeries, joka yksi kerrallaan kutsuu __pyydaSiirtoa-funktiota kullekin pelaajalle. Promise.mapSeries-kutsun palautusarvo sisältää listan pelaajista, jotka jatkavat peliä seuraavalle kierrokselle.

Tämä lista rakentuu pelaaja pelaajalta sen mukaan, mitä __pyydaSiirtoa-funktio palauttaa. Jos __pyydaSiirtoa palauttaa null, pelaaja ei jatka seuraavalle kierrokselle (= hän on hävinnyt pelin). Jos __pyydaSiirtoa palauttaa Pelaajan, pelaajan jatkaa seuraavalle kierrokselle.

Ylimmällä tasolla funktio aloitaPeli laittaa pyörät pyörimään. Se kutsuu rekursiivisesti aina uutta siirtokierrosta pelattavaksi. Kunkin siirtokierroksen päätteeksi se tarkistaa onko peli päättynyt (= vähemmän kuin kaksi pelaajaa jäljellä). Jos ei ole, se aloittaa uuden siirtokierroksen.

Asynkronoidun game-loopin perusominaisuus on, että kaikki funktiot palauttavat lupauksen. Tämä lupaus voidaan sitten ketjuttaa osaksi suurempaa lupausketjua. Poikkeuksena on funktio kuten viestiPelaajille, jonka oletetaan suorittavan tehtävänsä välittömästi (viestien lähettäminen kullekin pelaajalle yksinkertaisesti oletetaan onnistuvaksi, myöhemmässä versiossa oletuksesta luovutaan ja käytetään erillistä “disconnect”-handleria reagoimaan yhteysvirheisiin pelaajan ja palvelimen välillä).

Ylläolevasta koodista puuttuu vielä tärkein ohjelmistokehykselle ominainen aspekti; mahdollisuus kutsua kehyksen käyttäjän määrittämiä lisäfunktioita. Koska esimerkiksi shakkipelissä tehtävän siirron laillisuuden tarkistaminen on varsin erilainen prosessi kuin pokeripelissä tehtävän siirron laillisuuden tarkistaminen, on kehyksen käyttäjän pystyttävä pluggaamaan sisään haluamansa tarkistusfunktio.

Toisin sanoen, tämä kohta koodia:


.tap(function(siirto) {
   // Throws "Laitonsiirto"-Error jos kyseessä laiton siirto.
   // Kutsumme staattisesti valittua tarkistusfunktiota.
   return tarkistaSiirronLaillisuus(siirto);
})

menee kutakuinkin muotoon


.tap(function(siirto) {
   // Throws "Laitonsiirto"-Error jos kyseessä laiton siirto.
   // Kehyksen käyttäjä tarjoaa meille tarkistusfunktion osana
   // "laajennukset"-objektia, jonka hän on määrittänyt.
   return laajennukset['tarkistaSiirronLaillisuus'](siirto);
})

Antamalla käyttäjälle vapauden valita laajennukset-objektin funktioiden toteutukset, kehyksen käyttäjä kykenee toteuttamaan haluamansa pelilogiikan kehyksen pohjalle. Esimerkiksi timeout-virhetilanteen hallinta:

Vanha muoto:


.catch(TimeoutError, function() {
	// .timeout(aika) metodimme heitti virheen, eli
	// pelaaja ei ehtinyt tekemään siirtoaan ajoissa.

	// Vakioasetuksena pelaaja häviää pelin, eli palautamme arvon null.
	return null;
})

ja uusi muoto:


.catch(TimeoutError, function() {
	// .timeout(aika) metodimme heitti virheen, eli
	// pelaaja ei ehtinyt tekemään siirtoaan ajoissa.

	// Annamme kehyksen käyttäjän tarjoaman funktion 
	// päättää miten reagoidaan
	return laajennukset['aikaKuluiUmpeen']();
})

Huomioitavaa on, että kehyksen käyttäjän tarjoama kutsufunktio voi sisältään myös heittää virhetilanteita, jotka sitten kehyksen lupausketju nappaa kiinni. Tällä tavoin kutsufunktio voi esimerkiksi päättää pelin ennenaikaisesti (= ennen kuin vain yksi tai nolla pelaajaa on jäljellä).


laajennukset['aikaKuluiUmpeen'] = function() {
	throw new LopetaPeli();
}

Jatketaan tästä ensi kerralla.

Kuinka CSRF toimii?

Lomakkeiden lähetys ja vastaanotto ovat tyypillisen web-applikaation tärkeimpiä vastuutehtäviä.

Lomakkeiden ja niiden datalähetysten suojaus on tärkeä aspekti turvallisen web-applikaation kannalta. Keskitytään tässä postauksessa yhteen suojamuuriin; CSRF-suojaukseen.

CSRF

CSRF tulee sanoista “Cross-Site Request Forgery”. Sanahirviö tarkoittaa yksinkertaisesti tilannetta, jossa rikollinen käyttäjä huijaa web-applikaatiota luulemaan, että viesti tulee rehelliseltä käyttäjältä.

Erityisesti tämä puijaus kohdistuu lomakkeiden lähetyksiin. Tyypillisellä web-applikaatiolla on oltava jokin tapa mahdollistaa käyttäjiensä tallettaa/muokata sisältöä.

Tuo sisältö voi olla blogipostauksia, pankkimaksuja, lentovarauksia, jne.

Useimmiten uuden sisällön luomista varten web-applikaatio tarjoaa lomakkeen, jonka täyttämällä ja lähettämällä sisällön luonti tapahtuu.

Otetaan esimerkkinä pankkisuorituksia hallinnoiva sivusto. Sivustolla voi tehdä tilisiirron täyttämällä lomake:

Lähettäjän tili:

Saaja:

Saajan tili:

Summa:

Viesti:

Eräpäivä:

Lomakkeen alla on “Maksa”-nappula, jota painamalla lähetys lähtee liikkeelle.

Ongelmaksi ylläolevassa muodostuu, että kuka tahansa voi luoda ylläolevan kaltaisen datapaketin, ja lähettää sen nettipankkiapplikaation suuntaan. Esimerkiksi minä voisin luoda seuraavanlaisen lähetyksen:

Lähettäjän tili: Kimi Räikkönen FI00112233-4

Saaja: Jussi Hämäläinen

Saajan tili: FI22334455-6

Summa: 1000

Viesti: Jäätelöraha

Eräpäivä: 9.8.2016

Nettipankkiapplikaatio vastaanottaa ylläolevan lomakelähetyksen. Mitä tapahtuu vastaanoton jälkeen?

Ei mitään, sillä käyttäjä “Kimi Räikkönen” ei ole kirjautunut sisään. Eli tilisiirtoa ei tapahdu. Huomioitavaa on, että Räikkösen sisäänkirjautuminen ei vaikuta CSRF-suojauksen toimintaan.

Vain sisäänkirjautuneet käyttäjät voivat luoda tilisiirtoja, joissa “Lähettäjän tili” on oma tili.

Mutta mennään askel pidemmälle. Kuvitellaan, että olen jotenkin onnistunut injektoimaan skriptin nettipankin käyttöliittymään.

Tällä tarkoitan, että kun nettipankin käyttöliittymäsivu rakennetaan HTML-koodista, olen jollain tavalla onnistunut työntämään tuohon rakennusvaiheeseen palan painikkeeksi haluamaani koodia.

Ilmiöstä käytetään nimitystä XSS (Cross-Site Scripting).

XSS:n avulla kykenen toteuttamaan seuraavanlaisen tempun. Seuraavan kerran kun Kimi Räikkönen - siis oikea Kimi, joka tietää omat pankkitunnuksensa - loggautuu nettipankkijärjestelmään sisään ja siirtyy maksusuoritusten luomissivulle, minun määrittämä koodinpätkäni suoritetaan Räikkösen tietokoneen web-selaimessa.

Mitä tuo minun määrittämä koodinpätkä tekee?

Se lähettää lomakelähetyksen (kuten yllä, jossa Räikkönen vippasi minulle tonnin jäätelörahaa) nettipankin suuntaan.

Lomakelähetys saapuu nettipankin rajapintaan. Ja nyt tullaan tärkeään vaiheeseen: koska Kimi Räikkönen on kirjautunut sisään omilla oikeilla tunnuksillaan, nettipankki luulee, että Räikkönen toden totta on tuon tilisiirron takana. Ja miksi ei luulisi?

Tilisiirron tiedot sisältävä lomakelähetys lähti liikkeelle Räikkösen tietokoneelta. Nettipankkiapplikaatio ei tiedä, että lähetyksen liikkeellelähdön sai aikaan minun ohjelmoimani skripti, joka ajettiin Räikkösen www-selaimen sisällä.

Nettipankille tilanne näyttää siltä, että Räikkönen täytti lomakkeen ja klikkasi “Maksa”-nappulaa.

Joten nettipankilla ei ole mitään syytä epäillä, etteikö lomakelähetys olisi aito. Siispä se tekee tilisiirron ja raha vaihtaa omistajaansa.

Miten CSRF-suojaus auttaa?

CSRF-suojauksen ydinajatus on, että kun käyttäjälle tarjotaan lomaketta täytettäväksi, tuo lomake yksilöidään jollain tunnisteella. Myöhemmin web-applikaatio kykenee tämän yksilöidyn tunnisteen avulla varmistamaan, että saapuva lomakelähetys (esim. tilisiirron tiedot) on luotu asianmukaisesti.

Eli aiempi datalähetys

Lähettäjän tili: Kimi Räikkönen FI00112233-4

Saaja: Jussi Hämäläinen

Saajan tili: FI22334455-6

Summa: 1000

Viesti: Jäätelöraha

Eräpäivä: 9.8.2016

menee nyt muotoon

_CSRF: ejse72Hja7299391Jkla28

Lähettäjän tili: Kimi Räikkönen FI00112233-4

Saaja: Jussi Hämäläinen

Saajan tili: FI22334455-6

Summa: 1000

Viesti: Jäätelöraha

Eräpäivä: 9.8.2016

Kuten huomaamme yltä, lomakedatan yhteyteen on lisätty “_CSRF”-niminen lomakekenttä. Käytännössä web-applikaatio siis lähettää lomakesivun mukana CSRF-tunnisteen, ja myöhemmin vastaanottaa datan sisältäen CSRF-tunnisteen. Vain jos nämä kaksi CSRF-tunnistetta täsmäävät, applikaatio hyväksyy vastaanotetun datan.

Jos ne eivät täsmää, applikaatio kieltäytyy toimimasta.

Miksi CSRF-tunnisteiden täsmääminen ratkoo aiemman jäätelörahahuijauksen?

Yksikertaisesti siksi, että nettipankki osaa yhdistää tarjotun lomakkeen ja vastaanotetun lomakedatan toisiinsa. Täten jos minä XSS:n kautta lähetän tilisiirtolähetyksen (Räikkösen koneelta käsin, kiitos XSS:n), niin nettipankkiapplikaatio tekee seuraavan tarkistukset:

  1. Tilisiirtolähetys lähti liikkeelle sisäänkirjautuneelta käyttäjältä - > check!
  2. Tilisiirtolähetyksen CSRF-tunniste täsmää applikaation tallentaman tunnisteen kanssa -> fail!

CSRF-tunniste ei täsmää, sillä minun etukäteen tuottamani lomakelähetys ei tiedä tuota tunnistetta. Tunniste luodaan jokaiselle lomakkeelle erikseen, ja se on satunnainen merkkijono.

Lopputulos siis on, että nettipankkiapplikaatio ei tee tilisiirtoa, ja jään ilman jäätelörahaa.

Loppukaneetti: XSS:n avulla saattaa teoriassa olla mahdollista selvittää CRSF-tunniste kesken hyökkäyksen. Tällöin CSRF-suojaus menettää tehonsa. Tämä vaatii, että XSS-hyökkääjällä on mahdollisuus ajaa mielivaltaista Javascript-koodia uhrinsa www-selaimessa.

Jos tätä mahdollisuutta ei ole, CSRF-suojaus toimii ja estää useimmat muunlaiset hyökkäysyritykset; esim. linkki-injektion, jossa rikollinen on jotenkin saanut nettipankin käyttöliittymään lisättyä linkin, jota klikkaamalla etukäteen suunniteltu lomakedata lähtee salaa liikkeelle. Koska tuo etukäteen suunniteltu lomakedata ei voi mitenkään tietää ETUKÄTEEN sen hetkisen CSRF-tunnisteen merkkijonoa, CSRF-suojaus toimii ja rikollinen jää nuolemaan näppejään.

Kolme tapaa lukea tiedosto (Node.js)

Tiedoston lukeminen on varsin yleinen toimenpide Node.js-applikaatiossa. Alla esittelen lyhyesti kolme tapaa hoitaa luku-urakka.

Synkronoitu, bufferoitu

Synkronoitu ja bufferoitu tiedostoluku tapahtuu seuraavanlaisesti:


var fs = require('fs')

var tiedostonSisalto = fs.readFileSync('tiedosto.txt', 'utf8');

Ylläoleva koodi on äärimmäisen yksinkertainen. Luemme tiedoston nimeltä tiedosto.txt ja tallennamme sen sisällön tiedostonSisalto-muuttujaan. Määritämme erikseen vielä, että tiedoston on enkoodattu utf8-merkistöllä, jotta voimme käsitellä sisältöä oikein.

Ongelmaksi ylläolevassa muodostuu se, että lukeminen on synkronoitu toimenpide. Toisin sanoen, koko Nodejs:n runtime seisoo tyhjän panttina sen aikaa, kun tiedoston lukeminen kovalevyltä on käynnissä. Näin ei tarvitsisi olla, mutta ylläolevassa näin on.

Toimintojen suoritusjärjestys on kutakuinkin seuraava:

  1. Node.js-prosessi pyytää käyttöjärjestelmää avaamaan tiedoston.
  2. Käyttöjärjestelmä pyytää kovalevyä (sen ajureita) suorittamaan luvun.
  3. Tiedoston luku tapahtuu
  4. Käyttöjärjestelmä informoi Node.js-prosessia onnistuneesta luvusta.
  5. Node.js-prosessi jatkaa suoritustaan.

Ongelman ydin on siinä, että vaiheiden 2-4 ajan Node.js-prosessi seisoo paikallaan.

Asynkronoitu, bufferoitu

Ylläoleva ongelma ratkeaa suorittamalla tiedostonluku asynkronoidusti.


var fs = require('fs')

fs.readFile('tiedosto.txt', 'utf8', function(err, tiedostonSisalto) {
	console.log(3)
});

console.log(1);
console.log(2);

Sen sijaan, että määrittäisimme paluuarvon sisälleen ottavan muuttujan, syötämme tiedoston lukemisesta vastaavaan Node.js-funktioon kyytipojaksi callback-funktion. Callback-funktio nimensä mukaisesti sitten joskus tulee kutsutuksi, saaden parametrinään tiedoston sisällön.

Huomioitavaa on, että nyt Node.js-runtime ei seiso tyhjän panttina tiedostoluvun aikana. Sen sijaan tiedoston lukeminen fyysiseltä kovalevyltä tapahtuu yhtäaikaa Node.js-prosessin koodinajon kanssa.

Ylläolevassa koodissa console.log()-komennot kuvaavat eri koodirivien suoritusjärjestystä. Callback-funktion sisällä oleva lokkaus tapahtuu viimeisenä.

Asynkronoitu ja bufferoitu ratkaisu on pätevä, ja yleisesti käytössä. Mutta entä jos emme halua bufferoida koko tiedostoa keskusmuistiin? Jos tiedoston koko on esimerkiksi 20 gigatavua, meillä ei riitä keskusmuistissa edes tila:


FATAL ERROR: CALL_AND_RETRY_0 Allocation failed - process out of memory

Ratkaisu on striimata tiedosto pienissä paloissa.

Asynkronoitu, streamattu

Kolmas ja viimeinen tapa hoitaa tiedoston lukeminen on striimata tiedoston sisältö pienissä pätkissä kerrallaan. Tällöin kerrallaan keskusmuistissa on vain pieni osa tiedostoa; kun tuo osa on käsitelty, seuraava osa voi ottaa sen paikan.


var fs = require('fs');
var readStream = fs.createReadStream('tiedosto.txt');

readStream.on('data', function(chunk) {
	console.log(chunk);
});

Ylläolevassa ratkaisussa määritämme callback-funktion, jonka lähetämme kyytipojaksi tiedoston lukemisesta vastaavaan järjestelmäfunktioon. Tässä suhteessa ratkaisu on identtinen #2 ratkaisun kanssa.

Mutta ero #2 ratkaisuun tulee siinä, mitä tuo callback ottaa parametrinään sisälle. Kakkosratkaisussa koko tiedoston sisältö tuli parametrinä sisään. Nyt tulee vain pieni osa tiedostoa - sen sijaan callback-funktiota kutsutaan *uudelleen ja uudelleen niin kauan, kunnes koko tiedosto on pala palalta käsitelty.

Striimauksen ongelma on, että saamme palat yksitellen. Jos siis haluamme uudelleenrakentaa tiedoston sisällön eheänä kokonaisuutena, meidän täytyy erikseen yhdistää nuo palat yhteen.

Loppukaneetti: yllä kolme yleisintä tapaa hoitaa tiedoston lukeminen ja käsittely Node.js-applikaatiossa. Eri tavat sopivat eri ongelmiin:

  1. Synkronoitu ja bufferoitu sopii hyvin Node.js-applikaation initialisaatiovaiheeseen (eli kun applikaatio käynnistyy). Initialisaation aikana applikaatio rakentuu keskusmuistiin, ja varsinaisia loppukäyttäjien HTTP-kutsuja ei vielä käsitellä. Tästä syystä synkronoidun kutsun negatiiviset vaikutukset ovat vähäiset. Myöhemmin applikaation normaalin toiminnan aikana synkronoitu kutsu hidastaa merkittävästi applikaation vasteaikaa.

  2. Asynkronoitu ja bufferoitu sopii erinomaisesti pienten tiedostojen lukemiseen applikaation varsinaisen toiminta-ajon aikana. Applikaatio ei jumahda paikalleen, vaan pysyy käyttökelpoisena ja suorituskykyisenä.

  3. Asynkronoitu ja striimattu sopii suurten tiedostojen lukemiseen. Se sopii myös tapauksissa, joissa tiedoston sisältö voidaan pala palalta lähettää eteenpäin, esim. loppukäyttäjän HTTP-yhteyteen.

Async ja Await

Kokeilin eilen ensimmäistä kertaa async-await -funktion kirjoittamista Javascriptilla. Kyseessä on uusi ja mullistava tapa ohjelmoida asynkronoidusti toimivia funktioita.

Vastaava tapa on ollut esim. C#-kielessä jo kauan, mutta Javascriptiin ominaisuus on vasta tuloillaan. Se ei ole vielä virallisesti osana Javascript-kieltä. Mutta Babelin kaltaisten koodimuuntajien avulla tuota ominaisuutta pääsee testaamaan jo tänään.

Async-await ei ole virallisesti tuettu ominaisuus. Tuota ominaisuutta voidaan kuitenkin simuloida. Simulointi onnistuu yksinkertaisesti siten, että async ja await-avainsanoja sisältävä Javascript-koodi käännetään koodiksi, joka ei sisällä async ja await-avainsanoja, mutta toteuttaa vastaavat toiminnot muulla tavoin.

Minkä ongelman async-await ratkoo?

Kerrataanpa tuikitavallisen funktion määritys ja kutsuminen:


function hae(hetu) {
  // Meillä on jossain muualla määritelty 'henkiloTietokanta'
  // niminen Javascript-objekti.
  return henkiloTietokanta[hetu];
}

var henkilo = hae('010787-111A');
console.log(henkilo.nimi); // Jaakko Jantunen

Ylläoleva on perinteinen, synkronoitu Javascript-funktio. Koodi määrittää funktion, ja tämän jälkeen kutsuu tuota funktiota. Funktiokutsun seurauksena saadaan takaisin Henkilö-objekti, jonka attribuutti nimi printataan käyttäjälle.

Vastaavat funktiot ovat arkipäivää kaikissa yleisimmissä ohjelmointikielissä.

Nyt kysymys kuuluu: entä jos hae-funktio joutuisikin hakemaan henkilön tiedot tietokoneen kovalevyltä?

Vaatimus ei ole poikkeuksellinen, päinvastoin. Henkilötietokanta sisältää miljoonia rivejä tietoa. Tuollaisen tietomäärän pitäminen yksinomaan keskusmuistissa (=Javascript-objektin sisällä) on kutakuinkin mahdotonta. Entä jos palvelin joku kaunis päivä kaatuu? Kaikkien henkilöiden tiedot katoaisivat savuna ilmaan!

Meidän on siis pakko tallettaa henkilötiedot kovalevylle.

Nyt naivisti voisimme yrittää seuraavaa:


// HUOM! Tämä yritelmä ei toimi halutusti!

function hae(hetu) {
  // Meillä on nyt kovalevyllä 'henkiloTietokanta' tiedosto!
  // Ladataan ensin tietokanta keskusmuistiin.
  var tietokanta = kovalevy.lue('henkiloTietokanta');
  // Haetaan haluttu henkilö. Ei toimi kuten haluamme!
  return tietokanta[hetu];
}

var henkilo = hae('010787-111A'); //Error! Cannot read property of undefined!

Ylläoleva koodi ei toimi. Miksi? Koska tiedoston lukeminen kovalevyltä keskusmuistiin on asynkronoitu operaatio. Tämä tarkoittaa, että tiedoston lukeminen keskusmuistiin vie sen verran kauan aikaa, että Javascript-koodiajo siirtyy eteenpäin.

Vastaava tilanne syntyy meidän ihmisten elämässä usein. Esimerkkinä pankissa asiointi. Astut sisälle pankkiin ja otat vuoronumeron. Edelläsi jonossa on viisitoista henkilöä. Jäätkö kiltisti odottamaan pankin odotustilaan vai käytkö välillä vaikka lounaalla? Jos odotat varvastakaan liikuttamatta, toimit synkronoidusti. Jos käyt muilla asioilla ja palaat paikalle omaa vuoroasi varten myöhemmin, toimit asynkronoidusti.

Jotta ylläoleva analogia toimisi vielä täsmällisemmin, pankin tulisi ilmoittaa sinulle kun jono etenee vuoronumerosi kohdalle. Tälläinen järjestelmä on käytössä Helsingin Grand Casinolla - aloittaessasi jonotuksen pokeripöytään, saat taskuusi mukaan piipparin, joka ilmoittaa heti kun pöydässä on tilaa. Jonotuksen ajan voit huoletta törsätä pikkurahat kolikkopeleihin.

Koska Javascript-koodiajo siirtyy eteenpäin, tarkoittaa tämä, että seuraava rivi

return tietokanta[hetu];

on ongelmallinen. Yritämme etsiä tietokanta-objektista henkilöä hetun perusteella. Mutta juuri yllä totesimme, että tiedoston luku kovalevyltä vie muutaman tovin aikaa, ja koodinajo ei jää odottamaan. Toisin sanoen, tietokanta-objekti ei voi sisältää haluttua henkilötietokantaa.

Tilanne on sama kuin jos yrittäisit asua talossa, jonka rakentaminen on aloitettu viisi minuuttia sitten. Taloa ei yksinkertaisesti ole olemassa, joten etpä siinä voi asustellakaan.

Mikä on ratkaisu?

Ongelman ydin on siis siinä, että kovalevyltä tiedoston lukeminen on asynkronoitu operaatio, ts. se suoritetaan sitten joskus. Koodiajo ei jää odottamaan tuota operaatiota, vaan pyyhältää surutta eteenpäin.

Tästä syystä tarvitsemme konseptin lupaus, joka palauttaa kovalevy.lue-kutsusta lupausobjektin.

Tuo lupausobjekti sisältää tarvittavat mekanismit henkilötietokannan käyttöä varten sitten joskus.


function hae(hetu) {
  // Meillä on nyt kovalevyllä 'henkiloTietokanta' tiedosto!
  // Ladataan tietokanta keskusmuistiin.
  // Koska kyseessä on asynkronoitu operaatio, palautamme lupausobjektin.
  var lupaus = kovalevy.lue('henkiloTietokanta');

  // lupaus-objekti saa tulevaisuudessa käyttöönsä henkilötietokannan
  // Kun näin vihdoin tapahtuu, haluamme etsiä henkilön hetun perusteella!
  return lupaus.then(function(tietokanta) {
    return tietokanta[hetu];
  });
	
}

hae('010787-111A').then(function(henkilo) {
  console.log(henkilo.nimi); // Jaakko Jantunen
})

Nyt kaikki toimii jälleen. Olemme turvautuneet lupausobjektin käyttöön. Lupausobjekti tarjoaa kutsuttavaksemme then()-metodin, johon voimme tarjota parametriksi funktion. Tuo funktio saa sitten joskus funktiokutsun yhteydessä sisäänsä tietokannan. Myöhemmin teemme toisen then()-kutsun, jonne työnnämme sisään henkilön nimen printtaavan funktion.

Tässä on koko lupauskonseptin viehätysvoima - voimme ketjuttaa asynkronoituja funktiokutsuja lähes samaan tapaan kuin synkronoituja funktiokutsuja:


// Synkronoidut funktiokutsut
a(b(c(d(e()))));

// Asynkronoidut funktiokutsut

Promise.resolve(e())
.then(d)
.then(c)
.then(b)
.then(a)

Vieläkään emme ole aivan päässet itse postauksen aiheeseen, async-await-kuvioon.

Async-await

Ylläoleva henkilön haku toimii mainiosti lupausobjekteja ketjuttamalla. Mutta konsepti silti vaatii lupaus.then()-ketjuihin perustuvan koodaustyyliin. Tälläinen koodityyli ei välttämättä ole yhtä intuitiivinen kuin perinteinen synkronoitu koodaus.

Async-await tarjoaa tavan toteuttaa lupauksiin perustuva koodinajo tavalla, joka visuaalisesti muistuttaa tavanomaista synkronoitua koodaustapaa.

Tämä on (ymmärtääkseni) async-awaitin ainoa etu. Se ei tuo mitään uusia maagisia ominaisuuksia - se vain helpottaa koodinkirjoitusta silloin, kun käytämme asynkronoituja funktiokutsuja.

Katsotaan miten henkilötietojen haku onnistuu async-await-kuvion avulla:


async function hae(hetu) {
  // Meillä on nyt kovalevyllä 'henkiloTietokanta' tiedosto!
  // Ladataan tietokanta keskusmuistiin.
  var tietokanta = await kovalevy.lue('henkiloTietokanta');
  // Haetaan henkilö
  return tietokanta[hetu];
	
}

hae('010787-111A').then(function(henkilo) {
  console.log(henkilo.nimi); // Jaakko Jantunen
})

Kuten yltä huomaamme, hae-funktio muistuttaa synkronoitua funktiota. Ainoa ero on async ja await avainsanojen käyttö.

async function hae(hetu) await kovalevy.lue('henkiloTietokanta')

Async merkitsee, että kyseinen funktio palauttaa lupausobjektin. Await puolestaan merkkaa paikan, jossa koodi stoppaa - eli koodinajo jämähtää kuin seinään siksi aikaa, kunnes await-avainsanan perässä oleva lauseke on valmis.

Tässä esimerkissä await-termin perässä oleva lauseke on juurikin tiedoston lukeminen kovalevyltä. Toisin sanoen, await kiltisti odottaa, että kovalevyltä lukeminen on valmis. Vasta lukemisen onnistuttua koodinajo siirtyy eteenpäin kohti riviä return tietokanta[hetu].

Async-await-kuvion todellinen upeus piilee tilanteessa, jossa saman funktiokutsun sisällä on useita await-stoppauksia:


async function ostaVerkkokaupasta(ostaja, ostajanVerkkopankki, myyjanVerkkopankki, tuote) {
  // Varmistetaan ostajan tiedot ottamalla yhteys YTJ:n yritystietopalveluun
  var vahvistettu = await ytj.vahvistaYritys(ostaja);
  // Varmistetaan tuotteen saatavuus (joku varastolla kipaisee katsomaan).
  var tuoteSaatavilla = await varasto.tarkistaVarastaSaldo(tuote);

  if (vahvistettu && tuoteSaatavilla) {
    // Ostajan tiedot kunnossa ja tuote saatavilla!

    // Nostetaan rahat ostajan verkkopankista.
    var rahat = await ostajanVerkkopankki.nosta(hinta);
    // Annetaan rahat myyjälle
    var maksunTila = await myyjanVerkkopankki.talleta(rahat);

    if (maksunTila === true) {
      // Tilisiirto onnistui.
      // Haetaan tuote ja annetaan s asiakkaalle
      // (Haku tarkoittaa, että joku taas kipaisee varastolle.)
      return await varasto.haeTuote(tuote);
    } 

  }

}
var Nordea = /* rajapinta Nordean verkkopankkiin */
var OP = /* rajapinta OP:n verkkopankkiin */

ostaVerkkokaupasta('Nollaversio IT', Nordea, OP, 'Poravasara')
.then(function(poravasara) {
  if (!poravasara) {
    return console.log("Jäi saamatta.")
  }
  console.log("Poravasara saapunut ja käytettävissä");
})

Yllä funktion ostaVerkkokaupasta sisällä käytämme await-termiä viidesti. Jokaisen awaitin kohdalla koodinajo stoppaa ja jää odottamaan asynkronoidun operaation valmistumista. Funktio etenee askel kerrallaan, kunnes lopulta sitten joskus saamme (jos saamme) käyttöömme poravasaran.

Loppukaneetti: async-await-kuvio on varsin vahva lisäys Javascriptiin. Tällä hetkellä async- ja await-avainsanoja ei voi vielä käyttää ilman Babelin kaltaista koodimuuntajaa. Noheva Javascript-koodari kuitenkin jo etukäteen tutustuttaa itsensä noiden termien käyttöön, sillä mitä todennäköisimmin tulevaisuuden Javascript perustuu paljolti niiden pohjalle.

Näkymämalli (view model) helpottaa elämää

Laravellin kaltainen laittoman hieno ohjelmistokehys hoitaa valtavan määrän abstraktioita koodarin puolesta. Sisääntulevan palvelunpyynnön hallinta, tietokantayhteyden hallinta, jne… kaikki on valmiiksi pureskeltu, jotta ohjelmoijaparan ei tarvitse vaivata liiaksi päätään.

Mutta jotkin asiat Laravel jättää ohjelmoijan omien abstraktiovalintojen armoille. Yksi tälläinen on näkymämallin (engl. view model) konsepti.

Näkymämalli vs. malli?

Ennenkuin keskitymme näkymämalliin, on syytä kerrata ns. “tavallisen mallin” - eli yksinkertaisesti “mallin” - olemassaolon tarkoitus.

Malli edustaa yksittäistä domain-tason objektia. Domain-tason objekti on yksinkertaisesti jokin applikaation ydintehtävän kannalta oleellinen objekti; esimerkiksi nettipankin taustajärjestelmässä tuollainen domain-objekti voisi olla pankkitili.

Yksittäinen malli on ikäänkuin rakennepiirros (engl. blueprint) tuosta objektista; miltä objekti näyttää, mitä toimintoja se sisältää ja jne.

Englanniksi termi “model” tarkoittaa yleensä laajempaa kokonaisuutta kuin yksittäisen objektin rakennepiirrosta. Tässä yhteydessä käytämme käännöstermiä “malli” tarkoittamaan juurikin yksittäisen objektin “mallia”, eli rakennepiirrosta.

Tavallinen malli siis edustaa domain-objektia. Se kuvaa yksityiskohtaisesti, kuinka ympäröivä applikaatio voi vuorovaikuttaa objektin kanssa. Esimerkiksi pankkitili:


// Malli nimeltä "Pankkitili"

// App/Models/Pankkitili.php

class Pankkitili extends Model {
	
	public function haeSaldo() {};
	public function talleta() {};
	public function nosta() {};
	public function tilinOmistaja() {};

	// jne..
}

Malli on useimmiten paras suunnitella niin, että se on ainoastaan kiinnostunut domain-tason asioista. Mitä tarkoitan tällä? Tarkoitan, että mallin tulisi olla autuaan tietämätön käyttöliittymän olemassaolosta.

Jos malli on autuaan tietämätön käyttöliittymän olemassaolosta, malli EI saa sisältää seuraavanlaisia metodeja:


// Malli nimeltä "Pankkitili"

// App/Models/Pankkitili.php

class Pankkitili extends Model {
	// Seuraavat metodit liittyvät käyttöliittymään!
	// Mallin EI tulisi sisältää seuraavia metodeja, sillä ihannearkkitehtuurissa
	// malli ei tiedä käyttöliittymän olemassaolosta hölkäsen pöläystä.

	// Etunimi + sukunimi + asiakasnumero
	public function printtaaOmistajanTiedot() {};
	// Jos negatiivinen saldo, väri = punainen, muuten väri = vihreä
	public function varitaSaldo() {};
}

Ylläolevan mallin metodit printtaaOmistajanTiedot ja varitaSaldo ovat nk. käyttöliittymämetodeja. Tarkoittaen, että niiden olemassaolon syy on yksinomaan tarjota ihmiskäyttäjälle monipuolisempi ja visuaalisempi käyttöliittymä.

Itse applikaation ydintarkoituksen kannalta em. metodeilla ei ole osaa eikä arpaa. Pankkijärjestelmä itsessään ei ymmärrä miksi ihmeessä negatiivinen saldo tulisi olla punaisella fontilla - vain ihmissilmä ymmärtää punaisen värin tarkoituksen.

Siksi metodit printtaaOmistajanTiedot ja varitaSaldo on syytä abstraktoida ulos mallista ja siirtää näkymämallin sisälle.

Näkymämalli huolehtii datan muokkauksesta ihmissilmälle sopivaksi

Näkymämallin tarkoitus on juurikin ottaa vastuulleen mallin sisältämän datan muokkaus ihmissilmälle sopivaan muotoon. Kun näkymämalli vastaa visuaalisesta representaatiosta, varsinainen malli voi keskittyä omaan ydintehtäväänsä, eli itse applikaation kanssa vuorovaikutukseen. Eli lyhyesti:

  1. Malli keskittyy vuorovaikuttamaan applikaation kanssa.
  2. Näkymämalli keskittyy vuorovaikuttamaan ihmiskäyttäjän kanssa.

Jatketaan pankkiesimerkkiämme. Malli on edelleen tämä:


// Malli nimeltä "Pankkitili"

// App/Models/Pankkitili.php

class Pankkitili extends Model {
	
	public function haeSaldo() {};
	public function talleta() {};
	public function nosta() {};
	public function tilinOmistaja() {};

	// jne..
}

Luodaan mallin oheen näkymämalli, joka vastaa mm. saldon värittämisestä punaiseksi mikäli tili paukkuu pakkasella.

Näkymämallin nimeämisessä ohjenuorana on, että mallin nimen perään lisätään “Presenter”. Täten pankkitilin näkymämalli on “PankkitiliPresenter”.


// Näkymämalli nimeltä "PankkitiliPresenter"

// App/ViewModels/PankkitiliPresenter.php

class PankkitiliPresenter extends Model {
	
	public function printtaaOmistajanTiedot() {}	
	public function varitaSaldo() {}

	// jne..
}

Käytännön toteutus - miten näkymämalli saa tietoonsa mallin?

Yllä loimme pohjustukset kahdelle eri konseptille - malli ja näkymämalli. Loimme mallin nimeltä Pankkitili, ja tuota mallia vastaavan näkymämallin nimeltä PankkitiliPresenter.

Seuraavaksi näkymämalli tulee kytkeä yhteen mallin kanssa. Kytkentä on yhdensuuntainen. Pankkitilin ei tarvitse tietää PankkitiliPresenterin olemassaolosta, mutta PankkitiliPresenterin tulee saada käyttöönsä Pankkitili.

Jos PankkitiliPresenter ei tiedä Pankkitilin olemassaolosta mitään, se ei myöskään voi kutsua Pankkitili-objektin metodeja. Ja PankkitiliPresenterin on pakko kutsua Pankkitilin metodeja, sillä esimerkiksi saldon väritys onnistuu vain jos tuo saldosumma on tiedossa.

Yksi hyvä tapa hoitaa kytkös on seuraava:


// Malli nimeltä "Pankkitili"

// App/Models/Pankkitili.php

class Pankkitili extends Model {
	
	public function haeSaldo() {};
	public function talleta() {};
	public function nosta() {};
	public function tilinOmistaja() {};

	public function present() {
		return new PankkitiliPresenter($this);
	}
}


class PankkitiliPresenter extends Model {

	protected $tili;

	public function __construct(Pankkitili $tili) {
		$this->tili = $tili;
	}
	public function printtaaOmistajanTiedot() {
		// Varmista että nimet isolla alkukirjaimella.
		return $this->capitalize($this->tili->tilinOmistaja());
	}	
	public function varitaSaldo() {
		$saldo = $this->tili->haeSaldo();
		// Lisää väritys
		if ($saldo < 0) {
			return '<div class="red">' . $saldo . '</div>';
		} else {
			return '<div class="green">' . $saldo . '</div>';
		}

	}

	// jne..
}

Ylläolevassa arkkitehtuurissa Pankkitili-malli sisältää erillisen present-metodin. Tuo metodi palauttaa PankkitiliPresenter-objektin kutsujan käyttöön.

PankkitiliPresenter-objektia käyttämällä kutsuja saa luotua helposti HTML-koodin pätkän, joka sisältää saldosumman ja tarvittavan HTML-syntaksin tuon saldosumman värittämiseksi joko vihreäksi tai punaiseksi.

On huomattavaa, että esimerkiksi varitaSaldo-metodissa PankkitiliPresenterin tulee kutsua Pankkitilin metodia. Tästä syystä PankkitiliPresenterillä tulee olla aina käytettävissään Pankkitili-objekti.

Valitsemassamme ratkaisussa tuo Pankkitili-objekti annetaan parametrinä PankkitiliPresenterin konstruktoriin.

Näkymämallin käyttö

Ylläolevan ratkaisumme käyttö on helppoa. Aina kun saatavillamme on Pankkitili, on saatavillamme myös PankkitiliPresenter, sillä Pankkitili-malli sisältää metodi PankkitiliPresenter-objektin luomiseen.


// views/Saldoikkuna.php

// Oletetaan, että käytössämme on Pankkitili-objekti nimeltä $pankkitili.
// Esim. Controllerissa olemme avanneet näkymän kutsulla: 
// view('saldoikkuna')->with('pankkitili', $pankkitili);

<h1>Tämän hetkinen saldosi</h1>
<p><?php echo $pankkitili->present()->varitaSaldo() ;?></p>

Ylläoleva koodi toimii mainiosti. Aina kun haluamme kutsua jotain PankkitiliPresenterin metodia, käytämme muotoa:


$pankkitili->present()->metodi();

Loppukaneetti: näkymämallin käytön koko ydinajatus on, että applikaation kannalta oleelliset toiminnot ja käyttöliittymän kannalta oleelliset toiminnot erotetaan toisistaan. Applikaatiota ei kiinnosta se, millä värisävylle negatiivinen saldo näytetään ihmissilmälle. Ihmissilmää tuo asia kiinnostaa.

Datan lähetys näkymään

Laravellin kaltaisten täysiveristen ohjelmistokehysten yksi hienoimmista ominaisuuksista on datan käsittelyn ja datan näytön erottaminen. Laravellissa konseptit erotetaan toisistaan tiedostotasolla; datan käsittely - ns. bisneslogiikka - elää yhdessä tiedostossa, ja näyttölogiikka elää toisessa tiedostossa.

Bisneslogiikasta vastaava tiedosto olkoot kutsumanimeltään model, näyttölogiikasta vastaavaa tiedostoa kutsukaamme nimellä view.

Yleensä näiden kahden välissä istuu vielä kolmas konseptuaalinen palikka - tiedosto kutsumanimeltään controller. Laravel-kehys perustaakin vahvasti toimintansa nk. MVC-periaatteeseen.

MVC:ssä osa-alueet model, view ja controller erotetaan toisistaan. Erottelun ansiosta applikaation koodipohja on selkeämmin luettavissa ja muokattavissa.

Me yksinkertaistamme kolmijakoa hiukan ja muunnamme sen kaksijaoksi; datan käsittely sisältää MC-kirjaimet, ja datan näyttö sisältää V-kirjaimen.

Tutkitaan kahta eri tapaa siirtää vastikään käsiteltyä dataa näkymän käytettäväksi:

Tapa #1:

Tapa 1 - siirretään data suoraan view-tiedoston käyttöön.


// Controller.php

public function index() {
  // Hankitaan dataa jotenkin
  $dataset = getDatasetSomehow();
  // Siirretään data eksplisiittisesti view-tiedoston käyttöön.
  return view('View')->with('dataset', $dataset);
}


// View.php

// Renderöidään data ihmiskäyttäjäm nähtäville.
<?php echo $dataset; ?>

Tapa 1 on suositelluin tapa. Kutakin näkymätiedostoa (view) otettaessa käyttöön määritämme samalla datan, jonka avulla näkymätiedosto renderöidään lopulliseksi HTML-koodiksi. Voimme vapaasti määrittää mitä dataa siirrämme näkymän käyttöön. Voimme myös käyttää samaa tiedostoa usean eri datapaketin renderöimiseen:


// Controller1.php

public function index() {.
  return view('View')->with('dataset', 1);
}


// Controller2.php

public function index() {.
  return view('View')->with('dataset', 2);
}


// Controller3.php

public function index() {.
  return view('View')->with('dataset', 3);
}

Eri datapaketilla renderöidyt näkymät tuottavat eri HTML-koodit.

Tapa 2 - määritetään globaalisti näkymälle tarjottava data

Tapa nro. 1 on yleisin keino siirtää dataa näkymän käyttöön/näytettäväksi.

Mutta entä jos meillä on seuraavanlainen tilanne… tietty näkymätiedosto renderöidään aina tietyn vakiodatan turvin. Tämän vakiodatan lisäksi tuo näkymä ottaa vastaan myös muuttuvaa dataa. Esimerkki tälläisestä on web-portaali; vakiodata on esimerkiksi paikalliset säätiedot, jotka ovat kaikille käyttäjille samat ja aina nähtävillä (esim. yläreunan widgetin kautta).

Eli kaikki käyttäjät näkevät saman säätilatiedotteen portaalin yläreunassa. Nämä säätiedot ovat näkyvillä kaikilla portaalin alasivuilla.

Muuttuva data on puolestaan kullekin käyttäjälle yksilöllinen, esim. kunkin käyttäjän viimeisin sisäänkirjautuminen. Lisäksi kullakin alasivulla on oma muuttuva datansa.

Voisimme vallan mainiosti määrittää molemmat datapaketit aina näkymätiedostoa renderöidessämme:


// KirjautumisTiedotController.php

public function index() {
  $saa = $this->getSaaTiedot();
  $viimeisinKirjautuminen = $this->getLoginInfo();
  return view('kirjautumistiedot')
    ->with('saatiedot', $saa)
    ->with('viimeisinKirjautuminen', $viimeisinKirjautuminen);
}


// VastaanotetutViestitController.php

public function index() {
  $saa = $this->getSaaTiedot();
  $viestit = $this->getMessagesReceived();
  return view('viestit_vastaanotettu')
    ->with('saatiedot', $saa)
    ->with('viestit', $viestit);
}


// LahetetytViestitController.php

public function index() {
  $saa = $this->getSaaTiedot();
  $viestit = $this->getMessagesSent();
  return view('viestit_lahetetty')
    ->with('saatiedot', $saa)
    ->with('viestit', $viestit);
}

Ylläolevassa esimerkissä meillä on kolme eri näkymätiedostoa - kirjautumistiedot, viestit_vastaanotettu ja viestit_lahetetty.

Kukin niistä hoitaa oman leiviskänsä applikaation käyttöliittymän renderöimisestä ihmiskäyttäjän silmille sopivaksi.

Mutta kullakin näkymällä on ydintehtävän lisäksi myös oheistehtävä, joka on säätietojen renderöinti. Säätiedot ovat näkyvillä kaikilla applikaation alasivuilla, eli kaikki applikaation näkymät joutuvat ne renderöimään. Huomaamme tämän ylläolevissa kolmessa näkymäesimerkissä - kukin näkymä sisältää rivin:

->with('saatiedot', $saa)

On myös parempi tapa. Koska säätiedot on sisällytettynä jokaiseen applikaation alasivuun, voimme abstraktoida säätietojen hakeminen ns. näkymälaatijaan (engl. view composer).

Näkymälaatija on oma komponenttinsa, joka huolehtii tietyn datapaketin viemisestä eri näkymien saataville automatisoidusti ja keskitetysti. Toisin sanoen, datapakettia ei tarvitse enää määritellä näkymän saataville Controller-tiedoston sisällä, vaan tuo määrittely voidaan tehdä globaalisti näkymälaatijan sisällä.

Näkymälaatijasta tarkempi kuvaus (englanniksi): https://laravel.com/docs/5.1/views#view-composers.

Säätietojen hakeminen ja antaminen applikaation kaikkien näkymien saataville onnistuu näin:


// SaatiedotLaatijaProvider.php

class SaatiedotLaatijaProvider
{
  public function boot()
  {
    view()->composer('*', function($view) {
      $saatiedot = $this->getSaaTiedot();
      $view->with('saatiedot', $saatiedot);
    });
  }

  protected function getSaaTiedot() {
    /* Hanki säätiedot jotenkin */
  }
}


Määriteltyämme ylläolevan näkymälaatijan - tässä tapauksessa anonyymi funktio - huolehtimaan säätiedoista, voimme poistaa säätietojen hallinnasta vastaavan koodin kustakin Controllerista.


// KirjautumisTiedotController.php

public function index() {
  $viimeisinKirjautuminen = $this->getLoginInfo();
  return view('kirjautumistiedot')
    ->with('viimeisinKirjautuminen', $viimeisinKirjautuminen);
}


// VastaanotetutViestitController.php

public function index() {
  $viestit = $this->getMessagesReceived();
  return view('viestit_vastaanotettu')
    ->with('viestit', $viestit);
}


// LahetetytViestitController.php

public function index() {
  $viestit = $this->getMessagesSent();
  return view('viestit_lahetetty')
    ->with('viestit', $viestit);
}

Applikaation koodipohja selventyi huomattavasti - enää ei sama säätietojen hakeminen + tarjoaminen näkymän käyttöön elä kolmessa eri sijainnissa, vaan koko säätietoja hallinnoiva koodi elää yhdessä paikassa (laatijatiedoston sisällä).

Laravellin näkymälaatija on tehokas konsepti poistamaan tarpeetonta duplikaatiota. Mikäli tietty datapaketti on renderöitävä usealle applikaation alasivulle, on syytä harkita näkymälaatijan käyttöä.

Kuten aina, kyseessä on tradeoff. Näkymälaatijalla saadaan vähennettyä koodin duplikaatiota. Mutta huonona puolena on, että tietyn näkymätiedoston käytettävissä oleva data ei enää ole nähtävillä yhdessä koodilokaatiossa.

Ratkaisussa #1 kaikki näkymälle tarjottava data tuli Controller-tiedostosta. Ratkaisussa #2 osa datasta tulee Controllerista, toinen osa näkymälaatijalta.

Työkalupakin kätköistä - zip() ja unzip()

Tällä kertaa esittelen lyhyesti maanmainion Lodash-kirjaston apufunktiot zip ja unzip.

Kuten nimistä saattaa kyetä päättelemään, zip ja unzip tekevät päinvastaisia asioita. Ne ovat loogisesti toistensa käänteisfunktioita:

a === unzip(zip(a))

Käytännössä ylläoleva koodi ei toimi, sillä unzip palauttaa listan, mutta zip ei ota vastaan listaa. Teknisesti ne eivät ole täysin yksi yhteen toistensa käänteisoperaatioita, mutta loogisesti niitä voi ajatella toistensa käänteisfunktioina.

Mitä nuo funktiot saavat aikaan?

Zip

Zip-funktio ottaa vastaan kasan listoja, ja luo ryhmityksen kunkin listan n:nnelle jäsenelle. Huomioitavaa on, että zip ottaa listat vastaan yksitellen omina parametreinaan. Zip-operaation sisällä kaikki listojen ‘ännännet’ jäsenet ryhmitellään yhteen omaksi listakseen.

Tarve zip-funktion kaltaiselle apufunktiolle ilmenee erinomaisesti seuraavasta.

Esimerkki: meillä on kolme henkilöä, esim. työpaikan työntekijöitä. Kullakin työntekijällä on pituus ja paino. Työpaikan terveystutkimuksen osana tulee selvittää pituus- ja painojakaumat. Zip-funktio mahdollistaa tämän selvityksen luomisen vaivatta.


// Luodaan henkilöitä.
// Henkilö määritetään pituuden (cm) ja painon (kg) mukaan kahden elementin listana!
var matti = [168, 67];
var mikko = [179, 76];
var pirjo = [154, 51];

// Käytetään zip-apufunktiota, joka ryhmittelee pituudet ja painot erillisiin listoihin.
var jakaumat = _.zip(matti, mikko, pirjo);
var pituusjakauma = jakaumat[0];
var painojakauma = jakaumat[1];

Unzip

Siinä missä zip ryhmittelee kasan listoja kunkin listan n:nnen jäsenen mukaan, unzip ottaa vastaan ryhmitykset sisältävän listan ja uudelleenkokoaa alkuperäiset listat. Unzip on siis suoraan zip-funktion käänteisoperaatio.

Esimerkki: sääasemat ympäri Suomea mittaavat lämpötilan kerran päivässä, aina klo 18.00. Kerran viikossa kukin sääasema lähettää omat mittaustuloksensa keskuspalvelimelle. Keskuspalvelimen puolella meteorologi on kiinnostunut Suomen keskilämpötilasta kunakin viikonpäivänä. Unzip-operaatiolla tuo koko maan keskilämpötila on helppo selvittää kullekin viikonpäivälle:

// Yksittäisen aseman tulokset muotoa [ma,ti,ke,to,pe,la,su]
var mittaustulokset = [
  [12,16,16,14,18,12,12], // Muonio
  [16,16,17,17,15,16,19], // Kuopio
  [20,20,18,20,21,23,21], // Tampere
];

var lampotilatPaivittain = _.unzip(mittaustulokset);
// Käytetään _.mean-apufunktiota joka laskee listan jäsenten keskiarvon.
var keskiarvot = _.map(lampotilatPaivittain, _.mean);

// Keskiarvot sisältää nyt kunkin viikonpäivän keskilämpötilan Suomessa.
console.log(keskiarvot); // [16, 17.33, 17, ...]

Otetaan vielä vertailun vuoksi miltä lämpötilojen jaottelu päivälokeroihin näyttäisi ilman unzip-funktiota:

/////////////////
//  Ilman zip  //
/////////////////

// Luodaan lista joka sisältää oman keruulistan kullekin viikonpäivälle.
var lampotilatPaivittain = [[], [], [], [], [], [], []];

// Kaksi sisäkkäistä for-looppia, toinen luuppaa asemia, toinen viikonpäiviä.
for (var i = 0, j = mittaustulokset.length; i < j; i++) {
  for (var i2 = 0; i2 < 7; i2++) {
    lampotilatPaivittain[i2].push(mittaustulokset[i][i2]);    	
  }
}

////////////////////
//  Zipin kanssa  //
////////////////////

var lampotilatPaivittain = _.unzip(mittaustulokset);


Ero on - kuten niin kovin usein ohjelmoinnin piirissä - kuin yöllä ja päivällä.

Unzip- ja zip-funktiot ovat mukava pieni lisä ohjelmoijan työkalupakkiin. Vastaavan algoritmin kirjoittaminen käsin ei houkuta.

Neljä lähestymistapaa toiminnallisuuksien abstraktointiin

Aloitetaan heti esimerkillä. Tehtävämme on luoda pieni skripti, joka käy hakemassa dataa (listan numeroita) palvelimelta, käsittelee tuon datan ja näyttää käsittelyn tulokset ihmiskäyttäjälle.

Verrataan neljää eri ratkaisua, jotka kaikki toteuttavat em. vaatimuksen, mutta käyttävät erilaisia lähestymistapoja mitä tulee koodin strukturointiin.

(Käytän kielenä Javascriptiä, mutta artikkelin aihe ei ole Javascriptiin sidottu)

Ratkaisu #1:


$.ajax({
  url: 'serveri.php'
}).done(function(data) {
  // Käsittele data
  var summa = _.reduce(data, function(sum, datanum) {
    return sum + datanum;
  }, 0);

  // Näytetään summa käyttäjälle
  alert("Summa on " + summa);
});

Ratkaisu #2:


var vastaanotaData = function(data) {
  // Käsittele data
  var summa = _.reduce(data, function(sum, datanum) {
    return sum + datanum;
  }, 0);

  // Näytetään summa käyttäjälle
  alert("Summa on " + summa);
}

$.ajax({
  url: 'serveri.php'
}).done(vastaanotaData);

Ratkaisu #3:


var kasitteleData = function(data) {
  // Käsittele data
  return _.reduce(data, function(sum, datanum) {
    return sum + datanum;
  }, 0);
}

var naytaSumma = function(summa) {
  // Näytetään summa käyttäjälle
  alert("Summa on " + summa);
}

$.ajax({
  url: 'serveri.php'
}).done(function(data) {
  var summa = kasitteleData(data);
  naytaSumma(summa);
});

Ratkaisu #4:


var vastaanotaData = function(data) {
  var summa = kasitteleData(data);
  naytaSumma(summa);
}

var kasitteleData = function(data) {
  // Käsittele data
  return _.reduce(data, function(sum, datanum) {
    return sum + datanum;
  }, 0);
}

var naytaSumma = function(summa) {
  // Näytetään summa käyttäjälle
  alert("Summa on " + summa);
}

$.ajax({
  url: 'serveri.php'
}).done(vastaanotaData);

Neljä eri ratkaisua samaan ongelmaan - mikä on paras?

Lähdetään analysoimaan eri ratkaisuja.

Ratkaisu #1


$.ajax({
  url: 'serveri.php'
}).done(function(data) {
  // Käsittele data
  var summa = _.reduce(data, function(sum, datanum) {
    return sum + datanum;
  }, 0);

  // Näytetään summa käyttäjälle
  alert("Summa on " + summa);
});

Ensimmäisessä ratkaisussa kaikki koodi elää kivasti sisäkkäin ajax-kutsun sisällä. Datan vastaanotto, käsittely ja näyttäminen käyttäjälle ovat eroteltuna riveittäin, eivät funktioittain. Ratkaisu #1 edustaa alhaisinta abstraktion tasoa - eri toiminnallisuudet on kytketty suoraan toistensa perään ilman mahdollisuutta erotella niitä toisistaan.

Mutta miksi kukaan haluaisi erotella niitä toisistaan? Tämä kysymys on erittäin keskeisessä roolissa kaikessa ohjelmoinnissa. Yleensä eri loogiset toiminnot halutaan erotella toisistaan siksi, että yksittäisiä toimintoja voi uudelleenkäyttää muualla. Esimerkiksi datan käsittely on hyödyllinen konsepti ihan itsessään - se on siis hyödyllinen ilman, että käsiteltävä data tulee palvelimelta ja että käsitelty data näytetään käyttäjälle!

Tällä tavoin on loogista, että datan käsittelyä ei ole liitetty betonivalulla yhteen niiden toimintojen kanssa, jotka vastaavat vuoropuhelusta palvelimen ja näyttöruudun kanssa.

Ykkösratkaisuissa tämä yhteenliittymä on juurikin betoniin valettu. Eri toimintoja sisältävät koodirivit seuraavat toisiaan kiltisti peräkanaa.

Ratkaisun hyvä puoli on vähäinen koodimäärä. Merkittävin huono puoli on, että datan käsittely ja datan näyttäminen käyttäjälle on sidottuna teräslangalla yhteen - et voi käsitellä dataa ilman, että myös näyttäisit sen käyttäjälle.

Ratkaisu #2


var vastaanotaData = function(data) {
  // Käsittele data
  var summa = _.reduce(data, function(sum, datanum) {
    return sum + datanum;
  }, 0);

  // Näytetään summa käyttäjälle
  alert("Summa on " + summa);
}

$.ajax({
  url: 'serveri.php'
}).done(vastaanotaData);

Kakkosratkaisussa koodi selkenee hiukan. Erottelemme datan vastaanoton ja käsittelyn + näytön erilleen. Ajax-kutsulle tarjotaan palanpainikkeeksi vastaanotaData-niminen funktio, joka sisältää “käsittelykoodin” ja “näyttökoodin”.

Ratkaisu on hyvä alku - olemme saaneet alkuperäisestä pyhästä kolminaisuudesta (vastaanotto, käsittely, näyttö) yhden osan lohkaistua irralleen. Siirrytään eteenpäin.

Ratkaisu #3


var kasitteleData = function(data) {
  // Käsittele data
  return _.reduce(data, function(sum, datanum) {
    return sum + datanum;
  }, 0);
}

var naytaSumma = function(summa) {
	// Näytetään summa käyttäjälle
	alert("Summa on " + summa);
}

$.ajax({
  url: 'serveri.php'
}).done(function(data) {
	var summa = kasitteleData(data);
	naytaSumma(summa);
});

Kolmosratkaisussa kaksi jäljellejäänyttä yhteenliitettyä toimenpidettä (käsittely ja näyttö) erotetaan omiksi funktioikseen. Tällä tavoin kaikki kolme toisiinsa kahlittua toimintoa on saatu eroteltua erilleen.

Tämä erilleen erottelu on ohjelmoinnin keskiössä oleva konsepti. Kun erottelu tehdään funktioita käyttäen, sitä kutsutaan nimellä funktionaalinen abstraktio. Käytännössä se vain tarkoittaa, että tietty pala koodia irrotetaan erilleen ja paketoidaan pakettiin nimeltä ‘funktio’. Tuota funktiota voi käyttää eri puolilta applikaatiota uudestaan ja uudestaan.

Funktionaalinen abstraktio toimii siis näin:

// Ei abstraktoitu

var a = 2;
// Tehdään potenssiin korotus
var potenssiluku = 3;
var potenssilaskunTulos = 1;
for (var i = 0, i < 3; i++) {
  potenssilaskunTulos = potenssilaskunTulos * a;
}
// Potenssiin korotus valmis
console.log(potenssilaskunTulos); // 8

// Funktionaalinen abstraktio suoritettu!

// Luodaan funktio
function nostaPotenssiin(luku, potenssi) {
  var potenssilaskunTulos = 1;
  for (var i = 0, i < potenssi; i++) {
    potenssilaskunTulos = potenssilaskunTulos * luku;
  }
  return potenssilaskunTulos;
}

// Tehdään potenssiin korotus
var tulos = nostaPotenssiin(2, 3);
// Potenssiin korotus valmis
console.log(tulos); // 8

Ei sen kummempaa. Siirrytään seuraavaan ratkaisuun.

Ratkaisu #4


var vastaanotaData = function(data) {
  var summa = kasitteleData(data);
  naytaSumma(summa);
}

var kasitteleData = function(data) {
  // Käsittele data
  return _.reduce(data, function(sum, datanum) {
    return sum + datanum;
  }, 0);
}

var naytaSumma = function(summa) {
  // Näytetään summa käyttäjälle
  alert("Summa on " + summa);
}

$.ajax({
  url: 'serveri.php'
}).done(vastaanotaData);

Ratkaisussa numero 4 viemme funktionaalisen abstraktion vielä yhden askeleen pidemmälle. Saimme jo kolmosratkaisussa eroteltua erilleen alkuperäiset kolme toimintoa - vastaanoton, käsittelyn, ja näytön. Nelosratkaisun ero kolmoseen verrattuna on, että ajax-kutsun kyytipojaksi annettu done-metodin callback on erikseen nimetty ja määritelty funktio. Sen nimi on vastaanotaData.

Vastaavan funktion käyttä toi lisäarvoa ratkaisussa #2, joten takuulla se auttaa myös nyt, vai mitä?

Ei välttämättä.

Tässä kohtaa on hyvä huomata, että ratkaisussa #3 ei ollut funktiota nimeltä vastaanotaData. Se, että sen nimistä funktiota ei ole, ei tarkoita, etteikö toimintoa olisi olemassa. Toiminto oli olemassa jo ratkaisussa #3 - se vain sattui olemaan anonyyminä funktiona. Verrataan:


// Ratkaisu 3 - vastaanotto anonyyminä funktiona

.done(function(data) {
  var summa = kasitteleData(data);
  naytaSumma(summa);
});

// Ratkaisu 4 - vastaanotto erikseen nimettynä funktiona

.done(vastaanotaData);

Nyt kysymys kuuluukin, onko ratkaisu #3 automaattisesti huonompi kuin ratkaisu #4?

Juuri aiemmin mainitsin, että funktionaalinen abstraktio on ohjelmoinnin keskiössä, ja erittäin tärkeä työkalu. Voiko tästä johtaa, että pienikin lisäpotku funktionaalista abstraktiota poikkeuksetta parantaa koodin laatua?

Ei.

Saimme jo ratkaisussa #3 abstraktoitua kaiken sen mitä halusimmekin - eli vastaanoton, käsittelyn ja lopputuloksen näyttämisen ihmiskäyttäjälle.

Ylimääräinen abstraktio tuon kolmosratkaisun päälle ei enää paranna koodia - se saattaa jopa huonontaa sitä. Tässä tapauksessa muutos on lähinnä neutraali.

Huomioitavaa on, että tarjoamme Ajax-kutsun done-metodille kyytipojaksi funktion, joka vastaanottaa palvelimelta tuodun datan. Tuo funktio siis vastaanottaa datan riippumatta sen nimestä tai siitä onko sillä nimeä lainkaan.

Toinen huomiotava asia on, että tuo vastaanotaData-funktio ei tee mitään varsinaista työtä. Se ainoastaan koordinoi kahta kutsua muihin funktioihin. Nuo muut funktiot tekevät ns. oikeaa työtä. Koska vastaanotaData-funktio on ikäänkuin esimies, ei sen abstraktoinnista saa yhtä suurta hyötyä kuin työmiehen toiminnan abstraktoinnista.

Funktionaalinen abstraktio - eli “koodirivien paketointi funktion sisälle” - on äärimmäisen tärkeä työkalu ohjelmoijan arsenaalissa.

Mutta abstraktionkin voi viedä liian pitkälle. Abstraktio toimii vain siihen pisteeseen saakka, jossa viimeinenkin looginen toiminto on eroteltuna omaksi paketikseen. Tämän jälkeen abstraktion lisääminen tuppaa vain sotkemaan koodia.

Rekursiivinen lupausketju ajurina? (osa 1)

Olen epäilemättä varsin ihastunut lupauksiin (Promise). Tässä blogissa on blogin ensimmäisen kuukauden aikana julkaistu neljä kirjoitusta, joiden keskiössä toimii lupausten käyttö. Ja tässä on viides.

Tänään mieltäni askarrutti seuraava lupausten hyödyntämiseen liittyvä ajatus:

Entä jos rakentaisi lupausten varaan yleismaailmallisen “task-runnerin”, johon kytkeä varsinaiset ominaisuudet service provider-tyyliin.

Service Provider on itselleni Laravellin puolelta tutuksi tullut termi. Se tarkoittaa ohjelmakomponenttia, joka ohjelman suorituksen alkuvaiheessa lisää jonkin palvelun osaksi (ohjelma)kokonaisuutta.

Jos itse ohjelmisto on F1-auto, Service Provider on varikkomekanikko, joka ruuvaa kiinni sivupeilit (= lisäominaisuus) osaksi auton runkoa.

Pakko myöntää, etten itsekään ole täysin kärryillä mitä ajan tällä konseptilla takaa. Mutta jotain sen suuntaista, että haluaisin rakentaa lupausten varaan uuden ohjelmistokehyksen. Tuo kehys olisi suunnattu hyvin spesifiin käyttötarkoitukseen; vuoropohjaisten moninpelien ohjelmointiin.

Voiko rekursiivinen lupausketju toimia ajurina?

Kaikkein yleisimmässä muodossaan lupausketju toimii siten, että ketjun osanen suoritetaan heti kun edellisen osanen on saanut oman työnsä päätökseen. Ketju etenee siis yksi osasuoritus kerrallaan järjestyksessä.

Myös kaikki vuoropohjaiset pelit etenevät järjestyksessä; ensin on pelaajan #1 vuoro, sitten pelaajan #2, sitten pelaajan #3, jne. Kun kierros käyty läpi, vuoro siirtyy takaisin pelaajalle #1.

Esimerkkejä vuoropohjaisten pelien siirtojärjestyksestä:

Monopoli (3 pelaajaa): p1->p2->p3->p1->p2->p3->p1…

Shakki (kaksinpeli): p1->p2->p1->p2->p1…

Monopoli, pokeri, shakki, snooker, curling, laivanupotus… vuoropohjaisia pelejä on paljon ja todella monenlaisia. Katsotaan esimerkillinen lupauksiin perustuva ajuri, joka suorittaa yhden vuorokierroksen (= kaikki pelaajat tekevät yhden siirron). Käytetään esimerkkinä kolmen pelaajan Monopoli-peliä:


var Promise = require('bluebird');

// Vuorosimulaattori.js

// PeliLoppui-exception
function PeliLoppui() {};
PeliLoppui.prototype = new Error;

// Pelin update-metodi, jolla peliä viedään eteenpäin
function toteutaSiirto(pelaajaNimi, siirto) {
	// Tee siirto esim. shakkilaudalla.
}

// Apufunktio nopan heittämiseen, arpoo kaksi lukua 1-6.
function heitaNoppaa() {
	// [nopan silmäluku, toisen nopan silmäluku]
	return [Math.ceil(Math.random()*6), Math.ceil(Math.random()*6)];
}


// Pelaajan #1 siirtovuoro
function p1Siirto() {
	return new Promise(function(resolve, reject) {
		// Heitä noppaa
		var nopat = heitaNoppaa();
		// Tee siirto
		toteutaSiirto('p1', nopat);
		// Päätä vuoro täyttämällä lupaus.
		resolve();
	});
}

// Pelaajan #2 siirtovuoro
function p2Siirto() {
	// Vastaava kuin p1, mutta annetaan vuoro kakkospelaajalle.
}

// Pelaajan #3 siirtovuoro
function p3Siirto() {
	// Vastaava kuin p2, mutta annetaan vuoro kolmospelaajalle.
}

function aloitaVuorokierros(pelaajat) {
	// Kunkin pelaajan siirtofunktio on elementtinä *pelaajat*-listassa.
	// Kutakin funktiota kutsutaan järjestyksessä vuorotellen.
	
	// Promise.each-metodi käy pelaajat yksi kerrallaan läpi, antaen
	// siirtovuoron kullekin pelaajalle kertaalleen.

	Promise.each(pelaajat, function(annaVuoroPelaajalle) {
		// Muuttuja *annaVuoroPelaajalle* on funktio.
		// Se on joko *p1Siirto*, *p2Siirto* tai *p3Siirto*!
		return annaVuoroPelaajalle();
	})
	.then(function() {
		// Siirry seuraavalle kierrokselle!
		// HUOM! Ikuinen rekursio.
		// Ilman virhettä peli ei lopu koskaan.
		aloitaVuorokierros(pelaajat);
	})
	.catch(function() {
		// Pelissä tapahtui virhe, lopeta peli.
		// Peli lopetetaan heittämällä 'PeliLoppui',
		// joka napataan kiinni ylempänä call stäkissä.
		throw new PeliLoppui();
	})

}
// Luo kolme pelaajaa
var pelaajat = [p1Siirto, p2Siirto, p3Siirto];
// Aloita peli, johon nuo kolme pelaajaa osallistuvat.
aloitaVuorokierros(pelaajat)
.catch(PeliLoppui, function() {
	// Tässä on hyvä paikka kerätä roskat yms.
	// Tai esim. tallettaa pelin lopputulokset tietokantaan!
	console.log("Peli on loppunut, kiitos pelaajille.")
})

Ylläoleva koodi pyörii ikuista looppia aloitaVuorokierros-funktion ympärillä. Tällä tavoin se pystyy simuloimaan esimerkiksi Monopoli-peliä, joka ei pääty koskaan. Huomattavaa on, että koska tuo luuppi pyörii asynkronoidusti, on p1Siirto-funktion sisällä mahdollista kysyä ihmispelaajalta hänen siirtoaan.

Eli ihmispelaajalle voidaan p1Siirto-funktion sisältä käsin avata vaikka popup-ikkuna selaimessa, ja tuo popup-ikkuna tarjoaa ihmispelaajalle mahdollisuuden päättää siirrostaan. Kun pelaaja klikkaa popup-ikkunasta haluamaansa siirtoa, tieto välittyy palvelimelle, ja pelaajan siirtovuoro päättyy.

Tässä nopea naivi toteutus edellämainitusta:


// Pelaajan #1 TCP-socket tjms. viestintäväylä
// Se miten tämä socket on luotu on tekninen sivuseikka,
// jonka vastuu jätettäköön *socket.io*:n kaltaiselle kirjastolle.
var p1socket = /* luo socket jotenkin */

function p1Siirto() {
	return new Promise(function(resolve, reject) {
		// Lähetä ihmispelaajalle tieto siitä, että
		// nyt on hänen siirtovuoronsa.
		p1socket.send('Sinun siirtovuorosi - tee siirto.');

		// Tärkeää!
		// Jää kuuntelemaan ihmispelaajan vastausta!
		// Ohjaa saatu vastaus suoraan lupauksen täyttävään
		// resolve-funktioon!
		p1socket.on('siirto', resolve);

	}

}

// p2Siirto ja p3Siirto vastaavanlaiset...

Erittäin kaunista. Kunkin pelaajan siirtofunktio vie tiedon ihmispelaajalle, ja jää odottamaan ihmispelaajan vastausta. Kun vastaus saapuu, aiemmin luotu lupaus täytetään ja vuorokierros pyörähtää yhden pykälän eteenpäin.

Ylläoleva algoritmi on toki naurettavan naivi siinä mielessä, että se ei ota juuri mitään erikoistilanteita tai sivuehtoja huomioon. Esimerkiksi siirtovuorolla ei ole aikarajaa - eli pillastunut pelaaja voi kieltäytyä tekemästä siirtoa lainkaan ja tällä tavoin koko peli jää jumiin.

Palataan aikarajaan ja muihin ongelmiin seuraavassa postauksessa. Samalla pääsemme näkemään josko Promise.race-metodista olisi johonkin…

Chain() -metodi ketjuttaa funktiokutsut

Lodash on varsin hieno apukirjasto Javascriptin ohjelmointiin. Tuo kirjasto sisältää sadoittain pieniä apufunktioita, joiden avulla yleisimmät algoritmit voi toteuttaa nopeasti ja kivuttomasti.

Esimerkkinä vaikka algoritmi listan jakamisesta osiin. Ilman lodashia algoritmi näyttää tältä.


var lista = [1,2,3,4,5,6,7,8];
var jakoluku = 3;

// Jaetaan lista osiin (jokainen osa on uusi *lista*) siten, että 
// kukin osa sisältää *jakoluvun* verran elementtejä.

var jaettulista = [];

for (var i = 0, j = lista.length; i < j; i++) {
  var elementti = lista[i];

  // Jos i on tasajaollinen jakoluvulla,
  // on aika aloittaa uusi osalista.
  if (i % jakoluku === 0) {
    // i on joko 0, 3, 6, 9, ...jne.
    // Luodaan uusi osalista ja lisätään elementti siihen
    jaettulista.push([elementti]);
  }  

  // Jos ei ole tasajaollinen,
  // lisätään elementti tuoreimpaan osalistaan.
  else {
    // Lisätään elementti olemassaolevaan osalistaan
    jaettulista[jaettulista.length-1].push(elementti);
  }	
} 

console.log(jaettulista); // [[1,2,3], [4,5,6], [7,8]]

Saman saa aikaan Lodashin .chunk() metodilla näin:

var lista = [1,2,3,4,5,6,7,8];
var jakoluku = 3;
var jaettulista = _.chunk(lista, jakoluku);
console.log(jaettulista); // [[1,2,3], [4,5,6], [7,8]]

Ero on kuin yöllä ja päivällä.

Niin hieno kuin lodash onkin, siinä on puutteensa. Tai näin minä luulin vähintään vuoden päivät. Kunnes hoksasin dokumentaatiota lukemalla, että puute olikin vain illuusio. Löysin metodin nimeltä chain.

Chain() - mihin sitä tarvitaan?

Kuvitellaanpa seuraavanlainen korkean tason algoritmi:

Alkuasetelma: meillä on lista desimaalilukuja

Algoritmi:

  1. Pyöristä luvut tasaluvuiksi.

  2. Poista kaikki nollat.

  3. Kerro luvut yhteen.

Ylläoleva algoritmi näyttää lodashin avulla naivisti toteutettuna seuraavanlaiselta:


var lista = [1.9, 2.0, 2.1, 0.2];

var pyoristetyt = _.map(lista, Math.round);
var nollatPois  = _.compact(pyoristetyt);
var tulo = _.reduce(nollatPois, function(t, luku) {
  return t * luku;
}, 1);

// Välivaiheiden tulokset
console.log(pyoristetyt); // [2,2,2,0]
console.log(nollatPois); // [2,2,2]
// Lopullinen tulos eli lukujen tulo
console.log(tulo); // 8

Ylläoleva on ihan kiva, mutta huomion arvoista on, että joudumme käyttämään paljon väliaikaisia muuttujia. Välivaiheiden muuttujat pyoristetyt ja nollatPois ovat tälläisiä - algoritmi tallentaa niihin välitulokset, mutta loppukäyttäjä on kiinnostunut vain tulo-muuttujasta.

Yksi ratkaisu on jättää välimuuttujat pois:


var lista = [1.9, 2.0, 2.1, 0.2];

var tulo = _.reduce(_.compact(_.map(lista, Math.round)), function(t, luku) {
  return t * luku;
}, 1);

console.log(tulo); // 8

Ylläoleva lyhempää koodirivien määrää huomattavasti, mutta vaikeuttaa koodinlukua. Se näyttää rumalta, ja on vaikea pysyä silmämääräisesti kärryillä siitä, mitkä sulkumerkit muodostavat parin.

Eli trade-off; koodin rivimäärä pieneni, mutta koodinluku vaikeutui merkittävästi.

Mutta meillä on parempikin ratkaisu. Käytetään chain-apumetodia.

Chain() - the best of both worlds

Tässä on chainin varaan tukeutuva ratkaisu:


var lista = [1.9, 2.0, 2.1, 0.2];

var tulo = _
.chain(lista)
.map(Math.round)
.compact()
.reduce(function(t, luku) {
  return t * luku;
}, 1)
.value();

console.log(tulo); // 8

Ylläoleva chain-metodiin perustuva ratkaisu vaikuttaa selkeältä voitolta. Se on äärimmäisen helppolukuinen, sillä jokainen uusi metodikutsu alkaa omalta riviltään. Samaan aikaan välimuuttujia ei tarvita! Eli win-win.

Miten chain() toimii pinnan alla? Se muuntaa annetun argumentin (tässä lista) sellaiseen muotoon, että sitä voidaan juoksuttaa pitkin ketjua. Sillä chain()-metodi aloittama metodikutsujen sarja voidaan ajatella ketjuna, tai putkena. Tai liukuhihnana. Kukin metodi saa sisäänsä argumentin, muokkaa tuota argumenttia jotenkin, ja pötkäyttää ulos muokatun version argumentista. Seuraava putkenpalanen saa sisälleen tuon muokatun version, ja niin edelleen.

Putken/liukuhihnan loppupäädyssä kutsumme metodia value(), joka hakee lopullisen palautusarvon.

Kyseessä on erittäin vahva ja ennenkaikkea modulaarinen koodaustapa. Ketjuta funktiokutsut ja juoksuta haluamasi dataa ketjun lävitse. Yhdestä päästä menee raaka-aineet sisään, toisesta päästä tulee valmis tuote ulos.

Käyttöliittymän testaus (automatisointi)

Perinteisen web-applikaation peruspointti on tarjota käyttäjilleen mahdollisuus vuorovaikuttaa itsensä (esim. kerätä tänään dataa talteen, ja hyödyntää kerättyä dataa huomenna) ja/tai toistensa kanssa web-applikaation kautta.

Jotta tämä vuorovaikutus onnistuisi, täytyy web-applikaation tarjota jonkinmoinen käyttöliittymä.

Tyypillisessä tietokantapohjaisessa web-sovelluksesta tuo käyttöliittymä on HTML-sivu, joka sisältää linkit applikaation tarjoamiin toiminnallisuuksiin. Linkkejä klikkailemalla voi vuorovaikuttaa applikaation kanssa. Malliesimerkki tälläisestä applikaatiosta on vaikkapa Wikipedia.

Vuorovaikutus ihmiskäyttäjän kanssa on monen web-sovelluksen keskeisin huolenaihe. Toki on erikseen web-sovellukset, jotka eivät vuorovaikuta ihmiskäyttäjän kanssa, vaan käyvät tiedonvaihtoa toisen web-sovelluksen kanssa. Malliesimerkki tälläisestä applikaatiosta on osakepörssin rajapinta. Tuo rajapinta käy keskustelua muiden applikaatioiden - mm. uutissivustojen pörssikurssien päivityksestä vastaavien ohjelmien - kanssa.

Voiko tietokoneohjelma simuloida ihmiskäyttäjää?

Koska useimmilla applikaatioilla kommunikaatio ihmiskäyttäjän kanssa on keskiössä, on syytä kyetä varmistamaan, että käyttöliittymä toimii kuin vettä vain. Laravellin tapauksessa tämä varmistus tarkoittaa, että kukin HTML-sivu - joka siis edustaa tiettyä käyttöliittymän osaa - sisältää tarvittavat toiminnot, jotta applikaation käyttö on sujuvaa.

Mutta miten varmistua siitä, että käyttöliittymä tarjoaa tarvittavat toiminnot? Yksi tapa on silmämääräisesti selata käyttöliittymää. Ihmisaivot tekevät automaattisesti näin saapuessaan esim. Wikipedian etusivulle - luomme ikäänkuin mentaalisen kartan kaikista applikaation tarjoamista mahdollisuuksista.

Homman voi tietenkin myös automatisoida, ja se kannattaa automatisoida. Sen sijaan että silmämääräisesti tarkistaisimme käyttöliittymän, annetaan erillisen tietokoneohjelman tarkistaa käyttöliittymä.

Tätä on automatisoitu käyttöliittymätestaus.

Mitä testataan ja miten?

Käyttöliittymätestauksessa pääpaino on varmistaa, että applikaation käyttöliittymä tarjoaa tarvittavat toiminnot, jotta applikaation käyttö on sujuvaa.

Tyypillisen web-applikaation käyttöliittymä koostuu isosta kasasta HTML-sivuja. Täten web-applikaation kohdalla käyttöliittymätestaus tarkoittaa kutakuinkin HTML-sivujen sisällön testausta. Eli varmistetaan, että kukin HTML-sivu sisältää tarvittavat toiminnot, tiedot ja ohjeet, jotta vuorovaikutus applikaation kanssa onnistuu odotetusti.

Kirjoitetaan ensimmäinen testi. Oletetaan, että olemme rakentamassa uutta Wikipediaa. Wikipedian keskiössä on artikkeli, joten on luontevaa aloittaa ohjelmoimalla tarvittavat toiminnot yksittäisen artikkelin lukemista ja ylläpitoa varten.

Mitä toimintoja haluamme kytkeä osaksi konseptia nimeltä artikkeli? Ainakin mahdollisuuden lukea artikkeli. Lisäksi olisi kiva voida muokata artikkelia. Aloitetaan näistä kahdesta.

Entä millainen käyttöliittymän tulee olla, jotta lukeminen ja muokkaaminen onnistuvat?

Lukemista varten tarvitsemme jotain mitä lukea. Eli artikkelin sisällön tulee olla ihmissilmin nähtävillä.

Muokkausta varten tarvitsemme jonkinlaisen linkin tai nappulan, jonka kautta siirtyä artikkelin muokkaustilaan.

Testimme näyttää yleisilmeeltään tältä.


use \Integrated\Extensions\Laravel as IntegrationTest;

class ArtikkeliTesti extends IntegrationTest {

	public function testaaLukuMahdollisuus {

	}

	public function testaaMuokkausMahdollisuus {


	}
	

}

Seuraavaksi on syytä miettiä, miten nuo testit suoritetaan. Käyttöliittymätestauksen koko pointti on, että testaus suoritetaan ikäänkuin ihmiskäyttäjä toimisi testaajana. Oikeasti tuon testauksen tekee tietokoneohjelma, mutta tietokoneohjelma simuloi ihmisen toimintaa.

Paras tapa suorittaa käyttöliittymätestaus on siis toistaa niitä toimintoja, joita oikea ihmiskäyttäjä tekisi mikäli käyttäisi applikaatiota.


// ArtikkeliTesti.php

use \Integrated\Extensions\Laravel as IntegrationTest;

class ArtikkeliTesti extends IntegrationTest {

	public function testaaLukuMahdollisuus {
			// Simuloidaan ihmiskäyttäjää
			

			// Kirjoita selaimen osoiteriville tietyn artikkelin www-osoite
			$this->visit('/artikkelit/seppo-raty');

			// Nyt edessämme pitäisi olla Seppo Rädystä kertova artikkeli
			$this->seePageIs('seppo-raty');

			// Artikkelin tulisi mainita hänen urheilulajinsa...
			$this->see('keihäänheitto');

			// ... ja muutama kuolematon sitaatti
			$this->see('Saksa on paska maa');
			$this->see('Vittuillakseni heilutin');

			// Jos kaikki ylläolevat ehdot täyttyvät, voimme
			// luottaa, että kyseessä on Rädyn wikipedia-artikkeli.

	}

	public function testaaMuokkausMahdollisuus {
			// Simuloidaan ihmiskäyttäjää
			
			// Kirjoita selaimen osoiteriville tietyn artikkelin www-osoite
			$this->visit('/artikkelit/seppo-raty');

			// Emme ole kiinnostuneita artikkelin sisällöstä, mutta
			// olemme kiinnostuneita muokkausmahdollisuudesta.

			// Varmistetaan, että "Muokkaa"-nappula on olemassa, ja että
			// sitä klikkaamalla avautuu muokkausnäkymä!
			$this->click('Muokkaa')->seePageIs('/seppo-raty/muokkaa');

			// Jos ylläolevat ehdot täyttyvät, voimme luottaa,
			// että muokkaustoiminto on olemassa.

	}
	

}

Ylläolevat kaksi testiä - luku ja muokkaus - voidaan suorittaa automatisoidusti. Ihmiskäyttäjää ei tarvita. Testiä varten luotu tietokoneohjelma ajaa ylläolevat testit, ja varmistaa, että kaikki oletukset/ehdot täyttyvät. Mikäli jokin ehto ei täyty, asiasta raportoidaan eteenpäin (esim. kehittäjälle).

Ylläolevan kaltaisilla yksittäisillä testeillä voimme varmistaa pala palalta koko käyttöliittymän toiminnan. Entä jos haluamme testata toiminnon uuden artikkelin luonti? Se onnistuu näin.


// ArtikkeliTesti.php

// Muut testit kuten ennenkin

public function testaaArtikkelinLuonti() {
	
	// Artikkelin luontia varten käyttäjälle näytetään
	// HTML-lomake, johon artikkelin tiedot täytetään.

	$this->visit('/luo-artikkeli')->andSee('artikkeliluonti');

	// Varmista, että HTML-lomake on olemassa yrittämällä täyttää se...
	$this
	->type('Nollaversio IT', '#artikkelin_nimi') // Kirjoita nimi
	->type('Ihan ok firma.', '#artikkelin_teksti') // Kirjoita sisältö
	->press('Luo artikkeli'); // Paina "Submit"-nappulaa
	->andSee('Uusi artikkeli luotu!') // Varmista luonnin onnistuminen.
	// Varmista että olemme juuri luodun artikkelin sivulla.
	->onPage('/artikkelit/nollaversio-it'); 

}

Ylläolevan kaltaisilla testeillä voimme testata ilman epäluotettava ihmissilmän tarvetta koko käyttöliittymämme!

Loppukaneetti: on syytä huomata, että käyttöliittymätestaus keskittyy olennaisten seikkojen testaamiseen. Se ei testaa sitä, onko sivun värimaailma ‘ihmissilmää miellyttävä’, onko fonttikoko sopiva tai ovatko sivun eri komponentit nätisti rivissä.

Automatisoitu testaus keskittyy testaamaan aspekteja, jotka ovat a) ylipäätänsä testattavissa ja b) elintärkeitä applikaation toiminnan kannalta.

Värimaailma ei ole elintärkeä applikaation toiminnan kannalta, ainakaan Wikipedian tapauksessa. Sen sijaan mahdollisuus muokata artikkelia on elintärkeä applikaation toiminnan kannalta.

Jokaisella applikaatiolla on tietenkin omat reunaehtonsa sen suhteen, mitkä aspektit ovat tärkeitä ja mitkä eivät.

Varausten hallinta tietokannan tasolla

Otetaan esimerkki seuraavankaltaisesta applikaatiosta. Applikaatio mahdollistaa uhanalaisten sarvikuonojen ostamisen lemmikeiksi. Afrikan salametsästäjät (tai tässä tapauksessa ‘salakidnappaajat’) tuovat järjestelmään uusia sarvikuonoja, joita eurooppalaiset intoilijat ostavat.

Ostoprosessi ei kuitenkaan ole yksinkertainen. Kukin sarvikuono varataan ostoprosessin ajaksi - mikäli ostoprosessi menee onnistuneesti läpi, sarvikuonopolo rahdataan Eurooppaan uudelle isännälleen. Mikäli ostoprosessi ei mene lävitse, sarvikuono vapautuu takaisin markkinapaikalle.

Tietokanta voisi olla esim. tämän kaltainen:

// Sarvikuonot-taulu

| id |   ostaja  | hinta | tullut_myyntiin |
| -- | --------- | ----- | --------------- |
| 1  |   NULL    |  25   |   1.6.2016      |
| 2  |   NULL    |  32   |   3.6.2016      |
| 3  |   2       |  26   |   4.6.2016      |

// Ostajat-taulu

| id |   nimi    |  maa  |   email         |
| -- | --------- | ----- | --------------- |
| 1  |   Pekka   |  FI   |   pekka@24.fi   |
| 2  |   Mikko   |  FI   |   m85@gmail.com |

Järjestelmä toimii ylläolevia tauluja hyödyntäen. Ostaja kirjautuu sarvikuonojen markkinapaikalle - se miten tuo kirjautuminen tapahtuu ei ole tässä esimerkissä oleellista. Sen jälkeen hän selaa ostettavissa olevia sarvikuonoja. Sarvikuonot-taulusta saadaan helposti haettu vapaana (ostomielessä) olevat kuonokkaat - vapaalla sarvikuonolla ostaja-sarake on tyhjä (NULL).

SELECT * from Sarvikuonot WHERE ostaja=NULL

Mutta kuten alussa mainitsimme, haluamme myös sallia sarvikuonon varauksen itse ostoprosessin ajaksi.

Miksi tämä on tärkeää? Siksi, että muuten saattaisi hyvinkin käydä niin, että useampi henkilö yrittäisi samanaikaisesti ostaa samaa kuonokasta.

Ongelman ydin on siinä, että sarvikuonon ostaja-sarake päivitetään vasta aivan ostoprosessin lopussa. Tämä on loogista siinä mielessä, että ostos ei ole vahvistettu kuin vasta prosessin lopussa.

Mutta järjestelmän muiden asiakkaiden tämä ei ole optimaalista. On varsin ikävää jos joku heistä aloittaa oman ostoprosessinsa sarvikuonosta, jota sinä olet parhaillaan maksamassa Nordean nettipankissa. Kun maksusi menee läpi, tuo toinen asiakas on umpikujassa.

Hänen kannaltaan on varsin ikävää, mikäli ostos epäonnistuu aivan kalkkiviivoilla. Vähemmästäkin ihminen repii juurikasvunsa.

Ongelman ydin siis on, että ostoprosessilla on alku ja loppu. Mikäli ostoprosessi olisi pistemäinen tapahtuma, mitään ongelmaa ei olisi. Varaus ja osto tapahtuisivat tismalleen samalla ajan hetkellä, joten tarve varauksen olemassaololle poistuisi.

Esimerkkinä tälläisestä pistetapahtumasta on ruokakaupassa käynti. Sanotaan, että maitohyllyllä on tasan yksi maitopurkki. Kauppaan saapuu kaksi perhekuntaa maito-ostoksille.

Kumpi poppoo tuon maitopurtilon saa mukaansa? Kumpi ensimmäisenä sen hyllyltä nappaa. Voittaja vie maidon. Seuraava käsi hapuilee pelkkää tyhjää ilmaa. Tyhjyyttä kohti kurotteleva kyllä varsin nopeasti hoksaa, että maito meni jo, joten hänen ei tarvitse jatkaa ostoprosessiaan eteenpäin. Ainoastaan voittaja kävelee kohti kassapistettä.

Ratkaiskaamme sarvikuonojen varaus vs. osto -ongelma lisäämällä erillinen varaus-sarake tietokantatauluun.

Varaus ja osto eriteltynä tietokannassa

Uusi Sarvikuonot-taulu näyttää tältä:

// Sarvikuonot-taulu

| id |   ostaja  | varaaja | hinta | tullut_myyntiin |
| -- | --------- | ------- | ----- | --------------- |
| 1  |   NULL    |  NULL   |   25  |   1.6.2016      |
| 2  |   NULL    |  1      |   32  |   3.6.2016      |
| 3  |   2       |  2      |   26  |   4.6.2016      |

Järjestelmä toimii seuraavanlaisesti: heti kun potentiaalinen kuonoaddikti aloittaa ostoprosessin, hänen ostajanumeronsa lisätään sarvikuonon varaaja-kenttään.

Tällä tavoin taulu sisältää tiedon siitä, että kyseistä sarvikuonoa ollaan parhaillaan ostamassa. Muille asiakkaille tuota sarvikasta ei tarvitse näyttää listauksissa - heidän kannaltaan sarvikuono on jo myyty. Täten ostettavissa olevat sarvikuonot haetaan tietokantakomennolla:

SELECT * from Sarvikuonot WHERE varaaja=NULL

Kun varausta tekevä taho sitten lopulta vahvistaa kuonokkaan oston, tieto vahvistuksesta päivitetään tauluun ostaja-sarakkeeseen.

// Sarvikuonot-taulu
// Sarvikuonon #2 osto vahvistettu ostajalle #1.

| id |   ostaja  | varaaja | hinta | tullut_myyntiin |
| -- | --------- | ------- | ----- | --------------- |
| 1  |   NULL    |  NULL   |   25  |   1.6.2016      |
| 2  |   1       |  1      |   32  |   3.6.2016      |
| 3  |   2       |  2      |   26  |   4.6.2016      |

Kaksi eri asiakasta eivät voi enää aloittaa ostoprosessia samasta sarvikuonosta samanaikaisesti. Erinomaista. Onko järjestelmämme nyt täydellinen?

Ei todellakaan.

Entä jos ostoprosessi ei menekään läpi? Koska varaaja-kenttä on jo täytetty, sarvikuono on muiden asiakkaiden näkökulmasta ostettu. Mutta jos ostoprosessi menee pieleen (ehkä ostaja tulee katumapäälle kesken maksamisen), tuo sarvikuono on ikuisesti jumissa limbossa.

Tarvitsemme siis mekanismin, joka jollain tavoin vapauttaa limboon joutuneet sarvikuonot. Mekanismiksi on kaksi hyvää vaihtoehtoa.

Varausten vapautus - aktiivinen vs. passiivinen

Kerrataan, tietokantataulumme näyttää tältä:

// Sarvikuonot-taulu

| id |   ostaja  | varaaja | hinta | tullut_myyntiin |
| -- | --------- | ------- | ----- | --------------- |
| 1  |   NULL    |  NULL   |   25  |   1.6.2016      |
| 2  |   NULL    |  1      |   32  |   3.6.2016      |
| 3  |   2       |  2      |   26  |   4.6.2016      |

Ja potentiaalinen ostajamme #1 yllättäen saa aivoinfraktin ja poistuu linjoilta. Hän ei tule enää koskaan ostamaan edes tikkukaramellia saati savannin hyökkäysvaunua. Joten tehtävämme on jollain tavoin poistaa varaus sarvikuonolta #2.

Aktiivinen poisto

Yksi tapa hoitaa poistot on pitää yllä erillistä poisto-ohjelmaa, joka tasaisin väliajoin käy etsimässä + poistamassa erääntyneitä varauksia.

Vastaavan kaltainen systeemi on käytössä hotelleissa - jos et ole viimeistään klo 18 vastaanottamassa huoneesi avainta, varauksesi poistetaan asiakaspalvelijan toimesta.

Meidän sarvikuonomarkkinapaikkamme kohdalla loogisinta on kirjata ylös ajankohta, jolloin ostoprosessi alkoi. Vaadimme ostajilta, että heidän tulee suorittaa ostoprosessinsa läpi yhden tunnin aikana. Jos ostoprosessi on epäonnistunut (tai yhä kesken!) tuon yhden tunnin rajapyykin umpeuduttua, varaus poistetaan.

Muokataan tauluamme, jotta saamme kirjattua ylös varauksen tekoajankohdan:

// Sarvikuonot-taulu

| id |   ostaja  | varaaja | hinta | tullut_myyntiin |   varaus_tehty    |
| -- | --------- | ------- | ----- | --------------- | ----------------- |
| 1  |   NULL    |  NULL   |   25  |   1.6.2016      |       NULL        |
| 2  |   NULL    |  1      |   32  |   3.6.2016      | 26.7.16 klo 12.15 |
| 3  |   2       |  2      |   26  |   4.6.2016      | 21.7.16 klo 17.33 |

Ohessa pieni skripti, joka pyörii ikäänkuin taustapalveluna, käyden tasaisin väliajoin poistamassa erääntyneet varaukset:


// Tätä skriptiä kutsutaan esim. käyttöjärjestelmän cron-tabin toimesta.
// Esimerkiksi aina 1 minuutin välein.

// Aloita tietokanta-transaktio
DB::transaction(function() {
	// Rajapyykkinä toimii ajankohta yksi tunti sitten.
	$aikaRajapyykki = Carbon::now()->subHour();

	// Sarvikuonot, jotka ovat erääntyneet, 
	// mutta ei ostettu ('ostaja' on NULL),
	// tyhjennetään varaustiedot
	Sarvikuono
		::where('varaus_tehty', <, $aikaRajapyykki)
		->where('ostaja', NULL)
		->update([
			'varaaja' => NULL,
			'varaus_tehty' => NULL
		]);
});

Kun ylläoleva skripti on käynyt poistamassa varauksen tiedot, on sarvikuono #2 jälleen muiden markkinapaikan kävijöiden nähtävissä.

On myös toinen keino, ns. passiivinen poisto.

Passiivinen

Aktiivisessa poistossa meillä on erillinen, itse itseään kontrolloiva/ajastava prosessi (=käyttöjärjestelmän prosessi), joka käy tasaisin väliajoin tekemässä poistot. Tuo prosessi elää omaa elämäänsä irrallaan siitä prosessista, joka pyörittää markkinapaikkaamme.

Toinen vaihtoehto on jättää varauksen tiedot maatumaan tietokantaan, ja suorittaa erääntyneiden varausten käsittely suoraan applikaatiomme ydinkoodin puolella.

Tämä on läpeensä sysimusta idea, mutta esimerkin omaisesti esittelen myös sen.

Sarvikuonot-taulu ei muutu mihinkään. Se on edelleen tälläinen:

// Sarvikuonot-taulu

| id |   ostaja  | varaaja | hinta | tullut_myyntiin |   varaus_tehty    |
| -- | --------- | ------- | ----- | --------------- | ----------------- |
| 1  |   NULL    |  NULL   |   25  |   1.6.2016      |       NULL        |
| 2  |   NULL    |  1      |   32  |   3.6.2016      | 26.7.16 klo 12.15 |
| 3  |   2       |  2      |   26  |   4.6.2016      | 21.7.16 klo 17.33 |

Erillisen skriptin sijasta meillä on suoraan applikaatiomme sisuksiin koodattu sopivat reagoinnit erääntyneisiin varauksiin.

Esimerkiksi ostettavissa olevien kuonojen listaus näyttää nyt tältä:

// SarvikuonoController.php

public function vapaatKuonot() {

	// Vapaat kuonot ovat niitä, joilla pätee joko:
	// 1) 'varaaja' on tyhjä (NULL)
	// 2) 'varaus_tehty' ajankohta yli 1 tunti sitten

	$aikaRajapyykki = Carbon::now()->subHour();
	$vapaat = Sarvikuonot
		::where('varaus_tehty', <, $aikaRajapyykki)
		->where('ostaja', NULL)
		->get();

	return View::make('listaus', compact('vapaat'));	

	
}

Muut toiminnot joutuvat nyt turvautumaan vastaavaan logiikkaan. Esimerkiksi ostoprosessin lopussa on vielä kerran varmistettava, että varaus on yhä voimassa. Homma toimii, joten kuten.

Passiivisessa lähestymistavassa on puolensakin. Ylimääräinen prosessin (aktiivinen) olemassaolo lisää järjestelmän kuormitusta ja luo uudenlaisen bugityypin - jos erillinen poistoprosessi kaatuu, varaukset eivät enää eräänny lainkaan. Passiivisessa mallissa tätä riskiä ei ole, sillä “erääntyminen” on koodattu suoraan osaksi ydinalgoritmia.

Loppukaneetti: passiivisen ja aktiivisen lähestymistavan ero on pohjimmiltaan filosofinen, ja siitä löytyy oikean elämän esimerkkejä kosolti. Otetaan esimerkiksi käteisen rahan käyttö.

Yksi nostaa joka kuukausi 100 euroa käteistä, ja sujauttaa setelit lompakkoonsa. Jos hän kuukauden aikana tarvitsee käteistä, hän voi luottaa siihen, että sitä lompakosta löytyy. Hänen ei tarvitse jokaisen kirppariostoksen kohdalla erikseen miettiä asiaa.

Toinen ei nosta käteistä rahaa, vaan kantaa mukanaan yksinomaan muovirahaa. Hänen ei tarvitse huolehtia kuukausittaisesta Otto-automaatilla vierailusta. Mutta jos hän joskus sattuu tarvitsemaan käteistä, hänellä ei sitä ole. Toisin sanoen, jokaista ostosta tehdessään hänen täytyy erikseen varmistaa, että muoviraha käy.

Kyseessä on klassinen tradeoff.

Filteröi epäonnistujat pois (reflect + filter)

Lupauskirjasto Bluebirdin yksi vähemmän tunnetuista apumetodeista on reflect(). Ainakin allekirjoittaneelle tuo apufunktio pysyi tuntemattomana hyvää matkaa toista vuotta - ei vain tullut pakottavaa tarvetta, ja ongelmat sai aina ratkottua muutenkin.

Näin jälkikäteen ajateltuna nuo “muut” ratkaisut olivat aika hirveitä sekasotkuja, jotka toimivat jos jaksoivat.

Sittemmin otin reflectin käyttöön.

Minkä ongelman Promise.reflect() ratkoo?

Varsin usein omissa applikaatioissani on toiminnallisuuksia, joiden onnistunut läpivienti ei ole kriittistä. Jotkut toiminnot ovat luonteeltaan sellaisia, että ei niin väliä mikäli toiminto epäonnistuu nolosti. Tärkeintä on, että yhden vähäpätöisen toiminnon epäonnistuminen ei vedä mukanaan koko applikaatiota vessan pöntöstä alas.

Puhtaan perinteisessä synkronoidussa koodissa on luonnollista, että epäonnistunut toiminto napataan kiinni try-catch -siepparilla. Esim.


var _       = require('lodash');
var Promise = require('bluebird');

// SyncVarkaus.js

function kopioiBlogi(urls) {

	// Käydään yksitellen pihistämässä blogien HTML-sisältö.
	var blogiSisallot = _.map(urls, function(blogiURL) {
		var sisalto; // Täytetään sisällöllä
		try {
			sisalto = syncRequest(blogiURL);
		} catch (e) {
			// No, pöllintä ei onnistunut. Eipä hätiä.
			// Rikolliset aikeemme kohdistuvat seuraavaan uhriin.
			console.warn('Pölliminen epäonnistui - seuraava uhri sisään.');
		}	

		return sisalto;	

	})
	
	// blogiSisallot sisältää pöllityt sisällöt niistä blogeista,
	// joiden kähvellys EI epäonnistunut nolosti. Luuserit roikkuvat
	// vielä mukana undefined-arvoina. Mutta eivät pitkään.

	// Compact() suodattaa töpeksijät roskakoriin.
	return _.compact(blogiSisallot);
}

// Rikollisen uramme alkupiste.
var tulokset = kopioiBlogi([
	'http://www.pollitasta.fi',
	'http://www.tuplaamo.fi',
	'http://www.nollaversio.fi/public/blog'
]);

// Bestseller tiedossa.
koostaKirja(tulokset);

Ylläoleva on mukavaa perinteistä sync-koodia, jossa jokainen toiminto suoritetaan peräkanaa yksitellen. Ja try-catch-sieppari toimii kuin unelma.

Mutta kun Javascriptin (ja maalaisjärjen) luonteeseen kuuluu, että jokaista varkautta ei tarvitse tehdä perätysten. Niitä kun voi tehdä myös samanaikaisesti.

Siirrytään ihanaan lupausten maailmaan, ja suoritetaan rikossarjamme asynkronoidusti.


// AsyncVarkaus.js

function kopioiBlogi(urls) {

	return Promise.resolve(urls)
	.map(function(blogiURL) {
		// Jokainen request lähtee liikkeelle yht'aikaa.
		return asyncRequest(blogiURL);
	})
	.catch(function(err) {
		// Jotain meni nolosti pieleen.
	})

}

kopioiBlogi([
	'http://www.pollitasta.fi',
	'http://www.tuplaamo.fi',
	'http://www.nollaversio.fi/public/blog'
]).then(function(tulokset) {
	return koostaKirja(tulokset);
}).then(function(kirja) {
	// Valitaan sopivan korruptoitunut kustantaja.
	talentumMedia.julkaise(kirja);
})

Kaikki näyttää pintapuolin hyvältä. Käyttämällä Promise.map-metodia ammumme kaikki pöllimisyritykset käyntiin samanaikaisesti. Kukin kähvellys joko onnistuu tai epäonnistuu. Epäonnistuminen tippuu kivasti .catch()-sieppariin, joka sitten tekee jotain.

Mutta asiassa on ongelma. Jos yksikin yritys epäonnistuu, kaikki epäonnistuvat.

Tämä on .map-metodin ominaisuus - map-lupaus julistaa itsensä onnistujaksi vain jos jokainen sen alaisuudessa hyörivistä lupauksista onnistuu.

Jos yksikin alainen töpeksii, map-lupaus vetää pultit ja rikkoo kaiken. Siis estää ketään muutakaan onnistumasta.

Kerrataan vielä tämä tärkeä pointti uusiksi - map-lupaus epäonnistuu jos yhdenkin blogin pölliminen epäonnistuu!. Ja jos yksikin rosvous menee päin honkia, kaikki onnistuneet pöllimiset päätyvät jäteastiaan. Yksi kaikkien, ja kaikki yhden puolesta. Tämä ei tietenkään ole mitä haluamme.

Me haluamme, että jos yksi ryöstö menee reisille, muut ryöstöt voivat yhä onnistua.

Sata kultakelloa on parempi kuin 99, mutta 99 kultakelloa on parempi kuin pyöreä nolla.

Joten miten korjata asia?

Reflect()

Ratkaisu on sopivaan paikkaan sijoitettu reflect()-apumetodi.

Miksi reflect() toimii? Koska reflect() nappaa kiinni sekä onnistumiset että epäonnistumiset, ja välittää tiedon eteenpäin ns. neutraalissa muodossa.

Ikäänkuin luuseri ja maailmanmestari kävelisivät tasa-arvoisina rinta rinnan. Reflect() on koodimaailman emakko - kaikki kelpaa ruuaksi. Ja toisesta päästä tuleva tavara on aina vakioitua.

Katsotaanpa:


// AsyncVarkaus.js

function kopioiBlogi(urls) {

	return Promise.resolve(urls)
	.map(function(blogiURL) {
		// Yksittäinen varkaus - kokeillaan onnistuuko?
		return asyncRequest(blogiURL).reflect();
	})
	.filter(function(varkausLupaus) {
		// Suodatetaan(!) pois epäonnistuneet ryöstöt!
		return varkausLupaus.isFulfilled();
	})
	.map(function(onnistunutVarkaus) {
		// Vain onnistuneet rosvoukset jäljellä.
		// Joudumme kutsumaan teknisen apufunktion joka
		// hakee lopullisen tuloksen lupauksen syövereistä.
		return onnistunutVarkaus.value();
	})

	// Huomion arvoista, että emme tarvitse -catch-siepparia lainkaan!
	// Miksi? Koska kaikki luuserit on jo siivilöity ylempänä.
	// Yksikään päivänpilaaja ei elä tänne saakka.

}

kopioiBlogi([
	'http://www.pollitasta.fi',
	'http://www.tuplaamo.fi',
	'http://www.nollaversio.fi/public/blog'
]).tap(function(tulokset) {

	// Tulokset sisältää listan niistä blogisisällöistä, 
	// joiden ryöstö meni nappiin. 

	// Kokoa sisällöistä ikioma kirja.
	var valmisKirja = _.chain(tulokset)
	.reduce(function(kirja, pollittyBlogi) {
		kirja.lisaaUusiLuku(pollittyBlogi);
	}, new Kirja('Pölli Tästä Reloaded'))
	.value();

	talentumMedia.julkaise(valmisKirja);
})

Ylläolevassa koodissa huomattavaa ovat seuraavat rivit:

.filter(function(varkausLupaus) {...}

Apumetodi .filter (nimensä mukaisesti) suodattaa luuserit pois.

return asyncRequest(blogiURL).reflect();

Teemme requestin, ja kutsumme heti apumetodia reflect(). Kutsumalla reflectiä saamme luotua (ja palautetta ympäröivästä funktiosta ulos) uudentyyppisen lupauksen, joka itse osaa napata omat virheensä kiinni.

Jännittävää. Olemme ikäänkuin koulineet parannetun Pokemonin, joka on sisäsiisti.

Loppukaneetti: kun tietty operaatio on valinnainen, toisin sanoen sen onnistuminen ei ole kriittisen tärkeää, on syytä muistaa reflect() + filter() -kikka.

Ps. Huomasitko muuten, että käytimme yllä metodia tap() perinteisen then-metodin sijaan:

.tap(function(tulokset) {...}

Tämä siksi, että julkaisun suorittava anonyymi funktiomme on päätepiste. Se ei palauta mitään takaisin liukuhihnalle. Lisää tap vs. then eroista aiemmasta blogikirjoituksessani.

Muistilista uutta Laravel-projektia aloittaessa

Olen ihastanut suuresti checklist-manifestoon. Manifeston hengessä loin alkukesästä itselleni muistilistan asioista, joita uutta Laravel-projektia aloittaessa tulee ottaa huomioon.

Monet listan kohdista pätevät yleisesti kaikkiin ohjelmistoprojekteihin.

Laravel-checklist

Vaiheet 1-3: Projektikansion valmistelu, projekti-boilerplate, etc

  • 1. Alusta Git-repo projektikansioon, luo Github-repo, kytke yhteen.
  • 2. Lataa Composer.phar projektikansioon
  • 3. Kloonaa Laravel-boilerplate
  • 4. Muokkaa hakemisto-oikeudet (mm. Laravellin storage-kansio)
  • 5. Luo uusi Sublime-projekti

Vaiheet 6-9: Tietokannan luonti, valmistelu, tietokantayhteys, email-testaus

  • 6. Luo uusi tietokanta (esim. phpMyAdmin:in kautta)
  • 7. Päivitä projektitiedostoihin tietokannan käyttäjätunnus + salasana.
  • 8. Aseta email-ajuri osoittamaan testitiedostoon (loki).
  • 9. Luo “finnish”-kielitiedosto.

Vaiheet 10-12: Ensimmäiset tietokantataulut, relaatiot, mallit (models)

  • 10. Suorita ‘php artisan make:auth’, joka luo käyttäjähallinnan tietokantaan.
  • 11. Luo mallit kuvaamaan domain-käsitteitä. Tässä vaiheessa riittää tyhjä tiedosto kullekin mallille.
  • 12. Luo applikaation migraatiot (yksi per malli). Hahmottele kunkin mallin tietorakenne.

Vaiheet 13-15: Seeders, tehtaat, migraatioiden toiminnan varmistus

  • 13. Luo seeder-tehtaat (seeder factories) kullekin mallille.
  • 14. Luo seeder-tehtaiden avulla (feikki)käyttäjiä ym. domain-objekteja.
  • 15. Testaa, että migraatiot toimivat ja että relaatiot eri mallien välillä ovat kunnossa.

Tähän muistilistani päättyy. Tästä eteenpäin alkaa ns. raaka työ, eli itse applikaation toimintalogiikan ja käyttöliittymän ohjelmointi.

Tämä on se pisin ja uuvuttavin vaihe projektissa. Vaiheet 1-15 ovat verrattavissa arkkitehdin työhön. Vaiheet 16-20 ovat verrattavissa kirvesmiehen työhön.

Vaiheet 16-20: Toteuta logiikka, käyttöliittymä, jne.

  • 16. Hahmottele, koodaa, testaa, näpyttele sormet kipeäksi.
  • 17. Kiroile, paisko pari hiirtä tusinan päreiksi, harkitse puutarhurin uraa.
  • 18. Onnistu lopulta ratkomaan ongelmat.
  • 19. Juhlista valmista applikaatiota.
  • 20. Aloita seuraava projekti.

Ylläoleva checklist on osoittanut hyödyllisyytensä useammassa omassa projektissani. Kun on muistilista, jota seurata orjallisesti, pysyy laatu tasaisena ja työtahti tiiviinä.

Usecase-arkkitehtuurin vahvuus

Usecase-arkkitehtuuri on eräs tapa järjestää Laravel-pohjaisen tietokoneohjelman control flow.

Mitä usecase-arkkitehtuuri painottaa? Nimensä mukaisesti se pyrkii abstraktoimaan koodin erillisiin käyttötarkoituksiin, usecaseihin.

Käyttötarkoitus on esim. “nosta rahaa pankista”.

Otetaan esimerkki. Kuvitellaan järjestelmä, jossa loppukäyttäjä voi ryhtyä haluamansa pankin asiakkaaksi. Pankkeja on useita, ja asiakas voi yhden järjestelmän kautta hallita asiakkuuksiaan kussakin pankissa.

Ensimmäinen usecase - rahan nosto


// NostaRahaa_useCase.php

public function nostaRahaa(int $pankkiID, int $asiakasID, int $summa) {

	// Alkuvalmistelut, eli varmistetaan että asiakas-ID on olemassa
	$asiakas = Asiakas::findById($asiakasID); // Throws "EiOlemassa"
	// Varmistetaan, että pankkiID on olemassa
	$pankki = Pankki::findById($pankkiID); // Throws "EiOlemassa"

	// Usecasen tunnusmerkkejä on, että siinä tietyt toimenpiteet
	// suoritetaan järjestyksessä, ja tällä tavoin saavutetaan
	// haluttu lopputulos.

	// Tässä tapauksessa vaiheet ovat:
	// 1. Varmista asiakkuus
	// 2. Nosta rahat
	// 3. Lähetä ilmoitus nostosta asiakkaalle 

	// Virheet napataan kiinni ylempänä call stäkissä.
	
	// #1
	// Throws "EiAsiakkuutta" mikäli varmistus epäonnistuu.
	$pankki->varmistaAsiakkuus($asiakas); 

	// #2
	// Throws "EiKatetta" mikäli ei tarpeeksi rahaa tilillä.
	$nostettuSumma = $pankki->nostaTililta($asiakas, $summa); 

	// #3
	// Onnistuu aina (oletamme)
	$asiakas->lahetaSMS('nostoHyvaksytty', [
		'summa' => $nostettuSumma,
		'ajankohta' => Carbon::now(),
	]);

}  


// PankkiController.php

public function nostaRahaa(Request $request, int $pankkiID) {
	// parametrit tulevat IOC-containerin kautta

	// Validation sisääntullut request jotenkin
	try {
		$this->validateNostoRequest($request); // Throws "ValidaatioVirhe"		
	} catch (ValidaatioVirhe $vv) {
		return Response::error('Nosto epäonnistui - tarkista tiedot');
	}

	$asiakasID = $request->get('asiakasID');
	$summa     = $request->get('summa');

	// Kutsutaan usecasea!
	try {
		$nosto = (new NostaRahaa_useCase())->nostaRahaa($pankkiID, $asiakasID, $summa);
	} catch (Error $e) {
		// Ei tarvetta tietää mikä tietty virhe tapahtui, sen sijaan ilmoitetaan
		// virheviesti käyttäjälle. Viesti kertoo kaiken oleellisen.
		return Response::error($e);
	}

	// Kaikki meni oikein mukavasti.
	return Response::success('nostoOnnistui', $nosto);

}


Ylläoleva usecase-arkkitehtuuri erottelee sisääntulevan palvelupyynnön käsittelyn ja itse toiminnon läpiviemisen toisistaan. On syytä muistaa, että rahan nostaminen pankista on palvelupyyntö asiakkaalta pankille. Jotta tuo palvelupyyntö voidaan viedä läpi, täytyy asiakkaan tietokoneen lähettää tekninen palvelupyyntö järjestelmän palvelimelle.

Tässä onkin kaksi fundamentaalista konseptia:

  1. Palvelupyyntö siinä mielessä, että tosimaailmassa minä pyydän sinua tekemään jotain.
  2. Palvelupyyntö siinä mielessä, että kasa bittejä siirtyy tietokoneelta toiselle.

Jälkimmäinen on pelkkä bittimaailman kuvaus ensimmäisestä. Täydellisessä maailmassa jälkimmäiselle konseptille ei olisi lainkaan tarvetta. Mutta meidän maailmassamme on - tieto rahan nostosta täytyy jotenkin välittää kotikoneelta palvelimelle. Se ei välity telepatialla, joten joudumme turvautumaan teknisen palvelupyynnön lähettämiseen.

Usecase-arkkitehtuuri mahdollistaa näiden kahden konseptin erottelun kauas toisistaan. Siis kauas siinä mielessä, että ne sijaitsevat eri tiedostoissa. Tässä on suuri vahvuus.

Usecase-tiedoston ei tarvitse välittää siitä, millä tavoin asiakkaan kotikone ilmaisi palvelimen suuntaan halunsa nostaa rahaa.

Sen sijaan Controller-tiedosto (PankkiController.php) välittää tuommoisista alhaisen tason detaljeista. Controller ottaa sisään teknisen palvelupyynnön (siis #2 äskeisessä listassamme!), ja luo sen pohjalta oikean palvelupyynnön (#1 listassamme). Usecase-tiedosto ei koskaan edes tiedä #2 olemassaolosta - se välittää vain #1 käsittelystä.

Itse asiassa Usecase-tiedosto ei edes tiedä, että se on osa internet-applikaatiota. Sillä kaikki internet-liikenteeseen liittyvä logiikka elää Controller-tiedostossa.

Toinen usecase - rahan siirto

Lisätään järjestelmään toinen usecase. Mitä muuta haluamme pankkijärjestelmältämme kuin nostaa rahaa? No, ainakin siirtää rahaa yhdeltä tililtä toiselle.

Oletetaan, että rahan siirron voi tehdä miltä tahansa tililtä mille tahansa tilille. Tilien ei tarvitse olla samassa pankissa. Ainoa vaatimus on, että siirron tekevä asiakas omistaa lähtötilin, ja on asiakkaana siinä pankissa, jossa lähtötili sijaitsee.


// SiirraRahaa_useCase.php

public function siirraRahaa(
	int $lahtoPankkiID, /* Mistä pankista rahat lähtevät? */
	int $tuloPankkiID, /* Mihin pankkiin rahat saapuvat? */
	int $lahettajaID,    /* Kenen tili lähtöpankissa? */
	int $vastaanottajaID,  /* Kenen tili tulopankissa? */
	int $summa
) {
	// Tässä oletetaan, että jokaisella asiakkaalla voi olla max. yksi tili per pankki.
	// Täten yhdistelmä {pankki, asiakasID} kuvaa yksilöllisesti pankkitilin.
	// Oikeassa maailmassa käyttäisimme tietenkin *tilinumeroa*, mutta tämä järjestelmä
	// ei sellaista konseptia tunne.

	// Alkuvalmistelut, eli varmistetaan että lähettäjä ja vastaanottaja ovat olemassa.
	$lahettaja = Asiakas::findById($asiakasID); // Throws "EiOlemassa"
	$vastaanottaja = Asiakas::findById($vastaanottajaID); // Throws "EiOlemassa"

	// Varmistetaan, että molemmat pankit ovat olemassa.
	$lahtoPankki = Pankki::findById($lahtoPankkiID); // Throws "EiOlemassa"
	$tuloPankki = Pankki::findById($tuloPankkiID); // Throws "EiOlemassa"	

	// Tämän usecasen vaiheet ovat:
	// 1. Varmista asiakkuudet
	// 2. Nosta summa lähettäjän tililtä
	// 3. Lisää summa vastaanottajan tilille
	// 4. Lähetä ilmoitus nostosta lähettäjälle 
	// 5. Lähetä ilmoitus saapuneesta rahasummasta vastaanottajalle

	// Virheet napataan kiinni ylempänä call stäkissä.
	
	// #1 Varmista asiakkuudet
	// Throws "EiAsiakkuutta" mikäli varmistus epäonnistuu.
	$lahtoPankki->varmistaAsiakkuus($lahettaja); 
	$tuloPankki->varmistaAsiakkuus($vastaanottaja); 

	// Koska nosto yhdeltä tililtä ja talletus toiselle tilille
	// ovat toisistaan *riippuvaisia* operaatioita - eli joko
	// molemmat onnistuvat tai ei kumpikaan - meidän tulee
	// turvautua transaktioon.


	DB::transaction(function () use ($lahtoPankki, $tuloPankki, $lahettaja, $vastaanottaja, $summa) {

		// #2 Nosta summa lähettäjän tililtä
		// Throws "EiKatetta" mikäli ei tarpeeksi rahaa tilillä.
		$nostettuSumma = $lahtoPankki->nostaTililta($lahettaja, $summa); 

		// #3 Lisää summa vastaanottajan tilille
		$tuloPankki->talletaTilille($vastaanottaja, $nostettuSumma);
	});

	// #4 Lähetä ilmoitus nostosta
	$lahettaja->lahetaSMS('siirtoHyvaksytty', [
		'summa' => $summa,
		'ajankohta' => Carbon::now(),
	]);

	// #5 Lähetä ilmoitus saapuneesta rahasummasta
	$vastaanottaja->lahetaSMS('siirtoSaapunut', [
		'summa' => $summa,
		'ajankohta' => Carbon::now(),
	]);
}  


// PankkiController.php

public function nostaRahaa(Request $request, int $pankkiID) {
	// Kuten ennenkin
	// ...
}

public function siirraRahaa(Request $request, int $lahtoPankkiID) {
	// Parametrit IOC:in kautta
	// Miksi otamme IOC:n kautta $lahtoPankin, mutta emme $tuloPankkia?
	// Koska lähettäjä operoi omalla selaimellaan *tietyn* pankin käyttöliittymässä, 
	// ja kaikki lähettäjän tekemät palvelupyynnöt tehdään tietyn pankin suuntaan.
	// Toisin sanoen, kaikki sisääntulevat palvelupyynnöt tehdään URL:ään, jonka rakenne
	// on seuraavanlainen:

	/*
		http://pankkijarjestelma.fi/pankki/pankkiID/operaatio
	*/

	// Validoi sisääntullut request jotenkin
	try {
		$this->validateSiirtoRequest($request); // Throws "ValidaatioVirhe"		
	} catch (ValidaatioVirhe $vv) {
		return Response::error('Rahan siirto epäonnistui - tarkista tiedot');
	}

	// Haetaan siirtoon liittyvät tiedot.
	$tuloPankkiID = $request->get('tuloPankkiID');
	$lahettajaID = $request->get('lahettajaID');
	$vastaanottajaID = $request->get('vastaanottajaID');
	$summa     = $request->get('summa');

	// Kutsutaan usecasea!
	try {
		(new SiirraRahaa_useCase())->siirraRahaa(
			$lahtoPankkiID, 
			$tuloPankkiID,
			$lahettajaID,
			$vastaanottajaID, 
			$summa
		);

	} catch (Error $e) {
		// Ei tarvetta tietää mikä tietty virhe tapahtui, sen sijaan ilmoitetaan
		// virheviesti käyttäjälle. Viesti kertoo kaiken oleellisen.
		return Response::error($e);
	}

	// Kaikki meni oikein mukavasti.
	return Response::success('siirtoOnnistui');	


}


Tässä vaiheessa on hyvä mainita eräästä seikasta.

Kuten huomaamme, sisääntulevan datan validaatio on jaettu kahteen osaan. Esimerkiksi vastaanottajaID:

1) Ensin validoimme, että vastaanottajaID on mukana sisään tulevassa palvelupyynnössä. Tämä validointi tapahtuu $this->validateSiirtoRequest($request) rivillä. Millainen tuo metodi on? Esimerkiksi seuraavanlainen:


protected function validateSiirtoRequest(Request $request)
{
	// Throws "ValidaatioVirhe"
    $this->validate($request, [
        'tuloPankkiID' => 'required|int',
        'lahettajaID' => 'required|int',
        'vastaanottajaID' => 'required|int',
        'summa' => 'required|int|min:0|max:99999999',
    ]);

    // Kaikki kunnossa
    return true;
}

Huomioitavaa on, että tämä tarkistus/validatointi tapahtuu controllerin puolella.

2) Myöhemmin validoimme/tarkistamme - että kunkin ID:n takaa löytyy oikea, aito objekti. Eli jos pankkiID on 15, järjestelmässämme on olemassa Pankki, jonka ID on 15.

Tämä tarkistus tapahtuu usecasen puolella.

Controller-validaatio vs. usecase-validaatio

Miksi validaatio on jaettu kahteen paikkaan? Eikö olisi selkeämpää, jos molemmat validaatiot tehtäisiin yhdessä ja samassa paikassa?

Ei.

On syytä huomata, että nämä kaksi validaatiota tarkistavat eri asioita.

Controller-validaatio tarkistaa, että sisääntulevat ID:t ovat numeroita. Ne eivät saa olla esimerkiksi JPG-kuvia - on vaikea etsiä pankkia JPG-kuvan kautta.

Usecase-validaatio tarkistaa, että ID-numero (ja usecasen kohdalla me jo varmuudella tiedämme, että ID on numero, kiitos Controller-validaation!) vastaa jotakin järjestelmässä sijaitsee pankkia. On mahdollista, että palvelupyynnön mukana tullut ID-numero ei vastaa yhtäkään pankkia. Pankkeja ei kuitenkaan ole rajatonta määrää, numeroita sen sijaan on.

Tässä on ero. Controller validoi, että sisääntuleva data on oikeanmuotoista. Usecase validoi, että sisääntuleva data on järjellistä järjestelmän kannalta.

Summa summarum

Usecase-arkkitehtuurin vahvuus piilee juuri edellisessä huomiossa. Voimme käsitellä “ylätason toimintoja” selkeinä kokonaisuuksina, eli usecasenaina, käyttötarkoituksina. Samaan aikaan usecase on irrallaan kaikesta siitä ikävästä, mutta pakollisesta säläkoodista, joka liittyy internet-applikaation tekniseen toteutukseen. Eli HTTP-pyyntöjen hallinnasta, jne.

Hyvässä web-applikaatiossa päteekin, että itse applikaation ydinkoodi - tässä tapauksessa se koodi, joka suorittaa siirtoja ja nostoja pankkien välillä - ei edes tiedä asuvansa osana web-applikaatiota. Se tietää asuvansa osana applikaatiota, mutta webin olemassaolosta se on onnellisen tietämätön.

Suojaa tuloväylät - mutta miten?

Tyypillinen web-applikaatio ottaa vastaan monenlaista palvelupyyntöä. Osa pyynnöistä tulee rekisteröityneiltä käyttäjiltä, osa vierailta, osa hakkereilta, osa sisältää dataa, osa ei.

Kaiken tämän keskellä applikaatio tulisi kehittää niin, että jokainen sisääntuloväylä on suojattu asianmukaisesti. Eli portti on kunnossa ja pysyy kiinni esim. SQL-injektioille.

Helppo, nopea tapa huolehtia suojauksesta on jokaisen tuloväylän portilla tarkistaa, että asianmukaiset paperit ovat mukana:

versio 1


// AdminController.php

public function store(Request $request) {
	$this->tarkistaAdminOikeudet();

	// Kaikki hyvin, käsittele request
}

public function index(Request $request) {
	$this->tarkistaAdminOikeudet();

	// Kaikki hyvin, käsittele request
}

public function list(Request $request) {
	$this->tarkistaAdminOikeudet();

	// Kaikki hyvin, käsittele request
}

private function tarkistaAdminOikeudet() {
	if (Auth::user()->isNotAdmin()) {
		throw new Exception("Admin-oikeudet puuttuvat!");
	}
}

Ylläolevasta heti nähdään, että jotain on pielessä. Sama admin-tarkistus joudutaan tekemään kolmesti eri kohdissa.

Huomattavasti paremman ratkaisun tarjoaa konstruktori-metodi, joka mahdollistaa kaikille public-metodeille yhteisen tarkistuksen määrittämisen. Eli:

versio 2


// AdminController.php

public function __construct(Request $request) {

	$this->tarkistaAdminOikeudet();
}

public function store(Request $request) {

	// Kaikki hyvin, käsittele request
}

public function index(Request $request) {

	// Kaikki hyvin, käsittele request
}

public function list(Request $request) {

	// Kaikki hyvin, käsittele request
}

private function tarkistaAdminOikeudet() {
	if (Auth::user()->isNotAdmin()) {
		throw new Exception("Admin-oikeudet puuttuvat!");
	}
}

Kuten huomaamme, duplikaatio poistui. Tarkistus tehdään vain yhdessä pisteessä.

Mutta miksi turhaan edes keksiä pyörää uudestaan? Laravel tarjoaa “Middleware”-nimisen konseptin käyttöömme. Middleware on käytännössä yksi ylimääräinen kerros internetin ja applikaatiosi välissä. Tuo ylimääräinen “rasvakerros” soveltuu hyvin admin-tarkistuksen suorituspisteeksi.

versio 3

// Middleware/TarkistaAdmin.php


class TarkistaAdmin
{

    public function handle($request, Closure $next)
    {
        if (Auth::user()->isNotAdmin()) {
        	// Ohjataan käyttäjä kirjautumissivulle.
            return redirect('kirjaudu_sisaan');
        }

        // Kaikki ok!
        // Muu applikaatio voi luottaa että käyttäjällä on tarvittavat oikeudet!

        return $next($request);
    }

}


// AdminController.php

public function __construct(Request $request) {

	$this->middleware('tarkistaAdmin')
}

public function store(Request $request) {

	// Kaikki hyvin, käsittele request
}

public function index(Request $request) {

	// Kaikki hyvin, käsittele request
}

public function list(Request $request) {

	// Kaikki hyvin, käsittele request
}

Tämä on jo varsin pätevä ratkaisu. Ensinnäkin admin-tarkistuksen logiikka elää nyt poissa AdminControllerista. Tämä on ihan hyvä, sillä oletettavasti joku muukin komponentti applikaatiossa on kiinnostunut tekemään admin-tarkistuksia. Kun admin-tarkistus elää middleware-kerroksessa, se on kaikkien applikaation osasten käytettävissä.

Noin muutenkin on järkevintä tsekata admin-oikeudet mahdollisimman aikaisin. Tilanne on vastaava kuin lentokentällä - turvatarkastus tapahtuu keskitetysti ennen lähtöporteille siirtymistä. Millainen sotku syntyisi, jos turvatarkastus järjestettäisiin kunkin lähtöportin edessä erikseen? Aikamoinen.

Sama konsepti pätee web-applikaatioon - mitä aiemmin tarkastukset tehdään, sitä parempi. Aikainen tarkastus selkeyttää kaikkien osapuolten toimintaa. Lentokentälläkin on helpompi kuljeskella, kun turvatarkastus on rajattu tietylle alueelle.

Laravellin middleware-konsepti lisää myös uusia mahdollisuuksia valikoimaamme. Voimme esimerkiksi määrittää suoraan konstruktorissa, mille kaikille sisääntuloväylille (eli public metodeille) haluamme middleware-suojauksen pätevän.


// AdminController.php

public function __construct(Request $request) {

	$this->middleware('tarkistaAdmin', ['only' => [
		'store',
		'update'
	]]);
}

public function store(Request $request) {

	// SUOJATTU!
}

public function update(Request $request) {

	// SUOJATTU!
}

public function index(Request $request) {

	// EI SUOJATTU!
}

public function list(Request $request) {

	// EI SUOJATTU!
}

Kätevää, varsin kätevää. Esimerkin only-attribuutin lisäksi meillä on käytössämme except-attribuutti, joka toimii nimensä mukaisesti - se suojaa kaikki muut väylät paitsi erikseen except:in perässä määritellyt.

Lupausketju liukuhihnana - then vs. tap

Lupausten maailma on kaunis paikka. Pitkä, hyvin abstraktoitu lupausjono on Scarlett Johanssonin vartaloon verrattavissa oleva jumalallisen kauneuden symboli.

Mutta joskus tulee vastaan ongelmia, joihin lupausjono ei luontevasti sovellu. Tai ainakin voisi äkkiseltään luulla, että lupausjono ei toimi halutusti. Yksi tälläinen on seuraava.


// Lupaus jono alkaa
haeTennisTuloksetPalvelimelta()
.then(lajittelePelaajittain)
.then(printtaaFedererinTulokset)
.then(laskeKunkinPelaajanVoittoprosentti)

Hyvin kaunis lyhyt lupausjono, jonka asynkronoituun tyyliin etenee askel askeleelta. Koko lupausjono on kuin yksi iso liukuhihna. Kunkin then()-funkkarin kohdalla liukuhihnalla kulkeva tavara ohjataan apufunktioon (esim. lajittelePelaajittain).

Apufunktio voidaan ajatella koneena, joka jollain tavalla muuttaa tai transformoi saamansa esineen. Muutoksen/transformaation jälkeen tavara etenee kohti seuraavaa apufunktiota/käsittelypistettä.

Toimii kuin unelma. Mutta katsotaanpa mitä kunkin askeleen apufunktio palauttaa jonon seuraavalle kaverille tässä meidän tennistuloksia hallinnoivassa esimerkissämme.

Katsotaan vaiheittain:

haeTennisTuloksetPalvelimelta

null -> tulokset

Tämä on selvä peli - se hakee tulokset serveriltä ja työntää ne liukuhihnalle. Tämä vaihe istuu liukuhihnan alussa, joten se ei saa syötettä sisäänsä lainkaan. Sen sijaan se aloittaa hihnan toiminnan puskemalla erikseen palvelimelta haetut tulokset hihnalle.


lajittelePelaajittain

tulokset -> niputetut tulokset

Tämä myös helppo - se ottaa vastaan tulokset, ja lajittelee ne pelaajittain. Eli esimerkiksi Rafael Nadalin kaikki ottelutulokset paketoidaan kivasti yhteen nippuun siten, että myöhemmin niitä on helppo käsitellä erillään muista pelaajista. Tehtyään niputuksen tämä vaihe siirtää tuotetut niput takaisin liukuhihnalle kohti seuraavaa vaihetta.


printtaaFedererinTulokset

niputetut tulokset -> niputetut tulokset

Mutta entä tämä? Mikä on tämän käsittelyvaiheen tarkoitus? Nimensä mukainen. Vaihe etsii juuri niputetuista (pelaajittain!) tuloksista Roger Federerin tulokset, ja printtaa ne paperille. Miksi juuri Federer? En tiedä, eikä se ole oleellista.

Mikä on oleellista on se, että tämä vaihe EI transformoi/muunna koko sisääntulevaa datapakettia johonkin uuteen muotoon.

Joten mitä tämä vaihe sitten palauttaa liukuhihnalle? Me tiedämme alkuperäistä lupausjonoa tarkastelemalla, että seuraava vaihe (laskeKunkinPelaajanVoittoprosentti) odottaa saatavakseen niputetut tulokset. Toisin sanoen, seuraava vaihe odottaa edellisen vaiheen syötettä.

Tämä tarkoittaa, että printtaaFedererinTulokset vaihe on ikäänkuin tyhjänpäiväinen läpikulkupiste. Kuin tyhjä putki. Kuin kone, joka ei tee mitään. Huomioitavaa on, että kone tekee kyllä jotain (printtaa paperille Federerin tulokset), mutta liukuhihnan syötteen näkökulmasta mitään ei tapahdu.

Syöte vain kulkee läpi muuntumatta lainkaan!


// printtaaFedererinTulokset.js

module.exports = function(sisaantulevaData) {
	
	var federerTulokset = sisaanTulevaData['Federer'];

	// Emme ole kiinnostuneita tulostuksen onnistumisesta yms.
	// Kunhan kutsumme tulostusfunktiota ja jatkamme elämäämme eteenpäin.
	printtaa(federerTulokset);

	// Palautetaan saatu syöte identtisenä takaisin hihnalle.
	return sisaantulevaData;
}


laskeKunkinPelaajanVoittoprosentti

niputetut tulokset -> voittoprosentit pelaajittain

Taas selvä peli - tämä steppi ottaa sisäänsä niputetut tulokset ja aggregoi kunkin pelaajan osalta ne yhteen laskien voittoprosentin.

Ja avot - kaikki toimii.



Mutta.

Jokin häiritsee printtaaFedererinTulokset-vaiheessa. Tuo vaihe ottaa sisäänsä dataa ja puskee saman datan identtisenä ulos. Mitä järkeä tässä on?

Oleellinen huomio on, että noin maalaisjärjellä ajateltuna printtaaFedererinTulokset ei ole osa liukuhihnaa. Tai siis että se ei ole mikään käsittelyvaihe lainkaan! Se on enemmänkin vain liukuhihnan päällä sijaitseva tuntoanturi - kun paketti kulkee sen ylitse, jotain tapahtuu jossain. Tässä tapauksessa tuo “jotain” on, että printteri alkaa sylkemään aanelosta ulos.

Tuntoanturi ei muunna pakettia mitenkään.

Joten kauniin koodin nimissä olemme pakotettuja muokkaamaan lupausjonoa. PrinttaaFedererinTulokset ei saa olla käsittelyvaihe, sen tulee olla anturi.

Tap-funktio on anturi

Hoidetaan homma ottamaan käyttöön tap-funktio osana lupausketjua (liukuhihnaa). Tap-funktio on juuri tähän tarkoitukseen soveltuva - se ottaa sisäänsä edellisen vaiheen tuottamaan syötteen, mutta ei tuota mitään ulosmenevää tavaraa!

Toisin sanoen, tap-funktion käyttö mahdollistaa, että tap-funktiota seuraava vaihe saa syötteen sisään tap-funktiota edeltävältä vaiheelta.

Tässä tapauksessa laskeKunkinPelaajanVoittoprosentti saa syötteensä lajittelePelaajittain-vaiheelta. Tämä on juuri mitä haluammekin.

Eli lopullinen muoto.


// Lupaus jono alkaa
haeTennisTuloksetPalvelimelta()
.then(lajittelePelaajittain)
.tap(printtaaFedererinTulokset)
.then(laskeKunkinPelaajanVoittoprosentti)

Tap tap. Kaunista ja toteuttaa täydellisesti SINGLE RESPONSIBILITY-prinsiippiä. PrinttaaFedererinTulokset saa sisäänsä tarvittavan datan, mutta sen ei tarvitse huolehtia ulosmenevästä tavarasta lainkaan. Itse vaihe on nyt yhden rivin lyhyempi:

```javascript

// printtaaFedererinTulokset.js

module.exports = function(sisaantulevaData) {

    var federerTulokset = sisaanTulevaData['Federer'];

    // Emme ole kiinnostuneita tulostuksen onnistumisesta yms.
    // Kunhan kutsumme tulostusfunktiota ja jatkamme elämäämme eteenpäin.
    printtaa(federerTulokset);

    // Ei tarvitse palauttaa mitään!
}

```

Loppukaneetti: On tietenkin selvää, että useimmissa projekteissa tap vs. then -funktion käyttö on aika irrelevantti seikka. Tässä esimerkissä saimme säästettyä yhden rivin koodia, ja hitusen selvennettyä lupausketjun logiikkaa (kokenut koodari huomaa yhdellä silmäyksellä liukuhihnan toimintalogiikan). Hyöty on silti aika minimaalinen ja lähinnä kosmeettinen.

Tap-funktion suurin hyöty tulee silloin, kun joudumme lupausketjun osana kutsumaan koodia, jota emme hallitse. Kuvitellaan, että printtaaFedererinTulokset sijaitsee osana valtavaa, minimoitua JS-kirjastoa. Tuon kirjaston kirjoittaja on oikeaoppisesti koodannut funktion siten, että se ei palauta mitään ulos. Me emme pysty asiaan vaikuttamaan. Joudumme täten tilanteeseen, jossa emme voi käyttää pelkistä then()-vaiheista koostuvaa ketjua - printtaaFedererinTulokset-vaihe rikkoisi tuon ketjun.

Tässä tapauksessa tap-funktio pelastaa päivän suorastaan naurettavan helpolla. Kutsumme printtaaFedererinTulokset-kirjastofunktiota tap-funktion sisältä, ja kaikki toimii.

Raskas koodi erillisessä säikeessä? Lupaus auttaa.

Javascriptin hauska puoli on, että se ajaa itseään mukavasti yhdessä säikeessä. Tämä tarkoittaa, että kaikki koodi ajetaan perätysten, kiltisti jonossa.

Eli kun funktio A aloittaa ajonsa, funktio B ei voi aloittaa ennenkuin A on valmis. Täydellinen metafööri Javascriptille onkin McDonaldsin autokaistan jono - jos yksi autoilija jää suustansa kiinni noutotiskille, yksikään takana olevista autoista ei liiku senttiäkään.

Ohjelmoinnin maailmassa tämä tarkoittaa, että jos yksi funktio rohmuaa prosessorin ajoaikaa viisi sekuntia, kaikki muut ajovuoroa odottavat koodinpätkäset joutuvat vähintään tuon viisi sekuntia odottamaan.

Tämä kylmä totuus pätee sekä selaimen puolella että serverimaailmassa (Node.js).

Yksi tapa ratkoa jonotuksen tuomat haasteet on pitää huoli, että jono liikkuu vauhdilla. Mäkkärikin tekee näin - he pitävät huolen, ettei yksittäinen asiakas tuki koko autokaistaa, vaan siirtyy sutjakasti elämässään eteenpäin. Koodin puolella tämä on tehtävissä ohjelmoijan maalaisjärjen käytöllä - ohjelmoija arvioi parhaan kykynsä mukaan kuinka kauan kunkin koodinpätkän ajo kestää.

Jos ajo kestää kaksi mikrosekuntia, ei ongelmia.

Jos ajo kestää kaksi sekuntia, niin pulassa ollaan.

Mikä avuksi tilanteisiin, joissa yksittäinen koodinajo on pitkäkestoinen?

Luo uusi säie, joka tekee raskaat työt

Ratkaisu on yksinkertainen - uusi työmyyrä (säie), joka ottaa kontolleen raskaan työurakan. Selaimessa Web Worker-standardi mahdollistaa säikeen luomisen. Muita järkeviä vaihtoehtoja ei juuri ole.

Serveripuolella (Node.js) on enemmän vaihtoehtoja. Yksi vaihtoehto on ajaa raskas koodi kokonaan uudessa Node.js-instanssissa. Eli uudessa käyttöjärjestelmän prosessissa, joka pyörittää Node.js-koodia.

Se voi olla ihan hyväkin idea, mutta aika raskas, sillä koko Node.js-moottori täytyy ladata uusiksi tätä uutta “säiettä” varten. Jos koodinajo on pitkäkestoinen, tällä ei ole juuri merkitystä.

Toinen vaihtoehto olisi käyttää “kevyempää säiettä”, joka ajetaan jo olemassaolevan Node.js-prosessin alaisuudessa. Tällöin käyttöjärjestelmän ei tarvitse synnyttää uutta prosessia, vaan uusi prosessi syntyy kivasti käyttöjärjestelmän tietämättä asiasta mitään.

Valitaan kuitenkin vaihtoehto yksi ihan siksi, että yksi parhaista säiekirjastoista turvautuu siihen.

Threads -kirjasto ja lupaukset

Oletetaan, että meillä on koodinpätkä, joka etsii kaikki suomalaiset erisnimet tekstidokumentista. Skripti toimii seuraavasti:


// etsiErisnimet.js

var ERISNIMET = ['Aado', 'Aamu', 'Aapo', ... , 'Yrjö'];

module.exports = function(dokumentti) {
	
  var sanat = dokumentti.split(" "); // Erottele välilyönnillä

  var nimet = _.filter(sanat, function(sana) {
    return ERISNIMET.indexOf(sana) !== -1; // Löytyikö sana nimiluettelosta?
  })

  // Poista duplikaatit
  // ['Mikko', 'Mikko', 'Matti'] -> ['Mikko', 'Matti']
  return _.uniq(nimet);
}

Algoritmi on kompleksisuudeltaan about O(nk), jossa n kuvaa tekstin pituutta ja k etunimien lukumäärää. Ei ehkä ihan optimialgoritmi, mutta what the hell. Käyttö on helppoa.


// Testi.js
var nimiEtsinta = require('etsiErisnimet');

var nimet = nimiEtsinta('Pirkko ja Ville menivät kalaan.');
console.log(nimet) // ['Pirkko', 'Ville']

Seuraavaksi katsotaan, miten tuo algoritmi saadaan ajettua erillisessä säikeessä.

Ensinnäkin tarvitsemme säiekirjaston. Sen voi asentaa npm install threads –save -komennolla komentorivillä. Tämän lisäksi on syytä tehdä pieni muutos etsiErisnimet.js-tiedostoon, jotta se pystyy toimimaan threads-kirjaston kanssa. Muuta ei tarvita.

Sitten itse koodi. Huomattavaa on, että paketoimme aiemman erisnimien etsintään erikoistuneen koodin siten, että se voidaan ajaa säikeen sisällä.


// etsiErisnimetThreaded.js

// Tämä moduuli toimii yksinomaan wrapperinä.

var threads = require('threads'); // Säiekirjasto
var Promise = require('bluebird'); // Lupauskirjasto

module.exports = function(dokumentti) {

  return new Promise(function(resolve, reject) {
    // Luodaan uusi säie
    // Spawn-funktio ottaa parametrikseen sen moduulin nimen, 
    // jonka koodin säie ottaa ajaakseen.
    var thread = threads.spawn('etsiErisnimet');

    // Säie on luotu pinnan alla ja valmis toimimaan.
    // Lähetetään säikeelle viesti
    thread.send(dokumentti)
    // ...ja jäädään kuuntelemaan vastausta
    .on('message', function(loydetytErisnimet) {
      // Resolvoidaan lupaus saaduilla tuloksilla.
      return resolve(loydetytErisnimet);
    })
    .on('error', function(error) {
      // Rejektoidaan lupaus
      return reject(error);
    });

  });

}


// etsiErisnimet.js

// Aiempi erisnimien etsintä toimii kuten ennenkin, mutta
// tarvitsemme hiukan lisäkoodia hallitsemaan tiedonvaihtoa
// säikeiden välillä.

var ERISNIMET = ['Aado', 'Aamu', 'Aapo', ... , 'Yrjö'];

module.exports = function(dokumentti, takaisinlahetys) {

  var sanat = dokumentti.split(" "); // Erottele välilyönnillä

  var nimet = _.filter(sanat, function(sana) {
    return ERISNIMET.indexOf(sana) !== -1; // Löytyikö sana nimiluettelosta?
  })

  // Poista duplikaatit
  // ['Mikko', 'Mikko', 'Matti'] -> ['Mikko', 'Matti']
  takaisinlahetys(_.uniq(nimet));
}

Ylläoleva koodi on kaikki mitä tarvitsemme. Nyt voimme suorittaa erisnimietsinnän täysin erillään omassa säikeessään!


// RikosSeurantaApplikaatio.js

var Promise = require('bluebird');
var nimietsinta = require('etsiErisnimetThreaded');

function vastaanotaDokumentti(dokumentti) {
	
  nimietsinta(dokumentti)
  .then(function(loydetytNimet) {
    return tarkistaEpailyttavatNimiParit(loydetytNimet);
  })
  .catch(function(error) {
    console.log("Nimien etsintä epäonnistui");
    console.error(error);
  })
}

function tarkistaEpailyttavatNimiParit(nimet) {
  if (_.intersection(['Ilkka', 'Kanerva'], nimet).length === 2) {
    // Sekä Ilkka että Kanerva löytyivät, soita Karhuryhmä.
  }
}

Tällä tavoin olemme kivasti paketoineet säikeidenhallinnan ikävät sivuseikat lupausta tarjoavat abstraktion taakse. RikosSeurantaApplikaation ei tarvitse välittää tuon taivaallista säikeiden olemassaolosta - riittää, että se kutsuu tarjottua rajapintaa ja ottaa vastaan lupauksen.

Tuo lupaus sitten jonain kauniina aamuna realisoituu todeksi, ja kaikki ovat tyytyväisiä.

Lupausten mahti - pätkä koodiani

Lupaukset (engl. Promise) ovat varsin mahtavia. Siinä missä muuten asynkronoidun funktiokutsun matkapojaksi joutuisi lähettämään callback-funktion, lupaus mahdollistaa koodaustyylin, jossa callback liikkuu varjoissa - siis pinnan alla. Lupaus on käytännössä pieni wrapperi, kuin lahjapaketti, joka kaitsee isällisellä otteella callbackia.

Ehkä suurin hyöty lupauksen käytöstä on kuitenkin se, että virhetilanteet tulevat asianmukaisesti hoidetuksi. Lisäksi ne virheet tulevat hoidetuksi oikeassa paikassa - lupausketjun lopussa. Harva asia on hirveämpää kuin joutua kirjoittamaan varsinaista bisnes-koodia ja virhetilanteisiin reagoivaa hätäapukoodia sikin sokin. Lupausten avulla virhekoodi voi elää visuaalisesti kaukana varsinaisesta koodista. Tämä helpottaa koodinlukua.

Otan esimerkin lupausketjusta, jossa virheisiin reagoiva koodi on upotettu pahnanpohjimmaiseksi. Esimerkki on suoraan applikaatiostani, joka analysoi PGN-shakkipelitiedoston ja raportoi käyttäjälle takaisin pelaajien tekemät huonot siirrot. Huono siirto tarkoittaa siirtoa, jonka seurauksena vastustajan voittomahdollisuudet paranivat merkittävästi.


// Lupausketju

// Lupausketju koostuu kuudesta osavaiheesta, jotka suoritetaan järjestyksessä peräkkäin!
// Näiden jälkeen on virhetilanteet käsittelevä ekstravaihe.

// #1 PGN-datan separointi eli pelien erottelu toisistaan
// #2 Jokaisen pelin (map-apufunktio!) muuntaminen kasaksi peliasemia (FEN-muoto)
// #3 Asemien filteröinti niin, että vain tiettyjen siirtojen asemat ovat mukana
// #4 Asemien analysointi valittua shakkimoottoria käyttäen.
// #5 Analysointitulosten paketointi pelikohtaisesti
// #6 Asemien filteröinti, jätetään vain asemat joissa pelaaja tunaroi
// #7 Virhetilanteiden hallinta

function processPGN(pgnText) {
        return Promise.resolve(pgnText)
        // #1
        .then(function(pgnText) {
            return separateGames(pgnText);
        })
        // #2
        .then(function(separatedGames) {
            return _.map(separatedGames, function(game) {
                var gameID = uuid.v4(); 
                // Every position is associated with game id
                // so we can later know from which game each 
                // position came from (position.fromgame)
                return separateIndividualPositions(game, gameID);
            });
        })
        // #3
        .then(function(allPositions) {
            // Filter out those not in movenumber range
            return _.filter(allPositions, function(position) {
                return position.movenum >= 10 && position.movenum <= 30;
            });
        })
        // #4
        .map(analyzePosition, {concurrency: 4} /*Num of parallel engine instances to use?*/)
        // #5
        .then(function(results) {
            // Pack analyzed positions back into games
            var groupedIntoGames = _.groupBy(results, function(result) { 
                return result.fromgame;
            });
            // Sort positions by movenumber
            return _.mapValues(groupedIntoGames, function(positions) {
                return _.sortBy(positions, function(p) { return p.movenum})
            });
        })
        // #6
        .then(function(groupedIntoGames) {
            return _.mapValues(groupedIntoGames, function(positions) {
                if (!positions || positions.length < 2) return [];
                var currPosition = positions[0];

                return _.compact(_.map(_.tail(positions), function(position) {
                    var thisEval = parseFloat(position.evaluation);
                    var evalDiff = Math.abs(thisEval - parseFloat(currPosition.evaluation));

                    var oldFen = currPosition.fen;
                    var oldBest = currPosition.bestmove;
                    var oldEval = currPosition.evaluation;

                    // Replace old with the current for next loop run
                    currPosition = position;
                    // Evaluation changed > 75 centipawns -> bad move
                    if (evalDiff > 75) {
                        // Mistake found
                        return {
                            fenBefore: oldFen,
                            fenAfter: position.fen,
                            evalBefore: oldEval,
                            evalAfter: position.evaluation,
                            movenum: position.movenum,
                            evalDiff: evalDiff,
                            playedMove: position.move,
                            bestMove: oldBest
                        };
                    }

                    return null; // Nulls are filtered out later

                    
                }));

                


            });
        })
        // #7
        .catch(function(err) {
            // Something went wrong, lets bail.
            console.log("PGN analysis went wrong");
            Log::error(err);
        })  


}


Ylläoleva koodi voitaisiin helposti vielä muuttaa todella helppolukuiseen muotoon abstraktoimalla varsinainen bisneskoodi.


function processPGN(pgnText) {

        return Promise.resolve(pgnText)
        .then(separateGames)
        .then(separatePositionsForEachGame)
        .then(filterOutPositionsNotInMoveRange)
        .map(analyzePosition, {concurrency: 4})
        .then(packResultsIntoGames)
        .then(filterOutPositionsWhereNoMistakeWasMade)
        .catch(handleErrors)    
}

function separateGames(...) {...}
function separatePositionsForEachGame(...) {...}
// jne.


Aika kaunista.

Nodejs - riippuvuuksien injektointi

Riippuvuuksien injektointi (engl. dependency injection) on varsin vahva tapa varmistaa modulaarinen koodipohja. Kun tietyn komponentin jokainen alikomponentti otetaan vastaan “ulkoa annettuna”, on komponenttia mahdollista muokata rakentamalla se eri palikoista.

Alla esimerkki komponentista, joka hallitsee itse riippuvuuksiaan (alikomponenttejaan):


// Termostaatti.js

module.exports = Termostaatti;

function Termostaatti() {
	this.lampomittari = new Lampomittari();
	this.tuuletus = new Tuuletusjarjestelma();

	...

}

function Lampomittari() {...}
function Tuuletusjarjestelma() {...}


Ylläoleva Termostaatti-komponentti paitsi itse päättää omat alikomponenttinsa, myös sisältää alikomponenttien koodin sisuksissaan. Kyseessä on äärimmilleen viety tapa “paketoida” komponentti loogiseksi kokonaisuudeksi, ikäänkuin mustaksi laatikoksi.

Termostaatin loppukäyttäjän kannalta ratkaisu on peräti toimiva, olettaen, että loppukäyttäjä vain haluaa termostaatin käyttöönsä annetussa muodossa.

Ongelmana kuitenkin on, että esimerkiksi lämpömittarin koodipohjalla olisi ehkä käyttöä muuallakin, esimerkiksi komponenttia Leivinuuni rakennettaessa. Jos lämpömittarin koodi elää Termostaatin sisuksissa, se on käytännössä vangittuna ikuiseen tyrmään.

Täten helppo tapa parantaa koodia on refaktoroida Termostaatti muotoon, jossa lämpömittari elää omassa kooditiedostossaan, täten helposti siirrettävissä muihin tarkoituksiin.


// Termostaatti.js

var Lampomittari = require('Lampomittari');

module.exports = Termostaatti;

function Termostaatti() {
	this.lampomittari = new Lampomittari();
	this.tuuletus = new Tuuletusjarjestelma();
	...
}

function Tuuletusjarjestelma() {...}



// Lampomittari.js

module.exports = Lampomittari;

function Lampomittari() {...}


Lampomittari-alikomponentti otetaan ylläolevassa esimerkissä erikseen käyttöön osaksi Termostaatti-komponenttia. Lämpömittari ei siis enää elä Termostaatin sisällä. Selkeä parannus aiempaan siinä mielessä, että eri komponenttien koodipohjat ovat entistä paremmin jaoteltuina omiin tiedostoihinsa.

Varsinainen otsikon ongelma ei silti ratkennut - Termostaatti itse hallitsee alikomponentin ottamisen käyttöön.

Seuraava parannus on siirtää päätäntävalta pois Termostaatin ulottuvilta. Termostaatin vastuulla ei pidä olla lämpömittarin valinta. Termostaatin vastuulla on huolehtia lämpömittarin mitta-asteikon lukemisesta. Oleellista on, että termostaatti saa käyttöönsä luettavissa olevan lämpömittarin.

Oletetaan esimerkin nimissä, että meillä on kaksi eri tyyppistä lämpömittaria; digitaalinen mittari ja elohopeamittari.

Termostaattia ei kiinnosta kumpi mittari on sen käytettävissä KUNHAN VAIN molemmat mittarit ovat luettavissa ongelmitta.

Mutta meitä huoneiston omistajina asia saattaa kiinnostaa. Emme halua elohopeamittaria, sillä elohopea on ympäristömyrkky. Olemme viherhihhuleita, ja suosimme digitaalista mittaria (jonka toiminta ei perustu elohopean lämpölaajenemiseen).

Käytännössä meillä on kaksi tapaa toteuttaa koodipohja siten, että termostaatti ei ole edes tietoinen millaisen mittarin se saa käyttöönsä.

Tapa 1 (“tiedosto-interface”)

Helpoin tapa ratkoa ongelma on hoksata, että Lampomittari.js -tiedoston määrittämä komponentti otetaan käyttöön Termostaatti.js-tiedostossa nimellä “Lampomittari”.

Toisin sanoen, mitä ikinä Lampomittari.js-tiedosto määrittääkään, termostaatti näkee sen nimellä “Lampomittari”. Kyseessä on puhdas interface, joka pätee tiedostojärjestelmän tasolla. Niin kauan kuin Lampomittari.js-tiedoston nimi ei muutu, voimme kontrolloida termostaatin sisäistä toimintaa ilman että meidän tarvitsee koskea lainkaan Termostaatti.js-tiedostoon.

Eli:


// Termostaatti.js

var Lampomittari = require('Lampomittari');

module.exports = Termostaatti;

function Termostaatti() {
	this.lampomittari = new Lampomittari();
	this.tuuletus = new Tuuletusjarjestelma();
	...
}

function Tuuletusjarjestelma() {...}



// Lampomittari.js

module.exports = DigitaalinenLampomittari;

function DigitaalinenLampomittari() {...}

function ElohopeaLampomittari() {...}


Ylläoleva koodipohja antaa Lampomittari.js-tiedostolle tilaisuuden kontrolloida termostaatin sisäistä toimintaa. Jos haluamme vaihtaa termostaatin lämmonmittauksen vanhan koulukunnan menetelmiin, riittää yksi muutos:

// Muutos Lampomittari.js koodiin
module.exports = ElohopeaLampomittari;

Vielä parempaa - voimme käyttää koko Lampomittari.js-tiedostoa yhtenä suurena “dispatchina”. Tällöin kaikki eri tyyppiset mittarit elävät omissa tiedostoissaan, ja Lampomittari.js-tiedoston tehtäväksi jää valita niistä yksi ja tarjota sitä ulkopuolisille “Lampomittari”-interfacen nimissä.

// Lampomittari.js
var ElohopeaMittari = require('ElohopeaLampomittari');
var DigitalMittari  = require('DigitaalinenLampomittari');
var SaunaMittari    = require('SaunaLampomittari');

module.exports = SaunaMittari;

Mutta asiassa on ongelma. Entä jos huoneistoon halutaan useampi termostaatti? Entä jos eri termostaatit eivät halua käyttää samaa lämmönmittaustapaa?

Niin kauan kuin Lampomittari.js-tiedosto toimii interfacena, se pystyy tarjoamaan vain yhden tavan mitata lämpötila. Lisäksi tuo tapa on kirjoitettu suoraan lähdekoodiin. Tarkoittaen, että ohjelman ajon aikana tuo valittu tapa on vakio - sitä ei pysty muuttamaan.

Yksi suht typerä tapa ratkaista ongelma on luoda erillinen Termostaatti-tiedosto jokaista erilaista termostaattia varten:

// ElohopeaTermostaatti.js

var Lampomittari = require('ElohopeaLampomittari');

module.exports = Termostaatti;

function Termostaatti() {
	this.lampomittari = new Lampomittari();
	this.tuuletus = new Tuuletusjarjestelma();
	...
}

// DigitaalinenTermostaatti.js

var Lampomittari = require('DigitaalinenLampomittari');

module.exports = Termostaatti;

function Termostaatti() {
	this.lampomittari = new Lampomittari();
	this.tuuletus = new Tuuletusjarjestelma();
	...
}

Ylläolevassa ei ole mitään järkeä. Huomattavaa on, että eri tiedostojen välillä vain yksi koodirivi muuttuu - valitun lämpömittarin nimi.

Parempikin tapa on.

Tapa 2 (“riippuvuuksien injektointi moduuliin”)

Kaiken päämääränä on se, ettei meidän tarvitse koskea Termostaatti.js-tiedostoon silloin, kun haluamme vaihtaa termostaatin lämmönmittaustapaa. Yllä saavutimme tavoitteen require-komennon kautta; otimme käyttöön require-toiminnolla Lampomittari.fi -komponentin - joka ei itse asiassa ollut komponentti lainkaan, vaan ainoastaan esitti komponenttia. Oikea komponentti oli piilossa Lampomittari.js-tiedoston selän takana.

Vaihtoehtoinen tapa toteuttaa tavoitteemme on yksinkertaisesti syöttää tarvittavat alikomponentit sisään samalla kun luomme termostaattia.

Huomioitavaa on, että syötämme alikomponentit sisään ohjelman ajon aikana. Toisin sanoen, valinta käytetyistä alikomponenteista on tiedossa vasta ohjelman ajon aikana.

Tämä on fundamentaaline ero aiempiin ratkaisuyrityksiimme. Aiemmissa ratkaisuissa valinta oli aina kirjattu suoraan lähdekoodiin.


// Termostaatti.js

module.exports = function(lampomittari, tuuletusjarjestelma) {
	// Onko lampomittari digitaalinen vai elohopea? 
	// Emme tiedä. Emme välitä.
	return new Termostaatti(lampomittari, tuuletusjarjestelma);
}

function Termostaatti(lampomittari, tuuletusjarjestelma) {
	
	...
}


Tämä toimintamalli eroaa aiemmista siten, että Termostaatti ottaa vastaan alikomponentit täysin ulkoa annettuina. Termostaatti.js-tiedoston tehtäväksi jää rakentaa termostaatti kytkemällä ulkoatulevat komponentit osaksi kokonaisuutta. Tästä ajattelumallista käytetään nimitystä “Factory” eli tehdas. Termostaatin käyttäjä voi vapaasti syöttää haluamansa lämmönmittausmenetelmän sisään termostaattia projektiin lisätessään.


// Asuinhuoneisto.js

var termostaattitehdas = require('Termostaatti');

var saunanTermostaatti = termostaattitehdas(new SaunaMittari(), new Tuuletus());
var eteisenTermostaatti = termostaattitehdas(new ElohopeaMittari(), new Tuuletus());

...

Luonnollisesti tapojen #1 ja #2 välillä trade-off. Tapa 1 mahdollistaa loppukäyttäjän olevan auvoisen tietämätön mistään termostaatin sisäisistä aspekteista. Loppukäyttäjä vain ottaa termostaatin käyttöönsä, luottaen sen toimintaan. Tapa 2 antaa loppukäyttäjälle mahdollisuuden määritellä kustomoituja termostaatteja. Loppukäyttäjä voi itse rakentaa haluamansa termostaatin ikäänkuin LEGO-palikoita kokoamalla. Jokaisen palikan hän voi valita itse.

Ero on vastaava kuin Applen läppärin ja itsekootun pöytätietokoneen välillä. Applen läppäri on käytännössä yksi iso musta laatikko, ja sen sisäisten alikomponenttien muuttaminen vaatii Apple-sertifioidun ammattilaisen apua.

Itsekoottu pöytäkone taas… riittää, että ruuvaa sivukannen auki, vetää muistikamman irti, asettaa uuden muistikamman tilalle. Noin kahden minuutin juttu.

Laravel vinkit #1

Kuinka lisätä uusi raportointitoiminnallisuus ilman muutoksia vanhaan koodipohjaan?

Lyhyt vastaus: luomalla palveluntarjoaja, joka määrittää tapahtumakuuntelijan.

Laravellilla rakennettu järjestelmä on helposti laajennettavissa palveluntarjoajien kautta. Palveluntarjoajan mahdollistavat arkkitehtuurin rakentamisen siten, että uudet toiminnallisuudet elävät täysin erillään ns. ydinkoodista. Ydinkoodin ei edes tarvitse tietää uuden toiminnallisuuden olemassaolosta.

Paras tapa toteuttaa tämä erilläänolo on käyttää palveluntarjoajia (Service Provider).


Käytetään esimerkkinä Nordean pankkijärjestelmää.

Oletetaan, että Nordean mahtava nettipankki sallii asiakkaidensa lisätä pankkitileilleen rahaa. Ahneuspäissään pankkiväki ei ole lisännyt mahdollisuutta nostaa rahaa tililtä - ainoastaan talletus on mahdollista.

Ihana nettipankki toimii kuin unelma, kunnes tulee lakimuutoksen myötä lisävaatimus: jokaisen pankkitilille tehtävän talletuksen jälkeen järjestelmän tulee ilmoittaa viranomaisille ko. pankkitilin uusi saldo.

Oletetaan, että tässä esimerkissä viranomaisilla on upea HTTP-rajapinta nimeltä “Pankkipoliisi”. Rajapinnan tarkempi toiminta ei ole oleellista, joten oletetaan, että pintaa voidaan kutsua tyyliin:

Pankkipoliisi::ilmoita($asiakasID, $uusiSaldo)

Miten nykyistä Nordean pankkijärjestelmää tulee muuttaa, jotta lain vaatimus täyttyy?


Tarvittava muutos järjestelmän on yksinkertainen. Vanha ydinkoodi - joka huolehtii pankkitilin hallinnasta - voi pysyä tismalleen identtisenä.

Vaatimuksen täyttöä varten lisäämme järjestelmään palveluntarjoajan, joka puolestaan lisää tapahtumakuuntelijan. Tuo tapahtumakuuntelija on kaiken A ja O - se kuuntelee järjestelmän tuottamia tapahtumia ja valikoi niistä jatkokäsittelyyn itselleen mieluisat.

Käytännössä homma toimii siten, että vanha ydinkoodi tuottaa tapahtumia, ja uusi koodipohja reagoi noihin tapahtumiin.

Tämä patterni on yleismaailmallinen ja soveltuu moniin käyttötarkoituksiin: olemassaoleva järjestelmäkomponentti tuottaa informaatiota, uusi komponentti reagoi tuotettuun informaatioon.

Kaiken pohjalla toimii oletus siitä, että vanha komponentti ei tiedä uuden komponentin olemassaolosta mitään. Parhaimmillaan myöskään uusi komponentti ei havaitse vanhaa komponenttia. Kaikki informaatio kulkee tapahtumien muodossa.

Hieman karrikoiden; vanha komponentti “ampuu tapahtumia kohti tyhjyyttä”, uusi komponentti “vastaanottaa tapahtumia tyhjyydestä”.

Tämä on koko arkkitehtuurin perimmäinen ajatus - vanha ja uusi koodipohja elävät täysin omissa maailmoissaan tietämättä mitään toisistaan.

Uusi koodipohja vain ottaa sopivat tapahtumat kiinni.

Vanha ydinkoodi:


// Models/Pankkitili.php

public function saveCash(ICurrency $amount) {

	// Ei tarvetta transaktiolle kun lisätään rahaa -> menee aina läpi.

	// Lasketaan uusi saldo
	$this->balance = $this->balance + $amount->convert($this->accountCurrency);
	// Päivitetään muutos tietokantaan. 
	// Luo ja ampuu tapahtuman "pankkitili päivitetty!".
	$this->save();

	return true;
	
}

Yllä siis ydinkoodipohja. Koodi ei missään sisällä ekspliittistä käskyä ampua tapahtumaa. Tapahtuman luonti ja välitys järjestelmän muille komponenteille tapahtuu implisiittisesti, pinnan alla, Laravellin toimesta.

Kun lisätoiminnallisuutta järjestelmään lisätään, ylläolevaan koodipohjaan ei tarvitse koskea. Tämä on koko hajautetun, tapahtumien välitykseen perustuvan arkkitehtuurin keskeisin pointti.

Toteutetaan uusi raportointitoiminnallisuus lisäämällä palveluntarjoaja, jonka vastuulla on napata lennosta sopivat tapahtumat ja reagoida niihin.

// Providers/RaportointiPankkipoliisille.php

public function boot() {	

  // Ilmoitetaan halustamme kuunnella Pankkitiliin liittyviä "updated"-tapahtumia.
  Pankkitili::updated(function($tili) {
    // Tämä klosuuri ajetaan aina kun tilin saldo on päivittynyt.
    // Klosuurin sisällä käytössämme on $tili-objekti
    // $tili edustaa sitä Pankkitiliä, johon päivitys kohdistui.

    // Selvitetään uusi saldo lähettääksemme sen viranomaisille.
    $uusiSaldo = $tili->getBalance();

    $poliisi = new Pankkipoliisi(/*api-tunnukset tähän*/);

    // Ilmoitetaan käyttäjän uusi saldo, eli lähetetään käyttäjä-ID ja saldosumma.
    try {
      $poliisi->ilmoita(\Auth::user()->id(), $uusiSaldo);
    } catch (\Exception $e) {
      Log::warning('poliisi_ilmoitus_fail', $e);
    }
  });
}

Pankkipoliisin asetukset täytyy määritellä jossain config-tiedostossa, mutta periaate on ylläolevan mukainen.

Tiivistettynä:

  1. Vanhan koodipohjan (Pankkitili.php) ei tässä esimerkissä tarvinnut muuttua kun halusimme lisätä uuden toiminnallisuuden järjestelmään.
  2. Uusi palveluntarjoaja (RaportointiPankkipoliisille.php) lisäsi laajennuksen olemassaolevaan järjestelmään.
  3. Lisätyn laajennuksen määrittämä tapahtumakuuntelija nappaa sopivat tapahtumat kiinni ja reagoi niihin lähettämällä viestin viranomaisille.