Доброго времени суток, дорогой читатель!

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

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

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

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

НО

Из этого же подхода вытекает несколько ключевых вопросов разного уровня сложности:

  1. Каким образом контролировать миграции каждой схемы и вовремя подтягивать свежие обновления в каждую схему каждого клиента?

  2. Каким образом корректно идентифицировать конкретного тенанта по его запросу и переключаться на нужную схему?

Как это делают обычно?

  • Очень просто. Наваливают магии. Если речь идёт про Laraвелвелвел - существует Stancl/Tenancy, где вам даже не надо задумываться о том, как это работает. Вы пишите так, как будто у вас один клиент в системе, а stancl сделает всё остальное: в зависимости от стратегии изоляции данных (поддерживает и обычную префиксную систему, и разделение по серверам) определит в мидлваре для нужных роутов tenant_id и будет добавлять соответствующее условие при каждом запросе к базе.

И мы бы закрыли глаза и наслаждались процессом, если бы не три основных момента:

  1. Laravel это не про нас. У нас сотни модулей разного уровня сложности и мы не можем полагаться на глобальное состояние чего бы то ни было. Нам важна контрактность и чёткая цепочка переключений на разных тенантов в зависимости от поступившего запроса.

  2. Мы садисты и не можем просто взять go.

  3. Мы ненавидим Laravel и вообще у нас api-first система на голом php.

// невероятное стечение обстоятельств, но тем или менее...

Мои базовые потребности

  1. Начнём с основного. По моему скромному мнению все проблемы отсутствия единого flow обработки запросов (в том числе и в контексте мультитенатных приложений) в PHP берутся из одного неотъемлемого от этого языка понятия - exception. Весь дьявол кроется именно в исключениях. Вы априори не можете контролировать сложную многомодульную систему и знать о каждом событии в этой системе на всех уровнях приложения если используете исключения в качестве основного способа общения между его слоями. Это критически важно для мультитенатности. Нужно контролировать каждый миллиметр, быть в курсе каждого переключения между схемами тенантов, логировать всё и вся. С исключениями этого либо не добиться, либо добиться с большими оговорками и беспорядком в кодовой базе.

Иными словами нужен инструмент общения между слоями приложения, ликвидирующий там где только можно потребность использования исключений. С помощью него мы сможем явно переключаться между схемами тенантов, сделав этот нетрудоемкий, но священный ритуал, неотъемлемой частью любого handler.

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

  2. Кроме того мы будем придерживаться паттерна Repository для взаимодействия с базой данных, объединив необходимые репозитории в единой точке входа Repository Router, в котором будем определять нужное соединение для схемы конкретного тенанта, получая его uuid из слоя handler и передавая для последующих запросов дочерним репозиториям.

Вообщем-то, для реализации правильной мультитенантности на PHP придётся написать свой минифреймворк.

Знакомьтесь - Rift Framework.

Github: https://github.com/mainbotan/Rift
Документация (eng, может потребоваться VPN): https://rift-framework.com (дописываю из последних сил).

Для того, чтобы описать что внутри этого творения нужно наверное было бы написать не одну статью. Я лишь попытаюсь перечислить основные концепции, лежащие в основе:

1. ResultType

Я уже писал про свой велосипед - объект-контракт по аналогии с Haskell, делающий ошибки частью возвращаемого типа.
Мы переименовываем его в ResultType - часть ядра Rift. С помощью этого чуда ваш обработчик может выглядеть вот так (простите за такое количества кода, неудержался от демонстрации красоты такого подхода):

/**
 * 2FA registration. Confirmation code via email.
 * @version 0.0.1
 */
class RegistrateByEmail implements HandlerInterface 
{
    const AUTH_TOKEN_TTL = 3600 * 24;
    const VERIFY_TOKEN_TTL = 1800;

    private const TIMER_TOTAL = 'reg.total';
    private const TIMER_VALIDATION = 'reg.validation';
    private const TIMER_REPO_UNIT = 'reg.repo_unit';
    private const TIMER_EMAIL_CHECK = 'reg.email_check';
    private const TIMER_UID_GEN = 'reg.uid_gen';
    private const TIMER_HASH = 'reg.hash';
    private const TIMER_JWT_GEN = 'reg.jwt_gen';
    private const TIMER_VERIFY_CODE_GEN = 'reg.verify_code_gen';
    private const TIMER_VERIFY_CODE_ENCRYPT = 'reg.verify_code_encrypt';
    private const TIMER_VERIFY_CODE_SEND = 'reg.verify_code_send';
    
    private const ERROR_EMAIL_EXISTS = 'A client with the same email already exists. If it was you, log in to access your account.';
    private const ERROR_REGISTRATION_FAILED = 'Registration failed';

    public function __construct(
        private RegistrateByEmailValidator $validator,
        private RepositoriesRouter $repositoriesRouter,
        private UidManager $uidManager,
        private JwtManager $jwtManager,
        private HashManager $hashManager,
        private Stopwatch $stopwatch,
        private StopwatchManager $stopwatchManager,
        private MailerService $mailer,
        private EncryptionManager $encryptionManager
    ) {}

    public function execute(ServerRequestInterface $request): ResultType                        // вот это чудо
    {
        $this->stopwatch->start(self::TIMER_TOTAL);
        $requestBody = $request->getParsedBody();

        $this->startTimer(self::TIMER_VALIDATION);
        return $this->validator->validate($requestBody)
            ->tap(fn() => $this->stopTimer(self::TIMER_VALIDATION))            // вот как могу
            
            ->tap(fn() => $this->startTimer(self::TIMER_REPO_UNIT))
            ->then(fn() => $this->repositoriesRouter->factory())                         // вот как могу
            ->then(fn(RepositoriesFactory $factory) => $factory->tenants())
            ->tap(fn() => $this->stopTimer(self::TIMER_REPO_UNIT))
            
            ->ensure(                                                                           // вот как могу
                function(TenantRepository $repository) use ($requestBody) {
                    $this->startTimer(self::TIMER_EMAIL_CHECK);
                    $existing = $repository->getTenantUidByEmail($requestBody['email'])->result;
                    $this->stopTimer(self::TIMER_EMAIL_CHECK);
                    return !isset($existing[0]['uid']);
                },
                self::ERROR_EMAIL_EXISTS,
                Result::HTTP_CONFLICT
            )
            ->then(function(TenantRepository $repository) use ($requestBody) {
                $this->startTimer(self::TIMER_UID_GEN);
                $uid = $this->uidManager->generate();
                $this->stopTimer(self::TIMER_UID_GEN);

                $this->startTimer(self::TIMER_HASH);
                $hash = $this->hashManager->passwordHash($requestBody['password']);
                $this->stopTimer(self::TIMER_HASH);

                return $repository->createTenant([
                    'uid' => $uid,
                    'email' => $requestBody['email'],
                    'hash' => $hash
                ])->map(fn() => ['uid' => $uid]);
            })
            ->tap(fn() => $this->startTimer(self::TIMER_JWT_GEN))             // вот как могу
            ->then(function(array $jwtData) {
                return $this->jwtManager->encode($jwtData, self::AUTH_TOKEN_TTL)
                    ->map(fn($token) => [
                        'auth' => ['token' => $token],
                        'uid' => $jwtData['uid']
                    ]);
            })
            ->tap(fn() => $this->stopTimer(self::TIMER_JWT_GEN))
            ->then(function($result) use ($requestBody) {
                $this->startTimer(self::TIMER_VERIFY_CODE_GEN);
                $verifyCode = random_int(100000, 999999);
                $this->stopTimer(self::TIMER_VERIFY_CODE_GEN);

                $this->startTimer(self::TIMER_VERIFY_CODE_ENCRYPT);
                return $this->encryptionManager->encrypt($verifyCode)
                    ->tap(fn() => $this->stopTimer(self::TIMER_VERIFY_CODE_ENCRYPT))
                    ->tap(fn() => $this->startTimer(self::TIMER_VERIFY_CODE_SEND))
                    ->then(function($encryptedVerifyCode) use ($requestBody, $verifyCode) {
                        $this->mailer->sendConfirmationEmail($requestBody['email'], $verifyCode);
                        return Result::Success($encryptedVerifyCode);
                    })
                    ->tap(fn() => $this->stopTimer(self::TIMER_VERIFY_CODE_SEND))
                    ->then(function ($encryptedVerifyCode) use ($result) {
                        return $this->jwtManager->encode([
                            'uid' => $result['uid'],
                            'code' => $encryptedVerifyCode
                        ], self::VERIFY_TOKEN_TTL);
                    })
                    ->map(fn($verifyToken) => [
                        'auth' => $result['auth'],
                        'verify' => ['token' => $verifyToken]
                    ])
                    ->tap(fn() => $this->stopTimer(self::TIMER_TOTAL))
                    ->withMetric('stopwatch', $this->collectMetrics());
            })
            ->catch(function($error, $code) {
                return Result::Failure($code, self::ERROR_REGISTRATION_FAILED . ": $error")
                    ->withMetric('stopwatch', $this->collectMetrics());
            });
    }

    private function startTimer(string $timerName): void 
    {
        $this->stopwatch->start($timerName);
    }

    private function stopTimer(string $timerName): void 
    {
        $this->stopwatch->stop($timerName);
    }

    private function collectMetrics(): array 
    {
        return $this->stopwatchManager->collectMetrics($this->stopwatch, self::TIMER_TOTAL);
    }
}

а ответ ендпоинта будет опрятнее, чем вы когда-либо видели:

{
    "ok": false,
    "code": 200,
    "payload": {
        "auth": {
            "token": "..."
        }
    },
    "_meta": {
        "debug": [],
        "metrics": {
            "stopwatch": {
                "timings": {
                    "reg.total": {
                        "duration_ms": 84,
                        "duration_human": "84ms",
                        "memory_bytes": 0,
                        "memory_diff_bytes": 0,
                        "memory_human": "0 B",
                        "memory_diff_human": "0 B"
                    },
                    "reg.validation": {
                        "duration_ms": 0,
                        "duration_human": "0μs",
                        "memory_bytes": 2097152,
                        "memory_diff_bytes": 2097152,
                        "memory_human": "2 MB",
                        "memory_diff_human": "2 MB"
                    },
                    "reg.repo_unit": {
                        "duration_ms": 24,
                        "duration_human": "24ms",
                        "memory_bytes": 2097152,
                        "memory_diff_bytes": 2097152,
                        "memory_human": "2 MB",
                        "memory_diff_human": "2 MB"
                    },
                    "reg.email_check": {
                        "duration_ms": 0,
                        "duration_human": "0μs",
                        "memory_bytes": 2097152,
                        "memory_diff_bytes": 2097152,
                        "memory_human": "2 MB",
                        "memory_diff_human": "2 MB"
                    },
                    "reg.uid_gen": {
                        "duration_ms": 0,
                        "duration_human": "0μs",
                        "memory_bytes": 2097152,
                        "memory_diff_bytes": 2097152,
                        "memory_human": "2 MB",
                        "memory_diff_human": "2 MB"
                    },
                    "reg.hash": {
                        "duration_ms": 59,
                        "duration_human": "59ms",
                        "memory_bytes": 2097152,
                        "memory_diff_bytes": 2097152,
                        "memory_human": "2 MB",
                        "memory_diff_human": "2 MB"
                    },
                    "reg.jwt_gen": {
                        "duration_ms": 0,
                        "duration_human": "0μs",
                        "memory_bytes": 2097152,
                        "memory_diff_bytes": 2097152,
                        "memory_human": "2 MB",
                        "memory_diff_human": "2 MB"
                    }
                },
                "summary": {
                    "total_time_ms": 84,
                    "peak_memory": 2097152
                }
            }
        }
    }
}

Такой подход позволит нам конфигурировать Repository Router исходя из полученного в мидлваре tenant_id. Роутер же в свою очередь будет получать нужное PDO соединение и конфигурировать фабрику репозиториев. В конечном итоге handler - он же атомарный UseCase будет получать уже отконфигурированную фабрику репозиториев для работы со схемой тенанта.

0 магии - 100% контроля

Заключение

В общем и целом, Rift крайне странный акт садизма, предлагающий свою философию для разработки мультитенантных систем.

Всё построено на интерфейсах, конфигурация PHP-DI вынесено отдельно, и каждая реализация, от роутера запросов (а он тут тоже есть) до роутера репозиториев может свободно подменяться в зависимости от ваших потребностей и жизненной философии.

Зачем я это написал, пойду протрезвею. В любом случае мне 17 лет, думаю ещё напишу про миграционную систему....если не умру в процессе.

Комментарии (0)


  1. roxblnfk
    25.09.2025 07:52

    Не понял, чем помешали исключения и почему в PHP они подразумеваются для общения между слоями. Исключение — это просто исключение и бояться его не надо.

    Представленный подход с хендлерами в Rift выглядит прикольно. Но чёт мне кажется, что проблема с мультитенантностью слишком преувеличена: переключаться между тенантами можно тупо в мидвари, создавая тенантно-ориентированную область видимости. Причём за мидлварью код будет всё тот же, как с одним клиентом.


    1. mainbotan Автор
      25.09.2025 07:52

      По моему скромному мнению мультитенантность на пхп (да и не только) напротив сильно недооценена. И на то есть несколько основных причин:

      1. Отладка этого добра.

        Вы предлагаете делегировать установку глобального контекста на мидлварь для роутов, связанных с клиентской логикой. И казалось бы всё отлично, хэндлеры пишем как-будто бы для одного клиента.

        А что будет, если по каким-то неведомым или случайным причинам последовательность установки контекста будет нарушена или упоси господи произойдёт установка неправильного?

        Произойдет пиздец, выявление почему и где это случилось займет немало времени, а устранение последствий ещё больше.

        Красота ручного конфигурирования pdo соединения для конкретного тенанта через связку роутера + фабрики репозиториев как раз в том, что все строго изолировано. Нет никакого магического глобального состояния, шанс накосячить мал или вообще сводится к нулю. Мы просто принимаем uuid тенанта из мидлваря (например из jwt), и говорим, мне нужны репозитории под тенанта вот с таким ключом. И да, делаем так каждый раз, когда нам требуется взаимодействие с базой данных. В обмен на две строки в каждом хэндлере получаем изоляцию каждого роута и как следствие безопасность.

      2. Понимание кода.

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

      3. Тестирование.

        Здесь говорить даже ничего не надо. Думаю всем понятно, что глобальные состояния без обоснованных на то причин (это не обоснованная) если и не запрещены, то являются признаком дурного тона....именно по причине сложности тестирования.

      4. Очереди+консольные команды.

        Это вообще пиздец, я думаю и так понятно, что все эти глобальные скоупы отметаются.

      5. Запросы к общим схемам.

        Условно в схеме shared у вас хранятся iso коды валют. И что очевидно при написании логики отдельного тенанта они могут потребоваться. Манипуляции по переключению с глобального контекста займут время.

      Что касается ResultType. Идея просто в том, чтобы сделать ошибки частью возвращаемого каждым звеном приложения ответа. Это шаг в сторону той самой строгости и контрактности, которые опять же по моему скромному мнению необходимы в крупных multitenancy проектах.