Доброго времени суток, дорогой читатель!
Всё последующее содержание статьи очевидно является некой формой садизма, так что если если вы пришли за чоколадкой golangом, питоном, ванильным чапманом или другими формами сладкой жизни, лучше уходите.
Тема изоляции данных клиентов (мультитенантность) в saas или подобных продуктах исторически считается если не самой, то одной из наиболее сложных и требующих архитектурных извращений тем в веб-разработке.
Существует несколько разных подходов, от банального tenant_id в каждой таблице базы данных, до физического разделения данных на разные сервера под разных тенантнов.
Компромиссным решением является выделение идентичных по содержимому (таблицам) схем для каждого нового клиента в одной базе данных. Таким образом мы получаем относительную защиту от утечек данных между клиентами с минимальными затратами на аренду новых серверов. Именно этот вариант мы и будем рассматривать.
НО
Из этого же подхода вытекает несколько ключевых вопросов разного уровня сложности:
Каким образом контролировать миграции каждой схемы и вовремя подтягивать свежие обновления в каждую схему каждого клиента?
Каким образом корректно идентифицировать конкретного тенанта по его запросу и переключаться на нужную схему?
Как это делают обычно?
Очень просто. Наваливают магии. Если речь идёт про Laraвелвелвел - существует
Stancl/Tenancy
, где вам даже не надо задумываться о том, как это работает. Вы пишите так, как будто у вас один клиент в системе, аstancl
сделает всё остальное: в зависимости от стратегии изоляции данных (поддерживает и обычную префиксную систему, и разделение по серверам) определит в мидлваре для нужных роутовtenant_id
и будет добавлять соответствующее условие при каждом запросе к базе.
И мы бы закрыли глаза и наслаждались процессом, если бы не три основных момента:
Laravel это не про нас. У нас сотни модулей разного уровня сложности и мы не можем полагаться на глобальное состояние чего бы то ни было. Нам важна контрактность и чёткая цепочка переключений на разных тенантов в зависимости от поступившего запроса.
Мы садисты и не можем просто взять go.
Мы ненавидим Laravel и вообще у нас api-first система на голом php.
// невероятное стечение обстоятельств, но тем или менее...
Мои базовые потребности
Начнём с основного. По моему скромному мнению все проблемы отсутствия единого flow обработки запросов (в том числе и в контексте мультитенатных приложений) в PHP берутся из одного неотъемлемого от этого языка понятия - exception. Весь дьявол кроется именно в исключениях. Вы априори не можете контролировать сложную многомодульную систему и знать о каждом событии в этой системе на всех уровнях приложения если используете исключения в качестве основного способа общения между его слоями. Это критически важно для мультитенатности. Нужно контролировать каждый миллиметр, быть в курсе каждого переключения между схемами тенантов, логировать всё и вся. С исключениями этого либо не добиться, либо добиться с большими оговорками и беспорядком в кодовой базе.
Иными словами нужен инструмент общения между слоями приложения, ликвидирующий там где только можно потребность использования исключений. С помощью него мы сможем явно переключаться между схемами тенантов, сделав этот нетрудоемкий, но священный ритуал, неотъемлемой частью любого handler
.
Так же я придерживаюсь железного мнения о неказистости контроллеров в классическом понимании, неизбежно превращающихся в сборник методов на пару тысяч строк кода.
Кроме того мы будем придерживаться паттерна
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 лет, думаю ещё напишу про миграционную систему....если не умру в процессе.