Tehtävät
Neljännen osan oppimistavoitteet

Osaa esittää tietoa XML ja HTML-kielellä. Osaa luoda yhtä tietokantataulua käyttävän ja muokkaavan web-sovelluksen, jota voi käyttää selaimella. Tuntee sekvenssikaaviot. Osaa luoda useampaa tietokantataulua käyttävän ja muokkaavan web-sovelluksen.

Neljännen osan tehtävien lataaminen

Huom! Jos tehtävien lataaminen epäonnistuu, lataa NetBeansiin "Java EE Base" tai "Java Web and EE" -liitännäinen. Tämä onnistuu valitsemalla Tools -> Plugins -> Available Plugins ja etsimällä kyseisen liitännäisen listasta.

Erilaisia tiedon esitysmuotoja

Edellisen osan lopussa tutustuttiin lyhyesti JavaScript Object Notation (JSON)-formaattiin, joka on eräs tiedon esitysmuoto. Tarkastellaan tässä muutamia muita tiedon esitysmuotoja.

XML

XML (EXtensible Markup Language) on kieli, jolla kuvataan rakenteellista tietoa. Se koostuu kahdesta osasta, otsakkeesta ja rungosta. Otsake on valinnainen, ja sisältää esimerkiksi dokumentin versionumeron, tiedon käsittelyohjeita, sekä mahdollisia tietoja tiedon rakenteesta. XML-dokumentin runko alkaa juurielementistä, joita on vain yksi, jonka alla on yksi tai useampia elementtejä. Elementit voivat sisältää muita elementtejä, tai ne voivat sisältää (esimerkiksi) tekstimuotoisen arvon.

Elementit erotellaan toisistaan pienempi kuin (<) ja suurempi kuin (>) -merkeillä. Elementti avataan elementin nimen sisältävällä pienempi kuin -merkillä alkavalla ja suurempi kuin -merkkiin loppuvalla merkkijonolla, esim. <opiskelija>, ja suljetaan merkkijonolla jossa elementin pienempi kuin -merkin jälkeen on vinoviiva, esim </opiskelija>. Yksittäisen elementin sisälle voi laittaa muita elementtejä.

Alla on esimerkki XML-dokumentista, jossa on kuvattu opiskelijoiden nimiä. <?xml version="1.0"?> on dokumentin otsake ja <opiskelijat> dokumentin juurielementti. Dokumentti sisältää joukon <opiskelija>-elementtejä, joista jokaiselle on määritelty <nimi>-elementti. Jokaiseen <nimi>-elementtiin liittyy tekstiarvo -- esimerkiksi ensimmäisen opiskelijan nimi on Ada Lovelace.

<?xml version="1.0"?>
<opiskelijat>
  <opiskelija>
    <nimi>Ada Lovelace</nimi>
  </opiskelija>
  <opiskelija>
    <nimi>Edgar F. Codd</nimi>
  </opiskelija>
  <opiskelija>
    <nimi>Lixia Zhang</nimi>
  </opiskelija>
</opiskelijat>

XML-dokumentin elementit voivat sisältää useita elementtejä. Alla on kuvaus kahdesta kurssista sekä niihin liittyvistä opettajista.

<?xml version="1.0"?>
<kurssit>
  <kurssi>
    <nimi>Ohjelmoinnin perusteet</nimi>
    <luento>Ti 10-12</luento>
    <opettaja>
      <nimi>Charles Babbage</nimi>
    </opettaja>
  </kurssi>
  <kurssi>
    <nimi>Tietokantojen perusteet</nimi>
    <luento>Pe 12-14</luento>
    <opettaja>
      <nimi>Edgar F. Codd</nimi>
    </opettaja>
  </kurssi>
</kurssit>

HTML

HTML on hieman XML-kieltä muistuttava kieli web-sivustojen luomiseen. HTML on kuvauskieli, jonka avulla kuvataan sekä web-sivun rakenne että sivun sisältämä teksti. HTML-sivujen rakenne määritellään HTML-kielessä määritellyillä elementeillä. Yksittäinen HTML-dokumentti koostuu sisäkkäin ja peräkkäin olevista elementeistä.

Suurin osa HTML-kielen elementeistä tulee sulkea lopuksi. Osa HTML5:n elementeistä – esimerkiksi <br> – on kuitenkin ns. tyhjiä ("void"), eikä niille kirjoiteta erillistä lopetusta. Halutessaan tyhjät elementit voi lopettaa X(HT)ML-tyyliseen /-merkkiin, esimerkiksi seuraavasti: <br />.

HTML-dokumentin runko

Tyypillisen HTML-dokumentin runko näyttää seuraavalta. Kun klikkaat allaolevassa upotetussa elementissä -- (tämäkin materiaali on luotu HTML-kielellä) -- Result-tekstiä, näet HTML-sivun, ja kun painat HTML-tekstiä, näet HTML-koodin. Klikkaamalla elementin oikeassa ylälaidassa olevasta Edit in JSFiddle-linkistä, pääset muokkaamaan elementtiä. Käytössä oleva palvelu JSFiddle mahdollistaa HTML-sivujen näppärän kokeilun.

 

Yllä olevassa HTML-dokumentissa on dokumentin tyypin kertova otsake <!DOCTYPE html>, joka kertoo dokumentin olevan HTML-sivu. Tätä seuraa juurielementti <html>, joka aloittaa HTML-dokumentin. Elementti <html> sisältää yleensä kaksi elementtiä, elementit <head> ja <body>. Elementti <head> sisältää sivun otsaketiedot, eli esimerkiksi sivun käyttämän merkistön <meta charset="utf-8" /> ja otsikon <title>. Elementti <body> sisältää selaimessa näytettävän sivun rungon. Ylläolevalla sivulla on ensimmäisen tason otsake-elementti h1 (header 1, otsikko 1) ja tekstielementti p (paragraph, tekstikappale).

Elementit voivat sisältää attribuutteja, joilla voi olla yksi tai useampi arvo. Yllä olevassa HTML-dokumentissa elementille meta on määritelty erillinen attribuutti charset, joka kertoo dokumentissa käytettävän merkistön: "utf-8". Attribuuttien lisäksi elementit voivat sisältää tekstisolmun. Esimerkiksi yllä olevat elementit title, h1 ja p kukin sisältävät tekstisolmun eli tekstiä. Tekstisolmulle ei ole erillistä elementtiä tai määrettä, vaan se kirjoitetaan dokumenttiin tekstinä.

Puhe tekstisolmuista antaa viitettä jonkinlaisesta puurakenteesta. HTML-dokumentit, aivan kuten XML-dokumentit, ovat rakenteellisia dokumentteja, joiden rakenne on usein helppo ymmärtää puumaisena kaaviona. Ylläolevan web-sivun voi esittää esimerkiksi seuraavanlaisena puuna (attribuutit ja dokumentin tyyppi on jätetty merkitsemättä).

                   html
               /          \
             /              \
          head              body
        /       \         /      \
     meta       title     h1      p
                 :        :       :
              tekstiä  tekstiä tekstiä

Koska HTML-dokumentti on rakenteellinen dokumentti, on elementtien sulkemisjärjestyksellä väliä. Elementit tulee sulkea samassa järjestyksessä kuin ne on avattu. Esimerkiksi, järjestys <body><p>tekstisolmu!</body></p> on väärä, kun taas järjestys <body><p>tekstisolmu!</p></body> on oikea.

Kaikki elementit eivät kuitenkaan sisällä tekstisolmua, eikä kaikkia elementtejä suljeta erikseen. Yksi tällainen elementti on link-elementti, jota käytetään resurssien hakemiseen sivua varten.

Kun selaimet lataavat HTML-dokumenttia, ne käyvät sen läpi ylhäältä alas, vasemmalta oikealle. Kun selain kohtaa elementin, se luo sille uuden solmun selaimen ylläpitämään puurakenteeseen (DOM, document object model). Seuraavista elementeistä luodut solmut menevät aiemmin luodun solmun alle kunnes aiemmin kohdattu elementti suljetaan. Aina kun elementti suljetaan, puussa palataan ylöspäin edelliselle tasolle.

Listaelementit

Sivuille voi lisätä listoja mm. ol (ordered list, järjestetty lista) ja ul (unordered list, järjestämätön lista) -elementtien avulla. Elementeillä ol tai ul aloitetaan lista, ja listan sisälle asetettavat yksittäisiin listaelementteihin käytetään li (list item, listaelementti)-elementtiä. Yksittäiset listaelementit voivat taas sisältää esimerkiksi tekstisolmun tai lisää html-elementtejä.

Linkit toisille sivuille

Elementin a (anchor) avulla voi luoda linkin sivulta toiselle. Sivu, jolle käyttäjä siirtyy, merkitään elementin a attribuutin href arvolla. Jos sovelluksessasi on kaksi sivua, index.html ja oma.html, voi sivulta oma.html luoda linkin sivulle index.html komennolla <a href="index.html">index.html</a>.

Lisää HTML-sivujen tekemisestä!

Lisää tietoa HTML-sivujen tekemisestä löytyy mm. osoitteesta http://www.w3schools.com/html/.

HTML-kielisen dokumentin palauttavat Web-sovellukset

Muistellaan lyhyesti viime osassa tutuksi tullutta Spark-apukirjastoa. Spark on Java-kielellä toteutettu palvelinohjelmistojen luomiseen tarkoitettu kirjasto, joka tarjoaa ohjelmoijalle apuvälineitä pyyntöjä kuuntelevien palvelinten toteutukseen.

Sparkille määritellään get-metodin avulla palvelimen kuuntelemia osoitteita. Metodikutsun yhteydessä määritellään palvelimen palauttama data. Palautettava data on tekstiä, mutta selain päättelee palautetun tekstin sisällön perusteella, mitä tekstille tulee tehdä. Alla olevassa ohjelmakoodissa määritellään kaksi osoitetta, joista palautetaan dataa. Toinen palauttaa aiemmin nähdyn tekstin Hei maailma!, ja toinen palauttaa tekstin Moi maailma!.

package tikape;

import spark.Spark;

public class Main {

    public static void main(String[] args) {

        Spark.get("/hei", (req, res) -> {
            return "Hei maailma!";
        });

        Spark.get("/testi", (req, res) -> {
            return "Moi maailma!";
        });
    }
}

Selain näyttää käyttäjälle palvelimelta saamansa tekstimuotoisen vastauksen. Jos vastaus on HTML-muodossa, tulkitsee selain vastauksen, ja luo sen perusteella näkymän käyttäjälle. Periaatteessa palvelimelta voisi palauttaa suoraan HTML-koodia tekstimuodossa esimerkiksi seuraavalla tavalla.

Spark.get("/testi", (req, res) -> {
    return "<h1>Iso Viesti!</>";
});

HTML-koodin palauttaminen suoraan palvelinohjelmistosta on kuitenkin hyvin epätyypillistä. Käytännössä html-sivut luodaan lähes aina ensin erilliseen tiedostoon, jonka palvelin palauttaa käyttäjälle. Voimme tehdä näin myös Sparkin kautta. Tutustutaan tähän seuraavaksi.

Thymeleafin käyttöönotto ja HTML-sivun luominen

Thymeleaf on eräs väline HTML-sivujen palauttamiseen suoraan palvelinohjelmistolta. Thymeleaf tarjoaa käyttäjälle lisäksi mahdollisuuden palvelimelta saatavan datan lisäämiseksi suoraan HTML-sivulle.

Tutustutaan tässä Thymeleafin käyttöön Sparkin kanssa.

Thymeleafin saa lisättyä Maven-projektiin lisäämällä riippuvuuden spark-template-thymeleaf.

<dependency>
    <groupId>com.sparkjava</groupId>
    <artifactId>spark-template-thymeleaf</artifactId>
    <version>2.7.1</version>
</dependency>

Tehdään seuraavaksi resurssikansio (resources) projektin kansioon src/main/, jos sitä ei vielä ole. Uuden kansion saa luotua NetBeansin Files-välilehdellä klikkaamalla kansiota oikealla hiirennapilla, ja valitsemalla New -> Folder. Kun kansio on luotu, pitäisi käytössä olla kansio src/main/resources. Tämän jälkeen resources-kansioon tulee vielä luoda kansio templates, johon HTML-tiedostot tullaan laittamaan.

Kansio src/main/resources/templates on luotu.
Projektiin liittyvän kansion src allaolevassa kansiossa main on nyt kansio resources, jossa on taas kansio templates.

 

Lisätään kansioon templates uusi html-dokumentti (New -> HTML File), ja asetetaan tiedoston nimeksi index.html.

Kansioon src/main/resources/templates on luotu index.html-niminen tiedosto.
Nyt kansiossa src/main/resources/templates on tiedosto index.html.

 

Käyttämämme Thymeleaf-kirjasto olettaa, että HTML-tiedostot ovat tietyn muotoisia -- palataan tähän myöhemmin. Tässä välissä riittää, että html-sivun sisällöksi kopioi seuraavan aloitussisällön.

<!DOCTYPE html SYSTEM "http://www.thymeleaf.org/dtd/xhtml1-strict-thymeleaf-4.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">

    <head>
        <title>Otsikko</title>
        <meta charset="utf-8" />
    </head>

    <body>

        <h1>Hei maailma!</h1>

    </body>
</html>

Huom! Jos näet virheen 500 Internal Server Error! sekä NetBeansin lokeihin tulee viestiä "Parse errorista", tarkista, että sivun sisältö on aluksi täsmälleen ylläoleva.

Thymeleafin avulla luodun sivun palauttaminen käyttäjälle

Kansiossa src/main/java/resources/templates olevia .html-päätteisiä tiedostoja palautetaan käyttäjälle Sparkin avulla seuraavasti. Allaolevassa metodikutsussa määritellään kuunneltavaksi osoitteeksi /sivu. Kun käyttäjä tekee selaimella pyynnön osoitteeseen /sivu, hänelle palautetaan index-niminen HTML-kielinen dokumentti. Sivun nimen perusteella päätellään palautettava html-tiedosto -- nimi index muunnetaan muotoon src/main/java/resources/templates/index.html.

package tikape;

import java.util.HashMap;
import spark.ModelAndView;
import spark.Spark;
import spark.template.thymeleaf.ThymeleafTemplateEngine;

public class Main {

    public static void main(String[] args) {
        Spark.get("/sivu", (req, res) -> {
            HashMap map = new HashMap<>();

            return new ModelAndView(map, "index");
        }, new ThymeleafTemplateEngine());
    }
}

Kun yllä määritelty sovellus käynnistetään, ja kansiossa src/main/java/resources/templates on tiedosto index.html, näytetään tiedoston sisältö käyttäjälle. Huomaathan, että tiedoston sisällön tulee olla kuten edellisessä kappaleessa näytetty. Näkymä on käyttäjälle esimerkiksi seuraavanlainen:

Osoite http://localhost:4567/sivu avattuna.
Osoite http://localhost:4567/sivu avattuna.

Mitä tässä oikein tapahtuu? Tutkitaan sivun palauttamista vielä tarkemmin.

Spark.get("/sivu", (req, res) -> {
    HashMap map = new HashMap<>();

    return new ModelAndView(map, "index");
}, new ThymeleafTemplateEngine());

Metodikutsun ensimmäinen rivi lienee tuttu. Kerromme, että ohjelman tulee kuunnella osoitteeseen /sivu tehtäviä hakupyyntöjä. Tämän jälkeen tulee pyynnön käsittelyyn liittyvä lohko, josta tällä kertaa palautetaan olio, joka sisältää HashMap-olion sekä tiedon näytettävästä html-sivusta. Tämän jälkeen pyynnön käsittelyyn lisätään vielä erillinen olio, ThymeleafTemplateEngine, joka käsittelee html-sivun ennen sen palautusta.

Palvelimelta saadun tiedon näyttäminen käyttäjälle

Thymeleaf-komponentin avulla voimme lisätä html-sivulle tietoa. Tämä tapahtuu lisäämällä HashMap-olioon put-metodilla arvo, esimerkiksi map.put("teksti", "Hei mualima!");.

Spark.get("/sivu", (req, res) -> {
    HashMap map = new HashMap<>();
    map.put("teksti", "Hei mualima!");

    return new ModelAndView(map, "index");
}, new ThymeleafTemplateEngine());

Tämän jälkeen html-sivua index.html muokataan siten, että sinne lisätään "paikka" tiedolle. Tiedon lisääminen tapahtuu lisäämällä sivulle html-elementti, jossa on attribuutti th:text, jolle annetaan HashMap-olioon lisätyn arvon nimi aaltosulkujen sisällä siten, että aaltosulkuja edeltää dollarimerkki -- eli th:text="${teksti}". Elementti voi olla vaikka h2-elementti, jolloin kokonaisuus voisi olla vaikkapa seuraava <h2 th:text="${teksti}">testi</h2>.

<!DOCTYPE html SYSTEM "http://www.thymeleaf.org/dtd/xhtml1-strict-thymeleaf-4.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">

    <head>
        <title>Otsikko</title>
        <meta charset="utf-8" />
    </head>

    <body>
        <h1>Hei maailma!</h1>

        <h2 th:text="${teksti}">testi</h2>

    </body>
</html>

Kun käynnistämme palvelimen, ja avaamme osoitteen http://localhost:4567/sivu, näemme seuraavanlaisen näkymän.

Osoite http://localhost:4567/sivu avattuna.
Osoite http://localhost:4567/sivu avattuna.

Kuten ohjelmointikursseilta todennäköisesti muistetaan, HashMap> on ohjelmoinnissa käytettävä lokerikko, missä jokaisella lokerolla on nimi, mihin arvon voi asettaa. Alla olevassa esimerkissä luomme ensin HashMap-olion, jonka jälkeen asetamme lokeroon nimeltä teksti arvon "Hei mualima!".

HashMap map = new HashMap<>();
map.put("teksti", "Hei mualima!");

Kun HashMap-olio palautetaan pyynnön käsittelyn jälkeen -- return new ModelAndView(map, "index"); -- annetaan lokerikko Thymeleafin käyttöön.

Thymeleaf etsii annetusta HashMap-oliosta lokeroita th:text-attribuutille annetulla nimellä. Esimerkiksi kun Thymeleaf käsittelee edellä näkemämme <h2 th:text="${teksti}">testi</h2>-rivin, etsii se HashMap-oliosta lokeron nimeltä teksti, ja asettaa siinä olevan arvon elementin sisälle tekstisolmuksi. Tässä tapauksessa HTML-dokumentissa olevan h2-elementin sisältämä tekstisolmu "testi" korvataan HashMap-olion lokerosta teksti löytyvällä arvolla, eli tekstillä "Hei mualima!".

Listojen ja olioiden käsittely

Tutustutaan seuraavaksi olioiden ja listojen käsittelyyn Thymeleafin avulla. Oletetaan, että käytössämme on seuraava opiskelijaa kuvaava luokka.

package tikape;

public class Opiskelija {

    private Integer id;
    private String nimi;

    public Opiskelija() {
    }

    public Opiskelija(Integer id, String nimi) {
        this.id = id;
        this.nimi = nimi;
    }

    public Integer getId() {
        return id;
    }

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

    public String getNimi() {
        return nimi;
    }

    public void setNimi(String nimi) {
        this.nimi = nimi;
    }
}

Jokaisella opiskelijalla on siis tunnus sekä nimi. Tämän lisäksi, jokaiselle opiskelijalle kuuluu get- ja set-metodit, joiden avulla opiskelijaan liittyviä tietoja voidaan hakea ja muokata.

Muokataan aiempaa ohjelmaamme siten, että käytössämme on listallinen opiskelijoita, jotka palautetaan sivun mukana thymeleafin käsiteltäväksi.

package tikape;

import java.util.ArrayList;
import java.util.HashMap;
import spark.ModelAndView;
import spark.Spark;
import spark.template.thymeleaf.ThymeleafTemplateEngine;

public class Main {

    public static void main(String[] args) {
        ArrayList<Opiskelija> opiskelijat = new ArrayList<>();
        opiskelijat.add(new Opiskelija(1, "Ada Lovelace"));
        opiskelijat.add(new Opiskelija(2, "Charles Babbage"));

        Spark.get("/opiskelijat", (req, res) -> {
            HashMap map = new HashMap<>();
            map.put("teksti", "Hei mualima!");
            map.put("opiskelijat", opiskelijat);

            return new ModelAndView(map, "index");
        }, new ThymeleafTemplateEngine());
    }
}

Lisätään vielä opiskelijat html-sivulle.

<!DOCTYPE html SYSTEM "http://www.thymeleaf.org/dtd/xhtml1-strict-thymeleaf-4.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">

    <head>
        <title>Otsikko</title>
        <meta charset="utf-8" />
    </head>

    <body>
        <h1>Hei maailma!</h1>

        <h2 th:text="${teksti}">testi</h2>

        <h2 th:text="${opiskelijat}">opiskelijatesti</h2>

    </body>
</html>

Kun nyt haemme sivua, saamme (esimerkiksi) seuraavanlaisen näkymän.

Osoite http://localhost:4567/opiskelijat avattuna. Sivulla näkyy teksti Hei maailma!

							   Hei mualima!

							   [tikape.Opiskelija@4f4a43a5, tikape.Opiskelija@41ce9964]
Osoite http://localhost:4567/sivu avattuna. Opiskelijat näkyvät, mutta eivät toivotussa muodossa.

Listan läpikäynti Thymeleafin avulla

Ohjelmointikursseilla listan läpikäymiseen käytetään muunmuassa while ja for-lausetta. Edellä nähdyt opiskelijoiden tiedot voitaisiin tulostaa Java-koodissa seuraavasti:

ArrayList<Opiskelija> opiskelijat = new ArrayList<>();
opiskelijat.add(new Opiskelija(1, "Ada Lovelace"));
opiskelijat.add(new Opiskelija(2, "Charles Babbage"));

for (Opiskelija opiskelija: opiskelijat) {
    System.out.println("id: " + opiskelija.getId());
    System.out.println("nimi: " + opiskelija.getNimi());
    System.out.println();
}
id: 1
nimi: Ada Lovelace

id: 2
nimi: Charles Babbage

Vastaavanlainen toiminnallisuus löytyy myös Thymeleafista. Listan läpikäynti tapahtuu attribuutilla th:each, jolle annetaan sekä läpikäytävän listan nimi -- taas aaltosulkujen sisällä siten, että aaltosulkuja ennen on dollarimerkki -- että yksittäisen listaelementin nimi, jota käytetään listaa läpikäydessä. Alla olevassa esimerkissä aloitetaan lista ul-elementin avulla. Jokaiselle opiskelijalle luodaan oma li-elementti (<li th:each="opiskelija: ${opiskelijat}">...</li>), jonka sisälle haetaan käsiteltävään opiskelijaan liittyvät tiedot.

<!DOCTYPE html SYSTEM "http://www.thymeleaf.org/dtd/xhtml1-strict-thymeleaf-4.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">

    <head>
        <title>Otsikko</title>
        <meta charset="utf-8" />
    </head>

    <body>
        <h1>Hei maailma!</h1>

        <h2 th:text="${teksti}">testi</h2>

        <ul>
            <li th:each="opiskelija: ${opiskelijat}">
                <span th:text="${opiskelija.id}">1</span>
                <span th:text="${opiskelija.nimi}">Essi esimerkki</span>
            </li>
        </ul>

    </body>
</html>

Kun sivua tarkastelee selaimesta, näyttää se seuraavalta:

Osoite http://localhost:4567/opiskelijat avattuna. Sivulla näkyy teksti
								   Hei maailma!

								   Hei mualima!

								   1 Ada Lovelace
								   2 Charles Babbage
Osoite http://localhost:4567/sivu avattuna. Opiskelijat näkyvät listattuna.

 

Edellä olevassa esimerkissä käydään listalla olevat opiskelijat läpi, ja luodaan niiden perusteella sivulle dataa. Mielenkiintoista esimerkissä on se, että yksittäisen opiskelijan id-kenttään pääsee käsiksi sanomalla (esimerkiksi) <span th:text="${opiskelija.id}">1</span>. Tässä Thymeleaf päättelee opiskelija.id-kohdassa, että sen tulee etsiä opiskelija-oliolta getId()-metodia, kutsua sitä, ja asettaa tähän metodin palauttama arvo.

Tiedon lähettäminen palvelimelle

Tiedon lähettäminen (POST) palvelimelle tapahtuu HTML-sivuilla lomakkeen avulla.

Lomakkeen määrittely

Lomakkeelle (form) määritellään metodiksi (method) lähetys, eli POST, sekä osoite, johon lomakkeella oleva tieto tulee lähettää. Lomakkeen määrittely alkaa muodossa <form method="POST" action="/osoite">, missä /osoite on palvelimelle määritelty osoite. Tätä seuraa erilaiset lomakkeen kentät, esimerkiksi tekstikenttä (<input type="text" name="nimi"/>), johon syötettävälle arvolle tulee name-kentässä määritelty nimi. Lomakkeeseen tulee lisätä myös nappi (<input type="submit" value="Lähetä!"/>), jota painamalla lomake lähetetään. Lomake voi olla kokonaisuudessaan esimerkiksi seuraava:

<form method="POST" action="/opiskelijat">
    <span>Nimi:</span><br/>
    <input type="text" name="nimi"/><br/>
    <input type="submit" value="Lisää opiskelija"/>
</form>

Yllä määritelty lomake näyttää selaimessa (esimerkiksi) seuraavalta:

  
Nimi:

Nappia painamalla lomakkeeseen kirjoitettu tieto yritetään tämän materiaalin osoitteessa olevaan polkuun /opiskelijat. Ei taida onnistua..

Tiedon lähetyksen vastaanotto

Palvelimelle määritellään tietoa vastaanottava osoite metodilla post, jolle annetaan parametrina kuunneltava osoite, sekä koodi, joka suoritetaan kun osoitteeseen lähetetään tietoa. Pyynnön mukana lähetettävään tietoon -- esimerkiksi ylläolevalla lomakkeella voidaan lähettää nimi-niminen arvo palvelimelle -- pääsee käsiksi req-nimisen parametrin metodilla queryParams.

Spark.post("/opiskelijat", (req, res) -> {
    String nimi = req.queryParams("nimi");
    System.out.println("Vastaanotettiin " + nimi);

    return "Kerrotaan siitä tiedon lähettäjälle: " + nimi;
});

Samaa osoitetta voi käsitellä sekä get, että post-metodilla. Palvelin voi siis palauttaa selaimen tekemiin hakupyyntöihin tiettyä dataa -- esimerkiksi vaikkapa lomakkeen -- ja käsitellä lähetetyn tiedon erikseen. Alla on määritelty kaksi /opiskelijat-osoitetta kuuntelevaa toiminnallisuutta. Toinen palauttaa merkkijonona muotoillun lomakkeen (tämä kannattaisi tehdä erilliselle HTML-sivulle!), toinen taas palauttaa tekstin, jonka osana on lomakkeella lähetetty nimi.

package tikape;

import spark.Spark;

public class Main {

    public static void main(String[] args) {

        ArrayList<Opiskelija> opiskelijat = new ArrayList<>();
        opiskelijat.add(new Opiskelija(1, "Ada Lovelace"));
        opiskelijat.add(new Opiskelija(2, "Charles Babbage"));
  
        Spark.get("/opiskelijat", (req, res) -> {
            HashMap map = new HashMap<>();
            map.put("teksti", "Hei mualima!");
            map.put("opiskelijat", opiskelijat);

            return new ModelAndView(map, "index");
        }, new ThymeleafTemplateEngine());

        Spark.post("/opiskelijat", (req, res) -> {
            String nimi = req.queryParams("nimi");
            return "Kerrotaan siitä tiedon lähettäjälle: " + nimi;
        });

    }
}

Kun palvelin käynnistetään ylläolevalla ohjelmalla, löytyy osoitteesta http://localhost:4567/opiskelijat seuraavanlainen sivu:

Kun osoitteeseen http://localhost:4567/opiskelijat tehdään pyyntö, nähdään aiemmin määritelty lomake.
Kun osoitteeseen http://localhost:4567/opiskelijat tehdään pyyntö, nähdään aiemmin määritelty lomake.

Täytetään lomake -- vaikkapa nimellä Edgar F. Codd.

Lomakkeen nimi-kenttään asetettu arvo 'Edgar F. Codd'.
Lomakkeen nimi-kenttään asetettu arvo 'Edgar F. Codd'.

Kun painamme nyt nappia Lisää opiskelija, tekstikentän sisältö lähetetään palvelimelle lomakkeen action-kentän määrittelemään osoitteeseen. Jos lomakkeessa määritelty metodiksi (method) post, tehdään lähetyspyyntö. Jos action kenttä on /opiskelijat ja metodi POST, lähetettävä tieto vastaanotetaan ja suoritetaan rivillä post("/opiskelijat", (req, res) -> { alkavalla ohjelmakoodilla. Aiemmin määritellyllä ohjelmalla käyttäjälle näytetään seuraavanlainen sivu:

Lomake on lähetetty ja palvelin palauttaa tekstin 'Kerrotaan siitä tiedon lähettäjälle: Edgar F. Codd'.
Lomake on lähetetty ja palvelin palauttaa tekstin 'Kerrotaan siitä tiedon lähettäjälle: Edgar F. Codd'.

Tiedon säilöminen palvelimelle hetkellisesti

Voimme tallentaa vastaanotetun tiedon palvelimelle palvelimen käynnissäoloajaksi säilömällä sen esimerkiksi ArrayList-tyyppiseen listaan. Muokataan ylläolevaa aiempaa koodia siten, että hakupyyntö osoitteeseen /opiskelijat palauttaa sekä lomakkeen että tallennetut opiskelijat. Tämän lisäksi, lisätään osoitteeseen /opiskelijat tehtävän lähetyspyynnön käsittelyyn lomakkeelta saatavan nimi-kentän lisääminen ohjelmassa olevaan listaan.

package tikape;

import java.util.ArrayList;
import spark.Spark;

public class Main {

    public static void main(String[] args) {

        ArrayList<Opiskelija> opiskelijat = new ArrayList<>();
        opiskelijat.add(new Opiskelija(1, "Ada Lovelace"));
        opiskelijat.add(new Opiskelija(2, "Charles Babbage"));
  
        Spark.get("/opiskelijat", (req, res) -> {
            HashMap map = new HashMap<>();
            map.put("teksti", "Hei mualima!");
            map.put("opiskelijat", opiskelijat);

            return new ModelAndView(map, "index");
        }, new ThymeleafTemplateEngine());

        Spark.post("/opiskelijat", (req, res) -> {
            String nimi = req.queryParams("nimi");

            opiskelijat.add(new Opiskelija(opiskelijat.size() + 1, nimi));
            return "Kerrotaan siitä tiedon lähettäjälle: " + nimi;
        });

    }
}

Nyt kun osoitteessa /opiskelijat olevalla lomakkeella tehdään useampia pyyntöjä, tulee lomakesivulle lisää näytettäviä opiskelijoita.

Lomakkeella lähetetty arvot 'Edgar F. Codd' ja 'Ada Lovelace' ja lomake-sivu avattu uudestaan.
Lomakkeella lähetetty arvot 'Edgar F. Codd' ja 'Ada Lovelace' ja lomake-sivu avattu uudestaan.

Tiedon lisääminen edellisellä tavalla johtaa tilanteeseen, missä käyttäjä näkee lisäyksen yhteydessä vain listasivun. Hyvä käytäntö on lisätä lisäystoiminnallisuuden loppuun uudelleenohjauskutsu, jonka perusteella selain pyydetään tekemään uusi kutsu osoitteeseen, joka sisältää tietojen listaamisen. Tämä onnistuu seuraavasti.

Spark.post("/opiskelijat", (req, res) -> {
    String nimi = req.queryParams("nimi");

    System.out.println("Lisätään " + nimi);
    opiskelijat.add(new Opiskelija(opiskelijat.size() + 1, nimi));

    res.redirect("/opiskelijat");
    return "";
});
POST/Redirect/GET

Edellä kuvattu esimerkki luo tilanteen, missä tiedon lähettäminen palvelimelle POST-pyynnöllä aiheuttaa uudelleenohjauksen, mikä aiheuttaa uuden GET-pyynnön selaimen toimesta. Käytännössä kun käyttäjä lähettää tietoa palvelimelle, hänen selaimensa hakee tiedon lähettämisen jälkeen uuden päivittyneen sivun automaattisesti. Sivun uudelleen lataaminen (f5 tai refresh) ei myöskään lähetä lomakkeen tietoja automatttisesti uudelleen.

Useamman kentän lähettäminen

HTML-lomakkeelle voidaan määritellä useampia kenttiä. Jokaisella kentällä tulee olla eri nimi, jotta palvelimella voidaan ottaa lomakkeen tiedon vastaan. Esimerkiksi nimeä ja osoitetta voisi kerätä vaikkapa seuraavanlaisella lomakkeella.

<form method="POST" action="/opiskelijat">
    Nimi:<br/>
    <input type="text" name="nimi"/><br/>
    Osoite:<br/>
    <input type="text" name="osoite"/><br/>
    <input type="submit" value="Lisää opiskelija"/>
</form>

Lomake näyttää selaimessa (esimerkiksi) seuraavalta:

  
Nimi:

Osoite:

Tehtäväpohjassa on valmiina web-sovellus, joka näyttää käyttäjälle tehtäviä. Tässä tehtävänäsi on lisätä sovellukseen toiminnallisuus, jonka avulla käyttäjälle voi lisätä tehtäviä.

Toteuta toiminnallisuus seuraavia askeleita noudattaen:

  • Lisää tehtäväpohjassa olevaan index.html-tiedostoon lomake, joka sisältää tekstikentän tehtävän nimen syöttämiseksi. Lomake tulee lähettää palvelimelle POST-tyyppisenä pyyntönä.
  • Lisää sovellukseen (tiedosto Tyolista.java) osoite, joka kuuntelee lomakkeen lähetystä. Lisää tallennustoiminnallisuuden loppuun uudelleenohjaus, joka vie käyttäjän sivulle, joka näyttää kaikki tehtävät.
  • Tallenna lähetetty tieto listalle.

Huomaa, että joudut sammuttamaan palvelimen aina muutosten yhteydessä. Toisin sanoen, ohjelmointiympäristö ei automaattisesti päivitä muutoksia palvelimelle. Muistathan myös sammuttaa palvelimen kun tehtävä on valmis -- näin palvelin ei jää estämään muiden palvelinten käynnistymistä.

Kun sovellus toimii, palauta se TMC:lle.

Tietokannan käyttöönotto

Tietokannan käyttöönotto onnistuu kuten Java-ohjelmissa yleensä. Tällä kertaa tosin hyödynnämme tietokantaa osana web-sovellusta. Tietokannassa olevien opiskelijoiden käsittely tapahtuisi esimerkiksi seuraavasti:

package tikape;

import java.util.HashMap;
import spark.ModelAndView;
import spark.Spark;
import spark.template.thymeleaf.ThymeleafTemplateEngine;
import tikape.database.Database;
import tikape.database.OpiskelijaDao;

public class Main {

    public static void main(String[] args) throws Exception {
        Database database = new Database("jdbc:sqlite:opiskelijat.db");

        OpiskelijaDao opiskelijaDao = new OpiskelijaDao(database);

        Spark.get("/opiskelijat", (req, res) -> {
            HashMap map = new HashMap<>();
            map.put("opiskelijat", opiskelijaDao.findAll());

            return new ModelAndView(map, "index");
        }, new ThymeleafTemplateEngine());


        Spark.post("/opiskelijat", (req, res) -> {
            opiskelijaDao.saveOrUpdate(new Opiskelija(null, req.queryParams("nimi")));
            res.redirect("/opiskelijat");
            return "";
        });

    }
}

Tehtäväpohjassa on valmiina sama web-sovellus kuin edellisessä tehtävässä, eli sovellus joka näyttää käyttäjälle tehtäviä. Tässä tehtävässä laajennetaan sovellusta siten, että se käyttää sovellus tallentaa tehtävät tietokantaan (sekä hakee tehtävät tietokannasta). Kopioi edellisessä osassa toteuttamasi sovellus tänne lähtökohdaksi.

Luo tehtäväpohjassa olevaan kansioon db SQLite-tietokanta tasks.db. Tietokannassa tulee olla yksi taulu nimeltä Tehtava, jossa on ainakin sarake nimi.

Muokkaa sovellusta siten, että tehtävät tallennetaan ja haetaan kansiossa db olevasta tietokannasta tasks.db.

Huomaa, että joudut sammuttamaan palvelimen aina muutosten yhteydessä. Toisin sanoen, ohjelmointiympäristö ei automaattisesti päivitä muutoksia palvelimelle. Muistathan myös sammuttaa palvelimen kun tehtävä on valmis -- näin palvelin ei jää estämään muiden palvelinten käynnistymistä.

Kun sovellus toimii, palauta se TMC:lle.

Sekvenssikaaviot

Sekvenssikaaviot ovat järjestelmien (ja olioiden) vuorovaikutuksen visualisointiin käytettävä menetelmä. Sekvenssikaaviossa järjestelmät kuvataan pystysuorina viivoina ja järjestelmien väliset kutsut vaakasuorina viivoina. Aika kulkee ylhäältä alas. Järjestelmät kuvataan laatikoina sekvenssikaavion ylälaidassa, joista pystysuorat viivat lähtevät. Järjestelmien kutsuihin merkitään oleellinen kuvaustieto, esimerkiksi olioiden yhteydessä metodin nimi tai korkeammalla tasolla järjestelmän toimintaa kuvattavaessa haluttu toiminto. Kutsun palauttama tieto piirretään palaavana katkoviivana.

Alla on kuvattuna tilanne, missä käyttäjä haluaa hakea palvelimelta kaikki opiskelijat (vastaa edellisen luvun lopussa olevan sovellusken tarjoamaa toiminnallisuutta.

Selaimen, palvelimen sekä tietokannan välistä kommunikaatiota kuvaava sekvenssikaavio.
Käyttäjä tekee selaimella pyynnön palvelimelle menemällä osoitteeseen "/opiskelijat". Palvelimella oleva koodi tekee ensin pyynnön tietokantaan, missä haetaan kaikki tietokannassa olevat opiskelijat. Tämän jälkeen palvelin antaa opiskelijalistan sekä html-sivun nimen Thymeleafille, joka luo sivusta HTML-sivun. Lopulta luotu HTML-sivu palautetaan käyttäjälle.

Useampaa tietokantataulua käyttävä web-sovellus

Rakennetaan seuraavaksi useampaa tietokantataulua käyttävä web-sovellus. Tarve on seuraava:

Haluaisin käyttööni tehtävien hallintaan tarkoitetun englanninkielisen sovelluksen. Jokaisella tehtävällä on nimi sekä tieto siitä, että onko tehtävä tehty. Tehtäviin voi määritellä aihepiirejä, joiden perusteella tehtäviä pitäisi myös pystyä hakemaan. Tämän lisäksi sovelluksessa tulee olla käyttäjiä, joiden tulee pystyä ottamaan tehtäviä työn alle. Vain työn alle otettu tehtävä voidaan merkitä tehdyksi.

Kuvauksesta tunnistetaan käsitteet tehtävä, aihepiiri ja käyttäjä. Tämän lisäksi tehtävä voi kuulua yhteen tai useampaan aihepiiriin, ja jokaiseen aihepiiriin voi liittyä useampi tehtävä. Käyttäjällä voi olla useampia tehtäviä työn alla. Aihealueen kuvaus ei ota kantaa siihen, voiko sama tehtävä olla useammalla käyttäjällä samaan aikaan työn alla -- suunnitellaan tietokanta siten, että samaa tehtävää voi periaatteessa tehdä useampi käyttäjä.

Tekstimuodossa kuvattuna tietokantataulut ovat seuraavat. Koska sovellus haluttiin englanninkielisenä, myös tietokannan termistö on englanniksi.

Task((pk) id, name)
User((pk) id, name)
TaskAssignment((pk) id, (fk) task_id -> Task, (fk) user_id -> User, boolean completed)
Category((pk) id, name)
TaskCategory((pk) id, (fk) task_id -> Task, (fk) category_id -> Category)

Sovellus rakennetaan askeleittain. Toteutetaan ensin tehtävien lisääminen ja listaaminen. Tämän jälkeen lisätään mahdollisuus käyttäjien lisäämiseen ja listaamiseen. Tätä seuraa tehtävien lisääminen käyttäjälle, jonka jälkeen toteutetaan tehtävien suorittaminen.

Alustava sovelluksen kansiorakenne eriyttää aihealuetta kuvaavat käsitteet, tietokannan käsittelyyn tarvittavat luokat sekä html-sivut. Alla kansiorakenne puuna kuvattuna.

kayttaja@kone:~/kansio$ tree
.
├── pom.xml
├── src
│   ├── main
│   │   ├── java
│   │   │   └── tikape
│   │   │       └── tasks
│   │   │           ├── dao
│   │   │           │   ├── Dao.java
│   │   │           │   └── TaskDao.java
│   │   │           ├── database
│   │   │           │   └── Database.java
│   │   │           ├── domain
│   │   │           │   └── Task.java
│   │   │           └── TaskApplication.java
│   │   └── resources
│   │       └── templates
│   │           └── tasks.html
│   └── test
│       └── java
└── tasks.db

Sovelluksen pom.xml-tiedoston sisältö on seuraava.

<?xml version="1.0" encoding="UTF-8"?>
<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>tikape</groupId>
    <artifactId>tasks</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
    </properties>
  
    <dependencies>
        <dependency>
            <groupId>com.sparkjava</groupId>
            <artifactId>spark-core</artifactId>
            <version>2.7.1</version>
        </dependency>
        <dependency>
            <groupId>com.sparkjava</groupId>
            <artifactId>spark-template-thymeleaf</artifactId>
            <version>2.7.1</version>
        </dependency>
        <dependency>
            <groupId>org.xerial</groupId>
            <artifactId>sqlite-jdbc</artifactId>
            <version>3.21.0.1</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>1.7.25</version>
        </dependency>
    </dependencies>
</project>

Tehtävän lisääminen sovellukseen

Toteutetaan ensin tehtävien listaaminen ja lisääminen.

Yleisesti ottaen, laajempaa sovellusta rakennettaessa sovelluksen polut kannattaa toteuttaa kuvaamaan käsiteltäviä asioita. Luodaan tehtäviä varten web-sovellukseen polku /tasks, mistä tehtävät löytyvät. Sovelluksen tehtävien käsittelyyn liittyvä "rajapinta" tulee olemaan seuraavanlainen.

  • Tiedon hakeminen palvelimen osoitteesta /tasks listaa kaikki tehtävät.
  • Tiedon lähettäminen palvelimen osoitteeseen /tasks luo uuden tehtävän.

Luodaan näkymää varten sivu tasks.html, jonka avulla käyttäjälle listataan tehtävät sekä mahdollistetaan tehtävien lisääminen. Sivu tulee projektin kansioon src/main/resources/templates. Sivulla on sekä lista tehtäviä että lomake.

<!DOCTYPE html SYSTEM "http://www.thymeleaf.org/dtd/xhtml1-strict-thymeleaf-4.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">

    <head>
        <title>Tasks</title>
        <meta charset="utf-8" />
    </head>

    <body>
        <h1>Tasks</h1>

        <ul>
            <li th:each="task : ${tasks}">
                <span th:text="${task.name}">Task</span>
            </li>
        </ul>

        <h2>Add new task</h2>

        <form method="POST" action="/tasks">
            <input type="text" name="name"/><br/>
            <input type="submit" value="Add!"/>
        </form>
    </body>
</html>

Luodaan tämän jälkeen ongelma-aluetta kuvaava luokka Task. Ongelma-aluetta tai aihealuetta (domain) kuvaavat luokat kannattaa sovelluksen rakenteen asetetaan pakkaukseen domain. Esimerkissämme sovellus rakentuu pakkaukseen tikape.tasks, jolloin käsitteistöä kuvaavat luokat asetetaan pakkaukseen tikape.tasks.domain.

package tikape.tasks.domain;

public class Task {

    private Integer id;
    private String name;

    public Task(Integer id, String name) {
        this.id = id;
        this.name = name;
    }

    public Integer getId() {
        return id;
    }

    public String getName() {
        return name;
    }
}

Materiaalin kolmannessa osassa loimme tietokanta-abstraktion sekä harjoittelimme data access object-luokkien toteuttamista. Luodaan käyttöömme tarvittavat luokat tietokannassa olevien tehtävien käsittelyyn.

package tikape.tasks.database;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class Database {

    private String databaseAddress;

    public Database(String databaseAddress) throws ClassNotFoundException {
        this.databaseAddress = databaseAddress;
    }

    public Connection getConnection() throws SQLException {
        return DriverManager.getConnection(databaseAddress);
    }
}
package tikape.tasks.dao;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import tikape.tasks.database.Database;
import tikape.tasks.domain.Task;

public class TaskDao implements Dao<Task, Integer> {

    private Database database;

    public TaskDao(Database database) {
        this.database = database;
    }

    @Override
    public Task findOne(Integer key) throws SQLException {
        throw new UnsupportedOperationException("Not supported yet.");
    }

    @Override
    public List<Task> findAll() throws SQLException {
        List<Task> tasks = new ArrayList<>();

        try (Connection conn = database.getConnection();
            ResultSet result = conn.prepareStatement("SELECT id, name FROM Task").executeQuery()) {

            while (result.next()) {
                tasks.add(new Task(result.getInt("id"), result.getString("name")));
            }
        }

        return tasks;
    }

    @Override
    public Task saveOrUpdate(Task object) throws SQLException {
        // simply support saving -- disallow saving if task with 
        // same name exists
        Task byName = findByName(object.getName());

        if (byName != null) {
            return byName;
        } 

        try (Connection conn = database.getConnection()) {
            PreparedStatement stmt = conn.prepareStatement("INSERT INTO TASK (name) VALUES (?)");
            stmt.setString(1, object.getName());
            stmt.executeUpdate();
        }

        return findByName(object.getName());
    }

    private Task findByName(String name) throws SQLException {
        try (Connection conn = database.getConnection()) {
            PreparedStatement stmt = conn.prepareStatement("SELECT id, name FROM Task WHERE name = ?");
            stmt.setString(1, name);

            ResultSet result = stmt.executeQuery();
            if (!result.next()) {
                return null;
            }

            return new Task(result.getInt("id"), result.getString("name"));
        }
    }

    @Override
    public void delete(Integer key) throws SQLException {
        throw new UnsupportedOperationException("Not supported yet.");
    }
}

Luodaan vielä tietokanta sekä tietokantaan tehtävää kuvaava taulu. Luodaan nämä sovelluksen juureen tiedostoon nimeltä tasks.db.

CREATE TABLE Task (
    id integer PRIMARY KEY,
    name varchar(255)
);

Nyt palat ovat paikallaan. Käytössämme ovat (1) html-sivu, (2) käsitettä kuvaava luokka, (3) tietokanta-abstraktio ja dao-toteutus, ja (4) tietokantataulu. Luodaan lopulta web-sovelluksen käynnistävä luokka TaskApplication. Sovellus käsittelee pyyntöjä osoitteeseen /tasks.

package tikape.tasks;

import java.util.HashMap;
import spark.ModelAndView;
import spark.Spark;
import spark.template.thymeleaf.ThymeleafTemplateEngine;
import tikape.tasks.dao.TaskDao;
import tikape.tasks.database.Database;
import tikape.tasks.domain.Task;

public class TaskApplication {

    public static void main(String[] args) throws Exception {
        Database database = new Database("jdbc:sqlite:tasks.db");
        TaskDao tasks = new TaskDao(database);

        Spark.get("/tasks", (req, res) -> {
            HashMap map = new HashMap<>();
            map.put("tasks", tasks.findAll());

            return new ModelAndView(map, "tasks");
        }, new ThymeleafTemplateEngine());

        Spark.post("/tasks", (req, res) -> {
            Task task = new Task(-1, req.queryParams("name"));
            tasks.saveOrUpdate(task);

            res.redirect("/tasks");
            return "";
        });
    }
}

Sovellus tukee nyt tehtävien lisäämistä ja listaamista.

Käyttäjien lisääminen sovellukseen

Lisätään seuraavaksi käyttäjät sovellukseen. Käyttäjien käsittelyyn liittyvä rajapinta tulee olemaan seuraavanlainen web-sovelluksen käyttäjän näkökulmasta.

  • Tiedon hakeminen palvelimen osoitteesta /users listaa kaikki käyttäjät.
  • Tiedon lähettäminen palvelimen osoitteeseen /users luo uuden käyttäjän.

Käyttäjien toiminnallisuus ja niihin liittyvä ohjelmakoodi vastaa hyvin pitkälti tehtävien lisäämiseen ja listaamiseen liittyvää ohjelmakoodia. Voimme käytännössä copy-pasteta edellisen osan askeleet -- noudatetaan tässä Three Strikes And You Refactor-periaatetta. Kopioimme siis seuraavat tiedostot sekä muokkaamme niitä sopivasti:

  • tasks.html sivun muotoon users.html
  • Task.java-luokan luokaksi User.java
  • TaskDao.java-luokan luokaksi UserDao.java

Käyttäjää kuvaavan tietokantataulun nimeksi tulee User -- tietokantataulun luomiskomento on seuraava. Jotkut tietokannanhallintajärjestelmät ovat tosin varanneet kyseisen sanan käyttöönsä -- taulun User luominen ei siis onnistu kaikissa tietokannanhallintajärjestelmissä..

CREATE TABLE User (
    id integer PRIMARY KEY,
    name varchar(255)
);

Lisätään tämän jälkeen luokan TaskApplication main-metodiin käyttäjien käsittelyyn tarvittavat rivit.

public static void main(String[] args) throws Exception {

    Database database = new Database("jdbc:sqlite:tasks.db");
    TaskDao tasks = new TaskDao(database);
    UserDao users = new UserDao(database);

    Spark.get("/tasks", (req, res) -> {
        HashMap map = new HashMap<>();
        map.put("tasks", tasks.findAll());

        return new ModelAndView(map, "tasks");
    }, new ThymeleafTemplateEngine());

    Spark.post("/tasks", (req, res) -> {
        Task task = new Task(-1, req.queryParams("name"));
        tasks.saveOrUpdate(task);

        res.redirect("/tasks");
        return "";
    });

    Spark.get("/users", (req, res) -> {
        HashMap map = new HashMap<>();
        map.put("users", users.findAll());

        return new ModelAndView(map, "users");
    }, new ThymeleafTemplateEngine());

    Spark.post("/users", (req, res) -> {
        User user = new User(-1, req.queryParams("name"));
        users.saveOrUpdate(user);

        res.redirect("/users");
        return "";
    });
}

Tehtävien lisääminen käyttäjille

Lisätään seuraavaksi sovellukseen mahdollisuus tehtävien lisäämiseen käyttäjille. Toteutetaan toiminnallisuus siten, että tehtävä näkyy tehtävälistauksessa vain jos tehtävää ei ole lisätty käyttäjälle. Lisätään tämän jälkeen käyttäjille henkilökohtainen sivu, missä näkyy käyttäjälle määritellyt tehtävät.

Luodaan erillinen taulu TaskAssignment tehtävien käyttäjille lisäämistä varten. Taulu TaskAssignment on liitostaulu tehtävän ja käyttäjän välillä, jonka lisäksi taulu pitää kirjaa siitä, onko tehtävä tehty.

CREATE TABLE TaskAssignment (
    id integer PRIMARY KEY,
    task_id integer,
    user_id integer,
    completed boolean,
    FOREIGN KEY (task_id) REFERENCES Task(id),
    FOREIGN KEY (user_id) REFERENCES User(id)
);

Tietokannan koko rakenne on tällä hetkellä seuraava:

sqlite> .schema
CREATE TABLE Task (
    id integer PRIMARY KEY,
    name varchar(255)
);
CREATE TABLE User (
    id integer PRIMARY KEY,
    name varchar (255)
);
CREATE TABLE TaskAssignment (
    id integer PRIMARY KEY,
    task_id integer,
    user_id integer,
    completed boolean,
    FOREIGN KEY (task_id) REFERENCES Task(id),
    FOREIGN KEY (user_id) REFERENCES User(id)
);

Määritellään polku tehtävän lisäämiseen käyttäjälle muotoon /tasks/taskId, missä taskId viittaa tietyn tehtävän avaimeen. Polkuun tulee lähettää kenttä userId, jonka arvon tulee olla tehtävään määrättävän käyttäjän tunnus.

Luodaan ensin luokat TaskAssignment ja TaskAssignmentDao. Jälkimmäinen mahdollistaa vain yksittäisen TaskAssignment-olion tallentamisen.

package tikape.tasks.domain;

public class TaskAssignment {

    private Integer id;
    private Integer taskId;
    private Integer userId;
    private Boolean completed;

    public TaskAssignment(Integer id, Integer taskId, Integer userId, Boolean completed) {
        this.id = id;
        this.taskId = taskId;
        this.userId = userId;
        this.completed = completed;
    }

    public Integer getId() {
        return this.id;
    }
  
    public Integer getTaskId() {
        return taskId;
    }

    public Integer getUserId() {
        return userId;
    }

    public Boolean getCompleted() {
        return completed;
    }
}
package tikape.tasks.dao;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.List;
import tikape.tasks.database.Database;
import tikape.tasks.domain.TaskAssignment;

public class TaskAssignmentDao implements Dao<TaskAssignment, Integer> {

    private Database database;

    public TaskAssignmentDao(Database database) {
        this.database = database;
    }

    @Override
    public TaskAssignment findOne(Integer key) throws SQLException {
        throw new UnsupportedOperationException("Not supported yet.");
    }

    @Override
    public List<TaskAssignment> findAll() throws SQLException {
        throw new UnsupportedOperationException("Not supported yet.");
    }

    @Override
    public TaskAssignment saveOrUpdate(TaskAssignment object) throws SQLException {
        try (Connection conn = database.getConnection()) {
            PreparedStatement stmt = conn.prepareStatement(
                "INSERT INTO TaskAssignment (task_id, user_id, completed) VALUES (?, ?, 0)");
            stmt.setInt(1, object.getTaskId());
            stmt.setInt(2, object.getUserId());
            stmt.executeUpdate();
        }

        return null;
    }

    @Override
    public void delete(Integer key) throws SQLException {
        throw new UnsupportedOperationException("Not supported yet.");
    }
}

Toteutetaan näkymä muokkaamalla sivua tasks.html siten, että jokaisen listattavan tehtävän kohdalla on lista käyttäjistä. Jos listasta valitsee käyttäjän ja valitsee "Assign task!", tehtävä tulee lisätä kyseiselle käyttäjälle.

<!DOCTYPE html SYSTEM "http://www.thymeleaf.org/dtd/xhtml1-strict-thymeleaf-4.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">

    <head>
        <title>Tasks</title>
        <meta charset="utf-8" />
    </head>

    <body>
        <h1>Tasks</h1>

        <ul>
            <li th:each="task : ${tasks}">
                <span th:text="${task.name}">Task</span>
                <form th:action="@{~/tasks/{id}(id=${task.id})}" method="post">
                    <select name="userId">
                        <option th:each="user: ${users}" th:value="${user.id}" th:text="${user.name}">
                            user
                        </option>
                    </select>
                    <input type="submit" value="Assign task!"/>
                </form>
            </li>
        </ul>

        <h2>Add new task</h2>

        <form method="POST" action="/tasks">
            <input type="text" name="name"/><br/>
            <input type="submit" value="Add!"/>
        </form>
    </body>
</html>

Lomakkeen määrittely siten, että jokaisella tehtävällä on oma tunnuksensa ja lomakkeensa onnistuu Thymeleafin syntaksin avulla. Syntaksista lisää Thymeleafin dokumentaatiossa osoitteessa http://www.thymeleaf.org/doc/articles/standardurlsyntax.html.

Tehtävien listaamiseen käytettävää metodia tulee nyt muokata siten, että se antaa käyttäjät Thymeleafin käyttöön.

Spark.get("/tasks", (req, res) -> {
    HashMap map = new HashMap<>();
    map.put("tasks", tasks.findAll());
    map.put("users", users.findAll());

    return new ModelAndView(map, "tasks");
}, new ThymeleafTemplateEngine());

Luodaan seuraavaksi uusi metodi käyttäjien lisäämiseen. Metodi käsittelee pyyntöjä polkuun, jossa on muuttuva osa. Muuttuvan osan arvoon pääsee käsiksi Sparkin avulla. Metodissa otetaan käyttöön sekä muuttuva polun osa (eli tehtävän pääavain) että pyynnössä tuleva käyttäjän tunnus. Näiden perusteella luodaan uusi rivi tietokantatauluun TaskAssignment.

// polkuun määriteltävä parametri merkitään kaksoispisteellä ja 
// parametrin nimellä. Parametrin arvoon pääsee käsiksi kutsulla
// req.params
Spark.post("/tasks/:id", (req, res) -> {
    Integer taskId = Integer.parseInt(req.params(":id"));
    Integer userId = Integer.parseInt(req.queryParams("userId"));
  
    TaskAssignment ta = new TaskAssignment(-1, taskId, userId, Boolean.FALSE);
    taskAssignments.saveOrUpdate(ta);

    res.redirect("/tasks");
    return "";
});

Muokataan lopulta vielä tehtävien listaamiseen käytettävää metodia siten, että se näyttää listauksessa vain ne tehtävät, joita ei ole vielä asetettu kenenkään käyttöön. Luodaan tätä varten luokkaan TaskDao uusi metodi, joka hakee ne tehtävät, joiden pääavain ei esiinny taulussa TaskAssignment.

public List<Task> findAllNotAssigned() throws SQLException {
    List<Task> tasks = new ArrayList<>();

    try (Connection conn = database.getConnection();
        ResultSet result = conn.prepareStatement(
            "SELECT id, name FROM Task WHERE id NOT IN (SELECT task_id FROM TaskAssignment)"
        ).executeQuery()) {

         while (result.next()) {
            tasks.add(new Task(result.getInt("id"), result.getString("name")));
        }
    }

    return tasks;
}

Polkuun /tasks tehtävän pyynnön käsittelyä muokataan siten, että kaikki tehtävät hakevan metodin sijaan kutsutaan yllä kuvattua metodia.

Spark.get("/tasks", (req, res) -> {
    HashMap map = new HashMap<>();
    map.put("tasks", tasks.findAllNotAssigned());
    map.put("users", users.findAll());

    return new ModelAndView(map, "tasks");
}, new ThymeleafTemplateEngine());

Nyt tehtävät poistuvat tehtävälistauksesta sitä mukaa kun niitä määrätään käyttäjälle.

Henkilökohtainen tehtäväsivu

Toteutetaan seuraavaksi käyttäjille henkilökohtaiset tehtävät listaava sivu. Sivu tulee toimimaan osoitteessa /users/id, missä id on käyttäjän pääavain. Tehdään tätä varten ensin sivu, mikä sisältää käyttäjän nimen sekä käyttäjälle määrätyt tehtävät.

<!DOCTYPE html SYSTEM "http://www.thymeleaf.org/dtd/xhtml1-strict-thymeleaf-4.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">

    <head>
        <title>User's tasks</title>
        <meta charset="utf-8" />
    </head>

    <body>
        <h1 th:text="${user.name}">Name of the user</h1>

        <h2>Current tasks</h2>
  
        <ul>
            <li th:each="task : ${tasks}">
                <span th:text="${task.name}">Task</span>
            </li>
        </ul>

    </body>
</html>

Luodaan TaskDao-luokalle metodi, joka hakee käyttäjään liittyvät tehtävät. Haetaan vain ne käyttäjälle kuuluvat tehtävät, joita ei ole vielä tehty. Kyselyä kannattaa hahmotella ensin komentorivin kautta -- alla kuvattu mahdollinen kyselyn rakennusprosessi.

sqlite> SELECT name FROM Task, TaskAssignment
            WHERE Task.id = TaskAssignment.task_id AND TaskAssignment.user_id = 1;
Write
sqlite> SELECT id, name FROM Task, TaskAssignment
            WHERE Task.id = TaskAssignment.task_id AND TaskAssignment.user_id = 1;
Error: ambiguous column name: id
sqlite> SELECT Task.id, Task.name FROM Task, TaskAssignment
            WHERE Task.id = TaskAssignment.task_id AND TaskAssignment.user_id = 1;
1|Write
sqlite> SELECT Task.id, Task.name FROM Task, TaskAssignment
            WHERE Task.id = TaskAssignment.task_id AND TaskAssignment.user_id = 1
            AND TaskAssignment.completed = false;
Error: no such column: false
sqlite> SELECT Task.id, Task.name FROM Task, TaskAssignment
            WHERE Task.id = TaskAssignment.task_id AND TaskAssignment.user_id = 1
            AND TaskAssignment.completed = 0;
1|Write
sqlite> 

TaskDao-luokalle luotava uusi metodi on seuraavanlainen.

public List<Task> findNonCompletedForUser(Integer userId) throws SQLException {
    String query = "SELECT Task.id, Task.name FROM Task, TaskAssignment\n"
        + "              WHERE Task.id = TaskAssignment.task_id "
        + "                  AND TaskAssignment.user_id = ?\n"
        + "                  AND TaskAssignment.completed = 0";

    List<Task> tasks = new ArrayList<>();

    try (Connection conn = database.getConnection()) {
        PreparedStatement stmt = conn.prepareStatement(query);
        stmt.setInt(1, userId);
        ResultSet result = stmt.executeQuery();

        while (result.next()) {
            tasks.add(new Task(result.getInt("id"), result.getString("name")));
        }
    }

    return tasks;
}

Käyttäjäkohtaiseen osoitteeseen tulevat pyynnöt käsittelevä metodi ottaa pyynnön polusta tarkasteltavan käyttäjän tunnuksen. Tämän jälkeen käyttäjän tiedot haetaan tietokannasta, mitä seuraa yllä kuvatun metodin kutsuminen. Lopulta käyttäjän tiedot annetaan Thymeleafille sekä yllä kuvatulle user.html-sivulle.

Spark.get("/users/:id", (req, res) -> {
    HashMap map = new HashMap<>();
    Integer userId = Integer.parseInt(req.params(":id"));
    map.put("user", users.findOne(userId));
    map.put("tasks", tasks.findNonCompletedForUser(userId));

    return new ModelAndView(map, "user");
}, new ThymeleafTemplateEngine());

Luokan UserDao metodi findOne tulee täydentää sopivasti. Alkuperäisessä versiossamme jätimme metodin toteuttamatta.

Tällä hetkellä käyttäjäkohtaiseen sivuun ei pääse vielä käsiksi. Muokataan käyttäjien listaussivua users.html siten, että jokainen sivulla esiintyvä käyttäjän nimi on samalla linkki käyttäjän sivuun.

<!DOCTYPE html SYSTEM "http://www.thymeleaf.org/dtd/xhtml1-strict-thymeleaf-4.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">

    <head>
        <title>Users</title>
        <meta charset="utf-8" />
    </head>

    <body>
        <h1>Users</h1>

        <ul>
            <li th:each="user : ${users}">
                <a th:href="@{~/users/{id}(id=${user.id})}">
                    <span th:text="${user.name}">User</span>
                </a>
            </li>
        </ul>

        <h2>Add new user</h2>

        <form method="POST" action="/users">
            <input type="text" name="name"/><br/>
            <input type="submit" value="Add!"/>
        </form>
     </body>
</html>

Nyt sovelluksessa näkyvästä käyttäjien listauksesta pääsee käsiksi yksittäisen käyttäjän näkymään sekä hänelle määrättyihin tehtäviin.

Kategorioiden lisääminen

Lisätään seuraavaksi mahdollisuus kategorioiden lisäämiseen ja listaamiseen. Luodaan kategorioita varten ensin tietokantataulu Category.

CREATE TABLE Category (
    id integer PRIMARY KEY,
    name varchar(255)
);

Kategorioiden lisäämiseen ja listaamiseen tarvittava toiminnallisuus vastaa lähes täysin aiemmin toteutettuja tehtävien ja käyttäjien toiminnallisuuksia. Three Strikes And You Refactor -periaatteen mukaan kahdesti toistuva ohjelmakoodi ei ole ongelma, mutta jos sama koodi toistuu kolmessa eri paikassa tulee ohjelmaa refaktoroida selkeämmäksi. Otetaan tässä askeleita ohjelman selkeyttämiseksi.

Toisteisuuden vähentäminen samankaltaisista domain-luokista

Tarkastellaan ensin kategoriaa kuvaavan luokan luomista. Sekä kategorialla, tehtävällä että käyttäjällä on tunnus ja nimi. Luodaan abstrakti yliluokka AbstractNamedObject, joka sisältää nimen ja tunnuksen sekä niihin liittyvät getterit.

package tikape.tasks.domain;

public abstract class AbstractNamedObject {

    private Integer id;
    private String name;

    public AbstractNamedObject(Integer id, String name) {
        this.id = id;
        this.name = name;
    }

    public Integer getId() {
        return id;
    }

    public String getName() {
        return name;
    }
}

Nyt luokat kategoria, tehtävä ja käyttäjä voi toteuttaa perimällä luokan AbstractNamedObject. Alla kategoriaa kuvaava luokka.

package tikape.tasks.domain;

public class Category extends AbstractNamedObject {

    public Category(Integer id, String name) {
        super(id, name);
    }
}

Käyttäjien ja tehtävien kuvaamiseen käytettävät luokat muutetaan vastaavaan muotoon.

Toisteisuuden vähentäminen samankaltaisista DAO-luokista

Toteutetaan seuraavaksi kategorioiden käsittelyyn tarvittava tietokanta-abstraktio CategoryDao. Tämäkin luokka olisi vahvasti copy-pastea edellisistä luokista.

Toteutetaan ensin luokka AbstractNamedObjectDao, joka toteuttaa rajapinnan Dao. Luokka kapseloi niiden tietokantataulujen käsittelyyn liittyvää toiminnallisuutta, joissa on id ja nimi. Toteutus tehdään niin, että abstrakti luokka saa konstruktorin parametrina tietokannan lisäksi käsiteltävän tietokantataulun nimen, jota voi käyttää kyselyiden muodostamisessa.

protected Database database;
protected String tableName;

public AbstractNamedObjectDao(Database database, String tableName) {
    this.database = database;
    this.tableName = tableName;
}

Tehdään luokasta sellainen, että sen voi toteuttaa vain niille luokille, jotka perivät luokan AbstractNamedObject. Luokan "otsake" on tällöin seuraavaa muotoa:

public abstract class AbstractNamedObjectDao<T extends AbstractNamedObject>
        implements Dao<T, Integer> {

Luokka käsittelee geneeristä tyyppiä olevia olioita, joilla on id ja nimi. Tarvitsemme tavan olioiden luomiseen tietokannalta saaduista riveistä. Luodaan abstraktille luokalle abstrakti metodi createFromRow, joka palauttaa geneeristä tyyppiä olevan olion, ja joka saa parametrinaan resultSet-olion. Jokaisen luokan, joka perii luokan AbstractNamedObject tulee periä ja toteuttaa tämä metodi.

public abstract T createFromRow(ResultSet resultSet) throws SQLException;

Voimme nyt tehdä muista luokan metodeista yleiskäyttöisiä. Metodi findAll kysyy tietoa tietokantataulusta, jonka perivä luokka määrittelee. Kun tietokantakyselyn tuloksia käydään läpi, konkreettisten tulosten luomiseen käytetään luokkakohtaista metodia createFromRow. Metodin findAll rakenne on seuraavanlainen.

@Override
public List<T> findAll() throws SQLException {
    List<T> tasks = new ArrayList<>();

    try (Connection conn = database.getConnection();
        ResultSet result = conn.prepareStatement("SELECT id, name FROM " + tableName).executeQuery()) {

        while (result.next()) {
            tasks.add(createFromRow(result));
        }
    }

    return tasks;
}

Koko luokan AbstractNamedObjectDao toteutus on seuraava.

package tikape.tasks.dao;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import tikape.tasks.database.Database;
import tikape.tasks.domain.AbstractNamedObject;

public abstract class AbstractNamedObjectDao<T extends AbstractNamedObject>
        implements Dao<T, Integer> {

    protected Database database;
    protected String tableName;

    public AbstractNamedObjectDao(Database database, String tableName) {
        this.database = database;
        this.tableName = tableName;
    }

    @Override
    public T findOne(Integer key) throws SQLException {
        try (Connection conn = database.getConnection()) {
            PreparedStatement stmt = conn.prepareStatement("SELECT id, name FROM " + tableName + " WHERE id = ?");
            stmt.setInt(1, key);

            try (ResultSet rs = stmt.executeQuery()) {
                rs.next();
                return createFromRow(rs);
            }

        } catch (SQLException e) {
            System.err.println("Error when looking for a row in " + tableName + " with id " + key);
            e.printStackTrace();
            return null;
        }
    }

    @Override
    public List<T> findAll() throws SQLException {
        List<T> tasks = new ArrayList<>();

        try (Connection conn = database.getConnection();
            ResultSet result = conn.prepareStatement("SELECT id, name FROM " + tableName).executeQuery()) {

            while (result.next()) {
                tasks.add(createFromRow(result));
            }
        }

        return tasks;
    }

    @Override
    public T saveOrUpdate(T object) throws SQLException {
        // simply support saving -- disallow saving if task with 
        // same name exists
        T byName = findByName(object.getName());

        if (byName != null) {
            return byName;
        }

        try (Connection conn = database.getConnection()) {
            PreparedStatement stmt = conn.prepareStatement("INSERT INTO " + tableName + " (name) VALUES (?)");
            stmt.setString(1, object.getName());
            stmt.executeUpdate();
        }

        return findByName(object.getName());
    }

    private T findByName(String name) throws SQLException {
        try (Connection conn = database.getConnection()) {
            PreparedStatement stmt = conn.prepareStatement("SELECT id, name FROM " + tableName + " WHERE name = ?");
            stmt.setString(1, name);

            try (ResultSet result = stmt.executeQuery()) {
                if (!result.next()) {
                    return null;
                }

                return createFromRow(result);
            }
        }
    }

    @Override
    public void delete(Integer key) throws SQLException {
        throw new UnsupportedOperationException("Not supported yet.");
    }

    public abstract T createFromRow(ResultSet resultSet) throws SQLException;
}

Nyt omien Dao-luokkiemme toteutukset ovat hieman suoraviivaisempia. Alla on kuvattuna luokka liittyvä tietokanta-abstraktio CategoryDao.

package tikape.tasks.dao;

import java.sql.ResultSet;
import java.sql.SQLException;
import tikape.tasks.database.Database;
import tikape.tasks.domain.Category;

public class CategoryDao extends AbstractNamedObjectDao<Category> {

    public CategoryDao(Database database, String tableName) {
        super(database, tableName);
    }

    @Override
    public Category createFromRow(ResultSet resultSet) throws SQLException {
        return new Category(resultSet.getInt("id"), resultSet.getString("name"));
    }
}

Esimerkin jatkaminen jätetään omalle vastuulle. Seuraavana olisi näkymän kopiointi sekä TaskApplication-luokan muokkaaminen siten, että sovelluksessa pääsee käsiksi kategorioihin.

Sisällysluettelo