
Olá Habr!
UPD
Nesse recurso, a relevância do artigo pode ser zero vezes um comentário . A tarefa descrita no artigo pode ser resolvida com menos sofrimento pela biblioteca mcamara / laravel-location .
Obrigado pela dica DExploN !
Kat levantou. Multiplicado por zero é o fundo.
Quero falar sobre como, em um projeto, houve um problema com o roteamento e como resolvemos isso.
Inicialmente, nosso projeto era o site mais comum. O site estava em desenvolvimento, o público estava em expansão e surgiu a necessidade de apoiar o multilinguismo. O projeto foi baseado na estrutura do Laravel e não houve problemas com o multilinguismo (o idioma desejado foi retirado da sessão ou o padrão foi escolhido). Escrevemos traduções, prescrevemos chaves de tradução em vez de frases codificadas e utilizamos os seguintes recursos.
O problema
Em algum momento, a equipe de SEO percebeu que essa abordagem interfere na classificação do site. Em seguida, a equipe de desenvolvimento recebeu um comando para adicionar subpastas de idioma à URL, exceto o idioma padrão. Nossas rotas assumiram a seguinte forma:
Tudo se encaixou e voltamos a lançar novos recursos.
Um pouco mais tarde, houve a necessidade de implantar o aplicativo em vários domínios. Em geral, esses sites têm um banco de dados, mas algumas configurações podem variar dependendo do domínio.
Alguns sites podem ser multilíngues (além disso, com um conjunto limitado de idiomas, e não com todos os suportados), alguns - apenas um idioma.
Foi decidido processar todos os domínios com um aplicativo (nginx proxies todos os domínios para um upstream).
O conjunto de idiomas suportados por um site específico e o idioma padrão devem ser configurados no painel de administração, que é a raiz da versão invadida das variáveis config / env. Ficou claro que a solução atual não atende à nossa Lista de desejos.
Solução
Para simplificar a imagem e demonstrar a solução, implantei um novo projeto no laravel versão 6.2 e me recusei a usar o banco de dados. Nas versões 5.x, as diferenças são mínimas (mas é claro que não as pintarei).
Código do projeto disponível no GitHub
Primeiro, precisamos especificar na configuração do aplicativo todos os idiomas suportados.
Precisamos da essência do site e do serviço para determinar as configurações do site.
app / Entities / Site.php <?php declare(strict_types=1); namespace App\Entities; class Site { private $domain; private $defaultLanguage; private $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; } public function getDomain(): string { return $this->domain; } public function getDefaultLanguage(): string { return $this->defaultLanguage; } public function getSupportedLanguages(): array { return $this->supportedLanguages; } public function isLanguageSupported(string $language): bool { return in_array($language, $this->supportedLanguages); } 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 { public function detect(string $host): Site; }
app / Serviços / 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 { private $sites; public function __construct() { $sites = [ 'localhost' => [
Adicione nosso serviço ao contêiner
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 {
Agora defina as rotas.
Partes das rotas a serem localizadas são enquadradas por dois pontos negativos ( --
). Estas são máscaras para substituição. Agora configure essas máscaras.
config / routes.php <?php return [ 'web.about' => [
Para exibir o componente de seleção de idioma, precisamos transferir para o modelo apenas os idiomas suportados pelo site. Vamos escrever um middleware para isso ...
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 { private $view; 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); } }
Agora você precisa personalizar o roteador. Em vez disso, não o próprio roteador, mas uma coleção de rotas ...
app / Personalizado / Iluminar / Roteamento / 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 { private $config; private $localized = []; public function setConfig(array $config) { $this->config = $config; } 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); } } public function serialize() { return serialize([ 'routes' => $this->routes, 'allRoutes' => $this->allRoutes, 'nameList' => $this->nameList, 'actionList' => $this->actionList, ]); } public function unserialize($serialized) { $data = unserialize($serialized); $this->routes = $data['routes']; $this->allRoutes = $data['allRoutes']; $this->nameList = $data['nameList']; $this->actionList = $data['actionList']; } }
..., a classe principal do aplicativo ...
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; } $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); $routes = $this->get('router')->getRoutes(); $routes->setConfig($this->get('config')->get('routes')); if ($this->routesAreCached()) { $this->cachedRoutes = require $this->getCachedRoutesPath(); } $this->setLocale($this->getLocale()); } }
... e substitua nossas classes personalizadas.
O próximo passo é determinar o idioma da primeira parte do endereço URL. Para fazer isso, antes do envio, obteremos seu primeiro segmento, verificaremos o suporte do site para esse idioma e começaremos o envio com uma nova solicitação sem esse segmento. Vamos corrigir App\Http\Kernel
pouco a classe App\Http\Kernel
e, ao mesmo tempo, adicionar nosso middleware App\Http\Middleware\ViewData
ao grupo da web
app / Http / Kernel.php <?php namespace App\Http;
Se você não armazenar em cache as rotas, já poderá trabalhar. Mas em uma batalha sem cache, a ideia não é das melhores. Já ensinamos nosso aplicativo a receber rotas do cache, agora precisamos ensinar como salvá-lo corretamente. Customize a route:cache
comando do console route:cache
app / Personalizado / Iluminar / Base / 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 { 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) { $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) { $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; } }
O comando route:clear
simplesmente exclui o arquivo de cache, não o tocaremos. Mas o comando route:list
agora não interfere na opção de locale
.
app / Customizado / Iluminar / 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 { 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; } }
Agora, precisamos que esses comandos funcionem. Agora, as equipes de fornecedores funcionarão. Para substituir a implementação dos comandos do console, você precisa incluir um provedor de serviços no aplicativo que implementa a Illuminate\Contracts\Support\DeferrableProvider
. O método provides()
deve retornar uma matriz de chaves de contêiner correspondentes às classes de comando.
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 { 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', ]; } }
E, é claro, adicionamos o provedor à configuração.
Agora tudo funciona!
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 | +--------+----------+------------------+--------------+-------------------------+------------+
Isso é tudo. Obrigado pela atenção!