«…и мы должны иметь возможность видеть, когда, как и кто изменил данные» — распространённая хотелка при разработке приложения, работающего с БД.
Заказчик обычно хочет видеть, для некоторых таблиц, какие изменения в них вносились, когда вносились, кем вносились и какие были предыдущие значения. Существует множество решений этой задачи: можно обвесить необходимые таблицы триггерами, можно сохранять объекты с помощью хранимых процедур, можно перехватывать запросы к базе с помощью AOP и так далее. Hibernate предлагает собственно решение, envers, решающее задачу ведения истории с помощью одной аннотации.
Настройка envers
Чтобы включить автоматическое ведение истории изменений, необходимо выполнить два шага:
- Добавить библиотеку envers в classpath:
1
2
3
4
5
|
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-envers</artifactId>
<version>5.2.0.Final</version>
</dependency>
|
- Добавить аннотацию @Audited к классам, для которых требуется отслеживание истории изменении. Рекоменуется добавить эту аннотацию так же и к классам, имеющим связи с классами, уже помеченными @Audited. Далее в статье я буду использовать сущности из примера HQL.
1
2
3
4
5
6
7
8
9
|
@Entity
@Audited
public class Person extends AbstractIdentifiableObject {
@Getter
@Setter
private String firstName;
/* other fields */
}
|
- имя_сущности_AUD — для хранения исторических данных. Эти таблицы состоят из поля id отслеживамой сущности, поля rev, в котором хранится номер редакции, поля revtype, в котором хранится тип изменения (создано/модифицировано/удалено) и все отслеживаемые поля оригинальной сущности.
- REVINFO — для хранения списка ревизий. В этой таблице хранятся номера ревизий и время их создания. Так же в этой таблице можно хранить дополнительные данные, такие как имя пользователя, создавшего ревизию, например.
Когда
Для начала ответим на вопрос «Когда были изменены данные». Создадим пробную персону и проверим, как это отразилось в истории.
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
|
Passport p = new Passport();
p.setSeries("AS");
p.setNo("123456");
p.setIssueDate(LocalDate.now());
p.setValidity(Period.ofYears(20));
Address a = new Address();
a.setCity("Kickapoo");
a.setStreet("Main street");
a.setBuilding("1");
Person person = new Person();
person.setFirstName("Test");
person.setLastName("Testoff");
person.setDob(LocalDate.now());
person.setPrimaryAddress(a);
person.setPassport(p);
Company c = new Company();
c.setName("Acme Ltd");
p.setOwner(person);
person.setWorkingPlaces(Collections.singletonList(c));
Session session = sessionFactory.openSession();
session.beginTransaction();
session.merge(person);
session.getTransaction().commit();
session.close();
|
1
2
3
4
5
6
7
8
|
Object[] result = (Object[]) AuditReaderFactory
.get(session)
.createQuery()
.forRevisionsOfEntity(Person.class, false, true)
.getSingleResult();
System.out.println(result[0]);
System.out.println(result[1]);
System.out.println(result[2]);
|
Запрос forRevisionsOfEntity() возвращает данные о изменениях сущности. Первый параметр задаёт класс сущности, второй параметр регулирует, хотим ли мы получить только ревизии ( true) или ревизии и данные сущностей ( false). В первом случае возвращаются объекты ревизий, во втором — массив из трех объектов: данные сущности в ревизии, ревизия, тип изменения. Наконец последний параметр регулирует, включать ли в вывод удалённые записи или нет.
1
2
3
|
Person{firstName='Test', lastName='Testoff', dob=2016-08-25, passport=Passport{series='AS', no='123456', issueDate=2016-08-25, validity=P20Y, owner=Testoff}, primaryAddress=Address{city='Kickapoo', street='Main street', building='1', tenants=Test}, workingPlaces=[Company{name='Acme Ltd', workers=Test}]}
DefaultRevisionEntity(id = 1, revisionDate = 25.08.2016 11:02:15)
ADD
|
В результате мы видим, что в ревизии 1, созданной сегодня, 25 августа, в 11:02, была добавлена сущность Person и отображены её данные.
Как
Когда изменились данные мы видим, но интересно было бы посмотреть в динамике — какие были значения, какие стали. Попробуем изменить ранее созданную персону:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// Set new name
session = sessionFactory.openSession();
session.beginTransaction();
Person p = (Person) session
.createCriteria(Person.class)
.add(Restrictions.eq("firstName", "Test"))
.uniqueResult();
p.setFirstName("Johan");
p.setLastName("von Testow");
session.save(p);
session.getTransaction().commit();
session.close();
|
1
2
3
4
5
6
7
8
9
10
11
|
AuditReaderFactory
.get(session)
.createQuery()
.forRevisionsOfEntity(Person.class, false, true)
.getResultList()
.forEach(r -> {
Object[] v = (Object[])r;
System.out.println(v[0]);
System.out.println(v[1]);
System.out.println(v[2]);
});
|
1
2
3
4
5
6
|
Person{firstName='Test', lastName='Testoff', dob=2016-08-25, passport=Passport{series='AS', no='123456', issueDate=2016-08-25, validity=P20Y, owner=Testoff}, primaryAddress=Address{city='Kickapoo', street='Main street', building='1', tenants=Test}, workingPlaces=[Company{name='Acme Ltd', workers=Test}]}
DefaultRevisionEntity(id = 1, revisionDate = 25.08.2016 11:16:14)
ADD
Person{firstName='Johan', lastName='von Testow', dob=2016-08-25, passport=Passport{series='AS', no='123456', issueDate=2016-08-25, validity=P20Y, owner=von Testow}, primaryAddress=Address{city='Kickapoo', street='Main street', building='1', tenants=Johan}, workingPlaces=[Company{name='Acme Ltd', workers=Johan}]}
DefaultRevisionEntity(id = 2, revisionDate = 25.08.2016 11:16:14)
MOD
|
В этот раз я запрашиваю список ревизий и сразу вижу, что над сущностью Person было проведено две операции — создание записи и изменение данных. В обоих случаях видно когда была проведена операция и как стала выглядеть изменяемая сущность.
Очевидно, что выборка по всей истории изменений не так интересна, как выборка только по интересующему объекту. В AuditQuery можно задавать критерии выборки используя похожий на Criteria API подход. Создадим новую персону и проведём над ней несколько действий:
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
|
session = sessionFactory.openSession();
session.beginTransaction();
Address address = p.getPrimaryAddress();
session.detach(address);
address.setId(null);
Passport passport = new Passport();
passport.setSeries("TT");
passport.setNo("250816");
passport.setIssueDate(LocalDate.now());
passport.setValidity(Period.ofYears(20));
Person person = new Person();
person.setFirstName("Brunhild");
person.setLastName("Testonkowski");
person.setDob(LocalDate.now());
person.setPrimaryAddress(p.getPrimaryAddress());
person.setPassport(passport);
passport.setOwner(person);
session.save(person);
session.getTransaction().commit();
session.close();
session = sessionFactory.openSession();
session.beginTransaction();
person.setLastName("von Testow");
person.getPassport().setSeries("VT");
person.getPassport().setNo("101731");
session.merge(person);
session.getTransaction().commit();
session.close();
|
1
2
3
4
5
6
7
8
9
10
11
12
|
AuditReaderFactory
.get(session)
.createQuery()
.forRevisionsOfEntity(Person.class, false, true)
.add(AuditEntity.property("firstName").eq("Brunhild"))
.getResultList()
.forEach(r -> {
Object[] v = (Object[])r;
System.out.println(v[0]);
System.out.println(v[1]);
System.out.println(v[2]);
});
|
В запросе к истории я делаю ограничение по значению поля firstName, что позволяет мне просмотреть не всю историю изменений, а только по персоне с именем Брунгильда.
1
2
3
4
5
6
|
Person{firstName='Brunhild', lastName='Testonkowski', dob=2016-08-25, passport=Passport{series='TT', no='250816', issueDate=2016-08-25, validity=P20Y, owner=Testonkowski}, primaryAddress=Address{city='Kickapoo', street='Main street', building='1', tenants=Brunhild}, workingPlaces=[]}
DefaultRevisionEntity(id = 3, revisionDate = 25.08.2016 11:16:14)
ADD
Person{firstName='Brunhild', lastName='von Testow', dob=2016-08-25, passport=Passport{series='VT', no='101731', issueDate=2016-08-25, validity=P20Y, owner=von Testow}, primaryAddress=Address{city='Kickapoo', street='Main street', building='1', tenants=Brunhild}, workingPlaces=[]}
DefaultRevisionEntity(id = 4, revisionDate = 25.08.2016 11:16:14)
MOD
|
Можно так же убедиться, что изменения в связанных таблицах так же записываются:
1
2
3
4
5
6
7
8
9
10
11
|
AuditReaderFactory
.get(session)
.createQuery()
.forRevisionsOfEntity(Passport.class, false, true)
.getResultList()
.forEach(r -> {
Object[] v = (Object[])r;
System.out.println(v[0]);
System.out.println(v[1]);
System.out.println(v[2]);
});
|
1
2
3
4
5
6
7
8
9
10
11
12
|
Passport{series='AS', no='123456', issueDate=2016-08-25, validity=P20Y, owner=Testoff}
DefaultRevisionEntity(id = 1, revisionDate = 25.08.2016 11:16:14)
ADD
Passport{series='TT', no='250816', issueDate=2016-08-25, validity=P20Y, owner=Testonkowski}
DefaultRevisionEntity(id = 3, revisionDate = 25.08.2016 11:16:14)
ADD
Passport{series='VT', no='101731', issueDate=2016-08-25, validity=P20Y, owner=von Testow}
DefaultRevisionEntity(id = 4, revisionDate = 25.08.2016 11:16:14)
MOD
Passport{series='null', no='null', issueDate=null, validity=null, owner=None}
DefaultRevisionEntity(id = 5, revisionDate = 25.08.2016 11:16:14)
DEL
|
Наконец, кроме просмотра истории ревизий, можно запросить и конкретную ревизию:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
session = sessionFactory.openSession();
session.beginTransaction();
System.out.println(
session
.createCriteria(Person.class)
.add(Restrictions.eq("firstName", "Johan"))
.uniqueResult()
);
System.out.println(
AuditReaderFactory
.get(session)
.createQuery()
.forEntitiesAtRevision(Person.class, 1)
.getSingleResult()
);
session.getTransaction().commit();
session.close();
|
1
2
|
Person{firstName='Johan', lastName='von Testow', dob=2016-08-25, passport=Passport{series='AS', no='123456', issueDate=2016-08-25, validity=P20Y, owner=von Testow}, primaryAddress=Address{city='Kickapoo', street='Main street', building='1', tenants=Johan}, workingPlaces=[Company{name='Acme Ltd', workers=Johan}]}
Person{firstName='Test', lastName='Testoff', dob=2016-08-25, passport=Passport{series='AS', no='123456', issueDate=2016-08-25, validity=P20Y, owner=Testoff}, primaryAddress=Address{city='Kickapoo', street='Main street', building='1', tenants=Test}, workingPlaces=[Company{name='Acme Ltd', workers=Test}]}
|
forEntitiesAtRevision() создаёт запрос, достающие значение сущности заданного класса для заданной ревизии. На него так же можно накладывать критерии, как и на предыдущий запрос. В примере выше мы видим, что текущее состояние сущности и её историческое состояние отличаются.
Кто
Самый интересный вопрос — кто же внёс изменения в таблицу. Hibernate сам не записывает такую информацию, так как не знаком со структурой кажжого приложения в мире, поэтому для записывания данных пользователя придётся немного потрудится.
В первую очередь расширим сущность ревизии с тем, чтобы добавить в неё дополнительное поле:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
/**
* Custom revision entity that holds additional data.
*/
@Entity
@RevisionEntity(UserRevisionListener.class)
class UserRevision extends DefaultRevisionEntity {
/**
* User who created that revision.
*/
@Getter
@Setter
private String username;
}
|
Аннотация @RevisionEntity ссылается на UserRevisionListener, который будет вызываться для заполнения дополнительных полей ревизии. Пока что я буду писать в него константную строку, но в реальной жизни там будет больше кода:
1
2
3
4
5
6
7
8
9
10
11
12
|
/**
* Custom revision entity processor.
*/
class UserRevisionListener implements RevisionListener {
private final static String USERNAME = "vpupkin";
@Override
public void newRevision(Object o) {
UserRevision r= (UserRevision) o;
r.setUsername(USERNAME);
}
}
|
Наконец, надо не забыть добавить собственную сущность ревизии в конфигурацию маппинга:
1
|
<mapping class="ru.easyjava.data.hibernate.envers.UserRevision"/>
|
Результат на лицо:
1
2
3
|
Person{firstName='Test', lastName='Testoff', dob=2016-08-25, passport=Passport{series='AS', no='123456', issueDate=2016-08-25, validity=P20Y, owner=Testoff}, primaryAddress=Address{city='Kickapoo', street='Main street', building='1', tenants=Test}, workingPlaces=[Company{name='Acme Ltd', workers=Test}]}
UserRevision(super=DefaultRevisionEntity(id = 5, revisionDate = 25.08.2016 12:10:08), username=vpupkin)
ADD
|
Envers и JPA
Механизм envers будет работать и при использовании Hibernate в качестве JPA Implementation, но с небольшими изменениями. При создании AuditQuery надо передавать объект EntityManager, а не Session и не требуется явно регистрировать собственную сущность ревизий.
Код примера доступен на github.