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}]}
|