Поговорим о сервисах в Symfony
К предыдущим статьям больше всего вопросов было связано с тем, почему же не работает Slugify, как его заставить работать и почему автор, я, не написал о том, как нужно правильно работать с сервисами. И действительно, если вы не понимаете, как работает сервис-контейнер, автовайринг (автоматическая загрузка) аргументов, вы сделаете много ошибок в проектировании своих классов. Поэтому эту статью я решил посветить тому, как работать с загрузкой сервисов.
Начнем с настроек, которые указаны в config/services.yaml.
services:
_defaults:
autowire: true
autoconfigure: true
public: false
App\:
resource: '../src/*'
exclude: '../src/{DependencyInjection,Entity,Migrations,Tests,Kernel.php}'
Секция autowire: true означает, что любые сервисы (есть ограничения, о которых я скажу ниже) будут АВТОМАТИЧЕСКИ загружены, когда их вызовут. Чтобы это понять, давайте представим, что у вас нет контейнера. Вы пишете простой контроллер и в конструктор передаете нужные для его работы сервисы. Например, так:
class IndexController
{
private $users;
private $renderer;
public function __construct(UserRepository $users, Renderer $renderer)
{
$this->users = $users;
$this->renderer = $renderer;
}
public function list()
{
return $this->renderer->render('users/list.html.twig', ['users' => $this->users->list()];
}
}
Теперь настало время вызвать этот контроллер (напоминаю, у вас нет контейнера), и вы делаете так:
$index = new IndexController(
new UserRepository(new Connection(new PDO('dsn', 'username', 'password'))),
new Renderer(__DIR__ . '/templates')
);
$index->list();
Когда вы используете Symfony, вам не приходится делать такие вызовы, потому что контейнер фреймворка делает это за вас. Автозагрузка - это и есть autowire.
Двигаемся дальше, на очереди autoconfigure: true. Это настройка говорит о том, что все ваши сервисы будут автоматически зарегистрированы как команды, расширения для твига, аргумент-резолверы, слушатели, если вы отнаследуетесь от определенного класса или реализуете определенный интерфейс. В более ранних версиях фреймворка вам бы пришлось каждому сервису ставить тег, чтобы фреймворк понимал, чем является ваш сервис. Теперь этого делать не нужно.
public: false - это достаточно интересная настройка. Она все ваши сервисы по умолчанию делает приватными, а приватные сервисы нельзя достать через $container->get('service_name'). Это не столько фича, сколько защита от дурака, потому что вызывать сервисы из контейнера - из разряда bad practice, используйте DI.
App\:
resource: '../src/*'
exclude: '../src/{DependencyInjection,Entity,Migrations,Tests,Kernel.php}'
Здесь, в resource, указывается главная папка (* означает, что все подпапки, папки и классы), все классы внутри которой будут восприниматься как сервисы. В директиве exclude, наоборот, указываются классы и папки, которые сервисами быть не должны. Это концептуально ни на что не влияет, разве что на производительность.
Как я уже выше написал, автовайринг работает не с любыми сервисами, иногда ему нужно помочь. Если ваш сервис принимает в качестве аргументов скаляры, то контейнер никак не узнает о том, какие это скаляры, если вы явно не укажете. Для этого используют параметры. Например, вы написали клиент, который работает с API какого-то сервиса. Любой запрос этого сервиса требует авторизации. Т.е. вам нужно или в заголовках передавать уникальный ключ, или сначала пойти на ендпоинт авторизации, получить токен и потом ходить на все остальные ендпоинты с этим токеном. Любые уникальные ключи или токены должны находиться в переменных окружениях (при локальной разработке это файл .env). Давайте быстро набросаем примерный клиент, каким он мог бы быть:
<?php
namespace Service\Api;
use GuzzleHttp\ClientInterface;
class ApiClient
{
/**
* @var ClientInterface
*/
private $client;
/**
* @var string
*/
private $apiKey;
/**
* @var string
*/
private $baseApiUrl;
/**
* @var string
*/
private $accessToken;
public function __construct(ClientInterface $client, string $apiKey, string $baseApiUrl)
{
$this->client = $client;
$this->apiKey = $apiKey;
$this->baseApiUrl = $baseApiUrl;
}
public function auth()
{
$response = $this->client->request('POST', $this->baseApiUrl, [
'form_params' => [
'__auth' => $this->apiKey
]
])->getBody()->getContents();
$decodedResponse = \json_decode($response, true);
$this->accessToken = $decodedResponse['access_token'];
}
/**
* @return string
*/
public function getAccessToken(): string
{
return $this->accessToken;
}
}
В конструктор наш клиент принимает ClientInterface библиотеки Guzzle, строковый ключ и базовый урл апи. Мы могли бы пойти еще дальше и использовать PSRовский ClientInterface, написать к нему адаптер Guzzle, но наша цель сегодня - это не работа с API, а работа с сервисами, поэтому, с вашего позволения, я оставлю этот класс таким. В методе auth у нас происходит получение токена, который мы будем использовать в следующих запросах к данному API. Если вы попробуете использовать этот класс сейчас, то вы получите ошибку вида Cannot autowire <argument_name> of Client::__construct() method... Это происходит потому, что сервис-контейнер не знает, где ему взять $apiKey и $baseApiUrl. Давайте зарегистрируем их как параметры. Для начала добавляем в .env ключ и базовый урл:
BASE_URL=/path/to/api
API_KEY=some_api_key
Далее в секции parameters над секцией сервис регистрируем их как параметры:
parameters:
base_url: '%env(BASE_URL)%'
api_key: '%env(API_KEY)%'
services:
...
Ну и в конце описываем наш сервис:
Service\Api\ApiClient:
arguments:
- '@GuzzleHttp\Client'
- '%api_key%'
- '%base_url%'
Имя сервиса - это имя класса. В некоторых случаях симфони может попросить явно указать класс в директиве class. Если arguments не указывается без метода, то речь идет о конструкторе. Через @ указываются сервисы. Однако в сервис-контейнере нет такого сервиса как GuzzleHttp\Client, нам надо его определить. Делается это крайне просто:
GuzzleHttp\Client: ~
Service\Api\ApiClient:
arguments:
- '@GuzzleHttp\Client'
- '%api_key%'
- '%base_url%'
Поскольку аргументы газла необязательны, то мы можем просто поставить напротив определения знак тильды. Теперь Symfony увидит этот сервис. Через знак процента указываются параметры (к ним относятся api_key и base_url). Если вы указываете аргументы класса через тире, то порядок имеет значение (это даже плагин Symfony подскажет). Чтобы не зависеть от порядка, используйте именованные параметры. В этом случае ключ параметра - это название переменной сервиса:
Service\Api\ApiClient:
arguments:
$client: '@GuzzleHttp\Client'
$apiKey: '%api_key%'
$baseApiUrl: '%base_url%'
Все, теперь, когда вы в контроллере или где-либо еще запросите сервис ApiClient, фреймворка отдаст вам уже полностью сконфигурированный класс. Но мы можем сделать нашу работу еще проще. Перед использованием нашего сервиса нам нужно вызвать метод auth. А если вы забудете, то не получите токен авторизации. Фреймворк позволяет определить и это поведение:
Service\Api\ApiClient:
arguments:
$client: '@GuzzleHttp\Client'
$apiKey: '%api_key%'
$baseApiUrl: '%base_url%'
calls:
- method: auth
В секции calls мы перечисляем методы, которые нужно вызвать ДО того, как сервис попадет к нам. В этом случае мы можем сразу обращаться к методу getAccessToken(), так как в нем уже будет храниться токен авторизации. Если метод требует аргументов, прокидываем их по той же логике:
Service\Api\ApiClient:
arguments:
$client: '@GuzzleHttp\Client'
$apiKey: '%api_key%'
$baseApiUrl: '%base_url%'
calls:
- method: auth
arguments:
- ''
Таким образом, вы можете настроить конфигурацию своего сервиса так, как вам удобно, и если вы передали все нужные аргументы, вы получите готовый подключенный сервис.
Так как же решить проблему со Slugify? Все просто: или вы используете не интерфейс, а класс, или вы указываете в сервисах, какой класс вы хотите получите, когда запрашиваете SlugifyInterface:
Cocur\Slugify\Slugify: ~
Cocur\Slugify\SlugifyInterface:
arguments:
- '@Cocur\Slugify\Slugify'
В будущем мы обязательно поближе познакомимся с контейнером, узнаем, как автоконфигурация делает нашу жизнь проще, избавляя от написания тегов в каждому сервису.
В качестве закрепления материала предлагаю в комментариях написать реализацию паттерна Посетитель.
Комментарии