Tämä materiaali on lisensoitu Creative Commons BY-NC-SA-lisenssillä, joten voit käyttää ja levittää sitä vapaasti, kunhan alkuperäisten tekijöiden nimiä ei poisteta. Jos teet muutoksia materiaaliin ja haluat levittää muunneltua versiota, se täytyy lisensoida samanlaisella vapaalla lisenssillä. Materiaalien käyttö kaupalliseen tarkoitukseen on ilman erillistä lupaa kielletty.

Web-palvelinohjelmointi

Lukijalle

Tämä materiaali on tarkoitettu Helsingin yliopiston tietojenkäsittelytieteen laitoksen syksyn 2012 kurssille web-palvelinohjelmointi. Materiaaliin on vaikuttanut vahvasti Helsingin yliopistossa keväällä 2012 järjestetty kurssi web-sovellusohjelmointi, sekä siitä saatu palaute. Materiaalin kirjoittaja on Arto Vihavainen ja sen syntyyn ovat vaikuttaneet useat tahot, joista tärkeimmät ovat Matti Luukkainen ja Mikael Nousiainen. Syksyn kurssimateriaaliin liittyvistä ehdotuksista ja ajatuksista tulee kiittää muun muassa Kasper Hirvikoskea ja Hansi Keijosta. Erityinen kiitos kuuluu myös Martin Pärtelille, joka on mahdollistanut TMC:n käytön kurssilla.

Materiaali päivittyy kurssin edetessä ja sisältää myös kurssiin liittyvät tehtävät. Tehtävien lisäksi materiaali sisältää kysymysmerkillä merkittyjä pohdi-kohtia, joissa pääsee pohtimaan juuri tutuksi tullutta asiaa esimerkin kautta. Lampuilla merkityt kohdat taas sisältävät mm. arvokkaita vinkkejä erilaisista työkaluista.

Lue materiaalia siten, että teet samalla itse kaikki lukemasi esimerkit. Esimerkkeihin kannattaa tehdä pieniä muutoksia ja tarkkailla, miten muutokset vaikuttavat ohjelman toimintaan. Äkkiseltään voisi luulla, että esimerkkien tekeminen ja muokkaaminen hidastaa opiskelua. Tämä ei kuitenkaan pidä ollenkaan paikkansa. Oppiminen perustuu oleellisesti aktiiviseen tekemiseen ja rutiinin kasvattamiseen. Esimerkkien ja erityisesti omien kokeilujen tekeminen on parhaita tapoja sisäistää luettua tekstiä. Esimerkkejä tehdessä kannattaa kirjoittaa ne itse. Koodin copy-paste ei ole oppimisen kannalta yhtä tehokasta kuin itse kirjoittaminen.

Pyri tekemään tai ainakin yrittämään tehtäviä sitä mukaa kuin luet tekstiä. Jos et osaa heti tehdä jotain tehtävää, älä masennu, sillä saat ohjausaikoina neuvoja tehtävien tekemiseen.

Tekstiä ei ole tarkoitettu vain kertaalleen luettavaksi. Joudut varmasti myöhemmin palaamaan aiemmin lukemiisi kohtiin tai aiemmin tekemiisi tehtäviin. Tämä teksti ei sisällä kaikkea oleellista web-palvelinohjelmointiin liittyvää. Itse asiassa ei ole olemassa mitään kirjaa josta löytyisi kaikki oleellinen. Eli joudut joka tapauksessa ohjelmoijan urallasi etsimään tietoa myös omatoimisesti. Harjoitukset sisältävät jo jonkun verran ohjeita, mistä suunnista ja miten hyödyllistä tietoa on mahdollista löytää.

Jos (ja kun) materiaalista löytyy esimerkiksi kirjoitusvirheitä, raportoikaa niistä esimerkiksi kurssikanavalla. Tähän mennessä materiaalin kirjoitusvirheiden lahtaamisessa ovat auttaneet muun muassa nimimerkit Zeukkari, BiQ, vaakapallo, danu, smu, hubbard, Juusoh, gleant ja Pro|. Kiitos heille!

Web-sovelluksista yleisesti

Web-sovellukset koostuvat yleisesti ottaen kahdesta: palvelinpuolesta ja selainpuolesta. Palvelinpuoli hoitaa palvelimella tapahtuvan toiminnan, esimerkiksi tietokannan muokkauksen ja erilaiset laskentaoperaatiot. Selaimessa taas näytetään käyttäjälle data ja lähetetään tietoa palvelimelle, joka taas palauttaa käyttäjälle selaimessa näytettävää tietoa.

Käytännössä web-sovellukset koostuvat datasta jolle on määritelty ilmaisumuoto. Selainohjelmointiin ja käyttöliittymäsuunniteluun keskityttäessä painotetaan ulkoasun ja sisällön erotusta toisistaan CSS:n avulla, sekä luodaan interaktiivista toiminnallisuutta Javascriptiin ja nykyaikaisiin web-teknologioihin tukeutuen. HTML5 tukee videoiden näyttämistä, musiikin soittamista ja piirtämistä, ja WebGL taas laajentaa Canvas-toiminnallisuutta tuoden 3D-tuen ja -kiihdytyksen selaimiin.

Web-sovellukset eivät sisällä sivuja samalla tavalla kuin perinteiset web-sivut sisältävät. Vaikka web-sovelluksessa voi ulkopuolisen silmin olla esimerkiksi 5 sivua, uuden sisällön lisääminen tietokantaan tai vastaavaan tietoa tallentavaan järjestelmään mahdollistaa sivumäärän kasvattamisen ilman muutoksia lähdekoodissa. Käyttäjälle tarjotut hakutoiminnallisuudet mahdollistavat lähes rajattoman määrän sivuja; kukaan ei kirjoittaisi näitä käsin.

Pieni määrä sivupohjia (ns. templateja) ja palvelinpuolen sovelluslogiikkaa mahdollistaa sivujen luomisen lennosta suoraan web-osoitetta tai lomakkeella lähettyä dataa käyttäen.

Keskivertokäyttäjälle web-sovellukset näyttävät samalta kuin perinteiset web-sivut. Yksinkertaisen web-sovelluksen lähdekoodia katsottaessa voi olla mahdotonta päätellä ovatko sivut luotu dynaamisesti vai kirjoitettu jotain editoria käyttäen (tai käsin). Web-osoitteesta voi yrittää päätellä jotain (.html, ...), mutta oikeasti web-osoitteiden päätteet ovat vain osa osoitetta: .html -päätteisellä sivulla voi hyvin olla web-sovellus taustalla. Web-sovellus näyttää usein sovellukselta vain niille jotka muokkaavat sovellukseen liittyvää dataa. Sekä sovelluksen käyttö että datan muokkaus tapahtuu yleensä HTML-käyttöliittymää käyttäen. Web-sovellusta voi hallita myös työpöytäsovelluksella, joka muokkaa web-sovelluksen käyttämää dataa.

Javascript (käytännössä AJAX, Asynchronous JavaScript and XML) mahdollistaa sivujen sisällön uudelleen lataamisen ja päivittämisen ilman että käyttäjän tarvitsee tehdä erillisiä pyyntöjä tai kirjoittaa uutta osoitetta selaimen osoitepalkkiin. AJAX mahdollistaa muutosten tekemisen taustalla -- ilman että käyttäjän tarvitsee siirtyä sivulta toiselle -- joka pienentää web-sovellusten ja työpöytäsovellusten välistä eroa.

Työpöytäsovellukset tarjoavat enemmän interaktiivisuutta ja nopeutta web-sovelluksiin verraten. Web-sovellukset mahdollistavat saumattomat ohjelmistojen päivitykset, todellisen tiedot ja dokumenttien jakamisen ja kevyet käyttöliittymät.

Elämme jatkuvaa muutosta. Muutoksesta riippumatta web-sovellukset ovat järjestelmiä, jotka sisältävät dataa. Dataa voi käsitellä web-sivujen kautta ja muita rajapintoja käyttäen. Google tarjoaa Google Documents -palvelua ilmaiseksi kaikkien käyttöön, mahdollistaen käyttäjälle pääsyn dokumentteihin mistä tahansa. Microsoft tarjoaa kaikille ilmaista sähköpostipalvelua, verkko on täynnä ilmaisia webissä toimivia pelejä...

Tälläkin hetkellä ohjelmistoyhtiöt ympäri maailmaa kehittävät uusia innovaatioita, jotka tulevat tulevaisuudessa syrjäyttämään perinteiset työpöytäsovellukset.

Web-sovellusten kehittäminen

Sovellusten arkkitehtuurista perinteisesti puhuttaessa puhutaan talojen tai rakennusten rakentamisesta. Taloa suunnitellessa arkkitehdillä on selkeä tehtävä ja tavoitteet: kerää vaatimukset, tutki vaihtoehtoja ja luo pohjapiirrustus. Pohjapiirrustusta seuraten erillinen joukko työntekijöitä -- rakennusmiehet -- rakentaa konkreettisen rakennuksen.

Ohjelmistoja suunniteltaessa arkkitehti osallistuu sekä ohjelmiston suunnitteluun että kehitykseen, eli konkreettiseen rakentamiseen. Suunnittelussa hän aloittaa perustarpeista ja muutamasta huoneesta, jonka jälkeen jo muutama ihminen alkaa käyttämään rakennusta. Kun alkuperäinen suunnitelma on lähes valmis, rakennukseen muuttaa lisää ihmisiä. Nämä tarvitsevat lisää rakennukselta: uusia huoneita, pesulan, diskon ja luonnollisesti oleskelutilan, jossa on tilaa biljardipöydälle. Arkkitehti soveltaa alkuperäistä suunnitelmaansa mukauttamaan uudet ihmiset ja kehitystyö jatkuu.

Toimintoja kehitettäessä alkuperäiset asukkaat eivät muuta pois, vaan valittavat jatkuvasta rakennusmelusta. Yhä enemmän ihmisiä muuttaa rakennukseen ja rakennukselta vaaditaan taas lisää (mm. cartingrata ja curlinghalli).

Hyvän arkkitehtuurisuunnittelun perusta on mahdollisuuksien huomiointi. Huomioinnilla ei tarkoiteta sitä, että rakennetaan heti aluksi iso järjestelmä -- käytännössä järjestelmän valmistuessa sille ei olisi käyttäjiä sillä kaikki olisivat siirtyneet toiseen aiemmin tarpeellisia ominaisuuksia tarjonneeseen järjestelmään. Jos alkuperäinen suunnitelma tekee järjestelmän laajentamisesta vaikeaa, käyttäjät saattavat vaihtaa palvelua hitauden takia.

Ohjelmistokehityksessä saadaan käytännössä hyvin harvoin ensimmäisellä yrityksellä rakennettua toimiva ja tyydyttävä ratkaisu. Jokaista ohjelmistoa joudutaan laajentamaan, rajaamaan ja refaktoroimaan. Asiakkaalla tai asiakkailla on käytännössä aina uusia toivomuksia ohjelmiston elinkaaren varrella.

Arkkitehtuurin tulee mahdollistaa sopivan kokoisesta palasta aloittaminen sekä rakennettavan sovelluksen laajentaminen, myös toisten kehittäjien toimesta, mahdollisimman kevyesti. Hyvin harvat ohjelmistot ovat vain yhden ihmisen käsialaa, avoimeen lähdekoodiin ja online-versionhallintatyökaluihin (esim github) perustuvat projektit saavat ihmiset eri puolilta maailmaa tekemään työtä yhteisen mielenkiinnon parissa. Ryhmätyössä sovittujen käytänteiden (esim. nimeämiskäytänteet, versionhallinta, testaus, dokumentointi ym.) olemassaolo on oleellista. Kommunikointi niin koodin kautta kuin muita väyliä käyttäen on oleellista -- muuttujanimet a, b, c, foo ja bar aiheuttavat lukijalle lähinnä kylmiä väreitä.

Web-sovelluskehityksessä nopeasta kehityssyklistä on paljon hyötyä. Työkaluja valittaessa tarkoituksena on välttää nurkkaan ajautumista: työkaluista tulee pystyä myös pääsemään eroon. On paljon hyödyllisempää miettiä asiaa päivä ja käyttää muutama päivä prototyypin tekemiseen, koska prototyyppiä voidaan parantaa kuukausia, kuin miettiä kuukausi ja sitouttaa itsensä kuukauden aikana luotuun suunnitelmaan. Mitä nopeammin toiminnallisuutta on olemassa, sitä nopeammin siitä saa palautetta. Mitä vähemmän käytämme aikaa yksittäisen toiminnallisuuden toteuttamiseen -- KISS -- sitä helpommin siitä voi tarpeen vaatiessa hankkiutua eroon.

Kurssilla käytettävät työvälineet

Työkalut ovat oleellinen osa ohjelmistoryhmän yhteisten pelisääntöjä. Tällä kurssilla käytämme ohjelmointiympäristönä NetBeansia, ja ohjelmistoprojektien hallintaan Maven-projektinhallintatyökalua. Nykyaikaisestakin ohjelmistokehityksestä osa tapahtuu terminaalissa eli komentotulkissa, joten käytämme myös sitä kurssin aikana.

Oletamme että käytössäsi on Google Chrome tai vastaavat web-sovelluskehittäjän apuvälineet sisältävä www-selain.

Ohjelmointiympäristö: NetBeans

Ohjelmointiympäristöt tarjoavat kokoelman hyödyllisiä apuvälineitä usein toistuviin tapahtumiin, kuten ohjelmointiprojektien luomiseen, projektin paketointiin ym. Käytämme kurssin esimerkeissä NetBeans-ohjelmointiympäristöä. Ohjelmointiympäristön tai tuottavuutta yhtä paljon helpottavan työkalun käyttäminen on suositeltavaa. Älä siis ohjelmoi <aseta tähän joku perustekstieditori kuten nano, gedit, notepad tai notepad++>:lla. Vaikka ohjelmointiympäristön käyttö voi aluksi tuntua vaikealta tutustumiseen menevän ajan takia, pääset myöhemmin nostamaan käyttämäsi ajan korkoina.

Esimerkeissä ja tehtävissä oletetaan että käytössäsi on NetBeansin versio 7.2 (tai uudempi) ja TMC.

TMC

Kurssin tehtävien tekemisessä ja tarkistamisessa hyödynnetään tietojenkäsittelytieteen laitoksella kehitettyä Test My Code-palvelinta. TMC:n nykyisestä versiosta iso kiitos ja kumarrus kuuluu Martin Pärtelille.

TMC on NetBeans liitännäinen, joka lataa tehtävät suoraan ohjelmointiympäristöön. Tehtävien mukana tulee kurssihenkilökunnan kirjoittamia opiskelijaa ohjaavia testejä, jotka testaavat toteutusta ja auttavat ohjelmointiprosessissa eteenpäin. Kaiken testaaminen on kuitenkin hyvin haastavaa, joten tehtäviä kannattaa silti tehdä kurssin ohjausaikoina. Ohjausajat löytyvät kurssisivulta.

NetBeansin ja TMCn asennus

Tässä tehtävässä laitetaan työkalut kuntoon ja palautetaan ensimmäinen tehtävä TMC-palvelimelle.

TMC-käyttäjätunnuksen luominen

TMC ohjelmointiympäristöön tarjottavan liitännäistoiminnallisuuden lisäksi tehtävien palautukseen ja kurssin hallinnointiin käytettävä järjestelmä. TMC löytyy osoitteesta http://tmc.mooc.fi/hy. Kun avaat sivun, näet seuraavanlaiset yläosan.

Valitse oikeasta ylälaidasta Sign up ja kirjaudu järjestelmään. Käytä käyttäjätunnuksena (username) opiskelijanumeroasi, ja anna järjestelmään käyttämäsi sähköpostiosoite. Opiskelijanumeron käyttö on erityisen tärkeää: näin tekemäsi pisteet menevät omalle tilillesi ja voimme tunnistaa sinut kurssin arvostelussa. Älä käytä salasanana mitään olemassaolevaa salasanaasi!

Kun käyttäjätunnuksesi on luotu ja voit kirjatua järjestelmään, jatka eteenpäin.

NetBeans

Huom! Kurssilla käytetään NetBeansin versiota 7.2. tai uudempaa.

Jos käytät TKTL:n mikroluokkia, NetBeansin versio 7.2 löytyy komentorivillä osoitteesta /opt/netbeans-7.2/. Suoritettava tiedosto on osoitteessa /opt/netbeans-7.2/bin/netbeans.

NetBeans-sovelluskehitysympäristön ladattua osoitteesta http://netbeans.org/. Lataa versio kaikilla mausteilla, eli vaihtoehto "All". Jos koneellesi ei ole asennettu Javaa, löydät Javan sisältämän NetBeans-asennuksen osoitteesta http://java.sun.com/javase/downloads/widget/jdk_netbeans.jsp. Kun olet ladannut NetBeansin, asenna se koneellesi.

Huom! Jos NetBeans kysyy haluatko käyttää vanhoja asetuksia sitä käynnistettäessä, kannattaa valita ei.

TMCn asentaminen

TMC lataa kurssin tehtävät suoraan ohjelmointiympäristöön ja tarjoaa mahdollisuuden tehtävien lähettämiseen ja tarkistamiseen suoraan ohjelmointiympäristöstä.

TMC-liitännäisen saa lisättyä NetBeansin pluginvaihtoehdoksi näkyviin valitsemalla Tools -> Plugins. Valitse avautuvasta ikkunasta Settings-välilehti, ja klikkaa uuden liitännäispaikan lisäämiseen tarkoitettua Add-nappia.

Anna avautuvaan ikkunaan nimeksi TMC, ja osoitteeksi http://update.testmycode.net/tmc-netbeans_hy/updates.xml. Valitse lopulta OK.

Mene tämän jälkeen Available Plugins -välilehdelle ja etsi sieltä vaihtoehto Test My Code NetBeans Plugin, klikata sen vasemmalla puolella olevaa laatikkoa, ja painaa Install. Tämä asentaa TMC:n käyttöösi.

Kun TMC on asentunut, se pyytää käynnistämään NetBeansin uudestaan. Käynnistä NetBeans uudestaan. Tämän jälkeen NetBeansin valikossa on vaihtoehto TMC (kuvassa paprikan yläpuolella).

Käy vielä asettamassa TMCn asetukset. Valitse TMC -> Settings, ja täytä avautuvaan ikkunaan tietosi. Käyttäjätunnus on opiskelijanumerosi, salasanasi TMC:hen liittyvä salasanasi. Valitse kurssiksi s2012-wepa

Varmista että myös alaosassa olevat vaihtoehdot ovat valittuina ja paina OK. Tämän jälkeen NetBeans kysyy sinulta ladataanko saatavilla olevat tehtävät. Valitse "Download".

NetBeans lataa tehtävät, jonka jälkeen ne ovat näkyvissä NetBeans-projekteina. Pieni musta pallo projektin ikonissa tarkoittaa että tehtävää ei ole vielä yritetty. Jos pallo on vihreä, on tehtävästä kerätty kaikki pisteet.

Ensimmäisen tehtävän palautus

Kun avaat projektin, siihen liittyvät toiminnallisuudet aktivoituvat. Alla olevassa kuvassa hiiren näyttämä nappi suorittaa tehtävään liittyvät paikalliset testit. Ensimmäiseen tehtävään ei liity muuta tekemistä kuin sen testaaminen ja lähettäminen. Paina nappia.

Kun painat nappia, TMC suorittaa tehtävään liittyvät testit, joiden pitäisi mennä läpi. Kun tehtävään liittyvät testit menevät läpi, näet ikkunan joka kysyy "lähetetäänkö tehtävä palvelimelle?". Kun valitset kyllä, TMC lähettää tehtävän tehtäväpalvelimelle tarkastettavaksi.

Tämän jälkeen TMC suorittaa vielä tehtävään liittyvät testit palvelimella. Kun kaikki on OK, TMC ilmoittaa tehtävästä kerätyt pisteet.

Huh! Onneksi olkoon! Olet kerännyt ensimmäiset pisteesi.

Ohjelmistoprojektien hallinta

Ohjelmistokehittäjän arjesta huomattava osa menee projekteihin liittyviin tehtäviin kuten ylläpitoon. Jokaisessa ohjelmistoprojektissa on erilaisia lähdekoodiin liittyviä tavoitteita, joita kehittäjien tulee pystyä toteuttamaan. Lähdekoodia tulee pystyä paketoimaan tuotantoon siirettäväksi paketiksi (esim -.jar ja -.war -tiedostot), lähdekoodiin liittyviä testejä tulee pystyä ajamaan erillisellä palvelimella ja lähdekoodista tulee pystyä generoimaan erilaisia raportteja sekä luonnollisesti dokumentaatiota.

Työkalut kuten Apache Ant auttavat projektiin liittyvän lähdekoodin hallinnoinnissa ja kääntämisessä. Ant on käytännössä 2000-luvun alun vastine perinteisille Makefile-tiedostoille. Nykyaikaisempi Apache Maven auttaa käännösprosessin lisäksi projektiin liittyvien kirjastoriippuvuuksien automaattisessa hallinnassa.

Maven

Apache Maven on projektinhallintatyökalu, jota voi käyttää ohjelmakoodikäännösten automatisoinnin lisäksi lähes koko projektin elinkaaren hallintaan uuden projektin aloittamisesta lähtien. Maven tarjoaa ohjelmiston elinkaaren hallintaan joukon valmiiksi konfiguroituja vaiheita (phase), joita voidaan suorittaa komentoriviltä. Tyypillisiä jo olemassaolevia vaiheita ovat test, joka suorittaa projektiin liittyvät testit, sekä package, joka paketoi lähdekoodin projektityypistä riippuen sopivaan pakettiin. Oikeastaan maven on sovelluskehys plugin-komponenttien suoritukseen, yksinkertaisimmatkin tehtävät on tehty pluginien avulla.

Jokaisella Maven-projektilla on elinkaari, joka sisältää vaiheet lähtien projektin validoinnista, kääntämisestä ja testaamisesta aina tuotantoon siirtämiseen asti. Tarkempi listaus projektin erilaisista vaiheista löytyy Mavenin dokumentaatiosta. Kukin vaihe koostuu yhdestä tai useammasta projektiin liittyvästä tavoitteesta (goal), jotka suoritetaan vaiheen sisällä. Vaiheet riippuvat myös edellisistä vaiheista, esimerkiksi vaihetta test suoritettaessa Maven suorittaa ensin projektin validoinnin ja kääntämisen.

Mavenin pluginarkkitehtuuri mahdollistaa hyvin monipuolisen toiminnallisuuden. Esimerkiksi raportointiin ja staattiseen koodianalyysiin löytyy omat pluginit, samoin kuin web-palvelimen käynnistämiselle projektin testausta varten. Mavenin plugineista löytyy (ei kattava) lista osoitteessa http://maven.apache.org/plugins/index.html.

Maven automatisoi uusien projektien luomisen archetype-pluginin avulla, jonka avulla käyttäjät voivat tarjota valmiita projektirunkoja toisilleen. Projektirungot toimivat pohjina uusille samaa arkkitehtuuria käyttäville projekteille. Runkojen avulla ohjelmistokehittäjät voivat jakaa hyviksi todettuja käytänteitä, ja ne helpottavat myös organisaatioiden sisällä tapahtuvaa erilaisiin projektityyppeihin liittyvien arkkitehtuurien ylläpitoa.

Ohjelmistokehittäjän näkökulmasta yksi tärkeimmistä ominaisuuksista on riippuvuuksien automaattinen lataaminen. Mavenin avulla projektiin voi konfiguroida kirjastoriippuvuuksia, esimerkiksi riippuvuudet yksikkötestauskirjastoihin ja web-sovelluskehyksen kirjastoihin. Maven lataa riippuvuudet automaattisesti, jolloin kirjastoja ei tarvitse pitää esimerkiksi versionhallinnassa.

Mavenin projektirakenne

Mavenin archetype-pluginia käyttäen uuden projektin luonti tapahtuu helposti. Luodaan uusi projekti, jota tarkastelemme seuraavaksi. Uuden projektin luominen tapahtuu komentoriviltä esimerkiksi seuraavan komennon avulla.

mvn archetype:generate -DgroupId=fi.organisaatio -DartifactId=sovelluksen-nimi

Käytännössä komennossa mvn archetype:generate kutsutaan mavenin archetype-pluginin tavoitetta generate, ja annetaan sille kaksi parametria. Parametrilla -DgroupId kerrotaan katto-organisaation tai ryhmän tunnus, parametrilla -DartifactId kerrotaan luotavan sovelluksen nimi.

Komento hakee archetype-pluginista valmit projektipohjat, ja kysyy ensin mitä pohjaa haluat käyttää. Tämän jälkeen maven kyselee muita tietoja luotavasta projektista. Koska haluamme vain tutustua tässä mavenin projektirakenteeseen, vastaillaan kysymyksiin enter-painalluksilla. Tällöin maven käyttää oletusvastauksia.

Kun projekti on luotu, sillä on seuraavanlainen kansiorakenne. Kansiorakenteen saa kätevästi esille tree-komennon avulla.

$ tree
.
└── sovelluksen-nimi
    ├── pom.xml
    └── src
        ├── main
        │   └── java
        │       └── fi
        │           └── organisaatio
        │               └── App.java
        └── test
            └── java
                └── fi
                    └── organisaatio
                        └── AppTest.java

10 directories, 3 files

Sovelluksen ja testien lähdekoodit ovat eritelty erillisiin kansioihin. Projektin alla olevassa kansiossa src on projektiin liittyvät lähdekoodit. Kansion src alla on kansiot main ja test, joissa toisessa on projektiin liittyvää koodia, ja toisessa projektiin liittyvät testit. Maven-projektin konfiguraatiotiedosto pom.xml on projektin juuressa.

Project Object Model

Tiedoston pom.xml lyhenne pom tulee sanoista Project Object Model. XML-muotoinen pom sisältää projektiin liittyvän rakenteen, asetukset, kirjastoriippuvuudet ja erikseen konfiguroidut tavoitteet. Yksinkertaisimmillaan pom.xml -tiedosto sisältää kuvauksen organisaatiosta, projektin nimestä, versiosta ja lähdekoodin pakkausmuodosta. Edellisessä osiossa luodun projektin pom.xml -sisältö näyttää seuraavalta.

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>fi.organisaatio</groupId>
  <artifactId>sovelluksen-nimi</artifactId>
  <version>1.0-SNAPSHOT</version>
  <packaging>jar</packaging>

  <name>sovelluksen-nimi</name>
  <url>http://maven.apache.org</url>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>3.8.1</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
</project>

Alussa on xml-tiedoston otsake, joka määrittelee käytetyn XML-skeeman. Tämän jälkeen määritellään projektin tiedot (groupId = ryhmä, artifactId = projekti, version = projektin versio, packaging = pakkausmuoto). Tämän jälkeen tulee sovelluksen nimi (usein sama kuin projekti), sekä projektiin liittyvä osoite. Näitä seuraa projektiin liittyvät asetukset, yllä olevassa tiedostossa on määritelty että projekti käyttää UTF-8 -merkistökoodausta. Alaosassa olevassa dependencies-osiossa määritellään kirjastot, joista projekti riippuu. Esimerkissä projektille on määritelty riippuvuus yksikkötestauksessa käytettävään JUnit-sovelluskirjastoon, jonka maven lataa automaattisesti. Riippuvuuden scope-osiolla voidaan määritellä vaihe, johon riippuvuus liittyy -- yllä JUnit-kirjastoa käytetään vain vaiheessa test. Käytännössä siis JUnit on käytössä vain testausta varten, mutta se ei tule olemaan lopullisessa tuotteessa.

Riippuvuuksien hallinta

Projektikonfiguraatiossa (pom.xml) olevassa dependencies-osiossa määritellään projektin riippuvuudet. Riippuvuuksia ei ole pakko olla yhtäkään, tai niitä voi olla useita. Käytettävät kirjastot riippuvat usein myös muista kirjastoista. Maven (versiosta 2 lähtien) lataa automaattisesti myös käytettävien kirjastojen tarvitsemat riippuvuudet: esimerkiksi JUnit-kirjaston uusin versio (tätä kirjoitettaessa 4.10) vaatii hamcrest-nimisen kirjaston. Voimme kuitenkin vain määritellä riippuvuudeksi JUnit-kirjaston ja antaa Mavenin hoitaa loput.

Silloin tällöin riippuvuudet saattavat mennä ristiin. Esimerkiksi kirjasto A voi riippua kirjaston C versiosta 1.1, kun taas kirjasto B voi riippua kirjaston C versiosta 1.2. Tällä hetkellä Maven päättelee käytettävän kirjaston riippuvuuksien järjestyksen perusteella. Esimerkiksi alla olevassa konfiguraatiossa kirjastosta C käytettäisiin versiota 1.1 koska riippuvuus kirjastoon A on ennen riippuvuutta kirjastoon B.

  <dependencies>

    <dependency>
      <groupId>tarjoaja-A</groupId>
      <artifactId>kirjasto-A</artifactId>
      <version>...</version>
    </dependency>

    <dependency>
      <groupId>tarjoaja-B</groupId>
      <artifactId>kirjasto-B</artifactId>
      <version>...</version>
    </dependency>

  </dependencies>

Riippuvuuksia voi myös sulkea pois exclusions-tagin avulla. Alla kirjaston A riippuvuutta kirjasto C ei ladattaisi ollenkaan, jolloin kirjaston B määrittelemä riippuvuus pääsee käyttöön.

  <dependencies>

    <dependency>
      <groupId>tarjoaja-A</groupId>
      <artifactId>kirjasto-A</artifactId>
      <version>...</version>
      <exclusions>
        <exclusion>
          <groupId>tarjoaja-C</groupId>
          <artifactId>kirjasto-C</artifactId>
        </exclusion>
      </exclusions>
    </dependency>

    <dependency>
      <groupId>tarjoaja-B</groupId>
      <artifactId>kirjasto-B</artifactId>
      <version>...</version>
    </dependency>

  </dependencies>

Riippuvuudet ladataan valmiiksi määritellyistä kirjastovarastoista eli repositorioista. Käytännössä Mavenilla on muutamia oletuspaikkoja kirjastojen hakemiseen, mutta niitä voi myös konfiguroida lisää. Esimerkiksi harjoitustehtävissä olevissa konfiguraatioissa on määritelty TMC:n käyttämä repository TMC:hen liittyvien kirjastojen lataamiseen seuraavasti.

  <repositories>

    <repository>
      <id>tmc</id>
      <name>TMC repo</name>
      <url>http://maven.testmycode.net/nexus/content/groups/public</url>
      <releases>
        <enabled>true</enabled>
      </releases>
      <snapshots>
        <enabled>true</enabled>
      </snapshots>
    </repository>

  </repositories>

Lisää tietoa riippuvuuksien hallinnasta löytyy mavenin dokumentaatiosta. Hyviä paikkoja kirjastojen etsimiseen ovat muunmuassa http://search.maven.org/ ja http://mvnrepository.com/.

Valmiita komentoja

Kirjoittaessamme pom.xml-tiedoston sisältävässä kansiossa komennon mvn, näemme viestin, joka valittaa komennon puuttumisesta. Viestin konkreettinen sisältö riippuu mavenin versiosta, esimerkiksi mavenin versiossa 2 oleellinen sisältö on seuraavanlainen. Kolmosversiossa viesti on vaikealukuisempi...

$ mvn
...
You must specify at least one goal or lifecycle phase to perform build steps.
The following list illustrates some commonly used build commands:

  mvn clean
    Deletes any build output (e.g. class files or JARs).
  mvn test
    Runs the unit tests for the project.
  mvn install
    Copies the project artifacts into your local repository.
  mvn deploy
    Copies the project artifacts into the remote repository.
  mvn site
    Creates project documentation (e.g. reports or Javadoc).

Please see
http://maven.apache.org/guides/introduction/introduction-to-the-lifecycle.html
for a complete description of available lifecycle phases.
...

Selaamalla osoitteeseen http://maven.apache.org/guides/introduction/introduction-to-the-lifecycle.html näemme tarkemman listan toiminnoista, joita maven tarjoaa ohjelmistoprojektin eri elinkaarivaiheisiin.

Testien suorittaminen

Projektiin liittyvät testit suoritetaan käyttämällä mavenin vaihetta test. Käytännössä kukin vaihe liittyy johonkin tiettyyn pluginiin, esimerkiksi test-vaiheessa suoritetaan surefire-pluginin tavoite test. Lisätietoja vaiheiden oletusplugineista löytyy täältä.

Suoritetaan testit antamalla projektikansiossa komento mvn test.

$ mvn test
// tulostusta...
[INFO] ------------------------------------------------------------------------
[INFO] Building sovelluksen-nimi 1.0-SNAPSHOT
[INFO] ------------------------------------------------------------------------
// tulostusta...
-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running fi.organisaatio.AppTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.016 sec

Results :

Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
// tulostusta

Käytännössä projektiin liittyvät testitiedostot, joita esimerkkiprojektissamme on vain 1, suoritetaan. Jos testeissä on ongelmia, mavenista pääsee käsiksi niihin liittyviin raportteihin.

Projektin konfiguraation muokkaus on helppoa kun tietää mitä tekee. Esimerkiksi yksikkötestauskirjaston JUnit version vaihtaminen vanhasta versiosta 3.8.1 versioon 4.10 onnistuu helposti. Käytännössä vain version-tägin sisältö tulee vaihtaa:

  ...
  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.10</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  ...

Jos testit suoritetaan nyt uudestaan komennolla mvn test, huomataan että maven lataa JUnit-version 4.10 käyttöösi. Koska JUnit on yhteensopiva taaksepäin, testit menevät läpi.

New Dependencies

Tutustu tehtävässä tulevaan pom.xml -pohjaan. Mukana on TMC:n vaatimia asetuksia, esimerkiksi TMCn oman pluginvaraston osoite. Tässä tehtävässä sinun tulee lisätä tehtäväpohjan pom.xml-tiedostoon seuraavanlainen riippuvuus.

    <dependency>
      <groupId>org.slf4j</groupId>
      <artifactId>slf4j-api</artifactId>
      <version>1.6.6</version>
    </dependency>

Kun olet lisännyt riippuvuuden, suorita projektiin liittyvät testit. Jos testit menevät läpi, palauta tehtävä.

Paketointi

Projektin paketointi tapahtuu vaiheessa packaging, joka suoritetaan komennolla package. Suorittamalla komento

mvn package

Koska vaihe package tulee testien jälkeen, maven ensin kääntää ja testaa sovelluksen. Tämän jälkeen projekti paketoidaan projektijuuressa olevaan target-kansioon. Sovelluksesta luodun pakkauksen nimi tulee sisältämään sovelluksen nimen ja version, eli lopulliseksi paketin nimeksi tulee sovelluksen-nimi-1.0-SNAPSHOT.jar. Katsoessamme projektin kansiorakennetta, huomaamme että target-kansiossa on muutakin. Esimerkiksi target/surefire-reports-kansiossa on tarkemmat kuvaukset suoritetuista testeistä.

sovelluksen-nimi$ tree
.
├── pom.xml
├── src
│   ├── main
│   │   └── java
│   │       └── fi
│   │           └── organisaatio
│   │               └── App.java
│   └── test
│       └── java
│           └── fi
│               └── organisaatio
│                   └── AppTest.java
└── target
    ├── classes
    │   └── fi
    │       └── organisaatio
    │           └── App.class
    ├── maven-archiver
    │   └── pom.properties
    ├── sovelluksen-nimi-1.0-SNAPSHOT.jar
    ├── surefire
    ├── surefire-reports
    │   ├── fi.organisaatio.AppTest.txt
    │   └── TEST-fi.organisaatio.AppTest.xml
    └── test-classes
        └── fi
            └── organisaatio
                └── AppTest.class

19 directories, 9 files

Projektin käännettyjen tiedostojen siistiminen

Mavenin komennolla clean saa siistittyä projektiin poistetut käännöstiedostot. Oletuksena se poistaa target-kansion.

sovelluksen-nimi$ mvn clean
...
[INFO] --- maven-clean-plugin:2.4.1:clean (default-clean) @ sovelluksen-nimi ---
[INFO] Deleting /home/arto/tmp/sovelluksen-nimi/target
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
...
sovelluksen-nimi$ tree
.
├── pom.xml
└── src
    ├── main
    │   └── java
    │       └── fi
    │           └── organisaatio
    │               └── App.java
    └── test
        └── java
            └── fi
                └── organisaatio
                    └── AppTest.java

9 directories, 3 files

Webin peruskomponentit

"I just had to take the hypertext idea and connect it to the TCP and DNS ideas and – ta-da! – the World Wide Web." -- Tim Berners-Lee

Kolme internetin oleellisinta osaa ovat tapa yksilöidä palveluja ja palvelujen tarjoamia resursseja verkosta (DNS, Domain Name Services ja URI, Uniform Resource Identifier), protokolla viestien lähetykseen verkon yli (HTTP, HyperText Transfer Protocol) ja yhteinen dokumenttien esityskieli (HTML, HyperText Markup Language).

URI ja DNS

"The most important thing that was new was the idea of URI-or URL, that any piece of information anywhere should have an identifier, which will allow you to get hold of it." -- Tim Berners-Lee

Verkossa sijaitseva sivusto tunnistetaan sille annetun yksilöivän osoitteen perusteella. Osoite (URI eli Uniform Resource Identifier terminä käyttöön jäänyt URL Uniform Resource Locator) koostuu resurssin nimestä ja sijainnista, joiden perusteella kysyttyyn palvelimeen ja resurssiin voidaan muodostaa yhteys.

Periaatteessa palvelimelle tehtävää kyselyä voidaan ajatella metodikutsuna, jonka tulee palauttaa arvo tai heittää poikkeus.

Käytännössä kun käyttäjä kirjoittaa web-selaimen osoitekenttään URIn ja painaa enteriä, web-selain lähtee tekemään kyselyä annettuun osoitteeseen. Koska tekstimuotoiset osoitteet ovat käytännössä vain ihmisiä varten, tulee selaimen ensiksi kääntää haluttu osoite, esimerkiksi www.mooc.fi, IP-osoitteeksi. Jos IP-osoite on jo tiedossa esimerkiksi aiemmin osoitteeseen tehdyn kyselyjen takia, selain voi ottaa yhteyden IP-osoitteeseen. Jos taas IP-osoite ei ole tiedossa, tulee selaimen ensin tehdä kysely DNS-palvelimelle (Domain Name System), jonka tehtävänä on muuntaa tekstuaaliset osoitteet IP-osoitteiksi (esim. http://www.cs.helsinki.fi -> 128.214.166.78).

Ilman DNS-palvelimia ihmisten tulisi muistaa IP-osoitteet ulkoa, joka käytännössä tarkoittaisi ettei nykyinen internet toimisi.

IP-osoitteet yksilöivät tietokoneet, ja mahdollistavat koneiden löytämisen verkon yli. Käytännössä yhteys IP-osoitteen määrittelemään koneeseen avataan sovellustason HTTP-protokollan avulla kuljetustason TCP-protokollan yli. TCP-protokollan tehtävänä on varmistaa, että viestit pääsevät perille. Lisää tietoa konkreettisesta tietoliikenteestä kurssilla Tietoliikenteen perusteet.

Kun palvelin vastaanottaa tiettyyn resurssiin liittyvän pyynnön, palauttaa se vastauksen kyselijälle. Kun selain saa vastauksen, tarkistaa se vastaukseen liittyvän statuskoodin ja siihen liittyvät tiedot. Jos vastauksen saa tallettaa välimuistiin, tallennetaan se tulevaisuutta varten. Tämän jälkeen selain päättelee, mitä vastauksella tehdään, ja esimerkiksi renderöi vastaukseen liittyvän web-sivun käyttäjälle.

Käytännössä URIt näyttävät seuraavilta:

protokolla://isäntäkone[:portti]/polku/../[kohdedokumentti][?kyselyparametrit][#ankkuri]

Osoitteen osat

Tutki osoitetta http://www.googlefight.com/index.php?lang=en_GB&word1=Batman&word2=Superman. Mitkä tai mikä ovat/on osoitteen:

Mitkä näistä puuttuvat?

HTTP

HTTP (HyperText Transfer Protocol) on TCP/IP -protokollapinon sovellustason protokolla. Web-palvelimet ja selaimet keskustelevat HTTP-protokollaa käyttäen. HTTP-protokolla perustuu asiakas-palvelin malliin, jossa jokaista pyyntöä kohden on yksi vastaus (request-response paradigm). Käytännössä HTTP-asiakasohjelma (jatkossa selain) lähettää HTTP-viestin HTTP-palvelimelle (jatkossa palvelin), joka palauttaa HTTP-vastauksen. HTTP-protokollan versio 1.1 on määritelty RFC 2616-spesifikaatiossa.

Asiakas-palvelin malli

Asiakas-palvelin -mallissa (Client-Server model) asiakkaat käyttävät palvelimen tarjoamia palveluja. Kommunikointi asiakkaan ja palvelimen välillä tapahtuu usein verkon yli siten, että asiakasohjelmisto ja palvelinohjelmisto sijaitsevat erillisissä fyysisissä sijainneissa (eri tietokoneilla). Palvelinohjelmisto tarjoaa yhden tai useamman palvelun, joita asiakasohjelmisto käyttää.

Käytännössä asiakasohjelmisto tarjoaa käyttöliittymän ohjelmiston loppukäyttäjälle. Asiakasohjelmiston käyttäjän ei tarvitse tietää, että kaikki käytetty tieto ei ole hänen koneella. Käyttäjän tehdessä toiminnon asiakasohjelmisto pyytää tarpeen vaatiessa palvelimelta käyttäjän tarpeeseen liittyvää lisätietoa. Tyypillistä mallille on se, että palvelin tarjoaa vain asiakkaan pyytämät tiedot. Tällöin verkossa liikkuvan tiedon määrä pysyy vähäisenä.

Asiakas-palvelin -malli mahdollistaa hajautetut ohjelmistot: asiakasohjelmistoa käyttävät loppukäyttäjät voivat sijaita eri puolilla maapalloa palvelinohjelmiston sijaitessa tietyssä paikassa.

Chuck Norris

Valitse sopiva web-selain ja mene osoitteeseen http://www.imdb.com. Kirjoita sivuston ylälaidassa olevaan kenttään "Chuck Norris" ja paina Enter. Mitkä seuraavista askeleista tapahtuivat asiakasohjelmistossa, mitkä palvelinohjelmistossa, mitkä muualla? Voit olettaa että asiakasohjelmistolla tarkoitetaan valitsemaasi web-selainta.

  1. Osoitteen http://www.imdb.com kirjoittaminen.
  2. Osoitetta http://www.imdb.com vastaavan palvelimen etsiminen.
  3. Sivun http://www.imdb.com näyttäminen web-selaimen käyttäjälle.
  4. Chuck Norrikseen liittyvien tietojen ja elokuvien haku.

Haasteena perinteisessä asiakas-palvelin mallissa on se, että palvelin sijaitsee yleensä tietyssä keskitetyssä sijainnissa. Keskitetyillä palveluilla on mahdollisuus ylikuormittua asiakasmäärän kasvaessa. Kapasiteettia rajoittavat muunmuassa palvelimen fyysinen kapasiteetti (rauta), palvelimeen yhteydessä olevan verkon laatu ja nopeus, sekä tarjotun palvelun tyyppi. Esimerkiksi tietokantatransaktiota vaativat pyynnöt vievät huomattavasti enemmän aikaa kuin yksinkertaiset lukuoperaatiot. Asiakas-palvelin mallissa tuottaa haasteita myös vikasietoisuus, miten toimia jos palvelinkoneesta hajoaa esimerkiksi kovalevy?

Knock-knock! Who's there?

Lähes kaikki sovellusten verkkoliikenne sovellustason protokollasta riippumatta käyttää TCP-yhteyksiä ja -portteja kommunikointiin. TCP-yhteyksiä käytetään Javassa Socket- ja ServerSocket-luokkien avulla.

Eräs suosittu viestiprotokolla (eli säännöstö, joka kertoo kuinka kommunikoinnin tulee kulkea) alkaa sanoilla Knock knock!. Toinen osapuoli vastaa tähän Who's there?. Ensimmäinen osapuoli vastaa jotain, esim. Art, jonka jälkeen toisen osapuolen tulee vastata Art who?. Tähän ensimmäinen osapuoli vastaa viestillä joka päättyy "Bye.".

Server: Knock knock!
Client: Who's there?
Server: Robin
Client: Robin who?
Server: Robin your house! Bye.

Tehtäväpohjan mukana tulee projekti, johon palvelinpuolen toiminnallisuus on toteutettu valmiina luokassa KnockKnockServer. Palvelinohjelmisto kuuntelee vastaanottoa portissa 12345.

Tehtävänäsi on toteuttaa valmiiksi toteutettua palvelinkomponenttia varten asiakaspuolen toiminnallisuus, eli sovellus, joka tekee kyselyjä palvelimelle. Asiakaspuolen toiminnallisuutta varten on jo olemassa allaoleva runko, joka tulee myös mukana tehtäväpohjan luokassa KnockKnockClient.

Täydennä asiakasohjelmisto annettujen askelten mukaan siten, että sitä voi käyttää kommunikointiin viestiprotokollapalvelimen kanssa.

        // Luodaan yhteys palvelimelle
        Socket socket = new Socket("localhost", port);

        Scanner serverMessageScanner = new Scanner(socket.getInputStream());
        PrintWriter clientMessageWriter = new PrintWriter(
                socket.getOutputStream(), true);

        Scanner userInputScanner = new Scanner(System.in);

        // Luetaan viestejä palvelimelta
        while (serverMessageScanner.hasNextLine()) {
            // 1. lue viesti palvelimelta
            // 2. tulosta palvelimen viesti standarditulostusvirtaan näkyville

            // 3. jos palvelimen viesti loppuu merkkijonon "Bye.", poistu toistolausekkeesta

            // 4. pyydä käyttäjältä palvelimelle lähetettävää viestiä
            // 5. kirjoita lähetettävä viesti palvelimelle. Huom! Käytä println-metodia.
        }

Sinun tulee kirjoittaa asiakasohjelmiston lähdekoodi KnockKnockClient-luokan start-metodiin. Kun olet saanut ohjelmiston valmiiksi, suorita ohjelma, jotta voit kokeilla sitä. Tehtäväpohjan mukana on ohjelman käynnistävä main-metodin sisältävä luokka valmiina. Tulostuksen pitäisi olla esimerkiksi seuraavanlainen (käyttäjän syöttämät tekstit on merkitty punaisella):

Server: Knock knock!
Type a message to be sent to the server: Who's there?
Server: Lettuce
Type a message to be sent to the server: Lettuce who?
Server: Lettuce in! it's cold out here! Bye.

Jos asiakasohjelmisto lähettää virheellisiä viestejä, reagoi palvelin siihen seuraavasti:

Server: Knock knock!
Type a message to be sent to the server: What?
Server: You are supposed to ask: "Who's there?"
Type a message to be sent to the server: Who's there?
Server: Lettuce
Type a message to be sent to the server: huh
Server: You are supposed to ask: "Lettuce who?"
Type a message to be sent to the server: Lettuce who?
Server: Lettuce in! it's cold out here! Bye.

Kun ohjelma toimii mielestäsi vaaditulla tavalla, suorita ohjelman testit. Lähetä tehtävä lopulta TMC-palautusautomaattiin, kun tehtävän testit menevät läpi.

Periaatteessa web-palvelinohjelmistot toimivat kuten yllä toteutettu sovellus, mutta ne käyttävät kommunikointiin HTTP-protokollaa. Yllä olleessa tehtävässä toteutettiin osa selainta vastaavasta toiminnallisuudesta, palvelimen toiminnallisuus oli jo valmiina.

Käytännössä web-selaimetkin ottavat yhteyden haluttuun osoitteeseen liittyvään porttiin, kirjoittavat sinne, ja lukevat sieltä palautettavaa tietoa.

Haluaisitko kirjoittaa web-sovelluksia socketeilla? Jotkut tekevät tätäkin työkseen!

HTTP-viestin rakenne: palvelimelle lähetettävä kysely

HTTP-protokollan yli lähetettävät viestit ovat tekstimuotoisia. Viestit koostuvat riveistä jotka muodostavat otsakkeen, sekä riveistä jotka muodostavat viestin rungon. Viestin runkoa ei ole pakko olla olemassa. Viestin loppuminen ilmoitetaan kahdella peräkkäisellä rivinvaihdolla. Palvelimelle lähetettävän viestin, eli kyselyn, ensimmäisellä rivillä on pyyntötapa, halutun resurssin polku ja HTTP-protokollan versionumero.

PYYNTÖTAPA /POLKU_HALUTTUUN_RESURSSIIN HTTP/versio
otsake-1: arvo
otsake-2: arvo

valinnainen viestin runko

Pyyntötapa kertoo HTTP-protokollassa käytettävän pyynnön tavan (esim. GET tai POST), polku haluttuun resurssiin kertoo haettavan resurssin sijainnin palvelimella (esim. /index.html), ja HTTP-versio kertoo käytettävän version (esim. HTTP/1.0). Alla esimerkki hyvin yksinkertaisesta -- joskin yleisestä -- pyynnöstä. Huomaa että pyyntöä tehdessä yhteys palvelimeen on jo muodostettu, eli palvelimen osoitetta ei merkitä erikseen.

GET /index.html HTTP/1.0

Yksittäisen koneen dedikointi web-palvelimeksi jättää usein huomattavan osan koneen kapasiteetista käyttämättä. Nykyään yleisesti käytössä oleva HTTP/1.1 -protokolla mahdollistaa useamman palvelimen pitämisen samalla koneella virtuaalipalvelintekniikan avulla, jolloin yksittäiset palvelinkoneet voivat sisältää useita palvelimia. Käytännössä IP-osoitetta kuunteleva kone voi joko itsessään sisältää useita ohjelmistoilla emuloituja palvelimia, tai se voi toimia reitittimenä ja ohjata pyynnön tietylle esimerkiksi yrityksen sisäverkossa sijaitsevalle koneelle. Kun yksittäinen IP-osoite voi sisältää useampia palvelimia, pelkkä polku haluttuun resurssiin ei riitä oikean resurssin löytämiseen: resurssi voisi olla millä tahansa koneeseen liittyvällä virtuaalipalvelimella. HTTP/1.1 -protokollassa on pyynnöissä pakko olla mukana käytetyn palvelimen osoitteen kertova Host-otsake.

GET /index.html HTTP/1.1
Host: www.munpalvelin.net

HTTP-viestin rakenne: palvelimelta saapuva vastaus

Palvelimelle tehtyyn pyyntöön saadaan aina jonkinlainen vastaus. Jos tekstimuotoiseen osoitteeseen ei ole liitetty IP-osoitetta DNS-palvelimilla, selain ilmoittaa ettei palvelinta löydy. Jos palvelin löytyy, ja pyyntö saadaan tehtyä palvelimelle asti, tulee palvelimen myös vastata jollain tavalla.

Palvelimelta saatavan vastauksen sisältö on seuraavanlainen. Ensimmäisellä rivillä HTTP-protokollan versio, viestiin liittyvä statuskoodi, sekä statuskoodin selvennys. Tämän jälkeen on joukko otsakkeita, tyhjä rivi, ja mahdollinen vastausrunko. Vastausrunko ei ole pakollinen.

HTTP/versio statuskoodi selvennys
otsake-1: arvo
otsake-2: arvo

valinnainen vastauksen runko

Esimerkiksi:

HTTP/1.1 200 OK
Date: Sat, 07 Jan 2012 03:12:45 GMT
Server: Apache/2.2.14 (Ubuntu)
Vary: Accept-Encoding
Content-Length: 973
Connection: close
Content-Type: text/html;charset=UTF-8

.. runko ..

Statuskoodit

Statuskoodit (status code) kuvaavat palvelimella tapahtunutta toimintaa kolmella numerolla. Statuskoodien avulla palvelin kertoo mahdollisista ongelmista tai tarvittavista lisätoimenpiteistä. Yleisin statuskoodi on 200, joka kertoo kaiken onnistuneen oikein. HTTP/1.1 sisältää viisi kategoriaa vastausviesteihin.

Lisätietoja statuskoodeista osoitteissa http://httpcats.herokuapp.com.

telnet-työkalu

Linux-ympäristöissä on käytössä telnet-työkalu, jota voi käyttää yksinkertaisena asiakasohjelmistona pyyntöjen simulointiin. Telnet-yhteyden tietyn koneen tiettyyn porttiin saa luotua komennolla telnet isäntäkone portti. Esimerkiksi TKTL:n www-palvelimelle saa yhteyden seuraavasti:

$ telnet cs.helsinki.fi 80

Tätä seuraa telnetin infoa yhteyden muodostamisesta, jonka jälkeen pääsee kirjoittamaan pyynnön.

Trying 128.214.166.78...
Connected to cs.helsinki.fi.
Escape character is '^]'.

Yritetään pyytää HTTP/1.1 -protokollalla juuridokumenttia. Huom! HTTP/1.1 -protokollassa tulee pyyntöön lisätä aina Host-otsake. Jos yhteys katkaistaan ennen kuin olet saanut kirjoitettua viestisi loppuun, ota apuusi tekstieditori ja copy-paste. Muistathan myös että viesti lopetetaan aina kahdella rivinvaihdolla.

GET / HTTP/1.1
Host: cs.helsinki.fi

Palvelin lähettää meille vastauksen, jossa on statuskoodi ja otsakkeita sekä dokumentin runko.

HTTP/1.1 302 Found
Date: Sun, 26 Aug 2012 18:31:30 GMT
Server: Apache/2.2.14 (Ubuntu)
Location: http://www.cs.helsinki.fi/
Vary: Accept-Encoding
Content-Length: 290
Content-Type: text/html; charset=iso-8859-1

<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>302 Found</title>
</head><body>
<h1>Found</h1>
<p>The document has moved <a href="http://www.cs.helsinki.fi/">here</a>.</p>
<hr>
<address>Apache/2.2.14 (Ubuntu) Server at cs.helsinki.fi Port 80</address>
</body></html>

Juuripolkua palvelimelta cs.helsinki.fi haettaessa palvelin vastaa että dokumentti on löytynyt (302 Found), mutta se sijaitsee muualla (Location: http://www.cs.helsinki.fi/).

Kuinka monta hyppyä?

Käytä telnetiä ja aloita osoitteesta cs.helsinki.fi, tavoitteenasi on päästä laitoksen etusivulle http://www.cs.helsinki.fi/home/. Kuinka monta uudelleenohjausta saat ennenkuin pääset etusivulle?

HTTP-protokollan pyyntötavat

HTTP-protokolla määrittelee kahdeksan erillistä pyyntötapaa (Request method). Yleisimmin käytetyt ovat GET, POST ja HEAD, joiden lisäksi on olemassa PUT, DELETE, TRACE, OPTIONS ja CONNECT. Pyyntötavat määrittelevät rajoitteita ja suosituksia viestin rakenteeseen ja niiden prosessointiin palvelinpäässä. Esimerkiksi Java Servlet API (versio 2.5) sisältää seuraavan suosituksen GET-pyyntotapaan liittyen:

The GET method should be safe, that is, without any side effects for which users are held responsible. For example, most form queries have no side effects. If a client request is intended to change stored data, the request should use some other HTTP method.

The GET method should also be idempotent, meaning that it can be safely repeated. Sometimes making a method safe also makes it idempotent. For example, repeating queries is both safe and idempotent, but buying a product online or modifying data is neither safe nor idempotent.

Suomeksi yksinkertaistaen: GET-tyyppisten pyyntöjen ei pitäisi muuttaa palvelimella olevaa dataa.

GET

GET on yksinkertaisin pyyntötapa. Sitä käytetään esimerkiksi dokumenttien hakemiseen: kun kirjoitat osoitteen selaimen osoitekenttään ja painat enter, selain tekee GET-pyynnön. GET-pyynnöt eivät tarvitse otsaketietoja HTTP/1.1:n vaatiman Host-otsakkeen lisäksi. Mahdolliset kyselyparametrit lähetetään palvelimelle osana haettavaa osoitetta.

GET /lets/See?porkkana=1 HTTP/1.1
Host: t-avihavai.users.cs.helsinki.fi

POST

Käytännön ero POST- ja GET-kyselyn välillä on se, että POST-tyyppisillä pyynnoillä kyselyparametrit liitetään pyynnön runkoon. Rungon sisältö ja koko määritellään otsakeosiossa. POST-kyselyt mahdollistavat multimedian (kuvat, videot, musiikki, ...) lähettämisen palvelimelle.

POST /lets/See HTTP/1.1
Host: t-avihavai.users.cs.helsinki.fi
Content-Type: application/x-www-form-urlencoded
Content-Length: 10

porkkana=1

HEAD ja otsakkeet

HEAD-kyselyt ovat samantyyppisiä GET-kyselyiden kanssa, mutta niillä pyydetään palvelimelta vain haettavaan dokumenttiin liittyviä otsaketietoja. Tämä mahdollistaa muunmuassa muutosten seurannan palvelimelta saatavan Last-modified-otsakkeen avulla, sekä palvelimen toiminnallisuuden testaamisen.

HEAD-kyselyä on perinteisesti käytetty välimuistitoiminnallisuuden toteuttamiseen, jolloin selaimen välimuistissa olevista dokumenteista on tehty ensiksi vain HEAD-kysely. Jos dokumentti ei ole muuttunut, eli otsake Last-modified sisältää tarpeeksi vanhan ajan, ei dokumenttiin liittyvää runkoa ole haettu.

Luodaan telnetillä yhteys palvelimeen www.cs.helsinki.fi ja kysytään otsaketietoja resurssiin /home/ liittyen. Käytämme HTTP:n versiota 1.1 (HTTP/1.1), joten lisäämme pyyntöön myös Host-otsakkeen.

$ telnet www.cs.helsinki.fi 80
Trying 128.214.166.78...
Connected to www.cs.helsinki.fi.
Escape character is '^]'.
HEAD /home/ HTTP/1.1
Host: www.cs.helsinki.fi 

Vastauksena saamme palvelimen vastausrivin jossa on HTTP-versio, statuskoodi ja selvennys. Seuraavilla riveillä on otsakkeita, jotka tarjoavat lisätietoa haettavasta dokumentista ja käytettävästä palvelimesta.

HTTP/1.1 200 OK
Date: Sat, 07 Jan 2012 07:13:49 GMT
Server: Apache/2.2.14 (Ubuntu)
X-Powered-By: PHP/5.3.2-1ubuntu4.11
Last-Modified: Sat, 07 Jan 2012 07:13:51 GMT
Cache-Control: store, no-cache, must-revalidate
Cache-Control: post-check=0, pre-check=0
Vary: Accept-Encoding
Content-Type: text/html; charset=utf-8

Vastauksesta huomataan että TKTL:n palvelin on huonosti konfiguroitu: Last-Modified -otsake, joka kertoo milloin resurssia on muokattu, määrittelee tulevaisuudessa olevan päivämäärän. Otsake Date taas kertoo hetken, jolloin pyyntö on tehty. Otsakkeissa on myös muuta hyödyllistä tietoa. Esimerkiksi otsake Cache-Control sisältää välimuistitoiminnallisuuteen liittyviä ohjeita; arvo no-cache määrittelee että sivun ajankohtaisuus tulee aina varmistaa palvelimelta. Huomaamme myös mielenkiintoisen arvon store, jota -- sen yleisessä käytössä olemisesta huolimatta -- ei määritellä HTTP/1.1 -otsakkeeseen Cache-Control liittyvässä osiossa (arvo store on määrittelemätön laajennus). Käytännössä TKTL:n verkkosivu pyrkii siihen, että selain ei tallenna sitä välimuistiin.

Nykyaikaisempi tapa on HEAD-pyynnön sijaan resurssin version tarkastamiseen on ETag-otsakkeen käyttö osana GET-pyyntöä. GET-pyyntöä suoritettaessa pyynnön mukana lähetetään otsakkeena resurssin version yksilöivä tunnus. Jos palvelimella oleva versio on sama kuin selaimen tiedossa oleva yksilöivä tunnus, palvelin voi palauttaa yksinkertaisesti vastauksen HTTP 304 Not Modified, eli resurssi ei ole muuttunut. Kuten HEAD-pyynnössä, myös ETag-otsaketta käyttävässä GET-pyynnössä resurssi tulee olla jo kerran ladattu.

curl-työkalu

Telnetin lisäksi curl on hyödyllinen apuväline pyyntöjen tekemiseen ja tarkasteluun. Curl tarjoaa parametrin -i (include headers in output), jota voi käyttää palvelimen palauttamien otsaketietojen tarkasteluun. Esimerkiksi kysely curl -i hs.fi palauttaa seuraavat tiedot:

$ curl -i hs.fi
HTTP/1.1 301 Object Moved
Location: http://www.hs.fi/
Content-Length: 0

HTTP on tilaton protokolla

HTTP on tilaton protokolla, eli se ei tarvitse jatkuvasti avoinna olevaa yhteyttä toimiakseen. Tämä tarkoittaa sitä, että HTTP ei osaa yhdistä samalta käyttäjältä tulevia pyyntöjä toisiinsa, jolloin jokainen tehty pyyntö käsitellään omana erillisenä pyyntönään. Käytännössä yhden web-sivustonkin hakeminen saattaa sisältää kymmeniä pyyntöjä, sillä jokaiseen sivuun liittyy joukko kuvia ja skriptitiedostoja, joista kukin on oma erillinen resurssinsa.

Vaikka HTTP on tilaton protokolla, on asiakkaan tunnistamiseen käytetty pitkään erilaisia kiertotapoja. Klassinen tapa kiertää HTTP:n tilattomuus on ollut säilyttää GET-muotoisessa osoitteessa parametreja, joiden perusteella asiakas voidaan identifioida palvelinsovelluksessa. Parametrien käyttö osoitteissa ei ole kuitenkaan ongelmatonta: osoitteessa olevia parametreja voi helposti muokata käsin, jolloin palvelinsovelluksesta saattaa löytyä tietoturva-aukkoja tai ei-toivottua käyttäytymistä.

Eräässä järjestelmässä verkkokaupan toiminnallisuus oli toteutettu siten, että GET-parametrina säilytettiin numeerista ostoskorin identifioivaa tunnusta. Käyttäjäkohtaisuus oli toteutettu palvelinpuolella siten, että tietyllä GET-parametrilla näytettiin aina tietyn käyttäjän ostoskori. Uusien tuotteiden lisääminen ostoskoriin onnistui helposti, sillä pyynnöissä oli aina mukana ostoskorin tunnistava GET-parametri. Ostoskorit oli valitettavasti identifioitu juoksevalla numerosarjalla. Henkilöllä 1 oli ostoskori 1, henkilöllä 2 ostoskori 2 jne.. Koska käytännössä kuka tahansa pääsi katsomaan kenen tahansa ostoskoria vain osoitteessa olevaa numeroa vaihtamalla, olivat ostoskorien sisällöt välillä hyvin mielenkiintoisia.

HTTP-protokollan tilattomuus ei pakota palvelinohjelmistoja tilattomuuteen. Palvelimella tilaa pidetään yllä jollain tietyllä tekniikalla, joka taas ei näy HTTP-protokollaan asti. Nykyään yleisin tekniikka tilattomuuden kiertämiseen on evästeiden käyttö.

HTTP-protokollan tilattomuuden kiertäminen: evästeet

HTTP on tilaton protokolla, eli käyttäjän toimintaa ja tilaa ei pysty pitämään yllä puhtaasti HTTP-yhteyden avulla. Käytännössä suurin osa verkkosovelluksista kuitenkin sisältää käyttäjäkohtaista toiminnallisuutta, jonka toteuttamiseen sovelluksella täytyy olla jonkinlainen tieto käyttäjästä ja käyttäjän tilasta. HTTP/1.1 tarjoaa mahdollisuuden tilallisten verkkosovellusten toteuttamiseen evästeiden (cookies) avulla.

Asettamalla käyttäjän tekemän pyynnön vastaukseen eväste, tulee käyttäjän jatkossa pyyntöä tehdessä aina palauttaa kyseinen eväste pyynnön otsaketietoina. Tämä tapahtuu automaattisesti selaimen toimesta. Evästeitä käytetään istuntojen (session) ylläpitämiseen: istuntojen avulla pidetään kirjaa käyttäjästä useampien pyyntöjen yli.

Evästeet toteutetaan otsakkeiden avulla. Kun käyttäjä tekee pyynnön palvelimelle, ja palvelimella halutaan asettaa käyttäjälle eväste, palauttaa palvelun vastauksen mukana otsakkeen Set-Cookie, jossa määritellään käyttäjäkohtainen evästetunnus. Set-Cookie voi olla esimerkiksi seuraavan näköinen:

Set-Cookie: SESS57a5819a77579dfb1a1466ccceee22a0=0hr0aa2ogdfgkelogg; Max-Age=3600; Domain=".helsinki.fi"

Ylläoleva palvelimelta lähetetty vastaus ilmoittaa pyytää selainta tallettamaan evästeen. Selaimen tulee jatkossa lisätä eväste SESS57a5819a77579dfb1a1466ccceee22a0=0hr0aa2ogdfgkelogg jokaiseen helsinki.fi-osoitteeseen. Eväste on voimassa tunnin, eli tunnin kuluttua se voi poistaa. Tarkempi syntaksi evästeen asettamiselle on seuraava:

Set-Cookie: nimi=arvo [; Comment=kommentti] [; Max-Age=elinaika sekunteina] 
                      [; Expires=parasta ennen paiva] [; Path=polku tai polunosa jossa eväste voimassa]
                      [; Domain=palvelimen osoite (URL) tai osoitteen osa jossa eväste voimassa]
                      [; Secure (jos määritelty, eväste lähetetään vain salatun yhteyden kanssa)]
                      [; Version=evästeen versio]

Evästeet tallennetaan selaimen sisäiseen evästerekisteriin, josta niitä haetaan aina kun käyttäjä tekee kyselyn johonkin osoitteeseen. Evästeet lähetetään palvelimelle jokaisen viestin yhteydessä Cookie-otsakkeessa.

Cookie: SESS57a5819a77579dfb1a1466ccceee22a0=0hr0aa2ogdfgkelogg

Evästeiden nimet ja arvot ovat yleensä monimutkaisia ja satunnaisesti luotuja niiden yksilöllisyyden takaamiseksi. Samaan osoitteeseen voi liittyä myös useampia evästeitä. Yleisesti ottaen evästeet ovat sekä hyödyllisiä että haitallisia: niiden avulla voidaan luoda yksiöityjä käyttökokemuksia tarjoavia sovelluksia, mutta niitä voidaan käyttää myös käyttäjien seurantaan ympäri verkkoa.

Kekseliästä

Isossa osassa selaimia on valmiiksi web-sovelluskehityksessä hyödyntäviä apuvälineitä. Esimerkiksi uudemmat Google Chrome-selaimet tarjoavat hyödyllisen työvälineen sivustojen sisällön tarkasteluun. Painamalla F12 tai valitsemalla Tools -> Developer tools, pääset tutkimaan sivun lataamiseen ja sisältöön liittyvää statistiikkaa. Lisäneuvoja löytyy Google Developers -sivustolta.

Avaa developer tools, ja mene osoitteeseen http://www.hs.fi. Valitsemalla developer toolsien välilehden Resources, löydät valikon erilaisista sivuun liittyvistä resursseista. Avaa Cookies ja valitse vaihtoehto www.hs.fi. Kuinka moni palvelu pitää sinusta kirjaa kun menet Helsingin sanomien sivuille?

HTML

"In '93 to '94, every browser had its own flavor of HTML. So it was very difficult to know what you could put in a Web page and reliably have most of your readership see it." -- Tim Berners-Lee

HTML on rakenteellinen kuvauskieli, jolla voidaan esittää linkkejä sisältävää tekstiä sekä tekstin rakennetta. HTML koostuu elementeistä, jotka voivat olla sisäkkäin ja peräkkäin. Elementtejä käytetään ohjeina dokumentin jäsentämiseen ja käyttäjälle näyttämiseen. HTML-dokumenteissa elementit avataan elementin nimen sisältävällä pienempi kuin -merkillä (<) alkavalla ja suurempi kuin -merkkiin (>) loppuvalla merkkijonolla (<elementin_nimi>), ja suljetaan merkkijonolla jossa elementin pienempi kuin -merkin jälkeen on vinoviiva (</elementin_nimi>).

HTML:ää voi ajatella myös puumaisena kielenä. Juurisolmuna on elementti <html>, jonka lapsina ovat elementit <head> ja <body>.

Jos elementin sisällä ei ole muita elementtejä tai tekstisolmuja (tekstiä), voi elementin avata ja sulkea samalla merkkijonolla: (<elementin_nimi />).

HTML:stä on useita erilaisia standardeja, joista viimeisin on HTML5, a.k.a. HTML

<!DOCTYPE html>
<html lang="fi">
<head>
  <meta charset="UTF-8">
  <title>selainikkunassa näkyvä otsikko</title>
</head>
<body>
  <p>Tekstiä tekstielementin sisällä, tekstielementti runkoelementin sisällä, 
     runkoelementti html-elementin sisällä. Elementin sisältö voidaan asettaa
     useammalle riville.</p>
</body>
</html>

Ylläoleva HTML5-dokumentti sisältää dokumentin tyypin ilmaisevan aloitustägin (<!DOCTYPE html>), dokumentin aloittavan html-elementin (<html>), otsake-elementin ja sivun otsikon (<head>, jonka sisällä <title>), sekä runkoelementin (<body>).

Elementit voivat sisältää attribuutteja, joille voi antaa arvoja. Esimerkiksi ylläolevassa esimerkissä html-elementille on määritelty erillinen attribuutti lang, joka kertoo dokumentissa käytetystä kielestä. Ylläolevan esimerkin otsakkeessa on myös metaelementti, jota käytetään lisävinkin antamiseen selaimelle: "dokumentissa käytetään utf-8 merkistöä". Tämä kannattaa olla dokumenteissa aina.

Nykyaikaiset web-sivut sisältävät paljon muutakin kuin sarjan HTML-elementtejä. Linkitetyt resurssit, kuten kuvat ja tyylitiedostot, ovat oleellisia sivun ulkoasun ja rakenteen luomisessa. Selainpuolella suoritettavat skriptitiedostot, erityisesti Javascript, ovat luoneet huomattavan määrän syvyyttä nykyaikaiseen web-kokemukseen. Tällä kurssilla emme juurikaan paneudu selainpuolen toiminnallisuuteen, mutta sitä varten on tulossa erillinen kurssi Web-selainohjelmointi.

Lomakkeet

Lomakkeita käytetään tiedon lähettämiseen web-palveluille. HTML-elementti lomakkeelle on<form>. Lomake-elementille voidaan antaa attribuutteina toiminto (action), jolle voidaan määrittelee osoite mihin lomakkeen sisältö lähetetään, ja lomakkeen lähetystapa (method). Lomakkeen lähetystapa (GET tai POST) kertoo lähetetäänkö lomakkeen tiedon kyselyparametreina osana osoitetta (GET) vai pyynnön yhteydessä erillisenä datana (POST). Käytetään lähetystapaa POST.

<form action="kohdeosoite" method="POST">

Jos attribuuttia action ei ole määritelty, lähetetään lomake oletuksena nykyiseen osoitteeseen. Attribuutin method oletusarvo on GET.

Lomakekentät

Lomake-elementin alle voi asettaa useita erilaisia kenttiä. Jos kentän arvon haluaa lähettää eteenpäin, tulee kentällä olla attribuutti nimi (name), jonka arvoa käytetään kenttään asetetun tiedon avaimena.

Lomakkeen lähettäminen

Kun lomake lähetetään selain ohjaa käyttäjän kohdeosoitteeseen siten, että lähetettävän lomakkeen tiedot ovat mukana selaimen tekemässä pyynnössä. Jos lomakkeen lähetystapa on GET, on lomakkeen tiedot osana osoitetta. Lähetystavassa POST arvot tulevat erillisinä.

Alla on lomake jolla voi visualisoida tietojen lähettämistä. Lomakkeiden toimintona on http://t-avihavai.users.cs.helsinki.fi/lets/See), jossa on pyynnössä saatujen tiedojen tulostava web-palvelu.

<form method="POST" action="http://t-avihavai.users.cs.helsinki.fi/lets/See">
  <label>Käyttäjätunnus: <input type="text" name="tunnus" /></label>
  <label>Salasana: <input type="password" name="salasana" /></label>
  <input type="submit" />
</form>

Elementtien tunnistaminen

HTML-elementit muodostavat niinkutsutun DOM-puun, joka sisältää kaikki HTML-sivun elementit ja niiden ominaisuudet. Tämän kurssin puitteissa DOM-puu jää hyvin pieneen sivurooliin, ja tärkeintä omalta kannaltamme on seuraavat asiat:

Lomakekentillä olevat nimi-attribuutit (name) ja niihin liittyvät arvot lähetetään lomakkeen attribuutin action määrittelemään osoitteeseen. GET-tyyppiset pyynnön lisäävät attribuutit osaksi osoitetta, POST-tyyppisissä pyynnöissä attribuutit kulkevat osana pyyntöä.

Tutkitaan alla olevaa lomaketta:

<form method="GET" action="http://t-avihavai.users.cs.helsinki.fi/lets/See">
  <label>Viesti <input type="text" name="viesti" /></label>
  <input type="submit" />
</form>

Kun lomakkeessa painetaan submit-nappia, lomake lähetetään GET-tyyppisenä pyyntönä osoitteeseen http://t-avihavai.users.cs.helsinki.fi/lets/See. Lomakkeesta lähtevät tiedot liittyvät lomakkeen kenttiin. Yllä olevassa lomakkeessa on tasan yksi lomakekenttä: viesti. Kentät tunnistetaan lomaketta lähetettäessä niiden name-attribuutin mukaan. Koska lomakkeen lähetystyyppi on GET (method="GET"), liitetään lomakkeen kentät osaksi osoitetta.

Esimerkiksi, jos lomakkeen viesti-kenttään kirjoitetaan teksti Hei, ja lomake lähetetään, kysely tehdään osoitteeseen http://t-avihavai.users.cs.helsinki.fi/lets/See?viesti=Hei. Huomaa että lomakkeen sisältämät tiedot on jo osana osoitetta.

HTML-dokumentin muodostamaa puuta läpikäydessä elementit voidaan tunnistaa sekä niiden nimen, että niiden tunnuksen perusteella. Tunnus, eli id, on erillinen elementit yksilöivä attribuutti. Esimerkiksi ylläolevassa lomakkeessa olevaan viestikenttään voi liittää tunnuksen seuraavasti:

<form method="GET" action="http://t-avihavai.users.cs.helsinki.fi/lets/See">
  <label>Viesti <input type="text" name="viesti" id="viesti" /></label>
  <input type="submit" />
</form>

Nyt lomakkeessa oleva viestikenttä voidaan tunnistaa myös sen tunnuksen perusteella. Tunnusten käyttämisen tarve selkenee kurssilla Web-selainohjelmointi, nyt vain todetaan että niitä tarvitaan. Jos haluat lisää tietoa aiheeseen liittyen, W3Schools tarjoaa hyvän pohjustuksen DOM-puiden läpikäyntiin ja muokkaamiseen.

First Html Form

Tehtäväpohjassa tulee mukana web-projekti, jonka rakenne on seuraavanlainen:

$ tree
.
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── wad
    │   │       └── ekalomake
    │   │           └── servlet
    │   │               └── RequestParametersServlet.java
    │   ├── resources
    │   └── webapp
    │       ├── index.jsp
    │       └── WEB-INF
    │           └── web.xml
    └── test
        └── java
            └── wad
                └── ekalomake
                    └── servlet
                        └── RequestParametersServletTest.java

Projektille on jo konfiguroitu osoite /view jota kuuntelee luokka RequestParametersServlet. Luokka tulostaa pyynnössä mukana tulevat parametrit käyttäjän näkyville.

Tehtävänäsi on toteuttaa webapp-kansiossa (NetBeansin projektinäkymässä kansio Web Pages) olevaan index.jsp-sivuun seuraavannäköinen lomake:

Lomakkeen Nimi-kentän id sekä name attribuuttien arvon tulee olla name. Osoite-kentällä id ja name attribuuttien arvon tulee olla address. Jokaisen lipputyypin nimi tulee olla ticket. Vihreää lippua kuvaavan valinnan id:n tulee olla ticket-green ja arvon green. Vastaavasti keltaista lippua kuvaavan valinnan id:n tulee olla ticket-yellow ja arvon yellow, ja punaista lippua kuvaavan valinnan id:n tulee olla ticket-red ja arvon red.

Käytä lomakkeen action-attribuutin arvona merkkijonoa "${pageContext.request.contextPath}/view". Merkkijonon ${pageContext.request.contextPath} merkitys selviää myöhemmin. Lomakkeen metodilla ei ole väliä tässä tehtävässä.

Kun olet saanut lomakkeen valmiiksi, suorita siihen liittyvät testit. Kun testit menevät läpi, lähetä tehtävä TMC:lle.

NetBeans ei oletusasetuksillaan suostu käynnistämään web-palvelinta valittaessa Run project, jos projektiin liittyvät testit eivät mene läpi. Voit muuttaa projektin oletusasetuksia valitsemalla projektin nimen oikealla hiirennapilla -> Properties -> Actions. Valitse Action Run project, ja lisää sille ominaisuus skipTests=true. Tämän jälkeen palvelin käynnistyy vaikka testit eivät mene läpi.

Voit myös käynnistää web-pohjaiset projektit komentoriviltä Jetty-palvelimen avulla. Komento mvn jetty:start käynnistää palvelimen projektin target-kansiossa olevilla tiedostoilla.

Hello Web!

Tutustutaan tässä osiossa web-sovellusten rakenteeseen, sekä yksinkertaisten web-sovellusten toteuttamiseen servleteillä. Osa kappaleessa olevista esimerkeistä on tarkoitettu sillaksi HTTPn ja palvelimilla toimivien ohjelmistojen välillä. Sovellukset, joita tässä kappaleessa tehdään ovat osittain kivikautisia ja edustavat vahvasti myös huonoa ohjelmointityyliä. Haukumme itseämme jo nyt.

Servletit

Servletit ovat Javan teknologia dynaamisen palvelinpuolen web-toiminnallisuuden toteuttamiseen. Nimi Servlet tulee siitä, että servletit palvelevat (serve) käyttäjän tekemiä pyyntöjä: ne vastaanottavat pyyntöjä sekä myös vastaavat niihin.

Käytännössä Servlettejä toteutettaessa ohjelmoija perii javan valmiin HttpServlet-luokan, ja korvaavat yhden tai useamman sen tarjoamista metodeista. Yläluokka HttpServlet tarjoaa metodeja yleisimpien HTTP-protokollan pyyntöjen käsittelyyn. Esimerkiksi metodilla doGet käsitellään GET-tyyppinen pyyntö, kun taas metodilla doPost käsitellään POST-tyyppinen pyyntö.

Jokaisella pyyntöä käsittelevällä metodilla on parametrina kaksi rajapintaluokkaa: HttpServletRequest sisältää käyttäjän palvelimelle tekemän pyynnön tiedot ja HttpServletResponse sisältää käyttäjälle palvelimelta lähetettävän vastauksen. Rajapintaluokkien konkreettinen toteutus on käytettävän palvelimen toteuttajien vastuulla, Java tarjoaa vain rajapintamäärittelyt. Palvelinohjelmiston toteuttaja, me, taas käyttävät rajapintoja esimerkiksi pyynnön tietojen tarkasteluun ja vastauksen määrittelyyn.

Palvelimella toimiva web-sovellus voi koostua yhdestä tai useammasta Servletistä. Muutamissa esimerkeissä esiintyvä lets-sovellus sijaitsee osoitteessa http://t-avihavai.users.cs.helsinki.fi/lets/. Sovellukseen liittyy useampia Servlettejä, joista jokainen kuuntelee yhtä tai useampaa osoitetta. Esimerkiksi osoitteeseen http://t-avihavai.users.cs.helsinki.fi/lets/See on konfiguroitu Servlet, jonka tehtävänä on pyyntöön liittyvien otsakkeiden ja parametrien tulostaminen.

Servlettien kuuntelemat polut konfiguroidaan Javan web-sovelluksiin liittyvässä web.xml-tiedostossa.

Uudempi Servlet 3.0-spesifikaatio on osittain poistanut web.xml-tiedoston käytön servlettien konfiguroinnista: osa xml-tiedostoissa tapahtuvasta konfiguraatiosta on siirtynyt annotaatioden avulla tapahtuvaksi. Käytämme tällä kurssilla kuitenkin vielä hieman vanhempaa Servlet 2.5-spesifikaatiota: tausta-ajatuksena versiosta riippumatta on se, että servletit tulee kytkeä kuuntelemaan jotain osoitetta.

Luodaan seuraavaksi oma web-sovellus askeleittain, ja tutustutaan samalla web-sovellusten rakenteeseen.

Hello World!

Sovelluksemme tavoite on huikea: haluamme saada "Hello World!"-tekstin käyttäjälle näkyviin. Aloitetaan nollasta, eli projektin luomisesta.

Uusi maven-pohjainen web-sovellusprojekti

Vaikka voisimme käyttää mavenin archetype-pluginia web-sovellusprojektimme luomiseen, käytämme tässä NetBeansia. Kun NetBeansia käyttää projektien luomiseen, käyttää se käytännössä kuitenkin archetype-pluginia. NetBeansissa uuden projektin luominen aloitetaan valitsemalla File -> New Project.

Huom! Käytämme NetBeansin versiota 7.2, joka löytyy polusta /opt/netbeans-7.2/. NetBeansin voi käynnistää komentotulkista sanomalla:

$ /opt/netbeans-7.2/bin/netbeans

Tämä avaa wizard-ikkunan, jossa valitaan projektin tyyppi. Valitse kategoriasta Maven ja projektin tyypiksi Web Application.

Tämän jälkeen kysytään projektiin liittyviä perustietoja. Projektin nimeksi on asetettu hello-world, ja ryhmätunnukseksi werkko. Muita ei tarvitse vaihtaa. Voit toki päättää itse oman organisaatiosi (ryhmätunnuksesi) ja sovelluksen nimen (projektin nimen).

Tämän jälkeen NetBeans kysyy käytettävää palvelinta ja Java EE-versiota. Laitoksella (pitäisi löytyä) löytyy valmiina GlassFish-palvelin. Voimme käyttää tätä. Valitse Java EE-versioksi 5.

Huh! Projekti luotu! Nyt voisimme lähteä luomaan projektiin liittyviä lähdekooditiedostoja.

Projektin rakenne

Mennään tarkastelemaan projektin rakennetta tarkemmin: komentorivi on tähän mainio työkalu. Projekti on tallennettu aiemmin projektin perustietoja kysyvässä ikkunassa määriteltyyn sijaintiin. Huomaathan että sijainti on projektikohtainen, eli projektisi ei ole kansiossa /home/avihavai/repot/wad. Kun menemme terminaalissa projektin kansioon ja kirjoitamme komennon tree, näemme kutakuinkin seuraavanlaisen listauksen.

.../hello-world$ tree
.
├── pom.xml
└── src
    └── main
        ├── java
        │   └── werkko
        │       └── helloworld
        └── webapp
            ├── index.jsp
            └── WEB-INF
                ├── glassfish-web.xml
                └── web.xml

Projektirakenne vaikuttaa hyvin tutulta. Projektin juuressa on mavenin konfiguraatiotiedosto pom.xml ja projektin lähdekooditiedostot ovat src-kansion alla. Projektia luotaessa maven loi automaattisesti src-kansion alle kansion java, jonne tulee projektin lähdekooditiedostot. Projektiin liittyvät testitiedostot tulisi kansion src alle kansioon test. Tätä kansiota ei ole automaattisesti, mutta sen voi lisätä itse.

Aiemmin tuntematon kansio on src-kansion alla oleva kansio webapp. Kansio webapp tulee sisältämään (lähes) kaikki projektin web-puoleen liittyvät tiedostot. Käydään ne seuraavaksi yksitellen läpi web.xml-tiedostosta lähtien.

web.xml

Tiedosto web.xml sijaitsee webapp-kansion sisällä olevassa WEB-INF-kansiossa. Se sisältää projektin konfiguraation palvelinta varten. Evästeitä (joskus tunnetaan myös nimellä keksit) käyttävien istuntojen kesto määritellään session-config -elementin sisällä olevalla session-timeout-elementillä: jos käyttäjä on yli 30 minuuttia käyttämättä sovellusta, istunnot vanhenevat automaattisesti. Sovellukseen selaimella päätyvälle käyttäjälle ensisijaisesti näytettävä sivu taas määritellään welcome-file-list-elementin sisällä määritellyllä welcome-file-elementillä: kun käyttäjä selaa sovelluksen juuripolkuun (esimerkiksi aiemmassa esimerkissä ollut /lets/), käyttäjälle näytetään tiedosto index.jsp.

<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee" 
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
         xsi:schemaLocation="http://java.sun.com/xml/ns/javaee 
                             http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
    <!-- sovelluksen nimi //-->
    <display-name>hello-world</display-name>

    <!-- istunnon maksimipituus //-->
    <session-config>
        <session-timeout>
            30
        </session-timeout>
    </session-config>

    <!-- oletuksena näytettävä sivu //-->
    <welcome-file-list>
        <welcome-file>index.jsp</welcome-file>
    </welcome-file-list>
</web-app>

glassfish.xml

Tiedosto glassfish.xml ei liity yleisesti web-sovelluksiin, vaan se on NetBeansin luoma lisätiedosto sillä valitsimme aiemmin GlassFish-palvelimen. Tämä tiedosto sisältää GlassFish-palvelimeen liittyviä konfiguraatioita. Omasta näkökulmastamme se ei ole mielenkiintoinen.

kansio WEB-INF

Kansiossa WEB-INF oleviin tiedostoihin ei pääse sovelluksen päällä ollessa käsiksi selaimen kautta. Se sisältää projektiin liittyviä konfiguraatiotiedostoja sekä esimerkiksi JSP-sivuja, joita ei haluta näyttää käyttäjälle suoraan.

index.jsp

Kansiossa webapp oleva tiedosto index.jsp sisältää käyttäjälle näytettävän sivun. JSP-sivut ovat kuin HTML-sivuja, mutta niihin voi lisätä dynaamista toiminnallisuutta. NetBeans generoi JSP-sivuille valmiin pohjan, joka näyttää seuraavalta:

<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
   "http://www.w3.org/TR/html4/loose.dtd">

<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
        <title>JSP Page</title>
    </head>
    <body>
        <h1>Hello World!</h1>
    </body>
</html>

Sivulle on määritelty alkuun erillinen JSP-tägi, joka kertoo sivun sisällön olevan tyyppiä text/html, ja merkistön olevan UTF-8 -muotoista. NetBeans luo oletuksena HTML 4.01 -versioisia sivuja, mutta sivun rakennetta voi toki itse myös muuttaa. Selaimessa sivu näyttää otsikon JSP Page ja tekstin Hello World!. Esimerkki palvelimella olevasta sivusta löytyy osoitteesta http://t-avihavai.users.cs.helsinki.fi/lets/. Huomaa että selaimella sivua katsottaessa palvelin on ennen sivun lähettämistä käyttäjälle suorittanut alussa olleet komennot, ja niitä ei näy sivun lähdekoodia selaimesta katsottaessa.

HelloServlet

Koska index.jsp-sivussa lukee teksti Hello World!, sovelluksemme näyttäisi nyt jo käyttäjälle tekstin Hello World!. Haluamme kuitenkin luoda oman Servletin, joka tulostaa tekstin käyttäjälle. Luodaan HttpServlet-luokan perivä HelloServlet-luokka, joka kuuntelee osoitetta /hello.

Osoitteet, joita servletit kuuntelevat riippuvat aina osoitteesta, jossa servletin sisältämä sovellus toimii. Jos sovellus on osoitteessa http://www.werkko.com/app/ ja sovelluksella on polkua /hello kuunteleva servlet, pääsisi servlettiin osoitteessa http://www.werkko.com/app/hello.

Koska olemme fiksuhkoja, käytämme ohjelmistokehitysympäristöämme servlet-luokan luomiseen. Valitaan projektin nimi oikealla hiirennäppäimellä ja valitaan New -> Servlet. Jos Servlet-vaihtoehtoa ei ole olemassa, se löytyy Other... -vaihtoehdon avaamalla työkalulla.

Eteen aukeaa New Servlet-työkalu. Täytetään Servlet-luokan nimeksi HelloServlet, ja valitaan Servletille sopiva pakkaus (sovelluksia ei pitäisi tehdä juureen...).

NetBeans haluaa myös auttaa web.xml-tiedoston konfiguroinnissa ja kysyy mitä osoitetta juuri luotavan Servlet-luokan tulisi kuunnella. Asetetaan poluksi /hello.

Eteesi aukeaa Servlet-luokan lähdekoodi, jota voit lähteä muokkaamaan.

Tarkastellaan vielä tarkemmin juuri tapahtuneita muutoksia projektissa.

web.xml

NetBeans lisäsi web.xml-tiedostoon tiedot servletistä ja sen kuuntelemasta polusta. Nyt tiedosto näyttää seuraavalta:

<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee" 
                       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
                       xsi:schemaLocation="http://java.sun.com/xml/ns/javaee                               
                                           http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
    <!-- sovelluksen nimi //-->
    <display-name>hello-world</display-name>

    <!-- esitellään servlet //-->
    <servlet>
        <servlet-name>HelloServlet</servlet-name>
        <servlet-class>werkko.helloworld.HelloServlet</servlet-class>
    </servlet>

    <!-- kerrotaan mihin osoitteeseen tulevat pyynnöt ohjataan servletille //-->
    <servlet-mapping>
        <servlet-name>HelloServlet</servlet-name>
        <url-pattern>/hello</url-pattern>
    </servlet-mapping>

    <!-- istunnon maksimipituus //-->
    <session-config>
        <session-timeout>
            30
        </session-timeout>
    </session-config>

    <!-- oletuksena näytettävä sivu //-->
    <welcome-file-list>
        <welcome-file>index.jsp</welcome-file>
    </welcome-file-list>
</web-app>

Uutena on keskellä olevat elementit. Elementissä servlet esitellään Servlet-luokka, ja asetetaan sille nimi johon muualla konfiguraatiossa voidaan viitata. Elementissä servlet-mapping taas asetetaan tietyn nimiselle HelloServlet luokalle osoite, jota se kuuntelee. Juuri luotu servlettimme kuuntelee siis sovelluksen polkua /hello.

HelloServlet

Luokka HelloServlet perii Javan valmiin luokan HttpServlet ja sisältää HTTP-pyyntöjen käsittelyn. NetBeansin luoma luokka sisältää apumetodin processRequest, johon POST ja GET-tyyppiset pyynnöt ohjataan. Koko luokan sisältö näyttää seuraavalta (huomattava osa NetBeansin luomista kommenteista poistettu).

// pakkaus
package werkko.helloworld;

// tarvittavat importit
import java.io.IOException;
import java.io.PrintWriter;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

// peritään luokka HttpServlet
public class HelloServlet extends HttpServlet {
    
    // metodi pyyntöjen käsittelyyn
    // ei näin sitten oikeasti!
    protected void processRequest(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        response.setContentType("text/html;charset=UTF-8");
        PrintWriter out = response.getWriter();
        try {
            /* TODO output your page here. You may use following sample code. */
            out.println("<html>");
            out.println("<head>");
            out.println("<title>Servlet HelloServlet</title>");            
            out.println("</head>");
            out.println("<body>");
            out.println("<h1>Servlet HelloServlet at " + request.getContextPath() + "</h1>");
            out.println("</body>");
            out.println("</html>");
        } finally {            
            out.close();
        }
    }

    // korvattu metodi doGet, pyyntö ohjataan processRequest-metodille
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        processRequest(request, response);
    }

    // korvattu metodi doPost, pyyntö ohjataan processRequest-metodille
    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        processRequest(request, response);
    }
}

Oleellista yllä on metodi processRequest, jota sekä metodit doGet ja doPost kutsuvat. Metodia processRequest ei ole tietenkään pakko käyttää: pyynnön prosessoinnin voi hoitaa myös metodeissa doGet ja doPost.

Metodi processRequest asettaa ensin vastauksen sisällön tyypiksi html:n, ja merkistöksi UTF-8:n.

        response.setContentType("text/html;charset=UTF-8");

Tämän jälkeen avataan kirjoitusväylä vastausta varten, ja kirjoitetaan HTML-sisältö vastaukseen. Vaikka tulostamme vielä tällä viikolla HTMLää suoraan Servletistä, älä käytä tätä enää seuraavalla viikolla.

        PrintWriter out = response.getWriter();
        try {
            /* TODO output your page here. You may use following sample code. */
            out.println("<html>");
            out.println("<head>");
            out.println("<title>Servlet HelloServlet</title>");            
            out.println("</head>");
            out.println("<body>");
            out.println("<h1>Servlet HelloServlet at " + request.getContextPath() + "</h1>");
            out.println("</body>");
            out.println("</html>");
        } finally {            
            out.close();
        }

Hello World!

Muutetaan processRequest-metodia siten, että se tulostaa tekstin Hello World!.

    // metodi pyyntöjen käsittelyyn
    protected void processRequest(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        response.setContentType("text/html;charset=UTF-8");
        PrintWriter out = response.getWriter();
        try {
            out.println("<html>");
            out.println("<head>");
            out.println("<title></title>");            
            out.println("</head>");
            out.println("<body>");
            out.println("<h1>Hello World!</h1>");
            out.println("</body>");
            out.println("</html>");
        } finally {            
            out.close();
        }
    }

Nyt sovelluksemme /hello-osoitetta kuunteleva Servlet tulostaa käyttäjälle viestin Hello World!.

Sovelluksen testaaminen

Sovellusta voi testata suoraan NetBeansissa valitsemalla oikealla hiirennäppäimellä projektin nimi ja klikkaamalla Run. Tämä käynnistää aiemmin määritellyn palvelimen (tässä GlassFish), ja lisää sovelluksen palvelimelle. Kun palvelin on päällä, NetBeans käynnistää oletuksena myös selaimen sovelluksen katseluun. Huomaa että selaimessa näytetään sovellus, Servlettiä testataksesi sinun tulee lisätä servletin kuuntelema osoite selaimella haettavaan polkuun.

Esimerkiksi, jos sovellus aukeaa osoitteessa http://localhost:8080/hello-world/, olisi HelloServlet osoitteessa http://localhost:8080/hello-world/hello.

Ylläoleva esimerkki kannattaa tehdä myös itse.

 

 

Sovelluksen paketointi ja tuotantoon siirtäminen

Maven tarjoaa toiminnot tiedoston paketointiin. Kun kirjoitamme komentoriviltä projektikansiossa komennon mvn package, maven luo projektista .war-tiedoston, jonka voi siirtää tuotantopalvelimelle.

.../hello-world$ mvn package
...
[INFO] [war:war {execution: default-war}]
[INFO] Packaging webapp
[INFO] Assembling webapp [hello-world] in [....]
...
[INFO] Webapp assembled in [69 msecs]
[INFO] Building war: /.../hello-world/target/hello-world-1.0-SNAPSHOT.war
...

Luodun war-tiedoston voi kopioida esimerkiksi TKTL:n users-palvelimelle, jossa sitä voi ajaa tomcat-palvelimella. Jos sinulla ei ole tunnuksia TKTL:lle älä huoli, tutustumme myöhemmin kurssilla sovellusten siirtämiseen pilvialustoille.

Kone users.cs.helsinki.fi on TKTL:n opiskelijoille tarkoitettu palvelin, jossa voi ajaa omia ohjelmistoja. Users-koneella on käytössä java web-sovelluksia pyörittävä tomcat-palvelin sekä useita tietokannanhallintajärjestelmiä: MySQL, Oracle ja PostgreSQL. Users-koneelle pääsee kirjautumaan komennolla.

ssh users.cs.helsinki.fi -l <omatunnus>

Saamme käyttöömme tomcat-palvelimen komennolla wanna-tomcat. Laitoksella on käytössä tomcatin versio 6.

tunnus@users:~$ wanna-tomcat

This script will create a new tomcat environment for you in directory
/home/tunnus/tomcat. Please see http://users.cs.helsinki.fi/tomcat for more
information. Do you want to create a new tomcat installation
in /home/tunnus/tomcat (y/n)? <syötä y>

....

Tomcat environment has been setup for you. Now you can run 'start-tomcat'.
tunnus@users:~$

Käynnistetään palvelin komennolla start-tomcat.

tunnus@users:~$ start-tomcat
Using CATALINA_BASE:   /home/tunnus/tomcat
Using CATALINA_HOME:   /usr/share/tomcat6
Using CATALINA_TMPDIR: /home/tunnus/tomcat/temp
Using JRE_HOME:        /usr/lib/jvm/java-6-sun
Using CLASSPATH:       /usr/share/tomcat6/bin/bootstrap.jar
Tomcat has been started. It should be visible through URL
http://t-omatunnus.users.cs.helsinki.fi/

If you have problems, your tomcat log files are
available from /home/tunnus/tomcat/logs

Please, remember to stop (with stop-tomcat) tomcat instances with are
not used.
tunnus@users:~$ 

Kun menet web-selaimella osoitteeseen http://t-omatunnus.users.cs.helsinki.fi/, näet sivun jossa on otsikkona viesti "It works!".

Tomcat-palvelimen saa suljettua komennolla stop-tomcat.

Sovelluksen siirto users-koneelle.

Kopioidaan paketti users-koneelle.

tunnus@kone:~$ scp polku-projektiin/target/projektinnimi.war omatunnus@users.cs.helsinki.fi:

Mennään koneelle users.cs.helsinki.fi, ja siirretään pakkaus tomcat-kansiossa olevaan webapps-kansioon.

tunnus@kone:~$ ssh omatunnus@users.cs.helsinki.fi
tunnus@users:~$ mv <projektinnimi>.war tomcat/webapps/
tunnus@users:~$

Jos tomcat on päällä, pyrkii se käynnistämään sovelluksen automaattisesti. Näet tomcatin logeista tomcat/logs/catalina.out ohjelman logiin kirjoittamat viestit. Jos tomcat ei ole päällä, käynnistä se, ja selaa osoitteeseen http://t-omatunnus.users.cs.helsinki.fi/<projektinnimi>/hello.

Osoitteessa olevan palvelinohjelmiston pitäisi tulostaa HelloServlet-servletissä määritelty viesti.

Servlettien käynnistäminen ja elinkaari

Jos tiedostossa web.xml olevassa servletmäärittelyssä on määre load-on-startup, ja sen arvo on suurempi tai yhtäsuuri kuin 0, Servlet-luokasta ladataan ilmentymä palvelimelle heti web-sovelluksen käynnistyessä.

...
    <!-- esitellään servlet //-->
    <servlet>
        <servlet-name>HelloServlet</servlet-name>
        <servlet-class>werkko.helloworld.HelloServlet</servlet-class>
        <load-on-startup>1</load-on-startup>
    </servlet>
...

Muulloin servlet käynnistyy palvelimesta riippuen, usein esimerkiksi silloin kun Servletille määriteltyyn polkuun tehdään ensimmäinen pyyntö. Määre load-on-startup on hyödyllinen esimerkiksi silloin, jos sovelluksen tulee tehdä käynnistyessään esimerkiksi tietokantaoperaatioita. Käynnistyksen yhteydessä suoritettavien toimintojen määrittely onnistuu HttpServlet-luokasta perittävän init-metodin avulla.

Jos Servlettejä on useampia, ja niiden käynnistysjärjestyksellä on merkitystä, määrettä load-on-startup voi käyttää suoritusjärjestyksen määrittelyyn. Servletit käynnistetään load-on-startup-elementin arvojen määräämässä suoritusjärjestyksessä pienimmästä suurimpaan.

Jokaisesta Servlet-luokasta tehdään vain yksi ilmentymä. Samaa ilmentymää käytetään jokaisen Servletille tehdyn pyynnön käsittelyyn. Servlet-luokan oliomuuttujat ovat siis käytettävissä jokaisen pyynnön yhteydessä.

Jos NetBeansissa ei ole oletuspalvelinta sovelluksen testaamiseen, voit valita käyttöön esimerkiksi GlassFish -palvelimen. Huomaamme myöhemmin miten web-sovellukset voi käynnistää myös komentoriviltä esimerkiksi jetty-palvelimen avulla.

Kun käynnistät sovelluksen NetBeansissa, se avaa sovelluksen oletusselaimessa. Oletusselaimen voi vaihtaa NetBeansin asetuksista: Tools -> Options -> General -> Web browser

PageViewCounter

Luo pakkaukseen wad.pageviews.servlet luokka PageViewCounterServlet, joka perii luokan HttpServlet. Luokan PageViewCounterServlet tulee pitää kirjaa tehdyistä GET-tyyppisistä pyynnöistä, ja tulostaa tehtyjen pyyntöjen määrä käyttäjälle. Aseta Servlet kuuntelemaan sovelluksen polkua /count.

Käyttäjälle näytettävä tulostus voi olla esimerkiksi seuraavanlainen. Alla pyyntöjä on tehty yhteensä 3.

Pyyntöjä: 3

Kun olet saanut tehtävän valmiiksi, suorita siihen liittyvät testit ja lähetä se TMCn tarkastettavaksi.

Erota käyttöliittymä ja sovelluslogiikka!

Yksi klassisimmista neuvoista ohjelmia rakentaessa on "erota sovelluslogiikka ja käyttöliittymä". Aiemmassa Hello World!- esimerkissämme näin ei ole tehty, vaan käyttöliittymä on osa sovelluslogiikkaa. Ei näin!

Sovelluslogiikan ja käyttöliittymän erottaminen on hyvin tärkeää ohjelmistotuotannon kannalta: isompia sovelluksia rakennettaessa useamman ihmisen tulee pystyä muokkaamaan sen eri osia samanaikaisesti. Jos kaikki on samassa tiedostossa, tiedoston luettavuus kärsii huomattavasti ja muokkaukset aiheuttavat lähes taatusti versionhallintakonflikteja. Sovelluksen eri osien testaaminen vaikeutuu myös huomattavasti, jos sovelluksen eri osia ei ole erotettu.

Paljon käytetty tapa käyttöliittymäkoodin ja sovelluskoodin erottamiseksi on luoda jokaiselle näkymälle oma sivu, johon Servlet-luokka pyynnön lopulta ohjaa. JSP-sivut luodaan WEB-INF-kansion sisälle omaan kansioon, jotta sivuihin ei pääse käsiksi suoraan selaimella. Tällä kurssilla jsp-sivut sisältävän kansion nimi on jsp.

Jatketaan Hello World! -esimerkin muokkaamista ja luodaan kansioon WEB-INF kansio jsp. Tämä onnistuu NetBeansissa klikkaamalla kansiota WEB-INF oikealla hiirennäppäimellä, ja valitsemalla New -> Other -> Other -> Folder. Kansioon jsp lisätään hello.jsp-niminen jsp-sivu, jonka sisältö on seuraava:

<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
        <title>Hello World!</title>
    </head>
    <body>
        <h1>Hello World!</h1>
    </body>
</html>

Kun projektin rakennetta katsoo komentoriviltä tree-komennolla, näyttää se nyt seuraavalta.

... /hello-world$ tree
.
├── pom.xml
└── src
    └── main
        ├── java
        │   └── werkko
        │       └── helloworld
        │           └── HelloServlet.java
        └── webapp
            ├── index.jsp
            └── WEB-INF
                ├── glassfish-web.xml
                ├── jsp
                │   └── hello.jsp
                └── web.xml

Pyynnön ohjaaminen JSP-sivulle

Pyynnön ohjaaminen JSP-sivulle tapahtuu HttpServletRequest-rajapinnan tarjoaman RequestDispatcher-olion metodilla forward. Kun HttpServletRequest-rajapinnan toteuttavalta oliolta pyydetään RequestDispatcher-oliota metodilla getRequestDispatcher, sille annetaan käytettävän jsp-sivun sijainti. Lopullisessa war-tiedostossa, eli tiedostossa, joka sisältää web-sovelluksen, WEB-INF-kansio on juuressa. Käytämme siis hello.jsp:tä varten polkua /WEB-INF/jsp/hello.jsp.

        RequestDispatcher dispatcher = request.getRequestDispatcher("/WEB-INF/jsp/hello.jsp");
        dispatcher.forward(request, response);
    
        // tai request.getRequestDispatcher("/WEB-INF/jsp/hello.jsp").forward(request, response);

Muokkaa Servlet-luokkaasi siten, että metodi processRequest näyttää seuraavalta

    protected void processRequest(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        response.setContentType("text/html;charset=UTF-8");
        
        request.getRequestDispatcher("/WEB-INF/jsp/hello.jsp").forward(request, response);
    }

Nyt Servletin kuuntelemaan osoitteeseen tuleva pyyntö ohjautuu lopulta kansiossa /WEB-INF/jsp/ olevaan tiedostoon hello.jsp.

Käytännössä siis pyyntö tapahtuu seuraavasti:


1. Käyttäjä tekee palvelimelle pyynnön

  käyttäjän selaimella tekemä pyyntö
     ----------------------->            palvelin


2. Palvelin päättelee pyynnön perusteella oikean servletin
   
  palvelin: pyynnön polku?
     ----------------->                   servlet

3. Servletin koodi suoritetaan

4. Vaihtoehtoinen tilanne: 

4a) Jos requestdispatcher-olion avulla ei määritellä seuraavaa 
    kohdetta, vastaus palautetaan käyttäjälle

4b) Muuten, pyyntö ohjataan määritellyyn osoitteeseen, esimerkiksi
    toiselle servletille. Servletin tai JSPn sisältämät komennot prosessoidaan
     palvelimella. Lopulta vastaus lähetetään takaisin käyttäjälle

Ensiaskeleet dynaamisen tiedon lisäämiseen

Tällä hetkellä sovelluksemme on hieman kankea: sovellus ei näytä mitään palvelinpuolella luotua tietoa.

Selaimelta tulevaa pyyntöä edustavaan HttpServletRequest-olioon voi lisätä attribuutteja, jotka ovat pyynnössä mukana siihen asti kunnes palautettava sivu lähetetään takaisin selaimelle. Kun ohjaamme Servlet-luokassa pyynnön eteenpäin, on kaikki pyyntöön Servlet-luokassa lisätyt attribuutit olemassa vielä JSP-sivua näytettäessä.

JSP-sivu on tarkoitettu dynaamisen tiedon näyttämiseen. JSP:n versioon 2.0 otetun EL-kielen avulla sivulla voidaan käyttää käytännössä kaikkia HttpServletRequest oliolle attribuutiksi lisättyjä olioita. Lisätään seuraavaksi pyyntöön attribuutti "message", jonka sisältö on "ok, ehkä tässä on ideaa".

    protected void processRequest(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        response.setContentType("text/html;charset=UTF-8");

        request.setAttribute("message", "ok, ehkä tässä on ideaa");
        
        request.getRequestDispatcher("/WEB-INF/jsp/hello.jsp").forward(request, response);
    }

Nyt jsp-sivua renderöitäessä käytössä on attribuutti nimeltä message, jolla on arvo. EL-kielen avulla pyyntöön lisätyn attribuutin arvon voi näyttää sivulla seuraavasti:

<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
        <title>Hello World!</title>
    </head>
    <body>
        <h1>Hello World!</h1>

        <p>Viesti: ${message}</p>
    </body>
</html>

Kun ylläolevaa JSP-sivua renderöidään käyttäjälle, sivun renderöijä etsii message-nimisen attribuutin ja asettaa sen sisällön sivulle. Attribuutti aloitetaan JSP-sivulla dollarilla ja aukeavalla aaltosululla ${, ja lopetetaan sulkevalla aaltosululla }.

Servletin osoitteeseen tehtävä pyyntöön luotava vastaus näyttää selaimessa seuraavalta:

Hello World!

Viesti: ok, ehkä tässä on ideaa

Sovelluksen sijainti

Sovelluksemme sijainti palvelimella riippuu palvelimesta ja sovellukselle määritellystä konfiguraatiosta. Sovellukset ovat harvemmin palvelimen juuriosoitteessa, esim. http://palvelin.net/. Yleensä niille on oma aliosoite, esimerkiksi http://palvelin.net/sovellus. Tämä johtaa tilanteeseen, jossa sivulla käytettävien linkkien tulee olla dynaamisia. Esimerkiksi lomakkeiden lähettämisen tulee tapahtua sovellukselle.

Jos HTML-lomakkeen action-kenttä sisältää osoitteen /process, lomake lähetetään käytettävän palvelimen juuriosoitteeseen. Esimerkiksi jos lomake on palvelimella http://palvelin.net/, lähetettäisiin /process-osoitteeseen ohjautuva lomake käytännössä osoitteeseen http://palvelin.net/process -- riippumatta siitä, missä sovellus oikeasti on.

Tämän takia EL-kielessä pääsee käsiksi myös pyynnön tietoihin. Käyttämällä komentoa ${pageContext.request.contextPath} jsp-sivulla, sovelluksen osoitteen saa käyttöön. Käytännössä jos lomakkeen action-kentälle antaa arvon ${pageContext.request.contextPath}/process, lomake lähetetään sovelluksen sisältämään process-osoitteeseen, riippumatta siitä missä osoitteessa lomake oikeasti on.

Erillistä sovelluslogiikkaa

Jos sovelluksemme olisi hieman isompi ja jos koodi olisi Servlet-luokassa, tulisi Servlet-luokasta helposti hyvin raskas. Toteutetaan erillinen viestejä tuottava viestipalvelu. Viestipalvelun rajapinta on seuraavanlainen:

package werkko.helloworld;

public interface MessageService {
    public String getMessage();
}

Ainoa rajapinnan MessageService määrittelemä toiminnallisuus on viestin antaminen. Luodaan rajapinnalle konkreettinen toteutus. Luokka TimoSoiniMessageService toteuttaa rajapinnan MessageService ja tarjoaa poliittisia epähelmiä.

package werkko.helloworld;

import java.util.Random;

public class TimoSoiniMessageService implements MessageService {

    private Random random = new Random();
    private String[] messages = {
        "Tuli iso jytky!",
        "Tänään on tilipäivä!",
        "Missä EU, siellä ongelma.",
        "Se on rikkaiden Neuvostoliitto tämä EU.",
        "Kukkahattu ja gebardihattu kuuluvat samaan ravintoketjuun. "
            + "Molemmilla on käsi sinun taskussasi.",
        "Yleisen asevelvollisuuden säilyttäminen on Suomelle huomattavasti "
            + "tärkeämpi asia kuin demokratian puolustaminen jossain sellaisessa "
            + "maassa, missä ei edes ole demokratiaa.",
        "Jos ei muuta, niin ainakin nuoret oppivat armeijassa kuria. Elämässä "
            + "ei vaan selviä niin, että tekee kaiken aina vaan oman pään "
            + "mukaan. Viimeistään sen huomaa siinä vaiheessa, kun menee "
            + "naimisiin.",
        "Sitäkään ei saa unohtaa, että meitä pidetään virallisesta "
            + "puolueettomuudesta huolimatta länsiblokin osana. Se saattaa "
            + "johtaa vielä jossain vaiheessa vaikeuksiin.",
        "Keksin itse jytkyn. Tuli iso jytky, kun pitikin.",
        "Hallitusvastuu on populistin sudenkuoppa."
    };

    public String getMessage() {
        return messages[random.nextInt(messages.length)];
    }
}

Lisätään viestipalvelu Servlet-luokkaan ja käytetään sitä processRequest-metodissa.

// importit jne
public class HelloServlet extends HttpServlet {

    private MessageService messageService = new TimoSoiniMessageService();
    
    protected void processRequest(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        response.setContentType("text/html;charset=UTF-8");
        
        request.setAttribute("message", messageService.getMessage());
        
        request.getRequestDispatcher("/WEB-INF/jsp/hello.jsp").forward(request, response);
    }

    // doGet ja doPost
}

Nyt servletin kuuntelemaan osoitteeseen tehtävä pyyntö tuottaa esimerkiksi seuraavanlaisen vastauksen:

Hello World!

Viesti: Hallitusvastuu on populistin sudenkuoppa.

Juuri toteutettu poliittisia letkautuksia heittelevä Hei Maailma-sovellus on ylläpidettävyydeltään jo siedettävää luokkaa. Periaatteessa sovelluslogiikan vaihtaminen sellaiseksi, joka hakee viimeisimmän uutisen Helsingin sanomien etusivulta vaatisi vain uuden MessageService-rajapinnan toteuttavan luokan tekemistä ja sen vaihtamista HelloServlettiin.

Pyynnössä olevien parametrien käyttäminen

Pyynnön mukana voi lähettää parametreja palvelimelle. GET-tyyppisissä pyynnöissä parametrit ovat osana osoitetta. Esimerkiksi pyyntö osoitteeseen http://palvelin.net/sovellus?parametri1=arvo1&parametri2=arvo2 tekee käytännössä kyselyn osoitteessa http://palvelin.net/sovellus olevaan sovellukseen, ja antaa sovellukselle kaksi parametria. Parametrin parametri1 arvo on arvo1 ja parametri2-parametrin arvo on arvo2.

Servlettien avulla pyynnössä oleviin parametreihin pääsee käsiksi HttpServletRequest-rajapinnan tarjoaman getParameter-metodin avulla. Metodi getParameter palauttaa sekä GET- että POST-tyyppisissä pyynnöissä lähetetyt parametrit. Esimerkiksi seuraavaa Servlet-luokka ottaa pyynnöstä parametrin name ja käyttää sitä osana tervehdysviestiä.

// importit jne
public class HelloServlet extends HttpServlet {

    protected void processRequest(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        response.setContentType("text/html;charset=UTF-8");
        
        String name = request.getParameter("name");
        request.setAttribute("message", "Hello " + name);
        
        request.getRequestDispatcher("/WEB-INF/jsp/hello.jsp").forward(request, response);
    }

    // doGet ja doPost
}

Jos ylläolevan Servlet-luokan kuuntelemaan osoitteeseen tehtäisiin pyyntö, jossa parametrin name arvona on Mikke, olisi processRequest-metodin suorituksen jälkeen attribuutin message arvo Hello Mikke.

LoveMeter

Vuosien tutkimuksen ja miljoonien polttamisen jälkeen Kumpulan kampuksella on viimeinkin saatu aikaan algoritmi kahden henkilön yhteensopivuuden mittaamiseen. Algoritmin perusrakenne on seuraava:

        int minLength = Math.min(name1.length(), name2.length());
        
        int result = 0;
        for(int i = 0 ; i < minLength; i++) {
            result += (name1.charAt(i) * name2.charAt(i));
        }
        
        result += 42;
        
        return result % 100;

Tehtävänäsi on luoda algoritmin käyttöön web-sovellus. Tehtävän tekeminen ohjeistetaan askeleittain.

KumpulaLoveMeter

Toteuta pakkaukseen wad.lovemeter.service luokka KumpulaLoveMeter, joka toteuttaa rajapinnan LoveMeterService. Käytä yllä olevaa algoritmia soveltuvuuden laskemiseen. Testaa luokkaasi.

        LoveMeterService loveMeter = new KumpulaLoveMeter();
        int match = loveMeter.match("mikke", "kasper");

        System.out.println("mikke and kasper match " + match + "%");
mikke and kasper match 80%

LoveServlet

Toteuta pakkaukseen wad.lovemeter.servlet luokka LoveServlet, joka perii luokan HttpServlet. Luokan LoveServlet tulee kuunnella polkua /love.

Toteuta luokan LoveServlet metodi doGet siten, että se ottaa pyynnöstä parametrit name1 ja name2, ja laskee edellisessä tehtävässä toteutetun luokan avulla parametrina annettujen nimien yhteensopivuuden. Tämän jälkeen yhteensopivuus asetetaan nimien kanssa pyynnön attribuuteiksi, ja pyyntö ohjataan eteenpäin.

Ohjaa pyyntö tehtäväpohjassa valmiina tarjottuun polusta /WEB-INF/jsp/love.jsp löytyvään jsp-sivuun.

Huom! Käytä edellisessä kohdassa toteutettua luokkaa KumpulaLoveMeter oliomuuttujana, mutta tietenkin siten, että ohjelmoit rajapintaa käyttäen luokassa LoveServlet.

Lomake tietojen lähettämiseen

Toteuta vielä kansiossa Web Pages olevalle sivulle index.jsp lomake tietojen lähettämiseen. Sivu index.jsp näytetään kun käyttäjä selaa sovelluksen juuriosoitteeseen. Lomakkeessa tulee olla kentät nimillä name1 ja name2. Määrittele kentille myös id:t (name1 ja name2 vastaavasti). Lomake tulee lähettää LoveServlet-servletin kuuntelemaan polkuun.

Lomake voi näyttää esimerkiksi seuraavalta:

Testaa vielä sovellustasi kokonaisuudessaan.

Kun olet saanut tehtävän valmiiksi, lähetä se TMCn tarkastettavaksi.

MVC

MVC (model-view-controller) on ohjelmistoarkkitehtuurityyli, jonka tavoitteena on käyttöliittymän erottaminen sovelluslogiikasta. Model on yleensä näytettävä data, view on itse näkymä, ja controller vastaanottaa käyttäjän tekemät käskyt ja muokkaa niiden pohjalta sekä dataa että näytettävää näkymää.

Miten edellä esitetty Hello Web! -esimerkki liittyy MVC-arkkitehtuuriin? Mikä rooli on jsp-sivuilla, servleteillä, ja pyynnön sisältämällä attribuuteilla?

Dynaamiset JSP-sivut: JSTL ja EL

JSP-sivut ovat dynaamista tietoa sisältäviä HTML-sivuja, jotka prosessoidaan palvelimella käyttäjälle näyttämistä. Palvelimella tapahtuvan prosessoinnin yhteydessä JSP-sivu muunnetaan servletiksi, servletti käännetään, ja lopulta servletin tuottama tulostus näytetään käyttäjälle. Tämä mahdollistaa asiakkaan pyyntöön liittyvien tietojen käsittelyn JSP-sivuilla: JSP-sivut ovat servlettejä ja pääsevät täsmälleen samoihin tietoihin käsiksi kuin servletit. JSP-sivuille on myös mahdollista tuottaa sisältöä suoraan Javalla:

<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
        <title>Hello World!</title>
    </head>
    <body>
        <%
        System.out.println("Poor man's logging to server logs!");
        out.append("<p>Message to the page</p>");
        %>
    </body>
</html>

Yllä oleva JSP-sivu tuottaa seuraavanlaisen näkymän, sekä viestin Poor man's logging to server logs! palvelimen logeihin.

Message to the page

That being said, don't do it.: Java-koodin käyttäminen JSP-sivuilla on indikaattori erittäin huonosta suunnittelusta ja johtaa hyvin vaikeasti ylläpidettäviin näkymiin. Erityisesti JSP-sivulla olevassa koodissa sijaitsevan virheen etsiminen on yhtä tuskaa: virheiden stack tracet liittyvät luonnollisesti JSP-sivusta käännetyn servletin koodiin.

On oleellista kuitenkin ymmärtää, että kaikki sivuilla näytettävä tieto luodaan palvelimella.

Java-koodin käyttäminen JSP-sivuilla on kielletty. Tarvitsemme dynaamisuutta sivuillemme: esimerkiksi listojen tulostaminen ei onnistu ilman ohjelmakoodia. Näimme aiemmin esimerkin EL-kielestä, jonka avulla sivulla voi käyttää ja näyttää pyynnössä olevia attribuutteja. EL-kieli oli alunperin osa JSTL-kieltä, joka on kokoelma tägikirjastoja JSP-sivuilla näytettävän datan prosessointiin.

EL

EL eli Expression Language on kieli, jolla pääsee käsiksi mm. pyynnössä oleviin attribuutteihin. EL-kielen lauseet ilmaistaan ${ }-merkinnällä, jonka sisällä on lauseke. Näimme aiemmin yksinkertaisen EL:n käyttöesimerkin jossa pyyntöön lisätään servletissä attribuutti, esim. request.setAttribute("viesti", "attribuutin arvo");, joka näytetään JSP-sivulla, esim. <p>${viesti}</p>.

Olioiden käsittely

EL-kielen piste-operaattorin . avulla pääsee käsiksi attribuutteina olevien olioiden get-metodeihin. Esimerkiksi lause ${auto.hinta} tekee auto-nimellä lisättyyn attribuuttiin metodikutsun getHinta(). Tutkitaan tätä hieman tarkemmin. Oletetaan että käytössämme on seuraava luokka Item.

public class Item {
    private String name;
    private int price;

    public Item(String name, int price) {
        this.name = name;
        this.price = price;
    }

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

    public int getPrice() {
        return this.price;
    }
}

Jos Item-luokan ilmentymä lisätään pyynnön attribuutiksi, voi ilmentymän get-alkuista metodia kutsua pisteoperaattorin avulla JSP-sivulta.

    ...
    Item tmc = new Item("TMC", 330);
    request.setAttribute("item", tmc);
    ...

Kun olio on lisätty pyynnön attribuutiksi nimellä item, voidaan siihen liittyviin get-metodeihin viitata muodossa ${item.ominaisuus}. Tämä tekisi metodikutsun getOminaisuus(). Metodia getName() voi kutsua seuraavasti:

    ...
    <body>
        <p>Ja seuraavana vuorossa on: ${item.name}</p>
    </body>
    ...

Yllä oleva esimerkki luo seuraavanlaisen tulostuksen.

Ja seuraavana vuorossa on: TMC

Vastaavasti Item-olion metodia getPrice() voisi kutsua EL-kielen avulla lauseella ${item.price}

.

Collection-tyyppisten ilmentymien käsittely

EL-kielellä on notaatio [] Collection-tyyppisten olioiden (esim. listat, mapit, taulukot) käsittelyyn. Esimerkiksi, jos attribuutiksi on asetettu map-niminen hajautustaulu:

    ...
    Map<String, String> map = new HashMap<String, String>();
    map.put("text", "tada!");

    request.setAttribute("map", map);
    ...

Päästään siihen käsiksi map-attribuutin kautta. Tämä oli toki tuttua. Hajautustaulun sisällä oleviin arvoihin taas päästään käsiksi attribuutin kautta, esimerkiksi map-attribuutista voidaan hakea text-avaimella olevaa arvoa komennolla ${map['text']}.

    ...
    <body>
        <p>Collection-ilmentymiin pääsee käsiksi: ${map['text']}</p>
    </body>
    ...

Yllä olevan sivun tulostus olisi seuraavanlainen:

Collection-ilmentymiin pääsee käsiksi: tada!

Totuusarvolausekkeet ja laskuoperaatiot

EL-lauseiden sisään voi asettaa myös erilaisia vertailuja ja laskuoperaatioita. Esimerkiksi kahden tuotteen hinnan laskeminen on melko yksinkertaista:

    ...
    Item one = new Item("Milk", 173);
    Item another = new Item("Porridge", 222);

    request.setAttribute("item1", one);
    request.setAttribute("item2", another);
    ...

Laskuoperaation voi toteuttaa EL-lauseen sisällä. Esimerkiksi lause ${item1.price + item2.price} kutsuu ensin attribuutin item1 getPrice()-metodia, ja lisää sen palauttaman arvon attribuutin item2 getPrice()-metodin palauttamaan arvoon.

    ...
    <body>
        <p>${item1.name} and ${item2.name} cost together ${item1.price + item2.price}.</p>
    </body>
    ...

Yllä oleva esimerkki luo seuraavanlaisen tulostuksen.

Milk and Porridge cost together 395.

Katso lisätietoa erilaisista EL-kielen mahdollisuuksista sivuilta oraclen sivuilta.

Todellisuudessa, kukaan ei toivottavasti ohjelmoi kauppalistaa siten, että jokainen tuote lisätään erillisenä attribuuttina. Tutustutaan seuraavaksi JSTL-kieleen, jonka avulla sivuille saadaan toiminnallisuutta mm. listojen läpikäyntiin.

JSTL

JSTL eli JSP Standard Tag Library on kokoelma XML-tägikirjastoja, joiden avulla JSP-sivuille voidaan lisätä dynaamista toiminnallisuutta. Ensimmäinen JSTL-spesifikaatio (vuodelta 2002) määrittelee JSTL:n tavoitteeksi JSP-sivuja toteuttavien ihmisten elämän helpottamisen: osalla käyttöliittymäsuunnittelijoista ei ole ohjelmointitaustaa. Koska JSTL-tägit ovat XML:ää, ei niiden käyttö haittaa käyttöliittymäsuunnittelussa. JSTL:ää ja HTML:ää sisältäviä JSP-sivuja voi tarkastella myös suoraan selaimella.

Servlet-APIn versiota 2.5 käytettäessä käytämme JSTL:n versiota 1.2. JSTLn saa käyttöön lisäämällä projektin pom.xml-tiedostoon seuraavan riippuvuuden:

    <dependency>
        <groupId>jstl</groupId>
        <artifactId>jstl</artifactId>
        <version>1.2</version>
    </dependency>

Kun JSTL-riippuvuus on lisätty projektiin, voi JSTL-tägikirjastoja käyttää osana JSP-sivua. Käytettävä tägikirjasto tulee aina esitellä JSP-sivun alussa. Esimerkiksi seuraava määrittely tuo JSTLn ydinkirjaston käyttöön.

<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>

Tägikirjastoja käytetään niille määriteltyjen etuliitteiden avulla. Yllä olevassa määrittelyssä ydinkirjastolle määritellään etuliite c, jolloin siihen liittyviin elementteihin pääsee käsiksi <c:-etuliitteen avulla.

JSTL sisältää tägit mm. perusohjelmoinnissa käytettävien kontrollirakenteiden käyttöön, erilaisten tulostusten formatointiin, ja esimerkiksi erilaisten RSS/XML-syötteiden käsittelyyn. Tällä kurssilla hyödynnämme lähinnä kontrollirakenteita.

Toistolauseet: forEach

Meille oleellisin komento on forEach, jota käytetään Collection-rajapinnan toteuttavien kokoelmien läpikäyntiin. Sille määritellään attribuutti items, jonka arvona on EL-kielellä merkattu läpikäytävä joukko. Toinen attribuutti, var, määrittelee muuttujan nimen, johon kokoelmasta otettava alkio kullakin iteraatiolla tallennetaan. Perussyntaksiltaan forEach on seuraavanlainen.

    ...
    <pre>
        <c:forEach var="alkio" items="${joukko}">
            ${alkio}
        </c:forEach>
    </pre>
    ...

Yllä käytämme attribuuttia nimeltä joukko, ja tulostamme yksitellen sen sisältämät alkiot.

Huom! Klassisin virhe on määritellä iteroitava joukko merkkijonona items="joukko". Tämä ei luonnollisesti toimi.

Iteroitavan joukon alkioiden ominaisuuksiin pääsee käsiksi EL-kielellä. Tutkitaan seuraavaa esimerkkiä, jossa listaan lisätään kaksi esinettä, lista lisätään pyyntöön, ja lopulta näytetään JSP-sivulla.

    ...
    Item one = new Item("Milk", 173);
    Item another = new Item("Porridge", 222);

    List<Item> list = new ArrayList<Item>();
    list.add(one);
    list.add(another);

    request.setAttribute("list", list);
    ...
    ...
    <p>And the menu is:</p>
    <ol>
        <c:forEach var="item" items="${list}">
            <li>${item.name}</li>
        </c:forEach>
    </ol>
    ...

Lopullinen tulostus näyttää seuraavalta.

And the menu is:

  1. Milk
  2. Porridge

Lisää JSTL-kirjastoon liittyvää tietoa löytyy mm. osoitteista http://docs.oracle.com/javaee/5/tutorial/doc/bnakc.html ja http://www.jsptutorial.net/jsp-standard-tag-library-jstl.aspx.

My Albums

Harjoitellaan hieman JSTL:n ja EL:n käyttöä. Tässä tehtävässä toteutetaan pyyntöön lisättyjen albumeiden listaus JSP-sivulla. Kannattaa tutustua luokkiin Album ja ListServlet ennen aloittamista. JSTL-riippuvuus on lisätty valmiiksi projektin pom.xml-tiedostoon.

Albumien nimien listaaminen

Muokkaa kansiossa WEB-INF/jsp olevaa sivua list.jsp siten, että se tulostaa attribuuttina olevien albumien nimet. Albumit on säilötty listaan, joka on pyynnössä attribuuttina albums.

Albumien kappaleiden lisääminen

Lisää list.jsp sivulle toiminnallisuus albumien sisältämien kappaleiden listaamiseen. Vinkki: Kun käyt edellisessä osassa albumeita läpi, jokainen yksittäinen albumi on olio, johon EL-kielen avulla voi viitata.

Web-sovellukset, hyvät ohjelmointikäytänteet ja peruskäsitteistöä

Tutustutaan seuraavaksi web-sovellusten ohjelmoinnissa käytettäviin perusohjelmointikäytänteisiin. Pohjustamme myös siirtymistä Spring-sovelluskehyksen käyttöön.

POST-pyyntötyypin käyttäminen

Erilaisiin pyyntötyyppeihin tulee reagoida eri tavalla. GET-pyynnöt ovat suoria pyyntöjä, joilla käyttäjät pyytävät palvelimella olevia resursseja. POST-pyynnöt taas ovat pyyntöjä datan muokkaamiseen. Esimerkiksi lomakkeet, joilla muokataan palvelimella olevaa dataa, tulee lähettää POST-tyyppisenä pyyntönä.

Ehkäpä tärkein syy tämän säännön noudattamiselle liittyy webissä oleviin hakukoneisiin. Hakukoneet käyvät verkon sivustoja jatkuvasti läpi uuden tiedon hakemiseen. Ne seuraavat sivuilla olevia linkkejä, mutta rajoittavat yleensä pyyntönsä GET-tyyppisiin pyyntöihin. Jos sivuilla olevat normaalit linkit mahdollistavat tiedon poistamisen, sivujen läpikäynti hakukoneiden toimesta voi poistaa sivulla olevan datan.

Jos käyttäjän tekemällä POST-tyyppisellä pyynnöllä muokataan sivulla olevaa dataa (esim. datan lisääminen lomakkeen avulla), tulee käyttäjä ohjata tekemään uusi GET-tyyppinen pyyntö pyynnön prosessoinnin jälkeen. Tällä pyritään poistamaan mahdollisuus POST-tyyppisten pyyntöjen uudelleentekemiseen esimerkiksi F5-näppäintä painettaessa, ja se helpottaa huomattavasti sovelluksen sisäisen rakenteen suunnittelua. Jokaisella servletillä on täsmälleen yksi vastuu: yksi on tiedon lisäämiseen, toinen listaamiseen jne..

Käytännössä käyttäjän ohjaaminen uuden pyynnön tekemiseen tapahtuu palauttamalla POST-pyyntöön HTTP-statuskoodi 303 (tai 302) ja osoite uuteen sijaintiin. Kun selain saa "sivu muuttanut" -viestin, hakee se automaattisesti uudessa osoitteessa olevan sivun.

SOLID

Termi SOLID lanseerattiin Robert "Uncle Bob" Martinin toimesta 2000 luvun alussa. Termi sisältää hyviä olio-ohjelmoinnissa käytettäviä käytänteitä. SOLID on akronyymi seuraaville käsitteille: Single responsibility principle, Open/closed principle, Liskov substitution principle, Interface segregation principle, ja Dependency inversion principle.

Käytännössä SOLID on lista oliosuunnittelun periaatteita, joita seuratessa järjestelmän ylläpidettävyys ja kehitettävyys on huomattavasti helpompaa. Haluamme että omat web-sovelluksemme seuraavat yllälistattuja periaatteita.

Front Controller pattern

Front Controller on suunnittelumalli, jossa kaikille pyynnöille tarjotaan keskitetty käsittelypaikka. Käytännössä kaikki web-sovellukseen liittyvät pyynnöt ohjataan aluksi yhdelle kontrollipalvelulle, esimerkiksi servletille, joka ohjaa ne eteenpäin riippuen pyynnöstä ja pyynnössä haluttavasta resurssista.

Tyypillinen esimerkki Front Controllerin käytöstä on käyttäjän oikeuksien varmistaminen. Koska kaikki pyynnöt ohjataan ensin tietylle servletille, voidaan servletissä tarkistaa mm. käyttöoikeuksien olemassaolo, sekä mahdollisesti hakea käyttäjäkohtaisia tietoja. Toinen esimerkki on Wizard-tyyppiset sovellukset, joissa sivujen näyttöjärjestys on erittäin tärkeä. Tässä Front Controller voi helposti kontrolloida sivujen näyttöjärjestystä.

Front Controller-suunnittelumallia käytetään web-sovellusten toteuttamiseen liittyvän "bulk"-koodin piilottamiseen. Pyyntöjen prosessoinnit, ohjaukset, ym. tulee ohjelmointikielestä riippumatta aina toteuttaa. Sovelluskehykset tarjoavat valmiit rungot, joita käyttämällä ohjelmoija voi keskittyä web-sovelluksen oleellisempiin asioihin. Huomattava osa aktiivisessa käytössä olevista sovelluskehyksistä käyttää Front Controller-suunnittelumallia, mm. Zend, Symfony, Ruby on Rails, ASP .NET MVC ja Spring.

Oma Front Controller

Toteutetaan oma Front Controller-luokka. Ajatuksena on se, että kaikki pyynnöt ohjataan ensin tietylle Servlet-luokalle, jonka tehtävänä on: (1) ohjata pyynnöt niiden käsittelyyn erikoistuneille luokille, (2) hoitaa pyyntöihin liittyvät perustoiminnallisuudet. Kaikkien pyyntöjen ohjaaminen tietylle Servlet-luokalle onnistuu lisäämällä web.xml-dokumentissa oleviin kuunneltaviin osoitteisiin *. Esimerkiksi seuraava konfiguraatio ohjaisi kaikki polkuun /app/ ja sen alle tulevat pyynnöt servletille FrontControllerServlet.

    <servlet>
        <servlet-name>FrontControllerServlet</servlet-name>
        <servlet-class>wad.chat.core.FrontControllerServlet</servlet-class>
    </servlet>    
    <servlet-mapping>
        <servlet-name>FrontControllerServlet</servlet-name>
        <url-pattern>/app/*</url-pattern>
    </servlet-mapping>

Tyypillisiä yhteisiä tehtäviä, joita aiemmat Servlet-luokkamme ovat tähän mennessä toteuttaneet, on vastauksen merkistön asettaminen pyynnön käsittelyn alussa (response.setContentType("text/html;charset=UTF-8")), ja pyynnön ohjaaminen JSP-sivulle pyynnön lopussa (request.getRequestDispatcher(".../sivu.jsp").forward(request, response)). Näiden lisäksi jokaisella Servlet-luokalla on ollut oma spesifi toiminnallisuus.

Luodaan aluksi runko omalle FrontController-toteutuksellemme.

// importit ym
public class FrontControllerServlet extends HttpServlet {

    protected void processRequest(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        response.setContentType("text/html;charset=UTF-8");

        // pyynnön ohjaaminen oikealle käsittelijälle

        request.getRequestDispatcher(".../sivu.jsp").forward(request, response);
    }

    // doGet ja doPost kutsuvat processRequest-metodia
}

Toteutetaan pyynnön käsittelijää varten rajapinta, joka määrittelee kaksi metodia. Toinen metodi prosessoi pyynnön ja palauttaa sivun johon pyyntö ohjataan, toinen määrittelee käsittelijän kuunteleman polun. Kutsutaan rajapintaa nimellä Controller.

public interface Controller {
    String getListenedPath();

    String processRequest(HttpServletRequest request,
            HttpServletResponse response)
            throws Exception;
}

Luodaan ensimmäinen konkreettinen kontrolleritoteutus ListController. Toteutus kuuntelee sovelluksen polkua list, lisää pyyntöön listan merkkijonoja, ja palauttaa JSP-sivuun osoittavan osoitteen.

// importit
public class ListController implements Controller {

    @Override
    public String getListenedPath() {
        return "list";
    }

    @Override
    public String processRequest(HttpServletRequest request,
            HttpServletResponse response) throws Exception {
        List<String> strings = Arrays.asList("hello", "world");
        request.setAttribute("list", messageService.list());

        return "/WEB-INF/jsp/list.jsp";
    }
}

Muutetaan luokkaa FrontControllerServlet siten, että se tuntee Controller-rajapinnan toteuttamat luokat. Lisäämme kontrollerit init-metodissa, jota palvelin kutsuu Servlet-luokan luonnin yhteydessä. Tämän voisi toteuttaa myös esimerkiksi Javan reflektion avulla, jolloin luokan FrontControllerServlet ei tarvitsisi tietää Controller-rajapinnan toteuttamista luokista. Pitäydymme kuitenkin kevyemmässä esimerkissä.

// importit ym
public class FrontControllerServlet extends HttpServlet {

    private Map<String, Controller> pathToControllerMap;

    @Override
    public void init() {
        pathToControllerMap = new TreeMap<String, Controller>();

        Controller listController = new ListController();
        pathToControllerMap.put(listController.getListenedPath(), listController);
    }

    protected void processRequest(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        response.setContentType("text/html;charset=UTF-8");
     
        // haetaan sopiva kontrolleri
        Controller controller = getController(request.getRequestURI());
        if (controller == null) {
            // jos kontrolleria ei löydy, palautetaan statusviesti 404
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            response.getWriter().println("404: " + request.getRequestURI());
            return;
        }

        // muuten, ohjataan prosessointi kontrollerille
        String page = controller.processRequest(request, response);

        // ja pyyntö lopuksi oikealle JSP-sivulle
        request.getRequestDispatcher(page).forward(request, response);
    }

    // metodi oikean kontrollerin valitsemiseen löytämiseen
    private Controller getController(String uri) {
        if (!uri.contains("/")) {
            return pathToControllerMap.get(uri);
        }

        uri = uri.substring(uri.lastIndexOf("/") + 1);
        return pathToControllerMap.get(uri);
    }

    // doGet ja doPost kutsuvat processRequest-metodia
}

Luodaan seuraavaksi kontrolleri tiedon lisäämiseen. Luokka AddController saa pyynnön mukana dataa, jota sen pitää tallentaa myöhempää käyttöä varten. Jätämme itse tiedon tallentamisen harjoitustehtäväksi.

// importit
public class AddController implements Controller {

    @Override
    public String getListenedPath() {
        return "add";
    }

    @Override
    public String processRequest(HttpServletRequest request,
            HttpServletResponse response) throws Exception {
        String information = request.getParameter("information");
        // tallennetaan tieto

        return "/WEB-INF/jsp/add.jsp"; // ???
    }
}

Huomaamme palautettavan sivun osoitetta tarkastellessamme, että rikomme aiemmin mainittua sääntöä tiedon tallentamiseen liittyen. Jos pyynnössä tallennetaan tietoa, tulee se ohjata uudelleen tuplatallennusten estämiseksi (kts. kappale 6.1). Joudumme siis toteuttamaan mekanismin pyyntöjen uudelleen ohjaamiselle.

Koska pyyntöjen eteenpäin ohjaaminen on luokan FrontControllerServlet-vastuulla, ei uudelleenohjausta voi määritellä Controller-rajapinnan toteuttamissa luokissa.

Ratkaistaan ongelma määrittelemällä sääntö. Jos metodin processRequest palauttama merkkijono sisältää alkuosan redirect:, tulee pyyntö ohjata uudestaan redirect:-osaa seuraavaan osoitteeseen. Muutetaan luokkaa AddController siten, että siihen saapuvat pyynnöt ohjataan lopulta list-osoitetta kuuntelevalle kontrollerille.

// importit
public class AddController implements Controller {

    // mm. tiedon tallennuspaikka

    @Override
    public String getListenedPath() {
        return "add";
    }

    @Override
    public String processRequest(HttpServletRequest request,
            HttpServletResponse response) throws Exception {
        String information = request.getParameter("information");
        // tallennetaan tieto

        return "redirect:list"; // :)
    }
}

Muokataan luokkaa FrontControllerServlet siten, että se ottaa huomioon Controller-tyyppisten olioiden erilaiset palautukset. Jos metodin processRequest palauttamassa merkkijonossa on alkuosa redirect:, pyydetään käyttäjää tekemään uudelleenohjaus. Muulloin pyyntö ohjataan määritellylle JSP-sivulle. Luokkaa myös refaktoroidaan hieman.

// importit ym
public class FrontControllerServlet extends HttpServlet {

    private static final String REDIRECT_PREFIX = "redirect:";
    private Map<String, Controller> pathToControllerMap;

    // init-metodi, jossa kontrollerit luodaan ja lisätään pathToControllerMap-olioon

    protected void processRequest(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        response.setContentType("text/html;charset=UTF-8");

        // haetaan sopiva kontrolleri
        Controller controller = getController(request.getRequestURI());
        if (controller == null) {
            // jos kontrolleria ei löydy, palautetaan statusviesti 404
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            response.getWriter().println("404: " + request.getRequestURI());
            return;
        }

        // suoritetaan pyyntö löydetyllä kontrollerilla
        executeControllerAndResolveView(controller, request, response);
    }

    // suoritetaan pyyntö ja ohjataan pyynnön vastaus näkymän
    // päättelevälle metodille
    private void executeControllerAndResolveView(Controller controller,
            HttpServletRequest request, HttpServletResponse response)
            throws IOException, ServletException {
        String resultPath = null;
        try {
            resultPath = controller.processRequest(request, response);
        } catch (Exception ex) {
            throw new ServletException(ex);
        }

        resolveView(resultPath, request, response);
    }

    // pyyntöön liittyvän näkymän etsiminen
    private void resolveView(String resultPath,
            HttpServletRequest request, HttpServletResponse response)
            throws IOException, ServletException {

        // jos vastaus alkaa etuliitteellä "redirect:", käyttäjälle
        // tulee palauttaa pyyntö uudelleenohjaukseen
        if (resultPath.startsWith(REDIRECT_PREFIX)) {
            String redirectTo = resultPath.substring(REDIRECT_PREFIX.length());

            // metodi sendRedirect lähettää käyttäjälle tiedon sivun muualla
            // sijaitsemisesta. Käytännössä selain tekee uuden kyselyn vastauksessa
            // tulevaan osoitteeseen.
            response.sendRedirect(redirectTo);

        } else {
            request.getRequestDispatcher(resultPath).forward(request, response);
        }
    }

    // ...

Yllä oleva Front Controller rikkoo oliosuunnittelun hyviä periaatteita. Luokalla on monta vastuuta: se ohjaa pyynnöt kontrollerille ja päättelee kontrollerin vastauksen perusteella palautettavan sivun. Palautettavan sivun päättelyyn kannattaisi tehdä erillinen luokka. Jätämme sen kuitenkin tässä tekemättä.

Chat

Tässä tehtäväsarjassa luodaan chat-palvelun perustoiminnallisuutta. Sovelluksessa on valmiina äsken nähty Front Controller patternia seuraava FrontControllerServlet, jota käytät hyödyksesi uusia komponentteja tehdessäsi.

FrontControllerServlet käyttää hyödyksi Controller-rajapinnan toteuttavia luokkia. Controller-rajapinta on seuraavanlainen:

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public interface Controller {
    String getListenedPath();

    String processRequest(HttpServletRequest request,
            HttpServletResponse response)
            throws Exception;
}

Metodin getListenedPath tulee palauttaa polku, johon tulleet pyynnöt ohjataan rajapinnan toteuttavalle kontrolleriluokalle. Metodi processRequest hoitaa pyynnön. Metodissa processRequest ei esimerkiksi ohjata pyyntöä eteenpäin, vaan pyynnön ohjaamisen vastuu jätetään FrontControllerServlet-luokalle.

Metodi processRequest palauttaa merkkijonon. Jos merkkijono on jsp-sivun sijainti, esimerkiksi /WEB-INF/jsp/list.jsp, näytetään käyttäjälle haluttu JSP-sivu. Jos taas merkkijonossa on redirect:-etuliite, selainta pyydetään tekemään uudelleenohjaus redirect: -merkkijonoa seuraavaan osoitteeseen. Esimerkiksi, jos metodi processRequest palauttaa merkkijonon redirect:view, käyttäjä ohjattaisiin osoitteeseen http://palvelu.net/sovellus/view.

Sovelluksen lopullinen ulkoasu voi olla esimerkiksi seuraavanlainen:

Huom! Projektissa käytettävä Front Controller kuuntelee osoitteen /app/ alle tulevia pyyntöjä. Toteuta sovelluksesi siten, että otat tämän huomioon.

Viestien listaaminen

Luodaan ensin kontrolleri viestien listaamiselle. Kun tämä osio on valmis, luotu kontrolleri vastaanottaa list-osoitteeseen tulevat pyynnöt, lisää viestit pyynnön attribuutiksi, ja palauttaa FrontControllerServlet-luokalle näytettävän JSP-sivun jossa viestit listataan.

Toteuta pakkaukseen wad.chat.controller luokka ListMessagesController, joka toteuttaa pakkauksessa wad.chat.controller olevan rajapinnan Controller. ListMessagesController-luokalla on konstruktori, joka saa parametrinaan pakkauksessa wad.chat.service olevan rajapinnan MessageService. Metodin getListenedPath tulee kuunnella polkua list.

Toteuta metodi processRequest siten, että pyyntöön lisätään attribuutiksi messages, joka sisältää rajapinnan MessageService tarjoaman metodin list avulla saatavat viestit. Palauta lopulta osoite kansiossa WEB-INF/jsp sijaitsevaan list.jsp-sivuun.

Lisää tämän jälkeen sivulle list.jsp viestien tulostus JSTL:n forEach-toimintoa käyttäen.

Lisää lopulta juuri luotu kontrolleri FrontControllerServlet-luokan init-metodissa olevaan pathToControllerMap-olioon. Käytä valmista InMemoryMessageService-oliota viestipalveluna.

Viestien lisääminen

Toteutetaan seuraavaksi kontrolleri viestien lisäämiselle. Kun tämä osio on valmis, luotu kontrolleri vastaanottaa add-message-osoitteeseen tulevat pyynnöt, lisää viestit MessageService-olioon, ja uudelleenohjaa pyynnön ListMessagesController-luokan kuuntelemaan osoitteeseen.

Toteuta pakkaukseen wad.chat.controller luokka AddMessageController, joka toteuttaa pakkauksessa wad.chat.controller olevan rajapinnan Controller. AddMessageController-luokalla on konstruktori, joka saa parametrinaan pakkauksessa wad.chat.service olevan rajapinnan MessageService. Metodin getListenedPath tulee kuunnella polkua add-message. Lisää kontrolleri myös Front Controller-servletissä olevaan pathToControllerMap-olioon.

Toteuta metodi processRequest siten, että pyynnössä lähetettävä parametri message lisätään osaksi MessageService-olion viestejä. Palauta tämän jälkeen merkkijono "redirect:list", jolloin FrontControllerServlet osaa tehdä uudelleenohjauksen osoitteeseen list.

Lisää tämän jälkeen sivulle list.jsp lomake viestien lähettämiseen add-message -osoitetta kuuntelevaan kontrolleriin. Käytä tekstikentän nimenä ja id:nä merkkijonoa message.

Testaa lopuksi että sovelluksesi näyttää lähetetyt viestit.

Tietoturvan alkeet

Nykyinen Chat-sovelluksemme mahdollistaa erilaisia cross-site-scripting -hyökkäyksiä. Esimerkiksi Chat-palvelun hallinnoimaan osoitteeseen liittyvät evästeet ovat ilkeämielisen käyttäjän saatavilla. Testaa chattia lähettämällä sille viestiksi <SCRIPT SRC=http://www.cs.helsinki.fi/u/avihavai/trololo.js></SCRIPT>.

Huomaat että täysin muualla sijainnut lähdekooditiedosto suoritettiin sivullasi.

Tehdään pieni muutos, jonka avulla saadaan ainakin jonkintasoinen mielenrauha Chat-sovelluksemme tietoturvaan liittyen (myöhemmin huomaamme että mielirauha oli virheellinen...).

Klassinen ratkaisu on HTML-elementtejä aloittavien ja lopettavien merkkien muuttaminen niitä kuvaaviksi merkkijonoiksi. Esimerkiksi < -merkin näyttämiseksi HTML-koodissa tulee olla merkkijono &lt;

Koska pyörän uudelleen keksiminen on tässä epäoleellista, käytetään Apache Commons lang -kirjaston StringEscapeUtils-luokan tarjoamaa escapeHtml4-metodia mahdollisen HTML-koodin muuttamiseksi. Lisää pom.xml -tiedostoon Apache Commons lang -kirjastoriippuvuus:

Huom! varmista että käytät StringEscapeUtils-luokkaa pakkauksesta org.apache.commons.lang3 .

  <groupId>org.apache.commons</groupId>
  <artifactId>commons-lang3</artifactId>
  <version>3.1</version>

Lisää lopuksi AddMessageController-luokan processRequest-metodiin toiminnallisuus, jolla muutat HTML-merkit niitä kuvaaviksi merkkijonoiksi.

Suorita lopuksi testit, ja lähetä sovellus TMC:lle testattavaksi.

Pelkkä HTML-merkkien muuntaminen niitä kuvaaviksi merkkijonoiksi ei aina riitä sivuston turvaamiseksi. Merkistön muuntamisen voi kiertää helposti esimerkiksi lähettämällä viesti palvelimelle jollain toisella merkistökoodauksella. Vaikka palvelimelle tulevia viestejä ei muunnettaisikaan ennen tallennusta, tyypillisesti palvelimelta käyttäjälle lähetettävät viestit muunnetaan esimerkiksi käyttäjän selaimen toimesta. Tällöin yllä esitetty "tietoturvaratkaisu" ei ratkaise mitään.

Esimerkiksi HTML-koodi <marquee>web-palvelinohjelmointi</marquee> näyttää UTF-7 muodossa seuraavalta:

+ADw-marquee+AD4-web-palvelinohjelmointi+ADw-/marquee+AD4-

Dependency Injection

Dependency injection (riippuvuuksien injektointi) on suunnittelumalli, jonka avulla ohjelmiston käyttämät luokat voidaan päättää ajonaikaisesti. Oletetaan, että käytössämme on luokka HelloWorld, jonka ainut metodi on merkkijonon palauttava getMessage.

public class HelloWorld {
    public String getMessage() {
        return "Hello World!";
    }
}

Perinteisesti käytetyt oliot luodaan aina ohjelmoijan toimesta new-komennolla:

        // ...
        HelloWorld helloWorld = new HelloWorld();
        System.out.println(helloWorld.getMessage());
        // ...

Toinen lähestymistapa on määritellä käytössä olevat luokat erillisessä konfiguraatiotiedostossa. Sovellusta käynnistettäessä konfiguraatiotiedosto luetaan, ja sen perusteella luodaan oliot varastoon, eli oliokontekstiin, josta niitä voi tarvittaessa hakea. Esimerkiksi luokan HelloWorld voisi määritellä erilliseen konfiguraatiotiedostoon siten, että sille määritellään oma tunnus, jonka kautta siihen pääsee käsiksi:

    // ...
    <bean id="helloWorld" class="werkko.HelloWorld" />
    // ...

Sovellusta käynnistettäessä konfiguraatiotiedoston lataaja luo oliot oliokontekstiin, josta niihin pääsee käsiksi.

        // ...
        // luodaan context-olio, joka luo konfiguraatiotiedostossa (beans.xml)
        // määritellyt oliot oliokontekstiin
	ApplicationContext context = new ClassPathXmlApplicationContext("beans.xml");
        
        // nyt oliota ei luoda itse, vaan sitä käytetään oliokontekstin kautta
        HelloWorld helloWorld = (HelloWorld) context.getBean("helloWorld");
        System.out.println(helloWorld.getMessage());
        // ...

Ok, mutta eikö tämä tee vaan kaikesta paljon vaikeampaa?

Ensisilmäyksellä koodi vaikuttaa hyvin paljon monimutkaisemmalta. Pohditaan kuitenkin tämän lähestymistavan hyötyjä.

  1. Rajapintojen käyttäminen ja toteutusten vaihtaminen. Jos sovellus käyttää rajapintoja, voidaan todellinen toteutus piilottaa konfiguraation taakse. Tällöin komponentin toteuttajan ei tarvitse millään tavalla tietää rajapinnan toteuttavan luokan todellisesta toteutuksesta.
  2. Sovellusten testaaminen. Sovelluksia testatessa halutaan usein käyttää ns. mock-olioita, joiden avulla voidaan tarkistaa että metodit ja luokat toimivat oikein. Käytettävien olioiden vaihtaminen onnistuu konfiguraatiotiedostoa vaihtamalla.
  3. Olioiden parametrien injektointi. Koska olioiden luominen onnistuu konfiguraatiotiedoston avulla, voidaan olioiden parametrit myös määritellä konfiguraatiotiedostossa. Esimerkiksi tietokantaa käyttävien sovellusten testaamisessa kantana ei kannata käyttää tuotantokantaa. Tietokannan vaihtaminen onnistuu konfiguraatiotiedoston tai -parametrin vaihdolla.

Mikä ihmeen Bean?

Bean on uudelleenkäytettävä komponentti, jota käytetään tiedon käsittelyyn. Beanit ovat käytännössä tavallisia Java-luokkia, joilla on ennaltamääritelty nimeämiskäytäntö: tietoa asettavat metodit ovat muotoa setArvo(...), ja tietoa pyytävät metodit ovat muotoa getArvo().

Bean-luokilla on kolme kriteeriä:

  1. Luokalla tulee olla parametriton konstruktori. Tämän avulla sovelluskehykset pystyvät luomaan olioista ilmentymiä helposti.
  2. Luokan attribuutteihin tulee päästä käsiksi get- ja set-tyyppisillä metodeilla. Totuusarvon palauttavissa metodeissa käytetään get-etuliitteen sijaan is-etuliitettä. Tämän nimeämiskäytännön takia sovelluskehykset pääsevät olion attribuutteihin käsiksi ja voivat asettaa attribuutteja.
  3. Bean-tyyppisten luokkien tulee toteuttaa Serializable-rajapinta, jonka avulla olion tila voidaan tallentaa tarvittaessa.

Näiden nimeämiskäytäntöjen takia esimerkiksi EL-kieli toimii. Luistamme usein kolmannesta vaatimuksesta tällä kurssilla.

Dependency Injection ja Spring

Spring on sovelluskehys, jossa on valmiudet riippuvuuksien injektointiin. Springin oliokontekstitoiminnallisuuden saa projektin käyttöön lisäämällä seuraavan riippuvuuden pom.xml-tiedostoon:

        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
            <version>3.1.2.RELEASE</version>
        </dependency>

Olioiden kontekstiin lataamiseen käytettävä konfiguraatiotiedosto näyttää seuraavanlaiselta. Tiedostossa määritellään käytettävät nimiavaruudet sekä käyttöön ladattavat beanit. Alla lataamme pakkauksessa werkko olevan HelloWorld-olion oliokontekstiin, josta siihen pääsee käsiksi tunnuksella helloWorld.

<?xml version="1.0" encoding="UTF-8"?> 
<beans xmlns="http://www.springframework.org/schema/beans" 
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
       xsi:schemaLocation="http://www.springframework.org/schema/beans 
                           http://www.springframework.org/schema/beans/spring-beans-3.0.xsd"> 

    <bean id="helloWorld" class="werkko.HelloWorld" />
</beans>

Konfiguraatiotiedosto tulee tallentaa sijaintiin, josta Spring löytää sen. Mavenia käytettäessä kansion src/main/resources (NetBeansissa kansio "Other sources") tiedostot ovat projektin käytössä. Konfiguraation saa ladattua esimerkiksi Springin ClassPathXmlApplicationContext-luokan avulla seuraavasti.

import org.springframework.context.ApplicationContext;
import org.springframework.context.support.ClassPathXmlApplicationContext;

        // ...
        // luodaan context-olio, joka luo konfiguraatiotiedostossa (beans.xml)
        // määritellyt oliot oliokontekstiin
	ApplicationContext context = new ClassPathXmlApplicationContext("beans.xml");
        
        // helloWorld-olio on nyt löydettävissä oliokontekstista
        HelloWorld helloWorld = (HelloWorld) context.getBean("helloWorld");
        System.out.println(helloWorld.getMessage());
        // ...

Myös olion attribuuttien asetus onnistuu konfiguraatiotiedoston avulla. Muutetaan luokkaa Hello World siten, että sillä on myös metodi setMessage.

public class HelloWorld {
    private String message;

    public String getMessage() {
        return this.message;
    }

    public void setMessage(String message) {
        this.message = message;
    }
}

Ominaisuuksien asettaminen tapahtuu property-elementin avulla. Alla määritellään oliokontekstia varten olio helloWorld, jonka metodia setMessage kutsutaan arvolla Hullo! kun olio on luotu.

    <bean id="helloWorld" class="werkko.HelloWorld"> 
        <property name="message" value="Hullo!"/> 
    </bean>

Olioille voi luonnin yhteydessä myös antaa viitteitä toisiin olioihin. Luodaan luokka HelloWorldContainer, joka sisältää HelloWorld-olion.

// ...
public class HelloWorldContainer {
    private HelloWorld helloWorld;

    // muut metodit ym
    public void setHelloWorld(HelloWorld helloWorld) {
        this.helloWorld = helloWorld;
    }
}

HelloWorld-olion voi injektoida HelloWorldContainer-oliolle seuraavasti:

    <bean id="helloWorld" class="werkko.HelloWorld"> 
        <property name="message" value="Hullo!"/> 
    </bean>

    <bean id="helloWorldContainer" class="werkko.HelloWorldContainer"> 
        <property name="helloWorld" ref="helloWorld"/> 
    </bean>

Käytännössä yllä luodaan oliokontekstiin kaksi oliota: HelloWorld tunnuksella helloWorld ja HelloWorldContainer tunnuksella helloWorldContainer. Kun olio helloWorldContainer on luotu, sen setHelloWorld-metodia kutsutaan siten, että se saa parametrina viitteen aiemmin luotuun helloWorld-olioon.

Application Context


Huom! Kun aloitat tehtävän tekemisen lataa heti tarvittavat kirjastot valitsemalla oikealla hiirennäppäimellä projektin "Dependencies"-kansion ja vaihtoehdon "Download Declared Dependencies". Tämän pitäisi ladata tarvittavat kirjastot käyttöösi.

Kirjastojen lataaminen estää tilanteita, joissa NetBeans saattaa ehdottaa riippuvuuksia, joita ei löydy käytössä olevista dependency-palvelimista. Esimerkiksi Springin versio 3.2.0.BUILD-SNAPSHOT ei ole käytössämme vaikka NetBeans sitä ehdottaakin.


Lisätään tässä tehtävässä muutama olio sovelluksen oliokontekstiin. Sovellus ei ole web-sovellus, sillä haluamme lähteä liikkeelle pienistä asioista.

Tehtävässä tulee valmiina luokat HelloApplicationContext ja HelloInjectingProperties. Projektiin on konfiguroitu valmiiksi riippuvuus Springin oliokontekstiin.

Hello Application Context

Lisää kansiossa src/main/resources (NetBeansissa kansio "Other sources") olevaan beans.xml-tiedostoon uusi bean-elementti, jonka tunnus on helloApplicationContext ja jonka luokka on wad.applicationcontext.HelloApplicationContext.

Lisää myös lähdekooditiedostoissa olevaan App-lähdekooditiedostoon HelloApplicationContext-olion noutaminen oliokontekstista. Tulosta tämän jälkeen olion sisältämä viesti standarditulostusvirtaan (System.out...).

Hello Property Setting

Lisää beans.xml-tiedostoon bean, jonka tunnus on helloInjectingProperties ja luokka on HelloInjectingProperties. Anna luokalle parametri (property) nimeltä message, jonka arvo on My Cool Property.

Lisää tämän jälkeen App-lähdekooditiedostoon HelloInjectingProperties-olion noutaminen oliokontekstista. Tulosta tämän jälkeen olion sisältämä viesti standarditulostusvirtaan (System.out...).

Sovelluksen tulostuksen pitäisi olla seuraavanlainen:

Hello Application Context!
My Cool Property

Kun testit menevät läpi, lähetä sovellus TMC:lle testattavaksi.

Inversion of Control ja automaattinen riippuvuuksien haku

Käytännössä riippuvuuksien injektoinnissa on kyse kontrollin antamisesta sovelluskehykselle (Inversion of Control). Sen sijaan, että käyttäjä loisi itse ohjelman tarvitsemat oliot, olioiden luominen ja parametrien asettaminen tapahtuu sovelluskehyksen toimesta (käyttäjän määrittelemän konfiguraation pohjalta). Inversion of Controlin ja Dependency Injectionin hyödyt ovat mm.:

  1. Ohjelman komponentteja voi testata helposti yksitellen: testatessa käytettäviä ominaisuuksia voi injektoida tarvittaessa.
  2. Ohjelman kompleksisuus vähenee: eri komponentit voivat tuntea toiset komponentit vain rajapintojen kautta. Konkreettisia toteutuksia ei tarvitse nähdä, ja new-kutsuja ei juurikaan tarvitse tehdä.
  3. Ohjelman käyttämien komponenttien vaihtaminen on helppoa.

Tutustu Martin Fowlerin artikkeliin Inversion of Control Containers and the Dependency Injection pattern.

Miten käsitteet "Inversion of Control", "Dependency Injection" ja "Service Locator" liittyvät toisiinsa artikkelin perusteella?

 

Joissain sovelluskehyksissä riippuvuuksia ei tarvitse juurikaan konfiguroida, vaan sovelluskehys osaa päätellä ne ajonaikaisesti esimerkiksi annotaatioiden tai luokkahierarkian perusteella.

Case: Spring

Spring tarjoaa toiminnallisuuden beanien automaattiseen lataamiseen ja konfigurointiin. Käyttämällä automaattista komponenttien hakua (component-scan), sovellus osaa etsiä käytössä olevat komponentit, ja injektoida niitä tarvittaessa.

Jotta Spring ymmärtää, että luokka tulee ladata kontekstiin, tulee luokalle lisätä @Component-annotaatio. Esimerkiksi luokan HelloWorld saa ladattua automaattisesti oliokontekstiin, jos sillä on annotaatio @Component.

@Component
public class HelloWorld {

    public String getMessage() {
        return "Hello World!";
    }
}

Luokkien automaattisen lataamisen saa kytkettyä päälle asettamalla beans.xml-dokumenttiin component-scan-elementti, jolle annetaan parametrina pakkaus, josta olioita etsitään. Etsintä tapahtuu myös alipakkauksista.

<?xml version="1.0" encoding="UTF-8"?> 
<beans xmlns="http://www.springframework.org/schema/beans" 
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
       xmlns:context="http://www.springframework.org/schema/context"
       xsi:schemaLocation="http://www.springframework.org/schema/beans 
           http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
           http://www.springframework.org/schema/context 
           http://www.springframework.org/schema/context/spring-context-3.0.xsd"> 

    <context:component-scan base-package="werkko"/>
</beans>

Nyt kaikki pakkauksen werkko ja sen alipakkauksissa olevat annotoidut luokat ladataan valmiiksi oliokontekstiin. Huomaa, että konfiguraatiotiedostossa on otettu käyttöön nimiavaruus context, joka sisältää kontekstin käsittelyyn liittyviä konfiguraatioelementtejä.

Myös viitteiden asettaminen onnistuu automaattista konfiguraatiota käytettäessä. Annotaatiolla @Autowired voidaan määritellään attribuutti, joka tulee asettaa automaattisesti. Muokataan aiemmin nähtyä luokkaa HelloWorldContainer siten, että se ladataan automaattisesti oliokontekstiin, ja että sen attribuutti HelloWorld asetetaan automaattisesti.

// ...
@Component
public class HelloWorldContainer {
    @Autowired
    private HelloWorld helloWorld;

    // muut metodit ym
}

Annotaation @Autowired voi asettaa myös osaksi set-metodia.

// ...
@Component
public class HelloWorldContainer {
    private HelloWorld helloWorld;

    // muut metodit ym

    @Autowired
    public void setHelloWorld(HelloWorld helloWorld) {
        this.helloWorld = helloWorld;
    }
}

Oliokontekstissa olevien olioiden tunnukset määritellään oletuksena automaattisesti luokan tai sen perintähierarkiassa olevien luokkien nimen perusteella. Yllä käytössämme olisi oliot helloWorld (luotu luokasta HelloWorld) ja helloWorldContainer (luotu luokasta HelloWorldContainer). Käytännössä oliokontekstiin luotavan olion oletusnimi on sama kuin luokan nimi, mutta ensimmäinen kirjain pienellä.

Olioiden tunnukset voidaan myös määritellä käsin. Annotaatiolle @Component voi antaa parametrina merkkijonon, joka määrittelee olion nimen, esim. @Component("hello"). Jos @Autowired-annotaatiolle haluaa käyttöön tietyn luokan ilmentymän, tulee sen kaveriksi määritellä erillinen annotaatio @Qualifier, joka saa parametrina käytettävän olion tunnuksen:

    // ...
    @Autowired
    @Qualifier("hello")
    public void setHelloWorld(HelloWorld helloWorld) {
        this.helloWorld = helloWorld;
    }
    // ...

Ylläolevassa esimerkissä metodia setHelloWorld yritettäisiin kutsua antamalla sille parametrina oliokontekstista tunnuksella hello löytyvä olio.

Autowiring Dependencies

Harjoitellaan seuraavaksi omien automaattisesti oliokontekstiin ladattavien luokkien luomista.

MessagingApplication

Luo pakkaukseen wad.autowiring luokka MessagingApplication. Luokalla tulee olla metodi public void printMessage(), joka tulostaa viestin "Hello there" standarditulostusvirtaan. Kun olet luonut luokan, lisää sille vielä annotaatio @Component pakkauksesta org.springframework.stereotype.

Konfiguroi tämän jälkeen tiedostoon beans.xml automaattinen olioiden oliokontekstiin lataaminen pakkauksesta wad ja sen alipakkauksista.

Kun konfiguraatio on kunnossa, muokkaa luokkaa App siten, että haet luokan MessagingApplication ilmentymän oliokontekstista. Kutsu lopulta MessagingApplication-olion metodia printMessage.

MessageContainer

Luo pakkaukseen wad.autowiring luokka InMemoryMessageContainer, joka toteuttaa rajapinnan MessageContainer. Metodin public String getMessage() tulee palauttaa merkkijonon "Hello there". Kun olet luonut luokan, lisää sille vielä annotaatio @Component pakkauksesta org.springframework.stereotype.

Koska beans.xml on konfiguroitu lataamaan oliot automaattisesti oliokontekstiin, on myös annotaatiolla @Component merkitystä InMemoryMessageContainer-oliosta ilmentymä. Lisätään se osaksi MessagingApplication-luokkaa.

Lisää luokalle MessagingApplication oliomuuttuja MessageContainer, sekä sen asettava metodi setMessageContainer. Lisää metodille setMessageContainer annotaatio @Autowired. Tämä tarkoittaa että sovelluskehys yrittää automaattisesti lisätä siihen sopivan olion.

Muokkaa MessagingApplication-luokkaa vielä siten, että metodissa printMessage tulostetaan metodilla setMessageContainer asetetun messageContainer-olion getMessage-metodin palauttama viesti.

Testaa vielä lopulta sovelluksen toimintaa, ja lähetä se TMCn tarkastettavaksi.

Spring ja Web

Spring on sovelluskehys, joka tarjoaa hyödyllisiä apuvälineitä Java (ja .NET)-sovellusten toteuttamisen ja testaamisen helpottamiseksi. Se perustuu Dependency Injection -suunnittelumalliin, joka mahdollistaa sovelluksen komponenttien toisistaan riippumattoman suunnittelun ja toteutuksen. Spring on komponenttipohjainen ja tarjoaa tukea mm. tietokanta-, web-, ja mobiilisovellusten toteuttamiseen.

Tutustutaan seuraavaksi yksinkertaisen web-sovelluksen toteuttamiseen Springin avulla: käytämme materiaalissa Springin versiota 3.1.2.RELEASE.

Hello Spring Web

Springin web-toiminnallisuuden peruskivi on Front Controller-suunnittelumalli sekä Dependency Injection. Spring-sovelluskehyksen web-toiminnallisuuden saa käyttöön lisäämällä projektin pom-tiedostoon seuraavan riippuvuuden.

    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-webmvc</artifactId>
        <version>3.1.2.RELEASE</version>
    </dependency>

Oletamme että käytössä on jo spring-context-riippuvuus.

Spring tarjoaa oman Front Controller-toteutuksen nimeltä DispatcherServlet, jonka tehtävänä on ottaa sovellukseen tulevat pyynnöt kiinni, ja ohjata ne sovelluksen omille kontrollereille. Kun yllä mainitut riippuvuudet on lisätty, voi DispatcherServlet-luokan määritellä projektiin liittyvään web.xml-tiedostoon.

    <servlet>
        <servlet-name>front-controller</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <load-on-startup>1</load-on-startup>
    </servlet>
    
    <servlet-mapping>
        <servlet-name>front-controller</servlet-name>
        <url-pattern>/app/*</url-pattern>
    </servlet-mapping>

Servletin front-controller määrittelyssä oleva elementti load-on-startup kertoo, että servlet tulee ladata käyttöön heti sovellusta käynnistettäessä. Kaikki sovelluksen polkuun /app/ ja sen alle tulevat pyynnöt ohjautuvat front-controller-nimiselle servletille.

Tämän lisäksi haluamme konfiguroida Springin lataamaan riippuvuuksia automaattisesti. Springin DispatcherServletin lataamisen laukaisema prosessi etsii oletuksena konfiguraatiotiedostoa, jonka nimi on ${servletin-nimi}-servlet.xml. Yllä servlettimme nimi on front-controller, joten tiedoston nimi tulee olla front-controller-servlet.xml. Tiedosto sijaitsee samassa paikassa web.xml-tiedoston kanssa (WEB-INF -kansiossa).

Luodaan WEB-INF -kansioon tiedosto front-controller-servlet.xml, jossa sanotaan että sovellukseen liittyviä komponentteja tulee etsiä pakkauksesta werkko ja sen alipakkauksista. Alla oleva konfiguraatio lienee tutun näköinen.

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
           http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
           http://www.springframework.org/schema/context
           http://www.springframework.org/schema/context/spring-context-3.0.xsd">

    <context:component-scan base-package="werkko" />
</beans>

Spring tunnistaa kontrolleriluokat annotaation @Controller perusteella. Annotaatio @Controller on web-sovelluksen kontrollereita varten luotu erikoistapaus annotaatiosta @Component. Luodaan pakkaukseen werkko luokka HelloController:

package werkko;

import org.springframework.stereotype.Controller;

@Controller
public class HelloController {

}

Web-sovellusta käynnistettäessä huomaamme palvelimen logeista että Spring lataa yllä olevan luokan käyttöönsä (bean helloController):

INFO: Pre-instantiating singletons in ....DefaultListableBeanFactory..: defining beans [helloController, ..

Yllä olevasta kontrollerista ei kuitenkaan ole juurikaan hyötyä. Siinä ei esimerkiksi käsitellä yhtäkään pyyntöä. Lisätään seuraavaksi toiminnallisuutta pyynnön käsittelyyn.

Käyttäjän tekemät pyynnöt voidaan ohjata kontrolleriluokissa oleviin metodeihin annotaatioiden perusteella. Annotaatio @RequestMapping asetetaan pyynnön prosessoivalle metodille. Se saa parametrinaan polun, johon tulevat pyynnöt ohjataan kyseiselle metodille. Lisätään luokkaan HelloController metodi processRequest, joka saa parametrinaan HttpServletRequest ja HttpServletResponse-oliot. Metodilla processRequest on annotaatio @RequestMapping("hello") -- käytännössä siis kaikki sovelluksen /app/hello -polkuun tulevat pyynnöt ohjataan tälle metodille. Alkuosa /app/ johtuu siitä, että DispatcherServlet kuuntelee pyyntöjä osoitteeseen /app/*.

package werkko;

import java.io.IOException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
public class HelloController {

    @RequestMapping("hello")
    public void processRequest(HttpServletRequest request, HttpServletResponse response) {
        try {
            response.getWriter().write("Hello World!");
        } catch (IOException ex) {
            System.out.println("Ei onnistunut!");
        }
    }
}

Nyt kun sovelluksen polkuun /app/hello tekee pyynnön, näemme seuraavanlaisen näkymän:

Hello World!

Näkymän lisääminen

Puhuimme jo aiemmin siitä, että näkymän pitäisi olla JSP-sivuilla. Yllä olevaa sovellusta voi muokata siten, että se ohjaa pyynnön JSP-sivulle RequestDispatcher-oliota käyttäen. Esimerkiksi, jos käytössämme on kansiossa /WEB-INF/jsp/ oleva hello.jsp-tiedosto, voi pyynnön ohjata sivulle kuten aiemminkin:

    // ...
    @RequestMapping("hello")
    public void processRequest(HttpServletRequest request, HttpServletResponse response) {
        request.getRequestDispatcher("/WEB-INF/jsp/hello.jsp").forward(request, response); 
    }
    // ...

Springillä on myös oma mekanismi näkymien hallintaan. Luokka InternalResourceViewResolver tarjoaa toiminnallisuuden näytettävän sivun päättelyyn, mm. pyyntöjen uudelleenohjaamisen ja JSP-sivujen näyttämisen. InternalResourceViewResolver konfiguroidaan springin konfiguraatiotiedostossa, eli meidän tapauksessa tiedostossa front-controller-servlet.xml.

    <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver" /> 

Nyt pyyntöjä prosessoivat metodit voivat palauttaa merkkijonon, joka kertoo näytettävän sivun sijainnin. Esimerkiksi edellinen processRequest-metodi voidaan muuttaa seuraavaan muotoon.

    // ...
    @RequestMapping("hello")
    public String processRequest(HttpServletRequest request, HttpServletResponse response) {
        return "/WEB-INF/jsp/hello.jsp";
    }
    // ...

Koska sivujen sijainnit ovat yleensä hyvin samankaltaiset, voi InternalResourceViewResolver-luokalle määritellä haettavan tiedoston sijainnin tarkemmin. Määrittelemällä parametrit prefix ja suffix, olio käyttää niitä osana näytettävän sivun hakemista. Muunnetaan front-controller-servlet.xml -tiedostossa oleva konfiguraatio seuraavanlaiseksi:

    <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> 
        <property name="prefix" value="/WEB-INF/jsp/" /> 
        <property name="suffix" value=".jsp" /> 
    </bean>

Nyt metodin processRequest voi muuttaa seuraavanlaiseksi.

    // ...
    @RequestMapping("hello")
    public String processRequest(HttpServletRequest request, HttpServletResponse response) {
        return "hello";
    }
    // ...

Palautettava sivu päätellään lisäämällä konfiguraatiotiedoston prefix-osa metodin palauttaman merkkijonon alkuun, ja suffix osa merkkijonon loppuun. Käytännössä merkkijonosta hello tulee merkkijono /WEB-INF/jsp/hello.jsp

Spring Web Hello World

Tässä tehtävässä rakennetaan Spring WebMVC-kehyksen avulla toimiva dynaaminen verkkosivu. Tehtävä koostuu controller-luokasta sekä JSP-sivusta.

HelloWorldController

Tehtäväpohjassa on valmiina Maven-riippuvuus Springin ydintoiminnallisuudelle (spring-context), jota käytettiin jo aiemmissa tehtävissä. Jotta Springin WebMVC-ominaisuuksia voisi käyttää, täytyy sitä varten lisätä muutama lisämoduuli riippuvuuksiin. Kaikkien Spring-kehyksen riippuvuuksien groupId-tunniste on org.springframework. Spring WebMVC-riippuvuuden artifactId-tunniste on:

Lisää riippuvuus pom.xml-tiedostoon. Huom! Käytä Springin versiota 3.1.2.RELEASE.

Luo pakkaukseen wad.spring.web.helloworld luokka HelloWorldController. Springille täytyy kertoa, että kyseessä on controller-luokka, joten lisää sille vielä annotaatio @Controller pakkauksesta org.springframework.stereotype.

Luokalla HelloWorldController tulee olla metodi public String processRequest(HttpServletRequest request, HttpServletResponse response), joka käsittelee sovellukselle tulevia HTTP-pyyntöjä. Lisäksi Springille täytyy kertoa mitä URL-polkua tai -polkuja metodi käsittelee. Määrittele metodille annotaatio @RequestMapping, jolla on parametrina "*". Metodi siis käsittelee kaikkia DispatcherServlet-olion saamia pyyntöjä. Huomaa että DispatcherServlet kuuntelee taas /app/-polkua ja sen alle tulleita pyyntöjä.

Käytännössä metodi ja sen annotaatio vastaavat yhdessä Chat-tehtävää varten tehtyä yksittäistä Controller-rajapinnan toteuttavaa luokkaa.

Metodin processRequest tulee asettaa HttpServletRequest-oliolle attribuutti message, jonka arvo on "Great Scott!". Metodin tulee lisäksi palauttaa merkkijono, /WEB-INF/jsp/hello.jsp (sivua ei vielä ole).

Konfiguroi lopuksi tiedostoon front-controller-servlet.xml olioiden automaattinen lataaminen oliokontekstiin pakkauksesta wad ja sen alipakkauksista.

View: hello.jsp

Jotta Spring pystyisi tulkitsemaan Controller-luokkien metodien palauttamat merkkijonot oikealla tavalla näkymien JSP-sivujen nimiksi, täytyy Springin konfiguraatioon lisätä sopiva ViewResolver. JSP-sivut paketoidaan yleensä lähdekoodin kääntämisen yhteydessä samaan JAR- tai WAR-paketista Java-luokkien tavukoodin kanssa. JSP-sivujen hakeminen pakettien sisältä onnistuu ViewResolver-luokan org.springframework.web.servlet.view.InternalResourceViewResolver avulla.

Konfiguroi tiedostoon front-controller-servlet.xml uusi bean luokasta org.springframework.web.servlet.view.InternalResourceViewResolver, ja aseta sille kentän prefix arvoksi /WEB-INF/jsp/ ja kentän suffix arvoksi .jsp.

Luo hakemistoon /WEB-INF/jsp JSP-sivu hello.jsp. Hakemisto löytyy NetBeansin projektinäkymästä Web Pages-kansion alta. JSP-sivulla tulee olla teksti Hello World! ja EL-kielellä tehty viittaus processRequest-metodin asettamaan attribuuttiin message, jotta viesti näytetään sivulla.

Muuta lopuksi luokan HelloWorldController palauttamaa merkkijonoa siten, että juuri konfiguroitu InternalResourceViewResolver oikeasti löytää JSP-sivun.

Pyyntöjä käsittelevät metodit

Kontrolleriluokissamme on käytetty metodia processRequest(HttpServletRequest request, HttpServletResponse response) pyyntöjen käsittelyyn. Nimi processRequest on tuttu jo tehtävistä ennen Springiä. Mutta. Pyyntöä käsittelevän metodin nimen ei ole pakko olla processRequest, eikä sen tarvitse saada parametreinaan HttpServletRequest ja HttpServletResponse-olioita.

Spring päättelee annotaation @RequestMapping perusteella metodin, johon pyyntö ohjataan. Metodille määriteltävät parametrit ovat oikeastaan melko vapaat: parametriksi voi määritellä esimerkiksi Writer-olion, johon kirjoitettu teksti palautetaan käyttäjälle. HttpServletRequest- tai HttpServletRequest-olioita käytetään hyvin harvoin parametreina -- pyyntöä käsittelevä metodi voi myös olla parametriton. Seuraava metodi tuottaa käytännössä saman lopputuloksen kuin aiemmin näkemämme pyynnön JSP-sivulla ohjaava processRequest-metodi.

    // ...
    @RequestMapping("hello")
    public String hello() {
        return "hello";
    }
    // ...

Parametri Model

Ehkäpä oleellisin ja eniten käytetty parametri on Model, jota käytetään pyynnön attribuuttien tallentamiseen. Model on rajapinta, johon Spring asettaa sopivan toteutuksen. Attribuutin lisääminen Model-oliolle sen metodin addAttribute avulla tarkoittaa käytännössä samaa kuin attribuutin lisääminen HttpServletRequest-oliolle metodilla setAttribute.

Spring syöttää @RequestMapping-annotaation omaavalle metodille parametrit sitä kutsuttaessa. Esimerkiksi edellisessä tehtävässä toteutettiin kontrolleriluokan metodi, joka näytti seuraavalta:

    // ...
    public String processRequest(HttpServletRequest request, HttpServletResponse response) {
        request.setAttribute("message", "Great Scott!");
        return "hello";
    }
    // ...

Saman metodin voi toteuttaa myös seuraavanlaisena.

    // ...
    public String viewHelloPage(Model model) {
        model.addAttribute("message", "Great Scott!");
        return "hello";
    }
    // ...

Lopputulos on käytännössä täysin sama.

Luokka Model sisältää siis vastaavan toiminnallisuuden kuin HttpServletRequest-luokan attribuutit, mutta toiminnallisuus on erotettu siististi erilliseksi olioksi.

Password Generator

Tehtävässä rakennetaan salasanageneraattori, jolta saa uuden salasanan aina kun generaattorin verkkosivu ladataan.

PasswordGeneratorController

Luo pakkaukseen wad.passwordgenerator luokka PasswordGeneratorController ja lisää sille kontrolleriluokkien tarvitsema annotaatio.

Toteuta luokkaan PasswordGeneratorController metodi public String generatePassword(), joka palauttaa uuden salasanan merkkijonona. Käytä salasanan generointiin UUID-luokan staattista metodia randomUUID(). Voit muuttaa UUID-luokan ilmentymän merkkijonoksi kaikille olioille yhteisellä toString-metodilla.

Toteuta seuraavaksi luokkaan PasswordGeneratorController metodi public String newPassword(Model model), joka vastaa polkuun new-password tuleviin pyyntöihin. Metodin newPassword tulee generoida uusi salasana generatePassword-metodia käyttäen, sekä asettaa luotu salasana Model-olion attribuutiksi avaimella password. Tämän jälkeen metodi newPassword palauttaa näkymän nimen, joka johtaa seuraavassa kohdassa tehtävälle JSP-sivulle password.jsp.

Huomaathan että DispatcherServlet kuuntelee vain polkua /app/ ja sen alle tulevia pyyntöjä.

View: password.jsp

Konfiguroi Spring lisäämällä tiedostoon front-controller-servlet.xml edellistä tehtävää vastaava konfiguraatio:

Luo lopuksi hakemistoon /WEB-INF/jsp JSP-sivu password.jsp. JSP-sivulla tulee olla teksti Password Generator ja viittaus Controller-metodin asettamaan attribuuttiin password, jotta generoitu salasana näytetään sivulla.

POST-tyyppiset pyynnöt ja uudelleenohjaukset

Aiemmin totesimme että tietoa muokkaavat tai lisäävät pyynnöt tulee tehdä POST-tyyppisinä. Edellä määrittelemämme metodit eivät kuitenkaan ainakaan tuo ilmi sitä, miten ne käsittelevät POST-tyyppisiä pyyntöjä. Annotaatiolle @RequestMapping voi määritellä pyyntötyypin parametrilla method. Jos annotaatiolla @RequestMapping on useampia parametreja, tulee jokainen niistä määritellä erikseen.

Esimerkiksi annotaatiolla @RequestMapping(value = "add", method = RequestMethod.POST) esitellään add-osoitteeseen tulevia POST-pyyntöjä kuunteleva metodi.

    // ...
    @RequestMapping(value = "add", method = RequestMethod.POST)
    public String add() {
        // ...
        return "page"; // ???
    } 
    // ...

POST-tyyppisten pyyntöjen suorituksen jälkeen käyttäjä tulee ohjata aina tekemään uutta pyyntöä. Omassa Front Controller-luokassamme uudelleenohjauksen tarve pääteltiin redirect:-etuliitteestä. Itseasiassa, Springin InternalResourceViewResolver tarjoaa täsmälleen saman toiminnallisuuden.

Jos metodi palauttaa merkkijonon, joka alkaa merkkijonolla redirect:, pyyntö ohjataan merkkijonoa redirect: seuraavaan osoitteeseen.

    // ...
    @RequestMapping(value = "add", method = RequestMethod.POST)
    public String add() {
        return "redirect:page"; // :)
    } 
    // ...

Osoite, jonne pyyntö ohjataan, on riippuvainen nykyisestä osoitteesta. Esimerkiksi, jos metodi add kuuntelee osoitetta http://palvelin.net/sovellus/add, tekee käyttäjän selain ylläolevan metodin suorituksen pyynnön osoitteeseen http://palvelin.net/sovellus/list.

Pyynnössä olevien parametrien käsittely

Edellisessä osassa ollut esimerkki oli hieman torso: pyynnöissä ei ollut parametreja, joita olisi tallennettu. Aiemmin pyynnön parametrit on saatu HttpServletRequest-oliosta metodin getParameter avulla.

Pyynnössä olevat parametrit voidaan asettaa Springin toimesta @RequestParam-annotaatioilla merkittyihin muuttujiin. Annotaatiolla @RequestParam määritellään parametrin nimi, sekä parametrin pakollisuus. Esimerkiksi seuraavalla määrittelyllä pyynnössä on pakko olla parametri nimeltä item. Parametri asetetaan muuttujaan String item, josta sitä voi käyttää metodin sisällä.

    // ...
    @RequestMapping(value = "add", method = RequestMethod.POST)
    public String add(@RequestParam(value="item", required=true) String item) {
        // ...
    } 
    // ...

Pyynnöt ja evästeet

HTTP tarvitsee tilattomuutensa takia tavan käyttäjien seuraamiseen. HTTP/1.1-standardissa tilalliset verkkosovellukset toteutetaan yleensä evästeiden avulla. Javan Servlet-apissa on valmiina luokka HttpSession, jota käytetään evästeiden asettamiseen.

Luokkaan HttpSession pääsee käsiksi luokan HttpServletRequest metodilla getSession. Spring osaa asettaa HttpSession-olion myös suoraan osaksi pyyntöä käsittelevän metodin parametreja. Esimerkiksi seuraava metodi kuuntelee POST-tyyppisiä pyyntöjä osoitteeseen login ja odottaa että pyynnössä on parametrit username ja password. Spring asettaa HttpSession-olion HttpServletRequest-oliosta automaattisesti metodin käyttöön.

    // ...
    @RequestMapping(value = "login", method = RequestMethod.POST)
    public String login(
            @RequestParam(value = "username", required = true) String username,
            @RequestParam(value = "password", required = true) String password,
            HttpSession session) {
        if(!("mikael".equals(username) && "rendezvouspark".equals(password))) {
            return "redirect:login";        
        }

        return "awesomeness";
    }
    // ...

Very First Authentication

Tässä tehtävässä toteutetaan salasanasuojaus salaiselle sivulle. Tehtäväpohjassa on valmiina kaksi JSP-sivua login.jsp ja secret.jsp, joista ensimmäisen tehtävänä on kerätä käyttäjätunnus ja salasana sisäänkirjautumista varten. Jälkimmäinen sivu on salainen sivu, jonne pääsee vain, jos tietää oikean salasanan.

Tehtäväpohjassa olevassa web.xml-tiedostossa on määritelty omituinen filtteri. Palaamme niihin seuraavalla viikolla.

Pelkän käyttäjätunnuksen ja salasanan tarkistamisen lisäksi tehtävässä ne tallennetaan sessioon käyttämällä HttpSession-rajapintaa. Tietojen tallettaminen sessioon mahdollistaa sen, että sovellus muistaa käyttäjän tiedot myös sisäänkirjautumisen jälkeen tapahtuvilla HTTP-pyynnöillä. Kirjautumisen jälkeen avautuvan salaisen sivun voi siis ladata uudelleen lähettämättä käyttäjätunnusta ja salasanaa uudelleen niin pitkään kuin selaimessa oleva eväste on voimassa. Sessio voidaan myös tarvittaessa tuhota, jolloin palvelin ja selain "unohtavat" sisäänkirjautumisen. Tätä varten tehtäväpohjan salaisella sivulla (secret.jsp) on linkki uloskirjautumiseen.

AuthenticationController

Luo pakkaukseen wad.veryfirstauthentication.controller luokka AuthenticationController, joka toteuttaa tehtäväpohjan mukana annetun rajapinnan AuthenticationControllerInterface. Rajapinta määrittelee toteutettavan Controller-luokan metodit ja toimii apuna tehtävän automaattisille testeille. Rajapinnassa määriteltyjen metodien tulee toimia seuraavalla tavalla:

Ohjelmaa rakentaessa sitä kannattaa testata itse selaimessa, sillä siten sen toiminta selviää parhaiten!

Sovelluslogiikan erottaminen kontrollereista: palvelut

Aiemmissa esimerkeissä ohjelmiin liittyvää sovelluslogiikkaa on laitettu milloin minnekin. Esimerkiksi salasanageneraattori-tehtävässä sovelluslogiikka, joskin vähäinen sellainen, oli osana Controller-luokkaa, metodissa generatePassword. Tämä ei ole hyvän ohjelmointityylin mukaista, sillä tarkoituksena on pitää Controller-luokkien koodimäärä mahdollisimman vähäisenä. Tavoitteena on erottaa sovelluslogiikka, eli ohjelman varsinainen "pihvi", omiin luokkiinsa, joita kutsutaan palveluiksi (Service). Jokainen palveluluokka tarvitsee oman rajapintansa, jotta luokkien väliset riippuvuudet eivät kohdistuisi toteutuksiin, vaan rajapintoihin. Tällöin palvelujen toteutuksia voidaan vaihtaa esimerkiksi testattaessa sovellusta.

Spring tarjoaa toiminnallisuuden palveluiden automaattiseen lisäämiseen @Autowired-annotaation avulla. Palvelut tunnistetaan @Service-annotaation avulla. Muokataan kappalessa 4.7 nähtyä Timo Soinin viestipalvelua siten, että se on oliokontekstiin ladattava palvelu.

// importit jne

@Service
public class TimoSoiniMessageService implements MessageService {
    // sama toteutus kuin ennenkin
}

Nyt kontrolleriluokkaan voi ladata MessageService-rajapinnan toteuttavan luokan automaattisesti @Autowired-annotaatiota käyttämällä. Luodaan vielä erillinen MessageController-luokka viestien näyttämiselle.

// importit jne..

@Controller
public class MessageController {

    @Autowired
    private MessageService messageService;

    public String handleRequest(Model model) {
        model.addAttribute("truth", messageService.getMessage());
        return "view";
    }
}

Koska luokka TimoSoiniMessageService toteuttaa rajapinnan MessageService, ja se on merkattu annotaatiolla @Service, voi Spring injektoida TimoSoiniMessageService-luokan ilmentymän automaattisesti MessageService-tyyppiseen olioon.

Chatting with Anna

Huom! Jos testit kertovat että mock-objektit ovat null-arvoisia (Argument should be a mock, but is null!), päivitä TMC-pluginisi. TMC-pluginin saa päivitettyä valitsemalla Help -> Check for Updates. Voit myös lähettää tehtävän TMC:lle ilman testien läpimenoa -- palvelimella on ajantasainen versio TMC:stä.

Tässä tehtävässä toteutetaan alkuviikon Chat-sovelluksesta kehittyneempi versio Springiä apuna käyttäen. Lisäksi chatissa on mukana botti nimeltä Anna, joka vastailee esitettyihin kysymyksiin.

Tehtäväpohjassa on valmiina kaksi palvelua ja niitä vastaavat rajapinnat pakkauksessa wad.chattingwithanna.service.

ChatControllerInterface-rajapinnan toteuttaminen

Luo pakkaukseen wad.chattingwithanna.controller luokka ChatController, joka toteuttaa tehtäväpohjassa annetun rajapinnan ChatControllerInterface. Rajapinta määrittelee toteutettavan Controller-luokan metodit ja toimii apuna tehtävän automaattisille testeille. Metodin addMessage tulee vastata POST-pyyntöihin polussa add-message ja uudelleenohjata pyyntö polkuun list. Metodin list tulee vastata GET-pyyntöihin polussa list ja näyttää tehtäväpohjan mukana annettu näkymä list.jsp. Metodien logiikka toteutetaan tehtävän seuraavassa kohdassa.

Metodin käyttämän HTTP-metodin (GET/POST/...) voi määrittää @RequestMapping-annotaation parametrilla method esimerkiksi näin:

@RequestMapping(value = "path", method = RequestMethod.POST)

Huom! Kun @RequestMapping annotaatiolle annetaan useampi parametri, täytyy ensimmäinenkin (polun määrittävä) parametri asettaa nimellä value.

ChatController-luokan metodien toteuttaminen

Metodi addMessage saa parametrikseen käyttäjän lähettämän viestin merkkijonona. Spring voi hakea viestin automaattisesti metodille tulleesta HTTP-pyynnöstä parametrille määritettävän @RequestParam-annotaation avulla. Annotaation parametreista value on haettavan parametrin nimi ja required määrittelee hyväksytäänkö pyyntö, jossa parametria ei ole määritelty. Annotaatio määritellään metodin parametrille esimerkiksi näin:

public String operation(@RequestParam(value = "parameter-name", required = false) String parameter) {
    ...
}

Lisää metodin addMessage parametrille String message @RequestParam-annotaatio HTTP-parametrin nimellä message. HTTP-parametri message ei ole pakollinen, joten pyynnöt ilman sitä täytyy hyväksyä.

Jos parametrina oleva viesti on tyhjä tai null-viite, metodin tulee ohjata pyyntö suoraan listasivulle.

Metodissa tarvitaan tehtäväpohjan mukana annettuja palveluita MessageService ja ChatBot. Injektoi nämä riippuvuudet @Autowired-annotaation avulla ChatController-luokan oliomuuttujiksi.

addMessage metodin tulee aiemman chat-tehtävän mukaisesti muuttaa käyttäjän antamasta viestistä HTML-koodi tekstiksi käyttäen StringEscapeUtils.escapeHtml4-metodia ja tallettaa muutettu viesti MessageService-palvelulla. Lisää viestin alkuun merkkijono "You:", jotta lähetettyjen viestien osapuolet on helppo tunnistaa.

Seuraavaksi tulee kysyä viestiin vastausta botilta. Vastauksen kysyminen tapahtuu ChatBot-palvelun getAnswerForQuestion-metodilla. Talleta lopuksi botin antama vastaus MessageService-palvelulla (Huom! botille tehtävästä kysymyksestä ja sen lähettämästä vastauksesta HTML-koodia ei tule muuttaa!). Lisää viestin alkuun merkkijono botin nimi ja kaksoispiste ":", jotta lähetettyjen viestien osapuolet on helppo tunnistaa. Botin nimen saa kysyttyä ChatBot-rajapinnan metodilla getName().

Huom! Metodi getAnswerForQuestion voi heittää poikkeuksen, jos verkkoyhteys ei toimi, joten käsittele metodin heittämät poikkeukset siten, että käytät poikkeuksen viestiä botin vastauksena. Tällöin voit testata ohjelmaa, vaikka verkkoyhteyttä ei olisi.

Toteuta lopuksi metodi list, jonka tulee lisätä MessageService-palvelusta haettu lista viestejä Model-instanssiin avaimella messages.

Keskustelun tulisi näyttää esimerkiksi tältä (käyttäjän esittämät kysymykset on merkitty punaisella):

You: Who are you?
Anna: I am Anna, the .... Online Assistant.
You: How old are you?
Anna: I prefer not to discuss my age; let's talk about ....
You: When?
Anna: I'm sorry, but I don't know the answer to that just yet.
You: What's the time?
Anna: So the current East Coast time is 5:12 and the West Coast time is 3:12.
You: Are you fat?
Anna: Sorry, but I don't really have the expertise to comment on health matters...

MVC (model-view-controller) on (vieläkin :)) ohjelmistoarkkitehtuurityyli, jonka tavoitteena on käyttöliittymän erottaminen sovelluslogiikasta. Model on yleensä näytettävä data, view on itse näkymä, ja controller vastaanottaa käyttäjän tekemät käskyt ja muokkaa niiden pohjalta sekä dataa että näytettävää näkymää.

Miten toteuttamamme Spring Web-sovellukset liittyvät MVC-arkkitehtuuriin? Mikä rooli on jsp-sivuilla ja kontrollereilla? Entä mikä osa on Model?

Mihin osaan MVC:tä sovelluksen sovelluslogiikka kuuluu?

Merkistöongelmat

Selainten ja palvelinten välisessä matalan tason kommunikoinnissa kaikki viestit siirretään binäärimuodossa joukkona nollia ja ykkösiä. Binäärimuotoinen data käännetään tekstiksi sovitun merkistökoodauksen avulla. Merkistökoodauksia on useita erilaisia, joista perinteisin lienee ASCII (American Standard Code for Information Interchange). ASCII-merkistössä jokainen merkki kuvataan 8 bitin avulla, josta 7 bittiä on merkeille (viimeinen on esim. pariteettibitti, jota voidaan käyttää merkin oikeellisuuden tarkistamiseen).

ASCII-merkistö ei juurikaan tue erikoismerkkejä. Esimerkiksi suomessa käytetyille ääkkösille ei ole kuvausta ASCII-merkistössä. Erikoismerkkien tarpeen vuoksi on kehitetty lukuisia standardeja, mm. ISO-8859-versio. Merkistö ISO-8859-1 sisältää tuen mm. lähes kaikille suomen kielessä käytettäville merkeille, ja sitä käytetään paljon selainten ja palvelinten välisessä kommunikoinnissa.

Nykyisin web-sivuilla eniten käytetty merkistö on UTF-8, joka kuvaa merkit 8-32 bitin avulla.

Koska erilaisia merkistöjä on satoja ja käyttöjärjestelmävalmistajilla on usein hieman standardeista poikkeava oma merkistö, tulee palvelimella ja selaimella olla yhteisymmärrys käytettävästä merkistöstä. Yhteisymmärrys luodaan sisällyttämällä pyynnön ja vastauksen otsakkeisiin tieto käytetystä merkistöstä.

Esimerkiksi pyyntöön voi lisätä otsakkeen Accept-Charset, jolla kerrotaan toivottu merkistö.

Accept-Charset: utf-8

Vastauksessa käytetään otsaketta Content-Type vastauksen sisällön tyypin ja käytetyn merkistön ilmaisemiseen.

Content-Type: text/html; charset=utf-8

HTML-sivuilla usein käytetään vielä lisäksi erillistä otsakekenttää, jolla pyritään valmistamaan käytetyn merkistön oikeellisuus. Esimerkiksi alla olevalla otsakkeella kuvataan sivun sisällöksi text/html ja merkistöksi utf-8.

  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />

Merkistön sopiminen tapahtuu usein automaattisesti, mutta käytännössä ohjelmoijien tulee varautua merkistöongelmiin. Esimerkiksi servlettejä tehdessämme laitoimme servlet-koodin alkuun aina komennon response.setContentType("text/html;charset=UTF-8"), joka lisää Content-Type-otsakkeen vastaukseen.

        response.setContentType("text/html;charset=UTF-8");

Mielenkiintoisia ongelmia tarjoavat tilanteet, joissa otsaketiedoissa kerrotaan sisällön olevan tiettyä tyyppiä, mutta sisältö onkin erilaista. Jos merkistö on asetettu ASCII-muotoiseksi, mutta sisältö sisältää ASCII-aakkostossa määrittelemättömiä merkkejä, on sisällön tulkinta sovelluksen vastuulla. Merkistöongelmat voivat johtua myös palvelimen käyttämistä kolmannen osapuolen komponenteista. Esimerkiksi tietokannat tallentavat dataa yleensä tietyllä merkistöllä: jos ISO-8859-1 -merkistöä käyttävään tietokantaan tallennetaan UTF-8-muotoista dataa, tulee ainakin osa tallennetusta datasta olemaan korruptoitunutta.

Checklist

Jos sovelluksessasi on merkistöongelma, aloita seuraavista tarkistuksista:

  1. Varmista että pyynnöt ja vastaukset sisältävät otsakkeen merkistön asettamiseen.
  2. Varmista että JSP-sivujen alussa on alla oleva komento, jolla varmistetaan merkistön asetus.
    <%@page pageEncoding="UTF-8" contentType="text/html; charset=UTF-8"%>
    
  3. Varmista että HTML-sivuilla on pyynnöt ja vastaukset sisältävät otsakkeen merkistön asettamiseen.
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    
  4. Varmista että tietokanta on konfiguroitu käyttämään haluttua merkistöä.
  5. Varmista että palvelin on konfiguroitu käyttämään haluttua merkistöä.

Filtterit

Javan Servlet API sisältää servlettien lisäksi myös filttereitä. Filtterit ovat pyynnön ja vastauksen prosessoijia, joilla voidaan käsitellä pyyntöä ja vastausta ennen niiden servletille saapumista, sekä sen jälkeen kun servlet-koodi on suoritettu. Hyvin yleinen käyttötapa filtterille on merkistön asetus: yksittäistä filtteriä voidaan käyttää otsakkeiden asettamiseen pyyntöön ja vastaukseen.

Omia filttereitä toteutettaessa ohjelmoijan tulee periä rajapinta javax.servlet.Filter, sekä toteuttaa sen vaatimat metodit. Esimerkiksi alla on filtteri, joka tulostaa viestin ennen ja jälkeen seuraavan kohteen käsittelyä. Metodissa doFilter tehtävä komento chain.doFilter ohjaa pyynnön seuraavalle kohteelle, esimerkiksi servletille.

package wad.filter;

import java.io.IOException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;

public class SimpleFilter implements Filter {

    private void preprocess(ServletRequest request, ServletResponse response)
            throws IOException, ServletException {
        System.out.println("Before handling servlet code.");
    }

    private void postprocess(ServletRequest request, ServletResponse response)
            throws IOException, ServletException {
        System.out.println("After handling servlet code.");
    }

    public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain)
            throws IOException, ServletException {
        preprocess(request, response);

        chain.doFilter(request, response);

        postprocess(request, response);
    }

    public void init(FilterConfig fc) throws ServletException {
    }

    public void destroy() {
    }
}

Filtterit konfiguroidaan web.xml-tiedostoon lähes samoin kuin servletit. Poikkeuksena on se, että servlet-nimen sijaan käytetään elementtiä nimeltä filter. Alla on konfiguroitu yllä luotu SimpleFilter kuuntelemaan kaikkia web-sovellukseen tulevia pyyntöjä.

    <filter>
        <filter-name>CharSetFilter</filter-name>
        <filter-class>wad.filter.SimpleFilter</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>CharSetFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

Spring-sovelluskehyksellä on valmis filtteri, joka muuttaa kaikki pyynnöt ja vastaukset (esimerkiksi) UTF-8 merkistöä käyttäväksi: CharacterEncodingFilter. Filtterin saa käyttöön lisäämälle sen web.xml-tiedostoon.

    <!-- Filtteri: Kaikki pyynnöt utf-8:ksi -->
    <filter>
        <filter-name>encoding-filter</filter-name>
        <filter-class>
            org.springframework.web.filter.CharacterEncodingFilter
        </filter-class>
        <init-param>
            <param-name>encoding</param-name>
            <param-value>UTF-8</param-value>
        </init-param>
        <init-param>
            <param-name>forceEncoding</param-name>
            <param-value>true</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>encoding-filter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

Jatkossa käytämme ylläolevaa filtteriä osana Spring-sovelluksiamme.

Lue tietokone-lehden UTF-merkistöä käsittelevä artikkeli vuodelta 2001: Unicode ratkaisee merkistöongelmat.

Artikkeli on kirjoitettu yli 10 vuotta sitten. Mitä artikkeli kuvaa merkistöongelmina, ja miten Unicode toimii niihin ratkaisuna?

 

Tietokannat

Tietokanta on tietotekniikassa käytetty termi tietovarastolle. Se on kokoelma tietoja, joilla on yhteys toisiinsa. ... Tietokanta saattaa edustaa jotain selvästi rajattua kohdetta reaalimaailmasta. Tällainen kohde voi olla esimerkiksi yrityksen keräämät tiedot asiakkaistaan. - Wikipedia

Tietokanta on lähes aina webisoftan oleellisin osa, ilman sitä ei ole mitään näytettävää. - Mikael

Tähänastiset sovelluksemme ovat hukanneet käyttämänsä tiedot sovelluksen sammuessa. Tämä johtuu siitä että tietoa ei ole varastoitu mihinkään, vaan se on ollut vain sovelluksen muistissa. Tiedon säilymisen varmistamiseksi tieto tulee tallentaa pysyväismuistiin. Tietoa kuvataan usein taulumuotoisena, esimerkkinä seuraava taulu Henkilo:

Henkilo
idnimihuone
1ArtoB215
2MattiD232
3MikaelB215

Tietokantoja käytetään tiedon pysyväismuistiin (esim kovalevylle) tallentamiseen. Termiä tietokanta käytetään puhekielessä usein termin tietokannanhallintajärjestelmä korvaajana. Tietokannanhallintajärjestelmät (DBMS, Database Management System) ovat sovelluksia, jotka tarjoavat tukitoiminnallisuuksia tietokannan sydämen, eli tietokantamoottorin päälle. Tietokantamoottori tarjoaa toiminnallisuuden tiedon luomiseen, lukemiseen, päivittämiseen ja poistamiseen. Tätä toiminnallisuutta kutsutaan usein termillä CRUD (create, read, update and delete).

Esimerkiksi MySQL-tietokannanhallintajärjestelmää käytettäessä käyttäjä voi valita useamman tietokantamoottorin välillä, nykyään yleisin (ja oletus-) vaihtoehto on InnoDB-moottori.

Huomattava osa tietokannanhallintajärjestelmistä toteuttaa ACID-ominaisuudet (Atomicity, Concistency, Isolation, Durability), joilla pyritään turvaamaan järjestelmän luotettavuutta. Käytännössä ACID-ehtoja seuraamalla tallennettavat tiedot ovat eheitä myös virhetilanteissa. ACID koostuu seuraavista osista:

Tietokannanhallintajärjestelmät ovat erillisiä järjestelmiä, joihin tulee ottaa yhteys tietokantakyselyjä tehdessä. Käytännössä tietokannanhallintajärjestelmä kuuntelee jotain tiettyä porttia, johon yhteys otetaan. Useampi web-sovellus voi käyttää samaa tietokantapalvelinta. Tämä tuo haasteita skaalautuvuuden kanssa: jos tietokanta noudattaa ACID-periaatteita, ja tietokantaan tehtävien kyselyiden määrä on huomattava, voi tietokannan tehokkuus rajoittaa järjestelmän tehokkuutta. Eräs ratkaisu on tietokantapalvelimien monistaminen, mutta siinäkin on haasteena päivitysten synkronisointi palvelinten kesken.

Käsittelemme tässä kappaleessa relaatiotietokantojen käyttöä osana Javapohjaisia web-palvelinohjelmistoja. Esimerkeissä (ja tehtävissä) on käytössä Javalla kirjoitettu H2-tietokanta (v. 1.3.168), jonka saa käyttöön lisäämällä seuraavan riippuvuuden pom.xml-tiedostoon.

        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <version>1.3.168</version>
        </dependency>

JDBC...

JDBC (Java Database Connectivity) on Javalle on määritelty standarditapa tietokantayhteyden luomiseen sekä tietoa muokkaavien ja hakevien kyselyjen suorittamiseen. Tausta-ajatuksena JDBCn kehitykselle on ollut yhteisen rajapinnan määrittely tietokannoille -- käytettävään tietokantaan täytyy voida ottaa yhteys, ja jokaiseen tietokantaan tulee pystyä tekemään kyselyitä. Kehitykseen on vaikuttanut ODBC, joka on vastaava projekti C-kielelle. JDBC tarjoaa suoran kytköksen valmiisiin ODBC-toteutuksiin.

Jotta JDBCn avulla voidaan ottaa yhteys tietokantaan, tulee käytössä olla tietokantakohtainen JDBC-ajuri. Käytännössä jokainen tietokannanhallintajärjestelmä tarjoaa JDBC-ajurin. JDBC-ajurin vastuulla on tietokantayhteyden luomiseen liittyvät yksityiskohdat sekä kyselytulosten muuntaminen JDBC-standardin mukaiseen muotoon.

Oletetaan että käytössämme on seuraavanlainen tietokantataulu:

Henkilo
idnimihuone
1ArtoB215
2MattiD232
3MikaelB215

JDBCn avulla kyselyn tekeminen tietokantatauluun tapahtuu seuraavasti:

package wad.db;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;

public class Main {
    public static void main(String[] args) throws Exception {
        Class.forName("org.h2.Driver");
        String jdbcUrl = "<jdbc-osoite tietokantaan>";
        String username = "SA";
        String password = "";
        
        Connection connection = DriverManager.getConnection(jdbcUrl, username, password);
        
        Statement statement = connection.createStatement();
        ResultSet resultSet = statement.executeQuery("SELECT * FROM Henkilo");
        
        while(resultSet.next()) {
            int id = resultSet.getInt("id");
            String nimi = resultSet.getString("nimi"); 
            String huone = resultSet.getString("huone");
            
            System.out.println(id + "\t" + nimi + "\t" + huone);
        }
        
        connection.close();
    }   
}
1    Arto    B215
2    Matti   D232
3    Mikael  B215

Tutkitaan koodia hieman tarkemmin. Alussa rekisteröidään tietokantakohtainen JDBC-ajuri käyttöön (kutsun Class.forName tarvitsee vain ennen Javan versiota 6). Tämän jälkeen määritellään yhteys tietokantaan. Alla ei ole määritelty tietokantaosoitetta, mutta se voisi olla esimerkiksi "jdbc:h2:~/test", jos halutaan käyttää tiedostossa test sijaitsevaa tietokantaa. Jos palvelin on käynnissä portissa 9101, ja tietokannan nimi on test, osoite on "jdbc:h2:tcp://localhost:9101/~/test". H2-tietokannan saa käynnistettyä muistissa käyttämällä osoitetta "jdbc:h2:mem:tietokannannimi". Lisää vaihtoehtoja löytyy H2-tietokannan dokumentaatiosta.

Konkreettinen yhteys luodaan JDBCn DriverManager-luokan avulla.

        Class.forName("org.h2.Driver");
        String jdbcUrl = "<jdbc-osoite tietokantaan>";
        String username = "SA";
        String password = "";
        
        Connection connection = DriverManager.getConnection(jdbcUrl, username, password);

Kyselyn tekeminen tapahtuu pyytämällä yhteydeltä Statement-oliota, jota käytetään kyselyn tekemiseen ja tulosten pyytämiseen. Metodi executeQuery suorittaa parametrina annettavan SQL-kyselyn, ja palauttaa tulokset sisältävän ResultSet-olion.

        Statement statement = connection.createStatement();
        ResultSet resultSet = statement.executeQuery("SELECT * FROM Henkilo");

Tämän jälkeen ResultSet-oliossa olevat tulokset käydään läpi. Metodia next() kutsumalla siirrytään kyselyn palauttamissa tulosriveissä eteenpäin. Kultakin riviltä voi kysyä sarakeotsikon perusteella solun arvoa. Esimerkiksi kutsu getString("nimi") palauttaa kyseisellä rivillä olevan sarakkeen "nimi" arvon String-tyyppisenä.

        while(resultSet.next()) {
            int id = resultSet.getInt("id");
            String nimi = resultSet.getString("nimi"); 
            String huone = resultSet.getString("huone");
            
            System.out.println(id + "\t" + nimi + "\t" + huone);
        }

Lopulta tietokantayhteys suljetaan. Tämä vapauttaa tietokantakyselyyn liittyvät resurssit.

        connection.close();

JDBC:n avulla voi tehdä myös päivitysoperaatioita. Esimerkiksi seuraava kysely luo aiemmin nähdyn tietokantataulun, ja lisää sinne rivin.

        // ...

        Statement statement = connection.createStatement();

        // taulun luova kysely
        statement.executeUpdate(
                "CREATE TABLE Henkilo ("
                + "id INT NOT NULL PRIMARY KEY, "
                + "nimi VARCHAR(255) NOT NULL, "
                + "huone VARCHAR(255))");

	// lisätään rivi
	statement.executeUpdate("INSERT INTO Henkilo VALUES (1, 'arto', 'B215')");

        connection.close();

        // ...

Vuonna 2011 kerätyssä vaarallisimmat ohjelmointivirheet sisältävässä listassa on SQL-injektiot numerona 1.

Käytännössä suurin osa sovelluksista liittyy tietoon: tiedon hakemiseen tietokannasta, tiedon muokkaamiseen ja näyttämiseen, ja tiedon tallentamiseen tietokantaan. Jos hyökkääjä voi vaikuttaa tehtävien SQL-kyselyiden sisältöön, pääsee hän vaikuttamaan myös näytettävään tietoon.

Yksinkertaisimmillaan SQL-injektio on tilanne, jossa sovelluksen käyttäjä syöttää SQL-komennon osaksi siihen kuulumatonta tavaraa. Oletetaan esimerkiksi että sovellus suorittaa kirjautumisen tarkistamalla onko seuraavan kyselyn tulos tyhjä:

// ...
String nimi = "nimi";
String salasana = "salasana";

String kysely = "SELECT * FROM user WHERE username = '"
                           + nimi + "' AND password = '" + salasana + "'";
// ...

Jos käyttäjä antaa käyttäjänimen muodossa haha' OR '1'='1, on kyselyssä tuloksena kaikki taulun user käyttäjät ja kirjautuminen onnistuu aina.

SQL-injektioesimerkkejä löytyy mm. osoitteesta http://www.unixwiz.net/techtips/sql-injection.html

Prepared Statement

Prepared Statement-kyselyt ovat valmiiksi määriteltyjä kyselyitä, joissa kyselyissä käytettävät arvot annetaan parametreina. Valmiita kyselyitä käyttämällä pystytään estämään ainakin osa SQL-injektioista, sillä parametreja käytettäessä varmistetaan että parametrien arvot liittyvät aina vain tiettyyn kenttään -- kyselyparametrit eivät "vuoda yli". Prepared Statement-kyselyt mahdollistavat myös kyselyiden optimoinnin, sillä kysely on aina samanmuotoinen.

Valmiit kyselyt määritellään Connection-olion prepareStatement-metodin avulla. Metodi palauttaa PreparedStatement-olion, johon kyselyn käyttämät arvot määritellään parametreina. Esimerkiksi SQL-injektioesimerkissä oleva kysely luodaan valmiiden kyselyiden avulla seuraavasti:

        // ...
        String name = "nimi";
        String password = "kala";

        PreparedStatement statement = 
            connection.prepareStatement("SELECT * FROM user WHERE username = ? AND password = ?");

        statement.setString(1, name);
        statement.setString(2, password);
        
        ResultSet resultSet = statement.executeQuery();
        // ...

Vastaavasti tietokantaa muokkaavan kyselyn voi tehdä seuraavasti (alla oletetaan että taulussa user on vain kaksi saraketta):

        // ...
        String name = "nimi";
        String password = "kala";

        PreparedStatement statement = 
            connection.prepareStatement("INSERT INTO user VALUES (?, ?)");

        statement.setString(1, name);
        statement.setString(2, password);
        
	statement.execute();
        // ...

Huomaa että tietojenkäsittelytieteilijöille epätyypillisesti kyselyssä käytettävien parametrien indeksointi alkaa numerosta 1.

First SQL Queries

Kaverisi ohjelmoi ensimmäisiä SQL-kyselyjään tietokantasovellukseen, mutta jätti sovellukseen muutaman SQL-injektion mentävän aukon. Tässä tehtävässä paikkaat ne. Älä muuta tehtävässä muuta kuin luokan CourseDatabase metodeja listCoursesByName ja addCourse.

Bugi metodissa listCoursesByName

Luokkaan CourseDatabase toteutettuun metodiin listCoursesByName on jäänyt pieni tietoturva-aukko. Jos käyttäjä antaa parametriksi esimerkiksi merkkijonon

"hups' OR '1'='1"

tulee tietokantaan tehtävän hakukyselyn WHERE-ehto olemaan aina totta, jolloin listaus listaus sisältää aina kaikki kurssit. Korjaa kysely siten, että käytät PreparedStatement-tyyppistä kyselylausetta, jossa hakuehto asetetaan kyselyn parametriksi.

Bugi metodissa addCourse

Myös metodissa addCourse on "pieni" tietoturva-aukko. Jos käyttäjä antaa päivityskyselyn parametriksi esimerkiksi merkkijonon

"hups');DROP TABLE course;INSERT INTO course ('hah"

suoritetaan tietokannassa kolme kyselyä: (1) päivityskysely, (2) tietokantataulun "course" poistaminen, (3) päivityskysely. Ei hyvä.

Korjaa päivityskysely siten, että käytät PreparedStatement-tyyppistä lausetta kurssin lisäämiseen.

Kun olet valmis, lähetä sovellus TMC:lle tarkistettavaksi. Tutki myös sovelluksen rakennetta tarkemmin: ohjelmassa on huomattava määrä koodia saavutettuun hyötyyn nähden.

DataSource

DriverManager-luokkaa käytettäessä yhteys tietokantaan luodaan yleensä alusta asti. Yhteyden luominen voi kestää jopa sekunteja riippuen ajurin lataamisesta, palvelimen sijainnista ja käytössä olevasta infrastruktuurista. DriverManager-olion käyttöä suositellumpi tapa JDBC-yhteyksien luomiseen on DataSource-rajapinnan toteuttavan palvelun käyttö.

Eräs toteutus DataSource-rajapinnalle on Apache Commons-projektin DBCP-projekti. Commons DBCP-projektin saa käyttöön lisäämällä siihen liittyvän riippuvuuden pom.xml-tiedostoon.

        <dependency>
            <groupId>commons-dbcp</groupId>
            <artifactId>commons-dbcp</artifactId>
            <version>1.2.2</version>
        </dependency>

Yksinkertaisimmillaan yhteyden luominen ei juurikaan poikkea aiemmista esimerkeistämme.

// ...
import org.apache.commons.dbcp.BasicDataSource;
// ...      
  
        // ...
        BasicDataSource dataSource = new BasicDataSource();
        dataSource.setDriverClassName("org.h2.Driver");
        dataSource.setUrl(jdbcUrl);
        dataSource.setUsername(username);
        dataSource.setPassword(password);
        
        Connection connection = dataSource.getConnection();
        
        // ...

DataSource-rajapinnan toteuttavalle BasicDataSource-oliolle asetetaan käytettävä JDBC-ajuri, tietokannan osoite, käyttäjätunnus ja salasana, jonka jälkeen siltä voidaan pyytää uutta yhteyttä kutsumalla getConnection()-metodia.

Suurin hyöty meidän kannaltamme liittyy siihen, että voimme käyttää DataSource-olioita Springin IoC-mekanismin avulla. Lisätään projektiin Springin spring-context -riippuvuus, joka lienee jo hieman tutuhko.

        <!-- Oliokonteksti -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
            <version>3.1.2.RELEASE</version>
        </dependency>

Nyt voimme luoda käytettävän DataSource-olion Springin oliokontekstiin myöhempää käyttöä varten.

<?xml version="1.0" encoding="UTF-8"?> 
<beans xmlns="http://www.springframework.org/schema/beans" 
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
       xsi:schemaLocation="http://www.springframework.org/schema/beans 
                           http://www.springframework.org/schema/beans/spring-beans-3.0.xsd">
    
    <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource">
        <property name="driverClassName" value="org.h2.Driver"/>
        <property name="url" value="<kannan osoite>"/>
        <property name="username" value="SA" />
        <property name="password" value="" />
    </bean>
</beans>

Kontekstissa olevaan DataSource-olioon pääsee käsiksi sille määritellyllä tunnuksella. Käytännössä omaa DataSource-oliota ei tarvitse luoda, vaan Spring hoitaa sen puolestamme.

// ...
import javax.sql.DataSource;
// ...

        // ...
        ApplicationContext appContext = new ClassPathXmlApplicationContext("beans.xml");
        DataSource dataSource = (DataSource) appContext.getBean("dataSource");

        Connection connection = dataSource.getConnection();
        // ...

Koska DataSource-olion voi määritellä beaniksi, on sen asettaminen projektiin @Autowired-annotaation avulla mahdollista.

JDBC ja kielto pyörän uudelleen keksimiseen: JDBCTemplate

Spring tarjoaa oman JDBC-kyselyjen tekemistä helpottavan komponentin. Komponentti spring-jdbc saadaan käyttöön lisäämällä se projektin pom.xml-tiedostoon.

        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-jdbc</artifactId>
            <version>3.1.2.RELEASE</version>
        </dependency>

Riippuvuus tuo käyttöömme muutamia hyödyllisiä apuvälineitä, joista eräs on JdbcTemplate (dokumentaatio). Luokka JdbcTemplate hoitaa tietokantakyselyihin liittyviä toistuvia asioita puolestamme. Esimerkiksi tietokantayhteyden avaaminen, pyynnön suorittaminen, transaktioiden käsittely sekä yhteyden sulkeminen on luokan JdbcTemplate vastuulla.

JdbcTemplate-luokan konstruktori tarvitsee DataSource-olion parametrina. DataSource-olio injektoidaan yleensä JdbcTemplate-olion toiminnallisuutta käyttävälle luokalle. Oletetaan, että käytössämme on seuraavankaltainen tietokantataulu:

User
namepassword
Artowateva!
Mattirails!
Mikaelplay!

Kyselyiden muodostaminen Springin JdbcTemplate-luokan avulla on helpohkoa. Luodaan luokka UserDatabase yllä kuvatun taulun käsittelyyn.

// ... importit

@Component
public class UserDatabase {

    private JdbcTemplate jdbcTemplate;

    @Autowired
    public final void setDataSource(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    public void addUser(String name, String password) throws SQLException {
        this.jdbcTemplate.update("INSERT INTO User VALUES (?, ?)", name, password);
    }

    public List<Map<String, Object>> retrieveUsers() throws SQLException {
        return this.jdbcTemplate.queryForList("SELECT * FROM User");
    }
}    

Oletetaan että käytössämme olevassa beans.xml-konfiguraatiossa on määritelty DataSource-olio sekä haettu annotoidut oliot käyttöön komennolla <context:component-scan base-package="<pakkaus>">. Luokan UserDatabase käyttö onnistuu nyt oliokontekstin avulla.

        ApplicationContext appContext = new ClassPathXmlApplicationContext("beans.xml");
        UserDatabase database = (UserDatabase) appContext.getBean("userDatabase");

        database.addUser("Kasper", "spring!");

	List<Map<String, Object>> users = database.retrieveUsers();

        for (Map<String, Object> user : users) {
            String name = (String) user.get("name");
            String password = (String) user.get("password");

            System.out.println(name + "\t" + password);
        }
Arto      wateva!
Matti     rails! 
Mikael    play!  
Kasper    spring!

RowMapper

Springin JDBC-komponentti tarjoaa myös tuen tulosten kytkemiseen olioihin. Oletetaan että käytössämme on seuraava luokka User:

// pakkaus

public class User {

    private String name;
    private String password;

    public String getName() {
        return name;
    }

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

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

Kyselytulosten kytkeminen olioihin onnistuu JdbcTemplaten ja RowMapper-rajapinnan avulla. Käytännössä RowMapper rajapinnan toteuttava luokka luo ResultSet-oliossa olevista tuloksista halutun tyyppisiä olioita. Luodaan oma UserMapper-luokka, joka toteuttaa rajapinnan RowMapper. Huomaa että rajapinnalle annetaan tyyppiparametrina luokka, johon tulokset kytketään.

// pakkaus ja mahdollisesti muita importteja
import java.sql.ResultSet;
import java.sql.SQLException;
import org.springframework.jdbc.core.RowMapper;

public class UserMapper implements RowMapper<User> {

    public User mapRow(ResultSet resultSet, int rowIndex) throws SQLException {
        User user = new User();
        user.setName(resultSet.getString("name"));
        user.setPassword(resultSet.getString("password"));

        return user;
    }
}

Rajapinnan RowMapper toteuttavia luokkia käytetään Springin JdbcTemplatekyselyjen avulla seuraavasti:

        // ... jdbcTemplaten luonti dataSourcen avulla jne
        List<User> users = this.jdbcTemplate.query("SELECT * FROM User", new UserMapper());

        for(User user: users) {
            System.out.println(user.getName() + "\t" + user.getPassword());
        }
        // ...

JDBC Template SQL Queries

Toteutetaan tässä tehtävässä tietokantatoiminnallisuutta Springin JDBC Templaten avulla.

Metodit listCourses ja listCoursesByName

Toteuta luokkaan CourseDatabase metodit listCourses ja listCoursesByName.

Uusi RowMapper-olio ja kurssiolioiden noutaminen

Luo pakkaukseen wad.jdbctemplatequeries luokka CourseRowMapper, joka toteuttaa rajapinnan RowMapper<Course>. Toteuta luokkaan rajapinnan vaatima metodi mapRow, joka muuntaa tulosrivit Course-olioiksi.

Kun RowMapper-olio on valmis, muokkaa luokan CourseDatabase metodia getCourses siten, että siinä haetaan tietokannasta kaikki kurssit, muunnetaan ne CourseRowMapper-olion avulla Course-olioiksi, ja lopulta palautetaan ne.

Kun olet valmis, lähetä sovellus TMC:lle testattavaksi.

"Olioita tietokannassa, häh?"

Huomaamme, että tietokantataulujen kuvaaminen luokkien avulla on oikeastaan aika loogista. Tietokantataulun nimi vastaa luokan nimeä ja taulun attribuutit (eli sarakkeiden nimet) ovat luokan attribuutteja. Jokainen luokasta luotava olio vastaa yhtä tietokantataulun riviä. Viitteet eri taulujen välillä voidaan taas kuvata viitteinä olioiden välillä.

Sovelluksia suunniteltaessa yksi lähestymistapa ongelma-alueeseen on tallennettavaa tietoa kuvaavien olioiden luonti. Tämä tapahtuu luonnollisesti yhteistyössä asiakkaan kanssa, jolloin voidaan varmistaa että puhutaan asiakkaan ja ongelma-alueen käyttämällä kielellä. Tutkitaan seuraavaa lentokentän hallintajärjestelmän kuvausta:

Case: Lentokenttä

He told us: "...Basically what we need is a system for monitoring aircrafts and airports. Each aircraft has a unique identifier, e.g. LOL-52, and a numeric capacity. Airports on the other hand reside in a location, have a unique identifier and a name, e.g. e.g. LOL for Derby Field United States and FUN for International Tuvalu. Each airport can host one or more aircrafts..."

Ongelma-alueen purkaminen lähtee perinteisesti liikkeelle substantiivien etsimisellä.

He told us: "...Basically what we need is a system for monitoring aircrafts and airports. Each aircraft has a unique identifier, e.g. LOL-52, and a numeric capacity. Airports on the other hand reside in a location, have a unique identifier and a name, e.g. LOL for Derby Field United States and FUN for International Tuvalu. Each airport can host one or more aircrafts..."

Tämän jälkeen substantiiveista erotetaan pääkäsitteet, pääkäsitteiden attribuutit, ja ei-oleelliset käsitteet. Pääkäsitteitä ovat aircraft ja airport, ja system on ei-oleellinen käsite. Loput ovat pääkäsitteiden attribuutteja.

Pääkäsitteiden attribuutit löytyy etsimällä lisätietoa antavia lauseita (esim. has a, in a, ...). Yllä olevasta esimerkistä löytyy seuraavat käsitteet attribuutteineen.

Aircraft
 - identifier
 - capacity

Airport
 - location
 - identifier
 - name

Tämän lisäksi lentokenttä (Airport) sisältää yhden tai useamman lentokoneen. Käsitteitä tietokantaan lisättäviksi olioiksi muunnettaessa jokaiselle luokalle lisätään yleensä numeerinen id-attribuutti, joka toimii taulun pääavaimena. Oliot olisivat lopulta esimerkiksi seuraavankaltaiset:

// pakkaus

public class Aircraft {

    private Long id;
    private String identifier;
    private Integer capacity;

    // getterit ja setterit
}
// pakkaus

public class Airport {

    private Long id;
    private String location;
    private String identifier;
    private String name;
    private List<Aircraft> aircrafts;
    
    // getterit ja setterit
}

Luotujen käsitteiden pohjalta voidaan lähteä hahmottelemaan sovelluksen toimintaa. Oikeastaan tässäkään vaiheessa tietokannan käyttö ei ole vielä pakollista.

DAO-pattern

DAO-pattern (Data Access Object) on suunnittelumalli, jossa tiedon tallennusmekanismi kapseloidaan sovelluslogiikalle näkymättömäksi. DAO-rajapinnan ideana on mahdollistaa tallennusmekanismin helppo vaihtaminen: sovellus voi aluksi säilyttää olioita esimerkiksi muistissa -- rajapinnan käyttäjän ei tarvitse välittää toteutuksesta. Toteutetaan aiemmin esitellyille olioille DAO-oliot ja niihin liittyvät toteutukset.

Käytetään perinteistä CRUD-tyyliä rajapintojen metodien nimeämiseen.

public interface AircraftDAO {
    Aircraft create(Aircraft object);
    Aircraft read(Long id);
    Aircraft update(Aircraft object);
    void delete(Long id);
}
public interface AirportDAO {
    Airport create(Airport object);
    Airport read(Long id);
    Airport update(Airport object);
    void delete(Long id);
}

Huomaamme heti toistavamme itseämme. Refaktoroidaan rajapinnoista erillistä tyyppiparametria käyttävä yleishyödyllinen rajapinta DAO.

public interface DAO<T> {
    T create(T object);
    T read(Long id);
    T update(T object);
    void delete(Long id);
}

Nyt aiemmat rajapinnat AircraftDAO ja AirportDAO voidaan muuttaa seuraavanlaisiksi.

public interface AircraftDAO extends DAO<Aircraft> {
}
public interface AirportDAO extends DAO<Airport> {
}

Luodaan ensimmäinen DAO-toteutus, lentokoneiden tallentamiseen. Koska voimme vaihtaa toteutusta lennossa, tehdään ensimmäinen versio sellaiseksi, että siinä ei ole oikeaa tallennuslogiikkaa. Tiedot tallennetaan AircraftDAO-toteutuksen kapseloimaan Map-tyyppiseen olioon.

@Component
public class InMemoryAircraftDAO implements AircraftDAO {
 
    private Map<Long, Aircraft> aircrafts = new TreeMap<Long, Aircraft>();
    private static Long COUNTER = new Long(0);


    @Override
    public Aircraft create(Aircraft object) {
        if (object.getId() != null) {
            throw new IllegalArgumentException("A new object should not have an ID");
        }

        COUNTER++;
        object.setId(COUNTER);
        aircrafts.put(object.getId(), object);

        return object;
    }

    @Override
    public Aircraft read(Long id) {
        return aircrafts.get(id);
    }

    @Override
    public Aircraft update(Aircraft object) {
        if (object.getId() == null) {
            throw new IllegalArgumentException("An object to be updated should have an ID");
        }

        aircrafts.put(object.getId(), object);
        return object;
    }

    @Override
    public void delete(Long id) {
        aircrafts.remove(id);
    }
}

Käytössämme on nyt luokka AircraftDAO, joka tarjoaa toiminnallisuuden lentokone-olioiden muistiin tallentamiseen. Jos haluamme vaihtaa toteutuksen, meidän tulee luoda toinen toteutus rajapinnalle. Esimerkiksi tiedostoja käyttävä luokka FileAircraftDAO, joka tallentaisi lentokoneet tiedostoon. Huomaa, että Springin oliokontekstia käyttäessä joudumme määrittelemään komponentille erillisen tunnuksen, jotta olion automaattinen asetus onnistuu.

@Component(value="fileAircraftDAO")
public class FileAircraftDAO implements AircraftDAO {
    // ...
}

Myös muistia käyttävälle toteutukselle tulee määritellä oma nimi.

// ...
@Component(value = "inMemoryAircraftDAO")
public class InMemoryAircraftDAO implements AircraftDAO {
    // ...

Nyt voimme käyttää @Autowired-annotaation lisäksi @Qualifier-annotaatiota, jolla kerrotaan mikä toteutus halutaan käyttöön. Alla olevaan Application luokkaan injektoitaisiin InMemoryAircraftDAO-luokan toteutus.

// ...
@Component
public class Application {

    @Autowired
    @Qualifier("inMemoryAircraftDAO")
    private AircraftDAO aircraftDao;

    // ...

In-memory DAO

Album

Täydennä musiikkialbumia kuvaavaa luokkaa wad.inmemorydao.Album siten, että sillä on seuraavat attribuutit, sekä vastaavat getter- ja setter-metodit:

InMemoryAlbumDAO

Luo luokka wad.inmemorydao.InMemoryAlbumDAO, joka toteuttaa tehtäväpohjassa annetun rajapinnan wad.inmemorydao.AlbumDAO. Lisää luokalle vielä annotaatio Component, jotta Spring tunnistaa sen. Tämän DAO-luokan tulee tallettaa Album-oliot muistiin Javan HashMap-hajautustaulun avulla. Hajautustaulun avaimena tulee luonnollisesti käyttää albumin ID-tunnistetta. Luokan metodien tulee toimia seuraavalla tavalla:

AlbumApplication

Tehdään vielä lopuksi ohjelmaan hieman toiminnallisuutta, joka käyttää edellä tehtyä AlbumDAO-toteutusta.

Luo luokka wad.inmemorydao.AlbumApplication, joka toteuttaa tehtäväpohjassa annetun rajapinnan wad.inmemorydao.Application. Lisää luokalle vielä annotaatio Component, jotta Spring tunnistaa sen. Luokan tulee injektoida itselleen käyttöön AlbumDAO-toteutus. Luokan metodien tulee toimia seuraavalla tavalla:

Huom! Tehtäväpohjan luokassa Main on lisäksi hieman testausta helpottavaa koodia, jonka avulla voit tulostaa DAO-luokan varastoimia albumeja. Esimerkkikoodi poistaa kaikki albumit, joiden artisti on "Scorpions" ja tulostaa jäljelle jääneet albumit.

Object Relational Mapping ja JPA

ORM-työkalut (Object Relational Mapping) tarjoavat mm. toiminnallisuuden tietokantataulujen luomiseen määritellyistä luokista. Työkalut hallinnoivat luokkien välisiä viittauksia ja ylläpitävät mm. tietokannan eheyttä. Käyttäjän vastuulle jää yleensä sovellukselle tarpeellisten kyselyiden toteuttaminen niiltä osin kun niitä ei tarjota valmiiksi.

Relaatiotietokantojen käsittelyyn on kehitetty joukko ORM-sovelluksia joista nimekkäin lienee Hibernate. Oracle/Sun standardoi olioiden tallentamisen relaatiotietokantoihin JPA (Java Persistence API) -standardilla. JPA:n toteuttavat kirjastot (esim. Hibernate, EclipseLink) abstrahoivat relaatiotietokannan, ja mahdollistavat kyselyjen tekemisen suoraan ohjelmakoodista.

Käytämme tällä kurssilla EclipseLink-kirjaston versiota 2.4.0, jonka saa käyttöön lisäämällä projektiin seuraavat riippuvuudet. Alempi riippuvuus tuo käyttöön JPA2-APIn.

        <dependency>
            <groupId>org.eclipse.persistence</groupId>
            <artifactId>eclipselink</artifactId>
            <version>2.4.0</version>
        </dependency>

        <dependency>
            <groupId>org.eclipse.persistence</groupId>
            <artifactId>javax.persistence</artifactId>
            <version>2.0.0</version>
        </dependency>

EclipseLink-riippuvuudet eivät ole yleisesti käytössä olevissa riippuvuusvarastoissa. Joudumme lisäämään pom.xml-tiedostoon myös Eclipsen oman repository-osoitteen.

    <repositories>
        <!-- muut määritellyt latauspaikat -->
        <repository>
            <id>eclipselink repo</id>
            <url>http://download.eclipse.org/rt/eclipselink/maven.repo</url>
        </repository>
    </repositories>

Tallennettavat oliot

Muunnetaan luokka Aircraft entiteetiksi, eli olioksi jonka voi tallentaa JPA:n avulla tietokantaan.

// pakkaus

public class Aircraft {

    private Long id;
    private String identifier;
    private Integer capacity;

    // getterit ja setterit
}

Jokaisella tietokantaan tallennettavalla oliolla tulee olla annotaatio @Entity. Annotaation @Entity lisäksi jokaisella tallennettavalla luokalla on oltava @Id-annotaatiolla merkattu kenttä, joka toimii tietokantataulun ensisijaisena avaimena. JPA:ta käytettäessä id-attribuutti on usein numeerinen (Long tai Integer), mutta merkkijonojen käyttö on yleistymässä.

Numeeriselle avainattribuutille voidaan lisäksi määritellä annotaatio @GeneratedValue(strategy = GenerationType.AUTO), joka antaa id-kentän arvojen luomisen vastuun tietokannalle. Muokataan luokkaa Aircraft vielä siten, että se toteuttaa rajapinnan Serializable, ja lisätään sille tyhjä konstruktori. Luokka näyttää nyt seuraavalta:

// pakkaus

import java.io.Serializable;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class Aircraft implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    private String identifier;
    private Integer capacity;

    // getterit ja setterit
}

Koska haluamme, että tietokantataulumme ovat selkeitä, ja sarakkeiden nimet kuvaavia, määritellään ne tarkemmin @Column-annotaation avulla. Lisätään luokalle myös @Table-annotaatio, jonka avulla määritellään luotavan tietokantataulun nimi.

// pakkaus

import java.io.Serializable;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

@Entity
@Table(name = "Aircraft")
public class Aircraft implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "id")
    private Long id;
    @Column(name = "identifier")
    private String identifier;
    @Column(name = "capacity")
    private Integer capacity;

    // getterit ja setterit

Käytössämme on nyt täysiverinen tietokantaan tallennettava olio.

JPA:n konfiguraatio: persistence.xml

JPA:n konfigurointi tapahtuu persistence.xml-nimisen tiedoston avulla. Tiedosto persistence.xml asetetaan kansioon NetBeansissa näkyvään Other Sources-kansion (kansio /src/main/resources/) sisälle META-INF-kansioon. Alla olevassa konfiguraatiossa tiedostoon persistence.xml on määritelty käytettävän tietokannan osoite sekä hallinnoitavat entiteetit. Käytössämme on EclipseLink.

<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.0" xmlns="http://java.sun.com/xml/ns/persistence" 
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
             xsi:schemaLocation="http://java.sun.com/xml/ns/persistence                  
                http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd">
    <persistence-unit name="persistenceUnit" transaction-type="RESOURCE_LOCAL">
        <provider>org.eclipse.persistence.jpa.PersistenceProvider</provider>

        <class>pakkaus.Aircraft</class>

        <exclude-unlisted-classes>true</exclude-unlisted-classes>
        <properties>
            <property name="eclipselink.logging.level" value="FINE"/>
            <property name="eclipselink.ddl-generation" value="drop-and-create-tables"/>
            <property name="javax.persistence.jdbc.driver" value="org.h2.Driver"/>
            <property name="javax.persistence.jdbc.url" value="jdbc:h2:mem:inmemorydb"/>
            <property name="javax.persistence.jdbc.user" value="SA"/>
            <property name="javax.persistence.jdbc.password" value=""/>
        </properties>
    </persistence-unit>
</persistence>

Konfiguraatiossa määrittelee käyttöömme muistiin ladattavan tietokannan sekä Aircraft-luokan. Konfiguraation nimi (persistence-unit name) on persistenceUnit. Luodaan ensimmäinen esimerkkisovellus, jossa luokan Aircraft ilmentymä tallennetaan tietokantaan. Ohjelmassa käytetyt luokat selitetään hieman myöhemmin.

// pakkaus

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) throws Exception {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("persistenceUnit");
        EntityManager em = emf.createEntityManager();
        Aircraft craft = em.find(Aircraft.class, new Long(1));
        
        if(craft == null) {
            System.out.println("Not found :(");
        } else {
            System.out.println("Found!");
        }
        
        Aircraft airforceOne = new Aircraft();
        airforceOne.setCapacity(1);
        airforceOne.setIdentifier("Air Force One");

        // luodaan transaktio tallennusoperaation ajaksi 
        em.getTransaction().begin();
        airforceOne = em.merge(airforceOne);
        em.getTransaction().commit();

        System.out.println("Aircraft inserted to the database.");
        System.out.println("Autogenerated id: " + airforceOne.getId());
        System.out.println("Identifier: " + airforceOne.getIdentifier());

        
        craft = em.find(Aircraft.class, new Long(1));
        
        if(craft == null) {
            System.out.println("Not found :(");
        } else {
            System.out.println("Found!");
        }
    }
}

Sovellusta suoritettaessa näemme seuraavanlaisen tulostuksen (tulostusta "hieman" siistitty):

[EL Fine]: sql: CREATE TABLE Aircraft (id BIGINT NOT NULL, capacity INTEGER, identifier VARCHAR, PRIMARY KEY (id))
...
[EL Fine]: sql: SELECT id, capacity, identifier FROM Aircraft WHERE (id = ?)
	bind => [1]
Not found :(
[EL Fine]: sql: --INSERT INTO Aircraft (id, capacity, identifier) VALUES (?, ?, ?)
	bind => [1, 1, Air Force One]
...
Aircraft inserted to the database.
Autogenerated id: 1
Identifier: Air Force One
Found!

JPA luo tietokantataulua kuvaavan Aircraft-luokan käsittelyyn tarvittavat SQL-kyselyt, jotka se suorittaa automaattisesti ohjelmassa käytettyjen kyselyiden aikana. Tutkitaan seuraavaksi pikaisesti käytettyjä luokkia.

Persistence ja EntityManagerFactory

Luokkaa Persistence käytetään EntityManagerFactory-olion ohjelmalliseen luomiseen. Persistence-luokan luokkametodille createEntityManagerFactory annetaan parametrina käytettävän konfiguraation nimi, jonka perusteella tiedostosta persistence.xml etsitään oikea konfiguraatio. Luokkaa Persistence käytetään vain silloin, jos sovellusta halutaan käyttää erillisenä web-ympäristöstä (esim. yllä ollut demo) -- web-kontekstissa tarvittavat luokat injektoidaan automaattisesti.

Luokka EntityManagerFactory on tehdasluokka, josta saadaan entiteettien tallentamista hallinnoivia EntityManager-olioita.

EntityManager

EntityManager hallinnoi entiteettejä ja niiden tallennusta tietokantaan. Sovelluskehys -- tai sovelluskehittäjä -- luo EntityManager-olion tarpeen vaatiessa. EntityManager tarjoaa joukon palveluita, joista oleellisimmat ovat olion tietokantaan lisääminen (metodit persist ja merge), poistaminen (metodi remove) ja hakeminen (metodi find).

EntityManager tarjoaa toiminnallisuuden tietokantatransaktioiden hallintaan. Metodi getTransaction() palauttaa EntityTransaction-olion, jota käytetään transaktioden aloittamiseen (metodi begin) ja lopettamiseen (metodi commit).

JPA ja Web

Tutustutaan seuraavaksi samaan, mutta tällä kertaa web-kontekstissa. Koska käytämme Inversion of Control-tyyliä tukevaa sovelluskehystä, haluamme että käytettävät oliot luodaan meille automaattisesti. Spring tarjoaa ORM-tuen, jonka saa käyttöön lisäämällä riippuvuuden spring-orm pom.xml-tiedostoon. Oletamme, että myös aiemmin mainitut riippuvuudet ovat käytössä.

	<dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-orm</artifactId>
            <version>3.1.2.RELEASE</version>
        </dependency>

Springin konfiguraatio: front-controller-servlet.xml

Muutetaan seuraavaksi front-controller-servlet.xml-tiedostossa olevaa konfiguraatiota siten, että siellä luodaan DataSource-olio, EntityManagerFactory-olio, sekä JPA-transaktioita hallinnoiva JpaTransactionManager-olio. Näiden lisäksi määritellään erilliset tietokantapoikkeuksien käsittelyyn käytettävät luokat. Tiedosto front-controller-servlet.xml kokonaisuudessaan:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:tx="http://www.springframework.org/schema/tx"
       xmlns:jdbc="http://www.springframework.org/schema/jdbc"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
                            http://www.springframework.org/schema/beans/spring-beans-3.1.xsd
                            http://www.springframework.org/schema/context
                            http://www.springframework.org/schema/context/spring-context-3.1.xsd
                            http://www.springframework.org/schema/tx
                            http://www.springframework.org/schema/tx/spring-tx-3.1.xsd
                            http://www.springframework.org/schema/jdbc
                            http://www.springframework.org/schema/jdbc/spring-jdbc-3.1.xsd">

    <!-- Sovelluksemme lähdekooditiedostot sijaitsevat wad tai sen alipakkauksissa-->
    <context:component-scan base-package="wad" />

    <!-- Ladataan käyttöön springin view-resolver: luokka, jota käytetään
    näkymätiedostojen päättelyyn -->    
    <!-- Nyt Controller-luokkien metodeissa palautettu merkkijono määrittää
    JSP-tiedoston, joka näytetään -->
    <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <property name="prefix" value="/WEB-INF/jsp/" />
        <property name="suffix" value=".jsp" />
    </bean>

    <!-- lyhennetty versio BasicDataSource-olion luomisesta -->
    <jdbc:embedded-database id="dataSource" type="H2"/>

    <!-- käytämme persistence.xml -tiedostossa olevaa persistenceUnit-konfiguraatiota, 
            dataSource injektoidaan luotavaan entityManagerFactory-olioon, ja 
            JPA-apin toteuttaja on EclipseLink -->
    <bean id="entityManagerFactory" class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">
        <property name="persistenceUnitName" value="persistenceUnit" /> 
        <property name="dataSource" ref="dataSource" />
        <property name="jpaVendorAdapter">
            <bean class="org.springframework.orm.jpa.vendor.EclipseLinkJpaVendorAdapter"/>
        </property>
    </bean>

    <!-- Hallinnoidaan transaktioita automaattisesti -->
    <bean id="transactionManager" class="org.springframework.orm.jpa.JpaTransactionManager">
        <property name="entityManagerFactory" ref="entityManagerFactory"/>
    </bean>

    <!-- Transaktioiden hallinta voidaan määritellä annotaatioilla -->
    <tx:annotation-driven transaction-manager="transactionManager" />

    <!-- Muunnetaan tietokantaspesifit poikkeukset yleisemmiksi -->
    <bean class="org.springframework.orm.jpa.support.PersistenceAnnotationBeanPostProcessor"/>
    <bean class="org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor"/>
</beans>

JPA:n konfiguraatio: persistence.xml

Kun sovellus toimii palvelinympäristössä, tiedoston persistence.xml ei tarvitse sisältää tallennettavia luokkia. Tietokantayhteys on asetettu valmiiksi EntityManagerFactory-olion käyttöön, joten tietokantakonfiguraatiotakaan ei tarvitse persistence.xml-tiedostoon. Muutetaan tiedosto persistence.xml seuraavanlaiseksi:

<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.0" xmlns="http://java.sun.com/xml/ns/persistence" 
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
             xsi:schemaLocation="http://java.sun.com/xml/ns/persistence
                                 http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd">

    <persistence-unit name="persistenceUnit" transaction-type="RESOURCE_LOCAL">
        <properties>
            <property name="showSql" value="true"/>
            <!-- sovellusta käynnistettäessä tietokantataulut luodaan uudestaan -->
            <property name="eclipselink.ddl-generation" value="drop-and-create-tables"/>
            <property name="eclipselink.ddl-generation.output-mode" value="database"/>
            <!-- ei yritetä optimoida tietokantaolioita -->            
            <property name="eclipselink.weaving" value="false"/>
            <!-- logiin kirjoitettavien viestien taso on "FINE" -->
            <property name="eclipselink.logging.level" value="FINE"/>
        </properties>
    </persistence-unit>
</persistence>

Tässä vaiheessa lienee hyvä mainita, että konfiguraatioon liittyviä kysymyksiä ei ole kokeessa.

Ensimmäinen JPA:ta käyttävä DAO

Spring injektoi EntityManager-olion sovellukselle @PersistenceContext-annotaation avulla. Luodaan rajapinnan AircraftDAO toteuttava luokka JpaAircraftDAO, joka käyttää JPA:ta olioiden tallentamiseen tietokantaan. Huomaa, että annotoimme luokan annotaatiolla @Repository aiemmin käytetyn annotaation @Component sijaan. Spring muuntaa @Repository-annotaatioilla merkityissä luokissa tapahtuvat tietokantapoikkeukset ihmisystävällisemmiksi, sekä injektoi EntityManager-olion annotaatiolla @PersistenceContext merkittyyn olioon.

// pakkaus

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

@Repository
@Transactional
public class JpaAircraftDAO implements AircraftDAO {

    @PersistenceContext
    private EntityManager entityManager;

    public Aircraft create(Aircraft object) {
        return entityManager.merge(object);
    }

    public Aircraft read(Long id) {
        return entityManager.find(Aircraft.class, id);
    }

    public Aircraft update(Aircraft object) {
        return entityManager.merge(object);
    }

    public void delete(Long id) {
        Aircraft craft = read(id);
        if(craft != null) {
            entityManager.remove(craft);
        }
    }
}

Yllä on kaikki koodi mitä Aircraft-olioiden tietokantaan tallentamiseen tarvitaan.

Annotaatiolla @Transactional määrittelemme, että jokainen luokan metodi suoritetaan omassa transaktiossa. Huomaat todennäköisesti, että käytämme EntityManager-olion merge-metodia useassa paikassa. Metodi merge luo olion tietokantaan, jos se ei ole jo tietokannassa. Jos olio on jo tietokannassa, se päivittää olion tilan tietokantaan. Metodi palauttaa viitteen tietokannassa olevaan olioon.

Spring ja transaktiot

Spring tarjoaa apuvälineitä transaktioiden hallintaan. Luokka JpaTransactionManager delegoi transaktioiden käsittelyn EntityManager-olioille. Käytännössä transaktiot voidaan määritellä metodi- tai luokkatasolla annotaation @Transactional avulla. Tällöin annotaatiolla @Transactional merkittyä metodia suoritettaessa metodin alussa aloitetaan tietokantatransaktio, jossa tehdyt muutokset viedään tietokantaan metodin lopussa. Jos annotaatio @Transactional määritellään luokkatasolla, se koskee jokaista luokan metodia.

// pakkaus

@Repository
@Transactional
public class JpaAircraftDAO implements AircraftDAO {

    @PersistenceContext
    private EntityManager entityManager;

    public Aircraft create(Aircraft object) {
        return entityManager.merge(object);
    }

    public Aircraft read(Long id) {
        return entityManager.find(Aircraft.class, id);
    }

    // ...

Käytännössä emme kuitenkaan halua jokaiselle metodille transaktiota, jossa tietokantamuutosten tekeminen on mahdollista. Annotaatiolle @Transactional voidaan määritellä parametrin readOnly avulla kirjoitetaanko muutokset tietokantaan. Jos parametrin readOnly arvo on true, metodiin liittyvä transaktio perutaan metodin lopussa (rollback). Tällöin metodi ei yksinkertaisesti voi muuttaa tietokannassa olevaa tietoa. Muunnetaan ylläoleva luokka sellaiseksi, että metodi create käyttää tietokantaa muokkaavaa transaktiota, mutta metodissa read ei voi tehdä tietokantamuutoksia.

// pakkaus

@Repository
public class JpaAircraftDAO implements AircraftDAO {

    @PersistenceContext
    private EntityManager entityManager;

    @Transactional(readOnly=false)
    public Aircraft create(Aircraft object) {
        return entityManager.merge(object);
    }

    @Transactional(readOnly=true)
    public Aircraft read(Long id) {
        return entityManager.find(Aircraft.class, id);
    }

    // ...

JPQL

JPQL (Java Persistence Query Language on kyselykieli, jonka avulla @Entity-annotaatioilla merkittyjen luokkien instansseja voidaan hakea tietokannasta. Kyselyt ovat SQL-kyselyiden kaltaisia, mutta JPQL ei tue kaikkia SQL-kielen ominaisuuksia. EntityManager-ilmentymän avulla on mahdollista kirjoittaa myös puhtaita SQL-kyselyitä. Kaikki Aircraft-oliot listaava kysely on seuraavanlainen.

SELECT a FROM Aircraft a

EntityManager-luokan ilmentymän avulla voimme hakea listan esineitä vastaavasti.

String queryString = "SELECT a FROM Aircraft a";
Query query = entityManager.createQuery(queryString);
List<Aircraft> aircrafts = query.getResultList();

Ehtojen lisääminen tapahtuu parametrien avulla

String queryString = "SELECT a FROM Aircraft a WHERE a.capacity = :capacity";
Query query = entityManager.createQuery(queryString);
query.setParameter("capacity", 33);
List<Aircraft> aircrafts = query.getResultList();

Huom! Kyselyissä käytettävä Aircraft on entiteetin nimi, ei tietokantataulun nimi. Jos @Entity-annotaatiolle ei määritellä name-attribuuttia, entiteetin nimenä käytetään oletuksena luokan nimeä.

Ajan tallentaminen

Aikaa kuvaavat attribuutit tulee annotoida @Temporal-annotaatiolla, joka määrittelee mikä osa ajasta tallennetaan. Annotaatiolle annetaan parametrina TemporalType-tyyppinen arvo, joka kertoo tarkemman tallennusmuodon. Arvo TemporalType.DATE tallentaa päivämäärän (esim. 2012-09-15), TemporalType.TIME tallentaa kellonajan (esim. 18:00:00), ja arvo TemporalType.TIMESTAMP tallentaa päivän ja ajan (esim. 2012-09-15 18:00:00).

Annotaatiolla @Temporal merkityn attribuutin tulee olla joko tyyppiä java.util.Date tai tyyppiä java.util.Calendar. Alla on määritelty entiteettiluokka GroceryItem, joka kuvaa elintarviketta. Elintarvikkeella on myös parasta ennen-päivämäärä (bestBeforeDate).

// pakkaus

import java.io.Serializable;
import java.util.Date;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;

@Entity
@Table(name = "GroceryItem")
public class GroceryItem implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "id")
    private Long id;
    @Column(name = "name")
    private String name;
    @Column(name = "best_before")
    @Temporal(TemporalType.DATE)
    private Date bestBefore;
    
    // getterit ja setterit
}

Merkkijonot entiteettien avaimina

Joissain tapauksissa entiteettien avainarvot halutaan saada tietoon jo ennen tietokantaan tallentamista. Tällöin avainarvoina käytetään sovelluksen itse luomaa mahdollisimman yksilöivää arvoa. Jos avainarvoja ei luoda tietokannan toimesta, on mahdollista että useammalla entiteetillä on sama avainarvo. Tällöin pyritään siihen, että saman avainarvon kahdesti luomisen todennäköisyys on mahdollisimman pieni.

Yleisin ratkaisu satunnaisen avaimen luomiseen on satunnaisen merkkijonon käyttäminen avaimena. Javalla hyvin satunnaisen merkkijonon saa luotua esimerkiksi UUID-luokan avulla. UUID-luokka arpoo merkkijonon joukosta, jossa on noin 2128 vaihtoehtoa.

Esimerkiksi yllä luotua GroceryItem-entiteettiä tulee muuttaa seuraavasti, jotta sille voidaan asettaa merkkijonoavain. Avaimen generointi voi tapahtua esimerkiksi luokan parametrittomassa konstruktorissa tai osana luokkaa käyttävää palvelua.

// pakkaus

import java.io.Serializable;
import java.util.Date;
import java.util.UUID;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;

@Entity
@Table(name = "GroceryItem")
public class GroceryItem implements Serializable {

    @Id
    @Column(name = "id")
    private String id;
    @Column(name = "name")
    private String name;
    @Column(name = "best_before")
    @Temporal(TemporalType.DATE)
    private Date bestBefore;

    public GroceryItem() {
        this.id = UUID.randomUUID().toString();
    }
    
    // getterit ja setterit
}

Käytämme tällä kurssilla sekä numeerisia avaimia että merkkijonoavaimia.

JPA Chat


Huom! Jos et ole jo tarkistanut onko TMC:stä päivityksiä ja päivittänyt TMC:tä, tee se nyt. Voit tarkistaa päivitykset valitsemalla NetBeansista Help -> Check for Updates.

Tehtävässä on ollut huomattavasti ongelmia pajassa. Tässä muutamia hyödyllisiä koodirivejä heti alkuun.

// ...
@Repository
public class JpaMessageRepository implements MessageRepository<JpaMessage> {
    @PersistenceContext
    private EntityManager entityManager;
    // ...
// ...
@Service
public class JpaMessageService implements MessageService<JpaMessage> {
    @Autowired
    private MessageRepository<JpaMessage> messageRepository;
    // ...

Kannattaa tutustua myös seuraaviin linkkeihin: Generic Types (Oracle), Generic DAO Pattern in Java with Spring 3 and JPA2


Tässä tehtävässä toteutetaan chat-ohjelma, joka tallettaa lähetetyt viestit tietokantaan JPA:n avulla. Ohjelma pitää kirjaa viestin lähetysajankohdasta sekä viestin lähettäjästä. Lisäksi ohjelmassa on mahdollista estää (eli bannata) ennalta määritettyjen nimimerkkien käyttö. Lista estetyistä nimimerkeistä talletetaan tietokantaan erilliseen tauluun.

JpaMessage

Toteutetaan aluksi viestien tallettaminen tietokantaan.

Luo pakkaukseen wad.jpachat.data luokka JpaMessage, joka toteuttaa samassa pakkauksessa olevan rajapinnan Message. Tee luokasta entiteetti ja määritä sille tietokantataulun nimi Message. Entiteetillä tulee olla rajapinnan määrittämät neljä attribuuttia: id (uniikki generoitava merkkijonoavain), nickname (viestin lähettäneen käyttäjän nimimerkki), timestamp (viestin lähetysajankohta) ja message (viestin tekstisisältö). Voit luoda timestamp-olion esimerkiksi luokan konstruktorissa (new Date()).

Luo pakkaukseen wad.jpachat.repository luokka JpaMessageRepository, joka toteuttaa rajapinnan MessageRepository. Anna rajapinnalle tyyppiparametrina luokka JpaMessage. Lisää luokalle Springiä varten Repository-annotaatio ja toteuta DAO-metodit injektoidun EntityManager-instanssin avulla ylläolevien esimerkkien mukaisesti. Metodin list tulee palauttaa lista, joka sisältää kaikki viestit. Listaus onnistuu JPQL-kyselykielen avulla: tutustu vielä JPQL-osiossa olevaan kommenttiin entiteeteistä ja tietokantatauluista. Varmista että luokka JpaMessageRepository käyttää JpaMessage-olioita!

Huom! Tässä tehtävässä transaktiot määritellään palveluluokkiin. Selvitämme tätä myöhemmin kurssilla.

Luo pakkaukseen wad.jpachat.service luokka JpaMessageService, joka toteuttaa rajapinnan MessageService. Merkitse luokka annotaatiolla @Service palveluksi. Kuten huomaat, MessageService-rajapinnan metodit ovat lähes identtiset MessageRepository-rajapinnan kanssa, mikä ei ole sattumaa: palvelutasolla yleensä kerätään matalamman tason toiminnallisuutta (kuten repository-luokkien operaatioita) suuremmiksi kokonaisuuksiksi. Tämä tehtävä on kuitenkin sen verran yksinkertainen, että metodit ainoastaan delegoivat toiminnallisuuden vastaavalle repository-luokalle, joka saadaan käyttöön injektoimalla se palveluluokkaan. Poikkeuksia ovat metodit create, jonka tulee määritellä uusi uniikki merkkijonoavain annetulle JpaMessage-entiteetille UUID.randomUUID()-metodin avulla, sekä delete, joka saa parametrina ID-merkkijonon sijaan JpaMessage-instanssin: tässä tapauksessa metodin täytyy välittää annetun instanssin ID repositoryn delete-metodille. Delegoinnin lisäksi palveluluokan tulee huolehtia tietokantatransaktioiden määrittelystä, joten merkitse jokaiselle metodille Transactional-annotaatio ja määrittele sille boolean-tyyppinen parametri readOnly sen mukaan muuttaako metodi tietokannassa olevaa tietoa vai ei. Esimerkiksi viestien listaus ainoastaan lukee tietoa tietokannasta, joten readOnly-parametrin arvoksi tulee true.

Huom! Ilman transaktioiden määrittelyä tiedon lisääminen tietokantaan ei onnistu!

Täydennä lopuksi tehtäväpohjassa annetun luokan ChatController metodia addMessage siten, että se luo uuden JpaMessage viestin ja tallettaa sen tietokantaan käyttämällä MessageService-rajapintaa. Estä HTML-tagien käyttö nimimerkissä ja viestin sisällössä StringEscapeUtils.escapeHtml4-metodilla edellisten chat-tehtävien tapaan. Huom! Varmista että käytät MessageService-rajapintaa, etkä konkreettista toteutusta.

Tässä vaiheessa kannattaa kokeilla chattia ja tarkistaa, että viestien lähetys ja talletus toimii oikein. Chatissa olevat viestit säilyvät tallessa niin pitkään kuin ohjelma on käynnissä, joten chatista voi välillä poistua ja kirjautua sinne uudestaa eri nimimerkillä.

Viestien tulisi näyttää esimerkiksi tältä:

Mon Sep 17 09:53:04 EEST 2012 <El Barto> Anyone around?

Mon Sep 17 09:53:38 EEST 2012 <El Barto> ... it seems to be really quiet here

Mon Sep 17 09:56:37 EEST 2012 <Anna> I am Anna, the Online Assistant.

Mon Sep 17 09:56:50 EEST 2012 <El Barto> ????????

JpaBannedUser

Toteutetaan seuraavaksi nimimerkkien estäminen.

Luo pakkaukseen wad.jpachat.data luokka JpaBannedUser, joka toteuttaa samassa pakkauksessa olevan rajapinnan BannedUser. Tee luokasta entiteetti ja määritä sille tietokantataulun nimi BannedUser. Entiteetillä tulee olla rajapinnan määrittämät attribuutit: id (uniikki ohjelman generoima merkkijonoavain) ja nickname (estetty nimimerkki).

Luo pakkaukseen wad.jpachat.repository luokka JpaBannedUserRepository, joka toteuttaa rajapinnan BannedUserRepository. Anna rajapinnalle tyyppiparametrina luokka JpaBannedUser. Toteuta luokka edellisen kohdan ohjeiden mukaisesti, mutta käytä entiteettinä luokkaa JpaBannedUser. Metodin isNicknameBanned tehtävänä on selvittää tietokannasta löytyykö JpaBannedUser-entiteetin määrittämästä tietokantataulusta riviä, jossa on annettu nimimerkki. Jos nimimerkki löytyy, on se estetty ja metodi palauttaa true. Metodin toteutus onnistuu JPQL-kyselykielen avulla määrittelemällä where-ehto. Where-ehdon tulee testata onko nickname-attribuutti sama kuin annettu parametri. Varmista vielä että luokka JpaBannedUserRepository käyttää JpaBannedUser-olioita!

Luo pakkaukseen wad.jpachat.service luokka JpaBannedUserService, joka toteuttaa rajapinnan BannedUserService. Toteuta luokka edellisen kohdan ohjeiden mukaisesti, mutta käytä entiteettinä luokkaa JpaBannedUser.

Täydennä lopuksi luokan ChatController metodeja login sekä addMessage siten, että metodit tarkistavat BannedUserService-palvelun avulla onko pyynnössä lähetetty nimimerkki estetty. Jos nimimerkki on estetty, tulee kummankin metodin uudelleenohjata pyyntö osoitteeseen banned. Lisää lisäksi metodissa list MessageService-rajapinnan toteuttaman olion palauttamat viestit Model-olion attribuuttiin messages. Varmista myös että käytät BannedUserService-rajapintaa, etkä konkreettista toteutusta.

Jotta nimimerkin käytön estoa voisi kokeilla, täytyy estettyjä nimimerkkejä erikseen lisätä tietokantaan. Springin hallinnoimissa luokissa (joilla on jokin Springin annotaatiosta) voi määritellä metodille annotaation javax.annotation.PostConstruct, jolloin kyseinen metodi suoritetaan ohjelmaa käynnistettäessä. Tehtäväpohjan ChatController-luokassa on annettu tyhjä metodi nimeltä init, jolle tulee määritellä tämä annotaatio. Tällöin estetyt nimimerkit voidaan lisätä init-metodissa, jolloin lisäys tapahtuu ohjelman käynnistyessä. Estä nimimerkit El Bimbo ja Casanova käyttämällä BannedUserService-palvelua.

Huomaamme tietokantalogiikassa vieläkin melko paljon toisteisuutta. Esimerkiksi CRUD-operaatiot ovat lähes aina samanlaiset. Tutustutaan seuraavaksi Spring Data JPA-projektiin, joka pyrkii vähentämään tietokantalogiikan toteutukseen tarvittavan koodin määrää.

Spring Data JPA

Olemme huomanneet että iso osa tietokantatoiminnoista on valmiiden operaatioiden toistamista. Käytännössä lähes jokainen JPA:n kanssa paininut paljon tietokantakyselyitä luova sovelluskehittäjä on jossain vaiheessa luonut itselleen hieman seuraavankaltaisen pohjan, jonka perimällä saa käyttöön oleellisimmat toiminnot.

// yleiskäyttönen JpaDao

public abstract class JpaDAO<T> implements DAO<T> {

    @PersistenceContext
    EntityManager entityManager;

    private Class clazz;
    
    public JpaDAO(Class clazz) {
        this.clazz = clazz;
    }   
    
    @Override
    public void create(T instance) {
        entityManager.merge(instance);
    }

    @Override
    public T read(Long id) {
        return (T)entityManager.find(clazz, id);
    }

    @Override
    public T update(T instance) {
        return entityManager.merge(instance);
    }


    @Override
    public void delete(Long id) {
        T instance = read(id);

        if(instance != null) {
            entityManager.remove(instance);
        }
    }
    
    @Override
    public List<T> list() {
        // CriteriaBuilder on ohjelmallinen API kyselyjen tekemiseen
        CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
        CriteriaQuery query = criteriaBuilder.createQuery(clazz);
        return entityManager.createQuery(query).getResultList();
    }
}

Konkreettinen tietokantatoiminnallisuus yllä olevaa luokkaa käyttäen vaatisi luokan perimisen. Esimerkiksi aiemmin toteuttamamme JpaAircraftDAO pienenee seuraavaan muotoon.

// pakkaus

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

@Repository
@Transactional
public class JpaAircraftDAO extends JpaDAO<Aircraft> implements AircraftDAO {
    public JpaAircraftDAO() {
        super(Aircraft.class);
    }
}

Useat ohjelmistokehittäjät tarjoavat myös tekeleitään muiden käyttöön (esim. daofusion ja generic-dao). Näissäkin joudut aina perimään jonkun luokan.

Spring Data JPA (http://www.springsource.org/spring-data/jpa) on Spring-sovelluskehykseen liittyvä projekti, joka helpottaa tyypillisten JPA-tietokantaluokkien toteuttamista. Spring Data JPAn etuna muihin "geneeriset daot"-toteutuksiin on integroituminen Spring-sovelluskehykseen, ja sitä kautta pääsy inversion of control ja dependency injection -mekanismeihin. Spring Data JPAn saa käyttöön lisäämällä seuraavan riippuvuuden projektimme pom.xml-tiedostoon. Riippuvuuskonfiguraatioon on lisätty <exclusions>-elementti, jolla voidaan poistaa riippuvuuden käyttämiä välillisiä riippuvuuksia. Spring Data JPAn versio 1.1.0.RELEASE käyttää Spring-riippuvuuksia, joiden versio ei ole haluamamme 3.1.2.RELEASE.

        <dependency>
            <groupId>org.springframework.data</groupId>
            <artifactId>spring-data-jpa</artifactId>
            <version>1.1.0.RELEASE</version>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework</groupId>
                    <artifactId>spring-jdbc</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>org.springframework</groupId>
                    <artifactId>spring-orm</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>org.springframework</groupId>
                    <artifactId>spring-tx</artifactId>
                </exclusion>           
            </exclusions>
        </dependency> 

Kun tarvittu kirjasto on käytössä, meidän tulee konfiguroida lisäksi tietokantaoperaatioita tekevien luokkiemme sijainti projektille. Lisätään tiedostoon front-controller-servlet.xml seuraava rivi InternalResourceViewResolver-olion konfiguroinnin jälkeen.

    <jpa:repositories base-package="pakkaus.jossa.repository.luokat.ovat" />

Jotta saamme etuliitteellä jpa: alkavat elementit käyttöömme, tulee front-controller-servlet.xml-tiedostoon määritellä myös jpa-elementin sijainti:

       ...
       xmlns:jpa="http://www.springframework.org/schema/data/jpa"
       ...
       xsi:schemaLocation="http://www.springframework.org/schema/beans
                            http://www.springframework.org/schema/beans/spring-beans-3.1.xsd
                            ...
                            http://www.springframework.org/schema/data/jpa
                            http://www.springframework.org/schema/data/jpa/spring-jpa.xsd">
      ...

Luodaan taas lentokoneiden tallennuslogiikkaa, tällä kertaa Spring Data JPA:n avulla. Spring Data JPA määrittelee oman CrudRepository-rajapinnan, jolle annetaan tyyppiparametreina tallennettava olio sekä avainkentän tyyppi. Määritellään oma rajapinta AircraftRepository, joka perii Spring Data JPAn CrudRepository-rajapinnan.

// pakkaus

import org.springframework.data.repository.CrudRepository;

public interface AircraftRepository extends CrudRepository<Aircraft, Long> {
}

Tämän jälkeen tehdään AircraftRepository-luokkaa käyttävät muut komponentit. Hei! Eihän tuolla toteutettu tuota AircraftRepository-rajapintaa! Ei niin. Avainsanoina tälle magialle perintä, inversion of control ja dependency injection. Spring Data JPA:n käyttämissä luokissa on määritelty luokkatason transaktiot, joten tallennusoperaatiot toimivat myös ilman @Transactional-annotaatiota.

Easy Storing of Objects

Luodaan sovellus, jossa voi lisätä esineitä tietokantaan. Tietokannassa olevien esineiden lukumäärää pystyy myös kasvattamaan. Käyttöliittymätiedostot sekä suurin osa projektin konfiguraatiosta tulee valmiina projektin mukana.

Tallennettava: Item

Luo pakkaukseen wad.storage.domain luokka Item, joka toteuttaa rajapinnan Serializable. Luokalla Item on @Entity-annotaatio sekä attribuutit id, name ja count, joiden tyypit ovat Long, String ja Integer vastaavasti.

Lisää attribuutille id annotaatiot @Id, @GeneratedValue(strategy = GenerationType.TABLE) ja @Column. Lisää @Column annotaatio myös attribuuteille name ja count. Lisää jokaiselle attribuutille myös get- ja set-metodit.

Tallentaja: ItemRepository

Luo seuraavaksi pakkaukseen wad.storage.repository rajapintaluokka ItemRepository. Rajapintaluokka ItemRepository perii Spring Data JPA:n rajapintaluokan CrudRepository siten, että tallennettava olio on tyyppiä Item, ja avaimena on Long.

Lisää myös kansiossa /WEB-INF/ olevaan tiedostoon database.xml Spring Data JPA-tyyppisten repository-luokkien etsimiseen tarkoitettu konfiguraatio:

<jpa:repositories base-package="wad.storage.repository" />

Kontrolleri: StorageController

Luo pakkaukseen wad.storage.controller luokka StorageController, joka toteuttaa rajapinnan StorageControllerInterface. Luokka StorageController kapseloi rajapintaluokan ItemRepository, jonka tulee olla merkattu annotaatiolla @Autowired. Toteuta luokalta StorageController vaaditut metodit seuraavasti:

Kun sovelluksesi toimii, lähetä se TMC:lle. Vertaa myös tarvitsemasi tietokantalogiikkaan liittyvän koodin määrää edellisiin tehtäviin.

Omien kyselyiden toteuttaminen

Spring Data JPA ei tarjoa kaikkia kyselyitä valmiiksi. Jos tarvitset tietynlaisen kyselyn, sinun tulee yleensäottaen myös määritellä se. Laajennetaan aiemmin määriteltyä rajapintaa AircraftRepository siten, että sillä on metodi List<Aircraft> findByCapacity(Integer capacity) -- eli hae koneet, joilla on tietty kapasiteetti.

// pakkaus

import org.springframework.data.repository.CrudRepository;

public interface AircraftRepository extends CrudRepository<Aircraft, Long> {
    List<Aircraft> findByCapacity(Integer capacity);
}

Ylläoleva esimerkki on esimerkki kyselystä, johon Spring Data ei tarvitse erillistä toteutusta. Se arvaa että kysely olisi muotoa SELECT a FROM Aircraft a WHERE a.capacity = :capacity, ja luo sen valmiiksi. Lisää Spring Data JPA:n kyselyjen arvaamisesta löytyy sen dokumentaatiosta.

Tehdään toinen esimerkki, jossa joudumme oikeasti luomaan oman kyselyn. Lisätään rajapinnalle AircraftRepository metodi findAirForceOne, joka suorittaa kyselyn "SELECT a FROM Aircraft a WHERE a.identifier = 'Air Force One'".

// pakkaus

import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;

public interface AircraftRepository extends CrudRepository<Aircraft, Long> {
    List<Aircraft> findByCapacity(Integer capacity);
    @Query("SELECT a FROM Aircraft a WHERE a.identifier = 'Air Force One'")
    Aircraft findAirForceOne();
}

Käytössämme on nyt myös metodi findAirForceOne, joka suorittaa @Query-annotaatiossa määritellyn kyselyn. Tarkempi kuvaus kyselyiden määrittelystä osana rajapintaa löytyy Spring Data JPAn dokumentaatiosta.

Tietokantataulujen viitteet

Osoitteessa http://nikojava.wordpress.com/2011/08/04/essential-jpa-relationships/ on erittäin hyvä ja kattava kuvaus viitteiden lisäämisestä entiteetteihin. Tutustu osoitteessa olevaan blogikirjoitukseen ennen seuraavan tehtävän tekemistä.

Polut kuntoon!

REST-tyyppisessä ajatusmaailmassa osoitteissa käytettävien polkujen muodolla on suuri merkitys. Seuraavissa tehtävissä käytetään REST-tyyliä lähenteleviä polkuja. Springin avulla osoitepolkuihin lisättyjä parametreja voi käsitellä @PathVariable-annotaation avulla. Esimerkiksi alla on määritelty metodi assignAirport, joka kuuntelee polkuun {aircraftId}/airport tulevia pyyntöjä. Polun osa {aircraftId} muunnetaan luvuksi ja asetetaan @PathVariable-annotaatiolla merkattuun parametriin.

Metodi odottaa myös että pyynnön mukana tulee myös numeerinen parametri airportId.

    // ...
    @RequestMapping(value = "{aircraftId}/airport", method = RequestMethod.POST)
    @Override
    public String assignAirport(
                    @PathVariable(value="aircraftId") Long aircraftId, 
                    @RequestParam Long airportId) {
    // ...

Kontrollereissa voidaan määritellä @RequestMapping-annotaation avulla myös korkean tason osoite. Esimerkiksi seuraavassa luokassa oleva metodi assignAirport kuuntelee pyyntöjä osoitteeseen aircraft/{aircraftId}/airport.

//importit jne

@Controller
@RequestMapping("aircraft")
public class AircraftController {

    // ...
    @RequestMapping(value = "{aircraftId}/airport", method = RequestMethod.POST)
    @Override
    public String assignAirport(
                    @PathVariable(value="aircraftId") Long aircraftId, 
                    @RequestParam Long airportId) {
    // ...

Going for a Flight

Jatkokehitetään tässä tehtävässä sovellusta lentokoneiden ja lentokenttien hallintaan. Projektissa on jo valmiina ohjelmisto, jossa voidaan lisätä ja poistaa lentokoneita. Tavoitteena on lisätä toiminnallisuus lentokoneiden kotikenttien asettamiseksi.

Tallennettavat: Aircraft ja Airport.

Lisää luokkaan Aircraft attribuutti airport, joka on tyyppiä Airport. Koska usealla lentokoneella voi olla sama kotikenttä, käytä attribuutille airport annotaatiota @ManyToOne. Lisää attribuutille myös @JoinColumn-annotaatio, jonka avulla kerrotaan että tämä attribuutti viittaa toiseen tauluun. Lisää luokalle myös oleelliset get- ja set-metodit.

Lisää seuraavaksi Airport attribuutti aircrafts, joka on tyyppiä List<Aircraft>. Koska yhdellä lentokentällä voi olla useita koneita, lisää attribuutille annotaatio @OneToMany. Koska luokan Aircraft attribuutti airport viittaa tähän luokkaan, aseta annotaatioon @OneToMany parametri mappedBy="airport". Nyt luokka Airport tietää että attribuuttiin aircrafts tulee ladata kaikki Aircraft-oliot, jotka viittaavat juuri tähän kenttään.

Lisää myös luokalle Airport oleelliset get- ja set-metodit.

Lentokentän asetus lentokoneelle

Lisää sovellukselle toiminnallisuus lentokentän lisäämiseen lentokoneelle. Käyttöliittymä sisältää jo tarvittavan toiminnallisuuden, joten käytännössä sinun tulee toteuttaa luokan AircraftController metodi String assignAirport. Kun käyttäjä lisää lentokoneelle lentokenttää, käyttöliittymä lähettää POST-tyyppisen kyselyn osoitteeseen /app/aircraft/{aircraftId}/airport, missä aircraftId on lentokoneen tietokantatunnus. Pyynnön mukana tulee lisäksi parametri airportId, joka sisältää lentokentän tietokantatunnuksen.

Toteuta metodi siten, että haet aluksi tietokantatunnuksia käyttäen lentokoneen ja lentokentän, tämän jälkeen asetat lentokoneelle lentokentän ja lentokentälle lentokoneen, ja tallennat haetut oliot.

Ohjaa lopuksi pyyntö osoitteeseen /app/aircraft/

Kun olet valmis, lähetä sovellus TMC:lle tarkistettavaksi.

Kerrosarkkitehtuuri

Kerrosarkkitehtuuri jakaa sovelluksen kerroksiin; tiedon tallentamiseen ja hakemiseen liittyvään logiikkaan, sovelluksen palveluihin liittyvään logiikkaan, käyttöliittymästä tehtyjä pyyntöjä ohjaavaan kerrokseen, ja käyttöliittymäkerrokseen.

N-tier -arkkitehtuurilla tarkoitetaan yleisesti sovelluksen jakamista itsenäisiin osiin, jotka toimivat vuorovaikutuksessa muiden osien kanssa. Client-Server -arkkitehtuuri on esimerkki 2-tier arkkitehtuurista, 3-tier -arkkitehtuuri taas esimerkiksi jakaa käyttöliittymän, sovelluslogiikan, ja tietokantalogiikan erillisiksi osioikseen. Tällä kurssilla kerrosarkkitehtuurista puhuessamme tarkoitamme yleisesti ottaen seuraavaa jakoa:

Tiedon hakemiseen ja tallentamiseen liittyvässä logiikassa käytetään lähes aina valmiita komponentteja, esim. JPA tai Spring Data JPA. Alimman kerroksen toiminnallisuus -- relaatiotietokannat, key/value -tietokannat, dokumenttitietokannat, jne.. -- ovat web-sovelluskehittäjän näkökulmasta "done", eli omaa ei kannata lähteä toteuttamaan.

Sovelluksen oleellisin osa on palveluissa, eli keskitasossa. Sovelluslogiikka sisältää toiminnallisuuden, joka tekee sovelluksestamme arvokkaan. Sovelluslogiikka käyttää alempana olevan tietokantakerroksen tarjoamia palveluita. Sovelluslogiikkakerros sisältää yleensä myös transaktioiden hallinnan.

Sovelluslogiikkakerroksen päällä on käyttöliittymäkutsuja ohjaava kerros, "kontrollikerros". Käyttöliittymäkutsuja ohjaavan kerroksen tehtävänä on toimia rajapintana sovelluskerroksen ja käyttöliittymän välillä. Se vastaanottaa käyttäjän tekemiä pyyntöjä ja ohjaa niitä eteenpäin tarpeellisille palveluille. Kontrollikerroksen tulee olla mahdollisimman ohut; siinä käytetään sovelluskerroksen tarjoamaa toiminnallisuutta. Kutsun suorittamisen jälkeen kontrollikerros palauttaa dataa jossain muodossa. Javascript-kutsulla voi pyytää XML- tai JSON-muodossa olevaa dataa, jonka näyttäminen tapahtuu selainpuolella. Toisaalta, palautettu data voidaan ohjata erilliselle käyttöliittymän renderöintipalvelulle, joka luo näytettävän HTML-sivun.

Kontrollikerroksen päällä on käyttöliittymä. Käyttöliittymiä voi olla useita erilaisia. Kaikille yhteistä on se, että ne tekevät pyyntöjä web-sovelluksen tarjoamiin osoitteisiin.

Sovelluslogiikkakerros

Sovelluslogiikka jaetaan oliosuunnittelun periaatteiden mukaisesti toiminnallisuutta tarjoaviin palveluihin, joita kontrollikerros käyttää. Spring tarjoaa hyvät välineet sovelluslogiikan ja kontrollikerroksen erottamiseen. Tutustumme seuraavaksi kontrolliluokan ja sovelluslogiikan yhteistoimintaan. Pieni kertaus ennen sitä.

Inversion of Control ja Dependency Injection

Jokaisella oliolla on oma selkeä vastuualueensa, ja niiden sekoittamista tulee välttää. Inversion of Control ja Dependency Injection ovat suunnitelumalleja, joilla pyritään vähentämään olioiden turhia riippuvuuksia.

Inversion of Control

Perinteisissä ohjelmistoissa luokkien ilmentymien luominen on ohjelmoijan vastuulla. Huomasimme jo aiemmin että Spring luo käyttöömme luokkia joita tarvitsemme: Kontrollin käännöllä tarkoitetaan ohjelman toiminnan hallinnan vastuun siirtämistä sovelluskehykselle ja ohjelmaa suorittavalle palvelimelle.

Dependency Injection

Dependency Injectionin tehtävänä on syöttää riippuvuudet silloin kun niitä tarvitaan.

Käytännössä siis: palvelin ja sovelluskehys ottaa vastuuta luokkien hallinnoinnista. Sovelluskehys syöttää riippuvuudet niitä tarvittaessa. Molemmat toiminnallisuudet ovat oleellisia kerrosarkkitehtuurin kerrosten toisistaan erottamisessa.

Palveluesimerkki: HitCounter

Luodaan palvelu jonka tehtävänä on yksittäisten vierailujen määrän laskeminen. Määritellään rajapinta HitCounter-palvelulle. Rajapinta tarjoaa kaksi metodia: getCount(), joka palauttaa kävijöiden määrän, ja incrementCount(), joka kasvattaa kävijöiden määrää yhdellä.

// pakkaus 

public interface HitCounter {
    int getCount();
    void incrementCount();
}

Luodaan rajapinnalle toteutus InMemoryHitCounter. Toteutus merkitään annotaatiolla @Service. Annotaatio @Service on kuin @Component, mutta se kuvaa paremmin komponentit tarkoitusta.

// pakkaus ja muut importit

import org.springframework.stereotype.Service;


@Service
public class InMemoryHitCounter implements HitCounter {
    private int count = 0;

    @Override
    public int getCount() {
        return count;
    }

    @Override
    public void incrementCount() {
        count++;
    }
}

Luodaan seuraavaksi näkymä, jonka tehtävänä on kertoa käyntien määrä. Esimerkissämme näkymä sijaitsee tiedostossa hits.jsp.

<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
        <title>uno dos tres</title>
    </head>
    <body>
        <h1>Hits: ${hits}</h1>
    </body>
</html>

Näkymässämme on EL-tägi hits, jonka tehtävänä on näyttää osumien määrä. Luodaan lopuksi kontrolleri HitController, joka vastaanottaa pyynnöt osoitteeseen hitme, kutsuu HitCounter-palvelun tarjoamia metodeja, ja palauttaa lopuksi luodun mallin näkymää varten.

// pakkaus

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
public class HitController {

    @Autowired
    HitCounter hitCounter;

    @RequestMapping("hitme")
    public String incrementAndReturn(Model model) {
        hitCounter.incrementCount();
        model.addAttribute("hits", hitCounter.getCount());
        
        return "check";
    }
}

Tietokantatransaktioiden hallinta palvelutasolla

Sovelluksia suunniteltaessa transaktiot toteutetaan yleensä palvelutasolla. Tällöin tietokantatason operaatioita kerätään saman transaktion sisään, ja kaikkien suoritus riippuu transaktion onnistumisesta. Oletetaan että käytössämme on aiemmin luotu AircraftRepository, ja Aircraft-olioiden avain on tyyppiä Long. Luodaan rajapinta AircraftService, joka tarjoaa metodit Iterable<Aircraft> list() ja void changeIdentifierString(Long aircraftId, String identifierString).

// pakkaus

public interface AircraftService {
    Iterable<Aircraft> list();
    void changeIdentifierString(Long aircraftId, String identifierString);
}

Luodaan seuraavaksi rajapinnalle toteutus. Tutki erityisesti metodin changeIdentifierString toteutusta.

// importit ym

@Service
public class RepositoryAircraftService implements AircraftService {

    @Autowired
    private AircraftRepository aircraftRepository;

    @Override
    @Transactional(readOnly = true)
    public Iterable<Aircraft> list() {
        return aircraftRepository.findAll();
    }

    @Override
    @Transactional(readOnly = false)
    public void changeIdentifierString(Long aircraftId, String identifierString) {
        Aircraft craft = aircraftRepository.findOne(aircraftId);
        if ( craft == null ) {
            throw new NoSuchElementException("No aircraft with id " + aircraftId);
        }

        craft.setIdentifier(identifierString);
    }

//...

Huomionarvoista on että metodissa changeIdentifierString oliota Aircraft ei tallenneta erikseen. JPAn EntityManager-luokkaa käytettäessä entiteetit ovat EntityManager-olion hallinnoitavana koko transaktion ajan. Metodissa changeIdentifierString aloitetaan transaktio, luetaan Aircraft-olio tietokannasta, ja asetetaan oliolle uusi identifier-muuttujan arvo metodilla setIdentifier. Transaktion lopussa EntityManager tutkii onko hallinnoitaviin olioihin tehty muutoksia. Jos muutoksia on tehty, muutokset tallennetaan tietokantaan automaattisesti.

Entiteetti on hallinnoitava jos EntityManager on hakenut sen tietokannasta kyseisen transaktion sisällä. Myös Spring Data JPA käyttää EntityManager-olioita toteutuksessaan.

Movie Database

Tämä tehtävä on avoin tehtävä jossa saat itse suunnitella huomattavan osan ohjelman sisäisestä rakenteesta. Ainut määritelty asia ohjelmassa on käyttöliittymä, joka tulee tehtäväpohjan mukana. Käyttöliittymään on määritelty EL-kielellä attribuutit, joita käyttöliittymän tulee näyttää. Tehtäväpohjassa on myös valmis konfiguraatio spring-projektille.

Tehtävästä on mahdollista saada yhteensä 4 pistettä.

Luo tehtävässä sovellus, joka toimii kuten osoitteessa http://t-avihavai.users.cs.helsinki.fi/moviedatabase/ oleva sovellus. Sovelluksessasi ei tarvitse olla XSS-tarkastusta, ja yksittäisen näyttelijän elokuvia tarkasteltaessa uuden elokuvan lisäämisessä ei tarvitse poistaa näyttelijällä jo olevia elokuvia.

Vinkki: Aloita yksittäisestä asiasta, esimerkiksi näyttelijän lisäämisestä ja poistamisesta. Suunnittele ensin sopiva tietokantaolio, sekä sille sopivat repository-oliot. Jatka tämän jälkeen palvelukerroksella, ja siirry siitä kontrolleriin. Kannattaa hyödyntää käyttöliittymätiedostoissa käytettyä EL-kieltä tietokantaolioiden attribuuttien määrittelyssä.

Pisteytys:

  1. + 1p: Näyttelijän lisääminen ja poistaminen onnistuu. Käyttöliittymän olettamat osoitteet ja niiden parametrit:
    • GET /app/actor/ - näyttelijöiden listaus, ei parametreja pyynnössä.
    • POST /app/actor/ - parametri name, joka sisältää lisättävän näyttelijän nimen. Lisäyksen tulee lopulta ohjata pyyntö osoitteeseen /app/actor/.
    • POST /app/actor/{actorId}/delete - polun parametri actorId, joka sisältää poistettavan näyttelijän tietokantatunnuksen. Poiston tulee lopulta ohjata pyyntö osoitteeseen /app/actor/.


  2. + 1p: Elokuvan lisääminen ja poistaminen onnistuu. Käyttöliittymän olettamat osoitteet ja niiden parametrit:
    • GET /app/movie/ - elokuvien listaus, ei parametreja pyynnössä.
    • POST /app/movie/ - elokuvan lisäys, parametrit name, joka sisältää lisättävän elokuvan nimen, ja lengthInMinutes, joka sisältää elokuvan pituuden minuuteissa. Lisäyksen tulee lopulta ohjata pyyntö osoitteeseen /app/movie/.
    • POST /app/movie/{movieId}/delete - polun parametri movieId, joka sisältää poistettavan elokuvan tietokantatunnuksen. Poiston tulee lopulta ohjata pyyntö osoitteeseen /app/movie/.


  3. + 2p: Näyttelijän voi lisätä elokuvaan (kun näyttelijä tai elokuva poistetaan, poistetaan myös viitteet näyttelijästä elokuvaan ja elokuvasta näyttelijään). Käyttöliittymän olettamat osoitteet ja niiden parametrit:
    • GET /app/actor/{actorId} - polun parametri actorId, joka sisältää näytettävän näyttelijän tietokantatunnuksen. Näyttää sivun /WEB-INF/jsp/actor.jsp (tutustu tiedostoon, ja varmista että pyynnössä on sivun sisältämät attribuutit.)
    • POST /app/actor/{actorId}/movie - polun parametri actorId, joka sisältää kytkettävän näyttelijän tietokantatunnuksen, ja parametri movieId, joka sisältää kytkettävän elokuvan tietokantatunnuksen. Lisäämisen tulee lopulta ohjata pyyntö osoitteeseen /app/actor/.

Kontrolleritaso

Kontrolleritaso kuuntelee käyttöliittymältä tulevia pyyntöjä, ja vastaanottaa lähetettyä dataa. Käytännössä sovelluskehyksissä pyynnöt ohjataan kontrollereille erillisen front controllerin kautta. Front controller kuuntelee kaikkia sovellukselle ohjattuja pyyntöjä. Springiä käytettäessä käytämme Springin omaa DispatcherServlet-toteutusta, joka toimii front controllerina.

Kontrolleritason ensisijaisena vastuuna on pyyntöjen kuuntelu, pyyntöjen ohjaaminen oikealle palvelulle, sekä tuotetun tiedon ohjaaminen oikealle näkymälle. Jotta palveluille ei ohjata epäoleellista dataa, esimerkiksi huonoja arvoja sisältäviä parametreja, on kontrolleritason vastuulla myös pyyntöparametrien validointi. Parametrien validointi tapahtuu käytännössä käsin, joko siten, että ohjelmoija itse toteuttaa validoinnin, tai jotain olemassaolevaa validointimekanismia käyttämällä.

Pyyntöparametrien validointi

Ensimmäinen askel pyyntöparametrien validointiin on pyyntöparametrien asettaminen odotetun tyyppisiin muuttujiin. Esimerkiksi numeerisia arvoja saavan parametrin oikeellisuuden voi varmistaa asettamalla sen Integer-tyyppiseen muuttujaan. Poikkeustapauksessa tiedämme heti, että käyttäjä on yrittänyt syöttää sovellukselle virheellisiä arvoja.

Alkeellinen muuttujien arvojen validointi toteutetaan kontrolleriin tai erilliseen validointiluokkaan. Oletetaan että seuraava lomake sijaitsee tiedostossa grocery-item-form.jsp, lomakkeen lähetys ohjautuu luokassa GroceryItemController olevalle metodille addGroceryItem. Lisäksi käytössämme on erillinen rajapinta GroceryItemService, joka tarjoaa metodit tallentamiseen ja listaamiseen.

<form action="grocery-item" method="POST">
    <input type="text" name="name"/> <br/>
    <input type="submit"/>
</form>
// ...
@Controller
public class GroceryItemController {
    
    @Autowired
    private GroceryItemService groceryItemService; 

    @RequestMapping(value = "grocery-item", method=RequestMethod.GET)
    public String viewForm() {
        return "grocery-item-form";
    }


    @RequestMapping(value = "grocery-item", method=RequestMethod.POST)
    public String addGroceryItem(@RequestParam String name) {
        // validointi

        groceryItemService.add(name);
        return "redirect:success";
    }

    // ...
} 
// ...
public interface GroceryItemService {
    void add(String name);
    List<GroceryItem> list();
}

Ensimmäisessä validoinnissa estämme merkkijonot, joiden pituus on alle 5 tai yli 20. Jos merkkijono ei kelpaa, lomakkeessa tulee näyttää virheviesti "Length of name should be between 5 and 20.". Tämän lisäksi lomakkeessa tulee näyttää syötetty teksti.

Jotta lomakkeessa voisi näkyä aiemmin syötetty teksti, tulee lomakkeessa olla sille attribuutti. Muokataan lomaketta siten, että siinä on paikka sekä virheelle että syötetylle datalle. Lomakkeessa olevalle input-elementille voi asettaa arvon value-attribuutin avulla. Virheviesti on merkattu EL-kielen attribuuttina ${nameError} ja näytetään lomakekentän jälkeen.

<form action="grocery-item" method="POST">
    <input type="text" name="name" value="${name}" /> ${nameError} <br/>
    <input type="submit"/>
</form>

Muunnetaan seuraavaksi kontrolleriluokassa olevaa addGroceryItem-metodia siten, että nimen ollessa liian pitkä tai liian lyhyt, käyttäjälle palautetaan lomake jossa näkyy aiemmin lähetetty arvo sekä virhekuvaus. Tarvitsemme tätä varten POST-pyyntöä kuuntelevassa metodissa Model-olion.

// ...
@Controller
public class GroceryItemController {
    
    @Autowired
    private GroceryItemService groceryItemService; 

    @RequestMapping(value = "grocery-item", method=RequestMethod.GET)
    public String viewForm() {
        return "grocery-item-form";
    }

    @RequestMapping(value = "grocery-item", method=RequestMethod.POST)
    public String addGroceryItem(Model model, @RequestParam String name) {
        // validointi
        if(name.length() < 5 || name.length() > 20) {
            model.addAttribute("name", name);
            model.addAttribute("nameError", "Length of name should be between 5 and 20.");
            return "grocery-item-form";
        }

        groceryItemService.add(name);
        return "redirect:success";
    }

    // ...
} 

Huomattavaa tässä on se, että emme pyydä käyttäjän selainta tekemään uudelleenohjausta lomakkeen lähetyksen epäonnistuessa, vaan palautamme käyttäjän takaisin lomakesivulle. Jos tarkistusmetodi tekisi uudelleenohjauksen myös lomakesivulle, ei Model-olioon lisättyjä attribuutteja olisi käytössä.

Miksi Model-olioon lisätyt attribuutit eivät ole käytössä redirect-pyynnön jälkeen?

 

 

Going for a Ball

Tehtävän mukana tulee sovellus, jota käytetään tanssijuhliin ilmoittatumiseen. Tällä hetkellä käyttäjä voi ilmoittautua juhliin oikeastaan minkälaisilla tiedoilla tahansa. Tehtävänäsi on toteuttaa parametreille seuraavanlainen validointi:

  1. Nimen (name) tulee olla vähintään 4 merkkiä pitkä ja enintään 30 merkkiä pitkä.
  2. Osoitteen (address) tulee olla vähintään 4 merkkiä pitkä ja enintään 50 merkkiä pitkä.
  3. Sähköpostiosoitteessa (email) tulee olla @-merkki.

Jos yksikään ylläolevista tarkastuksista epäonnistuu, tulee käyttäjälle näyttää rekisteröitymislomake uudelleen. Tällöin lomakkeessa tulee olla lähetetyt parametrit valmiina. Tämän lisäksi jokaiselle virhetapaukselle tulee olla oma virheviesti. Virheviestit lisätään Model-olioon, jotta ne voidaan näyttää näkymässä. Virheviesteinä tulee käyttää seuraavia:

  1. Attribuutin nimi: nameError, arvo: Length of name should be between 4 and 30.
  2. Attribuutin nimi: addressError, arvo: Length of address should be between 4 and 50.
  3. Attribuutin nimi: emailError, arvo: Email should contain a @-character.

Virheviestiä ei tule lisätä vastaukseen jos kyseistä virhettä ei lähetetyssä lomakkeessa ole. Käyttöliittymä on tehtävässä valmiina -- älä muuta luokassa RegistrationController olevien metodien parametrimäärittelyjä. Saat toki muokata metodien sisältöä sekä lisätä uusia metodeja. Alkuperäisten metodien toiminnan tulee säilyä kun käyttäjän antamat rekisteröitymistiedot ovat oikein.

Huom! TMC:ssä on bugi, joka liittyy tämän viikon tehtävien tarkistukseen. Jos saat TMC:stä virheviestin

java.lang.NoSuchMethodError: org.hamcrest.Matcher.describeMismatch

ratkaisussasi on hyvin todennäköisesti virhe. Ongelmana on se, että TMC:n käyttämä testikehys käyttää testausta auttavan Hamcrest-kirjaston vanhempaa versiota kuin itse testit. Tämän takia testikehys suorittaa metodit eri tavalla kuin toivottu.

Virheviesti kertoo käytännössä että sovelluksesta ei löydy metodia jolla kertoisin tästä virheestä.

Toistaiseksi virheen kanssa tulee elää, käytännössä pulman voi ratkaista tarkastelemalla mitä asioita testi testaa. Jo testimetodien nimien pitäisi olla melko kuvaavia.

Voit myös testata projekteja NetBeansissa valitsemalla projektin oikealla hiirennäppäimellä, ja klikkaamalla vaihtoehtoa Test. Tällöin ylläolevan ongelman ei pitäisi ilmaantua, ja saat hieman selkeämmän virhekuvauksen. Samoin projektien testaaminen onnistuu komentoriviltä komennolla mvn test.

Lomakkeet ja oliot

Kun pohdimme lomakkeita hieman enemmän, huomaamme että ne sopivat melko hyvin olio-ohjelmointiajatteluun. Lomakkeissa syötetään usein tietoa johonkin käsitteeseen -- luokkaan liittyen -- ja yhden lomakkeen sisältämät tiedot kuvautuvat helposti oliona. Onkin itseasiassa suhteellisen helppoa rakentaa oma luokka, joka muuntaa pyynnössä olevat parametrit tietynlaiseksi olioksi.

Ajatustasolla olion luominen pyynnöstä tapahtuu luomalla ensin oliosta ilmentymä, ja sen jälkeen käymällä pyynnön parametreja läpi. Jos pyynnössä on parametri, joka on saman niminen kuin olion attribuutti, asetetaan parametrin arvo olion attribuutiksi. Huomattava osa yleisessä käytössä olevista sovelluskehyksistä tekee tämän jollain tavalla puolestamme. Spring-sovelluskehyksessä tämän toiminnallisuuden saa käyttöön lisäämällä Springin konfiguraatioon komennon <mvc:annotation-driven />. Myös mvc-nimiavaruus tulee lisätä Springin konfiguraatioon.

...
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"	
       xmlns:mvc="http://www.springframework.org/schema/mvc"
       xmlns:context="http://www.springframework.org/schema/context"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
                            ...       
                            http://www.springframework.org/schema/mvc 
                            http://www.springframework.org/schema/mvc/spring-mvc-3.1.xsd
                            ...
                            http://www.springframework.org/schema/context/spring-context-3.1.xsd">



<mvc:annotation-driven />

Nyt voimme käyttää olioita kontrollerissa olevien metodien parametrina. Tutkitaan tätä hieman tarkemmin.

Oletetaan että käytössämme on luokka Person, joka sisältää attribuutit name ja email. Luokka on bean-tyyppinen, eli sillä on parametriton konstruktori ja getterit ja setterit (luistamme taas Serializable-rajapinnan toteutuksesta).

// pakkaus jne
public class Person {

    private String name;
    private String email;

    public String getName() {
        return name;
    }

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

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }
}

Kai olet jo huomannut, että bean-tyyppistä luokkaa luodessa sinun tarvitsee kirjoittaa luokalle vain attribuutit, ja valita NetBeansin insert code-toiminnallisuus sopivat toiminnallisuudet. Esimerkiksi get- ja set-metodien käsin kirjoittaminen on turhaa.

Person-luokalla on attribuutit name ja email. Luodaan lomake tietojen lähettämiseen.

    <form action="person" method="POST">
        <span>Name: <input type="text" name="name" ></span><br>
        <span>Email: <input type="text" name="email" ></span><br>
        <input type="submit">
    </form>

Huomaa, että lomakkeen kenttien nimet ovat samat kuin luokan Person attribuuttien nimet. Luodaan seuraavaksi kontrollerimetodi pyynnön vastaanottamista varten. Spring-sovelluskehyksen annotaatio @ModelAttribute asettaa pyyntöön liittyviä parametreja annotoidun olion arvoksi.

    @RequestMapping(value = "person", method = RequestMethod.POST)
    public String createPerson(@ModelAttribute Person person) {
        // lähetetään person-olio esimerkiksi palvelulle
        return "redirect:list";
    }

Pyyntöä suoritettaessa Spring asettaa pyynnön parametreja ensin @ModelAttribute-annotaatiolla merkattuihin olioihin. Tämän jälkeen arvoja asetetaan @RequestParam-annotaatiolla merkattuihin muuttujiin.

Olioiden validointi

Lomakkeiden ja lähetettävän datan validointi on web-sovelluksille hyvin oleellista. Emme halua että käyttäjä pääsee lähettämään sovelluksellemme toisten koneilla suoritettavaa koodia. Ensimmäinen askel -- jonka olemme jo ottaneet -- on tallennettavan datan järkevä esitys. Käytämme datan esittämiseen olioita, joihin on määritelty sopivat kenttien tyypit. Tämä helpottaa työtämme jo hieman: esimerkiksi numerokenttiin ei saa asetettua merkkijonoja.

Tämä ei kuitenkaan vielä riitä.

Javassa on oma API Bean-tyyppisten olioiden validoinnille: Bean Validation API (Javadoc). Bean validation API on, kuten muutkin JSRt (Java Specification Request, vain rajapinta, jolla voi olla useampi toteuttaja. Käytämme JSR-303 -apin, eli Bean Validation APIn, toteutuksena Hibernate Validator-projektia (dokumentaatio), jonka saamme käyttöömme lisäämällä pom.xml-tiedostoon seuraavan riippuvuuden.

        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-validator</artifactId>
            <version>4.3.0.Final</version>
        </dependency>

Koska JSR-303:n määrittelemä Bean Validation API on hieman vaillinainen, eikä tarjoa työkaluja esimerkiksi sähköpostiosoitteiden validointiin, käytämme suoraan Hibernaten toteutusta.

Oliomuuttujien validointi

Muuttujien validointia varten tarkistettaville muuttujille määritellään annotaatiot. Muokataan aiemmin käytettyä luokkaa Person siten, että henkilöllä tulee olla henkilötunnus, nimi ja sähköpostiosoite.

// pakkaus jne
public class Person {

    private String socialSecurityNumber;
    private String name;
    private String email;

    public String getSocialSecurityNumber() {
        return socialSecurityNumber;
    }

    public void setSocialSecurityNumber(String socialSecurityNumber) {
        this.socialSecurityNumber = socialSecurityNumber;
    }

    public String getName() {
        return name;
    }

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

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }
}

Lisätään seuraavaksi attribuuttien validointi. Sovitaan että henkilötunnus ei saa koskaan olla tyhjä, ja sen tulee olla tasan 11 merkkiä pitkä. Nimen tulee olla vähintään 5 merkkiä pitkä, ja korkeintaan 30 merkkiä pitkä, ja sähköpostiosoitteen tulee olla validi sähköpostiosoite. Hibernate Validator-projektista löytyy näitä varten sopivia validointiannotaatioita. Annotaatio @NotBlank varmistaa ettei annotoitu attribuutti ole tyhjä -- lisätään se kaikkiin. Annotaatiolla @Length voidaan määritellä pituusrajoitteita muuttujalle, ja annotaatiolla @Email varmistetaan, että attribuutin arvo on varmasti sähköpostiosoite.

// pakkaus

import org.hibernate.validator.constraints.Email;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank;

public class Person {

    @NotBlank
    @Length(min = 11, max = 11)
    private String socialSecurityNumber;

    @NotBlank
    @Length(min = 5, max = 30)
    private String name;

    @NotBlank
    @Email
    private String email;

    // getterit ja setterit

Testataan luokan validointia ensin komentoriviltä. Olion validointi tapahtuu validoijan avulla, jonka voi luoda javax.validationAPIn (JSR-303) avulla. Tämän jälkeen luodaan validoitava olio, joka annetaan validoijalle. Validoija taas palauttaa joukon validointivirheitä, jotka lopuksi tulostamme. Kun olemme nähneet joukon virheitä, asetamme sähköpostiosoite-kenttään arvon, jonka jälkeen validoimme olion uudestaan.

// pakkaus ja muita importteja

import java.util.Set;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import org.hibernate.validator.HibernateValidator;
import org.hibernate.validator.HibernateValidatorConfiguration;

public class ValidatoreApp {

    public static void main(String[] args) {
        // luodaan validoija
        HibernateValidatorConfiguration config = Validation.byProvider(HibernateValidator.class).configure();
        ValidatorFactory factory = config.buildValidatorFactory();
        Validator validator = factory.getValidator();

        // luodaan validoitava
        Person person = new Person();

        // validointi
        Set<ConstraintViolation<Person>> violations = validator.validate(person);

        // validointivirheiden tulostus
        for (ConstraintViolation<Person> violation : violations) {
            String path = violation.getPropertyPath().toString();
            String message = violation.getMessage();

            System.out.println(path + " " + message);
        }

        person.setName("El Barto");
        person.setEmail("elbarto");

        System.out.println("*****");

        for (ConstraintViolation<Person> violation : validator.validate(person)) {
            String path = violation.getPropertyPath().toString();
            String message = violation.getMessage();

            System.out.println(path + " " + message);
        }
    }
}

Ylläolevan ohjelman tulostus on seuraavanlainen:

email may not be empty
socialSecurityNumber may not be empty
name may not be empty
*****
email not a well-formed email address
socialSecurityNumber may not be empty

Käytännössä siis validointi onnistuu Hibernate Validator-komponentin avulla annotaatioilla. Lisätään validointi seuraavaksi osaksi kontrolleriluokkaa.

Pyynnössä saatavan olion validointi kontrollerissa

Pyynnössä saatavan olion, joka on merkitty annotaatiolla @ModelAttribute validointi on helpohkoa. Kun olemme lisänneet Spring-konfiguraatioon komennon <mvc:annotation-driven/>, saamme käyttöön muitakin toiminnallisuuksia kuin olioiden generoinnin pyynnöstä. Yksi ominaisuus on olioiden validointi. Spring lataa automaattisesti käyttöönsä validointikehyksen, jos sellainen löytyy käytössä olevista kirjastoista. Jos olemme lisänneet Hibernate Validator-projektin osaksi pom.xml-tiedostoa, on se käytössämme automaattisesti.

Itse olion validointi tapahtuu lisäämällä kontrollerimetodissa olevalle @ModelAttribute-annotaatiolle merkatulle oliolle annotaatio @Valid (javax.validation.Valid;).

    @RequestMapping(value = "person", method = RequestMethod.POST)
    public String create(@Valid @ModelAttribute Person person) {
        // .. toteutus
   }

Nyt person-olion ilmentymä validoidaan heti kun se kontrollerissa vastaanottaa pyynnön. Validointivirheet eivät kuitenkaan ole kovin kaunista luettavaa. Yllä olevalla kontrollerimetodilla esimerkiksi virheellisen nimen kohdalla saamme statuskoodin 500, sekä hieman kaoottisen ilmoituksen.

org.springframework.web.util.NestedServletException: Request processing failed; 
  nested exception is org.springframework.validation.BindException: 
  org.springframework.validation.BeanPropertyBindingResult: 4 errors
Field error in object 'person' on field 'name': rejected value []; 
  codes [NotBlank.person.name,NotBlank.name,NotBlank.java.lang.String,NotBlank]; 
  arguments [org.springframework.context.support.DefaultMessageSourceResolvable: 
  codes [person.name,name]; 
  arguments []; 
  default message [name]]; 
  default message [may not be empty]
  ...

Ei kovin kaunista katseltavaa.

Helpotetaan elämäämme hieman kytkemällä validointitulokset erilliseen olioon.

Olioiden validointi ja BindingResult

Kontrollerimetodissa oliota validoidessa validointivirheet aiheuttavat poikkeuksen, jos niille ei erikseen määritellä tallennuspaikkaa. Luokka BindingResult toimii validointivirheiden tallennuspaikkana, jonka kautta voimme käsitellä virheitä omien tarpeidemme mukaan. BindingResult-olio kuvaa aina yksittäisen olion luomisen ja validoinnin onnistumista, ja se tulee asettaa heti validoitavan olion jälkeen. Seuraavassa esimerkki kontrollerista, jossa validoinnin tulos lisätään BindingResult-olioon.

    @RequestMapping(value = "person", method = RequestMethod.POST)
    public String create(@Valid @ModelAttribute Person person, BindingResult bindingResult) {
        if(bindingResult.hasErrors()) {
            // validoinnissa virheitä: virheiden käsittely
        }

        // muu toteutus 
    }

Ylläolevassa esimerkissä kaikki validointivirheet tallennetaan BindingResult-olioon. Oliolla on metodi hasErrors, jonka perusteella päätämme jatketaanko pyynnön prosessointia vai ei. Yleinen muoto lomakedataa tallentaville kontrollereille on seuraavanlainen:

    @RequestMapping(value = "person", method = RequestMethod.POST)
    public String create(@Valid @ModelAttribute Person person, BindingResult bindingResult) {
        if(bindingResult.hasErrors()) {
            return "form";
        }

        // .. toteutus
    }

Yllä oletetaan että lomake lähetettiin sivulta "form". Käytännössä jos näemme virheen validoinnissa, palaamme takaisin sivulle. Aiempaa lähestymistapaa seuraten voisimme käyttää erillistä Model-oliota validointivirheiden lisäämiseksi ja niiden näyttämiseksi sivulla. Tutkitaan seuraavaksi toista tapaa.

Springin lomakkeet ja BindingResult

Spring tarjoaa käyttöömme tägikirjaston lomakkeiden luomiseen JSP-sivulla. Lomakekirjaston saa JSP-sivulla käyttöön lisäämällä sivun alkuun seuraavan komennon.

<%@ taglib uri="http://www.springframework.org/tags/form" prefix="form"%>

Komento tuo käyttöömme springin lomakkeet, joita voi käyttää form:-etuliitteellä. Lomakkeet määritellään kuten normaalit HTML-lomakkeet, mutta sisältävät muutaman apuvälineen. Lomakkeen attribuutti commandName kertoo mihin kontrollerissa olevaan olioon lomakkeen kentät tulee pyrkiä liittämään. Sitä käytetään yhdessä kontrolleriluokan ModelAttribute-annotaation kanssa. Lomakkeen kentät määritellään polkujen path avulla, jotka kertovat ModelAttribute-annotaatiolla merkityn olion kentät. Ehkä oleellisin on kuitenkin tägi <form:errors path="..." />, jonka avulla saamme kenttiin liittyvät virheet esille.

Luodaan lomake aiemmin nähdyn Person-olion luomiseen.

<%@ taglib uri="http://www.springframework.org/tags/form" prefix="form"%>
<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
        <title>Person</title>
    </head>
    <body>
        <h1>Create new person</h1>
        <form:form commandName="person" action="${pageContext.request.contextPath}/person" method="POST">
            <form:input path="socialSecurityNumber" /><form:errors path="socialSecurityNumber" /><br/>
            <form:input path="name" /><form:errors path="name" /><br/>
            <form:input path="email" /><form:errors path="email" /><br/>
            <input type="submit">
        </form:form>
    </body>
</html>

Yllä on määritelty lomake, joka lähettää lomakkeen tiedot osoitteessa <sovellus>/person olevalle alla kontrollerimetodille. Lomakkeelle tullessa tarvitsemme erillisen tiedon käytössä olevasta oliosta. Alla on näytetty sekä kontrollerimetodi, joka ohjaa GET-pyynnöt lomakkeeseen, että kontrollerimetodi, joka käsittelee POST-tyyppiset pyynnöt. Huomaa erityisesti @ModelAttribute-annotaatio kummassakin metodissa, sekä annotaation parametri "person", joka vastaa lomakkeessa olevaan command-attribuuttia. Tämän avulla lomake tietää, mitä oliota käsitellään.

// yleistä tauhkaa

    @RequestMapping(value = "person", method = RequestMethod.GET)
    public String viewForm(@ModelAttribute("person") Person person) {
        return "form";
    }

    @RequestMapping(value = "person", method = RequestMethod.POST)
    public String create(@Valid @ModelAttribute Person person, BindingResult bindingResult) {
        if(bindingResult.hasErrors()) {
            return "form";
        }

        // .. toteutus
    }

Jos lomakkeella lähetetyissä kentissä on virheitä, virheet tallentuvat BindingResult-olioon. Tarkistamme kontrollerimetodissa create ensin virheiden olemassaolon -- jos virheitä on, palataan takaisin lomakkeeseen. Tällöin Spring tuo lomakkeelle käyttöön sekä validointivirheet BindingResult-oliosta, että aiemmin lomakkeeseen syötetyt arvot @ModelAttribute-annotaatiolla merkitystä oliosta. Huomaa että virheet ovat pyyntökohtaisia, ja esimerkiksi kutsu "redirect:person" kadottaisi virheet.

Huom! Springin lomakkeita käytettäessä lomakesivut haluavat käyttöönsä olion, johon data kytketään jo sivua ladattaessa. Yllä lisäsimme pyyntöön Person-olion seuraavasti:

    @RequestMapping(value = "person", method = RequestMethod.GET)
    public String view(@ModelAttribute("person") Person person) {
        return "form";
    }

Lomakkeista löytyy lisää tietoa Springin näkymäteknologioihin liittyvän dokumentaation osassa: http://static.springsource.org/spring/docs/current/spring-framework-reference/html/view.html. Jotta annotaatioilla avulla tapahtuva validointi toimisi, tulee Springin konfiguraatiossa olla rivi <mvc:annotation-driven />.

Going for a better Ball

Edellisessä tehtävässä parametrien validointi tehtiin käsin osana kontrolleriluokan toimintaa. Käytetään tällä kertaa ModelAttributea, Spring form-elementtiä ja annotaatioilla tapahtuvaa validointia.

Jotta validoinnin saa päälle, sinun tulee lisätä seuraava rivi front-controller-servlet.xml-tiedostoon. Tiedostoon on valmiiksi määritelty mvc:-nimiavaruus.

<mvc:annotation-driven />

Spring Form-lomake on toteutettu valmiiksi tehtäväpohjan mukana tuleviin JSP-sivuihin. Tehtävänäsi on toteuttaa validointitoiminnallisuus pakkauksessa wad.registration.domain olevaan luokkaan Registration.

  1. Nimen (name) tulee olla vähintään 4 merkkiä pitkä ja enintään 30 merkkiä pitkä.
  2. Osoitteen (address) tulee olla vähintään 4 merkkiä pitkä ja enintään 50 merkkiä pitkä.
  3. Sähköpostiosoitteen (email) tulee olla validi sähköpostiosoite.

Jos yksikään ylläolevista tarkastuksista epäonnistuu, tulee käyttäjälle näyttää rekisteröitymislomake uudelleen. Muista lisätä kontrolleriin validoitavalle parametrille annotaatio @Valid. Virheviestien ei tule näkyä vastauksessa jos lomakkeessa ei ole virhettä. Käyttöliittymä on tehtävässä valmiina.

Validointi ja entiteetit

Vaikka esimerkissämme käyttämäämme Person-luokkaa ei oltu merkitty @Entity-annotaatiolla -- eli se ei ollut tallennettavissa JPAn avulla tietokantaan -- mikään ei estä meitä lisäämästä sille @Entity-annotaatiota. Toisaalta, lomakkeet voivat usein sisältää tietoa, joka liittyy useaan eri talletettavaan olioon. Tällöin onkin fiksua luoda erillinen lomakkeen validointiin tarkoitettu lomakeolio, Form Object, jonka pohjalta luodaan tietokantaan tallennettavat oliot kunhan validointi onnistuu. Erilliseen lomakeobjektiin voi täyttää myös kannasta haettavia listoja ym. ennalta.

Model ja Redirect

Kun käyttäjä ohjataan tekemään uusi pyyntö redirect:-komennon avulla, selaimelle käytännössä palautetaan HTTP statuskoodi 303 (tai 302), sekä uusi osoite. Tällöin selain tekee pyynnön uuteen osoitteeseen. Koska HTTP on tilaton protokolla, ei sillä ole välineistöä käyttäjän pyyntöjen yhdistämiseen: aiemmassa pyynnössä käytössä oleva data ei ole käytössä seuraavassa pyynnössä.

Tämä on yleensä täysin hyväksyttävää, ja toivottavaakin, mutta joissain tapauksissa ohjelmoija haluaa aiemmasta pyynnöstä tietoja myös seuraavaan pyyntöön. Tyypillinen käyttötapaus on tietyn informaatioviestin lisääminen sivulle, johon käyttäjä ohjataan. HTTP-protokollan tarjoamat evästeet mahdollistavat tiedon katoamisen kiertämisen: jos kaivattu tieto tallennetaan sessioon, on se olemassa myös seuraavalla pyynnöllä. Vastaava toiminnallisuus on myös useassa sovelluskehyksessä valmiina.

Spring tarjoaa pyynnöissä käytettävän parametrin RedirectAttributes, johon voi tallentaa attribuutteja, jotka halutaan näkyville seuraavalla pyynnöllä. Käytännössä RedirectAttributes-olio tallentaa attribuutit sessioon, josta ne lisätään Model-olion attribuutteihin seuraavan pyynnön yhteydessä.

Jotta RedirectAttributes toimii, on Spring-konfiguraatiossa oltava merkkijono <mvc:annotation-driven />.

Pohditaan tilannetta, jossa luomme uutta Person-oliota. Kun henkilö on luotu, käyttäjä ohjataan sivulle, jossa on henkilön tiedot. Kun käyttäjä tulee sivulle ensimmäisen kerran, sivulla näytetään myös viesti "New person created!". Lisätään Person-luokalle attribuutti id, jota käytetään henkilön tunnistamiseen.

// pakkaus

import org.hibernate.validator.constraints.Email;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank;

public class Person {

    private Long id;

    @NotBlank
    @Length(min = 11, max = 11)
    private String socialSecurityNumber;

    @NotBlank
    @Length(min = 5, max = 30)
    private String name;

    @NotBlank
    @Email
    private String email;

    // getterit ja setterit

Käytössämme on rajapinta PersonService, joka tarjoaa seuraavat metodit:

public interface PersonService {
    Person create(Person registration);
    Person read(Long id);
}

Rajapinnan toteutus injektoidaan valmiiksi kontrolleriin -- kontrollerin ei oikeastaan tarvitse tietää toteutuksesta sen enempää: palvelutaso on abstrahoitu kontrollerilta. Muokataan aiemmin käytössämme ollutta PersonController-luokan create metodia siten, että se saa parametrina RedirectAttributes-olion. Parametrina saatu Person-olio annetaan PersonService-palvelun metodille create, joka muunmuassa asettaa oliolle yksilöivän tunnuksen ja (mahdollisesti) tallentaa sen esimerkiksi tietokantaan. Tämän jälkeen asetamme RedirectAttributes-oliolle kaksi attribuuttia.

Normaali attribuutti, joka lisätään metodilla addAttribute on käytössä tämän pyynnön loppuun asti. Alla olevassa esimerkissä lisäämme attribuutin id, jonka Spring myöhemmin asettaa osaksi osoitetta, johon käyttäjä ohjataan palautettavaan merkkijonooon. Tällöin pyyntö ohjautuu juuri luotua Person-oliota käsittelevälle sivulle. Toinen attribuutti, joka lisätään metodilla addFlashAttribute, on käytössä vain seuraavan pyynnön ajan. Attribuutti message asetetaan automaattisesti seuraavan pyynnön attribuutteihin.

// pakkaus ja importit

@Controller
public class PersonController {

    @Autowired
    private PersonService personService;

    @RequestMapping(value = "person", method = RequestMethod.POST)
    public String create(RedirectAttributes redirectAttributes,
            @Valid @ModelAttribute(value="person") Person person,
            BindingResult bindingResult) {
        if(bindingResult.hasErrors()) {
            return "form";
        }
        
        person = personService.create(person);
        
        redirectAttributes.addAttribute("id", person.getId());
        redirectAttributes.addFlashAttribute("message", "New person created!");
        return "redirect:person/{id}";
    }

// muut metodit

Nyt jos pyyntöä osoitteeseen person/{id} kuuntelee oma kontrollerimetodi, on sillä käytössä attribuutti message ensimmäisellä kerralla kun käyttäjä päätyy sivulle. Kontrollerimetodi, joka kuuntelee osoitetta person/{id} voi olla esimerkiksi seuraavanlainen.

    // ...
    @RequestMapping(value = "person/{personId}", method = RequestMethod.GET)
    public String viewPerson(Model model, @PathVariable Long personId) {
        Person person = personService.read(personId);
        model.addAttribute("person", person);
        
        return "person";
    }

    // ...

Itse sivu person.jsp voi näyttää esimerkiksi seuraavanlaiselta (huomaa mielikuvituksen puute):

<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
        <title>${person.name}</title>
    </head>
    <body>
        <h1>${person.name} ${message}</h1>

        <p>Email-address ${person.email}</p>
    </body>
</html>

Huomionarvoista tässä on se, että kun käyttäjä luo uuden Person olion, hänet ohjataan sivulle, joka näyttää käyttäjän tiedot. Sivun voi tallentaa kirjanmerkiksi, sillä sivu on aina olemassa. Kuitenkin ensimmäisellä kerralla käyttäjän tullessa sivulle, sivulla näkyy myös käyttäjäystävällinen viesti "New person created!".

Post/Redirect/Get

POST/Redirect/GET on yleinen web-suunnittelumalli, jonka avulla voidaan vältää osa toisteisista lomakkeiden lähetyksistä sekä helpottaa kirjanmerkkien käyttöä. Käytännössä ajatuksena on pyytää käyttäjä tekemään GET-pyyntö onnistuneen POST-pyynnön jälkeen. GET-pyynnössä palautetaan sivu, jossa näytetään muuttuneet tiedot, kun taas POST-pyynnöllä tehtiin pyyntö tiedon muuttamiseen.

Parannellaan tässä tehtävässä erään tilauspalvelun toimintaa.

Validointi

Tällä hetkellä käyttäjän syöttämiä tietoja ei validoida millään tavalla. Lisää sovellukseen tilauksen (Order) validointi seuraavasti:

Jos joku edelläolevista ehdoista ei täyty, näytä käyttäjälle lomake siten, että siinä näkyy virheviestit ja jo syötetyt tiedot. Käyttöliittymä on rakennettu puolestasi, joten sinun tarvitsee muokata vain luokkia OrderController ja Order. Huom! Kannattanee käyttää @NotEmpty-annotaatiota esineiden tyhjyyden tarkistamiseen.

Post, Redirect, Get

Muuta sovellusta siten, että lomakkeen lähetyksen onnistuessa käyttäjä ohjataan erilliseen osoitteeseen, jossa näkyy hänen juuri tekemänsä ostos. Käyttäjän tulee pystyä asettamaan osoite kirjanmerkiksi, eli sen tulee olla pysyvä. Kun käyttäjä ohjautuu sivulle ensimmäistä kertaa, sivulla tulee näkyä viesti Order placed!. Toteuta uudelleenohjaus käyttämällä RedirectAttributes luokkaa osana toteutusta: aseta attribuutiksi orderId luodun tilauksen id, sekä lisää pyyntöön flash-attribuutti message, joka sisältää viestin Order placed!. Joudut myös muokkaamaan metodin palauttamaa merkkijonoa sopivasti.

REST

REST (representational state transfer) on HTTP-protokollaan perustuva arkkitehtuurimalli erityisesti web-pohjaisten sovellusten toteuttamiseen. Taustaidea on periaatteessa yksinkertainen: osoitteilla määritellään haettavat ja muokattavat resurssit, pyyntömetodit kuvaavat resurssiin kohdistuvaa operaatiota, ja pyynnön rungossa on tarvittaessa resurssiin liittyvää dataa. HTTP:n GET- ja POST-komentojen lisäksi REST-sovellukset käyttävät ainakin PUT ja DELETE-pyyntöjä. Esimerkiksi yksinkertainen henkilöstörekisteri voitaisiin toteuttaa seuraavilla osoitteilla ja pyyntötavoilla.

Oleellisia asioita RESTissä ovat resurssien nimentä web-osoitteita käyttäen sekä HTTP-protokollan tarjoamien pyyntötyyppien käyttö. Osoitteissa käytetään substantiivejä -- ei getPerson?id={tunnus} vaan /henkilo/{tunnus}, ja pyynnöt kategorisoidaan pyyntötyyppien mukaan. DELETE-tyyppisessä pyynnössä poistetaan, POST-tyyppisellä pyynnöllä lisätään, PUT-tyyppisellä pyynnöllä joko lisätään tai päivitetään, ja GET-tyyppisellä pyynnöllä haetaan. Kuten normaalissakin HTTP-kommunikaatiossa, GET-pyyntöjen ei tule muuttaa dataa.

REST on hyödyllinen arkkitehtuurimalli mm. olemassaolevien jo hieman ikääntyneiden palveluiden kapselointiin. Sovelluskehittäjä voi kehittää uuden käyttöliittymän, ja käyttää vanhaan sovellukseen liittyvää toiminnallisuutta REST-rajapinnan kautta. REST-arkkitehtuuriin perustuvat palvelut ovat nykyään hyvin yleisiä ja niiden luomiseen on tehty huomattava määrä apuohjelmia. Yksinkertaisen REST-palvelun voi luoda esimerkiksi suoraan olemassaolevasta NetBeansin Web Services -osiossa olevien toimintojen avulla.

Tutustu Roy T. Fieldingin ja Richard N. Taylorin artikkeliin "Principled Design of the Modern Web Architecture", jossa REST-arkkitehtuurityyli määritellään.

Vaikka yllä määrittelemme REST-apin HTTP-apin kautta, on Roy Fielding (nykyään?) sitä mieltä, että REST-apissa on oleellista mahdollisuus resurssien välillä navigointiin. Datan tulee olla hypertekstimäistä, ja osoitteiden palauttaman datan tulee sisältää linkit toisiin resursseihin.

Olemme hieman kerettiläisiä, ja määrittelemme REST-apin toisin kuin Fielding toivoo: jos noudattaisimme hypertekstimäistä navigointia emme voisi käyttää PUT- ja DELETE-metodeja.

Luodaan seuraavaksi oma REST-arkkitehtuuria seuraava kontrolleri aiemmin nähtyjen Person-olioiden luontiin ja muokkaamiseen.

REST-tyyppinen arkkitehtuuri Person-olioiden tallentamiseen ja muokkaamiseen

Alla on esimerkki REST-tyylisen rajapinnan avulla luodusta olutpalvelusta. Olutpalvelussa käyttäjä voi lisätä, muokata ja poistaa oluita. Oletetaan, että käytössämme on palvelu PersonService, joka tarjoaa meille seuraavanlaisen rajapinnan:

public interface PersonService {
    Person create(Person person);
    Person read(Long identifier);
    Person update(Long identifier, Person person);
    void delete(Long identifier);

    List<Person> list();
}

Rajapinta tarjoaa CRUD-toiminnallisuuden, sekä metodin kaikkien Person-olioiden listaamiseen. Toteutetaan seuraavaksi kontrolleri, joka kuuntelee pyyntöjä seuraaviin osoitteisiin:

// pakkaus ja importit
@Controller
public class PersonController {

    @Autowired
    private PersonService personService;

    @RequestMapping(method = RequestMethod.POST, value = "person")
    public String create(RedirectAttributes redirectAttributes, 
                         @Valid @ModelAttribute Person person,
                         BindingResult bindingResult) {
        if(bindingResult.hasErrors()) {
            return "form"; // käytössä form.jsp, jossa lomake henkilön luomiseen
        }
 
        person = personService.create(person);
        
        redirectAttributes.addAttribute("personId", person.getId());
        redirectAttributes.addFlashAttribute("message", "Created!");

        return "redirect:person/{personId}";
    }

    @RequestMapping(method = RequestMethod.GET, value = "person/{personId}")
    public String read(Model model, @PathVariable Long personId) {
        model.addAttribute("person", personService.read(personId));

        return "view"; // käytössä view.jsp -niminen jsp-sivu
    }

    @RequestMapping(method = RequestMethod.PUT, value = "person/{personId}")
    public String update(RedirectAttributes redirectAttributes,
                         @ModelAttribute Person person, @PathVariable Long personId) {
        person = personService.update(personId, person);

        redirectAttributes.addAttribute("personId", person.getId());
        redirectAttributes.addFlashAttribute("message", "Updated!");

        return "redirect:person/{personId}";
    }

    @RequestMapping(method = RequestMethod.DELETE, value = "person/{personId}")
    public String delete(@PathVariable Long personId) {
        personService.delete(personId);

        return "redirect:/person";
    }

    @RequestMapping(method = RequestMethod.GET, value = "person")
    public String list(Model model) {
        model.addAttribute("list", personService.list());

        return "list"; // käytössä list.jsp -niminen jsp-sivu
    }
}

Kuten huomaat, REST-toteutuksemme käyttää aiemmin tutuksi tulleita kommunikointimenetelmiä. Osoitteet identifioivat resurssin, pyyntötavat halutun resurssin. Ylläolevan esimerkin ohjaustyyleistä kannattaa ottaa mallia myös omiin sovelluksiin. Kontrolleriluokan metodit -- kuten kontrollerit kokonaisuudessaan -- tulee pitää mahdollisimman pieninä. Varsinainen sovelluslogiikka hoidetaan erillisessä palvelussa.

PUT ja DELETE HTML-lomakkeissa

HTML-lomakkeet tukevat vain pyyntötyyppejä GET ja POST. Haluamme pystyä käyttämään REST-tyyppisiä pyyntöjä, eli GET- ja POST-pyyntöjen lisäksi myös PUT- ja DELETE-komentoja. Käytännössä pyyntötyyppien muokkaus toteutetaan sovelluskehysten toimesta siten, että lomakkeisiin asetetaan erillinen attribuutti, joka määrittelee pyynnön tyypin. Sovelluskehys muokkaa pyyntöä attribuutin perusteella ennen sen päätymistä kontrollerille siten, että pyyntö käyttää haluttua pyyntötyyppiä.

Springissä pyyntötyypin muuttaminen onnistuu Springin form-tägillä ja erillisellä filtterillä. Filtteri tulee käsittelemään front-controller-servletille ohjautuvia pyyntöjä. Tämä onnistuu lisäämällä seuraava konfiguraatio web.xml-tiedostoon.

    <filter>
        <filter-name>http-method-filter</filter-name>
        <filter-class>org.springframework.web.filter.HiddenHttpMethodFilter</filter-class>
    </filter>

    <filter-mapping>
        <filter-name>http-method-filter</filter-name>
        <servlet-name>front-controller</servlet-name>
    </filter-mapping>

Kun filtteri on konfiguroitu, voidaan Springin form-tägillä määriteltyjä lomakkeita muokata siten, että niissä käytetään PUT- tai DELETE-pyyntöjä. Normaalit GET- ja POST-pyynnöt toimivat kuten ennenkin. Esimerkiksi seuraavaa lomaketta voisi käyttää henkilön poistamiseen.

        <form:form action="${pageContext.request.contextPath}/person/${personId}" method="DELETE">
            <input type="submit">
        </form:form>

Sovellusta käytettäessä Spring luo lomakkeelle piilokentän, jonka nimenä on _method ja arvona DELETE.

        <form id="command" action="/validatortesting/app/person/1" method="post">
            <input type="hidden" name="_method" value="DELETE"/>
            <input type="submit"/>
        </form>

Huom! Kun luot lomakkeille kontrolleria, varmista että palautusarvot ovat kunnossa. Esimerkiksi DELETE-tyyppisen kutsun käsittelyn jälkeen käyttäjä tulee ohjata tekemään uusi GET-pyyntö.

Taking a REST

Tässä tehtävässä toteutetaan Unipalvelu, johon käyttäjä voi tallentaa tietoja nukkumisestaan. Tehtävässä halutaan harjoitella erityisesti REST-tyylisiä osoitteita ja pyyntöjä. Emme kuitenkaan vielä toteuta varsinaista rajapintaa vaan harjoittelemme osoitteita ja pyyntöjä tavallisilla HTML-näkymillä. Tehtävään on jo valmiiksi konfiguroitu web.xml tiedostoon HiddenHttpMethodFilter-filtteri, joka mahdollistaa DELETE-pyynnön.

Domain-luokka

Muokkaa pakkauksessa wad.rest.domain oleva luokka Sleep niin, että se toteuttaa alla olevan rungon. Tee luokasta entiteetti ja määritä sille tietokantataulun nimi. Muista myös luoda getterit ja setterit attribuuteille.

import org.springframework.format.annotation.DateTimeFormat;

public class Sleep {

    private Long id;
    
    @DateTimeFormat(pattern = "d.M.y H.m")
    private Date start;
    
    @DateTimeFormat(pattern = "d.M.y H.m")
    private Date end;
    
    private String feeling;
}

@DateTimeFormat annotaatiolla Spring muuntaa patternin d.M.y H.m muotoisen merkkijonoesityksen päivämäärästä ja ajasta (kuten 23.9.2012 18.41) Date-olioksi. Voimme siis syöttää lomakkeessa merkkijonon, joka muutetaan suoraan Dateksi. Kätevää! Jotta @DateTimeFormat toimii, on pom.xmlään lisätty jo valmiiksi riippuvuus pakettiin joda-time.

Attribuuteista:

Muista myös nimetä jokaisen attribuutin tietokantataulun sarakkeiden nimet!

Repository-rajapinta

Tee entiteetille repository-rajapinta SleepRepository pakkaukseen wad.rest.repository. Rajapinnan tulee tuttuun tapaan periä Spring Data JPA:n rajapintaluokka CrudRepository siten, että tallennettava olio on tyyppiä Sleep ja avaimena on Long.

Palvelu

Haluamme nyt noudattaa kerrosarkkitehtuuria. Toteuta siis pakkaukseen wad.rest.service palveluluokka JpaSleepService, joka toteuttaa samassa pakkauksessa olevan rajapinnan SleepService. Toteuta rajapinnan määrittelemät metodit injektoimalla luokkaan @Autowired-annotaatiota käyttäen SleepRepositoryn toteuttava luokka. Injektoitavasta luokasta löytyy tarvittavat metodit JpaSleepServicen toteuttamiseen.

Muista määrittää luokan JpaSleepServicen metodeille @Transactional-annotaatiot. Määritä annotaatiolle myös boolean-tyyppiset parametrit readOnly sen mukaan muuttaako metodi tietokannassa olevaa tietoa vai ei.

Kontrollerit ja näkymät

Tehtävässä on valmiina jo yksi kontrolleri BaseController. Tämän sisältämä metodi getIndex kaappaa juuren (joka on edelleen /app/) osoitteen ja palauttaa näkymän index. Modeliin myös lisätään tyhjä Sleep-olio näkymään lisättävää lomaketta varten.

Muokkaa näkymää index.jsp. Lisää sinne Spring-lomake Sleep luokkaa varten. Lomakkeen commandName tulee olla sleep, action ohjaa polkuun /app/sleeps ja method on POST.

Lomakkeessa tulee olla Spring-lomakkeen tarjoamat tekstikentät poluille start, end ja feeling. Määrittele kentille myös id:t (start, end ja feeling vastaavasti). Lisää myös mahdolliset virhetulostukset kyseisille poluille.

Toteuta pakkaukseen wad.rest.controller kontrolleriluokka RESTSleepController. Luokan tulee toteuttaa samassa pakkauksessa sijaitseva rajapinta SleepController. Injektoi luokkaan @Autowired-annotaatiota käyttäen SleepServicenn toteuttava luokka. Luokan metodien tulee toimia seuraavanlaisesti:

Muokkaa vielä näkymää sleep.jsp lisäämälle sinne Spring-lomake Sleep entiteetin poistoa varten. Lomakkeen tulee tehdä DELETE-pyyntö osoitteeseen /app/sleeps/{id}. Lomakkeessa ei tarvitse olla muuta kuin input submittausta varten.

Testaa nyt ohjelma vielä kokonaisuudessaan ja kun testit menevät läpi, lähetä sovellus TMC:lle. You better get some REST now!

JSON-muotoista dataa käyttävä REST-sovellus

REST-tyyppisten palveluiden ei aina kannata palauttaa kokonaista HTML-sivua: REST-rajapintaa käytetäänkin usein ilman erillistä HTML-sivua. Tarvitsemme kuitenkin jonkun tavan tiedon esittämiseen ja siirtämiseen. Yleisimmät tiedonsiirtomuodot tällä hetkellä ovat JSON ja XML, joista ensimmäisen suosio kasvaa jatkuvasti. Olemme tällä kurssilla lähinnä kiinnostuneita JSON-muotoisesta datasta.

JSON

JSON (JavaScript Object Notation) on Javascriptin käyttämä tiedonsiirtoformaatti. JSON-formaatin yksi hienous on se, että JSON-objekteja voi lukea suoraan Javascript-olioiksi. Tämä tulee tutummaksi kurssilla Web-selainohjelmointi. JSON mahdollistaa avain-arvo -parien ja listojen esittämisen. Oletetaan, että käytössämme on seuraava olutta kuvaava luokka Beer.

public class Beer {
    private Long id;
    private String name;

    public Long getId() {
        return id;
    }

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

    public String getName() {
        return name;
    }

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

Oluella on numeerinen tunnus ja nimi. Yksittäinen olut, jonka nimi on "Hacker-Pschorr Hefe Weisse" ja tunnus on 10, esitetään JSON-notaatiolla seuraavasti.

{"name":"Hacker-Pschorr Hefe Weisse","id":10}

Listarakenteessa, jossa oluita on enemmän, avain-arvo -parit esitetään pilkulla erotettuna hakasulkeiden ([]) sisällä.

[{"name":"Hacker-Pschorr Hefe Weisse","id":10},
 {"name":"Buttface Amber Ale","id":11},
 {"name":"Yellow Snow","id":12}]

JSON hyväksyy arvoiksi merkkijonoja, numeroita, toisia avain-arvo -pareja, listoja, totuusarvoja ja tyhjiä null -elementtejä.

Kuten suurin osa sovelluskehyksistä, Spring tarjoaa palvelun olioiden JSON-muotoon muuttamiseksi. Jotta olioiden muuttaminen JSON-muotoon toimisi, tulee Spring-konfiguraatiossamme olla komento <mvc:annotation-driven /> ja pom.xml-tiedostossa riippuvuus Jackson JSON-kirjastoon.

        <dependency>
            <groupId>org.codehaus.jackson</groupId>
            <artifactId>jackson-mapper-asl</artifactId>
            <version>1.9.9</version>
        </dependency>

Spring käyttää Jacksonia JSON-datan tuottamiseen. Käytännössä Jackson käy läpi luokan attribuutit, ja luo niiden arvojen pohjalta JSON-dataa. Esimerkiksi Beer-olion voi tulostaa JSON-muodossa Jacksonin avulla seuraavasti:

// pakkaus

import java.io.IOException;
import java.io.StringWriter;
import org.codehaus.jackson.map.ObjectMapper;

public class BeerJackson {

    public static void main(String[] args) throws IOException {
        // Olio, joka muuttaa oliot JSON-dataksi
        ObjectMapper mapper = new ObjectMapper();

        // luodaan olut
        Beer beer = new Beer();
        beer.setId(1L);
        beer.setName("Buttface Amber Ale");

        // luodaan olio, johon JSON-muotoinen data tallennetaan
        StringWriter stringWriter = new StringWriter();

        // luodaan JSON-muotoinen esitys beer-oliosta
        // ja ohjataan se stringWriter-olioon
        mapper.writeValue(stringWriter, beer);
        
        // tulostetaan JSON-muotoinen esitys
        System.out.println(stringWriter);
    }
}

Ylläolevan ohjelman tulostus on seuraavanlainen:

{"id":1,"name":"Buttface Amber Ale"}

Jotta kontrollerimetodit osaavat käsitellä JSON-dataa, tulee @RequestMapping-metodeille määritellä erilliset produces- ja consumes-attribuutit, joilla kerrotaan minkälaista dataa kyseinen osoite tuottaa ja ottaa vastaan. Luodaan ensimmäinen metodi, list, jonka tehtävänä on tuottaa JSON-muotoinen lista kaikista palvelussa olevista oluista. Koska metodi tuottaa tietoa, se tarvitsee produces-attribuutin. Tuotetun tiedon muoto on application/json. Tämän lisäksi määrittelemme metodille annotaation @ResponseBody, jonka avulla sanomme, että metodin palauttama arvo asetetaan vastauksen runkoon.

    // ...
    @RequestMapping(method = RequestMethod.GET, value = "beer", produces="application/json")
    @ResponseBody
    public List<Beer> list() {
        return beerService.list();
    }
    // ...

Jos beerService.list()-metodi palauttaa listan oluista, luodaan niistä JSON-muotoinen lista, joka palautetaan käyttäjälle. Alla on esimerkki metodin palautuksesta, kun selaimella tehdään kysely palveluun, jossa on aiemmin nähdyt kolme olutta.

[{"id":3,"name":"Yellow Snow"},
{"id":2,"name":"Buttface Amber Ale"},
{"id":1,"name":"Hacker-Pschorr Hefe Weisse"}]

Voimme määritellä kontrollerimetodin vastaanottamaan JSON-muotoista dataa asettamalla siihen liittyvään @RequestMapping-annotaatioon consumes-attribuutin. Attribuutille consumes asetetaan arvo "application/json", jolla ilmoitamme että metodi vastaanottaa JSON-muotoista dataa. Tämän lisäksi metodin vastaanottama data tulee muuntaa JSON-muotoon. Voimme tehdä tämän annotoimalla metodin parametri Beer @RequestBody-annotaatiolla.

    // ...
    @RequestMapping(method = RequestMethod.POST, value = "beer", consumes = "application/json")
    public String create(RedirectAttributes redirectAttributes, @RequestBody Beer beer) {
        beer = beerService.create(beer);

        redirectAttributes.addAttribute("beerId", beer.getId());

        return "redirect:beer/{beerId}";
    }
    // ...

Ylläoleva metodi vastaanottaa POST-tyyppisiä pyyntöjä, joiden sisältö on JSON-muotoista dataa. JSON-muodossa olevasta datasta luodaan Beer-olio, joka tallennetaan beerService-olion toimesta. Tallennuksen jälkeen pyyntö ohjataan uudelle sivulle.

JSON-tyyppistä dataa kuuntelevien metodien toiminnallisuutta voi kätevästi testata curl-komennon avulla. Alla olevassa esimerkissä palvelimelle lähetetään POST-tyyppinen pyyntö, joka sisältää JSON-tyyppistä dataa. Pyyntö tehdään osoitteeseen http://palvelin-ja-sovellus/beer.

Kenoviivan käyttö mahdollistaa komennon kirjoittamisen useammalle riville.

curl -X POST -H "Content-Type: application/json; charset=utf-8" \
-d "{\"name\":\"Blithering Idiot\"}" \
http://palvelin-ja-sovellus/beer

Loput BeerController-luokan metodit onnistuu ylläolevien metodien avulla. Alla on esimerkkitoteutus BeerController-luokasta, jossa on mukana myös init-metodi, joka @PostConstruct-annotaation ansiosta suoritetaan kontrolleria ensimmäistä kertaa ladattaessa.

// pakkaus ja importit

@Controller
public class BeerController {

    @Autowired
    private BeerService beerService;
    
    @PostConstruct
    private void init() {
        Beer beer = new Beer();
        beer.setId(1L);
        beer.setName("Hacker-Pschorr Hefe Weisse");
        beerService.create(beer);
        
        beer = new Beer();
        beer.setId(2L);
        beer.setName("Buttface Amber Ale");
        beerService.create(beer);
     
        beer = new Beer();
        beer.setId(3L);
        beer.setName("Yellow Snow");
        beerService.create(beer);
        
    }

    @RequestMapping(method = RequestMethod.POST, value = "beer", consumes = "application/json")
    public String create(RedirectAttributes redirectAttributes, @RequestBody Beer beer) {
        beer = beerService.create(beer);
        redirectAttributes.addAttribute("beerId", beer.getId());
        return "redirect:beer/{beerId}";
    }

    @RequestMapping(method = RequestMethod.GET, value = "beer/{beerId}", produces="application/json")
    @ResponseBody
    public Beer read(@PathVariable Long beerId) {
        return beerService.read(beerId);
    }

    @RequestMapping(method = RequestMethod.PUT, value = "beer/{beerId}", consumes="application/json")
    public String update(RedirectAttributes redirectAttributes, @RequestBody Beer beer, 
                         @PathVariable Long beerId) {
        beer = beerService.update(beerId, beer);
        redirectAttributes.addAttribute("beerId", beer.getId());
        return "redirect:beer/{beerId}";
    }

    @RequestMapping(method = RequestMethod.DELETE, value = "beer/{beerId}")
    public String delete(@PathVariable Long beerId) {
        beerService.delete(beerId);
        return "redirect:/beer";
    }

    @RequestMapping(method = RequestMethod.GET, value = "beer", produces="application/json")
    @ResponseBody
    public List<Beer> list() {
        return beerService.list();
    }
}

RESTgull Service

Tietojenkäsittelytieteen laitos on erikoistunut muiden erikoisalueidensa lisäksi lokkien bongaamiseen. Erästä TKTL:llä yhä kehitettävää järjestelmää käytetään lintubongausten hallinnointiin mm. museoviraston toimesta. Tässä tehtävässä toteutetaan REST-tyyppinen JSON-dataa käsittelevä rajapinta lokkibongausten käsittelyyn.

Sovelluksessa on toteutettuna kaikki muut luokat paitsi luokka GullSightingController. Toteuta luokkaan seuraava toiminnallisuus:

Voit testata toteuttamasi palvelun toimimintaa curl-komentorivityökalun avulla. Tämän tehtävän testit ovat poikkeuksellisesti vain TMC-palvelimella, etkä saa testeiltä palautetta paikallisesti.

SOA

Jeff Bezos @ Amazon: Anyone who doesn't do this will be fired.

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 pyritään luomaan tilanne, jossa palveluita voidaan käyttää myös tulevaisuudessa kehitettävien sovellusten toimesta. Avoimet rajapinnat helpottavat huomattavasti palveluihin integroitumista.

SOA-palveluita käyttävät esimerkiksi toiset palvelut tai selainohjelmistot. Esimerkiksi web-sivustot voivat hakea JSON-muotoista dataa Javascriptin avulla ilman tarvetta omalle palvelinkomponentille. SOA-arkkitehtuuri helpottaa myös ikääntyvien sovellusten jatkokäyttöä: ikääntyvät sovellukset voidaan kapseloida esimerkiksi REST-rajapinnan taakse, jonka kautta sovelluksen käyttö onnistuu myös jatkossa.

Rakentamalla palvelut avoimia rajapintoja käyttäen, palveluiden käyttäjän ei tarvitse välittää palvelun toteutuksessa käytetystä ohjelmointikielestä. Niin pitkään kun palvelun tuottama data seuraa avointa standardia, esimerkiksi JSON-dataa tuottavaa REST-tyyliä, ei palveluiden taustalla olevilla ohjelmointikielillä ole väliä. Oleellista kuitenkin on, että kun palvelua kehitetään, sitä käyttävien sovellusten toiminnan ei tule rikkoutua.

Oikeastaan, olemme jo toteuttaneet SOA-arkkitehtuuriin perustuvia sovelluksia. Esimerkiksi aiemmin toteutettu RESTGull Service toimii palveluna, jota muut voivat käyttää.

Pastebin

Tässä tehtävässä toteutetaan Pastebin-palvelua (jopa nimeltään) muistuttava sovellus. Sovellusta käytetään siis lyhyiden teksti- tai koodipätkien (snippet) tallettamiseen ja tarkasteluun.

Tehtäväpohjan mukana tulee valmiina palvelun käyttöliittymä, jossa on seuraavat toiminnot:

Lisäksi käyttöliittymässä on automaattinen syntax highlight lukuisille eri ohjelmointikielille.

Jokaiseen sovelluksen tallettamaan snippetiin liittyy käyttäjän nimi, jonka perusteella tietyllä nimellä talletettuja snippetejä voi listata.

Domain-luokat

Luo pakkaukseen wad.pastebin.data luokka JpaUser, joka toteuttaa samassa pakkauksessa olevan rajapinnan User. Tee luokasta entiteetti ja määritä sille tietokantataulun nimi User.

Entiteetillä JpaUser tulee olla rajapinnan määrittämät attribuutit:

Luo pakkaukseen wad.pastebin.data luokka JpaSnippet, joka toteuttaa samassa pakkauksessa olevan rajapinnan Snippet. Tee luokasta entiteetti ja määritä sille tietokantataulun nimi Snippet.

Entiteetillä JpaSnippet tulee olla rajapinnan määrittämät attribuutit:

Repository-rajapinnat ja omien kyselyjen toteuttaminen

Tee kummallekin entiteetille oma repository-rajapinta käyttäen Spring Data JPA:n rajapintaa org.springframework.data.jpa.repository.JpaRepository, joka toimii samalla tavoin kuin aiemmin käytetty CrudRepository, mutta siinä on enemmän toimintoja mm. tulosten järjestämiseen liittyen. Rajapintojen nimet tulee olla: wad.pastebin.repository.JpaUserRepository ja wad.pastebin.repository.JpaSnippetRepository.

Jotta sovelluksessa voitaisiin etsiä käyttäjiä nimimerkin perusteella tai listata tietyn käyttäjän snippetit, täytyy sitä varten luoda mukautettuja kyselyitä. Onneksi Spring Data JPA tekee uusien kyselyjen määrittelemisestä helppoa, sillä kyselyn toteuttamiseen täytyy ainoastaan lisätä sopivan niminen metodi repository-rajapintaan.

Metodien nimeäminen tapahtuu seuraavien sääntöjen mukaisesti:

Sovelluksessa tarvitaan seuraavat kyselyt:

Palvelukerros

Luo palveluluokka wad.pastebin.service.JpaUserService, joka toteuttaa samassa pakkauksessa olevan rajapinnan UserService. Palveluluokassa tulee määritellä transaktio jokaiselle metodille erikseen ja luokan tulee käyttää JpaUserRepository-rajapintaa tietokantaoperaatioihin. Palveluluokan metodien tulee toimia CRUD-metodien osalta normaalisti (kuten aiemmissa tehtävissä) ja muiden metodien osalta seuraavasti:

Luo palveluluokka wad.pastebin.service.JpaSnippetService, joka toteuttaa rajapinnan SnippetService. Toteuta luokka edellisen palveluluokan ohjeiden mukaisesti seuraavin poikkeuksin:

Tulosten järjestäminen

Tavallisesti tietokannasta haetut tulokset eivät ole missään ennalta määritellyssä järjestyksessä ja järjestys saattaa jopa vaihdella eri kyselyiden välillä. Sovelluksessa talletettavien snippetien kannalta kiinnostavimpia ovat usein uusimmat snippetit, joten haetut snippetit täytyy järjestää ajan mukaan. Spring Data JPA:n rajapinta JpaRepository mahdollistaa tulosten järjestämisen tietokantatasolla (jolloin se on tehokasta) metodin findAll(Sort sort) avulla. Esimerkiksi nimimerkin mukaan järjestäminen tapahtuisi näin:

jpaUserRepository.findAll(new Sort(Sort.Direction.ASC, "nickname"));

Luokkan Sort konstruktorin ensimmäinen parametri on järjestyksen suunta: Sort.Direction.ASC järjestää pienimmästä suurimpaan (esim. 1..9, A..Z) ja Sort.Direction.DESC suurimmasta pienimpään (9..1, Z..A), toinen on entiteetin attribuutin nimi.

Järjestä seuraavien palvelukerroksen metodikutsujen tulokset:

PastebinController

Huom! Tässä vaiheessa kannattaa alkaa testata ohjelmaa selaimella, jotta saat varmistettua, että kontrollerin metodit toimivat oikein.

Luo luokka wad.pastebin.controller.PastebinController, joka toteuttaa rajapinnan PastebinControllerInterface. Luokan metodien tulee toimia seuraavalla tavalla:

Tutustu artikkeliin The Biggest Thing Amazon Got Right: The Platform.

Uskotko että Amazonin ohjelmistokehittäjät olisivat lähteneet SOA-muutokseen ilman Jeff Bezosin viestin kohtaa nro 6?

Seuraako juuri toteuttamasi Pastebin-sovellus SOA-arkkitehtuurityyliä? Jos ei, mitä muutoksia siihen tulisi tehdä?

Asynkroniset palvelut

Tähän mennessä toteuttamissamme sovelluksissa pyynnön suorittaminen on tapahtunut seuraavasti:

  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
  4. Palvelu vastaanottaa pyynnön, suorittaa pyyntöön liittyvät operaatiot mahdollisesti muiden palveluiden kanssa, ja palauttaa vastauksen kontrollerille
  5. Kontrolleri ohjaa pyynnön sopivalle näkymälle, joka palautetaan käyttäjälle.

Tietokantaoperaation tai palvelukutsun valmistumisen odottaminen ei aina ole käyttäjäystävällistä. Jos sovelluksemme suorittaa esimerkiksi raskaampaa laskentaa tai on muista palveluista johtuen hidas, kannattaa pyyntö suorittaa asynkronisesti.

Asynkroniset metodikutsut tapahtuvat seuraavasti:

  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
  4. Palvelu asettaa pyynnön suoritusjonoon ja palvelukutsusta palataan heti (palvelun tyyppi on void)
  5. Kontrolleri ohjaa pyynnön sopivalle näkymälle, joka palautetaan käyttäjälle.

Käytännössä asynkroniset metodikutsut toteutetaan luomalla palvelukutsusta erillinen säie, jossa pyyntöä käsitellään. Tämäkin on toteutettu usean sovelluskehyksen puolesta automaattisesti. Asynkronisten metodikutsujen käyttöön saaminen vaatii taas pienen määrän konfiguraatiota. Lisää ensin Springin konfiguraatiotiedostoon nimiavaruusmäärittely spring-task-nimiavaruudelle.

...
    xmlns:task="http://www.springframework.org/schema/task"
    xsi:schemaLocation="
        ...
        http://www.springframework.org/schema/task
        http://www.springframework.org/schema/task/spring-task-3.1.xsd
        ...">

...

Kun nimiavaruus on konfiguroitu, lisätään konfiguraatiotiedostoon rivi, jolla sanomme että tehtävien hallinta hoidetaan annotaatioiden avulla.

    <task:annotation-driven />

Kun konfiguraatio on paikallaan, voimme toteuttaa palvelutason metodeja asynkronisesti. Jotta palvelutason metodi olisi asynkroninen, sen tulee olla void-tyyppinen, sekä sillä tulee olla annotaatio @Async.

Tutkitaan kahta erilaista tapausta, jossa tallennetaan Item-olioita. Item-olion sisäinen muoto ei ole niin tärkeä. Ensimmäisessä tapauksessa Item-olion tunnuksen generoinnin vastuu on tietokannalla. Kun tietokanta on generoinut tunnuksen ja tallentanut olion tietokantaan, palautetaan siihen viite.

    @RequestMapping(method = RequestMethod.POST, value = "item")
    public String create(RedirectAttributes redirectAttributes,
            @Valid @ModelAttribute Item item, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "form";
        }

        item = itemService.create(item);
        
        redirectAttributes.addAttribute("itemId", item.getId());
        return "redirect:item/{itemId}";
    }

Palvelutason metodi create palauttaa ylläolevassa tapauksessa viitteen luotavaan olioon. Tarkastellaan toisenlaista tapausta, jossa Item-oliolle generoidaan viite kontrollerimetodissa. Palvelutason metodi on void-tyyppinen, eikä tallennuksen tapahtumisen ajankohta ole niin oleellinen.

    @RequestMapping(method = RequestMethod.POST, value = "item")
    public String create(RedirectAttributes redirectAttributes,
            @Valid @ModelAttribute Item item, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "form";
        }

        item.setId(UUID.randomUUID().toString());
        itemService.create(item);
        
        redirectAttributes.addAttribute("itemId", item.getId());
        return "redirect:item/{itemId}";
    }

Kahden yllä esitetyn kontrollerimetodin ero on hyvin pieni, mutta vain toisessa palvelutason toteutus asynkronisesti on mahdollista.

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

    public void create(Item item) {
        itemRepository.save(item);
    }

Metodin muuttaminen asynkroniseksi vaatii @Async-annotaation.

    @Async
    public void create(Item item) {
        itemRepository.save(item);
    }

Voila! Käytännössä asynkroniset metodikutsut toteutetaan asettamalla metodikutsu suoritusjonoon, josta se suoritetaan kun sovelluksella on siihen mahdollisuus. Yleensä tämä tapahtuu erittäin nopeasti.

Heavy Calculations

Kumpulan kampuksella sijaitseva sisennyksen tutkimusyksikkö SIIT on kehittänyt tutkimussiivessään mullistavan merkkijonoalgoritmin ja haluavat demonstroida sen toiminnallisuutta web-sivulla. Muutaman yrityksen jälkeen he ovat huomanneet, että algoritmi on hieman hitaahko. Tehtävänäsi on tässä tehtävässä muuttaa demoamiseen käytettävää sovellusta siten, että algoritmin prosessointi tehdään taustalla. Älä kuitenkaan muuta itse algoritmin toimintaa.

Lisää sovellukselle konfiguraatio asynkronisten metodien suorittamiseen ohjelmallisesti, sekä muuta algoritmin sisältävää palveluluokkaa InMemoryDataProcessor siten, että prosessoinnin sisältävä metodi sendForProcessing suoritetaan asynkronisesti. Älä muuta metodin sendForProcessing sisältöä!

Toistuvat metodikutsut

Springin task-toiminnallisuuden avulla voimme myös toteuttaa palveluita, joihin kytketyt toiminnallisuudet suoritetaan tietyin aikavälein. Voisimme esimerkiksi haluta RSS-lukijan, joka hakee uusimmat uutiset kerran minuutissa. Annotaatio @Scheduled mahdollistaa tietyin aikavälein tapahtuvat pyynnöt. Sille voidaan määritellä ajastuksia esimerkiksi cron-formaatissa. Seuraavan komponenttiluokan metodi executeOnceInAMinute suoritetaan kerran minuutissa.

@Component
public class CronService {

    @Scheduled(cron = "1 * * * * *")
    public void executeOnceInAMinute() {
        // ...
    }
}

Lisää Springin Async- ja Scheduled-annotaatioista mm. Springin dokumentaatiosta.

Case: Sovellus binääritiedostojen tallentaminen

Tyypillinen sovelluskehittäjien jossain vaiheessa kohtaama vaatimus on palvelu käyttäjän tiedostojen vastaanottamiseen ja tallentamiseen. Toteutetaan tässä palvelu, johon voi lähettää tiedostoja. Tiedostot tallennetaan tietokantaan, josta ne voi listata.

Jotta tiedostojen lähettäminen palvelimelle olisi mahdollista, tarvitsemme Apache Commons-projektin fileupload-komponentin käyttöömme. Fileupload-komponentti tarvitsee käyttöönsä commons-io -projektin, joten lisätään molemmat pom.xml-tiedostoomme:

    <dependency>
        <groupId>commons-fileupload</groupId>
        <artifactId>commons-fileupload</artifactId>
        <version>1.2.2</version>
    </dependency>
    <dependency>
        <groupId>commons-io</groupId>
        <artifactId>commons-io</artifactId>
        <version>2.4</version>
    </dependency> 

Tämän lisäksi lisätään front-controller-servlet.xml-tiedostoon erillinen Apache Commonsia käyttävä bean, jonka tehtävänä on käsitellä tiedostoja sisältävät pyynnöt.

    <bean id="multipartResolver"
        class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
        <!-- hyväksytään korkeintaan megatavun kokoiset tiedostot -->
        <property name="maxUploadSize" value="1000000"/>
    </bean>

Toteutetaan ensin tiedoston lähettämiseen soveltuva lomake ja kontrolleriluokka.

    <form action="${pageContext.request.contextPath}/app/data" method="POST" enctype="multipart/form-data">
        Name:<br/>
        <input type="text" name="name"/><br/>
        Description:<br/>
        <textarea name="description">
        </textarea><br/>
        File:<br/>
        <input type="file" name="file"/><br/>
        <input type="submit"/>
    </form>
// pakkaus

import java.io.IOException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;

@Controller
public class BinaryDataController {

    @RequestMapping(method = RequestMethod.GET, value = "data")
    public String view(Model model) {
        return "form";
    }

    @RequestMapping(method = RequestMethod.POST, value = "data")
    public String post(@RequestParam("name") String name,
            @RequestParam("description") String description,
            @RequestParam("file") MultipartFile file) throws IOException {

        System.out.println("Name: " + name);
        System.out.println("Description: " + description);
        System.out.println("File length: " + file.getBytes().length);

        return "redirect:data";
    }
}

Sovellusta testatessamme törmäämme uudestaan ja uudestaan HTTP-statuskoodiin 400. Sovelluksen koodia läpikäydessä emme löydä konkreettista virhettä, ja lähdemme etsimään virheen aiheuttajaa muualta. GlassFishin versioon 3.1.2 on päässyt bugi, joka aiheuttaa ongelmia tiedostojen käsittelyssä. Ongelman voi onneksi kiertää vaihtamalla palvelimen vanhempaan versioon.

Kun lähetämme lomakkeen, palvelimen logeissa näkyy lomakkeella lähetetyt arvot.

Seuraava askel on tiedostoja tallentavan olion suunnittelu. Luodaan entiteetti DataObject, sekä palvelu DataService. Tallennetaan entiteettiin data oliomuuttujaan content, joka on byte-taulukko, ja jolla on @Lob-annotaatio. Tällä ilmoitamme kentän sisältävän binääridataa. Oliomuuttujaan liittyvään @Column-annotaatioon lisätään myös tallennettavan tiedon koko (length). Koska tiedostoja ladattaessa palvelimen tulee pystyä kertomaan ladattavan tiedoston tyyppi ja, otamme tiedoston mediatyypin (mimetype) ja nimen talteen. Loput kentät lienevät tuttuja.

// pakkaus ja importit

@Entity(name = "DataObject")
@Table(name = "DataObject")
public class DataObject implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "id")
    private Long id;

    @Column(name = "name")
    private String name;

    @Column(name = "filename")
    private String filename;

    @Column(name = "description")
    private String description;

    @Lob
    @Column(name = "data", length = 1000000)
    private byte[] content;

    @Column(name = "mimetype")
    private String mimetype;


    // getterit ja setterit

Luodaan seuraavaksi palvelurajapinta tiedostojen tallentamiselle. Rajapinta DataStorageService tarjoaa perus CRUD-toiminnallisuuden sekä listauksen. Huomaa, että rajapinta on itseasiassa hyvin tuttu. Huomattava osa web-sovelluksista perustuvat CRUD-toiminnallisuuteen.

public interface DataStorageService {
    DataObject create(DataObject object);
    DataObject read(Long identifier);
    DataObject update(Long identifier, DataObject object);
    void delete(Long identifier);

    List<DataObject> list();
}

Käytämme tietokantaan tallennukseen Spring Data JPA:ta, joten tietokantatoiminnallisuutemme on käytännössä hyvin kevyt.

public interface DataStorageRepository extends JpaRepository<DataObject, Long> {
}

Palvelukerrokselle toteutetaan transaktiot, sekä tiedostojen tallennus. Palvelun toteutus jätetään lukijalle. Lisätään vielä kontrolleriin toiminnallisuus, jossa data lähetetään palvelutasolle, sekä ylimääräinen view-metodi, jossa näytetään yksittäisen tiedoston tiedot.

// pakkaus

import java.io.IOException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

@Controller
public class BinaryDataController {
    @Autowired
    private DataStorageService dataStorageService;
    
    @RequestMapping(method = RequestMethod.GET, value = "data/{objectId}")
    public String view(Model model, @PathVariable Long objectId) {
        model.addAttribute("dataObject", dataStorageService.read(objectId));
        return "view";
    }
    
    @RequestMapping(method = RequestMethod.GET, value = "data")
    public String view() {
        return "form";
    }

    @RequestMapping(method = RequestMethod.POST, value = "data")
    public String post(
            RedirectAttributes redirectAttributes,
            @RequestParam("name") String name,
            @RequestParam("description") String description,
            @RequestParam("file") MultipartFile file) throws IOException {

        // mahdollinen validointi, datan voi kytkeä myös olioon suoraan
        
        DataObject dataObject = new DataObject();
        dataObject.setName(name);
        dataObject.setDescription(description);
        dataObject.setMimetype(file.getContentType());
        dataObject.setContent(file.getBytes());
        dataObject.setFilename(file.getOriginalFilename());
        
        dataObject = dataStorageService.create(dataObject);
        
        redirectAttributes.addAttribute("objectId", dataObject.getId());
        return "redirect:data/{objectId}";
    }
}

Toiminnallisuus datan lähettämiseen ja katsomiseen on nyt kunnossa. Toteutetaan vielä erillinen kontrolleri dataobjektien lataamiseen palvelimelta. Ensimmäinen versio, DownloadController-luokassa oleva metodi download, kuuntelee pyyntöjä osoitteeseen download/{objectId}. Pyynnön polussa tulevan tunnuksen perusteella tietokannasta haetaan haluttu olio. Oliosta haetaan siihen liittyvä data, ja lisätään se osaksi vastausta. Vastaukseen lisätään myös tiedot vastauksen sisällöstä (content type), pituudesta (content length), ja pakotetaan selain lataamaan tiedosto asettamalla otsake Content-Disposition. Tämän jälkeen data kopioidaan käyttäjälle vastaukseksi.

// pakkaus ja importit

@Controller
public class DownloadController {

    @Autowired
    private DataStorageService dataStorageService;

    @RequestMapping(method = RequestMethod.GET, value = "download/{objectId}")
    public void download(@PathVariable Long objectId, HttpServletResponse response) throws Exception {
        DataObject object = dataStorageService.read(objectId);
        if (object == null) {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        byte[] content = object.getContent();
        
        response.setContentType(object.getMimetype());
        response.setContentLength(content.length);
        response.setHeader("Content-Disposition", "attachment; filename=" + object.getFilename());
        
        InputStream inputStream = new ByteArrayInputStream(content);
        OutputStream outputStream = response.getOutputStream();
        IOUtils.copy(inputStream, outputStream);
        response.flushBuffer();
    }

// ...

Tyylikkäämpi tapa juuri toteutetun tiedoston lataamisen toteuttamiseen on ResponseEntity-luokan käyttäminen. ResponseEntity-luokan luominen on käytännössä eriyttänyt HTTP-vastauksen sisältämän datan erilliseksi single responsibility principle-periaatetta seuraavaksi luokaksi.

    // ...

    @RequestMapping(method = RequestMethod.GET, value = "download/{objectId}")
    @ResponseBody
    public ResponseEntity<byte[]> download(@PathVariable Long objectId) throws Exception {
        DataObject object = dataStorageService.read(objectId);
        if (object == null) {
            return new ResponseEntity<byte[]>(HttpStatus.NOT_FOUND);
        }

        byte[] content = object.getContent();
        
        // otsakkeiden luonti
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.parseMediaType(object.getMimetype()));
        headers.setContentLength(content.length);
        headers.set("Content-Disposition", "attachment; filename=\"" + object.getFilename() + "\"");

        // datan palautus
        return new ResponseEntity<byte[]>(content, headers, HttpStatus.OK);
    }

    // ...

Family Album

Pelkän tekstimuotoisen tiedon esittäminen ei aina riitä. Koska olemme jo tutustuneet hieman tiedostojen lähettämiseen ja lataamiseen, tehdään seuraavaksi verkkopalvelu, jonne voi koota kuvista oman albumin. Jokaiselle kuvalle voi tallettaa myös tekstikommentin.

Tehtäväpohjan mukana tulee yksinkertainen käyttöliittymä, jonka avulla voi lähettää palveluun uusia kuvia sekä selata aiemmin lähetettyjä kuvia. Tehtäväpohjassa on myös valmis Spring-peruskonfiguraatio.

Huom! Tämä tehtävä on avoin tehtävä, eikä sen mukana tule ainuttakaan toteutukseen liittyvää luokkaa. Pääset siis koodaamaan ohjelman täysin itsenäisesti, ja ohjelman täytyy vain toteuttaa vaadittu HTTP-rajapinta.

Projekti on konfiguroitu siten, että Spring Data JPA-luokkia haetaan oletuksena pakkauksesta wad.familyalbum.repository, lähdekooditiedostojen oletetaan olevan pakkauksessa wad tai sen alipakkauksissa.

Kuvatiedoston vastaanottaminen selaimelta ja lähettäminen selaimelle

Sovelluksen varastoimilla kuvilla (eli kuvan entiteetillä) tulee olla seuraavat attribuutit:

Tallenna kuvat tietokantaan. Huom! Joudut konfiguroimaan tiedostojen vastaanottamiseen käytettävän multipartResolver-beanin itse.

Tehtäväpohjan mukana annetun käyttöliittymän tulee toimia osoitteessa /app/album. Tämän sivun avulla voit testata ohjelmaasi.

Sovelluksen tulee toteuttaa seuraavat HTTP-pyynnöt:

Tässä vaiheessa kuvien lähettämisen ja selaamisen pitäisi onnistua mukana tulevan käyttöliittymän avulla. Download-linkit eivät vielä toimi, keskitymme niihin seuraavaksi.

Kuvatiedoston lähettäminen selaimelle liitetiedostona

Käyttöliittymässä listattavia kuvia voi tarkastella yksitellen klikkaamalla kuvaa. Tällä tavoin näytettävän kuvatiedoston tallentaminen selaimella tietokoneelle ei kuitenkaan ole suoraviivaista, vaan vaatii mm. tiedoston nimeämisen, sillä selain ei tiedä tiedoston nimeä.

Selain voidaan "pakottaa" tallentamaan tiedosto palvelimen tarjoamalla nimellä asettamalla HTTP-vasteeseen otsake Content-Disposition. Otsaketiedossa tiedoston nimi tulee lainausmerkkien sisälle, eli lainausmerkit kuuluvat otsakkeeseen:

Content-Disposition: attachment; filename="image.jpg"

Laajenna sovellusta siten seuraavalla HTTP-pyynnöllä:

Nyt voit kokeilla ladata tiedostoja käyttöliittymän Download-linkkien avulla.

JSON-rajapinta kuvien tietojen lukemiseen

Jotta kuvien tekstimuotoisia kuvauksia ja muita tietoja voisi käyttää asiakasohjelmissa, täytyy sovelluksen tarjota pääsy näihin tietoihin esim. JSON-muotoisen tiedon avulla.

Huom! Spring (tai oikeastaan JSON-kirjasto Jackson) muodostaa JSON-muotoisen HTTP-vasteen domain-olioista (tässä tapauksessa kuvan entiteetistä) sen getter-metodien perusteella, joten kuvan binääridata päätyy myös JSON-vasteeseen, koska binääridatallekin on luonnollisesti oltava getter-metodi. Emme kuitenkaan halua binääridataa JSON-vasteeseen, koska kuvan sisältö on tarkoitettu ladattavaksi erillisestä osoitteesta. Yksittäisen getterin sulkeminen pois JSON-vasteesta onnistuu getter-metodille asetettavan annotaation org.codehaus.jackson.annotate.JsonIgnore avulla.

@JsonIgnore-annotaatiota käytetään esimerkiksi näin:

@JsonIgnore
public byte[] getData() {
    return data;
}

Laajenna vielä sovellusta siten, että se palauttaa JSON-muotoisia vastauksia seuraaville HTTP-pyynnöille:

Yksittäisen kuvan JSON-esitys tulisi näyttää esimerkiksi tältä (sisennykset ja rivinvaihdot on lisätty selkeyden vuoksi):

{
  "id": 42,
  "description": "Red sunset",
  "contentType": "image/png",
  "fileName": "red-sunset.png",
  "timestamp": 13728372938
}

Lista kuvista puolestaan näyttää esimerkiksi tällaiselta:

[
  {
    "id": 7,
    "description": "Red sunset",
    "contentType": "image/png",
    "fileName": "red-sunset.png",
    "timestamp": 13728372938
  },
  {
    "id": 8,
    "description": "Blue moon",
    "contentType": "image/jpeg",
    "fileName": "bluemoon.jpg",
    "timestamp": 13728374643
  }
]

Olemassaolevien palveluiden käyttö

Huomattava osa sovelluskehityksestä sisältää integroitumista olemassaoleviin ohjelmistokomponentteihin. Olemassaolevat komponentit tarjoavat jonkinlaisen rajapinnan, jota sovelluskehittäjät voivat käyttää integroitumiseen. Integraatio ei ole aina helppoa: joissain tapauksissa komponenttiin tulee rakentaa erillinen integraatiorajapinta ennenkuin komponenttia voi käyttää.

Monoliittiset "minä sisällän kaiken mahdollisen" -sovellukset ovat usein vaikeita ylläpitää, sillä uuden toiminnallisuuden lisääminen vaatii olemassaolevan sovelluksen muokkaamista. Sovellus voi olla kirjoitettu nykyään hyvin vähäisesssä käytössä olevalla kielellä (vrt. pankkijärjestelmät ja COBOL), ja sovellukset sisältävät harvoin muokkausta tukevia automaattisia testejä.

Rakentamalla tarvittu sovellus alusta lähtien palveluorientoituneesti, eli siten, että se koostuu erillisistä palveluista, saadaan jatkokehitys ja ylläpito huomattavasti helpommaksi. Jokainen palvelu kapseloi oman yksittäisen toimintansa, jonka se tekee erittäin hyvin -- yksikään palvelu ei yritä tehdä kaikkea. Konkreettinen sovellus, joka tarjotaan asiakkaalle, toimii palveluita käyttävänä kapelimestarina, jonka tehtävänä on koordinoida palveluiden välistä toimintaa sopivasti.

Esimerkiksi jos palvelu tarjoaa valmiin REST-rajapinnan, on siihen integroituminen helpohkoa. Käytännössä palvelua käyttävän sovelluksen omat palvelut ottavat yhteyden REST-rajapinnan tarjoamaan palveluun, ja hakee sieltä tarvittavaa tietoa.

Periaatteessa REST-palveluita käyttävien sovellusten tekeminen ei poikkea millään tavalla verkkosovelluksia käsittelevien sovellusten tekemisestä. Yksinkertaisimmillaan GET-pyynnön voi toteuttaa Javalla URL- ja HttpURLConnection-luokkia käyttämällä seuraavasti.

    URL url = new URL("http://palvelun-osoite.net/resurssi");
    HttpURLConnection connection = (HttpURLConnection) url.openConnection();
    if (connection.getResponseCode() != 200) {
        System.out.println("Response code was not ok! :(");
        return;
    }

    Scanner sc = new Scanner(connection.getInputStream());
    while(sc.hasNextLine()) {
        System.out.println(sc.nextLine());
    }

    connection.disconnect();

Jos vastaus sisältää JSON-muotoista dataa, on se osana vastauksen runkoa. Käytännössä Javan valmiita luokkia käytetään harvemmin verkkokyselyjen tekemiseen. Apache Software Foundation-projekti HTTP Components tarjoaa valmiin toiminnallisuuden esimerkiksi pyynnön palauttaman rungon hakemiseen. Esimerkiksi ylläolevan sovelluksen toiminnallisuuden saa toteutettua HttpComponentsin tarjoaman Fluent-APIn avulla seuraavasti.

    Content content = Request.Get("http://palvelun-osoite.net/resurssi").execute().returnContent();

    System.out.println(content.asString());

Huom! Ohjelmien ja koodin vertailu rivimäärien perusteella on lähinnä naurettavaa. Tärkeämpää on ymmärrettävyys, ylläpidettävyys, sekä luonnollisesti jatkokehitysmahdollisuudet myös muiden sovelluskehittäjien toimesta.

 

 

JSON-dataa tarjoavan REST-palvelun käyttö

Aiemmin näkemäämme Jackson JSON-kirjastoa voi käyttää tekstimuotoisen datan muuttamiseen olioiksi. Aiemmin näkemämme ObjectMapper-luokka tarjoaa olioiden JSON-dataksi muuntamisen lisäksi myös käännöksen toiseen suuntaan. Esimerkiksi jos käytössämme on aiemmin nähty Beer-luokka, on tekstimuotoisen datan muuttaminen olueksi helppoa.

public class Beer {
    private Long id;
    private String name;

    public Long getId() {
        return id;
    }

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

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
    String data = "{\"id\":1,\"name\":\"Buttface Amber Ale\"}";
    
    // Olio, joka muuttaa JSON-datan olioksi
    ObjectMapper mapper = new ObjectMapper();

    // luetaan olut merkkijonosta
    Beer beer = mapper.readValue(data, Beer.class);

    // valmista!

Yhdistämällä kaksi edellistä esimerkkiä, voimme luoda oman REST-asiakasohjelmiston. Emme kuitenkaan halua tehdä sitä, sillä ylläoleva(kin) on toteutettu valmiiksi huomattavassa osassa sovelluskehyksiä. Esimerkiksi Spring tarjoaa oman RestTemplate-luokan, jonka avulla REST-rajapinnan tarjoavien sovellusten käyttäminen on helpohkoa (dokumentaatio).

RestTemplate-oliolle tulee konfiguroida käytettävä viestikäsittelijä, joka määrittelee millaista dataa käsitellään (esim. JSON, XML). Konfiguraation voi tehdä ohjelmallisesti, tai RestTemplate-olion voi luoda osana sovelluksen käynnistystä. Alla on esimerkki, jossa ensin luodaan RestTemplate-olio, jolle asetetaan JSON-muotoista dataa käsittelevä MappingJacksonHttpMessageConverter-olio, jonka jälkeen tehdään kaksi pyyntöä. Ensimmäisessä pyynnössä haetaan yksittäinen tunnuksella 1 määritelty Aircraft-tyyppinen olio, toisessa haetaan lista Aircraft-olioita.

    String uri = "http://palvelu-ja-sen-osoite/aircrafts";
    RestTemplate restTemplate = new RestTemplate();
    List<HttpMessageConverter<?>> converters = new ArrayList<HttpMessageConverter<?>>();
    converters.add(new MappingJacksonHttpMessageConverter());

    restTemplate.setMessageConverters(converters);
    
    Aircraft aircraft = restTemplate.getForEntity(uri + "/1", Aircraft.class).getBody();
    List<Aircraft> aircrafts = restTemplate.getForObject(uri, List.class);

Beers Done Right

Materiaalissa on aiemmin nähty oluiden tallentamiseen tarkoitettu palvelu. Tämän tehtävän mukana tulee JSON-muotoista olutdataa tuottava REST-palvelu. Sinun tulee luoda REST-asiakassovellus, joka keskustelee REST-palvelun kanssa. Palvelua varten on määritelty pakkauksessa wad.beers.client oleva rajapinta BeerRestService.

Luo pakkaukseen wad.beers.client luokka BeerRestClient, joka toteuttaa rajapinnan BeerRestService. Lisää luokalle annotaatio @Service, ja toteuta luokan metodit siten, että ne käyttävät JSON-muotoista dataa tuottavaa REST-palvelua. Pakkauksessa wad.beers.client oleva kontrolleriluokka BeerController asettaa luomallesi luokalle käytettävän REST-palvelun juuriosoitteen sovelluksen käynnistyessä.

Käytä luokan BeerRestClient toteutuksessa Springin tarjoamaa luokkaa RestTemplate. Luo RestTemplate-olio BeerRestClient-luokan konstruktorissa.

Huom! Tässä tehtävässä sinun tarvitsee koskea vain itse luomaasi luokkaan BeerRestClient.

Palveluiden aggregointi

Palveluiden aggregointi, eli useamman palvelun datan yhdistäminen, on usein tarpeellista sovelluksia tehdessä. Pohditaan esimerkkiä, jossa tavoitteena on luoda sovellus, jossa henkilöitä asetetaan työhuoneisiin. Käytössämme on REST-api henkilöiden ja huoneiden käsittelyyn. Henkilöiden käsittelyyn tarkoitettu API on määritelty seuraavasti.

Huoneiden käsittelyssä käytetty API on lähes samanlainen.

Entä kun haluamme tietää tietyssä huoneessa olevat henkilöt? Tai henkilölle asetettu huone?

Huoneessa olevat henkilöt

Yksi vaihtoehto huoneessa olevien henkilöiden listaamiseksi on muokata huonepalvelun apia siten, että se palauttaa myös huoneeseen liittyvät henkilöt. Tämä ei kuitenkaan ole kovin mielekästä, tai edes järkevää. Huoneista vastaavan palvelun tulee hoitaa vain huoneisiin liittyviä asioita, ei henkilöiden listaamista.

Parempi vaihtoehto on määritellä huoneeseen liittyville henkilöille oma kyselyosoite, joka aggregoi huoneiden ja henkilöiden tiedot yhteen. Määritellään uusi REST-tyyppinen rajapinta huoneen kautta tapahtuvien henkilöiden käsittelyyn.

Henkilön huone

Kuten edellä, yksi vaihtoehto olisi muokata henkilöpalvelua siten, että se palauttaisi myös henkilön huoneen tiedot. Tämä ei kuitenkaan ole mielekästä, sillä henkilöitä käytetään todennäköisesti myös muuhunkin: huonetietojen listaus jokaisella kyselyllä on turhaa.

Määritellään erillinen rajapinta henkilöiden huoneiden käsittelyyn.

Käytännössä palveluita yhdistelevien osoitteiden määrittelyyn ei ole sovittu yhtä oikeaa lähestymistapaa, vaan sovelluskehittäjän tulee itse suunnitella mahdollisimman kuvaavat osoitteet.

Yllä olevissa aggregaattipalveluissa on tarkoituksella jätetty pois henkilöiden ja huoneiden muokkaaminen. Ongelman muodostaa jo usein se, että esimerkiksi huoneisiin liittyvien henkilöiden listaaja ei pakosti esitä henkilöistä samoja tietoja kuin henkilöpalvelu. Oikeastaan henkilöiden ja huoneiden muokkaaminen ei edes kuulu yllä määritellyt rajapinnat toteuttavan palvelun vastuulle.

Suunnitellaan palvelut siten, että ne toteuttavat oman vastuualueensa hyvin, ja jättävät muut tehtävät muille.

High Five!

Tehtävät High Five! ja Hot or Not kuuluvat samaan tehtäväsarjaan, joissa toteutetaan palvelu pelitulosten ja -arvostelujen säilyttämiseen. Ensimmäinen osa High Five! keskittyy pelien ja tulosten tallentamiseen, mikä toteutetaan erillisenä palveluna.

Pelitulospalvelu tarjoaa REST-rajapinnan pelien ja tuloksien käsittelyyn. Rajapinnan kaikki syötteet ja vasteet ovat JSON-muotoisia. Tehtäväpohjassa on toteutettu valmiiksi entiteetit Game ja Score, sekä tarvittavat palvelut niiden käsittelyyn ja tallettamiseen tietokantaan.

Huom! Tämä tehtävä on osittain avoin siten, että joudut tutkimaan tehtäväpohjassa annettua koodia, jotta voit hyödyntää sitä.

GameController

Pelejä käsitellään entiteetin Game avulla.

Toteuta luokka wad.highfive.controller.GameController, joka tarjoaa REST-rajapinnan pelien käsittelyyn:

ScoreController

Jokaiselle pelille voidaan tallettaa pelikohtaisia tuloksia entiteetin Score avulla, eli jokainen pistetulos kuuluu tietylle pelille. Tulokseen liittyy aina pistetulos points numerona, pelaajan nimimerkki nickname ja toteuttamasi palvelun itsensä tuottama aikaleima timestamp.

Toteuta luokka wad.highfive.controller.ScoreController, joka tarjoaa REST-rajapinnan tuloksien käsittelyyn:

Hot or Not

Palvelu Hot or Not lisää edellisen tehtävän pelitulospalveluun mahdollisuuden arvostella yksittäisiä pelejä antamalla niille numeroarvosanan 0-5.

Hot or Not-palvelun tulee käyttää High Five!-palvelun REST-rajapintaa, jonka avulla se tarjoaa samanlaisen rajapinnan pelien ja tulosten käsittelyyn. Ainoastaan pelien arvostelut käsitellään ja talletetaan tässä palvelussa! Arvosteluihin käytettävä entiteetti Rating ja siihen liittyvät palveluluokat on valmiina tehtäväpohjassa.

Huom! Edellisen tehtävän tavoin tämäkin tehtävä on osittain avoin siten, että joudut tutkimaan tehtäväpohjassa annettua koodia, jotta voit hyödyntää sitä. Joudut myös lukemaan tehtävän High Five! kuvausta tämän tehtävän toteutuksessa.

Huom! Valmis High Five!-palvelu löytyy osoitteesta http://high-five.herokuapp.com/app/games, joten voit tehdä tämän tehtävän täysin riippumatta edellisestä tehtävästä. Valmis palvelu on jokseenkin hidas, ja se ei ole aina päällä. Jos huomaat että testisi eivät mene läpi esimerkiksi timeoutin takia, suorita testit uudestaan. Vaihtoehtoisesti voit käyttää osoitetta http://t-avihavai.users.cs.helsinki.fi/highfive/app/games.

GameRestClient ja GameController

Tee luokka wad.hotornot.service.GameRestClient, joka toteuttaa rajapinnan GameService. Luokan tulee käyttää High Five!-palvelua kaikissa rajapinnan määrittelemissä toiminnoissa. REST-rajapinnan käyttö onnistuu Springin RestTemplate-luokan avulla vastaavalla tavalla kuin tehtävässä Beers Done Right.

Huom! GameRestClient-luokan setUri-metodi ottaa parametriksi yllä annetun URL-osoitteen valmiiseen High Five!-palveluun.

Luo luokka wad.hotornot.controller.GameController, joka tarjoaa täsmälleen samanlaisen JSON/REST-rajapinnan kuin High Five!-palvelun GameController, mutta siten, että jokainen toiminto käyttää valmista High Five!-palvelua rajapinnan GameService kautta.

Huom! Muista asettaa GameService-rajapinnan kautta URL-osoite valmiiseen High Five!-palveluun ohjelman käynnistyessä, esimerkiksi controller-luokan @PostConstruct-metodissa.

ScoreRestClient ja ScoreController

Tee luokka wad.hotornot.service.ScoreRestClient, joka toteuttaa rajapinnan ScoreService. Luokan tulee käyttää High Five!-palvelua kaikissa rajapinnan määrittelemissä toiminnoissa. REST-rajapinnan käyttö onnistuu Springin RestTemplate-luokan avulla vastaavalla tavalla kuin tehtävässä Beers Done Right.

Huom! ScoreRestClient-luokan setUri-metodi ottaa parametriksi yllä annetun URL-osoitteen valmiiseen High Five!-palveluun.

Luo luokka wad.hotornot.controller.ScoreController, joka tarjoaa täsmälleen samanlaisen JSON/REST-rajapinnan kuin High Five!-palvelun ScoreController, mutta siten, että jokainen toiminto käyttää valmista High Five!-palvelua rajapinnan ScoreService kautta.

Huom! Muista asettaa ScoreService-rajapinnan kautta URL-osoite valmiiseen High Five!-palveluun ohjelman käynnistyessä, esimerkiksi controller-luokan @PostConstruct-metodissa.

RatingController

Jokaiselle pelille voidaan tallettaa lisäksi pelikohtaisia arvosteluja entiteetin Rating avulla. Arvosteluun liittyy numeroarvosana rating (0-5) ja toteuttamasi palvelun itsensä tuottama aikaleima timestamp.

Arvostelut liittyvät peleihin, jotka on talletettu eri palveluun, joten entiteetin Rating viittaus peliin täytyy tallettaa suoraan avaimena. Koska peleihin viitataan REST-rajapinnassa pelin nimellä, talletetaan jokaiseen Rating-entiteettiin pelin nimi attribuuttiin gameName. Tämän attribuutin avulla voidaan siis löytää arvosteluja pelin nimen perusteella.

Toteuta luokka wad.hotornot.controller.RatingController, joka tarjoaa REST-rajapinnan arvostelujen käsittelyyn:

Palveluiden skaalautuvuus

Käyttäjien määrän kasvaessa palveluiden tulee pystyä skaalautua mukana. Palveluiden ja sovellusten skaalautumiseen on käytännössä kaksi vaihtoehtoa: resurssien kasvattaminen (vertikaalinen skaalautuminen), sekä palvelinmäärän kasvattaminen (horisontaalinen skaalautuminen). Resurssien kasvattamiseen sisältyy lisämuistin hankinta, algoritmien optimointi ym, kun taas palvelinmäärän kasvattamisessa sovelluskehittäjän tulee luoda mahdollisuus pyyntöjen jakamiseen palvelinten välillä.

Sovellukset eivät skaalaannu lineaarisesti, ja skaalautumiseen liittyy paljon muutakin kuin resurssien lisääminen. Jos yksiytimisellä prosessorilla varustettu palvelin pystyy käsittelemään sata pyyntöä sekunnissa, emme voi laskea, että kahdeksanytiminen prosessori pystyy käsittelemään kahdeksansataa pyyntöä sekunnissa. Skaalautuminen ei ole vain palvelun tehon lisäämistä. Aivan kuten raketin lisääminen autoon vauhdin lisäämiseksi ei pakosti tuota hyvää lopputulosta, yksittäinen tehokas komponentti sovelluksessa ei takaa sovelluksen tehokkuutta.

Käytännössä skaalautumisesta puhuttaessa puhutaan horisontaalisesta skaalautumisesta, jossa käyttöön hankitaan lisää tietyntyyppisiä resursseja (esim. palvelimia). Vertikaalinen skaalautumisen harkinta on mahdollista tietyissä tapauksissa, esimerkiksi tietokantapalvelimen toimintaa suunniteltaessa, mutta yleisesti ottaen horisontaalinen skaalautuminen on kustannustehokkaampaa. (vrt. kahden miestyökuukauden käyttö algoritmin optimointiin, joka aiheuttaa algoritmin 10% tehoparannuksen, vs. ylimääräisen palvelimen hankkiminen, josta saa saman hyödyn.)

Horisontaalinen skaalautuminen

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

Jos sovellukseen ei liity tilaa, 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. 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 usein termillä (sticky session).

Pelkkä palvelinmäärän kasvattaminen ja kuormantasaus ei kuitenkaan ole aina tarpeeksi. 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 kuormantasaaja lisää kuormaa entisestään paljon laskentaa tekevälle palvelimelle. On mahdollista käyttää kuormantasaajaa, joka pitää kirjaa palvelinten tilasta, mutta käytännössä palvelinten kuorma vaihtuu hyvin nopeasti, ja reagointi kuorman vaihtumiseen 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. Käytännössä lähestymistavassa käytetään 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 asiakkaan pyyntöjä vastaanottavilla palvelimilla, ja datan muokkaamista tai prosessointia vaativat kyselyt (esim POST) ohjataan asiakkaan pyyntöjä vastaanottavien palvelinten toimesta laskentaklusterille.

Viestijonot

Kun palvelinohjelmistoja skaalataan siten, että osa laskennasta siirretään erillisille palvelimille, on oleellista että palveluiden välillä kulkevat viestit (pyynnöt ja vastaukset) säilyvät. 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 APIn, jonka viestijonosovelluksen tarjoajat voivat toteuttaa (vrt. JDBC). Nykyään myös AMQP-protokolla on kasvattanut suosiotaan.

Aivan kuten esimerkiksi REST-tyylisen rajapinnan käyttö osana palveluorientoitunutta arkkitehtuuria, myös viestijonot mahdollistavat eri sovellusten helpon integroimisen. Viestejä tuottava sovellus voi olla toteutettu esimerkiksi COBOLilla, kun taas viestejä vastaanottava sovellus voidaan toteuttaa esimerkiksi Perlillä.

JMS ja ActiveMQ

Tutustutaan seuraavaksi hieman tarkemmin JMS-rajapintaan ja sen käyttöön. Käytetään JMS-rajapinnan toteutuksena ActiveMQ-toteutusta. ActiveMQ:n käyttöön tarvittavat kirjastot saa käyttöön lisäämällä projektiin liittyvään pom.xml-tiedostoon seuraavan riippuvuuden.

        <dependency>
            <groupId>org.apache.activemq</groupId>
            <artifactId>activemq-core</artifactId>
            <version>5.5.1</version>
        </dependency>

ActiveMQ-palvelimen saa lataamalla sen ActiveMQ:n kotisivuilta http://activemq.apache.org. Yleinen *nix-versio käy hyvin. Kun ActiveMQ on ladattu, se puretaan sopivaan kansioon. ActiveMQ käynnistyy sen alikansiossa bin olevan activemq-tiedoston avulla seuraavasti:

apache-activemq $ ./bin/activemq start

Komento start käytännössä tämä käynnistää viestijonopalvelimen oletuksena paikallisen koneen porttiin 61616 (osoite "tcp://localhost:61616"). Viestijonopalvelin on erillinen sovellus, joka sisältää joukon viestijonoja. Jokaiseen viestijonoon voi sekä asettaa viestejä, että niistä voi hakea viestejä. Luodaan seuraavaksi sovellus viestien lisäämiseksi viestijonoon. Lisätään viestijonoon nimeltä "messages" TextMessage-tyyppisiä olioita, jotka sisältävät tekstiä.

        // avataan yhteys
        ConnectionFactory connectionFactory = new ActiveMQConnectionFactory("tcp://localhost:61616");
        Connection connection = connectionFactory.createConnection();
        connection.start();

        // luodaan sessio viestien lähettämiseen, ei käytetä transaktioita
        Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE);

        // haetaan viestijono nimeltä "messages"
        Destination destination = session.createQueue("messages");
	
	// luodaan messageproducer-olio, jota käytetään viestien lähetykseen
        MessageProducer producer = session.createProducer(destination);

        // luodaan lähetettävä viesti, viesti sisältää merkkijonon "Hello World!"
        TextMessage message = session.createTextMessage("Hello World!");

        // lähetetään viesti
        producer.send(message);

	// suljetaan yhteys
        connection.close();

Luodaan seuraavaksi sovellus, joka vastaanottaa viestejä.

        // avataan yhteys
        ConnectionFactory connectionFactory = new ActiveMQConnectionFactory("tcp://localhost:61616");
        Connection connection = connectionFactory.createConnection();
        connection.start();

        // luodaan sessio viestien vastaanottamiseen, ei käytetä transaktioita
        Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE);

        // haetaan viestijono nimeltä "messages"
        Destination destination = session.createQueue("messages");

	// luodaan messageconsumer-olio, jota käytetään viestien lukemiseen
        MessageConsumer consumer = session.createConsumer(destination);
	
        // haetaan viesti jonosta -- jos viestiä ei ole, jäädään odottamaan
        // kaikille viestityypeille on yhteinen Message-rajapinta, joka 
	// tulee muuttaa halutuksi tyypiksi
        Message message = consumer.receive();

        // tulostetaan viestin sisältö
        TextMessage textMessage = (TextMessage) message;
	System.out.println(textMessage.getText());

	// suljetaan yhteys
        connection.close();
Hello World!

Viestinjonon saa sammutettua komennolla stop.

apache-activemq $ ./bin/activemq stop

Käytännössä viestien lähettäjän ja vastaanottajan vastuulla on yhteyden luominen, viestijonon valinta, viestin luominen tai hakeminen ja yhteyden sulkeminen. Huomattava osa koodista on kuitenkin toisteista, ja viestien prosessoinnin helpottamiseksi on kehitetty muutamia apuvälineitä. Esimerkiksi Spring tarjoaa oman JMS-tukikirjaston. Saamme sen käyttöön lisäämällä projektimme pom.xml-tiedostoon seuraavan riippuvuuden.

        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-jms</artifactId>
            <version>3.1.2.RELEASE</version>
        </dependency>

Spring JMS tarjoaa käyttöömme muunmuassa JmsTemplate luokan. JmsTemplate tarjoaa apuvälineitä JMS-pyyntöjen tekemiseen. JmsTemplate hoitaa muunmuassa yhteyden avaamisen ja sulkemisen, ja sen voi konfiguroida osana sovelluksen konfiguraatiota. Automaattisesti injektoitavan JmsTemplate-beanin voi luoda sovellukseen määrittelemällä beanin osana front-controller-servlet.xml-konfiguraatiota.

    <!-- ... -->

    <!-- viestijono "messages" -->
    <bean id="destination" class="org.activemq.message.ActiveMQQueue">
        <constructor-arg value="messages" />
    </bean>

    <!-- yhteyden luomiseen tarvittu tehdas -->
    <bean id="connectionFactory" class="org.activemq.ActiveMQConnectionFactory">
        <property name="brokerURL" value="tcp://localhost:61616" />
    </bean>

    <!-- JmsTemplate, joka käyttää viestijonoa ja aiemmin määriteltyä yhteystehdasta -->
    <bean id="jmsTemplate" class="org.springframework.jms.core.JmsTemplate">
        <property name="connectionFactory" ref="connectionFactory" />
        <property name="defaultDestination" ref="messages" />
    </bean>
    
    <!-- ... -->

Nyt aiemmin esitetyn viestin lähetyksen voi hoitaa helpommin. Oletetaan että käytössämme on rajapinta MessageSender:

public interface MessageSender {
    void enqueue(final String message);
}

Rajapinnan toteuttaa luokka JmsMessageSender:

@Service
public class JmsMessageSender implements MessageSender {
    @Autowired
    private JmsTemplate jmsTemplate;

    @Override
    @Async
    public void enqueue(final String message) {
        jmsTemplate.send(new MessageCreator() {
            @Override
            public Message createMessage(Session session) throws JMSException {
                return session.createTextMessage(message);
            }
        });
    }
}

Vastaavasti aiemmin esitetyn viestin vastaanottamisen voi toteuttaa myös hieman helpommin. Oletetaan että käytössämme on rajapinta MessageReceiver:

public interface MessageReceiver {
    String dequeue();
}

Rajapinnan toteuttaa luokka JmsMessageReceiver:

@Service
public class JmsMessageReceiver implements MessageReceiver {
    @Autowired
    private JmsTemplate jmsTemplate;

    @Override
    public String dequeue() {
        TextMessage textMessage = (TextMessage) jmsTemplate.receive();
        return textMessage.getText();
    }
}

Kummankin ylläolevista luokista voi injektoida automaattisesti palveluksi esimerkiksi kontrolleriluokkaan. Oikeastaan fiksumpi tapa viestin vastaanottamiseen olisi toteuttaa MessageListener-rajapinta, jolloin Spring kutsuu vastaanottajan metodia aina tarvittaessa. Luodaan toinen versio luokasta JmsMessageReceiver:

@Component
public class JmsMessageReceiver implements MessageListener {

    // ...

    @Override
    public void onMessage(Message message) {
        TextMessage textMessage = (TextMessage) message;

        // tee jotain vastaanotetulla viestillä, esimerkiksi
        // lähetys erilliseen (injektoituun) palveluun tai tietokantaan
    }
}

MessageListener-toteutukset tulee konfiguroida Springiin. Jotta ylläoleva luokka toimisi, tulee front-controller-servlet.xml-tiedostoon erillinen varasto viestien kuuntelijoille.

    <bean class="org.springframework.jms.listener.DefaultMessageListenerContainer">
        <property name="connectionFactory" ref="connectionFactory" />
        <property name="destination" ref="destination" />
        <property name="messageListener" ref="jmsMessageReceiver"/>
    </bean>

InstantGram

InstantGram on viime viikolla tehtyä perhealbumia soveltava sovellus. Sovelluksessa käyttäjä voi lisätä palveluun kuvan, joka filtteröidään hieman erinäköiseksi. Koska Kumpulan SIIT-siiven kuvankäsittelytaidot ovat heikot, käytössä on vain Sepia-filtteri.

Kuvien prosessointi web-sovelluksen sisältämällä palvelimella ei ole mielekästä, joten simuloidaan tässä tehtävässä kuvien siirtämistä erilliselle koneelle prosessointia varten. Sovelluksessa tulee mukana ActiveMQ-viestijono, jossa olevaan jonoon kuvat lisätään prosessointia varten. Kun prosessointi on valmis, palautetaan kuvat takaisin toista tallennusta varten.

Huom! Tässä tehtävässä viestijonon käyttöä simuloidaan, oikeasti viestijono tulee asentaa erilliselle koneelle, tai ainakin palvelinsovelluksesta erilliseen sovellukseen. Pakkauksessa wad.instantgram.backend olevat lähdekooditiedostot sijaitsisivat oikeasti erillisellä palvelimella, samoin viestijonototeutus. Viestijono käyttää paikallisen koneen porttia 46420. Sovelluksen viestijonon käynnistystoteutuksesta ei kannata juurikaan ottaa mallia.

Tässä tehtävässä tutustut hieman viestijonon käyttämiseen.

Viestin lähetys

Toteuta pakkauksessa wad.instantgram.frontend.queue olevaan luokkaan ActiveMQImageSender viestin lähetys. Viesti tulee lähettää osoitteessa tcp://localhost:46420 olevan viestijonopalvelimen jonoon ImagesToProcessingQueue. Käytä viestityyppiä javax.jms.ObjectMessage, jonka olioksi asetat lähetettävän kuvan.

Käytä apuna luokan ActiveMQImageSender perimän luokan AbstractMessageQueueService tarjoamia toiminnallisuuksia: Metodilla getJmsTemplate() pääset käsiksi JmsTemplate-olioon. Kun käytät sen send-metodia, käytä versiota jolle annetaan käytettävän viestijonon nimi merkkijonona.

Lisää myös viestin lähetys luokkaan ImageController. Viesti tulee asettaa viestijonoon sen jälkeen kun se on lisätty imageService-oliolle. Käytä kontrollerissa ImageSender-rajapintaa, jonka luokka ActiveMQImageSender toteuttaa.

Kun olet valmis, voit testata viestin lähetyksen toimimista sovelluksella. Näet palvelimen logeista viestin kun backend-palvelin vastaanottaa kuvan.

Viestin vastaanotto

Toteuta pakkauksessa wad.instantgram.frontend.queue olevaan luokkaan ActiveMQProcessedImageReceiver viestin vastaanotto. Viesti tulee vastaanottaa osoitteessa tcp://localhost:46420 olevan viestijonopalvelimen jonosta ProcessedImageQueue. Vastaanotettavat viestit ovat tyyppiä javax.jms.ObjectMessage, jonka olioksi vastaanotettava kuva on asetettu.

Käytä apuna luokan ActiveMQProcessedImageReceiver perimän luokan AbstractMessageQueueService tarjoamia toiminnallisuuksia: Metodilla getJmsTemplate() pääset käsiksi JmsTemplate-olioon. Kun käytät sen receive-metodia, käytä versiota jolle annetaan käytettävän viestijonon nimi merkkijonona.

Kun olet valmis, voit testata sovelluksen toimintaa. Alussa testata viestin lähetyksen toimimista sovelluksella. Näet palvelimen logeista viestin kun backend-palvelin vastaanottaa kuvan.

 

Viestijonojen käyttö monimutkaistaa yksinkertaista sovellusta ja yksinkertaistaa monimutkaista sovellusta. -- mikke

 

Yleistä sovelluskehityksestä

Web-sovelluskehityksessä nopeasta kehityssyklistä on huomattavasti hyötyä. Työkaluja valittaessa tarkoituksena on välttää nurkkaan ajautumista: työkaluista tulee pystyä myös pääsemään eroon. On paljon hyödyllisempää miettiä asiaa päivä ja käyttää muutama päivä prototyypin tekemiseen, koska prototyyppiä voidaan parantaa kuukausia, kuin miettiä kuukausi ja sitouttaa itsensä kuukauden aikana luotuun suunnitelmaan. Mitä nopeammin toiminnallisuutta on olemassa, sitä nopeammin siitä saa palautetta. Mitä vähemmän käytämme aikaa yksittäisen toiminnallisuuden toteuttamiseen -- KISS -- sitä helpommin siitä voi tarpeen vaatiessa hankkiutua eroon.

Vaikka olemme nähneet tähän mennessä paljon esimerkkejä mm. SOA-arkkitehtuurista, voi ohjelmiston prototyyppin tehdä ensin "yksinkertaiseksi" kerrosarkkitehtuuria noudattavaksi sovellukseksi. Kun prototyypin toiminta on varmistettu, voidaan sovellusta lähteä kehittämään paremmaksi -- skaalautumisen tarve selvenee projektin myötä. Prototyypin kehitys ei missään nimessä tarkoita hyvien ohjelmointikäytänteiden tai järkevän arkkitehtuurin unohtamista. Päinvastoin, prototyypit päätyvät usein tuotantokäyttöön ja jatkokehitykseen. Järkevä arkkitehtuuri ja rajapintojen käyttö mahdollistaa sovelluksen pilkkomisen erillisiin SOA-arkkitehtuuria mukaileviin palveluihin ilman että sovelluksen ulkopuolinen toiminta muuttuu. Rajapintojen alla olevat toteutukset toki muuttuvat.

Jotta sovelluskehitys olisi nopeaa, tulee ohjelmistokehitystiimillä olla käytössä yhteiset työkalut ja käytänteet. Olemme kurssin alusta käyttäneet Mavenia, joka helpottaa sovelluksen riippuvuuksien hallintaa. Tutustutaan tässä muutamaan hyödylliseen käytänteeseen.

Kehitys, Integraatio, QA, Tuotanto

Perinteisesti sovelluksia kehitettäessä sovellusta suoritetaan neljässä eri paikassa. (1) Jokaisella ohjelmistokehittäjällä on oma "hiekkalaatikko", jossa koodiin voi tehdä muutoksia vaikuttamatta muiden tekemään työhön. Jokaisella ohjelmistokehittäjällä on yleensä samat tai samankaltaiset työkalut (ohjelmointiympäristö, ...), mikä helpottaa muiden kehittäjien auttamista. Aina kun sovelluskehittäjä on saanut tehtävän valmiiksi, tehtävään liittyvä koodi ja muutokset lähetetään integraatiopalvelimelle esim. git-versionhallintaa käyttäen. (2) Integraatiopalvelimen tehtävänä on yhdistää ohjelmistokehitystiimin lähdekoodit ja suorittaa niihin liittyvät testit jokaisen muutoksen yhteydessä. Integraatiopalvelin kuuntelee käytännössä versionhallintajärjestelmässä tapahtuvia muutoksia, ja hakee uusimman lähdekoodiversion muutoksen yhteydessä. Integraatiopalvelimella on käytössä osajoukko tuotantopalvelimen käyttämästä datasta validointitarkoituksiin.

(3) QA-ympäristö (Staging-palvelin) on lähes identtinen ympäristö tuotantoympäristöön verrattuna. QA-ympäristöön kopioidaan ajoittain tuotantoympäristön data, ja se toimii viimeisenä testaus- ja validointipaikkana (Quality assurance) ennen tuotantoon siirtoa. QA-ympäristöä käytetään myös demo- ja harjoitteluympäristönä. Kun QA-ympäristössä oleva sovellus on päätetty toimivaksi, siirretään sovellus tuotantoympäristöön. (4) Tuotantoympäristö voi olla yksittäinen palvelin, tai se saattaa olla joukko palvelimia, joihin uusin muutos propagoidaan hiljalleen. Tuotantoympäristön tulee olla erillinen ympäristö muista ympäristöistä.

Integraatiopalvelin ja Continuous Integration

Jatkuvassa integroinnissa (Continuous integration) jokainen ohjelmistoprojektin jäsen lisää päivittäiset muutoksensa olemassaolevaan kokonaisuuteen. Tämä vähentää virheiden löytämiseen ja korjaamiseen kuluvaa aikaa, koska virheet löytyvät jo varhaisessa vaiheessa. Kun virheet löytyvät aikaisin ovat muutokset vielä kehittäjillä mielessä, ja syiden etsiminen vie huomattavasti vähemmän aikaa kuin integraatioita harvemmin tehtäessä.

Jatkuvaa integrointia seuraten ohjelmistokehittäjä hakee kehityksen alla olevan version versionhallinnasta aloittaessaan työn. Hän toteuttaa uuden pienen ominaisuuden testeineen, testaten uutta toiminnallisuutta jatkuvasti. Kun ohjelmistokehittäjä on saanut muutoksen tehtyä, ja kaikki testit menevät läpi hänen paikallisella työasemalla, hän lähettää muutokset versionhallintaan. Kun versionhallintaan tulee muutos, jatkuvaa integrointia suorittava työkalu hakee uusimman version ja suorittaa sille sekä yksikkö- että integraatiotestit.

Testejä sekä paikallisella kehityskoneella että erillisellä integraatiokoneella ajettaessa ohjelmistotiimi mahdollisesti huomaa virheet, jotka ovat piilossa kehittäjän paikallisen konfiguraation johdosta. Kehittäjä ei aina ota koko ohjelmistoa omalle koneelleen -- ohjelmisto voi koostua useista komponenteista -- jolloin kaikkien vaikutusten testaaminen paikallisesti on mahdotonta. Jos käännös ei mene läpi integraatiokoneella, kehittäjän tulee tehdä korjaukset mahdollisimman nopeasti.

Työkaluja automaattiseen kääntämiseen ja jatkuvaan integrointiin ovat muunmuassa Jenkins, Apache Continuum ja CruiseControl. TKTL:llä on opiskelijoiden käyttöön suunnattu Jenkins-asennus osoitteessa http://jenkins.staff.cs.helsinki.fi/.

Konfiguraatioprofiilit

Jos sovellusta ei ole rakennettu kunnolla, on sen siirtäminen eri ympäristöjen välillä tuskaa. Hyvin rakennetussa sovelluksessa ympäristön vaihtaminen ei aiheuta muutoksia sovelluksen lähdekoodiin. Käytännössä sovellusten hallinta tapahtuu profiilien avulla. Spring tarjoaa profiileille oman konfiguraatiotyylin. Luodaan seuraavaksi sovellus, jossa on erillinen tuotanto- ja kehitysympäristö. Kehitysympäristössä käytössä on muistiin ladattava H2-tietokanta, tuotantoympäristössä PostgreSQL-tietokantaa (oletetaan että pom.xml:ssä) on molempien tarvitsemat riippuvuudet.

Luodaan ensin persistence.xml-tiedostoon kaksi erillistä persistence-unit-konfiguraatiota. Toinen on tuotannolle, toinen kehitysympäristölle. Oleellisin ero konfiguraatioissa on se, että tuotantoympäristössä tietokantatauluja ei tuhota aina sovelluksen käynnistyessä.

<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.0" xmlns="http://java.sun.com/xml/ns/persistence" 
                              xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
                              xsi:schemaLocation="http://java.sun.com/xml/ns/persistence
                                 http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd">
    <persistence-unit name="persistenceUnitDev" transaction-type="RESOURCE_LOCAL">
        <properties>
            <property name="showSql" value="true"/>
            <property name="eclipselink.ddl-generation" value="drop-and-create-tables"/>
            <property name="eclipselink.ddl-generation.output-mode" value="database"/>
            <property name="eclipselink.weaving" value="false"/>
            <property name="eclipselink.logging.level" value="FINE"/>
        </properties>
    </persistence-unit>
    
    <persistence-unit name="persistenceUnitProduction" transaction-type="RESOURCE_LOCAL">
        <properties>
            <property name="eclipselink.ddl-generation" value="create-tables"/>
            <property name="eclipselink.ddl-generation.output-mode" value="database"/>
            <property name="eclipselink.weaving" value="false"/>
            <property name="eclipselink.logging.level" value="SEVERE"/>
        </properties>
    </persistence-unit>
</persistence>

Muokataan tämän jälkeen sovelluksen tietokantakonfiguraatiota. Konfiguraatioon määritellään profiilit "production" ja "dev,default", jotka sisältävät profiiliin liittyvän konfiguraation. Profiili "dev,default" sisältää käytännössä kaksi profiilia: profiili "dev" ja oletusprofiili "default". Profiilissa "production" asetetaan muuttujan persistenceUnitName arvoksi persistenceUnitProduction, luetaan konfiguraatiotiedosto production.properties, ja luodaan konfiguraatiotiedostosta luettujen parametrien pohjalta DataSource-olio. Profiilissa "dev,default" asetetaan muuttujan persistenceUnitName arvoksi persistenceUnitDev, luetaan konfiguraatiotiedosto development.properties, ja luodaan muistiin ladattava tietokanta.

    <!-- ... -->
    <bean id="entityManagerFactory" class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">
        <property name="persistenceUnitName" value="${persistence.unit}" /> 
        <property name="dataSource" ref="dataSource" />
        <property name="jpaVendorAdapter">
            <bean class="org.springframework.orm.jpa.vendor.EclipseLinkJpaVendorAdapter"/>
        </property>
    </bean>

    <!-- ... -->

    <!-- tuotantoympäristön konfiguraatio -->
    <beans profile="production">
	<!-- ympäristöön liittyvä konfiguraatiotiedosto -->
        <context:property-placeholder location="classpath:production.properties"/>

	<!-- luodaan tietokantakonfiguraatio luetun konfiguraatiotiedoston perusteella -->
        <bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource">
            <property name="driverClassName" value="org.postgresql.Driver"/>
            <property name="url" value="${database.url}"/>
            <property name="username" value="${database.username}"/>
            <property name="password" value="${database.password}"/>
        </bean>
    </beans>
    
    <!-- oletuskonfiguraatio -->
    <beans profile="dev,default">
	<!-- ympäristöön liittyvä konfiguraatiotiedosto -->
        <context:property-placeholder location="classpath:development.properties"/>

        <!-- muistiin ladattava tietokanta -->
        <jdbc:embedded-database id="dataSource" type="H2"/> 
    </beans>
    <!-- ... -->

Tiedosto production.properties sisältää tietokantakonfiguraation, erillisen deployment.location-parametrin sekä käytettävän persistenceunit-konfiguraation nimen. Tiedostot kansiossa src/main/resources kopioidaan osaksi classpath-polkua sovellusta paketoitaessa.

deployment.location=PRODUCTION
database.url=jdbc:postgresql://localhost/superapp
database.username=supermies
database.password=rul44
persistence.unit=persistenceUnitProduction

Tiedosto development.properties sisältää vain parametrin deployment.location.

deployment.location=DEVELOPMENT
persistence.unit=persistenceUnitDev

Nyt kun sovellusta käynnistetään ilman erillisiä konfiguraatioita, on sovelluksessa käytössä muistiin ladattava tietokanta sekä tiedostosta development.properties ladatut konfiguraatiot. Konfiguraatioparametrit voi injektoida myös suoraan sovellukseen.

// ..
@Controller
public class DefaultController {

    @Value("${deployment.location}")
    private String location;
// ...

Käytännössä profiilien hallinta kannattaa toteuttaa esimerkiksi niin, että integraatio-, qa- ja tuotantoympäristöön määritellään ympäristömuuttuja, joka kertoo käytettävän profiilin. Springiä käytettäessä ympäristömuuttuja on SPRING_PROFILES_ACTIVE tai spring.profiles.active.

Ympäristömuuttujan asetus tapahtuu *nix-koneilla export-komennolla.

export SPRING_PROFILES_ACTIVE=production

Hello, I am...

Tässä projektissa saat valmiin sovelluspohjan. Sovelluksessa voi kuvailla itseään adjektiiveilla. Haluamme konfiguroida projektiin kolme profiilia: dev,default (sekä default että dev profiili), ci ja production.

Yrityksissä joissa sovelluskehittäjät pääsevät käsiksi eri ympäristöihin, on erittäin hyödyllistä nähdä aktiivinen profiilin sovelluksesta. Tämä helpottaa debuggaamista ja estää tuotantopalvelimen tapaturmaista käyttöä. Sovelluksen näkymä hello.jsp on tehty niin, että selaimen oikeaan yläreunaan tulee punaisella profiilin nimi. Profiilin nimet on konfiguroitu property-tiedostoihin default.properties, ci.properties ja production.properties avaimella profile.name. Löydät tiedostot Netbeansistä Other Sources:in alta kansiosta src/main/resources/properties.

Muokkaa konfiguraatiotiedostoa database.xml. Luo sinne edellä mainitut kolme profiilia. Haluamme käyttää jokaisessa profiilissa erillistä muistista ladattavaa H2 tietokantaa. Käytä jdbc:embedded-database-elementtiä tietokantojen luomiseen. Käytä id:nä dataSource.

Haluamme myös jokaisessa profiilissä luoda PropertyPlaceholderConfigurer:n, jotta voimme noutaa tässä tapauksessa profiilin nimen sitä vastaavasta property-tiedostosta. Tämä onnistuu konfiguraatiolla:

    <context:property-placeholder location="classpath:properties/TIEDOSTON_NIMI.properties" />

Nyt voimme esimerkiksi noutaa profiilin nimen käyttäen annotaatiota @Value kuten alla.

    @Value("${profile.name}")
    private String name;

Kun ajat sovelluksen Netbeansillä käytetään automaattisesti profiilia default (joka on kehitysprofiili). Voit testata muita profiileja Jetty:n avulla. Aseta ensiksi aktiivinen profiili asettamalla komentoriviltä ympäristömuuttuja komennolla export SPRING_PROFILES_DEFAULT=PROFIILIN_NIMI. Aja tämän jälkeen projekti komennolla mvn jetty:start projektin juuressa. Aktiivisen profiilin tulisi muuttua ja kyseisen profiilin nimi näkyä näkymässä.

Testaaminen

Web-sovellusten testaamiseen kuuluu yksikkötestaus, integraatiotestaus ja systeemitestaus. Yksikkötestauksessa testataan sovellukseen kuuluvia yksittäisiä komponentteja ja varmistetaan että niiden tarjoamat rajapinnat toimivat kuten pitäisi. Integraatiotestauksessa testataan että komponentit toimivat yhdessä kuten niiden pitäisi, ja systeemitestauksessa sovellusta testataan ulkopuolelta esim. selaimella.

Yksikkötestaus

Yksikkötestauksella tarkoitetaan lähdekoodiin kuuluvien yksittäisten osien testaamista. Termi yksikkö viittaa ohjelman pienimpiin mahdollisiin testattaviin toiminnallisuuksiin, kuten olion tarjoamiin metodeihin. Single responsibility principlen mukaisesti haluamme pilkkoa ohjelmiston pieniin yksittäisen vastuun omaaviin yksikköihin, joiden testaaminen on helppoa: niillä on vain yksi vastuu. Testaus tapahtuu yleensä testausohjelmistokehyksen avulla, jolloin luodut testit voidaan suorittaa automaattisesti. Yleisin Javalla käytettävä testauskehys on JUnit. JUnit-kirjaston saa käyttöön lisäämällä siihen liittyvän riippuvuuden pom.xml-tiedostoon.

        <dependency>
            <groupId>junit</groupId>  
            <artifactId>junit</artifactId>  
            <version>4.10</version>  
            <scope>test</scope>
        </dependency>

Yksittäisen riippuvuuden määre scope kertoo milloin riippuvuutta tarvitaan. Määrittelemällä scope-elementin arvoksi test on riippuvuudet käytössä vain testejä ajettaessa. Uusia testiluokkia voi luoda NetBeansissa valitsemalla New -> Other -> JUnit -> JUnit Test. Tämän jälkeen NetBeans kysyy testiluokalle nimeä ja pakkausta. Huomaa että lähdekoodit ja testikoodit päätyvät erillisiin kansioihin -- juurin näin sen pitääkin olla. Kun testiluokka on luotu, on projektin rakenne kutakuinkin seuraavanlainen.

.
|-- pom.xml
`-- src
    |-- main
    |   |-- java
    |   |   `-- wad
    |   |       `-- ... oman projektin koodit
    |   |-- resources
    |   |       `-- ... resurssit
    |   `-- webapp
    |           `-- ... jsp-tiedostot ja konfiguraatiotiedostot
    `-- test
        `-- java
            `-- wad
                `-- ... testikoodit!

Testejä kirjoitetaan usein iteratiivisesti samaan aikaan sovellusta kehitettäessä. Paljon suosiota saanut TDD-menetelmä perustuu siihen, että sovellusta kehitetään kirjoittamalla siihen liittyviä testejä ensin. Tällöin sovellus on pakko pilkkoa pieniin osiin heti alusta.

TDD-sykli tarkemmin: (1) Luo uusi testi joka testaa ei-olemassaolevaa toiminnallisuutta. (2) Aja olemassaolevat testit ja varmista että uusi testi ei mene läpi. Jos testi menee läpi testi on viallinen tai toivottu toiminnallisuus on jo toteutettu. (3) Kirjoita uutta toiminnallisuutta varten yksinkertaisin mahdollinen toteutus, joka läpäisee testin. Uuden toiminnallisuuden tulee olla toteutettu siten, että se läpäisee juuri kirjoitetun testin – ei muuta. (4) Suorita olemassaolevat testit ja varmista että ne menevät läpi. Jos testit eivät mene läpi, tarkista uuden toiminnallisuuden aiheuttamat muutokset. (6) Refaktoroi eli siisti koodia. Esimerkiksi toistuva koodi tulee siirtää omaan metodiinsa. (7) Palaa kohtaan (1)

Yksinkertainen Laskin-esimerkki (ohjelmistojen mallintaminen, kesä 2011):

Ensimmäinen testi: Halutaan että Laskin-olion voi luoda

import org.junit.Test;
public class LaskinTest {
    @Test
    public void testLaskimenLuonti() {
        Laskin laskin = new Laskin();
    }
}

Luodaan toiminnallisuus joka läpäisee testin

public class Laskin {
}

Testit ajetaan ja kaikki menevät läpi. Huomaa että testiluokan tulee olla samannimisessä pakkauksessa (joskin eri sijainnissa) kuin testattavan luokan. Tällöin testiluokka ei tarvitse erillistä import-käskyä testattavalle luokalle. Seuraava testi: Halutaan että laskin voi laskea laskun 1+1

import org.junit.Assert;
import org.junit.Test;
public class LaskinTest {

    @Test
    public void testLaskimenLuonti() {
        Laskin laskin = new Laskin();
    }

    @Test
    public void testYksiPlusYksi() {
        Laskin laskin = new Laskin();
        Assert.assertEquals(2, laskin.plus(1, 1));
    }
}

Toteutetaan toiminnallisuus joka läpäisee testin. Huomaa että toiminnallisuuden tulee vain toteuttaa testin vaatima toiminnallisuus. Yksi plus yksi testille riittää hyvin toteutus joka palauttaa aina arvon 2.

public class Laskin {
    public int plus(int ekaluku, int tokaluku) {
        return 2;
    }
}

Kun testit suoritetaan, ne menevät läpi. Seuraava testi: Halutaan että laskin voi laskea laskun 1+2

import org.junit.Assert;
import org.junit.Test;
public class LaskinTest {
    @Test
    public void testLaskimenLuonti() {
        Laskin laskin = new Laskin();
    }
    @Test
    public void testYksiPlusYksi() {
        Laskin laskin = new Laskin();
        Assert.assertEquals(2, laskin.plus(1, 1));
    }
    
    @Test
    public void testYksiPlusKaksi() {
        Laskin laskin = new Laskin();
        Assert.assertEquals(3, laskin.plus(1, 2));
    }
}

Toteutetaan toiminnallisuus joka läpäisee uuden testin. Jos plusmetodin toiminnallisuutta muutetaan siten, että se palauttaa aina luvun kolme, aikaisempi testi ei enää mene läpi. On mahdollista toteuttaa toiminnallisuus myös ehtolauseen avulla ("jos ekaluku on yksi, ja tokaluku on kaksi, palauta 3") – mutta myöhemmin joutuisimme refaktoroimaan koodin testit läpäiseväksi.

public class Laskin {

    public int plus(int ekaluku, int tokaluku) {
        return ekaluku + tokaluku;
    }
}

Kun testit suoritetaan, ne menevät läpi – ja sykli jatkuu kunnes toiminnallisuus on valmis.

Testit ovat erittäin oleellisia sovelluksen ylläpitovaiheessa. Käytännössä uuden sovelluskehittäjän liittyessä ohjelmistotiimiin, on olemassaolevan sovelluksen muokkaus erittäin vaikeaa ilman testejä. Yksi muutos voi rikkoa useampia toiminnallisuuksia, joiden rikkoutumisesta olemassaolevat testit ilmoittavat.

Integraatiotestaus

Spring tarjoaa JUnit-kirjastolle tuen, jonka avulla saamme Autowired-annotaatiot toimimaan. Lisätään Spring-test -riippuvuus projektimme pom.xml-tiedostoon.

        <dependency>
          <groupId>org.springframework</groupId>
          <artifactId>spring-test</artifactId>
          <version>3.1.2.RELEASE</version>
          <scope>test</scope>
        </dependency>

Springin oliokontekstia käyttävät yksikkötestit tarvitsevat kaksi annotaatiota alkuun. Annotaatio @RunWith(SpringJUnit4ClassRunner.class) kertoo että käytämme Springiä yksikkötestien ajamiseen ja annotaatiolle @ContextConfiguration(locations = {".."}) annetaan käytettävien konfiguraatioiden sijainnit. Testiluokan alku näyttää esimerkiksi seuraavalta:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/front-controller-servlet.xml"})
public class MyTest {
    @Autowired
    private MyService myService;

    // ... testit jne

Ylläolevan konfiguraatiomäärittelyn avulla Spring pyrkii lataamaan oliokontekstin, ja asettamaan halutut oliot testiluokkiin, jossa niiden toiminnallisuutta voidaan testata.

Systeemitestaus

Systeemitestaukseen on monenlaisia työkaluja, joista eräs on Selenium. Selenium on web-testauskehys, joka antaa sovelluskehittäjälle mahdollisuuden käydä läpi sovelluksen käyttöliittymää ohjelmallisesti samalla varmistaen että sivuilla on toivotut asiat. Se simuloi myös Javascript-komponenttien toiminnallisuutta, minkä avulla käyttöliittymän testaus helpottuu huomattavasti. Selenium on erittäin hyödyllinen esimerkiksi käyttötapausten läpikäynnissä. Saamme Seleniumin käyttöön lisäämällä sen osaksi pom.xml-tiedostoa.

        <dependency>
            <groupId>org.seleniumhq.selenium</groupId>
            <artifactId>selenium-java</artifactId>
            <version>2.13.0</version> 
            <scope>test</scope> 
        </dependency>

Ajatellaan käyttötapausta jossa käyttäjä haluaa syöttää tunnuksen lomakekenttään ja päätyä toisenlaiselle sivulle. Haluamme löytää lomakekentän nimeltä "name". Kun kenttään asetetaan arvo "Bob" ja kenttään liittyvä lomake lähetetään, tulee sivulla olla lomakekenttä nimeltä "age".

public class SeleniumTest {
    private WebDriver driver;
    private String baseAddress;    
 
    @Before
    public void setUp() {
        this.driver = new HtmlUnitDriver();
        this.baseAddress = "...";
    }

    @Test
    public void onceBobSubmittedElementAgeIsAvailable() {
        // haetaan haluttu osoite (aiemmin määritelty muuttuja)
        driver.get(osoite);

        // haetaan kenttä nimeltä tunnus
        WebElement element = driver.findElement(By.name("name"));

        // asetetaan kenttään arvo
        element.sendKeys("Bob");

        // lähetetään lomake
        element.submit();
	
        // haetaan kenttä nimeltä "age"
	element = driver.findElement(By.name("age"));
	
	Assert.assertNotNull("Element \"age\" not found.", element);
    }

Yllä käytämme HtmlUnitDriver-oliota html-sivun läpikäyntiin. Haemme ensin määritellyn osoitteen, eli surffaamme haluttuun osoitteeseen. Haemme osoitteesta saadusta lähdekoodista kentän name-attribuutilla "name", ja lisäämme kenttään arvon "Bob". Tämän jälkeen lomake lähetetään. Kun lomake on lähetetty, haetaan kenttää jolla attribuutin name arvona on "age". Jos kenttää ei löydy (eli palautettu arvo on null), testi epäonnistuu ja käyttäjä näkee viestin "Element "age" not found.".

CAS 7782-49-2 (Selenium)

Tehtäväpohjan mukana tulee testiluokka BeerSeleniumTest, joka alustaa Seleniumin WebDriver-olion. Tehtävänäsi on tässä lisätä luokalle kaksi testimetodia. Tässä tehtävässä ei ole paikallisia testejä. Huom! Seuraa ohjeita tarkasti. Jos olet varma että testisi ovat oikein, mutta TMC:ltä tuleva vastaus ilmoittaa virheen, lähetä vastauksesi TMC:lle vielä muutaman kerran -- testien testaus tapahtuu erillisten säikeiden avulla, jotka voivat toimia epädeterministisesti.

Kun testaat sovellusta, varmista että käytät sovelluksen juuriosoitetta testaamiseen -- testaamiseen käytetty jetty toimii (yleensä omassa tapauksessamme) osoitteessa http://localhost:8090/. Glassfishille tyypillinen erillinen sovelluksen juuriosoite http://localhost:8090/W5-W5E06... ei siis ole käytössä.

Huom! Suosittelemme että tutustut tässä välissä Mozilla Firefoxille saatavilla olevaan Selenium IDE-projektiin.

submitAndVerify

Toteuta testimetodi public void submitAndVerify(), joka ensin menee sovelluksen sivulle ja tarkistaa ettei sivuilla ole olutta "Up Up Down Down Left Right Left Right BA Select". Tämän jälkeen olut "Up Up Down Down Left Right Left Right BA Select" lisätään kenttään, jonka tunnus on name, ja lomake lähetetään. Tämän jälkeen sinun tulee varmistaa, että olut on olemassa.

Huom! Toteuta varmistukset junit-sovelluskehyksen Assert-luokan tarjoamilla staattisilla metodeilla.

submitThreeAndVerify

Toteuta testimetodi public void submitThreeAndVerify(), joka ensin menee sovelluksen sivulle ja tarkastaa ettei sivuilla ole oluita "Gargamel Ale", "Crazy Ivan" ja "Hoptimus Prime". Tämän jälkeen oluet "Gargamel Ale", "Crazy Ivan" ja "Hoptimus Prime" lisätään sivulle sivun lomaketta käyttäen. Lopulta testi testaa että lisätyt kolme olutta ovat list-sivulla.

Skaalautuminen ja pilvipalvelut

Jatketaan sovellusten skaalautumisen pohdintaa vielä hieman. Olemme aiemmin pohtineet vertikaalista ja horisontaalista skaalautumista sekä viestijonojen käyttöä vastauksena käyttäjien määrän lisääntymiseen. Aiemmin esitetyt lähestymistavat auttavat ongelmissamme, mutta voimme vielä parantaa tilannetta.

Sovelluksen suorituskykyä ja toiminnallisuutta mitattaessa oleelliseen rooliin nousee sovelluksen profilointi. Esimerkiksi NetBeans sisältää valmiin sovelluksen profiloijan, jonka avulla voi tutkia eri sovelluksen osissa käytettyä aikaa. Profiloijan käyttäminen yhdessä kuormitustestaustyökalun kanssa (esim. Apache JMeter tai The Grinder) auttaa kyselyiden simuloinnissa, jolloin ongelmakohdat löytyvät helposti.

Kuormitustestauksessa tulee ottaa huomioon sovellukseen tulevien kyselyiden todellinen profiili. Sovelluksen profilointituloksissa on huomattavia eroja riippuen pyyntöprofiileista. Jos 99% sovellukseen kohdistuvista pyynnöistä on GET-tyyppisiä pyyntöjä, ei kuormitustestaustyökalun tule testata 50% POST, 50% GET -tyyppisellä profiililla.

Spring Insight

Spring tarjoaa sovelluskehittäjille Spring Insight-sovelluksen, jonka avulla sovelluksen suorituskyvyn analysointi helpottuu huomattavasti. Se kiinnittyy sovelluksessa tapahtuviin suorituspolkuihin, jotka alkavat esimerkiksi HTTP-pyynnöistä. Jokaisessa suorituspolussa on joukko operaatioita, jotka kuvaavat merkittäviä tapahtumia suorituspolussa. Operaatiot ovat esimerkiksi tietokantakyselyitä tai transaktioiden tallennuksia.

Spring Insight generoi suorituspolkudatasta automaattisia yhteenvetoja, joiden perustella sovelluksen toimintaa voi analysoida melko tarkasti. Tämä mahdollistaa muunmuassa ongelmakohtien löytämisen, jonka avulla sovelluksen suoritustehoa voi parantaa. Lue lisää täältä...

Cache

Käytännössä huomattava osa web-palvelinohjelmistoille tulevista kyselyistä on GET-tyyppisiä pyyntöjä. GET-tyyppiset pyynnöt eivät muokkaa palvelimella olevaa dataa, vaan pyytävät vain tietoa. Esimerkiksi tietokannasta dataa hakevat GET-tyyppiset pyynnöt luovat aina yhteyden erilliseen tietokantasovellukseen, josta ne hakevat dataa pyynnön perusteella. Sovellusten skaalautumista pohtiva sovelluskehittäjä alkaakin miettimään että eikö uudestaan ja uudestaan haettavaa dataa voisi tallentaa välimuistiin.

Ovela ohjelmoija hyödyntää Javan valmista kalustoa välimuistin toteuttamiseen. Esimerkiksi Javan luokka LinkedHashMap tarjoaa mainion pohjan oman välimuistin toteutukseen. Luokan metodia removeEldestEntry kutsutaan aina uuttaa arvoa lisättäessä. Käytännössä välimuistin voi luoda perimällä luokan LinkedHashMap, ja korvaamalla metodin removeEldestEntry siten, että huomioon otetaan välimuistille määriteltävä parametrin cacheSize, joka määrittelee välimuistin koon.

public class SimpleCache<K, V> extends LinkedHashMap<K, V> {
    private int cacheSize;

    public SimpleCache(int cacheSize) {
        super();
        this.cacheSize = cacheSize;
    }
        
    @Override
    public boolean removeEldestEntry(Map.Entry eldest) {
        return size() > cacheSize;
    }   
}

Yllä olevan toteutuksen voi lisätä osaksi palveluluokkien metodeja. Pohditaan seuraavaa BeerService-rajapintaa, joka on tullut jo tutuksi. Luodaan sille seuraavaksi erillinen palvelu, joka käyttää Spring Data JPA:ta. Repository-luokkaa ei näytetä erikseen.

public interface BeerService {
    
    Beer create(Beer beer);
    Beer read(Long identifier);
    // ...
public class JpaBeerService implements BeerService {

    @Autowired
    private BeerRepository beerRepository;

    private SimpleCache<Long, Beer> beerCache;

    public JpaBeerService() {
        // luodaan cache, johon voi varastoida 1000 oluen tiedot
        this.beerCache = new SimpleCache<Long, Beer>(1000);
    }

    @Override
    @Transactional(readOnly = false)
    public Beer create(Beer beer) {
        // tallennetaan cacheen vasta kun olut haetaan
        return beerRepository.save(beer);
    }

    @Override
    @Transactional(readOnly = true)
    public Beer read(Long identifier) {
        // jos olut on cachessa, palautetaan se sieltä
        if(beerCache.containsKey(identifier)) {
            return beerCache.get(identifier);
        }

        // muuten haetaan olut tietokannasta, ja tallennetaan se
        // cacheen
        Beer beer = beerRepository.findOne(identifier);
        beerCache.put(identifier, beer);
        return beer;
    }
    // ...

Melko ovelaa, eikö?

Ovelaa kyllä, muttei fiksua. Keksimme vahingossa taas pyörän uudestaan.

EHcache

Maailmalla on huomattava määrä valmiita cache-toteutuksia, joista yksi on EHcache. Vaikka tässä tutustumme EHcacheen vain pikaisesti, tarjoaa se toiminnallisuuden muunmuassa hajautettujen välimuistien toteutukseen ja ns. big data -skaalan sovellusten tukemiseen.

Koska käytössämme on Spring, konfiguroidaan EHcache Springille. Spring tarjoaa erilaisten välimuistitoteutusten abstraktion. Lisätään ensin EHcacheen liittyvä riippuvuus pom.xml-tiedostoomme.

        <dependency>
            <groupId>net.sf.ehcache</groupId>
            <artifactId>ehcache-core</artifactId>
            <version>2.6.0</version>
        </dependency>

Olemme jo tykästyneet annotaatioihin, joten otetaan käyttöön välimuistin hallinnointi annotaatioilla. Huomaa että Springin cache-abstraktio ei ota kantaa välimuistin konfiguraatioon, joten se tulee luoda erikseen. Luodaan EHCachelle yksinkertainen konfiguraatio, joka määrittelee muistissa käytettävän cachen. Erillisen konfiguraatiotiedoston käyttäminen on kätevää, sillä voimme kehityskoneilla käyttää yksinkertaista cachea, kun taas tuotantokoneilla cachekonfiguraatio voi olla täysin erilainen. Luodaan kansioon src/main/resources, eli Other Sources, tiedosto ehcache-dev.xml, johon lisätään seuraava konfiguraatio. Tarkemmin konfiguraatiosta löytyy mm. EHcachen omasta dokumentaatiosta.

<ehcache maxBytesLocalHeap="10M">
    <defaultCache maxElementsInMemory="1000" eternal="true"
                  overflowToDisk="false" memoryStoreEvictionPolicy="LFU" />
    <-- käytössämme on cache nimeltä beers -->
    <cache name="beers"/>
</ehcache>

Lisätään seuraavaksi Spring-konfiguraatioomme (esim. front-controller-servlet.xml) seuraava konfiguraatio. Haluamme hallinnoida välimuistia annotaatioilla, ja käyttää EHcachea välimuistin toteuttajana. EHcachen konfiguraatiotiedostona on yllä määritelty ehcache-dev.xml-tiedosto.

    <!-- ... -->
    xmlns:cache="http://www.springframework.org/schema/cache"
                            http://www.springframework.org/schema/cache 
                            http://www.springframework.org/schema/cache/spring-cache-3.1.xsd
    <!-- ... -->

    <!-- annotaatioilla hallittava cache -->
    <cache:annotation-driven />
    <bean id="cacheManager" class="org.springframework.cache.ehcache.EhCacheCacheManager">
        <property name="cacheManager" ref="ehcache" />
    </bean>

    <!-- käytetään EHcachea cachena -->
    <bean id="ehcache" class="org.springframework.cache.ehcache.EhCacheManagerFactoryBean" >
        <!-- konfiguraatio löytyy kansiosta src/main/resources, 
        joka kopioidaan classpathiin sovelluksen käynnistyessä -->
        <property name="configLocation" value="classpath:ehcache-dev.xml" />
    </bean>

    <!-- ... -->

Kun olemme konfiguroineet välimuistin, voimme toteuttaa välimuistin hieman eritavalla kuin aiemmin. Spring tarjoaa käyttöömme mm. @Cacheable- ja @CacheEvict-annotaatiot, joiden avulla voidaan määritellä välimuistin käyttö ja tyhjennys metodeille. Yllä olevan JpaBeerService-luokan toteutus hoituisi nyt seuraavasti.

public class JpaBeerService implements BeerService {

    @Autowired
    private BeerRepository beerRepository;

    @Override
    @Transactional(readOnly = false)
    @Cacheable(value="beers", key="#beer.id")
    public Beer create(Beer beer) {
        return beerRepository.save(beer);
    }

    @Override
    @Transactional(readOnly = true)
    @Cacheable("beers")
    public Beer read(Long id) {
        return beerRepository.findOne(id);
    }
    // ...

Käytännössä metodille read luodaan proxy-metodi, joka ensin tarkistaa onko haettavaa tulosta välimuistissa. Välimuistina käytetään välimuistia nimeltä "beers", joka on aiemmin konfiguroitu tiedostossa ehcache-dev.xml. Jos tulos on välimuistissa, palautetaan se sieltä, muuten tulos haetaan tietokannasta ja tallennetaan välimuistiin. Oleellisinta tässä lähestymistavassa on se, että välimuistin konfiguraatiota voi muuttaa käytettävien profiilien avulla. Aiemmin toteutettu ohjelmallisesti tehty välimuisti ei skaalaudu, kun taas valmista cache-komponenttia käyttävä sovellus skaalautuu konfiguraatiosta riippuen.

Välimuistia voi käyttää myös kontrolleritasolla, esimerkiksi JSON-muotoista olutdataa palauttavan kontrollerimetodin tuloksen saa välimuistiin seuraavasti.

@Controller
public class BeerController {

    @Autowired
    private BeerService beerService;

    // ...

    @Cacheable("beers")
    @RequestMapping(method = RequestMethod.GET, value = "beer/{beerId}", produces="application/json")
    @ResponseBody
    public Beer read(@PathVariable Long beerId) {
        return beerService.read(beerId);
    }
    // ...

Huom! Spring tai EHcache ei tarkista muuttuuko cachen takana oleva data. Dataa muuttavat metodit tulee annotoida sopivasti annotaatiolla @CacheEvict, jotta välimuistista poistetaan muuttuneet tiedot.

Cached calculations

Ovela kaverisi on koodannut pelitietojen hakemiseen tarkoitettuun sovellukseen oman GameCache-toteutuksensa, joka tallentaa erilliseltä REST-palvelulta saatavia tuloksia paikalliseen välimuistiin. Refaktoroi sovellusta ja erityisesti GameRestClient-luokkaa siten, että heivaat kaverisi toteutuksen mäkeen, ja alat käyttämään Springin valmiiksi tarjoamaa Cache-abstraktiota. Fiksumpi kaverisi on konfiguroinut sovellukseen valmiiksi EHcachen.

Käytä annotaatiota @CacheEvict välimuistin tyhjentämiseen niissä tapauksissa, kun se tulee tyhjentää. Huomaa, että myös metodin findAll() tulokset tulee tallentaa välimuistiin. Tyhjennä findAll-metodin tuloslista välimuistista kun tallennettua tietoa muutetaan.

Lisää tietoa Springin cache-abstraktiosta löytyy Springin dokumentaatiosta.

Pilvipalvelut

Pilvipalvelut ovat verkossa toimivia palveluita. Pilvipalveluissa maksetaan vain käytetyistä resursseista -- sovelluksesta (SaaS, Software as a Service), alustasta (PaaS, Platform as a Service) tai laskentakapasiteetista (IaaS, Infrastructure as a Service). Pilvipalveluille ominaista on skaalautuvuus, palveluita käytetään vain kun on tarve. Jos käyttäjiä on paljon, käytössä olevien resurssien määrää voi dynaamisesti lisätä -- jos käyttäjiä on vähän, resurssien määrää voidaan laskea.

Sovellusalustaa palveluna (PaaS, Platform as a Service) tarjoavat yritykset mahdollistavat järjestelmän nopeamman kehittämisen -- sovelluskehittäjän ei tarvitse välittää alustasta sillä se on jo valmiina. Kustannusten arviointi on helpompaa sillä pilvipalveluiden laskutus tapahtuu käytön mukaan -- sovelluskehittäjä voi lisätä oman ylläpitokurstannuksen. Sovellusalustat piilottavat taustalla olevan infrastruktuurin, jolloin sovelluskehittäjän ei tarvitse välittää taustalla toimivasta palvelusta. Osa PaaS-tarjoajista toimii IaaS-tarjoajien tarjoamien palveluiden päällä: Esimerkiksi kohta tutuksi tuleva Heroku pyörii Amazonin päällä.

Strato

Havaintopaikkojen ja säähavaintojen lisäys ja listaus.

Tämä tehtävä on avoin tehtävä jossa saat itse suunnitella huomattavan osan ohjelman sisäisestä rakenteesta. Osa sovelluksen toiminnallisuudesta on ohjelmoitu valmiiksi. Valmiina on mm. käyttöliittymä (jsp-sivut), domain-objektit, ja osa kontrollereiden toiminnallisuudesta. Myös ensimmäiseen osaan tarvittavat palveluluokat ja repository-luokat ovat valmiina.

Käyttöliittymään on määritelty EL-kielellä attribuutit, joita käyttöliittymän tulee näyttää. Tehtäväpohjassa on myös valmis konfiguraatio spring-projektille.

Tehtävästä on mahdollista saada yhteensä 4 pistettä.

Luo tehtävässä sovellus, joka toimii kuten osoitteessa http://strato.herokuapp.com oleva sovellus. Sovelluksessasi ei tarvitse olla XSS-tarkastusta.

Huom! Viestijonojen tai erillisten REST-rajapintaa käyttävien palveluiden käyttäminen ei liene tarpeellista tehtävässä.

Pisteytys:

  1. + 1p: Havaintopisteiden (ObservationPoint) lisääminen ja näyttäminen onnistuu.

    Havaintopisteiden hallintasivulle tulee päästä aloitussivulla olevan linkin ("Observation points") kautta. Kun uusi havaintopiste on lisätty, se näytetään Observation points-linkin avaamalla sivulla. Käytä havaintopisteiden sivuna points.jsp-sivua.

  2. + 1p: Havaintojen (Observation) lisääminen havaintopisteisiin ja niiden näyttäminen onnistuu.

    Havaintojen lisäyssivulle tulee päästä aloitussivulla olevan linkin ("Observations") kautta. Kun uusi havainto on lisätty, se näytetään Observation-linkin avaamalla sivulla. Käytä havaintojen sivuna observations.jsp-sivua.

  3. + 2p: Havaintojen sivuttaminen. Tällä hetkellä kaikki havainnot näytetään samalla sivulla. Olisi kuitenkin hyvä, että havainnot olisi sivutettu, jolloin sivulla näytetään vain tietty määrä havaintoja kerrallaan. Toteuta sovellukseen sivutus siten, että havaintoja näytetään kerrallaan maksimissaan 5, ja havainnot ovat järjestetty lisäysajan (timestamp) mukaan laskevasti (DESC).

    JSP-sivulla observations.jsp on valmiina käyttöliittymän tarvitsema toiminnallisuus sivutukseen. Jotta sivutus toimisi, sinun tulee lisätä pyyntöön sivujen kokonaismäärää kuvaava attribuutti totalPages, nykyistä sivunumeroa kuvaava attribuutti pageNumber, sekä tietenkin kyseisellä sivulla näkyvät havainnot. Tarvitset myös pyynnöstä parametrin pageNumber, joka kuvaa käyttäjän haluamaa sivunumeroa.

    Seuraavasta koodinpätkästä ja Spring Data JPA:sta saattaa olla hyötyä, googlettamalla löydät varmasti lisää apua.
        public Page<Observation> listObservations(Integer pageNumber, Integer pageSize) {
            PageRequest request = new PageRequest(pageNumber - 1, pageSize,
                    Sort.Direction.DESC, "timestamp");
            return observationRepository.findAll(request);
        }
    

Sovellus Herokuun!

Tässä esitellään askeleet ylläolevan sovelluksen siirtämiseen Herokuun. Jotta onnistut, sinun tulee rekisteröityä osoitteessa https://api.heroku.com/signup ja asentaa Heroku-työvälineet koneellesi. Työvälineet löytyy osoitteesta https://toolbelt.heroku.com/.

Jotta sovellus toimisi Herokussa, tulee sitä muokata hieman. Ensimmäinen on maven-dependency-plugin-liitännäisen lisääminen pom.xml-tiedostoon. Heroku luo liitännäisen avulla sovelluksesta käynnistettävän ohjelman. Käytännössä pom.xml-tiedostoon tulee lisätä seuraavat rivit plugins-elementin sisälle.

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-dependency-plugin</artifactId>
                <version>2.3</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>copy</goal>
                        </goals>
                        <configuration>
                            <artifactItems>
                                <artifactItem>
                                    <groupId>org.mortbay.jetty</groupId>
                                    <artifactId>jetty-runner</artifactId>
                                    <version>7.5.4.v20111024</version>
                                    <destFileName>jetty-runner.jar</destFileName>
                                </artifactItem>
                            </artifactItems>
                        </configuration>
                    </execution>
                </executions>
            </plugin>

Nyt heroku osaa luoda sovelluksestamme komentoriviltä käynnistettävän version. Tämän lisäksi sovelluksen juurikansioon tulee luoda tiedosto Procfile, joka sisältää sovelluksen käynnistämiseen tarvittavan komennon. Käytännössä sovellusta herokuun lisättäessä sovellus ensin paketoidaan herokun toimesta mvn package-komennolla, jonka jälkeen tiedoston Procfile-sisältö suoritetaan. Alla olevalla konfiguraatiolla käynnistetään Java-tyyppinen web-sovellus, jolle annetaan parametreina käytettävä profiili sekä Herokun valmiit java-konfiguraatiot ja sovelluksen portti.

web: java -Dspring.profiles.active=production $JAVA_OPTS -jar target/dependency/jetty-runner.jar --port $PORT target/*.war

Kun sovelluksen konfiguraatio on valmis, lisätään se seuraavaksi herokuun. Oletamme tässä että olet sovelluksen juurikansiossa (tiedosto pom.xml on samassa kansiossa), ja että olet asentanut heroku-sovelluksen koneellesi. Oletamme myös, että käytössäsi on *nix-järjestelmä. Kirjaudutaan ensin heroku-palveluun, ja lisätään herokuun ssh-avain.

sovelluksen-nimi$ heroku login
Enter your Heroku credentials.
Email: käyttäjätunnus (sähköpostiosoite)
Password (typing will be hidden): salasana
Authentication successful.
sovelluksen-nimi$ heroku keys:add

Tämän jälkeen suoritetaan komento mvn clean, joka poistaa target-kansion.

sovelluksen-nimi$ mvn clean
...
[INFO] BUILD SUCCESSFUL
sovelluksen-nimi$

Alustetaan kansioon git-repo, lisätään kansiossa olevat tiedoston versionhallintaa, ja lopuksi commitataan muutokset.

sovelluksen-nimi$ git init
sovelluksen-nimi$ git add .
sovelluksen-nimi$ git commit -am "init"

Luodaan heroku-kohde, johon lähdekoodeja voi lisätä. Heroku luo automaattisesti sovellukselle osoitteen, johon se käynnistyy.

sovelluksen-nimi$ heroku create
Creating still-taiga-8629... done, stack is cedar
http://still-taiga-8629.herokuapp.com/ | git@heroku.com:still-taiga-8629.git
sovelluksen-nimi$

Pushataan versionhallinnassa olevat tiedostot lopulta herokuun.

sovelluksen-nimi$ git push heroku master
...

Tämän jälkeen sovellus siirretään herokuun, ja heroku alkaa käynnistämään sitä aiemmin automaattisesti luotuun osoitteeseen. Kun käynnistyminen onnistuu, sovellus on nähtävillä luodussa osoitteessa.

Jos sovelluksen polkua haluaa muuttaa, voi sen tehdä herokun hallintakäyttöliittymästä. Kirjaudu herokuun, valitse Apps -> sovellus -> Settings. Voit muuttaa sovelluksen nimeä vaihtamalla name-kohdassa olevan nimen ja klikkaamalla rename. Jos nimen vaihtaminen onnistuu (nimi ei esim. ole varattu), siirtää heroku sovelluksen uuteen paikkaan. Jos nimeksi valitaan esim high-five, tulee projektikansion .git/config -tiedostoa muokata siten, että se heroku-repo käyttää oikeaa osoitetta. Ylläolevassa esimerkissä config-tiedoston sisältö on aluksi seuraavanlainen.

[core]
        repositoryformatversion = 0
        filemode = true
        bare = false
        logallrefupdates = true
[remote "heroku"]
        url = git@heroku.com:still-taiga-8629.git
        fetch = +refs/heads/*:refs/remotes/heroku/*

Kun sovellus nimetään uudestaan, siirtyy myös sen osoite. Esimerkiksi high-five-sovelluksen osoite muuttuu muotoon git@heroku.com:high-five.git. Muunnetaan konfiguraatiota soveltuvasti.

[core]
        repositoryformatversion = 0
        filemode = true
        bare = false
        logallrefupdates = true
[remote "heroku"]
        url = git@heroku.com:high-five.git
        fetch = +refs/heads/*:refs/remotes/heroku/*

Nyt voimme lisätä muutoksia herokuun. Muutokset käynnistävät palvelimen automaattisesti uudestaan, jolloin uusin versio on aina näkyvillä loppukäyttäjälle.

Skaalautuminen pilvessä

Pilvipalveluiden käyttäminen mahdollistaa sovelluksen skaalautumisen tarpeen mukaan. Kun sovellus on hyvin aktiivisessa käytössä, voidaan siitä luoda useampia resursseja helposti. Toisaalta, jos sovelluksen käyttö pienenee, voidaan käytössä olevia resursseja vähentää. Pilvipalveluilla ei vielä ole yhtenäistä skaalautumismallia, mutta skaalautuminen perustuu samoihin periaatteisiin kuin "normaalien" sovellusten skaalautuminen.

Kun pilvipalvelu huomaa resurssien lisätarpeen, se käynnistää uusia sovellusta pyörittäviä palvelimia. Uusien palvelimien käynnistyksen tulee olla helppoa ja selkeästi määriteltyä, esimerkiksi Herokulla uuden sovelluksen käynnistämisen vaativat komennot on määritelty erilliseen Procfile-nimiseen tiedostoon, jonka pohjalta uusia palvelimia käynnistetään. Kun uusi palvelin käynnistetään, lisätään se automaattisesti sovellukseen liittyvälle (ohjelmistopohjaiselle) reitittimelle, joka alkaa ohjaamaan pyyntöjä myös uudelle palvelimelle. Reititin tarjotaan käytännössä aina pilvipalvelun puolesta.

Jos pyyntöjen määrä palvelulle vähenee, voidaan käynnissä olevia palvelimia ajaa alas. Esimerkiksi Heroku sammuttaa ylimääräisiä palvelimia jos niille ei ole ollut tarvetta viimeisen tunnin aikana. Maksullisissa pilvipalveluissa yksi palvelin on yleensä aina päällä, jolloin vasteajat ovat aina nopeita. Ilmaiset pilvipalvelut sammuttavat viimeisimmätkin palvelimet jos pyyntöjä ei tule vähään aikaan. Tällöin palvelin käynnistetään vain tarpeen vaatiessa.

Useamman palvelimen pyörittäminen nostaa palvelun vikasietoisuutta. Käytännössä pilvipalveluissa palvelimet pyörivät erillisissa fyysisissä sijainneissa, jolloin vikatapaukset tietyssä koneessa eivät vaikuta koko sovelluksen toimintaan.

Pilvipalvelut eivät ota kantaa sovelluksen sisäiseen rakenteeseen. Esimerkiksi sovelluksen arkkitehtuuria ei muuteta viestijonoja käyttäväksi tarvittaessa. Sovelluksen tehokas arkkitehtuurisuunnittelu on aina sovelluskehittäjän vastuulla. Jos palvelimia käynnistetään paljon sen takia, että sovelluksen suunnittelija ei ole ottanut huomioon erilaisia sovelluksen tarpeita (esim. raskaan prosessoinnin hoitaminen erillisellä palvelulla), tulee pilvipalvelun käyttämisen kustannukset myös olemaan korkeammat sillä palvelimia käynnistetään niiden kuorman perusteella. Raskas prosessointi kannattaa toteuttaa erillisellä palvelulla siten, että prosessointiin erikoistunut palvelu hakee työt erillisestä töitä varastoivasta viestijonosta.

Kannattaa tutustua herokun skaalautumismalliin osoitteessa http://www.heroku.com/how/scale. Miten Herokun malli poikkeaa Amazon Beanstalkin mallista (Best practises, Architectural overview)?.

 

 

Autentikointi ja autorisointi

Autentikointi ja autorisointi. Autentikoinnilla tarkoitetaan sitä, että käyttäjän identiteetti varmistetaan. Autentikointi tapahtuu esimerkiksi käyttäjätunnuksen ja salasanan avulla. Autorisoinnilla taas tarkoitetaan sen varmistamista, että käyttäjä saa tehdä hänen yrittämiään asioita.

HTTP-protokolla vaatii sen toteuttajilta kaksi autentikointitapaa: "Basic autentikoinnin" ja "digest autentikoinnin". Emme käsittele niitä tässä, lisätietoa niistä löytää täältä.

Autentikointi

Autentikointi, eli käyttäjän identiteetin varmistaminen, on yksi web-sovellusten oleellisista ominaisuuksista. Käyttäjän identiteetti tarkistetaan yleisimmin käyttäjätunnus-salasana -parin avulla siten, että käyttäjä lähettää ne HTTP-yhteyden yli palvelimelle. Kiitettävä osa nykyaikaisista sovelluskehyksistä tarjoaa autentikointimekanismin osana tietoturvakomponenttiaan. Autentikointisovellukset toimivat yleensä erillisinä filttereinä tarkistaen sovellukselle tehtäviä pyyntöjä.

Käyttäjän identiteetin varmistaminen vaatii käyttäjälistan, joka taas yleensä ottaen tarkoittaa käyttäjän rekisteröintiä palveluun. Käyttäjän rekisteröitymisen vaatiminen heti sovellusta käynnistettäessä voi rajoittaa käyttäjien määrää huomattavasti, joten rekisteröitymistä kannattaa pyytää vasta kun siihen on tarve.

Erillinen rekisteröityminen ei ole aina tarpeen. Web-sovelluksille on käytössä useita kolmannen osapuolen tarjoamia keskitettyjä identiteetinhallintapalveluita. Esimerkiksi OpenAuth:n avulla sovelluskehittäjä voi antaa käyttäjilleen mahdollisuuden käyttää jo olemassaolevia tunnuksia.

Rite of Passage

Back to Basics. Yksinkertaisimmat pääsytarkistukset toteutetaan filtterinä, joka käsittelee pyynnöt ennen sovellukselle pääsyä. Tässä tehtävässä toteutat oman filtterin, joka varmistaa ettei sovellusta voi käyttää ilman käyttöoikeuksia. Autentikointi toteutetaan filtterin avulla, tieto varmistumisen onnistumisesta tallennetaan sessioon.

Luo javax.servlet.Filter-rajapinnan toteuttava luokka AccessControlFilter pakkaukseen wad.accesscontrol. Konfiguroi web.xml siten, että kaikki front-controller servletille menevät pyynnöt menevät luokan AccessControlFilter kautta.

Toteuta itse filtteri seuraavasti: Jos pyynnön osoitteessa (metodi getRequestURI()) on merkkijono "login", tulee pyynnöstä tarkistaa parametrit username ja password. Jos parametrin username arvo on "username" ja parametrin password arvo on "password", tulee sessioon asettaa attribuutti nimeltä "authenticated" arvolla true. Tämän jälkeen pyyntö ohjataan osoitteeseen {sovelluksen juuriosoite}/app/secret.

Jos pyynnön osoitteessa ei ole merkkijonoa "login", tulee sessiosta tarkistaa attribuutti "authenticated". Jos attribuuttia "authenticated" ei ole asetettu, eli sen arvo on null, käyttäjä tulee ohjata osoitteeseen {sovelluksen juuriosoite}/denied.jsp. Jos attribuutti "authenticated" on asetettu, prosessointia jatketaan parametrina saadun FilterChain-olion avulla.

Huom! Vaikka Filter-rajapinnan metodi doFilter saa parametrinaan ServletRequest ja ServletResponse-oliot, voit olettaa että ne ovat tyyppiä HttpServletRequest ja HttpServletResponse. Toteuta pyynnön uudelleenohjaukset HttpServletResponse-olion sendRedirect-metodilla, sovelluksen juuriosoitteen saat pyyntöön liittyvällä metodilla getContextPath().

Muista myös, että uudelleenohjauksen jälkeen metodin suoritus tulee lopettaa.

Autorisointi

Autentikoinnin lisäksi tulee varmistaa, että käyttäjät pääsevät käsiksi vain heille oikeutettuihin resursseihin. Käyttäjät määritellään yleensä joko yksilöllisesti tai roolien kautta. Yleisin lähestymistapa käyttöoikeuksien määrittelyyn on käyttöoikeuslistat (ACL, Access Control List), joiden avulla määritellään käyttäjien (tai käyttäjäroolien) pääsy resursseihin. Käytännössä käyttöoikeuslistat määritellään osoitteille tai metodeille.

Autentikaatio ja autorisointi Springissä

Spring tarjoaa erillisen tietoturvakomponentin, jota voi käyttää myös web-sovelluksissa. Koska Spring on komponenttipohjainen sovelluskehys, myös tietoturvaan liittyvät komponentit tulee lisätä käyttöä varten. Lisätään ensin pom.xml-tiedostoon tietoturvaan liittyvät riippuvuudet. Otamme tässä vaiheessa myös käyttöön muutamia myöhemmin tarvitsemiamme palasia. Koska Spring Securityn omaan konfiguraatioon on määritelty hieman vanhemmat Spring-riippuvuudet, pidämme sen riippuvuudet melko alhaalla pom.xml-tiedostoa. Tällöin aiemmin määritellty riippuvuudet hakevat nykyaikaiset versiot, ja vanhemmat versiot jäävät hakematta.

        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-web</artifactId>
            <version>3.1.2.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-config</artifactId>
            <version>3.1.2.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-taglibs</artifactId>
            <version>3.1.2.RELEASE</version>
        </dependency>

Spring tarjoaa oman filtterin, joka on hieman kuten aiemmin itse luomamme filtteri. Konfiguroidaan se web.xml-tiedostoon. Jotta filtterille voidaan ladata tietoturvakonfiguraatio, tulee web.xml-tiedostoon määritellä myös konfiguraation sijainti sekä erillinen kuuntelija konfiguraation latausta varten. Käytännössä filtteri on erillinen muusta palvelinohjelmistosta, joten sitä ei konfiguroida osana front-controller-servlet.xml-tiedostoa.

    <!-- ... -->    
    <display-name>sovelluksen-nimi</display-name>  
    
    <!-- Tietoturvafiltteri. Huom! Filtterin nimen tulee olla täsmälleen tämä. -->
    <filter>
        <filter-name>springSecurityFilterChain</filter-name>
        <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>springSecurityFilterChain</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
    
    <!-- Ladataan konteksti ja tietoturvakonfiguraatio -->
    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>

    <!-- kerrotaan tietoturvakonfiguraation sijainti yllä määritellylle kontekstin lataajalle. -->
    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/security.xml</param-value>
    </context-param>

    <!-- Filtteri: Kaikki pyynnöt utf-8:ksi -->
    <!-- ... -->    

Tämän lisäksi tarvitsemme erillisen tietoturvakonfiguraation. Lisätään kansioon WEB-INF tiedosto security.xml, jossa on tietoturvakonfiguraatio. Alla olevassa konfiguraatiossa määrittelemme pääsynvalvonnan osoitteisiin, sekä käyttäjätunnusten hallinnan. Polkuun pub ja sen alipolkuihin on pääsy kaikilta, kun taas polku hidden ja sen alipolut vaativat autentikoitumista (isAutenticated()). Juuripolku on kaikille sallittu, ja loput poluista on kielletty kaikilta. Lisäksi määrittelemme että Spring security hoitaa kirjautumissivun ja logout-osoitteen. Kirjautumissivu on automaattinen, sovelluksesta voi poistua tekemällä (oletuksena) pyynnön osoitteeseen http://osoite-ja-sovellus/j_spring_security_logout.

Tämän lisäksi tiedostossa määritellään kaksi käyttäjätunnusta, "el_barto" ja "turjakas" sekä niiden salasanat. Käyttäjätunnuksille määritellään lisäksi roolit authorities-attribuutilla. Esimerkiksi käyttäjätunnuksella el_barto on sekä admin-rooli, että ope-rooli. Polkuihin voi määritellä autentikoinnin lisäksi pääsyn myös roolien perusteella. Esimerkiksi intercept-url-elementille lisätty attribuutti access="hasRole('admin')" vaatii, että roolina on admin. Vastaavasti useita rooleja voi määritellä sanomalla access="hasAnyRole('admin','ope')".

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"	
       xmlns:sec="http://www.springframework.org/schema/security"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
                            http://www.springframework.org/schema/beans/spring-beans-3.1.xsd
                            http://www.springframework.org/schema/security 
                            http://www.springframework.org/schema/security/spring-security-3.1.xsd">
    
    <sec:http use-expressions="true">
        <sec:intercept-url pattern="/pub/**" access="permitAll" />
        <sec:intercept-url pattern="/hidden/**" access="isAuthenticated()" />
        <sec:intercept-url pattern="/" access="permitAll" />
        <sec:intercept-url pattern="/**" access="denyAll" />
        <sec:form-login />
        <sec:logout />
    </sec:http>

    <sec:authentication-manager>
        <sec:authentication-provider>
            <sec:user-service>
                <sec:user name="el_barto" password="arto" authorities="admin,ope" />
                <sec:user name="turjakas" password="mikke" authorities="ope" />
            </sec:user-service>
        </sec:authentication-provider>
    </sec:authentication-manager>
</beans>

You Shall Not Pass!

Projektin pohjaan on konfiguroitu Spring Securityn vaatima filtteritoiminnallisuus. Kansion WEB-INF alla olevassa tiedostossa security.xml on määritelty pääsyrajaukset ja käyttäjätunnukset. Nykyisessä konfiguraatiossa sekä juuripolku että tiedosto index.jsp on sallittu kaikille. Tiedostossa on myös määritelty käyttäjä mikael, jonka salasana on rendezvous.

Muokkaa konfiguraatiota siten, että aiemman konfiguraation lisäksi osoitteeseen public ja sen alipolkuihin on pääsy kaikilta. Osoitteiden app ja secret alipolkuihin vaaditaan käyttäjärooli user. Pääsy kaikkiin muihin polkuihin tulee kieltää.

Lisää sovellukseen myös käyttäjä kasper roolilla user. Käyttäjän kasper salasana on spring.

Kun olet muokannut konfiguraatiota, testaa sovellustasi ja lähetä se TMC:lle.

Näkymätason autorisointi

Autorisointi pelkkien polkujen perusteella ei aina riitä. Käyttöliitymissä halutaan esimerkiksi tarjota käyttäjäroolikohtaista toiminnallisuutta. Aiemmin lisäämämme riippuvuus spring-security-taglibs tarjoaa näkymissä käytettävän tägikirjaston, jonka perusteella sivun eri osa-alueiden näkymistä voidaan rajoittaa käyttäjäkohtaisesti.

        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-taglibs</artifactId>
            <version>3.1.2.RELEASE</version>
        </dependency>

Tägikirjaston saa käyttöön JSP-sivulla lisäämällä sivun alkuun seuraavan rivin, joka määrittelee nimiavaruuden sec tägikirjastoille.

<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %>

Nyt JSP-sivuilla voi määritellä alueita, joiden näkyminen vaatii tietyn roolin. Esimerkiksi vain admin-roolille näkyvä linkki määritellään sec-nimiavaruudessa olevan elementin authorize avulla seuraavasti:

        <!-- sivun muuta sisältöä -->

        <sec:authorize access="hasRole('admin')">
            <a href="${pageContext.request.contextPath}/admin/">Admin pages</a>
        </sec:authorize>

        <!-- sivun muuta sisältöä -->

Attribuutti access määrittelee käytössä olevat roolit. Attribuutille käy mm. arvot isAuthenticated(), hasRole('...') ja hasAnyRole('...').

Metoditason autorisointi

Näkymätason autorisointi ei myöskään aina riitä. Usein halutaan rajoittaa toiminnallisuutta siten, että tietyt operaatiot (esim. poisto tai lisäys) halutaan mahdollistaa vain tietyille rooleille. Käyttöliittymän näkymää rajoittamalla ei voida rajoittaa kutsuja polkuihin, ja aiemmin luotu polkuihin tehtävien kutsujen rajoitus ei auta esimerkiksi REST-tyyppisissä osoitteissa, varsinkin jos GET-pyyntöihin halutaan oikeus kaikille.

Sovelluskehykset tarjoavat usein myös metoditason autorisoinnin. Esimerkiksi Springissä metoditason autorisointi hoidetaan annotaatioilla: metodeille määritellään annotaatiot, jotka kertovat käytetyt rajoitukset. Koska metoditason autorisointi tapahtuu web-sovelluksen sisällä, tulee se konfiguroida osana front-controller-servlet.xml-tiedostoa (tai vastaavassa). Komento sec:global-method-security pre-post-annotations="enabled" lisää käyttöömme annotaatiot, joilla voimme rajata metodien käyttöä esimerkiksi käyttäjäroolien perusteella.

        <!-- ... -->
        xmlns:sec="http://www.springframework.org/schema/security"
        <!-- ... -->
                            http://www.springframework.org/schema/security 
                            http://www.springframework.org/schema/security/spring-security-3.1.xsd
                            <!-- ... -->
        <!-- ... -->       
        <sec:global-method-security pre-post-annotations="enabled" />
        <!-- ... -->

Kun konfiguraatiotiedostoon on lisätty rivi sec:global-method-security pre-post-annotations="enabled", on käytössämme mm. annotaatio @PreAuthorize, jolla voidaan määritellä roolit, jotka käyttäjällä tulee olla metodin suorittamiseen. Esimerkiksi annotaatio @PreAuthorize("hasRole('ope')") vaatii, että käyttäjän tulee olla kirjautunut roolissa ope, jotta annotoidun metodin suoritus onnistuu. Annotaatioon @PreAuthorize voidaan määritellä parametrina samoja komentoja kuin aiemminkin, esim. arvot isAuthenticated(), hasRole('...') ja hasAnyRole('...') ovat käytössä. Koska samalla komponentilla voi olla useita toteutuksia, tulee annotaatiot asettaa osaksi rajapintaa.

Tiitinen List

Tiitisen lista on Supon 1990-luvulla saama lista henkilöistä, joiden uskotaan olleen vuorovaikutuksessa Stasin edustajan kanssa. Jatkokehitämme tässä Supon tietojärjestelmää, jotta saamme listan näkyville myös medialle.

Medialle näytettävä sivu

Lisää konfiguraatiotiedostoon security.xml käyttäjätunnus media jonka salasana on media, ja jonka rooli on media.

Muokkaa tämän jälkeen kansiossa WEB-INF/jsp olevaa sivua page.jsp siten, että käyttäjäroolissa media sivulla page.jsp nähdään oikean listan tilalla kovakoodattu lista (myös lisäyslomake on piilossa):

            <ul>
                <li>Susanna Reinboth</li>
            </ul>

Kun käyttäjä kirjautuu sivulle rooleissa admin tai supo sivu käyttäytyy kuten ennen, eli käyttäjälle näytetään tiitisen listan sisältö. Jos käyttäjällä on rooli admin, voi listalla olevia asioita myös poistaa.

Tarkempi pääsynvalvonta

Huomaat järjestelmää jatkokehittäessäsi, että vaikka roolille supo ei näytetä DELETE-nappia, voi kuka tahansa sivuille kirjautunut tehdä HTTP-pyynnön listaelementtien poistamista hallinnoivaan osoitteeseen onnistuneesti. Kun lisäsit media-käyttäjätunnuksen, myös media voi lisätä tai poistaa listan elementtejä sopivia HTTP-pyyntöjä tekemällä -- vaikkakaan itse listan sisältö ei heille näy.

Korjataan tämä ongelma.

Muokkaa front-controller-servlet.xml-tiedostoa siten, että annotaatioilla toimiva metoditason pääsynvalvonta kytketään päälle.

Lisää tämän jälkeen listapalveluun annotaatiot, joilla rajoitetaan metodien kutsuoikeuksia. Metodia create kutsuttaessa tulee olla kirjautunut joko roolilla admin tai supo, metodin remove tulee toimia vain jos käyttäjä on kirjautunut roolilla admin.

Kun olet valmis, testaa sovellustasi ja lähetä se TMC:lle.

Käyttäjien hakeminen tietokannasta

Käyttäjien hakeminen tietokannasta tai muusta palvelusta onnistuu muuttamalla security.xml-tiedostossa olevaa konfiguraatiota. Esimerkiksi jos käytössämme on USERS- ja ROLES-taulut, voimme käyttää erillistä JDBC-yhteyttä käyttäjätunnusten hakemiseen. Kysely tulee toki muokata vastaamaan käytössä olevaa tietokantarakennetta.

    <!-- ... -->

    <sec:authentication-manager>
        <sec:authentication-provider>
            <sec:jdbc-user-service data-source-ref="dataSource"
 		   users-by-username-query="SELECT name,password,enabled FROM USERS WHERE name=?" 
 		   authorities-by-username-query="SELECT u.name, r.authority from USERS u, ROLES r
		      where u.user_id = r.user_id and u.name =?  "/>
        </sec:authentication-provider>
    </sec:authentication-manager>
    <!-- ... -->

Jos pääsynhallinnan haluaa toteuttaa itse, tarjoaa Spring Security tarjoaa rajapinnan UserDetailsService, joka tulee toteuttaa omaa palvelua toteuttaessa. Kts. esim https://github.com/avihavai/wad-2012/blob/master/v6-runko-db/src/main/java/wad/spring/service/WadUserDetailsService.java.

Lisää tietoa tietokannan käytöstä osana autentikaatiota löytyy Spring Securityn dokumentaatiosta.

HTTPS

Usein kommunikointi selaimen ja palvelimen välillä halutaan salata, erityisesti salasanojen lähetyksen yhteydessä.

HTTPS on HTTP-pyyntöjä SSL (nykyisin myös TLS)-rajapinnan yli. HTTPS mahdollistaa sekä käytetyn palvelun verifioinnin sertifikaattien avulla että lähetetyn ja vastaanotetun tiedon salauksen.

HTTPS-pyynnöissä asiakas ja palvelin sopivat käytettävästä salausmekanismista ennen varsinaista kommunikaatiota. Käytännössä selain ottaa ensiksi yhteyden palvelimen HTTPS-pyyntöjä kuuntelevaan porttiin (yleensä 443), lähettäen palvelimelle listan selaimella käytössä olevista salausmekanismeista. Palvelin valitsee näistä parhaiten sille sopivan (käytännössä vahvimman) salausmekanismin, ja lähettää takaisin salaustunnisteen (palvelimen nimi, sertifikaatti, julkinen salausavain). Selain ottaa mahdollisesti yhteyttä sertifikaatin tarjoajaan -- joka on kolmas osapuoli -- ja tarkistaa onko sertifikaatti kunnossa.

Selain lähettää palvelimelle salauksessa käytettävän satunnaisluvun palvelimen lähettämällä salausavaimella salattuna. Palvelin purkaa viestin ja saa haltuunsa selaimen haluaman satunnaisluvun. Viesti voidaan nyt lähettää salattuna satunnaislukua ja julkista salausavainta käyttäen.

Käytännössä kaikki web-palvelimet tarjoavat HTTPS-toiminnallisuuden valmiina, joskin se täytyy ottaa palvelimilla käyttöön. Esimerkiksi Herokussa HTTPS:n saa käyttöön komennolla heroku addons:add piggyback_ssl. Lisätietoa Herokun SSL-tuesta osoitteessa http://devcenter.heroku.com/articles/ssl.

Internationalisointi ja lokalisointi

Internationalisoinnilla (i18n) tarkoitetaan sovelluksen rakentamista siten, että sen toteuttaminen usealla kielellä on mahdollista. Lokalisoinnilla (l10n) taas tarkoitetaan yksttäiselle kieli- tai kulttuurialueelle tehtävää kielitoteutusta.

Sovellusta internatinalisoidessa luodaan oletuskieli, joka ylikirjoitetaan erillisillä lokalisaatioilla. Sovelluksessa lopulta käytettävän kielen valinta tapahtuu ympäristön tai erillisen parametrin avulla. Esimerkiksi Javassa on käytössä Locale-luokka, joka sisältää määrittelyt eri maiden kielille.

Käytännössä internationalisaatio alkaa määrittelemällä sovellukseen viestikohtaiset tägit. Tägeille tehdään kielikohtaiset resurssitiedostot, josta kielikohtaiset tekstit haetaan tägien paikalle. Lokalisoidun käyttöliittymän luominen tapahtuu siis käytännössä tägien ja resurssitiedostojen perusteella.

Javassa on valmiina ResourceBundle-luokka, jota voi käyttää resurssien hakemiseen. Käytännössä resurssit ovat kielikohtaisissa properties-tiedostoissa. Esimerkiksi luokan ResourceBundle staattisella metodilla getBundle haetaan resursseja tietyllä kielellä.

    ResourceBundle resources = ResourceBundle.getBundle("resources", new Locale("fi", "FI"));

Hakee suomenkielisiä resursseja resources-tiedostosta. Käytännössä käytettävän tiedoston nimi yritetään päätellä annettavista parametreista, tarkempi sopivan tiedoston päättelyn kuvaus löytyy getBundle-metodin API-kuvauksesta.

Lokalisaatiotiedoston resources_fi.properties sisältö voi olla esimerkiksi seuraavanlainen:

welcome=Tervetuloa {0}!
currentTime=Tänään on {0} ja kello on {1}.

Aaltosuluilla merkityillä tägeillä merkitään kohdat, johon voidaan lisätä muita parametreja. Esimerkiksi viestin welcome näyttäminen onnistuu MessageFormatter-luokan avulla seuraavasti.

    Locale locale = new Locale("fi", "FI");
    ResourceBundle resources = ResourceBundle.getBundle("resources", locale);
    MessageFormat formatter = new MessageFormat("");
    formatter.setLocale(locale);
 
    formatter.applyPattern(messages.getString("welcome"));
    String output = formatter.format(new Object[] {"Arto"});

    System.out.println(output);

Tulostus näyttää seuraavalta:

Tervetuloa Arto!

Koska dependency injection on jees, ja tämänkin voi tehdä hieman helpommin, käytämme jatkossa Springiä.

Spring-sovellusten lokalisointi

Spring tarjoaa erillisen luokan viestien hakemiseen. Luokka ResourceBundleMessageSource osaa hakea tarvittavat resurssitiedostot sille konfiguroitavasta polusta. Esimerkiksi alla määrittelemme, että lokalisaatiotiedostot alkavat sanalla resources ja sijaitsevat sovelluksen juuripolussa. Käytännössä lokalisaatiotiedostot (esim. resources_fi.properties) asetettaisiin kansioon src/main/resources, josta ne kopioidaan sovellusta käännettäessä oikeaan paikkaan.

    <bean id="messageSource" class="org.springframework.context.support.ResourceBundleMessageSource">
        <property name="basename">
            <value>resources</value>
        </property>
    </bean>

Nyt Springillä on käytössä viestien lataamiseen tarvittava luokka. Jos sovellus ladataan oliokontekstin kautta, voi sen injektoida osaksi sovellusta.

@Component
public class MyApplication {
    private Locale locale;
    @Autowired
    private MessageSource messageSource;

    public void setLocale(Locale locale) {
        this.locale = locale;
    }

    // ...
}

Itse viestien käyttäminen on myös hieman helpompaa. Sovellus lataa käynnistyessään kaikki lokalisoidut tiedostot ja käytettävän tekstin päättely tapahtuu koodissa. Esimerkiksi aiemman welcome-tekstin tulostus tapahtuu seuraavasti:

    // ...
    String name = "Arto";
    System.out.println(messageSource.getMessage("welcome", new Object[] { name }, locale));
    // ...

Tulostettaville viesteille määritellään siis oma tägi, käytettävät parametrit, ja käytettävä kieli.

Gissningslek!

Tuttusi on koodannut ohjelmoinnin peruskurssilla binäärihakua simuloivan arvauspelin. Hänen ulkomaiset kaverinsa eivät kuitenkaan millään ymmärtäneet pelin ideaa, koska peli toimii ainoastaan suomen kielellä. Koska olet jo huomattavasti edistyneempi ohjelmoija, pyysi tuttusi kääntämään pelin myös ruotsin ja englannin kielelle.

Tehtäväpohjassa on valmis arvauspeli, jossa on kiinteät suomenkieliset tekstit.

Kansainvälistämisen konfigurointi

Konfiguroi Springille org.springframework.context.support.ResourceBundleMessageSource-bean tunnuksella messageSource, joka etsii käännöstekstejä haluamastasi polusta. Voit itse valita minkä nimisiin tiedostoihin talletat tekstit.

Käännöstekstien luominen

Käännöstiedostojen viestien avaimet on määritelty etukäteen ja ne ovat seuraavat (suomenkielisillä esimerkeillä):

Voit tarkistaa suomenkieliset tekstit ja niiden käyttötavan tehtäväpohjan luokasta wad.gissningslek.CommandLineGuessingGame.

Luo ohjelmalle käännöstekstitiedostot suomen, ruotsin ja englannin kielelle esimerkkien mukaisesti. Muista käyttää tiedostojen nimissä kielien kaksikirjaimisia tunnuksia. Kiinnitä huomiota paikkoihin, joissa tarvitset viestien interpolaatiota ({n}-merkinnöillä). Ole tarkka kirjoitusasun ja etenkin välilyöntien suhteen, sillä tehtävässä edellytetään täsmälleen oikeita vastauksia!

Ruotsinkielisen arvauspelin tulisi näyttää tältä:

Tänk på något tal i intervallet 0...100.
Jag lovar att klara av att gissa talet du tänkte på med 7 frågor.

Nu frågar jag dig en serie frågor. Svara dem ärligt.

Är ditt tal större än 50? (j/n)
n
Är ditt tal större än 25? (j/n)
n
Är ditt tal större än 12? (j/n)
j
Är ditt tal större än 19? (j/n)
j
Är ditt tal större än 22? (j/n)
n
Är ditt tal större än 21? (j/n)
j
Talet du tänkte på var 22.

Englanninkielisen arvauspelin tulisi näyttää tältä:

Choose a number in interval 0...100.
I promise that I can guess the number you chose by asking you 7 questions.

I'm going to ask you a series of questions. Please answer them honestly.

Is the number greater than 50? (y/n)
y
Is the number greater than 75? (y/n)
n
Is the number greater than 63? (y/n)
n
Is the number greater than 57? (y/n)
y
Is the number greater than 60? (y/n)
n
Is the number greater than 59? (y/n)
n
Is the number greater than 58? (y/n)
n
The number you chose was 58.

Pelin kansainvälistäminen

Muokkaa tehtäväpohjan luokkaa wad.gissningslek.CommandLineGuessingGame siten, että haet kaikki tulostettavat tekstit MessageSource-rajapinnan avulla käyttäen luokalle annettua locale-määrittelyä. Pelin logiikkaa ei tule muuttaa.

Voit vaihtaa ohjelman käyttämää kieltä muokkaamalla luokan wad.gissningslek.Main määrittämää localea.

Huom! Myös käyttäjältä vastaanotettava vastaus kysymykseen riippuu valitusta kielestä!

Web-sovellusten internationalisointi

Web-sovellusten internationalisointi tapahtuu samalla lähestymistavalla kuin komentorivisovelluksen lokalisointi. Käyttöliittymätekstit muutetaan tageiksi, ja lokalisoidut tekstit tallennetaan niille sopivaan paikkaan. Web-sovellusten lokalisointiin käytetään usein luokkaa ReloadableResourceBundleMessageSource, joka toimii lähes samoin kuin aiemmin näkemämme ResourceBundleMessageSource. Itse konfiguraatio asetetaan osaksi front-controller-servlet.xml-tiedostoa.

Alla on määritelty konfiguraatio, joka hakee resources-nimisiä resurssitiedostoja sovelluksen juuripolusta. Tiedostot kopioidaan juuripolkuun kansiosta src/main/resources. Tämän lisäksi konfiguraatiolle on määritelty oletusmerkistöksi UTF-8.

    <bean id="messageSource" class="org.springframework.context.support.ReloadableResourceBundleMessageSource">
        <property name="basename" value="classpath:resources" />
        <property name="defaultEncoding" value="UTF-8" />
    </bean>

Jotta web-sovellus osaisi pitää kirjaa käytettävästä kielestä, tallennetaan valittu kieli evästeeseen. Spring tarjoaa tähän tarkoitukseen valmiin luokan CookieLocaleResolver. Alla olevassa konfiguraatiossa on lisäksi määritelty oletuskieleksi englanti.

    <bean id="localeResolver" class="org.springframework.web.servlet.i18n.CookieLocaleResolver">
        <property name="defaultLocale" value="en" />
    </bean>

Sovelluksen kielen vaihtaminen onnistuu helposti lisäämällä ylimääräinen pyynnön käsittelijä. Web-sovelluksiin kohdistuviin pyyntöihin voidaan määritellä (Javan standardilähestymistavan, eli filttereiden) lisäksi interceptor-olioita. Luokka LocaleChangeInterceptor prosessoi palvelimelle tulevat pyynnöt, ja vaihtaa kieltä tarvittaessa. Käytännössä kielen vaihto tapahtuu esimerkiksi lisäämällä polkuun parametri locale. Esimerkiksi pyyntö osoitteeseen http://..sovellusjne../polku?locale=fi vaihtaa kieleksi suomen.

    <mvc:interceptors>
        <bean id="localeChangeInterceptor" class="org.springframework.web.servlet.i18n.LocaleChangeInterceptor" />
    </mvc:interceptors>

Internationalisointia varten tarvitsemme myös erillisen tägin käyttöliittymän viestien asetukseen. Spring tarjoaa tähän valmiin tägikirjaston.

<%@taglib prefix="spring" uri="http://www.springframework.org/tags" %>

Tägikirjastoa käytetään seuraavasti. Attribuutin code arvo on aina näytettävän viestin avain. Attribuutille code lisätään argumentteja attribuutin arguments avulla.

    <spring:message code="welcome" arguments="${name}"/>

Parlez vous Français?

Euroopan Unionin kielikomissio haluaa selvittää kuinka monta EU-jäsenmaan kansalaista osaa puhua ranskaa. Web-sovellusta varten on luotu prototyyppi, johon on kovakoodattu englanninkielinen versio sovelluksesta. Hyvä ystäväsi Jean-Jacques-Antoine Courtois on myös ystävällisesti suunnitellut kielitiedostoissa käytettävät avaimet ja tehnyt ranskankielisen käännöksen sovellukseen vaadittavista ranskankielisistä viesteistä. Löydät tiedoston Netbeansistä Other Sources-kansion alta polusta src/main/resources/bundles/messages_fr.properties.

Konfiguraatio

Muokkaa konfiguraatiotiedostoa front-controller-servlet.xml ja lisää sinne seuraavat konfiguraatiot:

Ensimmäinen konfiguraatio mahdollistaa uudestaan ladattavien property-tiedostojen (tässä tapauksessa kielitiedostojen) käytön. Toinen konfiguraatio asettaa nykyisen lokaalin evästeen avulla ja kolmas konfiguraatio vaihtaa lokaalin (ja evästeen) kun sovelluksessa mihin tahansa polkuun lisätään parametri ?locale=KIELIKOODI.

Näkymien internationalisointi

Sinun täytyy tehdä lokalisaatiotiedostot suomen- ja englanninkielistä lokalisaatiota varten. Katso mallia tarjotusta ranskankielisestä lokaalisaatiotiedostosta. Lokalisoi nyt näkymät question.jsp, yes.jsp ja no.jsp käyttämään tiedostoissa määritettyjä viestejä.

Testaa lopuksi sovellus huolellisesti selaimessa. Voit vaihtaa kielen lisäämällä mihin tahansa polkuun parametrin ?locale=KIELIKOODI. Asetus muistetaan evästeen avulla.

Makupala selainohjelmointiin

Huomattava osa nykyaikaisista web-palveluista lataavat käyttäjälle palvelimelta vain staattisen käyttöliittymän. Käyttöliittymä sisältää joukon JavaScript-komponentteja, jotka tuovat ja vievät dataa tarvittaessa käyttäjälle -- esimerkiksi JSON-muodossa. Seuraavassa tehtäväsarjassa toteutetaan palvelintoiminnallisuus erillisille selainpuolen sovellukselle. Tehtävän sovelluksessa käytettävää selainpuolen toiminnallisuutta toteutetaan näillä näkymin kurssin Web-selainohjelmointi noin viikolla 4.

Tökkel

Kurssin viimeisessä ohjelmointitehtävässä toteutetaan palvelinpuolen REST-rajapinta pienelle selaimessa toimivalle tehtävänhallintasovellukselle. Kuten huomaat ylläolevasta kuvakaappauksesta, sovelluksen käyttöliittymä alkaa muistuttaa jo enemmän nykyisiä web-sovelluksia pelkän mustan tekstin ja valkoisen taustan sijaan.

Sovelluksessa on kaksi eri näkymää: projektit (projects) ja tehtävät (tasks). Tehtäviä voi luoda tehtävälistaan täysin ilman projekteja tai luokitella niitä eri projektien alle. Tehtävän voi merkitä aloitetuksi, jolloin selain laskee sekunteja aloitusajankohdasta. Tehtävän lopettamisen jälkeen listauksessa näkyy tehtävän aloitus- ja lopetusaika.

Sovelluksen käyttöliittymä toimii lähes täysin eri tavalla kuin aiempien tehtävien käyttöliittymät: palvelimelta ei ladatakaan JSP-sivuja, vaan käyttöliittymän JavaScript-koodi kommunikoi suoraan palvelimen REST-rajapinnan kanssa JSON-muotoisilla viesteillä. REST-rajapinnan ja JSON-tietomuodon käyttö onkin yleistä nykyaikaisissa web-sovelluksissa juuri sen takia, että JavaScript-koodilla on helppo käyttää tällaista rajapintaa. Lisäksi selaimessa näytettävää sivua ei koskaan vaihdeta tai ladata uudelleen -- selaimen näyttämän sivun sisältöä ainoastaan muutetaan käyttäjän tekemien toimintojen perusteella.

Tehtäväpohjassa on valmiina ainoastaan JavaScriptillä ja HTML:llä tehty käyttöliittymä, jota ei tarvitse muuttaa. Loppu tehtävä on jätetty avoimeksi siten, että palvelinpuolen tulee toteuttaa käyttöliittymän vaatima REST-rajapinta projektien ja tehtävien hallinnointiin.

Huom! Tämän tehtävän testit tarkistavat ainoastaan REST-rajapinnan toiminnan, joten (kuten jo useassa aiemmassakin tehtävässä) tehtävään kirjoitettua koodia ei tarkisteta mitenkään. Joudut siis käynnistämään sovelluksen itse ja kokeilemaan käyttöliittymää, jotta voit todeta mitkä ominaisuudet toimivat oikein ja saada tarvittaessa selkeitä virheilmoituksia poikkeustilanteista.

JavaScript- ja HTML-pohjaiset käyttöliittymät

Tökkel-sovelluksen käyttöliittymää ei suinkaan ole toteutettu kokonaan itse, vaan siinä käytetään lukuisia valmiita komponentteja:

Projektien hallinta

Tehtäväpohjassa on valmiina tyhjät pakkaukset ohjelman luokille aiempien tehtävien tapaan. Valmis Spring-konfiguraatio etsii Spring Data JPA:n repository-luokkia pakkauksesta wad.tokkel.repositories.

Toteuta projektien hallintaan entiteetti, muut apuluokat, sekä controller-luokka, joka tarjoaa REST-rajapinnan projektien käsittelyyn:

Uusi projekti luodaan JSON-muotoisella pyynnöllä esimerkiksi näin:

{"name": "Wadillinen projekti"}

Sovelluksen tallettama projekti näyttää esimerkiksi tältä:

{
    "id": 4,
    "name": "Wadillinen projekti",
    "creationTime": 1349682202493
}

Attribuutti creationTime voidaan toteuttaa javassa java.util.Date-luokan avulla, kuten aiempienkin tehtävien aikaleimat.

Projektien hallinnan pitäisi toimia käyttöliittymässä, joten testaa nyt ohjelmaa selaimessa!

Tehtävien hallinta

Huom! Tässä tehtävän kohdassa tehtäviä käsitellään vielä irrallaan projekteista eli tehtävää varten valittua projektia ei oteta vielä huomioon!

Toteuta tehtävien hallintaan entiteetti, muut apuluokat, sekä controller-luokka, joka tarjoaa REST-rajapinnan tehtävien käsittelyyn:

Uusi tehtävä luodaan JSON-muotoisella pyynnöllä esimerkiksi näin:

{"description": "Keitä kahvia"}

Sovelluksen tallettama tehtävä näyttää esimerkiksi tältä:

{
    "id": 5,
    "description": "Keitä kahvia"
}

Kokeile nyt luoda ja tuhota tehtäviä selaimella sovelluksen käyttöliittymässä.

Tiettyyn projektiin liittyvien tehtävien hallinta

Laajenna tehtävä-entiteettiä siten, että projekti voi sisältää tehtäviä. Tähän riittää yksisuuntainen viite tehtävästä projektiin, eli käytännössä tehtävän tulee ainoastaan tietää mihin projektiin se kuuluu.

Muokkaa myös projektien poistamista siten, että projektia poistettaessa kaikki siihen kuuluvat tehtävät poistetaan.

Laajenna tehtävien käsittelyyn käytettävää REST-rajapintaa seuraavilla toiminnoilla:

Uusi tehtävä luodaan haluttuun projektiin JSON-muotoisella pyynnöllä esimerkiksi näin:

{
    "description": "Muista nukkua!"
}

Huom! Käyttöliittymän toteutuksen takia REST-rajapintaan täytyy tehdä yksi poikkeusratkaisu, jolla tehtäviä tulee voida liittää projekteihin myös seuraavalla tavalla:

Tässä poikkeustapauksessa talletettavan tehtävän projektin avain tulee aina toimittaa JSON-pyynnössä kuten alla:

{
    "description": "Muista nukkua!",
    "projectId": 4
}

Sovelluksen tulee siis lukea annettu projectId-attribuutti ja yhdistää tehtävä projectId-avaimen mukaiseen projektiin. Jos (kuten aiemmissa tehtävissä on neuvottu) käytät Springin @RequestBody-annotaatiota vastaanottamaan JSON-muotoisen pyynnön tiedot suoraan entiteettiolioon, on projektin avaimen vastaanottaminen mahdollista toteuttaa lisäämällä entiteettiin getter- ja setter-metodit pelkälle id-avaimelle. Tällöin myös palvelimen vaste tulee automaattisesti sisältämään projektin avaimen. Tarkoitus ei ole kuitenkaan tallettaa avainta erikseen tietokantaan, koska viittaus projektiin tulee tehdä käyttämällä JPA-annotaatioita. Jotta tiettyä oliomuuttujaa ei talletettaisi tietokantaan, voidaan se merkitä @Transient-annotaatiolla:

public class Task {
    // ...

    @Transient
    private Integer projectId;

    public Integer getProjectId() {
        return projectId;
    }

    public void setProjectId(Integer projectId) {
        this.projectId = projectId;
    }

    // ...
}

Sovelluksen tallettama tehtävä näyttää esimerkiksi tältä (luotiin se kummalla tahansa tavalla yllämainituista):

{
    "id": 6
    "description": "Muista nukkua!",
    "projectId": 4
}

Huom! Tiettyyn projektiin kuuluvan tehtävän JSON-esityksen tulee aina sisältää projectId-attribuutti, myös tehtävälistausta tai yksittäisiä tehtäviä haettaessa.

Kokeile nyt sovelluksen käyttöliittymässä lisätä tehtäviä projekteihin!

Tehtävän aloittaminen ja lopettaminen

Lisätään sovellukseen lopuksi mahdollisuus merkitä tehtävä aloitetuksi ja lopetetuksi.

Laajenna tehtävä-entiteettiä siten, että se sisältää tehtävän aloitusajan startedTime ja lopetusajan stoppedTime. Käytä jälleen java.util.Date-luokkaa aikaleimojen tallettamiseen.

Laajenna tehtävien käsittelyyn käytettävää REST-rajapintaa seuraavilla toiminnoilla:

Aiemmin luotu tehtävä aloitetaan JSON-muotoisella PUT-pyynnöllä esimerkiksi osoitteeseen /app/tasks/6:

{"start": true}

Palvelin palauttaa aloitetun tehtävän tiedot:

{
    "id": 6,
    "description": "Muista nukkua!",
    "startedTime": 1349684486981,
    "stoppedTime": null,
    "projectId": 4
}

Paluuviestissä startedTime kertoo aloitusajankohdan. Lopetusajankohta stoppedTime on null-viite, koska tehtävää ei ole vielä lopetettu.

Aiemmin aloitettu tehtävä lopetetaan JSON-muotoisella PUT-pyynnöllä esimerkiksi osoitteeseen /app/tasks/6:

{"stop": true}

Palvelin palauttaa lopetetun tehtävän tiedot:

{
    "id": 6,
    "description": "Muista nukkua!",
    "startedTime": 1349684486981,
    "stoppedTime": 1349684668320,
    "projectId": 4
}

Huom! Voit toteuttaa start- ja stop-pyyntöjen vastaanottamisen vastaavasti kuin tehtävän edellisen kohdan projectId-avaimen väliaikaisen talletuksen käyttämällä @Transient-annotaatiota entiteetissä.

Testaa lopuksi tehtävien aloitusta ja lopettamista sovelluksen käyttöliittymässä!

Kurssikoe 16.10. 16:00-18:30, A111

Tekemisellä on ollut hyvin iso rooli kurssilla. Kurssin pisteistä 60% saa kurssin tehtävistä, loput kokeesta. Tämä tarkoittaa käytännössä sitä, että kurssilla vaadittu ohjelmointiosaaminen on varmistettu ja arvosteltu jo kurssin aikana. Ohjelmointi- tai konfigurointitehtäviä ei ole kokeessa. Kokeessa keskitytään teorian ja taustakäsitteiden ymmärryksen ja niiden selitystaidon varmistamiseen.

Tiistain luennolla äänestetään kahdesta koevaihtoehdosta:

Vaihtoehto a) Koepaperissa on 9 teoriakysymykstä, joista tulee vastata kuuteen. Yksittäisen vastauksen maksimipituus on 2 sivua ja yksittäisestä kysymyksestä voi saada maksimissaan 4 pistettä.

Vaihtoehto b) Koepaperissa on 5 teoriakysymystä, joista tulee vastata neljään. Yksittäisen vastauksen maksimipituus on 4 sivua, ja yksittäisestä tehtävästä voi saada maksimissaan 6 pistettä.

Kurssilla on käytössä koeleikkuri, joten kokeesta tulee saada yhteensä vähintään 12 pistettä.

Kuvien piirtäminen on hyvin suositeltua kummassakin vaihtoehdossa. Luennolla valittiin vaihtoehto a, eli kokeessa tulee olemaan 9 teoriakysymystä, joista tulee vastata kuuteen.

Luennolla pohdittiin myös sitä, millaisia tyypilliset koekysymykset voisivat olla. Osallistujilta tuli mm. seuraavia ehdotuksia:

Huom! Kokeessa tulee olemaan myös ylimääräinen tehtävä (max 6p). Ylimääräisellä tehtävällä voi korvata viikon, jolta on kerännyt vähiten pisteitä. Koe on kuitenkin ajoitettu siten, että ylimääräiseen tehtävään ei ole varsinaisesti aikaa. Vastaus ylimääräiseen tehtävään onnistunee jos varsinaisten vastausten jäsentelyyn ei tarvitse juurikaan käyttää aikaa. Ylimääräisestä tehtävästä saatuja pisteitä ei huomioida koeleikkurissa.

Kurssipalaute

Käy antamassa kurssista palautetta laitoksen palautejärjestelmään. Mitä olisit toivonut enemmän, mitä vähemmän, mikä meni hyvin ja mitä pitäisi vielä parantaa? Palautejärjestelmän osoite on https://ilmo.cs.helsinki.fi/kurssit/servlet/Lomake

Koska palautteet ovat anoynyymejä, tästä tehtävästä ei saa pisteitä.