Advanced Kittenry - Tietokantasovellusohjeet

Arkkitehtuuri ja MVC

Tällä kurssilla pyritään kevyeen MVC-mallin mukaiseen koodiarkkitehtuuriin. Monille käsite on hieman hämärä, siispä avaamme sitä tällä sivulla tarkemmin.

Käytännössä MVC-arkkitehtuuri tarkoittaa sitä, että koodi jaetaan kolmeen osaan: näkymiin, kontrollereihin ja malleihin (engl. views, controllers ja models). Mallit käyttävät tietokantaa ja abstrahoivat sen ohjelmointirajapinnaksi, jota muu koodi käyttää. Näkymät hoitavat kaiken HTML:ään liittyvän koodin ja kontrollerit ottavat vastaan käyttäjän antamat aineistopyynnöt, hakevat ja päivittävät tietoa malleja käyttäen ja välittävät tiedot näkymälle, joka näyttää sivun.

MVC-mallin vastuunjako
MVC-mallin vastuunjako

Nyrkkisääntöjä

  • Malli vastaa tiedon käsittelemisestä ja abstrahoinnista ja ainoastaan siitä. Mallissa ei ikinä käsitellä HTML:ää tai käyttäjän lähettämiä pyyntöjä ja parametrejä, nämä ovat näkymän ja kontrollerin tehtäviä.
  • Kaikki HTML-koodi sijoitetaan näkymiin
  • Kaikki käyttäjän lähettämät GET- ja POST-paremetrit vastaanotetaan kontrollerissa.
  • SQL-kyselyjä ei ikinä tehdä kontrollereiden tai näkymien puolella.

Mallit

Mallit sisältävät kaikki sovelluksen tietokannassa olevan tiedon ja käsittelyyn liittyvät toiminnnot. Kaikki tietokantaa käsittelevä koodi kuuluu siis malleihin.

Mallit toteutetaan keräämällä eri asioihin ja tietokohteisiin liittyviä toimintoja luokiksi ja/tai funktioiksi. Ohessa Javalla tehty esimerkki malliluokasta tietokohteelle kissa:

import Kissalista.Models.Tietokanta;
import java.util.List;
import java.util.ArrayList;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

class Kissa {
  
  private int id;
  private String nimi;
  private String turkinVari;
  private int rotuId;

  private Kissa(ResultSet tulos) {
    Kissa(
      tulos.getInteger("id"),
      tulos.getString("nimi"),
      tulos.getString("turkin_vari"),
      tulos.getInteger("rotuId")
    );
  }
  public Kissa(int id, String nimi, String vari, int rotuId) {
    this.id = id;
    this.nimi = nimi;
    this.turkinVari = vari;
    this.rotuId = rotuId;
  }
  
  /** Hakee tietokannasta yksittäisen kissan id-numeron perusteella */
  public static Kissa haeKissa(int id) throws Exception {
    Connection yhteys = null;
    PreparedStatement kysely = null;
    ResultSet tulokset = null;

    try {
      String sql = "SELECT * FROM kissat WHERE id = ?";
      yhteys = Tietokanta.getConnection();
      kysely = yhteys.getKysely(sql);
      kysely.setInteger(1, id);
      tulokset = kysely.executeQuery();

      if (tulokset.next()) {
        return new Kissa(tulokset);
      } else {
        return null;
      }

    } finally {
      try { tulokset.close(); } catch (Exception e) {  }
      try { kysely.close(); } catch (Exception e) {  }
      try { yhteys.close(); } catch (Exception e) {  }
    }

  }
  /** Hakee tietokannasta listan kissoja nimen perusteella */
  public static List<Kissa> haeKissat(String nimi) throw Exception {
    Connection yhteys = null;
    PreparedStatement kysely = null;
    ResultSet tulokset = null;

    try {
      String sql = "SELECT * FROM kissat WHERE nimi = ?";
      yhteys = Tietokanta.getConnection();
      kysely = yhteys.getKysely(sql);
      kysely.setString(1, nimi);
      tulokset = kysely.executeQuery();
      
      ArrayList<Kissa> l = new ArrayList<Kissa>();
      while (tulokset.next()) {
        l.append(new Kissa(tulokset));
      } 
      return l;

    } finally {
      try { tulokset.close(); } catch (Exception e) {  }
      try { kysely.close(); } catch (Exception e) {  }
      try { yhteys.close(); } catch (Exception e) {  }
    }

  }

  /** Tallentaa kissan tietokantaan */
  public boolean tallenna() throw Exception {
    Connection yhteys = null;
    PreparedStatement kysely = null;
    ResultSet tulokset = null;

    try {
      String sql = "INSERT INTO kissat(nimi, turkin_vari, rotuId) VALUES(?,?,?) RETURNING id";
      yhteys = Tietokanta.getConnection();
      kysely = yhteys.getKysely(sql);
      kysely.setString(1, nimi);
      kysely.setString(2, turkinVari);
      kysely.setInteger(3, rotuId);
      tulokset = kysely.executeQuery();
      
      if (tulokset.next()) {
        id = tulokset.getInteger("id");
        return true;
      } else {
        return false;
      }

    } finally {
      try { tulokset.close(); } catch (Exception e) {  }
      try { kysely.close(); } catch (Exception e) {  }
      try { yhteys.close(); } catch (Exception e) {  }
    }
  }
  /** Poistaa kissan tietokannasta */
  public boolean poista() throw Exception {
    Connection yhteys = null;
    PreparedStatement kysely = null;

    try {
      String sql = "DELETE FROM kissat where id = ?";
      yhteys = Tietokanta.getConnection();
      kysely = yhteys.getKysely(sql);
      kysely.setInteger(1, id);
      return kysely.execute();
    } finally {
      try { kysely.close(); } catch (Exception e) {  }
      try { yhteys.close(); } catch (Exception e) {  }
    }
  }

  /** Getterit ja setterit */
  public int getId() {
    return this.id;
  }
  public void setId(int id) {
    this.id = id;
  }

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

  public String getTurkinVari() {
    return this.turkinVari;
  }
  public void setTurkinVari(String turkinVari) {
    this.turkinVari = turkinVari;
  }

  public int getRotuId() {
    return this.rotuId;
  }
  public void setRotuId(int rotuId) {
    this.rotuId = rotuId;
  }
}

Vastaava luokka PHP-kielellä on hyvin samanlainen, joskin hieman lyhyempi.

Huomaa miten malliluokka on käytännössä pelkkä abstraktitaso, joka piilottaa tietokantakoodin rumat yksityiskohdat siistin oliokäyttöliittymän taakse.

Usein voi olla järkevää tehdä malleja myös abstraktimmeille asioille kuten istunnoille ja sille millä oikeuksilla käyttäjä on kirjautunut. Näiden ei ole tietenkään pakko käyttää tietokantaa (ainakaan suoraan), mutta ne ovat silti malleja.

Näkymät

Näkymä määrittää käyttöliittymän ulkoasun ja tietojen näytön esityksen käyttöliittymässä. Tietokantasovelluksen tapauksessa käytetään nk. template-tiedostoja eli tiedostoja, joissa määritellään sivulle tuleva HTML-koodi, ja sen sekaan tulevat muuttujat.

Javalla käytetään JSP-tiedostoja, PHP:llä tavallisia php-tiedostoja. Kummassakin on lähinnä HTML-koodia, jonka seassa on hieman ohjelmakoodia.

Näkymissä ei ikinä käsitellä tietokantaa suoraan, ja erittäin harvoin niissä on varsinaista koodia yli kolmea riviä peräkkäin. Sensijaan, että haettaisiin tiedot näkymässä suoraan kannasta käytetään sovitun nimisiä muuttujia, joiden sisältö näytetään. Se, mistä muuttujien sisältö tulee, näkymän ei tarvitse tietää mitään. Sille riittää että tieto on siellä missä sen pitääkin. (Tiedon sijoittaminen oikeisiin muuttujiin on sitävastoinkontrollerin tehtävä.)

Ohessa esimerkki kahdella tiedostolla toteutetusta kissalistasta. Jälkimmäistä tiedostoa voi käyttää myös muualla kissan näyttämiseen.

views/kissalista.php

<?php
  /** Tiedosto, jonka tarkoituksena on näyttää lista kissoja.
    * Olettaa, että muuttujassa $tiedot on kentät kissat. */ 
?>
<h1>Kissat</h1>
<p>Kissoja yhteensä <?php echo count($kissat); ?> kpl</p>  
<div class="kissat">
  <?php foreach($kissat as $kissa): ?>
  <?php require 'kissa.php'; ?>
  <?php endforeach; ?>
</div>

views/kissa.php

<?php
  /** Tiedosto, jonka tarkoituksena on näyttää kissan tiedot.
    * Olettaa, että muuttuja $kissa on asetettu. */ 
?><div class="kissa">
  <div class="row nimi">
    <label>Nimi:</label>
    <div class="value"><?php echo $kissa->getNimi(); ?></div>
  </div>
  <div class="row turkin_vari">
    <label>Turkin väri:</label>
    <div class="value"><?php echo $kissa->getTurkinVari(); ?></div>
  </div>
  <div class="row rotu">
    <label>Rotu:</label>
    <div class="value"><?php echo $kissa->getRotu(); ?></div>
  </div>
</div>

Yleensä on tapana myös tehdä muutama ns. template-näkymätiedosto, joissa määritellään kaikille sovelluksen sivuille yhteisiä elementtejä.

Kontrollerit

Kontrolleri (tunnetaan myös nimellä käsittelijä tai ohjain) sitoo yhteen mallin ja näkymän ja välittää tietoa niiden välillä.

Kontrolleri vastaanottaa käyttäjältä tulevat aineistopyynnöt, käyttää mallia tiedon hakemiseen ja muuttamiseen, ja lopuksi yleensä siirtää suorituksen jollekin näkymälle. Yleensä kontrolleri välittää samalla näkymälle jotain tietoa näytettäväksi, esimerkiksi listan mallin palauttamia olioita tai virheviestin.

Kontrollerissa sensijaan ei koskaan käsitellä tietokantaa suoraan, tai lähetetä selaimelle HTML:ää tai muuta sisältöä. Nämä ovat mallien ja näkymien tehtäviä.

Ohessa esimerkkejä kontrollereista PHP:llä ja Javalla

<?php 
  //Otetaan käyttöön kirjastotiedosto, joka hakee kasan omatekoisia yleistoimintoja, sekä malliluokka:
  require_once 'src/common.php';
  require_once 'src/models/kissa.php';
  
  //Selvitetään onko käyttäjä tehnyt haun
  $hakusana = null;
  if (!empty($_GET['haku'])) {
    $hakusana = $_GET['haku'];
  }

  //Kutsutaan malliluokan staattista metodia
  $kissat = Kissa::etsiHakusanalla($hakusana);
  
  //Näytetään näkymä lähettäen sille muutamia muuttujia
  naytaNakymä("kissalista", array(
    'title' => "Kissalista",
    'kissat' => $kissat
  ));
package Kissalista.Servlets;

import java.io.IOException;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import Kissalista.Models.Kissa;

/* Kontrolleriluokka, joka näyttää listan kissoja. 
 * Kissalista voi olla hakusanalla suodatettu, 
 * mikäli parametri haku on lähetetty aineistopyynnön mukana.
 */
public class KissaServlet extends HttpServlet {
  /**
   * Processes requests for both HTTP
   * <code>GET</code> and <code>POST</code> methods.
   *
   * @param request servlet request
   * @param response servlet response
   * @throws ServletException if a servlet-specific error occurs
   * @throws IOException if an I/O error occurs
   */
  protected void processRequest(HttpServletRequest request, 
                                HttpServletResponse response)
      throws ServletException, IOException {
    response.setContentType("text/html;charset=UTF-8");
    
    String hakusana = request.getParameter("haku");
    List<Kissa> kissat = null;
    if (hakusana != null && hakusana.length > 0) {
      kissat = Kissa.hae(hakusana);
    } else {
      kissat = Kissa.kaikkiKissat();
    }
    
    request.setAttribute("kissat", kissat);
    if (kissat.length() == 0) {
      request.setAttribute("viesti", "Kissoja ei löytynyt");
    }
    
    /* Luodaan RequestDispatcher-olio, joka osaa näyttää näkymätiedoston lista.jsp */
    RequestDispatcher dispatcher = request.getRequestDispatcher("lista.jsp");
    /* Pyydetään dispatcher-oliota näyttämään JSP-sivunsa */
    dispatcher.forward(request, response);
  }
  
  /**
   * Handles the HTTP
   * <code>GET</code> method.
   *
   * @param request servlet request
   * @param response servlet response
   * @throws ServletException if a servlet-specific error occurs
   * @throws IOException if an I/O error occurs
   */
  @Override
  protected void doGet(HttpServletRequest request, HttpServletResponse response)
          throws ServletException, IOException {
      processRequest(request, response);
  }

  /**
   * Handles the HTTP
   * <code>POST</code> method.
   *
   * @param request servlet request
   * @param response servlet response
   * @throws ServletException if a servlet-specific error occurs
   * @throws IOException if an I/O error occurs
   */
  @Override
  protected void doPost(HttpServletRequest request, HttpServletResponse response)
          throws ServletException, IOException {
      processRequest(request, response);
  }
}

Koodin sijoittelu tiedostojärjestelmään

Eri osioiden koodit sijoitetaan käytetystä kielestä riippuen eri paikkaan. Ohjaajien toiveet sijoittelusta ovat alla:

PHP:tä käytettäessä sijoita kaikki mallit ja näkymät omaan kansioonsa. Kontrollerit taas sijoitetaan projektin juureen. Näkymät sijoitetaan kansioon views.

Useimmiten on tarpeen luoda vielä oma kansionsa yleiskäyttöisille kirjastotiedostoille, jotka eivät varsinaisesti ole malleja. Yleiskäyttöisen koodin kansion voi nimetä kuvaavasti esim. libs. Luo malleille oma models-kansio yleiskäyttöisen koodin alle.

Kansioiden nimet voi halutessaan suomentaa.

views/
  userlist.php
libs/
  models/
    user.php
  common.php (Yleiskäyttöisiä funktioita)
users.php (Käyttäjänhallinnan kontrolleri)
~~~

Javaa käytettäessä sijoita mallit ja kontrollerit omiin paketteihinsa projektin yhteisen paketin alle src-kansioon.

Kontrollerit ovat Javan tapauksessa aina HttpServlet-luokan aliluokkia eli servlettejä, joten niiden paketin voi nimetä myös esim. servletit.

Näkymät - eli yleensä jsp-tiedostot - menevät omaan kansioonsa joka on oletuksena nimeltään web.

src/
  Paketti/
    Models/
      User.java
    Servlets/
      UserServlet.java
web/
  user.jsp

Nyrkkisääntöjä

  • Malli vastaa tiedon käsittelemisestä ja abstrahoinnista ja ainoastaan siitä. Mallissa ei ikinä käsitellä HTML:ää tai käyttäjän lähettämiä pyyntöjä ja parametrejä, nämä ovat näkymän ja kontrollerin tehtäviä.
  • Kaikki HTML-koodi sijoitetaan näkymiin
  • Kaikki käyttäjän lähettämät GET- ja POST-paremetrit vastaanotetaan kontrollerissa.
  • SQL-kyselyjä ei ikinä tehdä kontrollereiden tai näkymien puolella.

Linkkejä

Seuraavaksi:

Siirry seuraavaksi kielikohtaisiin toteuttamisohjeisiin: