Красота и лаконичность ссылок: пишем сокращатель на Symfony

Google давно прикрыли свою лавочку. Пришло время написать свой сокращатель ссылок на Symfony! Вот вам полноценный туториал.

Symfony – один из самых популярных enterprise-фреймворков на PHP. Если вы слышали про MVC, знаете про Request, Response, владеете знаниями ООП и хотите разрабатывать проекты с хорошей архитектурой и легкой поддержкой – смотрите в сторону Symfony. Это сложный, но и обладающий рядом преимуществ фреймворк: продвинутая ORM в виде Doctrine, удобный роутинг, поддержка аннотаций, сотни готовых бандлов по реализации популярного функционала, крутой Security компонент, формы, etc.

Напишем сокращатель ссылок, который не только сокращает, но и ведет статистику по количеству переходов.

Symfony – это MVC фреймворк, но тут есть некоторые замечания. В Symfony нигде явно не указан модельный слой, как это сделано, например, в Laravel, где вся модель (чаще всего) пишется в одном единственном классе. В Symfony Модель – это сущности, сервисы, Value Object и другие компоненты, логику которых вы пишете сами.

Установка и настройка

Как и любой фреймворк или библиотека, Symfony устанавливается через пакетный менеджер Composer так:

composer create-project symfony/website-skeleton my_project

my_project – имя будущего проекта: может быть любым.

После установки фреймворка настройте соединение с базой. Поскольку Symfony достаточно дружелюбный PHP фреймворк, создать новую базу вы можете прямо из него, отредактировав .env файл из корня проекта:

DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name

Значения db_user, db_password и db_name меняйте на свои, а db_name вообще необязательно должен существовать в системе. Symfony создаст базу сам, когда вы выполните следующую команду в терминале в корне проекта:

php bin/console doctrine:database:create

Фреймворк Symfony обладает богатым набором консольных команд, сильно облегчающих жизнь разработчика.

Знакомство с фреймворком

Весь код, который вы будете писать, находится в src. Там вы можете видеть папки Controller, Entity, Migrations и Repository. Конечно, это не все папки, которыми нужно ограничиться, и даже не строгая структура, которой нужно придерживаться, но почти достаточно для нашего проекта.

Еще не работали с Symfony? Немного пробежимся по матчасти.

Сущность – это объект, используемый для хранения данных. Другими словами, свойства сущности – это поля в таблице базы данных. Тип, размер и название мы указываем с помощью аннотаций Doctrine ORM. Чтобы создать сущность, мы для начала должны определиться, какие свойства нам нужны. Нам нужен id, верно? Но мы будем использовать не автоинкрементный идентификатор, а UUID. Необязательно, но почему бы не начать его использовать?

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

Что нам нужно еще? Конечно, URL, который мы сокращаем. URL можно представить в качестве не простого типа данных, а Value Object, что обеспечивает строгую типизацию и валидацию, гарантирующую, что на момент создания сущность всегда валидна. Также нам нужен токен (token), дата создания (createdAt) и количество переходов (views). Кажется, все.

Прежде чем создать сущность, нужно создать объекты-значения (Value Object), без которых сущность существовать не может. Но где их создать?

Поскольку мы собираемся использовать Doctrine, наш объект-значение придется аннотировать как @ORM\Embeddable(), но по умолчанию Doctrine смотрит только в папку App\Entity. Да, можно создать отдельную папку ValueObject и в настройках добавить возможность доктрине видеть и эту папку тоже. Это возможно, но объекты-значения всегда принадлежат конкретной сущности в системе. Именно поэтому их надо класть рядом. Отсюда вывод: создать папку Model, в ней папку Link (это и есть наша модель), куда поместить Entity и ValueObject. Теперь в файле config/packages/doctrine.yaml можем прописать следующие настройки, чтобы доктрина увидела наши сущности и объекты-значения:

mappings:
            Model:
                is_bundle: false
                type: annotation
                dir: '%kernel.project_dir%/src/Model'
                prefix: 'App\Model'
                alias: Model

Объекты-значения

Value Object – это простой пользовательский тип данных, проверенный на границы и тип. Вы можете захотеть использовать простой строковый тип данных для хранения и передачи URL. Но до попадания в сущность или же уже в ней его нужно проверить на соответствие, что бессмысленно, если вы забыли вызвать проверку, или сама сущность невалидна, с неправильным электронным адресом. Вот как будет выглядеть URL:

<?php

declare(strict_types=1);

namespace App\Model\Link\ValueObject;

use App\Model\Link\Exception\InvalidUrlException;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Embeddable()
 */
final class Url
{
    /**
     * @ORM\Column(type="string", unique=true)
     */
    private $token;

    /**
     * @param string $url
     *
     * @throws InvalidUrlException
     */
    public function __construct(string $url)
    {
        if (!filter_var($url, FILTER_VALIDATE_URL)) {
            throw new InvalidUrlException(sprintf('%s - is not a valid url', $url));
        }

        $this->token = rtrim($url, '/');
    }

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

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

Аннотация @ORM\Embeddable() говорит доктрине, что класс – встраиваемый объект, используемый не напрямую, а внутри какой-то сущности. В @ORM\Column(type="string", unique=true) определяется тип данных, уникальность, длина и многое другое. Тем, кто знаком с Java или активно на нем пишет, это будет напоминать аннотации из другого популярного фреймворка – Spring.

В конструкторе класса мы проверяем, является ли переданный электронный адрес валидным. Если нет, выкидываем кастомную ошибку. Если да – обрезаем последний слеш, иначе два одинаковых электронных адреса могут восприниматься системой по-разному, если у одного из них будет такой слеш. Дальше делаем простой геттер и имплементацию метода __toString(), чтобы передавать наш объект напрямую без геттера.

Второй объект-значение – токен. Его можно бы оставить обычной строкой, но тогда неизвестно, кто и какой токен нам сгенерирует, а нам нужно оставить одно единственное правило генерации. Вот такой класс Token:

<?php

declare(strict_types=1);

namespace App\Model\Link\ValueObject;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Embeddable()
 */
final class Token
{
    /**
     * @ORM\Column(type="string", length=100)
     */
    private $value;

    public function __construct()
    {
        $this->value = base_convert(rand(1, 10000000), 10, 36);
    }

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

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

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

Сущности

Как будет выглядеть наша сущность – уже определились: у нее UUID, URL, token, views и createdAt. Чтобы начать использовать UUID, скачайте библиотеку ramsey/uuid-doctrine следующей командой в терминале в корне проекта:

composer require ramsey/uuid-doctrine

После в файле config/packages/doctrine.yaml пропишите настройки:

doctrine:
     dbal:
          types:
               uuid:  Ramsey\Uuid\Doctrine\UuidType

Так мы сообщим Доктрине, что теперь есть специальный тип UuidType, который мы будем использовать в аннотации к полю. Пишем сущность:

<?php

declare(strict_types=1);

namespace App\Model\Link\Entity;

use App\Model\Link\ValueObject\Token;
use App\Model\Link\ValueObject\Url;
use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Exception;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;

/**
 * @ORM\Entity(repositoryClass="App\Repository\LinkRepository")
 */
final class Link
{
    /**
     * @var UuidInterface
     *
     * @ORM\Id
     * @ORM\Column(type="uuid", unique=true, name="uuid")
     * @ORM\GeneratedValue(strategy="CUSTOM")
     * @ORM\CustomIdGenerator(class="Ramsey\Uuid\Doctrine\UuidGenerator")
     *
     */
    public $uuid;

    /**
     * @var Url
     *
     * @ORM\Embedded(class="App\Model\Link\ValueObject\Url", columnPrefix=false)
     */
    public $url;

    /**
     * @var Token
     *
     * @ORM\Embedded(class="App\Model\Link\ValueObject\Token", columnPrefix=false)
     */
    public $token;

    /**
     * @var DateTime
     *
     * @ORM\Column(type="datetime")
     */
    public $createdAt;

    /**
     * @param Url $url
     * @param Token $token
     *
     * @throws Exception
     */
    public function __construct(
        Url $url,
        Token $token
    ) {
        $this->uuid = Uuid::uuid4();
        $this->url = $url;
        $this->token = $token;
        $this->createdAt = new DateTime();
    }

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

    /**
     * @return Url
     */
    public function getUrl(): Url
    {
        return $this->url;
    }

    /**
     * @return Token
     */
    public function getToken(): Token
    {
        return $this->token;
    }

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

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

Эта аннотация к полю означает, что наш uuid будет первичным ключом, уникальным, типа uuid, генерировать значения будет кастомный генератор Ramsey\Uuid\Doctrine\UuidGenerator.

     * @ORM\Id
     * @ORM\Column(type="uuid", unique=true, name="uuid")
     * @ORM\GeneratedValue(strategy="CUSTOM")
     * @ORM\CustomIdGenerator(class="Ramsey\Uuid\Doctrine\UuidGenerator")

Аннотация определяет, что типом данного поля будет встроенный объект Url и его поле value, над которым мы поставили ORM\Column.

@ORM\Embedded(class="App\Model\Link\ValueObject\Url", columnPrefix=false)

Здесь мы указываем, какой репозиторий будет заниматься обработкой сущности.

/**
 * @ORM\Entity(repositoryClass="App\Repository\LinkRepository")
 */

Также напишем простые геттеры, и все, сущность готова. Осталось обновить базу. Сделаем это:

php bin/console make:migration
php bin/console doctrine:migrations:migrate

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

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

Контроллеры

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

По умолчанию, фреймворк Symfony предлагает наследоваться от AbstractController, в котором уже есть нужные компоненты: шаблонизатор Twig, роутинг, методы по работе с безопасностью, EntityManager и многое другое. Но чтобы познакомиться с фреймворком поближе, предлагаем не наследоваться вообще, а все, что нам понадобится, инжектить через конструктор. Так мы поступим с нашим репозиторием, менеджером, роутингом и твигом.

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Model\Link\Entity\Link;
use App\Model\Link\ValueObject\Token;
use App\Model\Link\ValueObject\Url;
use App\Repository\LinkRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Routing\RouterInterface;
use Twig\Environment;

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

    /**
     * @var LinkRepository
     */
    private $linkRepository;

    /**
     * @var Environment
     */
    private $twig;

    /**
     * @var RouterInterface
     */
    private $router;

    /**
     * @param EntityManagerInterface $em
     * @param LinkRepository $linkRepository
     * @param Environment $twig
     * @param RouterInterface $router
     */
    public function __construct(
        EntityManagerInterface $em,
        LinkRepository $linkRepository,
        Environment $twig,
        RouterInterface $router
    ) {
        $this->em = $em;
        $this->linkRepository = $linkRepository;
        $this->twig = $twig;
        $this->router = $router;
    }

Вы могли заметить уже второй раз класс LinkRepository. Это не просто так. Дело в том, что Symfony использует паттерн Репозиторий для управления коллекцией объектов: в репозиториях вы описываете логику по тому, как достать и что достать из коллекции. Если вы используете консольные команды Symfony для быстрого описания и генерации сущностей, репозиторий для этой сущности создается автоматически. Так он выглядит:

<?php

declare(strict_types=1);

namespace App\Repository;

use App\Model\Link\Entity\Link;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Symfony\Bridge\Doctrine\RegistryInterface;

/**
 * @method Link|null find($id, $lockMode = null, $lockVersion = null)
 * @method Link|null findOneBy(array $criteria, array $orderBy = null)
 * @method Link[]    findAll()
 * @method Link[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class LinkRepository extends ServiceEntityRepository
{
    public function __construct(RegistryInterface $registry)
    {
        parent::__construct($registry, Link::class);
    }

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

Пишем простой экшен по выводу формы:

/**
     * @Route("/", name="view_links")
     */
    public function viewLinks()
    {
        return new Response($this->twig->render('links.html.twig'));
    }

Роутинг так же, как и поля сущностей, может настраиваться через аннотации, где первым аргументом указываете путь на сайте (/ – это главная страница), имя маршрута, которое можно использовать в ссылках тега, методы (POST, GET или другие), а также регулярные выражения для полного соответствия.

Экшены контроллера должны возвращать объект Response. Если бы мы унаследовались от AbstractController, достаточно было бы вернуть $this->render() с именем шаблона. Но раз мы этого не сделали, стоит обернуть вызов шаблона в объект Response и вернуть его.

PHP фреймворк Symfony использует шаблонизатор Twig. Если вы знакомы с Laravel и работали с Blade, с Twig разобраться не составит труда. Вот так выглядит шаблон links.html.twig:

{% block body %}
    <form action="{{ path('generate_links') }}" method="post">
        <div class="form-group">
            <input type="url" class="form-control" name="url">
        </div>
        <button type="submit" class="btn btn-primary">Сократить</button>
    </form>
{% endblock %}

Шаблонизатор Twig позволяет наследовать шаблоны друг от друга, в результате чего нужно всего лишь унаследовать base.html.twig и переопределить его блоки. С помощью функции path в теге action вы указываете, какой роут будет обрабатывать данную форму. Напишем роут:

/**
     * @Route("/links/generate", name="generate_links")
     */
    public function createLink(Request $request)
    {
        if (!$request->get('url')) {
            return new RedirectResponse($this->router->generate('view_links'));
        }

        $url = new Url($request->get('url'));

        /** @var Link $existsLink */
        $existsLink = $this->linkRepository->findLinkByUrl($url);

        if (!$existsLink) {
            $link = new Link($url, new Token());

            $this->em->persist($link);
            $this->em->flush();

            return new Response($this->twig->render('links.html.twig', [
                'code' => $link->getToken()
            ]));
        }

        return new Response($this->twig->render('links.html.twig', [
            'code' => $existsLink->getToken()->getValue()
        ]));
    }

Экшен принимает объект Request и проверяет, есть ли у него параметр url из формы. Если нет, возвращает пользователя на форму обратно. Создаем объект Url, передавая туда данные из Request. Далее достаем из коллекции объект с таким же электронным адресом: если его нет, создаем объект сущности Link, заполняем в конструкторе и отдаем менеджеру, который сначала подготавливает, а потом обновляет таблицу после вызова flush().

Теперь можем достать сгенерированный токен и отправить его на этот же шаблон с формой. Если такой Link уже есть в базе, сначала достаем из $existsLink токен, который является объектом класса Token, а потом значение, и отправляем на ту же форму.

Теперь нужно обновить шаблон, чтобы он показывал укороченную ссылку:

{% if code is defined %}
        <span class="btn btn-default small">Ваш URL: </span><a href="{{ url('view_links') }}{{ code }}">{{ url('view_links') }}{{ code }}</a>
    {% endif %}

Если у нас переменная code определена, составляем ссылку с названием нашего сайта (http://localhost:8000/) и токеном. Иначе говоря, после вставки электронного адреса в форму и нажатия на кнопку, сверху формы у вас появится url вида http://localhost:8000/dg3f.

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

/**
     * @Route("/{token}", name="redirect")
     */
    public function redirectToSite(Request $request)
    {
        /** @var Link $link */
        $link = $this->linkRepository->findUrlByToken($request->get('token'));

        if ($link === null) {
            return new RedirectResponse('https://proglib.io');
        }
        
        $this->linkRepository->updateViews($link->getUrl());

        return new RedirectResponse($link->getUrl()->getValue());
    }

Роут выглядит как /{token}, где token – это плейсхолдер, то есть он может постоянно меняться. Получаем токен из Request, и если такого такого токена нет (то есть кто-то решил случайно подобрать его руками), генерируем ссылку на любой другой сайт (в нашем случае наш сайт). Иначе делаем редирект на электронный адрес, который достаем из объекта Url, что является свойством класса Link.

Осталось написать реализацию методов репозитория:

/**
     * @param Url $url
     * @return mixed
     * @throws NonUniqueResultException
     */
    public function findLinkByUrl(Url $url)
    {
        return $this
            ->createQueryBuilder('link')
            ->where('link.url.token = :url')
            ->setParameter('url', $url->getValue())
            ->getQuery()
            ->getOneOrNullResult();
    }

    /**
     * @param string $token
     * @return mixed
     * @throws NonUniqueResultException
     */
    public function findUrlByToken(string $token)
    {
        return $this
            ->createQueryBuilder('link')
            ->where('link.token.token = :token')
            ->setParameter('token', $token)
            ->getQuery()
            ->getOneOrNullResult();
    }

  /**
     * @param $url
     *
     * @throws DBALException
     */
    public function updateViews(Url $url)
    {
        $sql = '
            UPDATE link
            SET views = views + 1
            WHERE url = :url
        ';

        $this->getEntityManager()->getConnection()->executeUpdate($sql, [
            'url' => $url->getValue()
        ]);
    }

Вот и все! Оцените работу сокращателя :)

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ