Keväästä 2018 alkaen Ohjelmistotekniikka (vanhalta nimeltään Ohjelmistotekniikan menetelmät) on siirtynyt aineopintoihin. Kurssin esitietoina on Ohjelmoinnin jatkokurssi sekä Tietokantojen perusteet. Oletuksena on, että molemmista kursseista on käyty suhteellisen tuore versio ja että molempien aihepiiri on vielä hyvin mielessä.
Kurssin oppimistavoitteet ovat edelleen suunnilleen samat kuin aiemmin. Kurssin suoritettuaan opiskelija
- tuntee ohjelmistotuotantoprosessin vaiheet
- on tietoinen vesiputousmallin ja ketterän ohjelmistotuotannon luonteesta
- osaa soveltaa versionhallintaa osana ohjelmistokehitystä
- osaa soveltaa UML-mallinnustekniikkaa ohjelmiston suunnittelussa ja dokumentoinnissa tarkoituksenmukaisella tavalla
- tuntee ohjelmiston testauksen eri vaiheet
- osaa soveltaa automatisoitua testausta yksinkertaisissa ohjelmistoprojekteissa
- tuntee tärkeimpiä ohjelmiston suunnitteluperiaatteita ja osaa soveltaa niitä yksinkertaisissa projekteissa
Kurssin suoritusmuoto poikkeaa radikaalisti aiemmasta viikoittaiset luennot ja laskuharjoitukset sisältävästä kurssista, nykyinen OTM muistuttaakin läheisesti entistä Ohjelmoinnin harjoitustyötä.
Kurssin ensimmäisen kolmen viikon aikana harjoitellaan versionhallintaa, yksikkötestausta sekä UML-kaavioiden tekemistä. Toisesta viikosta alkaen aloitetaan oman harjoitustyön tekeminen. Harjoitustyön tekemisen ohessa osoitetaan riittävä osaaminen kurssin oppimistavoitteiden suhteen, koetta kurssilla ei ole. Tarkemmat arvosteluperusteet täällä.
Tälle sivulle on koottu erinäistä asiaa liittyen kurssin "teoriaan" sekä erinäisiin menetelmiin, kuten UML-kaavioihin. Sivu kannattaa lukea kokonaisuudessaan heti ensimmäisen viikon aikana, tosin luvusta Työkaluja alkaen olevaa asiaa tarvitaan oikeastaan vasta viikosta 2 eteenpäin.
Ohjeita työn aloittamiseen täällä
Jos huomaat tehtävissä tai muussa materiaalissa kirjoitusvirheitä, kirjaudu GitHubiin ja toimi täällä olevan ohjeen mukaan.
Kun tehdään pientä ohjelmaa omaan käyttöön, ei työskentelymenetelmillä ole suurta merkitystä. Kun ohjelmiston koko on suurempi ja erityisesti, jos sitä tehdään useamman ihmisen toimesta ulkoiselle käyttäjälle tai tilaajalle, ei pelkkä häkkeröinti enää tuota optimaalista tulosta. Tarvitaankin jonkinlainen systemaattinen menetelmä ohjaamaan ohjelmistokehittäjien toimintaa ja varmistamaan, että ohjelmistosta tulee käyttäjien käyttötarkoitukseen sopiva.
Ohjelmiston systemaattinen tekeminen, eli ohjelmistotuotanto (engl. Software engineering) sisältää useita erilaisia aktiviteettejä, joiden aikana tekemisen fokus on hieman erilaisissa asioissa. Näitä aktiviteetteja tai vaiheita niinkuin niitä joskus nimitetään ovat seuraavat
- vaatimusmäärittely jonka tehtävä on selvittää, kuinka ohjelmiston halutaan toimivan
- suunnittelu jonka aikana mietitään, miten halutunkaltainen ohjelmisto tulisi rakentaa
- toteutusvaiheessa määritelty ja suunniteltu ohjelmisto koodataan
- testauksen tehtävä taas on varmistaa ohjelmiston laatu, että se ei ole liian buginen ja että se toimii kuten vaatimusmäärittely sanoo
- ylläpitovaiheessa ohjelmisto on jo käytössä ja siihen tehdään bugikorjauksia ja mahdollisia laajennuksia.
Katsotaan vielä kutakin vaihetta hieman tarkemmin.
Käytetään seuraavassa esimerkkinä kurssia varten tehtyä yksinkertaista todo-sovellusta.
Vaatimusmäärittelyn aikana kartoitetaan ohjelman tulevien käyttäjien tai tilaajan kanssa se, mitä toiminnallisuutta ohjelmaan halutaan. Ohjelman toiminnalle siis asetetaan asiakkaan haluamat vaatimukset. Tämän lisäksi kartoitetaan ohjelman toimintaympäristön ja toteutusteknologian järjestelmälle asettamia rajoitteita.
Vaatimusmäärittelyn tuloksena on useimmiten jonkinlainen dokumentti, johon vaatimukset kirjataan. Dokumentin muoto vaihtelee suuresti, se voi olla paksu mapillinen papereita tai vaikkapa joukko postit-lappuja.
Esimerkkisovelluksemme on siis klassinen TodoApp, eli sovellus, jonka avulla käyttäjien on mahdollista pitää kirjaa omista tekemättömistä töistä, eli todoista.
Vaatimusmäärittely kannattaa yleensä aloittaa tunnistamalla järjestelmän erityyppiset käyttäjäroolit. Sovelluksellamme ei ole toistaiseksi muuta kuin normaaleja käyttäjiä. Jatkossa sovellukseen saatetaan lisätä myös ylläpitäjän oikeuksilla varustettu käyttäjärooli.
Kun sovelluksen käyttäjäroolit ovat selvillä, mietitään mitä toiminnallisuuksia kunkin käyttäjäroolin halutaan pystyvän tekemään sovelluksen avulla.
Todo-sovelluksen normaalien käyttäjien toiminnallisuuksia ovat esim. seuraavat
- käyttäjä voi luoda järjestelmään käyttäjätunnuksen
- käyttäjä voi kirjautua järjestelmään
- kirjautumisen jälkeen käyttäjä näkee omat tekemättömät työt eli todot
- käyttäjä voi luoda uuden todon
- käyttäjä voi merkitä todon tehdyksi, jolloin todo häviää listalta
Ylläpitäjän toiminnallisuuksia voisivat olla esim. seuraavat
- ylläpitäjä näkee tilastoja sovelluksen käytöstä
- ylläpitäjä voi poistaa normaalin käyttäjätunnuksen
Ohjelmiston vaatimuksiin kuuluvat myös toimintaympäristön rajoitteet. Todo-sovellusta koskevat seuraavat rajoitteet
- ohjelmiston tulee toimia Linux- ja OSX-käyttöjärjestelmillä varustetuissa koneissa
- käyttäjien ja todojen tiedot talletetaan paikallisen koneen levylle
Vaatimusmäärittelyn aikana hahmotellaan yleensä myös sovelluksen käyttöliittymä.
Kurssin aiemmissa versioissa käyttäjien vaatimukset dokumentointiin käyttötapauksina (engl. use case). Käytämme tällä kurssilla hieman kevyempää tapaa, ja kirjaamme järjestelmältä toivotun toiminnallisuuden vapaamuotoisena ranskalaisista viivoista koostuvana feature-listana. Katso tarkemmin Todo-sovelluksen alustavasta vaatimusmäärittelystä.
Ohjelmiston suunnittelu jakautuu yleensä kahteen erilliseen vaiheeseen.
Arkkitehtuurisuunnittelussa määritellään ohjelman rakenne karkealla tasolla
- mistä suuremmista rakennekomponenteista ohjelma koostuu
- miten komponentit yhdistetään, eli minkälaisia komponenttien väliset rajapinnat ovat
- mitä riippuvuuksia ohjelmalla on esim. tietokantoihin ja ulkoisiin rajapintoihin
Arkkitehtuurisuunnittelua tarkentaa oliosuunnittelu, missä mietitään ohjelmiston yksittäisten komponenttien rakennetta, eli minkälaisista luokista komponentit koostuvat ja miten luokat kutsuvat toistensa metodeja sekä mitä apukirjastoja luokat käyttävät.
Myös ohjelmiston suunnittelu, erityisesti sen arkkitehtuuri dokumentoidaan usein jollain tavalla. Joskus tosin dokumentaatio on hyvin kevyt, esim. valkotaululle piirretty kaavio tai se voi jopa puuttua kokonaan ja ajatellaankin että hyvin muotoiltu koodi voi korvata dokumentoinnin.
Toteutuksen yhteydessä ja sen jälkeen järjestelmää testataan. Testauksessa on monta erilaista näkökulmaa, eli pääasiallista kiinnostuksen kohdetta. Näitä eri näkökulmia nimitetään usein testaustasoiksi. Testauksen terminologia vaihtelee hieman mutta yleisimmin puhutaan kolmesta testaustasosta eli yksikkötestauksesta, integraatiotestauksesta ja järjestelmätestauksesta.
Yksikkötestauksessa (engl. unit testing) tutkitaan yksittäisten metodien ja luokkien toimintaa. Yksikkötestauksen tekee usein testattavan luokan ohjelmoija ja hyvä tapa on tehdä luokalle yksikkötestit samalla kun luokka ohjelmoidaan.
Kun erikseen ohjelmoidut komponentit (eli luokat tai luokkien muodostamat kokoelmat) yhdistetään, suoritetaan integraatiotestaus (engl. integration testing), jossa varmistetaan erillisten komponenttien yhteentoimivuus. Myös integraatiotestit tehdään useimmiten ohjelmoijien toimesta.
Järjestelmätestauksessa (engl. system testing) testataan järjestelmää kokonaisuutena ja verrataan, että se toimii vaatimusdokumentissa sovitun määritelmän mukaisesti. Järjestelmätestauksessa testien näkökulma on sama kuin loppukäyttäjän, eli testit suoritetaan ohjelmiston käyttöliittymän kautta. Järjestelmätestauksen suorittavat usein testauksen ammattilaiset.
Ohjelmistoja on perinteisesti tehty vaihe vaiheelta etenevän vesiputousmallin (engl. waterfall model) mukaan. Vesiputousmallissa edellä esitellyt ohjelmistotuotannon vaiheet suoritetaan peräkkäin:
Vesiputousmallissa suoritetaan siis ensin vaatimusmäärittely, jonka seurauksena kirjoitetaan vaatimusdokumentti, johon pyritään kokoamaan kaikki ohjelmalle osoitettavat vaatimukset mahdollisimman tarkasti dokumentoituna. Määrittelyvaiheen päätteeksi vaatimusdokumentti jäädytetään. Jäädytettyä vaatimusmäärittelyä käytetään usein ohjelman kehittämisen vaatimien resurssien arvioinnin perustana ja myös sopimus ohjelman hinnasta saatetaan tehdä vaatimusmäärittelyn pohjalta.
Vaatimusmäärittelyä seuraa suunnitteluvaihe, joka myös dokumentoidaan tarkoin. Pääsääntöisesti suunnitteluvaiheen aikana ei enää tehdä muutoksia määrittelyyn. Joskus tämäkin on tarpeen. Suunnittelu pyritään tekemään niin täydellisenä, että ohjelmointivaiheessa ei enää ole tarvetta muuttaa suunnitelmia.
Suunnittelun jälkeen toteutetaan ohjelman yksittäiset komponentit ja tehdään niille yksikkötestaus. Tämän jälkeen erilliset komponentit liitetään yhteen eli integroidaan ja suoritetaan integrointitestaus.
Integroinnin jälkeen ohjelmalle tehdään järjestelmätestaus, eli testataan, että ohjelmisto toimii kokonaisuutena niin kuin määrittelydokumentissa on määritelty.
Vesiputousmalli on monella tapaa ongelmallinen. Mallin toimivuus perustuu siihen oletukseen, että ohjelman vaatimukset pystytään määrittelemään täydellisesti ennen kuin suunnittelu ja ohjelmointi alkaa. Näin ei useinkaan ole. On lähes mahdotonta, että asiakkaat pystyisivät tyhjentävästi ilmaisemaan kaikki ohjelmalle asettamansa vaatimukset. Vähintäänkin riski sille, että ohjelma on käytettävyydeltään huono, on erittäin suuri. Usein käy myös niin, että vaikka ohjelman vaatimukset olisivat kunnossa vaatimusten laatimishetkellä, muuttuu toimintaympäristö (tapahtuu esim. yritysfuusio) ohjelman kehitysaikana niin ratkaisevasti, että valmistuessaan ohjelma on vanhentunut. Hyvin yleistä on myös se, että vasta käyttäessään valmista ohjelmaa asiakkaat alkavat ymmärtää, mitä he olisivat ohjelmalta halunneet.
Asiakkaan muuttuvien vaatimuksien lisäksi toinen suuri ongelma on se, että vesiputousmallissa ohjelmistoa aletaan testata verrattain myöhäisessä vaiheessa. Erityisesti integraatiotestauksessa on tyypillistä että ohjelmasta löydetään pahoja ongelmia, joiden korjaaminen hidastaa ohjelmiston valmistumista paljon ja käy kalliiksi.
Vesiputousmallin heikkoudet ovat johtaneet viime vuosina yleistyneiden ketterien (engl. agile) ohjelmistokehitysmenetelmien käyttöönottoon.
Ketterissä menetelmissä lähdetään oletuksesta, että vaatimuksia ei voi tyhjentävästi määritellä ohjelmistokehitysprosessin alussa. Koska näin ei voida tehdä, ei sitä edes yritetä vaan pyritään toimimaan niin, että asiakkaan vaatimukset saadaan tarkennettua pikkuhiljaa ohjelmiston kehitysprosessin aikana ja lopputuloksesta saadaan sitä kautta mahdollisimman halutun kaltainen.
Ketterä ohjelmistokehitys etenee yleensä siten, että ensin kartoitetaan pääpiirteissään ohjelman vaatimuksia ja ehkä hahmotellaan järjestelmän alustava arkkitehtuuri. Tämän jälkeen suoritetaan useita iteraatioita (joista käytetään yleisesti myös nimitystä sprintti), joiden aikana ohjelmaa rakennetaan pala palalta eteenpäin. Kussakin iteraatiossa suunnitellaan ja toteutetaan valmiiksi pieni osa ohjelman vaatimuksista. Vaatimukset voivat myös tarkentua koko prosessin ajan.
Yksittäinen iteraatio, joka on kestoltaan tyypillisesti 1-4 viikkoa, siis lisää järjestelmään pienen osan koko järjestelmän toivotusta toiminnallisuudesta. Tyypillisesti tärkeimmät ja toteutuksen kannalta haasteellisimmat ja riskialttiimmat toiminnallisuudet toteutetaan ensimmäisillä iteraatioilla. Yksi iteraatio sisältää toteutettavaksi valittujen vaatimusten tarkennuksen, suunnittelun, toteutuksen sekä testauksen.
Jokainen iteraatio tuottaa toimivan ja toteutettujen ominaisuuksien kannalta testatun järjestelmän. Asiakas pääsee kokeilemaan järjestelmää jokaisen iteraation jälkeen. Tällöin voidaan jo aikaisessa vaiheessa todeta, onko kehitystyö etenemässä oikeaan suuntaan ja vaatimuksia voidaan tarvittaessa tarkentaa ja lisätä.
Jokainen iteraatio siis sisältää määrittelyä, suunnittelua, ohjelmointia ja testausta ja jokaisen iteraation jälkeen saadaan asiakkaalta palautetta siitä, onko kehitystyö etenemässä oikeaan suuntaan:
Ketterässä ohjelmistokehityksessä dokumentointi ei ole yleensä niin keskeisessä osassa kuin perinteisissä menetelmissä.
Vähäisemmän dokumentaation sijaan testauksella ja ns. jatkuvalla integroinnilla on hyvin suuri merkitys. Yleensä pyritään siihen, että järjestelmään lisättävät uudet komponentit testataan välittömästi ja pyritään heti integroimaan kokonaisuuteen; tästä työskentelytavasta käytetään nimitystä jatkuva integrointi (engl. continuous integration). Näin uusia versioita järjestelmästä syntyy jopa päivittäin.
Uusien komponenttien toimiminen pyritään varmistamaan perinpohjaisella automaattisella testauksella. Joskus jopa "testataan ensin", eli jo ennen uuden komponentin toteuttamista ohjelmoidaan komponentin toimintaa testaavat testitapaukset. Testitapausten valmistuttua toteutetaan komponentti ja siinä vaiheessa kun komponentti läpäisee testitapaukset, se integroidaan muuhun kokonaisuuteen.
Erilaisia ketteriä ohjelmistokehitysmenetelmiä on olemassa lukuisia, näistä tunnetuin nykyään on Scrum.
Ketterät menetelmät ovat nykyään vallitseva tapa tehdä ohjelmistoja. Ketterien menetelmien rinnalle ovat viime vuosina nousseet ketteryyden ideaa hieman jalostavat Lean-menetelmät. Palaamme aiheeseen tarkemmin kurssilla Ohjelmistotuotanto.
Tämän kurssin harjoitustyö pyritään tekemään osittain ketterien menetelmien hengessä, eli vaatimusmäärittely ja suunnittelu pidetään kevyenä ja ohjelmaa aletaan toteuttaa jo heti alkuvaiheessa. Ohjelmasta pyritään mahdollisuuksien mukaan tekemään jokaisen iteraation eli viikon päätteeksi toimiva versio jota sitten viikko viikolta laajennetaan. Kurssin vaatimaa dokumentaatiota tehdään osin matkan varrella.
Tarvitsemme ohjelmistokehityksessä suuren joukon käytännön työkaluja.
Olet jo ehkä käyttänyt muilla kursseilla komentoriviä ja versionhallintaa, molemmat ovat tärkeässä roolissa ohjelmistokehityksessä ja niiden harjoittelu on aiheena viikon 1 tehtävissä.
Olet todennäköisesti ohjelmoinut Javaa suurimmaksi osaksi NetBeansilla ja luottanut siihen että kaikki hoituu valitsemalla sopivia toimintoja valikoista ja painamalla "vihreää nappia".
Alamme tämän kurssin myötä hieman tutkimaan, miten Javalla tehdyn ohjelmiston hallinnointi (esim. koodin kääntäminen, koodin sekä testin suorittaminen ja koodin paketoiminen NetBeansin ulkopuolella suoritettavissa olevaksi jar-paketiksi) tapahtuu NetBeansin "ulkopuolella".
Java-projektien hallinnointiin on olemassa muutamakin vaihtoehto. Käytämme tällä kurssilla mavenia, joka lienee jo useimmille osittain tuttu esim. Tietokantojen perusteista.
Ohje Mavenin käytön aloittamiseen.
Ohjelmistojen testaus tapahtuu nykyään ainakin yksikkö- ja integraatiotestien osalta automatisoitujen testityökalujen toimesta. Java-maailmassa testausta dominoi lähes yksinvaltiaan tavoin JUnit. Tulet kurssin ja myöhempienkin opintojesi aikana kirjoittamaan paljon JUnit-testejä.
JUnitiin tutustumme viikon 2 tehtävissä.
Osa ohjelmiston dokumentointia on lähdekoodin API:n eli käytännössä luokkien julkisten metodien kuvaus. Javassa lähdekoodi dokumentoidaan käyttäen JavaDoc-työkalua. Dokumentointi tapahtuu kirjoittamalla koodin yhteyteen sopivasti muotoiltuja kommentteja.
Sovelluksen JavaDocia voi tarkastella selaimen avulla
Myös NetBeans osaa näyttää ohjelmoidessa koodiin määritellyn javadocin:
Automaattisten testien lisäksi koodille voidaan määritellä erilaisia automaattisesti tarkastettavia tyylillisiä sääntöjä, joiden avulla on mahdollista ylläpitää koodin luettavuutta ja varmistaa että joka puolella koodia noudatetaan samoja tyylillisiä konventioita.
Käytämme kurssilla tarkoitukseen Checkstyle-nimistä työkalua:
Checkstyle is a development tool to help programmers write Java code that adheres to a coding standard. It automates the process of checking Java code to spare humans of this boring (but important) task. This makes it ideal for projects that want to enforce a coding standard.
Ohje checkstylen konfiguroimiseen.
Ohjelmistojen dokumentoinnissa ja sovelluksen suunnittelun yhteydessä on usein tapana visualisoida ohjelman rakennetta ja toimintaa UML-kaavioilla.
UML tarjoaa lukuisia erilaisia kaaviotyyppejä, hyödynnämme kurssilla kuitenkin näistä ainoastaan kolmea.
Kurssilla Tietokantojen perusteet olet jo tutustunut luokkakaavioiden käyttöön.
Luokkakaavioiden käyttötarkoitus on ohjelman luokkien ja niiden välisten suhteiden kuvailu. Todo-sovelluksen oleellista tietosisältöä edustavat käyttäjää vastaava luokka User:
public class User {
private String name;
private String username;
public User(String username, String name) {
this.name = name;
this.username = username;
}
public String getName() {
return name;
}
public String getUsername() {
return username;
}
}
ja tehtävää vastaava luokka Todo:
package todoapp.domain;
public class Todo {
private int id;
private String content;
private boolean done;
private User user;
public Todo(int id, String content, boolean done, User user) {
this.id = id;
this.content = content;
this.done = done;
this.user = user;
}
public String getContent() {
return content;
}
public User getUser() {
return user;
}
public int getId() {
return id;
}
public boolean isDone() {
return done;
}
public void setDone() {
done = true;
}
}
Jokaiseen todoon liittyy yksi käyttäjä, ja yksittäiseen käyttäjään liittyviä todoja voi olla useita. Tilannetta kuvaa seuraava luokkakaavio
Luokkakaavioon on nyt merkitty molempien luokkien oliomuuttujat sekä metodit.
Yleensä ei ole mielekästä kuvata luokkia tällä tarkkuudella, eli luokkakaavioihin riittää merkitä luokan nimi
Luokkien tarkemmat detaljit selviävät koodia katsomalla tai JavaDoc:ista.
UML-kaavioissa olevat "viivat" kuvaavat luokkien olioiden välistä pysyvää yhteyttä. Joissain tilanteissa on mielekästä merkata kaavioihin myös ei-pysyvää suhdetta kuvaava katkoviiva, eli riippuvuus.
Eräs tällainen tilanne on Ohjelmoinnin perusteiden Unicafe-tehtävän luokkien Maksukortti ja Kassapääte suhde. Maksukortin koodi on seuraava:
public class Maksukortti {
private double saldo;
public Maksukortti(double saldo) {
this.saldo = saldo;
}
public double saldo() {
return this.saldo;
}
public void lataaRahaa(double lisays) {
this.saldo += lisays;
}
public boolean otaRahaa(double maara) {
if (this.saldo < maara) {
return false;
}
this.saldo -= maara;
return true;
}
}
Kuten huomataan, koodissa ei mainita kassapäätettä millään tavalla.
Kassapäätteen hieman lyhennetty koodi on seuraava:
public class Kassapaate {
private int edulliset;
private int maukkaat;
private static final double EDULLISEN_HINTA = 2.5;
private static final double MAUKKAAN_HINTA = 4.3;
// käteismyyntiin liittyvät metodit poistettu
public boolean syoEdullisesti(Maksukortti kortti) {
if (kortti.saldo() < EDULLISEN_HINTA) {
return false;
}
kortti.otaRahaa(EDULLISEN_HINTA);
this.edulliset++;
return true;
}
public boolean syoMaukkaasti(Maksukortti kortti) {
// ...
}
public void lataaRahaaKortille(Maksukortti kortti, double summa) {
if (summa < 0) {
return;
}
kortti.lataaRahaa(summa);
this.rahaa += summa;
}
}
Kassapääte käyttää maksukortteja hetkellisesti lounaiden maksamisen ja rahan lataamisen yhteydessä. Kassapääte ei kuitenkaan muista pysyvästi yksittäisiä maksukortteja. Tämän takia kassapäätteellä on riippuvuus maksukortteihin, mutta ei kuitenkaan normaalia yhteyttä, sillä UML-kaavioon merkattu yhteys viittaa pysyvään, ajallisesti pidempikestoiseen suhteeseen.
Tilannetta kuvaava luokkakaavio on seuraava:
Riippuvuus siis kuvataan katkoviivallisena nuolena, joka kohdistuu siihen luokkaan mistä ollaan riippuvaisia. Riippuvuuteen ei merkitä numeroa toisin kuin yhteyteen.
Tarkastellaan toisena esimerkkinä riippuvuudesta todo-sovelluksen sovelluslogiikasta vastaavaa luokkaa TodoService, jonka koodi hieman lyhennettynä näyttää seuraavalta:
public class TodoService {
private TodoDao todoDao;
private User loggedIn;
public void createTodo(String content, User user) {
Todo todo = new Todo(content, user);
todoDao.create(todo);
}
public List<Todo> getUndone() {
if (loggedIn==null) {
return new ArrayList<>();
}
return todoDao.getAll()
.stream()
.filter(t->{
return t.getUser().equals(loggedIn);
})
.filter(t->!t.isDone())
.collect(Collectors.toList());
}
// ...
}
Sovelluslogiikkaa hoitava olio tuntee kirjautuneen käyttäjän, mutta pääsee käsiksi kirjautuneen käyttäjän todoihin ainoastaan todoDao-olion välityksellä. Tämän takia luokalla ei ole yhteyttä luokkaan Todo, luokkien välillä on kuitenkin riippuvuus, sillä sovelluslogiikka käsittelee metodeissaan todo-olioita.
Merkitään luokkakaavioon seuraavasti:
Riippuvuuksien merkitseminen luokkakaavioihin ei ole välttämättä kovin oleellinen asia, niitä kannattaa merkitä jos ne tuovat esille tilanteen kannalta jotain oleellista.
TodoService siis tuntee TodoDao-olion (jos unohdit mikä on DAO, kertaa Tietokantojen perusteiden DAO-suunnittelumalli), jonka avulla se pääsee todo-olioihin. TodoDao ei ole itseasiassa luokka vaan rajapinta:
public interface TodoDao {
void create(Todo todo);
List<Todo> getAll();
void setDone(int id);
}
TodoDaosta voi olla olemassa useita eri toteutuksia. Tällä hetkellä ohjelmassa on todo-oliot tiedostoon tallettava FileTodoDao
public class FileTodoDao implements TodoDao {
public List<Todo> todos;
private String file;
// ...
}
sekä testien käyttämä FakeTodoDao. Jos ohjelmaa halutaan laajentaa siten, että tiedot talletetaan tiedostojen sijaan tietokantaan, voidaan tarkoitusta varten tehdä uusi toteutus rajapinnasta SqlTodoDao.
Rajapinta ja sen toteuttavat luokat kuvataan luokkakaaviossa seuraavasti:
Samaa merkintätapaa eli valkoisen nuolenpään sisältävää viivaa käytetään perinnän merkitsemiseen. Esim. jos Todo-sovelluksessa olisi normaalin käyttäjän eli luokan User perivää ylläpitäjää kuvaava luokka SuperUser, merkattaisiin se luokkakaavioon seuraavasti:
Todo-sovelluksen koodi on sijoitettu pakkauksiin seuraavasti:
Pakkausrakenne voidaan kuvata UML:ssä pakkauskaaviolla:
Pakkausten välille on merkitty riippuvuudet katkoviivalla. Pakkaus todoapp.ui riippuu pakkauksesta todoapp.domain sillä ui:n luokka Main käyttää domain-pakkauksen luokkia Todo ja TodoService.
Vastaavasti pakkaus todoapp.domain riippuu pakkauksesta todoapp.dao sillä domainin luokka TodoService käyttää dao-pakkauksen rajapintoja TodoDao ja UserDao.
Pakkauskaavioihin on myös mahdollista merkitä pakkausten sisältönä olevia luokkia normaalin luokkakaaviosyntaksin mukaan:
Sovelluksen koodi on organisoitu kerrosarkkitehtuurin periaatteiden mukaan. Asiasta lisää hieman myöhemmin tässä dokumentissa.
Luokka- ja pakkauskaaviot kuvaavat ohjelman rakennetta. Ohjelman toiminta ei kuitenkaan tule niistä ilmi millään tavalla.
Esim. ohjelmoinnin perusteiden Unicafe-tehtävää kuvaava luokkakaavio näyttää seuraavalta:
Vaikka kaavioon on merkitty metodien nimet, ei ohjelman toimintalogiikka, esim. mitä tapahtuu kun kortilla ostetaan edullinen lounas, selviä kaaviosta millään tavalla.
Tietokantojen perusteiden viikolla 4 on lyhyt maininta sekvenssikaavioista.
Sekvenssikaaviot on alunperin kehitetty kuvaamaan verkossa olevien ohjelmien keskinäisen kommunikoinnin etenemistä. Sekvenssikaaviot sopivat kohtuullisen hyvin kuvaamaan myös sitä, miten ohjelman oliot kutsuvat toistensa metodeja suorituksen aikana.
Koodia katsomalla näemme, että lounaan maksaminen tapahtuu siten, että ensin kassapääte kysyy kortin saldoa ja jos se on riittävä, vähentää kassapääte lounaan hinnan kortilta ja palauttaa true:
public class Kassapaate {
private static final double EDULLISEN_HINTA = 2.5;
// ...
public boolean syoEdullisesti(Maksukortti kortti) {
if (kortti.saldo() < EDULLISEN_HINTA) {
return false;
}
kortti.otaRahaa(EDULLISEN_HINTA);
this.edulliset++;
return true;
}
//...
}
Sekvenssikaaviona kuvattuna tilanne näyttää seuraavalta:
Sekvenssikaaviossa oliot kuvataan laatikoina, joista lähtee alaspäin olion "elämänlanka". Kaaviossa aika etenee ylhäältä alas. Metodikutsut kuvataan nuolena, joka yhdistää kutsujan ja kutsutun olion elämänlangat. Paluuarvo merkitään katkoviivalla.
Jos saldo ei riitä, etenee suoritus seuraavan sekvenssikaavion tapaan:
Tarkastellaan hieman monimutkaisempaa tapausta, yrityksen palkanhallinnasta vastaavaa ohjelmaa:
public class Henkilo {
private String nimi;
private int palkka;
private String tilinumero;
public Henkilo(String nimi, int palkka, String tilinumero) {
this.nimi = nimi;
this.palkka = palkka;
this.tilinumero = tilinumero;
}
public void setPalkka(int palkka) {
this.palkka = palkka;
}
public int getPalkka() {
return palkka;
}
public String getTilinumero() {
return tilinumero;
}
}
public class Henkilostorekisteri {
private Map<String, Henkilo> henkilot;
private PankkiRajapinta pankki;
public Henkilostorekisteri() {
henkilot = new HashMap<String, Henkilo>();
pankki = new PankkiRajapinta();
}
public void lisaa(Henkilo henkilo){
henkilot.set(henkilo, henkilo);
}
public void suoritaPalkanmaksu(){
for (Henkilo henkilo : henkilot.values()) {
String tiliNro = henkilo.getTilinumero();
int palkka = henkilo.getPalkka();
pankki.maksaPalkka(tiliNro, palkka);
}
}
public void asetaPalkka(String nimi, int uusiPalkka){
Henkilo h = hekilot.get(nimi);
h.setPalkka(uusiPalkka);
}
}
public class PankkiRajapinta {
public void maksaPalkka(String tilinumero, int summa) {
// suorittaa maksun verkkopankin internet-rajapinnan avulla
// yksityiskohdat piilotettu
}
}
Sekvenssikaaviot siis kuvaavat yksittäisten suoritusskenaarioiden aikana tapahtuvia asioita. Kuvataan nyt seuraavan pääohjelman aikaansaamat tapahtumat:
public static void main(String[] args) {
Henkilostorekisteri rekisteri = new Henkilostorekisteri();
Henkilo arto = new Henkilo("Hellas", 1500, "1234-12345");
rekisteri.lisaa(arto);
Henkilo sasu = new Henkilo("Tarkoma", 6500, "4455-123123");
rekisteri.lisaa(sasu);
rekisteri.asetaPalkka("Hellas", 3500);
rekisteri.suoritaPalkanmaksu();
}
Sekvenssikaavio on seuraavassa:
Kaavio alkaa tilanteesta, jossa Henkilostorekisteri on jo luotu, mutta henkilöolioita ei vielä ole olemassa.
Toiminta alkaa siitä, kun pääohjelma eli main luo henkilön nimeltä arto. Seuraavaksi main kutsuu rekisterin metodia lisaa ja antaa parametriksi luodun henkilöolion.
Vastaava toistuu kun main luo uuden henkilön ja lisää sen rekisteriin.
Seuraavana toimenpiteenä main kasvattaa arton palkkaa kutsumalla rekisterin metodia asetaPalkka. Tämä saa aikaan sen, että rekisteri kutsuu arto-olion metodia setPalkka. Rekisterin viivaan on merkitty paksunnus, joka korostaa, että sen metodia on kutsuttu.
Viimeinen ja monimutkaisin toiminnoista käynnistyy, kun main kutsuu rekisterin metodia suoritaPalkanmaksu. Rekisteri kysyy ensin arton tilinumeroa ja palkkaa ja kutsuu paluuarvoina olevilla tiedoilla pankin metodia maksaPalkka ja sama toistuu sasun kohdalla.
Sekvenssikaaviot eivät ole optimaalinen tapa ohjelman suorituslogiikan kuvaamiseen. Ne sopivat jossain määrin olio-ohjelmien toiminnan kuvaamiseen, mutta esim. funktionaalisella tyylillä tehtyjen ohjelmien kuvaamisessa ne ovat varsin heikkoja.
Tietynlaisten tilanteiden kuvaamiseen ohjelmoinnin perusteissakin käsitellyt vuokaaviot voivat sopia paremmin.
Voit halutessasi lukea lisää sekvenssikaavioista kurssin vanhan version materiaalista.
Katsotaan seuraavassa muutamia sovelluksen suunnittelussa noudatettuja periaatteita.
Kuten jo mainittiin, todo-sovellus noudattaa kerrosarkkitehtuuria. Koodin tasolla kerrosrakenne näkyy siinä, miten sovelluksen koodi jakautuu pakkauksiin
ja minkälaisia riippuvuuksia pakkausten välisillä luokilla on.
Riippuvuudet kuvaava pakkauskaavio havainnollistaa koodin rakenteen kerroksellisuuden
Kerrosarkkitehtuurissa ylimpänä on käyttöliittymästä vastaava kerros. Käyttöliittymäkerroksen vastuulla on muodostaa sovelluksen käyttöliittymä ja reagoida käyttäjän syötteisiin.
Sovelluslogiikka, eli esim. käyttäjän kirjautumisesta huolehtiminen, todojen luominen ja niiden tehdyksi merkkaaminen on käyttöliittymän alapuolella olevan sovelluslogiikkakerroksen vastuulla. Sovelluslogiikkakerroksen koodi on pakkauksessa nimeltään todoapp.doman.
Sovelluslogiikan alapuolella on datan tallennuksesta vastaava kerros, jonka käytännössä muodostavat DAO-suunnittelumallin (ks. Tietokantojen perusteiden Dao-suunnittelumalli) inspiroimana muodostetut rajapintojen TodoDao ja UserDao toteuttamat luokat.
Kerrosarkkitehtuuri (engl. layered architecture tai multitier architecture) on ehkä eniten käytetty ohjelmistojen arkkitehtuurimalli, eli yleisesti käytetty tapa ohjelmiston rakenteen strukturointiin. Käytännössä lähes jokainen ohjelmisto noudattaa ainakin jossain määrin kerroksellisuuden periaatetta. On olemassa lukuisia arkkitehtuurimalleja, joihin tutustutaan tarkemmin kursseilla Ohjelmistotuotanto ja Ohjelmistoarkkitehtuurit.
Ohjelmistojen suunnitteluun on aikojen saatossa muodostunut joukko periaatteita, joiden noudattamisen on todettu parantavan koodin laatua.
Jo Ohjelmoinnin perusteissa aloittelevaa ohjelmoijaa varoitellaan copy pasten vaaroista. DRY-periaate ilmaisee asian seuraavasti
Every piece of knowledge must have a single, unambiguous, authoritative representation within a system
Periaate yleistää toisteettomuuden koskemaan koodin lisäksi muitakin ohjelmistoon liittyviä asioita, esim. dokumentaatiota. Luokkien dokumentoiminen JavaDoc:in avulla ilmentää osin tätä periaatetta.
Toinen jo ohjelmoinnin perusteista tuttu periaate Single responsibility tarkoittaa karkeasti ottaen, että oliolla tulee olla vain yksi vastuu eli yksi asiakokonaisuus, mihin liittyvästä toiminnasta luokan oliot itse huolehtivat. Tämän jo vuosikymmeniä vanhan säännön nimen lanseerannut Robert "Uncle Bob" Martin ilmaisee asian seuraavasti A class should have only one reason to change.
Kerrosarkkitehtuurin voi ajatella ilmentävän tätä periaatetta laajentaen sen yksittäisten luokkien ja olioiden tasolta sovellusten suurempiin kokonaisuuksiin.
Todo-sovelluksen suunnittelussa periaatetta on noudatettu suhteellisen hyvin
- käyttöliittymästä on eristetty sovelluslogiikka kokonaan
- käyttäjä ja tehtävät on talletettu omiin luokkiinsa User ja Todo
- sovelluslogiikan suorittamisesta, eli User- ja Todo-olioiden manipuloinnista vastaa oma luokka TodoService
- tietojen talletuksesta levylle vastaavat DAO-oliot, jotka on vielä jaettu kahteen vastuualueeseen eli käyttäjistä vastaavaan UserDaoon ja todoista vastaavaan TodoDaoon.
Program to an interface, not to an Implementation, eli ohjelmoi käyttämällä rajapintoja äläkä konkreettisia implementaatioita
- Laajennettavuuden kannalta ei ole hyvä idea olla riippuvainen konkreettisista luokista, sillä ne saattavat muuttua
- Parempi on tuntea vain rajapintoja (tai abstrakteja luokkia) ja olla tietämätön siitä mitä rajapinnan takana on
- Tämä mahdollistaa myös rajapinnan takana olevan luokan korvaamisen kokonaan uudella luokalla
Todo-sovelluksessa sovelluslogiikka ei käytä suoraan konkreettisia DAO-olioita, se tuntee ainoastaan rajapinnat, ja tämä taas mahdollistaa konkreettisen toteutuksen vaihtamisen esim. testeissä tai tulevaisuudessa ohjelman laajennuksen yhteydessä:
Minimoi riippuvuudet, eli älä tee spagettikoodia, jossa kaikki oliot tuntevat toisensa. Pyri eliminoimaan riippuvuudet siten, että luokat tuntevat mahdollisimman vähän muita luokkia, ja mielellään nekin vain rajapintojen kautta.
Kerrosarkkitehtuuri tähtää osaltaan riippuvuuksien eliminointiin, esim. käyttöliittymä on nyt riippuvainen ainoastaan sovelluslogiikkakerroksen luokista TodoService ja Todo, mutta ei millään tavalla tietojen talletuksesta vastaavista DAO-luokista.
Turhien riippuvuuksien eliminointiin liittyy läheisesti tapa, jolla oliot pääsevät käsiksi riippuvuuksiinsa eli tarvitsemiinsa olioihin.
Sovelluslogiikasta huolehtiva TodoService-olio tarvitsee toimiakseen TodoDao- ja UserDao-oliot. Se saa oliot konstruktorin parametrina:
public class TodoService {
private TodoDao todoDao;
private UserDao userDao;
private User loggedIn;
public TodoService(TodoDao todoDao, UserDao userDao) {
this.userDao = userDao;
this.todoDao = todoDao;
}
// ...
}
eli kun sovelluksen alustava metodi init luo ensin sopivat DAO-oliot ja antaa ne konstruktorin parametrina luotavalle TodoService-oliolle:
public class Main extends Application {
private TodoService todoService;
// ...
@Override
public void init(){
FileUserDao userDao = new FileUserDao("users.txt");
FileTodoDao todoDao = new FileTodoDao("todos.txt", userDao);
todoService = new TodoService(todoDao, userDao);
}
// ...
}
Tekniikasta, missä oliolle annetaan sen riippuvuudet ulkopuolelta joko konstruktorin parametrina, erillisten metodien avulla tai jollain muulla tekniikalla, käytetään nimitystä riippuvuuksien injektointi (engl. dependency injection).
Riippuvuuksien injektointi helpottaa erityisesti testaamista, sillä se mahdollistaa, että luokille annetaan niiden normaalien riippuvuuksien sijaan testausta varten luotuja valekomponentteja.
Todo-sovelluksessa on luokkaa TodoService testattu juuri näin. Esim. UserDao:n valekomponentti sisältää alussa yhden käyttäjän:
public class FakeUserDao implements UserDao {
List<User> users = new ArrayList<>();
public FakeUserDao() {
users.add(new User("testertester", "Teppo Testaaja"));
}
// ...
}
DAO:jen valekomponentit injektoidaan testattavalle luokalle:
public class TodoServiceUserTest {
FakeTodoDao todoDao;
FakeUserDao userDao;
TodoService service;
@Before
public void setUp() {
todoDao = new FakeTodoDao();
userDao = new FakeUserDao();
service = new TodoService(todoDao, userDao);
}
@Test
public void nonExistingUserCanLogIn() {
boolean result = service.login("nonexisting");
assertFalse(result);
assertEquals(null, service.getLoggedUser());
}
@Test
public void existingUserCanLogIn() {
boolean result = service.login("testertester");
assertTrue(result);
User loggedIn = service.getLoggedUser();
assertEquals("Teppo Testaaja", loggedIn.getName() );
}
// ...
}
Toisin kuin todelliset DAO:t, testeissä käytettävät valekomponentit eivät tallenna dataa levylle. Tämä tekee testaamisesta helpompaa.
Katso lisää Todo-sovelluksen arkkitehtuurikuvauksesta ja testausdokumentista.
Muutamia käyttöliittymän ja tietojen tallettamisen toteuttamiseen sekä sovelluksen konfigurointiin liittyviä vihjeitä on koottu tänne