«…и мы должны иметь возможность видеть, когда, как и кто изменил данные» — распространённая хотелка при разработке приложения, работающего с БД.
Заказчик обычно хочет видеть, для некоторых таблиц, какие изменения в них вносились, когда вносились, кем вносились и какие были предыдущие значения. Существует множество решений этой задачи: можно обвесить необходимые таблицы триггерами, можно сохранять объекты с помощью хранимых процедур, можно перехватывать запросы к базе с помощью 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.