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.
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-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.
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.
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ö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.
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.
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.
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.
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 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.
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.
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/.
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.
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
"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).
"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 (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 -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.
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?
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-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
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 (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-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, 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 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?
"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.
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.
<input type="text" />
<input type="password" />
<textarea name="tekstialue"></textarea>
<input type="checkbox" name="porkkanaa" /> Porkkanaa <br/> <input type="checkbox" name="naurista" /> Naurista <br/> <input type="checkbox" name="kaalia" /> Kaalia <br/>
value
arvo lähetetään name
attribuutin arvona.
<input type="radio" name="valinta" value="porkkanaa"/> Porkkanaa<br/> <input type="radio" name="valinta" value="naurista"/> Naurista<br/> <input type="radio" name="valinta" value="kaalia"/> Kaalia<br/>
<input type="submit" value="Lähetä" />
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>
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.
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.
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 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.
Sovelluksemme tavoite on huikea: haluamme saada "Hello World!"-tekstin käyttäjälle näkyviin. Aloitetaan nollasta, eli projektin luomisesta.
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.
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.
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(); }
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!
.
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.
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.
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
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.
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
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:
Viesti: ok, ehkä tässä on ideaa
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.
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:
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ön mukana voi lähettää parametreja palvelimelle. GET-tyyppisissä pyynnöissä parametrit ovat osana osoitetta. Esimerkiksi pyyntö osoitteeseen http://palvelin.net/sovellus?parametri1=arvo1¶metri2=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
.
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.
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%
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
.
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?
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 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>
.
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}
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!
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 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.
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:
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.
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.
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
.
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.
Tutustutaan seuraavaksi web-sovellusten ohjelmoinnissa käytettäviin perusohjelmointikäytänteisiin. Pohjustamme myös siirtymistä Spring-sovelluskehyksen käyttöön.
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.
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 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.
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ä.
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.
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.
mvn jetty:start
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.
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 <
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 (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ä.
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ä:
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.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.
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.
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.
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...).
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.
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.:
new
-kutsuja ei juurikaan tarvitse tehdä.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.
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.
Harjoitellaan seuraavaksi omien automaattisesti oliokontekstiin ladattavien luokkien luomista.
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
.
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 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
.
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:
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
Tässä tehtävässä rakennetaan Spring WebMVC-kehyksen avulla toimiva dynaaminen verkkosivu. Tehtävä koostuu controller-luokasta sekä JSP-sivusta.
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:
spring-webmvc
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.
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.
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"; } // ...
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.
Tehtävässä rakennetaan salasanageneraattori, jolta saa uuden salasanan aina kun generaattorin verkkosivu ladataan.
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ä.
Konfiguroi Spring lisäämällä tiedostoon front-controller-servlet.xml
edellistä tehtävää vastaava konfiguraatio:
InternalResourceViewResolver
, joka etsii JSP-tiedostoja polusta /WEB-INF/jsp/
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.
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
.
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) { // ... } // ...
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"; } // ...
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.
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:
String viewLoginPage()
vastaa GET-pyyntöihin osoitteessa login
ja palauttaa näkymän login.jsp
String login(String username, String password, HttpSession session)
vastaa POST-pyyntöihin osoitteessa login
, sekä:
username
ja password
käyttämällä @RequestParam
-annotaatiotasecret
, metodi tekee uudelleenohjauksen osoitteeseen login
— annetulla käyttäjätunnuksella ei ole väliäusername
ja password
secret
String viewSecretPage(Model model, HttpSession session)
vastaa GET-pyyntöihin osoitteessa secret
, sekä:
secret
, metodi tekee uudelleenohjauksen osoitteeseen login
— annetulla käyttäjätunnuksella ei ole väliä. Huom! HttpSession-olion getAttribute-metodin palauttama arvo voi olla myös null. Ohjaa tällöin myös käyttäjä osoitteeseen login.Model
-attribuuttiin username
, jotta se voidaan näyttää salaisella sivullasecret.jsp
String logout(HttpSession session)
vastaa GET-pyyntöihin osoitteessa logout
, tuhoaa (eli invalidoi) HTTP-session ja tekee uudelleenohjauksen osoitteeseen login
Ohjelmaa rakentaessa sitä kannattaa testata itse selaimessa, sillä siten sen toiminta selviää parhaiten!
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.
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
.
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
.
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?
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.
Jos sovelluksessasi on merkistöongelma, aloita seuraavista tarkistuksista:
<%@page pageEncoding="UTF-8" contentType="text/html; charset=UTF-8"%>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
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?
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 | ||
---|---|---|
id | nimi | huone |
1 | Arto | B215 |
2 | Matti | D232 |
3 | Mikael | B215 |
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:
Atomicity
): tietokantaan tehtävä kyselysarja suoritetaan kokonaan, tai sitä ei suoriteta lainkaan. Tähän liittyy käsite transaktio. Tietokantatransaktioiden avulla voidaan suorittaa joukko tietokantakyselyitä siten, että jos yksikin kysely epäonnistuu, yhtäkään ei suoriteta (palataan aiempaan tilaan). Esimerkiksi tilisiirtoja suorittaessa tehdään useampia käskyjä: "Ota rahaa tililtä 1, laita rahaa tilille 2" -- jos vain toinen onnistuu, rahat katoavat.Consistency
): tietokannan tulee olla jokaisen transaktion jälkeen eheässä ja johdonmukaisessa tilassa. Esim. tietokantaan tehtävien muutosten tulee näkyä muille tietokantaa käyttäville ohjelmille aina samalla tavalla, johdonmukaisesti.Isolation
): Tietokantaan tehtävät transaktiot eivät saa vaikuttaa toisiinsa, ja kesken oleva transaktio ei saa näkyä muille transaktioille.Durability
): Kun transaktio on suoritettu loppuun, tehdyt muutokset eivät saa kadota järjestelmästä. 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 (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 | ||
---|---|---|
id | nimi | huone |
1 | Arto | B215 |
2 | Matti | D232 |
3 | Mikael | B215 |
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-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.
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
.
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.
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.
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.
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 | |
---|---|
name | password |
Arto | wateva! |
Matti | rails! |
Mikael | play! |
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!
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 JdbcTemplate
n 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()); } // ...
Toteutetaan tässä tehtävässä tietokantatoiminnallisuutta Springin JDBC Templaten avulla.
listCourses
ja listCoursesByName
Toteuta luokkaan CourseDatabase
metodit listCourses
ja listCoursesByName
.
listCourses
tulee hakea jdbcTemplate
-olion avulla tietokannasta kaikki kurssit, ja antaa tuloksena saatu lista metodin printCourses
tulostettavaksi.listCoursesByName
tulee hakea jdbcTemplate
-olion avulla tietokannasta kurssit, joiden nimi (name
) on parametrina annettu merkkijono. Tulosta kyselyn tuloksena saatu lista metodin printCourses
avulla.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.
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 (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; // ...
Täydennä musiikkialbumia kuvaavaa luokkaa wad.inmemorydao.Album
siten, että sillä on seuraavat attribuutit, sekä vastaavat getter- ja setter-metodit:
id
- uniikki merkkijonotunnus, jonka perusteella albumi voidaan tunnistaaartist
- albumin artistin niminame
- itse albumin nimiyear
- albumin julkaisuvuosi int
-kokonaislukunaLuo 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:
create
tallettaa annetun albumin hajautustauluun. Jotta albumeille saataisiin yhdenmukaiset uniikit ID-tunnisteet, on järkevää generoida ja asettaa ne tässä metodissa. Käytä ID-tunnisteena Javan tarjoamaa generaattoria UUID.randomUUID()
. Jos parametrina annetulla albumilla on jo tunniste, tulee metodin heittää poikkeus IllegalArgumentException
.update
päivittää hajautustauluun uuden albumin ID-tunnuksen perusteella. Jos parametrina annetulla albumilla ei ole tunnistetta, tulee metodin heittää poikkeus IllegalArgumentException
.read
palauttaa albumin, jolla on annettu ID-tunnus. Jos ID-tunnuksella ei löydy albumia, palauttaa metodi null
-viitteen.delete
poistaa albumin, jolla on annettu ID-tunnus. Jos ID-tunnuksella ei löydy albumia, metodi ei tee mitään.list
palauttaa listan talletetuista albumeista. Jotta metodi toimisi oikein, tulee sen kopioida hajautustaulussa olevat albumit uuteen listaan ja palauttaa se. Palautettujen albumien järjestyksellä ei ole merkitystä.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:
populateAlbums
luo ja tallettaa vähintään neljä albumia, joiden nimi, artisti ja vuosi tulee olla määriteltyjä. Vuoden tulee olla suurempi kuin 1900. Voit itse keksiä albumien loput tiedot.deleteAlbumsFromArtist
poistaa albumit, joiden artistina on metodille parametrina annettu artistin nimiHuom! 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.
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>
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 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.
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
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
).
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>
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>
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.
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 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 (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ä.
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 }
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.
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.
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> ????????
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ää.
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.
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.
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.
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" />
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:
String view(Model model)
kuuntelee GET-tyyppisiä pyyntöjä osoitteeseen view
. Pyynnössä ei ole parametreja. Metodi view
saa parametrinaan Model
-olion, jonka attribuuttiin items
tulee asettaa kaikki rajapintaluokasta ItemRepository
metodilla findAll
saatavat esineet. Metodi palauttaa view
-merkkijonon, jolloin käyttäjälle näytetään /WEB-INF/jsp/view.jsp
-tiedosto.String add(String name)
kuuntelee POST-tyyppisiä pyyntöjä osoitteeseen add
. Pyynnössä tulee olla parametri nimeltä name
, jonka pohjalta luodaan uusi Item
-olio, joka tallennetaan rajapintaluokan ItemRepository
avulla. Uuden Item
-olion count
-muuttujan arvon tulee olla aina aluksi 1. Pyyntö ohjataan lopulta view
-osoitteeseen.String increaseCount(String name)
kuuntelee POST-tyyppisiä pyyntöjä osoitteeseen increaseCount
. Pyynnössä tulee olla parametri nimeltä itemId
. Pyynnön parametria itemId
käytetään Item
-olion etsimiseen ItemRepository
-rajapintaluokan avulla. Kun olio on löytynyt, kasvata sen count-arvoa yhdellä ja ohjaa pyyntö view
-osoitteeseen. Muistathan varmistaa että olion uusi arvo tallennetaan myös tietokantaan (esim. ItemRepository-oliota käyttämällä..).Kun sovelluksesi toimii, lähetä se TMC:lle. Vertaa myös tarvitsemasi tietokantalogiikkaan liittyvän koodin määrää edellisiin tehtäviin.
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.
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) { // ...
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.
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.
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 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.
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ä.
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.
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"; } }
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.
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:
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/
.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/
.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 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ä.
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?
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:
name
) tulee olla vähintään 4 merkkiä pitkä ja enintään 30 merkkiä pitkä.address
) tulee olla vähintään 4 merkkiä pitkä ja enintään 50 merkkiä pitkä.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:
nameError
, arvo: Length of name should be between 4 and 30
.addressError
, arvo: Length of address should be between 4 and 50
.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
.
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.
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.
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.validation
APIn (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, 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.
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.
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 />
.
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
.
name
) tulee olla vähintään 4 merkkiä pitkä ja enintään 30 merkkiä pitkä.address
) tulee olla vähintään 4 merkkiä pitkä ja enintään 50 merkkiä pitkä.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.
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.
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 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.
Tällä hetkellä käyttäjän syöttämiä tietoja ei validoida millään tavalla. Lisää sovellukseen tilauksen (Order
) validointi seuraavasti:
name
) tulee olla vähintään 4 merkkiä ja enintään 30 merkkiä pitkä.address
) tulee olla vähintään 4 merkkiä ja enintään 50 merkkiä pitkä.items
) ei saa olla tyhjä.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.
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 (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.
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:
{id}
identifioidun henkilöt tiedot.{id}
-tunnuksella merkityn henkilön tietoja. Henkilön tiedot lähetetään pyynnön rungossa.{id}
.// 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.
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ö.
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.
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 Date
ksi. Kätevää! Jotta @DateTimeFormat
toimii, on pom.xml
ään lisätty jo valmiiksi riippuvuus pakettiin joda-time
.
Attribuuteista:
id
- Automaattisesti generoituva Long
-tyyppinen avain, käytä annotaatiota @GeneratedValue
strategialla AUTO
.start
- Käytä annotaatiota @Temporal
tyypillä DATE
. Haluamme myös validoida, ettei attribuutti voi olla null.end
- Kuten edeltävä attribuutti.feeling
- Halumme validoida, ettei attribuutti voi olla tyhjä tai null.Muista myös nimetä jokaisen attribuutin tietokantataulun sarakkeiden nimet!
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
.
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 SleepRepository
n toteuttava luokka. Injektoitavasta luokasta löytyy tarvittavat metodit JpaSleepService
n toteuttamiseen.
Muista määrittää luokan JpaSleepService
n metodeille @Transactional
-annotaatiot. Määritä annotaatiolle myös boolean-tyyppiset parametrit readOnly
sen mukaan muuttaako metodi tietokannassa olevaa tietoa vai ei.
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 SleepServicen
n toteuttava luokka. Luokan metodien tulee toimia seuraavanlaisesti:
public String create(Model model, Sleep sleep, BindingResult result)
sleeps
.Sleep
entiteetti. Käytä @ModelAttribute
-annotaatiota ja varmista, että entiteetti on validi. @ModelAttribute
n arvona käytetään lomakkeeseen määriteltyä commandName
a.result
oliossa on virheitä, lisää model
iin parametrissa saatu sleep
entiteetti attribuutilla sleep
. Tällöin metodin tulee palauttaa näkymä index
virheiden näyttämistä varten.Sleep
entiteetin tietokantaan ja ohjaa käyttäjän osoitteeseen /app/sleeps
.public String read(Model model, Long id)
sleeps/{id}
, polussa tulee haettavan Sleep
entiteetin ID.model
iin attribuutilla sleep
.sleep
.public String delete(Long id)
sleeps/{id}
, polussa tulee poistettavan Sleep
entiteetin ID.Sleep
entiteetin poistamiseen./app/sleeps
.public String list(Model model)
sleeps
.Sleep
entiteeteistä ja lisää ne model
iin attribuutilla sleeps
.sleeps
.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!
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 (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(); } }
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:
gull
palauttaa JSON-muotoisen listan tietokannassa olevista havainnoista. Käytä GullSightingService-rajapintaa havaintojen noutamiseen.gull/{tunnus}
palauttaa JSON-muotoisen esityksen tunnus
-polkumuuttujan identifioimasta havainnosta. Käytä olemassaolevaa GullSightingService-rajapintaa havainnon noutamiseen.gull
tallentaa uuden GullSighting
-olion GullSightingService-rajapinnan avulla. Pyynnön voi ohjata esimerkiksi osoitteeseen gull/{juuriLuodunHavainnonId}
gull/{tunnus}
poistaa tunnus
-merkkijonon identifioiman havainnon. Ohjaa pyyntö poiston jälkeen osoitteeseen /gull
.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.
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ää.
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.
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:
id
(automaattisesti generoitu numeerinen avain, käytä annotaatiota @GeneratedValue
strategialla AUTO
)nickname
(käyttäjän nimimerkki)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:
id
(automaattisesti generoitava numeerinen avain, käytä annotaatiota @GeneratedValue
strategialla AUTO
)user
(@ManyToOne
-viittaus snippetin lähettäneeseen käyttäjään, eli entiteettiin JpaUser
)timestamp
- snippetin talletusajankohtacontent
- snippetin tekstisisältö -- määrittele sarakkeen maksimipituudeksi yli 30000 merkkiä @Column
-annotaation length
-parametrilla, jotta sarakkeeseen mahtuu paljon tekstiä, ja käytä @Lob
-annotaatiota, sillä se mahdollistaa suurien tekstimäärien talletuksen tietokantaanTee 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:
List<T<>
-olio, jossa T
on entiteetin luokka, jos tarkoitus on saada useampia vastauksia -- esim. JpaUser
, jos halutaan tuloksen yksittäinen käyttäjäfindByX
, jossa X
on entiteetin attribuutin nimi (alkukirjain isolla), johon haku kohdistuu -- esim. findByNickname
, jos halutaan tehdä haku nimimerkin perusteellaString nickname
, kun haetaan tiettyä nimimerkkiäSovelluksessa tarvitaan seuraavat kyselyt:
JpaUserRepository
-rajapintaan tulee lisätä metodi yksittäisen käyttäjän hakemiseen nimimerkin perusteellaJpaSnippetRepository
-rajapintaa tulee lisätä metodi kaikkien tietyn käyttäjän snippetien hakemiseen (käyttäjän perusteella) -- lisäksi metodille tulee antaa parametrina järjestämiseen käytettävä olio, joka on tyyppiä org.springframework.data.domain.Sort
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:
findAll()
metodin tulee listata kaikki käyttäjätfindByNickname(String nickname)
metodin tulee hakea yksittäinen käyttäjä nimimerkin perusteella (mikä on jo toteutettu repository-tasolla)Luo palveluluokka wad.pastebin.service.JpaSnippetService
, joka toteuttaa rajapinnan SnippetService
. Toteuta luokka edellisen palveluluokan ohjeiden mukaisesti seuraavin poikkeuksin:
findAll()
metodin tulee listata kaikki snippetitfindByUser(User user)
metodin tulee listata kaikki tietyn käyttäjän snippetitTavallisesti 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:
JpaUserService.findAll()
metodin tulee palauttaa käyttäjät aakkosjärjestyksessäJpaSnippetService.findAll()
metodin tulee palauttaa snippetit käänteisessä aikajärjestyksessä, uusin ensinJpaSnippetService.findByUser(User user)
metodin tulee palauttaa snippetit käänteisessä aikajärjestyksessä, uusin ensinHuom! 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:
createSnippet(String nickname, String content)
snippets
nickname
ja content
JpaUser
käyttäjän annetulle nimimerkille -- jos vastaavaa käyttäjää ei löydy, luo metodi uuden käyttäjän annetulla nimimerkilläStringEscapeUtils.escapeHtml4
-metodin avullasnippets/[id]
, jossa näytetään kyseinen snippet -- [id]
tulee olla juuri luodun snippetin numeerinen avainlistSnippets(Model model)
snippets
model
-instanssiin avaimella snippets
list-snippets
viewSnippet(Model model, Long snippetId)
snippets/{snippetId}
, jossa {snippetId}
on haettavan snippetin numeerinen avainsnippets
model
-instanssiin avaimella snippet
view-snippet
listSnippetsByUser(Model model, String nickname)
users/{nickname}/snippets
, jossa {nickname}
on haettavan käyttäjän nimimerkkiusers
model
-instanssiin avaimella user
model
-instanssiin avaimella snippets
list-snippets-by-user
listUsers(Model model)
users
model
-instanssiin avaimella users
list-users
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ä?
Tähän mennessä toteuttamissamme sovelluksissa pyynnön suorittaminen on tapahtunut seuraavasti:
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:
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.
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öä!
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.
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); } // ...
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.
Sovelluksen varastoimilla kuvilla (eli kuvan entiteetillä) tulee olla seuraavat attribuutit:
id
- numeerinen avaindescription
- käyttäjän antama tekstimuotoinen kuvauscontentType
- kuvatiedoston mediatyyppifileName
- kuvatiedoston alkuperäinen nimitimestamp
- aikaleima, jolloin kuvatiedosto on talletettuTallenna 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:
GET /app/album
asettaa Model
-instanssiin attribuutin images
, joka sisältää listan kaikista kuvista, ja näyttää näkymän album
.POST /app/images
vastaanottaa yksittäisen tiedoston ja sen tekstikuvauksen HTTP multipart/form-data
muodossa. Tiedoston parametrin nimi on file
ja kuvauksen description
. Sovelluksen tulee tallettaa tiedoston kuvaus, mediatyyppi sekä alkuperäinen tiedostonimi. Tiedostolla tulee olla numeerinen avain ja uudelle tiedostolle annettu avain täytyy palauttaa HTTP-vasteen otsakkeessa X-Image-Id
, jotta asiakasohjelmisto tietää mikä on uuden kuvan avain. Pyyntö tulee lopuksi uudelleenohjata osoitteeseen /app/album
.GET /app/images/{imageId}/download
lähettää numeerisella avaimella imageId
löytyvän tiedoston asiakasohjelmistolle. HTTP-vasteeseen tulee asettaa tiedoston mediatyyppi (Content-Type
) sekä tiedoston koko tavuina (Content-Length
), jotta asiakasohjelmisto, kuten selain, osaa tulkita tiedoston sisällön oikein.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.
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ä:
GET /app/images/{imageId}/download/attachment
toimii muutoin samalla tavalla kuin edellisen kohdan tiedoston lähetys asiakasohjelmistolle, mutta HTTP-vasteeseen tulee asettaa otsake Content-Disposition
edellämainittujen ohjeiden mukaisesti.Nyt voit kokeilla ladata tiedostoja käyttöliittymän Download-linkkien avulla.
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:
GET /app/images/{imageId}
palauttaa yksittäisen kuvan JSON tiedot ilman kuvan varsinaista binääridataaGET /app/images
palauttaa JSON-listan, jossa on kaikkien talletettujen kuvien tiedot ilman binääridataaYksittä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 } ]
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.
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);
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, 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.
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ä.
Pelejä käsitellään entiteetin Game
avulla.
Toteuta luokka wad.highfive.controller.GameController
, joka tarjoaa REST-rajapinnan pelien käsittelyyn:
POST /app/games
luo uuden pelin sille annetulla nimellä ja palauttaa luodun pelin tiedot - ainoan vastaanotettavan attribuutin nimi on name
. (Huom. vieläkin! Kaikki syötteet ja vastaukset ovat JSON-muotoisia)GET /app/games
listaa kaikki talletetut pelitGET /app/games/[name]
palauttaa yksittäisen pelin tiedot pelin nimen perusteellaDELETE /app/games/[name]
poistaa nimen mukaisen pelinJokaiselle 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:
POST /app/games/[name]/scores
luo uuden tuloksen pelille name
- vastaanotettavat attribuutit ovat points
ja nickname
GET /app/games/[name]/scores
listaa talletetut tulokset pelille name
GET /app/games/[name]/scores/[id]
palauttaa yksittäisen tulokset tiedot pelin nimen name
ja tuloksen avaimen id
perusteellaDELETE /app/games/[name]/scores/[id]
poistaa avaimen id
mukaisen tuloksenPalvelu 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
.
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.
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.
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:
POST /app/games/[name]/ratings
luo uuden arvostelun pelille name
- anoa vastaanotettava attribuutti on rating
GET /app/games/[name]/ratings
listaa talletetut arvostelut pelille name
GET /app/games/[name]/ratings/[id]
palauttaa yksittäisen arvostelun tiedot pelin nimen name
ja avaimen id
perusteellaDELETE /app/games/[name]/ratings/[id]
poistaa avaimen id
mukaisen arvostelunKä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.)
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.
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 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.
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.
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
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.
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ä.
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/.
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
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ä.
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ö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.
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.
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.".
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.
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.
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.
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ä...
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.
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.
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 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ä.
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:
ObservationPoint
) lisääminen ja näyttäminen onnistuu. Observation points
-linkin avaamalla sivulla. Käytä havaintopisteiden sivuna points.jsp
-sivua.Observation
) lisääminen havaintopisteisiin ja niiden näyttäminen onnistuu. Observation
-linkin avaamalla sivulla. Käytä havaintojen sivuna observations.jsp
-sivua.timestamp
) mukaan laskevasti (DESC
).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.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.
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. 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, 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.
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.
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.
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>
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.
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('...')
.
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.
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.
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.
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 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.
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.
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 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.
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.
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östiedostojen viestien avaimet on määritelty etukäteen ja ne ovat seuraavat (suomenkielisillä esimerkeillä):
game.thinkNumber
: Ajattele jotain lukua väliltä 0...100.game.promiseGuess
: Lupaan pystyä arvaamaan ajattelemasi luvun 7 kysymyksellä.game.answerQuestions
: Esitän sinulle seuraavaksi sarjan kysymyksiä. Vastaa niihin rehellisesti.game.question
: Onko lukusi suurempi kuin 50? (k/e)game.answer.yes
: kgame.guessedNumber
: Ajattelemasi luku on 28.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.
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 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}"/>
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
.
Muokkaa konfiguraatiotiedostoa front-controller-servlet.xml
ja lisää sinne seuraavat konfiguraatiot:
org.springframework.context.support.ReloadableResourceBundleMessageSource
ja aseta sille basename
:ksi classpath:bundles/messages
ja defaultEncoding
:ksi UTF-8
org.springframework.web.servlet.i18n.CookieLocaleResolver
ja aseta sille defaultLocale
:ksi englantimvc:interceptors
:eihin bean org.springframework.web.servlet.i18n.LocaleChangeInterceptor
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
.
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.
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.
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:
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:
POST /app/projects
luo uuden projektin ja palauttaa luodun projektin tiedotGET /app/projects
listaa kaikki talletetut projektitGET /app/projects/[id]
palauttaa yksittäisen projektin tiedot id-avaimen perusteellaDELETE /app/projects/[id]
poistaa id-avaimen mukaisen projektinUusi 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!
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:
POST /app/tasks
luo uuden tehtävän ja palauttaa luodun tehtävän tiedotGET /app/tasks
listaa kaikki talletetut tehtävätGET /app/tasks/[id]
palauttaa yksittäisen tehtävän tiedot id-avaimen perusteellaDELETE /app/tasks/[id]
poistaa id-avaimen mukaisen tehtävänUusi 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ä.
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:
POST /app/projects/[id]/tasks
luo uuden tehtävän id
-avaimen mukaiseen projektiin ja palauttaa luodun tehtävän tiedotGET /app/projects/[id]/tasks
listaa kaikki talletetut tehtävät id-avaimen mukaisesta projektistaGET /app/projects/[projectId]/tasks/[taskId]
palauttaa yksittäisen tehtävän tiedot taskId-avaimen perusteella (projektin avainta ei ole pakko käyttää!)DELETE /app/projects/[projectId]/tasks/[taskId]
poistaa taskId-avaimen mukaisen tehtävänUusi 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:
POST /app/tasks
luo uuden tehtävän JSON-pyynnössä olevan projectId
-avaimen mukaiseen projektiin ja palauttaa luodun tehtävän tiedotTä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!
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:
PUT /app/tasks/[taskId]
aloittaa tai lopettaa taskId
-avaimen mukaisen tehtävän riippuen pyynnön sisällöstäPUT /app/projects/[projectId]/tasks/[taskId]
aloittaa tai lopettaa taskId
-avaimen mukaisen tehtävän riippuen pyynnön sisällöstä (projektin avainta ei ole pakko käyttää!)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ä!
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.
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ä.