Wiemy już jak rozpocząć projekt za pomocą Spring Initializr, teraz zajmiemy się bazą danych i standardem JPA. JPA (Java Persistence API) to tak zwany standard ORM, czyli mapowania obiektowo – relacyjnego (object – relational mapping). Mapowanie to jest sposobem odwzorowania struktury obiektowego systemu na relacyjną bazę danych. Polega to na tym, że w relacyjnej bazie danych mamy tabele, które są ze sobą powiązane kluczami (połączenia takie nazywane są relacjami), natomiast w systemie obiektowym – są to obiekty ze swoimi polami.
Jedna z najprostszych do skonfigurowania relacyjnych baz danych to H2, uruchamiana w pamięci komputera. Ma to swoje dobre i złe strony, jednakże przewagą nad innymi jest właśnie prostota konfiguracji. Baza uruchamia się w pamięci wraz ze startem serwera aplikacyjnego, jest łatwo dostępna przez konsolę, oraz zapewnia to wszystko, co znamy z innych baz typu SQL. W celu dołączenia bazy H2 do swojego projektu, należy w Spring Initializr zaznaczyć H2 w sekcji SQL w opcjach dodatkowych. Przejdźmy do kodowania.
Konwencja tworzenia oprogramowania w Javie EE (jak również w Javie ogólnie) zaleca wykorzystanie paczek (java package) do zorganizowania klas. Jeśli chodzi o encje (tj. obiekty będące odwzorowaniem bazodanowych tabel) najczęściej wykorzystywane są paczki o nazwach dbo (database object), model lub połączenie tych dwóch – db.model (w tym ostatnim przypadku często klasy dostępu do danych ukrywają się w paczce db.dao)
W tym przypadku encje zapiszemy w paczkach model, natomiast dao – w repositories (jest to dość powszechne w Springu, gdzie DAO określa się mianem repozytoriów danych).
Tworzenie encji
Mamy już nasz projekt skonfigurowany, należy dodać do niego trochę “mięsa”. W tym celu utworzymy dwa POJO w paczce it.gniado.spring5webapp.model – Book oraz Author. W klasie książki umieścimy takie pola jak title oraz isbn (oba typu String), oraz authors, będący zbiorem obiektów typu Author (Set<Author>). W klasie Author natomiast – dodamy jedynie firstName oraz lastName (również oba pola typu String). Dodawszy konstruktory, gettersy, settery oraz hashCode, equals i toString, nasze klasy powinny przedstawiać się następująco:
package it.gniado.spring5webapp.model; import java.util.HashSet; import java.util.Set; public class Book { private String title; private String isbn; private Set<Author> authors = new HashSet<>(); public Book() { } public Book(String title, String isbn) { this.title = title; this.isbn = isbn; } public Book(String title, String isbn, Set<Author> authors) { this.title = title; this.isbn = isbn; this.authors = authors; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public String getIsbn() { return isbn; } public void setIsbn(String isbn) { this.isbn = isbn; } public Set<Author> getAuthors() { return authors; } public void setAuthors(Set<Author> authors) { this.authors = authors; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Book)) return false; Book book = (Book) o; return id != null ? id.equals(book.id) : book.id == null; } @Override public int hashCode() { return id != null ? id.hashCode() : 0; } @Override public String toString() { final StringBuffer sb = new StringBuffer("Book{"); sb.append("id=").append(id); sb.append(", title='").append(title).append('\''); sb.append(", isbn='").append(isbn).append('\''); sb.append(", authors=").append(authors); sb.append('}'); return sb.toString(); } }
package it.gniado.spring5webapp.model; import java.util.HashSet; import java.util.Set; public class Author { private Long id; private String firstName; private String lastName; private Set<Book> books = new HashSet<>(); public Author() { } public Author(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } public Author(String firstName, String lastName, Set<Book> books) { this.firstName = firstName; this.lastName = lastName; this.books = books; } public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } public Set<Book> getBooks() { return books; } public void setBooks(Set<Book> books) { this.books = books; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Author)) return false; Author author = (Author) o; return id != null ? id.equals(author.id) : author.id == null; } @Override public int hashCode() { return id != null ? id.hashCode() : 0; } @Override public String toString() { final StringBuffer sb = new StringBuffer("Author{"); sb.append("id=").append(id); sb.append(", firstName='").append(firstName).append('\''); sb.append(", lastName='").append(lastName).append('\''); sb.append(", books=").append(books); sb.append('}'); return sb.toString(); } }
W tym momencie są to zwykłe obiekty POJO (Plain Old Java Object – zwykły, stary obiekt Javy).
Adnotacje JPA
Aby przekształcić je w pełnoprawne encje (rozumiane przez JPA jako elementy odwzorowujące stan bazy) nalezy skorzystać z kilku adnotacji:
- @Entity
informuje, że klasa istotnie jest encją. Nadawanie encji nazwy (jako parametr dodany w nawiasie) nie jest konieczne. Pozwoli to wykorzystywanie nadanej nazwy jako alias w zapytaniach JPQL, ale pomijając te działanie – skorzystamy po prostu z nazwy klasy. Encyjność wymaga tego, aby wszystkie pola były prywatne i miały publiczne gettery/settery. Tabela utworzona z takiej encji będzie się nazywała tak jak klasa, a jej kolumny – jak pola. - @Id
adnotacja Entity wymaga istnienia unikalnego identyfikatora, jak w tabelach. Rozwiązuje się to poprzez nadanie jednemu z pól właśnie adnotacji @Id. Najczęściej ta adnotacja nadawana jest polu typu Long. - @GeneratedValue
dodatkowa adnotacja dla pola @Id. Oznacza ona, że wartość pola będzie generowana automatycznie podczas zapisu encji do bazy.
Rodzaje relacji
Każda relacja w JPA może być jedno lub dwukierunkowa. Jednokierunkowa oznacza, że tylko w jednej z klas będzie odwołanie do drugiej z nich, dwukierunkowa natomiast – że odwołanie do drugiej strony relacji będzie po każdej ze stron (w naszym wypadku w klasie Book będzie odwołanie do Author, a w klasie Author – do Book).
- @OneToOne
łączy encje w sposób jeden do jednego. Wydaje się być niepotrzebne, czy nieefektywne z poziomu bazy danych, jednakże upraszcza kod pozbywając nas klas z dziesiątkami pól. Należy pamiętać, że w przypadku jeden do jednego, żadna ze stron relacji nie może być kolekcją. - @OneToMany / @ManyToOne
relacja jeden do wielu / wiele do jednego. Pozwala na połączenie np. jednej osoby z wieloma adresami (np. adres zameldowania, zamieszkania etc), lub jednego adresu z wieloma osobami (np. dom rodzinny – pod jednym adresem mieszka kilka osób). Wymaga, aby jedna (i tylko jedna) ze stron była kolekcją. - @ManyToMany
To samo co wyżej, jednak w zestawie wiele do wielu. Może być wykorzystane np. w przypadku adresów jak wyżej (jedna osoba ma wiele adresów, oraz pod jednym adresem mieszka kilka osób). Własnie z tej adnotacji skorzystamy łącząc ze sobą książkę i autora (książkę może napisać kilku autorów, szczególnie książki naukowe, natomiast jeden autor może napisać wiele książek).
Klasy Book i Author po przystosowaniu ich do standardu JPA:
package it.gniado.spring5webapp.model; import java.util.HashSet; import java.util.Set; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.JoinColumn; import javax.persistence.JoinTable; import javax.persistence.ManyToMany; @Entity public class Book { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; private String title; private String isbn; @ManyToMany private Set<Author> authors = new HashSet<>(); public Book() { } public Book(String title, String isbn) { this.title = title; this.isbn = isbn; } public Book(String title, String isbn, Set<Author> authors) { this.title = title; this.isbn = isbn; this.authors = authors; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public String getIsbn() { return isbn; } public void setIsbn(String isbn) { this.isbn = isbn; } public Set<Author> getAuthors() { return authors; } public void setAuthors(Set<Author> authors) { this.authors = authors; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Book)) return false; Book book = (Book) o; return id != null ? id.equals(book.id) : book.id == null; } @Override public int hashCode() { return id != null ? id.hashCode() : 0; } @Override public String toString() { final StringBuffer sb = new StringBuffer("Book{"); sb.append("id=").append(id); sb.append(", title='").append(title).append('\''); sb.append(", isbn='").append(isbn).append('\''); sb.append(", authors=").append(authors); sb.append('}'); return sb.toString(); } }
package it.gniado.spring5webapp.model; import java.util.HashSet; import java.util.Set; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.ManyToMany; @Entity public class Author { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; private String firstName; private String lastName; @ManyToMany(mappedBy = "authors") private Set<Book> books = new HashSet<>(); public Author() { } public Author(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } public Author(String firstName, String lastName, Set<Book> books) { this.firstName = firstName; this.lastName = lastName; this.books = books; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } public Set<Book> getBooks() { return books; } public void setBooks(Set<Book> books) { this.books = books; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Author)) return false; Author author = (Author) o; return id != null ? id.equals(author.id) : author.id == null; } @Override public int hashCode() { return id != null ? id.hashCode() : 0; } @Override public String toString() { final StringBuffer sb = new StringBuffer("Author{"); sb.append("id=").append(id); sb.append(", firstName='").append(firstName).append('\''); sb.append(", lastName='").append(lastName).append('\''); sb.append(", books=").append(books); sb.append('}'); return sb.toString(); } }
Konsola bazy danych H2
Po wystartowaniu serwera, możemy spróbować połączyć się z konsolą bazy danych. Aby tego dokonać, należy pamiętać o dodaniu odpowiedniego wpisu do pliku application.properties (powinien znajdować się w katalogu resources, jeśli go nie ma – należy go utworzyć). W pliku tym na ten moment potrzebujemy tylko jednej linii:
spring.h2.console.enabled=true
Teraz, po wystartowaniu serwera, możemy połączyć się z konsolą wpisując w przeglądarkę adres http://localhost:8080/h2-console . Połączywszy się z bazą zobaczymy, że automatycznie zostały stworzone tabele w oparciu o nasze encje, co więcej – dla relacji @ManyToMany zostały utworzone tabele złączeniowe. Jedna rzecz jedynie może być niepokojąca – utworzone zostały dwie tabele – dla relacji Books -> Authors, oraz dla relacji Authors -> Books. Dużo lepiej byłoby, gdybyśmy mieli jedną taką tabelę.
.
W tym celu możemy skorzystać z dodatkowej adnotacji JPA – @JoinTable, a istniejącą relację uzupełnić o argument mappedBy – wskazujący który element w drugiej klasie jest odpowiedzialny za związanie encji. Finalny kształt klas Book oraz Author:
package it.gniado.spring5webapp.model; import java.util.HashSet; import java.util.Set; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.JoinColumn; import javax.persistence.JoinTable; import javax.persistence.ManyToMany; @Entity public class Book { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; private String title; private String isbn; @ManyToMany @JoinTable(name = "author_book", joinColumns = @JoinColumn(name = "book_id"), inverseJoinColumns = @JoinColumn(name = "author_id")) private Set<Author> authors = new HashSet<>(); public Book() { } public Book(String title, String isbn, Publisher publisher) { this.title = title; this.isbn = isbn; this.publisher = publisher; } public Book(String title, String isbn, Publisher publisher, Set<Author> authors) { this.title = title; this.isbn = isbn; this.publisher = publisher; this.authors = authors; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public String getIsbn() { return isbn; } public void setIsbn(String isbn) { this.isbn = isbn; } public Set<Author> getAuthors() { return authors; } public void setAuthors(Set<Author> authors) { this.authors = authors; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Book)) return false; Book book = (Book) o; return id != null ? id.equals(book.id) : book.id == null; } @Override public int hashCode() { return id != null ? id.hashCode() : 0; } @Override public String toString() { final StringBuffer sb = new StringBuffer("Book{"); sb.append("id=").append(id); sb.append(", title='").append(title).append('\''); sb.append(", isbn='").append(isbn).append('\''); sb.append(", authors=").append(authors); sb.append('}'); return sb.toString(); } }
package it.gniado.spring5webapp.model; import java.util.HashSet; import java.util.Set; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.ManyToMany; @Entity public class Author { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; private String firstName; private String lastName; @ManyToMany(mappedBy = "authors") private Set<Book> books = new HashSet<>(); public Author() { } public Author(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } public Author(String firstName, String lastName, Set<Book> books) { this.firstName = firstName; this.lastName = lastName; this.books = books; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } public Set<Book> getBooks() { return books; } public void setBooks(Set<Book> books) { this.books = books; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Author)) return false; Author author = (Author) o; return id != null ? id.equals(author.id) : author.id == null; } @Override public int hashCode() { return id != null ? id.hashCode() : 0; } @Override public String toString() { final StringBuffer sb = new StringBuffer("Author{"); sb.append("id=").append(id); sb.append(", firstName='").append(firstName).append('\''); sb.append(", lastName='").append(lastName).append('\''); sb.append(", books=").append(books); sb.append('}'); return sb.toString(); } }
Oraz ich reprezentacja w bazie danych:
Jak widać w klasie Author do pola z relacją (books) dodaliśmy w adnotacji relacji @ManyToMany informację o mapowaniu (mappedBy = “authors”). Oznacza to, że w klasie, która jest po drugiej stronie relacji (czyli Book) pole authors wskazuje na tę właśnie encję.
W klasie Book natomiast mamy inną adnotację – @JoinTable. Jest to adnotacja informująca JPA, że należy stworzyć tabelę złączeniową, wraz z jej nazwą, oraz kolumnami łączącymi encje.
@JoinTable(name = "author_book", joinColumns = @JoinColumn(name = "book_id"), inverseJoinColumns = @JoinColumn(name = "author_id"))
- @JoinTable
adnotacja ta informuje o istnieniu tabeli złączeniowej. Zawiera ona informacje o nazwie tabeli złączeniowej, oraz odpowiednich kolumnach- name – definiuje nazwę tabeli złączeniowej
- joinColumns – definiuje relację między tabelą złączeniową, a encją będącą właścicielem relacji (w tym wypadku Book)
- inverseJoinColumns – definiuje relację między tabelą złączeniową, a encją nie będącą właścicielem relacji (w tym wypadku Author)
- @JoinColumn
wykorzystywana jest do określania jak ma wyglądać kolumna w bazie danych odpowiedzialna za relację. W tym wypadku podawana po to, by określić nazwę kolumny w tabeli złączeniowej- name – jak wyżej – korzysta się z tego atrybutu aby nadać nazwę, tym razem kolumnie