Osaa käsitellä eri muodossa olevaa tietoa. Osaa järjestää ja rajata tietokannasta haettavaa tietoa. Tuntee käsitteen mediatyyppi. Tietää miten binäärimuotoista tietoa käsitellään web-sovelluksissa. Tuntee käsitteen tilattomuus. Tietää mitä evästeet ovat ja mihin niitä käytetään. Tuntee evästeisiin liittyviä oleellisia lakiteknisia asioita.
Lombok ja "Boilerplaten" vähentäminen
Javan tietokohteita kuvaavat luokat tarvitsevat oletuksena konstruktoreita sekä gettereitä ja settereitä. Thymeleaf hyödyntää luokan get-metodeja HTML-sivuja täydentäessä. Spring Data JPA taas käyttää parametritonta konstruktoria sekä attribuuttien arvojen asettamiseen käytettäviä set-metodeja.
Hyvin yksinkertainenkin luokka -- kuten alla oleva tapahtumaa kuvaava Event
-- sisältää paljon ohjelmakoodia.
public class Event {
private String name;
public Event() {
}
public Event(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
}
Suurin osa ohjelmakoodista on oleellista vallitsevien käytänteiden takia -- esimerkiksi Thymeleafin ja Spring Data JPA tarvitsee getterit ja setterit -- mutta samalla epäoleellista. Edellä kuvattujen luokkien sekä niiden attribuuttien määrän lisääntyessä projekteissa tulee olemaan lopulta satoja ellei tuhansia rivejä "turhahkoa" lähdekoodia.
Lombok on kirjasto, joka on suunniteltu vähentämään projekteissa esiintyvien toisteisten konstruktorien, getterien ja setterien määrää. Lombokin saa projektin käyttöön lisäämällä projektin pom.xml
-tiedostoon lombok-riippuvuuden.
Jos käytössäsi on NetBeansin sijaan IntelliJ Idea ohjelmointiympäristö joudut edellisen lisäksi asentamaan 'Lombok Plugin' liitännäisen. Ohjeet tähän löydät täältä. Liitännäisen asentamisen jälkeen voit asetuksista (Build, Execute, Deployment > Compiler > Annotation Processors) laittaa rastin kohtaan "Enable Annotation processing" jonka jälkeen Lombok annotaatioiden pitäisi toimia.
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
Projekti tarjoaa mahdollisuuden gettereiden ja settereiden luomiseen lennossa. Ohjelmoijan näkökulmasta edellä kuvattu luokka Event
toimii täysin samalla tavalla, jos konstruktorit ja metodit poistetaan ja luokkaan lisätään muutama annotaatio.
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Event {
private String name;
}
Edellä käytetyt annotaatiot toimivat seuraavasti: Annotaatio @NoArgsConstructor
luo luokalle parametrittoman konstruktorin, annotaatio @AllArgsConstructor
luo luokalle kaikki attribuutit sisältävän konstruktorin, ja annotaatio @Data
luo attribuuteille getterit ja setterit.
Lombok toimii yhdessä myös @Entity
-annotaation kanssa. Alla Event
-luokasta on tehty tietokantaan tallennettava.
import javax.persistence.Entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.jpa.domain.AbstractPersistable;
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Event extends AbstractPersistable<Long> {
private String name;
}
Tehtäväpohjassa on sovellus tehtävien hallintaan. Tutustu ohjelmaan ja muokkaa entiteettiluokkaa tai luokkia siten, että ne hyödyntävät Lombokia. Tehtävän testit eivät tarkastele Lombokin käyttöönottoa, eli tarkasta toiminnallisuus itse.
Tiedon tallentamista ja hakemista
Jatketaan tietokannassa olevan tiedon hakemisen ja tallentamisen parissa. Edellisessä osassa tutuksi tullut Spring Data JPA tarjoaa merkittävän määrän tietokantakyselyiden logiikkaa valmiina, mikä vähentää tarvetta rutiininomaisten tietokantakyselyiden kirjoittamiseen.
Tietokantakyselyt saa näkyville palvelimen logeihin lisäämällä sovelluksen kansioon src/main/resources
tiedoston application.properties
-- eli projektin konfiguraatiotiedoston -- ja lisäämällä konfiguraatiotiedostoon rivin spring.jpa.show-sql=true
.
spring.jpa.show-sql=true
Ajan tallentaminen
Tietoon liittyy usein aikamääreitä. Esimerkiksi kirjalla on julkaisupäivämäärä, henkilöllä on syntymäpäivä, elokuvalla on näytösaika jne. Javan kahdeksannessa versiossa julkaistiin uusia ajan käsittelyyn tarkoitettuja luokkia. Luokkaa LocalDate käytetään vuoden, kuukauden ja päivämäärän tallentamiseen. Luokka LocalDateTime mahdollistaa taas vuoden, kuukauden ja päivämäärän lisäksi tuntien, minuuttien, sekuntien ja millisekuntien tallentamisen.
import java.time.LocalDate;
import java.time.LocalDateTime;
public class Demo {
public static void main(String[] args) {
System.out.println(LocalDate.now());
System.out.println(LocalDateTime.now());
}
}
2017-10-15 2017-10-15T22:11:10.433
Luokkia voi käyttää suoraan entiteettien oliomuuttujina. Alla on määritelty henkilöä kuvaava entiteetti. Henkilöllä on pääavain (id), nimi, ja syntymäpäivä.
import java.time.LocalDate;
import javax.persistence.Entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.jpa.domain.AbstractPersistable;
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Person extends AbstractPersistable<Long> {
private String name;
private LocalDate birthday;
}
Sovelluksen käynnistyessä tietokantaan luodaan -- kun käytössä on H2-tietokannanhallintajärjestelmä -- seuraavanlainen tietokantataulu. Syntymäpäivää kuvaava sarake birthday
luodaan date-tyyppisenä sarakkeena.
CREATE TABLE PERSON (
id bigint not null,
birthday date,
name varchar(255),
primary key (id)
)
Aikamääreiden lähettäminen onnistuu myös sovelluksesta palvelimelle. Tällöin kontrollerin metodissa tulee määritellä menetelmä aikaa kuvaavan merkkijonon muuntamiseen aikamääreeksi. Muunto merkkijonosta aikamääreeksi onnistuu DateTimeFormat
-annotaation avulla. Annotaatiolle annetaan parametrina tiedon muoto, alla DateTimeFormat.ISO.DATE.
public String create(@RequestParam String name,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate birthday) {
...
Edellä oleva muoto DateTimeFormat.ISO.DATE olettaa, että päivämäärä lähetetään palvelimelle muodossa yyyy-MM-dd
. Tässä on ensin vuosi (4 numeroa), sitten viiva, sitten kuukausi (2 numeroa), sitten viiva, ja lopulta päivä (2 numeroa). Tämä muoto liittyy RFC3339-spesifikaatioon, joka määrittelee muodon, mitä päivämäärissä pitäisi käyttää kun tietoa lähetetään palvelimelle. Spesifikaation takia voimme olettaa (tai toivoa), että esimerkiksi HTML:n date-elementtiin syötettävä päivämäärä lähetetään palvelimelle em. muodossa.
HTML-lomake, jolla henkilö voidaan luoda, on melko suoraviivainen.
<form th:action="@{/persons}" method="POST">
<input name="name" type="text"/><br/>
<input name="birthday" type="date"/><br/>
<input type="submit"/>
</form>
Web-sovellukset voivat sijaita käytännössä lähes millä tahansa aikavyöhykkeellä. Sovellus käyttää oletuksena palvelimen asetuksissa asetettua aikavyöhykettä. Sovelluksen aikavyöhykkeen muutaminen onnistuu sekä ohjelmallisesti että käynnistyksen yhteydessä. Ohjelmallisesti aikavyöhyke asetetaan TimeZone-luokan metodilla setDefault
-- tästä esimerkki alla.
import java.util.TimeZone;
import javax.annotation.PostConstruct;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
@PostConstruct
public void started() {
TimeZone.setDefault(TimeZone.getTimeZone("GMT"));
}
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
Käynnistyksen yhteydessä aikavyöhyke annetaan sovellukselle parametrina komentoriviltä. Kun sovellus on paketoitu (Clean & Build), sen voi käynnistää komentoriviltä. Sovellus löytyy projektin kansiosta target
.
$ java -Duser.timezone=GMT -jar target/sovellus.jar
Saat kansion target poistettua Clean-komennolla. Kansio kannattaa poistaa, sillä se sisältää kaikki sovelluksen ajamiseeen tarvittavat kirjastot.
Tulosten järjestäminen ja rajoittaminen
Tietokantakyselyn tulokset halutaan usein tietynlaisessa järjestyksessä tai niin, että kysely palauttaa vain rajatun joukon kaikista tuloksista. Jos järjestäminen tai rajaus toteutetaan web-sovelluksessa (eli ei tietokannassa), sovelluksessa tehdään juuri se työ, mihin tietokannat on tarkoitettu. Samalla, jos tietokannan tieto noudetaan sovellukseen järjestämistä tai rajausta varten, käytetään tiedon kopiointiin paikasta toiseen turhaan aikaa ja resursseja.
Spring Data JPAn rajapinta JpaRepository mahdollistaa muutaman lisäparametrin käyttämisen osassa pyyntöjä. Voimme esimerkiksi käyttää parametria PageRequest, joka tarjoaa apuvälineet sivuttamiseen sekä pyynnön hakutulosten rajoittamiseen. Alla olevalla PageRequest-oliolla haluasimme ensimmäiset 10 hakutulosta attribuutin name mukaan käänteisessä järjestyksessä.
Pageable pageable = PageRequest.of(0, 10, Sort.Direction.DESC, "name");
Pageable-olion voi antaa parametrina suurelle osasta JpaRepositoryn valmiista metodeista. Esimerkiksi findAll-metodille tarjottuna tietokannasta haettaisiin vain kymmenen ensimmäistä tulosta sarakkeen name mukaan järjestettynä.
// ...
public PersonController {
@Autowired
private PersonRepository personRepository;
public String list(Model model) {
Pageable pageable = PageRequest.of(0, 10, Sort.Direction.DESC, "name");
model.addAttribute("persons", personRepository.findAll(pageable));
return "index";
}
// ...
}
Oletetaan, että edellä käytössämme on seuraavanlainen PersonRepository-toteutus.
import org.springframework.data.jpa.repository.JpaRepository;
public interface PersonRepository extends JpaRepository<Person, Long> {
}
Kyselyiden rajoittaminen on suoraviivaista. Jos tuloksia halutaan hakea tietyllä attribuutin arvolla, rajapinnalle voidaan lisätä muotoa findByAttribuutti(Tyyppi arvo)
. Esimerkiksi päivämäärän perusteella tapahtuva haku onnistuu seuraavasti.
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PersonRepository extends JpaRepository<Person, Long> {
List<Person> findByBirthday(LocalDate birthday);
}
Tarkemmin kyselyiden luomisesta löytyy osoitteessa https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query-methods olevan dokumentaation kohdasta Query creation. Edellistä esimerkkiä voidaan laajentaa siten, että rajapinnalla on myös metodi nimen ja syntymäpäivän mukaan etsimiselle.
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PersonRepository extends JpaRepository<Person, Long> {
List<Person> findByBirthday(LocalDate birthday);
List<Person> findByNameAndBirthday(String name, LocalDate birthday);
}
Myös edellä kuvatuille metodeille voidaan määritellä parametriksi Pageable-olio. Jos oletamme, että käyttäjä antaa pageablen aina metodille findByBirthday
, voidaan sen määrittely muuttaa seuraavaksi.
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PersonRepository extends JpaRepository<Person, Long> {
List<Person> findByBirthday(LocalDate birthday, Pageable pageable);
List<Person> findByNameAndBirthday(String name, LocalDate birthday);
}
Tehtävässä on käytössä viestien lähetykseen käytettävä sovellus. Muokkaa sovellusta siten, että sovelluksessa näkyy aina vain uusimmat 5 viestiä. Käytä tässä hyödyksi yllä nähtyä Pageable-oliota.
Sovelluksessa ei ole testejä. Palauta se kun se toimii toivotulla tavalla.
Tehtävänäsi on täydentää kokeiden ja koekysymysten hallintaan tarkoitettua sovellusta. Sovellukseen on toteutettu valmiiksi rungot kokeiden ja koekysymysten lisäämiseen tarvittaviin kontrollereihin, jonka lisäksi sovelluksessa on osittain valmiina tarvitut Exam
ja Question
-entiteetit.
Lisää sovellukseen tarvittavat Repository-rajapinnat ja täydennä Exam- ja Question-entiteettejä niin, että yhteen kokeeseen monta kysymystä ja yksi kysymys voi liittyä useampaan kokeeseen. Toteuta myös kontrollereille tarvittavat metodit ja toiminnallisuudet -- saat näitä selville HTML-sivuja tarkastelemalla.
Muokkaa sovellusta lopulta niin, että osoitteessa "/exams" näytettävät kokeet järjestetään koepäivämäärän mukaan.
Huomaa, että testit eivät käsittele päivämääriä. Kokeile sovelluksen toimintaa -- myös tässäkin tehtävässä -- myös manuaalisesti.
Mediatyypit
Palvelimelle tehtävät pyynnöt ja palvelimelta saatavat vastaukset voivat sisältää erimuotoista tietoa. Pyyntö tai vastaus voi sisältää esimerkiksi tekstidokumentin, kuvatiedoston tai esimerkiksi PDF-tiedoston. Palvelin vastaanottaa ja kertoo pyynnön tyypin HTTP-protokollan mukana kulkevalla otsakkeella Content-Type
.
Tätä tietoa lähetettävän tai vastaanotettavan datan muodosta kutsutaan mediatyypiksi. Dataa käsittelevä ohjelmisto päättää mediatyypin perusteella miten data käsitellään. Mediatyyppi sisältää yleensä kaksi osaa; mediatyypin sekä tarkenteen (esim application/json
). Kattava lista eri mediatyypeistä löytyy IANA-organisaation ylläpitämästä mediatyyppilistasta.
Tyypillisiä mediatyyppejä ovat erilaiset kuvat image/*
, videot video/*
, äänet audio/*
sekä erilaiset tekstimuodot kuten JSON application/json
.
Web-palvelut voivat tarjota käytännössä mitä tahansa näistä tiedostotyypeistä käyttäjälle; käyttäjän sovellusohjelmisto päättelee vastauksessa tulevan mediatyypin mukaan osaako se käsitellä tiedoston.
Yksinkertaisimmillaan mediatiedoston lähetys palvelimelta toimii Springillä seuraavasti. Oletetaan, että käytössämme on levypalvelin ja polussa /media/data/
oleva PNG-kuvatiedosto architecture.png
.
@GetMapping(path = "/images/1", produces = "image/png")
public void copyImage(OutputStream out) throws IOException {
Files.copy(Paths.get("/media/data/architecture.png"), out);
}
Yllä olevassa esimerkissä kerromme että metodi kuuntelee polkua "/images/1" ja tuottaa image/png
-tyyppistä sisältöä. Spring asettaa kontrollerin metodin parametriksi automaattisesti OutputStream
-olion, johon pyynnön vastaus voidaan kirjoittaa. Files
-luokan tarjoama copy
-metodi kopioi kuvan suoraan tiedostosta pyynnön vastaukseksi.
Ylläolevan kontrollerimetodin palauttaman kuvan voi näyttää osana sivua img
-elementin avulla. Jos metodi kuuntelee osoitetta /media/image.png
, HTML-elementti <img src="/media/image.png" />
hakee kuvan automaattisesti osoitteesta sivun latautuessa.
Huom! Jos kuvat ovat staattisia eikä niitä esimerkiksi lisäillä tai poisteta, tulee niiden olla esimerkiksi projektin kansiossa /src/main/resources/public/img
-- niille ei tule määritellä kontrollerimetodia. Kansion public
alla olevat tiedostot kopioidaan web-sovelluksen käyttöön, ja niihin pääsee käsiksi web-selaimella ilman tarvetta kontrollerille.
Tiedostojen tallentaminen ja lataaminen
Web-sivuilta voi lähettää tiedostoja palvelimelle. Alla oleva lomake HTML-koodi luo lomakkeen, joka voi sisältää myös binääridataa (kts. multipart/form-data).
<form th:action="@{/files}" method="POST" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="Send!"/>
</form>
Lomake lähettää tiedot palvelimelle, jonka tulee käsitellä pyyntö. Pyynnön käsittely tapahtuu aivan kuten minkä tahansa muunkin pyynnön, mutta tässä tapauksessa pyynnön parametrin tyyppi on MultipartFile, joka sisältää lähetettävän tiedoston tiedot.
Alla oleva kontrollerimetodi vastaanottaa pyynnön, ja tulostaa pyynnössä lähetetyn tiedoston koon ja tyypin. Se ei kuitenkaan tee vielä muuta.
@PostMapping("/files")
public String create(@RequestParam("file") MultipartFile file) {
System.out.println(file.getSize());
System.out.println(file.getContentType());
return "redirect:/files";
}
MultipartFile-olio sisältää myös viitteen tavutaulukkoon, joka sisältää pyynnössä lähetetyn datan. Tavutaulukon -- eli tässä tapauksessa datan -- tallennus tietokantaan onnistuu seuraavasti. Entiteetti FileObject
sisältää tavutaulukon siten, että sen voi tallentaa tietokantaan.
import javax.persistence.Entity;
import javax.persistence.Lob;
import org.springframework.data.jpa.domain.AbstractPersistable;
// muita sopivia annotaatioita
@Entity
public class FileObject extends AbstractPersistable<Long> {
@Lob
private byte[] content;
}
Annotaatiolla @Lob kerrotaan että annotoitu muuttuja tulee tallentaa tietokantaan isona dataobjektina. Tietokantamoottorit tallentavat nämä tyypillisesti erilliseen isommille tiedostoille tarkoitettuun sijaintiin, jolloin tehokkuus ei juurikaan kärsi erikokoisten kenttien takia.
Kun entiteetille tekee repository-olion, voi sen ottaa käyttöön myös kontrollerissa. Tietokantaan tallentaminen tapahtuu tällöin seuraavasti:
@PostMapping("/files")
public String save(@RequestParam("file") MultipartFile file) throws IOException {
FileObject fo = new FileObject();
fo.setContent(file.getBytes());
fileObjectRepository.save(fo);
return "redirect:/files";
}
Tiedoston lähetys kontrollerista onnistuu vastaavasti. Tässä tapauksessa oletamme, että data on muotoa image/png
; kontrolleri palauttaa tietokantaoliolta saatavan tavutaulukon pyynnön vastauksen rungossa.
@GetMapping(path = "/files/{id}", produces = "image/png")
@ResponseBody
public byte[] get(@PathVariable Long id) {
return fileObjectRepository.findOne(id).getContent();
}
Tässä tehtävässä toteutetaan sovellus gif-kuvien varastointiin ja selaamiseen.
Pääset toteuttamaan huomattavan osan sovelluksesta itse -- tarkista että suunnittelemasi domain-oliot sopivat yhteen annetun näkymän kanssa.
Tehtäväpohjassa olevassa gifs.html
-sivussa on toiminnallisuus, minkä avulla kuvia näytetään käyttäjälle.
Toteuta toiminnallisuus, jonka avulla seuraavat toiminnot ovat käytössä.
- Kun käyttäjä tekee GET-tyyppisen pyynnön osoitteeseen
/gifs
, hänet ohjataan osoitteeseen/gifs/1
. - Kun käyttäjä tekee GET-tyyppisen pyynnön osoitteeseen
/gifs/{id}
, hänelle näytetään sivugifs
. Pyynnön modeliin tulee lisätä attribuutticount
, joka sisältää tietokannassa olevien kuvien määrän. Tämän lisäksi, pyyntöön tulee lisätä attribuuttinext
, joka sisältää seuraavan kuvan tunnuksen -- jos sellainen on olemassa, attribuuttiprevious
, joka sisältää edeltävän kuvan tunnuksen -- jos sellainen on olemassa, jacurrent
, joka sisältää nykyisen kuvan tunnuksen -- jos sellainen on olemassa. - Kun käyttäjä tekee GET-tyyppisen pyynnön osoitteeseen
/gifs/{id}/content
, tulee hänelle palauttaa tunnukslla{id}
tietokannassa oleva kuva -- vastauksen mediatyypiksi tulee asettaa myösimage/gif
.
HTML-sivulla on myös lomake, jonka avulla palvelimelle voi lähettää uusia kuvia. Toteuta palvelimelle toiminnallisuus, jonka avulla osoitteeseen /gifs
tehdystä POST-pyynnöstä otetaan sisältö talteen ja tallennetaan se tietokantaa. Huom! Tallenna sisältö vain jos sen mediatyyppi on image/gif
. Pyyntö uudelleenohjataan aina lopuksi osoitteeseen /gifs
.
Kun tietokantaan tallennetaan isoja tiedostoja, kannattaa tietokanta suunnitella siten, että tiedostot ladataan vain niitä tarvittaessa. Voimme lisätä olioattribuuteille annotaatiolla @Basic
lisämääreen fetch
, minkä avulla hakeminen rajoitetaan eksplisiittisiin kutsuihin. Tarkasta tässä vaiheessa edellisen tehtävän mallivastaus -- huomaat että sielläkin -- vaikka annotaatio @Basic
ei ollut käytössä -- konkreettinen kuva ladataan hyvin harvoin.
import javax.persistence.Basic;
import javax.persistence.Entity;
import javax.persistence.Lob;
import org.springframework.data.jpa.domain.AbstractPersistable;
// muut annotaatiot
@Entity
public class FileObject extends AbstractPersistable<Long> {
@Lob
@Basic(fetch = FetchType.LAZY)
private byte[] content;
}
Ylläoleva @Basic(fetch = FetchType.LAZY)
annotaatio pyytää JPA-toteutusta (tapauksessamme Hibernate) luomaan annotoidun olioattribuutin get-metodiin ns. proxymetodin -- data haetaan tietokannasta vasta kun metodia getContent()
kutsutaan.
Yleiskäyttöinen tiedoston tallennus ja lataaminen
Edellisessä esimerkissä määrittelimme kontrollerimetodin palauttaman mediatyypin osaksi @RequestMapping
annotaatiota. Usein tiedostopalvelimet voivat kuitenkin palauttaa lähes minkätyyppisiä tiedostoja tahansa. Tutustutaan tässä yleisempään tiedoston tallentamiseen ja lataukseen.
Käytämme edellisessä esimerkissä käytettyä FileObject
-entiteettiä toteutuksen pohjana.
Jotta voimme kertoa tiedoston mediatyypin, haluamme tallentaa sen tietokantaan. Tallennetaan tietokantaan mediatyypin lisäksi myös tiedoston alkuperäinen nimi sekä tiedoston pituus.
import javax.persistence.Basic;
import javax.persistence.Entity;
import javax.persistence.Lob;
import org.springframework.data.jpa.domain.AbstractPersistable;
// muut annotaatiot
@Entity
public class FileObject extends AbstractPersistable<Long> {
private String name;
private String mediaType;
private Long size;
@Lob
@Basic(fetch = FetchType.LAZY)
private byte[] content;
}
Pääsemme kaikkiin kenttiin käsiksi MultipartFile
-olion kautta; muokataan aiemmin näkemäämme kontrolleria siten, että otamme kaikki yllämääritellyt kentät tietokantaan tallennettavaan olioon.
@PostMapping("/files")
public String save(@RequestParam("file") MultipartFile file) throws IOException {
FileObject fo = new FileObject();
fo.setName(file.getOriginalName());
fo.setMediaType(file.getContentType());
fo.setSize(file.getSize());
fo.setContent(file.getBytes());
fileObjectRepository.save(fo);
return "redirect:/files";
}
Nyt tietokantaan tallennettu olio tietää myös siihen liittyvän mediatyypin. Haluamme seuraavaksi pystyä myös kertomaan kyseisen mediatyypin tiedostoa hakevalle käyttäjälle.
ResponseEntity-oliota käytetään vastauksen paketointiin; voimme palauttaa kontrollerista ResponseEntity-olion, jonka pohjalta Spring luo vastauksen käyttäjälle. ResponseEntity-oliolle voidaan myös asettaa otsaketietoja, joihin saamme asetettua mediatyypin.
@GetMapping("/files/{id}")
public ResponseEntity<byte[]> viewFile(@PathVariable Long id) {
FileObject fo = fileObjectRepository.findOne(id);
final HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(fo.getContentType()));
headers.setContentLength(fo.getSize());
return new ResponseEntity<>(fo.getContent(), headers, HttpStatus.CREATED);
}
Ylläolevassa esimerkissä vastaanotetaan pyyntö, minkä pohjalta tietokannasta haetaan FileObject-olio. Tämän jälkeen luodaan otsakeolio HttpHeaders
ja asetetaan sille palautettavan datan mediatyyppi ja koko. Lopuksi palautetaan ResponseEntity
-olio, mihin data, otsaketiedot ja pyyntöön liittyvä statusviesti (tässä tapauksessa CREATED) liitetään.
Edeltävä esimerkki ei ota kantaa tiedoston nimeen tai siihen, miten se ladataan. Voimme lisäksi vastaukseen Content-Disposition-otsakkeen, minkä avulla voidaan ehdottaa tiedoston tallennusnimeä sekä kertoa että tiedosto on liitetiedosto, jolloin se tulee tallentaa.
@GetMapping("/files/{id}")
public ResponseEntity<byte[]> viewFile(@PathVariable Long id) {
FileObject fo = fileObjectRepository.findOne(id);
final HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(fo.getContentType()));
headers.setContentLength(fo.getSize());
headers.add("Content-Disposition", "attachment; filename=" + fo.getName());
return new ResponseEntity<>(fo.getContent(), headers, HttpStatus.CREATED);
}
Tässä tehtävässä toteutetaan yleisempi tiedostojen varastointiin ja näyttämiseen käytettävä sovellus.
Kuten edellisessä tehtävässä, pääset toteuttamaan huomattavan osan sovelluksesta itse -- tarkista että suunnittelemasi domain-oliot sopivat yhteen annetun näkymän kanssa.
Toteuta toiminnallisuus, jonka avulla seuraavat toiminnot ovat käytössä.
-
Kun käyttäjä tekee GET-tyyppisen pyynnön osoitteeseen
/files
, pyyntöön lisätään tietokannasta löytyvät tiedostot ja käyttäjä ohjataan sivullefiles.html
. -
Kun käyttäjä lähettää lomakkeella tiedoston osoitteeseen
/files
, pyynnöstä otetaan talteen kaikki tiedot mitä näkymässä halutaan näyttää, ja tallennetaan ne tietokantaan. Pyyntö ohjataan lopuksi uudelleen osoitteeseen/files
. -
Kun käyttäjä klikkaa yksittäiseen tiedostoon liittyvää delete-nappia, tulee tiedosto poistaa tietokannasta. Lopuksi pyyntö uudelleenohjataan osoitteeseen
/files
. - Kun käyttäjä klikkaa yksittäiseen tiedostoon liittyvää nimeä sen lataamista varten, tulee tiedosto lähettää käyttäjälle. Aseta pyyntöön datan lisäksi myös tiedoston mediatyyppi että ja ehdotus tiedoston tallennusnimestä.
Tietokantamigraatiot
Ohjelmistojen kehityksessä tulee vastaan tyypillisesti tilanne, missä tuotantokäytössä olevaa tietokantaskeemaa tulee muuntaa. Koska käytössä oleva tietokantaversio voi poiketa ohjelmistokehittäjän koneesta riippuen -- joku saattaa työstää uutta versiota, jollain toisella voi olla työn alla korjaukset vanhempaan versioon -- tarvitaa myös tietokantamuutosten automatisointiin välineitä. Tähän käytetään esimerkiksi Flyway-kirjastoa, josta molemmista löytyy myös Spring Boot-ohjeet.
Käytännössä tietokantamigraatiot toteutetaan niin, että tietokannasta pidetään yllä tietokantataulujen muutos- ja muokkauskomennot sisältäviä versiokohtaisia tiedostoja. Käytössä olevaan tietokantaan on määritelty esimerkiksi taulu, jossa on tieto tämänhetkisestä versiosta. Jos käynnistettävässä sovelluksessa on uudempia muutoksia, ajetaan niihin liittyvät komennot tietokantaan ja tietokantaan merkitty versio päivittyy.
HTTP-protokollan tilattomuus ja evästeet
HTTP on tilaton protokolla. Tämä tarkoittaa sitä, että jokainen pyyntö on erillinen kokonaisuus, joka ei liity aiempiin pyyntöihin. Suunnittelupäätöksen taustalla oli ajatus siitä, että verkkosivulle ladattava sisältö voi sijaita useammalla eri palvelimella. Jos HTTP ottaisi kantaa käyttäjän tilaan, tulisi myös hajautettujen ratkaisujen tilan ylläpitoon ottaa kantaa -- tämä olisi myös ollut melko tehotonta (Basic HTTP as defined in 1992). Päätös tilattomuudesta oli alunperin hyvä: suurin osa verkkoliikenteestä liittyy muuttumattoman sisällön hakemiseen, palvelinten ei tarvitse varata resursseja käyttäjän tilan ylläpitämiseen, ja palvelinten ja selainohjelmistojen toteuttajien ei tarvinnut toteuttaa mekanismeja käyttäjien tilan ylläpitämiseen.
Käyttäjän tunnistamiseen pyyntöjen välillä on kuitenkin tarvetta. Esimerkiksi verkkokaupat ja muut käyttäjän kirjautumista vaativat palvelut tarvitsevat tavan käyttäjän tunnistamiseen. Klassinen -- mutta huono -- tapa kiertää HTTP:n tilattomuus on ollut säilyttää GET-muotoisessa osoitteessa parametreja, joiden perusteella asiakas voidaan tunnistaa palvelinsovelluksessa. Tämä ei kuitenkaan ole suositeltavaa, sillä osoitteessa olevia parametreja voi muokata käsin, ja ne saattavat jättää sovellukseen ylimääräisiä tietoturva-aukkoja.
Eräässä järjestelmässä -- onneksi jo vuosia sitten -- 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 tavalla tekniikalla, joka ei näy HTTP-protokollaan asti. Yleisin tekniikka tilattomuuden kiertämiseen on evästeiden käyttö.
HTTP ja evästeet
Merkittävä osa verkkosovelluksista sisältää käyttäjäkohtaista toiminnallisuutta, jonka toteuttamiseen sovelluksella täytyy olla jonkinlainen tieto käyttäjästä sekä mahdollisesti käyttäjän tilasta. HTTP/1.1 tarjoaa mahdollisuuden tilallisten verkkosovellusten toteuttamiseen evästeiden (cookies) avulla.
Kun palvelin asettaa pyynnön vastaukseen evästeen, tulee selaimen jatkossa lähettää evästetieto jatkossa aina palvelimelle. 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 HTTP-protokollan 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 selain ja palvelin voi unohtaa sen tunnin kuluttua sen asettamisesta. 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 selaimella kyselyn. 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 palvelinosoitteeseen voi liittyä 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.
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?
Evästeet ja istunnot
Kun selain lähettää palvelimelle pyynnön yhteydessä evästeen, palvelin etsii evästeen perusteella käynnissä olevaa istuntoa eli sessiota. Jos sessio löytyy, annetaan siihen liittyvät tiedot sovelluksen käyttöön käyttäjän pyynnön käsittelyä varten. Jos sessiota taas ei löydy, voidaan selaimelle palauttaa uusi eväste ja aloittaa uusi sessio, jolloin session tiedot löytyvät jatkossa palvelimelta.
Javassa sessioiden käsittelyyn löytyy HttpSession-luokka, joka tarjoaa välineet sessio- ja käyttäjäkohtaisen tiedon tallentamiseen. Oleellisimmat luokan metodit ovat public void setAttribute(String name, Object value)
, joka tallentaa sessioon arvon, sekä public Object getAttribute(String name)
, jonka avulla kyseinen arvo löytyy.
Session saa yksinkertaisimmillaan käyttöön lisäämällä sen kontrollerimetodin parametriksi. Tällöin Spring liittää metodiin parametrin automaattisesti. Alla on kuvattuna sovellus, joka pitää sessiokohtaista kirjaa käyttäjien tekemistä pyynnöistä.
import javax.servlet.http.HttpSession;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class VisitCountController {
@GetMapping("*")
@ResponseBody
public String count(HttpSession session) {
int visits = 0;
if (session.getAttribute("count") != null) {
visits = (int) session.getAttribute("count");
}
visits++;
session.setAttribute("count", visits);
return "Visits: " + visits;
}
}
Kun käyttäjä tekee ensimmäistä kertaa pyynnön sovellukseen, palauttaa sovellus merkkijonon "Visits: 1". Vastauksen yhteydessä palautetaan myös eväste. Kun käyttäjä tekee seuraavan kerran pyynnön sovellukseen, lähettää selain pyynnön yhteydessä myös evästeen palvelimelle, jolloin palvelin osaa tunnistaa käyttäjän ja hakee oikean istunnon tiedot -- vastaukseksi palautuu lopulta merkkijono "Visits: 2".
Toteuta sovellus, joka palauttaa käyttäjälle merkkijonon "Hello there!" jos käyttäjä ei ole ennen vieraillut sovelluksessa. Jos käyttäjä on vieraillut sovelluksessa aiemmin, tulee sovelluksen palauttaa käyttäjälle merkkijono "Hello again!".
Istuntojen toiminnallisuuden testaaminen selaimella onnistuu näppärästi selainten tarjoaman anonyymitoiminnallisuuden avulla. Esimerkiksi Chromessa voi valita "New incognito window", mikä avaa käyttöön selainikkunan, missä ei aluksi ole lainkaan vanhoja evästeitä muistissa. Kun palvelimelle tehdään pyyntö, tallentuu vastauksen yhteydessä palautettava eväste selaimen muistiin vain siksi aikaa kun anonyymi-ikkuna on auki.
Session pituus riippuu esimerkiksi palvelimen asetuksista session timeout
ja siitä, että salliiko käyttäjä evästeiden käytön.
HttpSession-olioon pääsee käsiksi myös muualla sovelluksessa, ja sen voi injektoida esimerkiksi palveluun @Autowired
-annotaation avulla. Edellinen kontrolleriin toteutettu toiminnallisuus voitaisiin tehdä myös palvelussa.
// importit
@Service
public class CountService {
@Autowired
private HttpSession session;
public int incrementAndCount() {
int count = 0;
if (session.getAttribute("count") != null) {
count = (int) session.getAttribute("count");
}
count++;
session.setAttribute("count", count);
return count;
}
}
Nyt kontrollerin koodi olisi kevyempi:
// importit
@Controller
public class VisitCountController {
@Autowired
private CountService countService;
@RequestMapping("*")
@ResponseBody
public String count() {
return "Visits: " + countService.incrementAndCount();
}
}
Reload Heroes -sovellus pitää kirjaa käyttäjän tekemistä sivun uudelleenlatauksista. Kun käyttäjä saapuu sovellukseen ensimmäistä kertaa, hänelle luodaan satunnainen käyttäjätunnus ja hänen vierailujen määrä asetetaan yhteen. Jokaisen uudelleenvierailun yhteydessä käyttäjän vierailujen määrä kasvaa yhdellä.
Täydennä luokan ReloadStatusController
metodin reload toimintaa seuraavasti.
- Metodin tulee palauttaa model-attribuuttina "scores" viisi eniten uudelleenlatauksia tehnyttä käyttäjää suuruusjärjestyksessä. Listan ensimmäisellä sijalla on eniten uudelleenlatauksia tehnyt henkilö, toisella sijalla toiseksi eniten jne.
- Metodin tulee lisäksi palauttaa pyynnön tehneeseen henkilöön liittyvä ReloadStatus-olio modelin attribuuttina status. Jos käyttäjä ei ole tehnyt yhtäkään pyyntöä aiemmin, tulee käyttäjälle luoda uusi tunnus sekä alustaa uudelleenlatausten määrä yhteen. Jos taas käyttäjä on tehnyt pyyntöjä aiemmin, tulee käyttäjän tekemien pyyntöjen määrää kasvattaa yhdellä. Tieto pyyntöjen määrästä tulee myös tallentaa tietokantaan.
Voit testata sovelluksen toimintaa selaimen anonyymitilassa. Anonyymitilassa selain ei lähetä normaalitilassa kertyneitä evästeitä palvelimelle.
Muutamia faktoja sessioista:
- Sessio häviää kun käyttäjä poistaa selaimesta evästeet.
- Sessio häviää kun evästeen elinikä kuluu loppuun.
- Jokaisessa päätelaitteessa on tyypillisesti oma sessio: jos palvelua käytetään kännykällä ja padilla, kummallakin on omat evästeet. Tämä on nykyään muuttumassa, esimerkiksi jotkut Googlen tuotteet pitävät kirjaa evästeistä myös laitteiden yli.
- Käyttäjä voi estää evästeiden käytön selaimen asetuksista.
Sessioiden käyttö on näppärää sellaisen tiedon tallentamiseen mikä saakin kadota. Jos tiedon säilyvyys on oleellista sovelluksen toiminnan kannalta, kannattaa se tallentaa esimerkiksi tietokantaan.
Springin annotaatio @Autowired
luo oletuksena yhden ilmentymän luokasta, joka asetetaan @Autowired
-annotaatiolla määriteltyyn luokkaan. Tarvittavien komponenttien luomista voidaan kontrolloida erillisen @Scope
-annotaation avulla, mikä mahdollistaa ilmentymien luonnin esimerkiksi sessiokohtaisesti. Seuraavassa on esimerkki ostoskorista, joka on sessiokohtainen ja jokaiselle käyttäjälle oma. Annotaatio @Component
on luokan toiminnalle oleellinen -- sen avulla Spring tietää, että luokka tulee ladata @Autowired-annotaatioon liittyvän toiminnallisuuden löydettäväksi.
// importit
@Component
@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ShoppingCart implements Serializable {
private Map<Item, Integer> items;
public ShoppingCart() {
this.items = new HashMap<>();
}
public Map<Item, Integer> getItems() {
return items;
}
public void setItems(Map<Item, Integer> items) {
this.items = items;
}
public boolean isEmpty() {
return items == null || items.isEmpty();
}
}
Ylläolevasta komponentista luotavat ilmentymät ovat elossa vain käyttäjän session ajan, eli sen aikaa kun käyttäjän eväste on elossa. Ylläolevasta ostoskorista saa lisättyä ilmentymän sovellukseen aivan kuten muistakin komponenteista, eli @Autowired
-annotaatiolla.
Tarkempaa tietoa em. annotaatiosta löytyy Springin dokumentaatiosta.
Tässä tehtävässä toteutetaan verkkokauppaan ostoskoritoiminnallisuus.
Ostoskori
Luo pakkaukseen wad.domain
luokka ShoppingCart
, joka tarjoaa seuraavat toiminnallisuudet.
-
Metodi
getItems()
palauttaaMap<Item, Long>
-tyyppisen olion, joka sisältää ostoskorissa olevien tuotteiden tuotekohtaisen lukumäärän. -
Metodi
addToCart(Item item)
lisää ostoskoriin yhden kappaleenItem
-tyyppistä esinettä. -
Metodi
removeFromCart(Item item)
poistaa ostoskorista yhden kappaleenItem
-tyyppistä esinettä. Jos lukumäärä laskee nollaan,getItems()
-metodin ei tule sisältää enää kyseistä tuotetta.
Kontrolleri ostoskorille
Tee ostoskorista sessiokohtainen, eli eri käyttäjien tulee saada eri ostoskori käyttöönsä. Annotaatiosta Scope
on tässä hyötyä.
Luo projektiin sopiva kontrolleri, joka tarjoaa seuraavat osoitteet ja toiminnallisuudet.
-
GET /cart asettaa model-olion "items"-nimiseen attribuuttiin ostoskorin sisällön (aiempi
getItems()
). Pyynnön vastauksena käyttäjälle näytetään sivu, joka luodaan polussa/src/main/resources/templates/cart.html
olevasta näkymästä. -
POST /cart/items/{id} lisää ostoskoriin yhden {id}-tunnuksella tietokannasta löytyvän Item-olion. Pyyntö ohjataan osoitteeseen
/cart
. -
DELETE /cart/items/{id} poistaa ostoskorista yhden {id}-tunnuksella tietokannasta löytyvän Item-olion. Pyyntö ohjataan osoitteeseen
/cart
.
Tilauksen tekeminen
Muokkaa luokkaa wad.controller.OrderController
siten, että tilaus tallennetaan tietokantaan. Tutustu luokkiin Order
ja OrderItem
ennen toteutusta. Varmista että esimerkiksi OrderItem
viittaa oikeaan tietokantatauluun.
Kun tilaus on tehty, tyhjennä ostoskori.
Lakiteknisiä asioita evästeisiin liittyen
Euroopan komissio on säätänyt yksityisyydensuojaan liittyvän lain, joka määrää kertomaan käyttäjille evästeiden käytöstä. Käytännössä käyttäjältä tulee pyytää lupaa minkä tahansa sisällön tallentamiseen hänen koneelleen (ePrivacy directive, Article 5, kohta (3)). Myöhemmin säädetty tarkennus tarkentaa määritelmää myös evästeiden käytön kohdalla.
Lisätietoa mm. osoitteessa http://eur-lex.europa.eu/LexUriServ/LexUriServ.do?uri=OJ:L:2009:337:0011:0036:EN:PDF, http://ec.europa.eu/justice/data-protection/article-29/documentation/opinion-recommendation/files/2012/wp194_en.pdf sekä http://finlex.fi/fi/laki/ajantasa/2014/20140917#L24P205.