Регистрация пользователей и тесты

Моделирование юзкейса запроса регистрации по email. Декомпозиция объектов. Абстрактные типы данных. Проработка сущностей, агрегатов и сервисов через Unit-тесты подходом Test First. Написание и тестирование доменных сервисов.

  • 00:00:36 Команда запроса регистрации
  • 00:01:17 Обработчик команды
  • 00:02:46 Реализация юзкейса
  • 00:05:04 Идентификатор пользователя
  • 00:06:56 Инкремент через секвенцию
  • 00:08:51 Использование UUID идентификаторов
  • 00:12:36 Библиотека генерации UUID
  • 00:13:32 Сортировка по дате
  • 00:17:01 Сохранение пользователя
  • 00:18:25 Проверка уникальности email
  • 00:20:27 Неочевидность кода
  • 00:22:39 Проверки на корректность
  • 00:24:11 Объекты-значения вместо примитивов
  • 00:27:22 Объект-значение Id
  • 00:28:36 Инкапсуляция проверок
  • 00:30:10 Вспомогательные конструкторы
  • 00:30:58 Вспомогательные сервисы
  • 00:31:45 Токен с истекающим временем
  • 00:32:52 Сервис генерации токенов
  • 00:33:57 Самодокументирующийся код
  • 00:35:05 Вызов Unit Of Work
  • 00:38:08 Отправление письма подтверждения
  • 00:39:27 Придумывание методов в тестах
  • 00:40:12 Тест для Email
  • 00:41:19 Тест для Id
  • 00:42:25 Библиотека Assert для проверок
  • 00:44:14 Тест для создания пользователя
  • 00:45:21 Реализация сущности и значений
  • 00:46:54 Интерфейсы сервисов
  • 00:48:38 Написание токенизера через тесты
  • 00:51:41 Класс PasswordHasher
  • 00:54:20 Медленные алгоритмы хэширования
  • 00:56:46 Ускорение хэширования в тестах
  • 00:58:45 Подведение итогов
  • 01:00:24 Отличие Test First от TDD
Скрытый контент (код, слайды, ...) для подписчиков. Открыть →
Дмитрий Елисеев
elisdn.ru
Комментарии (53)
Arunas

спасибо

Ответить
Bondarenko Alexandr

Дмитрий, спасибо! Не планируете ли рассмотреть подход с валидацией всех данных сущности отложено, чтобы, к примеру, дать более информативный ответ пользователю о том, какие данные были введены неверно. К примеру М. Фаулер писал о неком Шаблоне Увеломления ( Notification Pattern ) ( https://martinfowler.com/articles/replaceThrowWithNotification.html, https://martinfowler.com/eaaDev/Notification.html, . В текущем подходе, при модификации данных сущности, исключение будет выброшено при обнаружении первого невалидного аргумента. До остальных проверок не дойдет и пользователь не узнает, что он где-то еще мог допустить ошибку при вводе данных.

Ответить
Алексей

Первичную валидацию (на обязательность и пр.) данных можно сделать в экшене или command через Assert::lazy(), а дальше не вижу ничего страшного выводить по одной ошибки при уже логических ошибках

Ответить
Bondarenko Alexandr

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

Ответить
Алексей

Не является ли использование флашера отдаленной привязкой к Doctrine, ведь не все библиотеки для работы с БД работают по такой схеме? И является ли использование флашера вообще правильным, ведь мы отправляем в БД сразу вообще все изменения, о которых наш Handler даже может и не знать. Мне кажется, что сохранять в БД нужно уже в репозитории, причем весь агрегат вместе с дочерними сущностями (например user, user_profile) и только их и внутри транзакции, а не всё, что накопил EM. Что думаете?

Ответить
Bondarenko Alexandr

Если инфраструктура может предоставить Api именно для таких взаимодействий: $this->users->add($user), $this->flusher->flush(), то тут никуда не деться. В прикладном слое некоторые детали взаимодействия с инфраструктурой знать нормально, но опосредовано. Что касается гибкости, мне кажется, что тут тоже особо нет проблем. Если перейти на стиль взаимодействия с бд, когда при вызове метода $this->users->add($user) данные будут сразу вставляться в бд, можно, к примеру, инжектить заглушку Flusher'a ( но лучше, конечно, хранить код в чистоте и вообще не инжектить, если перешли на другую ORM ).

Ответить
Bondarenko Alexandr

Да и сложно представить ситуацию, когда используешь ORM с UoW и переходишь на более простой вариант ORM.

Ответить
Алексей

Дмитрий, Вы выбрали использовать простые типы данных в командах, преобразуя их в DTO уже в Handler. Я видел еще 2 варианта, кто-то сразу в команду передает DTO, кто-то передает в команду простые типы и в конструкторе делает DTO. Никак не могу понять, есть ли какая-то разница кроме вкусовщины?

Ответить
Bondarenko Alexandr

Вы имеете ввиду VO, не DTO. И тут правильнее сказать, что VO инициализируются в хэндлере, не преобразуются. Вне прикладного слоя инициализировать не норм. Во-первых, внешним слоям программы ( контроллеры, cli-команды ) не следует знать о доменном слое. А VO - это элемент модели, Во-вторых, если HttpController'e вызывает некий Handler, а вы захотите вызывать Handler еще из Cli, вам придется дублировать логику инициализации VO.

Ответить
Алексей

Да-да, я про VO, прошу прощения)) В таком случае можно инициализировать в команде, это разгрузит код хэндлера

Ответить
Bondarenko Alexandr

Считаю, что в прикладной команде тоже не норм инициализировать VO. Это просто объект для переноса данных в прикладной слой.

Ответить
Konstantin

А может быть уже есть библиотека, в которой определены сущности Entity? Email и т.п.

Ответить
Максим (@myks92)

Здравствуйте.

В ваших роликах, при проектировании, сущности всегда встречал повторяющиеся Value Objects: ID, Status, Role, Name, Email.

Каждый раз, при проектировании какой-либо сущности, вы копируйте ID, который является UUID. И таким образом мы получаем копипасту, в тестах и сущностях.

Если в наших проектах Status, Role, Name как-то может поменяться, то ID и Email никогда не меняется. Таким образом его можно вынести в отдельную папку Value Object и использовать от туда. Но вы этого не делаете. Почему?) вместо этого у вас ID на каждую сущность. Можете пояснить по этому вопросу?

И ещё один вопрос «является ли status, role» VO?

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

Да, это всё Value Objects.

Наличие своего Id у каждой сущности избавляет от путаницы при типизации. Если есть метод:

class Project
{
    public function assign(Member\Id $member, Position\Id $position) { ... }
}

то в него никто вместо member->id случайно не передаст position->id или company->id.

Если же будет один общий класс Id или Uuid на всю систему, то может быть путаница.

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

Можно вынести общий абстрактный класс Id и наследовать все User\Id уже от него. Но будут неудобства, если некоторые идентификаторы вдруг понадобится сделать числовыми инкрементными. Тогда придётся делать ещё один отдельный базовый класс.

В данном случае можно безболезненно вынести Email. Но и уже в этом можем заметить, что агрегат User использует его метод isEqualTo, а другим сущностям из другого модуля этот метод может быть не нужен.

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

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

Так что выносить или нет - вопрос не тривиальный.

Ответить
Максим (@myks92)

Теперь понял))) "Шубка выделки не стоит") Благодарю!

Ответить
Дмитрий

А что произойдет если мы в какой то момент захотим изменить алгоритм хэширования пароля? Как мы будем проверять пароль? придется проверять все использованные ранее алгоритмы? или просто пользователи будут вынуждены восстатавливать свои пароли?

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

Если поменяем алгоритм в password_hash, то ничего не сломается, так как password_verify продолжит работать со всеми возможными алгоритмами.

Ответить
Вопросник

А не лучше бы было оставить все же интерфейсы и работать с ними - Tokenizer, PasswordHasher и т.д. а сами реализации вынести в отдельную инфраструктуру?

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

Да, можно. Тогда можно будет включить более строгое правило объявлять все классы как abstract или final.

Ответить
Олег

Очень похоже на ваш доклад с PhpRussia 2019 )

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

Да. Доклад как раз был по коду прошлогоднего проекта на Symfony. И сюда многое оттуда перешло.

Ответить
Андрей

Раньше на сайте был доступен ещё 1 проект?

Ответить
Huang

Кто-нибудь желает добавить к видео субтитры? Потому что я не понимаю по-русски.

Ответить
Юлия Елисеева

Здравствуйте! К некоторым видео уже добавлены субтитры. Например, к 45, 46, 47 и 51 эпизодам.

Ответить
Дмитрий

А если бы мы хешировали пароль по старинке с помощью соли, то нужно было бы ее передавать вместе с $passwordHash в requestJoinByEmail и везде добавлять проверки $salt !== null, там где есть $passwordHash !== null? Или можно как-то более интереснее поступить?

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

Да, тогда можно сделать объект-значение new Password($hash, $salt)

Ответить
kashamamina

а я что-то не понял момент, почему в классе Token мы приводим euid в нижний регистр (хотя он вроде и так всегда в нижнем), но вопрос в том почему в юнит тесте этого класса мы проверяем обычный euid, не приведен в нижний регистр?

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

Регистр проверяется в отдельном тестовом методе:

class CreateTest extends TestCase
{
    ...

    public function testCase(): void
    {
        $value = Uuid::uuid4()->toString();

        $token = new Token(mb_strtoupper($value), new DateTimeImmutable());

        self::assertEquals($value, $token->getValue());
    }

    ...
}
Ответить
Ruslan

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

За урок отдельное спасибо.

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

Как раз недавно добавили v6.

Ответить
Ruslan

Спасибо. Я уже было подумал, что всё заброшено и никто не читает комменты.

Ответить
Дмитрий

А почему UUID передается "извне". Почему бы его в конструкторе не создавать?

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

Чтобы указывать нужный ID в тестах и фикстурах.

Ещё если нужно будет вернуть идентификатор из контроллера, то может понадобиться создавать идентификатор прямо в контроллере и оттуда передавать в команду.

Ответить
Михаил

Дмитрий, а почему

throw new DomainException('User already exists.')

находится в usecase, если это правило доменной сущности? То есть если в системе появится место, где еще раз используется requestJoinByEmail, то про такую проверку разработчик может в теории забыть, хотя эта проверка очевидно должна быть обязательной для всех операций, связанных с запросом на регастрацию по email

Ответить
Михаил

или я что то не так понял?

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

если это правило доменной сущности?

Это не правило самой сущности. Сущность следит только за своими внутренностями. Она не имеет доступа к БД и не знает, есть ли там кто-то ещё.

Ответить
Борис

На классе Email я кайфанул. Спасибо за инсайт

Ответить
Борис

по sender-у на емайл в JoinByEmail/Request/ а не лучше Event сделать ?

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

Да, можно сделать. Особенно если использовать очередь, чтобы отправлять письма в фоне.

Ответить
Борис

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

В контроллере?

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

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

Ответить
Алексей

Благодарю Дмитрия за его труд. И хочу высказать свое мнение по поводу передачи в конструктор класса большого числа переменных. Но ведь можно пойти и другим путем. Просто использовать сеттеры. Иначе зачем их тогда прописывать. И тогда отпадает необходимость все или многие параметры оборачивать в объекты. Лично у меня возникает дискомфорт при создании дополнительных сущностей без которых можно обойтись. А потом для этих сущностей приходится писать конвертеры для доктрины. Это только мое лично мнение, и не критика подхода Дмитрия.

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

Просто использовать сеттеры.

Не просто. С сеттерами объект никак не контролирует инвариант при создании.

Ответить
Voviktemp

Дима, ребята, привет.

Мне кажется такой подход:

$this->users->add($user); #UserRepository
$this->flusher->flush();

Нас сильно привязывает к инфраструктуре.

Допустим у нас есть другой компонент, комментариев. Там будет тоже самое.

$this->comment->add($comment);  #CommentRepository
$this->flusher->flush();

И везде будет то же самое.

Почему бы нам не инкапсулировать необходимость вызова ->flush(); в Отдельный объект что-то вроде:

class FlushFlag
{
    private static bool $needFlush = false;
    private EntityManagerInterface $em;

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

    public static function needFlush(): void
    {
        self::$needFlush = true;
    }

    public function flush(): void
    {
        if (self::needFlush()) {
            $this->em->flush();
        }
    }
}

И потом вызывать его методах ->add(...) в репозиториях:

FlushFlag::needFlush();

где это нужно...

Удалить $this->flusher->flush(); в командах..

Затем в Middleware только в одном месте получить FlushFlag через DI в конструкторе и выполнить:

$this->flushFlag->flush();

Тем самым мы уберем зависимость от инфраструктуры (в данном случае от выбранной ORM) и инкапсулируем ее особенность в той же инфраструктуре в виде Middleware...

И, например, если мы захотим хранить юзеров в файлах, нам нужно будем менять только репозиторий (UserRepository), инфраструктуру, не трогая бизнес логику (не надо удалять везде где используется UserRepository строку $this->flusher->flush(); ) + уберем зависимость от Flusher в наших Command.

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

И потом вызывать его методах ->add(...) в репозиториях

Это сработает только для add() и remove(), но не сработает в командах редактирования, где мы вызываем тольео get().

А чтобы отвязаться от инфраструктуры мы можем выполнять команды в шине CommandBus и уже в ней делать flush().

Ответить
Voviktemp

Спасибо за ответ. Жду урок по CommandBus. Хорошего дня!

Ответить
lambdahhh

Столкнулся с такой проблемой, сделал свой тип поля для доктрины Status как у вас и в методе convertToPHPValue мы возвращаем return !empty($value) ? new Status($value) : null;

Но статусы могут быть не только у User, но и у других сущностей, и они могут иметь как общие статусы, типа active, inactive, так и для разных сущностей специфические

UserStatus active inactive

HouseStatus active inactive send new

WorkStatus active trainee fired

создавать под статусы разных сущностей отдельные типы полей в доктрине показалось too match, в то же время в методе convertToPHPValue у нас нет никакого понимания, какой тип статуса вернуть, какие есть варианты решения этой проблемы?

Ответить
slo_nik

Добрый вечер. По поводу класса Id. В нём используется __toString(). Решил немного побаловаться rector-ом. При проверке класса Id получил такую рекомендацию.

 -class Id
+class Id implements \Stringable
  {
     public function __construct(
     private readonly string $value
----------- end diff -----------

В документации написано следующее

Интерфейс Stringable обозначает класс, реализующий метод __toString(). В отличие от большинства интерфейсов, Stringable неявно присутствует в любом классе, в котором определён магический метод __toString(), хотя он может и должен быть объявлен явно.

Нужно ли следовать этой рекомендации?

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

Там сразу в следующм абзаце:

Его основное значение - разрешить функциям выполнять проверку типа на соответствие типу string|Stringable, чтобы принимать либо строковый примитив, либо объект, который может быть преобразован в строку.

Если в коде будут функции или методы с таким типом параметра, то придётся добавить. А с остальными классами можно не торопиться это делать. Но лучше добавить везде по аналогии с Serializable и подобными интерфейсами.

Ответить
slo_nik

Как раз в классе id есть метод getValue(): string.

Получается надо добавлять.

Как я понял, этот интерфейс обязывает добавлять метод __toString() в класс, дабы избежать проблем?

Stringable неявно присутствует в любом классе

Это надо понимать так, что из-за наличия метода __toString() в классе

Ответить
Зарегистрируйтесь или войдите чтобы оставить комментарий

Или войти через:

Yandex
MailRu
GitHub
Google