Multitenancy (мультиарендность) — это подход к проектированию приложения, когда один экземпляр приложений обслуживает несколько клиентов с непересекающимися наборами данных. Например сайт по учёту персональных финансов имеет одну копию кода, одно хранилище данных и много клиентов, при этом каждому клиенту доступны только его собственные данные. Наиболее популярен этот подход, по очевидным причинам, в облачных SaaS решениях — каждый клиент видит общий, единый, экземпляр приложения как свою собственную копию.
Чаще всего проблемы с реализацией multitenancy кроются не на уровне логики приложения, а на уровне хранения данных: мы должны уметь отделять данные одного клиента от другого и не давать им шанса смешаться. Существует три основных подхода к решению этой проблемы:
- Разделение на уровне базы данных — под каждого клиента создаётся (или запускается) отдельный экземпляр БД, уникальный для этого клиента и в нём распологаются данные только этого клиента.
- Разделение на уровне схемы — в одной и той же базе данных создаются разные схемы (schema/namespace) в которых создаются копии структуры таблиц, необходимых для работы клиента и данные каждого клиента живут в отдельно схеме.
- Разделение на уровне таблиц — и база данных одна, и схема одна, и даже таблицы те же самые. Но в каждой таблице заводится столбец (дискриминатор), указывающий, какому клиенту принадлежит какая таблица.
Hibernate поддерживает первые два подхода к multitenancy, а третий подход можно достаточно несложно сымитировать. В прошлой статье я описывал database multitenancy, а а в этой опишу schema multitenancy.
Подготовка базы данных
В режиме schema multitenancy hibernate сам не создаёт схемы для разных клиентов и не создаёт структуру хранения, всё это авторы приложения должны сделать вручную. Для этой статьи я использую PostgreSQL, в котором создам пустую базу данных и две схемы для двух разных клиентов. Клиентские схемы я наполню таблицами вручную.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
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 test
CREATE SCHEMA au;
ALTER SCHEMA au OWNER TO test;
GRANT USAGE ON SCHEMA au TO public;
CREATE SCHEMA de;
ALTER SCHEMA de OWNER TO test;
GRANT USAGE ON SCHEMA de TO public;
|
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
|
SET SCHEMA '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);
SET SCHEMA '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);
|
Схема данных, код сущностей и код примера идентичны таковым из примера database multitenancy.
Настройка 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">SCHEMA</property>
<property name="hibernate.multi_tenant_connection_provider">ru.easyjava.data.hibernate.multitenancy.MultitenantSchemaProvider</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» выбирает стратегию разделения данных. В рамках моего примера я выбираю «SCHEMA«, так как планирую данные клиентов хранить в разных схемах одной базы данных. Обратите внимание, что в названии свойства «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
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
|
/**
* Schema selecting multitenant provider.
*
* Works with PgSQL database and HikariCP pool.
*/
public class MultitenantSchemaProvider implements MultiTenantConnectionProvider {
/**
* Connection pool.
*/
private HikariDataSource connectionProvider;
/**
* Switches search path on specified connection.
* @param c connection to operate on
* @param schema tenant id
* @throws SQLException is thrown when not able
* to return connection to the pool.
*/
private void setSchemaTo(Connection c, String schema) throws SQLException {
try {
c.createStatement().execute("SET SCHEMA '" + schema.toLowerCase() + "'");
} catch (SQLException e) {
connectionProvider.evictConnection(c);
throw new HibernateException("Error while switching schema", e);
}
}
/**
* Here we instantiate database connection pool.
*/
public MultitenantSchemaProvider() {
HikariConfig parameters = new HikariConfig();
parameters.setDataSourceClassName("org.postgresql.ds.PGSimpleDataSource");
parameters.setUsername("test");
parameters.setPassword("test");
parameters.setMaximumPoolSize(2);
parameters.addDataSourceProperty("databaseName", "test");
parameters.addDataSourceProperty("serverName", "192.168.75.5");
connectionProvider = new HikariDataSource(parameters);
}
@Override
public Connection getAnyConnection() throws SQLException {
return connectionProvider.getConnection();
}
@Override
public void releaseAnyConnection(Connection connection) throws SQLException {
connectionProvider.evictConnection(connection);
}
@Override
public Connection getConnection(String s) throws SQLException {
Connection c = getAnyConnection();
setSchemaTo(c, s);
return c;
}
@Override
public void releaseConnection(String s, Connection connection) throws SQLException {
setSchemaTo(connection, "public");
releaseAnyConnection(connection);
}
@Override
public boolean supportsAggressiveRelease() {
return false;
}
/* Spi related mandatory methods */
@Override
public boolean isUnwrappableAs(Class aClass) {
return false;
}
@Override
public <T> T unwrap(Class<T> aClass) {
return null;
}
}
|
В этот раз я напрямую реализовал интерфейс MultiTenandProvider. Мой собственный код по переключению схем в базе находится в методе setSchemaTo, который переключает схему в базе и, если что-то идёт не так, возвращает соединение в пул и кидает исключение.
Из методов интерфейса следует обратить внимание на пары getAnyConnection()/releaseAnyConnection() и getConnection()/releaseConnection(). Первая пара использутся для получения соединения «по умолчанию», когда tenant id неизвестен или не требуется, например при старте Hibernate и сканировании таблиц. Вторая пара используется для работы с tenant специфичными соединениями.
Так же в моём собственном провайдере используется пул HikariCP для поддержания нескольких соединений с базой.
Использование 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}]}
|