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