MVC-arkkitehtuuri
Nyt kun meillä on luotuna Tehtava-malli propertyineen ja pystymme näyttämään
listan tehtäviä TableView-komponentissa, on aika pohtia koko sovelluksen
arkkitehtuuria.
Sovelluksen arkkitehtuuri tarkoittaa ohjelman eri osien ja vastuualueet järjestämisestä muodostuvaa kokonaisuutta. Arkkitehtuuri muodostuu päätöksistä joiden kumoaminen, muuttaminen tai refaktorointi on erittäin vaikeaa. Hyvä arkkitehtuuri tekee koodista helpommin ymmärrettävää, laajennettavaa ja testattavaa.
Aivan minimaalisissa projekteissa arkkitehtuuria ei tietenkään tarvitse enemmälti pohtia. Tässä projektissa arkkitehtuurille on kuitenkin jo tarvetta erityisesti siksi, että tehtävien käsittely, tallennus ja käyttöliittymä eivät kasautuisi yhteen samaan luokkaan.
Arkkitehtuuria ei usein tarvitse miettiä nollasta, vaan on olemassa valmiita yleisesti hyväksi todettua artkkitehtuuriratkaisuja. Eräs ratkaisu sovelluksen arkkitehtuurin suunnitteluun on MVC (engl. Model-View-Controller).
MVC-arkkitehtuurissa sovellus jaetaan kolmeen osaan: malliin (model),
näkymään (view) ja ohjaimeen (controller). Malli huolehtii datasta ja sen
käsittelystä, näkymä näyttää käyttöliittymän ja ohjain välittää käyttäjän
toiminnot mallille sekä päivittää näkymää. Tässä projektissa MVC auttaa
selkeyttämään rakennetta niin, että MainController ei vastaa enää yksin
kaikesta, vaan tehtävälistan logiikka ja tallennus voidaan siirtää omaan
malliluokkaansa.
Käytännön sovelluksessa ei yleensä ole vain yhtä mallia, yhtä näkymää ja yhtä kontrolleria. Samassa ohjelmassa voi olla useita malleja eri tiedoille, useita näkymiä eri ruuduille tai käyttötilanteille sekä useita kontrollereita, jotka vastaavat omista käyttöliittymän osistaan. MVC kuvaa siis ennen kaikkea vastuiden jakamisen periaatetta, ei sitä, että koko sovellus pitäisi rakentaa vain yhdestä Model-, View- ja Controller-luokasta.
Tässä osassa tunnistamme sovelluksemme osien vastuualueet ja erotamme loput datan hallinnan ja tallennuksen toimintoja kontrollerista omaan malliluokkaansa.
MVC-arkkitehtuurin kerrokset ja vastuut
Katsotaan tarkemmin, mitkä ovat kunkin kerroksen eli osan vastuut MVC-arkkitehtuurissa ja miten kukin kerros toteutetaan tässä projektissa.
Näkymä (view)
- Vastuu: Miltä sovellus näyttää.
- Toteutus Todo-sovelluksessa: FXML-tiedostot, jotka kuvaavat käyttöliittymän rakenteen.
- Rajoitukset: Ei sisällä lainkaan sovelluslogiikkaa (ei esim. tiedä miten tehtävät tallennetaan kovalevylle).
Malli (model)
- Vastuu: Mitä dataa sovelluksessa on ja miten sitä käsitellään. Tästä käytetään usein termejä sovelluslogiikka, liiketoimintalogiikka tai bisneslogiikka.
- Toteutus Todo-sovelluksessa: Olemme jo tehneet
Tehtava-luokan mallintamaan yksittäistä tehtävää. Tässä osassa luomme lisäksiTehtavakokoelma-luokan, joka pitää sisällään tehtävälistan tilan ja tarjoaa metodit tehtävien lisäämiseen, poistamiseen ja tallentamiseen. - Rajoitukset: Ei tiedä mitään JavaFX-näkymästä (
TableView,TextField), vaan luottaa observable-rakenteisiin kertoakseen muutoksista kiinnostuneille osapuolille.
Ohjain (controller)
- Vastuu: Toimia tulkkina näkymän ja mallin välillä.
- Toteutus Todo-sovelluksessa:
MainControllerreagoi käyttäjän tekemiin toimintoihin, kuten painikkeen painallukseen, kutsuu mallin (Tehtavakokoelma) metodeja, ja sitoo näkymän (TableView) kiinni malliinObservable-tietorakenteiden avulla.
MVC kannustaa noudattamaan yhden vastuun periaatetta
Yksi vastuu (engl. Single Responsibility) on yksi ohjelmistosuunnittelun periaate, jonka mukaan jokaisella luokalla tai ohjelman osalla pitäisi olla yksi selkeä vastuualue; yksi pääasiallinen syy muuttua. Ajatus on, että samaan luokkaan ei tule kasata asioita, jotka muuttuvat eri syistä. Yhden vastuun periaate on yksi viidestä SOLID-periaatteesta, johon palataan tarkemmin myöhemmässä osassa. "Syy muuttua" tarkoittaa tässä yhteydessä tarvetta tai vaatimusta, jonka vuoksi luokan toteutusta joudutaan muuttamaan.
Todo-sovelluksessamme esimerkiksi tehtävien tallentaminen tiedostoon voisi muuttua siksi, että haluamme vaihtaa JSON-tiedoston tietokantaan. Käyttöliittymä puolestaan voi muuttua siksi, että haluamme näyttää tehtävät eri tavalla, tai vaikkapa tarjota sama sovellus komentorivi- tai verkkoversiona. Jos sama luokka huolehtisi sekä tallennuksesta että käyttöliittymästä, nämä kaksi erilaista muutostarvetta sotkeutuisivat toisiinsa.
MVC-arkkitehtuuri tukee yhden vastuun periaatteen noudattamista, koska eri syistä muuttuvat asiat erotetaan lähtökohtaisesti eri kerroksiin. Tässä projektissa periaate näkyy esimerkiksi näin:
Tehtava(ja myöhemmin tässä osassa myösTehtavakokoelma) kuuluvat malliin, koska niiden tehtävä on kuvata sovelluksen dataa ja siihen liittyviä sääntöjä.MainControllerei tallenna tiedostoja itse, vaan delegoi sen mallille.- FXML-näkymä sisältää käyttöliittymän rakenteen, ei sovelluslogiikkaa.
Jos MainController vastaisi samaan aikaan painikkeiden käsittelystä, syötteiden
tarkistuksesta, tehtävien tallennuksesta ja tiedoston lukemisesta, luokalla
olisi monta eri syytä muuttua. Tällöin sitä olisi vaikeampi testata, ylläpitää
ja laajentaa turvallisesti.
Sovelluksen pakkausten refaktorointi
JavaFX-sovelluksessa MVC-arkkitehtuurin mukainen jako on helpointa nähdä projektin pakkauksista ja kansiorakenteesta. Tällä hetkellä sovelluksemme luokat jakautuvat seuraaviin pakkauksiin:
Refaktoroidaan nykyisten pakkausten nimet ja jaetaan luokat uusiin pakkauksiin niin, että MVC-arkkitehtuurin mukainen vastuunjako näkyy selkeämmin:
fi.jyu.ohj2.nimi.todo
├── model
│ └── Tehtava
├── controller
│ └── MainController
├── App
└── Main
Aloitetaan muuttamalla nykyinen data-alipakkaus model-alipakkaukseen.
(Alipakkaus on siis pakkaus, joka sijaitsee toisen pakkauksen sisällä, kuten
data-alipakkaus on fi.jyu.ohj2.nimi.todo-pakkauksen alipakkaus.) Avaa IDEAn
projektiselain ja klikkaa hiiren toissijaisella painikkeella
data-alipakkausta. Valitse sitten Rename avautuneesta valikosta. Tämän
jälkeen muuta avautuneesta valikosta pakkauksen data-loppuosa
model-loppuosaan ja paina Refactor:
Tee tämän jälkeen uusi alipakkaus nimeltään controller (ks. osa
)
ja raahaa MainController-luokka uuteen pakkaukseen:
Huomaa, että IDEA osaa automaattisesti refaktoroida luokkien sisällä olevia
package-määreitä sekä FXML-tiedostossa olevan luokkaviitteen.
Tehtäväkokoelma
Siirrämme nyt sovelluksen sydämen, eli tehtävälistan hallinnan ja tietojen luku- ja tallennusoperaatiot, pois kontrollerista omaan luokkaansa. Kyseinen toiminnallisuus liittyy selvästi sovelluksen dataan ja sen käsittelyyn, joten tehtävälista ja sen hallinta kuuluu MVC-arkkitehtuurissa mallikerrokseen.
Luodaan model-pakkaukseen luokka Tehtavakokoelma ja siirretään siihen
tehtävien hallintaan kuuluvat toiminnot: tehtava-lista, listaan liittyvä
alustus, lataa()-metodi ja tallenna()-metodi. Seuraamme myös
kapselointiperiaatetta: teemme tehtavat-listasta private ja teemme
apumetodit getTehtavat(), lisaaTehtava() sekä poistaTehtava(), joilla
hoidetaan tehtävien lisääminen ja poistaminen sekä kytkentä käyttöliittymään.
Samalla teemme pari pientä refaktorointia: siirrämme tallennustiedoston
sijainnin sekä ObjectMapper-olion kokoelman attribuutteihin, sillä kumpaakin
käytetään latauksen ja tallennuksen yhteydessä.
package fi.jyu.ohj2.nimi.todo.model;
import javafx.beans.Observable;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import tools.jackson.core.JacksonException;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
// import-määreet piilotettu tilan säästämiseksi
public class Tehtavakokoelma {
private final ObservableList<Tehtava> tehtavat = FXCollections.observableArrayList(
tehtava -> new Observable[]{tehtava.tehtyProperty()}
);
private final Path tiedostoPolku = Path.of("tehtavat.json");
private final ObjectMapper mapper = new ObjectMapper();
public Tehtavakokoelma() {
tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
tallenna();
});
}
public ObservableList<Tehtava> getTehtavat() {
return tehtavat;
}
public void tallenna() {
mapper.writeValue(tiedostoPolku, tehtavat);
}
public void lataa() {
if (Files.notExists(tiedostoPolku)) {
return;
}
try {
List<Tehtava> kaikkiTehtavat = mapper.readValue(tiedostoPolku, new TypeReference<>() {});
tehtavat.addAll(kaikkiTehtavat);
} catch (JacksonException je) {
IO.println("JSONin lukeminen epäonnistui: " + je.getMessage());
}
}
public void lisaaTehtava(String teksti) {
if (teksti == null || teksti.isBlank()) {
return;
}
teksti = teksti.trim();
tehtavat.add(new Tehtava(teksti, false));
}
public void poistaTehtava(Tehtava tehtava) {
if (tehtava == null) {
return;
}
tehtavat.remove(tehtava);
}
}
Huomaa, miten kaikki säännöt ("otsikko ei saa olla tyhjä", "päivitä tiedosto kun lisätään tai ominaisuus muuttuu") asuvat nyt täällä malliluokassa!
Kontrollerin uusi rooli
Päivitetään lopuksi MainController. Kontrollerin rooli on nyt selkeä
"virkailija" mallin ja näkymän välissä. Sillä ei ole enää kokoelmiin liittyvän
logiikan taakkaa, vaan se vain viestii käyttöliittymän ja Tehtavakokoelman
välillä. Tehtavakokoelma toimii tässä ylätason mallina, jota kontrolleri
käyttää: se omistaa tehtävälistan, huolehtii sen lataamisesta ja tallentamisesta
sekä tarjoaa metodit tehtävien lisäämiseen ja poistamiseen.
Vaikka tässä vaiheessa tämä refaktorointi saattaa vaikuttaa vain koodin siirtämisestä paikasta toiseen, kysymys on enemmänkin vastuiden erottamisesta eri luokkiin MVC-mallin mukaisesti. Kun tehtävälistan hallinta, tallennus ja syötteiden tarkistus ovat omassa malliluokassaan, niitä voidaan kehittää ja testata itsenäisesti ilman käyttöliittymää, ja kontrolleri pysyy yksinkertaisempana.
package fi.jyu.ohj2.nimi.todo.controller;
import fi.jyu.ohj2.nimi.todo.model.Tehtava;
import fi.jyu.ohj2.nimi.todo.model.Tehtavakokoelma;
import javafx.collections.transformation.SortedList;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.cell.CheckBoxTableCell;
import java.net.URL;
import java.util.Comparator;
import java.util.ResourceBundle;
// import-määreet piilotettu tilan säästämiseksi
public class MainController implements Initializable {
@FXML
private Button lisaaUusiTehtavaPainike;
@FXML
private TextField uusiTehtavaNimi;
@FXML
private TableView<Tehtava> tehtavaTaulu;
@FXML
private Button poistaValittuPainike;
// HIGHLIGHT_YELLOW_BEGIN
private Tehtavakokoelma tehtavakokoelma = new Tehtavakokoelma();
// HIGHLIGHT_YELLOW_END
@Override
public void initialize(URL url, ResourceBundle resourceBundle) {
// HIGHLIGHT_YELLOW_BEGIN
SortedList<Tehtava> tehtavatLajiteltu = tehtavakokoelma.getTehtavat().sorted(Comparator.comparing(Tehtava::getTehty));
// HIGHLIGHT_YELLOW_END
tehtavaTaulu.setItems(tehtavatLajiteltu);
tehtavaTaulu.setEditable(true);
TableColumn<Tehtava, Boolean> tehtySarake = new TableColumn<>("Tehty");
tehtySarake.setCellValueFactory(cd -> cd.getValue().tehtyProperty());
tehtySarake.setCellFactory(CheckBoxTableCell.forTableColumn(tehtySarake));
tehtavaTaulu.getColumns().add(tehtySarake);
TableColumn<Tehtava, String> tekstiSarake = new TableColumn<>("Tehtävä");
tekstiSarake.setCellValueFactory(cd -> cd.getValue().tekstiProperty());
tehtavaTaulu.getColumns().add(tekstiSarake);
// HIGHLIGHT_YELLOW_BEGIN
tehtavakokoelma.lataa();
// HIGHLIGHT_YELLOW_END
uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
poistaValittuPainike.setOnAction(event -> poistaValittu());
}
private void lisaaTehtava() {
// HIGHLIGHT_YELLOW_BEGIN
tehtavakokoelma.lisaaTehtava(uusiTehtavaNimi.getText());
// HIGHLIGHT_YELLOW_END
uusiTehtavaNimi.clear();
uusiTehtavaNimi.requestFocus();
}
private void poistaValittu() {
Tehtava valittuTehtava = tehtavaTaulu.getSelectionModel().getSelectedItem();
// HIGHLIGHT_YELLOW_BEGIN
tehtavakokoelma.poistaTehtava(valittuTehtava);
// HIGHLIGHT_YELLOW_END
}
// HIGHLIGHT_RED_BEGIN
private void tallenna() {
ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(Path.of("tehtavat.json"), tehtavat);
}
private void lataa() {
Path path = Path.of("tehtavat.json");
if (Files.notExists(path)) {
return;
}
try {
ObjectMapper mapper = new ObjectMapper();
List<Tehtava> kaikkiTehtavat = mapper.readValue(path.toFile(), new TypeReference<>() {});
tehtavat.addAll(kaikkiTehtavat);
} catch (JacksonException je) {
IO.println("JSONin lukeminen epäonnistui: " + je.getMessage());
}
}
// HIGHLIGHT_RED_END
}
Tehtävät
Palauta osan 8.3 perusteella edistetty projekti. Kertaus tämän osan vaiheista:
- Jäsennä projekti kerroksiin (vähintään malli + käyttöliittymälogiikka).
- Siirrä tiedoston luku- ja kirjoituslogiikka pois kontrollerista
Tehtavakokoelma-luokkaan. - Muuta
MainController-luokka delegoimaan tallennus- ja latausoperaatiot mallille.
Kun vaihe on valmis, tee git add muuttuneille tiedostoille ja git commit.
Palauta projektisi tiedostot.