Вы находитесь на странице: 1из 144

Архитектура Сложных веб

приложений

Adel F
Архитектура Сложных веб приложений
Adel F
Эта книга предназначена для продажи на http://leanpub.com/acwa_rus

Эта версия была опубликована на 2020-02-17

Это книга с Leanpub book. Leanpub позволяет авторам и издателям участвовать в так
называемом Lean Publishing - процессе, при котором электронная книга становится
доступна читателям ещё до её завершения. Это помогает собрать отзывы и пожелания для
скорейшего улучшения книги. Мы призываем авторов публиковать свои работы как можно
раньше и чаще, постепенно улучшая качество и объём материала. Тем более, что с нашими
удобными инструментами этот процесс превращается в удовольствие.

© 2020 Adel F
Оглавление

Предисловие . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1

Внедрение зависимостей . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
Принцип Единственной Ответственности . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
Dependency Injection . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
Наследование . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
Пример с загрузкой картинок . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
Расширение интерфейсов . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
Трейты . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36
Статические методы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
Пара слов в конце главы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 44

Безболезненный рефакторинг . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45
“Статическая” типизация . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45
Шаблоны . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
Поля моделей . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49

Слой Приложения . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 52
Передача данных запроса . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 54
Работа с базой данных . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 60
Сервисные классы или классы команд? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63

Обработка ошибок . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65
Исключения (Exceptions) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69
Базовый класс исключения . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 72
Глобальный обработчик . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 74
Проверяемые и непроверяемые исключения . . . . . . . . . . . . . . . . . . . . . . . . . . 76
Пара слов в конце главы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 82

Валидация . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
Валидация связанная с базой данных . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
Два уровня валидации . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 86
Валидация аннотациями . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 88
Value objects . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 91
Объект-значение как композиция других значений . . . . . . . . . . . . . . . . . . . . . 93
ОГЛАВЛЕНИЕ

Объекты-значения не для валидации . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 95


Пара слов в конце главы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 98

События . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99
Database transactions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 100
Очереди . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 102
События . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 104
Использование событий Eloquent . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 106
Сущности как поля классов-событий . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 108

Unit-тестирование . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
Первые шаги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
Тестирование классов с состоянием . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 116
Тестирование классов с зависимостями . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 119
Типы тестов ПО . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 125
Тестирование в Laravel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 125
Unit-тестирование Слоя Приложения . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 132
Стратегия тестирования приложения . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 139
Предисловие
«Разработка ПО — это Искусство Компромисса», wiki.c2.com

Я видел множество проектов, выросших из простой «MVC» структуры.


Часто разработчики объясняют шаблон MVC так: «View (представление) — это HTML-
шаблоны, Model (модель) — это класс Active Record (например, Eloquent) и один контроллер,
чтобы править всеми!».
Хорошо, не один, но обычно вся дополнительная логика реализуется в классах-контроллерах.
Контроллеры часто содержат огромное количество кода, реализующего разную логику (за-
грузку картинок, вызовы внешних API, работу с базой, и т.д.).
Иногда некоторая логика выносится в «базовые» контроллеры, просто чтобы уменьшить
количество дублированного кода.
Одни и те же проблемы возникают как в средних проектах, так и в огромных порталах с
миллионами посетителей в день.
Шаблон MVC был изобретен в 1970-х годах для графического интерфейса пользователя (GUI).
Простые CRUD (Create, Read, Update and Delete) веб-приложения, в сущности, являются
просто интерфейсом к базе данных, поэтому пере-изобретённый шаблон «MVC для веб» стал
очень популярен.
Однако, веб-приложения очень быстро перестают быть только лишь интерфейсом к базе
данных.
Что говорит шаблон MVC про работу с файлами (изображения, музыка, видео), внешними
API, кэшэм?
Что если у сущностей поведение отличается от простого Создать-Изменить-Удалить?
Ответ простой: Модель в терминах MVC — это не только класс Active Record. Она содержит
всю логику работы с данными приложения.
Больше 90% кода современного сложного веб-приложения — это Модель.
Создатель фреймворка Symfony Fabien Potencier как-то написал: «Я не люблю MVC, потому
что это не то, как работает веб. Symfony2 — это HTTP фреймворк; это Request/Response
фреймворк»
Я могу сказать тоже самое про Laravel и многие другие фреймворки.
Фреймворки, такие как Laravel, содержат кучу RAD-возможностей, которые позволяют раз-
рабатывать приложения очень быстро, срезая некоторые углы.
Они весьма полезны на стадии приложения «интерфейс для работы с базой данных», но
часто становятся источником боли по мере развития.
Я делал много рефакторингов просто, чтобы избавить приложения от таких возможностей.
Вся эта авто-магия и «удобные» валидации в стиле «быстро сходить в базу данных и
проверить нет ли такого email в таблице» хороши, но разработчик должен полностью
понимать как они работают и когда лучше от них отказаться.
Предисловие 2

С другой стороны, советы от крутых разработчиков в стиле «ваш код на 100% должен быть
покрыт юнит-тестами», «не используйте статические методы» и «зависеть нужно только от
абстракций» быстро становятся своеобразными карго-культами для некоторых проектов.
Слепое следование им приводит к огромным потерям во времени.
Я видел интерфейс IUser с более чем 50 свойствами (полями) и класс User: IUser со всеми
этими свойствами скопированными туда (это был C# проект).
Я видел огромное количество абстракций просто для того, чтобы достичь требуемого про-
цента покрытия юнит-тестами.
Некоторые эти советы могут быть неверно истолкованы, некоторые применимы только в
конкретной ситуации, некоторые имеют важные исключения.
Разработчик должен понимать какую проблему решает шаблон или совет и при каких
условиях он применим.
Проекты бывают разные.
К некоторым хорошо придутся определённые шаблоны и практики. Для других они будут
излишни.
Один умный человек сказал: «Разработка ПО — это всегда компромисс между краткосрочной
и долгосрочной продуктивностью».
Если мне нужен один функционал в другом месте проекта, то я могу скопировать его туда.
Это будет очень продуктивно, но начнет очень быстро доставлять проблемы.
Почти каждое решение про рефакторинг или применение какого-либо шаблона представ-
ляет собой ту же дилемму.
Иногда, решение не применять шаблон, который сделает код «лучше» будет более пра-
вильным, поскольку полезный эффект от него будет меньше, чем время затраченное на его
реализацию.
Балансирование между шаблонами, практиками, техниками, технологиями и выбор наибо-
лее подходящей комбинации для конкретного проекта является наиболее важным умением
разработчика/архитектора.
В этой книге я покажу наиболее частые проблемы, возникающие в процессе роста проекта
и как разработчики обычно решают их.
Причины и условия данных решений весьма важная часть книги. Я не хочу создавать новые
карго-культы.
Я должен предупредить:

• Эта книга не для начинающих. Чтобы понимать описываемые проблемы вы должны


поучаствовать хотя бы в одном проекте. В одиночку или в команде.
• Эта книга не пособие. Много шаблонов будут описаны поверхностно, с целью просто
познакомить читателя с ними. Несколько полезных ссылок вас ожидает в конце книги.
• Примеры этой книги никогда не будут идеальными. Я могу назвать какой-то код
«корректным» и найти кучу ошибок в нем в следующей главе.
Внедрение зависимостей
Принцип Единственной Ответственности
Вы, возможно, слышали о Принципе Единственной Ответственности (Single Responsibility
Principle, SRP).
Одно из его определений: «Каждый модуль, класс или функция должны иметь ответствен-
ность над единой частью функционала».
Много разработчиков упрощают его до «класс должно делать что-то одно», но это определе-
ние не самое удачное.
Роберт Мартин предложил другое определение, где заменил слово «ответственность» на
«причину для изменения»: «Класс должен иметь лишь одну причину для изменения».
«Причина для изменения» более удобный термин и мы можем начать рассуждать об
архитектуре используя его.
Почти все шаблоны и практики имеют своей целью лучше подготовить приложение к изме-
нениям, но приложения бывают разные, с разными требованиями и разными возможными
изменениями.
Я часто встречаю заявления: «Если вы располагаете весь ваш код в контроллерах, вы нару-
шаете SRP!».
Представим простое приложение, представляющее из себя простые списки сущностей с
возможностью создавать их, изменять и удалять, так называемое CRUD-приложение.
Все, что оно делает — лишь предоставляет интерфейс к строчкам в базе данных, без какой-
либо дополнительной логики.

1 public function store(Request $request)


2 {
3 $this->validate($request, [
4 'email' => 'required|email',
5 'name' => 'required',
6 ]);
7
8 $user = User::create($request->all());
9
10 if (!$user) {
11 return redirect()->back()->withMessage('...');
12 }
13
14 return redirect()->route('users');
15 }
Внедрение зависимостей 4

Какие изменения возможны в таком приложении?


Добавление/удаление полей или сущностей, косметические изменения интерфейса… слож-
но представить что-то большее.
Я не думаю, что такой код нарушает SRP. Почти.
Теоретически, может поменяться алгоритм редиректов, но это мелочи, я не вижу смысла
рефакторить данный код.
Рассмотрим новое требование к этому приложению из прошлой главы — загрузка аватара
пользователя и посылка email после регистрации:

1 public function store(Request $request)


2 {
3 $this->validate($request, [
4 'email' => 'required|email',
5 'name' => 'required',
6 'avatar' => 'required|image',
7 ]);
8
9 $avatarFileName = ...;
10 \Storage::disk('s3')->put(
11 $avatarFileName, $request->file('avatar'));
12
13 $user = new User($request->except('avatar'));
14 $user->avatarUrl = $avatarFileName;
15
16 if (!$user->save()) {
17 return redirect()->back()->withMessage('...');
18 }
19
20 \Email::send($user->email, 'Hi email');
21
22 return redirect()->route('users');
23 }

Метод store содержит уже несколько ответственностей. Напомню, что кроме него есть ещё и
метод update, меняющий сущность, и код, загружающий аватары, должен быть скопирован
туда.
Любое изменение в алгоритме загрузки аватаров уже затронет как минимум два места в коде
приложения.
Часто бывает сложно уловить момент, когда стоит начать рефакторинг.
Если эти изменения коснулись лишь сущности пользователь, то, вероятно, этот момент ещё
не настал.
Однако, наверняка, загрузка картинок понадобится и в других частях приложения.
Внедрение зависимостей 5

Здесь я бы хотел остановиться и поговорить о двух важных базовых характеристиках


программного кода — связность (cohesion) и связанность (coupling).
Связность — это степень того, как методы одного класса (или части другой программной
единицы: функции, модуля) сконцентрированы на главной цели этого класса.
Близко к SRP.
Связанность между двумя классами (функциями, модулями) — это степень того, как много
они знают друг о друге.
Сильная связанность означает, что какие-то знания принадлежат нескольким частям кода и
каждое изменения может вызвать каскад изменений в других частях приложения.

Текущая ситуация с методом store является хорошей ситуацией потери качества кода.
Он начинает реализовывать несколько ответственностей — связность падает.
Загрузка картинок реализована в нескольких местах приложения — связанность растет.
Самое время вынести загрузку изображений в свой класс.
Первая попытка:

1 final class ImageUploader


2 {
3 public function uploadAvatar(User $user, UploadedFile $file)
4 {
5 $avatarFileName = ...;
6 \Storage::disk('s3')->put($avatarFileName, $file);
7
8 $user->avatarUrl = $avatarFileName;
9 }
10 }

Я привёл этот пример, потому что я часто встречаю такое вынесение функционала, захваты-
вающее слишком многое.
В этом случае класс ImageUploader кроме своей главной обязанности (загрузки изображе-
ний) присваивает и значение полю класса User.
Что в этом плохого?
Класс ImageUploader знает про класс User и его свойство avatarUrl.
Такие знания часто меняются. Простейший случай — загрузка изображений для другой
сущности.
Чтобы реализовать это изменение, придётся изменять класс ImageUploader, а также методы
класса UserController.
Это и есть пример, когда одно маленькое изменение порождает целый каскад изменений в
классах, не связанных с изначальным изменением.
Попробуем реализовать ImageUploader с высокой связностью:
Внедрение зависимостей 6

1 final class ImageUploader


2 {
3 public function upload(string $fileName, UploadedFile $file)
4 {
5 \Storage::disk('s3')->put($fileName, $file);
6 }
7 }

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

Dependency Injection
Класс ImageUploader создан, но как использовать его в методе UserController::store?

1 $imageUploader = new ImageUploader();


2 $imageUploader->upload(...);

Или просто сделать метод upload статическим и вызывать его так:

1 ImageUploader::upload(...);

Это было просто, но теперь метод store имеет жесткую зависимость от класса ImageUploader.
Представим, что таких зависимых методов в приложении стало много и команда решила
использовать другое хранилище для изображений.
Но не для всех, а лишь некоторых мест их загрузки. Как разработчики будут реализовывать
это изменение?
Создадут класс AnotherImageUploader и заменят вызовы класса ImageUploader на вызовы
AnotherImageUploader во всех нужных местах.
В крупных проектах такие изменения, затрагивающие большое количество классов, крайне
нежелательны — они часто приводят к ошибкам.
Изменение способа хранения изображений не должно влиять на код, работающий с сущно-
стями.

Приложение с такими жёсткими связями выглядит как металлическая сетка.


Очень сложно взять, например, класс ImageUploader, и использовать его в другом приложе-
нии. Или написать для него юнит-тесты.
Внедрение зависимостей 7

Вместо жёстких зависимостей на классы техника Внедрения Зависимостей (Dependency


Injection, DI) предлагает классам просить нужные зависимости.

1 final class ImageUploader


2 {
3 /** @var Storage */
4 private $storage;
5
6 /** @var ThumbCreator */
7 private $thumbCreator;
8
9 public function __construct(
10 Storage $storage, ThumbCreator $thumbCreator)
11 {
12 $this->storage = $storage;
13 $this->thumbCreator = $thumbCreator;
14 }
15
16 public function upload(...)
17 {
18 $this->thumbCreator->...
19 $this->storage->...
20 }
21 }

Laravel и другие фреймворки содержат контейнер зависимостей (DI-контейнер) — специаль-


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

1 public function store(


2 Request $request, ImageUploader $imageUploader)
3 {
4 //...
5 $avatarFileName = ...;
6 $imageUploader->upload(
7 $avatarFileName, $request->file('avatar')
8 );
9 //...
10 }

В Laravel контейнер зависимостей умеет внедрять зависимости прямо в аргументы методов


контроллеров.
Внедрение зависимостей 8

Зависимости стали менее жёсткими. Классы не создают другие классы и не требуют стати-
ческих методов.
Однако, метод store и класс ImageUploader зависят от конкретных классов.
Принцип Инверсии Зависимостей (буква D в SOLID) гласит:

Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба


типа модулей должны зависеть от абстракций. Абстракции не должны зависеть от
деталей. Детали должны зависеть от абстракций.

Требование абстракции в сообществах языка PHP и остальных, где есть понятие интерфейс,
трактуется однозначно: зависимости должны быть только от интерфейсов, а не от классов.
Я буду часто повторять, что проекты бывают разные. Для какого-нибудь проекта будет
нормальным сразу заводить интерфейс, и потом класс, его реализующий. Для других же
это будет не совсем оправданным.
Вы, вероятно, слышали про технику Test-driven Development (TDD).
Грубо говоря, она предлагает писать тесты одновременно с кодом, который этими тестами
проверяется.
Попробуем рассмотреть написание класса ImageUploader одновременно с тестами.
Классы Storage и ThumbCreator, необходимые для работы ImageUploader ещё не реализо-
ваны, но это не мешает реализовать его и проверить на соответствия требованиям.
Можно создать интерфейсы Storage и ThumbCreator с необходимыми методами и начать
работу.
Для тестирования эти интерфейсы будут «реализованы» с помощью специальных объектов-
моков (мы поговорим про них в главе про тестирование).

1 interface Storage
2 {
3 // методы...
4 }
5
6 interface ThumbCreator
7 {
8 // методы...
9 }
10
11 final class ImageUploader
12 {
13 /** @var Storage */
14 private $storage;
15
16 /** @var ThumbCreator */
17 private $thumbCreator;
Внедрение зависимостей 9

18
19 public function __construct(
20 Storage $storage, ThumbCreator $thumbCreator)
21 {
22 $this->storage = $storage;
23 $this->thumbCreator = $thumbCreator;
24 }
25
26 public function upload(...)
27 {
28 $this->thumbCreator->...
29 $this->storage->...
30 }
31 }
32
33 class ImageUploaderTest extends TestCase
34 {
35 public function testSomething()
36 {
37 $storageMock = \Mockery::mock(Storage::class);
38 $thumbCreatorMock = \Mockery::mock(ThumbCreator::class);
39
40 $imageUploader = new ImageUploader(
41 $storageMock, $thumbCreatorMock
42 );
43 $imageUploader->upload(...
44 }
45 }

Класс ImageUploader все ещё не может быть использован в приложении, но он уже написан
и протестирован.
Позже, когда будут написаны классы, реализующие эти интерфейсы, можно будет настроить
контейнер зависимостей в Laravel, например, так:

1 $this->app->bind(Storage::class, S3Storage::class);
2 $this->app->bind(ThumbCreator::class, ImagickThumbCreator::class);

После этого класс ImageUploader может быть использован в приложении.


Когда контейнер зависимостей создаёт его экземпляр, он сканирует его зависимости в кон-
структоре (Storage и ThumbCreator), находит нужные реализации (S3Storage и ImagickThumbCreator)
исходя из своей конфигурации и подставляет их создаваемому объекту.
Если эти реализации тоже имеют зависимости, то они также будут внедрены.
Внедрение зависимостей 10

Техника TDD хорошо зарекомендовала себя во многих проектах, где она стала стандартом.
Мне тоже нравится этот подход.
Разрабатывая функционал одновременно тестируя его, получаешь мало с чем сравнимое
удовольствие, ощущая насколько продуктивно получается работать.
Однако, я крайне редко вижу его использование на проектах.
Оно требует некоего уровня архитектурного мышления, поскольку необходимо заранее
знать какие интерфейсы будут нужны, заранее декомпозировать приложение.
Обычно на проектах все намного проще и прозаичнее.
Сначала пишется класс ImageUploader, который содержит всю логику по хранению и
созданию миниатюр.
Потом, возможно, некоторый функционал будет вынесен в классы Storage и ThumbCreator.
Интерфейсы не используются.
Иногда на проекте случается примечательное событие — один из разработчиков читает
про Принцип Инверсии Зависимостей и решает, что у проекта серьезные проблемы с
архитектурой.
Классы не зависят от абстракций! Нужно немедленно создать интерфейс к каждому классу!
Но имена ImageUploader, Storage и ThumbCreator уже заняты классами.
Как правило, в данной ситуации выбирается один из двух кошмарных путей создания
интерфейсов.
Первый это создание пространства имён *\Contracts и создание всех интерфейсов там.
Пример из исходников Laravel:

1 namespace Illuminate\Contracts\Cache;
2
3 interface Repository
4 {
5 //...
6 }
7
8
9 namespace Illuminate\Contracts\Config;
10
11 interface Repository
12 {
13 //...
14 }
15
16
17 namespace Illuminate\Cache;
18
19 use Illuminate\Contracts\Cache\Repository as CacheContract;
20
Внедрение зависимостей 11

21 class Repository implements CacheContract


22 {
23 //...
24 }
25
26 namespace Illuminate\Config;
27
28 use ArrayAccess;
29 use Illuminate\Contracts\Config\Repository as ConfigContract;
30
31 class Repository implements ArrayAccess, ConfigContract
32 {
33 //...
34 }

Тут совершён двойной грех: использование одного имени для интерфейса и класса, а также
одного имени для абсолютно разных программных объектов.
Механизм пространств имён даёт возможность для таких обходных маневров.
Как можно увидеть, даже в коде класса приходится использовать алиасы CacheContract и
ConfigContract.
Любой Laravel проект имеет 4 программных объекта с именем Repository.
Класс, который использует и кеширование и конфигурацию, выглядит так (если не исполь-
зовать алиасы):

1 use Illuminate\Contracts\Cache\Repository;
2
3 class SomeClassWhoWantsConfigAndCache
4 {
5 /** @var Repository */
6 private $cache;
7
8 /** @var \Illuminate\Contracts\Config\Repository */
9 private $config;
10
11 public function __construct(Repository $cache,
12 \Illuminate\Contracts\Config\Repository $config)
13 {
14 $this->cache = $cache;
15 $this->config = $config;
16 }
17 }

Только имена переменных помогают понять какая конкретно зависимость была использова-
Внедрение зависимостей 12

на.
Однако, имена Laravel-фасадов для этих интерфейсов выглядят весьма натурально: Config и
Cache.
С такими именами для интерфейсов, классы их использующие выглядели бы намного
лучше.
Второй вариант: использование суффикса *Interface, например, создать интерфейс StorageInterface.
Таким образом, имея класс Storage реализующий интерфейс StorageInterface, мы постули-
руем, что у нас есть интерфейс и его «главная» реализация. Все остальные реализации этого
интерфейса выглядят вторичными.
Само существование интерфейса StorageInterface выглядит искусственным: он был создан,
чтобы код удовлетворял каким-то принципам, или просто для юнит-тестирования.
Это явление встречается и в других языках.
В C# принят префикс I*. Интерфейс IList и класс List, например.
В Java не приняты префиксы и суффиксы, но там часто случается такое:

1 class StorageImpl implements Storage

Это тоже ситуация с интерфейсом и его реализацией по умолчанию.


Возможны две ситуации с интерфейсами:
1. Есть интерфейс и несколько возможных реализаций. В этом случае интерфейс стоит
назвать естественным именем, а в именах реализаций использовать префиксы, которые
описывают эту реализацию.

1 interface Storage{}
2
3 class S3Storage implements Storage{}
4
5 class FileStorage implements Storage{}

2. Есть интерфейс и одна реализация.


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

1 final class ImageUploader


2 {
3 /** @var Storage */
4 private $storage;
5
6 /** @var ThumbCreator */
7 private $thumbCreator;
8
9 public function __construct(Storage $storage,
10 ThumbCreator $thumbCreator)
11 {
12 $this->storage = $storage;
13 $this->thumbCreator = $thumbCreator;
14 }
15
16 public function upload(...)
17 {
18 $this->thumbCreator->...
19 $this->storage->...
20 }
21 }

Он зависит от неких Storage и ThumbCreator и использует только их публичные методы.


Разработчику, который работает в данный момент с этим кодом, всё равно классы это или
интерфейсы.
Контейнер внедрения зависимостей, убрав необходимость создавать объекты зависимостей,
подарил нам супер-абстракцию: классам совсем неважно знать от чего именно он зависит -
от интерфейса или класса.
В любой момент, при изменении условий, класс может быть сконвертирован в интерфейс, а
весь его функционал перенесен в новый класс, реализующий этот интерфейс (S3Storage).
Это изменение, наряду с конфигурацией контейнера зависимостей будут единственными
на проекте.
Весь остальной код, использовавший класс, будет работать как прежде, только теперь он
зависит от интерфейса.
Используя суффиксы *Interface или другие отличительные признаки интерфейсов, мы
лишаем себя этой абстракции, а также загрязняем наш код.
Внедрение зависимостей 14

1 class Foo
2 {
3 public function __construct(
4 LoggerInterface $logger, StorageInterface $storage) {...}
5 }
6
7 class Foo
8 {
9 public function __construct(
10 Logger $logger, Storage $storage) {...}
11 }

Просто сравните эти два конструктора.


Разумеется, код публичной библиотеки или пакета должен быть максимально гибким.
Поэтому, все зависимости в них должны быть на интерфейсы (или абстрактные классы).
Однако, в обычном проекте зависимости на реальные классы — абсолютно нормальная
практика.

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

1 interface Queue
2 {
3 public function push($job, $data = '', $queue = null);
4 }
5
6 class RedisQueue implements Queue
7 {
8 public function push($job, $data = '', $queue = null)
9 {
10 // реализация
11 }
12 }

Разумеется, классы проекта используют зависимость на интерфейс Queue, а менеджер


зависимостей лишь подставляет RedisQueue.
Внедрение зависимостей 15

Когда возникла необходимость логировать все задачи в очереди, был создан класс OurRedisQueue,
отнаследованный от RedisQueue.

1 class OurRedisQueue extends RedisQueue


2 {
3 public function push($job, $data = '', $queue = null)
4 {
5 // логирование
6
7 return parent::push($job, $data, $queue);
8 }
9 }

Задача решена: все вызовы метода push логируются.


Некоторое времени спустя, в новой версии Laravel, в интерфейсе Queue добавился метод
pushOn, который представляет собой тот же push, но с другой очередностью параметров.
Класс RedisQueue получил очевидную реализацию:

1 interface Queue
2 {
3 public function push($job, $data = '', $queue = null);
4 public function pushOn($queue, $job, $data = '');
5 }
6
7 class RedisQueue implements Queue
8 {
9 public function push($job, $data = '', $queue = null)
10 {
11 // реализация
12 }
13
14 public function pushOn($queue, $job, $data = '')
15 {
16 return $this->push($job, $data, $queue);
17 }
18 }

Поскольку класс OurRedisQueue отнаследован от RedisQueue, никаких изменений в проекте


при обновлении версии фреймворка не понадобилось.
Всё работает как прежде и команда начала использовать новый метод pushOn.
В новом обновлении команда Laravel могла сделать небольшой рефакторинг:
Внедрение зависимостей 16

1 class RedisQueue implements Queue


2 {
3 public function push($job, $data = '', $queue = null)
4 {
5 return $this->innerPush(...);
6 }
7
8 public function pushOn($queue, $job, $data = '')
9 {
10 return $this->innerPush(...);
11 }
12
13 public function innerPush(...)
14 {
15 // реализация
16 }
17 }

Рефакторинг абсолютно естественный и не меняет поведения класса, однако через некоторое


время после обновления команда замечает, что логирование помещения сообщений в оче-
редь работает не всегда.
Легко предположить, что логирование метода push работает, а pushOn перестало.
Когда наследуются от неабстрактного класса, то у этого класса на высоком уровне образуются
две ответственности: перед собственными клиентами и перед наследниками, которые тоже
используют его функционал.
Вторая ответственность не очень явная и довольно легко сделать ошибку, которая приведёт
к сложным и трудноуловимым багам.
Даже на таком простом примере с логированием это привело к багу, который было бы не так
просто найти.
Чтобы избежать подобных осложнений, я в своих проектах все неабстрактные классы
помечаю как final, запрещая наследование от них.
Шаблон для создания нового класса в моей среде разработки содержит ключевые слова ‘final
class’ вместо просто ‘class’.
Финальные классы имеют ответственность только перед своими клиентами и её гораздо
проще контролировать.
Кстати, дизайнеры языка программирования Kotlin, судя по всему, думают также и решили
сделать классы в своём языке финальными по умолчанию.
Чтобы сделать наследование возможным необходимо использовать ключевое слово ‘open’
или ‘abstract’:
Внедрение зависимостей 17

1 open class Foo {}

Мне нравится это.


Эта концепция — «final or abstract» — не полностью устраняет опасность наследования
реализации.
Абстрактный класс с protected-методами и его наследники могут попасть в такую же
ситуацию, как описанную ранее с очередями.
Каждое использование ключевого слова protected создаёт неявную связь между классами
предка и наследника.
Изменения в классе могут породить баги в наследниках.
Механизм внедрения зависимостей предоставляет возможность просто просить нужный
функционал, без необходимости наследоваться.
Задача логирования сообщений в очереди может быть решена с помощью шаблона Декора-
тор:

1 final class LoggingQueue implements Queue


2 {
3 /** @var Queue */
4 private $baseQueue;
5
6 /** @var Logger */
7 private $logger;
8
9 public function __construct(Queue $baseQueue, Logger $logger)
10 {
11 $this->baseQueue = $baseQueue;
12 $this->logger = $logger;
13 }
14
15 public function push($job, $data = '', $queue = null)
16 {
17 $this->logger->log(...);
18
19 return $this->baseQueue->push($job, $data, $queue);
20 }
21 }
22
23 // конфигурация контейнера зависимостей
24 // в сервис провайдере
25 $this->app->bind(Queue::class, LoggingQueue::class);
26
27 $this->app->when(LoggingQueue::class)
Внедрение зависимостей 18

28 ->needs(Queue::class)
29 ->give(RedisQueue::class);

Предупреждение: этот код не будет работать в реальном Laravel-окружении, поскольку эти


классы имеют более сложную процедуру инициации.
Контейнер с такой конфигурацией будет подставлять экземпляр LoggingQueue каждому, кто
просит экземпляр Queue.
Экземпляры же класса LoggingQueue будут получать экземпляр RedisQueue и будут предо-
ставлять такой же функционал.
После обновления Laravel с новым методом pushOn появится ошибка, что класс LoggingQueue
не реализует все методы интерфейса Queue.
Команда может решить как именно логировать этот метод и нужно ли.
Еще одним плюсом данного подхода является то, что конструктор классов полностью под
контролем.
В варианте с наследованием приходится вызывать parent::__construct и передавать туда
все нужные параметры.
Это станет дополнительной, совершенно ненужной связью между двумя классами.
Класс декоратора же не имеет никаких неявных связей с декорируемым классом и позволяет
избежать целого ряда проблем в будущем.

Пример с загрузкой картинок


Вернемся к примеру с загрузкой картинок с предыдущей главы.
Класс ImageUploader был вынесен из контроллера чтобы реализовать логику загрузки
изображений.
Требования к этому классу:

• загружаемая картинка должна быть проверена на неприемлемый контент


• если проверка пройдена, картинка должна быть загружена в определенную папку
• если нет, то пользователь, который загрузил эту картинку, должен быть заблокирован
после нескольких попыток.
Внедрение зависимостей 19

1 final class ImageUploader


2 {
3 /** @var GoogleVisionClient */
4 private $googleVision;
5
6 /** @var FileSystemManager */
7 private $fileSystemManager;
8
9 public function __construct(
10 GoogleVisionClient $googleVision,
11 FileSystemManager $fileSystemManager)
12 {
13 $this->googleVision = $googleVision;
14 $this->fileSystemManager = $fileSystemManager;
15 }
16
17 /**
18 * @param UploadedFile $file
19 * @param string $folder
20 * @param bool $dontBan
21 * @param bool $weakerRules
22 * @param int $banThreshold
23 * @return bool|string
24 */
25 public function upload(
26 UploadedFile $file,
27 string $folder,
28 bool $dontBan = false,
29 bool $weakerRules = false,
30 int $banThreshold = 5)
31 {
32 $fileContent = $file->getContents();
33
34 // Проверки используя $this->googleVision,
35 // $weakerRules и $fileContent
36
37 if (check failed)
38 if (!$dontBan) {
39 if (\RateLimiter::..., $banThreshold)) {
40 $this->banUser(\Auth::user());
41 }
42 }
43
Внедрение зависимостей 20

44 return false;
45 }
46
47 $fileName = $folder . 'some_unique_file_name.jpg';
48
49 $this->fileSystemManager
50 ->disk('...')
51 ->put($fileName, $fileContent);
52
53 return $fileName;
54 }
55
56 private function banUser(User $user)
57 {
58 $user->banned = true;
59 $user->save();
60 }
61 }

Начальный рефакторинг
Простая ответственность за загрузку картинок разрослась и стала содержать несколько
других ответственностей.
Этот класс явно нуждается в рефакторинге.
Если представить, что класс ImageUploader будет вызываться из консоли, то Auth::user()
будет возвращать null, поэтому должна быть добавлена соответствующая проверка, но го-
раздо удобнее и гибче просто просить в этом методе объект пользователя (User $uploadedBy)
потому, что:

1. В этом случае можно быть уверенным, что в этой переменной будет не-null значение.
2. Каждый, кто вызывает этот класс, может сам решить какой объект пользователя ему
передать. Это не всегда Auth::user().

Функционал блокировки пользователя может понадобиться где-то еще.


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

1 final class BanUserCommand


2 {
3 public function banUser(User $user)
4 {
5 $user->banned = true;
6 $user->save();
7 }
8 }

Это действие часто встречает мощное противодействие со стороны других разра-


ботчиков. «Зачем делать целый класс ради двух строк?».
«Теперь будет трудно читать код, ведь надо будет каждый раз искать этот новый
класс в редакторе, чтобы посмотреть как все сделано».
В книге потихоньку будут даваться частичные причины такого выноса логики
в классы. Здесь же я могу лишь написать, что в современных IDE классы со-
здаются за секунды, навигация осуществляется одним кликом, а название класса
BanUserCommand быстро позволяет понять, что он делает, без заглядывания
внутрь.

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


зить неподобающий контент».
Параметр $banThreshold был добавлен в попытке добавить гибкости классу. Как часто
случается, эта гибкость никому не оказалась нужной.
Стандартное значение 5 всех устраивало. Проще это вынести в константу. Если в будущем
эта гибкость понадобится, можно будет это добавить через конфигурацию или параметры
фабрики.

1 final class WrongImageUploadsListener


2 {
3 const BAN_THRESHOLD = 5;
4
5 /** @var BanUserCommand */
6 private $banUserCommand;
7
8 /** @var RateLimiter */
9 private $rateLimiter;
10
11 public function __construct(
12 BanUserCommand $banUserCommand,
13 RateLimiter $rateLimiter)
14 {
15 $this->banUserCommand = $banUserCommand;
16 $this->rateLimiter = $rateLimiter;
Внедрение зависимостей 22

17 }
18
19 public function handle(User $user)
20 {
21 $rateLimiterResult = $this->rateLimiter
22 ->tooManyAttempts(
23 'user_wrong_image_uploads_' . $user->id,
24 self::BAN_THRESHOLD
25 );
26
27 if ($rateLimiterResult) {
28 $this->banUserCommand->banUser($user);
29 return false;
30 }
31 }
32 }

Реакция системы на загрузку неподобающего контента может поменяться в будущем, но эти


изменения коснутся только этого класса.
Эта локальность изменений, когда для изменения одной логики не надо копаться в тонне
другой, крайне важна для больших проектов.
Следующая ответственность, которую надо убрать, это «проверка контента картинок»:

1 final class ImageGuard


2 {
3 /** @var GoogleVisionClient */
4 private $googleVision;
5
6 public function __construct(
7 GoogleVisionClient $googleVision)
8 {
9 $this->googleVision = $googleVision;
10 }
11
12 /**
13 * @param string $imageContent
14 * @param bool $weakerRules
15 * @return bool true if content is correct
16 */
17 public function check(
18 string $imageContent,
19 bool $weakerRules): bool
Внедрение зависимостей 23

20 {
21 // Проверки используя $this->googleVision,
22 // $weakerRules и $fileContent
23 }
24 }

1 final class ImageUploader


2 {
3 /** @var ImageGuard */
4 private $imageGuard;
5
6 /** @var FileSystemManager */
7 private $fileSystemManager;
8
9 /** @var WrongImageUploadsListener */
10 private $listener;
11
12 public function __construct(
13 ImageGuard $imageGuard,
14 FileSystemManager $fileSystemManager,
15 WrongImageUploadsListener $listener)
16 {
17 $this->imageGuard = $imageGuard;
18 $this->fileSystemManager = $fileSystemManager;
19 $this->listener = $listener;
20 }
21
22 /**
23 * @param UploadedFile $file
24 * @param User $uploadedBy
25 * @param string $folder
26 * @param bool $dontBan
27 * @param bool $weakerRules
28 * @return bool|string
29 */
30 public function upload(
31 UploadedFile $file,
32 User $uploadedBy,
33 string $folder,
34 bool $dontBan = false,
35 bool $weakerRules = false)
36 {
Внедрение зависимостей 24

37 $fileContent = $file->getContents();
38
39 if (!$this->imageGuard->check($fileContent, $weakerRules)) {
40 if (!$dontBan) {
41 $this->listener->handle($uploadedBy);
42 }
43
44 return false;
45 }
46
47 $fileName = $folder . 'some_unique_file_name.jpg';
48
49 $this->fileSystemManager
50 ->disk('...')
51 ->put($fileName, $fileContent);
52
53 return $fileName;
54 }
55 }

Класс ImageUploader потерял несколько ответственностей и весьма рад этому.


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

1 $imageUploader->upload($file, $user, 'gallery', false, true);

Булевы параметры всегда выглядят уродливо и повышают когнитивную нагрузку на чтение


кода.
Я попробую удалить их двумя разными путями:

• ООП путем;
• путём конфигурации.

ООП путь
Я собираюсь использовать полиморфизм, поэтому надо создать интерфейсы.
Внедрение зависимостей 25

1 interface ImageChecker
2 {
3 public function check(string $imageContent): bool;
4 }
5
6 final class StrictImageChecker implements ImageChecker
7 {
8 /** @var ImageGuard */
9 private $imageGuard;
10
11 public function __construct(
12 ImageGuard $imageGuard)
13 {
14 $this->imageGuard = $imageGuard;
15 }
16
17 public function check(string $imageContent): bool
18 {
19 return $this->imageGuard
20 ->check($imageContent, false);
21 }
22 }
23
24 final class WeakImageChecker implements ImageChecker
25 {
26 /** @var ImageGuard */
27 private $imageGuard;
28
29 public function __construct(
30 ImageGuard $imageGuard)
31 {
32 $this->imageGuard = $imageGuard;
33 }
34
35 public function check(string $imageContent): bool
36 {
37 return $this->imageGuard
38 ->check($imageContent, true);
39 }
40 }
41
42 final class SuperTolerantImageChecker implements ImageChecker
43 {
Внедрение зависимостей 26

44 public function check(string $imageContent): bool


45 {
46 return true;
47 }
48 }

Создан интерфейс ImageChecker и три его реализации:

• StrictImageChecker для проверки картинок со строгими правилами.


• WeakImageChecker для нестрогой проверки.
• SuperTolerantImageChecker для случаев, когда проверка не нужна.

WrongImageUploadsListener класс превратится в интерфейс с двумя реализациями:

1 interface WrongImageUploadsListener
2 {
3 public function handle(User $user);
4 }
5
6 final class BanningWrongImageUploadsListener
7 implements WrongImageUploadsListener
8 {
9 // реализация та же самая
10 // с RateLimiter и BanUserCommand
11 }
12
13 final class EmptyWrongImageUploadsListener
14 implements WrongImageUploadsListener
15 {
16 public function handle(User $user)
17 {
18 // ничего не делаем
19 }
20 }

Класс EmptyWrongImageUploadsListener будет использоваться вместо параметра $dontBan.


Внедрение зависимостей 27

1 final class ImageUploader


2 {
3 /** @var ImageChecker */
4 private $imageChecker;
5
6 /** @var FileSystemManager */
7 private $fileSystemManager;
8
9 /** @var WrongImageUploadsListener */
10 private $listener;
11
12 public function __construct(
13 ImageChecker $imageChecker,
14 FileSystemManager $fileSystemManager,
15 WrongImageUploadsListener $listener)
16 {
17 $this->imageChecker = $imageChecker;
18 $this->fileSystemManager = $fileSystemManager;
19 $this->listener = $listener;
20 }
21
22 /**
23 * @param UploadedFile $file
24 * @param User $uploadedBy
25 * @param string $folder
26 * @return bool|string
27 */
28 public function upload(
29 UploadedFile $file,
30 User $uploadedBy,
31 string $folder)
32 {
33 $fileContent = $file->getContents();
34
35 if (!$this->imageChecker->check($fileContent)) {
36 $this->listener->handle($uploadedBy);
37
38 return false;
39 }
40
41 $fileName = $folder . 'some_unique_file_name.jpg';
42
43 $this->fileSystemManager
Внедрение зависимостей 28

44 ->disk('...')
45 ->put($fileName, $fileContent);
46
47 return $fileName;
48 }
49 }

Логика булевых параметров переехала в интерфейсы и их реализации.


Работа с файловой системой тоже может быть упрощена созданием фасада для работы с ней
(я говорю о шаблоне Facade, а не о Laravel-фасадах).
Единственная проблема, которая осталась, это создание экземпляров ImageUploader с нуж-
ными зависимостями для каждого случая.
Она может быть решена комбинацией шаблонов Builder и Factory, либо конфигурацией
DI-контейнера.
Признаться, я этот ООП путь привел лишь для того, чтобы показать, что «так тоже можно».
Для текущего примера решение выглядит чересчур громоздким. Попробуем другой вариант.

Configuration way
Я буду использовать файл конфигурации Laravel, чтобы хранить все настройки.
config/image.php:

1 return [
2 'disk' => 's3',
3
4 'avatars' => [
5 'check' => true,
6 'ban' => true,
7 'folder' => 'avatars',
8 ],
9
10 'gallery' => [
11 'check' => true,
12 'weak' => true,
13 'ban' => false,
14 'folder' => 'gallery',
15 ],
16 ];

Класс ImageUploader, использующий конфигурацию (интерфейс Repository):


Внедрение зависимостей 29

1 final class ImageUploader


2 {
3 /** @var ImageGuard */
4 private $imageGuard;
5
6 /** @var FileSystemManager */
7 private $fileSystemManager;
8
9 /** @var WrongImageUploadsListener */
10 private $listener;
11
12 /** @var Repository */
13 private $config;
14
15 public function __construct(
16 ImageGuard $imageGuard,
17 FileSystemManager $fileSystemManager,
18 WrongImageUploadsListener $listener,
19 Repository $config)
20 {
21 $this->imageGuard = $imageGuard;
22 $this->fileSystemManager = $fileSystemManager;
23 $this->listener = $listener;
24 $this->config = $config;
25 }
26
27 /**
28 * @param UploadedFile $file
29 * @param User $uploadedBy
30 * @param string $type
31 * @return bool|string
32 */
33 public function upload(
34 UploadedFile $file,
35 User $uploadedBy,
36 string $type)
37 {
38 $fileContent = $file->getContents();
39
40 $options = $this->config->get('image.' . $type);
41
42 if (Arr::get($options, 'check', true)) {
43 $weak = Arr::get($options, 'weak', false);
Внедрение зависимостей 30

44
45 if (!$this->imageGuard->check($fileContent, $weak)){
46 if(Arr::get($options, 'ban', true)) {
47 $this->listener->handle($uploadedBy);
48 }
49
50 return false;
51 }
52 }
53
54 $fileName = $options['folder'] . 'some_unique_file_name.jpg';
55
56 $defaultDisk = $this->config->get('image.disk');
57
58 $this->fileSystemManager
59 ->disk(Arr::get($options, 'disk', $defaultDisk))
60 ->put($fileName, $fileContent);
61
62 return $fileName;
63 }
64 }

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

Расширение интерфейсов
Иногда нам необходимо расширить интерфейс каким-нибудь методом.
В главе про Доменный слой мне нужно будет отправлять несколько событий в каждом классе
сервиса.
Интерфейс Dispatcher в Laravel имеет только метод dispatch, обрабатывающий одно собы-
тие:
Внедрение зависимостей 31

1 interface Dispatcher
2 {
3 //...
4
5 /**
6 * Dispatch an event and call the listeners.
7 *
8 * @param string|object $event
9 * @param mixed $payload
10 * @param bool $halt
11 * @return array|null
12 */
13 public function dispatch($event,
14 $payload = [], $halt = false);
15 }

Каждый раз придется делать такой foreach:

1 foreach ($events as $event)


2 {
3 $this->dispatcher->dispatch($event);
4 }

Но копипастить это в каждом методе сервисов не очень хочется.


Языки C# и Kotlin имеют фичу «метод-расширение», который натурально «добавляет» метод
к любому классу или интерфейсу:

1 namespace ExtensionMethods
2 {
3 public static class MyExtensions
4 {
5 public static void MultiDispatch(
6 this Dispatcher dispatcher, Event[] events)
7 {
8 foreach (var event in events) {
9 dispatcher.Dispatch(event);
10 }
11 }
12 }
13 }

После этого, каждый класс может использовать метод MultiDispatch:


Внедрение зависимостей 32

1 using ExtensionMethods;
2
3 //...
4
5 dispatcher.MultiDispatch(events);

В PHP такой фичи нет.


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

1 use Illuminate\Contracts\Events\Dispatcher;
2
3 abstract class BaseService
4 {
5 /** @var Dispatcher */
6 private $dispatcher;
7
8 public function __construct(Dispatcher $dispatcher)
9 {
10 $this->dispatcher = $dispatcher;
11 }
12
13 protected function dispatchEvents(array $events)
14 {
15 foreach ($events as $event)
16 {
17 $this->dispatcher->dispatch($event);
18 }
19 }
20 }
21
22 final class SomeService extends BaseService
23 {
24 public function __construct(..., Dispatcher $dispatcher)
25 {
26 parent::__construct($dispatcher);
27 //...
28 }
29
Внедрение зависимостей 33

30 public function someMethod()


31 {
32 //...
33
34 $this->dispatchEvents($events);
35 }
36 }

Использование наследования для того, чтобы унаследовать функционал — не очень хорошая


идея.
Конструкторы становятся более сложными со всеми этими parent::__construct вызовами.
Расширение другого интерфейса этим же базовым классом повлечет за собой изменения
конструкторов всех сервисов.
Создание нового интерфейса выглядит более естественным.
Классы сервисов нуждаются только в одном методе multiDispatch и можно сделать простой
интерфейс с этим методом:

1 interface MultiDispatcher
2 {
3 public function multiDispatch(array $events);
4 }

и реализовать его:

1 use Illuminate\Contracts\Events\Dispatcher;
2
3 final class LaravelMultiDispatcher implements MultiDispatcher
4 {
5 /** @var Dispatcher */
6 private $dispatcher;
7
8 public function __construct(Dispatcher $dispatcher)
9 {
10 $this->dispatcher = $dispatcher;
11 }
12
13 public function multiDispatch(array $events)
14 {
15 foreach($events as $event)
16 {
17 $this->dispatcher->dispatch($event);
18 }
Внедрение зависимостей 34

19 }
20 }
21
22 class AppServiceProvider extends ServiceProvider
23 {
24 public function boot()
25 {
26 $this->app->bind(
27 MultiDispatcher::class,
28 LaravelMultiDispatcher::class);
29 }
30 }

Класс BaseService может быть удалён и сервис-классы могут просто использовать этот
новый интерфейс:

1 final class SomeService


2 {
3 /** @var MultiDispatcher */
4 private $dispatcher;
5
6 public function __construct(..., MultiDispatcher $dispatcher)
7 {
8 //...
9 $this->dispatcher = $dispatcher;
10 }
11
12 public function someMethod()
13 {
14 //...
15
16 $this->dispatcher->multiDispatch($events);
17 }
18 }

В качестве бонуса теперь можно легко переключиться на другой движок обработки событий
вместо стандартной Laravel-реализации, реализовав этот интерфейс в новом классе, исполь-
зуя там вызов другого движка.
Такие интерфейсы-обёртки могут быть весьма полезны, делая проект немного менее зави-
симым от конкретных библиотек.
Когда вызывающие классы хотят полный интерфейс, просто с новым методом, новый
интерфейс может наследоваться от старого:
Внедрение зависимостей 35

1 interface MultiDispatcher extends Dispatcher


2 {
3 public function multiDispatch(array $events);
4 }
5
6 final class LaravelMultiDispatcher
7 implements MultiDispatcher
8 {
9 /** @var Dispatcher */
10 private $dispatcher;
11
12 public function __construct(Dispatcher $dispatcher)
13 {
14 $this->dispatcher = $dispatcher;
15 }
16
17 public function multiDispatch(array $events)
18 {
19 foreach($events as $event) {
20 $this->dispatcher->dispatch($event);
21 }
22 }
23
24 public function listen($events, $listener)
25 {
26 $this->dispatcher->listen($events, $listener);
27 }
28
29 public function dispatch(
30 $event, $payload = [], $halt = false)
31 {
32 $this->dispatcher->dispatch($event, $payload, $halt);
33 }
34
35 // Other Dispatcher methods
36 }

Для больших интерфейсов это может быть весьма долгим, рутинным действием.
Некоторые IDE для других языков (как C#) могут генерировать такой код делегации интер-
фейса автоматически.
Я надеюсь, для PHP тоже скоро реализуют.
Внедрение зависимостей 36

Трейты
Трейты в PHP позволяют магически добавлять функционал в класс практически бесплатно.
Это весьма мощная магия: они могут залезать в приватные части классов и добавлять новые
публичные и даже приватные методы в них.
Я не люблю их. Это часть тёмной магии PHP, мощной и опасной.
Они могут с успехом использоваться в классах тестов, поскольку там нет хорошей причины
организовывать полноценное DI, но лучше избегать их использования в главном коде
приложения.
Трейты — это не ООП, а чистые ООП решения всегда будут более естественными.

Трейты, расширяющие интерфейсы


Проблема с множественной обработкой событий может быть решена с помощью трейта:

1 trait MultiDispatch
2 {
3 public function multiDispatch(array $events)
4 {
5 foreach($events as $event) {
6 $this->dispatcher->dispatch($event);
7 }
8 }
9 }
10
11 final class SomeService
12 {
13 use MultiDispatch;
14
15 /** @var Dispatcher */
16 private $dispatcher;
17
18 public function __construct(..., Dispatcher $dispatcher)
19 {
20 //...
21 $this->dispatcher = $dispatcher;
22 }
23
24 public function someMethod()
25 {
26 //...
27
Внедрение зависимостей 37

28 $this->multiDispatch($events);
29 }
30 }

Трейт MultiDispatch предполагает, что у класса, который будет его использовать есть поле
dispatcher класса Dispatcher.
Лучше не делать таких неявных предположений. Решение с отдельным интерфейсом MultiDispatcher
намного более явное и стабильное.

Трейты как части класса


В языке C# имеется такая фича как partial class.
Она может быть использована когда некоторый класс становится большим и разработчик
может разделить его на несколько файлов:

1 // Foo.cs file
2 partial class Foo
3 {
4 public void bar(){}
5 }
6
7 // Foo2.cs file
8 partial class Foo
9 {
10 public void bar2(){}
11 }
12
13 var foo = new Foo();
14 foo.bar();
15 foo.bar2();

Когда тоже самое случается в PHP, трейты используются с той же целью. Пример из Laravel:

1 class Request extends SymfonyRequest


2 implements Arrayable, ArrayAccess
3 {
4 use Concerns\InteractsWithContentTypes,
5 Concerns\InteractsWithFlashData,
6 Concerns\InteractsWithInput,

Большой класс Request разделен на несколько составляющих.


Когда класс «хочет» разделиться на несколько — это большой намек на то, что у него
Внедрение зависимостей 38

слишком много ответственностей.


Класс Request вполне можно было бы скомпоновать из нескольких других классов, таких как
Session, RequestInput, Cookies и т.д.

1 class Request
2 {
3 /** @var Session */
4 private $session;
5
6 /** @var RequestInput */
7 private $input;
8
9 /** @var Cookies */
10 private $cookies;
11
12 //...
13
14 public function __construct(
15 Session $session,
16 RequestInput $input,
17 Cookies $cookies
18 //...
19 ) {
20 $this->session = $session;
21 $this->input = $input;
22 $this->cookies = $cookies;
23 //...
24 }
25 }

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

1 class Request
2 {
3 public function __construct(
4 array $query = array(),
5 array $request = array(),
6 array $attributes = array(),
7 array $cookies = array(),
8 array $files = array(),
9 array $server = array(),
10 $content = null)
11 {
12 //...
13 }
14
15 //...
16 }

Трейты как поведение


Eloquent-трейты, такие как SoftDeletes - примеры поведенческих трейтов. Они изменяют
поведение классов.
Классы Eloquent моделей содержат как минимум две ответственности: хранение состояния
сущности и выборку/сохранение/удаление сущностей из базы, поэтому Eloquent-трейты
тоже могут менять то, как модели взаимодействуют с базой данных, а также добавлять новые
поля и методы в них.
Эти трейты надо как-то конфигурировать и тут раскрывается большой простор для фантазии
разработчиков пакетов.
Трейт SoftDeletes:

1 trait SoftDeletes
2 {
3 /**
4 * Get the name of the "deleted at" column.
5 *
6 * @return string
7 */
8 public function getDeletedAtColumn()
9 {
10 return defined('static::DELETED_AT')
11 ? static::DELETED_AT
12 : 'deleted_at';
13 }
14 }
Внедрение зависимостей 40

Он ищет константу DELETED_AT в классе и если находит, то использует её значение для


имени поля, либо использует стандартное.
Даже для такой простейшей конфигурации была применена магия (функция defined).
Другие Eloquent трейты имеют более сложную конфигурацию. Я нашел одну библиотеку
и Eloquent трейт там выглядит так:

1 trait DetectsChanges
2 {
3 //...
4 public function shouldLogUnguarded(): bool
5 {
6 if (! isset(static::$logUnguarded)) {
7 return false;
8 }
9 if (! static::$logUnguarded) {
10 return false;
11 }
12 if (in_array('*', $this->getGuarded())) {
13 return false;
14 }
15 return true;
16 }
17 }

Простая настройка, а сколько сложностей. Просто представьте:

1 class SomeModel
2 {
3 protected function behaviors(): array
4 {
5 return [
6 new SoftDeletes('another_deleted_at'),
7 DetectsChanges::create('column1', 'column2')
8 ->onlyDirty()
9 ->logUnguarded()
10 ];
11 }
12 }

Явная настройка поведения с удобной конфигурацией, которую будет подсказывать ваша


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

Без магии, конечно, не обойдётся, это все-таки Eloquent, но выглядит намного более объектно-
ориентированно и, что намного важнее, явно.
Разумеется, эти behaviours существуют только в моем воображении и, вероятно, я не вижу
некоторых проблем, но эта идея мне нравится намного больше, чем трейты.

Бесполезные трейты
Некоторые трейты просто абсолютно бесполезны. Я нашел один такой в исходниках Laravel:

1 trait DispatchesJobs
2 {
3 protected function dispatch($job)
4 {
5 return app(Dispatcher::class)->dispatch($job);
6 }
7
8 public function dispatchNow($job)
9 {
10 return app(Dispatcher::class)->dispatchNow($job);
11 }
12 }

Не знаю почему один из методов публичный, а второй защищенный… я думаю, это просто
ошибка.
Он просто добавляет методы Dispatcher в класс.

1 class WantsToDispatchJobs
2 {
3 use DispatchesJobs;
4
5 public function someMethod()
6 {
7 //...
8
9 $this->dispatch(...);
10 }
11 }

Но ведь намного проще делать так:


Внедрение зависимостей 42

1 class WantsToDispatchJobs
2 {
3 public function someMethod()
4 {
5 //...
6
7 \Bus::dispatch(...);
8
9 //or just
10
11 dispatch(...);
12 }
13 }

Эта простота — главная причина того, что разработчики не используют внедрение зависи-
мостей в PHP.

1 class WantsToDispatchJobs
2 {
3 /** @var Dispatcher */
4 private $dispatcher;
5
6 public function __construct(Dispatcher $dispatcher)
7 {
8 $this->dispatcher = $dispatcher;
9 }
10
11 public function someMethod()
12 {
13 //...
14
15 $this->dispatcher->dispatch(...);
16 }
17 }

Этот класс намного проще прошлых примеров, поскольку имеет явную зависимость от
Dispatcher.
Он явно постулирует, что для работы ему необходим диспатчер.
В случае, когда этот класс захотят перенести в другой проект или написать тесты для него,
разработчикам не придется полностью сканировать его код и искать эти глобальные вызовы
функций или фасадов.
Единственная проблема — громоздкий синтаксис с конструктором и приватное поле.
Синтаксис в языке Kotlin намного более элегантный:
Внедрение зависимостей 43

1 class WantsToDispatchJobs(private val dispatcher: Dispatcher)


2 {
3 //somewhere...
4 dispatcher.dispatch(...);
5 }

Синтаксис PHP является некоторым барьером для использования DI и я надеюсь, что скоро
это будет нивелировано либо изменениями в синтаксисе, либо инструментами в средах
разработки.
После нескольких лет использования и неиспользования трейтов я могу сказать, что разра-
ботчики используют трейты по двум причинам:

• борясь с последствиями архитектурных проблем;


• создавая архитектурные проблемы (иногда не осознавая этого).

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

Статические методы
Я писал, что используя статические методы и классы мы создаем жёсткую связь, но иногда
это нормально.
Пример из прошлой главы:

1 final class CacheKeys


2 {
3 public static function getUserByIdKey(int $id)
4 {
5 return sprintf('user_%d_%d', $id, User::VERSION);
6 }
7
8 public static function getUserByEmailKey(string $email)
9 {
10 return sprintf('user_email_%s_%d',
11 $email,
12 User::VERSION);
13 }
14 //...
15 }
16
17 $key = CacheKeys::getUserByIdKey($id);
Внедрение зависимостей 44

Ключи кэширования необходимы в двух местах: в классах декораторах для выборки сущно-
стей и в классах-слушателей событий, которые отлавливают события изменения сущностей
и чистят кэш.
Я мог бы использовать класс CacheKeys через DI, но в этом мало смысла.
Все эти классы декораторов и слушателей формируют некую структуру, которую можно
назвать «модуль кэширования» для этого приложения.
Класс CacheKeys будет приватной частью этого модуля. Никакой другой код приложения не
должен об этом классе знать.

Использование статических методов для таких внутренних зависимостей, которые не рабо-


тают с внешним миром (файлами, БД или API) — нормальная практика.

Пара слов в конце главы


Самое большое преимущество использования внедрения зависимостей это явный и чёткий
контракт класса.
Публичные методы говорят о том, какую работу способен этот класс исполнять. Параметры
конструктора говорят о том, что нужно для этой работы этому классу.
В больших, долго длящихся проектах это очень важно. Классы могут быть легко протести-
рованы и использованы в любых условиях. Необходимо лишь предоставить им нужные
зависимости. Вся эта магия, как методы __call, фасады Laravel и трейты разрушают эту
гармонию.
С другой стороны, мне сложно представить, например, HTTP-контроллеры вне приложения
Laravel и, я надеюсь, никто не пишет юнит-тесты для них.
Поэтому, это вполне подходящее место для использования функций-хелперов (redirect(),
view()) и Laravel фасадов (Response, URL).
Безболезненный рефакторинг
“Статическая” типизация
Большие, долго-текущие проекты постоянно нуждаются в рефакторинге, чтобы оставаться в
форме.
Извлечение методов и классов из других методов и классов.
Переименование их, добавление и удаление параметров.
Переключение с одного метода или класса на другой.
Современные среды разработки (IDE) имеют на борту кучу инструментов облегчающих
рефакторинг, а иногда выполняющих его полностью автоматически.
Однако, динамическая природа PHP часто вставляет палки в колёса.

1 public function publish($id)


2 {
3 $post = Post::find($id);
4 $post->publish();
5 }
6
7 // Или
8
9 public function makePublish($post)
10 {
11 $post->publish();
12 }

В обоих этих случаях IDE не может самостоятельно понять, что был вызван метод publish
класса Post.
В случае необходимости добавить новый параметр в этот метод, нужно будет найти все
использования этого метода.

1 public function publish(User $publisher)

IDE не сможет сама найти их.


Разработчику придётся искать по всему проекту слово “publish” и найти среди результатов
именно вызовы данного метода.
Для каких-то более распространённых слов (name или create) и больших размеров проекта
это может быть весьма мучительно.
Безболезненный рефакторинг 46

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

1 $user = User::create($request->all());
2 //or
3 $user->fill($request->all())

Эта автомагия, которая помогала нам так быстро создавать приложения, показывает свою
истинную сущность, преподнося такие вот сюрпризы.
Такие баги в продакшене иногда очень критичны и каждая минута важна, а я до сих пор
помню как целый день в огромном проекте, который длится уже лет 10, искал все возможные
присвоения одного поля, пытаясь найти где оно получает некорректное значение.
После нескольких подобных случаев тяжелого дебага, а также сложных рефакторингов, я
выработал себе правило: делать PHP-код как можно более статичным.
IDE должна знать все про каждый метод и каждое поле, которое я использую.

1 public function publish(Post $post)


2 {
3 $post->publish();
4 }
5
6 // Или с phpDoc
7
8 public function publish($id)
9 {
10 /**
11 * @var Post $post
12 */
13 $post = Post::find($id);
14 $post->publish();
15 }

Комментарии phpDoc могут помочь и в сложных случаях:


Безболезненный рефакторинг 47

1 /**
2 * @var Post[] $posts
3 */
4 $posts = Post::all();
5 foreach($posts as $post)
6 {
7 $post->// Здесь IDE должна подсказывать
8 // все методы и поля класса Post
9 }

Подсказки IDE приятны при написании кода, но намного важнее, что, подсказывая их, она
понимает откуда они и всегда найдёт их использования.
Если функция возвращает объект какого-то класса, он должен быть объявлен как return-тип
(начиная с PHP7) или в @return теге phpDoc комментария функции.

1 public function getPost($id): Post


2 {
3 //...
4 }
5
6 /**
7 * @return Post[] | Collection
8 */
9 public function getPostsBySomeCriteria(...)
10 {
11 return Post::where(...)->get();
12 }

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

Шаблоны
На сегодняшний день все больше и больше проектов имеют только API-интерфейс, однако
количество проектов, напрямую генерирующих HTML все ещё велико.
Они используют шаблоны, в которых тоже много вызовов методов и полей.
Типичный вызов шаблона в Laravel:
Безболезненный рефакторинг 48

1 return view('posts.create', [
2 'author' => \Auth::user(),
3 'categories' => Category::all(),
4 ]);

Он выглядит как вызов некоей функции. Сравните с этим псевдо-кодом:

1 /**
2 * @param User $author
3 * @param Category[] | Collection $categories
4 */
5 function showPostCreateView(User $author, $categories): string
6 {
7 //
8 }
9
10 return showPostCreateView(\Auth::user(), Category::all());

Хочется так же описать и параметры шаблонов.


Это легко, когда шаблоны написаны на чистом PHP - комментарии phpDoc работают как и
везде.
Для шаблонных движков, таких как Blade, это не так просто и зависит от IDE.
Я работаю в PhpStorm, поэтому могу говорить только про него.
С недавних пор там тоже можно объявлять типы через phpDoc:

1 <?php
2 /**
3 * @var \App\Models\User $author
4 * @var \App\Models\Category[] $categories
5 */
6 ?>
7
8 @foreach($categories as $category)
9 {{$category->//Category class fields and methods autocomplete}}
10 @endforeach

Я понимаю, что многим это кажется уже перебором и бесполезной тратой времени, но после
всех этих усилий по статической “типизации” мой код в разы более гибче.
Я легко нахожу все использования полей и методов, могу переименовать все автоматически.
Каждый рефакторинг приносит минимум боли.
Безболезненный рефакторинг 49

Поля моделей
Использование магических методов __get, __set, __call и других соблазнительно, но опасно,
как песня сирен.
Находить такие магические вызовы будет сложно.
Если вы используете их, лучше снабдить эти классы нужными phpDoc комментариями.
Пример с небольшой Eloquent моделью:

1 class User extends Model


2 {
3 public function roles()
4 {
5 return $this->hasMany(Role::class);
6 }
7 }

Этот класс имеет несколько виртуальных полей, представляющих поля таблицы users, а
также поле roles.
С помощью пакета laravel-ide-helper можно автоматически сгенерировать phpDoc для этого
класса.
Всего один вызов artisan команды и для всех моделей будут сгенерированы комментарии:

1 /**
2 * App\User
3 *
4 * @property int $id
5 * @property string $name
6 * @property string $email
7 * @property-read Collection|\App\Role[] $roles
8 * @method static Builder|\App\User whereEmail($value)
9 * @method static Builder|\App\User whereId($value)
10 * @method static Builder|\App\User whereName($value)
11 * @mixin \Eloquent
12 */
13 class User extends Model
14 {
15 public function roles()
16 {
17 return $this->hasMany(Role::class);
18 }
19 }
20
Безболезненный рефакторинг 50

21 $user = new User();


22 $user->// Здесь IDE подскажет все поля!

Возвратимся к примеру из прошлой главы:

1 public function store(Request $request, ImageUploader $imageUploader)


2 {
3 $this->validate($request, [
4 'email' => 'required|email',
5 'name' => 'required',
6 'avatar' => 'required|image',
7 ]);
8
9 $avatarFileName = ...;
10 $imageUploader->upload($avatarFileName, $request->file('avatar'));
11
12 $user = new User($request->except('avatar'));
13 $user->avatarUrl = $avatarFileName;
14
15 if(!$user->save()) {
16 return redirect()->back()->withMessage('...');
17 }
18
19 \Email::send($user->email, 'Hi email');
20
21 return redirect()->route('users');
22 }

Создание сущности User выглядит странновато.


До некоторых изменений оно выглядело хотя бы красиво:

1 User::create($request->all());

Потом пришлось его поменять, поскольку поле avatarUrl нельзя присваивать напрямую из
объекта запроса.

1 $user = new User($request->except('avatar'));


2 $user->avatarUrl = $avatarFileName;

Оно не только выглядит странно, но и небезопасно.


Этот метод используется в обычной регистрации пользователя.
В будущем может быть добавлено поле admin, которое будет выделять администраторов от
обычных смертных.
Какой-нибудь сообразительный хакер может просто сам добавить новое поле в форму
регистрации:
Безболезненный рефакторинг 51

1 <input type="hidden" name="admin" value="1">

Он станет администратором сразу же после регистрации.


По этим причинам некоторые эксперты советуют перечислять все нужные поля (есть ещё
метод $request->validated(), но его изъяны будут понятны позже в книге, если будете читать
внимательно):

1 $request->only(['email', 'name']);

Но если мы и так перечисляем все поля, может просто сделаем создание объекта более
цивилизованным?

1 $user = new User();


2 $user->email = $request['email'];
3 $user->name = $request['name'];
4 $user->avatarUrl = $avatarFileName;

Этот код уже можно показывать в приличном обществе.


Он будет понятен любому PHP-разработчику.
IDE теперь всегда найдёт, что в этом месте полю email класса User было присвоено значение.
“Что если у сущности 50 полей?”
Вероятно, стоит немного поменять интерфейс пользователя? 50 полей - многовато для
любого, будь то пользователь или разработчик.
Если не согласны, то дальше в книге будут показаны пару приемов, с помощью которых
можно сократить данный код даже для большого количества полей.
Мы сделали наш код более удобным для будущих рефакторингов или дебага.
Эта “статическая типизация” не является обязательной, но она крайне полезна.
Необходимо хотя бы попробовать.
Слой Приложения
Продолжаем наш пример.
Приложение растёт и в форму регистрации добавились новые поля: дата рождения и опция
согласия получения email-рассылки.

1 public function store(


2 Request $request,
3 ImageUploader $imageUploader)
4 {
5 $this->validate($request, [
6 'email' => 'required|email',
7 'name' => 'required',
8 'avatar' => 'required|image',
9 'birthDate' => 'required|date',
10 ]);
11
12 $avatarFileName = ...;
13 $imageUploader->upload(
14 $avatarFileName, $request->file('avatar'));
15
16 $user = new User();
17 $user->email = $request['email'];
18 $user->name = $request['name'];
19 $user->avatarUrl = $avatarFileName;
20 $user->subscribed = $request->has('subscribed');
21 $user->birthDate = new DateTime($request['birthDate']);
22
23 if(!$user->save()) {
24 return redirect()->back()->withMessage('...');
25 }
26
27 \Email::send($user->email, 'Hi email');
28
29 return redirect()->route('users');
30 }

Потом у приложения появляется API для мобильного приложения и регистрация пользова-


телей должна быть реализована и там.
Слой Приложения 53

Давайте ещё нафантазируем некую консольную artisan-команду, которая импортирует поль-


зователей и она тоже хочет их регистрировать.
И telegram-бот!
В итоге, у приложения появилось несколько интерфейсов, кроме стандартного HTML и везде
необходимо использовать такие действия как регистрация пользователя или публикация
статьи.
Самое естественное решение здесь выделить общую логику работы с сущностью (User в
данном примере) в отдельный класс.
Такие классы часто называют сервисными классами:

1 final class UserService


2 {
3 public function getById(...): User;
4 public function getByEmail(...): User;
5
6 public function create(...);
7 public function ban(...);
8 ...
9 }

Но множество интерфейсов приложения (API, Web, и т.д.) не являются единственной причи-


ной создания сервисных классов.
Методы контроллеров начинают расти и обычно содержат две большие части:

1 public function doSomething(Request $request, $id)


2 {
3 $entity = Entity::find($id);
4
5 if(!$entity) {
6 abort(404);
7 }
8
9 if(count($request['options']) < 2) {
10 return redirect()->back()->withMessage('...');
11 }
12
13 if($entity->something) {
14 return redirect()->back()->withMessage('...');
15 }
16
17 \Db::transaction(function () use ($request, $entity) {
18 $entity->someProperty = $request['someProperty'];
19
Слой Приложения 54

20 foreach($request['options'] as $option) {
21 //...
22 }
23
24 $entity->save();
25 });
26
27 return redirect()->...
28 }

Этот метод реализует как минимум две ответственности: логику работы с HTTP запросом/о-
тветом и бизнес-логику.
Каждый раз когда разработчик меняет http-логику, он вынужден читать много кода бизнес-
логики и наоборот.
Такой код сложнее дебажить и рефакторить, поэтому вынесение логики в сервис-классы тоже
может быть хорошой идеей для этого проекта.

Передача данных запроса


Начнем создавать класс UserService.
Первой проблемой будет передача данных запроса туда.
Некоторым методам не нужно много данных.
Например, для удаления статьи нужно только её id.
Однако, для таких действий, как регистрация пользователя, данных может понадобиться
много.
Мы не можем использовать класс Request, поскольку он доступен только для web.
Попробуем простые массивы:

1 final class UserService


2 {
3 /** @var ImageUploader */
4 private $imageUploader;
5
6 /** @var EmailSender */
7 private $emailSender;
8
9 public function __construct(
10 ImageUploader $imageUploader, EmailSender $emailSender)
11 {
12 $this->imageUploader = $imageUploader;
13 $this->emailSender = $emailSender;
14 }
Слой Приложения 55

15
16 public function create(array $request)
17 {
18 $avatarFileName = ...;
19 $this->imageUploader->upload(
20 $avatarFileName, $request['avatar']);
21
22 $user = new User();
23 $user->email = $request['email'];
24 $user->name = $request['name'];
25 $user->avatarUrl = $avatarFileName;
26 $user->subscribed = isset($request['subscribed']);
27 $user->birthDate = new DateTime($request['birthDate']);
28
29 if(!$user->save()) {
30 return false;
31 }
32
33 $this->emailSender->send($user->email, 'Hi email');
34
35 return true;
36 }
37 }
38
39 public function store(Request $request, UserService $userService)
40 {
41 $this->validate($request, [
42 'email' => 'required|email',
43 'name' => 'required',
44 'avatar' => 'required|image',
45 'birthDate' => 'required|date',
46 ]);
47
48 if(!$userService->create($request->all())) {
49 return redirect()->back()->withMessage('...');
50 }
51
52 return redirect()->route('users');
53 }

Я просто вынес логику без каких-либо изменений и вижу проблему.


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

1 $data = [
2 'email' => $email,
3 'name' => $name,
4 'avatar' => $avatarFile,
5 'birthDate' => $birthDate->format('Y-m-d'),
6 ];
7
8 if($subscribed) {
9 $data['subscribed'] = true;
10 }
11
12 $userService->create($data);

Выглядит чересчур витиевато.


Просто вместе с кодом создания пользователя в сервис переехала и HTML-специфика
передаваемых данных.
Булевы значения проверяются присутствием нужного ключа в массиве.
Значения даты получаются преобразованием из строк.
Использование такого формата данных весьма неудобно, если эти данные пришли не из
HTML-формы.
Нам нужен новый формат данных.
Более естественный, более удобный.
Шаблон Data Transfer Object(DTO) предлагает просто создавать объекты с нужными поля-
ми.

1 final class UserCreateDto


2 {
3 /** @var string */
4 private $email;
5
6 /** @var DateTime */
7 private $birthDate;
8
9 /** @var bool */
10 private $subscribed;
11
12 public function __construct(
13 string $email, DateTime $birthDate, bool $subscribed)
14 {
15 $this->email = $email;
16 $this->birthDate = $birthDate;
17 $this->subscribed = $subscribed;
18 }
Слой Приложения 57

19
20 public function getEmail(): string
21 {
22 return $this->email;
23 }
24
25 public function getBirthDate(): DateTime
26 {
27 return $this->birthDate;
28 }
29
30 public function isSubscribed(): bool
31 {
32 return $this->subscribed;
33 }
34 }

Частенько я слышу возражения вроде “Я не хочу создавать целый класс только для того,
чтобы передать данные. Массивы справляются не хуже.”
Это верно отчасти и для какого-то уровня сложности приложений создавать классы DTO,
вероятно, не стоит.
Дальше в книге я приведу пару аргументов в пользу DTO, а здесь я лишь напишу, что в
современных IDE создать такой класс - это написать его имя в диалоге создания класса,
горячая клавиша создания конструктора (Alt-Ins > Constructor…), перечислить поля с типами
в аргументах конструктора, горячая клавиша по которой PhpStorm создаст все нужные поля
класса (Alt-Enter на параметрах конструктора > Initialize fields) и горячая клавиша создания
методов-геттеров (Alt-Ins в теле класса > Getters…).
Меньше 30 секунд и класс DTO с полной типизацией создан. Заглядывать в этот класс
разработчики будут крайне редко, поэтому какой-то сложности в поддержке приложения
он не добавляет.

1 final class UserService


2 {
3 //...
4
5 public function create(UserCreateDto $request)
6 {
7 $avatarFileName = ...;
8 $this->imageUploader->upload(
9 $avatarFileName, $request->getAvatarFile());
10
11 $user = new User();
12 $user->email = $request->getEmail();
Слой Приложения 58

13 $user->name = $request->getName();
14 $user->avatarUrl = $avatarFileName;
15 $user->subscribed = $request->isSubscribed();
16 $user->birthDate = $request->getBirthDate();
17
18 if(!$user->save()) {
19 return false;
20 }
21
22 $this->emailSender->send($user->email, 'Hi email');
23
24 return true;
25 }
26 }
27
28 public function store(Request $request, UserService $userService)
29 {
30 $this->validate($request, [
31 'email' => 'required|email',
32 'name' => 'required',
33 'avatar' => 'required|image',
34 'birthDate' => 'required|date',
35 ]);
36
37 $dto = new UserCreateDto(
38 $request['email'],
39 new DateTime($request['birthDate']),
40 $request->has('subscribed'));
41
42 if(!$userService->create($dto)) {
43 return redirect()->back()->withMessage('...');
44 }
45
46 return redirect()->route('users');
47 }

Теперь это выглядит канонично.


Сервисный класс получает чистую DTO и выполняет действие.
Правда, метод контроллера теперь довольно большой. Конструктор DTO класса может быть
весьма длинным.
Можно попробовать вынести логику работы с данными запроса оттуда.
В Laravel есть удобные классы для этого - Form Requests.
Слой Приложения 59

1 final class UserCreateRequest extends FormRequest


2 {
3 public function rules()
4 {
5 return [
6 'email' => 'required|email',
7 'name' => 'required',
8 'avatar' => 'required|image',
9 'birthDate' => 'required|date',
10 ];
11 }
12
13 public function authorize()
14 {
15 return true;
16 }
17
18 public function getDto(): UserCreateDto
19 {
20 return new UserCreateDto(
21 $this->get('email'),
22 new DateTime($this->get('birthDate')),
23 $this->has('subscribed'));
24 }
25 }
26
27 final class UserController extends Controller
28 {
29 public function store(
30 UserCreateRequest $request, UserService $userService)
31 {
32 if(!$userService->create($request->getDto())) {
33 return redirect()->back()->withMessage('...');
34 }
35
36 return redirect()->route('users');
37 }
38 }

Если какой-либо класс просит класс, наследованный от FormRequest как зависимость,


Laravel создаёт его и выполняет валидацию автоматически.
В случае неверных данных метод store не будет выполняться, поэтому можно всегда быть
уверенными в валидности данных в UserCreateRequest.
Слой Приложения 60

Классы Form request содержат методы rules для валидации данных и authorize для автори-
зации действия.
Form request явно не лучшее место для реализации авторизации.
Авторизация часто требует контекста, например какой юзер, с какой сущностью, какое
действие.
Это все можно делать в методе authorize, но в сервисном классе это делать намного проще,
поскольку там уже будут все нужные данные да и не придется повторять тоже самое для API
и других интерфейсов.
В моих проектах всегда есть базовый FormRequest класс, в котором метод authorize с одним
оператором return true;, который позволяет забыть про авторизацию на этом уровне.

Работа с базой данных


Простой пример:

1 class PostController
2 {
3 public function publish($id, PostService $postService)
4 {
5 $post = Post::find($id);
6
7 if(!$post) {
8 abort(404);
9 }
10
11 if(!$postService->publish($post)) {
12 return redirect()->back()->withMessage('...');
13 }
14
15 return redirect()->route('posts');
16 }
17 }
18
19 final class PostService
20 {
21 public function publish(Post $post)
22 {
23 $post->published = true;
24 return $post->save();
25 }
26 }
Слой Приложения 61

Публикация статьи - это пример простейшего не-CRUD действия и я буду использовать его
часто.
Я буду переводить Post как Статья, чтобы не было “публикация публикации”.
В примере все выглядит неплохо, но если попробовать вызвать метод сервиса из консоли, то
нужно будет опять доставать сущность Post из базы данных.

1 public function handle(PostService $postService)


2 {
3 $post = Post::find(...);
4
5 if(!$post) {
6 $this->error(...);
7 return;
8 }
9
10 if(!$postService->publish($post)) {
11 $this->error(...);
12 } else {
13 $this->info(...);
14 }
15 }

Это пример нарушения Принципа единственной ответственности (SRP) и высокой связан-


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

1 class PostController
2 {
3 public function publish($id, PostService $postService)
4 {
5 $post = $postService->getById($id);
6
7 if(!$postService->publish($post)) {
8 return redirect()->back()->withMessage('...');
9 }
10
11 return redirect()->route('posts');
12 }
13 }
Слой Приложения 62

Этот код просто получает сущность и передаёт её в другой метод сервиса.


Метод контроллера абсолютно не нуждается в сущности Post.
Он может просто попросить сервис опубликовать статью.
Наиболее логичное и простое решение:

1 class PostController
2 {
3 public function publish($id, PostService $postService)
4 {
5 if(!$postService->publish($id)) {
6 return redirect()->back()->withMessage('...');
7 }
8
9 return redirect()->route('posts');
10 }
11 }
12
13 final class PostService
14 {
15 public function publish(int $id)
16 {
17 $post = Post::find($id);
18
19 if(!$post) {
20 return false;
21 }
22
23 $post->published = true;
24
25 return $post->save();
26 }
27 }

Одно из главных преимуществ создания сервисных классов - это консолидация всей работы
с бизнес-логикой и инфраструктурой, включая хранилища данных, такие как базы данных
и файлы, в одном месте, оставляя Web, API, Console и другим интерфейсам работу исключи-
тельно со своими обязанностями.
Часть для работы с Web должна просто готовить данные для сервис классов и показывать
результаты пользователю.
Тоже самое про другие интерфейсы.
Это Принцип единственной ответственности для слоёв.
Слоёв? Да. Все сервис-классы, которые прячут логику приложения внутри себя и предостав-
ляют удобные методы для Web, API и других частей приложения, формируют структуру,
Слой Приложения 63

которая имеет множество имён:

• Сервисный слой (Service layer), из-за сервисных классов.


• Слой приложения (Application layer), потому что он содержит всю логику приложения,
исключая интерфейсы к нему.
• В GRASP этот слой называется Слой контроллеров (Controllers layer), поскольку сер-
висные классы там называются контроллерами.
• наверняка есть и другие названия.

В этой книге я буду называть его Слоем приложения. Потому что могу.

Сервисные классы или классы команд?


Когда сущность большая, с кучей различных действий, сервисный класс для нее тоже будет
большим.
Разные действия, такие как отредактировать статью или опубликовать её, требуют разных
зависимостей.
Всем нужна база данных, но одни хотят посылать письма, другие - залить файл в хранилище,
третьи - дёрнуть какое-нибудь API.
Количество параметров конструктора сервисного класса растёт довольно быстро, хотя каж-
дое из них может использоваться в одном-двух методах.
Довольно быстро становится понятно, что класс занимается слишком многими делами.
Поэтому часто разработчики начинают создавать один класс на каждое действие с сущно-
стями.
Насколько мне известно, стандарта на название таких классов тоже нет.
Я видел суффикс UseCase (например PublishPostUseCase), суффикс Action (PublishPostAction),
но предпочитаю суффикс Command: CreatePostCommand, PublishPostCommand, DeletePostCommand.

1 final class PublishPostCommand


2 {
3 public function execute($id)
4 {
5 //...
6 }
7 }

В шаблоне Command Bus суффиксCommand используется для DTO-классов, а классы испол-


няющие команды называются CommandHandler.
Слой Приложения 64

1 final class ChangeUserPasswordCommand


2 {
3 //...
4 }
5
6 final class ChangeUserPasswordCommandHandler
7 {
8 public function handle(
9 ChangeUserPasswordCommand $command)
10 {
11 //...
12 }
13 }
14
15 // или если один класс исполняет много команд
16
17 final class UserCommandHandler
18 {
19 public function handleChangePassword(
20 ChangeUserPasswordCommand $command)
21 {
22 //...
23 }
24 }

В книге я, для простоты, буду использовать Service-классы.


Небольшая ремарка про длинные имена классов. “ChangeUserPasswordCommandHandler -
ого! Не слишком ли длинное имя? Не хочу писать его каждый раз!”
Полностью его написать придется всего один раз - при создании.
Дальше IDE будет полностью подсказывать его.
Хорошее, полностью описывающее класс, название намного важнее.
Каждый разработчик может сразу сказать что примерно делает класс ChangeUserPasswordCommandHandle
не заглядывая внутрь него.
Обработка ошибок
Язык С, который дал основу синтаксиса для многих современных языков, имеет простую
конвенцию для ошибок.
Если функция что-то возвращает, но не может вернуть из-за оишбки, она возвращает null.
Если функция выполняет какую-то задачу, не возвращая никакого результата, то в случае
успеха она возвращает 0, а в случае ошибки -1 или какой-нибудь код ошибки.
Много PHP разработчиков полюбили такую простоту и используют те же принципы.
Код может выглядеть так:

1 final class ChangeUserPasswordDto


2 {
3 private $userId;
4 private $oldPassword;
5 private $newPassword;
6
7 public function __construct(
8 int $userId, string $oldPassword, string $newPassword)
9 {
10 $this->userId = $userId;
11 $this->oldPassword = $oldPassword;
12 $this->newPassword = $newPassword;
13 }
14
15 public function getUserId(): int
16 {
17 return $this->userId;
18 }
19
20 public function getOldPassword(): string
21 {
22 return $this->oldPassword;
23 }
24
25 public function getNewPassword(): string
26 {
27 return $this->newPassword;
28 }
29 }
Обработка ошибок 66

30
31 final class UserService
32 {
33 public function changePassword(
34 ChangeUserPasswordDto $command): bool
35 {
36 $user = User::find($command->getUserId());
37 if($user === null) {
38 return false; // пользователь не найден
39 }
40
41 if(!password_verify($command->getOldPassword(),
42 $user->password)) {
43 return false; // старый пароль неверный
44 }
45
46 $user->password = password_hash($command->getNewPassword());
47 return $user->save();
48 }
49 }
50
51 final class UserController
52 {
53 public function changePassword(UserService $service,
54 ChangeUserPasswordRequest $request)
55 {
56 if($service->changePassword($request->getDto())) {
57 // возвращаем успешный ответ
58 } else {
59 // возвращаем ответ с ошибкой
60 }
61 }
62 }

Ну, по крайней мере, это работает.


Но что, если пользователь хочет узнать в чем причина ошибки?
Комментарии рядом с return false бесполезны во время выполнения кода.
Можно попробовать коды ошибки, но часто кроме кода нужна и дополнительная информа-
ция для пользователя.
Попробуем создать специальный класс результата функции:
Обработка ошибок 67

1 final class FunctionResult


2 {
3 /** @var bool */
4 public $success;
5
6 /** @var mixed */
7 public $returnValue;
8
9 /** @var string */
10 public $errorMessage;
11
12 private function __construct() {}
13
14 public static function success(
15 $returnValue = null): FunctionResult
16 {
17 $result = new self();
18 $result->success = true;
19 $result->returnValue = $returnValue;
20
21 return $result;
22 }
23
24 public static function error(
25 string $errorMessage): FunctionResult
26 {
27 $result = new self();
28 $result->success = false;
29 $result->errorMessage = $errorMessage;
30
31 return $result;
32 }
33 }

Конструктор этого класса приватный, поэтому все объекты могут быть созданы только с
помощью статических методов FunctionResult::success и FunctionResult::error.
Это простенький трюк называется “именованные конструкторы”.

1 return FunctionResult::error("Something is wrong");

Выглядит намного проще и информативнее, чем


Обработка ошибок 68

1 return new FunctionResult(false, null, "Something is wrong");

Код будет выглядеть так:

1 class UserService
2 {
3 public function changePassword(
4 ChangeUserPasswordDto $command): FunctionResult
5 {
6 $user = User::find($command->getUserId());
7 if($user === null) {
8 return FunctionResult::error("User was not found");
9 }
10
11 if(!password_verify($command->getOldPassword(),
12 $user->password)) {
13 return FunctionResult::error("Old password isn't valid");
14 }
15
16 $user->password = password_hash($command->getNewPassword());
17
18 $databaseSaveResult = $user->save();
19
20 if(!$databaseSaveResult->success) {
21 return FunctionResult::error("Database error");
22 }
23
24 return FunctionResult::success();
25 }
26 }
27
28 final class UserController
29 {
30 public function changePassword(UserService $service,
31 ChangeUserPasswordRequest $request)
32 {
33 $result = $service->changePassword($request->getDto());
34
35 if($result->success) {
36 // возвращаем успешный ответ
37 } else {
38 // возвращаем ответ с ошибкой
39 // с текстом $result->errorMessage
Обработка ошибок 69

40 }
41 }
42 }

Каждый метод (даже save() Eloquent модели в этом воображаемом мире) возвращает объект
FunctionResult с полной информацией о том, как завершилось выполнение функции.
Когда я показывал этот пример на одном семинаре один слушатель сказал: “Зачем так
делать? Есть же исключения!”
Да, исключения (exceptions) есть, но дайте лишь показать пример из языка Go:

1 f, err := os.Open("filename.ext")
2 if err != nil {
3 log.Fatal(err)
4 }
5 // do something with the open *File f

Обработка ошибок там реализована примерно так же. Без исключений. Популярность языка
растёт, поэтому без исключений вполне можно жить.
Однако, чтобы продолжать использовать класс FunctionResult придётся реализовать стек
вызовов функций, необходимый для отлавливания ошибок в будущем и корректное логи-
рование каждой ошибки.
Все приложение будет состоять из проверок if($result->success).
Не очень похоже на код моей мечты…
Мне нравится код, который просто описывает действия, не проверяя состояние ошибки на
каждом шагу.
Попробуем использовать исключения.

Исключения (Exceptions)
Когда пользователь просит приложение выполнить действие (зарегистрироваться или отме-
нить заказ), приложение может выполнить его или нет.
Во втором случае, причин может быть множество.
Одним из лучших иллюстраций этого является список кодов HTTP-ответа.
Там есть коды 2хх и 3хх для успешных ответов, таких как 200 Ok или 302 Found.
Коды 4xx and 5xx нужны для неуспешных ответов, но они разные.

• 4xx для ошибок клиента: 400 Bad Request, 401 Unauthorized, 403 Forbidden, и т.д.
• 5xx для ошибок сервера: 500 Internal Server Error, 503 Service Unavailable, и т.д.

Соответственно, все ошибки валидации, авторизации, не найденные сущности и попытки


изменить пароль с неверным старым паролем - это ошибки клиента.
Обработка ошибок 70

Недоступность стороннего API, ошибка хранилища файлов или проблемы со связью с базой
данных - это ошибки сервера.
Есть две противоборствующие школы обработок ошибок:

1. Девизом школы Аскетов Исключения является “Исключения только для исключитель-


ных ситуаций”. Любое исключение считают вещью весьма необычной, способной про-
изойти только из-за событий непреодолимой силы (отказ бд или файловой системы) и
почти все исключения превращаются в 500-тые ответы сервера. Для ситуаций с неверно
введённым email или неправильным паролем они используют что-то вроде объекта
FunctionResult.
2. Адепты же школы Единого Верного Пути считают любую негативную ситуацию, т.е.
ситуацию, которая не даёт выполнить действие пользователя, исключением.

Код аскетов как и их девиз выглядит более логично, но ошибки клиента придётся постоянно
протаскивать наверх, как в примерах выше, из функций к тем, кто их вызвал, из Слоя
приложения в контроллеры и т.д.
Код же их противников имеет унифицированный алгоритм работы с любой ошибкой (про-
сто выбросить исключение) и более чистый код, поскольку не надо проверять результаты
методов на ошибочность.
Есть только один вариант выполнения запроса, который приводит к успеху: приложение
получило валидные данные, сущность пользователя загружена из базы данных, старый
пароль совпал, поменяли пароль на новый и сохранили все в базе.
Любой шаг в сторону от этого единого пути должен вызывать исключение.
Юзер ввёл невалидные данные - исключение, этому пользователю нельзя выполнить это
действие - исключение, упал сервер с базой данных - разумеется, тоже исключение.
Проблемой Единого Верного Пути является то, что где-то нужно будет отделить ошибки
клиента от ошибок сервера, поскольку ответы мы должны сгенерировать разные (помните
про 400-ые и 500-цену коды ответов?) да и логироваться такие ошибки должны по-разному.
Сложно сказать какой из путей предпочтительнее.
Когда приложение только-только обзавелось отдельным слоем Приложения, второй путь
кажется более приятным.
Код чище, в любом приватном методе сервисного класса если что-то не понравилось можно
просто выбросить исключение и оно сразу дойдёт до адресата.
Однако если приложение будет расти дальше, например будет создан еще и Доменный слой,
то это увлечение исключениями может оказаться вредным.
Некоторые из них, будучи выброшенными, но не пойманными на нужном уровне могут
быть проинтерпретированы неверно на более высоком уровне.
Количество try-catch блоков начнёт расти и код уже не будет таким чистым.
Laravel выбрасывает исключения для 404 ошибки, для ошибки доступа (код 403) да и вообще
имеет класс HttpException в котором можно указать HTTP-код ошибки.
Поэтому, в этой книге я тоже выберу второй вариант и буду генерировать исключения при
любых проблемах.
Обработка ошибок 71

Пишем код с исключениями:

1 class UserService
2 {
3 public function changePassword(
4 ChangeUserPasswordDto $command): void
5 {
6 $user = User::findOrFail($command->getUserId());
7
8 if(!password_verify($command->getOldPassword(),
9 $user->password)) {
10 throw new \Exception("Old password is not valid");
11 }
12
13 $user->password = password_hash($command->getNewPassword());
14
15 $user->saveOrFail();
16 }
17 }
18
19 final class UserController
20 {
21 public function changePassword(UserService $service,
22 ChangeUserPasswordRequest $request)
23 {
24 try {
25 $service->changePassword($request->getDto());
26 } catch(\Throwable $e) {
27 // log error
28 // return failure web response with $e->getMessage();
29 }
30
31 // return success web response
32 }
33 }

Даже на таком просто примере видно, что код Метода UserService::changePassword стал
намного чище.
Любой шаг в сторону от основной ветки выполнения вызывает исключение, которое ловится
в контроллере.
Eloquent тоже имеет методы для работы в таком стиле: findOrFail(), firstOrFail() и кое-какие
другие *OrFail() методы.
Правда этот код все ещё не без проблем:
Обработка ошибок 72

1. Exception::getMessage() не самое лучшее сообщение для того, чтобы показывать поль-


зователю.

Сообщение “Old password is not valid” ещё неплохо, но, например, “Server Has Gone Away
(error 2006)” точно нет.
2. Любые серверные ошибки должны быть записаны в лог.
Мелкие приложения используют лог-файлы.
Когда приложение становится популярным, исключения могут происходить каждую секун-
ду.
Некоторые исключения сигнализируют о проблемах в коде и должны быть исправлены
немедленно.
Некоторые исключения являются нормой: интернет не идеален, запросы в самые стабильные
API один раз из миллиона могут заканчиваться неудачей.
Однако если частота таких ошибок резко возрастает, то разработчики должны среагировать
тоже.
В таких случаях, когда контроллере за ошибками требует много внимания, лучше использо-
вать специализированные сервисы, которые позволят группировать исключения и работать
с ними намного удобнее.
Если интересно, можете просто погуглить “error monitoring services” и найдёте несколько
таких сервисов.
Большие компании строят свои специализированные решения для записи и анализа логов
со всех своих серверов (часто на основе популярного на момент написания книги стэка ELK:
Elastic, LogStash, Kibana).
Некоторые компании не логируют ошибки клиента.
Некоторые логируют, но в отдельных хранилищах.
В любом случае, для любого приложения необходимо четко разделять ошибки сервера и
клиента.

Базовый класс исключения


Первый шаг - создать базовый класс для всех исключений бизнес-логики, таких как “Старый
пароль неверен”.
В PHP есть класс DomainException, который мог бы быть использован с этой целью, но он
уже используется в других местах, например в сторонних библиотеках и это может привести
к путанице.
Проще создать свой класс, скажем BusinessException.
Обработка ошибок 73

1 class BusinessException extends \Exception


2 {
3 /**
4 * @var string
5 */
6 private $userMessage;
7
8 public function __construct(string $userMessage)
9 {
10 $this->userMessage = $userMessage;
11 parent::__construct("Business exception");
12 }
13
14 public function getUserMessage(): string
15 {
16 return $this->userMessage;
17 }
18 }
19
20 // Теперь ошибка верификации старого пароля вызовет исключение
21
22 if(!password_verify($command->getOldPassword(), $user->password)) {
23 throw new BusinessException("Old password is not valid");
24 }
25
26 final class UserController
27 {
28 public function changePassword(UserService $service,
29 ChangeUserPasswordRequest $request)
30 {
31 try {
32 $service->changePassword($request->getDto());
33 } catch(BusinessException $e) {
34 // вернуть ошибочный ответ
35 // с одним из 400-ых кодов
36 // с $e->getUserMessage();
37 } catch(\Throwable $e) {
38 // залогировать ошибку
39
40 // вернуть ошибочный ответ (с кодом 500)
41 // с текстом "Houston, we have a problem"
42 // Не возвращая реальный текст ошибки
43 }
Обработка ошибок 74

44
45 // вернуть успешный ответ
46 }
47 }

Этот код ловит BusinessException и показывает его сообщение пользователю.


Другие исключения покажут некое “Внутренняя ошибка, мы работаем над этим” и исклю-
чение будет отправлено в лог.
Код работает корректно, но секция catch будет повторена один в один в каждом методе
каждого контроллера.
Стоит вынести логику обработки исключений на более высокий уровень.

Глобальный обработчик
В Laravel (как и почти во всех фреймворках) есть глобальный обработчик исключений и, как
ни странно, здесь весьма удобно обрабатывать почти все исключения нашего приложения.
В Laravel класс app/Exceptions/Handler.php реализует две очень близкие ответственности:
логирование исключений и сообщение пользователям о них.

1 namespace App\Exceptions;
2
3 class Handler extends ExceptionHandler
4 {
5 protected $dontReport = [
6 // Это означает, что BusinessException
7 // не будет логироваться
8 // но будет показан пользователю
9 BusinessException::class,
10 ];
11
12 public function report(Exception $e)
13 {
14 if ($this->shouldReport($e))
15 {
16 // Это отличное место для
17 // интеграции сторонних сервисов
18 // для мониторинга ошибок
19 }
20
21 // это залогирует исключение
22 // по умолчанию в файл laravel.log
23 parent::report($e);
Обработка ошибок 75

24 }
25
26 public function render($request, Exception $e)
27 {
28 if ($e instanceof BusinessException)
29 {
30 if($request->ajax())
31 {
32 $json = [
33 'success' => false,
34 'error' => $e->getUserMessage(),
35 ];
36
37 return response()->json($json, 400);
38 }
39 else
40 {
41 return redirect()->back()
42 ->withInput()
43 ->withErrors([
44 'error' => trans($e->getUserMessage())]);
45 }
46 }
47
48 // Стандартный показ ошибки
49 // такой как страница 404
50 // или страница "Oops" для 500 ошибок
51 return parent::render($request, $e);
52 }
53 }

Простой пример глобального обработчика.


Метод report может быть использован для дополнительного логирования.
Вся catch секция из контроллера переехала в метод render.
Здесь все ошибки логики будут отловлены и правильные сообщения пользователя будут
сгенерированы.
Для ошибок консоли есть незадокументированный метод renderForConsole($output, Exception
$e), который тоже можно перекрыть в этот классе.
Посмотрите на контроллер:
Обработка ошибок 76

1 final class UserController


2 {
3 public function changePassword(UserService $service,
4 ChangeUserPasswordRequest $request)
5 {
6 $service->changePassword($request->getDto());
7
8 // возвращаем успешный ответ
9 }
10 }

Ну не красота?

Проверяемые и непроверяемые исключения


Закройте глаза. Сейчас я буду вещать о высоких материях, которые в конце концов окажутся
бесполезными. Представьте себе берег моря и метод UserService::changePassword.
Подумайте какие ошибки там могут возникнуть?

• IlluminateDatabaseEloquentModelNotFoundException если пользователя с таким id не


существует
• IlluminateDatabaseQueryException если запрос в базу данных не может быть выполнен.
• AppExceptionsBusinessException если старый пароль неверен
• TypeError если где-то глубоко внутри кода функция foo(SomeClass $x) получит пара-
метр $x с другим типом
• Error если $var->method() будет вызван, когда переменная $var == null
• еще много других исключений

С точки зрения вызывающего этот метод, некоторые из этих ошибок, такие как Error,
TypeError, QueryException, абсолютно вне контекста.
Какой-нибудь HTTP-контроллер вообще не знает, что с этими ошибками делать.
Единственное, что он может сделать - показать пользователю “Произошло что-то плохое и я
не знаю, что с этим делать”.
Но некоторые из них имеют смысл для него.
BusinessException говорит о том, что что-то не так с логикой и там есть сообщения прямо
для пользователя и контроллер точно знает, что с этим исключением делать.
Тоже самое можно сказать про ModelNotFoundException. Контроллер может показать 404
ошибку на это.
Да, мы вынесли все это из контроллеров в глобальный обработчик, но это не важно.
Итак, два типа ошибок:
Обработка ошибок 77

1. Ошибки, которые понятны вызывающему коду и могут быть эффективно обработаны


там
2. Другие ошибки

Первые ошибки хорошо бы обработать там, где этот метод вызывается, а вторые можно и
пробросить выше.
Запомним это и взглянем на язык Java.

1 public class Foo


2 {
3 public void bar()
4 {
5 throw new Exception("test");
6 }
7 }

Этот код даже не скомпилируется.


Сообщение компилятора: “Error:(5, 9) java: unreported exception java.lang.Exception; must be
caught or declared to be thrown”
Есть два способа исправить это. Поймать его:

1 public class Foo


2 {
3 public void bar()
4 {
5 try {
6 throw new Exception("test");
7 } catch(Exception e) {
8 // do something
9 }
10 }
11 }

Или описать исключение в сигнатуре метода:


Обработка ошибок 78

1 public class Foo


2 {
3 public void bar() throws Exception
4 {
5 throw new Exception("test");
6 }
7 }

В этом случае, каждый код вызывающий метод bar будет вынужден также что-то делать с
этим исключением:

1 public class FooCaller


2 {
3 public void caller() throws Exception
4 {
5 (new Foo)->bar();
6 }
7
8 public void caller2()
9 {
10 try {
11 (new Foo)->bar();
12 } catch(Exception e) {
13 // do something
14 }
15 }
16 }

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


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

1 Throwable(checked)
2 / \
3 Error(unchecked) Exception(checked)
4 \
5 RuntimeException(unchecked)

Throwable, Exception и все их наследники - проверяемые исключения.


Кроме Error, RuntimeException и всех их наследников. Их можно выбросить везде и ничего
за это не будет.
Обработка ошибок 79

1 public class File


2 {
3 public String getCanonicalPath() throws IOException {
4 //...
5 }
6 }

Что сигнатура метода getCanonicalPath говорит разработчику?


Там нет никаких параметров, возвращает строку, может выбросить исключение IOException
а также любое непроверяемое исключение.
Возвращаясь к двум типам ошибок:

1. Ошибки, которые понятны вызывающему коду и могут быть эффективно обработаны


там
2. Другие ошибки

Проверяемые исключения созданы для ошибок первого типа.


Непроверяемые - для второго.
Вызывающий код может что-то сделать с проверяемыми исключениями и эта строгость
обязывает его сделать это.
Все это приводит к более корректной обработке ошибок.
Хорошо, в Java это есть, в PHP - нет.
Почему я все ещё об этом говорю?
IDE, которое я использую, PhpStorm, имитирует поведения Java.

1 class Foo
2 {
3 public function bar()
4 {
5 throw new Exception();
6 }
7 }

PhpStorm подсветит ‘throw new Exception();’ с предупреждением: ‘Unhandled Exception’.


И есть два пути избавиться от этого:

1. Поймать исключение
2. Описать его в тэге @throws phpDoc-комментария метода:
Обработка ошибок 80

1 class Foo
2 {
3 /**
4 * @throws Exception
5 */
6 public function bar()
7 {
8 throw new Exception();
9 }
10 }

Список непроверяемых классов конфигурируется.


По умолчанию он выглядит так: Error, RuntimeException and LogicException.
Их можно выбрасывать не опасаясь предупреждений.
Со всей этой информацией можно попробовать спроектировать структуру классов исключе-
ния для приложения.
Я бы хотел информировать код, вызывающий UserService::changePassword про ошибки:

1. ModelNotFoundException, когда пользователь с таким id не найден


2. BusinessException, эта ошибка содержит сообщение, предназначенное для пользовате-
ля и может быть обработано сразу.

Все остальные ошибки могут быть обработаны позже.


Итак. в идеальном мире:

1 class ModelNotFoundException extends \Exception


2 {...}
3
4 class BusinessException extends \Exception
5 {...}
6
7 final class UserService
8 {
9 /**
10 * @param ChangeUserPasswordDto $command
11 * @throws ModelNotFoundException
12 * @throws BusinessException
13 */
14 public function changePassword(
15 ChangeUserPasswordDto $command): void
16 {...}
17 }
Обработка ошибок 81

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


придется копировать все эти @throws тэги в методе контроллера:

1 final class UserController


2 {
3 /**
4 * @param UserService $service
5 * @param Request $request
6 * @throws ModelNotFoundException
7 * @throws BusinessException
8 */
9 public function changePassword(UserService $service,
10 ChangeUserPasswordRequest $request)
11 {
12 $service->changePassword($request->getDto());
13
14 // возвращаем успешный ответ
15 }
16 }

Не очень удобно. Даже если учесть, что PhpStorm умеет генерировать все эти тэги автомати-
чески.
Возвращаясь к нашему неидеальному миру: Класс ModelNotFoundException в Laravel уже
отнаследован от RuntimeException.
Соответственно, он непроверяемый по умолчанию.
Это имеет смысл, поскольку глубоко внутри собственного обработчика ошибок Laravel
обрабатывает эти исключения сам.
Поэтому, в нашем текущем положении, стоит тоже пойти на такую сделку с совестью:

1 class BusinessException extends \RuntimeException


2 {...}

и забыть про тэги @throws держа в голове то, что все исключения BusinessException будут
обработаны в глобального обработчике.
Это одна из главных причин почему новые языки не имеют такую фичу с проверяемыми
исключениями и большинство Java-разработчиков не любят их.
Другая причина: некоторые библиотеки просто пишут “throws Exception” в своих методах.
“throws Exception” вообще не дает никакой полезной информации.
Это просто заставляет клиентский код повторять этот бесполезный “throws Exception” в своей
сигнатуре.
Я вернусь к исключениям в главе про Доменный слой, когда этот подход непроверяемыми
исключениями станет не очень удобным.
Обработка ошибок 82

Пара слов в конце главы


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

1 // Без исключений
2 $user = User::find($command->getUserId());
3 if($user === null) {
4 // обрабатываем ошибку
5 }
6
7 $user->doSomething();
8
9
10 // С исключением
11 $user = User::findOrFail($command->getUserId());
12 $user->doSomething();

С другой стороны, использование объектов как FunctionResult даёт разработчикам больший


контроль над исполнением.
Например, findOrFail вызванное в неправильном месте в неправильное время заставит
приложение показать пользователю 404ю ошибку вместо корректного сообщения об ошибке.
С исключениями надо всегда быть настороже.
Валидация
“…But, now you come to me, and you say: “Don Corleone, give me justice.” But you don’t ask with
respect. You don’t offer friendship…”

Валидация связанная с базой данных


Как всегда, главу начнём с примера кода с практиками, накопленными в предыдущих
главах.
Создание статьи:

1 class PostController extends Controller


2 {
3 public function create(Request $request, PostService $service)
4 {
5 $this->validate($request, [
6 'category_id' => 'required|exists:categories',
7 'title' => 'required',
8 'body' => 'required',
9 ]);
10
11 $service->create(/* DTO */);
12
13 //...
14 }
15 }
16
17 class PostService
18 {
19 public function create(CreatePostDto $dto)
20 {
21 $post = new Post();
22 $post->category_id = $dto->getCategoryId();
23 $post->title = $dto->getTitle();
24 $post->body = $dto->getBody();
Валидация 84

25
26 $post->saveOrFail();
27 }
28 }

Я только краткости ради вернул валидацию обратно в контроллер.


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

1 $this->validate($request, [
2 'category_id' => [
3 'required|exists:categories,id,deleted_at,null',
4 ],
5 'title' => 'required',
6 'body' => 'required',
7 ]);

Была добавлена проверка на пометку на удаление.


Могу ещё добавить, что таких правок может понадобиться много, ведь статьи не только
создаются.
Любое другое изменение может опять сломать нашу “умную” валидацию.
Например, пометка “archived” для категорий, которая позволит им оставаться на сайте, но
не позволяет добавлять новые статьи в них.
Мы не делали никаких изменений в форме добавления статьи, и вообще во всей HTTP части
приложения.
Изменения касались либо бизнес-логики (архивные категории), либо логики хранения
данных (Soft delete), однако менять приходится классы HTTP-запросов с их валидацией.
Это ещё один пример высокой связанности (high coupling).
Не так давно мы решили вынести всю работу с базой данных в Слой Приложения, но по
старой привычке все ещё лезем в базу напрямую из валидации, игнорируя все абстракции,
которые строятся с помощью Eloquent или Слоя Приложения.
Валидация 85

Надо разделить валидацию. В валидации HTTP слоя, нам просто необходимо убедиться, что
пользователь не ошибся в вводе данных:

1 $this->validate($request, [
2 'category_id' => 'required',
3 'title' => 'required',
4 'body' => 'required',
5 ]);
6
7 class PostService
8 {
9 public function create(CreatePostDto $dto)
10 {
11 $category = Category::find($dto->getCategoryId());
12
13 if($category === null) {
14 // throw "Category not found" exception
15 }
16
17 if($category->archived) {
18 // throw "Category archived" exception
19 }
20
21 $post = new Post();
22 $post->category_id = $category->id;
23 $post->title = $dto->getTitle();
24 $post->body = $dto->getBody();
25
26 $post->saveOrFail();
27 }
28 }

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


более правильных местах.
Теперь код работает более стабильно: валидация в контроллерах или FormRequest не меня-
ется от случайных изменений в других слоях.
Метод PostService::create не доверяет такие важные проверки вызывающему коду и валиди-
рует все сам.
В качестве бонуса, приложение теперь имеет намного более понятные тексты ошибок.
Валидация 86

Два уровня валидации


В прошлом примере валидация была разделена на две части и я сказал, что метод PostService::create
не доверяет сложную валидацию вызывающему коду, но он все ещё доверяет ему в простом:

1 $post->title = $request->getTitle();

Здесь мы уверены, что заголовок у нас будет непустой, однако на 100 процентов уверенности
нет.
Да, сейчас оно проверяется правилом ‘required’ при валидации, но это далеко, где-то в
контроллерах или ещё дальше.
Метод PostService::create может быть вызван из другого кода и там эта проверка может быть
забыта.
Давайте рассмотрим пример с регистрацией пользователя (он удобнее):

1 class RegisterUserDto
2 {
3 /** @var string */
4 private $name;
5
6 /** @var string */
7 private $email;
8
9 /** @var DateTime */
10 private $birthDate;
11
12 public function __construct(
13 string $name, string $email, DateTime $birthDate)
14 {
15 $this->name = $name;
16 $this->email = $email;
17 $this->birthDate = $birthDate;
18 }
19
20 public function getName(): string
21 {
22 return $this->name;
23 }
24
25 public function getEmail(): string
26 {
27 return $this->email;
Валидация 87

28 }
29
30 public function getBirthDate(): DateTime
31 {
32 return $this->birthDate;
33 }
34 }
35
36 class UserService
37 {
38 public function register(RegisterUserDto $request)
39 {
40 $existingUser = User::whereEmail($request->getEmail())
41 ->first();
42
43 if($existingUser !== null) {
44 throw new UserWithThisEmailAlreadyExists(...);
45 }
46
47 $user = new User();
48 $user->name = $request->getName();
49 $user->email = $request->getEmail();
50 $user->birthDate = $request->getBirthDate();
51
52 $user->saveOrFail();
53 }
54 }

После начала использования DTO мы вынуждены забыть про то, что данные в web запросе
были отвалидированы.
Любой может написать такой код:

1 $userService->register(new RegisterUserDto('', '', new DateTime()));

Никто не может поручиться, что в getName() будет лежать непустая строка, а в getEmail()
строка с верным email адресом.
Что делать?
Можно дублировать валидацию в сервисном классе:
Валидация 88

1 class UserService
2 {
3 public function register(RegisterUserDto $request)
4 {
5 if(empty($request->getName())) {
6 throw //
7 }
8
9 if(!filter_var($request->getEmail(),
10 FILTER_VALIDATE_EMAIL)) {
11 throw //
12 }
13
14 //...
15 }
16 }

Или сделать такую же валидацию в конструкторе DTO класса, но в приложении будет


куча мест, где нужно будет получать email, имя или подобные данные. Куча кода будет
дублироваться.
Я могу предложить два варианта.

Валидация аннотациями
Проект Symfony содержит отличный компонент для валидации аннотациями - symfony/validator.
Для того, чтобы использовать его вне Symfony нужно установить composer-пакеты symfony/validator,
doctrine/annotations и doctrine/cache и сделать небольшую инициализацию.
Перепишем наш RegisterUserDto:

1 use Symfony\Component\Validator\Constraints as Assert;


2
3 class RegisterUserDto
4 {
5 /**
6 * @Assert\NotBlank()
7 * @var string
8 */
9 private $name;
10
11 /**
12 * @Assert\NotBlank()
13 * @Assert\Email()
Валидация 89

14 * @var string
15 */
16 private $email;
17
18 /**
19 * @Assert\NotNull()
20 * @var DateTime
21 */
22 private $birthDate;
23
24 // Конструктор и геттеры остаются такими же
25 }

Небольшая инициализация в контейнере:

1 $container->bind(
2 \Symfony\Component\Validator\Validator\ValidatorInterface::class,
3 function() {
4 return \Symfony\Component\Validator\Validation
5 ::createValidatorBuilder()
6 ->enableAnnotationMapping()
7 ->getValidator();
8 });

И можно использовать валидатор в сервисном классе:

1 class UserService
2 {
3 /** @var ValidatorInterface */
4 private $validator;
5
6 public function __construct(ValidatorInterface $validator)
7 {
8 $this->validator = $validator;
9 }
10
11 public function register(RegisterUserDto $dto)
12 {
13 $violations = $this->validator->validate($dto);
14
15 if (count($violations) > 0) {
16 throw new ValidationException($violations);
Валидация 90

17 }
18
19 $existingUser = User::whereEmail($dto->getEmail())->first();
20
21 if($existingUser !== null) {
22 throw new UserWithThisEmailAlreadyExists(...);
23 }
24
25 $user = new User();
26 $user->name = $dto->getName();
27 $user->email = $dto->getEmail();
28 $user->birthDate = $dto->getBirthDate();
29
30 $user->saveOrFail();
31 }
32 }

Правила валидации описываются в специальных phpDoc-комментариях, которые называют-


ся аннотациями.
Метод ValidatorInterface::validate возвращает список нарушений правил валидации.
Если он пуст - все хорошо. Если нет, выбрасываем исключение валидации - ValidationException.
Используя эту явную валидацию, в Слое Приложения можно быть уверенным в валидности
данных.
Также, в качестве бонуса можно удалить валидацию в слое Web, API и т.д, поскольку все
данные уже проверяются глубже.
Выглядит неплохо, но с этим есть некоторые проблемы.

Проблема данных Http запроса


В первую очередь, данные, которые передаются от пользователей в HTTP-запросе, не всегда
равны данным, передаваемым в Слой Приложения.
Когда пользователь меняет свой пароль, приложение запрашивает старый пароль, новый
пароль и повторить новый пароль.
Валидация Web-слоя должна проверить поля нового пароля на совпадение, а Слою Прило-
жения эти данные просто не нужны, он получит только значения старого и нового пароля.
Другой пример: одно из значений, передаваемых в Слой Приложения заполняется email-
адресом текущего пользователя.
Если этот email окажется пустым, то пользователь может увидеть сообщение “Формат email
неверный”, при том, что он даже не вводил никакого email!
Поэтому, делать валидацию пользовательского ввода в Слое Приложения - не самая лучшая
идея.
Валидация 91

Проблема сложных структур данных


Представьте некий DTO создания заказа такси - CreateTaxiOrderDto.
Это будет авиа-такси, поэтому заказы могут быть из одной страны в другую.
Там будут поля fromHouse, fromStreet, fromCity, fromState, fromCountry, toHouse, toStreet,
toCity,…
Огромный DTO с кучей полей, дублирующих друг-друга, зависящие друг от друга.
Номер дома не имеет никакого смысла без имени улицы.
Имя улицы, без города и страны.
И представьте какой будет хаос, когда наше такси станет интер-галактическим!

Value objects
Решение этой проблемы лежит прямо в RegisterUserDto.
Мы не храним отдельно $birthDay, $birthMonth и $birthYear. Не валидируем их каждый раз.
Мы просто храним объект DateTime! Он всегда хранит корректную дату и время.
Сравнивая, даты мы никогда не сравниваем их года, месяцы и дни. Там есть метод diff() для
сравнений дат.
Этот класс содержит все знание о датах внутри себя.
Давайте попробуем сделать что-то похожее:

1 final class Email


2 {
3 /** @var string */
4 private $email;
5
6 private function __construct(string $email)
7 {
8 if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
9 throw new InvalidArgumentException(
10 'Email ' . $email . ' is not valid');
11 }
12
13 $this->email = $email;
14 }
15
16 public static function create(string $email)
17 {
18 return new static($email);
19 }
20
21 public function value(): string
Валидация 92

22 {
23 return $this->email;
24 }
25 }
26
27 final class UserName
28 {
29 /** @var string */
30 private $name;
31
32 private function __construct(string $name)
33 {
34 if (/* Some validation of $name value*.
35 It depends on project requirements. */) {
36 throw new InvalidArgumentException(
37 'Invalid user name: ' . $name);
38 }
39
40 $this->name = $name;
41 }
42
43 public static function create(string $name)
44 {
45 return new static($name);
46 }
47
48 public function value(): string
49 {
50 return $this->name;
51 }
52 }
53
54 final class RegisterUserDto
55 {
56 // Поля и конструктор
57
58 public function getUserName(): UserName
59 {...}
60
61 public function getEmail(): Email
62 {...}
63
64 public function getBirthDate(): DateTime
Валидация 93

65 {...}
66 }

Да, создавать класс для каждого возможного типа вводимых данных - это не то, о чем мечтает
каждый программист.
Но это естественный путь декомпозиции приложения.
Вместо того, чтобы использовать строки и всегда сомневаться лежит ли в них нужное
значение, эти классы позволяют всегда иметь корректные значения, как с DateTime.
Этот шаблон называется Объект-значение(Value Object или VO).
Метод getEmail() больше не возвращает какую-то строку, он возвращает Email, который без
сомнения можно использовать везде, где нужны email-адреса.
UserService может без страха использовать эти значения:

1 final class UserService


2 {
3 public function register(RegisterUserDto $dto)
4 {
5 //...
6 $user = new User();
7 $user->name = $dto->getName()->value();
8 $user->email = $dto->getEmail()->value();
9 $user->birthDate = $dto->getBirthDate();
10
11 $user->saveOrFail();
12 }
13 }

Да, вызовы ->value() выглядят не очень.


Это можно решить перекрыв метод __toString() в классах Email и UserName, но я не уверен,
что это стработает со значениями в Eloquent.
Даже если и сработает - это будет неявной магией.
Я лишнюю магию не люблю, позднее в книге мы найдем решение этой проблемы.

Объект-значение как композиция других


значений
Объекты-значения Email и UserName это просто оболочки для строк.
Шаблон Объект-значение - более широкое понятие.
Георгафическая координата может быть описана двумя float значениями: долгота и широта.
Обычно, мало кому интересна долгота, без знания широты.
Создав объект GeoPoint, можно во всем приложении работать с ним.
Валидация 94

1 final class GeoPoint


2 {
3 /** @var float */
4 private $latitude;
5
6 /** @var float */
7 private $longitude;
8
9 public function __construct(float $latitude, float $longitude)
10 {
11 $this->latitude = $latitude;
12 $this->longitude = $longitude;
13 }
14
15 public function getLatitude(): float
16 {
17 return $this->latitude;
18 }
19
20 public function getLongitude(): float
21 {
22 return $this->longitude;
23 }
24
25 public function isEqual(GeoPoint $other): bool
26 {
27 // просто примера ради
28 return $this->getDistance($other)->getMeters() < 10;
29 }
30
31 public function getDistance(GeoPoint $other): Distance
32 {
33 // Вычисление расстояния между $this и $other
34 }
35 }
36
37 final class City
38 {
39 //...
40 public function setCenterPoint(GeoPoint $centerPoint)
41 {
42 $this->centerLatitude = $centerPoint->getLatitude();
43 $this->centerLongitude = $centerPoint->getLongitude();
Валидация 95

44 }
45
46 public function getCenterPoint(): GeoPoint
47 {
48 return new GeoPoint(
49 $this->centerLatitude, $this->centerLongitude);
50 }
51
52 public function getDistance(City $other): Distance
53 {
54 return $this->getCenterPoint()
55 ->getDistance($other->getCenterPoint());
56 }
57
58 }

Примером того, как знание о координатах инкапсулировано в классе GeoPoint, является


метод getDistance класса City.
Для вычисления дистанции между городами, просто используется расстояние между двумя
центральными точками городов.
Другие примеры объектов-значений:

• Money(int amount, Currency currency)


• Address(string street, string city, string state, string country, string zipcode)

Вы заметили, что в прошлом примере я пытаюсь не использовать примитивные типы, такие


как строки и числа?
Метод getDistance() возвращает объект Distance, а не int или float.
Класс Distance может иметь методы getMeters(): float или getMiles(): float.
А также Distance::isEqual(Distance $other) для сравнения двух расстояний.
Это тоже объект-значение!
Хорошо, иногда это чересчур.
Для многих проектов GeoPoint::getDistance(): возвращающий число с плавающей запятой
расстояния в метрах более, чем достаточно.
Я лишь хотел показать пример того, что я называю “мышлением объектами”.
Мы ещё вернемся к объектам-значениям позднее в этой книге.
Вероятно, вы понимаете, что этот шаблон слишком мощныЙ, чтобы использоваться только
как поле в DTO.

Объекты-значения не для валидации


Валидация 96

1 final class RegisterUserDto


2 {
3 // fields and constructor
4
5 public function getUserName(): UserName
6 {...}
7
8 public function getEmail(): Email
9 {...}
10
11 public function getBirthDate(): DateTime
12 {...}
13 }
14
15 final class UserController extends Controller
16 {
17 public function register(
18 Request $request, UserService $service)
19 {
20 $this->validate($request, [
21 'name' => 'required',
22 'email' => 'required|email',
23 'birth_date' => 'required|date',
24 ]);
25
26 $service->register(new RegisterUserDto(
27 UserName::create($request['name']),
28 Email::create($request['email']),
29 DateTime::createFromFormat('some format', $request)
30 ));
31
32 //return ok response
33 }
34 }

В этом коде можно легко найти дубликаты.


Значение email-адреса сначала валидируется с помощью Laravel валидации, а потом в
конструкторе класса Email:
Валидация 97

1 final class Email


2 {
3 private function __construct(string $email)
4 {
5 if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
6 throw new InvalidArgumentException(
7 'Email ' . $email . ' is not valid');
8 }
9
10 $this->email = $email;
11 }
12
13 public static function create(string $email)
14 {
15 return new static($email);
16 }
17 }

Идея удаления кода Laravel-валидации выглядит интересной.


Можно удалить вызов $this->validate() и просто ловить исключение InvalidArgumentException
в глобальном обработчике ошибок.
Но, как я уже говорил, данные HTTP-запроса, не всегда равны данным, передаваемым в Слой
Приложения, да и исключение InvalidArgumentException может быть выброшено во многих
других ситуациях.
Опять может повториться ситуация, когда пользователь видит ошибки про данные, которые
он не вводил.
Если вы помните, PhpStorm по умолчанию имеет 3 класса непроверяемых исключений:
Error, RuntimeException and LogicException:

• Error означает ошибку языка PHP, например TypeError, ParseError, и т.д.


• RuntimeException означает ошибку времени выполнения, не зависящую от нашего
кода, например проблемы с соединением с базой данных.
• InvalidArgumentException наследует от LogicException.

Описание LogicException в документации PHP: “Исключение означает ошибку в логике


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

Пара слов в конце главы


Вынесение логики в Слой Приложения ведет к некоторым проблемам с валидацией данных.
Мы не можем напрямую использовать объекты FormRequest и приходится использовать
некие объекты передачи данных(DTO), пусть даже это будут простые массивы.
Если Слой Приложения всегда получает ровно те данные, которые ввел пользователь, то вся
валидация может быть перенесена туда и использовано решение с пакетом symfony/validator
или другим.
Но это будет опасно и не очень удобно, если идет работа со сложными структурами данных,
такими как адреса или точки координат например.
Валидация может быть оставлена в Web, API и других частях кода, а Слой Приложения будет
просто доверять переданным ему данным.
По моему опыту это работает только в маленьких проектах.
Большие проекты, над которыми работают команды разработчиков, постоянно будут стал-
киваться с проблемами невалидных данных, которые будут вести к неправильным значени-
ям в базе данных или просто к неправильным исключениям.
Шаблон объект-значение требует некоторого дополнительного кодинга и “мышления объек-
тами” от программистов, но это наиболее безопасный и естественный способ представлять
данные, имеющие какой-то дополнительный смысл, т.е. “не просто строка, а email”.
Как всегда, это выбор между краткосрочной и долгосрочной производительностью.
События
Правило прокрастинатора: если что-то можно отложить, это надо отложить.

Действие Слоя Приложения всегда содержит главную часть, некоторые дополнительные


действия.
Регистрация пользователя состоит из собственно создания сущности пользователя, а также
посылка соответствующего письма.
Обновление текста статьи содержит обновление значения $post->text с сохранением сущно-
сти, а также, например вызов Cache::forget для инвалидации кеша.
Псевдо-реальный пример: сайт с опросами.
Создание сущности опроса:

1 final class PollService


2 {
3 /** @var BadWordsFilter */
4 private $badWordsFilter;
5
6 public function __construct(BadWordsFilter $badWordsFilter
7 /*, ... другие зависимости */)
8 {
9 $this->badWordsFilter = $badWordsFilter;
10 }
11
12 public function create(PollCreateDto $request)
13 {
14 $poll = new Poll();
15 $poll->question = $this->badWordsFilter->filter(
16 $request->getQuestion());
17 //...
18 $poll->save();
19
20 foreach($request->getOptionTexts() as $optionText)
21 {
22 $pollOption = new PollOption();
23 $pollOption->poll_id = $poll->id;
24 $pollOption->text =
События 100

25 $this->badWordsFilter->filter($optionText);
26 $pollOption->save();
27 }
28
29 // Вызов генерации sitemap
30
31 // Оповестить внешнее API о новом опросе
32 }
33 }

Это обычное создание опроса с опциями ответа, с фильтрацией мата во всех текстах и
некоторыми пост-действиями.
Объект опроса непростой.
Он абсолютно бесполезен без опций ответа.
Мы должны позаботиться о его консистентности.
Этот маленький промежуток времени, когда мы уже создали объект опроса и добавили его
в базу данных, но еще не создали его опции ответа, очень опасен!

Database transactions
Первая проблема - база данных.
В это время может произойти некоторая ошибка (с соединением с базой данных, да даже
просто в функции проверки на мат) и объект опроса будет в базе данных, но без опций
ответа.
Все движки баз данных, которые созданы для хранения важных данных, имеют механизм
транзакций.
Они гарантируют консистентность внутри транзакции.
Все запросы, обернутые в транзакцию, либо будут выполнены полностью, либо, при возник-
новении ошибки во время выполнения, ни один из них.
Выглядит как решение:

1 final class PollService


2 {
3 /** @var DatabaseConnection */
4 private $connection;
5
6 public function __construct(..., DatabaseConnection $connection)
7 {
8 ...
9 $this->connection = $connection;
10 }
11
События 101

12 public function create(PollCreateDto $request)


13 {
14 $this->connection->transaction(function() use ($request) {
15 $poll = new Poll();
16 $poll->question = $this->badWordsFilter->filter(
17 $request->getQuestion());
18 //...
19 $poll->save();
20
21 foreach($request->getOptionTexts() as $optionText) {
22 $pollOption = new PollOption();
23 $pollOption->poll_id = $poll->id;
24 $pollOption->text =
25 $this->badWordsFilter->filter($optionText);
26 $pollOption->save();
27 }
28
29 // Вызов генерации sitemap
30
31 // Оповестить внешнее API о новом опросе
32 });
33 }
34 }

Отлично, наши данные теперь консистентны, но эта магия транзакций даётся базе данных
небесплатно.
Когда мы выполняем запросы в транзакции, база данных вынуждена хранить две копии
данных: для успешного или неуспешного результатов.
Для проектов с нагрузкой, которые могут выполнять сотни одновременных транзакций,
которые длятся долго, это может сильно сказаться на производительности.
Для проектов с небольшой нагрузкой это не настолько важно, но все равно стоит приобрести
привычку выполнять транзакции как можно быстрее.
Проверка на мат может требовать запроса на специальное API, которое может занять страшно
много времени.
Попробуем вынести из транзакции все, что возможно:
События 102

1 final class PollService


2 {
3 public function create(PollCreateDto $request)
4 {
5 $filteredRequest = $this->filterRequest($request);
6
7 $this->connection->transaction(
8 function() use ($filteredRequest) {
9 $poll = new Poll();
10 $poll->question = $filteredRequest->getQuestion();
11 //...
12 $poll->save();
13
14 foreach($filteredRequest->getOptionTexts()
15 as $optionText) {
16 $pollOption = new PollOption();
17 $pollOption->poll_id = $poll->id;
18 $pollOption->text = $optionText;
19 $pollOption->save();
20 }
21 });
22
23 // Вызов генерации sitemap
24
25 // Оповестить внешнее API о новом опросе
26 }
27
28 private function filterRequest(
29 PollCreateDto $request): PollCreateDto
30 {
31 // фильтрует тексты в запросе
32 // и возвращает такое же DTO но с "чистыми" данными
33 }
34 }

Очереди
Вторая проблема - время выполнения запроса.
Приложение должно отвечать как можно быстрее.
Создание опроса содержит тяжелые действия, такие как генерация sitemap или вызовы
внешних API.
Обычное решение - отложить эти действия с помощью механизма очередей.
События 103

Вместо того, чтобы выполнять тяжелое действие в обработчике web-запроса, приложение


может создать задачу для выполнения этого действия и положить его в очередь.
Очередью может быть таблица в базе данных, список в Redis или специальный софт для
очередей, например RabbitMQ.
Laravel предоставляет несколько путей работы с очередями. Один из них: jobs. Можно
перевести как Работы или Задачи.
Как я говорил ранее, действие состоит из главной части и несколько второстепенных
действий.
Главное действие при создании сущности опроса не может быть выполнено без фильтрации
мата, но все пост-действия можно отложить.
Вообще, в некоторых ситуациях, которые занимают слишком много времени, можно дей-
ствие полностью отложить, сказав пользователю “Скоро выполним”, но это пока не наш
случай.

1 final class SitemapGenerationJob implements ShouldQueue


2 {
3 public function handle()
4 {
5 // Вызов генератора sitemap
6 }
7 }
8
9 final class NotifyExternalApiJob implements ShouldQueue {}
10
11 use Illuminate\Contracts\Bus\Dispatcher;
12
13 final class PollService
14 {
15 /** @var Dispatcher */
16 private $dispatcher;
17
18 public function __construct(..., Dispatcher $dispatcher)
19 {
20 ...
21 $this->dispatcher = $dispatcher;
22 }
23
24 public function create(PollCreateDto $request)
25 {
26 $filteredRequest = $this->filterRequest($request);
27
28 $poll = new Poll();
События 104

29 $this->connection->transaction(...);
30
31 $this->dispatcher->dispatch(
32 new SitemapGenerationJob());
33 $this->dispatcher->dispatch(
34 new NotifyExternalApiJob($poll->id));
35 }
36 }

Если класс содержит интерфейс ShouldQueue, то выполнение этой задачи будет отложено в
очередь.
Этот код выполняется достаточно быстро, но мне он все ещё не нравится.
Таких пост-действий может быть очень много и сервисный класс начинает знать слишком
много.
Он выполняет каждое пост-действие, но с высокоуровневой точки зрения, действие создания
опроса не должно знать про генерацию sitemap или взаимодействие с внешними API.
В проектах с огромным количеством пост-действий, контролировать их становится очень
непросто.

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

1 final class PollCreated


2 {
3 /** @var int */
4 private $pollId;
5
6 public function __construct(int $pollId)
7 {
8 $this->pollId = $pollId;
9 }
10
11 public function getPollId(): int
12 {
13 return $this->pollId;
14 }
15 }
16
События 105

17 use Illuminate\Contracts\Events\Dispatcher;
18
19 final class PollService
20 {
21 /** @var Dispatcher */
22 private $dispatcher;
23
24 public function __construct(..., Dispatcher $dispatcher)
25 {
26 ...
27 $this->dispatcher = $dispatcher;
28 }
29
30 public function create(PollCreateDto $request)
31 {
32 // ...
33
34 $poll = new Poll();
35 $this->connection->transaction(
36 function() use ($filteredRequest, $poll) {
37 // ...
38 });
39
40 $this->dispatcher->dispatch(new PollCreated($poll->id));
41 }
42 }
43
44 final class SitemapGenerationListener implements ShouldQueue
45 {
46 public function handle($event)
47 {
48 // Call sitemap generator
49 }
50 }
51
52 final class EventServiceProvider extends ServiceProvider
53 {
54 protected $listen = [
55 PollCreated::class => [
56 SitemapGenerationListener::class,
57 NotifyExternalApiListener::class,
58 ],
59 ];
События 106

60 }

Теперь Слой Приложения просто сообщает, что был создан новый опрос (событие PollCreated).
Приложение имеет конфигурацию для реагирования на события.
Классы слушателей событий (Listener) содержат реакцию на события.
Интерфейс ShouldQueue работает точно также, сообщая о том, когда должно быть запущено
выполнение этого слушателя: сразу же или в отложено.
События - очень мощная вещь, но здесь тоже есть ловушки.

Использование событий Eloquent


Laravel генерирует кучу событий.
События системы кеширования: CacheHit, CacheMissed, и т.д..
События оповещений: NotificationSent, NotificationFailed, и т.д..
Eloquent тоже генерирует свои события.
Пример из документации:

1 class User extends Authenticatable


2 {
3 /**
4 * The event map for the model.
5 *
6 * @var array
7 */
8 protected $dispatchesEvents = [
9 'saved' => UserSaved::class,
10 'deleted' => UserDeleted::class,
11 ];
12 }

Событие UserSaved будет генерироваться каждый раз когда сущность пользователя будет
сохранено в базу данных.
Сохранено, означает любой update или insert запрос.
Использование этих событий имеет множество недостатков.
UserSaved не самое удачное название для этого события.
UsersTableRowInsertedOrUpdated более подходящее.
Но и оно не всегда верное.
Это событие не будет сгенерировано при массовых операциях со строками базы данных.
Событие “Deleted” не будет вызвано, если строка в базе данных будет удалена с помощью
механизма каскадного удаления в базе данных.
Главная же проблема это то, что это события уровня инфраструктуры, события строчек базы
данных, но они используются как бизнес-события, или события доменной области.
Разницу легко осознать в примере с созданием сущности опроса:
События 107

1 final class PollService


2 {
3 public function create(PollCreateDto $request)
4 {
5 //...
6 $this->connection->transaction(function() use (...) {
7 $poll = new Poll();
8 $poll->question = $filteredRequest->getQuestion();
9 //...
10 $poll->save();
11
12 foreach($filteredRequest->getOptionTexts() as $optionText){
13 $pollOption = new PollOption();
14 $pollOption->poll_id = $poll->id;
15 $pollOption->text = $optionText;
16 $pollOption->save();
17 }
18 });
19 //...
20 }
21 }

Вызов $poll->save(); сгенерирует событие ‘saved’ для этой сущности.


Первая проблема в том, что объект опроса еще не готов и неконсистентен.
Он все еще не имеет опций ответа.
В слушателе, который захочет отправить email с этим опросом, явно хочется иметь этот
объект полностью, со всеми вариантами ответа.
Для отложенных слушателей это не проблема, но на машинах разработчиков часто значение
QUEUE_DRIVER - ‘sync’, поэтому все отложенные слушатели и задачи будут выполняться
сразу и поведение будет разным.
Я сильно рекомендую избегать кода, который правильно работает “иногда, в некоторой
особой ситуации”, а иногда может преподнести неприятный сюрприз.
Вторая проблема - эти события вызываются прямо внутри транзакции.
Выполнение слушателей сразу или даже отправка их в очередь делают транзакции более
долгими и хрупкими.
А самое страшное, что событие вроде PollCreated, может быть вызвано, но дальше в
транзакции будет ошибка и вся она будет откачена назад.
Письмо же пользователю об опросе, который даже не создался, все равно будет отправлено.
Я нашел пару пакетов для Laravel, которые ловят все эти события, хранят их временно, и
выполняют только после того, как транзакция будет успешно завершена (гуглите “Laravel
transactional events”).
Да, они решают множество из этих проблем, но всё это выглядит так неестественно!
События 108

Простая идея генерировать нормальное бизнес-событие PollCreated после успешной тран-


закции намного лучше.

Сущности как поля классов-событий


Я часто вижу как Eloquent-сущности используются напрямую в поля событий:

1 final class PollCreated


2 {
3 /** @var Poll */
4 private $poll;
5
6 public function __construct(Poll $poll)
7 {
8 $this->poll = $poll;
9 }
10
11 public function getPoll(): Poll
12 {
13 return $this->poll;
14 }
15 }
16
17 final class PollService
18 {
19 public function create(PollCreateDto $request)
20 {
21 // ...
22 $poll = new Poll();
23 // ...
24 $this->dispatcher->dispatch(new PollCreated($poll));
25 }
26 }
27
28 final class SendPollCreatedEmailListener implements ShouldQueue
29 {
30 public function handle(PollCreated $event)
31 {
32 // ...
33 foreach($event->getPoll()->options as $option)
34 {...}
События 109

35 }
36 }

Это просто пример слушателя, который использует значения HasMany-отношения.


Этот код работает. Когда выполняется код $event->getPoll()->options Eloquent делает запрос
в базу данных и получает все опции ответа.
Другой пример:

1 final class PollOptionAdded


2 {
3 /** @var Poll */
4 private $poll;
5
6 public function __construct(Poll $poll)
7 {
8 $this->poll = $poll;
9 }
10
11 public function getPoll(): Poll
12 {
13 return $this->poll;
14 }
15 }
16
17 final class PollService
18 {
19 public function addOption(PollAddOptionDto $request)
20 {
21 $poll = Poll::findOrFail($request->getPollId());
22
23 if($poll->options->count() >= Poll::MAX_POSSIBLE_OPTIONS) {
24 throw new BusinessException('Max options amount exceeded');
25 }
26
27 $poll->options()->create(...);
28
29 $this->dispatcher->dispatch(new PollOptionAdded($poll));
30 }
31 }
32
33 final class SomeListener implements ShouldQueue
34 {
35 public function handle(PollOptionAdded $event)
События 110

36 {
37 // ...
38 foreach($event->getPoll()->options as $option)
39 {...}
40 }
41 }

А вот тут уже не все хорошо.


Когда сервисный класс проверяет количество опций ответа, он получается свежую коллек-
цию текущих опций ответа данного опроса.
Потом он добавляет новую опцию, вызвав $poll->options()->create(…);
Дальше, слушатель, выполняя $event->getPoll()->options получает старую версию опций
ответа, без новосозданной.
Это поведение Eloquent, который имеет два механизма работы с отношениями. Метод
options() и псевдо-поле options, которое вроде бы и соответствует этому методу, но хранит
свою версию данных.
Поэтому, передавая сущность в события, разработчик должен озаботиться консистентностью
значений в отношениях, например вызвав:

1 $poll->load('options');

до передачи объекта в событие.


Это все делает код приложения весьма хрупким. Его легко сломать неосторожной передачей
объекта в событие.
Намного проще просто передавать id сущности.
Каждый слушатель всегда может получить свежую версию сущности, запросив её из базы
данных по этому id.
Unit-тестирование
100% покрытие тестами должно быть следствием, а не целью.

Первые шаги
Вы, вероятно, уже слышали про unit-тестирование.
Оно довольно популярно сейчас.
Я довольно часто общаюсь с разработчиками, которые утверждают, что не начинают писать
код, пока не напишут тест для него.
TDD-маньяки!
Начинать писать unit-тесты довольно сложно, особенно если вы пишете, используя фрейм-
ворки, такие как Laravel.
Unit-тесты одни из лучших индикаторов качества кода в проекте.
Фреймворки пытаются сделать процесс добавления новых фич как можно более быстрым,
позволяя срезать углы в некоторых местах, но высоко-связанный код обычная тому цена.
Сущности железно связанные с базой данных, классы с большим количеством зависимостей,
которые бывает трудно найти (Laravel фасады).
В этой главе я постараюсь протестировать код Laravel приложения и показать главные
трудности, но начнем с самого начала.
Чистая функция - это функция, результат которой зависит только от введенных данных.
Она не меняет никакие внешние значения и просто вычисляет результат.
Примеры:

1 function strpos(string $needle, string $haystack)


2 function array_chunk(array $input, $size, $preserve_keys = null)

Чистые функции очень простые и предсказуемые.


Unit-тесты для них писать легко.
Попробуем написать простую функцию (это может быть и методом класса):
Unit-тестирование 112

1 function cutString(string $source, int $limit): string


2 {
3 return ''; // просто пустая строка пока
4 }
5
6 class CutStringTest extends \PHPUnit\Framework\TestCase
7 {
8 public function testEmpty()
9 {
10 $this->assertEquals('', cutString('', 20));
11 }
12
13 public function testShortString()
14 {
15 $this->assertEquals('short', cutString('short', 20));
16 }
17
18 public function testCut()
19 {
20 $this->assertEquals('long string shoul...',
21 cutString('long string should be cut', 20));
22 }
23 }

Я здесь использую PHPUnit для написания тестов.


Название функции не очень удачное, но просто взглянув на тесты, можно понять что она
делает.
Тесты проверяют результат с помощью assertEquals.
Unit-тесты могут служить документацией к коду, если они такие же простые и легко-
читаемые.
Если я запущу эти тесты, то получу такой вывод:

1 Failed asserting that two strings are equal.


2 Expected :'short'
3 Actual :''
4
5 Failed asserting that two strings are equal.
6 Expected :'long string shoul...'
7 Actual :''

Разумеется, ведь наша функция еще не написана.


Время ее написать:
Unit-тестирование 113

1 function cutString(string $source, int $limit): string


2 {
3 $len = strlen($source);
4
5 if($len < $limit) {
6 return $source;
7 }
8
9 return substr($source, 0, $limit-3) . '...';
10 }

Вывод PHPUnit после этих правок:

1 OK (3 tests, 3 assertions)

Отлично!
Класс unit-теста содержит список требований к функции:

• Для пустой строки результат тоже должен быть пуст.


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

Успешные тесты говорят о том, что код удовлетворяет требованиям.


Но это не так!
В коде небольшая ошибка и функция не работает как задумано, если длина строки совпадает
с лимитом.
Хорошая привычка: если найден баг, надо написать тест, который его воспроизведен и
упадёт.
В любом случае, нам нужно будет проверить исправлен ли этот баг и unit-тест хорошее место
для этого.
Новые тест-методы:
Unit-тестирование 114

1 class CutStringTest extends \PHPUnit\Framework\TestCase


2 {
3 // старые тесты
4
5 public function testLimit()
6 {
7 $this->assertEquals('limit', cutString('limit', 5));
8 }
9
10 public function testBeyondTheLimit()
11 {
12 $this->assertEquals('beyondl...',
13 cutString('beyondlimit', 10));
14 }
15 }

testBeyondTheLimit выполняется хорошо, а testLimit падает:

1 Failed asserting that two strings are equal.


2 Expected :'limit'
3 Actual :'li...'

Исправление простое: поменять < на <=

1 function cutString(string $source, int $limit): string


2 {
3 $len = strlen($source);
4
5 if($len <= $limit) {
6 return $source;
7 }
8
9 return substr($source, 0, $limit-3) . '...';
10 }

Сразу же запускаем тесты:

1 OK (5 tests, 5 assertions)

Отлично. Проверка краевых значений (0, длина $limit, длина $limit+1, и т.д.) очень важная
часть тестирования.
Многие ошибки находятся именно в этих местах.
Unit-тестирование 115

Когда я писал функцию cutString, я думал, что длина исходной строки мне понадобится
дальше и сохранил её в переменную, но оказалось, что дальше нам нужна только переменная
$limit.
Теперь я могу удалить эту переменную.

1 function cutString(string $source, int $limit): string


2 {
3 if(strlen($source) <= $limit) {
4 return $source;
5 }
6
7 return substr($source, 0, $limit-3) . '...';
8 }

И опять: запускаем тесты!


Я изменил код и мог что-то сломать при этом.
Лучше это обнаружить как можно скорее и исправить.
Эта привычка сильно повышает итоговую производительность.
С хорошо написанными тестами, почти любая ошибка будет поймана сразу и разработчик
может исправить её пока тот код, который он поменял, у него всё еще в голове.
Я всё внимание обратил на главный функционал и забыл про пред-условия.
Разумеется, параметр $limit в реальном проекте никогда не будет слишком маленький, но
хороший дизайн функции предполагает проверку этого значения тоже:

1 class CutStringTest extends \PHPUnit\Framework\TestCase


2 {
3 //...
4
5 public function testLimitCondition()
6 {
7 $this->expectException(InvalidArgumentException::class);
8
9 cutString('limit', 4);
10 }
11 }
12
13 function cutString(string $source, int $limit): string
14 {
15 if($limit < 5) {
16 throw new InvalidArgumentException(
17 'The limit is too low');
18 }
Unit-тестирование 116

19
20 if(strlen($source) <= $limit) {
21 return $source;
22 }
23
24 return substr($source, 0, $limit-3) . '...';
25 }

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

Тестирование классов с состоянием


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

1. инициализация объекта в нужном состоянии


2. выполнение тестируемого действия
3. проверка результата

Есть также шаблон AAA: Arrange, Act, Assert, который описывает те же три шага.

Тесты чистых функций тоже имеют эти 3 части, но все они располагаются в одной строке
кода.
Начну простого пример с теста воображаемой сущности Статья, которая не является Eloquent
моделью.
Её можно создать только с непустым заголовком, а текст может быть пустым.
Но опубликовать эту статью можно только, если её текст не пустой.
Unit-тестирование 117

1 class Post
2 {
3 /** @var string */
4 public $title;
5
6 /** @var string */
7 public $body;
8
9 /** @var bool */
10 public $published;
11
12 public function __construct(string $title, string $body)
13 {
14 if (empty($title)) {
15 throw new InvalidArgumentException(
16 'Title should not be empty');
17 }
18
19 $this->title = $title;
20 $this->body = $body;
21 $this->published = false;
22 }
23
24 public function publish()
25 {
26 if (empty($this->body)) {
27 throw new CantPublishException(
28 'Cant publish post with empty body');
29 }
30
31 $this->published = true;
32 }
33 }

Конструктор класса Post - чистая функция, поэтому тесты для нее подобны предыдущим:
Unit-тестирование 118

1 class CreatePostTest extends \PHPUnit\Framework\TestCase


2 {
3 public function testSuccessfulCreate()
4 {
5 // инициализация и выполнение
6 $post = new Post('title', '');
7
8 // проверка
9 $this->assertEquals('title', $post->title);
10 }
11
12 public function testEmptyTitle()
13 {
14 // проверка
15 $this->expectException(InvalidArgumentException::class);
16
17 // инициализация и выполнение
18 new Post('', '');
19 }
20 }

Однако, метод publish зависит от текущего состояния объекта и части тестов более ощутимы:

1 class PublishPostTest extends \PHPUnit\Framework\TestCase


2 {
3 public function testSuccessfulPublish()
4 {
5 // инициализация
6 $post = new Post('title', 'body');
7
8 // выполнение
9 $post->publish();
10
11 // проверка
12 $this->assertTrue($post->published);
13 }
14
15 public function testPublishEmptyBody()
16 {
17 // инициализация
18 $post = new Post('title', '');
19
20 // проверка
Unit-тестирование 119

21 $this->expectException(CantPublishException::class);
22
23 // выполнение
24 $post->publish();
25 }
26 }

При тестировании исключений проверка, которая обычно последняя, происходит до вы-


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

Тестирование классов с зависимостями


Одной из важных особенностей unit-тестирования является тестирование в изоляции.
Unit (класс, функция или другой модуль) должен быть изолирован от всего остального мира.
Это будет гарантировать, что тест тестирует только этот модуль.
Тест может упасть только по двум причинам: неправильный тест или неправильный код
тестируемого модуля.
Тестирование в изоляции даёт нам эту простоту и быстродействие.
Настоящие unit-тесты выполняются очень быстро, поскольку во время их выполнения не
происходит никаких тяжелых операций, вроде запросов в базу данных, чтения файлов или
вызовов API.
Когда класс просит некоторые зависимости, тест должен их ему предоставить.

Зависимости на реальные классы


В главе про внедрение зависимостей я разговаривал про два типа возможных интерфейсов:

1. Есть интерфейс и несколько возможных реализаций.


2. Есть интерфейс и одна реализация.

Для второго случая я предлагал не создавать интерфейса, теперь же хочу проанализировать


это.
Какая зависимость может быть реализована только одним возможным способом?
Все операции ввода/вывода, такие как вызовы API, операции с файлами или запросы в
базу данных, всегда могут иметь другие возможные реализации. С другим драйвером,
декоратором и т.д.
Иногда класс содержит некоторые большие вычисления и разработчик решает вынести эту
логику в отдельный класс.
Этот новый класс становится новой зависимостью.
Unit-тестирование 120

В этом случае трудно себе представить другой возможный вариант реализации этой зави-
симости и это прекрасный момент, чтобы поговорить про инкапсуляцию и почему unit-
тестирование называется unit-тестированием, т.е. тестированием модулей, а не тестирова-
ние классов или функций.
Это пример описанного случая. Класс TaxCalculator был вынесен в свой класс из класса
OrderService.

1 class OrderService
2 {
3 /** @var TaxCalculator $taxCalculator */
4 private $taxCalculator;
5
6 public function __construct(TaxCalculator $taxCalculator)
7 {
8 $this->taxCalculator = $taxCalculator;
9 }
10
11 public function create(OrderCreateDto $orderCreateDto)
12 {
13 $order = new Order();
14 //...
15 $order->sum = ...;
16 $order->taxSum = $this->taxCalculator
17 ->calculateOrderTax($order);
18 //...
19 }
20 }

Но если мы взглянем на класс OrderService, то увидим, что TaxCalculator не выглядит его


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

Класс OrderService здесь модуль, который содержит не только класс OrderService, но и класс
TaxCalculator.
Класс TaxCalculator должен быть внутренней зависимостью, а не внешней.
Unit-тестирование 121

1 class OrderService
2 {
3 /** @var TaxCalculator $taxCalculator */
4 private $taxCalculator;
5
6 public function __construct()
7 {
8 $this->taxCalculator = new TaxCalculator();
9 }
10
11 //...
12 }

Теперь всему остальному коду необязательно знать про TaxCalculator.


Unit-тесты могут тестировать класс OrderService не заботясь о предоставлении ему объекта
TaxCalculator.
Если условия изменятся и TaxCalculator станет внешней зависимостью (разные алгоритмы
подсчета налогов), то зависимость будет несложно сделать публичной, нужно будет просто
поставить его как параметр в конструктор и поменять код тестов.
Модуль - весьма широкое понятие.
В начале этой статьи модулем была маленькая функция, а иногда в модуле может содер-
жаться несколько классов.
Программные объекты внутри модуля должны быть сфокусированы на одной ответственно-
сти, другими словами, иметь сильную связность.
Когда методы класса полностью независимы друг от друга, класс не является модулем.
Каждый метод класса - это модуль в данном случае.
Возможно, стоит вынести эти методы в отдельные классы, чтобы разработчики не просмат-
ривали кучу лишнего кода каждый раз?
Помните я говорил, что часто предпочитаю классы одной команды, такие как PublishPostCommand,
а не PostService классы?
Это упрощает тестирование.

Стабы и фейки
Обычно зависимость - это интерфейс, который имеет несколько реализаций.
Использование реальных реализаций этого интерфейса во время unit-тестирования - плохая
идея, поскольку там могут проводиться те самые операции ввода-вывода, замедляющие
тестирование и не дающие провести тестирование этого модуля в изоляции.
Прогон unit-тестов должен быть быстр как молния, поскольку запускаться они будут часто
и важно, чтобы разработчик запустив их не потерял фокус над кодом.
Написал код - прогнал тесты, еще написал код - прогнал тесты.
Быстрые тесты позволят ему оставаться более продуктивным, не позволяя отвлекаться.
Unit-тестирование 122

Решение в лоб задачи изоляции класса от зависимостей - создание отдельной реализации


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

1 interface TaxCalculator
2 {
3 public function calculateOrderTax(Order $order): float;
4 }
5
6 class OrderService
7 {
8 /** @var TaxCalculator $taxCalculator */
9 private $taxCalculator;
10
11 public function __construct(TaxCalculator $taxCalculator)
12 {
13 $this->taxCalculator = $taxCalculator;
14 }
15
16 public function create(OrderCreateDto $orderCreateDto)
17 {
18 $order = new Order();
19 //...
20 $order->sum = ...;
21 $order->taxSum = $this->taxCalculator
22 ->calculateOrderTax($order);
23 //...
24 }
25 }
26
27 class FakeTaxCalculator implements TaxCalculator
28 {
29 public function calculateOrderTax(Order $order): float
30 {
31 return 0;
32 }
33 }
34
35 class OrderServiceTest extends \PHPUnit\Framework\TestCase
36 {
37 public function testCreate()
38 {
Unit-тестирование 123

39 $orderService = new OrderService(new FakeTaxCalculator());


40
41 $orderService->create(new OrderCreateDto(...));
42
43 // some assertions
44 }
45 }

Работает!
Такие классы называются фейками.
Библиотеки для unit-тестирования могут создавать такие классы на лету.
Тот же самый тест, но с использованием метода createMock библиотеки PHPUnit:

1 class OrderServiceTest extends \PHPUnit\Framework\TestCase


2 {
3 public function testCreate()
4 {
5 $stub = $this->createMock(TaxCalculator::class);
6
7 $stub->method('calculateOrderTax')
8 ->willReturn(0);
9
10 $orderService = new OrderService($stub);
11
12 $orderService->create(new OrderCreateDto(...));
13
14 // some assertions
15 }
16 }

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

Моки
Иногда разработчик хочет протестировать вызваны ли методы стаба, сколько раз и какие
параметры переданы были.

Вообще, я не считаю идею тестирования вызовов методов зависимостей хорошей


идеей.
Unit-тестирование 124

Unit-тест в этом случае начинает знать слишком многое о том, как этот класс
работает.
Как следствие, такие тесты очень легко ломаются.
Небольшой рефакторинг и тесты падают.
если это случается слишком часто, команда может просто забить на unit-тестирование.
Это называется тестированием методом белого ящика.
Тестированием методом черного ящика пытается тестировать только входные и
выходные данные, не залезая внутрь.
Разумеется, тестирование черным ящиком намного стабильнее.

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

1 class OrderServiceTest extends \PHPUnit\Framework\TestCase


2 {
3 public function testCreate()
4 {
5 $stub = $this->createMock(TaxCalculator::class);
6
7 // Конфигурация мок-класса
8 $stub->expects($this->once())
9 ->method('calculateOrderTax')
10 ->willReturn(0);
11
12 $orderService = new OrderService($stub);
13
14 $orderService->create(new OrderCreateDto(...));
15
16 // некоторые проверки
17 }
18 }

Теперь тест проверяет, что во время выполнения метода OrderService::create с переданными


параметрами TaxCalculator::calculateOrderTax был вызван ровно один раз.
С мок-классами можно делать различные проверки на значения параметров и количество
вызовов, настраивать возвращаемые значения, выбрасывать исключения и т.д.
Я не хочу фокусироваться на этом в данной книге.
Фейки, стабы и моки имеют общее имя - test doubles, название для объектов, которые
подставляются вместо реальных с целью тестирования.
Они могут использоваться не только в unit-тестах, но в и интеграционных тестах.
Unit-тестирование 125

Типы тестов ПО
Люди придумали множество способов тестировать приложения.
Security testing для проверки приложений на различные уязвимости.
Performance testing для проверки насколько хорошо приложение ведет себя при нагрузке.
В этой главе мы сфокусируемся на проверке корректности работы приложений.
Unit-тестирование уже было рассмотрено.
Интеграционное тестирование проверяет совместную работу нескольких модулей.
Пример: Попросить UserService зарегистрировать нового пользователя и проверить, что
новая строка создана в базе данных, нужное событие (UserRegistered) было сгенерировано
и соответствующий email был послан (ну или хотя бы фреймворк получил команду сделать
это).

Функциональное тестирование (приёмочное или E2E - end to end) проверяет приложение


на соответствие функциональным требованиям.
Пример: Требование о создании некоей сущности (этот процесс может быть детально описан
в QA-документации).
Тест открывает браузер, идёт на специфическую страницу, заполняет поля значениями,
“нажимает” кнопку Создать и проверяет, что нужная сущность была создана, путем поиска
её на определенной странице.

Тестирование в Laravel
Laravel (текущая версия 6.12) предоставляет много инструментов для различного тестирова-
ния.

Инструменты Laravel для функционального тестирования


Инструменты для тестирования HTTP запросов, браузерного и консоли делают функцио-
нальное тестирование в Laravel весьма удобным, но как всегда мне не нравятся примеры из
документации.
Один из них, совсем немного измененный:
Unit-тестирование 126

1 class ExampleTest extends TestCase


2 {
3 public function testBasicExample()
4 {
5 $response = $this->postJson('/users', [
6 'name' => 'Sally',
7 'email' => 'sally@example.com'
8 ]);
9
10 $response
11 ->assertOk()
12 ->assertJson([
13 'created' => true,
14 ]);
15 }
16 }

Этот тест просто проверяет, что запрос POST /user вернул успешный результат.
Это не выглядит законченным тестом.
Тест должен проверять, что пользователь реально создан.
Но как?
Первый ответ, приходящий в голову: просто сделать запрос в базу данных и проверить это.
Опять пример из документации:

1 class ExampleTest extends TestCase


2 {
3 public function testDatabase()
4 {
5 // Сделать запрос на создание пользователя
6
7 $this->assertDatabaseHas('users', [
8 'email' => 'sally@example.com'
9 ]);
10 }
11 }

Хорошо. Напишем другой тест подобным образом:


Unit-тестирование 127

1 class PostsTest extends TestCase


2 {
3 public function testDelete()
4 {
5 $response = $this->deleteJson('/posts/1');
6
7 $response->assertOk();
8
9 $this->assertDatabaseMissing('posts', [
10 'id' => 1
11 ]);
12 }
13 }

А вот тут уже небольшая ловушка.


Абсолютно такая же как и в главе про валидацию.
Просто добавив трейт SoftDeletes в класс Post, мы уроним этот тест.
Однако, приложение работает абсолютно также, выполняет те же самые требования и
пользователи этой разницы не заметят.
Функциональные тесты не должны падать в таких условиях.
Тест, который делает запрос в приложение, а потом лезет в базу данных проверять результат,
не является настоящий функциональным тестом.
Он знает слишком многое про то, как работает приложение, как оно хранит данные и какие
таблицы для этого использует.
Это еще один пример тестирования методом белого ящика.
Как я уже говорил, функциональное тестирование проверяет удовлетворяет ли приложение
функциональным требованиям.
Функциональное тестировать не про базу данных, оно о приложении в целом.
Поэтому нормальные функциональные тесты не лезут внутрь приложения, они работают
снаружи.

1 class PostsTest extends TestCase


2 {
3 public function testCreate()
4 {
5 $response = $this->postJson('/api/posts', [
6 'title' => 'Post test title'
7 ]);
8
9 $response
10 ->assertOk()
11 ->assertJsonStructure([
Unit-тестирование 128

12 'id',
13 ]);
14
15 $checkResponse = $this->getJson(
16 '/api/posts/' . $response->getData()->id);
17
18 $checkResponse
19 ->assertOk()
20 ->assertJson([
21 'title' => 'Post test title',
22 ]);
23 }
24
25 public function testDelete()
26 {
27 // Здесь некоторая инициализация, чтобы создать
28 // объект Post с id = $postId
29
30 // Удостоверяемся, что этот объект есть
31 $this->getJson('/api/posts/' . $postId)
32 ->assertOk();
33
34 // Удаляем его
35 $this->jsonDelete('/posts/' . $postId)
36 ->assertOk();
37
38 // Проверяем, что больше в приложении его нет
39 $this->getJson('/api/posts/' . $postId)
40 ->assertStatus(404);
41 }
42 }

Этому тесту абсолютно все равно как удален объект, с помощью ‘delete’ SQL запроса или с
помощью Soft delete шаблона.
Функциональный тест проверяет поведение приложения в целом.
Ожидаемое поведение, если объект удален - он не возвращается по своему id и тест проверяет
именно это.
Схема процессинга запросов “POST /posts/” и “GET /post/”:

Что должен видеть функциональный тест:


Unit-тестирование 129

Моки Laravel-фасадов
Laravel предоставляет удобную реализацию шаблона Service Locator - Laravel-фасады.
Я всегда говорю Laravel-фасады, чтобы не путаться со структурным шаблоном Фасад,
который про другое.
Laravel не только предлагает их использовать, но и предоставляет инструменты для тести-
рования кода, который использует фасады.
Давайте напишем один из предыдущих примеров с использованием Laravel-фасадов и
протестируем этот код:

1 class Poll extends Model


2 {
3 public function options()
4 {
5 return $this->hasMany(PollOption::class);
6 }
7 }
8
9 class PollOption extends Model
10 {
11 }
12
13 class PollCreated
14 {
15 /** @var int */
16 private $pollId;
17
18 public function __construct(int $pollId)
19 {
20 $this->pollId = $pollId;
21 }
22
23 public function getPollId(): int
24 {
25 return $this->pollId;
26 }
27 }
28
29 class PollCreateDto
Unit-тестирование 130

30 {
31 /** @var string */
32 public $title;
33
34 /** @var string[] */
35 public $options;
36
37 public function __construct(string $title, array $options)
38 {
39 $this->title = $title;
40 $this->options = $options;
41 }
42 }
43
44 class PollService
45 {
46 public function create(PollCreateDto $dto)
47 {
48 if(count($dto->options) < 2) {
49 throw new BusinessException(
50 "Please provide at least 2 options");
51 }
52
53 $poll = new Poll();
54
55 \DB::transaction(function() use ($dto, $poll) {
56 $poll->title = $dto->title;
57 $poll->save();
58
59 foreach ($dto->options as $option) {
60 $poll->options()->create([
61 'text' => $option,
62 ]);
63 }
64 });
65
66 \Event::dispatch(new PollCreated($poll->id));
67 }
68 }
69
70 class PollServiceTest extends TestCase
71 {
72 public function testCreate()
Unit-тестирование 131

73 {
74 \Event::fake();
75
76 $postService = new PollService();
77 $postService->create(new PollCreateDto(
78 'test title',
79 ['option1', 'option2']));
80
81 \Event::assertDispatched(PollCreated::class);
82 }
83 }

• Вызов Event::fake() трансформирует Laravel-фасад Event в мок-объект.


• Метод PostService::create создаёт опрос с опциями ответа, сохраняет его в базу данных
и генерирует событие PollCreated.
• Вызов Event::assertDispatched проверяет, что это событие было вызвано.

Я вижу несколько недостатков:

• Это не является unit-тестом.

Фасад Event был заменен моком, но база данных - нет.


Реальные строчки будут добавлены в базу данных.
Для того, чтобы сделать этот тест более чистым, в класс теста обычно добавляют трейт
RefreshDatabase, который создаёт базу данных заново каждый раз.
Это очень медленно.
Один такой тест может быть выполнен за разумное время, но сотни таких займут уже
несколько минут и никто не будет их выполнять после каждый мелких правок.

• База данных используется в каждом тесте.

Соответственно, база данных должна быть такая же как и на сервере продакшена.


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

• Тесты проверяют только генерацию событий.

Для того, чтобы проверить создание записей в базе данных нужно использовать вызовы
методов, таких как assertDatabaseHas или что-то вроде PollService::getById, которое делает
этот тест неким функциональным тестом к Слою Приложения, поскольку он просит Слой
Приложения чтото сделать и проверяет результат тоже вызвав его.
Unit-тестирование 132

• Зависимости класса PollService не описаны явно.

Чтобы понять, что конкретно он требует для своей работы, нужно просмотреть весь его код.
Это делает написание тестов для него весьма неудобным.
Хуже всего, если в класс будет добавлена новая зависимость с помощью laravel-фасада.
Тесты будут продолжать работать как ни в чем не бывало, но не с моком, а с реальной
реализацией этого фасада: реальные вызовы API и т.д.
Я слышал несколько реальных историй, когда разработчики запускали тесты и это приво-
дило к тысячам реальных денежных переводов!
Я думаю, что ноги у таких случаев растут именно из-за таких вот случаев, когда неожиданно
в тесты попадет реальная реализация.
Я называю это “форсированным интеграционным тестированием”.
Разработчик хочет написать unit-тест, но код обладает такой высокой связанностью, так
крепко сцеплен с фреймворком, что этого просто не получается.
Попробуем это сделать!

Unit-тестирование Слоя Приложения

Отсоединяем код от laravel-фасадов


Для того, чтобы протестировать метод PollService::create в изоляции, нужно убрать исполь-
зование Laravel-фасадов и базы данных (Eloquent).
Первая часть несложная, у нас есть Внедрение Зависимостей.

• Фасад Event представляет интерфейс IlluminateContractsEventsDispatcher.


• Фасад DB - IlluminateDatabaseConnectionInterface.

Вообще, последнее не совсем правда. Фасад DB представляет IlluminateDatabaseDatabaseManager,


который содержит вот такую вот магию:

1 class DatabaseManager
2 {
3 /**
4 * Dynamically pass methods to the default connection.
5 *
6 * @param string $method
7 * @param array $parameters
8 * @return mixed
9 */
10 public function __call($method, $parameters)
Unit-тестирование 133

11 {
12 return $this->connection()->$method(...$parameters);
13 }
14 }

Как видите, Laravel использует магию PHP по полной и не всегда уважает принципы ООП.
Спасибо пакету barryvdh/laravel-ide-helper, который помогает обнаруживать те классы,
которые реально выполняют нужные действия.

1 class PollService
2 {
3 /** @var \Illuminate\Database\ConnectionInterface */
4 private $connection;
5
6 /** @var \Illuminate\Contracts\Events\Dispatcher */
7 private $dispatcher;
8
9 public function __construct(
10 ConnectionInterface $connection, Dispatcher $dispatcher)
11 {
12 $this->connection = $connection;
13 $this->dispatcher = $dispatcher;
14 }
15
16 public function create(PollCreateDto $dto)
17 {
18 if(count($dto->options) < 2) {
19 throw new BusinessException(
20 "Please provide at least 2 options");
21 }
22
23 $poll = new Poll();
24
25 $this->connection->transaction(function() use ($dto, $poll) {
26 $poll->title = $dto->title;
27 $poll->save();
28
29 foreach ($dto->options as $option) {
30 $poll->options()->create([
31 'text' => $option,
32 ]);
33 }
34 });
Unit-тестирование 134

35
36 $this->dispatcher->dispatch(new PollCreated($poll->id));
37 }
38 }

Хорошо. Для интерфейса ConnectionInterface я могу создать фейк класс FakeConnection.


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

1 use Illuminate\Support\Testing\Fakes\EventFake;
2 //...
3
4 class PollServiceTest extends TestCase
5 {
6 public function testCreatePoll()
7 {
8 $eventFake = new EventFake(
9 $this->createMock(Dispatcher::class));
10
11 $postService = new PollService(
12 new FakeConnection(), $eventFake);
13
14 $postService->create(new PollCreateDto(
15 'test title',
16 ['option1', 'option2']));
17
18 $eventFake->assertDispatched(PollCreated::class);
19 }
20 }

Этот тест выглядит очень похоже на прошлый, с фасадами, но теперь он намного строже с
зависимостями класса PollService.
Но не совсем.
Любой разработчик всё еще может использовать любой фасад внутри класса PollService и
тест будет продолжать работать.
Это происходит потому, что здесь используется специальный базовый класс для тестов,
предоставляемый Laravel, который полностью настраивает рабочее окружение.
Unit-тестирование 135

1 use Illuminate\Foundation\Testing\TestCase as BaseTestCase;


2
3 abstract class TestCase extends BaseTestCase
4 {
5 use CreatesApplication;
6 }

Для unit-тестов это недопустимо, нужно использовать обычный базовый класс PHPUnit:

1 abstract class TestCase extends \PHPUnit\Framework\TestCase


2 {
3 }

Теперь, если кто-то добавить вызов фасада, тест упадёт с ошибкой:

1 Error : Class 'SomeFacade' not found

Отлично, от laravel-фасадов мы код полностью избавили.

Отсоединяем от базы данных


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

1 interface PollRepository
2 {
3 //... другие методы
4
5 public function save(Poll $poll);
6
7 public function saveOption(PollOption $pollOption);
8 }
9
10 class EloquentPollRepository implements PollRepository
11 {
12 //... другие методы
13
14 public function save(Poll $poll)
15 {
16 $poll->save();
17 }
Unit-тестирование 136

18
19 public function saveOption(PollOption $pollOption)
20 {
21 $pollOption->save();
22 }
23 }
24
25 class PollService
26 {
27 /** @var \Illuminate\Database\ConnectionInterface */
28 private $connection;
29
30 /** @var PollRepository */
31 private $repository;
32
33 /** @var \Illuminate\Contracts\Events\Dispatcher */
34 private $dispatcher;
35
36 public function __construct(
37 ConnectionInterface $connection,
38 PollRepository $repository,
39 Dispatcher $dispatcher)
40 {
41 $this->connection = $connection;
42 $this->repository = $repository;
43 $this->dispatcher = $dispatcher;
44 }
45
46 public function create(PollCreateDto $dto)
47 {
48 if(count($dto->options) < 2) {
49 throw new BusinessException(
50 "Please provide at least 2 options");
51 }
52
53 $poll = new Poll();
54
55 $this->connection->transaction(function() use ($dto, $poll) {
56 $poll->title = $dto->title;
57 $this->repository->save($poll);
58
59 foreach ($dto->options as $optionText) {
60 $pollOption = new PollOption();
Unit-тестирование 137

61 $pollOption->poll_id = $poll->id;
62 $pollOption->text = $optionText;
63
64 $this->repository->saveOption($pollOption);
65 }
66 });
67
68 $this->dispatcher->dispatch(new PollCreated($poll->id));
69 }
70 }
71
72 class PollServiceTest extends \PHPUnit\Framework\TestCase
73 {
74 public function testCreatePoll()
75 {
76 $eventFake = new EventFake(
77 $this->createMock(Dispatcher::class));
78
79 $repositoryMock = $this->createMock(PollRepository::class);
80
81 $repositoryMock->method('save')
82 ->with($this->callback(function(Poll $poll) {
83 return $poll->title == 'test title';
84 }));
85
86 $repositoryMock->expects($this->at(2))
87 ->method('saveOption');
88
89 $postService = new PollService(
90 new FakeConnection(), $repositoryMock, $eventFake);
91
92 $postService->create(new PollCreateDto(
93 'test title',
94 ['option1', 'option2']));
95
96 $eventFake->assertDispatched(PollCreated::class);
97 }
98 }

Это корректный unit-тест.


Класс PollService был протестирован в полной изоляции, не касаясь среды Laravel и базы
данных.
Но почему это не радует меня?
Unit-тестирование 138

Причины в следующем:

• Я был вынужден создать абстракцию с репозиторием исключительно для того, чтобы


написать unit-тесты.

Код класса PollService без него выглядит в разы читабельнее, что весьма важно.
Это похоже на шаблон Репозиторий, но не является им.
Он просто пытается заменить операции Eloquent с базой данных.
Если объект опроса будет иметь больше отношений, то придётся реализовывать методы
save%ИмяОтношения% для каждого из них.

• Запрещены почти все операции Eloquent.

Да, они будут работать корректно в реальном приложении, но не в unit-тестах.


Раз за разом разработчики будут спрашивать себя - “а для чего нам эти unit-тесты?”

• С другой стороны, такие unit-тесты очень сложные.

Их сложно писать и сложно читать.


Притом, что это пример один из простейших - просто создание одной сущности с отноше-
ниями.

• Каждая добавленная зависимость заставить переписывать все unit-тесты.

Это делает поддержку таких тестов весьма трудоемким занятием.


Это сложно измерить, но мне кажется, что польза от таких тестов намного меньше усилий
затрачиваемых на них и урону читабельности кода.
В начале этой главы я сказал, что “Unit-тесты - одни из лучших индикаторов качества кода
в проекте”.
Если код сложно тестировать, скорее всего он обладает высокой связанностью.
Класс PollService точно обладает.
Он содержит основную логику (проверку количества опций ответа и создание сущности
с опциями), а также логику приложения (транзакции базы данных, генерация событий,
проверку на мат в одном из прошлых вариантов и т.д.).
Это может быть исправлено отделением Слоёв Приложения и Доменной Логики.
Следующая глава об этом.
Unit-тестирование 139

Стратегия тестирования приложения


В этом разделе я не хочу говорить про большие компании, которые создают или уже имеют
стратегии тестирования до того, как проект начался.
Разговор будет про мелкие проекты, которые начинают расти.
В самом начале проект тестируется членами команды, которые просто используют прило-
жение.
Время от времени, менеджер или разработчики открывают приложение, выполняют в нём
некоторые действия, проверяя корректность его работы и насколько красив его интерфейс.
Это неавтоматическое тестирование без какой-либо стратегии.
Дальше (обычно после каких-нибудь болезненных ошибок на продакшене) команда решает
что-то изменить.
Первое, очевидное, решение - нанять ручного тестировщика.
Он может описать главные сценарии работы с приложением и после каждого обновления
проходить по этим сценариям, проверяя, что приложение работает как требуется (это
называется регрессионное тестирование).
А также тестировать новый функционал.
Если приложение продолжит расти, то количество сценариев тоже будет расти.
В то же время, команда наверняка начнет чаще выкатывать обновления и вручную проверять
каждый сценарий при каждом обновлении станет невозможно.
Решение - писать автоматические тесты.
Сценарии использования, написанный ручным тестировщиком могут быть сконвертиро-
ваны в автоматические тесты для Selenium или других инструментов функционального
тестирования.
С точки зрения пользователя вашего приложения, функциональное тестирование является
самым важным и весьма желательно уделить ему достаточное внимание.
Тем более, что если ваше приложение - это API, то писать функциональные тесты к нему -
одно удовольствие.
А что же unit-тесты?
Да, они могут помочь нам проверить много специфических случаев, которые сложно будет
покрыть функциональными тестами, но главная их задача - помогать нам писать код.
Помните пример с cutString в начале главы?
Писать такой код с тестами чаще быстрее, чем без них.
Тесты сразу же проверят код на правильность, проверят поведение кода в краевых случаях и
в дальнейшем не позволят изменениям в коде нарушить требования к этому коду.
Написание unit-тестов должно быть простым.
Они не должны быть тяжелым камнем на шее проекта, который постоянно хочется выбро-
сить.
В коде наверняка есть много чистых функций, и писать их с помощью тестов - весьма
хорошая практика.
Unit-тестирование 140

Unit-тесты же для контроллеров или Слоя Приложения, как мы уже убедились ранее, писать
весьма неприятно, а поддерживать - тем более. “Форсированно интеграционные” тесты
проще, но они могут быть не очень стабильны.
Если ваш проект имеет основную логику (не связанную с базой данных), которую вы очень
хотите покрыть unit-тестами, чтобы, например, проверить поведение при краевых случаях,
это весьма толстый намёк на то, что основная логика проекта выросла настолько, что
нуждается в отделении от Слоя Приложения.
В свой собственный слой.

Вам также может понравиться