Wsparcie dla pola typu JSONB w PostgreSQL dla Spring Data JPA + Hibernate

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.

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.
}
view raw JsonbObject.java hosted with ❤ by GitHub

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...
}
view raw JsonbEntity.java hosted with ❤ by GitHub

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;
}
}
view raw CustomType.java hosted with ❤ by GitHub

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;
view raw Column.java hosted with ❤ by GitHub

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> { }
view raw JsonbRepository.java hosted with ❤ by GitHub

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);
}
}
view raw RepositoryTest.java hosted with ❤ by GitHub

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)
view raw blog_post.sql hosted with ❤ by GitHub

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)
view raw blog_post2.sql hosted with ❤ by GitHub

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. 

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *