Laravel. Локализованный роутинг

от автора

КДПВ

Привет, Хабр!

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

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

Проблема

В какой-то момент команда SEO поняла, что такой подход мешает ранжированию сайта. Тогда команде разработки поступила команда добавить языковые подпапки в УРЛ, кроме языка по умолчанию. Наши роуты приняли примерно такой вид:

Страница Роут ru (язык по умолчанию) Роут en Роут fr
О нас /o-nas /en/about-us /fr/a-propos-de-nous
Контакты /kontakty /en/contacts /fr/coordonnees
Новости /novosti /en/news /fr/les-nouvelles

Всё встало на свои места и мы снова принялись за новые фичи.
Чуть позже возникла необходимость развернуть приложение на нескольких доменах. В целом эти сайты имеют одну БД, но в зависимости от домена могут меняться некоторые настройки.
Некоторые сайты могут быть мультиязычные (причем с ограниченным набором языком, а не со всеми поддерживаемыми), некоторые — только один язык.

Было принято решение обрабатывать все домены одним приложением (nginx проксирует все домены на один апстрим).

Набор поддерживаемых конкретным сайтом языков и язык по умолчанию должен настраиваться в админке, что на корню зарубило вариант конфига/env-переменных. Стало ясно, что текущее решение не удовлетворяет наши хотелки.

Решение

Для упрощения картины и демонстрации решения я развернул новый проект на laravel версии 6.2 и отказался от использования БД. В версиях 5.x отличия незначительные (но расписывать их я, конечно же, не буду).

Код проекта доступен на GitHub

Для начала нам нужно указать в конфигурации приложения все поддерживаемые языки.

config/app.php

<?php  return [ // ...       'locale' => 'en',     'fallback_locale' => 'en',     'supported_locales' => [         'en',         'ru',         'de',         'fr',     ], // ... ];

Нам понадобится сущность сайта Site и сервис определения настроек сайта.

app/Entities/Site.php

<?php  declare(strict_types=1);  namespace App\Entities;  class Site {      /**      * @var string Домен сайта      */     private $domain;      /**      * @var string Язык по умолчанию      */     private $defaultLanguage;      /**      * @var string[] Список поддержиаемых языков      */     private $supportedLanguages = [];      /**      * @param string   $domain             Домен      * @param string   $defaultLanguage    Язык по умолчанию      * @param string[] $supportedLanguages Список поддерживаемых языков      */     public function __construct(string $domain, string $defaultLanguage, array $supportedLanguages)     {         $this->domain = $domain;         $this->defaultLanguage = $defaultLanguage;         if (!in_array($defaultLanguage, $supportedLanguages)) {             $supportedLanguages[] = $defaultLanguage;         }         $this->supportedLanguages = $supportedLanguages;     }      /**      * Возвращает домен сайта      *      * @return string      */     public function getDomain(): string     {         return $this->domain;     }      /**      * Возвращает язык по умолчанию для сайта      *      * @return string      */     public function getDefaultLanguage(): string     {         return $this->defaultLanguage;     }      /**      * Возвращает список поддерживаемых сайтом языков      *      * @return string[]      */     public function getSupportedLanguages(): array     {         return $this->supportedLanguages;     }      /**      * Проверяет поддержку сайтом языка      *      * @param string $language      * @return bool      */     public function isLanguageSupported(string $language): bool     {         return in_array($language, $this->supportedLanguages);     }      /**      * Проверяет, является ли передаваемый язык основным      *      * @param string $language      * @return bool      */     public function isLanguageDefault(string $language): bool     {         return $language === $this->defaultLanguage;     } } 

app/Contracts/SiteDetector.php

<?php  declare(strict_types=1);  namespace App\Contracts;  use App\Entities\Site; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;  interface SiteDetector {      /**      * Определяет сайт по хосту      *      * @param string $host Хост      *      * @return Site Сущность сайта      *      * @throws NotFoundHttpException Если сайт не известен      */     public function detect(string $host): Site; } 

app/Services/SiteDetector/FakeSiteDetector.php

<?php  declare(strict_types=1);  namespace App\Services\SiteDetector;  use App\Contracts\SiteDetector; use App\Entities\Site; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;  /**  * Для демонстрации сайты находятся в памяти.  * В реальном проекте всё хранится в БД, что позволяет изменять настройки через админку.  */ class FakeSiteDetector implements SiteDetector {      /**      * @var Site[] Хранилище      */     private $sites;      public function __construct()     {         $sites = [             'localhost' => [ // Все языки                 'default' => 'en',                 'support' => ['ru', 'de', 'fr'],             ],             'site-all.local' => [ // Все языки                 'default' => 'en',                 'support' => ['ru', 'de', 'fr'],             ],             'site-ru.local' => [ // Только русский                 'default' => 'ru',                 'support' => [],             ],             'site-en.local' => [                 'default' => 'en', // Только английский                 'support' => [],             ],             'site-de.local' => [                 'default' => 'de', // Только немецкий                 'support' => [],             ],             'site-fr.local' => [                 'default' => 'fr', // Только французский                 'support' => [],             ],             'site-eur.local' => [ // Немецкий и французский                 'default' => 'de',                 'support' => ['fr'],             ],         ];         foreach ($sites as $domain => $site) {             $default = $site['default'];             $support = array_merge([$default], $site['support']);             $this->sites[$domain] = new Site($domain, $default, $support);         }     }      public function detect(string $host): Site     {         $host = trim(mb_strtolower($host));         if (!array_key_exists($host, $this->sites)) {             throw new NotFoundHttpException();         }         return $this->sites[$host];     } }

Добавим наш сервис в контейнер

app/Providers/AppServiceProvider.php

<?php  namespace App\Providers;  use App\Contracts\SiteDetector; use App\Services\SiteDetector\FakeSiteDetector; use Illuminate\Support\ServiceProvider;  class AppServiceProvider extends ServiceProvider {      // ...      /**      * Register any application services.      *      * @return void      */     public function register()     {         // ...          /*          * Строим сервис.          */         $this->app->singleton(FakeSiteDetector::class, function () {             return new FakeSiteDetector();         });          /*          * Биндим контракт          */         $this->app->bind(SiteDetector::class, FakeSiteDetector::class);         // ...     }      // ... } 

Теперь определим роуты.

routes/web.php

<?php  // ...  Route::get('/', 'DemoController@home')->name('web.home'); Route::get('/--about--', 'DemoController@about')->name('web.about'); Route::get('/--contacts--', 'DemoController@contacts')->name('web.contacts'); Route::get('/--news--', 'DemoController@news')->name('web.news');  // ... 

Части роутов, подлежащие локализации, обрамлены двойными минусами (--). Это маски для замены. Теперь законфигурируем эти маски.

config/routes.php

<?php  return [     'web.about' => [ // Имя роута         'about' => [ // Маска без обрамляющих символов             'de' => 'uber-uns', // язык => слаг             'en' => 'about-us',             'fr' => 'a-propos-de-nous',             'ru' => 'o-nas',         ],     ],     'web.news' => [         'news' => [             'de' => 'nachrichten',             'en' => 'news',             'fr' => 'nouvelles',             'ru' => 'novosti',         ],     ],     'web.contacts' => [         'contacts' => [             'de' => 'kontakte',             'en' => 'contacts',             'fr' => 'contacts',             'ru' => 'kontakty',         ],     ], ]; 

Для отображения компонента выбора языка нам нужно передать в шаблон только те языки, которые поддерживаются сайтом. Напишем для этого мидлварь…

Http/Middleware/ViewData.php

<?php  namespace App\Http\Middleware;  use App\Contracts\SiteDetector; use Closure; use Illuminate\Contracts\View\Factory as ViewFactory; use Illuminate\Contracts\View\View; use Illuminate\Http\Request;  class ViewData {      /**      * @var ViewFactory      */     private $view;      /**      * @var SiteDetector      */     private $detector;      public function __construct(ViewFactory $view, SiteDetector $detector)     {         $this->view = $view;         $this->detector = $detector;     }      public function handle(Request $request, Closure $next)     {         /*          * Определяем сайт          */         $site = $this->detector->detect($request->getHost());          /*          * Передаём в шаблон панели выбора языка ссылки          */         $languages = [];         foreach ($site->getSupportedLanguages() as $language) {             $url = '/';             if (!$site->isLanguageDefault($language)) {                 $url .= $language;             }             $languages[$language] = $url;         }          $this->view->composer(['components/languages'], function(View $view) use ($languages) {             $view->with('languages', $languages);         });          return $next($request);     } } 

Теперь нужно кастомизировать роутер. Вернее не сам роутер, а коллекцию роутов…

app/Custom/Illuminate/Routing/RouteCollection.php

<?php  namespace App\Custom\Illuminate\Routing;  use Illuminate\Routing\Route; use Illuminate\Routing\RouteCollection as BaseRouteCollection; use Serializable;  class RouteCollection extends BaseRouteCollection implements Serializable {      /**      * @var array Конфигурация локализации роутов.      */     private $config;      private $localized = [];      public function setConfig(array $config)     {         $this->config = $config;     }      /**      * Заменяет маски локлизуемых роутов.      *      * @param string $language Язык      */     public function localize(string $language)     {         $this->flushLocalizedRoutes();         foreach ($this->config as $name => $placeholders) {             if (!$this->hasNamedRoute($name) || empty($placeholders)) {                 continue;             }              /*              * Получаем именованный роут              */             $route = $this->getByName($name);              /*              * Запоминаем              */             $this->localized[$name] = $route;              /*              * Удаляем его из коллекции              */             $this->removeRoute($route);              /*              * Меняем шаблон              */             $new = clone $route;             $uri = $new->uri();             foreach ($placeholders as $placeholder => $paths) {                 if (!array_key_exists($language, $paths)) {                     continue;                 }                 $value = $paths[$language];                 $uri = str_replace('--' . $placeholder . '--', $value, $uri);             }             $new->setUri($uri);             $this->add($new);         }          /*          * Обновляем индексы          */         $this->refreshNameLookups();         $this->refreshActionLookups();     }      private function removeRoute(Route $route)     {         $uri = $route->uri();         $domainAndUri = $route->getDomain().$uri;         foreach ($route->methods() as $method) {             $key = $method.$domainAndUri;             if (array_key_exists($key, $this->allRoutes)) {                 unset($this->allRoutes[$key]);             }             if (array_key_exists($uri, $this->routes[$method])) {                 unset($this->routes[$method][$uri]);             }         }     }      private function flushLocalizedRoutes()     {         foreach ($this->localized as $name => $route) {             /*              * Получаем именованный роут              */             $old = $this->getByName($name);              /*              * Удаляем его из коллекции              */             $this->removeRoute($old);              /*              * Добавляем исходный              */             $this->add($route);         }     }      /**      * @inheritDoc      */     public function serialize()     {         return serialize([             'routes' => $this->routes,             'allRoutes' => $this->allRoutes,             'nameList' => $this->nameList,             'actionList' => $this->actionList,         ]);     }      /**      * @inheritDoc      */     public function unserialize($serialized)     {         $data = unserialize($serialized);         $this->routes = $data['routes'];         $this->allRoutes = $data['allRoutes'];         $this->nameList = $data['nameList'];         $this->actionList = $data['actionList'];     } } 

…, основной класс приложения …

app/Custom/Illuminate/Foundation/Application.php

<?php  namespace App\Custom\Illuminate\Foundation;  use App\Custom\Illuminate\Routing\RouteCollection; use App\Exceptions\UnsupportedLocaleException; use Illuminate\Contracts\Config\Repository; use Illuminate\Foundation\Application as BaseApplication; use Illuminate\Routing\UrlGenerator;  class Application extends BaseApplication {      private $isLocaleEstablished = false;      private $cachedRoutes = [];      public function __construct($basePath = null)     {         parent::__construct($basePath);     }      public function setLocale($locale)     {          /*          * При попытке сменить локаль на уже установленную, не шевелимся.          */         if ($this->getLocale() === $locale && $this->isLocaleEstablished) {             return;         }          /** @var Repository $config */         $config = $this->get('config');         $urlGenerator = $this->get('url');          $defaultLocale = $config->get('app.fallback_locale');         $supportedLocales = $config->get('app.supported_locales');          /*          * Проверяем поддержку выбранной локали          */         if (!in_array($locale, $supportedLocales)) {             throw new UnsupportedLocaleException();         }          /*          * Для дополнительных языков добавляем префикс в генераторе УРЛ          */         if ($defaultLocale !== $locale && $urlGenerator instanceof UrlGenerator) {             $request = $urlGenerator->getRequest();             $rootUrl = $request->getSchemeAndHttpHost() . '/' . $locale;             $urlGenerator->forceRootUrl($rootUrl);         }          /*          * Проводим обычную процедуру смены локали          */         parent::setLocale($locale);          /*          * Применяем локализацию к роутам          */         if (array_key_exists($locale, $this->cachedRoutes)) {             $fn = $this->cachedRoutes[$locale];             $this->get('router')->setRoutes($fn());         } else {             $this->get('router')->getRoutes()->localize($locale);         }         $this->isLocaleEstablished = true;      }      public function bootstrapWith(array $bootstrappers)     {         parent::bootstrapWith($bootstrappers);          /**          * После бутстрапа роутеру нужно задать конфигурацию локализуемых роутов          * и задать приложению локаль по умолчанию          *          * @var RouteCollection $routes          */         $routes = $this->get('router')->getRoutes();         $routes->setConfig($this->get('config')->get('routes'));         if ($this->routesAreCached()) {             /** @noinspection PhpIncludeInspection */             $this->cachedRoutes = require $this->getCachedRoutesPath();         }         $this->setLocale($this->getLocale());     } } 

… и подменить наши кастомные классы.

bootstrap/app.php

<?php  // $app = new Illuminate\Foundation\Application($_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)); $app = new App\Custom\Illuminate\Foundation\Application($_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)); $app->get('router')->setRoutes(new App\Custom\Illuminate\Routing\RouteCollection());  // ...

Следующий шаг — определение языка по первой части УРЛ запроса. Для этого перед диспатчингом мы получим первый его сегмент, проверим поддержку такого языка сайтом, и запустим диспатчинг с новым запросом уже без этого сегмента. Немного поправим класс App\Http\Kernel, а заодно добавим наш миддлварь App\Http\Middleware\ViewData в группу web

app/Http/Kernel.php

<?php  namespace App\Http;  // ... use App\Contracts\SiteDetector; use App\Http\Middleware\ViewData; use Closure; use Illuminate\Foundation\Http\Kernel as HttpKernel; use Illuminate\Http\Request; // ...  class Kernel extends HttpKernel {      // ...      /**      * The application's route middleware groups.      *      * @var array      */     protected $middlewareGroups = [         // ...         'web' => [             // ...             ViewData::class,         ],         // ...     ];      // ...      /**      * Get the route dispatcher callback.      *      * @return Closure      */     protected function dispatchToRouter()     {         return function (Request $request) {             /*              * Определяем сайт              */             /** @var SiteDetector $siteDetector */             $siteDetector = $this->app->get(SiteDetector::class);             $site = $siteDetector->detect($request->getHost());              /*              * Определяем первый сегмент УРЛ              */             $segment = (string)$request->segment(1);              /*              * Если первый сегмент УРЛ совпадает с одним из поддерживаемых сайтом языков, значит это язык              */             if ($segment && $site->isLanguageSupported($segment)) {                 $language = $segment;             } else {                 $language = $site->getDefaultLanguage();             }              /*              * Задаём приложению список поддерживаемых локалей              */             $this->app->get('config')->set('app.supported_locales', $site->getSupportedLanguages());              /*              * Задаём приложению локаль по умолчанию              */             $this->app->get('config')->set('app.fallback_locale', $site->getDefaultLanguage());              /*              * Задаём приложению локаль              */             $this->app->setLocale($language);              /*              * Если текущий язык не совпадает с языком сайта по умолчанию              */             if (!$site->isLanguageDefault($language)) {                 /*                  * Вырезаем первый сегмент из УРЛ запроса.                  */                 $server = $request->server();                 $server['REQUEST_URI'] = mb_substr($server['REQUEST_URI'], mb_strlen($language) + 1);                 $request = $request->duplicate(                     $request->query->all(),                     $request->all(),                     $request->attributes->all(),                     $request->cookies->all(),                     $request->files->all(),                     $server                 );             }              /*              * Запускаем диспатчинг              */             $this->app->instance('request', $request);             return $this->router->dispatch($request);         };     } } 

Если не кэшировать роуты, то можно уже работать. Но на бою без кэша — идея не из лучших. Мы уже научили наше приложение получать роуты из кэша, теперь нужно научить правильно его сохранять. Кастомизируем консольную команду route:cache

app/Custom/Illuminate/Foundation/Console/RouteCacheCommand.php

<?php  declare(strict_types=1);  namespace App\Custom\Illuminate\Foundation\Console;  use App\Custom\Illuminate\Routing\RouteCollection as CustomRouteCollection; use Illuminate\Routing\Route; use Illuminate\Routing\RouteCollection; use Illuminate\Foundation\Console\RouteCacheCommand as BaseCommand;  class RouteCacheCommand extends BaseCommand {      /**      * Execute the console command.      *      * @return void      */     public function handle()     {         /*          * Сначала удаляем старый кэш          */         $this->call('route:clear');          /*          * Получаем роуты свежего приложения          */         $routes = $this->getFreshApplicationRoutes();          if (count($routes) === 0) {             $this->error("Your application doesn't have any routes.");             return;         }          /*          * Подготавливаем кэш и сохраняем          */         $this->files->put(             $this->laravel->getCachedRoutesPath(), $this->buildRouteCacheFile($routes)         );          $this->info('Routes cached successfully!');         return;     }      protected function buildRouteCacheFile(RouteCollection $base)     {         /*          * Кэш файл будет представлять собой массив анонимных функций.          *           * Ключ массива - локаль, значение - функция, возвращающая экземпляр класса Illuminate\Routing\RouteCollection          */          $code = '<?php' . PHP_EOL . PHP_EOL;         $code .= 'return [' . PHP_EOL;          $stub = '    \'{{key}}\' => function() {return unserialize(base64_decode(\'{{routes}}\'));},';         foreach (config('app.supported_locales') as $locale) {             /** @var CustomRouteCollection|Route[] $routes */             $routes = clone $base;             $routes->localize($locale);             foreach ($routes as $route) {                 $route->prepareForSerialization();             }             $line = str_replace('{{routes}}', base64_encode(serialize($routes)), $stub);             $line = str_replace('{{key}}', $locale, $line);             $code .= $line . PHP_EOL;         }         $code .= '];' . PHP_EOL;         return $code;     } } 

Команда route:clear просто удаляет файл кэша, Её мы трогать не будем. А вот команде route:list теперь не помешает опция locale.

app/Custom/Illuminate/Foundation/Console/RouteListCommand.php

<?php  declare(strict_types=1);  namespace App\Custom\Illuminate\Foundation\Console;  use Illuminate\Foundation\Console\RouteListCommand as BaseCommand; use Symfony\Component\Console\Input\InputOption;  class RouteListCommand extends BaseCommand {      /**      * Execute the console command.      *      * @return void      */     public function handle()     {         $locales = $this->option('locale');          /*          * Выполняем родительскую команду для каждой локали          */         foreach ($locales as $locale) {             if ($locale && in_array($locale, config('app.supported_locales'))) {                 $this->output->title($locale);                 $this->laravel->setLocale($locale);                 $this->router = $this->laravel->get('router');                 parent::handle();             }         }     }      protected function getOptions()     {         /*          * Все поддерживаемые приложением локали          */         $all = config('app.supported_locales');          /*          * Определяем опции родительской команды          */         $result = parent::getOptions();          /*          * Добавляем опцию локалей          */         $result[] = ['locale', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Locales', $all];         return $result;     } } 

Теперь нам нужно эти команды заставить работать. Сейчас будут работать вендорные команды. Чтобы заменить реализацию консольных команд, нужно включить в приложение сервис провайдер, реализующий интерфейс Illuminate\Contracts\Support\DeferrableProvider. Метод provides() должен вернуть массив ключей контейра, соответствующих классам команд.

app/Providers/CommandsReplaceProvider.php

<?php  declare(strict_types=1);  namespace App\Providers;  use App\Custom\Illuminate\Foundation\Console\RouteCacheCommand; use App\Custom\Illuminate\Foundation\Console\RouteListCommand; use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Support\DeferrableProvider; use Illuminate\Support\ServiceProvider;  class CommandsReplaceProvider extends ServiceProvider implements DeferrableProvider {      /**      * Register any application services.      *      * @return void      */     public function register()     {         $this->app->singleton('command.route.cache', function (Application $app) {             return new RouteCacheCommand($app->get('files'));         });          $this->app->singleton('command.route.list', function (Application $app) {             return new RouteListCommand($app->get('router'));         });         $this->commands($this->provides());     }      public function provides()     {         return [             'command.route.cache',             'command.route.list',         ];     } } 

Ну и конечно же, добавляем провайдер в конфигурацию.

config/app.php

<?php  return [     // ...     'providers' => [         App\Providers\CommandsReplaceProvider::class,     ],     // ... ];

Теперь всё работает!

user@host laravel-localized-routing $ ./artisan route:list  en ==  +--------+----------+----------+--------------+-------------------------+------------+ | Domain | Method   | URI      | Name         | Action                  | Middleware | +--------+----------+----------+--------------+-------------------------+------------+ |        | GET|HEAD | /        | web.home     | DemoController@home     | web        | |        | GET|HEAD | about-us | web.about    | DemoController@about    | web        | |        | GET|HEAD | contacts | web.contacts | DemoController@contacts | web        | |        | GET|HEAD | news     | web.news     | DemoController@news     | web        | +--------+----------+----------+--------------+-------------------------+------------+  ru ==  +--------+----------+----------+--------------+-------------------------+------------+ | Domain | Method   | URI      | Name         | Action                  | Middleware | +--------+----------+----------+--------------+-------------------------+------------+ |        | GET|HEAD | /        | web.home     | DemoController@home     | web        | |        | GET|HEAD | kontakty | web.contacts | DemoController@contacts | web        | |        | GET|HEAD | novosti  | web.news     | DemoController@news     | web        | |        | GET|HEAD | o-nas    | web.about    | DemoController@about    | web        | +--------+----------+----------+--------------+-------------------------+------------+  de ==  +--------+----------+-------------+--------------+-------------------------+------------+ | Domain | Method   | URI         | Name         | Action                  | Middleware | +--------+----------+-------------+--------------+-------------------------+------------+ |        | GET|HEAD | /           | web.home     | DemoController@home     | web        | |        | GET|HEAD | kontakte    | web.contacts | DemoController@contacts | web        | |        | GET|HEAD | nachrichten | web.news     | DemoController@news     | web        | |        | GET|HEAD | uber-uns    | web.about    | DemoController@about    | web        | +--------+----------+-------------+--------------+-------------------------+------------+  fr ==  +--------+----------+------------------+--------------+-------------------------+------------+ | Domain | Method   | URI              | Name         | Action                  | Middleware | +--------+----------+------------------+--------------+-------------------------+------------+ |        | GET|HEAD | /                | web.home     | DemoController@home     | web        | |        | GET|HEAD | a-propos-de-nous | web.about    | DemoController@about    | web        | |        | GET|HEAD | contacts         | web.contacts | DemoController@contacts | web        | |        | GET|HEAD | nouvelles        | web.news     | DemoController@news     | web        | +--------+----------+------------------+--------------+-------------------------+------------+ 

На этом всё. Спасибо за внимание!


ссылка на оригинал статьи https://habr.com/ru/post/481726/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *