Из коробки 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 сервера, если он установлен не на локальной машине.