Multitenancy (мультиарендность) — это подход к проектированию приложения, когда один экземпляр приложений обслуживает несколько клиентов с непересекающимися наборами данных. Например сайт по учёту персональных финансов имеет одну копию кода, одно хранилище данных и много клиентов, при этом каждому клиенту доступны только его собственные данные. Наиболее популярен этот подход, по очевидным причинам, в облачных SaaS решениях — каждый клиент видит общий, единый, экземпляр приложения как свою собственную копию.
Чаще всего проблемы с реализацией multitenancy кроются не на уровне логики приложения, а на уровне хранения данных: мы должны уметь отделять данные одного клиента от другого и не давать им шанса смешаться. Существует три основных подхода к решению этой проблемы:
- Разделение на уровне базы данных — под каждого клиента создаётся (или запускается) отдельный экземпляр БД, уникальный для этого клиента и в нём распологаются данные только этого клиента.
- Разделение на уровне схемы — в одной и той же базе данных создаются разные схемы (schema/namespace) в которых создаются копии структуры таблиц, необходимых для работы клиента и данные каждого клиента живут в отдельно схеме.
- Разделение на уровне таблиц — и база данных одна, и схема одна, и даже таблицы те же самые. Но в каждой таблице заводится столбец (дискриминатор), указывающий, какому клиенту принадлежит какая таблица.
Hibernate поддерживает первые два подхода к multitenancy, а третий подход можно достаточно несложно сымитировать. В этой статье я опишу только database multitenancy, а остальные варианты опишу в последующих статьях.
Подготовка базы данных
В режиме database multitenancy hibernate сам не создаёт базы и не создаёт структуру хранения, всё это авторы приложения должны сделать вручную. Для этой статьи я использую PostgreSQL, в котором создам три базы данных, одна по умолчанию, две другие для двух разных клиентов. Клиентские базы я наполню таблицами вручную, а базу по умолчанию оставлю пустой.
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
|
CREATE ROLE test WITH PASSWORD 'test';
ALTER ROLE test WITH LOGIN;
CREATE DATABASE test OWNER test;
CREATE DATABASE AU OWNER test;
CREATE DATABASE DE OWNER test;
\c AU
CREATE TABLE address (
id bigint NOT NULL,
building character varying(255),
city character varying(255),
street character varying(255)
);
ALTER TABLE address OWNER TO test;
CREATE TABLE company (
id bigint NOT NULL,
name character varying(255)
);
ALTER TABLE company OWNER TO test;
CREATE SEQUENCE hibernate_sequence
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER TABLE hibernate_sequence OWNER TO test;
CREATE TABLE passport (
id bigint NOT NULL,
issuedate date,
no character varying(255),
series character varying(255),
validity bytea
);
ALTER TABLE passport OWNER TO test;
CREATE TABLE person (
id bigint NOT NULL,
dob date,
firstname character varying(255),
lastname character varying(255),
passport_id bigint NOT NULL,
person_id bigint NOT NULL
);
ALTER TABLE person OWNER TO test;
CREATE TABLE person_companies (
person_id bigint NOT NULL,
company_id bigint NOT NULL
);
ALTER TABLE person_companies OWNER TO test;
ALTER TABLE ONLY address
ADD CONSTRAINT address_pkey PRIMARY KEY (id);
ALTER TABLE ONLY company
ADD CONSTRAINT company_pkey PRIMARY KEY (id);
ALTER TABLE ONLY passport
ADD CONSTRAINT passport_pkey PRIMARY KEY (id);
ALTER TABLE ONLY person
ADD CONSTRAINT person_pkey PRIMARY KEY (id);
ALTER TABLE ONLY person
ADD CONSTRAINT uk_33yyiniy3o7irjyb5nbmidt4u UNIQUE (passport_id);
ALTER TABLE ONLY person_companies
ADD CONSTRAINT fk2xnkexg6vm6l0346lru8r6qaa FOREIGN KEY (company_id) REFERENCES company(id);
ALTER TABLE ONLY person
ADD CONSTRAINT fk701a8b9b2kw01q32ws1rc42bp FOREIGN KEY (person_id) REFERENCES address(id);
ALTER TABLE ONLY person_companies
ADD CONSTRAINT fkj1do3s4ycpku0fpmm3pinysau FOREIGN KEY (person_id) REFERENCES person(id);
ALTER TABLE ONLY person
ADD CONSTRAINT fkn3sbguup9nkxgiqxgfc0sigxj FOREIGN KEY (passport_id) REFERENCES passport(id);
\c DE
CREATE TABLE address (
id bigint NOT NULL,
building character varying(255),
city character varying(255),
street character varying(255)
);
ALTER TABLE address OWNER TO test;
CREATE TABLE company (
id bigint NOT NULL,
name character varying(255)
);
ALTER TABLE company OWNER TO test;
CREATE SEQUENCE hibernate_sequence
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER TABLE hibernate_sequence OWNER TO test;
CREATE TABLE passport (
id bigint NOT NULL,
issuedate date,
no character varying(255),
series character varying(255),
validity bytea
);
ALTER TABLE passport OWNER TO test;
CREATE TABLE person (
id bigint NOT NULL,
dob date,
firstname character varying(255),
lastname character varying(255),
passport_id bigint NOT NULL,
person_id bigint NOT NULL
);
ALTER TABLE person OWNER TO test;
CREATE TABLE person_companies (
person_id bigint NOT NULL,
company_id bigint NOT NULL
);
ALTER TABLE person_companies OWNER TO test;
ALTER TABLE ONLY address
ADD CONSTRAINT address_pkey PRIMARY KEY (id);
ALTER TABLE ONLY company
ADD CONSTRAINT company_pkey PRIMARY KEY (id);
ALTER TABLE ONLY passport
ADD CONSTRAINT passport_pkey PRIMARY KEY (id);
ALTER TABLE ONLY person
ADD CONSTRAINT person_pkey PRIMARY KEY (id);
ALTER TABLE ONLY person
ADD CONSTRAINT uk_33yyiniy3o7irjyb5nbmidt4u UNIQUE (passport_id);
ALTER TABLE ONLY person_companies
ADD CONSTRAINT fk2xnkexg6vm6l0346lru8r6qaa FOREIGN KEY (company_id) REFERENCES company(id);
ALTER TABLE ONLY person
ADD CONSTRAINT fk701a8b9b2kw01q32ws1rc42bp FOREIGN KEY (person_id) REFERENCES address(id);
ALTER TABLE ONLY person_companies
ADD CONSTRAINT fkj1do3s4ycpku0fpmm3pinysau FOREIGN KEY (person_id) REFERENCES person(id);
ALTER TABLE ONLY person
ADD CONSTRAINT fkn3sbguup9nkxgiqxgfc0sigxj FOREIGN KEY (passport_id) REFERENCES passport(id);
|
В скрипте выше и в коде примера используется схема данных из примера управления сущностями в Hibernate.
Настройка Hibernate
Включение multitenancy в hibernate производится в два действия — выбираем стратегию разделения данных и реализуем дружественный этой стратегии метод переключения соединений.
Стратегию проще всего назначить в hibernate.cfg.xml, хотя можно и программно, в ходе создания SessionFactory.
1
2
3
4
5
6
7
8
9
10
11
12
|
<hibernate-configuration>
<session-factory>
<property name="hibernate.multiTenancy">DATABASE</property>
<property name="hibernate.multi_tenant_connection_provider">ru.easyjava.data.hibernate.multitenancy.MultitenantDatabaseProvider</property>
<property name="hibernate.hbm2ddl.auto">update</property>
<property name="hibernate.dialect">org.hibernate.dialect.PostgreSQL94Dialect</property>
<!-- mappings skipped -->
</session-factory>
</hibernate-configuration>
|
Свойство «hibernate.multiTenancy» выбирает стратегию разделения данных. В рамках моего примера я выбираю «DATABASE«, так как планирую данные клиентов хранить в разных базах данных. Обратите внимание, что в названии свойства «hibernate.multiTenancy» присутствует заглавная T, если написать всё в нижнем регистре, multitenancy не включится.
Второе свойство, «hibernate.multi_tenant_connection_provider» выбирает реализацию провайдера соединений с базой данных, который будет возвращать соединение сообразно переданному в него tenant id — идентификатору клиента.
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
38
39
40
|
/**
* Database selecting multitenant provider.
*
* Works with H2 database and HikariCP pool.
*/
public class MultitenantDatabaseProvider extends AbstractMultiTenantConnectionProvider {
/**
* Provides common db parameters.
* @return parameters map.
*/
private Map<String,String> dbParameters() {
Map<String,String> parameters = new HashMap<>();
parameters.put("hibernate.hikari.dataSourceClassName", "org.postgresql.ds.PGSimpleDataSource");
parameters.put("hibernate.hikari.username", "test");
parameters.put("hibernate.hikari.password", "test");
parameters.put("hibernate.hikari.maximumPoolSize", "2");
return parameters;
}
@Override
protected ConnectionProvider getAnyConnectionProvider() {
Map<String,String> parameters = dbParameters();
parameters.put("hibernate.hikari.dataSource.url", "jdbc:postgresql://127.0.0.1:5432/test");
HikariConnectionProvider p =new HikariConnectionProvider();
p.configure(parameters);
return p;
}
@Override
protected ConnectionProvider selectConnectionProvider(String s) {
Map<String,String> parameters = dbParameters();
parameters.put("hibernate.hikari.dataSource.url", "jdbc:postgresql://127.0.0.1:5432/"+s);
HikariConnectionProvider p =new HikariConnectionProvider();
p.configure(parameters);
return p;
}
}
|
Провайдер должен реализовать два метода: selectConnectionProvider() и getAnyConnectionProvider(). Первый метод достаточно очевиден — он получает tenant id, то есть идентификатор клиента, и строит по нему соединение. В моём случае наименование базы и идентификатор клиента совпадают.
Второй метод используется как метод получения соединения «по умолчанию», когда tenant id неизвестен или не требуется, например при старте Hibernate и сканировании таблиц. При этом в базе данных, которую вернёт getAnyConnectionProvider(), будет сгенерирована структура данных, если Hibernate соответствующим образом настроен. Именно поэтому при создании трёх баз данных, структуру данных я создал только в двух.
Так же в провайдере выше показывается, что можно создавать любые конфигурации соединений, использовать пулы соединений и вообще всё, что придёт в голову.
Использование Multitenancy
Использовать multitenancy просто: при открытии сессии достаточно указать tenant id и всё остальное Hibernate сделает сам. Для примера я создам два разных объекта с двумя разными tenant id и потом выберу все объекты с разными tenant id
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
|
protected void writeAUPerson() {
Session session = sessionFactory
.withOptions()
.tenantIdentifier("au")
.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();
}
protected void writeDEPerson() {
Session session = sessionFactory
.withOptions()
.tenantIdentifier("de")
.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
35
|
@Test
public void testGreeter() {
writeAUPerson();
writeDEPerson();
Session session = sessionFactory
.withOptions()
.tenantIdentifier("au")
.openSession();
session.beginTransaction();
session
.createCriteria(Person.class)
.list()
.stream()
.forEach(System.out::println);
session.getTransaction().commit();
session.close();
session = sessionFactory
.withOptions()
.tenantIdentifier("de")
.openSession();
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-10-03, passport=Passport{series='AS', no='123456', issueDate=2016-10-03, 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-10-03, passport=Passport{series='RY', no='654321', issueDate=2016-10-03, validity=P20Y, owner=von Testoff}, primaryAddress=Address{city='Oberdingeskirchen', street='Hbf Platz', building='1', tenants=Johan}, workingPlaces=[Company{name='Acme Ltd', workers=Johan}]}
|