Laravel Lokalisiertes Routing

KDPV


Hallo habr


UPD
In dieser Ressource ist die Relevanz des Artikels möglicherweise null mal ein Kommentar . Die im Artikel beschriebene Aufgabe kann durch die mcamara / laravel-Lokalisierungsbibliothek mit weniger Schmerzen gelöst werden.
Danke für den Tipp DExploN !

Kat hob. Mit Null multipliziert ist der Boden.

Ich möchte Ihnen erzählen, wie in einem Projekt ein Problem mit dem Routing aufgetreten ist und wie wir es gelöst haben.


Zunächst war unser Projekt die am häufigsten verwendete Site. Die Website entwickelte sich, das Publikum expandierte und es entstand das Bedürfnis, die Mehrsprachigkeit zu unterstützen. Das Projekt basierte auf dem Laravel-Framework und es gab keine Probleme mit der Mehrsprachigkeit (die gewünschte Sprache wurde aus der Sitzung entfernt oder die Standardsprache wurde verwendet). Wir haben Übersetzungen geschrieben, Übersetzungsschlüssel anstelle von hartcodierten Phrasen verschrieben und die folgenden Funktionen verwendet, um zu funktionieren.


Das problem


Irgendwann stellte das SEO-Team fest, dass dieser Ansatz das Ranking der Website beeinträchtigt. Anschließend erhielt das Entwicklungsteam den Befehl, der URL Sprachunterordner mit Ausnahme der Standardsprache hinzuzufügen. Unsere Routen hatten folgende Form:


SeiteRout ru (Standardsprache)Route enRout fr
Über uns/o-nas/en/about-us/fr/a-propos-de-nous
Kontaktdaten/kontakty/en/contacts/fr/coordonnees
Nachrichten/novosti/en/news/fr/les-nouvelles

Alles passte zusammen und wir machten uns wieder an die neuen Funktionen.
Wenig später musste die Anwendung auf mehreren Domänen bereitgestellt werden. Im Allgemeinen verfügen diese Sites über eine Datenbank, einige Einstellungen können jedoch je nach Domäne variieren.
Einige Websites sind möglicherweise mehrsprachig (außerdem mit einer begrenzten Anzahl von Sprachen und nicht mit allen unterstützten Sprachen), andere nur in einer Sprache.


Es wurde beschlossen, alle Domänen mit einer Anwendung zu verarbeiten (nginx überträgt alle Domänen auf eine vorgelagerte).


Die von einer bestimmten Site unterstützten Sprachen und die Standardsprache sollten im Admin-Bereich konfiguriert werden, der das Stammverzeichnis der gehackten Version der config / env-Variablen ist. Es stellte sich heraus, dass die aktuelle Lösung unseren Wunschzettel nicht erfüllt.


Lösung


Um das Bild zu vereinfachen und die Lösung zu demonstrieren, habe ich ein neues Projekt auf laravel Version 6.2 implementiert und mich geweigert, die Datenbank zu verwenden. In den Versionen 5.x sind die Unterschiede gering (aber ich werde sie natürlich nicht malen).

Projektcode auf GitHub verfügbar

Zunächst müssen in der Anwendungskonfiguration alle unterstützten Sprachen angegeben werden.


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

Wir benötigen das Wesentliche der Site Site und den Dienst zum Bestimmen der Site-Einstellungen.


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 / Verträge / 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]; } } 

Fügen Sie unseren Service dem Container hinzu


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

Definieren Sie nun die Routen.


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

Teile von Routen, die lokalisiert werden sollen, sind durch doppelte Minuszeichen ( -- ) eingerahmt. Dies sind Masken zum Ersetzen. Konfigurieren Sie nun diese Masken.


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

Um die Sprachauswahlkomponente anzuzeigen, müssen nur die von der Site unterstützten Sprachen in die Vorlage übertragen werden. Lassen Sie uns eine Middleware dafür schreiben ...


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

Jetzt müssen Sie den Router anpassen. Eher nicht der Router selbst, sondern eine Sammlung von Routen ...


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

..., die Hauptklasse der Anwendung ...


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

... und ersetzen Sie unsere benutzerdefinierten Klassen.


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

Der nächste Schritt besteht darin, die Sprache aus dem ersten Teil der URL-Adresse zu bestimmen. Dazu rufen wir vor dem Versand das erste Segment ab, überprüfen die Unterstützung der Site für eine solche Sprache und beginnen mit dem Versand einer neuen Anfrage ohne dieses Segment. App\Http\Middleware\ViewData wir die Klasse App\Http\Kernel und fügen gleichzeitig unsere Middleware App\Http\Middleware\ViewData zur App\Http\Middleware\ViewData


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

Wenn Sie die Routen nicht zwischenspeichern, können Sie bereits arbeiten. Aber in einer Schlacht ohne Cache ist die Idee nicht die beste. Wir haben unserer Anwendung bereits beigebracht, Routen aus dem Cache zu empfangen. Jetzt müssen wir lernen, wie sie richtig gespeichert wird. Passen Sie den Konsolenbefehl route:cache


app / Benutzerdefiniert / 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; } } 

Der Befehl route:clear löscht einfach die Cache-Datei, wir werden sie nicht berühren. Der Befehl route:list beeinträchtigt jetzt jedoch nicht die Option locale .


app / Benutzerdefiniert / 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; } } 

Jetzt müssen wir diese Befehle zum Laufen bringen. Jetzt werden Verkäuferteams arbeiten. Um die Implementierung von Konsolenbefehlen zu ersetzen, müssen Sie einen Dienstanbieter in die Anwendung aufnehmen, der die Illuminate\Contracts\Support\DeferrableProvider . Die provides() -Methode sollte ein Array von Containerschlüsseln zurückgeben, die den Befehlsklassen entsprechen.


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

Und natürlich fügen wir den Provider der Konfiguration hinzu.


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

Jetzt funktioniert alles!


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

Das ist alles Vielen Dank für Ihre Aufmerksamkeit!

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


All Articles