Opas JPA:n ja Hibernaten käyttöön – Suhdekartoitus

Esittely

Tässä artikkelissa perehdymme Suhdekartoitukseen JPA:lla ja Hibernatella Javassa.

Javan persistenssirajapinta (Java Persistence API, JPA) on Java-ekosysteemin persistenssistandardi. Sen avulla voimme kartoittaa toimialuemallimme suoraan tietokantarakenteeseen, ja sen jälkeen voimme joustavasti käsitellä objekteja koodissamme – sen sijaan, että sotkisimme hankalia JDBC-komponentteja, kuten Connection, ResultSet jne.

Tehdään kattava opas JPA:n käyttämisestä Hibernaten kanssa sen toimittajana. Tässä artikkelissa käsittelemme suhdekartoituksia.

  • Opas JPA:n käyttöön Hibernaten kanssa – Peruskartoitus
  • Opas JPA:n käyttöön Hibernaten kanssa – Suhteiden kartoitus (olet täällä)
  • Opas JPA:n käyttöön Hibernaten kanssa: Inheritance Mapping (tulossa pian!)
  • Guide to JPA with Hibernate – Querying (tulossa pian!)

Esimerkkimme

Ennen aloittamista muistutetaan esimerkistä, jota käytimme sarjan edellisessä osassa. Ajatuksena oli kartoittaa koulun malli, jossa oppilaat käyvät opettajien antamia kursseja.

Tältä tämä malli näyttää:

Kuten näemme, on olemassa muutamia luokkia, joilla on tiettyjä ominaisuuksia. Näiden luokkien välillä on suhteita. Tämän artikkelin loppuun mennessä olemme kartoittaneet kaikki nämä luokat tietokantataulukoihin ja säilyttäneet niiden väliset suhteet.

Pystymme lisäksi hakemaan niitä ja käsittelemään niitä objekteina ilman JDBC:n vaivaa.

Suhteet

Määritellään ensin suhde. Jos katsomme luokkakaaviotamme, näemme muutamia suhteita:

Opettajat ja kurssit – opiskelijat ja kurssit – kurssit ja kurssimateriaalit.

Opiskelijoiden ja osoitteiden välillä on myös yhteyksiä, mutta niitä ei pidetä suhteina. Tämä johtuu siitä, että Address ei ole entiteetti (eli sitä ei ole kuvattu omaan tauluunsa). JPA:n kannalta se ei siis ole suhde.

Suhteita on muutamaa tyyppiä:

  • One-to-Many
  • Many-to-One
  • One-to-One
  • Many-to-One
  • Many-to-Many

Käsitellään näitä suhteita yksitellen.

One-to-Many/Many-to-One

Aloitetaan One-to-Many- ja Many-to-One-suhteista, jotka liittyvät läheisesti toisiinsa. Voisi sanoa, että ne ovat saman kolikon vastakkaiset puolet.

Mikä on One-to-Many-suhde?

Nimensä mukaisesti se on suhde, joka yhdistää yhden entiteetin moniin muihin entiteetteihin.

Esimerkissämme tämä olisi Teacher ja niiden Courses. Opettaja voi antaa useita kursseja, mutta kurssin antaa vain yksi opettaja (tämä on monesta yhteen -näkökulma – monta kurssia yhdelle opettajalle).

Toinen esimerkki voisi olla sosiaalisessa mediassa – valokuvalla voi olla monta kommenttia, mutta jokainen näistä kommenteista kuuluu kyseiseen yhteen kuvaan.

Ennen kuin sukellamme tämän suhteen kartoittamisen yksityiskohtiin, luodaan oliomme:

@Entitypublic class Teacher { private String firstName; private String lastName;}@Entitypublic class Course { private String title;}

Luokan Teacher kenttien tulisi nyt sisältää luettelo kursseista. Koska haluamme kartoittaa tämän suhteen tietokantaan, joka ei voi sisältää luetteloa olioista toisen olion sisällä – merkitsemme sen @OneToMany-merkinnällä:

@OneToManyprivate List<Course> courses;

Käytimme tässä kenttätyyppinä List, mutta olisimme voineet käyttää Set– tai Map-kenttätyyppiä (joskin tämä vaatii hieman enemmän konfigurointia).

Miten JPA kuvastaa tätä suhdetta tietokannassa? Yleensä tämäntyyppistä suhdetta varten meidän on käytettävä vierasavainta taulussa.

JPA tekee tämän puolestamme, kun otetaan huomioon antamamme tiedot siitä, miten sen pitäisi käsitellä suhdetta. Tämä tapahtuu @JoinColumn-annotaation avulla:

@OneToMany@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private List<Course> courses;

Tämän annotaation käyttäminen kertoo JPA:lle, että COURSE-taulussa on oltava vierasavainsarake TEACHER_ID, joka viittaa TEACHER-taulun ID-sarakkeeseen.

Lisätään tietoja näihin taulukoihin:

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');

Ja nyt tarkistetaan, toimiiko suhde odotetulla tavalla:

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");

Voidaan nähdä, että opettajan kurssit kerätään automaattisesti, kun haetaan Teacher-instanssi.

Jos testaaminen Javassa ei ole sinulle tuttua, saatat olla kiinnostunut lukemaan Yksikkötestausta Javassa JUnit 5:llä!

Omistava puoli ja kaksisuuntaisuus

Edellisessä esimerkissä Teacher-luokkaa kutsuttiin yksi-monia-suhteen omistavaksi puoleksi. Tämä johtuu siitä, että se määrittelee kahden taulukon välisen liitossarakkeen.

Luokkaa Course kutsutaan tässä suhteessa viittaavaksi osapuoleksi.

Olisimme voineet tehdä Course-luokan suhteen omistavaksi osapuoleksi kartoittamalla Teacher-kentän sen sijaan @ManyToOne-kenttään Course-luokassa:

@ManyToOne@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private Teacher teacher;

Kurssiluetteloa Teacher-luokassa

@ManyToOne@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private Teacher teacher;

Ei ole enää tarvetta pitää kurssiluetteloa. Suhde olisi toiminut päinvastoin:

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");

Tällä kertaa käytimme merkintää @ManyToOne samalla tavalla kuin käytimme merkintää @OneToMany.

Huomautus: On hyvä käytäntö sijoittaa suhteen omistava puoli siihen luokkaan/taulukkoon, jossa vierasavainta pidetään.

Tapauksessamme tämä koodin jälkimmäinen versio on siis parempi. Mutta entä jos haluamme silti, että Teacher-luokkamme tarjoaa pääsyn Course-luetteloonsa?

Voimme tehdä sen määrittelemällä kaksisuuntaisen suhteen:

@Entitypublic class Teacher { // ... @OneToMany(mappedBy = "teacher") private List<Course> courses;}@Entitypublic class Course { // ... @ManyToOne @JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID") private Teacher teacher;}

Pidämme @ManyToOne-kuvauksemme Course-oliossa. Kartoitamme kuitenkin myös Course-luettelon Teacher-olioon.

Mitä tässä on tärkeää huomata, on mappedBy-lipun käyttö @OneToMany-annotaatiossa viittauspuolella.

Ilman sitä meillä ei olisi kaksisuuntaista suhdetta. Meillä olisi kaksi yksisuuntaista suhdetta. Molemmat entiteetit kartoittaisivat vierasavaimia toiselle entiteetille.

Sen avulla kerromme JPA:lle, että toinen entiteetti on jo kartoittanut kentän. Se on mapattu teacher-kentän Course-olion teacher-kentällä.

Eager vs. Lazy Loading

Toinen huomionarvoinen asia on eager ja lazy loading. Kun kaikki suhteet on kartoitettu, on viisasta välttää kuormittamasta ohjelmiston muistia laittamalla siihen liikaa olioita, jos se on tarpeetonta.

Kuvitellaan, että Course on raskas objekti, ja lataamme kaikki Teacher-oliot tietokannasta jotain operaatiota varten. Meidän ei tarvitse hakea tai käyttää kursseja tätä operaatiota varten, mutta ne ladataan silti Teacher-objektien rinnalle.

Tämä voi olla tuhoisaa sovelluksen suorituskyvylle. Teknisesti tämä voidaan ratkaista käyttämällä tiedonsiirto-objektien suunnittelumallia (Data Transfer Object Design Pattern) ja hakemalla Teacher-tiedot ilman kursseja.

Tämä voi kuitenkin olla massiivinen ylilyönti, jos kaikki, mitä saamme mallin avulla, on kurssien pois jättäminen.

Onneksi JPA ajatteli eteenpäin ja teki One-to-Many-suhteista oletusarvoisesti laiskasti latautuvia.

Tämähän tarkoittaa sitä, että suhdetta ei ladata heti, vaan vasta kun ja jos sitä todella tarvitaan.

Esimerkissämme tämä tarkoittaisi sitä, että ennen kuin kutsumme Teacher#courses-metodia, kursseja ei haeta tietokannasta.

Monesta yhteen -suhteet sen sijaan ovat oletusarvoisesti innokkaita, mikä tarkoittaa, että suhde ladataan samaan aikaan, kun olio ladataan.

Voidaan muuttaa näitä ominaisuuksia asettamalla molempien annotaatioiden fetch-argumentti:

@OneToMany(mappedBy = "teacher", fetch = FetchType.EAGER)private List<Course> courses;@ManyToOne(fetch = FetchType.LAZY)private Teacher teacher;

Tämä kääntäisi alkuperäisen toimintatavan. Kurssit ladattaisiin innokkaasti, heti kun lataamme Teacher-objektin. Sitä vastoin teacher ei ladattaisi, kun noutaisimme courses-objektin, jos se ei ole sillä hetkellä tarpeellinen.

Valinnaisuus

Keskustellaan nyt valinnaisuudesta.

Suhde voi olla valinnainen tai pakollinen.

Harkitkailkaamme Yksi-monelta-puolta – se on aina valinnainen, emmekä voi tehdä sille mitään. Monesta yhteen -puoli sen sijaan tarjoaa meille mahdollisuuden tehdä siitä pakollisen.

Vakiossa suhde on valinnainen, eli voimme tallentaa Course:n määrittelemättä sille opettajaa:

Course course = new Course("C# 101");entityManager.persist(course);

Tehdään nyt tästä suhteesta pakollinen. Tätä varten käytämme @ManyToOne-merkinnän optional-argumenttia ja asetamme sen arvoksi false (oletusarvoisesti se on true):

@ManyToOne(optional = false)@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private Teacher teacher;

Siten emme voi enää tallentaa kurssia määrittelemättä sille opettajaa:

Course course = new Course("C# 101");assertThrows(Exception.class, () -> entityManager.persist(course));

Mutta jos annamme tälle kurssille opettajan, se toimii taas hienosti:

Teacher teacher = new Teacher();teacher.setLastName("Doe");teacher.setFirstName("Will");Course course = new Course("C# 101");course.setTeacher(teacher);entityManager.persist(course);

Nyt ainakin näyttää siltä. Jos olisimme ajaneet koodin, olisi heitetty poikkeus:

javax.persistence.PersistenceException: org.hibernate.PersistentObjectException: detached entity passed to persist: com.fdpro.clients.stackabuse.jpa.domain.Course

Miksi näin? Olemme asettaneet kelvollisen Teacher-objektin Course-objektiin, jota yritämme säilyttää. Emme kuitenkaan ole persistoineet Teacher-objektia ennen kuin yritämme persistoida Course-objektia.

Siten Teacher-objekti ei ole hallittu olio. Korjataan tämä ja yritetään uudelleen:

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

Tämän koodin suorittaminen persistoi molemmat oliot ja säilyttää niiden välisen suhteen.

Cascading Operations

Olisimme kuitenkin voineet tehdä toisenkin asian – olisimme voineet kaskadoida ja siten levittää Teacher-olion pysyvyyttä, kun persistoimme Course-olion.

Tämä on järkevämpää ja toimii niin kuin odotimme sen toimivan, kuten ensimmäisessä esimerkissä, joka heitti poikkeuksen.

Tehdäksemme tämän muutamme annotaation cascade-lippua:

@ManyToOne(optional = false, cascade = CascadeType.PERSIST)@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private Teacher teacher;

Siten Hibernate tietää persistoida tarvittavan objektin myös tässä relaatiossa.

Kaskadoitavia operaatioita on useita erilaisia: PERSIST, MERGE, REMOVE, REFRESH, DETACH ja ALL (joka yhdistää kaikki edelliset).

Voidaan myös laittaa cascade-argumentti suhteen One-to-Many-puolelle, jolloin operaatiot kaskadoituvat myös opettajilta heidän kursseilleen.

One-to-One

Nyt kun olemme luoneet JPA:n suhdekartoituksen perusteet One-to-Many/Many-to-One-suhteiden ja niiden asetusten avulla, voimme siirtyä One-to-One-suhteisiin.

Tällä kertaa sen sijaan, että meillä olisi suhde yhden entiteetin välillä toisella puolella ja useiden entiteettien välillä toisella puolella, meillä on korkeintaan yksi entiteetti kummallakin puolella.

Tämä on esimerkiksi Course:n ja sen CourseMaterial:n välinen suhde. Kartoitetaan ensin CourseMaterial, mitä emme ole vielä tehneet:

@Entitypublic class CourseMaterial { @Id private Long id; private String url;}

Yksittäisen olion ja yhden toisen olion kartoittamiseen tarkoitettu annotaatio on yllättäen @OneToOne.

Ennen kuin asetamme sen malliimme, muistakaamme, että suhteella on omistava puoli – mieluiten se puoli, joka pitää vierasavainta tietokannassa.

Esimerkissämme se olisi CourseMaterial, koska on järkevää, että se viittaa Course:een (vaikka voisimme toimia myös toisin päin):

@OneToOne(optional = false)@JoinColumn(name = "COURSE_ID", referencedColumnName = "ID")private Course course;

Ei ole mitään järkeä olla materiaalia ilman kurssia, joka kattaa sen. Siksi suhde ei ole optional siihen suuntaan.

Suunnasta puheen ollen, tehdään suhteesta kaksisuuntainen, jotta pääsemme käsiksi kurssin aineistoon, jos sillä on sellainen. Lisätään Course-luokkaan:

@OneToOne(mappedBy = "course")private CourseMaterial material;

Tässä kerromme Hibernatelle, että Course:n sisällä oleva materiaali on jo kuvattu CourseMaterial-olion course-kentällä.

Tässä ei myöskään ole optional-attribuuttia, koska se on oletusarvoisesti true, ja voisimme kuvitella kurssin, jossa ei ole materiaalia (hyvin laiskalta opettajalta).

Suhteen kaksisuuntaistamisen lisäksi voisimme myös lisätä kaskadointioperaatioita tai saada entiteetit latautumaan innokkaasti tai laiskasti.

Many-to-Many

Nyt viimeiseksi: Many-to-Many-suhteet. Säilytimme nämä loppuun, koska ne vaativat hieman enemmän työtä kuin edelliset.

Todennäköisesti tietokannassa Many-to-Many-suhteeseen kuuluu keskimmäinen taulukko, joka viittaa molempiin muihin taulukoihin.

Meille onneksi JPA tekee suurimman osan työstä, meidän tarvitsee vain heittää muutama annotaatio, ja se hoitaa loput puolestamme.

Esimerkkimme tapauksessa Many-to-Many-suhde on siis Student– ja Course-instanssien välinen suhde, koska opiskelija voi osallistua useammalle kurssille, ja kurssia voi seurata useampi opiskelija.

Kartoittaaksemme Many-to-Many-suhteen käytämme @ManyToMany-annotaatiota. Tällä kertaa käytämme kuitenkin myös @JoinTable-annotaatiota asettaaksemme taulukon, joka edustaa suhdetta:

@ManyToMany@JoinTable( name = "STUDENTS_COURSES", joinColumns = @JoinColumn(name = "COURSE_ID", referencedColumnName = "ID"), inverseJoinColumns = @JoinColumn(name = "STUDENT_ID", referencedColumnName = "ID"))private List<Student> students;

Katso nyt läpi, mitä tässä tapahtuu. Annotaatio ottaa muutaman parametrin. Ensinnäkin meidän on annettava taulukolle nimi. Olemme valinneet sen nimeksi STUDENTS_COURSES.

Sen jälkeen meidän on kerrottava Hibernatelle, mitkä sarakkeet yhdistetään, jotta STUDENTS_COURSES täytetään. Ensimmäinen parametri, joinColumns määrittelee, miten taulukossa määritetään suhteen omistavan puolen liitossarake (vieras avain). Tässä tapauksessa omistava puoli on Course.

Toisaalta parametri inverseJoinColumns tekee saman, mutta viittaavalle puolelle (Student).

Asetetaan datajoukko, jossa on opiskelijoita ja kursseja:

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

Tämä ei tietenkään toimi suoraan. Meidän on lisättävä metodi, jolla voimme lisätä opiskelijoita kurssiin. Muokataan Course-luokkaa hieman:

public class Course { private List<Student> students = new ArrayList<>(); public void addStudent(Student student) { this.students.add(student); }}

Nyt voimme täydentää tietokokonaisuuttamme:

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

Kun tämä koodi on suoritettu, se säilyttää Course-, Teacher– ja Student-instanssit sekä niiden suhteet. Haetaan esimerkiksi opiskelija persistoidusta kurssista ja tarkistetaan, että kaikki on kunnossa:

Course courseWithMultipleStudents = entityManager.find(Course.class, 1L);assertThat(courseWithMultipleStudents).isNotNull();assertThat(courseWithMultipleStudents.students()) .hasSize(2) .extracting(Student::firstName) .containsExactly("John", "Will");

Voidaan tietysti edelleen kartoittaa suhde kaksisuuntaiseksi samalla tavalla kuin edellisille suhteille.

Voidaan myös kaskadoida operaatioita sekä määritellä, latautuvatko oliot laiskasti vai innokkaasti (Many-to-Many-suhteet ovat oletusarvoisesti laiskoja).

Loppupäätelmä

Se päättää tämän artikkelin, joka käsittelee kartoitettujen olioiden suhteita JPA:lla. Olemme käsitelleet Many-to-One-, One-to-Many-, Many-to-Many- ja One-to-One-suhteita. Lisäksi olemme tutkineet kaskadointioperaatioita, kaksisuuntaisuutta, valinnaisuutta ja eager/lazy loading fetch-tyyppejä.

Tämän sarjan koodi löytyy GitHubista.

Vastaa

Sähköpostiosoitettasi ei julkaista.