Od wersji 9.4 PostgreSQL oferuje nowy typ danych, którym jest JSONB. Wcześniej w wersji 9.2 został wprowadzony typ JSON, który przechowuje dane w tym formacie w wersji tekstowej. Wymagało to parsowania danych przed wykonaniem jakiejkolwiek operacji na nich. JSONB przechowuje zdekomponowane dane binarne, dzięki czemu mamy do nich szybszy dostęp. Dodatkowo możemy ich użyć do tworzenia indeksów, joinów itp.
Wygląda to bardzo pięknie i równie dobrze sprawdza się w praktyce, jednak Hibernate nie wspiera natywnie tego rodzaju pól. Można to jednak obejść, tworząc własny typ danych oraz mówiąc mu jak może ten typ zserializować do formatu JSON. W tym poście przejdziemy przez proces tworzenia własnego typu.
Wpis jest częścią serii wpisów na temat Spring Boot. Zapraszam do zapoznania się z pozostałymi wpisami na blogu!
- Spring Boot – szybkie tworzenie aplikacji web w Javie
- Spring Boot – Interakcja z bazą danych czyli Spring Data JPA
- Spring Boot – Widoki
- Użycie Spring Security w Spring Boot
- Spring Boot Web – Przekazywanie zmiennych do aplikacji przez URL czyli użycie @RequestParam i @PathVariable
- Spring Boot Test – testowanie aplikacji w Spring Boocie
- Wsparcie dla pola typu JSONB w PostgreSQL dla Spring Data JPA + Hibernate
- Wysyłanie plików na serwer przez Spring Boot
- Spring Boot – Spring Data JPA część II: Powiązania między tabelami
- Spring Mail + Spring Boot – łatwe wysyłanie maili z aplikacji w Javie
- Docker + Spring Boot – zamykamy aplikację w kontenerze Dockerowym
- Java Bean Validation + Spring Boot – sprawdzanie poprawności danych w Spring Boocie
- Spring Boot + Swagger UI
- Serwer Amazon EC2 i SSL od Let’s encrypt w aplikacji Spring Boot
Przykładowy kod do tego postu można znaleźć na githubie pod adresem: https://github.com/mloza/spring-boot-jpa-jsonb-hibernate
Zaczynamy!
Na początek zaczniemy od zdefiniowania prostej encji, która będzie zawierała pole z klasą, którą chcemy serializować do JSONa. Klasa ta będzie miała kilka przykładowych pól, jakąś listę i mapę. Przykładowo może wyglądać w następujący sposób:
public class JsonbObject implements Serializable { | |
private int intField; | |
private String stringField; | |
private int[] intArrayField; | |
private Map<String, String> mapField = new HashMap<>(); | |
// setters, getters etc. | |
} |
Następnie z wykorzystaniem tej klasy tworzymy encję:
Entity | |
public class JsonbEntity { | |
@Id | |
@GeneratedValue | |
private long id; | |
@Column | |
private JsonbObject jsonbObject; //pole które bÄdzie zapisane jako JSONB | |
// setters, getters, toString... | |
} |
Kolejnym krokiem jest stworzenie Hibernatowego typu, który będzie umiał serializować i deserializować z JSONa naszą klasę.
public class CustomType implements UserType { | |
@Override | |
public int[] sqlTypes() { | |
return new int[]{Types.JAVA_OBJECT}; | |
} | |
@Override | |
public Class returnedClass() { | |
return JsonbObject.class; | |
} | |
@Override | |
public boolean equals(Object o, Object o1) throws HibernateException { | |
return Objects.equals(o, o1); | |
} | |
@Override | |
public int hashCode(Object o) throws HibernateException { | |
return o.hashCode(); | |
} | |
@Override | |
public Object nullSafeGet(ResultSet resultSet, String[] names, SharedSessionContractImplementor sharedSessionContractImplementor, Object owner) throws HibernateException, SQLException { | |
final String cellContent = resultSet.getString(names[0]); | |
if (cellContent == null) { | |
return null; | |
} | |
try { | |
final ObjectMapper mapper = new ObjectMapper(); | |
return mapper.readValue(cellContent.getBytes(Charset.defaultCharset()), returnedClass()); | |
} catch (final Exception ex) { | |
throw new RuntimeException("Failed to convert String to Invoice: " + ex.getMessage(), ex); | |
} | |
} | |
@Override | |
public void nullSafeSet(PreparedStatement preparedStatement, Object value, int idx, SharedSessionContractImplementor sharedSessionContractImplementor) throws HibernateException, SQLException { | |
if (value == null) { | |
preparedStatement.setNull(idx, Types.OTHER); | |
return; | |
} | |
try { | |
final ObjectMapper mapper = new ObjectMapper(); | |
final StringWriter w = new StringWriter(); | |
mapper.writeValue(w, value); | |
w.flush(); | |
preparedStatement.setObject(idx, w.toString(), Types.OTHER); | |
} catch (final Exception ex) { | |
throw new RuntimeException("Failed to convert Invoice to String: " + ex.getMessage(), ex); | |
} | |
} | |
@Override | |
public Object deepCopy(Object value) throws HibernateException { | |
try { | |
// use serialization to create a deep copy | |
ByteArrayOutputStream bos = new ByteArrayOutputStream(); | |
ObjectOutputStream oos = new ObjectOutputStream(bos); | |
oos.writeObject(value); | |
oos.flush(); | |
oos.close(); | |
bos.close(); | |
ByteArrayInputStream bais = new ByteArrayInputStream(bos.toByteArray()); | |
return new ObjectInputStream(bais).readObject(); | |
} catch (ClassNotFoundException | IOException ex) { | |
throw new HibernateException(ex); | |
} | |
} | |
@Override | |
public boolean isMutable() { | |
return true; | |
} | |
@Override | |
public Serializable disassemble(Object o) throws HibernateException { | |
return (Serializable) o; | |
} | |
@Override | |
public Object assemble(Serializable serializable, Object o) throws HibernateException { | |
return serializable; | |
} | |
@Override | |
public Object replace(Object original, Object target, Object owner) throws HibernateException { | |
return original; | |
} | |
} |
Mając już to wszystko, musimy na chwilę wrócić do naszej encji i dodać adnotacje mówiącą Hibernate, że do obsługi naszego pola powinien wykorzystać nasz typ.
@Column | |
@Type(type = "pl.mloza.hibernate.CustomType") | |
private JsonbObject jsonbObject; |
W tym momencie mamy już prawie wszystko. Ostatnim krokiem jest rozszerzenie dialektu PostgreSQL, aby powiedzieć Hibernate, że typ JAVA_OBJECT jest znany i powinien być obsługiwany.
public class CustomPostgreSQL94Dialect extends PostgreSQL94Dialect { | |
public CustomPostgreSQL94Dialect() { | |
this.registerColumnType(Types.JAVA_OBJECT, "jsonb"); | |
} | |
} |
Nie zapomnijmy jeszcze w propertisach poinformować aplikacji, że powinna używać naszego dialektu zamiast domyślnego. Należy dodać linię:
spring.jpa.database-platform=pro.hrsoft.hibernate.CustomPostgreSQL94Dialect
Uruchomienie!
To wszystko pozwoli nam już uruchomić aplikację. Jednak dla ułatwienia interakcji z bazą danych, będziemy potrzebowali jeszcze stworzyć repozytorium JPA. Kod wygląda następująco:
public interface JsonbRepository extends CrudRepository<JsonbEntity, Long> { } |
Gdy mamy już wszystko możemy dodać test, który wykona zapis i odczyt danych z bazy. Wygląda on następująco:
@SpringBootTest | |
public class RepositoryTest extends AbstractTestNGSpringContextTests { | |
@Autowired | |
private JsonbRepository repository; | |
@Test | |
public void shouldSaveDataInJSONB() { | |
JsonbObject jsonbObject = new JsonbObject() | |
.setIntArrayField(new int[]{1, 3, 5, 9}) | |
.setIntField(10) | |
.setStringField("String field tests"); | |
jsonbObject | |
.getMapField() | |
.put("test", "value"); | |
jsonbObject.getMapField().put("test2", "another value"); | |
JsonbEntity jsonbEntity = new JsonbEntity() | |
.setJsonbObject(jsonbObject); | |
JsonbEntity entity = repository.save(jsonbEntity); | |
Optional<JsonbEntity> jsonbObjectNew = repository.findById(entity.getId()); | |
System.out.println(jsonbObjectNew); | |
} | |
} |
Użycie tego w ten sposób jest bardzo wygodne, nie zajmujemy się już serializacją czy deserializacją danych, wykonujemy query i dostajemy obiekt, z którym możemy pracować.
Jeśli sprawdzimy, jak wpis wygląda w bazie, otrzymamy na konsoli następujący wynik:
blog_post=> select * from jsonb_entity; | |
id | jsonb_object | |
----+----------------------------------------------------------------------------------------------------------------------------------------------- | |
2 | {"intField": 10, "mapField": {"test": "value", "test2": "another value"}, "stringField": "String field tests", "intArrayField": [1, 3, 5, 9]} | |
(1 row) |
Querowanie danych w polu JSONB
Jak już wcześniej wspominałem po danych zapisanych w polach JSON i JSONB możemy querować i wyciągać wartości. Możemy do tego również użyć repozytoriów JPA, wstawiając query do adnotacji metody @Query. Jednak jest to temat na oddzielny wpis, pokażę tylko przykładowe query, które wygląda następująco:
blog_post=> SELECT jsonb_object->>'stringField' FROM jsonb_entity WHERE jsonb_object->>'intField'='10'; | |
?column? | |
String field tests | |
(1 row) |
Podsumowanie
Mimo że użycie pola JSON w hibernate nie jest wspierane natywnie, to można z pomocą kilku klas dodać tę obsługę i działa to bardzo fajnie. Raz dodane wsparcie może być wielokrotnie używane w różnych miejscach. JSON daje nam elastyczność, której czasem może brakować w relacyjnej bazie danych. Dzięki temu możemy połączyć dwa światy: RDS oraz NoSQL. Trzeba jednak pamiętać, że wykonywanie query na polach JSON może być nieco wolniejsze, a dane w nich przechowywane mogą zajmować więcej miejsca niż gdyby były rozbite na pola w tabeli.
Co do wielkości przechowywanego obiektu, udało mi się znaleźć informację, że można zapisać do 1GB danych w tym polu.