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.
}

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ż uruichomić aplikację. Jednak dla ułatwienia interakcji z bazą danych, będizemy 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. Jendak 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. 

Michał Autor

Dodaj komentarz

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

This site uses Akismet to reduce spam. Learn how your comment data is processed.