Наверняка многие из вас сталкивались с проблемой хранения перечислений в базе данных, возникающей при попытке реализации удобного способа работы с разного рода служебными справочниками — статусами, типами объектов и так далее.
Суть её очень проста: если хранить перечисления как сущности (
Особенно актуально это в том случае, когда поле, содержащее перечисление, аннотировано как
Однако сама идея хранения в базе данных заманчива простотой построения запросов вручную, очень ценной при отладке ПО или решении сложных ситуаций. Когда можно написать не просто
На самом деле, существует очень простое и изящное решение этой проблемы, убивающее сразу всех зайцев.
Решение это основывается на простом хаке, который, хоть и хак, не привносит никаких сайд-эффектов. Как известно, перечисления в Java — всего лишь синтаксический сахар, внутри представленый всё теми же экземплярами классов, порождённых от
Нам всего лишь надо прочитать из справочника в базе данных актуальный идентификатор элемента перечисления и поместить его в это поле. Тогда мы сможем использовать штатным образом
Тут может показаться, что такой подход рождает проблемы с сериализацией объектов, однако это не так, ибо спецификация платформы Java дословно говорит нам следующее:
То есть при сериализации перечисления всегда преобразуются в строковую форму, а числовое значение игнорируется. Вуаля!
А теперь немного практики. Для начала, определим модель данных для нашего примера:
Теперь опишем эту же схему данных на Java. Обратите внимание, что в данном случае определяется и перечисление, и класс сущности для справочника. Чтобы избежать повторения однообразного кода, справочники для перечислений наследуются от
Теперь нам всего лишь осталось прочитать значения из базы данных и записать их в поле
Как видно, мы просто получаем из Hibernate полный список классов, отражённых на базу данных, отбираем из них все, наследуемые от объявленного выше
Для достижения полного дзена можно (и нужно) добавить также проверку того, что в базе данных не содержится лишних значений, то есть что таблица-справочник и объявление enum в коде синхронизированы.
Данный подход используется нами (Open Source Technologies) в достаточно крупных системах (от полумиллиона строк исходного кода и больше) с распределённой сервис-ориентированной архитектурой на базе JMS и очень хорошо себя показал — как в части удобства использования, так и в части надёжности. Чего и вам желаю :)
Суть её очень проста: если хранить перечисления как сущности (
@Entity
), то с ними получается крайне неудобно работать, база данных нагружается лишними запросами даже несмотря на кэширование, а сами запросы к БД усложняются лишними JOIN'ами. Если же перечисление определять как enum, то с ними становится удобно работать, но возникает проблема синхронизации с базой данных и отслеживания ошибок таковой синхронизации.Особенно актуально это в том случае, когда поле, содержащее перечисление, аннотировано как
@Enumerated(EnumType.ORDINAL)
— всё мгновенно ломается при смене порядка объявления значений. Если же мы храним значения в строковом виде — как @Enumerated(EnumType.STRING)
— возникает проблема скорости доступа, так как индексы по строковым полям менее эффективны и занимают больше места. Более того, вне зависимости от способа хранения значения поля при отсутствии в базе данных таблицы со списком допустимых значений мы никак не защищены от некорректных или устаревших данных и, как следствие, проблем.Однако сама идея хранения в базе данных заманчива простотой построения запросов вручную, очень ценной при отладке ПО или решении сложных ситуаций. Когда можно написать не просто
SELECT id, title FROM product WHERE status = 5
, а, скажем, SELECT id, title FROM product JOIN status ON status.id = product.status_id WHERE status.code = 'NEW'
— это очень ценно. В том числе и тем, что мы всегда можем быть уверены в том, что status_id
содержит корректное значение, если поставим FOREIGN KEY
.На самом деле, существует очень простое и изящное решение этой проблемы, убивающее сразу всех зайцев.
Решение это основывается на простом хаке, который, хоть и хак, не привносит никаких сайд-эффектов. Как известно, перечисления в Java — всего лишь синтаксический сахар, внутри представленый всё теми же экземплярами классов, порождённых от
java.lang.Enum
. И вот как раз в последнем есть чудесное поле ordinal
, объявленное как private
, которое и хранит значение, возвращаемое методом ordinal()
и используемое ORM для помещения в базу.Нам всего лишь надо прочитать из справочника в базе данных актуальный идентификатор элемента перечисления и поместить его в это поле. Тогда мы сможем использовать штатным образом
EnumType.ORDINAL
для хранения в базе с быстрым и удобным доступом, сохраняя таким образом все прелести собственно Enum'ов в Java, и не иметь проблем с синхронизацией идентификаторов и их актуальностью.Тут может показаться, что такой подход рождает проблемы с сериализацией объектов, однако это не так, ибо спецификация платформы Java дословно говорит нам следующее:
1.12. Serialization of Enum Constants
Enum constants are serialized differently than ordinary serializable or externalizable objects. The serialized form of an enum constant consists solely of its name; field values of the constant are not present in the form.
То есть при сериализации перечисления всегда преобразуются в строковую форму, а числовое значение игнорируется. Вуаля!
А теперь немного практики. Для начала, определим модель данных для нашего примера:
CREATE SEQUENCE status_id;
CREATE SEQUENCE product_id;
CREATE TABLE status (
id INTEGER NOT NULL DEFAULT NEXT VALUE FOR status_id,
code CHARACTER VARYING (32) NOT NULL,
CONSTRAINT status_pk PRIMARY KEY (id),
CONSTRAINT status_unq1 UNIQUE KEY (code)
);
INSERT INTO status (code) VALUES ('NEW');
INSERT INTO status (code) VALUES ('ACTIVE');
INSERT INTO status (code) VALUES ('DELETED');
CREATE TABLE product (
id INTEGER NOT NULL DEFAULT NEXT VALUE FOR product_id,
status_id INTEGER NOT NULL,
title CHARACTER VARYING (128) NOT NULL,
CONSTRAINT product_pk PRIMARY KEY (id),
CONSTRAINT product_unq1 UNIQUE KEY (title),
CONSTRAINT product_fk1 FOREIGN KEY (status_id)
REFERENCES status (id) ON UPDATE CASCADE ON DELETE RESTRICT
);
CREATE INDEX product_fki1 ON product (status_id);
Теперь опишем эту же схему данных на Java. Обратите внимание, что в данном случае определяется и перечисление, и класс сущности для справочника. Чтобы избежать повторения однообразного кода, справочники для перечислений наследуются от
SystemDictionary
. Также обратите внимание на аннотацию @MappedEnum
, которая будет нами использоваться в дальнейшем, чтобы определять, какие перечисления отражены на базу данных.public enum Status {
NEW,
ACTIVE,
DELETED
}
@Retention(value = RetentionPolicy.RUNTIME)
public @interface MappedEnum {
Class<? extends Enum> enumClass();
}
@MappedSuperclass
public class SystemDictionary {
@Id
@GeneratedValue(generator = "entityIdGenerator")
@Column(name = "id", nullable = false, unique = true)
private Integer id;
@Column(name = "code", nullable = false, unique = true, length = 32)
private String code;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
}
@Entity
@Table(name = "status")
@SequenceGenerator(name = "entityIdGenerator", sequenceName = "status_id")
@MappedEnum(enumClass = Status.class)
public class StatusEx extends SystemDictionary {
}
@Entity
@Table(name = "product")
@SequenceGenerator(name = "entityIdGenerator", sequenceName = "product_id")
public class Product {
@Id
@GeneratedValue(generator = "entityIdGenerator")
@Column(name = "id", nullable = false, unique = true)
private Integer id;
@Column(name = "status_id", nullable = false, unique = false)
@Enumerated(EnumType.ORDINAL)
private Status status;
@Column(name = "title", nullable = false, unique = true)
private String title;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public Status getStatus() {
return status;
}
public void setStatus(Status status) {
this.status = status;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
}
Теперь нам всего лишь осталось прочитать значения из базы данных и записать их в поле
ordinal
, а также не забыть обновить ещё и массив values
, чтобы можно было получать экземпляры перечисления по индексу из getEnumConstants()
— это не только используется тем же Hibernate при работе с перечислениями, но и просто местами очень удобно. Сделать это можно сразу после инициализации подключения к базе данных использованием примерно такого кода:public interface SessionAction {
void run(Session session);
}
public class EnumLoader implements SessionAction {
@Override
public void run(Session session) {
Iterator<PersistentClass> mappingList = configuration.getClassMappings();
while (mappingList.hasNext()) {
PersistentClass mapping = mappingList.next();
Class<?> clazz = mapping.getMappedClass();
if (!SystemDictionary.class.isAssignableFrom(clazz))
continue;
if (!clazz.isAnnotationPresent(MappedEnum.class))
continue;
MappedEnum mappedEnum = clazz.getAnnotation(MappedEnum.class);
updateEnumIdentifiers(session, mappedEnum.enumClass(), (Class<SystemDictionary>) clazz);
}
}
private void updateEnumIdentifiers(
Session session,
Class<? extends Enum> enumClass,
Class<? extends SystemDictionary> entityClass) {
List<SystemDictionary> valueList =
(List<SystemDictionary>) session.createCriteria(entityClass).list();
int maxId = 0;
Enum[] constants = enumClass.getEnumConstants();
Iterator<SystemDictionary> valueIterator = valueList.iterator();
while (valueIterator.hasNext()) {
SystemDictionary value = valueIterator.next();
int valueId = value.getId().intValue();
setEnumOrdinal(Enum.valueOf(enumClass, value.getCode()), valueId);
if (valueId > maxId)
maxId = valueId;
}
Object valuesArray = Array.newInstance(enumClass, maxId + 1);
for (Enum value : constants)
Array.set(valuesArray, value.ordinal(), value);
Field field;
try {
field = enumClass.getDeclaredField("$VALUES");
field.setAccessible(true);
Field modifiersField = Field.class.getDeclaredField("modifiers");
modifiersField.setAccessible(true);
modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);
field.set(null, valuesArray);
} catch (Exception ex) {
throw new Exception("Can't update values array: ", ex);
}
}
private void setEnumOrdinal(Enum object, int ordinal) {
Field field;
try {
field = object.getClass().getSuperclass().getDeclaredField("ordinal");
field.setAccessible(true);
field.set(object, ordinal);
} catch (Exception ex) {
throw new Exception("Can't update enum ordinal: " + ex);
}
}
}
Как видно, мы просто получаем из Hibernate полный список классов, отражённых на базу данных, отбираем из них все, наследуемые от объявленного выше
SystemDictionary
и, одновременно, содержащие аннотацию @MappedEnum
, после чего обновляем числовые значения экземпляров класса перечисления. Собственно, на этом всё. Теперь мы спокойно можем:- Хранить перечисления как Java Enum и объявлять содержащие их поля как
@Enumerated(EnumType.ORDINAL)
- Автоматически контролировать синхронизацию справочников в коде и базе данных
- Не заботиться о порядке объявления идентификаторов в коде и соответствия их идентификаторам в базе данных
- Выполнять удобные запросы к базе данных, содержащие доступ к значениям перечислений по их строковому названию
- ...
- PROFIT!
Для достижения полного дзена можно (и нужно) добавить также проверку того, что в базе данных не содержится лишних значений, то есть что таблица-справочник и объявление enum в коде синхронизированы.
Данный подход используется нами (Open Source Technologies) в достаточно крупных системах (от полумиллиона строк исходного кода и больше) с распределённой сервис-ориентированной архитектурой на базе JMS и очень хорошо себя показал — как в части удобства использования, так и в части надёжности. Чего и вам желаю :)