Поговорим о сервисах в Symfony

28.10.2022 в 12:20
6758
+201

К предыдущим статьям больше всего вопросов было связано с тем, почему же не работает 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'

В будущем мы обязательно поближе познакомимся с контейнером, узнаем, как автоконфигурация делает нашу жизнь проще, избавляя от написания тегов в каждому сервису.

В качестве закрепления материала предлагаю в комментариях написать реализацию паттерна Посетитель.

loader
28.10.2022 в 12:20
6758
+201
Комментарии
Новый комментарий

Логические задачи с собеседований