Из коробки Hibernate поддерживает некий общий набор типов данных SQL и типов данных Java, а также отображений между ними. В основном в этот набор входят базовые вещи, такие как даты, строки, числа, блобы и так далее. С полным списком, а он довольно большой чтобы приводить его здесь, можно ознакомиться в документации Hibernate.
Как я уже сказал, набор этих типов довольно общий и поддерживается большинством баз данных, с которыми работает Hibernate. С другой стороны, каждая база может иметь свои собственные, уникальные и, следовательно, неподдерживаемые типы данных. Со стороны Java, в свою очередь, тоже можно представить собственные структуры данных, которые напрямую не поддерживаются в Hibernate, но желательно иметь возможность их сохранять.
Для решения этой проблемы в Hibernate существует поддержка пользовательских типов данных. То есть можно написать, как тот или иной класс в Java должен сохраняться в тот или иной тип данных (читай столбец в таблице) в SQL базе.
Например, в PostgreSQL существует встроенный тип данных inet для хранения IPv4 и IPv6 адресов. И мы хотели бы использовать этот тип данных при разработке приложения, управляющего, например, выделением ip сетей. И у нас есть проблема: Hibernate не знает про этот тип данных ровным счётом ничего. Попробуем реализовать поддержку этого типа вручную.
Подготовка
В первую очередь создадим Maven проект и добавим в него Hibernate и JDBC драйвер PostgreSQL:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <lombok.version>1.16.6</lombok.version> <postgresql.version>9.4-1206-jdbc42</postgresql.version> <hibernate.version>5.1.0.Final</hibernate.version> <junit.version>4.12</junit.version> <hamcrest.version>1.3</hamcrest.version> </properties> <dependencies> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>${lombok.version}</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <version>${postgresql.version}</version> </dependency> <dependency> <groupId>org.hibernate</groupId> <artifactId>hibernate-core</artifactId> <version>${hibernate.version}</version> </dependency> <dependency> <groupId>org.hibernate</groupId> <artifactId>hibernate-java8</artifactId> <version>${hibernate.version}</version> </dependency> </dependencies> |
Вторым шагом настроим Hibernate на работу с Postgresql:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | <hibernate-configuration> <!-- a SessionFactory instance listed as /jndi/name --> <session-factory> <property name="hibernate.dialect">org.hibernate.dialect.PostgreSQL94Dialect</property> <property name="hibernate.connection.url">jdbc:postgresql://127.0.0.1/test</property> <property name="hibernate.connection.username">test</property> <property name="hibernate.connection.password">test</property> <mapping class="ru.easyjava.data.hibernate.entity.Network"/> </session-factory> </hibernate-configuration> |
По сравнению с H2 базой, используемой ранее, отличия минимальные — изменился Hibernate dialect, да настройки JDBC. Так же я отключил автоматическое создание таблиц, так как нам надо создать таблицу с типом столбцов неизвестным HIbernate.
Модель данных
Описание одной сохраняемой в базе сети будет состоять из двух классов. В первом классе определяется собственно тип «ip сеть»:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | /** * Describes ip network as a pair of host and mask. */ @Data public class NetworkObject implements Serializable { /** * Serializable requirement. */ private static final long serialVersionUID = 1L; /** * Address part of network. */ private final String address; /** * Bitmask part of the network. */ private final short bitmask; } |
Сеть состоит из адреса сети и маски сети. Во втором классе мы будем использовать объект сети как часть хранимой сущности:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | /** * Network storing entity. */ @Entity @ToString public class Network { /** * Entity id. */ @Id @GeneratedValue @Getter @Setter private Long id; /** * Network data. */ @Type(type = "ru.easyjava.data.hibernate.type.NetworkObjectType") @Getter @Setter private NetworkObject network; } |
Обратите внимание, что поле network встроено в сущность Network, а не связано через один-к-одному или иным типом связи. Кроме того, NetworkObject не является и сущностью. Это всего лишь обычный класс, наподобие BigDecimal или LocalDateTime. Аннотация @Type объясняет Hibernate, к кому обращаться, для преобразования этого класса в SQL столбец и обратно.
Пользовательский тип
Чтобы реализовать поддержку собственного типа данных в Hibernate, необходимо реализовать интерфейс UserType и его методы:
1 2 3 4 5 6 7 8 9 10 11 12 | /** * Mapping of NetworkObject to PGSQL cidr type. */ public class NetworkObjectType implements UserType { /** * Reference to the type for Hibernate. */ public static final NetworkObjectType INSTANCE = new NetworkObjectType(); //Methods } |
Поле INSTANCE используется, например, для спецификации типов возвращаемых из SQL запросов. Методов же в интерфейсе UserType немало:
1 2 3 4 | @Override public int[] sqlTypes() { return new int[]{StringType.INSTANCE.sqlType()}; } |
sqlTypes() собщает Hibernate, как представлять данные со стороны базы данных. В моём случае для простоты я буду использовать строковое представление.
1 2 3 4 | @Override public Class returnedClass() { return NetworkObject.class; } |
returnedClass() наоборот, сообщает какой класс будет отдан при чтении из базы.
1 2 3 4 | @Override public boolean equals(Object o, Object o1) throws HibernateException { return Objects.equals(0, 01); } |
equals(), очевидно, сравнивает два объекта. В моей реализации я полагаюсь на корректную реализацию equals() от project lombok. Равно как и для hashCode():
1 2 3 4 | @Override public int hashCode(Object o) throws HibernateException { return o.hashCode(); } |
Наконец, самое главное происходит в двух методах. Первый, nullSafeGet(), восстанавливает объект при чтении его из базы:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | @Override public Object nullSafeGet( ResultSet rs, String[] names, SessionImplementor si, Object owner) throws HibernateException, SQLException { String network = rs.getString(names[0]); if (null != network) { String[] buf = network.split("/"); //Host addresses are stored without bitmask //So we have check, where we are equipped with //bitmask and, if not, find a proper value. Short prefix = 128; if (buf.length > 1) { prefix = Short.valueOf(buf[1]); } else { if (!buf[0].contains(":")) { prefix = 32; } } return new NetworkObject(buf[0], prefix); } return null; } |
В nullSafeGet() передаётся самый обычный JDBC ResultSet, из которого мы получаем значение поля, как оно представлено в базе данных и строим каким-то образом необходимый объект из этого значения. В нашем частном случае адрес сети представляется как строка вида «адрес/маска», а адрес хоста как строка вида «адрес». Поэтому я разбиваю значение поля по символу «/», проверяю наличие маски и если она отсутствует, угадываю тип адреса (IPv4 или IPv6) и добавляю соответствующую маску хоста.
Вторая функция, nullSafeSet(), наоборот, преобразовывает значения класса в вид, пригодный для записи в базу:
1 2 3 4 5 6 7 8 9 | @Override public void nullSafeSet(PreparedStatement st, Object value, int i, SessionImplementor sessionImplementor) throws HibernateException, SQLException { if ( null == value ) { st.setNull(i, StringType.INSTANCE.sqlType()); } else { NetworkObject network = (NetworkObject) value; st.setString(i, network.getAddress() + "/" + network.getBitmask()); } } |
В nullSafeSet() передаётся JDBC PreparedStatement, аргументы которого заполняются. В моём конкретном примере я строю строку вида «адрес/маска»
Далее следуют функции, которые нужные для поддержки кэширования и отслеживания изменений.
1 2 3 4 | @Override public boolean isMutable() { return false; } |
isMutable() сообщает Hibernate, могут ли поля объекта менять свои значения после создания или нет. Для упрощения кода я сделал NetworkObject immutable и, соответственно, возвращаю здесь false.
1 2 3 4 | @Override public Object deepCopy(Object o) throws HibernateException { return o; } |
deepCopy() создаёт полную копию объекта. Так как мой объект immutable, я возвращаю оригинал.
1 2 3 4 5 6 7 8 9 | @Override public Serializable disassemble(Object o) throws HibernateException { return (Serializable) o; } @Override public Object assemble(Serializable cached, Object owner) throws HibernateException { return cached; } |
assemble() и disassemble() конвертируют объект в вид, пригодный для хранения в кэше второго уровня и восстанавливают объект из вида, пригодного для хранения в кэше второго уровня. Так как NetworkObject реализует Serializable, я просто привожу его к этому интерфейсу. Как вариант реализации, я мог бы преобразовывать объект в строку и возвращать её, а впоследствии создавать из строки по новой.
1 2 3 4 | @Override public Object replace(Object original, Object target, Object owner) throws HibernateException { return original; } |
Наконец replace() копирует изменения из нового объекта в старый. Так как объект immutable, достаточно вернуть оригинал.
Для тех, кому интересно, полный код пользовательского типа приведён под катом.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 | /** * Mapping of NetworkObject to PGSQL cidr type. */ public class NetworkObjectType implements UserType { /** * Reference to the type for Hibernate. */ public static final NetworkObjectType INSTANCE = new NetworkObjectType(); @Override public int[] sqlTypes() { return new int[]{StringType.INSTANCE.sqlType()}; } @Override public Class returnedClass() { return NetworkObject.class; } @Override public boolean equals(Object o, Object o1) throws HibernateException { return Objects.equals(0, 01); } @Override public int hashCode(Object o) throws HibernateException { return o.hashCode(); } @Override public Object nullSafeGet( ResultSet rs, String[] names, SessionImplementor si, Object owner) throws HibernateException, SQLException { String network = rs.getString(names[0]); if (null != network) { String[] buf = network.split("/"); //Host addresses are stored without bitmask //So we have check, where we are equipped with //bitmask and, if not, find a proper value. Short prefix = 128; if (buf.length > 1) { prefix = Short.valueOf(buf[1]); } else { if (!buf[0].contains(":")) { prefix = 32; } } return new NetworkObject(buf[0], prefix); } return null; } @Override public void nullSafeSet(PreparedStatement st, Object value, int i, SessionImplementor sessionImplementor) throws HibernateException, SQLException { if ( null == value ) { st.setNull(i, StringType.INSTANCE.sqlType()); } else { NetworkObject network = (NetworkObject) value; st.setString(i, network.getAddress() + "/" + network.getBitmask()); } } @Override public boolean isMutable() { return false; } @Override public Object deepCopy(Object o) throws HibernateException { return o; } @Override public Serializable disassemble(Object o) throws HibernateException { return (Serializable) o; } @Override public Object assemble(Serializable cached, Object owner) throws HibernateException { return cached; } @Override public Object replace(Object original, Object target, Object owner) throws HibernateException { return original; } } |
Тестирование
Для теста понадобится запущенный PostgreSQL, в котором мы создадим пользователя, базу данных и таблицу:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | CREATE ROLE test WITH PASSWORD 'test'; ALTER ROLE test WITH LOGIN; CREATE DATABASE test OWNER test; \c test CREATE TABLE network (id BIGINT PRIMARY KEY, network INET); GRANT ALL ON network TO test; CREATE SEQUENCE hibernate_sequence; GRANT ALL ON hibernate_sequence TO test; CREATE CAST (CHARACTER VARYING AS inet) WITH INOUT AS ASSIGNMENT; |
Кроме таблицы и sequence для автогенерации первичных ключей я создаю автоматическое преобразование из inet в строку и обратно. Это позволяет мне использовать строковый тип данных со стороны Hibernate.
Для сохранения в базу я использую адреса example.org:
1 2 3 4 5 6 7 8 9 10 11 | NetworkObject example = new NetworkObject("93.184.216.34", (short)24); NetworkObject example6 = new NetworkObject("2606:2800:220:1:248:1893:25c8:1946", (short)64); Network n4 = new Network(); n4.setNetwork(example); Network n6 = new Network(); n6.setNetwork(example6); session.save(n4); session.save(n6); |
Которые потом прочитаю из базы:
1 2 3 4 | session.createCriteria(Network.class) .list() .stream() .forEach(System.out::println); |
1 2 | Network(id=1, network=NetworkObject(address=93.184.216.34, bitmask=24)) Network(id=2, network=NetworkObject(address=2606:2800:220:1:248:1893:25c8:1946, bitmask=64)) |
И проверю, что в базе данных они корректно представлены как ip сети:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | test=# select * from network; id | network ----+--------------------------------------- 3 | 93.184.216.34/24 4 | 2606:2800:220:1:248:1893:25c8:1946/64 (2 строки) test=# \d network Таблица "public.network" Колонка | Тип | Модификаторы ---------+--------+-------------- id | bigint | NOT NULL network | inet | Индексы: "network_pkey" PRIMARY KEY, btree (id) |
Код примера доступен на gitbub. Код из примера использует PostgreSQL. В hibernate.cfg.xml примера следует заменить адрес сервера с ‘127.0.0.1’ на адрес вашего PostgreSQL сервера, если он установлен не на локальной машине.