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:
-
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 pakkauksessatikape
.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. -
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>
-
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.
-
Projekti git-repositorioksi -- projektin luominen git-repositorioksi tapahtuu ajamalla komento
git init
projektin juurikansiossa (kansio, jossa löytyy tiedostopom.xml
). Jos sovellus on jo esimerkiksi githubissa, ei tätä tarvitse tehdä. -
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.
-
Turhien lähdekooditiedostojen poistaminen -- suorita projektin juurikansiossa komento
mvn clean
, joka poistaa projektista käännetyt lähdekooditiedostot (kansio target). -
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. -
Tiedostojen lähettämiseen sitoutuminen -- suorita projektin juurikansiossa komento
git commit -m "viesti"
, joka sitouttaa lähetykseen juuri lisätyt tiedostot. -
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.
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..
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..
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 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..
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!).
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.
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:
- Sarakkeen arvot eivät saa sisältää listoja.
- Taulun sarakkeet eivät muodosta toistuvia ryhmiä.
- Sarakkeen arvojen tulee olla saman tyyppisiä.
- Jokaisen sarakkeen nimen tulee olla tietokantataulussa uniikki.
- Sarakkeiden järjestyksen ei tule vaikuttaa tietokantataulun toimintaan.
- Tietokantataulussa ei saa olla kahta täsmälleen samanlaista riviä.
- 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.
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.
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)
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)
Tätä tehtävää ei tehdä, eikä se lataudu TMC:stä.
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.