Мультитенантная архитектура в Symfony — Блог Work Solutions
Мультитенантная архитектура в Symfony: проблемы и их решения
ГлавнаяБлогКак мы работаемМультитенантная архитектура в Symfony: проблемы и их решения
Как мы работаем20 апреля 2024

Мультитенантная архитектура в Symfony: проблемы и их решения

Фотография автора
Денис БондарчукBackend-разработчик

Бывают случаи, когда требования к проекту растут, возникают неожиданные сложности и необходимость внедрять новые архитектурные решения. В нашем случае, после двух лет поддержки системы для компании ВипСервис, мы осознали потребность перейти на мультитенантную архитектуру. 

О том, что такое мультитенантность, и как мы пришли к этому решению, рассказали подробно в другой статье. Здесь же опишем процесс внедрения новой архитектуры с точки зрения фреймворка Symfony и кодовой базы. Расскажем, как заставить Doctrine создавать подключение в зависимости от пользователя, что делать с кэшированием и как добавить уникальную функциональность только для одного тенанта. 

Мультитенантность

Мультитеннантность (multi-tenancy) — это архитектурный паттерн программного обеспечения, при котором один и тот же экземпляр программы используется для обслуживания нескольких арендаторов или клиентов. Каждый арендатор работает со своим представлением данных и конфигурации, изолированными от других арендаторов.

Основная идея мультитеннантности — предоставить множеству клиентов доступ к одному и тому же программному продукту, при этом разделяя их данные и конфигурации. Это помогает повысить эффективность использования ресурсов и снизить затраты на развертывание и обслуживание для поставщика ПО.

Мы реализовали мультитенантность или же мультиарендность в проекте через разделение базы данных для каждого клиента. Под клиентом понимаем именно организацию или же подразделение компании, а не единичного пользователя. Каждая база данных имеет полностью одинаковую структуру и отличается самими данными, которые в ней содержатся (компании, заказы и тд.). Такое разделение характерно всем сущностям системы за исключением пользователей, однако об этом расскажем позже.

Поздняя инициализация зависимостей

В качестве ORM используем Doctrine, для его конфигурации обычно достаточно указать url базы данных в файле doctrine.yaml.

Изображение статьи

Указав url, мы позволим Symfony статически инициализировать компонент DBAL. Это даст нам выполнять запросы к базе данных на любом этапе жизненного цикла приложения, в котором доступно внедрение зависимостей.

Изображение статьи

Но что делать, если ни приложение, ни сам разработчик не знают, к какой базе данных (тенанту) нужно сконфигурировать подключение? На самом деле не обязательно указывать url конфигурации, как было на первом скриншоте. Все реквизиты для подключения можно задать динамическими силами PHP. Этим мы и воспользовались. Symfony предоставляет возможность подписаться на все основные события жизненного цикла приложения, достаточно в событии RequestEvent переинициализировать класс Connection.

Изображение статьи

Информация о тенанте хранится в HTTP заголовке X-Tenant-ID, исходя из него производится подключение к нужной базе данных. Класс DynamicConnection кастомный, единственная его функция — обновление своего состояния в методе selectDb.

Изображение статьи

Теперь наша конфигурация выглядит так:

Изображение статьи

При таком подходе помните, что любые обращения к БД до вызова selectDb приведут к ошибке. Это значит, что теперь мы не можем использовать условные репозитории и query builder’ы до события RequestEvent: ни на этапе сборки зависимостей, ни внутри конструктора классов, а также сеттеров, которые используются DI контейнером.

Изображение статьи

Однако это небольшая плата за заложенную базу в новой архитектуре.

Синхронизация пользователей

Вернемся к пользователям. Мультитенантная архитектура накладывает ограничения в виде изолированности данных, однако пользователи — это другая история. Среди них есть менеджеры и администраторы, которые могут быть привязаны не только к одному подразделению. 

Номинально, в структуре хранения мало что изменилось. В каждой БД так и осталась таблица users, но теперь каждому пользователю присвоен глобальный идентификатор. Это нужно, чтобы однозначно определить, что пользователь из каждого тенанта технически является одной личностью с общими реквизитами доступа. Для этого выделили сервис аутентификации, в котором и хранится связь реквизитов доступа с глобальным идентификатором. Схема выглядит так: 

Изображение статьи

В ней как раз и скрывается одна неочевидная проблема. Даже если аутентификация прошла успешна, это не гарантирует отсутствие ошибок. Так происходит из за того, что глобально такой пользователь существует, но на запрошенном тенанте его нет.

Для решения проблемы необходимо заботиться об их синхронизации. При каждом изменении данных или создании нового пользователя реализовали вызов консольной команды на синхронизацию для всех подразделений.

Вызов консольной команды выбрали потому, что когда мы находимся в контексте запроса, уже имеем активное подключение к определенной БД, и динамически поменять мы его не можем. Тогда как при вызове консольной команды, контекст будет создаваться по новой, исходя из флага, который мы ей передадим. 

Изображение статьи

Кэширование

Основная проблема в том, что в отличие от БД, мы имеем всего один экземпляр Redis, что может привести к коллизиям данных между тенантами. Решение тут очень похоже на инициализацию базы данных. Все происходит также в обработчике события RequestEvent, за исключением того, что теперь мы подменяем не connection, а указываем namespace для Redis клиента.

Изображение статьи
Изображение статьи

Фичи тенантов

Возможность добавить уникальную функциональность для одного из подразделений — важная задача, которую нам нужно было решить. Препятствием стало то, что мы имеем один экземпляр приложения, то есть и единую кодовую базу.

Проще всего было бы использовать глобальный класс Config и постоянные проверки для вызова нужной функциональности.

Изображение статьи

Выглядит не так страшно, но только до момента пока условия выполнения и количество вызовов не начнут разрастаться. Тут нам на помощь приходит Symfony и возможность гибкой настройки DI-контейнера.

С помощью атрибутов мы можем динамически оборачивать зависимости в объект-декоратор, который возьмет на себя работу за вызов функциональности и проверку активности фичи.

Изображение статьи

Теперь не нужно выполнять проверки вручную. Все, что нужно — использовать класс, обернутый в фичу, как и другие классы. Если фича не будет активна, то метод просто не выполнится или вернет null, как если бы мы использовали метод с поведением по умолчанию.

Symfony дает нам внедрится в этап сборки DI-контейнеров. Поэтому все, что нужно для реализации задачи — это перехватить все инжекты, помеченные декоратором AsTenantFeature и подменить их на класс декоратор TenantFeature.

Изображение статьи

Теперь можем заглянуть в класс TenantFeature. Все, что он делает — это выбирает необходимую стратегию для вызова метода. Если фича активна, то вызов метода просто проксируется к исходному классу. Иначе результат зависит от сигнатуры метода: если метод может вернуть null, то вернется null; если возвращаемый тип указан как void, то ничего не произойдет. В остальных случаях будет выброшено исключение.

Изображение статьи

Активность проверяется с помощью таблицы tenant_feature_activity. Каждый тенант хранит в ней свой список фич. Записи в таблицах состоят из имени фичи и флага активности. Нужно только проверить активность в текущем контексте.

Заключение

Symfony действительно очень гибкий инструмент. Не нужно думать, что те ограничения, с которыми мы столкнулись, и те паттерны, которые применяли — это максимум, что можно получить из фреймворка. Оглядываясь назад, видим моменты, которые можно было довести до идеала. Например, вместо указания объединенного типа ServiceName|TenantFeature, возможно реализовать генерацию прокси классов, как это делает Doctrine для Lazy Load.

Итак, переход на мультитенантную архитектуру в проекте на Symfony потребовал нестандартных решений для изоляции данных разных клиентов при использовании единой кодовой базы. Задействовали динамическое определение подключения к базе данных на основе HTTP-заголовков, синхронизацию пользовательских учетных данных через глобальные идентификаторы и консольные команды. Также задействовали уникальные пространства имен для кэширования в Redis и гибкий механизм включения специфических функций для отдельных клиентов. Такой подход позволил успешно перевести систему на мультитенантную модель, сохранив единое приложение и код, но обеспечив необходимую изоляцию данных и индивидуальную настройку для каждого клиента.

876
42

Другие статьи

Ко всем статьям
Фоновое изображение: четверть круга закрыват часть круга

Интересные статьи и кейсы
от Work Solutions

Нажимая кнопку «Подписаться», я даю согласие на обработку персональных данных

Спасибо за подписку!

Фоновое изображение: верхний полукруг