« mooc.fi

Sisällysluettelo

Tehtävät

Osa 6

Kurssin kuudes osio alkaa edellisen osion sisältöä kertaavalla tehtävällä, missä korjataan erään verkkokauppasovelluksen tietoturvaongelmia. Tämän jälkeen tutustumme skaalautuvien sovellusten rakentamiseen sekä palveluperustaisiin arkkitehtuureihin.

Uhkamallinnus

Uhkamallinnus on lähestymistapa sovelluksen tietoturvan analysointiin. Uhkamallinnuksen voi jakaa kolmeen korkean tason askeleeseen: (1) Sovelluksen komponenttien ja niiden välisen kommunikaation analysointi, (2) Tietoturvariskien tunnistaminen ja niiden priorisointi, sekä (3) Suojamenetelmien tunnistaminen.

Sovelluksen komponenttien tunnistamisella sekä niiden välisen kommunikaation analysoinnilla selvitetään sovellukseen liittyvät kirjastot, palvelut sekä kommunikaatiomenetelmät. Tässä tarkastellaan olemassaolevia käyttötapauksia, sekä luodaan myös uusia käyttötapauksia, joiden perusteella päätellään miten järjestelmää käytetään. Samalla tunnistetaan kohdat, joiden kautta hyökkääjä voi päästä järjestelmään käsiksi ja tunnistetaan resurssit, joista hyökkääjä voisi olla kiinnostunut. Askeleen tuloksena on lista sovellukseen liittyvistä kirjastoista, palveluista, kommunikaatiomenetelmistä sekä resursseista, jonka lisäksi jokaisesta järjestelmään pääsyn mahdollistavasta kohdasta sekä järjestelmän resurssista kirjataan mahdolliset käyttäjätasot ja niiden mahdollistamat oikeudet.

Tietoturvariskien tunnistamisessa ja priorisoinnissa määritellään mahdollisia uhkia. Näitä lähestytään potentiaalisen hyökkääjän näkökulmasta, ja työhön liittyy valmiita kategorisointeja, joita voidaan käyttää muistilistana. Eräs kategorisaatio uhkille on STRIDE. STRIDE tulee sanoista Spoofing (hyökkääjä haluaa uskotella olevansa joku toinen), Tampering (hyökkääjä haluaa muokata dataa, tuloksia tai verkkoliikennettä, tai yleisesti häiritä järjestelmän toimintaa), Repudiation (hyökkääjä haluaa suorittaa järjestelmässä toimintoja, joista hän ei jää kiinni, ja joihin hänellä ei ole oikeuksia), Information Disclosure (hyökkääjä haluaa päästä käsiksi salaiseen tietoon, esim. tiedosto tai verkkoliikenne), Denial of Service (hyökkääjä haluaa estää muita käyttäjiä pääsemästä järjestelmään käsiksi), Elevation of Privilege (hyökkääjä haluaa järjestelmään tai järjestelmän resursseihin paremmat oikeudet, joiden avulla hänestä tulee luotettu käyttäjä, ja hän pääsee tekemään epätoivottuja toimintoja). Vaiheen tuloksena on lista uhkista: uhkiin liittyy myös sovelluskehittäjän kannalta vaikeat tapaukset kuten tilanne, missä sovelluksen käyttäjä käyttää järjestelmää esimerkiksi kirjastosta ja unohtaa kirjautua järjestelmästä ulos. Jokaiseen uhkaan liitetään myös riski, joka sisältää tiedon uhkaan toteutumiseen liittyvästä vaikutuksesta, todennäköisyydestä ja sen vähentämisestä sekä uhkan helppoudesta (mm. löydettävyys). Priorisointi perustuu näiden tekijöiden yhteisvaikutukseen: eräs priorisointimenetelmä on DREAD.

Kolmanteen vaiheeseen liittyy tietoturvariskeiltä suojautumiseen liittyvien menetelmien tunnistaminen. Tutustu teemaan tarkemmin osoitteessa https://www.owasp.org/index.php/Application_Threat_Modeling.

Huomaa, että uhkamallinnuksessa ei aina tarkastella sovelluksen sisäistä rakennetta. Tämä on kuitenkin tärkeää, sillä sovelluksen sisäinen logiikka sisältää usein virheitä, jotka saattavat myös johtaa ongelmiin. Alla olevassa tehtävässä tutustutaan tähän ja edellisen viikon teemoihin tarkemmin.

Tehtävässä on mukana verkkokauppasovellus, jonka kautta käyttäjät voivat tilata itselleen verkkokaupan tuotteita. Sovellukseen liittyy kuitenkin muutama tietoturvariski. Tässä kertaustehtävässä tehtävänäsi on tunnistaa nämä tietoturvariskit sekä korjata ne, joiden korjaaminen on mahdollista sovelluksesta.

Keskity tehtävässä käyttäjien oikeuksiin (autentikaatio), oikeuksien varmistamiseen (autorisaatio) sekä verkkokaupan sovelluslogiikan toimivuuteen verkkokaupan jatkuvuuden kannalta. Tehtävässä ei toistaiseksi ole automaattisia testejä.

Sovellusten skaalautuminen

Kun sovellukseen liittyvä liikenne ja tiedon määrä kasvaa niin isoksi, että sovelluksen käyttö takkuilee, tulee asialle tehdä jotain.

Olettaen, että sovelluksen konfiguraatio on kunnossa, sovelluksen skaalautumiseen on useampia lähtökohtia: (1) olemassaolevien resurssien käytön tehostaminen esimerkiksi välimuistitoteutusten ja palvelintehon kasvattamisen avulla, (2) resurssien määrän kasvattaminen esimerkiksi uusia palvelimia hankkimalla, (3) toiminnallisuuden jakaminen pienempiin vastuualueisiin ja palveluihin sekä näiden määrän kasvattaminen.

Sovellukset eivät tyypillisesti skaalaannu lineaarisesti, ja skaalautumiseen liittyy paljon muutakin kuin resurssien lisääminen. Jos yksi palvelin pystyy käsittelemään tuhat pyyntöä sekunnissa, emme voi olettaa, että kahdeksan palvelinta pystyy käsittelemään kahdeksantuhatta pyyntöä sekunnissa, sillä tehoon vaikuttavat myös muut käytetyt komponentit sekä verkkokapasiteetti. Skaalautumiseen ei ole olemassa yhtä oikeaa lähestymistapaa. Joskus tehokkaamman palvelimen hankkiminen on nopeampaa ja kustannustehokkaampaa kuin sovelluksen muokkaaminen -- esimerkiksi hitaasti toimiva tietokanta tehostuu tyypillisesti huomattavasti lisäämällä käytössä olevaa muistia, joskus taas käytetyn tietokantakomponentin vaihtaminen tehostaa sovellusta merkittävästi. Oleellista sovelluskehityksen kannalta on kuitenkin lähestyä ongelmaa pragmaattisesti ja optimoida käytettyjä henkilöresursseja; jos sovellus ei tule olemaan laajassa käytössä, ei sen skaalautumista kannata pitää tärkeimpänä sovelluksen ominaisuutena.

Palvelinpuolen välimuistit

Tyypillisissä web-palvelinohjelmistoissa huomattava osa kyselyistä on GET-tyyppisiä pyyntöjä. GET-tyyppiset pyynnöt eivät muokkaa palvelimella olevaa dataa, vaan pyytävät tietoa. Esimerkiksi tietokannasta dataa hakevat GET-tyyppiset pyynnöt luovat yhteyden tietokantasovellukseen, josta data haetaan. Jos näitä pyyntöjä on useita, eikä tietokannassa oleva data juurikaan muutu, kannattaa turhat tietokantakyselyt karsia.

Spring Bootia käytettäessä palvelimessa käytettävän välimuistin konfigurointi tapahtuu lisäämällä konfiguraatiotiedostoon annotaatio @EnableCaching. Oman välimuistitoteutuksen toteuttaminen tapahtuu luomalla CacheManager-rajapinnan toteuttava luokka sovellukseen. Jos taas omaa välimuistitoteutusta ei tee, etsii sovellus käynnistyessään välimuistitoteutusten (Ehcache, Hazelcast, Couchbase...) konfiguraatiotiedostoja. Jos näitä ei löydy, välimuistina käytetään yksinkertaista hajautustaulua.

Kun välimuisti on konfiguroitu, voimme lisätä välimuistitoiminnallisuuden palvelumetodeille @Cacheable-annotaation avulla. Alla olevassa esimerkissä metodin read palauttama tulos asetetaan välimuistiin.

@Service
public class MyService {

    @Autowired
    private MyRepository myRepository;

    @Cacheable("my-cache-key")
    public My read(Long id) {
        return myRepository.findOne(id);
    }

    // ...

Käytännössä annotaatio @Cacheable luo metodille read proxy-metodin, joka ensin tarkistaa onko haettavaa tulosta välimuistissa -- proxy-metodit ovat käytössä vain jos metodia kutsutaan luokan ulkopuolelta. Jos tulos on välimuistissa, palautetaan se sieltä, muuten tulos haetaan tietokannasta ja se tallennetaan välimuistiin. Metodin parametrina annettavia arvoja hyödynnetään cacheavaimen toteuttamisessa, eli jokaista haettavaa oliota kohden voidaan luoda oma tietue välimuistiin. Tutustu seuraavaksi Springin cache-dokumentaatioon.

Välimuistitoteutuksen vastuulla ei ole pitää kirjaa tietokantaan tehtävistä muutoksista, jolloin välimuistin tyhjentäminen muutoksen yhteydessä on sovelluskehittäjän vastuulla. Dataa muuttavat metodit tulee annotoida sopivasti annotaatiolla @CacheEvict, jotta välimuistista poistetaan muuttuneet tiedot.

Kumpulan kampuksella majaileva ilmatieteen laitos kaipailee pientä viritystä omaan sääpalveluunsa. Tällä hetkellä palvelussa on toiminnallisuus sijaintien hakemiseen ja lisäämiseen. Ilmatieteen laitos on lisäksi toteuttanut säähavaintojen lisäämisen suoraan tuotantotietokantaan, mihin ei tässä palvelussa päästä käsiksi. Palvelussa halutaan kuitenkin muutama lisätoiminnallisuus:

Lisää sovellukseen välimuistitoiminnallisuus. Osoitteisiin /locations ja /locations/{id} tehtyjen hakujen tulee toimia siten, että jos haettava sijainti ei ole välimuistissa, se haetaan tietokannasta ja tallennetaan välimuistiin. Jos sijainti taas on välimuistissa, tulee se palauttaa sieltä ilman tietokantahakua.

Lisää tämän jälkeen sovellukseen toiminnallisuus, missä käytössä oleva välimuisti tyhjennetään kun käyttäjä lisää uuden sijainnin tai tekee GET-tyyppisen pyynnön osoitteeseen /flushcaches. Erityisesti jälkimmäinen on tärkeä asiakkaalle, sillä se lisää tietokantaan tietoa myös palvelinohjelmiston ulkopuolelta.

Palvelinmäärän kasvattaminen

Skaalautumisesta puhuttaessa puhutaan käytännössä lähes aina horisontaalisesta skaalautumisesta, jossa käyttöön hankitaan esimerkiksi lisää palvelimia. Vertikaalinen skaalautumisen harkinta on mahdollista tietyissä tapauksissa, esimerkiksi tietokantapalvelimen ja -kyselyiden toimintaa suunniteltaessa, mutta yleisesti ottaen horisontaalinen skaalautuminen on kustannustehokkaampaa. Käytännöllisesti ajatellen kahden viikon ohjelmointityö kymmenen prosentin tehonparannukseen on tyypillisesti kalliimpaa kuin muutaman päivän konfiguraatiotyö ja uuden palvelimen hankkiminen. Käyttäjien määrän kasvaessa uusien palvelinten hankkiminen on joka tapauksessa vastassa.

Pyyntöjen määrän kasvaessa yksinkertainen ratkaisu on palvelinmäärän eli käytössä olevan raudan kasvattaminen. Tällöin pyyntöjen jakaminen palvelinten kesken hoidetaan erillisellä kuormantasaajalla (load balancer), joka ohjaa pyyntöjä palvelimille.

Jos sovellukseen ei liity tilaa (esimerkiksi käyttäjän tunnistaminen tai ostoskori), kuormantasaaja voi ohjata pyyntöjä käytössä oleville palvelimille round-robin -tekniikalla. Jos sovellukseen liittyy tila, tulee tietyn asiakkaan tekemät pyynnöt ohjata aina samalle palvelimelle, sillä evästeet tallennetaan oletuksena palvelinkohtaisesti. Tämän voi toteuttaa esimerkiksi siten, että kuormantasaaja lisää pyyntöön evästeen, jonka avulla käyttäjä identifioidaan ja ohjataan oikealle palvelimelle. Tätä lähestymistapaa kutsutaan termillä (sticky session).

Pelkkä palvelinmäärän kasvattaminen ja kuormantasaus ei kuitenkaan aina riitä. Kuormantasaus helpottaa verkon kuormaa, mutta ei ota kantaa palvelinten kuormaan. Jos yksittäinen palvelin käsittelee pitkään kestävää laskentaintensiivistä kyselyä, voi kuormantasaaja ohjata tälle palvelimelle lisää kyselyjä "koska eihän se ole vähään aikaan saanut mitään töitä". Käytännössä tällöin entisestään paljon laskentaa tekevä palvelimen saa lisää kuormaa. On kuitenkin mahdollista käyttää kuormantasaajaa, joka lisäksi pitää kirjaa palvelinten tilasta, mutta käytännössä kuorma vaihtuu usein hyvin nopeasti, ja reagointi ei aina ole nopeaa.

Parempi ratkaisu palvelinmäärän kasvattamiselle on palvelinmäärän kasvattaminen ja sovelluksen suunnittelu siten, että laskentaintensiiviset operaatiot käsitellään erillisillä palvelimilla. Tällöin käytetään käytännössä erillistä laskentaklusteria aikaa vievien laskentaoperaatioiden käsittelyyn, jolloin käyttäjän pyyntöjä kuuntelevan palvelimen kuorma pysyy alhaisena.

Riippuen pyyntöjen määrästä, palvelinkonfiguraatio voidaan toteuttaa jopa siten, että staattiset tiedostot (esim. kuvat) löytyvät erillisiltä palvelimilta, GET-pyynnöt käsitellään erillisillä pyyntöjä vastaanottavilla palvelimilla, ja datan muokkaamista tai prosessointia vaativat kyselyt (esim POST) ohjataan asiakkaan pyyntöjä vastaanottavien palvelinten toimesta laskentaklusterille.

Palvelinmäärän kasvattaminen onnistuu myös tietokantapuolella. Tällöin käyttöön tulevat tyypillisesti hajautetut tietokantapalvelut kuten Apache Cassandra ja Apache Geode. Riippumatta käyttöön valitusta teknologiasta, aiemmin käyttämämme Spring Data JPA:n ohjelmointimalli sopii myös näihin tietokantoihin: esimerkiksi Cassandran käyttöönottoon löytyy ohjeistusta osoitteesta http://projects.spring.io/spring-data-cassandra/.

Tiedostojen jakaminen ja tietokannat

Kun sovelluksen kasvu saavuttaa pisteen, missä yksittäisestä tietokantapalvelimesta siirrytään useamman palvelimen käyttöön, on hyvä hetki miettiä sovelluksen tietokantarakennetta. Tietokantojen määrän kasvaessa numeeristen tunnusten (esim Long) käyttäminen tunnisteena on ongelmallista. Jos tietokantataulussa on numeerinen tunnus ja useampi sovellus luo uusia tietokantarivejä, tarvitaan erillinen palvelu tunnusten antamiselle -- tämän palvelun kaatuessa koko sovellus voi kaatua. Toisaalta, jos palvelua ei ole toteutettu hyvin, on tunnusten törmäykset mahdollisia, mikä johtaa helposti tiedon katoamiseen. Numeeristen avainten käyttö erityisesti osoitteiden yhteydessä tekee niistä myös helposti arvattavia, mikä voi myös luoda tietoturvariskejä yhdessä huonosti toteutetun pääsynvalvonnan kanssa. Yhtenä vaihtoehtona numeerisille tunnuksille on ehdotettu UUID-pohjaisia merkkijonotunnuksia, jotka voidaan luoda ennen olion tallentamista tietokantaan.

Spring Data JPAn tapauksessa tämä tarkoittaa sitä, että AbstractPersistable-luokan periminen ei onnistu kuten ennen. Voimme kuitenkin toteuttaa oman UUIDPersistable-luokan, joka luo tunnuksen automaattisesti.

@MappedSuperclass
public abstract class UUIDPersistable implements Persistable<String> {

    @Id
    private String id;

    public UUIDPersistable() {
        this.id = UUID.randomUUID().toString();
    }

    public String getId() {
        return this.id;
    }

    public void setId(String id) {
        this.id = id;
    }

    @JsonIgnore
    @Override
    public boolean isNew() {
        return false;
    }

    // muuta mahdollista
}

Ylläoleva toteutus luo uuden id-avaimen olion luontivaiheessa, jolloin se on käytössä jo ennen olion tallentamista tietokantaan. Rajapinta Persistable on rajapinta, jota Spring Data -projektit käyttävät olioiden tallennuksessa erilaisiin tietokantoihin.

Nyt voimme luoda merkkijonotunnusta käyttävän entiteetin seuraavasti:

@Entity
public class Person extends UUIDPersistable {

    private String name;

    public String getName() {
        return this.name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

Tehtäväpohjassa on kuvapalvelu, joka tarjoaa toiminnallisuutta kuvien listaukseen ja pienennykseen.

Tällä hetkellä kuvan lisääminen ohjaa käyttäjän suoraan /images-osoitteeseen, eli palvelun juureen. Muuta sovellusta siten, että käyttäjä ohjataan uuteen osoitteeseen /images/{id}, missä id on juuri luodun kuvan merkkijonoavain. Toteuta myös sopiva kontrolleri, joka toimii yhteistyössä index.html-näkymässä olevan koodin kanssa.

Kun käyttäjä hakee kuvaa, tällä hetkellä kuva haetaan aina tietokannasta. Muokkaa kuvien lähettämistä siten, että käyttäjälle palautetaan kuvan mukana myös ETag-otsake, jonka arvoksi on asetettu kyseisen kuvan id-kentän arvo (huom! alkuperäisillä kuvilla ja thumbnaileilla on eri arvot.). Seuraavan kerran kun käyttäjä pyytää samaa kuvaa, hän lähettää pyynnön mukana myös If-None-Match-otsakkeen, joka sisältää aiemmin lähetetyn ETag-otsakkeen arvon. Tässä lienee apua annotaatiosta @RequestHeader.

Riippumatta otsakkeen If-None-Match arvosta, palauta vastauksena vain statuskoodi 304, eli ei muokattu. Huom! Älä lähetä kuvaa tällöin vastauksessa -- käytä vain statuskoodia.

Evästeet ja useampi palvelin

Kun käyttäjä kirjautuu palvelinohjelmistoon, tieto käyttäjästä pidetään tyypillisesti yllä sessiossa. Sessiot toimivat evästeiden avulla, jotka palvelin asettaa pyynnön vastaukseen, ja selain lähettää aina palvelimelle. Sessiotiedot ovat oletuksena yksittäisellä palvelimella, mikä aiheuttaa ongelmia palvelinmäärän kasvaessa. Edellä erääksi ratkaisuksi mainittiin kuormantasaajien (load balancer) käyttö siten, että käyttäjät ohjataan aina samalle koneelle. Tämä ei kuitenkaan ole aina mahdollista -- kuormantasaajat eivät aina tue sticky session -tekniikkaa -- eikä kannattavaa -- kun palvelinmäärää säädellään dynaamisesti, uusi palvelin käynnistetään tyypillisesti vasta silloin, kun havaitaan ruuhkaa -- olemassaolevat käyttäjät ohjataan ruuhkaantuneelle palvelimelle uudesta palvelimesta riippumatta.

Yksi vaihtoehto on tunnistautumisongelman siirtäminen tietokantaan -- skaalautumista helpottaa tietokannan hajauttaminen esimerkiksi käyttäjätunnusten perusteella. Sen sijaan, että käytetään palvelimen hallinnoimia sessioita, pidetään käyttäjätunnus ja kirjautumistieto salattuna evästeessä. Eväste lähetetään kaikissa tiettyyn osoitteeseen tehtävissä kutsuissa; palvelin voi tarvittaessa purkaa evästeessä olevan viestin ja hakea käyttäjään liittyvät tiedot tietokannasta.

Tässä tehtävässä hiotaan Springin PersistentTokenBasedRememberMeServices-komponenttia luomalla siihen JPA:ta käyttävä tietokantatoiminnallisuus. PersistentTokenBasedRememberMeServices-komponentti tarjoaa evästeisiin perustuvan autentikaation, joka perustuu seuraaviin askeleisiin:

  1. Kun käyttäjä kirjautuu siten, että hän valitsee Remember Me-vaihtoehdon, käyttäjälle annetaan eväste.
  2. Eväste sisältää käyttäjän käyttäjätunnuksen, evästeen sarjanumeron, sekä viestin. Sarjanumero ja viesti ovat satunnaisesti generoituja ja ne tallennetaan tietokantaan.
  3. Kun käyttäjä vierailee sivulla, hän lähettää pyynnössä evästeen. Tällöin käyttäjätunnus, sarjanumero ja viesti haetaan tietokannasta.
  4. Jos kaikki edellämainitut löytyvät tietokannasta, oletetaan että käyttäjä on kirjautunut sivulle. Samalla käytetty viesti poistetaan tietokannasta, ja tietokantaan luodaan uusi satunnainen viesti aiemmin käytettyjen sarjanumeron ja käyttäjätunnuksen pariksi. Käyttäjälle palautetaan uusi eväste, missä on uudet tiedot.
  5. Jos käyttäjätunnus ja sarjanumero on oikein, mutta viesti on väärin, oletetaan että joku on yrittänyt ryövätä käyttäjän tunnuksen ja pyydetään käyttäjää kirjautumaan. Samalla kaikki käyttäjään liittyvät aiemmat evästetiedot / kirjautumisdatat poistetaan tietokannasta.
  6. Jos käyttäjätunnusta ja sarjanumeroa ei ole olemassa, pyydetään käyttäjää kirjautumaan.

Ylläolevan toteutuksen etuna on se, että se skaalautuu melko hyvin. Palvelinten määrää voi kasvattaa niin pitkään kuin tietokantatason toiminta toimii hyväksyttävällä tasolla.

Projektiin on toteutettu valmiiksi entiteettiluokka CustomPersistentToken sekä PersistentTokenBasedRememberMeServices-palvelun tarvitseman PersistentTokenRepository-rajapinnan toteuttavan luokan runko, joka löytyy sijainnista wad.auth.CustomPersistentTokenService.

Toteuta tarvittava toiminnallisuus JPA-tuen aikaansaamiseksi. Tarvitset ainakin palvelun CustomPersistentToken-entiteetin tallentamiseen, jonka lisäksi tarvitset sopivaa logiikkaa CustomPersistentTokenService-luokkaan; voit ottaa mallia esimerkiksi Springin vastaavasta JDBC-rajapintaa käyttävästä JdbcTokenRepositoryImpl-luokasta, missä vastaava toteutus on tehty ilman JPA:ta -- voit käytännössä hyödyntää samaa toimintaideaa omassa koodissasi. Lue siis JdbcTokenRepositoryImpl-luokan toteutus, ja kopioi sen logiikka omaan JPA-pohjaiseen toteutukseesi.

Asynkroniset metodikutsut ja rinnakkaisuus

Jokaiselle palvelimelle tulevalle pyynnölle määrätään säie, joka on varattuna pyynnön käsittelyn loppuun asti. Jokaisen pyynnön käsittelyyn kuuluu ainakin seuraavat askeleet: (1) pyyntö lähetetään palvelimelle, (2) palvelin vastaanottaa pyynnön ja ohjaa pyynnön oikealle kontrollerille, (3) kontrolleri vastaanottaa pyynnön ja ohjaa pyynnön oikealle palvelulle tai palveluille, (4) palvelu vastaanottaa pyynnön, suorittaa pyyntöön liittyvät operaatiot muiden palveluiden kanssa, ja palauttaa lopulta vastauksen metodin suorituksen lopussa, (5) kontrolleri ohjaa pyynnön sopivalle näkymälle, ja (6) vastaus palautetaan käyttäjälle. Pyyntöä varten on palvelimella varattuna säie kohdissa 2-6. Jos jonkun kohdan suoritus kestää pitkään -- esimerkiksi palvelu tekee pyynnön toiselle palvelimelle, joka on hidas -- on säie odotustilassa.

Palvelukutsun suorituksen odottaminen ei kuitenkaan aina ole tarpeen. Jos sovelluksemme suorittaa esimerkiksi raskaampaa laskentaa, tai tekee pitkiä tietokantaoperaatioita joiden tuloksia käyttäjän ei tarvitse nähdä heti, kannattaa pyyntö suorittaa asynkronisesti. Asynkronisella metodikutsulla tarkoitetaan sitä, että asynkronista metodia kutsuva metodi ei jää odottamaan metodin tuloksen valmistumista. Jos edellisissä askeleissa kohta 4 suoritetaan asynkronisesti, ei sen suoritusta tarvitse odottaa loppuun.

Ohjelmistokehykset toteuttavat asynkroniset metodikutsut luomalla palvelukutsusta erillisen säikeen, jossa pyyntö käsitellään. Spring Bootin tapauksessa asynkroniset metodikutsut saa käyttöön lisäämällä sovelluksen konfiguraatioon (tapauksessamme usein Application-luokassa) rivi @EnableAsync. Kun konfiguraatio on paikallaan, voimme suorittaa metodeja asynkronisesti. Jotta metodisuoritus olisi asynkroninen, tulee metodin olla void-tyyppinen, sekä sillä tulee olla annotaatio @Async.

Tutkitaan tapausta, jossa tallennetaan Item-tyyppisiä olioita. Item-olion sisäinen muoto ei ole niin tärkeä.

    @RequestMapping(method = RequestMethod.POST)
    public String create(@ModelAttribute Item item) {
        itemService.create(item);
        return "redirect:/items";
    }

Oletetaan että ItemService-olion metodi create on void-tyyppinen, ja näyttää seuraavalta:

    public void create(Item item) {
        // koodia.. 
    }

Metodin muuttaminen asynkroniseksi vaatii @Async-annotaation ItemService-luokkaan.

    @Async
    public void create(Item item) {
        // koodia.. 
    }

Käytännössä asynkroniset metodikutsut toteutetaan asettamalla metodikutsu suoritusjonoon, josta se suoritetaan kun sovelluksella on siihen mahdollisuus.

Tehtäväpohjassa on sovellus, joka tekee raskasta laskentaa (nukkuu kuin tutkijat). Tällä hetkellä käyttäjä joutuu odottamaan laskentapyynnön suoritusta pitkään, mutta olisi hienoa jos käyttäjälle kerrottaisiin laskennan tilasta jo laskentavaiheessa.

Muokkaa sovellusta siten, että laskenta tallennetaan kertaalleen jo ennen laskentaa -- näin siihen saadaan viite; aseta oliolle myös status "PROCESSING". Muokkaa tämän jälkeen luokkaa CalculationService siten, että laskenta tapahtuu asynkronisesti.

Huom! Älä poista CalculationService-luokasta koodia

        try {
            Thread.sleep(2000);
        } catch (InterruptedException ex) {
            Logger.getLogger(CalculationService.class.getName()).log(Level.SEVERE, null, ex);
        }

Kun sovelluksesi toimii oikein, laskennan lisäyksen pitäisi olla nopeaa ja käyttäjä näkee lisäyksen jälkeen laskentakohtaisen sivun, missä on laskentaan liittyvää tietoa. Kun sivu ladataan uudestaan noin 2 sekunnin kuluttua, on laskenta valmistunut.

Rinnakkain suoritettavat metodikutsut

Koostepalvelut, eli palvelut jotka keräävät tietoa useammasta palvelusta ja yhdistävät tietoja käyttäjälle, tyypillisesti haluavat näyttää käyttäjälle vastauksen.

Näissä tilanne on usein se, että palveluita on useita, ja niiden peräkkäinen suorittaminen on tyypillisesti hidasta. Suoritusta voi nopeuttaa ottamalla käyttöön rinnakkaisen suorituksen, joka onnistuu esimerkiksi Javan ExecutorService-luokan avulla. Voimme käytännössä lisätä tehtäviä niitä suorittavalle palvelulle, jolta saamme viitteen tulevaa vastausta varten.

Spring tarjoaa myös tähän apuvälineitä. Kun lisäämme sovellukselle AsyncTaskExecutor-rajapinnan toteuttaman olion (esimerkiksi ThreadPoolTaskExecutor), voimme injektoida sen sovelluksemme käyttöön tarvittaessa. Tietynlaisen olion sovellukseen tapahtuu luomalla @Bean-annotaatiolla merkitty olio konfiguraatiotiedostossa. Alla esimerkiksi luodaan edellämainitut oliot.

// konfiguraatiotiedosto
    @Bean
    public AsyncTaskExecutor asyncTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(8);
        return executor;
    }

Nyt voimme ottaa käyttöön sovelluksessa AsyncTaskExecutor-rajapinnan toteuttavan olion.

    @Autowired
    private AsyncTaskExecutor taskExecutor;

Käytännössä tehtävien lisääminen rinnakkaissuorittajalle tapahtuu esimerkiksi seuraavasti. Alla luodaan kolme Callable-rajapinnan toteuttavaa oliota, annetaan ne taskExecutor-ilmentymälle, ja otetaan jokaisen kohdalla talteen Future-viite, mihin suorituksen tulos asetetaan kun suoritus on loppunut. Future-oliosta saa tuloksen get-metodilla.

    // käytössä myös ylläoleva taskExecutor
    List<Future<TuloksenTyyppi>> results = new ArrayList<>();

    results.add(taskExecutor.submit(new Callable<TuloksenTyyppi>() {
        @Override
        public TuloksenTyyppi call() {
            // laskentaa.. -- tulos voi olla käytännössä mitä tahansa
            return new TuloksenTyyppi();
        }
    }));
    
    for (Future<TuloksenTyyppi> result: results) {
        TuloksenTyyppi t = result.get();

        // tee jotain tällä..
    }

Tehtäväpohjaan on lähdetty toteuttamaan sovellusta, joka etsii eri palveluiden rajapinnoista halutun esineen hintaa ja palauttaa halvimman. Palvelusta on toteutettu ensimmäinen versio, mutta se on liian hidas.

Ennenkuin sovelluskehittäjät juoksevat hakemaan uutta rautaa, muokkaa palvelun QuoteService-toiminnallisuutta siten, että se suorittaa hintakyselyt rinnakkain nykyisen peräkkäissuorituksen sijaan.

Viestijonot

Kun palvelinohjelmistoja skaalataan siten, että osa laskennasta siirretään erillisille palvelimille, on oleellista että palveluiden välillä kulkevat viestit (pyynnöt ja vastaukset) eivät katoa, ja että käyttäjän pyyntöjä vastaanottavan palvelimen ei tarvitse huolehtia toisille palvelimille lähetettyjen pyyntöjen perille menemisestä tai lähetettyjen viestien vastausten käsittelystä. Eniten käytetty lähestymistapa viestien säilymisen varmentamiseen on viestijonot (messaging, message queues), joiden tehtävänä on toimia viestien väliaikaisena säilytyspisteenä. Käytännössä viestijonot ovat erillisiä palveluita, joihin viestien tuottajat (producer) voivat lisätä viestejä, joita viestejä käyttävät palvelut kuluttavat (consumer).

Viestijonoja käyttävät sovellukset kommunikoivat viestijonon välityksellä. Tuottaja lisää viestejä viestijonoon, josta käyttäjä niitä hakee. Kun viestin sisältämän datan käsittely on valmis, prosessoija lähettää viestin takaisin. Viestijonoissa on yleensä varmistustoiminnallisuus: jos viestille ei ole vastaanottajaa, jää viesti viestijonoon ja se tallennetaan esimerkiksi viestijonopalvelimen levykkeelle. Viestijonojen konkreettinen toiminnallisuus riippuu viestijonon toteuttajasta.

Viestijonosovelluksia on useita, esimerkiksi ActiveMQ ja RabbitMQ. Viestijonoille on myös useita standardeja, joilla pyritään varmistamaan sovellusten yhteensopivuus. Esimerkiksi Javan melko pitkään käytössä ollut JMS-standardi määrittelee viestijonoille rajapinnan, jonka viestijonosovelluksen tarjoajat voivat toteuttaa. Nykyään myös AMQP-protokolla on kasvattanut suosiotaan. Myös Spring tarjoaa komponentteja viestijonojen käsittelyyn, tutustu lisää aiheeseen täällä.

Palvelukeskeiset arkkitehtuurit

Monoliittisten "minä sisällän kaiken mahdollisen"-sovellusten ylläpitokustannukset kasvavat niitä kehitettäessä, sillä uuden toiminnallisuuden lisääminen vaatii olemassaolevan sovelluksen muokkaamista sekä testaamista. Olemassaoleva sovellus voi olla kirjoitettu hyvin vähäisesssä käytössä olevalla kielellä (vrt. pankkijärjestelmät ja COBOL) ja esimerkiksi kehitystä tukevat automaattiset testit voivat puuttua siitä täysin. Samalla myös uusien työntekijöiden tuominen ohjelmistokehitystiimiin on vaikeaa, sillä sovellus voi hoitaa montaa vastuualuetta samaan aikaan.

Yrityksen toiminta-alueiden laajentuessa sekä uusien sovellustarpeiden ilmentyessä aiemmin toteutettuihin toiminnallisuuksiin olisi hyvä päästä käsiksi, mutta siten, että toiminnallisuuden käyttäminen ei vaadi juurikaan olemassaolevan muokkausta. Koostamalla sovellus erillisistä palveluista saadaan luotua tilanne, missä palvelut ovat tarvittaessa myös uusien sovellusten käytössä. Palvelut tarjoavat rajapinnan (esim. REST) minkä kautta niitä voi käyttää. Samalla rajapinta kapseloi palvelun toiminnan, jolloin muiden palvelua käyttävien sovellusten ei tarvitse tietää sen toteutukseen liittyvistä yksityiskohdista. Oleellista on, että yksikään palvelu ei yritä tehdä kaikkea. Tämä johtaa myös siihen, että yksittäisen palvelun toteutuskieli tai muut teknologiset valinnat ei vaikuta muiden komponenttien toimintaan -- oleellista on vain se, että palvelu tarjoaa rajapinnan jota voi käyttää ja joka löydetään.

Yrityksen kasvaessa sen sisäiset toiminnat ja rakennettavat ohjelmistot sisältävät helposti päällekkäisyyksiä. Tällöin tilanne on käytännössä se, että aikaa käytetään samankaltaisten toimintojen ylläpitoon useammassa sovelluksessa -- pyörä keksitään yhä uudestaan ja uudestaan uudestaan uusia sovelluksia kehitettäessä.

SOA (Service Oriented Architecture), eli palvelukeskeinen arkkitehtuuri, on suunnittelutapa, jossa eri sovelluksen komponentit on suunniteltu toimimaan itsenäisinä avoimen rajapinnan tarjoavina palveluina. Pilkkomalla sovellukset erillisiin palveluihin luodaan tilanne, missä palveluita voidaan käyttää myös tulevaisuudessa kehitettävien sovellusten toimesta. Palveluita käyttävät esimerkiksi toiset palvelut tai selainohjelmistot. Selainohjelmistot voivat hakea palvelusta JSON-muotoista dataa Javascriptin avulla ilman tarvetta omalle palvelinkomponentille. SOA-arkkitehtuurin avulla voidaan helpottaa myös ikääntyvien sovellusten jatkokäyttöä: ikääntyvät sovellukset voidaan kapseloida rajapinnan taakse, jonka kautta sovelluksen käyttö onnistuu myös jatkossa.

Rakennetaan seuraavaksi muutama palvelu, joiden toiminnallisuus yhdistetään lopulta.

Toteuta Spring Data RESTin avulla REST-rajapinta huoneistojen hallintaan.

Jokaisella huoneistolla tulee olla uniikki nimi (name), joka ei saa olla tyhjä. Huoneistojen lisäys tapahtuu tekemällä JSON-muotoinen POST-pyyntö osoitteeseen /api/apartments (esim. {"name":"The Cupboard Under the Stairs"}). Vastaavasti GET-pyyntö osoitteeseen /api/apartments palauttaa HAL-spesifikaatiota seuraavan JSON-vastauksen, missä huoneet on listattu.

yksittäisen huoneiston haku ja poisto tapahtuu osoitteessa /api/apartments/{id}, missä id on huoneiston uniikki tunnus.

Käytä huoneiston tunnuksena (id) Long-tyyppistä muuttujaa.

Toteutetaan sovellus henkilöiden luomiseen.

Toteuta Spring Data RESTin avulla REST-rajapinta henkilöiden hallintaan.

Jokaisella henkilöllä tulee olla uniikki nimi (name), uniikki käyttäjätunnus (username) sekä salasana (password), joista yksikään ei saa olla tyhjä.

Henkilöiden lisäys tapahtuu tekemällä JSON-muotoinen POST-pyyntö osoitteeseen /api/persons (esim. {"name":"Harry Potter", "username":"hedwig", "password":"nimbus2000"}).

Vastaavasti GET-pyyntö osoitteeseen /api/persons palauttaa HAL-spesifikaatiota seuraavan JSON-vastauksen, missä henkilöt on listattu.

Yksittäinen henkilö voidaan hakea tunnuksen perusteella osoitteesta /api/persons/{id}, missä id on henkilön uniikki tunnus. Poistamisen ei kuitenkaan tule onnistua.

Käytä henkilön tunnuksena (id) Long-tyyppistä muuttujaa.

Huom! Toteuta toiminnallisuus siten, että GET-pyynnön yhteydessä henkilön salasanaa ei palauteta. Vastauksen tulee siis sisältää aina vain nimi ja käyttäjätunnus. Etsi apua Googlesta, avainsanoja ovat ainakin @JsonProperty, @JsonIgnore sekä esimerkiksi haku "json ignore property on deserialization but allow on serialization".

Lisää tämän jälkeen sovellukseen rajapinta /authenticate, jonka avulla voidaan tarkistaa löytyykö käyttäjärekisteristä sopiva käyttäjätunnus-salasana -pari. Rajapinnalle voidaan tehdä POST-tyyppinen pyyntö JSON-muodossa. JSON-data sisältää käyttäjätunnus-salasana -parin ({"username":"tunnus","password":"jackbauer"}). Jos tietokannasta löytyy käyttäjä annetulla käyttäjätunnuksella ja salasanalla, metodin tulee palauttaa statuskoodi 200 eli "OK", sekä käyttäjän nimi vastauksen rungossa. Jos käyttäjää ei löydy, palautettavan arvon tulee olla 401 eli "Unauthorized".

Toteuta vastaus siten että autentikointiin käytettävä kontrollerimetodi palauttaa ResponseEntity-olion. ResponseEntitylle voi määritellä vastauksen statuskoodin sekä rungon. Alla oleva ResponseEntity-olion runko sisältää merkkijonon "jack bauer" ja palauttaa statuskoodin 200 eli "OK".

		    ResponseEntity<String> vastaus = new ResponseEntity<>("jack bauer", HttpStatus.OK);

Jatkokehitetään sovellusta huoneistojen varaamiseen ja varaustilanteen tarkasteluun. Käytössäsi on huoneistojen käsittelyyn tarvittava rajapinta, jonka palveluntarjoaja on toteuttanut sinua varten. Rajapintaa käytetään ApartmentService-luokan avulla, joka löytyy pakkauksesta wad.ext.apartments.

Voit käyttää Huoneistot tehtävän vastausta osana tätä tehtävää:

  1. Paketoi tehtävä Huoneistot komennolla mvn clean package
  2. Käynnistä huoneistot-sovellus komennolla java -Dserver.port=12345 -jar target/Huoneistot-1.0-SNAPSHOT.jar
  3. Tämä käynnistää Huoneistot sovelluksen paikallisen koneesi porttiin 12345 -- sovellus siis osoitteessa http://localhost:12345 ja sen tarjoamaa rajapintaa voi hyödyntää osoitteesta http://localhost:12345/api.

Tällä hetkellä sovelluksen tarjoama polussa /api/reservations oleva rajapinta mahdollistaa uusien varausten tekemisen sekä varausten poistamisen. Muokkaa rajapintaa siten, että sen kautta voi vain hakea tämänhetkisen varaustilanteen, mutta ei voi tehdä muutoksia siihen.

Lisää tämän jälkeen sovellukseen kontrolleri, joka kuuntelee osoitteeseen /reservations-tehtäviä pyyntöjä. Kun osoitteeseen tehdään GET-tyyppinen pyyntö, tulee pyynnön modeliin lisätä sekä kaikki olemassaolevat varaukset että kaikki asunnot.

Lisää asunnot modeliin parametrin nimellä "apartments" -- huoneistoihin pääset käsiksi ApartmentService-toteutuksen avulla. Varausten tulee olla modelissa parametrilla "reservations". Näytä käyttäjälle polussa /src/main/resources/templates/reservations.html olevasta näkymästä luotu sivu.

Tämän lisäksi, kun osoitteeseen /reservations tehdään POST-pyyntö, missä on varauksen tiedot, varaus tulee tallentaa tietokantaan. POST-pyynnön mukana tulee muuttujat reservationStart, reservationEnd sekä apartmentId -- voit todennäköisesti hyödyntää luokkaa Reservation tässä. Kun otat pyynnön vastaan, lisää varaukseen varattavan huoneiston nimi -- voit hakea yksittäisen huoneiston tiedot huoneiston tunnuksen perusteella ApartmentService-palvelusta. Muistathan että POST-pyynnön jälkeen pyyntö tulee aina uudelleenohjata.

Huom! Lisää kontrollerille lisäksi @PostConstruct-annotaatiolla merkitty metodi, joka suoritetaan kun kontrolleri on ladattu. Aseta siinä ApartmentService-palvelulle osoite -- käytä aiempaa huoneistotehtävän vastausta testaukseen.


Muokkaa tämän jälkeen varaustoiminnallisuutta siten, että päällekkäisten varausten tekeminen ei onnistu. Samaa huoneistoa ei siis tule voida varata kahdesti samalle aikajaksolle tai osittain päällekkäiselle aikajaksolle.

Kun varausten rajaus toimii, muokkaa vielä varauksen maksuun liittyvää toiminnallisuutta. Jokaiseen varaukseen liittyy muuttuja paymentStatus, joka asetetaan varauksen luonnin yhteydessä "UNPAID"-tilaan, eli maksamattomaksi. Lisää sovellukseen toiminto, joka muuttaa varauksen maksutilaksi "PAID". Tilan tulee muuttua jos osoitteeseen /reservations/{id}/payment tehdään POST-tyyppinen pyyntö -- tässä id on varauksen uniikki tunnus (id).

Nyt varausten maksu voitaisiin periaatteessa hoitaa erillisessä palvelussa, joka päivittäisi varauksen tilan maksun yhteydessä.