Bevezetés
Ebben a cikkben a JPA-val és a Hibernate-tel való kapcsolattérképezésbe merülünk el Java-ban.
A Java Persistence API (JPA) a Java ökoszisztéma perszisztencia-szabványa. Lehetővé teszi, hogy a tartományi modellünket közvetlenül az adatbázis struktúrájára képezzük le, és ezután rugalmasan manipulálhatjuk az objektumokat a kódunkban – ahelyett, hogy olyan nehézkes JDBC komponensekkel bajlódnánk, mint a Connection
, ResultSet
stb.
Egy átfogó útmutatót készítünk a JPA használatáról a Hibernate-tel mint szállítóval. Ebben a cikkben a kapcsolati leképezésekkel foglalkozunk.
- Útmutató a JPA-hoz a Hibernate-tel – Alap leképezés
- Útmutató a JPA-hoz a Hibernate-tel – Kapcsolati leképezés (itt vagy)
- Útmutató a JPA-hoz a Hibernate-tel: Inheritance Mapping (hamarosan itt lesz!)
- Guide to JPA with Hibernate – Querying (hamarosan itt lesz!)
Példánk
A kezdés előtt emlékezzünk vissza a sorozat előző részében használt példára. Az ötlet egy iskola modelljének leképezése volt, ahol a diákok a tanárok által tartott kurzusokat veszik fel.
Íme, így néz ki ez a modell:
Amint látjuk, van néhány osztály bizonyos tulajdonságokkal. Ezek az osztályok között kapcsolatok vannak. A cikk végére mindezeket az osztályokat leképezzük adatbázis-táblákra, megőrizve kapcsolataikat.
Ezeken túlmenően képesek leszünk objektumként lekérdezni és manipulálni őket, a JDBC gondjai nélkül.
Kapcsolatok
Először is definiáljunk egy kapcsolatot. Ha megnézzük az osztálydiagramunkat, láthatunk néhány kapcsolatot:
Tanárok és kurzusok – hallgatók és kurzusok – kurzusok és tananyagok.
A hallgatók és címek között is vannak kapcsolatok, de ezek nem számítanak kapcsolatoknak. Ennek az az oka, hogy egy Address
nem entitás (azaz nincs saját táblára leképezve). Tehát, ami a JPA-t illeti, ez nem kapcsolat.
A kapcsolatoknak van néhány típusa:
- One-to-Many
- Many-to-One
- One-to-One
- Many-to-Many
Vegyük sorra ezeket a kapcsolatokat.
One-to-Many/Many-to-One
Az One-to-Many és a Many-to-One kapcsolatokkal kezdjük, amelyek szorosan kapcsolódnak egymáshoz. Úgy is mondhatnánk, hogy ezek ugyanannak az éremnek az ellentétes oldalai.
Mi az Egy a sokhoz kapcsolat?
Amint a neve is mutatja, ez egy olyan kapcsolat, amely egy entitást sok más entitással kapcsol össze.
Példánkban ez egy Teacher
és az ő Courses
-jük lenne. Egy tanár több kurzust is tarthat, de egy kurzust csak egy tanár tart (ez a sok az egyhez perspektíva – sok kurzus egy tanárhoz).
Egy másik példa lehet a közösségi médiában – egy fotóhoz sok hozzászólás tartozhat, de minden egyes hozzászólás ehhez az egy fotóhoz tartozik.
Mielőtt belemerülnénk e kapcsolat leképezésének részleteibe, hozzuk létre az entitásokat:
@Entitypublic class Teacher { private String firstName; private String lastName;}@Entitypublic class Course { private String title;}
Most, a Teacher
osztály mezőinek tartalmazniuk kell a kurzusok listáját. Mivel ezt a kapcsolatot egy adatbázisban szeretnénk leképezni, amely nem tartalmazhat egy másik entitáson belüli entitások listáját – egy @OneToMany
annotációval fogjuk megjegyezni:
@OneToManyprivate List<Course> courses;
Mezőtípusként itt egy List
-et használtunk, de választhattunk volna egy Set
vagy egy Map
-t is (bár ez egy kicsit több konfigurációt igényel).
Hogyan tükrözi a JPA ezt a kapcsolatot az adatbázisban? Általában az ilyen típusú kapcsolatokhoz idegen kulcsot kell használnunk egy táblában.
A JPA megteszi ezt helyettünk, figyelembe véve a mi bemenetünket arra vonatkozóan, hogy hogyan kezelje a kapcsolatot. Ez a @JoinColumn
annotáción keresztül történik:
@OneToMany@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private List<Course> courses;
Ezzel az annotációval a JPA-nak megmondjuk, hogy a COURSE
táblának rendelkeznie kell egy TEACHER_ID
idegen kulcs oszlopkal, amely hivatkozik a TEACHER
tábla ID
oszlopára.
Adjunk hozzá néhány adatot ezekhez a táblákhoz:
insert into TEACHER(ID, LASTNAME, FIRSTNAME) values(1, 'Doe', 'Jane');insert into COURSE(ID, TEACHER_ID, TITLE) values(1, 1, 'Java 101');insert into COURSE(ID, TEACHER_ID, TITLE) values(2, 1, 'SQL 101');insert into COURSE(ID, TEACHER_ID, TITLE) values(3, 1, 'JPA 101');
És most ellenőrizzük, hogy a kapcsolat az elvárásoknak megfelelően működik-e:
Teacher foundTeacher = entityManager.find(Teacher.class, 1L);assertThat(foundTeacher.id()).isEqualTo(1L);assertThat(foundTeacher.lastName()).isEqualTo("Doe");assertThat(foundTeacher.firstName()).isEqualTo("Jane");assertThat(foundTeacher.courses()) .extracting(Course::title) .containsExactly("Java 101", "SQL 101", "JPA 101");
Láthatjuk, hogy a Teacher
példány lekérdezésekor automatikusan összegyűjtjük a tanár kurzusait.
Ha nem ismeri a Java-ban történő tesztelést, akkor érdemes elolvasnia a Unit Testing in Java with JUnit 5 című részt!
Owning Side and Bidirectionality
Az előző példában a Teacher
osztályt az egy a sokhoz kapcsolat birtokló oldalának nevezzük. Ez azért van így, mert ez határozza meg a két tábla közötti összekötő oszlopot.
A Course
-t ebben a kapcsolatban hivatkozó oldalnak nevezzük.
A Course
-t a kapcsolat birtokló oldalává tehettük volna, ha helyette a Teacher
mezőt a @ManyToOne
mezővel képezzük le a Course
osztályban:
@ManyToOne@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private Teacher teacher;
A Teacher
osztályban most nincs szükség a kurzusok listájára. A kapcsolat fordítva működött volna:
Course foundCourse = entityManager.find(Course.class, 1L);assertThat(foundCourse.id()).isEqualTo(1L);assertThat(foundCourse.title()).isEqualTo("Java 101");assertThat(foundCourse.teacher().lastName()).isEqualTo("Doe");assertThat(foundCourse.teacher().firstName()).isEqualTo("Jane");
Ezúttal a @ManyToOne
annotációt használtuk, ugyanúgy, ahogy a @OneToMany
-t.
Megjegyzés: Jó gyakorlat, hogy a kapcsolat birtokos oldalát abba az osztályba/táblába tesszük, ahol az idegen kulcsot fogjuk tartani.
Ez esetben tehát a kódnak ez a második verziója a jobb. De mi van akkor, ha továbbra is azt akarjuk, hogy a Teacher
osztályunk hozzáférést biztosítson a Course
listájához?
Ezt megtehetjük egy kétirányú kapcsolat definiálásával:
@Entitypublic class Teacher { // ... @OneToMany(mappedBy = "teacher") private List<Course> courses;}@Entitypublic class Course { // ... @ManyToOne @JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID") private Teacher teacher;}
Megtartjuk a @ManyToOne
leképezésünket a Course
entitáson. Azonban egy Course
-ek listáját is leképezzük a Teacher
entitáshoz.
Mit itt fontos megjegyezni, az a mappedBy
flag használata a @OneToMany
annotációban a hivatkozó oldalon.
Enélkül nem lenne kétirányú kapcsolatunk. Két egyirányú kapcsolatunk lenne. Mindkét entitás idegen kulcsokat képezne le a másik entitáshoz.
Mivel azt mondjuk a JPA-nak, hogy a mezőt már egy másik entitás képezi le. A Course
entitás teacher
mezője képezi le.
Eager vs Lazy Loading
Egy másik dolog, amit érdemes megjegyezni, az eager és a lazy loading. Mivel minden kapcsolatunkat leképeztük, bölcs dolog elkerülni, hogy a szoftver memóriáját túl sok entitás betöltésével terheljük, ha ez nem szükséges.
Tegyük fel, hogy a Course
egy nehéz objektum, és valamilyen művelethez az összes Teacher
objektumot betöltjük az adatbázisból. Ehhez a művelethez nem kell lekérnünk vagy használnunk a kurzusokat, de a Teacher
objektumokkal együtt mégis betöltődnek.
Ez pusztító hatással lehet az alkalmazás teljesítményére. Technikailag ez megoldható az Adatátviteli objektum tervezési minta használatával, és a Teacher
információk lekérdezésével a kurzusok nélkül.
Ez azonban hatalmas túlzás lehet, ha a mintával csak annyit nyerünk, hogy kizárjuk a kurzusokat.
Hála Istennek, a JPA előre gondolt, és alapértelmezés szerint lustán töltik be az egy a sokhoz kapcsolatokat.
Ez azt jelenti, hogy a kapcsolat nem fog azonnal betöltődni, hanem csak akkor, amikor és ha valóban szükség van rá.
Példánkban ez azt jelentené, hogy amíg nem hívjuk meg a Teacher#courses
metódust, addig a kurzusok nem kerülnek lehívásra az adatbázisból.
Ezzel szemben a Many-to-One kapcsolatok alapértelmezés szerint buzgóak, ami azt jelenti, hogy a kapcsolat az entitással egy időben töltődik be.
Ezeket a tulajdonságokat mindkét annotáció fetch
argumentumának beállításával megváltoztathatjuk:
@OneToMany(mappedBy = "teacher", fetch = FetchType.EAGER)private List<Course> courses;@ManyToOne(fetch = FetchType.LAZY)private Teacher teacher;
Ez megfordítaná az eredeti működést. A kurzusok mohón töltődnének be, amint betöltünk egy Teacher
objektumot. Ezzel szemben a teacher
nem töltődne be, amikor a courses
objektumot lehívjuk, ha éppen nincs rá szükség.
Optionalitás
Most beszéljünk az opcionalitásról.
Egy kapcsolat lehet opcionális vagy kötelező.
Az egy a sokhoz oldalról nézve – mindig opcionális, és nem tudunk vele mit kezdeni. A Sok-az-egyhez oldalon viszont lehetőségünk van arra, hogy kötelezővé tegyük.
Egy kapcsolat alapértelmezés szerint opcionális, vagyis menthetünk egy Course
-t anélkül, hogy tanárral rendelnénk hozzá:
Course course = new Course("C# 101");entityManager.persist(course);
Most tegyük kötelezővé ezt a kapcsolatot. Ehhez használjuk a @ManyToOne
megjegyzés optional
argumentumát, és állítsuk false
-ra (alapértelmezés szerint true
):
@ManyToOne(optional = false)@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private Teacher teacher;
Így többé nem menthetünk el egy kurzust anélkül, hogy tanárt rendelnénk hozzá:
Course course = new Course("C# 101");assertThrows(Exception.class, () -> entityManager.persist(course));
De ha adunk neki egy tanárt, ismét jól működik:
Teacher teacher = new Teacher();teacher.setLastName("Doe");teacher.setFirstName("Will");Course course = new Course("C# 101");course.setTeacher(teacher);entityManager.persist(course);
Hát, legalábbis úgy tűnik. Ha lefuttattuk volna a kódot, akkor egy kivételt dobott volna:
javax.persistence.PersistenceException: org.hibernate.PersistentObjectException: detached entity passed to persist: com.fdpro.clients.stackabuse.jpa.domain.Course
Miért van ez? Egy érvényes Teacher
objektumot állítottunk be a Course
objektumba, amelyet megpróbálunk tartósítani. Azonban nem perzisztáltuk a Teacher
objektumot, mielőtt megpróbáltuk volna perzisztálni a Course
objektumot.
Ezért a Teacher
objektum nem egy kezelt entitás. Javítsuk ki ezt, és próbáljuk meg újra:
Teacher teacher = new Teacher();teacher.setLastName("Doe");teacher.setFirstName("Will");entityManager.persist(teacher);Course course = new Course("C# 101");course.setTeacher(teacher);entityManager.persist(course);entityManager.flush();
Ezt a kódot futtatva mindkét entitást perzisztenssé tesszük, és megőrzi a köztük lévő kapcsolatot.
Cascading Operations
Viszont csinálhattunk volna mást is – kaszkádolhattunk volna, és így propagáltuk volna a Teacher
objektum perzisztenciáját, amikor a Course
objektumot perzisztenssé tesszük.
Ennek több értelme van, és úgy működik, ahogyan azt elvárnánk, mint az első példában, ami kivételt dobott.
Ezért módosítjuk az annotáció cascade
flagjét:
@ManyToOne(optional = false, cascade = CascadeType.PERSIST)@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private Teacher teacher;
Így a Hibernate tudja, hogy a szükséges objektumot ebben a relációban is persistálni kell.
A kaszkád műveletnek több típusa van: PERSIST
, MERGE
, REMOVE
, REFRESH
, DETACH
és ALL
(amely az előzőeket egyesíti).
A kaszkád argumentumot a kapcsolat egytől sokig oldalára is feltehetjük, így a műveletek a tanároktól a kurzusaikra is kaszkádként kerülnek.
One-to-One
Most, hogy az One-to-Many/Many-to-One kapcsolatokon és azok beállításain keresztül megteremtettük a kapcsolatleképezés alapjait a JPA-ban, áttérhetünk az One-to-One kapcsolatokra.
Ezúttal ahelyett, hogy az egyik oldalon egy entitás, a másikon pedig egy csomó entitás között lenne kapcsolat, ezúttal mindkét oldalon legfeljebb egy entitás lesz.
Ez például egy Course
és annak CourseMaterial
közötti kapcsolat. Először is képezzük le a CourseMaterial
-et, amit még nem tettünk meg:
@Entitypublic class CourseMaterial { @Id private Long id; private String url;}
Az egyetlen entitás egyetlen másik entitáshoz való leképezésének annotációja – nem meglepő módon – a @OneToOne
.
Mielőtt beállítanánk a modellünkben, emlékezzünk arra, hogy egy kapcsolatnak van egy birtokló oldala – lehetőleg az az oldal, amelyik az idegen kulcsot fogja tartani az adatbázisban.
Példánkban ez a CourseMaterial
lenne, mivel van értelme, hogy hivatkozik egy Course
-re (bár fordítva is lehetne):
@OneToOne(optional = false)@JoinColumn(name = "COURSE_ID", referencedColumnName = "ID")private Course course;
Nincs értelme, hogy legyen anyagunk anélkül, hogy ne lenne egy kurzus, amely felölelné azt. Ezért a kapcsolat nem optional
ilyen irányú.
Apropó irány, tegyük a kapcsolatot kétirányúvá, hogy hozzáférhessünk egy kurzus anyagához, ha az rendelkezik ilyennel. A Course
osztályban adjuk hozzá:
@OneToOne(mappedBy = "course")private CourseMaterial material;
Itt azt mondjuk a Hibernate-nek, hogy a Course
-en belüli anyagot már leképezte a CourseMaterial
entitás course
mezője.
Ezeken kívül itt nincs optional
attribútum, mivel az alapértelmezés szerint true
, és el tudnánk képzelni egy anyag nélküli kurzust (egy nagyon lusta tanártól).
A kapcsolat kétirányúvá tételén túlmenően kaszkád műveleteket is hozzáadhatnánk, vagy az entitásokat lelkesen vagy lustán tölthetnénk be.
Many-to-Many
Most, de nem utolsósorban: Many-to-Many kapcsolatok. Ezeket azért tartottuk a végére, mert egy kicsit több munkát igényelnek, mint az előzőek.
Egy adatbázisban a Many-to-Many kapcsolat egy középső táblát foglal magában, amely mindkét másik táblára hivatkozik.
A JPA szerencsénkre a munka nagy részét elvégzi, nekünk csak néhány annotációt kell kidobnunk, a többit pedig elintézi helyettünk.
A példánkban tehát a Many-to-Many kapcsolat a Student
és Course
példányok közötti kapcsolat lesz, mivel egy hallgató több kurzusra is járhat, és egy kurzust több hallgató is követhet.
A Many-to-Many kapcsolat leképezéséhez a @ManyToMany
annotációt fogjuk használni. Ezúttal azonban egy @JoinTable
annotációt is használunk a kapcsolatot reprezentáló táblázat létrehozásához:
@ManyToMany@JoinTable( name = "STUDENTS_COURSES", joinColumns = @JoinColumn(name = "COURSE_ID", referencedColumnName = "ID"), inverseJoinColumns = @JoinColumn(name = "STUDENT_ID", referencedColumnName = "ID"))private List<Student> students;
Most nézzük át, hogy mi történik itt. Az annotáció néhány paramétert vesz fel. Először is nevet kell adnunk a táblának. Mi úgy választottuk, hogy STUDENTS_COURSES
.
Ezután meg kell mondanunk a Hibernate-nek, hogy mely oszlopokat kell összekapcsolni a STUDENTS_COURSES
feltöltéséhez. Az első paraméter, a joinColumns
azt határozza meg, hogy a kapcsolat tulajdonosi oldalának join oszlopát (idegen kulcsát) hogyan konfiguráljuk a táblázatban. Ebben az esetben a birtokló oldal egy Course
.
A másik oldalon a inverseJoinColumns
paraméter ugyanezt teszi, de a hivatkozó oldalra (Student
).
Állítsunk be egy adathalmazt hallgatókkal és kurzusokkal:
Student johnDoe = new Student();johnDoe.setFirstName("John");johnDoe.setLastName("Doe");johnDoe.setBirthDateAsLocalDate(LocalDate.of(2000, FEBRUARY, 18));johnDoe.setGender(MALE);johnDoe.setWantsNewsletter(true);johnDoe.setAddress(new Address("Baker Street", "221B", "London"));entityManager.persist(johnDoe);Student willDoe = new Student();willDoe.setFirstName("Will");willDoe.setLastName("Doe");willDoe.setBirthDateAsLocalDate(LocalDate.of(2001, APRIL, 4));willDoe.setGender(MALE);willDoe.setWantsNewsletter(false);willDoe.setAddress(new Address("Washington Avenue", "23", "Oxford"));entityManager.persist(willDoe);Teacher teacher = new Teacher();teacher.setFirstName("Jane");teacher.setLastName("Doe");entityManager.persist(teacher);Course javaCourse = new Course("Java 101");javaCourse.setTeacher(teacher);entityManager.persist(javaCourse);Course sqlCourse = new Course("SQL 101");sqlCourse.setTeacher(teacher);entityManager.persist(sqlCourse);
Természetesen ez nem fog működni a dobozból. Hozzá kell adnunk egy módszert, amely lehetővé teszi, hogy diákokat adjunk hozzá egy kurzushoz. Módosítsuk egy kicsit a Course
osztályt:
public class Course { private List<Student> students = new ArrayList<>(); public void addStudent(Student student) { this.students.add(student); }}
Most már kiegészíthetjük az adathalmazunkat:
Course javaCourse = new Course("Java 101");javaCourse.setTeacher(teacher);javaCourse.addStudent(johnDoe);javaCourse.addStudent(willDoe);entityManager.persist(javaCourse);Course sqlCourse = new Course("SQL 101");sqlCourse.setTeacher(teacher);sqlCourse.addStudent(johnDoe);entityManager.persist(sqlCourse);
A kód lefutása után a Course
, Teacher
és Student
példányaink, valamint kapcsolataik megmaradnak. Például, kérjünk le egy diákot egy persistált kurzusból, és ellenőrizzük, hogy minden rendben van-e:
Course courseWithMultipleStudents = entityManager.find(Course.class, 1L);assertThat(courseWithMultipleStudents).isNotNull();assertThat(courseWithMultipleStudents.students()) .hasSize(2) .extracting(Student::firstName) .containsExactly("John", "Will");
A kapcsolatot természetesen továbbra is leképezhetjük kétirányúnak, ugyanúgy, ahogy az előző kapcsolatoknál tettük.
A műveleteket kaszkádosíthatjuk is, valamint meghatározhatjuk, hogy az entitások lustán vagy buzgón töltődjenek be (a Many-to-Many kapcsolatok alapértelmezés szerint lusták).
Következtetés
Ezzel zárjuk ezt a cikket a JPA-val leképezett entitások kapcsolatairól. A Many-to-One, One-to-Many, Many-to-Many és One-to-One kapcsolatokkal foglalkoztunk. Ezen kívül megvizsgáltuk a kaszkád műveleteket, a kétirányúságot, az opcionalitást és az eager/lazy loading fetch-típusokat.
A sorozat kódja megtalálható a GitHubon.