Laravel Routage localisé

KDPV


Bonjour, Habr!


UPD
Sur cette ressource, la pertinence de l'article peut s'avérer être zéro fois un commentaire . La tâche décrite dans l'article peut être résolue avec moins de douleur par la bibliothèque de localisation mcamara / laravel .
Merci pour le conseil DExploN !

Kat leva. Multiplié par zéro est le bas.

Je veux vous expliquer comment un projet a rencontré un problème de routage et comment nous l'avons résolu.


Au début, notre projet était le site le plus courant. Le site se développe, l'audience s'élargit et le besoin se fait sentir de soutenir le multilinguisme. Le projet était basé sur le framework Laravel et il n'y avait aucun problème avec le multilinguisme (la langue souhaitée a été extraite de la session, ou la langue par défaut a été prise). Nous avons écrit des traductions, prescrit des clés de traduction au lieu d'expressions codées en dur et pris en charge les fonctionnalités suivantes.


Le problème


À un moment donné, l'équipe SEO a réalisé que cette approche interférait avec le classement du site. Ensuite, l'équipe de développement a reçu une commande pour ajouter des sous-dossiers de langues à l'URL, à l'exception de la langue par défaut. Nos itinéraires ont pris la forme suivante:


La pageRout ru (langue par défaut)Route enRout fr
À propos de nous/o-nas/en/about-us/fr/a-propos-de-nous
Coordonnées/kontakty/en/contacts/fr/coordonnees
Actualités/novosti/en/news/fr/les-nouvelles

Tout s'est mis en place et nous avons de nouveau mis en place de nouvelles fonctionnalités.
Un peu plus tard, il était nécessaire de déployer l'application sur plusieurs domaines. En général, ces sites ont une base de données, mais certains paramètres peuvent varier en fonction du domaine.
Certains sites peuvent être multilingues (en outre, avec un ensemble limité de langues, et pas avec toutes les langues prises en charge), certains - une seule langue.


Il a été décidé de traiter tous les domaines avec une seule application (nginx assure le proxy de tous les domaines vers un en amont).


L'ensemble des langues prises en charge par un site particulier et la langue par défaut doivent être configurés dans le panneau d'administration, qui est la racine de la version piratée des variables config / env. Il est devenu clair que la solution actuelle ne satisfait pas notre liste de souhaits.


Solution


Pour simplifier l'image et démontrer la solution, j'ai déployé un nouveau projet sur laravel version 6.2 et j'ai refusé d'utiliser la base de données. Dans les versions 5.x, les différences sont mineures (mais bien sûr je ne les peindrai pas).

Code de projet disponible sur GitHub

Tout d'abord, nous devons spécifier dans la configuration de l'application toutes les langues prises en charge.


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

Nous avons besoin de l'essence du Site site et du service pour déterminer les paramètres du 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]; } } 

Ajoutez notre service au conteneur


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); // ... } // ... } 

Définissez maintenant les itinéraires.


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'); // ... 

Des parties d'itinéraires à localiser sont encadrées de doubles points négatifs ( -- ). Ce sont des masques à remplacer. Configurez maintenant ces masques.


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', ], ], ]; 

Pour afficher le composant de sélection de la langue, nous devons transférer au modèle uniquement les langues prises en charge par le site. Écrivons un middleware pour cela ...


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); } } 

Vous devez maintenant personnaliser le routeur. Plutôt, pas le routeur lui-même, mais une collection de routes ...


app / Personnalisé / Illuminer / Routage / 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']; } } 

..., la classe principale de l'application ...


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()); } } 

... et remplacez nos classes personnalisées.


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()); // ... 

L'étape suivante consiste à déterminer la langue à partir de la première partie de l'adresse URL. Pour ce faire, avant d'envoyer, nous obtiendrons son premier segment, vérifierons la prise en charge du site pour une telle langue et commencerons à envoyer une nouvelle demande sans ce segment. App\Http\Kernel peu la classe App\Http\Kernel , et en même temps ajoutons notre middleware App\Http\Middleware\ViewData au groupe 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); }; } } 

Si vous ne cachez pas les itinéraires, vous pouvez déjà travailler. Mais dans une bataille sans cache, l'idée n'est pas l'une des meilleures. Nous avons déjà appris à notre application à recevoir des itinéraires du cache, nous devons maintenant apprendre à l'enregistrer correctement. Personnaliser la route:cache commandes de la console 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; } } 

La commande route:clear supprime simplement le fichier cache, nous ne le toucherons pas. Mais la commande route:list maintenant n'interfère pas avec l'option 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; } } 

Maintenant, nous devons faire fonctionner ces commandes. Maintenant, les équipes de fournisseurs travailleront. Pour remplacer l'implémentation des commandes de la console, vous devez inclure un fournisseur de services dans l'application qui implémente l' Illuminate\Contracts\Support\DeferrableProvider . La méthode provides() doit renvoyer un tableau de clés de conteneur correspondant aux classes de commandes.


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', ]; } } 

Et bien sûr, nous ajoutons le fournisseur à la configuration.


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

Maintenant, tout fonctionne!


 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 | +--------+----------+------------------+--------------+-------------------------+------------+ 

C’est tout. Merci de votre attention!

Source: https://habr.com/ru/post/fr481726/


All Articles