Java külső könyvtárak

Fő kategória: Java.

Ezen az oldalon olyan problémák megoldásaival foglalkozunk, amelyekhez külső könyvtárakra van szükség. A naplózásnál megadok egy részletes leírással ellátott teljes és működő példát, a többinél viszont terjedelmi okok miatt csak a különbséget jelzem.

Naplózás

Áttekintés

A naplózás (kapcsolódó fogalmak angolul log, logging, logfile; gyakran magyarul is logolásnak ill. logfájlnak hívjuk) azt jelenti, hogy a program futása során bizonyos üzeneteket kiírunk. Ennek számos oka lehet: egyrészt hogy lássuk, egyáltalán fut-e a program (sokkal megnyugtatóbb, ha futás során írogat ezt-azt, mintha csak a fekete képernyőt látnánk), vannak-e enne hibák, ill. szükség esetén jelentősen megkönnyíti a hibakeresést. A naplózást általában fájlba írjuk, és a legelső, amit egy hiba keresése során végrehajtunk az az, hogy megnézzük a naplófájlt. És tegyük azt is hozzá, hogy a hibák szinte természetes részei a szoftvernek, hibamentes szoftver nem létezik. Egy napló bejegyzés tartalma rendszertől függetlenül nagy vonalakban a következő:

  • időpont,
  • forrásfájl teljes elérési útvonallal, azon belül sor,
  • osztálynév, eljárás neve,
  • változók, paraméterek aktuális értéke,
  • többszálú programok esetén a szál azonosítója.

Ugyancsak rendszertől függetlenül az alábbi döntéseket kell meghoznunk:

  • Egészen pontosan miket írjon ki a fentiekből. Kell mind? Hell valami más is?
  • Mikor mit írjon ki. A naplózásnak vannak szintjei:
    • ERROR Lehet azt kérni, hogy csak a hibákat írja ki; azon belül is csak a fatális hibákat (FATAL, ami után a program megáll), vagy minden hibát.
    • INFO A hibákon túl bizonyos információkat is, pl. hogy elindult a program stb.
    • DEBUG Hibakereséshez kapcsolódó alap információkat is írjon ki, pl. egyes eljárások belépési pontjai, vagy bizonyos fontosabb változók pillanatnyi értékei.
    • TRACE Annyi információt írjon ki, amivel teljes nyomkövetést biztosít, beleértve akár a nagyobb struktúrák belsejét.
  • Mindezt konfigurálhatóvá szeretnénk tenni, pl. a fejlesztői vagy teszt környezetben több információt szeretnénk kiíratni, produkciós környezetben kevesebbet.
  • Egy idő után rájövünk, hogy érdemes lenne olyan szinten is konfigurálhatóvá tenni, hogy komponensenként meg tudjuk mondani, melyik mennyit naplózzon. Pl. egy hibakeresés kapcsán, reprodukáláskor az érintett komponensek esetén célszerű a nyomkövetést is bekapcsolni, a többi esetén viszont csak a normál hibakeresést, melynek oka az, hogy a kiírt adat mennyisége exponenciálisan növekszik a szintekkel, és a túl sok irreleváns adat gátolja a hibakeresést.
  • Tovább gondolva: azt is meg szeretnénk mondani, hogy fájlba írja-e a napló bejegyzéseket vagy konzolra, esetleg mindkettőre. Ill. azt is, hogy pontosan mit szeretnénk fájlba írni és mit konzolra, pl. konzolja csak alap információkat (elindult a program, hiba történt stb.), míg fájlba több mindent.

Van pár funkció, melyre egy méreten túl tetszőleges megvalósítás során gondolni kell:

  • A gyakorlat azt mutatja, hogy a naplófájlok igen nagyra tudnak hízni, ezt valahogyan kezelni kell. Pl. ha a fájl elér egy bizonyos méretet, akkor kezdjen úgy naplófájlt, a régieket automatikusan archiválja (a tömörítés naplófájlok esetén különösen hatékony!) vagy törölje.
  • Az úgynevezett flush mechanizmusra is gondolnunk kell, ami a következőt jelenti. A fájlba írás úgy általában drága művelet: meg kell nyitni a fájlt, majd miután fizikailag is belekerült az adat, le kell zárnunk. Ehelyett sokkal hatékonyabb az, ha a memóriában gyűjtjük az információt, majd egyszerre több mindent kiírunk. Igen ám, csakhogy ha váratlanul megáll a program, akkor az utolsó szelet (ami a memóriában van, de még nincs kimentve) elveszik, és hibakeresés szempontjából ez a leglényegesebb rész: mi történt pontosan akkor, ami a hibát kiváltotta. A naplózás során erre is gondolni kell.
  • A programozás során meg kell tudnunk adni minden lényeges információt, pl. adott esetben megfelelően formázott paramétereket.

Látható tehát, hogy teljesen általános problémák vetődnek fel, amelyek minden valamirevaló programnál jelentkeznek. Így jogosan feltételezhetjük, hogy az idők során erre kialakultak szabványok, és ez így igaz. A Java esetén létezik egy Java Logging API az 1.4-es verzió óta, ezt a gyakorlatban viszont szinte sohasem használjuk, hisz nekünk kell megírnunk hozzá a formázót és a kiírót is. Részletesen emiatt ezzel most nem foglalkozunk; jó cikkek születtek a témában, amit az alfejezet végén, az összefoglalónál megadok. Viszont fent láthattuk, hogy a megoldandó problémák is teljesen általánosak, így számomra teljesen érthetetlen, hogy egy olyan gazdag programozási nyelv esetén, mint a Java, miért nem lett a formázás és kiírás is szabvány része. Hosszú ideig kvázi-szabványnak volt tekinthető a log4j.

Egy "gyalogos" példa

Lássunk egy egyszerű példát, melyet a https://www.mkyong.com/logging/log4j-hello-world-example/ leírás alapján állítottam össze! Ehhez a példához - ahogy mindegyikhez ezen az oldalon - külső könyvtárra van szükség. Ezt elvileg számos forrásból beszerezhetjük: magától a fejlesztő cégtől; ha meg van a forrása, mi magunk is lefordíthatjuk; valamint léteznek olyan oldalak, ahol számos ilyen könyvtár megtalálható. (Ez utóbbit egyébként artifactory-knak hívjuk az artifact dictionay szavakból.) Az egyik legnépszerűbb ilyen gyűjtemény oldal a Maven Repository, ami elérhető a https://mvnrepository.com oldalon. Ezen az oldalon egyébként csak olyan könyvtárakról lesz szó, amelyek kellően általánosak ahhoz, hogy szerepeljenek ebben a gyűjteményben.

Egy ilyen könyvtár beazonosításához 3 dologra van szükség:

  • GroupID: ez általában a gyártót jelöli (de amúgy bármi lehet).
  • ArtifactID: ez maga a könyvtár.
  • Version: a pontos, általában 3 szintű verzió.

Jelen esetben az alábbit fogjuk használni:

  • GroupID: log4j
  • ArtifactID: log4j
  • Version: 1.2.17

Ezt kell tehát beszereznünk, jelen esetben célszerűen letöltenünk a Maven Repository oldalról, azon belül innen: https://mvnrepository.com/artifact/log4j/log4j/1.2.17. (Tipp: ha a Google keresőbe beírjuk a kulcsszavakat, jelen esetben pl azt, hogy "log4j jar" akkor szinte biztos, hogy ez lesz az első találat.) Ott kattintsunk a Files sorban található bundle-re (közvetlen link: https://repo1.maven.org/maven2/log4j/log4j/1.2.17/log4j-1.2.17.jar) és töltsük le. Ugyanabba a könyvtárba, ahova letöltöttük, valósítsuk meg a programot, pl. így:

import org.apache.log4j.Logger;
 
public class Main {
    private static Logger logger = Logger.getLogger(Main.class);
 
    public static void main(String[] args) {
        logger.info("Start of the main function.");
        logger.debug("This is a debug message");
    }
 
}

Az első sorban a használt osztály, mégpedig a org.apache.log4j.Logger importálása történik meg. (Ami egyébként valójában nem tölt be semmit, csak megjelöli a fordítónak, hogy hol található. Erről még később részletesebben lesz szó.) A naplózáshoz létrehozunk egy statikus Logger példányt az adott osztályt (nem objektumot!) átadva paraméterül, majd azon tudjuk meghívni a megfelelő eljárásokat, pl. a példában az info() vagy debug(). (Érdemes megnézni az eljárások listáját, melyet itt nem részletezek.) A példa két sort ír ki: az egyiket információ szinten, a másikat pedig hibakeresés szinten.

Azt, hogy ténylegesen hova írja a napló bejegyzéseket, és milyen formában, konfigurációs fájl segítségével adhatjuk meg. Hozzunk létre egy log4j.properties nevű szöveg fájlt ezzel a tartalommal:

# Root logger option
log4j.rootLogger=DEBUG, stdout, file

# Redirect log messages to console
log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.Target=System.out
log4j.appender.stdout.Threshold=INFO
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n

# Redirect log messages to a log file, support file rolling
log4j.appender.file=org.apache.log4j.RollingFileAppender
log4j.appender.file.File=logger-application.log
log4j.appender.file.MaxFileSize=5MB
log4j.appender.file.MaxBackupIndex=10
log4j.appender.file.layout=org.apache.log4j.PatternLayout
log4j.appender.file.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n

A konfiguráció értelmezése a következő:

  • Az első sorban alapértelmezettre teszi a DEBUG (hibakeresés) szintet, és definiál két célt: stdout és file.
  • A következő szakasz definiálja a stdout-ot:
    • konzolja írja,
    • felüldefiniálja a naplózási szintet (csak az INFO és magasabb prioritású bejegyzéseket írja ki, a DEBUG üzeneteket nem),
    • megadja a formáját: először a dátumot írja ki, majd a naplózási szintet (5 karakter szélesen, hogy függőlegesen igazítva legyen), utána a forrásfájl nevét, mast annak sornak a számát, ahol a naplóbejegyzés van, végül magát a bejegyzést.
  • A második nagy szakasz pedig a fájlba írást definiálja:
    • megadja, hogy olyan legyen a kiírási stratégia, hogy felülírja legrégebbit (RollingFileAppender),
    • megadja a fájl nevét,
    • beállítja, hogy a maximális fájlméret 5 MB lehet, és legfeljebb 10 naplófájlt ment el,
    • beállítja ugyanazt a formátumot.

Összefoglalva, ebben a pillanatban 3 fájunk van, ugyanabban a könyvtárban:

  • log4j-1.2.17.jar: ez definiálja a naplózáshoz szükséges osztályokat. Ilyen egyébként ezen az oldalon mindegyik program esetén lesz.
  • Main.java: ez a mi forrásunk.
  • log4j.properties: konfigurációs fájl. Ez specifikus a log4j-1.2.17.jar könyvtárra.

A következő lépés a fordítás. Ha megpróbáljuk a szokásos módon, javac Main.java paranccsal lefordítani, az alábbi hibaüzenetet kapjuk:

> javac Main.java
Main.java:1: error: package org.apache.log4j does not exist
import org.apache.log4j.Logger;
                       ^
Main.java:4: error: cannot find symbol
    private static Logger logger = Logger.getLogger(Main.class);
                   ^
  symbol:   class Logger
  location: class Main
Main.java:4: error: cannot find symbol
    private static Logger logger = Logger.getLogger(Main.class);
                                   ^
  symbol:   variable Logger
  location: class Main
3 errors

A fordításhoz meg kell adni azt, hogy melyik könyvtárban keresse a org.apache.log4j.Logger osztályt, amit az osztály elérési útvonal (classpath) megadásával tudjuk megtenni, a következőképpen:

javac -classpath log4j-1.2.17.jar Main.java

Ezt a következőképpen is rövidíthetjük:

javac -cp log4j-1.2.17.jar Main.java

Ha szokásosan próbáljuk futtatni (java Main), akkor is hibába ütközünk:

>java Main
Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/log4j/Logger
        at Main.<clinit>(Main.java:4)
Caused by: java.lang.ClassNotFoundException: org.apache.log4j.Logger
        at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:583)
        at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:178)
        at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:521)
        ... 1 more

Itt is meg kell adni az osztály elérési útvonalban a log4j-1.2.17.jar könyvtárat. Ha a fenti módon próbálkoznánk, akkor ismét hibába futunk bele (nem egyszerű!):

>java -cp log4j-1.2.17.jar Main
Error: Could not find or load main class Main
Caused by: java.lang.ClassNotFoundException: Main

A problémát az okozza, hogy az alapértelmezett osztály elérési útvonal tartalmazza az adott könyvtárat, amit a -cp paraméterrel teljesen felülírtunk. Így az adott könyvtárat is meg kell adni:

java -cp log4j-1.2.17.jar;. Main

Ez már jó, elindul, működik. Annyit még érdemes hozzátenni, hogy - miért is lenne egyszerű az élet? - Linux alatt kicsit másképp kell megadni, ott ugyanis az elválasztó karakter nem a pontosvessző (;), hanem a kettőspont (:). Linux alól tehát a fenti sort a következőképpen tudjuk futtatni:

java -cp log4j-1.2.17.jar:. Main

Apróságok ezek, de órákat, napokat el lehet tölteni azzal, hogy rájöjjünk, mi a probléma, ha nem értünk hozzá.

A futás eredménye egy egysoros bejegyzés a konzolon:

2019-09-30 08:53:25 INFO  Main:7 - Start of the main function.

Valamint létrejön egy logger-application.log nevű fájl az alábbi tartalommal:

2019-09-30 08:53:25 INFO  Main:7 - Start of the main function.
2019-09-30 08:53:25 DEBUG Main:8 - This is a debug message

Ha többször lefuttatjuk, akkor hozzáfűzi a naplófájlban a további bejegyzéseket, az elvárt módon.

Használhatóságban tehát igen éles a határ a csak standard könyvtárakat használó programok és a külső könyvtárakat is tartalmazó programok között. És ne feledjük: egy valós program tipikusan több tucat külső könyvtárat használ, melyek között függőségek lehetnek; akár több ezer forrásfájlt tartalmazhatnak, így áttekinthetetlenné bonyolódik a dolog. Erre is találtak ki megoldásokat, melyek kvázi-szabvánnyá nőtték ki magukat, melyek közül egyet bemutatok az alábbi alfejezetben.

A fenti példa Maven segítségével

Lássuk, hogyan lehet a fentieket valamelyest automatizálni! Erre számos módszert hoztak létre az idők folyamán, melyek közül néhányról szó van a fejlesztési eszközök oldalon is. Most a Java-ban legelterjedtebb Maven-t mutatom be. Töltsük le a szoftvert a https://maven.apache.org/ oldalról, csomagoljuk ki valahova, majd a bin könyvtárat adjuk hozzá a PATH környezeti változóhoz. Ellenőrizzük a működését a mvn -version paranccsal!

Első lépésben fel kell építenünk a Maven számára egy könyvtárszerkezetet!

  • src/: ide kerülnek a források.
    • main/: ide kerülnek a főprogram forrásai.
      • java/: ide kerülnek a Java források.
        • hu/faragocsaba/logging/: nem jó gyakorlat a gyökér csomagba helyezni a forrásokat, érdemes csomagszerkezetet kialakítani. Ha már egyébként is ki kell alakítanunk egy könyvtárszerkezetet, akkor ezen a ponton térünk át a javasolt gyakorlatra. A Java-ban a könyvtárszerkezetnek követnie kell a csomagszerkezetet. A csomaghierarchia elvileg bárhogy kinézhet, a valóságban viszont a gyártó weboldalának fordított címével kezdődik, majd a projekt nevével folytatódik, és ez tekinthető a projekt gyökerének. Tehát általánosságban a formátum com.mycompany.myproject. Ide helyezzük bele a fenti Main.java programot egyetlen módosítással: első sorként szúrjuk be a következőt: package hu.faragocsaba.logging;, majd a szokásos formázási gyakrolatnak megfelelően hagyjunk ki egy üres sort.
      • resources/: ide kerülnek a különböző erőforrás fájlok, pl. konfigurációs fájlok. Másoljuk ide a fenti log4j.properties fájlt!
    • test/: ide kerülnek az automatikus tesztek. Ebben a példában ez üresen marad, később ide is kerül tartalom.
      • java/: ide kerülnek az automatikus tesztek megvalósításai; most üresen marad.
      • resources/: teszt specifikus erőforrás osztályok. Pl. a fenti resources mutathat az éles környezetre, míg az itteni egy teszt környezetre. Ebben a példában üresen marad.
  • pom.xml: ez a Maven XML-formázott konfigurációs állománya, melyről részletesen lesz szó.

A pom.xml tartalma legyen az alábbi:

<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.faragocsaba</groupId>
    <artifactId>logging</artifactId>
    <version>1.0</version>

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

    <dependencies>
        <dependency>
            <groupId>log4j</groupId>
            <artifactId>log4j</artifactId>
            <version>1.2.17</version>
        </dependency>
    </dependencies>
</project>

A fájl felépítése:

  • A fejlécben megadtuk a névtereket és sémákat.
  • Ezt követően megadtuk az alapadatokat.
  • A tulajdonságok között beállítottuk a verziót. Ha ezt nem állítanánk be, akkor alapértelmezésben - kissé érthetetlen módon - az 1.5-öst választaná, amit viszont már nem támogat - no comment… És hogy ne legyen annyira egyszerű az élet: 1.10-ig kétféleképpen is megadhatjuk (1.x és x), a 11-estől az 1.11 már nem működik.
  • Itt jön a lényeg: megadtuk a függőséget.

Ebből a példából is látható egyébként, hogy recseg-ropog a Maven alatt a talaj: az XML önmagában túl bőbeszédű, egyre problémásabb, így várhatóan hamarosan a történelem szemétdobjára kerül, és helyét átveszi pl. a sokkal kompaktabb Gradle. Mindenesetre most még ezt fogjuk használni, és a továbbiakban csak a <dependency> részt fogom megadni, a többit vagy meg kell hagyni, vagy értelemszerűen módosítani (pl. artifactId).

Ha ezzel megvagyunk, adjuk ki a következő parancsot abból a könyvtárból, ahol a pom.xml található:

mvn clean install

Ez egyrészt törli a korábbi fordítás eredményeit (ami értelemszerűen az első futtatáskor még üres), másrészt végigviszi a teljes fordítási folyamatot. Először letölti a szükséges jar fájlokat. Ne ijedjünk meg, először sok mindent letölt, ugyanis magához a fordításhoz is szüksége van jar fájlokra, de ezt csak egyszer kell kivárni. Az ehhez a programhoz szükséges jar fájlt is letölti:

Downloading from central: https://repo.maven.apache.org/maven2/log4j/log4j/1.2.17/log4j-1.2.17.pom
Downloaded from central: https://repo.maven.apache.org/maven2/log4j/log4j/1.2.17/log4j-1.2.17.pom (22 kB at 87 kB/s)
Downloading from central: https://repo.maven.apache.org/maven2/log4j/log4j/1.2.17/log4j-1.2.17.jar
Downloaded from central: https://repo.maven.apache.org/maven2/log4j/log4j/1.2.17/log4j-1.2.17.jar (490 kB at 598 kB/s)

Az eredmény c:\Users\[username]\.m2\repository\ (ill. Linuxon a /home/[username]/.m2/repository/) könyvtárba kerül, a log4j-1.2.17.jar egészen pontosan ide: c:\Users\[username]\.m2\repository\log4j\log4j\1.2.17\log4j-1.2.17.jar. A fordítás eredménye a target/ könyvtárba kerül. Több minden kerül oda, számunkra most a legfontosabb a logging-1.0.jar.

A futtatás még eléggé macerás, ugyanis meg kell adnunk:

  • a keletkezett jar fájlt,
  • az összes felhasznált jar könyvtárat, valamint
  • a main() függvényt tartalmazó osztály teljes elérési útvonalát.

Tehát az indítása így néz ki:

java -cp target\logging-1.0.jar;c:\Users\[username]\.m2\repository\log4j\log4j\1.2.17\log4j-1.2.17.jar hu.faragocsaba.logging.Main

Futtatható jar készítése

Az indítás a fent megadott formában nem egyszerű! Mindig meg kell adni az összes könyvtár elérését, ami már egy közepes program esetén is áttekinthetetlen. A Maven-nek azonban vannak ún. beépülői, melynek segítéségével egyszerűsíthetünk a folyamaton. Ha a pom.xml tartalmát az alábbira változtatjuk, akkor egyrészt a keletkező jar fájlba beleteszi az összes szükséges osztályt, másrészt a fő függvényt tartalmazó osztály megadásával futtathatóvá tudjuk azt tenni:

<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.faragocsaba</groupId>
    <artifactId>logging</artifactId>
    <version>1.0</version>

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

    <dependencies>
        <dependency>
            <groupId>log4j</groupId>
            <artifactId>log4j</artifactId>
            <version>1.2.17</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-assembly-plugin</artifactId>
                <configuration>
                    <archive>
                        <manifest>
                            <mainClass>hu.faragocsaba.logging.Main</mainClass>
                        </manifest>
                    </archive>
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                </configuration>
                <executions>
                    <execution>
                        <id>make-assembly</id>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

Így meg a pom.xml kezdi kinőni az áttekinthetőség korlátait, de sajnos nem alapértelmezett ez a teljesen logikus fordítás. Ha a fenti módon lefordítjuk (mvn clean install), akkor a target/ könyvtárban két jar keletkezik: a fenti logging-1.0.jar, valamint egy másik, jóval nagyobb méretű: logging-1.0-jar-with-dependencies.jar. Ezt már a következőképpen tudjuk indítani:

java -jar target\logging-1.0-jar-with-dependencies.jar

Természetesen át lehet nevezni, át lehet másolni, hogy még egyszerűbb legyen. Valamint Windows operációs rendszer alatt már attól elindul, ha rákattintunk, mintha futtatható lenne, pl. Total Commanderben Entert nyomunk.

Program elkészítése Eclipse segítéségével

A fentieket el lehet készíteni kézzel, akármilyen szövegszerkesztővel, majd parancssorból fordíthatjuk és futtathatjuk, de ez a folyamat túl sok időt vesz igénybe. Ha idáig nem tettük meg, ezen a ponton mindenképpen érdemes elkezdenünk használni valamilyen integrált fejlesztői környezetet (Integrated Development Environment, IDE). Most az egyik legelterjedtebb ilyen környezetet mutatom be: az Eclipse-t. Töltsük le a programot innen: https://www.eclipse.org/, és állítsuk be a Fejlesztőeszközök oldalon leírtak szerint. Hozzunk létre egy új programot úgy, ahogy az a Java bevezető oldalon látható (File → New → Other… → Maven → Maven Project stb.). Az alábbi lépéseket kell végrehajtanunk:

  • pom.xml szerkesztése: akár egy az egyben lecserélhetjük a tartalmat a fentire; egyébként is, elég furcsán formáz az Eclipse.
  • Jobb kattintás az src/main/java könyvtáron → New → Package, itt adjuk meg a telje csomagot, pl. hu.faragocsaba.logging. A háttérben elkészül a megfelelő könyvtárstruktúra.
  • Jobb kattintás a hu.faragocsaba.logging csomagon → New → Class → itt adjuk meg az osztály nevét (Main) → megadhatjuk, hogy generálja le a main() függvényt → Finish. Majd másoljuk be a fenti megvalósítást.
  • Jobb kattintás az src/main/resources könyvtáron → New → Other → General → File (hogy mért rejtették el ennyire a sima fájl létrehozását…) → Next → a fájl neve legyen log4j.properties → Finish. Majd másoljuk be a fájl tartalmát fentről.
  • A háttérben automatikusan lefordítja; sőt, le is tölti a szükséges jar fájlokat.
  • Jobb kattintás a main() függvényen → Run as → Java Application. Ha mindent jól csináltunk, a program lefut.
  • Jobb kattintás magán a projektnéven (logging) → Refresh (kb. középen van). Ennek következtében frissíti a fájlstruktúrát, és megjelenik a logger-application.log fájl. Az Eclipse-ben meg tudjuk nézni a tartalmát.
  • A következőkben elég a fenti ikonok között a zöld körben jibbra mutató fehér háromszögre kattintani az indításhoz; a fordítást az IDE elvégzi.

Egyéb naplózó keretrendszerek

A log4j-n kívül van még pár naplózó keretrendszer:

Szöveges fájlok feldolgozása

Nem gondolnánk, hogy problémát okozhat a formázott szövegfájlok feldolgozása, vagy azok létrehozása, de már a legegyszerűbb esetekben is könnyen beletörik a bicskánk. A második meglepetés akkor ér minket, amikor rájövünk, hogy ezekre a valóban széles körben elterjedt problémákra a Java nem feltétlenül nyújt szabványos, beépített megoldást. Lássuk, mit lehet tenni!

CSV

XML

TODO: DOM, SAX, XPath, JAXB, Xerces

JSON

A JSON a JavaScript Object Notation rövidítése, és ahogy a nevéből is kikövetkeztethető, először a JavaScript objektumok szerializálására használták. Kulcs-érték párokból áll, ami az attribútumnak és az értékének felel meg, de gyűjteményeket is tárolhatunk segítségével. A JSON idővel kinőtte magát, mivel jóval általánosabb célokra is használható a JavaScripten túl. Az ember és a gép számára is jól olvasható, könnyen kiterjeszthető, viszonylag tömör. Jelenleg ez a számítógépek közötti adatcsere leggyakoribb formátuma azokan az esetekben is, ahol nincs JavaScript a kommunikációban.

A két legfontosabb művelet az alábbi:
- Objektum → JSON
- JSON → objektum

Ebben a szakaszban ezeket nézzük meg különböző módokon. Mindegyik megvalósítás ennél jóval többet tud, mellyel akkor érdemes megismerkedni, ha szükség van rá. Tipikus probléma egyébként a körkörös hivatkozás: ha az egyik objektum hivatkozik a másikra, és a másik az egyikre, akkor végtelenre nő a JSON mérete. Ezekben az esetekben le kell rögzíteni azt, hogy melyik oldal vezéreli a kapcsolatot.

Jackson

A Jackson az egyik legelterjedtebb JSON kezelő könyvtár, számos nagyobb rendszer használja. A pom.xml-be írjuk be a függőséget:

<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.faragocsaba</groupId>
    <artifactId>jacksonexample</artifactId>
    <version>1.0</version>

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

    <dependencies>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.10.1</version>
        </dependency>
    </dependencies>
</project>

Hozzuk létre azokat az osztályokat, amelyekből JSOn-t szeretnénk készíteni, ill. amikre szeretnénk betölteni a JSON-t. A példában személyek szerepelnek, akiknek lehetnek lakcímeik. Egy személynek egy lakcíme van, de ugyanazon a lakcímen többen is lakhatnak. Atipikus módon a lakcím fogja kezelni a kapcsolatot és azt fogjuk konvertálni, hogy jól lássuk a gyűjtemények konvertálását is.

src/main/java/data/Person.java:

package data;

import com.fasterxml.jackson.annotation.JsonBackReference;

public class Person {
    private String name;
    private int age;
    @JsonBackReference private Address address;

    public Person() {}

    public Person(String name, int age, Address address) {
        super();
        this.name = name;
        this.age = age;
        this.address = address;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public Address getAddress() {
        return address;
    }

    public void setAddress(Address address) {
        this.address = address;
    }

    @Override
    public String toString() {
        return "Person [name=" + name + ", age=" + age + "]";
    }
}

src/main/java/data/Address.java:

package data;

import java.util.ArrayList;
import java.util.List;

import com.fasterxml.jackson.annotation.JsonManagedReference;

public class Address {
    private String country;
    private String town;
    private String street;
    @JsonManagedReference private List<Person> persons = new ArrayList<Person>();

    public String getCountry() {
        return country;
    }

    public void setCountry(String country) {
        this.country = country;
    }

    public String getTown() {
        return town;
    }

    public void setTown(String town) {
        this.town = town;
    }

    public String getStreet() {
        return street;
    }

    public void setStreet(String street) {
        this.street = street;
    }

    public List<Person> getPersons() {
        return persons;
    }

    public void setPersons(List<Person> persons) {
        this.persons = persons;
    }

    public void addPerson(Person person) {
        persons.add(person);
    }

    @Override
    public String toString() {
        return "Address [country=" + country + ", town=" + town + ", street=" + street + ", persons=" + persons + "]";
    }
}

A példa, ami átkonvertálja az objektumok JSON-ba ill. fordítva:

src/main/java/logic/JacksonLogic.java:

package logic;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;

import data.Address;
import data.Person;

public class JacksonLogic {
    public static void objectToJson() {
        Address address = new Address();
        address.setCountry("Hungary");
        address.setTown("Budapest");
        address.setStreet("Pipacs utca 1");
        address.addPerson(new Person("Sanyi", 32, address));
        address.addPerson(new Person("Pista", 40, address));
        address.addPerson(new Person("Gyuri", 42, address));

        try {
            ObjectMapper mapper = new ObjectMapper();
            String objectAsJson = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(address);
            System.out.println(objectAsJson);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
    }

    public static void jsonToObject() {
        String jsonInString = "{\"country\":\"Hungary\",\"town\":\"Budapest\",\"street\":\"Pipacs utca 1\",\"persons\":[{\"name\":\"Sanyi\",\"age\":32},{\"name\":\"Pista\",\"age\":40},{\"name\":\"Gyuri\",\"age\":42}]}";
        try {
            ObjectMapper mapper = new ObjectMapper();
            Address address = mapper.readValue(jsonInString, Address.class);
            System.out.println(address);
        } catch (JsonMappingException e) {
            e.printStackTrace();
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
    }

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

Ha mindent jól csináltunk, akkor minkét konverzió rendben lefut, és a körkörös hivatkozás is megfelelően van kezelve.

Egyéb keretrendszerek

Noha a Jackson igen nagy népszerűségre tett szert, egyéb keretrendszerek is megjelentek, melyeket legalább felsorolás szintjén érdemes megemlíteni. Mivel a Jackson-nak is csak a felszínét érintettük, ahhoz is adok plusz forrásokat.

HTTP

Web szolgáltatások

A más számítógépek számára hálózaton keresztül nyújtott szolgáltatások összefoglaló neve web szolgáltatás (angolul web service). A megnevezés kissé megtévesztő, ugyanis létezik olyan konkrét megvalósítás, amit web service-nek nevezünk, de egyéb módszerek is, melyek szintén webes szolgáltatásnak tekinthetőek.

JAX-WS

Lássuk most a JAX-WS-t, a web szolgáltatások Java megvalósítását!

https://www.javatpoint.com/java-web-services-tutorial

TODO: folytatni

JAX-RS

A mai webes szolgáltatások túlnyomó többsége nem a fent részletezett, szűkebb értelembe vett web service megvalósítás, hanem az ún. REST lekérdezés: HTTP kérések, ahol a válasz tipikusan JSON formázottan érkezik. Noha a JAX-RS része a szabványnak, általában külső könyvtárak segítségével valósítjuk meg. Tesszük ezt annak ellenére, hogy valójában elviekben a szabvány Java eszközökkel is megtehetnénk: HTTP szervert és kliens is létre tudunk hozni, és a JSON formázást is valójában meg tudjuk oldani String műveletekkel, viszont az rendívül elbonyolítaná a kódot.

TODO: RESTEasy, Jersey

E-mail

Adatbázis kezelés

Adatok keletkeznek, és szeretnénk azokat tartósan eltárolni. Az adatokat adatbázisokban tároljuk, melyről az oldalamon is olvashatunk az Adatbázisok oldalon. Most azt nézzük meg, hogy mindezt hogyan tudjuk kezelni Java-ban. Nem véletlenül került ez a fejezet is ide: de facto szabványok kialakultak, de ezek nem részei az alap Java-nak. A példában MySQL adatbázist fogunk használni; az Adatbázisok oldalon leírtak szerint telepítsük fel, állítsuk be, hozzuk létre a teszt adatbázist a megadott táblákkal, és töltsük fel adatokkal.

JDBC

A JDBC a Java Database Connectivity (magyarul kb. Java adatbázis kapcsolat) rövidítése, így ebből már sejthető, hogy a Java-ból történő adatbázis elérés alapvető eleméről van szó. A JDBC gyártó specifikus. Nézzünk egy MySQL példát! Ebben az esetben a Maven repository-ból le tudjuk tölteni a JDBC meghajtót (ügyeljünk a megfelelő verzióra):

pom.xml:

<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.faragocsaba</groupId>
    <artifactId>dbexample</artifactId>
    <version>1.0</version>

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

    <dependencies>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.15</version>
        </dependency>
    </dependencies>
</project>

Az egyes adatbázis rendszerek esetén a következő JDBC-t kell használni:

Lássunk egy példát, ami tartalmaz beszúrást, lekérdezést és törlést!

src/main/java/dbexample/JdbcExample.java:

package dbexample;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

public class JdbcExample {
    public static void main(String[] args) {
        try (Connection connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/testdb?useSSL=false", "csaba", "farago")) {
            // insert
            PreparedStatement insertStatement = connection.prepareStatement("INSERT INTO person(id, name, age, addressid) VALUES (default, ?, ?, null)");
            insertStatement.setString(1, "Gyuri");
            insertStatement.setInt(2, 40);
            insertStatement.executeUpdate();

            // select
            Statement selectStatement = connection.createStatement();
            ResultSet resultSet = selectStatement.executeQuery("SELECT name, age FROM person");
            while (resultSet.next()) {
                String name = resultSet.getString("name");
                int age = resultSet.getInt("age");
                System.out.println(name + " (" + age + ")");
            }

            // delete
            PreparedStatement deleteStatement = connection.prepareStatement("DELETE FROM person WHERE name = ?");
            deleteStatement.setString(1, "Gyuri");
            deleteStatement.executeUpdate();
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

Ezzel a megoldással több probléma is van:

  • Kivétel kivétel hátán! A fenti példában a try-with-resources módszert alkalmazva ez nem látszódik, de a kapcsolat zárása is kivételt dob, még azt is le kell kezelni.
  • Az SQL parancsok szövegként "be vannak drótozva": egy esetleges elgépelés csak futási időben derül ki.
  • Nincs megoldva a relációs adattábla és az objektumorientált osztályszerkezet közötti átjárás. Igazából ebben a formában eléggé macerás az adatok kezelése.
  • A fenti példán ugyan nem látszik, de bonyolultabb esetekben lehetnek különbségek két adatbázis gyártó lekérdezései között.
  • Korábban be kellett tölteni a meghajtót a következő módon: Class.forName("com.mysql.jdbc.Driver");. Néhány leírás még tartalmazza ezt az utasítást. Benne lehet, ártani nem árt (max. annyit, hogy ez is kiválthat ellenőrzött kivételt, amit kezelni kell), de ma már nincs erre szükség.
  • A csatlakozást leíró szöveg tartalmazza ezt: ?useSSL=false. A tapasztalatom szerint enélkül rendben lefutott, a végén viszont kivételt dobott a lecsatlakozáskor.

E problémák kezelésére szintén szabványnak tekinthető megoldások születtek.

JPA és ORM

A JPA a Java Persistence API rövidítése, az ORM pedig a Object-Relational Mapping-é (objektum-relációs leképezés). Fent láthattuk, hogy a "gyalog" módszerrel eléggé nehézkes kezelni az adatokat. Az ORM létrehozza a kapcsolatot az objektumorientált programozási nyelvben (jelen esetben a Java-ban) jelen levő osztályok ill. objektumok, valamint a relációs adatmodellben levő adattáblák ill. azok tartalma között a kapcsolatot. Ez többek között a következő megfeleltetéseket jelenti (relációs adatbázis ↔ objektumorientált nyelv):

  • adattábla ↔ osztály
  • oszlop ↔ attribútum
  • sor ↔ objektum
  • a másik tábla kulcsa (foreign key) ↔ referencia
  • 1-1 kapcsolat ↔ egymásra hivatkozás
  • 1-n kapcsolat ↔ az 1 oldalról valamilyen gyűjteményt használhatunk
  • m-n kapcsolat ↔ mindkettő egy-egy gyűjteményben hivatkozik a másikra, így nem kell külön osztályként kezelni a kapcsolótáblát

A JPA maga a szabvány. A Java világban a legnépszerűbb megvalósítás a Hibernate, azt fogjuk most megnézni. Ez egy adatbázis független felületet nyújt a programozó felé, azaz elvileg ugyanúgy kell megvalósítani a dolgokat minden esetben, a mögöttes adatbázisttól függetlenül.

Feltételezzük, hogy az Adatbázisok oldalon leírtak szerint van beállítva a person és address. A pom.xml-ben benne kell hagyni a JDBC meghajtót, és hozzá kell tenni a hibernate-core megfelelő változatát:

<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.faragocsaba</groupId>
    <artifactId>ormexample</artifactId>
    <version>1.0</version>

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

    <dependencies>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.15</version>
        </dependency>
        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-core</artifactId>
            <version>5.4.8.Final</version>
        </dependency>
    </dependencies>
</project>

Létre kell hozni egy perzisztencia leíró fájlt, melyben beállítjuk az adatbázis kapcsolatot (src/main/resources/META-INF/persistence.xml):

<?xml version="1.0" encoding="UTF-8" ?>
<persistence xmlns="http://java.sun.com/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd"
             version="2.0">
    <persistence-unit name="hu.faragocsaba.ormexample">
        <description>Hibernate ORM Example</description>
        <class>ormexample.entity.Person</class> 
        <class>ormexample.entity.Address</class> 
        <exclude-unlisted-classes>true</exclude-unlisted-classes>
        <properties>
            <property name="hibernate.dialect" value="org.hibernate.dialect.MySQL8Dialect"/>
            <property name="hibernate.hbm2ddl.auto" value="validate"/>
            <property name="javax.persistence.jdbc.driver" value="com.mysql.jdbc.Driver"/>
            <property name="javax.persistence.jdbc.url" value="jdbc:mysql://127.0.0.1:3306/testdb"/>
            <property name="javax.persistence.jdbc.user" value="csaba"/>
            <property name="javax.persistence.jdbc.password" value="farago"/>
        </properties>
    </persistence-unit>
</persistence>

Némi magyarázat:

  • A hu.faragocsaba.ormexample az a név, amivel hivatkozunk erre a kódból.
  • A <class> rész adja meg, hogy hol találhatóak az entitások megvalósításai; ezekről lesz szó később.
  • A properties részben megadjuk az adatkapcsolat részleteit: azt, hogy milyen adatbázist használunk (MySQL), hol található, hogyan érhetjük el.
  • A hibernate.hbm2ddl.auto azt mondja meg, hogy mit kezdjen a Hibernate az adatmodellel. A validate azt jelenti, hogy ellenőrzi, megfelelő-e, de nem módosítja. Egyéb lehetőségek: update: módosítja a sémát; create: létrehozza azt (törli a korábbit); create-drop: az elején létrehozza, a végén törli. A legtöbb esetben a sémát a programtól függetlenül hozzuk létre, így legtöbbször célszerű a validate-et használni.

A következő lépésben megvalósítjuk a két adattáblához tartozó osztályokat.

src/main/java/ormexample/entity/Person.java:

package ormexample.entity;

import javax.persistence.*;

@Entity
@Table(name="person")
public class Person {
    @Id
    @GeneratedValue(strategy=GenerationType.IDENTITY)
    private Integer id;

    private String name;

    private Integer age;

    @ManyToOne
    @JoinColumn(name="addressid")
    private Address address;

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }

    public Address getAddress() {
        return address;
    }

    public void setAddress(Address address) {
        this.address = address;
    }

    @Override
    public String toString() {
        return "Person [id=" + id + ", name=" + name + ", age=" + age + "]";
    }
}

Ill. src/main/java/ormexample/entity/Address.java:

package ormexample.entity;

import java.util.List;
import javax.persistence.*;

@Entity
@Table(name="address")
public class Address {
    @Id
    @GeneratedValue(strategy=GenerationType.IDENTITY)
    private Integer id;

    private String country;

    private String town;

    private String street;

    @OneToMany(mappedBy="address")
    private List<Person> persons;

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getCountry() {
        return country;
    }

    public void setCountry(String country) {
        this.country = country;
    }

    public String getTown() {
        return town;
    }

    public void setTown(String town) {
        this.town = town;
    }

    public String getStreet() {
        return street;
    }

    public void setStreet(String street) {
        this.street = street;
    }

    public List<Person> getPersons() {
        return persons;
    }

    public void setPersons(List<Person> persons) {
        this.persons = persons;
    }

    public void addPerson(Person person) {
        this.persons.add(person);
    }

    @Override
    public String toString() {
        return "Address [id=" + id + ", country=" + country + ", town=" + town + ", street=" + street + "]";
    }
}

Néhány gondolat a példáról:

  • A fenti osztályokat entitásoknak (entity) hívjuk, és @Entity annotációval látjuk el.
  • A @Table annotáció opcionális. Ezzel adjuk meg a tábla nevet. Ha a táblanév és az entitás név megegyezik, akkor elhagyható.
  • A legtöbb esetben a fordító kitalálja a megfelelő leképezést. Bizonyos esetekben annotációk segítségével tudjuk ezt vezérelni.
  • A @Id annotáció az elsődleges kulcsot jelöli. Célszerű ezt az értéket generálni, és ennek megfelelően beállítani az adatbázisban (AUTO_INCREMENT) és itt a kódban is (@GeneratedValue(strategy=GenerationType.IDENTITY)).
  • Kell, hogy legyen paraméter nélküli publikus konstruktora az entitásnak.
  • A @OneToMany, és a párja, a @ManyToOne vezérli az 1-n kapcsolatot, mint a példában azt, hogy egy embernek egy címe lehet, de ugyanaz a címe több embernek is lehet.

Végül lássunk egy programot néhány alapművelettel!

src/main/java/ormexample/OrmExample.java:

package ormexample;

import javax.persistence.*;
import ormexample.entity.*;

public class OrmExample {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hu.faragocsaba.ormexample");
        EntityManager em = emf.createEntityManager();

        Address address = em.find(Address.class, 1);
        System.out.println(address);

        em.getTransaction().begin();
        Person gyuri = new Person();
        gyuri.setName("Gyuri");
        gyuri.setAge(40);
        gyuri.setAddress(address);
        em.persist(gyuri);
        em.getTransaction().commit();

        for (Person person : address.getPersons()) {
            System.out.println(person);
        }

        em.getTransaction().begin();
        em.remove(gyuri);
        em.getTransaction().commit();

        List<Address> budapestAddresses = em.createQuery("SELECT a FROM Address a WHERE a.town = :town").setParameter("town", "Szeged").getResultList();
        for (Address budapestAddress: budapestAddresses) {
            System.out.println(budapestAddress);
        }
    }
}

Néhány információ a programról:

  • Az EntityManager alapvető fontosságú, ez kezeli az entitások adatbázisba mentését, ill. onnan történő kiolvasását.
  • Az EntityManager kétféle lehet:
    • alkalmazás vezérelt (application managed), mint a fenti példában (ez esetben az EntityManagerFactory osztályt használjuk a példányosításhoz)
    • konténer vezérelt (container managed), alkalmazás szerverek esetén (@PersistenceContext private EntityManager entityManager;)
  • A Persistence.createEntityManagerFactory("hu.faragocsaba.ormexample") paramétere az, amit a persistence.xml-ben megadtunk.
  • Az alkalmazás vezérelt megoldásban explicit meg kell adnunk a tranzakció elejét (em.getTransaction().begin();) és végét (em.getTransaction().commit();). A fenti példában nem foglalkozunk a hibákkal, a valóságban hiba esetén a rollback() függvényt kell meghívnunk.
  • Az EntityManager find() metódusával tudunk lekérni kulcs alapján egy entitást.
  • A persist() függvény segítségével tudunk lementeni egy entitást.
  • A remove() törli az adatbázisból az entitást.
  • A példa nem illusztrálja, de fontos tudnunk, hogy az entitásoknak alapvetően két állapotuk van: csatlakoztatott (attached) és lekapcsolt (detached). Csatlakoztatott állapotban lehet segítségével adatbázis műveletet végrehajtani: ez esetben minden módosítás (tehát egy egyszerű attribútum beállítás is) lementődik (tranzakció commit esetén) az adatbázisba. Csatlakoztatni az em.merge(entity) hívással lehet, explicit lecsatlakoztatni pedig az em.detach(entity) hívással.
  • Az em.refresh(entity) beolvassa az adatbázisból az aktuális értéket, és beállítástól függően ez rekurzívan történik (cascade).
  • Az em.createQuery() segítségével tudunk az SQL-hez nagyon hasonló JPQL (Java Persistence Query Language) lekérdezéseket végrehajtani. Névvel ellátott, valamint natív lekérdezéseket is végre tudunk hajtani.
  • A fenti példa feltételezi azt, hogy az adatbázis olyan állapotban van, ahogy az Adatbázisok oldalon le van írva. A módosítást visszacsinálja, így hibamentes lefutás esetén ugyanabba az állapotba kerül, mint volt eredetileg. Érdemes megfigyelnünk viszont azt, hogy újabb és újabb lefutáskor az új elem azonosítója egyre nagyobb.

Előzetes az Enterprise Java lehetőségeiből

Szó volt a konténer vezérelt EntityManager lehetőségéről: ha alkalmazás szervert használunk, akkor ezt a lehetőséget választva nem kell a programból törődnünk a tranzakció kezeléssel, azt megoldja a konténer.

A Spring ezt is tovább gondolta; például a fenti SELECT utasítást lecserélhetjük erre: a függvény fejlécre egy megfelelő interfészben: List<Address> findByTown(String town);, és a keretrendszer maga legenerálja a szükséges lekérdezést pusztán a függvény nevéből.

Egységtesztelés

Az egységtesztelés elméletével a programozás bevezetőbe olvashatunk. Ebben a részben a Java egységteszteléssel ismerkedünk meg: először átnézzük a legnépszerűbb egységteszt könyvtárat, a junit-ot, majd megismerünk néhány ún. mockolási lehetőséget, végül elméleti szempontból járjuk körbe a témát.

És igen: bármily meglepő, de annak ellenére, hogy alapvető dologról van szó, és létezik de facto szabvány, nem része a Java szabvány könyvtárnak.

JUnit

A JUnit a legnépszerűbb Java egységteszt keretrendszer.

Egy példa

A bevezetőben már láttunk példát, de most ismételjük meg, és járjuk körbe kicsit részletesebben! Minthogy nem része a Java szabványkönyvtárnak, hozzá kell adnunk a projekthez ezt a függőséget:

<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.12</version>
    <scope>test</scope>
</dependency>

A legtöbb esetben a 4.12-es verziót használjuk. Az 5-ös verzióról később lesz szó.

Megjegyzés: van egy (minden bizonnyal) szoftverhiba, ami miatt a maven ezt a hibát írja ki:

[ERROR] Source option 5 is no longer supported. Use 6 or later.
[ERROR] Target option 1.5 is no longer supported. Use 1.6 or later.

Remélhetőleg ezt hamarosan ki fogják javítani. Addig a következőképpen tudjuk elkerülni: írjuk be az alábbi pár sort a pom.xml-be, közvetlenül a legfelső szint alá (a megfeleéő verziót megadva):

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

Tegyük fel, hogy van egy ilyen osztályunk, amit tesztelni szeretnénk:

package math;

public class Math {
    public int add(int a, int b) {
        return a + b;
    }
}

Egységteszteket elvileg bárhol létrehozhatunk, de célszerű betartanunk a következő konvenciót: különüljön el az üzleti kód és a tesz, az egységtesztek kerüljenek az src/test/java/ könyvtárba. A csomagszerkezet feleljen meg az eredetinek (amit már amiatt is érdemes megtennünk, mert így elérjük a tesztelendő osztály alapértelmezett láthatóságú részeit), és mindegyik tesztelendő osztályhoz hozzunk létre saját tesztosztályt, ami az eredeti osztály nevét + egy Test posztfixet tartalmazzon. A tesztesetek neve kezdődjön azzal, hogy test, majd következzen a tesztelendő függvény neve. Abban az esetben, ha ugyanazt a függvényt többféleképpen teszteljük (ez a tipikus), akkor az arra a tesztre vonatkozó egy-két szavas kiegészítést fűzzünk hozzá (tehát ne testFunc1(), testFunc2 stb. legyen, hanem pl. testFuncPositiveCase(), testFuncErrorCheck stb.). Jelen esetben hozzuk létre az src/test/java/math/MathTest.java forrásfájlt az alábbi tartalommal:

package math;

import static org.junit.Assert.assertEquals;
import org.junit.Test;

public class MathTest {
    @Test
    public void testAddPositive() {
        Math math = new Math();

        int result = math.add(2, 3);

        assertEquals(5, result);
    }
}

Látható, hogy a @org.junit.Test annotációval jeleztük, hogy junit tesztről van szó. Nem véletlenül "szellős" kicsit a teszteset. Egy jól felépített teszteset ugyanis pontosan 3 részből áll:
1. A teszteset előkészítése (jelen esetben a megfelelő osztály példányosítása).
2. A tesz végrehajtása (jelen esetben az összeadás).
3. Az eredmény ellenőrzése (assertEquals).

Az eredményt a különböző assert függvényekkel tudjuk ellenőrizni. A logikának kicsit ellentmondó módon először az elvárt értéket adjuk meg, és utána a ténylegeset, ugyanis hiba esetén ennek megfelelően formázza a hibaüzenetet.

Majd futtassuk a teszteket! Ezt megtehetjük a kedvenc IDE-nkből (pl. Eclipse-ben jobb kattintás a teszteseten, vagy magán az osztályon, és Run As → JUnit Test), vagy parancssorból, pl. így:

mvn test

Egyébként a legtöbb maven parancs (pl. mvn clean install) automatikusan lefuttatja a teszteket. Ha nem szeretnénk, ezt a -DskipTests paraméter megadásával tudjuk megadni.

A tesztek felépítése

A fent bemutatott @Test annotáció igazából a legtöbb esetben elegendő, ám vannak esetek, amikor ennél bonyolultabb dolgokat szeretnénk végrehajtani. Íme néhány további hasznos annotáció:

  • @Before: az ezzel annotált függvény végrehajtódik minden teszteset előtt. Például ha mindegyik teszt esetén ugyanazt a struktúrát szeretnénk felépíteni, és - ez fontos - mindig újra, akkor ezt az annotációt használhatjuk.
  • @After: a @Before párja, azaz ez minden teszteset végén hajtódik végre. Igazából ezt ritkán használjuk, mivel ide tipikusan adatkapcsolatok lezárását szokás tenni, de a jól felépített unit teszteknél ilyesmire nincs szükség.
  • @BeforeClass: az ezzel annotált függvény egyszer hajtódik végre az összes teszteset előtt. Ennek a függvénynek statikusnak kell lennie. A fenti példában létrehozhatunk egyetlen Math példányt a @BeforeClass annotációval ellátott függvényben, az lehet a teszt osztály egy member változója, és ezt az összes teszteset használja.
  • @AfterClass: a @BeforeClass párja, ami egyszer hajtódik végre, miután az összes teszteset lefutott.
  • @Ignore: figyelmen kívül lehet hagyni egy tesztesetet a fordítás során. Arra jó, hogy az ideiglenesen elhasaló teszteseteket rövid távon mellőzzük; a gyakorlat sajnos azt mutatja, hogy az ezzel ellátott tesztesetek "beragadnak". Ezzel az annotációval egyébként magát az osztályt is elláthatjuk; ez esetben az osztályban található egyik teszteset sem hajtódik végre.

Eredmény ellenőrzések

A fenti példában az assertEquals eljárással ellenőriztük, hogy az eredmény az elvárt-e. Ez a leggyakoribb ellenőrző függvény, de van még számos egyéb:

  • assertEquals(expected, actual): tehát előbb jön az elvárt eredmény, és utána az aktuális. Két lebegőpontos szám összehasonlításához használandó a harmadik paraméter, maely megmondja a maximális eltérést az elvárt és a tényleges között. Ennek az oka az, hogy a számábrázolás pontatlansága miatt esetenként két egyenlőnek gondolt értéke setén is lehet az összehasonlítás eredménye hamis,
  • assertTrue(boolean): a paraméterül kapott logikai kifejezés elvárt értéke igaz.
  • assertFalse(boolean): a paraméterül kapott logikai kifejezés elvárt értéke hamis.
  • assertNull(object): a paraméterül kapott objektum null kell, hogy legyen.
  • assertNotNull(object): a paraméterül kapott objektum nem lehet null.
  • fail(): a teszteset feltétel nélkül elhasal, pl. egy nem kívánt ágon.

Elvárt kivétel

A jól felépített egységtesztek minden lehetséges ágat leellenőriznek, így a kivételeket is. Ennek a legelegánsabb módja az, hogy a @Test annotációban expected parméterként megadjuk az elvárt kivételt. Lássunk egy példát! Először hozzunk létre a főprogramban egy kivételt, majd adjunk hozzá egy divide() függvényt:

package math;

class DivisionByZeroException extends Exception {}

public class Math {
    public int add(int a, int b) {
        return a + b;
    }

    public int divide(int a, int b) throws DivisionByZeroException {
        if (b == 0) {
            throw new DivisionByZeroException();
        } else return a / b;
    }
}

Szervezzük át az egységteszt kódot:

package math;

import static org.junit.Assert.assertEquals;
import org.junit.BeforeClass;
import org.junit.Test;

public class MathTest {
    static Math math;

    @BeforeClass
    public static void setUp() {
        math = new Math();
    }

    @Test
    public void testAddPositive() {
        int result = math.add(2, 3);
        assertEquals(5, result);
    }

    @Test
    public void testDivideSuccess() throws DivisionByZeroException {
        int result = math.divide(6, 3);
        assertEquals(2, result);
    }

    @Test(expected = DivisionByZeroException.class)
    public void testDivideException() throws DivisionByZeroException {
        math.divide(6, 0);
    }
}

A testDivideException() elvárt kivétele a DivisionByZeroException. Ha átírnánk a nevezőt 0-ról mondjuk 1-re, akkor ez az egységteszt elhasalna, mert nem érkezne meg az elvárt kivétel.

Időtúllépés tesztelése

A tesztesetnek opcionális paraméterként megadhatunk egy timeout értéket, ami azt jelzi, hogy legfeljebb hány ezredmásodperc ideig tarthat a teszteset. Ezzel nemcsak az eredményt, hanem az algoritmus hatékonyságát is ellenőrizhetjük. Lássunk erre is egy példát! A Fibonacci számok kiszámításának két módját adom meg: az egyik a rekurzív megvalósítás, a másik az iteratív. Az elsőnek exponenciális lesz a futási ideje, a másiknak lineáris. A tesztelendő osztály az alábbi:

package math;

public class Fibonacci {
    public static int fibonacciFast(int n) {
        if (n <= 1) {
            return n;
        }
        int a = 0, b = 1, temp;
        for (int i = 0; i < n - 1; i++) {
            temp = b;
            b = a + b;
            a = temp;
        }
        return b;
    }

    public static long fibonacciSlow(int n) {
        if (n <= 1) {
            return n;
        } else {
            return fibonacciSlow(n-1) + fibonacciSlow(n-2);
        }
    }
}

Amivel tesztelünk:

package math;

import static org.junit.Assert.assertEquals;
import org.junit.Test;

public class FibonacciTest {
    @Test(timeout = 1000)
    public void testFibonacciFast() {
        assertEquals(1134903170, Fibonacci.fibonacciFast(45));
    }

    @Test(timeout = 1000)
    public void testFibonacciSlow() {
        assertEquals(1134903170, Fibonacci.fibonacciSlow(45));
    }
}

Az első teszteset sikerül, a második elhasal.

Parametrizált tesztesetek

Gyakori eset, hogy egy-egy függvényt nagyon sokféle paraméter kombinációval szeretnénk meghívni, valójában ugyanúgy. Ha ezt egyetlen tesztesetbe tennénk, akkor megszegjük azt a szabályt, hogy egy egységteszt pontosan egy valamit ellenőrizzen (a szabályokról később még lesz szó). Ha mindegyiket külön tesztbe tesszük, akkor túl ugyanolyan sok függvény lesz, ami növeli a kód duplikátumok arányát. A megoldás a parametrizált tesztelés, ahol paraméterként adjuk meg a bemeneteket és az elvárt kimeneteket, és magát a tesztesetet csak egyszer írjuk meg.

Lássuk ezt egy példán keresztül!

package math;

import static org.junit.Assert.assertEquals;
import java.util.Arrays;
import java.util.Collection;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameter;

@RunWith(Parameterized.class)
public class MathTest {
    static Math math;

    @BeforeClass
    public static void setUp() {
        math = new Math();
    }

    @Parameter(0)
    public int a;

    @Parameter(1)
    public int b;

    @Parameter(2)
    public int result;

    @Parameterized.Parameters
    public static Collection<Object[]> primeNumbers() {
       return Arrays.asList(new Object[][] {
          {2, 3, 5},
          {-2, -3, -5},
          {0, 0, 0},
          {100, 100, 200},
          {-10, 10, 0}
       });
    }

    @Test
    public void testAdd() {
        assertEquals(result, math.add(a, b));
    }
}

Test suite

A @Suite annotációval ún. teszt készleteket tudunk létrehozni. ENnek illusztrálására kicsit szervezzük át a kódot! Az üzleti logika így néz ki:

// Math.java
package math;
public class Math {}

// Add.java
package math;
public class Add extends Math {
    public int add(int a, int b) {
        return a + b;
    }
}

// Divide.java
package math;
public class Divide extends Math {
    public int divide(int a, int b) throws DivisionByZeroException {
        if (b == 0) {
            throw new DivisionByZeroException();
        } else return a / b;
    }
}

// DivisionByZeroException.java
package math;
public class DivisionByZeroException extends Exception {
    private static final long serialVersionUID = 785018278084864807L;
}

Ennek megfelelően az átszervezett egységteszt:

// AddTest.java
package math;

import static org.junit.Assert.assertEquals;
import java.util.Arrays;
import java.util.Collection;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameter;

@RunWith(Parameterized.class)
public class AddTest {
    static Add add;

    @BeforeClass
    public static void setUp() {
        add = new Add();
    }

    @Parameter(0)
    public int a;

    @Parameter(1)
    public int b;

    @Parameter(2)
    public int result;

    @Parameterized.Parameters
    public static Collection<Object[]> primeNumbers() {
       return Arrays.asList(new Object[][] {
          {2, 3, 5},
          {-2, -3, -5},
          {0, 0, 0},
          {100, 100, 200},
          {-10, 10, 0}
       });
    }

    @Test
    public void testAdd() {
        assertEquals(result, add.add(a, b));
    }
}

Ill.:

package math;

import static org.junit.Assert.assertEquals;

import org.junit.BeforeClass;
import org.junit.Test;

public class DivideTest {
    static Divide divide;

    @BeforeClass
    public static void setUp() {
        divide = new Divide();
    }

    @Test
    public void testDivideSuccess() throws DivisionByZeroException {
        int result = divide.divide(6, 3);
        assertEquals(2, result);
    }

    @Test(expected = DivisionByZeroException.class)
    public void testDivideException() throws DivisionByZeroException {
        divide.divide(6, 0);
    }
}

Tegyük fel, hogy az összeadásokat és az osztásokat egy logikai egységbe szeretnénk szervezni. Ezt a következőképen tudjuk megtenni:

package math;

import org.junit.runner.RunWith;
import org.junit.runners.Suite;

@RunWith(Suite.class)
@Suite.SuiteClasses({AddTest.class, DivideTest.class})
public class MathTest {}

A MathTest-et unit tesztként lefuttatva mind az összeadás, mind az osztás tesztek végrehajtódnak.

Test runner

Az alapértelmezett teszt futtató véletlen sorrendben lefuttatja a teszteseteket. Előfordulhat, hogy ez nem megfelelő; ez esetben saját teszt futtatót lehet készíteni. Mi magunk nagyon ritkán készítünk teszt futtatót; erről egy jó leírás itt található: https://www.baeldung.com/junit-4-custom-runners. Ami fontosabb: bizonyos keretrendszerek használatakor ha azt szeretnénk ellenőrizni, hogy a függvényünk hogyan működik abban a környezetben, szükség lehet annak valamilyen szintű szimulált futtatására, azaz a tesztesetek előtti elindítására, majd leállítására. Így a keretrendszer gyártók többnyire elkészítik az ő teszt futtatójukat is.

Ha nem az alapértelmezett futtatóval szeretnénk futtatni a teszteket, akkor az osztályt a @RunWith annotációval kell ellátni, pl. @RunWith(CustomRunner.class).

JUnit 5

A JUnit 5-ös verziója átnevezésre került, az új név jupiter. Az írás pillanatában legfrissebb verziót a következőképpen tudjuk használni:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-engine</artifactId>
    <version>5.5.2</version>
    <scope>test</scope>
</dependency>

Az első, lényeges dolog, ami nekem feltűnt, az a logikusabb @Before és @After annotációt: @BeforeEach és @AfterEach, ill. @BeforeAll és @AfterAll. Egyik sem szorul magyarázatra. Bevezették a tesztek ismétlését: @RepeatedTest(number). A kivételkezelést is kódból oldja meg, annotáció paraméter helyett: expectThrows.

Bevezették a lambda kifejezések támogatását. Ezt egy példán keresztül mutatom be:

public class Math {
    public static int add(int a, int b) {
        return a + b;
    }
}

Ennek egy lehetséges egységtesztelése a következő:

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

public class MathTest {
    @Test
    public void testAdd() {
        assertAll(
            () -> assertEquals(5, Math.add(2, 3)),
            () -> assertEquals(8, Math.add(4, 4))
        );
    }
}

A parametrizált tesztelést is tovább gondolták; itt dinamikus teszteknek hívják:

import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;

public class MathTestFactory {
    @TestFactory
    public Stream<DynamicTest> testAddDynamic() {
        List<Object[]> testCases = Arrays.asList(new Object[][] {
            {2, 3, 5},
            {-2, -3, -5},
            {0, 0, 0},
            {100, 100, 200},
            {-10, 10, 0}
         });
        return DynamicTest.stream(
            testCases.iterator(),
            testCase -> "Testing " + (int)testCase[0] + " + " + (int)testCase[1] + " = " + (int)testCase[2],
            testCase -> assertEquals(testCase[2], Math.add((int)testCase[0], (int)testCase[1]))
        );
    }
}

Megjegyzés: ez rendesen működött Eclipse-ből, parancssorból viszont nem akart sehogyan sem működni. A neten található leírások (pl. https://dzone.com/articles/why-your-junit-5-tests-are-not-running-under-maven, https://github.com/junit-team/junit5/issues/1778, https://blog.codefx.org/libraries/junit-5-dynamic-tests/) nem segítettek.

Mockolás

A fent bemutatott egységtesztek egyszerűek, talán túl egyszerűek voltak. A valóságban előfordulnak esetek, amikor fel kell építeni a teszteset környezetét. A mock keretrendszerek ebben segítik a fejlesztőt.

Egy egyszerű Mockito példa

Tekintsük a következő példát: adatbázisból azonosító alapján kiolvasunk egy adatot, ami részvénnyel kapcsolatos információkat tartalmaz: a részvény neve, az ára, valamint az, hogy mennyit mozdult el az ára. Egy függvény kiszámolja az új árat. Ezt szeretnénk egységtesztelni! A problémát nyilván az adatbázis olvasás okozza, a példában ezt fogjuk mockolni. Lássuk először az üzleti logikát! A részvény kódja az alábbi:

package stock;

public class Stock {
    int id;
    String name;
    double actualPrice;
    double increase;

    public Stock(int id, String name, double actualPrice, double increase) {
        super();
        this.id = id;
        this.name = name;
        this.actualPrice = actualPrice;
        this.increase = increase;
    }
}

A valóságban természetesen ennél jobban ki kell dolgozni, pl. priváttá célszerű állítani az attribútumokat, kellenek getterek és setterek stb., most a tömörebb kód érdekében ettől eltekintünk. Az adatbázisból kiolvasó kód ez:

package stock;

public class StockDao {
    public int[] getAllIds() {
        int[] stockIds = null;
        //read stock IDs from database
        return stockIds;
    }

    public Stock readStockInfo(int id) {
        Stock stock = null;
        // read Stock from database
        return stock;
    }
}

Az üzleti logika, amit tesztelni szeretnénk:

package stock;

public class StockLogic {
    private StockDao stockDao;

    public StockLogic(StockDao stockDao) {
        this.stockDao = stockDao;
    }

    public double calculateNextPrice(int id) {
        Stock stock = stockDao.readStockInfo(id);
        return stock.actualPrice * (1 + stock.increase);
    }
}

Az egységtesztet Mockito-val valósítjuk meg. Ehhez az alábbi függőséget kell a pom.xml fájlhoz adni:

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-all</artifactId>
    <version>1.10.19</version>
    <scope>test</scope>
</dependency>

A junitra (ld. fent) továbbra is szükség van.

package stock;

import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.*;
import org.junit.Test;

public class StockLogicTest {
    @Test
    public void testCalculateNextPrice() {
        StockDao stockDao = mock(StockDao.class);
        when(stockDao.readStockInfo(3)).thenReturn(new Stock(3, "MyProduct", 125.2, 0.07));
        StockLogic stockLogic = new StockLogic(stockDao);

        double result = stockLogic.calculateNextPrice(3);

        assertEquals(result, 133.964, 0.001);
    }
}

A mock, valamint a when-thenReturn kódot tartalmazó sorok a lényegesek jelen esetben, amelyek "beégetik" azt, hogy melyik azonosítóra mit kell visszaadni. Megjegyzések:

  • A futtatás során nálam kiírja ezt a figyelmeztetést pár sorban: An illegal reflective access operation has occurred; nem sikerült megfejtenem,hogy mi okozza.
  • A fenti példa eléggé "élére vasalt": a legtöbb esetben sajnos nem ennyire egyszerű a tesztelésre való előkészítés.
  • A fenti példát természetesen Mockito nélkül is meg tudjuk valósítani: leszármaztatunk a StockDao osztályból, felüldefiniáljuk a readStock(id) eljárást egy feltétellel: ha a paraméterül átadott érték 3, akkor a fenti példányt adja vissza. Mockito-val mindez rövidebb, és egy helyen van a teszthez szükséges minden lényeges dolog.

A hívások számának ellenőrzése

A verify() metódussal tudjuk leellenőrizni azt, hogy meghívódott-e egy függvény vagy sem. Külön be tudjuk állítani azt, hogy a hívás megfelelő számszor, ill. megfelelő paraméterekkel történt-e.

Ennek illusztrálásához bővítsük ki a fenti StockLogic osztályt az alábbi függvénnyel, amely megkeresi a legnagyobb arányú növekedést:

    public double findBiggestIncrease() {
        double biggestIncrease = Double.NEGATIVE_INFINITY;
        for (int stockId : stockDao.getAllIds()) {
            Stock stock = stockDao.readStockInfo(stockId);
            if (stock.increase > biggestIncrease) {
                biggestIncrease = stock.increase;
            }
        }
        return biggestIncrease;
    }

Felépítünk egy olyan példát, melyben 5 elem van, és megnézzük, hogy a readStockInfo() függvény ötször lett-e meghívva, bármilyen paraméterrel:

    @Test
    public void testFindBiggestIncrease() {
        StockDao stockDao = mock(StockDao.class);
        int[] stockIds = {1, 2, 3, 4, 8};
        when(stockDao.getAllIds()).thenReturn(stockIds);
        when(stockDao.readStockInfo(1)).thenReturn(new Stock(1, "MyProduct1", 125.2, 0.07));
        when(stockDao.readStockInfo(2)).thenReturn(new Stock(2, "MyProduct2", 100.0, -0.07));
        when(stockDao.readStockInfo(3)).thenReturn(new Stock(3, "MyProduct3", 89.1, 0.12));
        when(stockDao.readStockInfo(4)).thenReturn(new Stock(4, "MyProduct4", 17.4, 0.2));
        when(stockDao.readStockInfo(8)).thenReturn(new Stock(8, "MyProduct8", 389.0, 0.09));
        StockLogic stockLogic = new StockLogic(stockDao);

        double result = stockLogic.findBiggestIncrease();

        verify(stockDao, times(5)).readStockInfo(anyInt());
        assertEquals(result, 0.2, 0.001);
    }

A times(5) elhagyható; ez esetben azt nézzük, hogy egyáltalán meg lett-e hívva. Az anyInt() helyére beírhatunk számot, így azt is le tudnánk ellenőrizni, hogy a megfelelő paraméterekkel történt-e a hívás:

    @Test
    public void testFindBiggestNotOrder() {
        StockDao stockDao = mock(StockDao.class);
        int[] stockIds = {1, 2, 3, 4, 8};
        when(stockDao.getAllIds()).thenReturn(stockIds);
        when(stockDao.readStockInfo(1)).thenReturn(new Stock(1, "MyProduct1", 125.2, 0.07));
        when(stockDao.readStockInfo(2)).thenReturn(new Stock(2, "MyProduct2", 100.0, -0.07));
        when(stockDao.readStockInfo(3)).thenReturn(new Stock(3, "MyProduct3", 89.1, 0.12));
        when(stockDao.readStockInfo(4)).thenReturn(new Stock(4, "MyProduct4", 17.4, 0.2));
        when(stockDao.readStockInfo(8)).thenReturn(new Stock(8, "MyProduct8", 389.0, 0.09));
        StockLogic stockLogic = new StockLogic(stockDao);

        double result = stockLogic.findBiggestIncrease();

        verify(stockDao).readStockInfo(4);
        verify(stockDao).readStockInfo(3);
        verify(stockDao).readStockInfo(2);
        verify(stockDao).readStockInfo(1);
        verify(stockDao).readStockInfo(8);
        assertEquals(result, 0.2, 0.001);
    }

A hívások sorrendjének ellenőrzése

A fenti példában szándékosan el lett rontva a hívás sorrendje az ellenőrzésnél; ennek ellenére a teszteset sikeresen lefut. Ezt a módszert kell alkalmaznunk akkor, ha a sorrend nem determinisztikus, pl. halmazok esetén. Ha számít a sorrend, akkor az org.mockito.InOrder osztályt használhatjuk:

import org.mockito.InOrder;

...

    @Test
    public void testFindBiggestIncreaseOrder() {
        StockDao stockDao = mock(StockDao.class);
        InOrder inOrder = inOrder(stockDao);
        int[] stockIds = {1, 2, 3, 4, 8};
        when(stockDao.getAllIds()).thenReturn(stockIds);
        when(stockDao.readStockInfo(1)).thenReturn(new Stock(1, "MyProduct1", 125.2, 0.07));
        when(stockDao.readStockInfo(2)).thenReturn(new Stock(2, "MyProduct2", 100.0, -0.07));
        when(stockDao.readStockInfo(3)).thenReturn(new Stock(3, "MyProduct3", 89.1, 0.12));
        when(stockDao.readStockInfo(4)).thenReturn(new Stock(4, "MyProduct4", 17.4, 0.2));
        when(stockDao.readStockInfo(8)).thenReturn(new Stock(8, "MyProduct8", 389.0, 0.09));
        StockLogic stockLogic = new StockLogic(stockDao);

        double result = stockLogic.findBiggestIncrease();

        inOrder.verify(stockDao).readStockInfo(1);
        inOrder.verify(stockDao).readStockInfo(2);
        inOrder.verify(stockDao).readStockInfo(3);
        inOrder.verify(stockDao).readStockInfo(4);
        inOrder.verify(stockDao).readStockInfo(8);
        assertEquals(result, 0.2, 0.001);
    }
...

Kivételkezelés

Ha egy függvény kivételt dobhat, azt adott inputra meg tudjuk adni. A fenti példát úgy módosítjuk, hogy a StockDao.readStockInfo() függvény StockNotFoundException kivételt dobjon olyan esetben, ha az adott azonosítójú részvény nem létezik. Ehhez először létrehozzuk magát a kivételt:

// StockNotFoundException.java
package stock;

public class StockNotFoundException extends RuntimeException {}

Az egyszerűség érdekében hoztuk létre nem ellenőrzött kivételként (a RuntimeException osztályból örököltetve), hogy nem kelljen mindenhova odaírni a throws StockNotFoundException-t; a valóságban ezt ellenőrzött kivételként célszerű létrehozni. Mindenesetre a StockDao.java-ban jelezzük, hogy a readStockInfo() ezt a kivételt dobhatja:

...
    public Stock readStockInfo(int id) throws StockNotFoundException {
        Stock stock = null;
        // read Stock from database
        return stock;
    }
...

Most pedig megírjuk a StockLogicTest.java forrásban a vonatkozó egységtesztet:

    @Test(expected = StockNotFoundException.class)
    public void testInvalidStock() {
        StockDao stockDao = mock(StockDao.class);
        doThrow(StockNotFoundException.class).when(stockDao).readStockInfo(5);
        StockLogic stockLogic = new StockLogic(stockDao);

        stockLogic.calculateNextPrice(5);
    }

A teszt a korábban bemutatott calculateNextPrice() függvényt hívja meg, de most az azonosító 5, ami a tesztünkben nem létezik. Itt az elvárt kivétel a StockNotFoundException, amit a mockolt StockDao adott inputra dob.

Nagyobb kontroll

Az első példát általánosíthatjuk az Answer interfész segítségével. Lássuk először a megoldást:

...
import org.mockito.stubbing.*;
...
    @Test
    public void testCalculateNextPriceWithAnswer() {
        StockDao stockDao = mock(StockDao.class);
        when(stockDao.readStockInfo(anyInt())).thenAnswer(new Answer<Stock>() {
            @Override
            public Stock answer(InvocationOnMock invocation) {
                Object[] args = invocation.getArguments();
                StockDao mockStockDao = (StockDao)invocation.getMock();
                // here we can manipulate the mock object
                if ((int)args[0] == 3) {
                    return new Stock(3, "MyProduct", 125.2, 0.07);
                }
                return null;
            }
        });
        StockLogic stockLogic = new StockLogic(stockDao);

        double result = stockLogic.calculateNextPrice(3);

        assertEquals(result, 133.964, 0.001);
    }

A kód olyan szempontból nem jó, hogy ugyanazt csinálja mint az előző, de hosszabb lett, az Answer megoldás erejét viszont jól illusztrálja: belül, ahol létrehozzuk a választ, egyéb műveleteket is végre tudunk hajtani a mockolt objektumon.

Az időtúllépés ellenőrzése

A verify() metódus második, opcionális paramétere az időtúllépésre vonatkozik, pl. a verify(myMock, timeout(100)).myFunc(myParam) azt jelenti, hogy az adott eljárás befejeződik-e egytized másodperc alatt. Jó példát erre viszont nem tudok adni, mert ezzel azt ellenőrizzük, hogy a mockolt változat fejeződik-e be adott időn belül, aminek szerintem semmi értelme nincs.

"Kémkedés"

Kémkedés (angolul spying) alatt azt értjük, hogy egy osztályt részben szeretnénk mockolni, részben az eredeti függvényeket használni. Egészen pontosan egy létez példányt egy az egyben lemásolunk, majd bizonyos részeit módosítjuk. A nem módosított részek az eredeti módon működnek.

Példaként vegyünk egy (egyébként rosszul megtervezett) osztályt, ami két dolgot csinál: az egyik függvénye kiolvas egy egész számot az adatbázisból, a másik pedig a kilolvasott szám négyzetét adja vissza értékül (a tervezés amiatt rossz, mert ezt a két dolgot a gyakorlatban külön kell választani).

package math;

public class MathDb {
    public int readNextValue() {
        int result = 0;
        // read value from database
        return result;
    }

    public int squareOfNextValue() {
        int value = readNextValue();
        return value * value;
    }
}

A példában az adatbázisból olvasós részt, tehát a readNextValue() függvényt mockolni szeretnénk, a négyzet számolós részt viszont eredeti formájában felhasználni. Valódi tesztesetek esetén jó eséllyel egy másiko osztályt tesztelünk, amely a mockoltat használja; most a jól áttekinthető példa érdekében az eredeti formájában hagyott eljárást teszteljük:

package math;

import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.*;
import org.junit.Test;
import org.mockito.Mockito;

public class MathDbTest {
    @Test
    public void testSquareOfNextValue() {
        MathDb spyMathDb = Mockito.spy(new MathDb());
        when(spyMathDb.readNextValue()).thenReturn(5);

        int result = spyMathDb.squareOfNextValue();

        assertEquals(25, result);
    }
}

A fenti másolást annotációval is meg lehet oldani. Ehhez a MockitoJUnitRunner osztállyal kell futtatni a tesztet:

package math;

import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.*;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Spy;
import org.mockito.runners.MockitoJUnitRunner;

@RunWith(MockitoJUnitRunner.class)
public class MathDbTest {
    @Spy
    MathDb spyMathDb = new MathDb();

    @Test
    public void testSquareOfNextValue() {
        when(spyMathDb.readNextValue()).thenReturn(5);

        int result = spyMathDb.squareOfNextValue();

        assertEquals(25, result);
    }
}

Ha ez nem lehetséges, akkor programkódból is elérhetővé tudjuk tenni az annotációkat:

package math;

import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.*;

import org.junit.Before;
import org.junit.Test;
import org.mockito.MockitoAnnotations;
import org.mockito.Spy;

public class MathDbTest {
    @Before
    public void init() {
        MockitoAnnotations.initMocks(this);
    }

    @Spy
    MathDb spyMathDb = new MathDb();

    @Test
    public void testSquareOfNextValue() {
        when(spyMathDb.readNextValue()).thenReturn(5);

        int result = spyMathDb.squareOfNextValue();

        assertEquals(25, result);
    }
}

A mockolt objektum visszaállítása

A reset() függvénnyel lehet a mockolt objektumot alapértelmezett állapotba állítani, például így:

    @Test
    public void testCalculateNextPriceWithReset() {
        StockDao stockDao = mock(StockDao.class);
        when(stockDao.readStockInfo(3)).thenReturn(new Stock(3, "MyProduct", 125.2, 0.07));
        StockLogic stockLogic = new StockLogic(stockDao);

        assertEquals(stockLogic.calculateNextPrice(3), 133.964, 0.001);

        reset(stockDao);

        when(stockDao.readStockInfo(3)).thenReturn(new Stock(3, "MyProduct", 125.2, 0.0));
        assertEquals(stockLogic.calculateNextPrice(3), 125.2, 0.001);
    }

Viselkedés vezérelt fejlesztés Mockito segítségével

Angolul behavior-driven development, szokásos rövidítéssel BDD. A BDD egy sokkal általánosabb fogalom, melyre most nem értünk ki; az egységtesztelés szempontjából ez a tesztek felépítésére van hatással: a given-when-then jól felépített mintát követi. Lássunk erre egy példát!

...
import static org.mockito.BDDMockito.*;
import static org.hamcrest.CoreMatchers.*;
...
    @Test
    public void testCalculateNextPriceBDD() {
        // given
        StockDao stockDao = mock(StockDao.class);
        given(stockDao.readStockInfo(3)).willReturn(new Stock(3, "MyProduct", 125.2, 0.07));
        StockLogic stockLogic = new StockLogic(stockDao);

        // when
        double result = stockLogic.calculateNextPrice(3);

        // then
        then(stockDao).should().readStockInfo(3);
        assertThat(result, is(133.964));
    }
....

Láthatóak a jól elkülönülő szakaszok, valamint azok a kulcsszavak, amellyel szinte mondatokként tudjuk a kódot olvasni.

A PowerMock

A Mockito igen hasznos keretrendszer egységteszteléshez, viszont vannak kötöttségei: nem lehet vele statikus függvényeket, privát függvényeket, final kulcsszóval ellátott függvényeket és konstruktorokat sem mockolni. A PowerMock ezeket is tudja. A Mockito mellett az EasyMock keretrendszerrel is együttműködik. Most a Mockito-t nézzük meg.

Mielőtt rátérnénk a lehetőségekre, néhány bevezető gondolat a PowerMock-ról. Az írás pillanatában aktuális verzió nem működött jól együtt a 9-es vagy annál újabb Java-val. Két külső könyvtárat is importálni kell: az egyik a JUnit komponens (így magát a junit-ot nem kell külön importálni), a másik pedig arra vonatkozik, hogy melyik mock keretrendszert használjuk. A pom.xml kb. így néz ki:

<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.faragocsaba</groupId>
    <artifactId>junit</artifactId>
    <version>1.0</version>

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

    <dependencies>
        <dependency>
            <groupId>org.powermock</groupId>
            <artifactId>powermock-module-junit4</artifactId>
            <version>1.7.4</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.powermock</groupId>
            <artifactId>powermock-api-mockito</artifactId>
            <version>1.7.4</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

(Megjegyzés: nem elég a verziószámot a tulajdonságok között átállítani, ténylegesen 8-as Java-t kell használni.)

Két annotációval el kell látni a teszt osztályt:

  • @RunWith(PowerMockRunner.class) - ellentétben a Mockito-val itt már kötelező.
  • @PrepareForTest(fullyQualifiedNames = "package.*") - az osztályokat úgymond elő kell készíteni a teszteléshez, egyébként nem működik a teszt.

A PoerMock egyébként felülről kompatibilis a Mockitot-val, például a fenti első példa - más importokkal és a két említett annotációval - egy az egyben működik:

package stock;

import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.assertEquals;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import static org.powermock.api.mockito.PowerMockito.*;

@RunWith(PowerMockRunner.class)
@PrepareForTest(fullyQualifiedNames = "stock.*")
public class StockLogicTest {
    @Test
    public void testCalculateNextPrice() {
        StockDao stockDao = mock(StockDao.class);
        when(stockDao.readStockInfo(3)).thenReturn(new Stock(3, "MyProduct", 125.2, 0.07));
        StockLogic stockLogic = new StockLogic(stockDao);

        double result = stockLogic.calculateNextPrice(3);

        assertEquals(result, 133.964, 0.001);
    }
}

Konstruktorok mockolása

A fenti példa talán túlzottan is vegytisztán elő volt készítve az egységteszteléshez. A valóságban a kód általában ennél problémásabb. A külső függőségeket tipikusan nem paraméterként kapják meg az osztályok, hanem maguk hozzák létre. Például a valóságban a StockLogic inkább a következőképpen néz ki:

package stock;

public class StockLogic {
    public double calculateNextPrice(int id) {
        Stock stock = (new StockDao()).readStockInfo(id);
        return stock.actualPrice * (1 + stock.increase);
    }
}

Ezt ebben a formában még Mocikoto-val sem tudnánk tesztelni, PowerMock-kal viszont igen!

package stock;

import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.assertEquals;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import static org.powermock.api.mockito.PowerMockito.*;

@RunWith(PowerMockRunner.class)
@PrepareForTest(fullyQualifiedNames = "stock.*")
public class StockLogicTest {
    @Test
    public void testCalculateNextPrice() throws Exception {
        StockDao stockDaoMock = mock(StockDao.class);
        whenNew(StockDao.class).withNoArguments().thenReturn(stockDaoMock);
        when(stockDaoMock.readStockInfo(3)).thenReturn(new Stock(3, "MyProduct", 125.2, 0.07));
        StockLogic stockLogic = new StockLogic();

        double result = stockLogic.calculateNextPrice(3);

        assertEquals(result, 133.964, 0.001);
    }
}

Statikus és final függvények mockolása

Írjuk át a kódot úgy, hogy az összes függvény statikus legyen!

package stock;

public class StockDao {
    public final static int[] getAllIds() {
        int[] stockIds = null;
        //read stock IDs from database
        return stockIds;
    }

    public final static Stock readStockInfo(int id) throws StockNotFoundException {
        Stock stock = null;
        // read Stock from database
        return stock;
    }
}

Ill.:

package stock;

public class StockLogic {
    public static double calculateNextPrice(int id) {
        Stock stock = StockDao.readStockInfo(id);
        return stock.actualPrice * (1 + stock.increase);
    }

    public static double findBiggestIncrease() {
        double biggestIncrease = Double.NEGATIVE_INFINITY;
        for (int stockId : StockDao.getAllIds()) {
            Stock stock = StockDao.readStockInfo(stockId);
            if (stock.increase > biggestIncrease) {
                biggestIncrease = stock.increase;
            }
        }
        return biggestIncrease;
    }
}

A kód többi része (Stock.java, StockNotFoundException.java) maradjon úgy, ahogy van. Ezt Mocikoto-val nem tudnánk mockolni, PowerMock-kal viszont igen!

package stock;

import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.assertEquals;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import static org.powermock.api.mockito.PowerMockito.*;

@RunWith(PowerMockRunner.class)
@PrepareForTest(fullyQualifiedNames = "stock.*")
public class StockLogicTest {
    @Test
    public void testCalculateNextPrice() {
        mockStatic(StockDao.class);
        when(StockDao.readStockInfo(3)).thenReturn(new Stock(3, "MyProduct", 125.2, 0.07));

        double result = StockLogic.calculateNextPrice(3);

        assertEquals(result, 133.964, 0.001);
    }
}

Ez a példa tehát elsősorban a statikus függvények mockolását illusztrálja, de azt is bemutatja, hogy ugyanazzal a módszerrel final függvényeket is tudunk mockolni.

Privát függvények mockolása

A Mockito privát függvények mockolására sem alkalmas, a PowerMock igen. Valójában a látszat ellenére igen ritka az, hogy erre legyen szükség, mivel a tesztelendő osztályok a mockolandó osztályok publik függvényeit használják. Ekkor lehet erre szükség, ha a mockolandó osztály publikus metódusait eredeti funkciójukban szeretnénk megtartani, vagy akár pont azokat szeretnénk tesztelni. Az egyszerűség érdekében most egy ilyen példát nézünk meg. Tehát mivel mindenképp szükség van az osztály bizonyos eredeti funkcióira, a privát függvények mockolása és a "kémkedés" kéz a kézben jár.

Példaként tekintsük a már bemutatott osztályt, amely egy elképzelt adatbázis kiolvasást emel négyzetre, annyi különbséggel, hogy az adatbázis olvasás legyen privát függvény:

[[/code]]
package math;

public class MathDb {
private int readNextValue() {
int result = 0;
// read value from database
return result;
}

public int squareOfNextValue() {
int value = readNextValue();
return value * value;
}
}
[[/code]]

Az erre vonatkozó egységteszt:

package math;

import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.assertEquals;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import static org.powermock.api.mockito.PowerMockito.*;

@RunWith(PowerMockRunner.class)
@PrepareForTest(fullyQualifiedNames = "math.*")
public class MathDbTest {
    @Test
    public void testSquareOfNextValue() throws Exception {
        MathDb mathDbMock = spy(new MathDb());
        doReturn(5).when(mathDbMock, "readNextValue");

        int result = mathDbMock.squareOfNextValue();

        assertEquals(25, result);
    }
}

Látható, hogy az eljárás nevét idézőjelbe kell tenni, ennélfogva természetesen csak futási időben derül ki, ha elgépeltünk valamit. Nem tökéletes tehát a megoldás, de működik!

Források

A téma fontosságát támasztja alá a hatalmas irodalom, ami az interneten fellelhető. Ebből szemezgetünk most. Jellemzően mindegyik jelentősebb Java programozással foglalkozó oldalnak van egységteszteléssel foglalkozó része, beleértve a mockolást is, sőt egyes oldalak (pl. a Baeldung) a tőlük nem megszokott módon külön oldalakra szedte a témát. A fent bemutatott mock keretrendszerek mellett egyéb könyvtárak is vannak, melyekről említést teszünk.

Általános célú könyvtárak

Amint azt láthattuk, a Java nyelv standard könyvtárkészlete messze többet tud, mint a programozási nyelvek többsége, pl. a C++. Viszont sok esetben ez sem elég: hiányos, félre csúszott. Gondoljunk pl. a következőkre:

  • Jó, hogy van a gyűjtemény keretrendszer, de úgy tűnik, leragadt a kezdeti adatszerkezeteken. A gazdagsága ellenére számos egyéb hiányzik: az a halmaz, amely ugyanazt az elemet többször is tartalmazhatja; az olyan asszociatív tömb, melyben egy kulcshoz több értéket is rendelhetünk; a megfordítható asszociatív tömb stb. Ráadásul sok esetben nehézkes a használata; pl. egyetlen utasítással csak igen nyakatekerten lehet létrehozni bármilyen adatstruktúrát.
  • A beépített könyvtárak gyakran aránytalanul hosszú, nehezen áttekinthető, nehezen megjegyezhető és sok esetben logikátlan felépítést eredményeznek. Gondoljunk csak a fájl kezelésre (ami ma sem egyszerű, kezdetben horror volt), az e-mail küldésre, az adatbázis kapcsolatra stb. Milyen jó lenne, ha tényleg csak annyit kellene írni, hogy ezt a szöveget ebbe a fájlba írd, azt a szöveget arra az e-mail címre küldd, stb. és az ismétlődő részek valamint a hibakezelés része el lenne fedve a programozó elől.
  • A Java szövegkezelője alapvetően jó, de némi tapasztalattal rájövünk, hogy lehetne jobb is. Szinte minden projektben van egy StringUtil osztály, melyben a projekt specifikus eljárásokon túl szinte teljesen általános műveletek is ismétlődnek. Pl. a beépített feldaraboló a vegytiszta esetben jól működik, de ha ennél picivel is többet szeretnénk, már az eredményt kell módosítani.

A programozó azt gondolná, hogy ezek a hiányosságok, melyek közösek számos projekten, előbb-utóbb bekerülnek a standard könyvtárak közé. A gyakorlat sajnos nem ezt mutatja; a kiegészítés nehézkesebbnek tűnik mint azt gondolnánk. Az egyes projektek saját megvalósulásain túl megjelentek olyan megvalósítások is, melyek már szinte szabványnak tekinthetők olyan értelemben, hogy számos kérdésre az a válasz, hogy a Java nyújtotta alap keretrendszerben ezt sajnos nem lehet megvalósítani, viszont használjuk ezt vagy azt. Ebben a szakaszban az Apache Commons könyvtárgyűjteményt, valamint a Google által kifejlesztett Guava nevűt fogjuk megnézni. Ezeknek a könyvtáraknak a puszta léte a bizonyítéka annak, hogy a Java nem elég rugalmas.

Guava

A Guava a Google válasza a Java standard könyvtárainak nyilvánvaló hiányosságaira. Ahhoz, hogy használni tudjuk, elérhetővé kell tennünk a programunk számára a megfelelő jar fájlt. Ha Maven-t használunk, akkor a következő sorokat kell beszúrnunk a projektünk pom.xml-ébe (célszerűen az aktuálisan legfrissebb verziót használva, ami az írás pillanatában a 28):

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>28.0-jre</version>
</dependency>

Ennek a szakasznak a célja az, hogy ízelítőt adjon a Guava lehetőségeiről, és nem célja a Guava részletes ismertetése. Ez utóbbira kiváló forrás a követhező wiki oldal: https://github.com/google/guava/wiki.

Fájl műveletek

A standard Java-ban a fájl műveletek nehézkesek. Noha az idők folyamán fejlődtek, azért érdemes megismerni a Guava fájlműveleteit, és reménykedni abban, hogy ezek hamarosan a standard könyvtárakba is bekerülnek. Néhány példa:

  • Szövegfájl minden sorának beolvasása: List<String> lines = Files.readLines(new File(fileName), Charsets.UTF_8);
  • Szöveg kiírása fájlba: Files.write("banana, orange, lemon, apple, plum".getBytes(), new File("fruits.txt")); (hozzáfűzés: append())
  • Üres fájl létrehozása: Files.touch(new File(fileName));

Matematika

Az IntMath, LongMath és BigIntegerMath olyan gyakori problémákra ad megoldásokat, mint például:

  • Ellenőrzött műveletek: alapból nincs túlcsordulás vizsgálat, tehát pl. a Integer.MAX_VALUE + Integer.MAX_VALUE eredménye -2, mely nehezen felderíthető hibákhoz vezet. Ellenben a IntMath.checkedAdd(Integer.MAX_VALUE, Integer.MAX_VALUE) kivételt dob.
  • Műveletek óriási számokkal, pl. BigIntegerMath.factorial(100).
  • Gyakori műveletek, pl. legnagyobb közös osztó: IntMath.gcd(15, 10).

Új gyűjtemények

A Guava számos új, igen hasznos gyűjtemény típust vezetett be, többek között:

  • Multiset: olyan halmaz, melyben az elemek ismétlődhetnek.
  • Multimap: olyan asszociatív tömb, melyben ugyanaz a kulcs több értéket is felvehet.
  • BiMap: kétirányú asszociatív tömb.
  • Table: olyan asszociatív tömb, melyben a kulcs egy táblázat egy cellája (tehát gyakorlatilag két érték adja a kulcsot).
  • ClassToInstanceMap: olyan asszociatív tömb, melyben a kulcs valamilyen típus.
  • Range: szakaszt definiál, pl. a Range<Integer> range = Range.closedOpen(3, 8); egy balról zárt, jobbról nyitott egészek szakaszát. Olyan műveleteket lehet segítségével végrehajtani, mint pl. azt, hogy egy elem benne van-e a szakaszban (range.contains(5)), vagy két szakasz metszetét (range.intersection(Range.open(5, 10))).

Lássunk egy BiMap példát! (A típusok a com.google.common.collect csomagban találhatóak.)

BiMap<Integer, String> biMap = HashBiMap.create();
biMap.put(1, "apple");
biMap.put(2, "peach");
biMap.put(3, "banana");
System.out.println(biMap.inverse().get("banana"));

Gráfok

A gráfok igen gyakori adatszerkezetek, és elég jól általánosíthatóak; igen meglepő, hogy a szabvány könyvtárak nem tartalmaznak gráfokat. A Guava ezt is pótolta. A következő 3 gráf típust használhatjuk:

  • Graph: egyszerű gráf, ahol az éleket adhatjuk meg. Tehát pl. két település között vezet-e közvetlenül út.
  • ValueGraph: olyan gráf, ahol az élek értéket tartalmazhatnak, pl. két település közötti legrövidebb út hossza.
  • Network: többszörös éleket is tartalmazhat. Ennek a központi eleme a csúcs helyett az él.

Az osztályok a com.google.common.graph csomagban találhatóak. Lássunk egy példát!

MutableValueGraph<String, Integer> roads = ValueGraphBuilder.directed().build();
roads.putEdgeValue("Budapest", "Szeged", 170);
roads.putEdgeValue("Budapest", "Debrecen", 230);
roads.putEdgeValue("Budapest", "Nyíregyháza", 230);
roads.putEdgeValue("Debrecen", "Nyíregyháza", 50);
System.out.println(roads.edgeValue("Budapest", "Szeged").get());

Talán egy következő verzióban az alap gráf algoritmusok megvalósítására is sor kerül (szélességi és mélységi bejárás, vagy akár a Dijkstra algoritmus).

Cache

Gyakori megoldandó feladat az, hogy egy drága művelettel beolvasunk adatokat, majd lehet, hogy nem sokkal később ismét beolvassuk. Ez esetben érdemes elmenteni a memóriában, vagy egy gyorsan elérhető helyen. Angolul ezt hívjuk cache-nek (ejtds: kes). Az egyébként, hogy mi a cache, elég viszonylagos, mert pl. egy hálózatról letöltött adat lokális diszkre mentése is cache-nek számít, és a modern memóriáknak létezik az a "legbelső bugyra", ami akár százszor gyorsabb elérést eredményez, mint a normál memóriából olvasás. A legtöbb esetben viszont cache alatt a memóriát értjük, ami már elég nagy és már elég gyors ahhoz, hogy erre a célra általában használni tudjuk.

Tehát a feladat az, hogy ha drágán beolvasunk valamilyen adatot, akkor miután feldolgoztuk, de dobjuk el rögtön, hanem mentsük el a memóriában, hátha szükség lesz még rá. Persze két dologra azért figyelni kell: ne mentsünk el túl sok mindent, mert a "jó lesz az még valamire" hozzáállás erőforrás pazarlásoz vezet, ill. túl sokáig se üljünk rajta, mert egy idő után a friss adat értékesebb, még ha drágábban is jutunk hozzá. A cache megvalósításánál tehát elég sok dologra figyelnünk kell, melyek kellően általánosak ahhoz, hogy érdemes legyen egy projektfüggetlen megvalósítást biztosítani. Ezt a Java nyelv karbantartói nem tették meg, viszont megtette a Guava! Lássunk egy példát!

LoadingCache<Integer, String> fruitCache = CacheBuilder.newBuilder()
    .maximumSize(10)
    .expireAfterAccess(5, TimeUnit.SECONDS)
    .build(new CacheLoader<Integer, String>() {
        @Override
        public String load(Integer carId) throws Exception {
            Thread.sleep(500);
            switch (carId) {
            case 0:
                return "apple";
            case 1:
                return "banana";
            case 2:
                return "peach";
            case 3:
                return "pear";
            case 4:
                return "grape";
            default:
                return null;
            }
        }
    });
try {
    for (int i = 0; i < 10; i++) {
        System.out.println(fruitCache.get(i % 4));
    }
} catch (ExecutionException e) {
    e.printStackTrace();
}

A példában egy drága műveletet szimulálunk, aminek a lekérdezése fél másodpercig tart. Láthatjuk, hogy a későbbi lekérdezések sokkal gyorsabbak. Érdemes eljátszani az időzítéssel, pl. úgy, hogy a lekérdezés után legalább 5 másodpercig várjon; ez esetben ismét belassul. Ill. ha a méretet túl alacsonyra vesszük, akkor is lassú lesz.

Megjegyzés: számomra sem volt világos ennek a pontos működése, emiatt leírom, hátha más is belebotlik ebbe a hibába. A cahce logikus felépítése a következő: lekérdezzük, benne van-e az adat a cache-ben, és ha benne van, akkor kiolvassuk onnan, ha nincs benne, akkor lekérdezzük és beletesszük. Itt kicsit más a megközelítés: egyből a cache-ből kell kiolvasni, és beletenni a load() függvény fogja. Oda kell tehát írnunk a külső hívást, és nem kell törődnünk a cache-eléssel. Kliens oldalon pedig csak a cache-t kell elérnünk. A cache-be tehát explicit nem teszünk bele soha semmit, az a keretrendszer feladata.

Apache Commons

Üzenetkezelés

Titkosítás

Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License