Kihagyás

6. gyakorlat - WEB API

A következő anyagrészek során Java servletekkel ismerkedhetünk meg. Itt fogunk némi betekintést nyerni abba, hogy hogyan is rakhatunk össze egy egyszerűbb webes API-t. Modernebb rendszerek használata esetén, mint pl. egy Spring Boot Web MVC Framework, már sok itt látott dolog automatizálható, és nem kell foglalkozzunk vele, azonban ahhoz, hogy nagyjából megérthessük mi történik ott, ahhoz nem árt egy kicsit alacsonyabb szintű rendszerekkel is foglalkozni egy rövid ideig.

Eddigi projektünket követve egy utazások és látnivalók felvételére készült alkalmazás fogunk elkészíteni.

Packing

Webes alkalmazás esetén ügyeljünk rá, hogy az alkalmazásunk war csomagolási kiterjesztésű legyen. Ezt a projektünk pom fájljában adhatjuk meg a következő módon:

1
<packaging>war</packaging>

ahol war = Web Application Archive.

Mivel jelenlegi alkalmazásunk egy annak megfelelő servlet konténerben fog futni, így ennek megfelelő csomagolást választunk neki, ez a war.

A tag-et ugyanarra a szintre kell elhelyezzük, mint akár a <groupId/>-t, vagy az <artifactId>-t. További referenciákért bátran látogassunk el a https://maven.apache.org/pom.html oldalra.

Projekt kezdése

Több mint valószínű, hogy sok-sok kiindulási template közül választhatnánk, mikor új webes projektet kezdünk, viszont ezek többsége mára már sajnos vagy függőség, vagy webtechnológia szintjén elavultabbá vált, így itt most egyiket sem fogjuk választani, összeszedjük magunknak, ami kell.

Webes applikációnkhoz készítsünk egy lehetőleg elkülönülő modult a következőképpen:

new-project-hi

Fontos, hogy válasszuk ki a megfelelő Parent attribútumot, valamint a groupId-t is úgy válasszuk meg, hogy az egyezzen a szülő package group azonosítójával (ha jót akarunk).

Servlet konténer

Jelen használt servlet konténerünk egy egyszerű Apache Tomcat lesz. Röviden, egy olyan szoftverről van szó, amely a Jakarta EE platform által specifikált eszközhalmaz egy minimális részét implementálja/tartalmazza. Erre majd a későbbiek során vissza térünk egy rövid pillanatra, de most elegendő egy rövid kitérőként tekinteni erre.

Fontosak lehetnek azonban a fenti linken található verziózások! Ha fejlesztés során olyan hibákkal találkozunk futtatás közben, mint pl. egy ClassNotFoundException, akkor gondolhatunk arra, hogy talán elcsúsztunk a verziókkal. Nagyon erős indikátor lehet erre a hibára, amennyiben implementációnk javax.* csomagokat használ, miközben a tomcat verziónk pl. ==10.x. Ebben az esetben ugyanis a tomcat implementáció már jakarta.* és NEM javax.* csomagokat keres. De erről a fenti linkeken többet olvashatunk.

Servlet Spec JSP Spec EL Spec WebSocket Spec Authentication (JASPIC) Spec Apache Tomcat Version Latest Released Version Supported Java Versions
6.1 4.0 6.0 2.2 3.1 11.0.x 11.0.0-M18 (alpha) 17 and later
6.0 3.1 5.0 2.1 3.0 10.1.x 10.1.20 11 and later
5.0 3.0 4.0 2.0 2.0 10.0.x (superseded) 10.0.27 (superseded) 8 and later
4.0 2.3 3.0 1.1 1.1 9.0.x 9.0.87 8 and later
3.1 2.3 3.0 1.1 1.1 8.5.x 8.5.100 7 and later
3.1 2.3 3.0 1.1 N/A 8.0.x (superseded) 8.0.53 (superseded) 7 and later
3.0 2.2 2.2 1.1 N/A 7.0.x (archived) 7.0.109 (archived) 6 and later (7 and later for WebSocket)
2.5 2.1 2.1 N/A N/A 6.0.x (archived) 6.0.53 (archived) 5 and later
2.4 2.0 N/A N/A N/A 5.5.x (archived) 5.5.36 (archived) 1.4 and later
2.3 1.2 N/A N/A N/A 4.1.x (archived) 4.1.40 (archived) 1.3 and later
2.2 1.1 N/A N/A N/A 3.3.x (archived) 3.3.2 (archived) 1.1 and later

A felhasználhatóság kedvéért a linken található táblázat itt is megjelenik, ám ezt csak óvatosan használjuk! Az írás pillanatában a Tomcat 11.x-es szoftver még erősen fejlesztés alatt áll, ezért a specifikációk változhatnak, míg az itt található táblázat nem feltétlenül fog!

Ahhoz, hogy legyen egy ilyen konténerünk, többféleképpen is eljárhatunk. Régebbi gyakorlatokhoz hasonlóan segíthet ebben egy elég széleskörűen használt plugin, amely egy beágyazott tomcat szervert fog futtatni számunkra.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<plugin>
    <groupId>org.codehaus.cargo</groupId>
    <artifactId>cargo-maven3-plugin</artifactId>
    <configuration>
        <!-- most egy beágyazott, v10 tomcat-et használunk -->
        <container>
            <containerId>tomcat10x</containerId>
            <type>embedded</type>
        </container>
        <configuration>
            <properties>
                <!-- fusson a 8080-as porton! -->
                <cargo.servlet.port>8080</cargo.servlet.port>
                <!-- low, medium vagy high -->
                <cargo.logging>medium</cargo.logging>
            </properties>
        </configuration>
        <deployables>
            <deployable>
                <groupId>${project.groupId}</groupId>
                <artifactId>${project.artifactId}</artifactId>
                <type>${project.packaging}</type>
                <properties>
                    <!-- ez azért jó, mert így nem lesz mindenféle default útvonal beállítva -->
                    <context>/</context>
                </properties>
            </deployable>
        </deployables>
    </configuration>
</plugin>

Nincs szükség külön tomcat szerver telepítésére, mára már teljesen jó és kijárt utak vannak standalone webservice-k készítésére. A gyakorlat során valószínűleg ez elegendő, és kényelmesebb is lehet. Amennyiben életutunk során szükségünk lenne egy külső szerverre való alkalmazás deploy-ra, úgy ellátogathatunk a régi gyakorlati részekhez.

Pluginok használatára már láttunk példát, evvel is hasonlóképp futtathatjuk alkalmazásunkat, mint egy javafx alkalmazás esetén.

1
./mvnw cargo:run

avagy a projekten belül:

1
./mvnw install && ./mvnw cargo:run -pl webapi

parancs segítségével futtathatjuk.

Térjünk rá ezután egy könnyebb útra, az alkalmazásunk indításához. Itt megjegyezzük, hogy ezen gyakorlatnak nem célja megismertetni a Spring Framework-öt, és annak csodáit, viszont a webapplikációnk indításához egy igen kellemes eszköz lehet.

1
2
3
4
5
6
7
8
@ServletComponentScan
@SpringBootApplication
public class App {

    public static void main(String[] args) {
        new SpringApplication(App.class).run(args);
    }
}

Egy ilyen - effektíve egy soros - Main class segítségével bármely számunkra kedves IDE használatával is indíthatjuk alkalmazásunkat.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>3.2.4</version>
</dependency>
<dependency>
    <groupId>org.apache.tomcat.embed</groupId>
    <artifactId>tomcat-embed-core</artifactId>
    <version>10.1.20</version>
</dependency>

Ehhez erre a két függőségre van szükségünk. Az elsőre a Spring Boot miatt, a másodikra a beágyazott Tomcat szerver miatt, melyet a keretrendszerünk ebben az esetben elindít számunkra. Egy beágyazott Tomcat szerver indítása nem túl veszélyes feladat, viszont jelenlegi gyakorlati anyagunk erre most nem terjed ki, így marad a plugin, vagy egy Spring Boot.

Modellek

A teljesség igénye nélkül, itt most megjelenítünk egy pár megvalósítás szempontjából fontosabb modell osztályt, melyek szükségesek lehetnek az API pontokkal való kommunikációhoz.

A konkrét utazások reprezentációjára szolgáló objektumok:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@ToString
public class Sight {

    private Long id;
    private String name;
    private BigDecimal price;
    private Integer openingHour;
    private Integer closingHour;
    private String description;
    private Integer popularity;
}

Egy create operáció után adott válasz formátuma:

1
2
public record InsertId(Object id) {
}

Ebben az esetben az Object, mint ID minden esetben Long típusú lesz, de meghagytuk a lehetőséget esetlegesen más típusú elsődleges kulcsoknak is.


Adatbázis módosítás után adott válasz (Update, vagy Delete).

1
2
public record ModifiedRows(int affectedRows) {
}

Egyes preferenciák beállítására szolgáló kérés formátuma.

1
2
3
4
5
public record Preferences(
        BigDecimal minPrice,
        BigDecimal maxPrice,
        Integer minPopularity) implements Serializable {
}

1
2
public record Message(String message) {
}

Servletek

A Java Servletek valamilyen féle alkalmazás-szerveren futó programok, melyeket egy köztes rétegként képzelhetünk el kliensek kérései (pl. HTTP), valamint alkalmazásunk/alkalmazásaink között.

Servleteink során a következő import-okat szinte biztosan használni fogjuk:

1
2
3
4
5
import jakarta.servlet.ServletConfig;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

Ők a következő függőségtől származnak:

1
2
3
4
5
6
7
<dependency>
    <groupId>jakarta.servlet</groupId>
    <artifactId>jakarta.servlet-api</artifactId>
    <version>6.0.0</version>
    <!-- Figyeljük meg a függőség scope-ját! -->
    <scope>provided</scope>
</dependency>

Ennél a pontnál kalandozzunk vissza az előző részek tartalmához. Említettük, hogy az alkalmazásunkat futtató servlet konténer (jelenleg Tomcat) implementál bizonyos specifikációkat. Adott konténer kiválasztása esetén a fejlesztő felelőssége, hogy utánajárjon annak, hogy a Jakarta EE specifikációk közül melyek azok, amelyeket tartalmaz a választott szoftver. A provided scope annyit mond most itt nekünk, hogy az evvel járó csomagokat elegendő fordítási időben behúzni, ugyanis ez majd a futás során valahonnan elérhető lesz. Ez a valahonnan jelenleg a Tomcat szoftvertől lesz, ő fogja biztosítani nekünk a szükséges jakarta osztályokat.

Kezdetben vegyük például egy egyszerű servlet elkészítését.

Teapot

Egy teás kanna!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Ez egy fontos annotáció, ugyanis ennek segítségével
// specifikálhatjuk, hogy milyen útvonalon fog élni az
// adott servletünk. Ha a HOST után, (bármely kedvenc
// http kliensünket (vagy itt most épp akár böngészőnket)
// használva) az itt leírt útvonalat adjuk meg, akkor ez
// a servlet fog reagálni a kéréseinkre.
@WebServlet(urlPatterns = "/teapot")
// Az is nagyon fontos továbbá, hogy osztályunk a HttpServlet
// class-ból öröklődjön.
public class TeapotServlet extends HttpServlet {

    // Itt a GET kérésekre való reagálási hajlamát adhatjuk meg
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        // Megadjuk a válasz formátumát. Most pl. text/html, de
        // ebben az esetben lehetne text/plain is. Van még jó sok,
        // nézzük meg őket!
        resp.setContentType(ContentType.TEXT_HTML.getMimeType());
        // Nem kötelező, de specifikálhatjuk a karakter kódolást is.
        resp.setCharacterEncoding(StandardCharsets.UTF_8.name());

        // Itt ez most egy teapot status. Általában OK == 200,
        // vagy hasonlókat fogunk inkább látni.
        // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/418
        resp.setStatus(418);  // Nincs beépített teapot válasz :(
        // megfogjuk a response writer-t
        // aztán írunk vele valami választ
        // most csak annyit, hogy teapot vagyok
        resp.getWriter().println("I am a teapot");
    }
}

A gyakorlat során főként POST kérésekkel fogunk foglalkozni. Bizonyos esetekben akár használhatnánk GET kéréseket is, azonban nem igazán akarunk olyan kéréseket tervezni, melyeknek fontos lenne a cache-elése, továbbá amennyiben kiegészítenénk az alkalmazásunkat olyan módon, hogy az valamilyen féle autentikációt is tartalmazzon, úgy megint csak oda jutnánk, hogy az egyes API endpoint-okat POST kérésekkel kellene megvalósítsuk.

Ilyen módon diplomatikusabb tehát, ha maradunk a POST kéréseknél. A fenti egyszerű esetben viszont, mivel olyanféle view adatokat közlünk a klienssel, így már inkább indokolt egy GET request használata.


Request

És hogy ezt hogyan is érjük el? Próbáljuk ki először egy kissé fapados módon! A következőkben, és itt is feltételezzük, hogy az alkalmazásunk a 8080-as porton fut.

Példa cURL segítségével a teapot servlet-hez:

1
curl http://localhost:8080/teapot

ugyanez fetch api használatával:

1
2
3
4
5
6
fetch("http://localhost:8080/teapot"
).then(resp => {
  return resp.text();
}).then(data => {
  console.log(data);
})

Read

Készítsük el elsőre a feladatunkhoz tartozó első servletként, a read servletet. Őt arra fogjuk használni, hogy az éppen megvalósított perzisztens tárhelytől adatokat kérjünk le. Mint a többi servlet megvalósítását, ezt is érdemes egy külön, pl. servlet csomag alá helyezni.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// Jelen servletünk több URL-t is elfogad, read és list alias alatt is hívhatjuk.
@WebServlet(urlPatterns = {"/api/sight/read", "/api/sight/list"})
public class SightReadServlet extends HttpServlet {

    // data access object, mellyel az utazásainkat kezelhetjük
    private Dao<Long, Sight> dao;
    // opcionális
    // validációra szolgáló factory, mely validációt kézzel is elvégezhetünk
    private ValidatorFactory validatorFactory;
    // egy opcionális logger
    // nem feltétele egy működő servletnek
    private final Logger logger = LoggerFactory.getLogger(getClass());

    // Az init metódus most nekünk csak a DataSource beszerzése miatt fontos,
    // azonban ebben a példában itt most található egy validátor is.
    @Override
    public void init(ServletConfig config) {
        // Honnan jön ez itt??? Később látni fogjuk!
        // Itt természetesen elegendő lehetne egy singleton-tól elkérni ezt,
        // de ha hosszútávon jót akarunk magunknak, akkor singleton-t csak akkor
        // alkalmazunk ha abszolút muszáj, vagy indokolt.
        var dataSource = (DataSource) config.getServletContext().getAttribute("ds");
        dao = new SightJooqDao(dataSource, SQLDialect.SQLITE);
        validatorFactory = (ValidatorFactory) config.getServletContext().getAttribute("val");
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        // Adott metódusaink belépésekor rögtön specifikálhatjuk is a válaszunk formátumát!
        resp.setContentType(ContentType.APPLICATION_JSON.getMimeType());
        // állíthatunk karakter kódolást is --> pl. UTF-8
        resp.setCharacterEncoding(StandardCharsets.UTF_8.name());
        // Az alap státusz kódunk egy OK (200) lesz.
        resp.setStatus(HttpStatus.SC_OK);

        // kérünk egy json szerializáló példányt
        var gson = JsonSupport.create();
        // beolvassuk a request body-t
        var reqModel = gson.fromJson(req.getReader(), ReadSightReq.class);
        var validator = validatorFactory.getValidator();
        Set<ConstraintViolation<ReadSightReq>> valResult = new HashSet<>();
        // amennyiben van request model, úgy validáljuk azt
        if (reqModel != null) {
            valResult = validator.validate(reqModel);
        }

        // opcionális: logolás
        valResult.forEach(el -> logger.error(ValidationLog.createLog(el)));
        // Amennyiben a validáció hibába, vagy hibákba ütközött, abban az esetben
        // szeretnénk ha a servlet pl. egy BAD_REQUEST (400) hibakóddal jelezné
        // problémáját. Amennyiben módunk van rá adhatunk némi információt is evvel
        // kapcsolatban, pl. egy error message formájában.
        if (!valResult.isEmpty()) {
            resp.setStatus(HttpStatus.SC_BAD_REQUEST);
            resp.getWriter().println(gson.toJson(new Error(valResult.stream()
                    .map(ValidationLog::createLog)
                    .collect(Collectors.joining("\n")))));
            return;
        }

        // Ha a kérés üres volt, akkor csak szimplán lekérjük az összes rekordot,
        Iterable<Sight> models;
        if (reqModel == null) {
            models = dao.findAll();
        }
        // máskülönben pedig szűrünk az adott kérésben szereplő modell szerint.
        else {
            models = dao.findAllByCrit(ConversionSupport.toModel(reqModel));
        }
        var modelList = StreamSupport.stream(models.spliterator(), false).toList();
        resp.getWriter().println(gson.toJson(modelList));
    }
}

Megvalósított servleteink esetén talán az egyik legfontosabb lépés a válasz írása. Itt és a következőkben egy egyszerűen használható, és népszerű json parser csomagot használunk: gson

További infókért nézzük meg a JSON szekciót.

Ezen kívül látható egy ContentType, egy HttpStatus, valamint egy StandardCharsets beállítás is. Az első kettő esetben a

1
2
3
4
5
<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpcore</artifactId>
    <version>4.4.16</version>
</dependency>

csomagot használtuk fel. A HttpStatus esetében használható a jakarta.servlet-api által szolgáltatott HttpServletResponse is. Használatukat tekintve, a jelenlegi szempontjaink alapján egyezőnek tekinthetők, azonban megjegyezzük, jelenleg csupán a kellemesebb olvashatóságot segítik elő (no magic variables), de érdemben ekvivalensek egy beégetett számmal/stringgel.

! Note !

dragon-danger Itt nem győzzük hangsúlyozni, hogy a kért válaszok formátuma nagyon fontos API tervezése esetén. Amennyiben ez eltér a megrendeléstől, úgy a ráépülő applikációk egyike sem fogja érteni a szerver által küldött válaszokat, itt nincs "pardon". Ha egy adott API endpoint egy alma és egy kukac mezőkkel rendelkező JSON-t vár, pl. String és Integer típusokat feltételezve, akkor amennyiben nem olvasható az adott válasz ebben az adott formátumban, úgy az egész mehet a levesbe!


Request

Példa az összes rekord lekérésére:

1
curl --request POST http://localhost:8080/api/sight/list

Ugyanez szűréssel:

1
curl --header "Content-Type: application/json" --data '{"id": 3}' --request POST http://localhost:8080/api/sight/list

Fetch API:

1
2
3
4
5
6
7
8
9
fetch("http://localhost:8080/api/sight/list", {
  "method": "post",
  "body": JSON.stringify({"name": "a"}),
  "headers": { "Content-Type": "application/json" }
}).then(
  resp => resp.json()
).then(data => {
  console.log(data);
})

Create

A create servletünk nagyon hasonló alapokra épül, mint akár a read, csupán a doPost megvalósításában lesznek eltérések. Ezen kívül természetesen az is különböző lesz, hogy milyen url-pattern alatt fogjuk elérni, melyet ne feledjünk el a megfelelő annotáció segítségével beállítani. Ezen URL-ek esetünkben pl. lehetnek a következők: "/api/sight/create", "/api/sight/insert", "/api/sight/add"

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Override
private void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    resp.setContentType(ContentType.APPLICATION_JSON.getMimeType());
    resp.setCharacterEncoding(StandardCharsets.UTF_8.name());
    resp.setStatus(HttpStatus.SC_CREATED);

    var gson = JsonSupport.create();
    // első különbség az elfogadott kérés típusában
    var reqModel = gson.fromJson(req.getReader(), NewSightReq.class);
    var validator = validatorFactory.getValidator();
    var valResult = validator.validate(reqModel);

    valResult.forEach(el -> logger.error(ValidationLog.createLog(el)));
    if (!valResult.isEmpty()) {
        resp.setStatus(HttpStatus.SC_BAD_REQUEST);
        resp.getWriter().println(gson.toJson(new Error(valResult.stream()
                .map(ValidationLog::createLog)
                .collect(Collectors.joining("\n")))));
        return;
    }
    // request modell átalakítása tényleges db modellé
    // gyakorlatilag a mezők megfelelő értékeinek másolása történik
    var dbModel = ConversionSupport.toModel(reqModel);
    // adott model tényleges mentése az adatbázisba
    dao.save(dbModel);
    // json konvertálás -> "{"id": dbModel::id}"
    resp.getWriter().println(gson.toJson(new InsertId(dbModel.getId())));
}

Adott esetben akár be is olvashatnánk minden kérést egy adott osztályba, esetünkben a Sight osztályba, azonban vegyük észre, hogy itt most egy NewSightReq típust használunk arra, hogy a kliens kérését beolvassuk. Ez az osztály pl. a következőképpen nézhet ki:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@ToString
public class NewSightReq {

    @NotBlank
    private String name;
    @PositiveOrZero
    @NotNull
    private BigDecimal price;
    @Min(0)
    @Max(23)
    @NotNull
    private Integer openingHour;
    @Min(0)
    @Max(24)
    @NotNull
    private Integer closingHour;
    private String description;
    @Min(0)
    @Max(10)
    @NotNull
    private Integer popularity;
}

Itt a jakarta.validation-api-t használjuk, mint függőség.

1
2
3
4
5
<dependency>
    <groupId>jakarta.validation</groupId>
    <artifactId>jakarta.validation-api</artifactId>
    <version>3.0.2</version>
</dependency>

Tárolásra és kérésre alkalmas osztályainkat nagyon sok esetben érdemes lehet szétválasztani. Már csak azért is, mert egy kérés során (mint itt is) nem feltétlenül várjuk el ugyanazon paramétereket. Továbbá a validációja is elkülönülhet annak, hogy mit tekintünk valid inputnak, valamint annak is, hogy adatbázis mentéskor mit tekintünk egy érvényes rekordnak. Gondoljunk például csak bele a UNIQUE megkötésekre. Ezt bevitelkor nem tudjuk, és nem is akarjuk ellenőrizni, csupán a tényleges adatbázisba való mentés lépésekor.


Request

Példa egy create kérésre cURL használatával:

1
curl -header "Content-Type: application/json" --data '{"name": "sth new", "price": 93.15, "openingHour": 6, "closingHour": 18, "description": "A long description to show you how good this sight is.\nBe prepared!"; "popularity": 7}' --request POST http://localhost:8080/api/sight/create

Update

Update esetén szinte ugyanaz történik, mint egy create esetén, csupán arra kell figyeljünk, hogy a megengedett URL-eket ennek a servletnek megfelelően adjuk meg. Pl. api/sight/update, és/vagy api/sight/refresh legyen. Ezen kívül természetesen egy kicsit más, de nagyon hasonló a beolvasott kérés formátuma is, amely itt most ténylegesen csak annyiban különbözik, hogy az UpdateSightReq class tartalmaz egy id mezőt is. Továbbá szükséges még a megfelelő dao::updateById metódusát is meghívni. Válaszunk pedig egy affectedRows nevű mezőt tartalmaz, mely a módosított rekordok számosságát hivatott jelölni.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Override
private void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    resp.setContentType(ContentType.APPLICATION_JSON.getMimeType());
    resp.setCharacterEncoding(StandardCharsets.UTF_8.name());
    resp.setStatus(HttpStatus.SC_OK);

    var gson = JsonSupport.create();
    // update request beolvasása
    var reqModel = gson.fromJson(req.getReader(), UpdateSightReq.class);
    var validator = validatorFactory.getValidator();
    var valResult = validator.validate(reqModel);

    valResult.forEach(el -> logger.error(ValidationLog.createLog(el)));
    if (!valResult.isEmpty()) {
        resp.setStatus(HttpStatus.SC_BAD_REQUEST);
        resp.getWriter().println(gson.toJson(new Error(valResult.stream()
                .map(ValidationLog::createLog)
                .collect(Collectors.joining("\n")))));
        return;
    }
    // request modell átalakítása tényleges db modellé
    // gyakorlatilag a mezők megfelelő értékeinek másolása történik
    var dbModel = ConversionSupport.toModel(reqModel);
    // db update
    var affectedRows = dao.updateById(reqModel.getId(), dbModel);
    // majd válasz írása
    resp.getWriter().println(gson.toJson(new ModifiedRows(affectedRows)));
}

Láthatjuk, hogy gondosabban megtervezett servletek esetén nagyon minimális változtatás is elég lehet különböző funkciók megvalósításához. Ugyanakkor, ez jelen pillanatban másra is adhat utalást. Vegyük észre, hogy ez a servlet, és az előzőek esetén is igaz, hogy nagyon kevés tényleges funkcionalitással rendelkeznek egy ilyen egyszerű esetben. Ha nagyon fapadosak akarunk lenni, akkor az egész doPost redukálható lenne a következő lényegi sorokra:

1
2
3
4
5
6
@Override
private void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    var reqModel = new Gson().fromJson(req.getReader(), UpdateSightReq.class);
    resp.getWriter().println(new Gson().toJson(new ModifiedRows(
            dao.updateById(reqModel.getId(), ConversionSupport.toModel(reqModel)))));
}

Ez elmondható a többi servletünkről is. Természetesen fontos a validáció, de ha esetleg hajlamot éreznénk a részletekben való elveszéshez, akkor gondoljunk csak a fenti megvalósításra.

Gyakorlásképpen, és egy teljesen ideális világot feltételezve, írjuk át az összes eddigi servletet ilyen módon!


Request

1
curl -header "Content-Type: application/json" --data '{"id": 3, "name": "change me", "price": 24.42, "openingHour": 12, "closingHour": 20, "description": "Come here kind Sir!"; "popularity": 8}' --request POST http://localhost:8080/api/sight/update

Delete

Delete esetében nem is időzünk már sokat, ő már tényleg szinte ugyanaz, mint egy update.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
private void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    resp.setContentType(ContentType.APPLICATION_JSON.getMimeType());
    resp.setCharacterEncoding(StandardCharsets.UTF_8.name());
    resp.setStatus(HttpStatus.SC_OK);

    var gson = JsonSupport.create();
    var reqModel = gson.fromJson(req.getReader(), DeleteSightReq.class);
    var validator = validatorFactory.getValidator();
    var valResult = validator.validate(reqModel);

    valResult.forEach(el -> logger.error(ValidationLog.createLog(el)));
    if (!valResult.isEmpty()) {
        resp.setStatus(HttpStatus.SC_BAD_REQUEST);
        resp.getWriter().println(gson.toJson(new Error(valResult.stream()
                .map(ValidationLog::createLog)
                .collect(Collectors.joining("\n")))));
        return;
    }
    var affectedRows = dao.deleteById(reqModel.getId());
    resp.getWriter().println(gson.toJson(new ModifiedRows(affectedRows)));
}

Request

1
curl -header "Content-Type: application/json" --data '{"id": 3}' --request POST http://localhost:8080/api/sight/delete

Servlet context

Történetünk elején láthattuk, hogy kaptunk egy pár ingyen DataSource, valamint ValidatorFactory objektumot is. De honnan is jöttek ezek? Applikációnk indulásakor van egy fontos event, melyre listener-t is köthetünk Ez pedig a ServletContextListener::contextInitialized event. Egyéb event listenerekkel a gyakorlat során valószínűleg nem fogunk találkozni, de röviden feladatuk annyiban ki is merül, hogy megfelelő eventekre specifikálhatunk velük egy adott munkavégzési folyamatot. Azaz, "ha jön egy ilyen event, akkor hajtsd végre ezt az action-t".

Egy ilyen listener-t például a következőképpen implementálhatunk:

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

    // ő lesz majd a DataSource objektumunk az alkalmazásunk pusztulásáig
    private final DataSource dataSource;
    // szintén az alkalmazásunk végéig él, és a requestek validálásáért szolgál
    private final ValidatorFactory validatorFactory;

    public ContextListener() {
        // final mezők létrehozása
        dataSource = new DataSourceFactory().getDataSource();
        validatorFactory = Validation.buildDefaultValidatorFactory();
    }

    @Override
    public void contextInitialized(ServletContextEvent sce) {
        var dbSupport = new SqlDbSupport(dataSource);
        //var authSupport = new CookieAuthSupport(new AuthService(new UserJooqDao(dataSource, SQLDialect.SQLITE)), null, null);
        var authSupport = new NopAuthenticator();
        // inicializálunk minden "globális" változót, ami csak kell
        // ezeket majd BÁRMELY servlet-ből elérhetjük
        sce.getServletContext().setAttribute("ds", dataSource);
        sce.getServletContext().setAttribute("auth", authSupport);
        sce.getServletContext().setAttribute("val", validatorFactory);
        // memory db init
        dbSupport.createTablesIfNotExist();
        dbSupport.insertIntoTables();
    }

    @Override
    public void contextDestroyed(ServletContextEvent sce) {
        // amennyiben a DataSource closable, úgy zárjuk azt be
        if (dataSource != null && dataSource instanceof AutoCloseable) {
            try {
                ((AutoCloseable) dataSource).close();
            } catch (Exception e) {
                // micsoda??? valami eléggé félrement
                throw new RuntimeException(e);
            }
        }
        if (validatorFactory != null) {
            validatorFactory.close();
        }
    }
}

Ez a class pedig mehet is egy listener package alá.

JSON string kezelés

Ahhoz, hogy használhassuk a kiszemelt csomagunkat, először szeretnénk behúzni azt, mint függőség.

1
2
3
4
5
<dependency>
    <groupId>com.google.code.gson</groupId>
    <artifactId>gson</artifactId>
    <version>2.10.1</version>
</dependency>

A gson package segítségével objektumokat fogunk tudni egészen egyszerű módon JSON kompatibilis stringekké konvertálni.

Például:

1
2
3
4
5
6
7
8
9
class Message {
    public final String msg;
}

...

new Gson().toJson(new Message("hello there"));
// >>
// "{\"msg\": \"hello there\"}"

Természetesen előfordulhat, hogy olyan objektumokat is kell szerializálni, melyeket alapból nem ismer a gson package. Ekkor lehetőségünk van létrehozni saját osztályokat, melyek adott objektumok beolvasását, illetve kiírását végzik.

Lássunk erre példát alább egy egyszerű LocalDate esetén.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Segítségével LocalDate objektumot fogunk egy egyszerű stringé alakítani.
// Az interface felülírandó metódusa a `serialize` lesz.
class LocalDateSerializer implements JsonSerializer<LocalDate> {

    @Override
    public JsonElement serialize(LocalDate date, Type typeOfSrc, JsonSerializationContext context) {
        // a datetime stringek kövesék az ISO formátumot!
        return new JsonPrimitive(date.format(DateTimeFormatter.ISO_LOCAL_DATE));
    }
}

// Segítségével LocalDate objektumot fogunk parsolni egy stringből.
// Az interface felülírandó metódusa a `deserialize`.
class LocalDateDeserializer implements JsonDeserializer<LocalDate> {

    @Override
    public LocalDate deserialize(JsonElement jsonElement, Type type, JsonDeserializationContext context) throws JsonParseException {
        // json elem beolvasása stringként
        String string = jsonElement.getAsString();

        if (string.length() > 20) {
            // a datetime string tartalmaz időzóna információt is
            ZonedDateTime zonedDateTime = ZonedDateTime.parse(string);
            return zonedDateTime.toLocalDate();
        }

        // egyszerű string parse
        return LocalDate.parse(string);
    }
}

...

// Ezt pl. elhelyezhetjük egy saját utility class-ban.
// Lényegében ő egy kiegészített képességekkel rendelkező Gson objektumot fog létrehozni.

new GsonBuilder()
        .setPrettyPrinting()
        .registerTypeAdapter(LocalDate .class, new LocalDateSerializer())
        .registerTypeAdapter(LocalDate .class, new LocalDateDeserializer())
        .create();

Viszonykövetés, sütik

Egy újabb fontosabb, és jelenleg talán utolsó témakörünk valamilyen féle viszonykövetés. Valószínűleg mindenkinek ismerősek lehetnek már a cookie-k, melyeket most felhasználói preferenciák tárolására fogunk használni. A cookie-k úgy általában is jellemzően valamiféle felhasználói információt tartalmaznak, melyeket adott webszerver készít el egy kliens számára, melyek aztán kliens oldalon tárolódnak! Egy session-el ellentétben ez egy fontos különbség, ugyanis session-t használva a szerverünk megszűnne szigorú értelemben vett RESTful API-nak lenni. REST esetén ui. egy kitételünk, hogy a webszerver úgymond állapottól mentes (stateless) legyen, azaz szerveroldalon ne tároljon semmiféle információt előző kérésekről/válaszokról. Minden kérés önmagában is végrehajtható kell legyen, rendelkeznie kell a kérés elvégzéséhez szükséges összes információval (pl. user adatok). A cookie-k kliens oldalon tárolódnak, így ezt a kérdéskört meg is oldják.

A sütiken kívül természetesen léteznek ennek megoldására egyéb webtechnológiák is, de itt most egyszerűségüknél fogva a cookie-kat választjuk.

Lássuk hogyan is készíthetünk egy cookie gyártó servletet!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// urlPatterns --> /api/pref, /api/preferences
@Override
private void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    resp.setContentType(ContentType.TEXT_PLAIN.getMimeType());
    resp.setStatus(HttpStatus.SC_NO_CONTENT);

    var gson = new Gson();
    var pref = gson.fromJson(req.getReader(), Preferences.class);
    var validator = validatorFactory.getValidator();
    var valResult = validator.validate(pref);

    valResult.forEach(el -> logger.error(ValidationLog.createLog(el)));
    if (!valResult.isEmpty()) {
        resp.setStatus(HttpStatus.SC_BAD_REQUEST);
        resp.getWriter().println(gson.toJson(new Error(valResult.stream()
                .map(ValidationLog::createLog)
                .collect(Collectors.joining("\n")))));
        return;
    }

    // cookie = new Cookie("preferences", someValue)
    var cookie = CookieBin.wrapCookie(ManagedCookies.PREFERENCES.toString(), pref);
    // mindenki számára láthatóvá tesszük a cookie-t adott host alatt
    cookie.setPath("/");
    // a cookie egészen az adott kliens bezárásáig maradjon életben
    cookie.setMaxAge(-1);
    // hozzáadjuk a sütit a válaszhoz, hogy kliensünk eltárolhassa
    resp.addCookie(cookie);
}

A ManagedCookies egy egyszerű enum, ahol a kezelt sütijeink nevét tároljuk:

1
2
3
4
5
6
7
8
public enum ManagedCookies {
    PREFERENCES {
        @Override
        public String toString() {
            return PREFERENCES.name().toLowerCase();
        }
    },
}

A fenti servletben a user preferenciák szerializálását is megfigyelhetjük CookieBin::wrapCookie, ahol a következő megfontolások mentén járunk el:

  • vannak user adataink, most preferenciák, amit együtteset szeretnénk kezelni
  • HttpServlet használata esetén a cookie management kicsit limitált olyan tekintetben, hogy csak szöveges kulcsok alatt, szöveges értékeket tárolhatunk.
  • így tehát szeretnénk a kezelt objektumokat egy egyértelmű string formátummá alakítani

Mindezek feloldására egyszerűen megköveteljük, hogy az adott cookie egy szerializálható objektum legyen. Ezt megtehetjük például a következő módon:

1
2
3
// Először egy SerializationUtils.serialize(cookie) segítségével az adott objektumot
// egy megfelelő byte[] kóddá alakítjuk, majd ezt kódoljuk egy standard base64 string formátumba.
new Cookie("some key", Base64.getEncoder().encodeToString(SerializationUtils.serialize(cookie)));

A fenti módon adott kulcs alatt tudunk tárolni bármilyen szerializálható objektumot. Alább pedig lássuk, hogy hogyan deszerializáljuk ugyanezt az objektumot.

1
2
3
// Először dekódoljuk a base64 string-et, majd deszerializáljuk az osztájunkat,
// hogy visszakapjuk a preferenciákat (vagy egyebet) tartalmazó osztályunkat.
SerializationUtils.deserialize(Base64.getDecoder().decode(cookie.getValue()));

Az implementációban felhasználtuk a org.apache.commons.lang3.SerializationUtils osztályt.

1
2
3
4
5
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.14.0</version>
</dependency>

Tegyük hozzá ezt a funckionalitást a read servlethez, hogy valóban csak a user által beállított preferenciák, mint szűrési feltétel alapján történjek objektumot lekérése.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@Override
private void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    resp.setContentType(ContentType.APPLICATION_JSON.getMimeType());
    resp.setCharacterEncoding(StandardCharsets.UTF_8.name());
    resp.setStatus(HttpStatus.SC_OK);

    var gson = JsonSupport.create();
    var reqModel = gson.fromJson(req.getReader(), ReadSightReq.class);
    var validator = validatorFactory.getValidator();
    Set<ConstraintViolation<ReadSightReq>> valResult = new HashSet<>();
    if (reqModel != null) {
        valResult = validator.validate(reqModel);
    }

    valResult.forEach(el -> logger.error(ValidationLog.createLog(el)));
    if (!valResult.isEmpty()) {
        resp.setStatus(HttpStatus.SC_BAD_REQUEST);
        resp.getWriter().println(gson.toJson(new Error(valResult.stream()
                .map(ValidationLog::createLog)
                .collect(Collectors.joining("\n")))));
        return;
    }
    Iterable<Sight> models;
    if (reqModel == null) {
        models = dao.findAll();
    } else {
        models = dao.findAllByCrit(ConversionSupport.toModel(reqModel));
    }
    var modelList = StreamSupport.stream(models.spliterator(), false);

    // Itt pl. van egy újítás, ahol is az adott kéréstől elkapjuk a sütiket.
    var jar = req.getCookies();
    // Amennyiben nincsenek sütik :(,
    // úgy egyszerűen visszaadjuk szűrés nélküli modelljeinket.
    if (jar == null) {
        resp.getWriter().println(gson.toJson(modelList.toList()));
        return;
    }

    // Szűrünk egyet a preferencia kulcs alatt található sütikre.
    var prefCookies = Arrays.stream(jar).filter(c -> Objects.equals(c.getName(), ManagedCookies.PREFERENCES.toString())).toList();
    // Amennyiben ez üres, úgy nem történik semmi, ha pedig nem, akkor megtörténik a szűrés.
    if (!prefCookies.isEmpty()) {
        var pref = CookieBin.unwrapCookie(prefCookies.get(0), Preferences.class);
        // szűrés minimum ár alapján
        if (pref.minPrice() != null && pref.minPrice().doubleValue() > 0) {
            modelList = modelList.filter(el -> el.getPrice().compareTo(pref.minPrice()) >= 0);
        }
        // szűrés maximum ár alapján
        if (pref.maxPrice() != null && pref.maxPrice().doubleValue() > 0) {
            modelList = modelList.filter(el -> el.getPrice().compareTo(pref.maxPrice()) <= 0);
        }
        // szűrés népszerűség alapján
        if (pref.minPopularity() != null && pref.minPopularity() > 0) {
            modelList = modelList.filter(el -> el.getPopularity() >= pref.minPopularity());
        }
    }

    resp.getWriter().println(gson.toJson(modelList.toList()));
}

Evvel a doPost módosítással készen is lennénk egy egyszerűbb viszonykövetéssel, és annak egy lehetséges használatának bemutatásával.

Core

DAO

DAO megvalósításaink során egy jobb alternatíva lehet, ha Data Access Object-jeink adatbázis implementációtól függetlenek. Vagy legalábbis azon forrást ne cipeljék magukkal, hogy honnan is szedik a Connection által reprezentált adatbázis csatlakozásukat.

Erre egy jó megoldás, ha a DAO egyes implementációi többek között rendelkeznek evvel a konstruktor paraméterrel. Azaz:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Saját DAO implementáció, avagy mire is jó egy osztály
class MyDaoImpl {
  ...

  // DataSource kívülről fog jönni, nekünk csupán
  // példányosításkor kell eldöntenünk, hogy ki legyen az.
  public MyDaoImpl(DataSource dataSource) {
      this.dataSource = dataSource;
  }
  ...
}

Egy DataSource-nak kötelessége megvalósítani a getConnection metódust, így egy beégetett DriverManager::getConnection helyett könnyedén használhatjuk akár saját DataSource implementációnkat is, melyhez nem kötjük hozzá szigorúan az adott DAO implementációnkat.

Egy DataSource kiszolgálását elintézhetünk több módon is, de lássunk példaként egy Factory, és egy Singleton megvalósítás együttesét. Ezek természetesen egymástól függetlenül is használhatók.

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

    private final SQLiteDataSource dataSource;
    private final KeepAliveConnection keepAlive;

    private SingletonSQLiteJDBCDataSource() {
        keepAlive = new KeepAliveConnection();
        dataSource = new SQLiteDataSourceFactory().getDataSource();
    }

    public static SingletonSQLiteJDBCDataSource getInstance() {
        return Instance.INSTANCE;
    }

    // Ezt a metódust kellene majd használni, mikor egy DAO példányosítása történik.
    public DataSource getDataSource() {
        return dataSource;
    }

    private static final class Instance {
        private static final SingletonSQLiteJDBCDataSource INSTANCE = new SingletonSQLiteJDBCDataSource();
    }

    @Override
    public void close() throws Exception {
        try {
            DSService.super.close();
        }
        catch (Exception e) {
            keepAlive.close();
            throw e;
        }
    }
}

amely megvalósításban a DSService a következő interface-nek felel meg:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public interface DSService extends AutoCloseable {
  // szeretnénk ha biztosan rendelkezne egy ilyen metódussal,
  // hiszen ez az ok, amiért készül
  DataSource getDataSource();

  // amennyiben a ds objektumunk rendelkezik close metódussal,
  // úgy bezárjuk azt
  @Override
  default void close() throws Exception {
    if (this.getDataSource() != null && this.getDataSource() instanceof AutoCloseable) {
      ((AutoCloseable) this.getDataSource()).close();
    }
  }
}

A keepAlive connection esetünkben pedig egészen egyszerűen azért felelős, hogy adatbázisunk (amennyiben memóriában él) biztosan rendelkezzék legalább egy élő session-el. Erre azért van szükség, mert egy memory-db esetén az utolsó connection elzárásakor az adatbázis törlődni fog! Ez van, hogy kellemes tulajdonság, azonban jelenleg ez egy nem kívánatos mellékhatása lenne egy memória adatbázisnak. Tegyünk hát ez ellen!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public class KeepAliveConnection implements AutoCloseable {

    private final Connection connection;

    // Kérünk egy connection-t, melyet majd ahol kell életben tartunk.
    public KeepAliveConnection() {
        try {
            connection = DriverManager.getConnection(ConfigSupport.getDbUrl());
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void close() throws Exception {
        connection.close();
    }
}

A factory osztályunk pedig jelenleg a következő:

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

    private final SQLiteConfig config;
    private final String url;

    public SQLiteDataSourceFactory() {
        this(ConfigSupport.getDbUrl(), new SQLiteConfig());
    }

    public SQLiteDataSourceFactory(SQLiteConfig cfg) {
        this(ConfigSupport.getDbUrl(), cfg);
    }

    public SQLiteDataSourceFactory(String conn) {
        this(conn, null);
    }

    public SQLiteDataSourceFactory(String conn, SQLiteConfig cfg) {
        url = conn;
        config = cfg;
    }

    // itt készül el a datasource
    public SQLiteDataSource getDataSource() {
        var ds = new SQLiteDataSource(config);
        if (url != null) {
            ds.setUrl(url);
        }
        return ds;
    }
}

A datasource kreálása egy robusztusabb használat érdekében el van szeparálva, azonban teljesen mindegy, hogy ilyen módon készítjük el a DataSource objektumunkat, avagy egyszerűen csak singleton osztályunkban létrehozunk egyet valamiféle alapbeállítással, eredményünk használattól függően ugyanaz lehet.

Egy sqlite data source elkészítése csupán a következőket igényli:

1
2
var ds = new SQLiteDataSource();
ds.setUrl("jdbc:sqlite:some connection string");

jOOQ

jOOQ csomag használatával

1
2
3
4
5
<dependency>
    <groupId>org.jooq</groupId>
    <artifactId>jooq</artifactId>
    <version>3.19.6</version>
</dependency>

már valóban SQL dialektustól független DAO implementációkat készíthetünk! Ehhez csupán meg kell adjunk egy elérhetőséget adatbázisunkhoz, valamint azt, hogy milyen dialect az, amit használni szeretnénk. A jOOQ szinte bármely elképzelhető SQL parancsok létrehozásában segíthet nekünk:

  • SQLITE
  • H2
  • MYSQL
  • POSTGRES
  • ORACLE
  • MARIADB

és még sok más!

Egy lehetséges megvalósítás a fenti megfontolások figyelembevételével a következő lehet, például a látnivalók esetén.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
public class SightJooqDao implements Dao<Long, Sight> {

    // A tábla neve, amelyet használunk
    private final static String TABLE = "sight";
    // Szeretnénk majd kapni egy ds-t, amit itt tárolunk
    private final DataSource dataSource;
    // Szeretnénk kapni egy sql-dialect-et is
    private final SQLDialect sqlDialect;  // = SQLDialect.SQLITE

    // Egy nekünk megfelelő konstruktor
    public SightJooqDao(DataSource ds, SQLDialect dialect) {
        dataSource = ds;
        sqlDialect = dialect;
    }

    // Record osztályból beolvassuk a megfelelő mezőket.
    private Sight readRecord(Record record) {
        return new Sight(
                record.get("id", Long.class),
                record.get("name", String.class),
                record.get("price", BigDecimal.class),
                record.get("opening", Integer.class),
                record.get("closing", Integer.class),
                record.get("description", String.class),
                record.get("popularity", Integer.class));
    }

    // Készítünk egy Map-ot megfelelő mezőkkel, melyeknek
    // megadjuk, hogy amennyiben léteznek az objektum által
    // reprezentált mezők alatti értékek, úgy adatbázis
    // szinten is szeretnénk ezeket beállítani.
    private Map<Field<?>, Object> getSetValues(Sight model) {
        var set = new HashMap<Field<?>, Object>();
        if (model.getName() != null) {
            set.put(field(name("name")), model.getName());
        }
        if (model.getPrice() != null) {
            set.put(field(name("price")), model.getPrice());
        }
        if (model.getOpeningHour() != null) {
            set.put(field(name("opening")), model.getOpeningHour());
        }
        if (model.getClosingHour() != null) {
            set.put(field(name("closing")), model.getClosingHour());
        }
        if (model.getDescription() != null) {
            set.put(field(name("description")), model.getDescription());
        }
        if (model.getPopularity() != null) {
            set.put(field(name("popularity")), model.getPopularity());
        }
        return set;
    }

    @Override
    public void save(Sight model) {
        assert model != null;
        // felsoroljuk a mezőket melyeknek értékeket szeretnénk majd adni
        var columns = List.of(
                field(name("name")),
                field(name("price")),
                field(name("opening")),
                field(name("closing")),
                field(name("description")),
                field(name("popularity")));
        // felsoroljuk a beállítandó értékeket
        var values = List.of(
                model.getName(),
                model.getPrice(),
                model.getOpeningHour(),
                model.getClosingHour(),
                model.getDescription(),
                model.getPopularity());

        // mentés, és utolsó beillesztett ID lekérése
        var id = DSL.using(dataSource, sqlDialect)
                .insertInto(table(name(TABLE)))  // szeretnék rekordot hozzáadni
                .columns(columns)  // ezen oszlopok felhasználásával
                .values(values)  // ezen értékekkel
                .returningResult(field(name("id")))  // add vissza az id-t
                .fetchOne(field(name("id")), Long.class);  // query
        model.setId(id);
    }

    @Override
    public Optional<Sight> findById(Long id) {
        assert id != null;
        // lekérünk egy darab rekordot
        return Optional.ofNullable(DSL.using(dataSource, sqlDialect)
                .select()
                .from(TABLE)
                .where(field(name("id")).eq(id))
                .fetchOne(this::readRecord));
    }

    @Override
    public Iterable<Sight> findAll() {
        return DSL.using(dataSource, sqlDialect)
                .select()
                .from(TABLE)
                .fetch(this::readRecord);
    }

    @Override
    public Iterable<Sight> findAllByIds(Iterable<Long> ids) {
        assert ids != null;
        var where = StreamSupport.stream(ids.spliterator(), false)
                .map(id -> field(name("id")).eq(id))
                .toList();
        return DSL.using(dataSource, sqlDialect)
                .select()  // select statement
                .from(TABLE)  // honnan szeretném?
                .where(or(where))  // milyen feltételekkel?
                .fetch(this::readRecord);  // query
    }

    @Override
    public Iterable<Sight> findAllByCrit(Sight model) {
        assert model != null;
        var where = new ArrayList<Condition>();

        if (model.getId() != null) {
            where.add(field(name("id")).eq(model.getId()));
        }
        if (model.getName() != null) {
            where.add(field(name("name")).likeIgnoreCase("%" + model.getName().replace("%", "\\%") + "%").escape('\\'));
        }
        if (model.getPrice() != null) {
            where.add(field(name("price")).eq(model.getPrice()));
        }
        if (model.getOpeningHour() != null) {
            where.add(field(name("opening")).eq(model.getOpeningHour()));
        }
        if (model.getClosingHour() != null) {
            where.add(field(name("closing")).eq(model.getClosingHour()));
        }
        if (model.getDescription() != null) {
            where.add(field(name("description")).likeIgnoreCase("%" + model.getDescription().replace("%", "\\%") + "%").escape('\\'));
        }
        if (model.getPopularity() != null) {
            where.add(field(name("popularity")).eq(model.getPopularity()));
        }

        return DSL.using(dataSource, sqlDialect)
                .select()
                .from(TABLE)
                .where(and(where))
                .fetch(this::readRecord);
    }

    @Override
    public int deleteById(Long id) {
        assert id != null;
        return DSL.using(dataSource, sqlDialect)
                .deleteFrom(table(name(TABLE)))  // szeretnék törölni
                .where(field(name("id")).eq(id))  // ezen feltétellel
                .execute();  // dew it!
    }

    @Override
    public int deleteAllByIds(Iterable<Long> ids) {
        assert ids != null;
        var where = StreamSupport.stream(ids.spliterator(), false)
                .map(id -> field(name("id")).eq(id))
                .toList();
        return DSL.using(dataSource, sqlDialect)
                .deleteFrom(table(name(TABLE)))
                .where(or(where))
                .execute();
    }

    @Override
    public int updateById(Long id, Sight model) {
        assert id != null;

        // ez egy field -> value map
        var set = getSetValues(model);
        if (set.isEmpty()) {
            return 0;
        }
        return DSL.using(dataSource, sqlDialect)
                .update(table(name(TABLE)))  // csinálj egy update statementet
                .set(set)  // ezeket szeretném setelni
                .where(field(name("id")).eq(id))  // ezen feltétel mellett
                .execute();  // dew it!
    }

    @Override
    public int updateAllByIds(Iterable<Long> ids, Sight model) {
        assert ids != null;

        var set = getSetValues(model);
        if (set.isEmpty()) {
            return 0;
        }

        var where = StreamSupport.stream(ids.spliterator(), false)
                .map(id -> field(name("id")).eq(id))
                .toList();
        // By design choice if there are no conditions, we update all records
        if (where.isEmpty()) {
            where = List.of(noCondition());
        }

        return DSL.using(dataSource, sqlDialect)
                .update(table(name(TABLE)))
                .set(set)
                .where(or(where))
                .execute();
    }

    // db pusztítás
    @Override
    public int prune() {
        // pusztuljon el mindenki!
        return DSL.using(dataSource, sqlDialect)
                .deleteFrom(table(name(TABLE))).execute();
    }

    // rekord számlálás
    @Override
    public int count() {
        // hányan gyűltünk itt össze?
        return DSL.using(dataSource, sqlDialect)
                .fetchCount(table(name(TABLE)));
    }
}

A DAO pedig, ebben az esetben egy ennek megfelelő interface:

1
2
3
4
public interface Dao<ID, T> {
    // itt bújnak a fent megvalósított metódusok.
    ...
}

jOOQ esetén általában elmondható, hogy akkor használjuk megfelelően a könyvtárat, ha egyetlen egy, láncolható DSL utasítással hajtjuk végre a kívánt lekérdezést. Legyen ez a CRUD bármely eleme is.

Pl., amit ne csináljunk: how, and how not to do

1
2
3
4
5
6
7
var dsl = DSL.using(dataSource, sqlDialect);
var sel = dsl.select(field(name("id"))).from(TABLE);
var cond = sel.where(noCondition());
for (var id : ids) {
    cond = cond.or(field(name("id")).eq(id));
}
var res = cond.fetch();

Igaz, hogy az api úgy néz ki, mintha még szeretné is ezt, de nem. Ne kínozzuk se magunkat, se mást. Nyugodtan alakítsuk csak ki a fenti condition-t külön. Könnyedén össze lehet fűzni azt utólag.

1
2
3
4
5
6
7
var where = ids.stream().map(id -> field(name("id")).eq(id))
        .toList();
var record = DSL.using(dataSource, sqlDialect)
        .select()
        .from(TABLE)
        .where(or(where))  // Na így már sokkal szebb, és nem is kínlódtunk annyit.
        .fetch();

System

Version
Java 17
Maven 3.9.6

Utolsó frissítés: 2024-04-09 17:53:17