Клиент хочет систему обучения у себя, в закрытом контуре, без интернета. Другой клиент хочет ту же систему, но чтобы ничего не устанавливать. Первым порывом было сделать ветку self-hosted и не мучиться. Через месяц эта ветка отстала на сорок коммитов, и я потратил день на перенос одного исправления.
Форк не работает. Работает то, что различия — это конфигурация.
Что различается на самом деле
Прежде чем строить абстракции, я сел и выписал реальные различия. Их оказалось меньше, чем я боялся.
- Регистрация. В облаке любой может создать организацию. В коробке организация одна, её создаёт установщик, самостоятельная регистрация закрыта или ограничена корпоративным доменом.
- URL. В облаке ключ организации в адресе обязателен. В коробке он бессмысленный шум: организация всё равно одна.
- Тарифы и лимиты. В облаке есть план, счётчики и блокировка при превышении. В коробке лимитов нет, платит клиент один раз.
- Почта и файлы. В облаке — свой SMTP и S3. В коробке — то, что дал клиент, вплоть до локального диска и внутреннего релея.
- Обновления. В облаке обновляю я, когда захочу. В коробке — администратор клиента, когда решится, и версия может отставать на полгода.
Всё остальное — а это 95 процентов кода — одинаково. Именно это соотношение и объясняет, почему форк проигрывает.
Режим как параметр, а не как ветка
Режим живёт в переменной окружения и превращается в энум:
enum DeploymentMode: string
{
case SelfHosted = 'self_hosted';
case Saas = 'saas';
public function allowsPublicSignup(): bool
{
return $this === self::Saas;
}
public function requiresOrgKeyInUrl(): bool
{
return $this === self::Saas;
}
}Методы на энуме — не украшение. Они дают одно место, где записано, что именно означает режим. Когда появится третий вариант — например, облако для одного крупного клиента с собственным доменом, — здесь появятся его базовые возможности. Привязки сервисов, маршруты и тесты всё равно придётся менять отдельно, зато пятьдесят условий по коду не возникнут.
Настройки собраны в один объект, который внедряется куда нужно:
final readonly class TenancySettings
{
public function __construct(
public DeploymentMode $mode,
public string $defaultOrganizationKey,
public ?string $allowedEmailDomains,
) {}
}Ключ организации, который остался в URL
Самое заметное различие для пользователя — адрес страницы. В облаке /org/acme/courses, в коробке хочется просто /courses.
Я не стал делать два набора маршрутов. Ниже фрагмент общего маршрута:
#[Route('/org/{orgKey}/courses', requirements: ['orgKey' => OrganizationRouteRequirements::PATTERN])]А в коробке orgKey получает значение по умолчанию из настроек, и генератор URL подставляет его сам:
final class OrgUrlHelper
{
public function path(string $route, array $params = []): string
{
if ($this->settings->mode->requiresOrgKeyInUrl()) {
$params['orgKey'] ??= $this->context->organization()->getKey();
} else {
$params['orgKey'] = $this->settings->defaultOrganizationKey;
}
return $this->router->generate($route, $params);
}
}Честно говоря, идеального решения тут нет. Адрес в коробке остаётся /org/main/courses, просто ключ всегда один и тот же. Сегмент можно убрать условным импортом или вторым набором маршрутов, но тогда придётся поддерживать два дерева. Я решил, что лишние девять символов в адресе дешевле.
Биллинг за интерфейсом
Тарифы — единственная подсистема, которой в коробке нет вовсе. Заворачивать её в условия было бы больно, поэтому она спрятана за контрактом:
interface TransactionBillingServiceInterface
{
public function canEnroll(Organization $org, Course $course): bool;
public function registerEnrollment(Organization $org, Enrollment $enrollment): void;
}В коробке подключается заглушка, которая разрешает всё и ничего не записывает:
final class StubTransactionBillingService implements TransactionBillingServiceInterface
{
public function canEnroll(Organization $org, Course $course): bool
{
return true;
}
public function registerEnrollment(Organization $org, Enrollment $enrollment): void
{
}
}Выбор реализации — в конфигурации сервисов, по параметру режима. Код, который записывает пользователя на курс, ничего не знает ни про тарифы, ни про их отсутствие.
Тут есть тонкость, которую я осознал не сразу. Заглушка должна возвращать разрешающий ответ, а не бросать исключение «не поддерживается». Соблазн написать throw new LogicException() большой — кажется, что так честнее. Но тогда любая новая проверка биллинга в облаке ломает коробку при следующем обновлении, причём в проде у клиента.
Ограничение регистрации доменом
В коробке нужно ограничить регистрацию корпоративным доменом почты. Одной проверки строки недостаточно: после неё пользователь обязан подтвердить адрес ссылкой из письма. И даже это доказывает владение корпоративным ящиком, а не текущий статус сотрудника; для строгого контура нужен SSO или список, которым управляет администратор. Политика не завязана на режим напрямую: облачный клиент тоже может захотеть такое ограничение.
final class CorporateDomainPolicy
{
public function isAllowed(string $email): bool
{
$domains = $this->settings->allowedEmailDomains;
if ($domains === null || $domains === '') {
return true;
}
$domain = mb_strtolower(substr(strrchr($email, '@') ?: '', 1));
return in_array($domain, array_map('trim', explode(',', mb_strtolower($domains))), true);
}
}Пустая настройка означает «без ограничений». До вызова политики адрес уже проверен валидатором email, поэтому пустой домен сюда не попадает. Это не то же самое, что режим развёртывания: одно решение — про то, где живёт приложение, другое — про то, кого пускают.
Как не размножить условия по коду
Главный риск подхода — россыпь if ($mode === DeploymentMode::Saas) в контроллерах. Я держу три правила.
Первое: проверять режим напрямую можно только в конфигурации сервисов и в трёх местах, где это буквально про инфраструктуру. Везде остальное — вопрос к объекту настроек или к отдельной политике.
Второе: если различие затрагивает поведение, а не одну строку, — это разные реализации интерфейса, а не условие. Биллинг сюда попал именно по этому признаку.
Третье: шаблоны про режим не знают вообще. Кнопка «Тарифы» показывается не по mode == 'saas', а по наличию соответствующей возможности:
{% if billing_enabled %}
<a href="{{ org_path('billing_index') }}">Тарифы</a>
{% endif %}Разница кажется косметической, а на деле это разница между «фронтенд знает про бизнес-модель» и «фронтенд знает про возможности».
Тесты в двух режимах
Набор тестов один, но режимозависимая группа прогоняется дважды. Группа помечена в PHPUnit, а переменную окружения задаёт CI matrix или команда запуска — сам PHPUnit не назначает разное окружение группам:
DEPLOYMENT_MODE=saas php bin/phpunit --group deployment-mode
DEPLOYMENT_MODE=self_hosted php bin/phpunit --group deployment-modeПолное дублирование смысла не имеет — большинство тестов от режима не зависит. Я выделил примерно тридцать функциональных тестов, где различие существенно: регистрация, генерация ссылок, доступ к разделу тарифов. Они помечены и гоняются в обеих конфигурациях, остальные — только в облачной.
Это ловит ровно одну категорию ошибок, зато надёжно: «в облаке работает, в коробке 500». Такое было дважды, оба раза из-за ссылки, собранной штатным генератором маршрутов вместо помощника с ключом организации.
Через год после разделения
Подход выдержал. Больше всего пользы принесла не техника, а сам список различий: пока он записан и умещается на экран, любое новое требование можно к нему приложить и спросить, действительно ли оно про режим развёртывания.
Дважды выяснялось, что нет. Ограничение по домену почты я сначала считал признаком коробки, а потом первый же облачный клиент его попросил. Настройка внешнего хранилища файлов — та же история.
Чего я до сих пор не решил: как выкатывать миграции клиенту, который обновляется раз в полгода и перепрыгивает через двенадцать версий. Пока это работает, потому что миграции аддитивные, но однажды это перестанет быть правдой.