Multitenancy(мультиарендность) — это подход к проектированию приложения, когда один экземпляр приложений обслуживает несколько клиентов с непересекающимися наборами данных. Например сайт по учёту персональных финансов имеет одну копию кода, одно хранилище данных и много клиентов, при этом каждому клиенту доступны только его собственные данные. Наиболее популярен этот подход, по очевидным причинам, в облачных SaaS решениях — каждый клиент видит общий, единый, экземпляр приложения как свою собственную копию.
Чаще всего проблемы с реализацией multitenancy кроются не на уровне логики приложения, а на уровне хранения данных: мы должны уметь отделять данные одного клиента от другого и не давать им шанса смешаться. Существует три основных подхода к решению этой проблемы:
- Разделение на уровне базы данных — под каждого клиента создаётся (или запускается) отдельный экземпляр БД, уникальный для этого клиента и в нём распологаются данные только этого клиента.
- Разделение на уровне схемы — в одной и той же базе данных создаются разные схемы (schema/namespace) в которых создаются копии структуры таблиц, необходимых для работы клиента и данные каждого клиента живут в отдельно схеме.
- Разделение на уровне таблиц — и база данных одна, и схема одна, и даже таблицы те же самые. Но в каждой таблице заводится столбец (дискриминатор), указывающий, какому клиенту принадлежит какая таблица.
Hibernate поддерживает первые два подхода к multitenancy, а третий подход можно реализовать вручную, используя интерцепторы и фильтры.
Фильтры в Hibernate
Hibernate позволяет определить простое SQL условие, которое будет прикладываться к любом запросу. Фактически фильтр это просто параметризованное SQL условие с именем, которое в рамках одной сессии прикладывается к каждому выполняемому запросу.
Определяется фильтр в два шага: вначале задаётся имя фильтра и список его параметров, затем собственно текст запроса для фильтра. Например, зная что мы делаем основанную на дискриминаторе multitenancy, и что в каждой таблице есть столбец дискриминатора, который называется, допустим, tenantId, мы могли бы написать следующий фильтр:
1 2 3 4 5 6 | @FilterDefs({ @FilterDef(name = "discriminator_filter", parameters={@ParamDef( name="tenantIdValue", type="string" )} ) }) @Filters({ @Filter(name="discriminator_filter", condition="tenantId = :tenantIdValue") }) |
Фильтр называется discriminator_filter, принимает параметр tenantIdValue типа String. Фильтр добавит к запросу SQL условие tenantId = :tenantIdValue.
Чтобы использовать фильтр, необходимо разрешить его применение в сессии:
1 2 3 4 | session = sessionFactory .openSession(); session.enableFilter("discriminator_filter") .setParameter("tenantIdValue", "de"); |
Discriminator multitenancy
Выше пример использования фильтров в Hibernate показывает, как ограничивать запросы по tenant id, хранящемуся в таблицах. Но этого недостаточно — его надо хранить и корректно заполнять.
В отличие от других подходов к multitenancy, этот подход требует внесения изменений в схему данных, в частности добавления столбца дискриминатора. Это проще всего сделать создав отдельный @MappedSuperclass для хранения этого столбца и унаследовать все остальные сущности от него.
1 2 3 4 5 6 7 8 9 10 | @MappedSuperclass public class AbstractDiscriminatorObject extends AbstractIdentifiableObject { /** * Actual tenant id value. */ @Getter @Setter private String tenantId; } |
Я использовал схему данных из других примеров multitenancy и добавил в неё дополнительный класс. В коде выше хорошо видно, что классы с @MappedSuperclass могут быть унаследованы друг от друга и создавать достаточно сложные иерархии наследования.
Последней частью имитации discriminator based multitenancy станет интерцептор, который будет заполнять tenant id при сохранении сущности в базу.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | public class DiscriminatorSettingInterceptor extends EmptyInterceptor { private TenantIdResolver resolver = new TenantIdResolver(); @Override public void preFlush(Iterator entities) { entities.forEachRemaining(o -> { if (o instanceof AbstractDiscriminatorObject) { AbstractDiscriminatorObject t = (AbstractDiscriminatorObject) o; if (t.getTenantId() == null) { String tenantId = resolver.resolveCurrentTenantIdentifier(); if (tenantId == null) { throw new IllegalStateException("No tenant id specified"); } t.setTenantId(tenantId); } } }); super.preFlush(entities); } } |
Код достаточно сложен на первый взгляд, но на самом деле ничего сверхестественного здесь не происходит. В метод preFlush() передаётся итератор с сохраняемыми сущностями, для каждой из которых проверяется тип и, если она содержит столбец дискриминатора и он пуст, он заполняется текущим значением tenant id. В качестве источника tenant id используется, весьма в стиле Hibernate, реализация интерфейса CurrentTenantIdentifierResolver, которая (в моём случае) просто хранит заранее заданное значение.
Наконец попробуем использовать всё что мы написали выше:
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 | protected void writeAUPerson() { resolver.setCurrentTenantIdentifier("au"); Session session = sessionFactory .openSession(); session.beginTransaction(); 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.merge(person); session.getTransaction().commit(); session.close(); } |
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 | protected void writeDEPerson() { resolver.setCurrentTenantIdentifier("de"); Session session = sessionFactory .openSession(); session.beginTransaction(); Passport p = new Passport(); p.setSeries("RY"); p.setNo("654321"); p.setIssueDate(LocalDate.now()); p.setValidity(Period.ofYears(20)); Address a = new Address(); a.setCity("Oberdingeskirchen"); a.setStreet("Hbf Platz"); a.setBuilding("1"); Person person = new Person(); person.setFirstName("Johan"); person.setLastName("von 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.merge(person); session.getTransaction().commit(); session.close(); } |
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 | @Test public void testGreeter() { writeAUPerson(); writeDEPerson(); Session session = sessionFactory .openSession(); session.enableFilter("discriminator_filter") .setParameter("tenantIdValue", "au"); session.beginTransaction(); session .createCriteria(Person.class) .list() .stream() .forEach(System.out::println); session.getTransaction().commit(); session.close(); session = sessionFactory .openSession(); session.enableFilter("discriminator_filter") .setParameter("tenantIdValue", "de"); session.beginTransaction(); session .createCriteria(Person.class) .list() .stream() .forEach(System.out::println); session.getTransaction().commit(); session.close(); } |
1 2 | Person{firstName='Test', lastName='Testoff', dob=2016-11-04, passport=Passport{series='AS', no='123456', issueDate=2016-11-04, validity=P20Y, owner=Testoff}, primaryAddress=Address{city='Kickapoo', street='Main street', building='1', tenants=Test}, workingPlaces=[Company{name='Acme Ltd', workers=Test}]} Person{firstName='Johan', lastName='von Testoff', dob=2016-11-04, passport=Passport{series='RY', no='654321', issueDate=2016-11-04, validity=P20Y, owner=von Testoff}, primaryAddress=Address{city='Oberdingeskirchen', street='Hbf Platz', building='1', tenants=Johan}, workingPlaces=[Company{name='Acme Ltd', workers=Johan}]} |