Tehtävät
Viidennen osan oppimistavoitteet

Osaa siirtää web-sovelluksen verkkoon, missä se on kaikkien nähtävillä. Tuntee käsitteen SQL-injektio. Tietää menetelmiä SQL-injektioon perustuvien hyökkäysten tekemiseen ja osaa toisaalta puolustautua niiltä. Tietää käsitteet tietokannan normalisointi ja tietokannan denormalisointi. Tuntee tietokannan normalisointiin liittyvät ensimmäisen, toisen ja kolmannen normaalimuodon. Osaa perustella noudattaako annettu tietokanta em. normaalimuotoja.

Web-sovelluksen siirtäminen verkkoon

Web-sovelluksemme on tähän mennessä toiminut vain paikallisella koneella, missä kehitystyötä on tehty. Tutustutaan tässä Heroku-nimisen pilvipalvelun käyttöön ja siirretään Web-sovellus verkkoon kaikkien nähtäväksi.

Herokulla on aiheeseen liittyen myös oma opas, johon kannattaa tutustua täällä.

Tarvitset sovelluksen siirtoon (1) tunnuksen Heroku-palveluun sekä (2) Heroku Toolbeltin.

Alkutoimet

Herokuun siirrettävät sovellukset tarvitsevat muutamia muutoksia:

  1. Ohjeet Herokulle sovelluksen käynnistämiseen. Ohjeet annetaan sovelluksen juuripolkuun lisättävän Procfile-nimisen tiedoston avulla. Tiedosto sisältää komennon, jota tulee käyttää sovelluksen käynnistämisessä.

    Alla olevassa esimerkissä oletetaan, että sovelluksen käynnistävä luokka on nimeltä Main ja se sijaitsee pakkauksessa tikape.

    web:    java -cp target/classes:target/dependency/* tikape.Main
        

    Komennon osa tikape.Main kuvaa pääohjelmaluokkaa, jonka kautta sovellus tulee käynnistää. Jos pääohjelmaluokkasi on toisessa pakkauksessa (ei tikape) tai pääohjelmaluokan nimi on jotain muuta (ei Main), tulee tätä muokata. Heroku käyttää tätä komentoa sovelluksen käynnistykseen.

  2. Maven-liitännäiset ohjelman kääntöprosessin automatisointiin. Sovelluksen pom.xml-tiedostoon tulee lisätä seuraavat rivit. Rivit lisätään esimerkiksi </properties>-rivin jälkeen.

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>2.5.1</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                    <optimize>true</optimize>
                    <debug>true</debug>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-dependency-plugin</artifactId>
                <version>2.4</version>
                <executions>
                    <execution>
                        <id>copy-dependencies</id>
                        <phase>package</phase>
                        <goals>
                            <goal>copy-dependencies</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
    
  3. Sovelluksen käynnistäminen Herokun määräämässä portissa. Jokainen web-sovellus käynnistettään tiettyyn porttiin, jonka se varaa käyttöönsä. Heroku pyörittää useampia sovelluksia samalla palvelinkoneella, joten sille pitää antaa mahdollisuus portin asetukseen.

    Portin asetus tapahtuu ympäristömuuttujan avulla, jonka Heroku antaa sovellukselle sovellusta käynnistettäessä. Käytännössä pääohjelmaluokkaan, joka käynnistää web-palvelimen, tulee lisätä seuraavat rivit -- lisää ne main-metodin alkuun.

    // asetetaan portti jos heroku antaa PORT-ympäristömuuttujan
    if (System.getenv("PORT") != null) {
        Spark.port(Integer.valueOf(System.getenv("PORT")));
    }
    

Ylläolevien muutosten avulla sovelluksen siirtäminen verkkoon onnistuu.

Heroku toolbeltin asennus

Asenna heroku toolbelt. Ohjeita löytyy esimerkiksi osoitteessa https://devcenter.heroku.com/articles/heroku-command.

Jos sinulla ei ole koneeseen pääkäyttäjän oikeuksia (root), asennuksen pitäisi silti olla mahdollista jos koneelle on ennestään asennettu muutama Herokun vaatima ohjelmapaketti. Joudut kuitenkin tekemään asennuksen hieman toisin (ohjeessa sudo-komennot ovat pääkäyttäjän oikeuksilla ajettavia komentoja, joita ei tarvita jos tarvittavat asennukset ovat jo ennalta tehtynä).

Sovelluksen luominen Herokuun

Sovelluksen luomiseen Herokuun tarvitaan kaksi askelta. Ensimmäisessä askeleessa luodaan projektista git-repositorio (tätä ei tarvitse tehdä jos sovellus on jo git-versionhallinnassa), jonka jälkeen luodaan herokuun sijainti johon sovellus kopioidaan.

Mikäli et ole aiemmin käyttänyt Git-versionhallintaa, tutustu Gitin käyttöön nyt. Lue oppaat osoitteista https://guides.github.com/activities/hello-world/ ja http://bit.ly/tikapeGit.

  1. Projekti git-repositorioksi -- projektin luominen git-repositorioksi tapahtuu ajamalla komento git init projektin juurikansiossa (kansio, jossa löytyy tiedosto pom.xml). Jos sovellus on jo esimerkiksi githubissa, ei tätä tarvitse tehdä.

  2. Heroku-projektin luominen -- suorita juurikansiossa komento heroku create. Tämä luo sovellukselle sijainnin herokuun, johon sovelluksen voi lähettää. Komennon toimintaan tarvitset aiemmin asennetun heroku toolbeltin.

Mahdollisissa ongelmatilanteissa kannattaa ensimmäiseksi katsoa mitä viestejä Herokun lokitiedostoon on päätynyt.

Sovelluksen lähetys Herokuun

Sovelluksen lähetys herokuun sisältää tyypillisesti neljä askelta. Ensin poistamme turhat käännetyt lähdekooditiedostot, jotta ne eivät häiritse herokun toimintaa. Tämän jälkeen lisäämme tiedostot versionhallintaan, sitoudumme niiden lähettämiseen, ja siirrämme ne herokuun.

  1. Turhien lähdekooditiedostojen poistaminen -- suorita projektin juurikansiossa komento mvn clean, joka poistaa projektista käännetyt lähdekooditiedostot (kansio target).
  2. Tiedostojen lisääminen versionhallintaan -- suorita projektin juurikansiossa komento git add ., joka lisää kaikki projektin tiedostot versionhallintaan. Huom! Varmista, että target-kansio ei pääse lipsahtamaan versionhallintaan tai Herokuun.
  3. Tiedostojen lähettämiseen sitoutuminen -- suorita projektin juurikansiossa komento git commit -m "viesti", joka sitouttaa lähetykseen juuri lisätyt tiedostot.
  4. Tiedostojen siirtäminen herokuun -- suorita projektin juurikansiossa komento git push heroku master, joka lähettää tiedostot herokuun.

Nyt sovelluksesi on verkossa kaikkien nähtävillä.

Olemassaolevan projektin siirto Herokuun askel askeleelta

Oletetaan, että käytössämme on osoitteessa https://github.com/avihavai/huonekalut/tree/370a67fae46f6fbbd3f0e2e3d7aa34668f70a6a4 oleva esimerkkisovellus. Sovellus tarjoaa toiminnallisuuden huonekalujen listaamiseen ja lisäämiseen.

Procfilen puuttuminen

Alla paikallisella koneella oleva projekti ensin putsataan (poistetaan turhat lähdekooditiedostot. Tämän jälkeen kirjaudutaan Heroku toolbeltin avulla Herokuun. Tätä seuraa uuden Heroku-sovelluksen luominen -- sovelluksen nimeksi asetetaan huonekalut-esim. Nimi on uniikki, eli kukaan muu ei voi ottaa tätä nimeä käyttöön.

user@kone:~/kansio/huonekalut$ mvn clean
...
user@kone:~/kansio/huonekalut$ heroku login
Enter your Heroku credentials:
Email: sposti@osoite.net
Password: ***************
Logged in as sposti@osoite.net
user@kone:~/kansio/huonekalut$ heroku create huonekalut-esim
Creating ⬢ huonekalut-esim... done
https://huonekalut-esim.herokuapp.com/ | https://git.heroku.com/huonekalut-esim.git
user@kone:~/kansio/huonekalut$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
nothing to commit, working directory clean
user@kone:~/kansio/huonekalut$ git push heroku master
Counting objects: 13, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (7/7), done.
Writing objects: 100% (13/13), 2.32 KiB | 0 bytes/s, done.
Total 13 (delta 0), reused 0 (delta 0)
remote: Compressing source files... done.
remote: Building source:
remote: 
remote: -----> Java app detected
remote: -----> Installing JDK 1.8... done
remote: -----> Installing Maven 3.3.9... done
remote: -----> Executing: mvn -DskipTests clean dependency:list install
...

Sovellus asentuu. Kun menemme osoitteeseen https://huonekalut-esim.herokuapp.com/ näemme seuraavanlaisen sivun.

Herokun virheviesti: Application error.

Sovelluksessa on virhe. Virheviestin saa selville Heroku toolbeltin avulla logeja tarkastelemalla.

user@kone:~/kansio/huonekalut$ heroku logs
user@kone:~/kansio/huonekalut$ 
...
2018-02-11T14:07:31.000000+00:00 app[api]: Build succeeded
2018-02-11T14:08:46.323557+00:00 heroku[router]: at=error code=H14 desc="No web processes running" method=GET path="/" host=huonekalut-esim.herokuapp.com request_id=43a2f9af-e5f6-4d60-9906-45f9d6145c32 fwd="..." dyno= connect= service= status=503 bytes= protocol=https

Sovelluksen virheviesti on No web processes running, eli web-sovelluksia ei ole käynnissä. Sovellus tarvitsee Herokua sovelluksen käynnistämiseen ohjeistavan tiedoston Procfile.

Luodaan tiedosto Procfile. Koska huonekalut-projektin Main-luokka on pakkauksessa tikape.huonekalut, tulee Procfile-tiedoston sisältöä muokata sopivasti. Tiedoston sisällöksi tulee seuraava merkkijono:

web:    java -cp target/classes:target/dependency/* tikape.huonekalut.Main

Lisätään Procfile versionhallintaan ja lähetetään projektista uusi versio Herokuun.

user@kone:~/kansio/huonekalut$ echo "web:    java -cp target/classes:target/dependency/* tikape.huonekalut.Main" > Procfile
user@kone:~/kansio/huonekalut$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Untracked files:
(use "git add <file>..." to include in what will be committed)

  Procfile

nothing added to commit but untracked files present (use "git add" to track)
user@kone:~/kansio/huonekalut$ git add Procfile
user@kone:~/kansio/huonekalut$ git commit -m "Herokun käynnistykseen tarvittava tiedosto Procfile"
[master 123bla] Herokun käynnistykseen tarvittava tiedosto Procfile

1 file changed, 1 insertion(+)
create mode 100644 Procfile
user@kone:~/kansio/huonekalut$ git push heroku master
Counting objects: 13, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (7/7), done.
Writing objects: 100% (13/13), 2.32 KiB | 0 bytes/s, done.
Total 13 (delta 0), reused 0 (delta 0)
remote: Compressing source files... done.
remote: Building source:
remote: 
remote: -----> Java app detected
remote: -----> Installing JDK 1.8... done
remote: -----> Installing Maven 3.3.9... done
remote: -----> Executing: mvn -DskipTests clean dependency:list install
...

Sovellus siirtyy taas Herokuun. Kun tarkastelemme sovellusta, sovellus näyttää tutulta..

Herokun virheviesti: Application error.

Maven-konfiguraation puuttuminen

Sovelluksessa on yhä virhe. Tarkastellaan virheviestiä Herokun logeista.

user@kone:~/kansio/huonekalut$ heroku logs
...
2018-02-11T14:23:27.162335+00:00 heroku[web.1]: Starting process with command `java -cp target/classes:target/dependency/* tikape.huonekalut.Main`
2018-02-11T14:23:29.732606+00:00 heroku[web.1]: Process exited with status 1
2018-02-11T14:23:29.750379+00:00 heroku[web.1]: State changed from starting to crashed
2018-02-11T14:23:29.359931+00:00 app[web.1]: Setting JAVA_TOOL_OPTIONS defaults based on dyno size. Custom settings will override them.
2018-02-11T14:23:29.364538+00:00 app[web.1]: Picked up JAVA_TOOL_OPTIONS: -Xmx300m -Xss512k -Dfile.encoding=UTF-8 
2018-02-11T14:23:29.642788+00:00 app[web.1]: Error: A JNI error has occurred, please check your installation and try again
2018-02-11T14:23:29.643688+00:00 app[web.1]: Exception in thread "main" java.lang.NoClassDefFoundError: spark/TemplateEngine
2018-02-11T14:23:29.643691+00:00 app[web.1]: 	at java.lang.Class.getDeclaredMethods0(Native Method)
2018-02-11T14:23:29.643693+00:00 app[web.1]: 	at java.lang.Class.privateGetDeclaredMethods(Class.java:2701)
2018-02-11T14:23:29.646781+00:00 app[web.1]: 	at java.lang.Class.privateGetMethodRecursive(Class.java:3048)
2018-02-11T14:23:29.646784+00:00 app[web.1]: 	at java.lang.Class.getMethod0(Class.java:3018)
2018-02-11T14:23:29.646786+00:00 app[web.1]: 	at java.lang.Class.getMethod(Class.java:1784)
2018-02-11T14:23:29.646788+00:00 app[web.1]: 	at sun.launcher.LauncherHelper.validateMainClass(LauncherHelper.java:544)
2018-02-11T14:23:29.646789+00:00 app[web.1]: 	at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:526)
2018-02-11T14:23:29.646791+00:00 app[web.1]: Caused by: java.lang.ClassNotFoundException: spark.TemplateEngine
2018-02-11T14:23:29.646793+00:00 app[web.1]: 	at java.net.URLClassLoader.findClass(URLClassLoader.java:381)
2018-02-11T14:23:29.646795+00:00 app[web.1]: 	at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
2018-02-11T14:23:29.646796+00:00 app[web.1]: 	at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:338)
2018-02-11T14:23:29.646798+00:00 app[web.1]: 	at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
2018-02-11T14:23:29.646800+00:00 app[web.1]: 	... 7 more
2018-02-11T14:23:31.210681+00:00 heroku[router]: at=error code=H10 desc="App crashed" method=GET path="/" host=huonekalut-esim.herokuapp.com request_id=35d815fe-806f-412b-8808-d775895d671a fwd="..." dyno= connect= service= status=503 bytes= protocol=https

Virheviesti java.lang.NoClassDefFoundError on melko selkeä. Java ei löydä tarvitsemaamme luokkaa. Lisätään projektin pom.xml-tiedostoon Maven-liitännäisen tarvitsemat tiedot. Tiedoston pom.xml sisältö on tämän jälkeen kokonaisuudessaan 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>huonekalut</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>org.xerial</groupId>
      <artifactId>sqlite-jdbc</artifactId>
      <version>3.21.0.1</version>
    </dependency>
    <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>com.google.code.gson</groupId>
      <artifactId>gson</artifactId>
      <version>2.8.2</version>
    </dependency>
    <dependency>
      <groupId>org.slf4j</groupId>
      <artifactId>slf4j-simple</artifactId>
      <version>1.7.25</version>
    </dependency>
      
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>2.5.1</version>
        <configuration>
          <source>1.8</source>
          <target>1.8</target>
          <optimize>true</optimize>
          <debug>true</debug>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-dependency-plugin</artifactId>
        <version>2.4</version>
        <executions>
          <execution>
            <id>copy-dependencies</id>
            <phase>package</phase>
            <goals>
              <goal>copy-dependencies</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
    
</project>

Lähetetään projekti taas Herokuun. Sormet ristiin jne.

user@kone:~/kansio/huonekalut$ git status
On branch master
Your branch is ahead of 'origin/master' by 1 commit.
  (use "git push" to publish your local commits)
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

  modified:   pom.xml

no changes added to commit (use "git add" and/or "git commit -a")
user@kone:~/kansio/huonekalut$ git add pom.xml
user@kone:~/kansio/huonekalut$ git commit -m "Projektin käännökseen tarvitut liitännäiset"
[master 124bla] Projektin käännökseen tarvitut liitännäiset
...
1 file changed, 31 insertions(+), 1 deletion(-)
user@kone:~/kansio/huonekalut$ git push heroku master
Counting objects: 3, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 647 bytes | 0 bytes/s, done.
Total 3 (delta 2), reused 0 (delta 0)
remote: Compressing source files... done.
remote: Building source:
remote: 
remote: -----> Java app detected
remote: -----> Installing JDK 1.8... done
remote: -----> Installing Maven 3.3.9... done
remote: -----> Executing: mvn -DskipTests clean dependency:list install
...

Portin määrittely

Sovellukseen on nyt lisätty tarvittu Procfile sekä pom.xml-tiedostoon tarvitut rivit. Kun tarkastelemme sovellusta, näkymä on harmittavan tuttu..

Herokun virheviesti: Application error.

Tiedämme onneksi mitä tehdä. Tarkastellaan virheviestiä Herokun logeista.

user@kone:~/kansio/huonekalut$ heroku logs
...
2018-02-11T14:37:29.796865+00:00 heroku[web.1]: Starting process with command `java -cp target/classes:target/dependency/* tikape.huonekalut.Main`
2018-02-11T14:37:31.109739+00:00 app[web.1]: Picked up JAVA_TOOL_OPTIONS: -Xmx300m -Xss512k -Dfile.encoding=UTF-8 
2018-02-11T14:37:31.106706+00:00 app[web.1]: Setting JAVA_TOOL_OPTIONS defaults based on dyno size. Custom settings will override them.
2018-02-11T14:37:31.217373+00:00 app[web.1]: Hello world!
2018-02-11T14:37:31.409503+00:00 app[web.1]: [Thread-0] INFO org.eclipse.jetty.util.log - Logging initialized @295ms to org.eclipse.jetty.util.log.Slf4jLog
2018-02-11T14:37:31.487930+00:00 app[web.1]: [Thread-0] INFO spark.embeddedserver.jetty.EmbeddedJettyServer - == Spark has ignited ...
2018-02-11T14:37:31.488042+00:00 app[web.1]: [Thread-0] INFO spark.embeddedserver.jetty.EmbeddedJettyServer - >> Listening on 0.0.0.0:4567
2018-02-11T14:37:31.492481+00:00 app[web.1]: [Thread-0] INFO org.eclipse.jetty.server.Server - jetty-9.4.6.v20170531
2018-02-11T14:37:31.559757+00:00 app[web.1]: [Thread-0] INFO org.eclipse.jetty.server.session - DefaultSessionIdManager workerName=node0
2018-02-11T14:37:31.563164+00:00 app[web.1]: [Thread-0] INFO org.eclipse.jetty.server.session - Scavenging every 660000ms
2018-02-11T14:37:31.559867+00:00 app[web.1]: [Thread-0] INFO org.eclipse.jetty.server.session - No SessionScavenger set, using defaults
2018-02-11T14:37:31.592726+00:00 app[web.1]: [Thread-0] INFO org.eclipse.jetty.server.AbstractConnector - Started ServerConnector@1faa7888{HTTP/1.1,[http/1.1]}{0.0.0.0:4567}
2018-02-11T14:37:31.592938+00:00 app[web.1]: [Thread-0] INFO org.eclipse.jetty.server.Server - Started @483ms
2018-02-11T14:39:00.117450+00:00 heroku[web.1]: Error R10 (Boot timeout) -> Web process failed to bind to $PORT within 90 seconds of launch 
2018-02-11T14:39:00.117535+00:00 heroku[web.1]: Stopping process with SIGKILL
2018-02-11T14:39:00.205503+00:00 heroku[web.1]: State changed from starting to crashed
2018-02-11T14:39:00.194080+00:00 heroku[web.1]: Process exited with status 137
2018-02-11T14:39:11.229117+00:00 heroku[router]: at=error code=H10 desc="App crashed" method=GET path="/" host=huonekalut-esim.herokuapp.com request_id=695ac260-a6d7-4e37-a453-ea089e680740 fwd="..." dyno= connect= service= status=503 bytes= protocol=https

Rivejä tarkasteltaessamme huomaamme tulostuksen "Hello world!". Sovellus on siis käynnistynyt! Viestejä eteenpäin tarkasteltaessamme huomaamme kuitenkin, että sovellus ei ole toiminut Herokun toivomalla tavalla. Virhe Web process failed to bind to $PORT within 90 seconds of launch kertoo, että sovellus ei ole ottanut Herokun tarjoamaa porttia käyttöön.

Lisätään sovelluksen käynnistämiseen käytettävän tikape.huonekalut.Main päämetodin alkuun rivit:

// asetetaan portti jos heroku antaa PORT-ympäristömuuttujan
if (System.getenv("PORT") != null) {
    Spark.port(Integer.valueOf(System.getenv("PORT")));
}

Lähetetään sovellus taas Herokuun.

user@kone:~/kansio/huonekalut$ git add src/main/java/tikape/huonekalut/Main.java
user@kone:~/kansio/huonekalut$ git commit -m "Käytetään Herokun tarjoamaa porttia."
[master 125bla] Käytetään Herokun tarjoamaa porttia.
...
user@kone:~/kansio/huonekalut$ git push heroku master
...
remote: -----> Discovering process types
remote:        Procfile declares types -> web
remote: 
remote: -----> Compressing...
remote:        Done: 61.2M
remote: -----> Launching...
remote:        Released v5
remote:        https://huonekalut-esim.herokuapp.com/ deployed to Heroku
remote: 
remote: Verifying deploy... done.
To https://git.heroku.com/huonekalut-esim.git
...

Entä nyt? Tarkastellaan taas sovelluksen osoitetta -- tuntuu toimivan.

Sovellus on Herokussa.

Sovellus on verkossa ja sinne voi lisätä huonekaluja.

Herokun tarjoaman tietokannan käyttöönotto

Olemme saaneet sovelluksemme verkkoon. Sovelluksessa on kuitenkin vielä iso ongelma. Se käyttää SQLiteä, joka hallinnoi tietokantaa levylle tallennettuna tiedostona. Pilvipalvelut kuten Heroku kuitenkin toimivat niin, että sovellukset voivat sammua ja käynnistyä uudestaan tarvittaessa -- levylle tehtyjä muutoksia ei hallinnoida. Käytännössä jokaisen uudelleenkäynnistyksen yhteydessä sovellus on siinä pisteessä, missä se lähettiin verkkoon. Käytetty tietokantatoiminnallisuus ei siis toimi pilvipalvelussa.

Tarvitsemme käyttöömme erillisen tietokannanhallintajärjestelmän. Heroku tarjoaa palveluna PostgreSQL-tietokannanhallintajärjestelmän. Ohjeistukset PostgreSQL:n käyttöönottoon löytyvät osoitteesta https://devcenter.heroku.com/articles/heroku-postgresql.

Tarvitsemme (1) PostgreSQL-ajurin tietokantayhteyden luomiseen, (2) Herokun tarjoaman PostgreSQL-tietokannan sekä (3) tavan muodostaa yhteys PostgreSQL-tietokantaan.

PostgreSQL-ajuri

PostgreSQL-ajuri lisätään projektin pom.xml-tiedostossa määriteltyihin riippuvuuksiin. Ajuri noudattaa JDBC-rajapintaa, eli tietokannanhallintajärjestelmän vaihtaminen ei ideaalitilanteessa vaikuta sovelluksessa käytettyihin SQL-kyselyihin tai muuhun koodiin. PostgreSQL-tietokannanhallintajärjestelmän ajurin riippuvuus on seuraavanlainen.

<dependency>
  <groupId>org.postgresql</groupId>
  <artifactId>postgresql</artifactId>
  <version>42.2.1</version>
</dependency>

Kun riippuvuus on lisätty projektin pom.xml-tiedostoon, sovelluksessa voidaan käyttää PostgreSQL-tietokantoja.

PostgreSQL-tietokannan luominen

Heroku tarjoaa harrastuskäyttöön ilmaisen PostgreSQL-tietokannan. Tietokanta on tosin hieman rajattu: ilmaisessa versiossa saa olla korkeintaan 10000 riviä -- tämä on ok kurssimme tarkoituksiin. Tietokannan luominen onnistuu Heroku toolbeltin avulla. Komento heroku addons:create heroku-postgresql:hobby-dev lisää Herokussa sijaitsevan sovelluksen käyttöön PostgreSQL-tietokannan.

user@kone:~/kansio/huonekalut$ heroku addons:create heroku-postgresql:hobby-dev
Creating heroku-postgresql:hobby-dev on ⬢ huonekalut-esim... free
Database has been created and is available
 ! This database is empty. If upgrading, you can transfer
 ! data from another database with pg:copy
Created tietokannan-nimi as DATABASE_URL
Use heroku addons:docs heroku-postgresql to view documentation

Nyt sovelluksemme käytössä on tietokanta. Tietokannan yhteystiedot saadaan Herokun kautta ympäristömuuttujana käyttöön.

Mikäli käytössä olevalle koneelle on asennettu PostgreSQL, yhteyden muodostaminen Herokun tarjoamaan tietokantaan on myös komentoriviltä helppoa. Tämä onnistuu komennolla. heroku pg:psql. Yleiset tietokannanhallintajärjestelmäkohtaiset komennot poikkeavat SQLite-tietokannanhallintajärjestelmästä, mutta SQL-komennot ovat tuttuja.

user@kone:~/kansio/huonekalut$ heroku pg:psql
--> Connecting to tietokannan-nimi
psql (9.5.11, server 10.1)
WARNING: psql major version 9.5, server major version 10.
         Some psql features might not work.
SSL connection (protocol: TLSv1.2, cipher: ECDHE-RSA-AES256-GCM-SHA384, bits: 256, compression: off)
Type "help" for help.

tietokannan-nimi::DATABASE=> help
You are using psql, the command-line interface to PostgreSQL.
Type:  \copyright for distribution terms
       \h for help with SQL commands
       \? for help with psql commands
       \g or terminate with semicolon to execute query
       \q to quit
tietokannan-nimi::DATABASE=> \d
No relations found.
tietokannan-nimi::DATABASE=> SELECT 1;
 ?column? 
----------
        1
(1 row)

tietokannan-nimi::DATABASE=> \q

Tietokanta on olemassa ja saamme muodostettua yhteyden siihen komentoriviltä.

Yhteys PostgreSQL-tietokantaan

Muokataan vielä sovelluksemme lähdekoodia siten, että otamme yhteyden Herokun tietokantaan mikäli yhteyteen liittyvä ympäristömuuttuja on tarjolla. Tehdään tästä erillinen metodi.

public static Connection getConnection() throws Exception {
    String dbUrl = System.getenv("JDBC_DATABASE_URL");
    if (dbUrl != null && dbUrl.length() > 0) {
        return DriverManager.getConnection(dbUrl);
    }

    return DriverManager.getConnection("jdbc:sqlite:huonekalut.db");
}

Lähdekoodia tulee muuttaa myös niistä kohdista, missä yhteys muodostetaan. Esimerkiksi esineiden noutamiseen liittyvässä toiminnallisuudessa ohjelman tulee hyödyntää uutta yhteyttä.

// ...
  
// avaa yhteys tietokantaan
Connection conn = getConnection();

// tee kysely
PreparedStatement stmt = conn.prepareStatement("SELECT nimi FROM Huonekalu");
ResultSet tulos = stmt.executeQuery();

// käsittele kyselyn tulokset
while (tulos.next()) {
    String nimi = tulos.getString("nimi");
    huonekalut.add(nimi);
}
// sulje yhteys tietokantaan
conn.close();

// ...

Sovelluksen siirto Herokuun

Siirretään lopuksi sovellus Herokuun.

user@kone:~/kansio/huonekalut$ git status
On branch master
Your branch is ahead of 'origin/master' by 3 commits.
(use "git push" to publish your local commits)
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)

  modified:   pom.xml
  modified:   src/main/java/tikape/huonekalut/Main.java

user@kone:~/kansio/huonekalut$ git add pom.xml
user@kone:~/kansio/huonekalut$ git add src/main/java/tikape/huonekalut/Main.java
user@kone:~/kansio/huonekalut$ git commit -m "Herokun tarjoaman tietokannan käyttöönotto."
user@kone:~/kansio/huonekalut$ git push heroku master
...

Kun tarkastelemme sovellusta verkossa, virheviesti on tällä kertaa hieman vähemmän kuvaava..

Herokun virheviesti: 500 Internal Server Error.

Sovellus on käynnissä, mutta siinä tapahtuu virhe. Seuraava askel on tuttu. Tarkastellaan Herokun logeja.

user@kone:~/kansio/huonekalut$ heroku logs
2018-02-11T15:32:25.356102+00:00 heroku[router]: at=info method=GET path="/favicon.ico" host=huonekalut-esim.herokuapp.com request_id=3b463660-67b3-420e-8b10-2dfb5ec53b1e fwd="..." dyno=web.1 connect=1ms service=157ms status=500 bytes=216 protocol=https
2018-02-11T15:32:25.353146+00:00 app[web.1]: [qtp1497053618-15] ERROR spark.http.matching.GeneralError - 
2018-02-11T15:32:25.353211+00:00 app[web.1]: org.postgresql.util.PSQLException: ERROR: relation "huonekalu" does not exist
2018-02-11T15:32:25.353216+00:00 app[web.1]:   Position: 18
2018-02-11T15:32:25.353388+00:00 app[web.1]: 	at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2422)
2018-02-11T15:32:25.353443+00:00 app[web.1]: 	at org.postgresql.core.v3.QueryExecutorImpl.processResults(QueryExecutorImpl.java:2167)
2018-02-11T15:32:25.353491+00:00 app[web.1]: 	at org.postgresql.core.v3.QueryExecutorImpl.execute(QueryExecutorImpl.java:306)
2018-02-11T15:32:25.353525+00:00 app[web.1]: 	at org.postgresql.jdbc.PgStatement.executeInternal(PgStatement.java:441)
2018-02-11T15:32:25.353570+00:00 app[web.1]: 	at org.postgresql.jdbc.PgStatement.execute(PgStatement.java:365)
2018-02-11T15:32:25.353615+00:00 app[web.1]: 	at org.postgresql.jdbc.PgPreparedStatement.executeWithFlags(PgPreparedStatement.java:155)
2018-02-11T15:32:25.353659+00:00 app[web.1]: 	at org.postgresql.jdbc.PgPreparedStatement.executeQuery(PgPreparedStatement.java:118)
2018-02-11T15:32:25.353693+00:00 app[web.1]: 	at tikape.huonekalut.Main.lambda$main$0(Main.java:34)
2018-02-11T15:32:25.353743+00:00 app[web.1]: 	at spark.TemplateViewRouteImpl$1.handle(TemplateViewRouteImpl.java:66)
2018-02-11T15:32:25.353819+00:00 app[web.1]: 	at spark.http.matching.Routes.execute(Routes.java:61)
2018-02-11T15:32:25.353852+00:00 app[web.1]: 	at spark.http.matching.MatcherFilter.doFilter(MatcherFilter.java:130)
2018-02-11T15:32:25.353900+00:00 app[web.1]: 	at spark.embeddedserver.jetty.JettyHandler.doHandle(JettyHandler.java:50)
2018-02-11T15:32:25.353943+00:00 app[web.1]: 	at org.eclipse.jetty.server.session.SessionHandler.doScope(SessionHandler.java:1568)
2018-02-11T15:32:25.354035+00:00 app[web.1]: 	at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:141)
2018-02-11T15:32:25.354108+00:00 app[web.1]: 	at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:132)
2018-02-11T15:32:25.354183+00:00 app[web.1]: 	at org.eclipse.jetty.server.Server.handle(Server.java:564)
2018-02-11T15:32:25.354257+00:00 app[web.1]: 	at org.eclipse.jetty.server.HttpChannel.handle(HttpChannel.java:317)
2018-02-11T15:32:25.354330+00:00 app[web.1]: 	at org.eclipse.jetty.server.HttpConnection.onFillable(HttpConnection.java:251)
2018-02-11T15:32:25.354447+00:00 app[web.1]: 	at org.eclipse.jetty.io.AbstractConnection$ReadCallback.succeeded(AbstractConnection.java:279)
2018-02-11T15:32:25.354545+00:00 app[web.1]: 	at org.eclipse.jetty.io.FillInterest.fillable(FillInterest.java:110)
2018-02-11T15:32:25.354644+00:00 app[web.1]: 	at org.eclipse.jetty.io.ChannelEndPoint$2.run(ChannelEndPoint.java:124)
2018-02-11T15:32:25.354739+00:00 app[web.1]: 	at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:673)
2018-02-11T15:32:25.354816+00:00 app[web.1]: 	at org.eclipse.jetty.util.thread.QueuedThreadPool$2.run(QueuedThreadPool.java:591)
2018-02-11T15:32:25.354886+00:00 app[web.1]: 	at java.lang.Thread.run(Thread.java:748)

Virhe ERROR: relation "huonekalu" does not exist on selkeä. Relaatio (tai toisin sanoen tietokantataulu) huonekalu puuttuu Herokun käyttämästä tietokannasta. Tämä johtuu siitä, että kyseistä tietokantataulua ei ole sinne koskaan luotukaan.

Tietokantataulun luominen Herokun PostgreSQL:ään

Jotta sovelluksemme toimisi, tulee käytössä olla tietokantataulu Huonekalu. Otetaan yhteys Herokun tarjoamaan tietokantaan komennolla heroku pg:psql ja luodaan tietokantataulu Huonekalu. PostgreSQL ei toimi täysin samalla tavalla kuin SQLite siinä, että pääavaimen automaattisen numeroinnin määräämiseen tapahtuu muuttujatyypin SERIAL avulla. Tästä lisää PostgreSQL:n dokumentaatiossa.

CREATE TABLE Huonekalu (
    id SERIAL PRIMARY KEY,
    nimi VARCHAR(255)
);
user@kone:~/kansio/huonekalut$ heroku pg:psql
--> Connecting to postgresql-encircled-91328
psql (9.5.11, server 10.1)
WARNING: psql major version 9.5, server major version 10.
         Some psql features might not work.
SSL connection (protocol: TLSv1.2, cipher: ECDHE-RSA-AES256-GCM-SHA384, bits: 256, compression: off)
Type "help" for help.

tietokannan-nimi::DATABASE=> CREATE TABLE Huonekalu (
tietokannan-nimi::DATABASE(>     id SERIAL PRIMARY KEY,
tietokannan-nimi::DATABASE(>     nimi VARCHAR(255)
tietokannan-nimi::DATABASE(> );
CREATE TABLE
tietokannan-nimi::DATABASE=> 
tietokannan-nimi::DATABASE=> SELECT * FROM Huonekalu;
 id | nimi 
----+------
(0 rows)

tietokannan-nimi::DATABASE=> \quit

Kun tietokantataulu on lisätty, sovellus alkaa toimimaan (ja mikäli ei, Herokun logit auttavat!).

En halua asentaa PostgreSQL:ää omalle koneelleni!

Edellisen esimerkin tietokantayhteyden muodostaminen olettaa, että koneelle asennetaan PostgreSQL. Voit halutessasi tehdä myös (esimerkiksi) niin, että tietokantataulu luodaan ohjelmallisesti Herokuun lähetettävän sovelluksen lähdekoodissa. Komennot kuten CREATE TABLE toimivat Javan kautta aivan yhtä hyvin kuin esimerkiksi INSERT-komennot.

SQL-injektiot

Suurin osa olemassaolevista sovelluksista käyttää tietokannanhallintajärjestelmiä jollain tavalla: tietoa haetaan tietokannasta, tietokannassa olevaa tietoa muokataan, ja tietokantaan tallennetaan tietoa. Tyypillisesti sovelluksiin on myös käyttöliittymä, minkä kautta sovelluksen käyttäjät pääsevät vaikuttamaan kyselyiden sisältöön.

SQL-injektioiden tekeminen onnistuu jos ohjelmoija jättää tietokantaa käsitteleviin kyselyihin ns. käyttäjän mentävän aukon. Tämä onnistuu Javalla siten, että kyselyt luodaan niin, että kyselyihin lisättävät parametrit syötetään kyselyyn suoraan merkkijonona.

Alla on esimerkki, missä käyttäjältä kysytään haettavan pyörän merkkiä.

Scanner lukija = new Scanner(System.in);
System.out.println("Minkä merkkiset pyörät tulostetaan?");
String merkki = lukija.nextLine();

// ...

PreparedStatement statement = connection.prepareStatement("SELECT * FROM Pyora WHERE merkki = ?");
statement.setString(1, merkki);

// ...

ResultSet resultSet = statement.executeQuery();

Kysely on turvallinen, sillä merkki asetetaan metodin setString avulla. Metodi tarkastaa myös, ettei kyselyssä ole ylimääräistä sisältöä.

Kyselystä saa helposti erittäin turvattoman. Seuraavassa esimerkissä on mahdollisuus SQL-injektioon.

Scanner lukija = new Scanner(System.in);
System.out.println("Minkä merkkiset pyörät tulostetaan?");
String merkki = lukija.nextLine();

// ...

PreparedStatement statement = connection.prepareStatement("SELECT * FROM Pyora WHERE merkki = '" + merkki + "'");

// ...

ResultSet resultSet = statement.executeQuery();

Kun käyttäjän syöttämä merkkijono lisätään suoraan osaksi kyselyä, voi käyttäjä syöttää SQL-lauseita komentoonsa. Jos käyttäjä syöttää ohjelmaan esimerkiksi merkkijono a' OR 'a'='a, on suoritettava SQL-lause lopulta muotoa:

SELECT * FROM Pyora WHERE merkki = 'a' OR 'a'='a'

Edellinen lause on aina totta, sillä tarkastus 'a'='a' on totta.

Myös muunlaisten SQL-lauseiden suoritus olisi em. tapauksessa mahdollista. Web-sarjakuva xkcd kuvastaa tätä ilmiötä hyvin Exploits of a Mom-sarjakuvallaan.

School: Hi, this is your son's school. We're having some computer trouble.
								    
								    Mom: Oh, dear -- Did he break something?
								    
								    School: In a way. Did you really name your son Robert'); DROP TABLE Students;--?
								    
								    Mom: Oh. Yes. Little Bobby Tables we call him.
								    
								    School: Well, we've lost this year's student records. I hope you're happy.
								    
								    Mom: And I hope you've learned to sanitize your database inputs.
http://xkcd.com/327/ -- Exploits of a Mom.

Tehtäväpohjassa on sovellus, joka mahdollistaa huonekalujen lisäämisen, listaamisen ja poistamisen. Muokkaa sovellusta siten, että huonekalujen poistamisessa on SQL-injektiomahdollisuus. Tällä hetkellä huonekalun, jonka id on 3, poistaminen onnistuu POST-pyynnöllä sovelluksen polkuun /delete/3. SQL-injektion tulee muuntaa tilannetta siten, että esimerkiksi pyyntö osoitteeseen /delete/3%20OR%2042=42 poistaakin kaikki tietokannan rivit. Edellisessä esimerkissä %20 on osoitteissa käytettävä välilyönnin merkki.

Tietokannan normalisointi ja denormalisointi

Tietokannan normalisointi on askeleittainen prosessi, mikä sisältää mahdollisten ongelmakohtien tunnistamisen ja niiden korjaamisen. Tietokannan normalisointiprosessin tuloksena tietokanta sisältää hyvin vähän toisteista tietoa. Tietokannan denormalisointi on käänteinen prosessi, missä tietokannassa sijaitsevan toisteisuuden määrä lisääntyy. Samalla tietokantakyselyiden tehokkuus tyypillisesti kasvaa.

Tarkastellaan näitä kahta seuraavaksi.

Tietokannan normalisointi

Tietokannan normalisoinnin tavoite on vähentää tietokantatauluissa esiintyvää toisteista tietoa. Pääpiirteittäin tavoite on sama kuin käsiteanalyysissä: lopulta jokainen taulu liittyy vain tiettyyn käsitteeseen ja taulun attribuutit liittyvät vain kyseisen taulun esittämään käsitteeseen.

Toisin kuin käsiteanalyysi, tietokannan normalisointi tehdään tyypillisesti olemassaolevalle tietokannalle tai sen suunnitelmalle. Tietokannan normalisoinnissa etsimme epäkohtia, jonka jälkeen näitä epäkohtia korjataan.

Tietokannan normalisointi tapahtuu askeleittain normaalimuotojen avulla.

Ensimmäinen normaalimuoto

Tietokantataulu on ensimmäisessä normaalimuodossa, jos se täyttää seuraavat ehdot:

  1. Sarakkeen arvot eivät saa sisältää listoja.
  2. Taulun sarakkeet eivät muodosta toistuvia ryhmiä.
  3. Sarakkeen arvojen tulee olla saman tyyppisiä.
  4. Jokaisen sarakkeen nimen tulee olla tietokantataulussa uniikki.
  5. Sarakkeiden järjestyksen ei tule vaikuttaa tietokantataulun toimintaan.
  6. Tietokantataulussa ei saa olla kahta täsmälleen samanlaista riviä.
  7. Rivien järjestyksen ei tule vaikuttaa tietokantataulun toimintaan.

Alla on esimerkki henkilöitä sisältävästä tietokantataulusta. Jokaiseen henkilöön liittyy tunnus (id), nimi sekä pilkuilla eroteltu lista puhelinnumeroita. Esimerkki rikkoo ensimmäistä normaalimuotoa, sillä puhelinnumerot sisältävät listoja.

Henkilo((pk) id, nimi, puhelinnumerot)
id nimi puhelinnumerot
1 Larry 555-1024, 555-2048
2 Moe 555-0512, 555-0256, 555-0128
3 Curly 555-0001, 555-0002, 555-0004

Ensimmäinen korjaus ylläolevaan tietokantatauluun on eritellä puhelinnumerot erillisiksi sarakkeikseen (tehty alla). Tämä ei ole kovin hyvä ratkaisu -- koko tietokantataulun rakennetta tulee muuttaa mikäli jollain on esimerkiksi neljä tai viisi erillistä numeroa. Tämä myös rikkoo ensimmäistä normaalimuotoa, sillä puhelinnumero muodostaa toistuvan ryhmän.

Henkilo((pk) id, nimi, puhelinnumero1, puhelinnumero2, puhelinnumero3)
id nimi puhelinnumero1 puhelinnumero2 puhelinnumero3
1 Larry 555-1024 555-2048
2 Moe 555-0512 555-0256 555-0128
3 Curly 555-0001 555-0002 555-0004

Sopivampi korjaus ongelmaan on muodostaa erillinen tietokantataulu puhelinnumeroille. Henkilön ja puhelinnumeron välillä on yhden suhden moneen -yhteys, eli yhteen henkilöön liittyy monta puhelinnumeroa, mutta jokainen puhelinnumero liittyy yhteen henkilöön.

Henkilo((pk) id, nimi)
id nimi
1 Larry
2 Moe
3 Curly

 

Puhelinnumero((pk) id, (fk) henkilo_id -> Henkilo, puhelinnumero)
id henkilo_id puhelinnumero
1 1 555-1024
2 1 555-2048
3 2 555-0512
4 2 555-0256
... ... ...

Funktionaalinen riippuvuus

Ensimmäisessä normaalimuodossa kyse on ensiaskeleista tietokannan rakenteen järkevöittämiseen. Muissa normaalimuodoissa käsite funktionaalinen riippuvuus sarakkeiden välillä on oleellinen.

Sarake B on funktionaalisesti riippuvainen sarakkeesta A (A määrää funktionaalisesti B:n), jos sarakkeen A arvon perusteella voidaan yksikäsitteisesti selvittää sarakkeen B arvo. Tällöin kirjoitetaan A -> B, ja sanotaan, että "sarake B on funktionaalisesti riippuvainen sarakkeesta A". Huom! A voi olla myös kokoelma sarakkeita!

Esimerkiksi henkilön nimi on funktionaalisesti riippuvainen henkilötunnuksesta, sillä henkilötunnuksen perusteella voidaan yksikäsitteisesti selvittää nimi. Toisaalta, henkilötunnus ei ole funktionaalisesti riippuvainen henkilön nimestä, koska useammalla henkillä voi olla sama nimi.

Selvittäminen voi tapahtua kyselyllä "SELECT DISTINCT b FROM Taulu WHERE a=tiedetty_arvo", missä avainsana DISTINCT palauttaa uniikit rivit. Jos attribuutti b on funktionaalisesti riippuva a:sta, tuottaa ylläoleva kysely joko yhden tai ei yhtään tulosriviä, mutta ei koskaan enempää. Tämän ehdon on oltava voimassa aina, ei vain hetkellisesti.

Esimerkki: Henkilo( (pk) id, nimi, henkilötunnus) -- mitkä arvot ovat funktionaalisesti riippuvaisia toisistaan?

Henkilo A: id A: nimi A: henkilötunnus
B: id ? ? ?
B: nimi ? ? ?
B: henkilötunnus ? ? ?

Sarakkeen perusteella voi aina määritellä itsensä. Esimerkiksi id -> id on aina totta.

Henkilo A: id A: nimi A: henkilötunnus
B: id kyllä ? ?
B: nimi ? kyllä ?
B: henkilötunnus ? ? kyllä

Voimmeko tunnistaa nimen perusteella henkilön yksilöivän tunnisteen? Useammalla henkilöllä voi olla sama nimi, joten tämä ei pidä paikkansa. Voimmeko tunnistaa henkilötunnuksen perusteella henkilön yksilöivän tunnisteen? Henkilötunnus on uniikki, joten oletetaan että kyllä (tämä pätee tosin vain Suomessa..).

Henkilo A: id A: nimi A: henkilötunnus
B: id kyllä ei kyllä
B: nimi ? kyllä ?
B: henkilötunnus ? ? kyllä

Voiko yksilöivän avaimen perusteella tunnistaa henkilön nimen? Kyllä.

Henkilo A: id A: nimi A: henkilötunnus
B: id kyllä ei kyllä
B: nimi kyllä kyllä ?
B: henkilötunnus ? ? kyllä

Loput jäävät omatoimiseen harjoitteluun.

Toinen normaalimuoto

Tietokantataulu on toisessa normaalimuodossa jos (1) se on ensimmäisessä normaalimuodossa ja (2) tietokantataulun sarakkeet (poislukien avaimet) ovat funktionaalisesti riippuvaisia tietokantataulun (yhdellä sarakkeella määritellystä) pääavaimesta.

Jos tietokantataulun pääavain on määritelty yhden sarakkeen avulla, ovat kaikki tietokantataulun sarakkeet automaattisesti funktionaalisesti riippuvaisia pääavaimesta. Käytännössä siis, jos taulu on ensimmäisessä normaalimuodossa ja sillä on yhden sarakkeen avulla määritelty pääavain, on se automaattisesti toisessa normaalimuodossa.

Jos taas tietokantataulun pääavain on määritelty useamman sarakkeen avulla, tulee tietokantataulun jokaisen sarakkeen olla riippuvainen koko avaimesta, eli osittaista riippuvuutta pääavaimesta ei sallita. Tarkastellaan tilannetta, missä tietokantataulun pääavain on määritelty useamman sarakkeen kautta ja tällainen tilanne tapahtuu.

Oletetaan seuraavat tietokantataulut, joissa pääavaimet on alleviivattu. Ensimmäisessä kahdessa tietokantataulussa pääavain on id, kolmannessa taulussa pääavain on määrätty kahden viiteavaimen yhdistelmänä.

  • Asiakas ((pk) id, nimi)
  • Kauppa ((pk) id, nimi, osoite)
  • Ostos ((fk) asiakas_id -> Asiakas, (fk) kauppa_id -> Kauppa, hinta, kaupunki)

Taulut Asiakas ja Kauppa ovat ensimmäisessä ja toisessa normaalimuodossa.

Tarkastellaan taulua Ostos. Taulun Ostos sarake hinta kertoo ostoksen hinnan. Sarake kaupunki kertoo missä ostos tehtiin.

Ostos ((fk) asiakas_id -> Asiakas, (fk) kauppa_id -> Kauppa, hinta, kaupunki)
asiakas_id kauppa_id hinta kaupunki
1 1 14.90 Helsinki
1 3 15.20 Vantaa
2 1 8.40 Helsinki
3 2 19.20 Espoo
3 3 10.40 Vantaa
4 1 12.20 Helsinki
... ... ... ...

Kun tarkastelemme taulua Ostos, huomaamme, että tietokantataulun sarake kaupunki on funktionaalisesti riippuvainen sarakkeesta kauppa_id. Koska sarake kauppa_id on osa tietokantataulun pääavaimesta, tämä rikkoo toista normaalimuotoa. Yksi ratkaisu ongelmaan on kaupungin siirtäminen tauluun Kauppa.

  • Asiakas ((pk) id, nimi)
  • Kauppa ((pk) id, nimi, osoite, kaupunki)
  • Ostos ((fk) asiakas_id -> Asiakas, (fk) kauppa_id -> Kauppa, hinta)

Nyt jokainen ylläolevista tietokantatauluista on ensimmäisessä ja toisessa normaalimuodossa.

Kandidaattiavain

Toisen normaalimuodon voi määritellä myös kandidaattiavain-käsitteen kautta. Tietokantataulun kandidaattiavaimet määritellään niiden tietokantataulun sarakkeiden joukkona, joiden avulla tietokantataulun rivit voidaan yksilöidä. Toisin sanoen, kandidaattiavainjoukko mahdollistaa tietokantatauuln rivin yksilöimisen.

Tietokantataululle voidaan määritellä tyypillisesti useampia kandidaattiavaimia, mutta näistä valitaan vain yksi tietokantataulun pääavaimeksi. Tarkastellaan taulua Henkilö, joka sisältää sarakkeet syntymäaika, etunimi, sukunimi ja puhelinnumero.

Kandidaattiavaimia etsitään sarakkeiden avulla muodostetusta joukkojen joukosta: {{syntymäaika}, {etunimi}, {sukunimi}, {puhelinnumero}, {syntymäaika, etunimi}, {syntymäaika, sukunimi}, {syntymäaika, puhelinnumero}, {etunimi, sukunimi}, {etunimi, puhelinnumero}, {syntymäaika, etunimi, sukunimi}, {syntymäaika, etunimi, puhelinnumero}, {syntymäaika, sukunimi, puhelinnumero}, {etunimi, sukunimi, puhelinnumero}, {syntymäaika, etunimi, sukunimi, puhelinnumero}}.

Jokaista joukkoa tarkastellaan niiden sisältämien sarakkeiden arvojoukkojen kautta. Jos joukolle on mahdollista löytää useampia rivejä, joissa kandidaattiavainjoukon arvot ovat samat, hylätään kandidaattiavain. Esimerkiksi useammalla henkilöllä voi olla sama syntymäaika, useammalla henkilöllä voi olla sama etunimi, ja useammalla henkilöllä voi olla sama sukunimi, joten {syntymäaika}, {etunimi}, {sukunimi} eivät ole kandidaattiavaimia. Vastaavasti joukko {etunimi, sukunimi} ei voi olla kandidaattiavain, sillä useammalla henkilöllä voi olla sama etunimi ja sukunimi.

Tätä prosessia jatkamalla tunnistetaan lopullinen kandidaattiavainten joukko. Edellisessä taulussa oikeastaan yksikään esitellyistä joukoista ei ole kandidaattiavainjoukko jos oletamme, että useammalla henkilöllä voi olla sama puhelinnumero.

Kandidaattiavainten avulla määriteltynä taulu on toisessa normaalimuodossa jos ja vain jos se on ensimmäisessä normaalimuodossa ja jokainen taulun kandidaattiavaimeen kuulumaton sarake on riippuvainen koko kandidaattiavaimen joukosta, mutta ei yksittäisestä joukon jäsenestä (jos joukkoon kuuluu useampi sarake).

Kolmas normaalimuoto

Kolmanteen normaalimuotoon liittyy oleellisesti käsite transitiivinen riippuvuus.

Transitiivinen riippuvuus

Transitiivisella riippuvuudella tarkoitetaan sitä, että sarake A on funktionaalisesti riippuvainen sarakkeesta C jonkun toisen sarakkeen kautta. Sarake A on transitiivisesti riippuvainen sarakkeesta C, jos sarake A on funktionaalisesti riippuvainen sarakkeesta B ja sarake B on funktionaalisesti riippuvainen sarakkeesta C. Tässä A, B ja C voivat olla myös sarakejoukkoja.

Tietokantataulu on kolmannessa normaalimuodossa jos se on toisessa normaalimuodossa ja siinä olevat sarakkeet eivät ole transitiivisesti riippuvaisia taulun pääavaimesta.

Jos tietokantataulu rikkoo kolmannen normaalimuodon, eli tietokantataulusta tunnistetaan sarakkeita, jotka ovat transitiivisesti riippuvaisia pääavaimesta, eriytetään ne omaksi taulukseen. Eräs klassinen esimerkki tällaisesta tilanteesta liittyy postinumeroon -- tarkastellaan seuraavaa taulua Osoite.

Osoite((pk) id, katuosoite, postinumero, postitoimipaikka)
id katuosoite postinumero postitoimipaikka
1 Työpajankatu 13 00580 Helsinki
2 Työpajankatu 2 R1 C 00580 Helsinki
3 Siltavuorenranta 18 00170 Helsinki
... ... ... ...

Yllä olevassa tietokantataulussa havaitaan funktionaalinen riippuvuus postinumero -> postitoimipaikka, eli postitoimipaikan saa selvitettyä postinumeron perusteella. Samalla kaikki sarakkeet ovat selvitettävissä taulun pääavaimen kautta, joten taulusta löytyy myös transitiivinen riippuvuus. Ratkaisu tähän on -- esimerkiksi -- luoda erillinen taulu postinumeroille.

  • Osoite((pk) id, katuosoite, postinumero)
  • Postinumero((pk) postinumero, postitoimipaikka)
Muita normaalimuotoja

Ensimmäisen, toisen ja kolmannen normaalimuodon lisäksi tietokannan normalisointiin käytetään Boyce-Codd -normaalimuotoa, Neljättä normaalimuotoa ja Viidettä normaalimuotoa.

Tämän kurssin puitteissa ensimmäiset kolme normaalimuotoa riittävät suunnitteluun.

Tietokannan denormalisointi

Tietokannan normalisointi johtaa tyypillisesti tilanteeseen, missä tietokannassa on useita tietokantatauluja, joista jokainen kuvaa jotain selkeää käsitettä. Tietokantataulujen väliset yhteydet tunnistetaan pää- ja viiteavainten avulla, ja taulujen attribuutit ovat selkeitä. Tietokannasta puuttuu toisteinen tieto.

Yleisesti ottaen yllä kuvattu tilanne on hyvä, mutta absoluuttinen hyvyys liittyy paljolti myös käyttötarkoitukseen. Esimerkiksi raportointiin tarkoitettujen järjestelmien ei kannata todennäköisesti -- jos raportin luonti on hidas operaatio -- luoda samoja raportteja yhä uudelleen ja uudelleen, vaan voi olla mielekästä luoda erillinen tietokantataulu (tai muutama), jotka sisältävät raporteille oleelliset tiedot valmiiksi laskettuna.

Myös tietokannan (tai tietokantataulun) käyttötarkoitus vaikuttaa normalisoinnin tarpeeseen. Esimerkiksi sivukäyntien kirjaamiseen tarkoitettu logitusjärjestelmä toimii tehokkaammin jos sivukäyntien tallentamiseen tarkoitetut osat järjestelmästä on denormalisoitu. Tarkastellaan tätä seuraavan esimerkin kautta.

Alla on annettuna kaksi tietokantaa, toinen on normalisoitu ja toinen denormalisoitu. Kumpaakin käytetään järjestelmässä kävijöiden tekemien tapahtumien kirjaamiseen.

Alla olevassa versiossa käyttäjä ja sivu on eriytetty omaksi käsitteekseen, johon tapahtuma-taulu viittaa. Kun tapahtumaa luodaan, tulee tapahtuman lisäämisen yhteydessä hakea käyttäjän tunnus taulusta Kayttaja sekä osoitetta vastaavan sivun tunnus taulusta Sivu.

  • Kayttaja ((pk) id, kayttajatunnus)
  • Sivu ((pk) id, osoite)
  • Tapahtuma ((pk) id, (fk) kayttaja_id -> Kayttaja, (fk) sivu_id -> Sivu, aika, operaatio, ip, laite)

Toinen vaihtoehto on tallentaa käyttäjätunnus ja sivun osoite sellaisenaan.

  • Tapahtuma ((pk) id, kayttajatunnus, osoite, aika, operaatio, ip, laite)

Luo SQLiten avulla tehtäväpohjan kansioon db kaksi yllä kuvattua tietokantaa. Ensimmäisen nimeksi tulee 'tapahtumat-normalisoitu.db' ja toisen nimeksi 'tapahtumat-denormalisoitu.db'. Tee tämän jälkeen ohjelma, joka testaa tiedon lisäämisen nopeutta edellä mainittuihin tietokantatauluihin. Luokan OperaatioidenTehokkuus main-metodin kutsun tulee lisätä 100 tapahtumaa kumpaankin tietokantaan.

Ota seuraava CREATE TABLE-lause lähtökohdaksi.

    CREATE TABLE Tapahtuma (
        id integer PRIMARY KEY, 
        kayttajatunnus varchar(255), 
        osoite varchar(255), 
        aika integer, 
        operaatio varchar(255), 
        ip varchar(255), 
        laite varchar(255)
    );
  

Voit olettaa, että järjestelmä saa jokaisen tapahtuman yhteydessä tietoonsa käyttäjätunnuksen, osoitteen, ajan, tehdyn operaation, ip-osoitteen sekä käyttäjän käyttämän laitteen. Tehtäväpohjassa on valmis toiminnallisuus satunnaisen tiedon luomiseen sekä paikat lisäyskyselyiden tekemiseen.

Kun tietoa lisätään normalisoituun tietokantaan, tapahtuman lisäämisen yhteydessä tulee hakea käyttäjätaulusta tieto käyttäjästä (ja tallentaa käyttäjä tauluun jos kyseistä käyttäjää ei vielä ole), jonka lisäksi sivutaulusta tulee hakea tieto sivusta osoitteen perusteella (sekä lisätä sivu jos sitä ei vielä ole).

Kun tietoa lisätään denormalisoituun tietokantaan, riittää tiedon tallentaminen tietokantatauluun.

Normalisoida vai eikö normalisoida?

Lue CodingHorror.com-blogista kirjoitus Maybe Normalizing Isn't Normal.

Tehtäväpohjassa on tehtävien hallintaan tarkoitettu sovellus. Sovelluksessa on kuitenkin harmittava ominaisuus: kun käyttäjälle lisätään tehtävä, kyseistä tehtävää ei enää näytetä sivulla, missä tehtäviä voi lisätä käyttäjille. Mikään ei kuitenkaan estä ilkeämielistä käyttäjää leikkimästä selainta ja tekemästä pyyntöjä palvelimelle.

Voit kokeilla tätä myös itse -- linux/unix/mac -komentorivillä seuraava komento lisää käyttäjälle, jonka pääavain on 2 tehtävän, jonka pääavain on 1.

kayttaja@kone:~/kansio$ curl --data "userId=2" http://localhost:4567/tasks/1
  

Yllä kuvatun komennon voi ajaa tällä hetkellä halutessaan vaikkapa miljoona kertaa, jolloin TaskAssignment-tauluun päätyy miljoona riviä.

Korjaa tilanne. Muokkaa sovellusta siten, että jokainen tehtävä voi olla määrättynä korkeintaan yhdelle käyttäjälle.

Tehtäväpohjassa ei ole toistaiseksi testejä, eli testaa korjauksesi toimintaa itse.

Sisällysluettelo