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 formátumok 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!

XML

Először olvassuk el a Szabványok oldal megfelelő szakaszát. A Java-ból való kezelést nagyrészt belső könyvtárakkal is meg tudjuk oldani, de hogy ne szakadjon ketté, meg logikailag is ide kívánkozik, együtt tárgyaljuk.

Igen tekintélyes mennyiségű a Java XML támogatását leíró szakirodalom; néhány közülük:

DOM

Az XML-nek egy jól definiált, logikus, egyértelmű fa struktúra felépítése van, amit DOM-nak (Document Object Model) hívunk. Ezt a struktúrát fel tudjuk építeni a memóriában is, amit megnézünk ebben a szakaszban.

Példaként hozzuk létre az alábbi fájlt, example.xml néven:

<?xml version="1.0"?>
<persons>
    <person id="1">
        <name>Sanyi</name>
        <age>34</age>
        <city>Budapest</city>
    </person>
    <person id="2">
        <name>Kata</name>
        <city>Budapest</city>
    </person>
    <person id="3">
        <name>Pista</name>
        <age>46</age>
        <city>Szeged</city>
    </person>
</persons>

Először elemezni (angolul parse) kell a dokumentumot, majd különféle bejárásokat tudunk végrehajtani: egy elem attribútumait vagy gyerekeit lekérdezni, tömbök esetén közvetlenül címezni, első gyerek → következő testvér jellegű lineáris bejárást végrehajtani stb. Az alábbi példa bemutat ezek közül pár lehetőséget. Ennek a leírásnak nem célja a DOM bejárási módozatok részletes bemutatása; szükség esetén a lent megadott hivatkozásokat olvassuk el.

import java.io.File;
import java.io.IOException;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

public class DomExample {
    public static void main(String[] args) {
        try {
            DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
            DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
            Document document = documentBuilder.parse(new File("example.xml"));
            document.getDocumentElement().normalize();

            Element rootElement = document.getDocumentElement();
            System.out.println("Root element: " + rootElement.getNodeName());
            NodeList childNodes = rootElement.getChildNodes();
            for (int i = 0; i < childNodes.getLength(); i++) {
                Node node = childNodes.item(i);
                if (node.getNodeType() == Node.ELEMENT_NODE) {
                    Element element = (Element)node;
                    String id = element.getAttribute("id");
                    System.out.println("id: " + id);

                    Node actualNode = element.getFirstChild();
                    while (actualNode != null) {
                        if (actualNode.getNodeType() == Node.ELEMENT_NODE) {
                            Element actualElement = (Element)actualNode;
                            System.out.println("  " + actualElement.getNodeName() + ": " + actualElement.getTextContent());
                        }
                        actualNode = actualNode.getNextSibling();
                    }
                    System.out.println();
                }
            }
        } catch (ParserConfigurationException | SAXException | IOException e) {
            e.printStackTrace();
        }
    }
}

A DOM feldolgozással kapcsolatos néhány oldal, példaprogramokkal:

XPath

A bejárás kissé nehézkes. A legtöbb esetben konkrét információt szeretnénk kigyűjteni. Az XPath szabványban lefektetett módszerrel egész bonyolult lekérdezéseket is végre tudunk hajtani. Az alábbi példában azoknak az embereknek a neveit kérdezzük le, akik Budapesten laknak. Az XPath kifejezés ez: /persons/person[city='Budapest']/name/text().

import java.io.File;
import java.io.IOException;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;

import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

public class XpathExample {
    public static void main(String[] args) {
        try {
            DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
            DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
            Document document = documentBuilder.parse(new File("example.xml"));

            XPathFactory xpathfactory = XPathFactory.newInstance();
            XPath xpath = xpathfactory.newXPath();
            XPathExpression expr = xpath.compile("/persons/person[city='Budapest']/name/text()");
            Object result = expr.evaluate(document, XPathConstants.NODESET);
            NodeList nodes = (NodeList) result;
            for (int i = 0; i < nodes.getLength(); i++) {
                System.out.println(nodes.item(i).getNodeValue());
            }
        } catch (ParserConfigurationException | SAXException | IOException | XPathExpressionException e) {
            e.printStackTrace();
        }

    }
}

További források az XPath használatára Java-ban:

SAX

A DOM felépítése a memóriában nagyobb XML fájlok esetén rendkívül erőforrás igényes (és most ne a mai számítógépekre gondoljunk elsősorban, hanem azokra, amelyek az XML fénykorában voltak tipikusak), ráadásul ez gyakran felesleges is. Ha az XML struktúrája nem túl bonyolult, viszont túl nagy (a fenti példát vehetjük alapul, de tegyük fel, hogy százmillió ember adatai vannak benne), akkor a DOM helyett használhatjuk a SAX-ot, ami a Simple API for XML rövidítése. Ez a dokumentum elemzés (parse) során minden egyes elemnél általunk megvalósított függvényt hív. Angolul az ilyen megoldást úgy hívjuk, hogy callback. A következő függvényeket kell megvalósítanunk:

  • startElement()): mindegyik XML elem elején hívódik.
  • characters(): két tag közötti szövegre hívódik.
  • endElement(): az XML elemek végén hívódik.

Lássunk egy példát!

import java.io.File;
import java.io.IOException;

import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;

import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

class SaxHandler extends DefaultHandler {
    private int indent = 0;

    private String formatAttributes(Attributes attributes) {
        int attrLength = attributes.getLength();
        if (attrLength == 0) {
            return "";
        }
        StringBuilder sb = new StringBuilder(", {");
        for (int i = 0; i < attrLength; i++) {
            sb.append(attributes.getLocalName(i) + ":" + attributes.getValue(i));
            if (i < attrLength - 1) {
                sb.append(", ");
            }
        }
        sb.append("}");
        return sb.toString();
    }

    private void indent() {
        for (int i = 0; i < indent; i++) {
            System.out.print("  ");
        }
    }

    @Override
    public void startElement(String uri, String localName, String qName, Attributes attributes) {
        indent++;
        indent();
        System.out.println(qName + formatAttributes(attributes) + " begin");
    }

    @Override
    public void endElement(String uri, String localName, String qName) {
        indent();
        indent--;
        System.out.println(qName + " end");
    }

    @Override
    public void characters(char ch[], int start, int length) {
        String chars = new String(ch, start, length).trim();
        if (!chars.isEmpty()) {
            indent++;
            indent();
            indent--;
            System.out.println(chars);
        }
    }
}

public class SaxExample {
    public static void main(String[] args) {
        try {
            SAXParserFactory saxParserFactory = SAXParserFactory.newInstance();
            SAXParser saxParser = saxParserFactory.newSAXParser();
            SaxHandler handler = new SaxHandler();
            saxParser.parse(new File("example.xml"), handler);
        } catch (ParserConfigurationException | SAXException | IOException e) {
            e.printStackTrace();
        }
    }
}

StAX

A StAX a Streaming API for XML processing rövidítése. Ez is része a standard Java könyvtáraknak, így a használatához nem kell importálni semmit. A SAX PUSH modell (tehát az elemző hívja a kezelő függvényeit, ahogy fent láthattuk), míg a StAX PULL modell (tehát annak ellenére, hogy a SAX-hoz hasonlóan a bejárás lineáris, itt az aktív fél a kezelő, ez hívja az elemző függvényeit, a DOM-hoz hasonlóan), így ebben ötvöződnek a DOM és a SAX előnyei.

Lássuk ezt egy példán keresztül; valósítsuk meg a fenti SAX megoldást StAX segítségével:

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.util.Iterator;

import javax.xml.stream.XMLEventReader;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.events.Attribute;
import javax.xml.stream.events.EndElement;
import javax.xml.stream.events.StartElement;
import javax.xml.stream.events.XMLEvent;

public class StaxExample {
    private static int indentLevel = 0;

    private static void indent() {
        for (int i = 0; i < indentLevel; i++) {
            System.out.print("  ");
        }
    }

    public static void main(String[] args) {
        try {
            XMLInputFactory xmlInputFactory = XMLInputFactory.newInstance();
            XMLEventReader reader = xmlInputFactory.createXMLEventReader(new FileInputStream("example.xml"));
            while (reader.hasNext()) {
                XMLEvent nextEvent = reader.nextEvent();
                if (nextEvent.isStartElement()) {
                    StartElement startElement = nextEvent.asStartElement();
                    indent();
                    indentLevel++;
                    System.out.println("Start element: " + startElement.getName().getLocalPart());
                    Iterator<Attribute> attributesIterator = startElement.getAttributes();
                    while (attributesIterator.hasNext()) {
                        Attribute attribute = attributesIterator.next();
                        indent();
                        System.out.println("Attribute " + attribute.getName().getLocalPart() + ": " + attribute.getValue());
                    }
                }
                if (nextEvent.isEndElement()) {
                    EndElement endElement = nextEvent.asEndElement();
                    indentLevel--;
                    indent();
                    System.out.println("End element: " + endElement.getName().getLocalPart());
                }
            }
        } catch (FileNotFoundException | XMLStreamException e) {
            e.printStackTrace();
        }

    }
}

További dokumentáció a témával kapcsolatban:

DTD validáció

Az XML-nek önmagában szigorú felépítése van, aminek a felépítését tovább specifikálhatjuk. Egy ilyen lehetőség a DTD (Document Type Definition), amit beletehetünk magába az XML fájlba, vagy akár külön fájlba is. Ez utóbbira lássunk egy példát! Módosítsuk az example.xml fájlt az alábbi módon:

<?xml version="1.0"?>
<!DOCTYPE persons SYSTEM "example.dtd">
<persons>
...

Valósítsuk meg a hivatkozott example.dtd fájlt is:

<!ELEMENT persons (person*)>
<!ELEMENT person (name,age?,city)>
<!ATTLIST person id CDATA "0">
<!ELEMENT name (#PCDATA)>
<!ELEMENT age (#PCDATA)>
<!ELEMENT city (#PCDATA)>

Ezzel megadtuk az XML felépítését: a gyökér elem a persons, ami akárhány person elemet tartalmazhat. A person felépítése: id attribútum, name elem, age opcionális elem és city elem. A domFactory.setValidating(true) függvényhívással megadhatjuk, hogy az elemzés során ellenőrizze-e az XML felépítésének helyességét:

import java.io.IOException;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

import org.xml.sax.ErrorHandler;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;

public class DtdValidation {
    public static void main(String[] args) {
        try {
            DocumentBuilderFactory domFactory = DocumentBuilderFactory.newInstance();
            domFactory.setValidating(true);
            DocumentBuilder documentBuilder = domFactory.newDocumentBuilder();
            documentBuilder.setErrorHandler(new ErrorHandler() {
                @Override
                public void warning(SAXParseException exception) throws SAXException {
                    System.out.println("Warning: " + exception);
                }

                @Override
                public void error(SAXParseException exception) throws SAXException {
                    System.out.println("Error: " + exception);
                }

                @Override
                public void fatalError(SAXParseException exception) throws SAXException {
                    System.out.println("Fatal error: " + exception);
                }
            });
            documentBuilder.parse("example.xml");
            System.out.println("DTD validation finished.");
        } catch (SAXException | IOException | ParserConfigurationException e) {
            e.printStackTrace();
        }
    }
}

Ez a példa úgy van felépítve, hogy működjön. De játsszunk el vele kicsit, pl. ideiglenesen töröljük ki az age utáni kérdőjelet (az jelzi ugyanis, hogy az az elem opcionális). Ez esetben - mivel Kata esetén nem adtuk meg - hibát fog jelezni. Ugyanakkor ellenőrzés nélkül rendben lefutnának a műveletek.

XSD validáció

A DTD mellett létezik egy másik, valójában jóval elterjedtebb XML validációs módszer is: ez az XSD (XML Schema Definition). EZ magát az XML-t definiálja, és az XSD felépítése önmagában XML. A fenti példa XSD definíciója például az alábbi lehet:

<?xml version="1.0" encoding="UTF-8" ?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">

<xs:element name="persons">
  <xs:complexType>
    <xs:sequence>
      <xs:element name="person" maxOccurs="unbounded">
        <xs:complexType>
          <xs:sequence>
            <xs:element name="name" type="xs:string"/>
            <xs:element name="age" type="xs:positiveInteger" minOccurs="0"/>
            <xs:element name="city" type="xs:string"/>
          </xs:sequence>
          <xs:attribute name="id" type="xs:positiveInteger" use="required"/>
        </xs:complexType>
      </xs:element>
    </xs:sequence>
  </xs:complexType>
</xs:element>

</xs:schema>

Az alábbi Java program végrehajtja az XSD szerinti validálást. Célszerű (bár nem kötelező) előbb kivenni az XML-ből a DTD validációt.

import java.io.File;
import java.io.IOException;
import javax.xml.XMLConstants;
import javax.xml.transform.stream.StreamSource;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
import org.xml.sax.SAXException;

public class XsdValidation {
    public static void main(String[] args) {
        try {
            Schema schema = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI).newSchema(new File("example.xsd"));
            schema.newValidator().validate(new StreamSource(new File("example.xml")));
            System.out.println("XSD validation passed.");
        } catch (SAXException | IOException e) {
            e.printStackTrace();
        }
    }
}

További források a témával kapcsolatban:

JAXB

A Java-ban tipikusan osztályokkal és objektumokkal dolgozunk. Létezik egy többé-kevésbé egyértelmű megfeleltetés a Java objektumorientált világa és az XML hierarchikus világa között. A fenti példában az egész XML megfelel egy személyekből álló listának, a személynek meg van azonosítója, neve, életkora és lakóhelye. A Java osztály persze nem tesz különbséget az XML attribútum és XML elem között, és van még néhány kisebb eltérés, de azért el tudjuk képzelni a konverziót a kettő között. A fenti módszerekkel viszont igen nehézkes ez a művelet, sok kódot kell hozzá írni, és problémát jelent a változtatások nyomkövetése is.

A JAXB ezt a problémát orvosolja: megteremti a kapcsolatot a két rendszer között. Ehhez megfelelően annotált osztályokat kell létrehoznunk, és ezután viszonylag kevés kóddal meg tudjuk oldani a konverziót. Két fogalommal ismerkedjünk meg:

  • Mashalling: ez az a folyamat, amikor objektumból XML-t hozunk létre.
  • Unmarshalling: az ellentétes folyamat, tehát amikor XML-ből hozzuk létre az objektumot.

Lássuk mindezt egy példán keresztül! Hozzuk létre a személy listát kezelő osztályt (Persons.java):

import java.util.List;
import javax.xml.bind.annotation.*;

@XmlRootElement
public class Persons {
    private List<Person> persons;

    public Persons() {}

    public Persons(List<Person> persons) {
        super();
        this.persons = persons;
    }

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

    @XmlElement(name = "person")
    public void setPersons(List<Person> persons) {
        this.persons = persons;
    }
}

Az @XmlRootElement és az @XmlElement annotációk elegendőek a feladat megoldásához. A személyt leíró osztály az alábbi (Person.java):

import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.*;

@XmlRootElement
@XmlType(propOrder = {"name", "age", "city"})
public class Person {
    private Integer id;
    private String name;
    private Integer age;
    private String city;

    public Person() {}

    public Person(Integer id, String name, Integer age, String city) {
        super();
        this.id = id;
        this.name = name;
        this.age = age;
        this.city = city;
    }

    @XmlAttribute
    public Integer getId() {
        return id;
    }

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

    public String getName() {
        return name;
    }

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

    public Integer getAge() {
        return age;
    }

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

    public String getCity() {
        return city;
    }

    @XmlElement
    public void setCity(String city) {
        this.city = city;
    }

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

Végül lássuk a marshalling és unmarshalling kódját:

import java.io.File;
import java.util.ArrayList;
import java.util.List;

import javax.xml.bind.*;

public class JaxbExample {
    public static void main(String[] args) {
        List<Person> persons = new ArrayList<Person>();
        persons.add(new Person(1, "Sanyi", 34, "Budapest"));
        persons.add(new Person(2, "Kata", null, "Budapest"));
        persons.add(new Person(3, "Pista", 46, "Szeged"));
        objectToXml(new Persons(persons));
        System.out.println();

        Persons generatedObject = xmlToObject(new File("example.xml"));
        System.out.println("Persons:");
        for (Person person: generatedObject.getPersons()) {
            System.out.println(person);
        }
    }

    private static void objectToXml(Persons persons) {
        try {
            JAXBContext jaxbContext = JAXBContext.newInstance(Persons.class);
            Marshaller jaxbMarshaller = jaxbContext.createMarshaller();
            jaxbMarshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true);
            jaxbMarshaller.marshal(persons, System.out);
        } catch (JAXBException e) {
            e.printStackTrace();
        }
    }

    private static Persons xmlToObject(File file) {
        try {
            JAXBContext jaxbContext = JAXBContext.newInstance(Persons.class);
            Unmarshaller jaxbUnmarshaller = jaxbContext.createUnmarshaller();
            Persons persons = (Persons)jaxbUnmarshaller.unmarshal(file);
            return persons;
        } catch (JAXBException e) {
            e.printStackTrace();
        }
        return null;
    }
}

Látható, hogy igen rövid kódrészlet elegendő a feldolgozás mindkét irányához.

Fontos megjegyzés: a JAXB csak a 8-as verzióig volt támogatott. A 9-es és 10-es verziókban elavulttá nyilvánították, és 11-esből pedig ki is vették. (Ez azt is jelenti, hogy ha 8-as módban használjuk a 11-es Java-t, akkor is fordítási hibára fut.) A következő külső függőségekkel tudjuk működésre bírni (https://stackoverflow.com/questions/52502189/java-11-package-javax-xml-bind-does-not-exist):

<dependency>
    <groupId>javax.xml.bind</groupId>
    <artifactId>jaxb-api</artifactId>
    <version>2.3.0</version>
</dependency>
<dependency>
    <groupId>com.sun.xml.bind</groupId>
    <artifactId>jaxb-core</artifactId>
    <version>2.3.0</version>
</dependency>
<dependency>
    <groupId>com.sun.xml.bind</groupId>
    <artifactId>jaxb-impl</artifactId>
    <version>2.3.0</version>
</dependency>

További források:

JAXB2

A fenti példának van még egy problémája: még egy ilyen egyszerű esetben is túl sokat kellett gépelni az XML struktúrákat tároló osztályok megvalósításánál. Valójában ez is egy igen jól automatizálható dolog, különösen akkor, ha rendelkezésünkre áll az XML sémát definiáló XSD fájl. A JAXB2 egy olyan Maven beépülő, amely fordítási időben legenerálja az XSD-ből a Java fájlokat. Lássunk erre is egy példát! Hozzunk létre egy Maven projektet, ahol a pom.xml a következőképpen 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>jaxb2example</artifactId>
    <version>1.0</version>

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

    <build>
        <plugins>    
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>jaxb2-maven-plugin</artifactId>
                <version>2.3</version>
                <executions>
                    <execution>
                        <id>xjc</id>
                        <goals>
                            <goal>xjc</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <sources>
                        <source>src/main/resources/example.xsd</source>
                    </sources>
                    <outputDirectory>${basedir}/src/main/java</outputDirectory>
                    <clearOutputDir>false</clearOutputDir>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Futtassuk le, pl. mvn clean install. Eredményül az src/main/java/generated könyvtárban: ObjectFactory.java és Persons.java; ez utóbbi tartalmazza a Person osztályt is. Ezt a fenihez hasonló módon használhatjuk:

package jaxb2example;

import java.io.File;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;

import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBException;
import javax.xml.bind.Marshaller;
import javax.xml.bind.Unmarshaller;

import generated.Persons;
import generated.Persons.Person;

public class Jaxb2Example {
    public static void main(String[] args) {
        Persons persons = new Persons();

        Person sanyi = new Person();
        sanyi.setId(BigInteger.valueOf(1));
        sanyi.setName("Sanyi");
        sanyi.setAge(BigInteger.valueOf(34));
        sanyi.setCity("Budapest");
        persons.getPerson().add(sanyi);

        Person kata = new Person();
        kata.setId(BigInteger.valueOf(2));
        kata.setName("Kata");
        kata.setCity("Budapest");
        persons.getPerson().add(kata);

        Person pista = new Person();
        pista.setId(BigInteger.valueOf(3));
        pista.setName("Pista");
        pista.setAge(BigInteger.valueOf(46));
        pista.setCity("Szeged");
        persons.getPerson().add(pista);

        objectToXml(persons);
        System.out.println();

        Persons generatedObject = xmlToObject(new File("example.xml"));
        System.out.println("Persons:");
        for (Person person: generatedObject.getPerson()) {
            System.out.println("Person [id=" + person.getId()
                + ", name=" + person.getName()
                + ", age=" + person.getAge()
                + ", city=" + person.getCity() + "]");
        }
    }

    private static void objectToXml(Persons persons) {
        try {
            JAXBContext jaxbContext = JAXBContext.newInstance(Persons.class);
            Marshaller jaxbMarshaller = jaxbContext.createMarshaller();
            jaxbMarshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true);
            jaxbMarshaller.marshal(persons, System.out);
        } catch (JAXBException e) {
            e.printStackTrace();
        }
    }

    private static Persons xmlToObject(File file) {
        try {
            JAXBContext jaxbContext = JAXBContext.newInstance(Persons.class);
            Unmarshaller jaxbUnmarshaller = jaxbContext.createUnmarshaller();
            Persons persons = (Persons)jaxbUnmarshaller.unmarshal(file);
            return persons;
        } catch (JAXBException e) {
            e.printStackTrace();
        }
        return null;
    }
}

Az előkészületek kicsit hosszabbak lettek, mivel a számunkra kényelmes konstruktort nem generálta le. (Egészen biztosan lehet ilyet is csinálni megfelelő konfigurációval, de ennek nem jártam részletesebben utána.)

További lehetőségek

Ennek a területnek (is) csak a felszínét karcoltuk. A bemutatott könyvtáraknak további lehetőségei vannak, ill. vannak más könyvtárak, amelyeknek esetleg lehetne olyan tulajdonságaik, amelyek miatt megfontolandó, hogy valós üzleti környezetben inkább azt használjuk. A teljesség igénye nélkül felsorolok néhány lényeges dokumentumot:

További XML feldolgozók:

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.

Egyéb formátumok

Régebben az XML, ma inkább a JSON az uralkodó adattípus. Egyéb formátumokkal viszonylag ritkán kell foglalkozni a gyakorlatban, így itt is csak megemlítjük. Két példa:

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). Ebben a részben érintjük a JAX-WS-t, ami egy korai, már eltűnőben levő megvalósítás, a ma prosperáló JAX-RS-t, valamint kitérünk az e-mail küldésre is.

Javasolt oldalak a témával kapcsolatban.

JAX-WS

Egykor ez volt 'A' web szolgáltatás a Java-ban, ma már eltűnőben van, helyét átvette a JAX-RS. De mivel még mindig létezik, áttekintjük a lényeget. Előtte tekintsünk át néhány fontosabb, vonatkozó rövidítést:

  • RPC: Remote Procedure Call (Távoli Eljáráshívás). Ahogy a neve is mutatja, arról van szó, hogy egy eljárást távolról hívunk meg.
  • JAX-RPC: Java API for XML-based RPC (Java API XML-alapú RPC-hez). Ez egy kezdeti távoli metódus hívás Java-n belül. Túlhaladott, helyét átvette a JAX-WS.
  • WSDL: Web Service Description Language (Web Szolgáltatás Leíró Nyelv). Ez egy (általában generált) XML dokumentum, ami leírja a web szolgáltatást: mi a szolgáltatás (többnyire függvény) neve, mik a paraméterek, mi a visszatérési érték stb.
  • SOAP: Simple Object Access Protocol (Egyszerű Objektum Elérő Protokoll). Ez valójában az az XML formázott üzenet, amely a tulajdonképpen hívást tartalmazza. Tehát pl. ez tartalmazza a hívott szolgáltatás konkrét paramétereit. Az üzenet maga két részből áll, fejléc (header) és törzs (body), amit egy boríték (envelope) tart egyben. Maga a hívás többnyire HTTP-n keresztül történik (bár történhet más protokollon keresztül is); ennek az az előnye, hogy még szigorúan limitált hálózati beállítások mellett is ez általában nyitva van, elérhető.
  • UDDI: Universal Description, Discovery, and Integration (Univerzális Leírás, Felfedezés és Integrálás). Ennek segítségével lehet lekérni a weben fellelhető web szolgáltatásokat, ill. lehet regisztrálni a miénket.
  • SAAJ: SOAP with Attachments API for Java (SOAP Csatolmányokkal API Java-hoz). A kezdetben ezzel lehetett SOAP üzeneteket létrehozni. Helyét átvette a JAX-WS.
  • JAX-WS: Java API for XML Web Services (Java API XML Web Szolgáltatásokhoz). Valójában tartalmazza a fent felsorolt összes technológiát. Technikailag a programozónak igazából néhány annotációt kell csak megadnia és per sor kódot megírnia, a többit elvégzi a keretrendszer. A fent említett JAX-RPC-t is tartalmazza, de van egy másik módszer is, a dokumentum alapú metódushívás. Programban ehhez egyetlen helyen kell megadni, hogy RCP vagy dokumentum legyen. Az egyes lehetőségek összehasonlításáról itt olvashatunk részletesen: https://www.ibm.com/developerworks/library/ws-whichwsdl/.

Lássunk egy példát! Ebben létrehozunk egy olyan web szolgáltatást, ami egy stringet vár paraméterül és egy stringet ad vissza, és mindössze annyit csinál, hogy elé írja azt, hogy hello. Nem kell hozzá külső könyvtár, a standard Java mindent tartalmaz. Először a szervert valósítjuk meg, majd a klienst. A szerver három részből áll: az interfészből, a megvalósításból és a publikáló kódból. Megfelelő forrásfájlba helyezve valósítsuk meg ezeket a következőképpen:

package webserviceexample;

import javax.jws.WebMethod;
import javax.jws.WebService;
import javax.jws.soap.SOAPBinding;
import javax.jws.soap.SOAPBinding.Style;

@WebService
@SOAPBinding(style = Style.RPC)
public interface JaxwsServer {
    @WebMethod
    String sayHello(String name);
}
package webserviceexample;

import javax.jws.WebService;

@WebService(endpointInterface = "webserviceexample.JaxwsServer")
public class JaxwsServerImpl implements JaxwsServer {
    @Override
    public String sayHello(String name) {
        return "Hello " + name;
    }
}
package webserviceexample;

import javax.xml.ws.Endpoint;

public class JaxwsPublisher {
    public static void main(String[] args) {
        Endpoint.publish("http://localhost:1977/hello", new JaxwsServerImpl());
        System.out.println("Service started.");
    }
}

Indítsuk el a fő programot, majd töltsük be a http://localhost:1977/hello?wsdl oldalt egy böngészővel. Ott láthatjuk a wsdl formázott web szolgáltatás leírást. Kísérletezzünk: állítsuk le a programot, a Style.RPC-t írjuk át erre: Style.DOCUMENT, majd indítsuk újra, és töltsük be ismét a fenti oldalt. Az RPC helyett a dokumentum stílusú leírást találjuk, ami kicsit más, nemcsak a formájában, hanem sokkal inkább a hívás mechanizmusában; a részletekbe itt ne megyünk bele.

Akár RPC, akár dokumentum stípusban publikáltuk a web szolgáltatást, a kliens hívó kódja ugyanaz:

package webserviceexample;

import java.net.MalformedURLException;
import java.net.URL;
import javax.xml.namespace.QName;
import javax.xml.ws.Service;

public class JaxwsClient {
    public static void main(String[] args) {
        try {
            URL url = new URL("http://localhost:1977/hello?wsdl");
            QName qname = new QName("http://webserviceexample/", "JaxwsServerImplService");
            Service service = Service.create(url, qname);
            JaxwsServer hello = service.getPort(JaxwsServer.class);
            String result = hello.sayHello("Csaba");
            System.out.println(result);
        } catch (MalformedURLException e) {
            e.printStackTrace();
        }
    }
}

A QName két paraméterét a fenti URL-en elérhető WSDL-ből tudjuk kiolvasni:

  • az első paraméter a targetNamespace,
  • a második a name.

A példában az egyszerűség érdekében a kliens ugyanott van, ahol a szerver. A valóságban ez inkább a következőképpen néz(ett) ki:

  • Van egy közös interfész, ami a példában a JaxwsServer osztályt tartalmazza.
  • A szerver a másik két szerver osztályt tartalmazza, plusz hivatkozik az interfészre.
  • A kliens a fenti kliens osztályt tartalmazza, és ez is hivatkozik az interfészre.
  • A szerver és a kliens kód egymástól független, akár a világ két távoli pontján történhet a fejlesztés és a futtatás is.

JAX-RS

A mai webes szolgáltatások túlnyomó többsége ún. REST lekérdezésen alapul: HTTP kérések, ahol a válasz tipikusan JSON formázottan érkezik. A JAX-RS része a szabványnak, a megvalósításhoz viszont külső könyvtárakat használunk, melyek közül kettő terjedt el: a RESTEasy (a JBoss terméke) és a Jersey (a GlassFish terméke).

Ahhoz, hogy futtatni tudjuk, szükségünk van webszerverre. Ezt részletesen az Enterprise Java oldalon tárgyaljuk; most a példában egy beépített módon is használható web szervert, a Jetty-t fogjuk segítségül hívni. A JAX-RS megvalósítások közül csak a Jersey-t mutatom be, melynek két oka van: egyrészt a lényegi rész ugyanaz mindkettőnél, másrészt sajnos csak ezt sikerült működésre bírnom.

Lássuk először a pom.xml fájlt:

<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>jettyjerseyexample</artifactId>
    <version>1.0</version>

    <dependencies>
        <dependency>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-server</artifactId>
            <version>9.4.12.RC2</version>
        </dependency>        
        <dependency>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-servlet</artifactId>
            <version>9.4.12.RC2</version>
        </dependency>        
        <dependency>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-util</artifactId>
            <version>9.4.12.RC2</version>
        </dependency>
        <dependency>
            <groupId>org.glassfish.jersey.containers</groupId>
            <artifactId>jersey-container-jetty-http</artifactId>
            <version>2.25</version>
        </dependency>
        <dependency>
            <groupId>org.glassfish.jersey.core</groupId>
            <artifactId>jersey-server</artifactId>
            <version>2.25</version>
        </dependency>
        <dependency>
            <groupId>org.glassfish.jersey.containers</groupId>
            <artifactId>jersey-container-servlet-core</artifactId>
            <version>2.25</version>
        </dependency>
    </dependencies>
</project>

Látható, hogy a Jetty és a Jersey oldalról is több függőséget kell beimportálnunk.

A Jetty szervert a főprogramban valósítjuk meg:

package hu.faragocsaba.jetty;

import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.glassfish.jersey.servlet.ServletContainer;

public class JettyServer {
    public static void main(String[] args) throws Exception {
        Server server = new Server(8080);

        ServletContextHandler context = new ServletContextHandler(ServletContextHandler.NO_SESSIONS);
        context.setContextPath("/jettyjerseyexample");
        server.setHandler(context);

        ServletHolder servletHolder = context.addServlet(ServletContainer.class, "/rest/*");
        servletHolder.setInitOrder(1);
        servletHolder.setInitParameter("jersey.config.server.provider.packages", "hu.faragocsaba.rest");

        server.start();
        server.join();
    }
}

A jersey.config.server.provider.packages beállításnál jelezzük, hogy a hu.faragocsaba.rest csomagban találjuk a megvalósítást.

package hu.faragocsaba.rest;

import javax.ws.rs.*;

@Path("/hello")
public class Hello {
    @GET
    @Path("/sayhello/{name}")
    public String sayHello(@PathParam("name") String name) {
        String result = "Hello " + name;
        return result;
    }
}

Igazából ez utóbbi a lényeg. Ha mindent jól csináltunk és elindítjuk a fő programot, akkor tetszőleges böngészővel megnyitva a http://localhost:8080/jettyjerseyexample/rest/hello/sayhello/Csaba oldalt kiírja azt, hogy Hello Csaba.

Megjegyzések:

  • A példában HTTP GET lekérést látunk. Lehetőségünk van POST, PUT és DELETE műveletek végrehajtására is a megfelelő annotáció megadásával.
  • A példában az paramétert az elérési útvonalban adjuk meg (@PathParam). További lehetőségek: @QueryParam (a GET kéréseknél a ? utáni, & jellel elválasztott paraméterek), @HeaderParam (HTTP fejléc információk), @CookieParam (süti paraméterek), @MatrixParam (pontosvesszővel elválasztott paraméterek), @FormParam (a HTTP POST esetében a formanyomtatvány paraméterei).
  • A példában a paraméter és a visszatérés típusa is egyszerű szöveg volt. A megfelelő függvény elé, pl. a @GET után írt @Consumes és a @Produces annotációkkal megadhatjuk a típust. pl. @Produces(MediaType.APPLICATION_JSON). (Ennek akkor van jelentősége, ha a visszatérési értéke nem String, hanem egy objektum, és automatikusan átalakítja JSON formázott stringgé.)
  • A visszatérési típus gyakran Response, ami gyakorlatilag egy megfelelő metaadatokkal ellátott HTTP válasz, HTTP kóddal, miegymással, kb. így: return Response.status(Response.Status.OK).entity(result).type(MediaType.APPLICATION_JSON).build();.

További források (melyek egy része nálam nem működött):

E-mail

E-mailt a Javamail segítségével tudunk küldeni. Ez a Java EE részét képezi, a standard Java-ban pedig a következőképpen tudjuk használni:

<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>mailexample</artifactId>
    <version>1.0</version>
    <dependencies>
        <dependency>
            <groupId>com.sun.mail</groupId>
            <artifactId>javax.mail</artifactId>
            <version>1.6.2</version>
        </dependency>
    </dependencies>
</project>

Forráskód, ami elküld egy üzenetet a Gmail szerver segítségével:

import javax.mail.*;
import javax.mail.internet.*;
import java.util.Properties;

public class GmailExample {
    private static final String smtpServer = "smtp.gmail.com";
    private static final String username = "[username]@gmail.com";
    private static final String password = "[password]";
    private static final String recipient = "[recipient]@gmail.com";

    public static void main(String[] args) throws AddressException, MessagingException {
        Properties properties = new Properties();
        properties.put("mail.smtp.port", "587");
        properties.put("mail.smtp.auth", "true");
        properties.put("mail.smtp.starttls.enable", "true");

        Session session = Session.getDefaultInstance(properties);
        Message message = new MimeMessage(session);
        message.setFrom(new InternetAddress(username));
        message.addRecipient(Message.RecipientType.TO, new InternetAddress(recipient));
        message.setSubject("test");
        message.setText("Test e-mail.");

        Transport transport = session.getTransport("smtp");
        transport.connect(smtpServer, username, password);
        transport.sendMessage(message, message.getAllRecipients());

        System.out.println("Done");
    }
}

Ahhoz, hogy használni tudjuk, a Gmail-ben be kell állítani azt, hogy a fiókhoz a kevésbé biztonságos szoftverek (mint a fenti is) hozzáférjenek: https://www.google.com/settings/security/lesssecureapps.

A további lehetőségekhez (pl. csatolmányok kezelése stb.) az alábbi leírásokban találunk információt:

Megjegyzés: volt olyan számítógép, amelyről nem működött a fenti példa; valószínűleg központilag le volt tiltva a megfelelő port.

Üzenetkezelés

JMS

A programozás oldalon vázolt elv, sőt, a Java-ban maga az interfész is szabványos. Ez utóbbit a Java üzenetkezelő szolgáltatás (Java Messaging Service, JMS) definiálja, és a Java szabvány része. Viszont ahogyan többféle adatbázis kezelő rendszer van, és nincs egy kitüntetett, ugyanúgy üzenetorientált köztes rétegből is több megvalósítás van. Ebben a szakaszban az ActiveMQ alapjaival ismerkedünk meg. Másik népszerű megoldás a JBoss által gondozott HornetQ, ami viszont az ActiveMQ részévé vált, Artemis néven.

Lássuk először azt, hogy programból hogyan tudjuk indítani a brókert! Ehhez az alábbi függőségekre van szükségünk a pom.xml fájlban:

        <dependency>
            <groupId>org.apache.activemq</groupId>
            <artifactId>activemq-all</artifactId>
            <version>5.15.11</version>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.10.1</version>
        </dependency>

(A Jackson nélkül nálam kivételt dobott induláskor.) A program lényege mindössze 3 sor:

import org.apache.activemq.broker.BrokerService;

public class ActiveMqEmbeddedBroker {
    public static void main(String[] args) throws Exception {
        BrokerService broker = new BrokerService();
        broker.addConnector("tcp://localhost:61616");
        broker.start();
    }
}

A másik lehetőség az önálló futtatás. Ehhez töltsük le az ActiveMQ-t erről az oldalról: http://activemq.apache.org/components/classic/download/, és csomagoljuk ki egy tetszőleges könyvtárba. A bin könyvtárát tegyük bele a PATH környezeti változóba, majd indítsuk el a programot a következőképpen:

activemq start

Az ActiveMQ alapértelmezett portja a 61616, amit megadtunk a fenti programban, ill. az önállóan futó mód ezt ki is naplózza.

A fogadó ill. a küldő kódjához adjuk hozzá a org.apache.activemq % activemq-all % 5.15.11 függőséget. (Ott a Jackson nem kell. Egyébként létezik külön csak a küldő ill. külön a fogadó számára létrehozott csomag; mi most azt használjuk, amelyben minden benne van).

Az első példa egy témára (topic) ír szöveget, mégpedig egymás után tízet:

import javax.jms.*;
import org.apache.activemq.ActiveMQConnectionFactory;

public class ActiveMqTopicProducer {
    public static void main(String[] args) throws JMSException {
        ActiveMQConnectionFactory connectionFactory = new ActiveMQConnectionFactory("tcp://localhost:61616");
        Connection connection = connectionFactory.createConnection();
        connection.start();
        Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE);
        Destination destination = session.createTopic("My Topic");
        MessageProducer producer = session.createProducer(destination);
        producer.setDeliveryMode(DeliveryMode.NON_PERSISTENT);
        for (char c = 'A'; c <= 'I'; c++) {
            String text = "Hello from Topic Producer! " + c;
            TextMessage message = session.createTextMessage(text);
            producer.send(message);
        }
        System.out.println("Message sent");
        session.close();
        connection.close();
    }
}

A kódból látható, hogy csak az ActiveMQConnectionFactory az ActiveMQ specifikus, minden más általános. A példa jóval egyszerűbb mint ahogy azt egy valódi program esetén meg kell valósítani: ott szükség van megfelelő kivételkezelésre, tranzakció kezelésre, ill. célszerű névszolgáltatást (JNDI) használni az üzenetkezelő rendszer megtalálásához, vagy legalább paraméter fájlból beolvasva. A kódban megadtuk még a kézbesítés módját, ami lehet PERSISTENT (amikor nem szabad, hogy elvesszen) vagy NON_PERSISTENT (ebben az esetben nem baj, ha elveszik).

A fogadó 3 szálon olvassa ki az üzeneteket:

import javax.jms.*;
import org.apache.activemq.ActiveMQConnectionFactory;

public class ActiveMqTopicConsumer extends Thread {
    public static void main(String[] args) throws JMSException {
        for (int i = 1; i <= 3; i++) {
            new ActiveMqTopicConsumer(i).start();
        }
    }

    private int n;

    public ActiveMqTopicConsumer(int n) {
        this.n = n;
    }

    @Override
    public void run() {
        try {
            ActiveMQConnectionFactory connectionFactory = new ActiveMQConnectionFactory("tcp://localhost:61616");
            Connection connection = connectionFactory.createConnection();
            connection.start();
            Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE);
            Destination destination = session.createTopic("My Topic");
            MessageConsumer consumer = session.createConsumer(destination);
            System.out.println("[" + n + "] Waiting for topic messages...");
            while (true) {
                Message message = consumer.receive();
                TextMessage textMessage = (TextMessage) message;
                System.out.println("[" + n + "] Received: " + textMessage.getText());
            }
        } catch (JMSException e) {
            e.printStackTrace();
        }
    }
}

Először a fogadót indítsuk el, utána a küldőt. Látni fogjuk, hogy mindegyik szál feldolgozza mindegyik üzenetet, az üzenetek sorrendje viszont csak szálon belül garantált:

[2] Waiting for topic messages...
[3] Waiting for topic messages...
[1] Waiting for topic messages...
[3] Received: Hello from Topic Producer! A
[2] Received: Hello from Topic Producer! A
[3] Received: Hello from Topic Producer! B
[3] Received: Hello from Topic Producer! C
[3] Received: Hello from Topic Producer! D
[3] Received: Hello from Topic Producer! E
[3] Received: Hello from Topic Producer! F
[3] Received: Hello from Topic Producer! G
[3] Received: Hello from Topic Producer! H
[3] Received: Hello from Topic Producer! I
[1] Received: Hello from Topic Producer! A
[1] Received: Hello from Topic Producer! B
[2] Received: Hello from Topic Producer! B
[1] Received: Hello from Topic Producer! C
[2] Received: Hello from Topic Producer! C
[1] Received: Hello from Topic Producer! D
[2] Received: Hello from Topic Producer! D
[1] Received: Hello from Topic Producer! E
[2] Received: Hello from Topic Producer! E
[1] Received: Hello from Topic Producer! F
[2] Received: Hello from Topic Producer! F
[1] Received: Hello from Topic Producer! G
[1] Received: Hello from Topic Producer! H
[2] Received: Hello from Topic Producer! G
[1] Received: Hello from Topic Producer! I
[2] Received: Hello from Topic Producer! H
[2] Received: Hello from Topic Producer! I

A sorba (queue) küldő komponens alig különbözik a fentitől:

import javax.jms.*;
import org.apache.activemq.ActiveMQConnectionFactory;

public class ActiveMqQueueProducer {
    public static void main(String[] args) throws JMSException {
        ActiveMQConnectionFactory connectionFactory = new ActiveMQConnectionFactory("tcp://localhost:61616");
        Connection connection = connectionFactory.createConnection();
        connection.start();
        Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE);
        Destination destination = session.createQueue("My Queue");
        MessageProducer producer = session.createProducer(destination);
        producer.setDeliveryMode(DeliveryMode.NON_PERSISTENT);
        for (char c = 'A'; c <= 'I'; c++) {
            String text = "Hello from Queue Producer! " + c;
            TextMessage message = session.createTextMessage(text);
            producer.send(message);
        }
        System.out.println("Messages sent");
        session.close();
        connection.close();
    }
}

A feliratkozás jellege itt minden esetben tartós, hiszen egy üzenetet pontosan egyszer dolgoznak fel. A fogadó itt is nagyon hasonlíthat a fentire, elég csak a createTopic()-ot createQueue()-re átírni, most viszont egy kicsit jobban átdolgozzuk, hogy a MessageListener interfésszel is megismerkedjünk (ami egyébként a fenti direkt olvasásnál jóval gyakoribb):

import javax.jms.*;
import org.apache.activemq.ActiveMQConnectionFactory;

public class ActiveMqQueueConsumer extends Thread implements MessageListener {
    public static void main(String[] args) throws JMSException {
        for (int i = 1; i <= 3; i++) {
            new ActiveMqQueueConsumer(i).start();
        }
    }

    private int n;

    public ActiveMqQueueConsumer(int n) {
        this.n = n;
    }

    @Override
    public void run() {
        try {
            ActiveMQConnectionFactory connectionFactory = new ActiveMQConnectionFactory("tcp://localhost:61616");
            Connection connection = connectionFactory.createConnection();
            connection.start();
            Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE);
            Destination destination = session.createQueue("My Queue");
            MessageConsumer consumer = session.createConsumer(destination);
            System.out.println("[" + n + "] Waiting for queue messages...");
            consumer.setMessageListener(this);
        } catch (JMSException e) {
            e.printStackTrace();
        }
    }

    public void onMessage(Message message) {
        try {
            TextMessage textMessage = (TextMessage) message;
            System.out.println("[" + n + "] Message received: " + textMessage.getText());
        } catch (JMSException e) {
            e.printStackTrace();
        }
    }
}

A lefutásnál mindegyik üzenetet pontosan egy fogadó dolgozza fel, de azt, hogy pontosan melyik, nem garantált. A lenti példában a 3-as sorszámú szál szóhoz sem jutott:

[2] Waiting for queue messages...
[1] Waiting for queue messages...
[3] Waiting for queue messages...
[1] Message received: Hello from Queue Producer! B
[2] Message received: Hello from Queue Producer! A
[2] Message received: Hello from Queue Producer! C
[1] Message received: Hello from Queue Producer! D
[1] Message received: Hello from Queue Producer! F
[2] Message received: Hello from Queue Producer! E
[1] Message received: Hello from Queue Producer! H
[2] Message received: Hello from Queue Producer! G
[2] Message received: Hello from Queue Producer! I

További információk:

Útválasztás

Az Apache Camel egy szabály alapú útválasztós motor. Ezzel a beérkező adatokat tudjuk feldolgozni és továbbítani. A Camel számos komponenst támogat; a teljes listát itt találjuk: https://camel.apache.org/components/latest/. Néhány példa:

  • file: ezzel egy adott könyvtárat lehet megadni, és a fájlokat olvassa be.
  • ftp, sftp: FTP szerverről történő olvasás ill. oda történő feltöltés.
  • jms: segítségével JMS queue-król és topicokról lehet olvasni ill. írni rá.
  • activemq: ha specifikusan az ActiveMQ-t szeretnénk használni.
  • kafka: adatkapcsolat a Kafka üzenetkezelővel.
  • direct: ezzel Camelen belül tudja egyik komponens küldeni a másiknak az adatot.
  • timer: időzítés, pl. másodpercenkénti üzenet indítás.
  • quartz: hasonló mint az előző, de sokkal szofisztikáltabb módon.
  • mock: egységtesztelésnél hasznos.

Az üzenet formája az Exchange. Ennek van bementi és kimeneti fejléce ill. törzse. Ha például fájlról van szó, akkor a fájl neve egy bemeneti fejléc információ, melyet a következőképpen tudunk lekérdezni: exchange.getIn().getHeader(Exchange.FILE_NAME, String.class). Erről bővebben itt olvashatunk: https://camel.apache.org/manual/latest/faq/using-getin-or-getout-methods-on-exchange.html.

A Camel megvalósítja az nagyvállalati integrációs mintákat (Enterprise Integration Patterns, EIP), melyről itt olvashatunk: https://camel.apache.org/manual/latest/enterprise-integration-patterns.html. Ennek segítségével számos műveletet végre lehet hajtani, melyek közül ismét csak párat elmítünk:

  • process: ennek segítségével fel tudjuk dolgozni, át tudjuk alakítani az üzenetet.
  • filter: ezzel tudjuk megszűrni az üzeneteket, akár XPath kifejezések, akár logikai értéket visszaadó függvény segítségével.
  • delay: ennek hatására valamennyi ideig vár mielőtt folytatja.
  • choice…when…otherwise: a klasszikus switch-case-default Cameles formája.
  • multicast: több végpontnak tudjuk elküldeni az üzenetet.
  • log: naplózni tudjuk az üzenetet a segítségével
  • loop: az üzenet bizonyos részein végig tudunk iterálni. Pl. végig tudunk lépkedni a fejlécben található kulcs-érték párokon, vagy akár fix számú lépést is tehetünk. Ezzel egyúttal fel is bontottuk az üzenetet több részre.
  • split: az üzenetet több részre tudjuk bontani, pl. akkor, ha az üzenet XML, és egy tömböt tartalmaz.
  • aggregate: segítségével több üzenetet tudunk egybe fűzni. Logikailag az előző inverze.
  • bean: egy osztály eljárását tudjuk segítségével meghívni.

Az útválasztás szabályait többféleképpen megadhatjuk. A teljes listát itt találjuk: https://camel.apache.org/manual/latest/languages.html. Néhány példa:

  • Java: magában a kódban adjuk meg a szabályokat.
  • Spring: a szabályokat XML fájlokban írjuk le. Így ez egy nem invazív megoldás, viszont XML-t kell hozzá szerkeszteni.
  • XPath: a szabályokat magát nem, a kiválasztást viszont megadhatjuk a segítségével.

A következő példa Java-ban definiálja a szabályt, a kimenet és a bemenet is könyvtár, és közben egy átalakítást hajt végre: a fájl nevét átnevezi úgy, hogy hozzácsapja az aktuális időbélyeget. A pom.xml-hez adjuk hozzá a következő függőséget:

        <dependency>
            <groupId>org.apache.camel</groupId>
            <artifactId>camel-core</artifactId>
            <version>3.0.0</version>
        </dependency>

A kód az alábbi:

import java.text.SimpleDateFormat;
import java.util.Date;
import org.apache.camel.*;
import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.impl.DefaultCamelContext;

class FileProcessor implements Processor {
    public void process(Exchange exchange) throws Exception {
        String originalFileName = exchange.getIn().getHeader(Exchange.FILE_NAME, String.class);
        Date date = new Date();
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH-mm-ss");
        String changedFileName = dateFormat.format(date) + " " + originalFileName;
        exchange.getIn().setHeader(Exchange.FILE_NAME, changedFileName);
    }
}

public class CamelExample {
    public static void main(String[] args) throws Exception {
        CamelContext camelContext = new DefaultCamelContext();
        camelContext.addRoutes(new RouteBuilder() {
          @Override
          public void configure() throws Exception {
            from("file://source?delete=true")
                .process(new FileProcessor())
                .to("file://destination");
          }
        });
        camelContext.start();
        Thread.sleep(10000);
        camelContext.stop();
        camelContext.close();
    }
}

A futtatáshoz hozzuk létre a source és destination könyvtárakat, a source-ba tegyük néhány példafájlt, majd futtassuk le a programot. Ha minden jól ment, akkor a forrás könyvtárból eltűntek a fájlok (ld. delete=true), a cél könyvtárban pedig megváltozott fájlnévvel megjelentek. A példában is látható, hogy a Camel a fluent API megközelítést használja.

További források:

Adatfolyamok

Kafka

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();
        }
    }
}

A fenti példához működő adatbázisra van szükség. Tesztelési céllal viszont használhatunk memóriában tárolt adatbázis is, ami a programmal együtt indul és áll le. (Az adatok tehát elvesznek.) Példaként a H2-t nézzük meg. Csak az eltéréseket mutatom meg. A pom.xml-ben nem a mysql függőséget kell betölteni, hanem a h2-t:

<project...>
    ....
    <dependencies>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <version>1.4.200</version>
        </dependency>
    </dependencies>
</project>

Egységteszteléshez kiválóan alkalmas ez a módszer; ebben az esetben célszerű odaírni ezt is: <scope>test</scope>.

A kódban a H2 adatbázis meghajtóját kell betölteni, és magát a sémát is létre kell hozni, mivel az nem létezik. Minden más változatlan:

        ...
        try (Connection connection = DriverManager.getConnection("jdbc:h2:mem:", "", "")) {
            // create
            PreparedStatement createPreparedStatement = connection.prepareStatement("CREATE TABLE PERSON(id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(200), age INT, addressid INT)");
            createPreparedStatement.executeUpdate();

            // insert
            ...

A JDBC 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.

További szabályok:

  • @Version: ezt használjuk az optimista lockoláshoz.
  • @Column(name="columnname"): megadhatjuk az oszlop nevét, ha eltér az adatbázisban.
  • A many-to-many kapcsolatot a @ManyToMany és a @JoinTable annotációkkal tudjuk vezérelni.
  • Ha a tábla néhány oszlopát külön osztályként szeretnénk megvalósítani, akkor az @Embeddable ill. @Embedded annotációkat használhatjuk.
  • Nagy méretű objektumok tárolása: @Lob.
  • Tranziens mezők: @Transient, transient, static, final.

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:

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;
    }
}

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

Az Apache Commons egy igen terjedelmes könyvtár halmaz, melynek kiinduló pontja az alábbi oldal: https://commons.apache.org/. Ezek igen sok területet érintenek; néhány közülük:

És még jó néhány.

Talán a legelterjedtebb közülük az Apache Commons Lang, ebből szemezgetünk ebben a részben. Ehhez az alábbi függőségre van szükségünk:

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.9</version>
        </dependency>

Az alábbi példákat nagyobbrészt a https://www.baeldung.com/java-commons-lang-3, https://www.baeldung.com/string-processing-commons-lang és https://www.baeldung.com/array-processing-commons-lang leírások alapján készítettem el.

String műveletek

A standard Java String osztály is igen gazdag, de számos művelet létezik, melyek gyakran előfordulnak, és az alapból hiányoznak. Íme néhány példa:

import org.apache.commons.lang3.StringUtils;

StringUtils.containsAny("abcdefghijk", "jkl"); // true
StringUtils.containsAny("abcdefghijk", "xyz"); // false
StringUtils.containsIgnoreCase("Faragó Csaba", "cs"); // true
StringUtils.countMatches("abacabadabaeabaf", "ab"); // 4
StringUtils.prependIfMissing("google.com", "www."); // www.google.com
StringUtils.prependIfMissing("www.google.com", "www."); // www.google.com
StringUtils.reverse("123456789"); // 987654321
StringUtils.rotate("123456789", 3); // 789123456
StringUtils.difference("apple 123", "apple 456"); // 456

És még természetesen számos egyéb.

Tömb műveletek

A Java Collection API kicsit háttérbe szorította a tömböket, így valójában az alap tömbökön eléggé foghíjas a rendszer által nyújtott lehetőségek. Az Apache Commons Lang igen gazdag művelet halmazt nyújt:

import org.apache.commons.lang3.ArrayUtils;

int[] array = {1, 2, 3, 4, 5};
System.out.println(ArrayUtils.toString(array));
ArrayUtils.insert(3, array, 1, 2, 3); // -> {1,2,3,1,2,3,4,5}, új tömb
ArrayUtils.removeAll(array, 1, 3); // -> {1,3,5}, új tömb
ArrayUtils.removeElements(array, 1, 3, 6); // -> {2,4,5}, új tömb
ArrayUtils.subarray(array, 1, 4); // -> {2,3,4}, új tömb
ArrayUtils.contains(array, 4); // true
ArrayUtils.reverse(array, 1, 4); // {1,2,3,4,5} -> {1,4,3,2,5}, helyben
ArrayUtils.swap(array, 0, 3, 2); // {1,4,3,2,5} -> {2,5,3,1,4}, helyben

Bizonyos műveletek (pl. reverse vagy swap) helyben történnek, mások (a példában az összes többi) új tömböt hoz létre.

Törtek

A tizedes tört nem létezik a standard Java-ban, pedig előfordulhat, hogy szükség van rá. A lebegőpontos aritmetika csak közelítése annak. Az Apache Commons Lang megvalósítása egyszerűsítést is tartalmaz:

import org.apache.commons.lang3.math.Fraction;

Fraction.getFraction(1, 6).add(Fraction.getFraction(1, 6)).toString(); // 1/3

Reflection

Az Apache Commons Lang reflection megvalósítására egy példa:

class Salute {
    public String name;

    public Salute(String name) {
        this.name = name;
    }

    public String sayHello() {
        return "Hello, " + name + "!";
    }
}

import org.apache.commons.lang3.reflect.ConstructorUtils;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.commons.lang3.reflect.MethodUtils;

Salute instance = ConstructorUtils.invokeConstructor(Salute.class, "Csaba");
System.out.println(FieldUtils.readField(instance, "name")); // Csaba
System.out.println((String)MethodUtils.invokeMethod(instance, "sayHello")); // Hello, Csaba!

Párok, hármasok

Tartja magát az elv, hogy az elem kettesek, elem hármasok stb. nem jók, helyette hozzunk létre osztályt, és nevesítsük a mezőket. Ez igaz is meg nem is. Ha ez alapból nyelvi elem lenne, akkor a Java-ban (ahol egy struktúra eléggé bőbeszédű) túl nagy enne a kísértés, hogy mindenre azt használjuk, ami a kód olvashatóságának rovására menne. Ugyanakkor azzal, hogy nincs, az gyakran tényleg feleslegesen kényszeríti a fejlesztőt felesleges osztályok létrehozására.

Az Apache Commons Lang tartalmaz elem ketteseket és elem hármasokat is. Mivel kell hozzá a megfelelő külső könyvtár, olyan könnyen azért nem fog elburjánozni a használata, de azért igen hasznos. Az elem kettesekből ráadásul módosítható és nem módosítható változat is létezik. A lenti példában egy módosítható elem kettesre és egy elem hármasra látunk példát:

import org.apache.commons.lang3.tuple.MutablePair;
import org.apache.commons.lang3.tuple.Triple;

MutablePair<String, Integer> pair = new MutablePair<String, Integer>("apple", 5);
pair.setRight(6);
System.out.println(pair.getLeft());

Triple<String, Integer, String> triple = Triple.of("apple", 5, "good");
System.out.println(triple.getLeft() + ", " + triple.getMiddle() + ",  " + triple.getRight());

Titkosítás

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