Multi Tenancy - это архитектурный подход в разработке, при котором один экземпляр приложения обслуживает множество разных клиентов так, что они имеют доступ только строго к своим данным. Например, сервис для обслуживания компании, ведения проектов, изолированных воркспейсов и тд. Наверняка вы использовали такие в работе. От простого почтового сервера до CMS.
Зайдя в одно окружение, юзер уверен что, увидит только свои данные. При этом физически все используют один и тот же хост и один процесс приложения, и часто одну и ту же базу данных.
Плюсы такой архитектуры:
✅ юзеры использую одну и ту же инфраструктуру, то есть легче её поддержка.
✅ не нужно держать инфраструктуру активной для юзеров, которые не пользуются приложением.
✅ возможность связывать данные между клиентами (при определенных условиях).
✅ возможность хранить общие данные.
Минусы:
❌ Очевидно - сложность реализации.
Плюс разные особенности разных подходов к решению.
Способ задать контекст, может быть реализован по-разному. Например, в Django есть приложение
▫️ Разделение по домену
▫️ Разделение по url
▫️ Определение ID на уровне хидера, выдаётся в момент аутентификации
▫️ Сохранение в JWT, то есть сохраняется в токен как кастомный payload.
Способ изоляции данных также имеет несколько решений.
▫️ разные БД
▫️ разные схемы в БД (не все СУБД поддерживают)
▫️ по FK-полям в общей схеме
Каждый подход к разделению данных тоже имеет свои плюсы и минусы.
Работает логика так: каждый запрос юзера на ранних этапах определяет контекст (в какой компании, проекте, воркспейсе находится юзер) и это влияет на то, какие настройки он подтягивает, как фильтруются запросы в БД.
В следующем посте рассмотрим один практический пример на базе FastAPI
#systemdesign
Зайдя в одно окружение, юзер уверен что, увидит только свои данные. При этом физически все используют один и тот же хост и один процесс приложения, и часто одну и ту же базу данных.
Плюсы такой архитектуры:
✅ юзеры использую одну и ту же инфраструктуру, то есть легче её поддержка.
✅ не нужно держать инфраструктуру активной для юзеров, которые не пользуются приложением.
✅ возможность связывать данные между клиентами (при определенных условиях).
✅ возможность хранить общие данные.
Минусы:
❌ Очевидно - сложность реализации.
Плюс разные особенности разных подходов к решению.
Способ задать контекст, может быть реализован по-разному. Например, в Django есть приложение
django.contrib.sites которое реализует разделение через домены. Это самая популярная техника.▫️ Разделение по домену
https://company1.my-app.com - домен для одной компанииhttps://company2.my-app.com - домен для второй компании▫️ Разделение по url
https://my-app.com/client1/ https://my-app.com/client2/ ▫️ Определение ID на уровне хидера, выдаётся в момент аутентификации
GET /api/v1/users HTTP/1.1
Host: api.myapp.com
X-Tenant-ID: client-id
▫️ Сохранение в JWT, то есть сохраняется в токен как кастомный payload.
Способ изоляции данных также имеет несколько решений.
▫️ разные БД
▫️ разные схемы в БД (не все СУБД поддерживают)
▫️ по FK-полям в общей схеме
Каждый подход к разделению данных тоже имеет свои плюсы и минусы.
Работает логика так: каждый запрос юзера на ранних этапах определяет контекст (в какой компании, проекте, воркспейсе находится юзер) и это влияет на то, какие настройки он подтягивает, как фильтруются запросы в БД.
В следующем посте рассмотрим один практический пример на базе FastAPI
#systemdesign
🔥3👏1
Недавно я реализовал Multi Tenancy систему на FastAPI + SQLAlchemy и пересобрал её в небольшой демо проект.
Ключевые особенности:
▫️ Основной всего является сущность Компании. Юзеры могут подключаться к разным компаниям, но в один момент времени могут находиться только в одной (по условиям ТЗ).
▫️ Разделение данных через FK-ссылку всех ключевых бизнес-сущностей на ID компании.
▫️ ID компании задаётся при логине и хранится в JWT. При смене компании потребуется новый токен.
▫️ Контекст активируется через зависимость.
▫️ Фильтр применяется автоматически для любого запроса.
Теперь подробней:
1️⃣ Логин
Когда юзер логинится, он указывает ID компании. Этот ID зашивается в JWT.
2️⃣ Запрос
Ключевое действие находится в функции get_current_user(...). Мы парсим JWT и достаем инфу о юзере и компании.
Для текущей и дочерних корутин этого запроса объявляется контекст конкретной компании. Объявление контекста происходит через
Никакого указания ID проекта\магазина\сайта со стороны юзера и проброса его по всем функциям. Это локальное состояние конкретного запроса и его цепочки корутин.
3️⃣ Активация фильтра для SQL
Реализация фильтра сделана штатными средствами SQLAlchemy - прослушивание ивента
Функция фильтрации add_tenant_filter(...) требует либо указать контекст, либо отключить фильтр. Если вдруг контекст не задан, то по умолчанию юзер просто получит ошибку
4️⃣ Админка
В
После логина админ тоже будет получать ошибки при обращении к юзерским сервисам, поэтому у него есть свои - админские сервисы через админские роуты.
На админских роутах стоит депенденси с функцией
Так же есть один юзерский запрос с
Если же админу нужно вызвать юзерский сервис с контекстом какой-либо компании, то есть функция
▫️Плюсы такого подхода:
- Контекст хранится в токене, нет лишнего запроса в БД
- При утечке токена одной компании другие не пострадают
- Фильтр автоматически применяется на любой запрос юзера
- Все данные в одной БД. Можно связывать данные разных клиентов через FK и хранить общие сущности без проблем.
▫️Минусы:
- Сломался один клиент - сломались все!
- Нужно продумывать индексы с учётом особенностей multi-tenancy
- Если не следить за чистотой кода и правильностью архитектуры, то можно серьезно накосячить. Правильные тесты - наше ВСЁ!
- Общие миграции
▫️Что еще можно добавить
- партиции таблиц с разделением по полю company_id
- автоматическое добавление текущей company_id для создаваемых юзером объектов. Моя версия в фукнции add_company_id(), но не факт, что это лучший способ.
▫️Как запустить
3. готово!
▫️Как протестить
1. клонируем проект
2. запускаем
3. готово!
И главное: мой пример не является эталоном и инструкцией к применению, скорей это эксперимент. Буду рад замечаниям и указаниям на проблемы такого подхода.
Другие статьи на почитать:
↗️один
↗️два
↗️три
#tricks #systemdesign
Ключевые особенности:
▫️ Основной всего является сущность Компании. Юзеры могут подключаться к разным компаниям, но в один момент времени могут находиться только в одной (по условиям ТЗ).
▫️ Разделение данных через FK-ссылку всех ключевых бизнес-сущностей на ID компании.
▫️ ID компании задаётся при логине и хранится в JWT. При смене компании потребуется новый токен.
▫️ Контекст активируется через зависимость.
▫️ Фильтр применяется автоматически для любого запроса.
Теперь подробней:
1️⃣ Логин
Когда юзер логинится, он указывает ID компании. Этот ID зашивается в JWT.
2️⃣ Запрос
Ключевое действие находится в функции get_current_user(...). Мы парсим JWT и достаем инфу о юзере и компании.
Для текущей и дочерних корутин этого запроса объявляется контекст конкретной компании. Объявление контекста происходит через
ContextVar (а вот и реальный пример их применения). Никакого указания ID проекта\магазина\сайта со стороны юзера и проброса его по всем функциям. Это локальное состояние конкретного запроса и его цепочки корутин.
3️⃣ Активация фильтра для SQL
Реализация фильтра сделана штатными средствами SQLAlchemy - прослушивание ивента
do_orm_execute и правка любого SQL запроса с with_loader_criteria. Фильтр реагирует только на модели с миксином CompanyMixin. Нужно только указать ID компании для филтра. Функция фильтрации add_tenant_filter(...) требует либо указать контекст, либо отключить фильтр. Если вдруг контекст не задан, то по умолчанию юзер просто получит ошибку
BadRequestError. 4️⃣ Админка
В
get_current_user() указание контекста необязательно. Это сделано для того, чтобы мог залогиниться суперадмин. Ведь должен же кто-то управлять компаниями "сверху". В моем случае я просто не указываю контекст, но можно явно проверять, является ли юзер суперадмином.
После логина админ тоже будет получать ошибки при обращении к юзерским сервисам, поэтому у него есть свои - админские сервисы через админские роуты.
На админских роутах стоит депенденси с функцией
bypass_company_filter(...). Это позволяет отключить фильтр и управлять любыми сущностями без ограничения. А так же депенденси пропускающий только админов.Так же есть один юзерский запрос с
bypass_company_filter() - это получение всех доступных юзеру компаний чтобы можно было на UI выбирать куда переключиться.Если же админу нужно вызвать юзерский сервис с контекстом какой-либо компании, то есть функция
set_company_context(...), которая временно включает фильтр и активирует ID указанной компании. Например получить все [имя сущности] юзера для конкретной компании.▫️Плюсы такого подхода:
- Контекст хранится в токене, нет лишнего запроса в БД
- При утечке токена одной компании другие не пострадают
- Фильтр автоматически применяется на любой запрос юзера
- Все данные в одной БД. Можно связывать данные разных клиентов через FK и хранить общие сущности без проблем.
▫️Минусы:
- Сломался один клиент - сломались все!
- Нужно продумывать индексы с учётом особенностей multi-tenancy
- Если не следить за чистотой кода и правильностью архитектуры, то можно серьезно накосячить. Правильные тесты - наше ВСЁ!
- Общие миграции
▫️Что еще можно добавить
- партиции таблиц с разделением по полю company_id
- автоматическое добавление текущей company_id для создаваемых юзером объектов. Моя версия в фукнции add_company_id(), но не факт, что это лучший способ.
▫️Как запустить
1. клонируем проект
2. запускаем just run3. готово!
▫️Как протестить
1. клонируем проект
2. запускаем
just test3. готово!
- Почему just?
- Патамушта!
И главное: мой пример не является эталоном и инструкцией к применению, скорей это эксперимент. Буду рад замечаниям и указаниям на проблемы такого подхода.
Другие статьи на почитать:
↗️один
↗️два
↗️три
#tricks #systemdesign
👍2🔥1