Динамическая маршрутизация пользовательских доменов

Динамическая доменная маршрутизация

Еще один способ реализации пользовательских доменов и субдоменов в Ларавел для мультитенантных приложений. Multitenancy — множественная аренда, когда один экземпляр приложения, обслуживает множество тенантов («арендаторов»).

Начальный способ

Первый способ, которым я реализовал пользовательские домены в Laravel, это добавил глобальный паттерн маршрута в RouteServiceProvider, который изменил регулярное выражение для сопоставления субдоменов, чтобы учесть полные домены:

Route::pattern('domain', '[a-z0-9.\]+');

Это позволило использовать функцию доменной маршрутизации следующим образом:

Route::domain('{domain}')->group(function() {
    Route::get('users/{user}', function (Request $request, string $domain, User $user) {
        // здесь ваш код
    });
});

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

Наилучший способ

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

namespace App\Http\Middleware;

use Closure;
use App\Tenant;

class CustomDomain
{
    /**
     * Обрабатываем входящий запрос
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        $domain = $request->getHost();
        $tenant = Tenant::where('domain', $domain)->firstOrFail();

        // Добавляем домен и тенанта в объект запроса
        // для дальнейшего использования в приложении
        $request->merge([
            'domain' => $domain,
            'tenant' => $tenant
        ]);

        return $next($request);
    }
}

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

Теперь в файл app/Http/Kernel.php вы можете добавить мидлварную группу или маршрутный мидлвар, который будет содержать этот новый мидлвар. Мне больше нравится вариант с созданием новой группы для доменной маршрутизации. В дальнейшем она пригодится, если появятся новый мидлвары для кастомной доменной маршрутизации.

protected $middlewareGroups = [
    ...
    'domain' => [
        \App\Http\Middleware\CustomDomain::class,
    ],
    ...
];

Теперь я могу назначить эту мидлварную группу любым маршрутам, которые должны обслуживаться в кастомном домене. Обычно я создаю новый файл routes/tenant.php, а затем, в файл app/Providers/RouteServiceProvider.php, добавляю для него новый метод вместе с пространством имён и префиксом:

public function map()
{
    $this->mapApiRoutes();
    $this->mapWebRoutes();
    $this->mapTenantRoutes();
}

protected function mapTenantRoutes()
{
    Route::middleware(['web', 'auth', 'domain'])
        ->namespace("$this->namespace\Tenant")
        ->name('tenant.')
        ->group(base_path('routes/tenant.php'));
}

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

public function index(Request $request)
{
    $tenant = $request->tenant;

    ...
}

Если вы используете blade-шаблоны, то мидлвар CustomDomain является отличным местом, откуда можно расшарить нужные данные, которые понадобятся в шаблонах, зависящих от тенантов.

namespace App\Http\Middleware;

use Closure;
use App\Tenant;
use Illuminate\Support\Facades\View;

class CustomDomain
{
    /**
     * Обрабатываем входящий запрос
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        $domain = $request->getHost();
        $tenant = Tenant::where('domain', $domain)->firstOrFail();

        // Добавляем домен и тенанта в объект запроса
        // для дальнейшего использования в приложении
        $request->merge([
            'domain' => $domain,
            'tenant' => $tenant
        ]);

        // Расшариваем данные тенанта 
        // для удобной настройки шаблонов
        View::share('tenantColor', $tenant->color);
        View::share('tenantName', $tenant->name);

        return $next($request);
    }
}

Прочие факторы

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

Аутентификация

Скорее всего, вы захотите, чтобы пользователи вашего приложения могли входить в систему только под тем тенантом, которому они принадлежат. Для этого вы можете сохранить атрибут tenant_id в модели User и изменить свою аутентификацию, чтобы учесть это при входе в систему. Вы можете сделать это, переопределив метод credentials в файле app/Http/Controllers/Auth/LoginController.php. Дефолтный метод (который находится в трейте AuthenticatesUsers ) выглядит так:

protected function credentials(Request $request)
{
    return $request->only($this->username(), 'password');
}

Узнать тенанта так же просто, как изменить его содержимое:

protected function credentials(Request $request)
{
    $credentials = $request->only($this->username(), 'password');

    $credentials['tenant_id'] = optional($request->tenant)->id;

    return $credentials;
}

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

Возможно вам будет интересно, как происходит сопоставление маршрутов аутентификации. По умолчанию эти маршруты добавляются в routes/web.php, но, если вы хотите использовать в них тенантный мидлвар (для настройки или по каким-то другим причинам), то вам лучше создать новый метод в RouteServiceProvider, например так:

protected function mapAuthRoutes()
{
    Route::middleware(['web', 'domain'])
        ->namespace("$this->namespace\Auth")
        ->name('auth.')
        ->group(base_path('routes/auth.php'));
}

Субдомены

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

Корневые и безтенантные домены

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

namespace App\Http\Middleware;

use Closure;
use App\Tenant;
use Illuminate\Support\Facades\View;

class CustomDomain
{
    /**
     * Обрабатываем входящий запрос
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        $domain = $request->getHost();
        $tenant = Tenant::where('domain', $domain)->first();

        if (!$tenant) {
            $adminDomain = config('app.admin_domain');

            if ($domain != $adminDomain) {
                abort(404);
            }
        }

        // Добавляем домен и тенанта в объект запроса
        // для дальнейшего использования в приложении
        $request->merge([
            'domain' => $domain,
            'tenant' => $tenant
        ]);

        if ($tenant) {
            View::share('tenantColor', $tenant->color);
            View::share('tenantName', $tenant->name);
        }

        return $next($request);
    }
}

Для данных шаблона, можно добавить резервный вариант непосредственно прямо в мидлвар или, например, в метод boot файла app/Providers/AppServiceProvider.php

public function boot()
{
    ...

    View::share('tenantColor', 'gray');
    View::share('tenantName', 'Admin');

    ...
}

Резюме

На этом всё! В целом, я считаю, что этот способ является наболее чистым методом реализации пользовательских доменов (и даже субдоменов) в Laravel.

Автор: Joe Lennon
Перевод: Алексей Широков

Наш Телеграм-канал — следите за новостями о Laravel.