Kihagyás

5. gyakorlat

MVC bevezetés

Az MVC architekturális model az egyik legegyszerűbb mind közül. 3 jól elkülöníthető réteget definiál melyek:

  • Model (M)
  • View (V)
  • Controller (C)

A rétegek közül a felhasználó a View-n keresztül kommunikál. A felhasználói interakció valamilyen eseményeket generál, melyre a Controller a megfelelő hívásokat ejtheti meg. A Controller tekinthető egy középső rétegnek, mert a Model réteget csak ő képes adatmódosítási céllal elérni. Tehát a View-ból például sosem kezdeményezünk adatbázis műveleteket, mert az veszélyes lenne az alkalmazás szempontjából, illetve a View-nak nem is kell tudnia arról, hogy az adat honnan jön és hogyan van tárolva. Számára az a lényeg, hogy tudja hogy milyen adatokat kell megjelenítenie a felhasználó számára.

A Controller-ben végzünk minden üzleti logikával kapcsolatos tevékenységet. Például bizonyos mezők értéke alapján egyéb értékeket állíthatunk be automatikusan, melyet már a felhasználó nem lát.

A Model réteg több dolgot foglalhat magában. Egyrészt a Bean osztályainkat rendre itt szoktuk létrehozni (A Bean definícióját lásd később). Másrészt az úgynevezett DAO is itt szokott helyet foglalni, mely az adatelérésért felelős, legyen az egy adatbázisban vagy egy fájlban.

Ezen felül a View rétegnek nyilván szüksége lehet magukra a Bean-ekre is, ezért ő használhat ilyen jellegű olvasási műveleteket közvetlenül a Model rétegből.

Az imént leírtak grafikusan szemléltetve:

mvc

MVC alkalmazás implementálása

Feladat

Készítsünk egy egyszerű címjegyzék alkalmazást!

A címjegyzékbe az alábbi funkcióknak kell elérhetőnek lennie:

  • Új kontakt hozzáadása, melynél a következő információkat kell lehet megadni:

    • Név (Kötelező)
    • Email (Kötelező)
    • Telefonszám (Opcionális) - lehet több is és meg lehet adni a típusát (work, home)
    • Lakcím (Opcionális)
    • Születésnap (Kötelező)
    • Szervezet (Opcionális)
    • Pozíció/Beosztás (Opcionális)
  • Kontakt szerkesztése
  • Kontakt törlése
  • Kontaktok listázása
  • Kontaktok közötti keresés a következők alapján

    • Név
    • Email
  • Export és import funkciók támogatása (vCard), melyhez tetszőleges 3rd party lib használható

Maven Multimodule

Maven segítségével létrehozhatunk úgynevezett multi-module projekteket is, ahol van egy parent pom, mely a projekt gyökerében található és és a package-elése POM-ra van állítva. Ebben a parent pom-ban, melyet aggregator POM-nak is szokás nevezni, megadjuk a module-okat, melyek az eddig látott projektektől semmiben sem különböznek, csupán meg kell adnunk a <parent> elemek között a parent POM-ot.

Mielőtt a konkrét példát megnéznénk, vegyük sorra, hogy milyen előnyöket szolgáltat a számunkra ennek a konstrukciónak a használata:

  • Duplikációk csökkentése, mivel a modulokat több helyen is felhasználhatjuk
  • Közös konfigurációk kiszervezése a parent POM-ba (vagy más néven Super POM-ba)

Címjegyzék projekt létrehozás

Készítsünk egy új projektet (File -> New -> Project), melynek típusát Maven-re állítsuk! A nevének adjuk a contacts-ot, illetve groupId-nak használhatjuk a hu.alkfejl elnevezést! A létrejövő projektben az src mappa törölhető is, mivel itt majd csak almodulokat hozunk létre!

Ezután a projektre jobb klikkelünk és a New-> Module opció alatt szintén Maven projektet adjunk meg. A következő oldalon a Parent értékét már fel is kínálja, hogy legyen az a contacts. Ezután adjuk neki a contacts-core nevet! Ennek eredményeképpen a Project View fülön a contacts alá kerül be a contacts-core, mely saját pom.xml-el rendelkezik.

A contacts/pom.xml tartalma a következő lehet:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<?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>hu.alkfejl</groupId>
    <artifactId>contacts</artifactId>
    <packaging>pom</packaging>
    <version>1.0-SNAPSHOT</version>
    <modules>
        <module>contacts-core</module>
    </modules>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>

</project>

Vegyük észre, hogy a parent POM package-elése pom lett, illetve a contacts-core megjelenik a modulok alatt (ami a buildelés miatt fontos).

Még egy dolgunk lehet, hogy a közös property-ket csak a parent pom.xml-ben adjuk meg (Pl.: <maven.compiler.source>11</maven.compiler.source>), így azt a submodule majd úgyis örökli (persze ettől függetlenül a submodule bármikor felülírhatja a megadott property-ket).

A contacts/contacts-core/pom.xml ezután valahogy így nézhet ki:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<?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">
    <parent>
        <artifactId>contacts</artifactId>
        <groupId>hu.alkfejl</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <packaging>jar</packaging>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>contacts-core</artifactId>
</project>

A projekt package-elését nem muszáj jar-ra állítani, mivel ez az alapértelmezett, de a jelenlegi célunk nyomatékosítása végett most megadtuk. A fontos rész a parent elemek között található, ahol megadjuk a hivatkozást a parent pom-ra. Vegyük észre, hogy jelen esetben nem kell megadnunk a groupId-t és a version-t, mivel a submodule ezeket a parent pom-ból átveszi az effective pom-ba.

Ezután ha kiadunk egy mvn package parancsot a parent könyvtárában, akkor az összes a modules elem alatt megadott submodule-t buildeli a rendszer (függőségeket is figyelembe veszi).

Model réteg

A model osztályok lesznek azok, akiket mind asztali, mind webes környezetből szeretnénk majd használni, így azokat a contacts-core submodule-on belül hozzuk létre.

Bean osztályok

Készítsünk egy hu.alkfejl.model package-et, melyben létrehozunk egy Contact.java állományt! Ez egy úgynevezett Bean osztály lesz, melynek néhány fontos tulajdonsággal rendelkeznie kell. Ez esetünkben kevésbé lesz fontos, viszont a keretrendszerek (pl. ORM-ek, mint például a Hibernate) ezeket masszívan használják, így követendő példa már most a megfelelő módon előkészítenünk alkalmazásainkat.

3 tulajdonsággal kell rendelkeznie egy Bean osztálynak:

  • Van publikus default konstruktora
  • Minden adattaghoz tartozik publikus getter/setter (másnéven accessorok)
  • Az osztálynak szerializálhatónak kell lennie

Az első kettőt talán nem kell elmagyarázni, de mi is az a szerializálhatóság? A Java rendelkezik az úgynevezett objektum szerializálással, ami szerint egy objektum reprezentálható egy bájt stream-mel, mely tartalmazza az objektum típusát, illetve hogy maga az objektum milyen típusú fieldekkel rendelkezik, továbbá a konkrét adatot is ezekhez a fieldekhez. Másszóval a bájtstreamben benne van az objektum aktuális állapota (melyik field milyen értéket tárol). Amennyiben ezt a bájtfolyamot valahova elmentjük ezt nevezzük szerializációnak. Ez megfelel az objektum aktuális állapotának elmentésével. Például lementhetjük egy objektum állapotát ilyen módon egy fájlba is, de például az is szerializáció, amikor egy objektumot JSON formára alakítunk (közkedvelt keretrendszer a Jackson ennek használatára). Amikor az elmentett állapotot visszatöltjük (a memóriába), akkor deszerializációról beszélünk. Ahhoz, hogy egy osztály objektumai szerializálhatóak legyenek egyszerűen meg kell valósítanunk a Serializable interface-t. Esetünkben egyelőre nem fogjuk alkalmazni az interfészt. Amint szükségünk lesz rá, akkor azt úgyis könnyen megtehetjük.

Mivel a model osztályainkat property-k használatával szeretnénk elkészíteni, így szükségünk lesz egy függőségre, melyet a contacts/contacts-core/pom.xml-ban adunk meg:

1
2
3
4
5
6
7
<dependencies>
    <dependency>
        <groupId>org.openjfx</groupId>
        <artifactId>javafx-base</artifactId>
        <version>11</version>
    </dependency>
</dependencies>

A property-k a javafx-base modulban találhatóak, így elég ezt a dependency-t használnunk (ne felejtsük el frissíteni a Maven modulokat).

Ezek után lássuk a Contact.java bean osztályunkat!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class Contact {
    private IntegerProperty id = new SimpleIntegerProperty(this, "id");
    private StringProperty name = new SimpleStringProperty(this, "name");
    private StringProperty email = new SimpleStringProperty(this, "email");
    private ObjectProperty<ObservableList<Phone>> phones = new SimpleObjectProperty<>(this, "phones");
    private StringProperty address = new SimpleStringProperty(this, "address");
    private ObjectProperty<LocalDate> dateOfBirth = new SimpleObjectProperty<>(this, "dateOfBirth");
    private StringProperty company = new SimpleStringProperty(this, "company");
    private StringProperty position = new SimpleStringProperty(this, "position");

  // generated getters és setters: Alt+Insert -> Getter and setter
}

A StringProperty-ket már láttuk, viszont érdemes megnézni a telefonszámok tárolását! A telefonszámokat egy saját Phone osztályban adjuk meg, ahol a telefonszámoknak van egy PhoneType-ja (pl.: otthoni vagy munkahelyi), illetve maga a telefonszám (hamarosan látjuk a részleteket). Mivel több telefonszámot szeretnénk tárolni, így azokat egy ObservableList<>-ben fogjuk megadni, melyet szintén elhelyezhetünk egy ObjectProperty-ben.

A Date osztályhoz nincs hozzá tartozó property osztály, így azt ObjectProperty-ként tudjuk tárolni. A könnyebb kötések kialakítása végett a java.util.Date helyett a java.time.LocalDate osztályt használjuk, mivel a felületen használni kívánt DatePicker is LocalDate-et használ belül property-ként.

Megjegyzés

A Lombok projekt egy olyan library, ami a motorikus feladatok eliminálásával megkönnyíti a fejlesztést. Például a getter/setter metódusokat egyetlen annotációval jelezhetjük a rendszer számára, illetve a loggoláshoz használt field-eket is egy rövidke annotációval rendelkezésre bocsáthatjuk. A fenti Contact osztályt a következőképpen is írhattuk volna:

1
2
3
4
@Data
public class Contact{
  // properties
}

A @Data annotáció legenerálja a getter-eket és setter-eket is, továbbá paraméteres konstruktor, toString(), equals(), hashCode() metódusokat is biztosít. Az alkalmazható feature-ök listájáért lásd a Lombok weboldalát!

IntelliJ-hez elérhető a Lombok plugin, melyet telepítenünk kell., ezen felül a projekthez hozzá kell adnunk a következő Maven függőséget:

1
2
3
4
5
6
7
8
<dependencies>
  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.16</version>
    <scope>provided</scope>
  </dependency>
</dependencies>

A Lombok projekt igen hasznos tud lenni, azonban a property-k esetében nem megfelelően generálja a getter/setter és XXXProperty() metódusokat, így jelen esetben nem használjuk.

A Phone osztály a következőképpen nézhet ki:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class Phone {

    public enum PhoneType{
        HOME("Home"),
        WORK("Work"),
        MOBILE("Mobile"),
        UNKNOWN("Unknown");

        private final StringProperty value = new SimpleStringProperty(this, "value");

        public String getValue() {
            return value.get();
        }

        public StringProperty valueProperty() {
            return value;
        }

        PhoneType(String value) {
            this.value.set(value);
        }

        @Override
        public String toString() {
            return getValue();
        }
    }


    private IntegerProperty id = new SimpleIntegerProperty(this, "id");
    private StringProperty number = new SimpleStringProperty(this, "number");
    private ObjectProperty<PhoneType> phoneType= new SimpleObjectProperty<>(this, "phoneType");

    // getters, setters
}

Mivel a PhoneType szorosan kapcsolódik a Phone-hoz, így az ezt reprezentáló enum-ot a Phone osztályon belül adjuk meg, mely első körben 4 féle értéket ad meg lehetségesként. Magát a PhoneType-ot is egy ObjectProperty-ként tartjuk számon.

Data Access Object (DAO)

Miután elkészültek a bean-ek, elkezdhetünk dolgozni a DAO-n. A DAO (Data Access Object) az adatelérési réteget adja meg, mely a model rétegen belülre sorolható. A konkrét hozzáadás és listázás itt történik (mármint az adatok tárolása, legyen az memória, adatbázis, fájl vagy bármi egyéb). A controller rétegünk majd ezt fogja felhasználni. Fontos, hogy itt tartsuk mindig szem előtt, hogy interface mögé rejtsük az aktuális implementációt, mely biztosítja, hogy könnyen cserélhető legyen a megvalósítás.

Először készítsük el a jól definiált interface-t! Ehhez készítsünk egy új interface-t ContactDAO néven a hu.alkfejl.dao csomagba!

1
2
3
4
5
6
7
public interface ContactDAO {

    List<Contact> findAll();
    Contact save(Contact contact);
    void delete(Contact contact);

}

Első körben 3 implementálandó metódussal rendelkezik, melyek már felhasználják ez előzőleg definiált bean osztályunkat. A 3 megadott metódus lenyomat az összes kontakt listázásához, egy kontakt mentéséhez/módosításához és a törléshez lesz majd használható. A hozzáadásnál fontos, hogy visszaadunk egy Contact értéket is, melynek szerepe, hogy a controller felé jelezze, hogy sikeresen megtörtént a beszúrás vagy valami hiba végett ez meghiúsult (ebben az esetben adhat vissza null-t is akár). Nyilván nem túl szofisztikált azt közölni a felhasználóval, hogy 'Valami hiba történt', mivel a felhasználó általában arra is kíváncsi, hogy mi volt a hiba oka. Erre most nem térünk ki, de bővítési lehetőségként mindenki elkészítheti saját maga számára.

Miután megvan az interface, el kell készítenünk egy tényleges implementációt is hozzá. Ehhez készítsünk egy osztályt az interface mellé ContactDAOImpl néven. A megvalósításunk jelen esetben egy SQLite adatbázist fog használni, ugyanakkor a konkrét adatbázis nem jelenik meg a kódban, hiszen a JDBC - Java Database Connectivity API-t használjuk. A JDBC egységes interfészt ad a különböző adatbázisok kezeléséhez, melyeket akár kombinálhatjuk is egy alkalmazáson belül. A konkrét megvalósításokat, melyeket az adatbázis gyártók (vendorok) szolgáltatják, a JDBC DriverManager osztályán keresztül regisztrálhatjuk. A DriverManager továbbá egy Connection factoryként is funkcionál, azaz tőle tudunk adatbázis kapcsolati objektumokat kérni a getConnection() factory metóduson keresztül. A JDBC segítségével mind DDL (Data Definition Language) és DML (Data Manipulation Language) parancsokat is kiadhatunk, továbbá tárolt eljárásokat is meghívhatunk. Az SQL utasításokhoz a JDBC a következő osztályokat biztosítja:

  • Statement: Egyszerű paraméter nélküli utasításokhoz. Például SELECT * FROM CONTACT.
  • PreparedStatement: Paraméteres lekérdezésekhez. Például: SELECT * FROM CONTACT WHERE id = ?, ahol az id értékét a valamilyen tetszőleges értékre állíthatjuk majd be.
  • CallableStatement: Tárolt eljárások használatához/meghívásához. Például: {call proc_name(?,?)}.

Ezek közül a későbbiekben az első kettőt fogjuk használni. A JDBC továbbá támogatja a tranzakciókezelést is, de ezzel a kurzuson nem foglalkozunk.

Magához a JDBC használatához nem kell semmilyen függőséget sem megadnunk, hiszen a JDBC részét képezi a Java SE-nek, azaz benne van az JDK-ban. Viszont az adott adatbázis driver-ét biztosítani kell futásközben, így adjuk hozzá az SQLite drivert a contacts-core függőségeihez!

1
2
3
4
5
6
<dependency>
    <groupId>org.xerial</groupId>
    <artifactId>sqlite-jdbc</artifactId>
    <version>3.34.0</version>
    <scope>runtime</scope>
</dependency>

Miután ezzel megvagyunk, akkor még érdemes lehet egy command line eszközt telepíteni, mellyel az SQLite-ot kezelhetjük. Ehhez az SQLite honlapjáról töltsük le a sqlite-tools-win32-x86-3340100.zip állományt vagy a neki megfelelő Linux-os vagy Mac-es zip-et! Benne megtalálhatjuk az sqlite3 binárist, melynek mappáját a kicsomagolás után adjuk hozzá a PATH környezeti változóhoz a könnyebb kezelhetőség végett. Ezután próbáljuk ki a command-line tool-t:

1
2
3
4
5
6
7
C:\>sqlite3

SQLite version 3.34.1 2021-01-20 14:10:07
Enter ".help" for usage hints.
Connected to a transient in-memory database.
Use ".open FILENAME" to reopen on a persistent database.
sqlite>

Az alap parancs kiadásakor egy in-memory adatbázis jön létre, azaz amikor kilépünk az sqlite3-ból, akkor elvész az összes addigi adat. Látható, hogy a .open FILENAME parancs kiadásával használhatunk egy fájlt, mint adatbázist, melyben az adatok perzisztensek lesznek, azaz megmaradnak a leállítás után is, hiszen azt a lemezen tartósan tároljuk. A .help parancs kiadásával többet is megtudhatunk a command-line eszköz használatáról. Egy igen hasznos parancs például a .read FILENAME, mellyel külső fájlban megadott SQL parancsokat futtathatunk (például egy ddl.sql fájlban megadjuk a DDL utasításokat, majd azokat a .read ddl.sql paranccsal le tudjuk futtatni).

A command-line tool használatát ki is válthatjuk, ha az IntelliJ-n belül a jobb felső sarokban a Database fülre navigálunk.

intellij database

Ezután a Database fülön a bal felső sarokban szereplő + jelre kattintva egy új adatbázist adathatunk hozzá. Ehhez válasszuk a lenyíló menüben a Data Source -> SQLite menüpontot! A Data Source-nak adhatunk egy tetszőleges nevet, majd a General beállításoknál a fájl alatt megadhatjuk, hogy melyik létező adatbázisfájlból dolgozunk. Amennyiben új állományt szeretnénk használni, akkor File megadása sor végén válasszuk a + jelet és adjuk meg az adatbázis fájl helyét. Az URL-t a JDBC-n belül is használni fogjuk majd, mivel ezzel az URL-el tudunk kapcsolatot létesíteni az adatbázis felé. Amennyiben az IntelliJ-n belül nincs még letöltve az SQLite Driver, akkor Properties lapon beállíthatjuk (utólag jobb klikk az adatbázis kapcsolatra és Properties menüpont kiválasztása). Ezután megjelenik a megfelelő nevű adatbázis a listában, amelyet lenyitva megtekinthetjük az adatbázishoz tartozó sémákat. Itt a main-re jobb klikk után New -> Table menüpontot választva létrehozhatjuk a kívánt táblákat (később egy táblára jobb klikk, majd Modify Table opcióval módosíthatjuk annak összetételét).

intellij database - modify table

Miután létrehoztuk a megfelelő táblákat jobb klikk a main sémára, majd SQL Scripts -> Generate DDL to Query Console. Itt láthatjuk a legenerált DDL utasításokat, mely így néz ki:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
create table CONTACT
(
    id INTEGER not null
        constraint CONTACT_pk
            primary key autoincrement,
    name text not null,
    email text not null,
    address text,
    dateOfBirth text not null,
    company text,
    position text
);

create unique index CONTACT_email_uindex
    on CONTACT (email);

create table PHONE
(
    id integer not null
        constraint PHONE_pk
            primary key autoincrement,
    number text not null,
    phoneType integer not null,
    contact_id int
        references CONTACT
);

Mivel az adatbázis elérését többször is használni kívánjuk majd, így célszerű ezt az értéket kiszerveznünk egy állományba. Hozzunk létre a contacts-core/resources alá egy application.properties állományt majd adjuk meg benne a következőt:

1
db.url=jdbc:sqlite:C:/projects/contacts/contacts-core/src/main/resources/contacts.db

Az URL-t mindenki módosítsa a saját környezetének megfelelően! Ezután készítsünk egy segédosztályt, mely beolvassa ezt a properties állományt, melyben a kulcs-érték párokat adjuk meg (itt bármilyen tetszőleges párokat felvehetünk, amit majd később használni szeretnénk). Erre a célra készítsünk a hu.alkfejl.config csomag alá egy ContactConfiguration nevű osztályt, melynek a tartalma a következő:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ContactConfiguration {

    private static Properties props = new Properties();

    static{
        try {
            props.load(ContactConfiguration.class.getResourceAsStream("/application.properties"));
        } catch (IOException e) {
            // TODO: logging
            e.printStackTrace();
        }

    }

    public static Properties getProps() {
        return props;
    }

    public static String getValue(String key) {
        return props.getProperty(key);
    }
}

Mivel egy statikus metódusokat kínáló osztályról lesz szó, így a statikus init blokkban adjuk meg a properties fájl betöltését. Ezután a property-ket eltároljuk egy lokális változóban, melyeket a getValue segítségével érhetünk el.

Ezután végre elkészíthetjük a DAO megvalósítását. Először nézzük a field-eket és a findAll metódust!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class ContactDAOImpl implements ContactDAO{

    // SQL Statements
    private static final String SELECT_ALL_CONTACTS = "SELECT * FROM CONTACT";
    private static final String INSERT_CONTACT = "INSERT INTO CONTACT (name, email, address, dateOfBirth, company, position) VALUES (?,?,?,?,?,?)";
    private static final String UPDATE_CONTACT = "UPDATE CONTACT SET name=?, email = ?, address = ?, dateOfBirth=?, company=?, position = ? WHERE id=?";
    private static final String DELETE_CONTACT = "DELETE FROM CONTACT WHERE id = ?";
    private String connectionURL;

    public ContactDAOImpl() {
        connectionURL = ContactConfiguration.getValue("db.url"); // obtaining DB URL
    }

    @Override
    public List<Contact> findAll() {

        List<Contact> result = new ArrayList<>();

        try(Connection c = DriverManager.getConnection(connectionURL);
            Statement stmt = c.createStatement();
            ResultSet rs = stmt.executeQuery(SELECT_ALL_CONTACTS)
        ){

            while(rs.next()){
                Contact contact = new Contact();
                contact.setId(rs.getInt("id"));
                contact.setName(rs.getString("name"));
                contact.setEmail(rs.getString("email"));
                contact.setAddress(rs.getString("address"));
                Date date = Date.valueOf(rs.getString("dateOfBirth"));
                contact.setDateOfBirth(date == null ? LocalDate.now() : date.toLocalDate());
                contact.setCompany(rs.getString("company"));
                contact.setPosition(rs.getString("position"));

                result.add(contact);
            }

        } catch (SQLException throwables) {
            throwables.printStackTrace();
        }

        return result;

    }
}

Elsőként létrehozzuk a kapcsolatot az adatbázis felé, melyet a JDBC DriverManager osztály getConnection factory metódusával tudunk megtenni. Mivel az összes kontakt lekéréshez nincs szükség paraméterre, így egy sima Statement-et használunk. Az összes lekérdező SQL utasításhoz használjuk az executeQuery metódust, mely az eredményt egy ResultSet objektumban adja vissza. Ezen osztályok mind implementálják az AutoClosable interfészt, így használhatóak a try-with-resources konstrukcióval.

Ezután a ResultSet-en végiglépegetünk a next() hívással, mely mindaddig igazat ad vissza ameddig van még sor az eredmény objketumban. A ciklusmagban minden alkalommal létrehozunk egy új Contact objektumot, majd beállítjuk a megfelelő field értékeit, melyeket az getXXX metódussal olvasunk ki a ResultSet objektumból, ahol XXX valamilyen alaptípus. Mivel az SQLite nem tud dátumot tárolni, így azt String-ként adtuk meg (TEXT típusú oszlop az adatbázis sémában), így ilyen módon is olvassuk ki. Ezt egy Date objektummá alakítjuk majd a toLocalDate() hívással alakítjuk LocalDate típusúra.

A következő kódrészlet a hozzáadás/mentés opciót szolgáltatja számunkra.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Override
public Contact save(Contact contact) {
  try(Connection c = DriverManager.getConnection(connectionURL);
      PreparedStatement stmt = contact.getId() <= 0 ? c.prepareStatement(INSERT_CONTACT, Statement.RETURN_GENERATED_KEYS) : c.prepareStatement(UPDATE_CONTACT)
  ){
      if(contact.getId() > 0){ // UPDATE
          stmt.setInt(7, contact.getId());
      }

      stmt.setString(1, contact.getName());
      stmt.setString(2, contact.getEmail());
      stmt.setString(3, contact.getAddress());
      stmt.setString(4, contact.getDateOfBirth().toString());
      stmt.setString(5, contact.getCompany());
      stmt.setString(6, contact.getPosition());

      int affectedRows = stmt.executeUpdate();
      if(affectedRows == 0){
          return null;
      }

      if(contact.getId() <= 0){ // INSERT
          ResultSet genKeys = stmt.getGeneratedKeys();
          if(genKeys.next()){
              contact.setId(genKeys.getInt(1));
          }
      }

  } catch (SQLException throwables) {
      throwables.printStackTrace();
      return null;
  }

  return contact;
}

Az első érdekes dolog, hogy itt egy paraméteres lekérdezésünk lesz, így egy PreparedStatement-et használunk. Ugyanakkor, dinamikusan döntjük el, hogy egy meglévő rekord frissítéséről vagy egy új sor beszúrásáról van szó. Ezt úgy döntjük el, hogy megvizsgáljuk a megkapott kontakt id-ját, mely egy 0-nál nagyobb egész érték abban az esetben, ha az már létezik és 0, amennyiben az még nem szerepel az adatbázisban.

A következő érdekes momentum, hogy új kontakt létrehozásakor a c.prepareStatement(INSERT_CONTACT, Statement.RETURN_GENERATED_KEYS) lekérdezést adjuk meg, melyben vegyük észre a RETURN_GENERATED_KEYS megadást. Ennek segítségével az executeUpdate hívás után a ResultSet-ben elérhetővé válik az összes generált oszlop értéke, azaz például az id, mely egy generált érték lekérhetővé válik, melyet be is állítunk a mentett kontakt számár, majd ezt az objektumot adjuk vissza. Az executeUpdate metódus azt adja vissza, hogy hány sor módosult az SQL utasítás hatására, mely esetünkben 1 kell hogy legyen mind a beszúrás és mind a frissítés esetében is.

Végül lássuk a törlést, melyben sok újdonságot nem látunk, kivéve azt, hogy itt nem használjuk fel az executeUpdate eredményét.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Override
public void delete(Contact contact) {

    try(Connection c = DriverManager.getConnection(connectionURL);
        PreparedStatement stmt = c.prepareStatement(DELETE_CONTACT);
    ) {
        stmt.setInt(1, contact.getId());
        stmt.executeUpdate();

    } catch (SQLException throwables) {
        throwables.printStackTrace();
    }

}

Ha megvagyunk a kontaktok kezelésével, akkor érdemes elkészíteni a PhoneDAO-t is, mely a következőképpen néz ki:

1
2
3
4
5
6
7
public interface PhoneDAO {

    List<Phone> findAllByContactId(Contact contact);
    List<Phone> findAllByContactId(int contactId);
    Phone save(Phone p, int contactId);
    void delete(Phone p);
}

Látható, hogy szeretnénk majd kontakt alapján visszakapni a hozzátartozó telefonszámokat, egy kontakthoz létrehozni egy telefonszámot, illetve törölni egy megadott telefonszámot.

Ezután készítsük el a konkrét implementációt, melyben a findAllByContactId metódus a következőképpen néz ki:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class PhoneDAOImpl implements PhoneDAO{

    private static final String SELECT_PHONES_BY_CONTACT_ID = "SELECT * FROM PHONE WHERE contact_id=?";
    private static final String INSERT_PHONE = "INSERT INTO PHONE (number, phoneType, contact_id) VALUES (?,?,?)";
    private static final String UPDATE_PHONE = "UPDATE PHONE SET number = ?, phoneType = ? WHERE id = ?";
    private static final String DELETE_PHONE = "DELETE FROM PHONE WHERE id = ?";
    private String connectionUrl;

    public PhoneDAOImpl() {
        this.connectionUrl = ContactConfiguration.getValue("db.url");
    }

    @Override
    public List<Phone> findAllByContactId(Contact contact) { // convenience method
        return findAllByContactId(contact.getId());
    }

    @Override
    public List<Phone> findAllByContactId(int contactId) {
        List<Phone> result = new ArrayList<>();

        try(Connection c = DriverManager.getConnection(connectionUrl);
            PreparedStatement statement = c.prepareStatement(SELECT_PHONES_BY_CONTACT_ID)){

            statement.setInt(1, contactId);
            ResultSet rs = statement.executeQuery();
            while(rs.next()){
                Phone phone = new Phone();
                phone.setId(rs.getInt("id"));
                phone.setNumber(rs.getString("number"));
                int ordinalValue = rs.getInt("phoneType");

                Optional<Phone.PhoneType> pt = Arrays.stream(Phone.PhoneType.values()).filter(phoneType -> phoneType.ordinal() == ordinalValue).findAny();
                phone.setPhoneType(pt.orElse(Phone.PhoneType.UNKNOWN));

                result.add(phone);

            }

        } catch (SQLException throwables) {
            throwables.printStackTrace();
        }

        return result;
    }

Alapvetően nincs a megvalósításban semmilyen extra, de nézzük meg, hogy az enum értékét hogyan tároljuk, hiszen ilyet még eddig nem csináltunk. A PhoneType attribútumot az adatbázisban egy egész értékként tároljuk (megjegyzem, hogy tárolhatnánk String-ként is), így amikor azt kinyerjük akkor a PhoneType enum összes lehetséges értékén (Phone.PhoneType.values()) végigiterálva (Stream API segítségével), megkeressük azt amelyiknek megegyezik az egész értéke (azaz az ordinal értéke) az adatbázisban megadott számmal. Amennyiben nem talált ilyet a filterezésünk, úgy az UNKNOWN enum értéket állítjuk be a létrehozott Phone objektum PhoneType attribútumaként.

Az alábbi kódrészlet, melyben a mentést (új rekord vagy rekord update) végezzük szintén nincs túl nagy újdonság, ugyanakkor figyeljünk arra, hogy a PhoneType-ot a megfelelő egész értékként az ordinal értékével mentsük!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Override
public Phone save(Phone phone, int contactId) {
    try(Connection c = DriverManager.getConnection(connectionUrl);
        PreparedStatement stmt = phone.getId() <= 0 ? c.prepareStatement(INSERT_PHONE, Statement.RETURN_GENERATED_KEYS) : c.prepareStatement(UPDATE_PHONE)
    ){
        if(phone.getId() > 0){ // UPDATE
            stmt.setInt(3, phone.getId());
        }else{ //INSERT
            stmt.setInt(3, contactId);
        }

        stmt.setString(1, phone.getNumber());
        stmt.setInt(2, phone.getPhoneType().ordinal());

        int affectedRows = stmt.executeUpdate();
        if(affectedRows == 0){
            return null;
        }

        if(phone.getId() <= 0){ // INSERT
            ResultSet genKeys = stmt.getGeneratedKeys();
            if(genKeys.next()){
                phone.setId(genKeys.getInt(1));
            }
        }

    } catch (SQLException throwables) {
        throwables.printStackTrace();
        return null;
    }

    return phone;
}

Végezetül a törlést láthatjuk, mely nagyon hasonló a kontaktnál látottakkal:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Override
public void delete(Phone phone) {
    try(Connection c = DriverManager.getConnection(connectionUrl);
        PreparedStatement stmt = c.prepareStatement(DELETE_PHONE);
    ) {
        stmt.setInt(1, phone.getId());
        stmt.executeUpdate();

    } catch (SQLException throwables) {
        throwables.printStackTrace();
    }
}

View réteg

A megjelenítési réteg jelen esetben egy másik submodule-ban fog helyet kapni, így készítsük is el az előzőeknek megfelelően az új submodule contacts-desktop néven! A létrehozáskor a már megismert javafx-maven-archetype-ot adjuk meg és állítsuk a parent-et szintén a contacts-ra. A property-ket a parent pom-ból kapjuk, így a submodule pom.xml-ben található property-ket kitörölhetjük.

Ezután a próbáljuk ki, hogy a contacts-desktop modulból elérjük-e a Contact osztályt! Elsőre nyilván nem, viszont Alt + Enter esetén fel is kínálja a rendszer, hogy Add dependency on module 'contacts-core'. Ekkor a contacts-desktop/pom.xml-be belekerül a következő:

1
2
3
4
5
6
7
8
...
<dependency>
    <groupId>hu.alkfejl</groupId>
    <artifactId>contacts-core</artifactId>
    <version>1.0-SNAPSHOT</version>
    <scope>compile</scope>
</dependency>
...

, amely megadja a kapcsolatot a két modul között. Ezután, ha buildeljük a parent project-et (mvn install), majd elindítjuk a desktop alkalmazásunkat a javafx:run plugin goal-al, akkor mindennek megfelelően működnie kell. Fontos, hogy az mvn install-t használjuk, hiszen így a legyártott contacts-core JAR állomány belekerül a lokális Maven repository-ba, ahonnan már tudja használni a contacts-desktop alkalmazás.

Ezután a contacts-desktop alkalmazásban a resources alatt hozzunk létre egy fxml könyvtárat, melyben az FXML állományainkat fogjuk tárolni! Hozzunk is létre egy main_window.fxml állományt ide! Az FXML állományokhoz controller-eket is fogunk használni, így ezeket a contacts-desktop-on belül a hu.alkfejl.controller package-ben fogjuk tárolni. Hozzuk ide létre a MainWindowController osztályt, majd adjuk meg ezt a controller-t az imént létrehozott FXML állománynak.

A SceneBuilder segítségével szerkesszük az állományt, melynek eredményeképpen a következő layout-ot kapjuk:

mvc_01

Hasonló eredmény eléréséhez figyeljük meg a bal oldali tree view-t! Amennyiben ez alapján nem sikerülne összerakni az alkalmazás fő ablakát, akkor segíthet az FXML kód tanulmányozása (melyben már láthatjuk a megadott fx:id-kat is):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.geometry.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<?import javafx.scene.text.*?>

<BorderPane prefHeight="400.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/11" xmlns:fx="http://javafx.com/fxml/1" fx:controller="hu.alkfejl.controller.MainWindowController">
   <top>
      <MenuBar BorderPane.alignment="CENTER">
        <menus>
          <Menu mnemonicParsing="false" text="File">
            <items>
              <MenuItem mnemonicParsing="false" text="Close" />
            </items>
          </Menu>
          <Menu mnemonicParsing="false" text="Edit">
            <items>
              <MenuItem mnemonicParsing="false" text="Add Contact" />
            </items>
          </Menu>
          <Menu mnemonicParsing="false" text="Help">
            <items>
              <MenuItem mnemonicParsing="false" text="About" />
            </items>
          </Menu>
        </menus>
      </MenuBar>
   </top>
   <center>
      <VBox prefHeight="200.0" prefWidth="100.0" BorderPane.alignment="CENTER">
         <children>
            <Label text="List of contacts">
               <font>
                  <Font name="System Bold" size="24.0" />
               </font>
               <VBox.margin>
                  <Insets bottom="10.0" right="10.0" top="10.0" />
               </VBox.margin>
            </Label>
            <TableView fx:id="contactTable" prefHeight="200.0" prefWidth="200.0">
              <columns>
                <TableColumn fx:id="nameColumn" prefWidth="179.0" text="Name" />
                <TableColumn fx:id="emailColumn" prefWidth="195.0" text="E-mail" />
                <TableColumn fx:id="actionsColumn" minWidth="0.0" prefWidth="124.0" text="Actions" />
              </columns>
            </TableView>
         </children>
      </VBox>
   </center>
   <left>
      <VBox prefHeight="200.0" prefWidth="100.0" BorderPane.alignment="CENTER" />
   </left>
</BorderPane>

Ezután készítsek el a MainWindowController állományt és töltsük be az adatbázisból a kontaktok listáját! Amennyiben nem a javafx-archetype-fxml-el hoztunk létre a submodule-t, akkor ne felejtsük el hozzáadni a következő függőséget!

1
2
3
4
5
<dependency>
    <groupId>org.openjfx</groupId>
    <artifactId>javafx-fxml</artifactId>
    <version>11</version>
</dependency>

A fenti függőség szükséges lesz a control elemek injektálásához (@FXML), illetve az Initializable interface használatához.

A táblázat értékekkel való feltöltéséhez a következő field injektálásokra lesz szükségünk, illetve magára a ContactDAO-ra:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class MainWindowController implements Initializable {

    ContactDAO dao = new ContactDAOImpl();

    @FXML
    private TableView<Contact> contactTable;
    @FXML
    private TableColumn<Contact, String> nameColumn;
    @FXML
    private TableColumn<Contact, String> emailColumn;
    ...
}

A TableView<Contact> típusnál a generikus paraméterben azt adhatjuk meg, hogy milyen típusú elemeket tartalmaz a táblázat. A TableColumn<Contact, String> megadásoknál a második típusparaméter az aktuális oszlopban megjelenített elem típusa, míg az első mindig megegyezik a TableView-nak megadott generikus paraméterrel.

Ezután az initialize metódus és a refreshTable a következőképpen néz ki:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
private void refreshTable() {
    contactTable.getItems().setAll(dao.findAll());
}

@Override
public void initialize(URL location, ResourceBundle resources) {
    refreshTable();

    nameColumn.setCellValueFactory(new PropertyValueFactory<>("name"));
    emailColumn.setCellValueFactory(new PropertyValueFactory<>("email"));
}

Mivel több helyen is szükség lehet majd a táblázat frissítésére, így ezen műveletet a refreshTable metódusban adjuk meg. A táblázathoz hozzárendeltük az adatokat, amik a sorokat adják, viszont a Contact objektumok fieldjeit le kell képezni a táblázat oszlopaihoz. Ezt a célt szolgálja a setCellValueFactory hívás a fenti kódban, mely a cellák értékét adja meg. Alapvetően a következőképpen lehet használni:

1
2
3
4
5
6
nameColumn.setCellValueFactory(new Callback<CellDataFeatures<Contact, String>, ObservableValue<String>>() {
  public ObservableValue<String> call(CellDataFeatures<Contact, String> c) {
      // p.getValue() returns the Contact instance for a particular TableView row
      return c.getValue().nameProperty();
  }
});

Itt elevenítsük fel, hogy a Callback első generikus paramétere a callback metódus paraméterének típusát adja meg (call(CellDataFeatures<Contact, String> c)), míg a második a visszatérési típusát (ObservableValue<String>). A CellDataFeatures egy wrapper osztály a cellákhoz, hogy minden kapcsolódó adatot elérhessünk az adott TableColumn megadásnál. Például a cellához tartozó Contact objektumot a c.getValue() hívással kaphatjuk meg, de hozzáférhetünk magához a TableColumn és a TableView objektumokhoz is. A fenti példakódban a nameProperty-t adjuk vissza, mely lévén egy StringProperty így ObservableValue<String> is egyben. Mivel sokszor van szükség arra, hogy egy bean property-jét használjuk az adott oszlopban, így erre a JavaFX biztosít egy külön megvalósítást, mely a PropertyValueFactory. A fenti kódrészlet megfelelője, így a nameColumn.setCellValueFactory(new PropertyValueFactory<>("name")); sor.

Mielőtt belemennénk az actionColumn rejtelmeibe, töltsük be az FXML-t az alkalmazás fő belépési pontján. Mivel több helyen is használhatunk ilyen jellegű betöltést, így ezt egy statikus metódusba szervezzük ki, mely az FXML állomány URL-jét várja.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class App extends Application {

    private static Stage stage;

    @Override
    public void start(Stage stage) {
        App.stage = stage;
        App.loadFXML("/fxml/main_window.fxml");
        stage.show();
    }

    public static FXMLLoader loadFXML(String fxml){

        FXMLLoader loader = new FXMLLoader(App.class.getResource(fxml));
        Scene scene = null;
        try {
            Parent root = loader.load();
            scene = new Scene(root);
        } catch (IOException e) {
            e.printStackTrace();
        }

        stage.setScene(scene);
        return loader;
    }

    public static void main(String[] args) {
        launch();
    }
}

A start metódusban elmentjük a kapott stage-et, majd meghívjuk a loadFXML metódust. Fontos, hogy visszaadjuk az FXMLLoader objektumot, amit jelen esetben nem használunk ugyan fel, de később még jól jöhet olyan esetben, amikor szükségünk lehet a controller-re.

Ezen a ponton próbáljuk ki, hogy megjelenik-e a táblázatban a megfelelő adat! Ne felejtsünk el az adatbázishoz hozzáadni adatokat, melyet az IDE-n belül könnyen megtehetünk, ha a CONTACT táblára kattintunk a Database alatt! A megjelenő adatok felett a + jelre kattintva egy új sort adhatunk a táblához. Miután megadtuk a szükséges adatokat, ne felejtsük el commit-álni a változtatásokat a DB submit gomb megnyomásával. Az adatbázis mellett ellenőrizzük azt is, hogy az FXML-ben az fx:id megadások szerepelnek-e!

Ezután adjuk meg a műveleteket megjelenítő oszlopot is (actionsColumn)! Elsőként injektáljuk ezt a controllerbe, illetve adjuk meg a megfelelő helyen az FXML-ben is az fx:id-t!

1
2
@FXML
private TableColumn<Contact, Void> actionsColumn;

Az első dolog, hogy a TableColumn második generikus paraméterében a Void típust adjuk meg, hiszen ez az oszlop nem egy konkrét property-hez kapcsolódik, így az érték típusát sem tudjuk megadni, ezért ez Void lesz. Ennek következményeképpen nem is adunk meg cellValueFactory-t, hiszen a cella értéke Void. Ugyanakkor ez számunkra nem is probléma, mert csak gombokat szeretnénk elhelyezni ebben az oszlopban. Azt, hogy az adott cellában mi jelenjen meg (a renderelés szempontjából megközelítve), azt a cellFactory határozza meg. Ennek függvényében az initialize(...) metódust a következőképpen egészítsük ki:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
actionsColumn.setCellFactory(param -> new TableCell<>(){
  private final Button deleteBtn = new Button("Delete");
  private final Button editBtn = new Button("Edit");

  {
      deleteBtn.setOnAction(event -> {
          Contact c = getTableRow().getItem();
          deleteContact(c);
          refreshTable();
      });

      editBtn.setOnAction(event -> {
          Contact c = getTableRow().getItem();
          editContact(c);
          refreshTable();
      });
  }

  @Override
  protected void updateItem(Void item, boolean empty) {
      super.updateItem(item, empty);
      if(empty){
          setGraphic(null);
      }
      else{
          HBox container = new HBox();
          container.getChildren().addAll(editBtn, deleteBtn);
          container.setSpacing(10.0);
          setGraphic(container);
      }
  }
});

A setCellFactory paraméterében egy Callback <TableColumn<S,​T>,​TableCell<S,​T>> value paramétert vár, melyet lambda kifejezés formájában meg is adunk. Ebből ugye látszik, hogy egy ​TableCell<S,​T> típusú elemet kell visszaadnunk, melyet meg is adunk úgy, hogy közben a TableCell osztályt kiterjesztjük (hasonlóan az anonymous inner class-okhoz, de itt osztályra alkalmazzunk, interface helyett). A kiterjesztésben két Button field-et adunk hozzá, illetve az init blokkban (mivel a leszármaztatott osztálynak nincs neve itt) megadjuk ezen gombok működését is. Egy TableCell objektum képes lekérni a sort amihez tartozik (getTableRow), mely sorhoz tartozó elemet a getItem-el kérhetünk le, amelynek típusa megegyezik a TableCell<S,T> S paraméterével, jelen esetben a Contact-al. A deleteContact és editContact metódusokat azonnal látjuk, de előtte fókuszáljunk az updateItem metódus felülírására. Az updateItem-et soha ne hívjuk meg manuálisan (annak hívása a grafikus elem, jelen dolga), ugyanakkor, ha egy cella kinézetét szeretnénk személyre szabni, akkor ahhoz ezt a metódust a legcélszerűbb felüldefiniálni. Itt két szabály van:

  • Mindig hívjuk meg a super.updateItem(item, empty); metódusát
  • Mindig csekkoljuk az üres cellákat (előfordulhat, hogy egy sorban nincs semmilyen megjelenítendő elem) és ott állítsuk null-ra a hozzátartozó grafikát, már ha ilyen esetben tényleg nem akarunk semmit látni a cellában

A fentiek után egy HBox-ra egyszerűen elhelyezzük a két gombot, majd ezt a konténert rajzoltatjuk ki.

Miután a fentieket megértettük, lássuk a deleteContact metódust:

1
2
3
4
5
6
7
8
private void deleteContact(Contact c) {
    Alert confirm = new Alert(Alert.AlertType.CONFIRMATION, "Are you sure you want to delete contact: " + c.getName(), ButtonType.YES, ButtonType.NO);
    confirm.showAndWait().ifPresent(buttonType -> {
        if(buttonType.equals(ButtonType.YES)){
            dao.delete(c);
        }
    });
}

Mielőtt egyből kitörölnénk az adott kontaktot, megerősítést várunk a felhasználótól, melyet az Alert osztály használatával tudjuk megvalósítani.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Override
public void start(Stage stage) {
    FXMLLoader loader = new FXMLLoader(getClass().getResource("/fxml/main_window.fxml"));
    Scene scene = null;
    try {
        Parent parent = loader.load();
        scene = new Scene(parent);

    } catch (IOException e) {
        e.printStackTrace();
    }

    stage.setScene(scene);
    stage.show();
}

A következő gyakorlaton innen folytatjuk majd.

Videók

Referenciák


Utolsó frissítés: 2021-03-18 12:47:23