Куда поместить код: статический метод или сервис?

В очередном обсуждении архитектуры и DDD на форуме Yii (да, такое бывает, но жуткий дефицит) возник вопрос по упрощённому фрагменту сущности User из демо-приложения:

class User extends ActiveRecord
{
    public static function isPasswordResetTokenValid($token)
    {
        if (empty($token)) {
            return false;
        }
        $timestamp = (int) substr($token, strrpos($token, '_') + 1);
        $expire = Yii::$app->params['user.passwordResetTokenExpire'];
        return $timestamp + $expire >= time();
    }
 
    public function generatePasswordResetToken()
    {
        $this->password_reset_token = Yii::$app->security->generateRandomString() . '_' . time();
    }
 
    ...
}

Сам вопрос примерно звучал так:

Скажите еще я верно понимаю что вот этот метод isPasswordResetTokenValid есть бизнес-логика доменного объекта User? Естественно я осознаю, что нужно убрать зависимость от Yii::$app->params['user.passwordResetTokenExpire'] и вынести её как аргумент метода, а от аргумента $token наоборот избавиться, а сам метод сделать методом объекта, вместо статичного.

В мире Yii обычно не принято думать об архитектуре. За это инквизиция часто сжигает на костре с воплями «вали в свой Symfony, еретик!», но мы попробуем.

В первоначальном коде выше имеем статический метод проверки токена, для упрощения кода помещённый в саму сущность User. И рядом имеем метод для его генерации. Они оба лезут то к компоненту Yii::$app->security, то в конфигурацию за параметром Yii::$app->params['user.passwordResetTokenExpire'], что выглядит весьма печально для людей, работающих с другими фреймворками.

Кто был на Неделе ООП, тот уже разбирается в репозиториях, сервисах и DI, а остальным читателям без познаний вменяемого ООП здесь будет сложно и непонятно. Итак...

Давайте избавимся от статического метода и уберём все зависимости от 'Yii::$app из нашей сущности. Нам нужно реализовать управление токенами. Куда этот код поместить? Чья это ответственность? Оставить всё в User, как оно сейчас и есть?

ООП - это про разнесение кода на самостоятельные объекты по их ответственностям. Нам просто нужна «штука, которая управляет токенами», имеющая свои настройки и зависимости. Так и сделаем её отдельно, чтобы не засорять User и прочие места. Придумаем и определим в доменной модели её интерфейс. Она должно генерировать и валидировать токен. Это и запишем:

namespace Domain\Service;
 
interface PasswordResetTokenizer
{
    public function generate();
    public function validate($token);
}

И в сервисе приложения будем эту штуку использовать:

namespace Application\Service;
 
use Domain\Repository\UserRepository;
use Domain\Service\PasswordHasher;
...
 
class UserService
{
    private $users;
    private $hasher;
    private $tokenizer;
    private $dispatcher;
 
    public function __construct(
        UserRepository $users,
        PasswordHasher $hasher,
        PasswordResetTokenizer $tokenizer,
        EventDispatcher $dispatcher
    )
    {
        $this->users = $users;
        $this->hasher = $hasher;
        $this->tokenizer = $tokenizer;
        $this->dispatcher = $dispatcher;
    }
 
    public function requestPasswordReset($email)
    {
        $user = $this->users->getByEmail($email);
        $user->requestPasswordReset($this->tokenizer->generate());
        $this->users->save($user);
        $this->dispatcher->dispatch($user->releaseEvents());
    }
 
    public function confirmPasswordReset($token$pasword)
    {
        if (!$this->tokenizer->validate($token)) {
            throw new \DomainException('Token is not valid.');
        }
        $user = $this->users->getByPasswordResetToken($token);
        $user->changePassword($this->hasher->hash($pasword));
        $this->users->save($user);
        $this->dispatcher->dispatch($user->releaseEvents());
    }
    
    ...
}

Общий код сущностей модели и сервисов приложения готов и его можно протестировать моками.

А теперь прикрутим этот код к Yii2. Определим фреймворкозависимую реализацию нашего доменного сервиса по работе с токенами:

namespace Infrastructre\Service;
 
use yii\base\Secuity;
 
class YiiPasswordResetTokenizer implements PasswordResetTokenizer
{
    private $security;
    private $timeout;
 
    public function __construct(Security $security$timeout)
    {
        $this->security = $security;
        $this->timeout = $timeout;
    }   
 
    public function generate()
    {
         return $this->security->generateRandomString() . '_' . time();
    }
 
    public function validate($token)
    {
        if (empty($token)) {
            return false;
        }
        $timestamp = (int) substr($tokenstrrpos($token'_') + 1);
        return $timestamp + $this->timeout >= time();
    }
}

По желанию пишем небольшой тест, мокающий Security и проверяющий корректность генерации и валидации.

Далее конфигурируем и привязываем реализацию к интерфейсу в контейнере:

Yii::$container->setSingleton('Domain\Service\PasswordResetTokenizer'function() {
    return new YiiPasswordResetTokenizer(
        Yii::$app->security,
        Yii::$app->params['user.passwordResetTokenExpire']        
    );
});

Теперь инъектим сервис приложения в контроллер:

class UserController
{
    private $service;
 
    public function __construct($id$moduleUserService $service$config = [])
    {
        $this->service = $service;
        parent::__construct($id$module$config);
    }
 
    ...
}

и используем в экшене:

$this->service->confirmPasswordReset($token$form->password);

DI контейнер подтянет все зависимости и всё заработает в боевых условиях.

А что если нужно осуществлять проверки вроде $user->isResetTokenValid() в самой сущности и её токен оставить приватным без геттера? Например, если нужно сделать защиту от спама, чтобы нельзя было отправить новый токен если не истёк старый. Тогда сущности можно попросить нужный сервис снаружи. При этом мы просто передаём его в метод сущности:

class UserService
{
    public function requestPasswordReset($email)
    {
        $user = $this->users->getByEmail($email);
        $user->requestPasswordReset($this->tokenizer);
        $this->users->save($user);
        $this->dispatcher->dispatch($user->releaseEvents());
    }
}

И сущность производит необходимые ей внутренние проверки и в случае успеха генерирует себе новый токен:

class User
{
    public function requestPasswordReset(PasswordResetTokenizer $tokenizer)
    {
        if ($this->hasValidPasswordResetToken($tokenizer)) {
            throw new \DomainException('Token is already sent.');
        }
        $token = $tokenizer->generate();
        $this->passwordResetToken = $token;
        $this->recordEvent(new PasswordResetRequested($this->id, $this->email, $token));
    }
 
    private function hasValidPasswordResetToken(PasswordResetTokenizer $tokenizer)
    {
        return $this->passwordResetToken !== null && $tokenizer->validate($this->passwordResetToken);
    }
 
    ...
}

Получаем полноценную сущность с инкапсулированной логикой, больше не думая о substr, strpos и прочих низкоуровневых деталях.

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

UPD: Как раз вовремя и в тему появился доклад о DI в Yii2:

P.S. Обсуждение успешно продолжилось и дополнилось новыми примерами. И приглашаю желающих поучаствовать в других рассуждениях об архитектуре.

Комментарии

 

sda

А зачем в контроллер внедрять сервис, почему нельзя его взять напрямую из сервис-локатора Yii::$app->get('userService'); ? Контроллеры же всё равно как правило зависят от фреймворка. Видел подобное в ларавел, наследуют контроллер от базового контроллера фреймворка и при этом внедряют в него зависимости через конструктор. Идею не понял.

Мне только на ум пришло возможное изменение названия userService на что-то другое. Тогда придется поправить контроллеры. Но это же не проблема, во времена Find&Replace.

Ответить

 

Дмитрий Елисеев

Можно как угодно:

$userService = Yii::$app->get('userService');
$userService = Yii::createObject(UserService::class);
$userService = Yii::$container->get('user.service');

Это смотря как настроить локатор или контейнер. А в случае конструктора контроллера можно вообще ничего в экшены не вписывать и UserService не настраивать.

Ответить

 

sda

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

Ответить

 

Андрей

Дим, когда следующий набор на неделю ООП?

Ответить

 

Дмитрий Елисеев

Через месяц-полтора. Надо первые уроки доработать и личный кабинет ученика улучшить. В рассылке обо всём сообщу.

Ответить

 

Алекс

Дмитрий, а на ютубе очередной вебинар хотя бы в планах имеете? По yii Вы прекрасно осветили главные темы, и именно Ваши вебинары позволили мне найти работу в этой области. Но всегда хочется "еще" )))). Стал самостоятельно изучать Symfony. Здорово было бы послушать Ваши объяснения по архитектуре Symfony.

Ответить

 

Дмитрий Елисеев

Да, со следующей недели проведу два вебинара по функциональному программированию, которые подготовил за этот месяц. Уже добавил темы на странице. За лето заготовил кучу черновиков для ещё нескольких скринкастов и статей и есть задумки начать освещать Symfony и Laravel. Ещё многие просят после ООП полноценный многодневный мастер-класс по созданию интернет-магазина провести и что-нибудь про паттерны и антипаттерны. Так что осень у меня будет жаркой.

Ответить

 

Алекс

Ура! Очень порадовали!

Ответить

 

Dmitriy Lanets

по SOLID практике лучше внедрять зависимость через конструктор, тем самым можно гарантировать выполнение контракта (интерфейса) а $userService = Yii::$app->get('userService');
это антиппатерн,

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

2. мы можем использовать данную конструкцию в любом месте а это чревато увеличением связанного кода и плохо поддерживаемого кода

Ответить

 

Анатолий Белов

Спасибо, за хорошую статью. Надеюсь записи вебинаров появятся...

Ответить

 

Дмитрий Елисеев

Первый уже появился.

Ответить

 

jonik

А откуда в UserController взялся класс-родитель?
> parent::__construct($id, $module, $config);

Ответить

 

Дмитрий Елисеев

Из класса Controller фреймворка.

Ответить

 

jonik

А целесообразно инжектить такие модули как user, session и др. из Yii::$app? Спасибо!

Ответить

 

Дмитрий Елисеев

Можно.

Ответить

Оставить комментарий

Войти | Завести аккаунт | Войти через


(никто не увидит)





Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>