[WebShake] Курс по фреймворку Symfony 4 - Часть 3

Работа с доктриной. Один ко многим: связываем пост с автором

На далеком прошлом уроке мы разбирали доктрину и связь один ко многим для связывания комментариев с постами. Мы научились определять отношения, создали форму и успешно сохранили первый комментарий. На этом уроке мы займемся примерно тем же самым, но в этот раз свяжем пользователя с постом, познакомимся с другими возможностями доктрины, разберем некоторые лучшие практики. Это удобно и необходимо. Поехали.

Поскольку с последней статьи прошло много времени, за которое я успел познакомиться с Symfony и Doctrine поближе, в этой статье мы будем пересмотрим логику по работе с сущностями.

Для начала вспомним, что у нас есть вот такая неказистая сущность поста:

<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\DBAL\Types\DateType;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity(repositoryClass="App\Repository\PostRepository")
 */
class Post
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", nullable=false)
     * @Assert\Length(min=10, max=255)
     */
    private $title;

    /**
     * @ORM\Column(type="text", nullable=false)
     */
    private $body;

    /**
     * @ORM\Column(type="string", nullable=false)
     */
    private $slug;

    /**
     * @ORM\Column(type="datetime")
     */
    private $created_at;

    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @return mixed
     */
    public function getTitle()
    {
        return $this->title;
    }

    /**
     * @param mixed $title
     */
    public function setTitle($title): void
    {
        $this->title = $title;
    }

    /**
     * @return mixed
     */
    public function getBody()
    {
        return $this->body;
    }

    /**
     * @param mixed $body
     */
    public function setBody($body): void
    {
        $this->body = $body;
    }

    /**
     * @return mixed
     */
    public function getSlug()
    {
        return $this->slug;
    }

    /**
     * @param mixed $slug
     */
    public function setSlug($slug): void
    {
        $this->slug = $slug;
    }

    /**
     * @return \DateTimeInterface
     */
    public function getCreatedAt(): ?\DateTimeInterface
    {
        return $this->created_at;
    }

    /**
     * @param \DateTimeInterface $created_at
     */
    public function setCreatedAt(\DateTimeInterface $created_at): void
    {
        $this->created_at = $created_at;
    }
}

Уберем репозиторий из аннотации @ORM\Entity(), уберем @Assert\Length над полем title (сущность должна быть валидна в любом случае, и поэтому валидность полей должна проверяться до создания сущности: в форме или дто), уберем все сеттеры. Как теперь будет выглядеть наша сущность:

<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity()
 * @ORM\Table(name="posts")
 */
class Post
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", nullable=false)
     */
    private $title;

    /**
     * @ORM\Column(type="text", nullable=false)
     */
    private $body;

    /**
     * @ORM\Column(type="string", nullable=false)
     */
    private $slug;

    /**
     * @ORM\Column(type="datetime")
     */
    private $createdAt;

    public function getId(): int
    {
        return $this->id;
    }

    /**
     * @return mixed
     */
    public function getTitle()
    {
        return $this->title;
    }

    /**
     * @return mixed
     */
    public function getBody()
    {
        return $this->body;
    }

    /**
     * @return mixed
     */
    public function getSlug()
    {
        return $this->slug;
    }

    /**
     * @return \DateTimeInterface
     */
    public function getCreatedAt(): ?\DateTimeInterface
    {
        return $this->createdAt;
    }
}

Также мы добавили аннотацию @Table, где определили имя таблицы через атрибут name, и позже добавим индексы. Вероятно, вы думаете, как без сеттеров теперь можно заполнить сущность? Через именованные конструкторы. И предвосхищая ваш вопрос, чем именованные конструкторы лучше сеттеров, прошу представить, что у вас есть сущность с 20-тью или больше полями. Разумеется, у такой сущности могут быть необязательные поля или даже половина из них. Предположим, эта сущность хранит информацию о пользователях из разных соц. сетей: гугл, гитхаб, вконтакте, что-то еще. Кроме обязательных полей, необходимых для авторизации на вашем сайте, соц сети могут по запросу отдавать и другую информацию о пользователях: город, ссылку на репозиторий, фотографию, возраст. А может и не отдавать. Именно поэтому многие из этих полей вам необходимо будет сделать nullable. Тем не менее, тестируя авторизацию через разные соц сети, вы точно знаете, откуда и что приходит. Гугл точно отдает почту, а вконтакте может и не отдавать. Гитхаб точно отдает данные о репозиториях, а у других соц сетей такого просто нет. И чтобы не путаться в том, когда и какой сеттер вызывать, можно использовать именованный конструктор, сигнатуру которого вы точно знаете.

Допустим, мы решили сделать пользователям удобно и сделали возможность хранить черновик статьи, не привязывая их ко времени публикации. Чем черновик отличается от опубликованной статьи? У черновика нет даты публикации, у черновика другой статус и все обязательные к публикации поля пока могут быть еще пустыми. Так давайте это отразим в сущности:

<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;

/**
 * @ORM\Entity()
 * @ORM\Table(name="posts")
 */
class Post
{
    public const DRAFT = 'draft';
    public const PUBLISHED = 'published';

    /**
     * @var Uuid
     * @ORM\Id()
     * @ORM\Column(type="uuid")
     */
    private $id;

    /**
     * @ORM\Column(type="string", nullable=true)
     */
    private $title;

    /**
     * @ORM\Column(type="text", nullable=true)
     */
    private $body;

    /**
     * @ORM\Column(type="string", nullable=true)
     */
    private $slug;

    /**
     * @var string
     * @ORM\Column(type="string", nullable=false)
     */
    private $status;

    /**
     * @ORM\Column(type="datetime_immutable", nullable=false)
     */
    private $createdAt;

    /**
     * @var \DateTimeImmutable
     * @ORM\Column(type="datetime_immutable", nullable=true)
     */
    private $publishedAt;

    /**
     * @var \DateTimeImmutable
     * @ORM\Column(type="datetime_immutable", nullable=false)
     */
    private $updatedAt;

    private function __construct()
    {
        $this->id = Uuid::uuid4();
        $this->createdAt = new \DateTimeImmutable('now', new \DateTimeZone('Europe/Moscow'));
    }

    /**
     * @return Uuid
     */
    public function getId(): Uuid
    {
        return $this->id;
    }

    /**
     * @return string|null
     */
    public function getTitle(): ?string
    {
        return $this->title;
    }

    /**
     * @return string|null
     */
    public function getBody(): ?string
    {
        return $this->body;
    }

    /**
     * @return string|null
     */
    public function getSlug(): ?string
    {
        return $this->slug;
    }

    /**
     * @return \DateTimeImmutable
     */
    public function getCreatedAt(): ?\DateTimeImmutable
    {
        return $this->createdAt;
    }

    /**
     * @return string
     */
    public function getStatus(): string
    {
        return $this->status;
    }

    /**
     * @return \DateTimeImmutable
     */
    public function getPublishedAt(): \DateTimeImmutable
    {
        return $this->publishedAt;
    }

    /**
     * @return \DateTimeImmutable
     */
    public function getUpdatedAt(): \DateTimeImmutable
    {
        return $this->updatedAt;
    }
}

Давай по порядку. Мы поменяли тип у id, сделав его не автоинкрементным, а использовали тип uuid. Скачайте следующую библиотеку:

composer require ramsey/uuid-doctrine

После того, как установите, в файле config/packages/doctrine.yaml найдите директиву types и напишите следующее:

types:
     uuid:  'Ramsey\Uuid\Doctrine\UuidType'

Теперь этот тип доступен вам в любых сущностях. Теперь давайте подумаем, чем uuid лучше автоинкремента. Если мы захотим кидать события между компонентами, нам надо передавать событию id созданного поста, который мы не знаем в случае автоинкремента, так как он генерируется на стороне базы, и нам надо будет дождаться флаша сущности. Генерируя же id самостоятельно, у нас есть к нему доступ в любой момент. Чтобы иметь возможность юзерам иметь черновики и при обновлении попадать на свою же статью, нам надо после сохранения (фоном или по кнопке) сделать редирект, например, со страницы /post/add на страницу /post/{uuid}/edit. В этом случае мы можем генерировать uuid на клиенте и передавать на сервер с остальными данными, где сохранить сущность. В случае с автоинкрементом мы бы так не смогли сделать.

Также мы все обязательные поля сделали необязательными (поменяли nullable=false на nullable=true), добавили поле статус, чтобы отличать черновик от опубликованного поста, а также добавили поля updatedAt и publishedAt. Конструктор сделали приватным и внутри него спрятали установку даты для поля createdAt, которое никогда не может быть nullable и есть как у черновика, так и опубликованного поста, поэтому ему самое место быть в обычном конструкторе. А также определили id, присвоив ему Uuid четвертой версии. Теперь давайте напишем именованные конструкторы для черновика и публикации:

    public static function fromDraft(?string $title, ?string $body, ?string $slug): Post
    {
        $post = new self();
        $post->title = $title;
        $post->body = $body;
        $post->slug = $slug;
        $post->updatedAt = new \DateTimeImmutable('now', new \DateTimeZone('Europe/Moscow'));
        $post->status = self::DRAFT;

        return $post;
    }

    public static function fromPublished(string $title, string $body, string $slug): Post
    {
        $post = new self();
        $post->title = $title;
        $post->body = $body;
        $post->slug = $slug;
        $post->updatedAt = new \DateTimeImmutable('now', new \DateTimeZone('Europe/Moscow'));
        $post->publishedAt = new \DateTimeImmutable('now', new \DateTimeZone('Europe/Moscow'));
        $post->status = self::PUBLISHED;

        return $post;
    }

Именованные конструкторы - это всего лишь статические методы, вызываемые у класса, а не объекта. Как мы видим, это очень удобно: некоторые поля мы можем спрятать внутри, передавая в качестве аргументов только то, что точно нужно, в нашем случае это заголовок, тело статьи и слаг. При этом именованные конструкторы внутри определяют статус, время, о которых нам не нужно думать. Пока мы не начали писать контроллер, давайте все же сделаем то, ради чего мы все собрались: определим связь юзера с постом.

/**
     * @var User
     * @ORM\ManyToOne(targetEntity="App\Entity\User")
     * @ORM\JoinColumn(referencedColumnName="id", onDelete="CASCADE")
     */
    private $user;

Для начала мы указали связь Многие к Одному от поста, что означает, что у одного юзера может быть много постов. В аннотации @ORM\JoinColumn мы указали, на какое поле делать референс, а также в атрибуте onDelete=“CASCADE” мы определили, что посты удалятся автоматически, если удалится пользователь. Если же вам не хочется удалять посты, тогда сделайте следующее:

   /**
     * @var User
     * @ORM\ManyToOne(targetEntity="App\Entity\User")
     * @ORM\JoinColumn(referencedColumnName="id", onDelete="SET NULL", nullable=true)
     */
    private $user;

Во-первых, мы добавили nullable, а во-вторых, поменяли onDelete с CASCADE на SET NULL, что значит, что при удалении юзера у его постов автоматически проставится null в базе. Удобно? Удобно. Теперь осталось немного отредактировать метод fromDraft, добавив туда юзера.

public static function fromDraft(
        User $user,
        ?string $title,
        ?string $body,
        ?string $slug
    ): Post {

        $post = new self();
        $post->title = $title;
        $post->body = $body;
        $post->slug = $slug;
        $post->user = $user;
        $post->updatedAt = new \DateTimeImmutable('now', new \DateTimeZone('Europe/Moscow'));
        $post->status = self::DRAFT;

        return $post;
    }

Внимание : не нужно определять юзера еще и в методе fromPublished, ведь если у вас на сайте будет премодерация и опубликовать может только админ, то при определении юзера в этом методе автором постам станет админ, а не пользователь. Достаточно юзера связать один раз.

И пока я не забыл, давайте поставим индексы. Для это в аннотации @Table надо сделать так:

@ORM\Table(name="posts", indexes={
     @ORM\Index(columns={"user_id"}),
     @ORM\Index(columns={"status"}),
     @ORM\Index(columns={"created_at"})
 })

Мы поставили индексы на юзера, статус и поле создания, по которым чаще всего будет идти поиск в базе.

Вроде закончили, теперь не забудьте выполнить миграцию:

php bin/console doctrine:migrations:diff && bin/console doctrine:migrations:migrate

Однако прежде чем написать контроллер, формы, сохранить сущность, давайте сделаем категории на сайте. Или теги. Можете называть, как вам больше нравится. Для этого создадим следующую сущность:

<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;

/**
 * @ORM\Entity()
 * @ORM\Table(name="categories", indexes={
 *     @ORM\Index(columns={"name"}),
 *     @ORM\Index(columns={"date"})
 *  })
 */
class Category
{
    /**
     * @var Uuid
     * @ORM\Id()
     * @ORM\Column(type="uuid")
     */
    private $id;

    /**
     * @var string
     * @ORM\Column(type="string", nullable=false)
     */
    private $name;

    /**
     * @var \DateTimeImmutable
     * @ORM\Column(type="datetime_immutable")
     */
    private $date;

    public function __construct(string $name)
    {
        $this->id = Uuid::uuid4();
        $this->name = $name;
        $this->date = new \DateTimeImmutable('now', new \DateTimeZone('Europe/Moscow'));
    }

    /**
     * @return Uuid
     */
    public function getId(): Uuid
    {
        return $this->id;
    }

    /**
     * @return \DateTimeImmutable
     */
    public function getDate(): \DateTimeImmutable
    {
        return $this->date;
    }

    /**
     * @return string
     */
    public function getName(): string
    {
        return $this->name;
    }
}

Все, что написано тут, вам уже знакомо, поэтому пропустим объяснения.

До встречи в следующем уроке! Там мы рассмотрим связь многие ко многим с помощью доктрины!

Работа с доктриной. Многие ко многим: связываем посты с категориями

Привет! В этом уроке мы сделаем таблицу категорий для связывания их с постами. Давайте займемся определением отношения между категориями и публикациями. У одной категорий может быть много постов, как и у одной публикации - много категорий. Это называется отношением Многие ко Многим (Many-to-Many).

Чтобы определить связь, необходимо у сущности Post завести поле categories:

   /**
     * @var ArrayCollection
     * @ORM\ManyToMany(targetEntity="App\Entity\Category", cascade={"persist"})
     * @ORM\JoinTable(name="post_categories")
     * @ORM\JoinColumn(referencedColumnName="id", nullable=false)
     */
    private $categories;

Поле $categories будет типа ArrayCollection. В аннотации @ORM\ManyToMany мы указываем сущность для связи, в @ORM\JoinTable указываем имя пивот-таблицы, а @ORM\JoinColumn вам уже знакома. Можно такую же связь определить со стороны категории, чтобы через какой-нибудь $categories->getPosts()->toArray() достать все посты для конкретной категории, однако мы не будем так делать и не определим двухстороннюю связь. Вместо этого мы будем делать простой запрос через query builder.

Не забудем выполнить миграцию:

php bin/console doctrine:migrations:diff;
php bin/console doctrine:migrations:migrate;

После такого рефакторинга ваш проект перестанет работать, потому что репозитории у нас устроены по-старому. Давайте их немного перепишем:

<?php

declare(strict_types=1);

namespace App\Repository;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use App\Entity\Post;

class PostRepository
{
    /**
     * @var EntityManagerInterface
     */
    private $em;

    /**
     * @var EntityRepository 
     */
    private $repository;

    public function __construct(EntityManagerInterface $em)
    {
        $this->em = $em;
        $this->repository = $em->getRepository(Post::class);
    }

    public function add(Post $post)
    {
        $this->em->persist($post);
    }

    public function findOneBySlug(string $slug)
    {
        return $this->repository->findOneBy(['slug' => $slug]);
    }
}

Теперь мы не наследуемся от ServiceEntityRepository; наш репозиторий занимается добавлением на сохранение в базу сущности; а также теперь у нас нет доступа напрямую к методам ServiceEntityRepository и желания сделать findAll() не возникнет. Все прежние методы вроде findOne, findOneBy, findAll доступны внутри репозитория и вы можете закрыть их своими методами, где явно определить, что и по какому полю вы достаете.

Так же поступаем и с репозиторием категории:

<?php

declare(strict_types=1);

namespace App\Repository;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use App\Entity\Category;

class CategoryRepository
{
    /**
     * @var EntityManagerInterface
     */
    private $em;

    /**
     * @var EntityRepository
     */
    private $repository;

    public function __construct(EntityManagerInterface $em)
    {
        $this->em = $em;
        $this->repository = $em->getRepository(Category::class);
    }

    public function add(Category $category)
    {
        $this->em->persist($category);
    }

    public function findOneByName(string $name)
    {
        return $this->repository->findOneBy(['name' => $name]);
    }
}

Скоро мы отрефакторим старые контроллеры, а также напишем новые. На следующем уроке мы займемся сохранением сущности поста вместе с его тегами, сохранением тега, редактирование поста, посмотрим на различные ситуации и то, как из них выйти. У уроку прикладываю гист, если вам лень переписывать.

Поговорим о сервисах в 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'

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

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

Слушатели: как работают и как их использовать не по стандарту

В одной из прошлых статей мы уже обсуждали работу со слушателями в Symfony. Если коротко, слушатели - это такие компоненты системы, которые могут быть оповещены о результате каких-то изменений этой самой системы с помощью других специальных компонентов - событий. Из данного определения может показаться, что эти компоненты действительно настолько независимы и события к ним приходят каким-то магическим образом, однако это не так и все намного проще.

Свой EventDispatcher

На самом деле, в Symfony есть два понятия - подписчик и слушатель. Разница между ними в том, что подписчик обычно подписывается на несколько событий одного цикла, а слушатель - на одно. Мы будем реализовывать именно подписчик, но чтобы вы знали, это работает одинаково (и Symfony к тому же все подписчики разбирает и превращает их в слушатели).

Чтобы разобраться в этом, предлагаю создать свой EventDispatcher. Для начала нам нужно определиться, из чего состоит этот компонент: событие, подписчик и сам диспетчер, который отправляет события конкретным подписчикам. Начнем описание переменных системы с интерфейса:

interface EventSubscriber
{
    /**
     * @return array
     */
    public function getSubscribedEvents(): array;
}

Что нам нужно знать от подписчика, так это только массив событий, на которые он подписан, остальное нас не касается. Метод getSubscribedEvents должен возвращать массив вида:

return [
     RequestEvent::class => 'onRequestEvent'
];

Где ключ массива - это название события (полное имя класса), второе - метод, куда мы инжектим объект события.

Чтобы в метод dispatch() инжектить конкретный тип данных, будем использовать базовый родительский класс события Event . Он может выглядеть так:

class Event
{
    /**
     * @var null
     */
    private $subject;

    /**
     * @var array
     */
    private array $arguments;

    public function __construct($subject = null, array $arguments = [])
    {
        $this->subject = $subject;
        $this->arguments = $arguments;
    }

    /**
     * @return null
     */
    public function getSubject()
    {
        return $this->subject;
    }

    /**
     * @return array
     */
    public function getArguments(): array
    {
        return $this->arguments;
    }
}

Теперь мы готовы перейти к сердцу нашей системы - диспетчеру:

class EventDispatcher
{
    /**
     * @var array
     */
    private array $subscribers = [];

    /**
     * @param EventSubscriber $subscriber
     *
     * @return EventDispatcher
     */
    public function addSubscriber(EventSubscriber $subscriber): self
    {
        $this->subscribers[] = $subscriber;

        return $this;
    }

    public function dispatch(Event $event)
    {
        $eventClass = \get_class($event);

        /** @var EventSubscriber $subscriber */
        foreach ($this->subscribers as $subscriber) {
            if (\in_array($eventClass, \array_keys($subscriber->getSubscribedEvents()))) {
                $method = $subscriber->getSubscribedEvents()[$eventClass];

                $subscriber->$method($event);
            }

            return;
        }
    }
}

Давайте разбираться. У нашего диспетчера есть приватное свойство, куда мы складываем всех подписчиков через метод addSubscriber . Когда мы вызываем метод dispatch() , передавая туда наше событие (которое обязательно должно быть подтипом класса Event), наш диспетчер перебирает всех подписчиков, проверяет, нет ли среди ключей массива каждого из подписчиков имя события (название класса), которое к нам пришло. Если есть, мы достаем метод (значение ключа) через имя события и вызываем у нашего подписчика этот метод, передавая туда объект события:

$subscriber->$method($event);

Если же мы ничего не нашли, просто выходим из метода. Это не должно быть ошибкой, если на событие нет подписчика.

Возможно, я объяснил недостаточно наглядно, поэтому давайте по порядку, но для этого напишем наше событие и подписчик для него.

Событие

final class RequestEvent extends Event
{
}

Подписчик

final class RequestSubscriber implements EventSubscriber
{
    public function onRequestEvent(RequestEvent $requestEvent)
    {
         var_dump(  
             $requestEvent->getSubject(),
             $requestEvent->getArguments()
         );
    }

    /**
     * @return array
     */
    public function getSubscribedEvents(): array
    {
        return [
            RequestEvent::class => 'onRequestEvent'
        ];
    }
}

Собираем все вместе

$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new RequestSubscriber());

$dispatcher->dispatch(new RequestEvent(new stdClass(), [
    'name' => 'Something was updated'
]));

Мы создали объект диспетчера, зарегистрировали подписчик и вызвали метод dispatch , куда передали наше событие (в качестве объекта в конструктор Event вы можете передать любой тип данных, начиная от примитивного int и заканчивая другим объектом). В этот момент происходит следующее:

  1. Мы получаем название класса события
$eventClass = \get_class($event); // RequestSubscriber
  1. Обходим все зарегистрированные подписчики:
foreach ($this->subscribers as $subscriber) {           
}
  1. Проверяем, нет ли среди ключей массива нашего подписчика текущего события:
if (\in_array($eventClass, \array_keys($subscriber->getSubscribedEvents()))) {
}
  1. Если мы обнаружили, что такое событие есть, достаем метод, который нужно вызвать, когда подписчик получит событие:
$method = $subscriber->getSubscribedEvents()[$eventClass];
  1. Вызываем его, передавая туда текущее событие:
$subscriber->$method($event);

Когда вы запустите этот код, вы получите следующее:

object(stdClass)#4 (0) {
}
array(1) {
  ["name"]=>
  string(21) "Something was updated"
}

Магия? Конечно нет. Вы вызываете подписчики и слушатели ровно в тот же момент, когда вызываете метод dispatch! Это самый обычный императивный подход, только неявный и слабосвязанный.

Имейте в виду, я показал лишь примитивную реализацию диспетчера, в Symfony все несколько сложнее: там вы можете указывать приоритет вызова методов, там есть автовайринг, что позволяет вам не заботиться о самостоятельном внедрении зависимостей в подписчик через конструктор, и, наконец, Symfony за счет имплементации интерфейса EventSubscriberInterface сама ставит теги (подробнее читайте в документации) вашим подписчикам и слушателям, что избавляет вас от необходимости вызывать метод addSubscriber .

Полный пример кода можете посмотреть по ссылке.

Откладываем события

Чуть ранее я написал, что слушатели отрабатывают в тот же момент, как только мы вызываем метод dispatch , и в этом проблема - мы не хотим, чтобы событие об изменении данных отправилось раньше, чем изменение зафиксируется, ведь транзакция может завершиться ошибкой, а событие уже отправлено. Беда. Однако решение уже придумали, причем достаточно элегантное и полностью рабочее, - хранить наши события в самой сущности. Выглядит это следующим образом:

  1. Нам нужен интерфейс, который будет возвращать массив событий:
interface EventsRoot
{
    public function releaseEvents(): array;
}
  1. Трейт, который реализует этот метод и метод добавления в массив событий:
trait EventsRootBehaviour
{
    private array $events = [];

    /**
     * @param $event
     *
     * @return void
     */
    protected function fireEvent($event): void
    {
        $this->events[] = $event;
    }

    /**
     * @return array
     */
    public function releaseEvents(): array
    {
        [$events, $this->events] = [$this->events, []];

        return $events;
    }
}
  1. Обертка над $em->flush() , которая сначала зафиксирует изменения в базу данных, а потом отправит события:
class Flusher
{
    /**
     * @var EntityManagerInterface
     */
    private EntityManagerInterface $em;

    /**
     * @var EventDispatcherInterface
     */
    private EventDispatcherInterface $dispatcher;

    public function __construct(EntityManagerInterface $em, EventDispatcherInterface $dispatcher)
    {
        $this->em = $em;
        $this->dispatcher = $dispatcher;
    }

    /**
     * @param EventsRoot ...$roots
     *
     * @return void
     */
    public function flush(EventsRoot ...$roots): void
    {
        $this->em->flush();

        foreach ($roots as $root) {
            foreach ($root->releaseEvents() as $event) {
                $this->dispatcher->dispatch($event);
            }
        }
    }
}

Теперь события отправятся только в том случае, если flush отработает без ошибок. Пример использования:

/**
 * @ORM\Entity()
 * @ORM\Table(name="articles"})
 */
class Article implements EventsRoot
{
   use EventsRootBehaviour;

   public function publish(\DateTimeImmutable $date)
   {
      // действия публикации
      $this->fireEvent(new ArticlePublished($this->id));
   }
}

Дальше вы реализуете подписчик на событие ArticlePublished. Теперь при публикации статьи вы должны передать в вашу обертку Flusher вашу сущность: $flusher->flush($article) . flush сначала отправит изменения в базу, а потом вызовет необходимые слушатели. Вот и все, теперь вы застрахованы от неожиданных последствий работы слушателей. Вместо EventDispatcherInterface вы можете использовать новый компонент Messenger, который позволяет ставить ваши события в очередь.

Надеюсь, эта статья помогла вам понять, что в слушателях нет ничего магического, но они дают удобную возможность снизить связанность вашего кода (однако увеличивают трудность понимания работы системы).

Разворачиваем Symfony приложение в Docker с использованием php 7.4

Многие из вас уже, вероятно, успели написать свое первое приложение и даже разместить его на хостинге, поэтому вам знакомы трудности с тем, чтобы установить веб-сервер, настроить базу данных, и это если вам не нужны дополнительные инструменты, такие как поисковые движки, NoSQL базы данных, системы очередей и многое другое. Разумеется, для локальной разработки существует готовое ПО, например, OpenServer, но они, как правило, достаточно тяжелые и предоставляют много дополнительных опций, которыми вы не пользуетесь.

В этой статье поговорим о Docker и о том, как развернуть приложение Symfony в контейнере.
Наверняка многие из вас уже слышали про Docker, однако я все равно приведу популярное определение для тех, кто только с ним знакомится:

Docker – это программная платформа для быстрой разработки, тестирования и развертывания приложений. Docker упаковывает ПО в стандартизованные блоки, которые называются контейнерами. Каждый контейнер включает все необходимое для работы приложения: библиотеки, системные инструменты, код и среду исполнения.

Итак, существует два основных определения - образ и контейнер. Образ - это иммутабельный (неизменяемый) шаблон, на базе которого строятся контейнеры, внутри которого запускаются ваши сервисы: веб-сервер, база данных, php-fpm и так далее. Образ содержит легковесную (не всегда) операционку, необходимые расширения и утилиты для создания контейнера.

Для развертывания Symfony приложения нам понадобятся 4 контейнера: nginx, php-fpm, php-cli и mysql. Для создания образа будем использовать Dockerfile, а для развертывания - docker-compose.

Установка

Все, что вам нужно, это зарегистрироваться на docker.com и скачать приложение под свою ОС. Скачали, авторизовались локально, поехали дальше.

Структура приложения

Структура нашего приложения будет выглядеть следующим образом:

В папке docker будут размещаться наши образы, а в папке app - Symfony приложение. Начнем с папки nginx, в ней, как вы уже догадались, находятся файл конфигурации default.conf и Dockerfile для описания нашего образа.

nginx

server {
    listen 80;
    index index.php index.html;
    error_log /var/log/nginx/error.log;
    access_log /var/log/nginx/access.log;
    root /symfony/public;

    client_max_body_size 128m;

    add_header 'Access-Control-Allow-Origin' '*' always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;
    add_header 'Access-Control-Allow-Methods' 'GET,POST,PUT,DELETE,HEAD,OPTIONS' always;
    add_header 'Access-Control-Allow-Headers' 'Origin,Content-Type,Accept,Authorization' always;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass php-fpm:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }
}

На первый взгляд, ничего необычного, но прошу вас обратить внимание на директивы root /symfony/public; и fastcgi_pass php-fpm:9000; . Чуть позже я объясню, что в них необычного.

Dockerfile для образа с nginx выглядит достаточно просто:

FROM nginx:1.17

ADD ./default.conf /etc/nginx/conf.d/default.conf

WORKDIR /symfony

Команда FROM указывает Docker, что мы хотим использовать официальный образ nginx 1.17-й версии, скачанный с docker hub. Команда ADD добавляет в контейнер с nginx файл default.conf с локальной машины (т.е. тот, что выше), а WORKDIR устанавливает рабочую директорию внутри контейнера.

php-cli

Dockerfile для php-cli будет выглядеть следующим образом:

FROM php:7.4-cli

RUN apt-get update && apt-get install -y \
    libpq-dev \
    wget \
    zlib1g-dev \
    libmcrypt-dev \
    libzip-dev

RUN docker-php-ext-install pdo pdo_mysql zip

RUN wget https://getcomposer.org/installer -O - -q | php -- --install-dir=/bin --filename=composer --quiet

WORKDIR /symfony

Кое-что в этом докерфайле вам должно быть знакомо, это команды apt-get update/apt-get install . Так как мы находимся в контейнере, где установлена linux система, мы можем запускать команды через RUN. Итак, мы устанавливаем необходимые утилиты для самой ОС, а с помощью команды docker-php-ext-install устанавливаем расширения для работы с базами данных, файлами и так далее. Те, кто хотя бы когда-то устанавливали расширения для php, понимают, на сколько это проще того, что было раньше. Дальше мы скачиваем установщик композера, с помощью специальных флагов указываем имя, папку, где будет лежать наш композер, а потом устанавливаем текущую директорию.

php-fpm

FROM php:7.4-fpm

RUN apt-get update && apt-get install -y \
    libpq-dev \
    wget \
    zlib1g-dev \
    libmcrypt-dev \
    libzip-dev

RUN docker-php-ext-install pdo pdo_mysql

WORKDIR /symfony

Для php-fpm Dockerfile почти такой же, за исключением того, что мы не устанавливаем composer, так как он нам не нужен, когда есть php-cli.

docker-compose

Утилита docker-compose идет в составе Docker и нужна для управления несколькими контейнерами одновременно. Она поможет нам быстро развернуть приложение и начать разработку. Я приведу весь файл конфигурации, а потом объясню, что каждая команда значит.

version: '3.0'

services:
  nginx:
    build:
      context: ./docker/nginx
    volumes:
      - ./app:/symfony
    container_name: ${PROJECT_NAME}-nginx
    restart: always
    ports:
      - "8081:80"

  php-fpm:
    build:
      context: ./docker/php-fpm
    volumes:
      - ./app:/symfony
    container_name: ${PROJECT_NAME}-php-fpm
    depends_on:
      - mysql

  php-cli:
    build:
      context: ./docker/php-cli
    volumes:
      - ./app:/symfony
    command: sleep 10000
    container_name: ${PROJECT_NAME}-php-cli

  mysql:
    image: mysql:8.0
    command: --default-authentication-plugin=mysql_native_password
    volumes:
      - mysql:/var/lib/mysql
    container_name: ${PROJECT_NAME}-mysql
    restart: always
    environment:
      - "MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}"
      - "MYSQL_DATABASE=${MYSQL_DATABASE}"
      - "MYSQL_USER=${MYSQL_USER}"
      - "MYSQL_PASSWORD=${MYSQL_PASSWORD}"

volumes:
  mysql:

В начале файла мы должны указать версию docker-compose, чтобы пользоваться или не пользоваться командами определенной версии. Далее, в директиве services, мы описываем наши контейнеры. Для этого указываем имя (nginx, php-cli, php-fpm, mysql), в build -> context мы должны указать, откуда брать Dockerfile, на основании которого мы будем собирать наш контейнер. Чтобы все ваши данные после завершения работы контейнера не были потеряны, используют волюмы. Том (volume) - это папка хоста, примонтированная к файловой системе контейнера. В данном случае мы монтируем папку app с локальной машины в папку symfony, которая находится в контейнере (слева направо).

Дальше мы можем указать container_name, иначе докер сгенерирует его сам и вам будет тяжело его запомнить, чтобы войти в контейнер. ${PROJECT_NAME} - это переменная, которую докер ищет в енв, в нашем случае - это .env файл, там вы можете указать любое произвольное имя. Таким образом, имя контейнера может быть таким: symfony-app-nginx . Директива restart:always , думаю, более чем очевидна и не требует объяснений. ports пробрасывает порты из контейнера (которые правее) наружу на нашу машину (которые левее). Таким образом, сайт будет открываться по localhost:8081.

То же самое мы проделываем с контейнером php-fpm. Сюда еще добавляется команда depends_on, где мы указываем, что php-fpm зависит от контейнера с mysql (который мы укажем ниже), а значит, пока не поднимется mysql, не поднимется php-fpm.

В php-cli добавляется директива command, в которой мы просто указали юниксовую команду, заставляющую терминал заснуть на 10 тысяч секунд. Зачем это нам нужно, затем, что в php-cli по умолчанию нет демона, который бы работал в фоне, поэтому наш контейнер после запуска сразу же завершится и вам не получится зайти в него.

Для контейнера с mysql мы не писали свой Dockerfile, а используем образ mysql:8.0. Без этой команды –default-authentication-plugin=mysql_native_password контейнер запустить не удастся. Что она делает и зачем нужна, можете прочитать тут. environment позволяет прокинуть какие-то данные внутрь контейнера. Таким образом, мы прокидываем наши пароли, название базы и имя юзера в контейнер.

Теперь самое интересное, запись mysql:/var/lib/mysql немного отличается от того, что вы видели ранее. Дело в том, что каждый раз при создании и удалении контейнера генерируется новый id для него, и, соответственно, каждый раз генерировалась бы новая папка с данными базы, т.е. база каждый раз была бы новой. Выход есть - использовать именованный том. Нам необходимо указать его в инструкции volumes, где mysql - это имя контейнера, а у контейнера примонтировать его следующим образом: mysql:/var/lib/mysql . Путь для хранения данных вашего именованного тома будет вычисляться по формуле <DOCKER_PATH>/volumes/<VOLUME_NAME>/_data . Теперь при удалении и создании контейнера Docker будет цеплять папку с вашим томом, зная имя контейнера. Чтобы проверить это, выполните в терминале следующую команду: docker volume ls . Она покажет все существующие волюмы. Выберите тот, который появится после того, как мы запустим наш docker-compose и выполните следующую команду: docker volume inspect <container_name> . Ответ будет примерно следующим:

[
    {
        "CreatedAt": "2020-02-15T19:33:19Z",
        "Driver": "local",
        "Labels": {
            "com.docker.compose.project": "symfony-app",
            "com.docker.compose.version": "1.23.2",
            "com.docker.compose.volume": "mysql"
        },
        "Mountpoint": "/var/lib/docker/volumes/symfony-app_mysql/_data",
        "Name": "symfony-app_mysql",
        "Options": null,
        "Scope": "local"
    }
]

Прежде чем мы запустим сборку и убедимся, что все хорошо, я расскажу про то, что обещал выше, а именно про root /symfony/public; и fastcgi_pass php-fpm:9000; , хотя, вероятно, вы уже и сами догадались. В root мы указываем имя рабочей директории из контейнера, которую примонтировали к нашей локальной папке, а именно symfony/public из контейнера будет смотреть в app/public нашего проекта. В fastcgi_pass, где мы должны указать адрес FastCGI-сервера, мы указываем имя контейнера с php-fpm и порт, который по умолчанию всегда 9000. Поскольку мы пользуемся утилитой docker-compose, контейнеры по умолчанию друг друга видят, поэтому нам не нужно их связывать через links или networks (о них читайте подробнее в документации).

Первый запуск

Давайте попробуем запустить то, что получилось. Для этого в корне проекта (там, где ваш docker-compose) выполните следующую команду docker-compose up --build -d . Эта команда поднимает ваши контейнеры, но перед этим запускает сборку (–build) и запускает ваши контейнеры в режиме демона (-d). Первая сборка будет долгой, поэтому придется подождать. Когда все выполнилось, вы должны будете увидеть примерно следующее:

Creating symfony-app-nginx   ... done
Creating symfony-app-php-cli ... done
Creating symfony-app-php-fpm ... done
Creating symfony-app-mysql ... done

Это означает, что все прошло хорошо. Чтобы убедиться, что сайт работает, создайте в папке app папку public и положите туда файл index.php с таким содержимым:

<?php

\phpinfo();

Вы должны будете увидеть знакомую вам страницу. Если все прошло хорошо, давайте установим Symfony в нашу папку app. Заходим в наш контейнер с php-cli: docker exec -it symfony-app-php-cli bash . В контейнере выполняем следующую команду, чтобы установить Symfony: composer create-project symfony/website-skeleton app .

После установки выполняем несколько команд, чтобы избавиться от вложенности папок:

mv /symfony/app/* /symfony
mv /symfony/app/.* /symfony
rm -Rf app

Теперь пробуйте открыть localhost:8081, вас должна приветствовать свежая версия Symfony:

Напоследок покажу, как подключиться к базе данных. Для этого необходимо указать те пароль и имя, что мы прокинули в контейнер ранее, а в качестве хоста надо указать имя контейнера, т.е. mysql:

DATABASE_URL=mysql://root:root@mysql/symfony?serverVersion=8.0

Чтобы остановить контейнеры, выполните команду docker-compose down

Проект можно скачать по ссылке.

Консольные команды в Symfony: расширенные возможности

Консольные команды Symfony - прекрасный инструмент для решения определенных задач. В этой статье поговорим о расширенных и неожиданных возможностях компонента Symfony/Console.

Будем использовать Docker проект, который написали в прошлой статье, поэтому зайдите в него и в корневой директории запустите контейнеры командой docker-compose up -d . После запуска зайдите в контейнер с php-cli:

docker exec -it symfony-app-php-cli bash

Создание простой команды

Чтобы создать команду и зарегистрировать ее, вам необходимо в папке Command или любой другой создать класс и отнаследоваться от Symfony\Component\Console\Command\Command . В примитивном случае ваша команда будет выглядеть следующим образом:

<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Command\Command;

final class AdvancedCommand extends Command
{
}

Чтобы команда была доступна из консоли, ей нужно дать имя. Сделать это можно тремя способами:

  1. Через свойство $defaultName
<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Command\Command;

final class AdvancedCommand extends Command
{
    protected static $defaultName = 'command:advance';
}
  1. Через конструктор
<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Command\Command;

final class AdvancedCommand extends Command
{
    public function __construct(string $name = 'command:advance')
    {
        parent::__construct($name);
    }
}
  1. Через метод configure
<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Command\Command;

final class AdvancedCommand extends Command
{
    /**
     * {@inheritDoc}
     */
    protected function configure(): void
    {
        $this->setName('command:advance');
    }
}

Выберите один из вариантов и выполните в терминале следующую команду:

php bin/console command:advance

Сейчас вы получите ошибку о том, что не определили метод execute, но мы увидели, что хотели, - команда работает.

Итак, всю полезную работу команда должна выполнять в методе execute, именно его вызывает Symfony, когда вы обращаетесь к своей команде:

<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

final class AdvancedCommand extends Command
{
    /**
     * {@inheritDoc}
     */
    protected function configure(): void
    {
        $this->setName('command:advance');
    }

    /**
     * {@inheritDoc}
     */
    protected function execute(InputInterface $input, OutputInterface $output)
    {
    }
}

Метод execute принимает два аргумента: InputInterface и OutputInterface. Как вы уже догадались, первый нужен для того, чтобы получить пользовательский ввод, а второй - вывести ответ обратно пользователю. Давайте напишем команду, которая будет спрашивать у пользователя его имя и фамилию, а затем выводить их вместе:

<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

final class AdvancedCommand extends Command
{
    /**
     * {@inheritDoc}
     */
    protected function configure(): void
    {
        $this
            ->setName('command:advance')
            ->setDescription('This command will ask you for name and surname and print them back.')
        ;
    }

    /**
     * {@inheritDoc}
     */
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        do {
            $name = $io->ask('Ваше имя');
        } while (null === $name);

        do {
            $surname = $io->ask('Ваша фамилия');
        } while (null === $surname);

        $io->success(\sprintf('Ваше полное имя: %s %s', $surname, $name));

        return 1;
    }
}

В методе configure мы добавили описание команды через setDescription, оно будет выводиться напротив вашей команды в терминале, когда вы запросите список всех команд. В методе execute мы используем специальный компонент SymfonyStyle, который умеет красиво выводить в консоль, спрашивать вопросы, рисовать таблицы, прогресс-бары и многое другое. В двух циклах do…while мы спрашиваем имя и фамилию пользователя до тех пор, пока он их не введет. Потом через метод success выводим обратно в терминал.

Расширенные возможности

А что если нам надо дать возможность пользователю передавать команде флаги для изменения ее поведения? Не проблема, компонент дает возможность задавать как аргументы, так и опции. Аргументы - это упорядоченные строки, разделенные пробелами. Они могут быть обязательными, опциональными и приходить в виде массива. Давайте запросим у пользователя имя и фамилию вместе с командой:

<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

final class AdvancedCommand extends Command
{
    /**
     * {@inheritDoc}
     */
    protected function configure(): void
    {
        $this
            ->setName('command:advance')
            ->setDescription('This command will ask you for name and surname and print them back.')
            ->addArgument('surname', InputArgument::REQUIRED)
            ->addArgument('name', InputArgument::REQUIRED)
        ;
    }

    /**
     * {@inheritDoc}
     */
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $name = $input->getArgument('name');
        $surname = $input->getArgument('surname');

        $io->success(\sprintf('Ваше имя: %s %s', $surname, $name));

        return 1;
    }
}

Вызвать эту команду можно следующим образом:

php bin/console command:advance Doe John

В методе configure мы определили аргументы, а в execute достали их. Все просто. Метод addArgument также принимает описание аргумента и значение по умолчанию, если оно есть. Чтобы пользователи вашей команды знали, что ждет ваша команда, вы можете вызвать вашу команду с флагом -h:

root@f876fdb5dd87:/symfony# php bin/console command:advance -h          
Description:
  This command will ask you for name and surname and print them back.

Usage:
  command:advance <surname> <name>

Arguments:
  surname               
  name                  

Options:
  -h, --help            Display this help message
  -q, --quiet           Do not output any message
  -V, --version         Display this application version
      --ansi            Force ANSI output
      --no-ansi         Disable ANSI output
  -n, --no-interaction  Do not ask any interactive question
  -e, --env=ENV         The Environment name. [default: "dev"]
      --no-debug        Switches off debug mode.
  -v|vv|vvv, --verbose  Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug

Вы также можете добавить дополнительную справку к вашей команде, вызвав в методе configure метод setHelp.

Опции - это, наоборот, не упорядоченные строки, передавать их можно в любом порядке, указывая их следующим образом:

php bin/console command:advance --surname=Doe --name=John

Чтобы использовать опции, надо определить их точно так же, как и аргументы:

<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

final class AdvancedCommand extends Command
{
    /**
     * {@inheritDoc}
     */
    protected function configure(): void
    {
        $this
            ->setName('command:advance')
            ->setDescription('This command will ask you for name and surname and print them back.')
            ->addOption('surname', 's', InputOption::VALUE_REQUIRED)
            ->addOption('name', 'm', InputOption::VALUE_REQUIRED)
        ;
    }

    /**
     * {@inheritDoc}
     */
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $surname = $input->getOption('surname');
        $name = $input->getOption('name');

        $io->success(\sprintf('Ваше имя: %s %s', $surname, $name));

        return 1;
    }
}

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

php bin/console -s Doe -m John

Рисуем таблицы и прогресс-бары

Таблицы могут применяться для удобного вывода структурированной информации. Предположим, у вас в приложении есть система ролей и прав, у каждой роли - свои права. Вы хотите менять права пользователям из консольной команды. Для этого хорошо бы иметь перед глазами полную информацию о том, что может каждая роль. Давайте это сделаем.

<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

final class AdvancedCommand extends Command
{
    /**
     * {@inheritDoc}
     */
    protected function configure(): void
    {
        $this
            ->setName('command:advance')
            ->setDescription('Command print roles and they permissions')
        ;
    }

    /**
     * {@inheritDoc}
     */
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $table = new Table($output);

        $roles = [
            [
                'id' => 1,
                'role' => 'ROLE_SUPERADMIN',
                'permissions' => \implode(', ', ['EDIT', 'CREATE', 'DELETE', 'CHANGE ROLE'])
            ],
            [
                'id' => 2,
                'role' => 'ROLE_ADMIN',
                'permissions' => \implode(', ', ['EDIT', 'CREATE', 'DELETE'])
            ],
            [
                'id' => 3,
                'role' => 'ROLE_EDITOR',
                'permissions' => \implode(', ', ['EDIT', 'CREATE'])
            ],
            [
                'id' => 4,
                'role' => 'ROLE_USER',
                'permissions' => \implode(', ', ['CREATE'])
            ]
        ];

        $table
            ->setHeaders(['Id', 'Роль', 'Права'])
            ->setRows($roles)
        ;

        $table->render();

        return 1;
    }
}

Представим, что мы достали из базы roles, а не создали массив тут. Это удобно, если у вас будет изменяться система прав. Теперь, если вы вызовите команду, вы увидите следующее:

Удобно, не так ли? Теперь давайте объединим вопрос и таблицу. Мы должны получить от пользователя id роли и назначить ее какому-либо из юзеров, почту которого мы так же попросим ввести:

<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

final class AdvancedCommand extends Command
{
    /**
     * {@inheritDoc}
     */
    protected function configure(): void
    {
        $this
            ->setName('command:advance')
            ->setDescription('Command print roles and they permissions')
        ;
    }

    /**
     * {@inheritDoc}
     */
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $table = new Table($output);

        $roles = [
            [
                'id' => 1,
                'role' => 'ROLE_SUPERADMIN',
                'permissions' => \implode(', ', ['EDIT', 'CREATE', 'DELETE', 'CHANGE ROLE'])
            ],
            [
                'id' => 2,
                'role' => 'ROLE_ADMIN',
                'permissions' => \implode(', ', ['EDIT', 'CREATE', 'DELETE'])
            ],
            [
                'id' => 3,
                'role' => 'ROLE_EDITOR',
                'permissions' => \implode(', ', ['EDIT', 'CREATE'])
            ],
            [
                'id' => 4,
                'role' => 'ROLE_USER',
                'permissions' => \implode(', ', ['CREATE'])
            ]
        ];

        $table
            ->setHeaders(['Id', 'Роль', 'Права'])
            ->setRows($roles)
        ;

        $table->render();

        do {
            $role = $io->ask('Введит id роли, которую хотите присвоить пользователю');
        } while (null === $role);

        do {
            $email = $io->ask('Введите почту пользователя, которому хотите присвоить роль');
        } while (null === $email);

        $io->success(\sprintf('Пользователю %s будет присвоена роль %s', $email, $role));

        $result = $io->confirm('Подтвердить?');

        if (true === $result) {
            // change role
            $io->success('Роль присвоена');

            return 1;
        }

        $io->comment('Изменение прав отменено');

        return 1;
    }
}

Мы использовали метод confirm, которой запросит у пользователя подтверждение и вернет его нам. Если мы получили подтверждение, меняем роль. Про все возможности таблицы в терминале вы можете узнать из документации.

Прогресс-бары

Прогресс-бары так же, как и таблицы, нужны для интерактивности, чтобы юзер видел, что команда работает, а не уснула. Например, у вас есть большая операция по изменению большой пачки данных из таблиц. Вы собираетесь выполнять ее в цикле с множеством операций для каждой из записи. Давайте сымитируем это поведение:

<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

final class AdvancedCommand extends Command
{
    /**
     * {@inheritDoc}
     */
    protected function configure(): void
    {
        $this
            ->setName('command:advance')
            ->setDescription('Command print roles and they permissions')
        ;
    }

    /**
     * {@inheritDoc}
     */
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $progressBar = $io->createProgressBar(100);

        for ($i = 0; $i < 100; $i++) {
            \sleep(1);

            $progressBar->advance();
        }

        $progressBar->finish();

        return 1;
    }
}

Мы создали прогресс бар, передали ему максимальное кол-во элементов. Дальше мы в простом цикле обходим элементы, засыпаем на 1 секунду и после этого увеличиваем прогресс-бар. Запустите нашу команду и посмотрите, что будет. Больше про прогресс-бары читайте в документации.

Вызываем другие консольные команды

Возможно, вы захотите вызывать одни консольные команды из других. Сделать это можно следующим образом:

<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

final class AdvancedCommand extends Command
{
    /**
     * {@inheritDoc}
     */
    protected function configure(): void
    {
        $this
            ->setName('command:advance')
            ->setDescription('Command print roles and they permissions')
        ;
    }

    /**
     * {@inheritDoc}
     */
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $command = $this->getApplication()->find('list');

        $code = $command->run($input, $output);

        $io->success($code);

        return 1;
    }
}

Мы вызвали встроенную команду list, которая вернет полный список команд нашего приложения. Чтобы узнать статус выполнения команды - успешно или нет, - можно использовать код, которая она вернула.

Если же вы хотите вызвать команду, которая принимает аргументы или опции, сделать это можно так:

<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

final class AdvancedCommand extends Command
{
    /**
     * {@inheritDoc}
     */
    protected function configure(): void
    {
        $this
            ->setName('command:advance')
            ->setDescription('Command print roles and they permissions')
        ;
    }

    /**
     * {@inheritDoc}
     */
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $command = $this->getApplication()->find('cache:warmup');

        $arrayInput = new ArrayInput([
            '--env' => 'dev',
        ]);

        $code = $command->run($arrayInput, $output);

        $io->success($code);

        return 1;
    }
}

Мы вызвали встроенную команду симфони для прогрева кэша и передали туда опцию, указывающую на окружение, в котором мы находимся.

Разумеется, вы можете внедрять любые сервисы через конструктор, Symfony их вам заинжектит. Вы также можете использовать ваши команды в качестве крон команд, указав полное имя к вашему проекту плюс php bin/console <command_name> .

Надеюсь, этих возможностей вам хватит, чтобы автоматизировать часть рутинных задач вашего приложения.

Собственные типы данных для Doctrine и Value Object

Типы данных - это такие множества данных, для которых характерны определенные свойства и для которых применимы определенные операции. У СУБД тоже есть типы данных, у которых есть свои ограничения. Скажем, у VARCHAR, INT, TEXT есть максимальная длина. Существуют также еще так называемые пользовательские типы данных, которые на самом деле используют примитивные, но при этом предоставляют совершенно другой управляющий интерфейс. Сегодня мы поговорим, как хранить в базе несовместимые с ней типы данных.

Собственные типы данных

Представим, у вас есть массив. В обычном случае перед сохранением в базу вы превратите его в строку через implode , а на выходе опять примените explode , чтобы получить тот же массив. Это неудобно, а еще об этом можно забыть, если данные достаются из разных мест. К счастью, Doctrine избавляет нас от этой рутины. Вспомните аннотации, которые мы писали над свойствами сущности:

 /**
  * @var string|null
  * @ORM\Column(type="string", nullable=true)
  */
private $field;

Доктрина из коробки предоставляет нам все допустимые типы для популярных СУБД. А что если мы захотим сохранить массив? Не проблема, давайте напишем свой тип:

<?php

use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;

final class SimpleArrayType extends Type
{
    public const NAME = 'simple_array';

   /**
     * {@inheritdoc}
     */
    public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform)
    {
        return $platform->getClobTypeDeclarationSQL($fieldDeclaration);
    }

    /**
     * {@inheritdoc}
     */
    public function convertToDatabaseValue($value, AbstractPlatform $platform)
    {
        if (!$value) {
            return null;
        }

        return implode(',', $value);
    }

    /**
     * {@inheritdoc}
     */
    public function convertToPHPValue($value, AbstractPlatform $platform)
    {
        if ($value === null) {
            return [];
        }

        return explode(',', $value);
    }

    /**
     * {@inheritdoc}
     */
    public function getName()
    {
        return self::NAME;
    }

    /**
     * {@inheritdoc}
     */
    public function requiresSQLCommentHint(AbstractPlatform $platform)
    {
        return true;
    }
}

Мы дали имя нашему типу, присвоив его константе NAME, который позже зарегистрируем. Метод getSQLDeclaration определяет длину нашего поля (чтобы понять, как он работает, перейдите в метод AbstractPlatform::getClobTypeDeclarationSQL . Самое интересное происходит в методах convertToDatabaseValue и convertToPHPValue . Как понятно из названия, первый метод конвертирует ваши данные в понятный базе тип, а второй - из типа базы данных в тип PHP. Таким образом, при вставке в базу данных доктрина сама позаботится за нас о том, чтобы вызвать для каждого типа метод convertToDatabaseValue , а при запросе из базы данных - convertToPHPValue . Метод requiresSQLCommentHint возвращает true, что говорит доктрине использовать комментарии к полю в базе данных для хранения названия типа: это подскажет доктрине, какой конкретно класс использовать, чтобы модифицировать наши данные.

Чтобы наш тип начал работать, его необходимо зарегистрировать. Сделать это можно в файле config/packages/doctrine.yml:

doctrine:
    dbal:
        types:
              simple_array: 'path/to/ClassType'

Теперь вы можете его использовать в аннотации @ORM\Column. Таким образом вы можете хранить не только данные, которые необходимо преобразовать, но и обычные объекты. Например, вы хотите хранить объект Slug, который, во-первых, будет возвращать строку слага, а во-вторых, проверять, что какой-то другой слаг не равен этому. Давайте напишем наш объект и тип к нему:

class Slug
{
    /**
     * @var string
     */
    private string $value;

    public function __construct(string $value)
    {
        $this->value = $value;
    }

    /**
     * @param Slug $slug
     *
     * @return bool
     */
    public function isEqualTo(Slug $slug): bool
    {
        return $this->value === $slug->getValue();
    }

    /**
     * @return string
     */
    public function getValue(): string
    {
        return $this->value;
    }
}
class SlugType extends StringType
{
    public const NAME = 'advanced_slug_type';

    /**
     * {@inheritDoc}
     */
    public function convertToDatabaseValue($value, AbstractPlatform $platform)
    {
        return $value instanceof Slug ? $value->getValue() : $value;
    }

    /**
     * {@inheritDoc}
     */
    public function convertToPHPValue($value, AbstractPlatform $platform)
    {
        return !empty($value) ? new Slug($value) : null;
    }

    /**
     * {@inheritDoc}
     */
    public function getName(): string
    {
        return self::NAME;
    }

    /**
     * {@inheritDoc}
     */
    public function requiresSQLCommentHint(AbstractPlatform $platform) : bool
    {
        return true;
    }
}

Не забудем его зарегистрировать:

doctrine:
    dbal:
        types:
              simple_array: 'SimpleArrayType'
              advanced_slug_type: 'SlugType'

Использовать типы можно так же, как простые объекты:

$post = new Post($title, new Slug($title));
$em->persist($post);
$em->flush();

Embeddable

Наверняка вы знакомы с понятием объект-значение, или Value Object. Объекты-значения - это такие объекты, которые сравниваются по значению, а не идентификатору. Вы можете помещать туда бизнес-логику или логику валидации. К примеру, Email или Phone - это объекты-значения. Получив в виде строки почту или телефон, объект-значение первым делом проверит его валидность. Как вы уже догадались, доктрина позволяет использовать и их. Для этого нам необходимо над нашим объектом-значением определить аннотацию Embeddable . Давайте реализуем популярный пример объекта-значения - VerifyCode . Он будет хранить сам код верификации и время его истечения:

/**
 * @ORM\Embeddable()
 */
class VerifyCode
{
    /**
     * @var string|null
     * @ORM\Column(type="string", nullable=true)
     */
    private string $code;

    /**
     * @var \DateTimeImmutable|null
     * @ORM\Column(type="datetime_immutable", nullable=true)
     */
    private \DateTimeImmutable $expiresAt;

    public function __construct(string $code, \DateTimeImmutable $expiresAt)
    {
        $this->code = $code;
        $this->expiresAt = $expiresAt;
    }

    /**
     * @param DateTimeImmutable $now
     *
     * @return bool
     */
    public function isValid(\DateTimeImmutable $now): bool
    {
        return $this->expiresAt >= $now;
    }

    /**
     * @return string|null
     */
    public function getCode(): ?string
    {
        return $this->code;
    }

    /**
     * @return DateTimeImmutable|null
     */
    public function getExpiresAt(): ?DateTimeImmutable
    {
        return $this->expiresAt;
    }
}

Объекты-значения не являются полноценными сущностями, у них нет идентификатора, поэтому хранить их отдельно нельзя, они принадлежат другим сущностям. Наш VerifyCode будет принадлежать сущности Phone:

/**
 * @ORM\Entity()
 */
class Phone
{
    /**
     * @var VerifyCode
     * @ORM\Embedded(class=VerifyCode::class, columnPrefix=false)
     */
    private VerifyCode $verifyCode;
}

В аннотации Embedded мы указываем имя встраиваемого класса и указываем, что префикс нам не нужен. Дополнительной конфигурации не нужно, мы уже можем использовать наш VO:

$phone->changeVerificationCode(new VerifyCode(4234234, new \DateTimeImmutable('+1 day')));
$em->flush();

Чтобы достать из базы по коду верификации, нам понадобится составить запрос следующим образом:

$phone = $this->repository->findOneBy(['verifyCode.code' => $code]);

Через точку указываем свойство нашего объекта-значения.

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

3 Likes