Академический Документы
Профессиональный Документы
Культура Документы
приложений
Adel F
Архитектура Сложных веб приложений
Adel F
Эта книга предназначена для продажи на http://leanpub.com/acwa_rus
Это книга с 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
ОГЛАВЛЕНИЕ
События . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99
Database transactions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 100
Очереди . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 102
События . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 104
Использование событий Eloquent . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 106
Сущности как поля классов-событий . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 108
Unit-тестирование . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
Первые шаги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
Тестирование классов с состоянием . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 116
Тестирование классов с зависимостями . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 119
Типы тестов ПО . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 125
Тестирование в Laravel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 125
Unit-тестирование Слоя Приложения . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 132
Стратегия тестирования приложения . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 139
Предисловие
«Разработка ПО — это Искусство Компромисса», wiki.c2.com
С другой стороны, советы от крутых разработчиков в стиле «ваш код на 100% должен быть
покрыт юнит-тестами», «не используйте статические методы» и «зависеть нужно только от
абстракций» быстро становятся своеобразными карго-культами для некоторых проектов.
Слепое следование им приводит к огромным потерям во времени.
Я видел интерфейс IUser с более чем 50 свойствами (полями) и класс User: IUser со всеми
этими свойствами скопированными туда (это был C# проект).
Я видел огромное количество абстракций просто для того, чтобы достичь требуемого про-
цента покрытия юнит-тестами.
Некоторые эти советы могут быть неверно истолкованы, некоторые применимы только в
конкретной ситуации, некоторые имеют важные исключения.
Разработчик должен понимать какую проблему решает шаблон или совет и при каких
условиях он применим.
Проекты бывают разные.
К некоторым хорошо придутся определённые шаблоны и практики. Для других они будут
излишни.
Один умный человек сказал: «Разработка ПО — это всегда компромисс между краткосрочной
и долгосрочной продуктивностью».
Если мне нужен один функционал в другом месте проекта, то я могу скопировать его туда.
Это будет очень продуктивно, но начнет очень быстро доставлять проблемы.
Почти каждое решение про рефакторинг или применение какого-либо шаблона представ-
ляет собой ту же дилемму.
Иногда, решение не применять шаблон, который сделает код «лучше» будет более пра-
вильным, поскольку полезный эффект от него будет меньше, чем время затраченное на его
реализацию.
Балансирование между шаблонами, практиками, техниками, технологиями и выбор наибо-
лее подходящей комбинации для конкретного проекта является наиболее важным умением
разработчика/архитектора.
В этой книге я покажу наиболее частые проблемы, возникающие в процессе роста проекта
и как разработчики обычно решают их.
Причины и условия данных решений весьма важная часть книги. Я не хочу создавать новые
карго-культы.
Я должен предупредить:
Метод store содержит уже несколько ответственностей. Напомню, что кроме него есть ещё и
метод update, меняющий сущность, и код, загружающий аватары, должен быть скопирован
туда.
Любое изменение в алгоритме загрузки аватаров уже затронет как минимум два места в коде
приложения.
Часто бывает сложно уловить момент, когда стоит начать рефакторинг.
Если эти изменения коснулись лишь сущности пользователь, то, вероятно, этот момент ещё
не настал.
Однако, наверняка, загрузка картинок понадобится и в других частях приложения.
Внедрение зависимостей 5
Текущая ситуация с методом store является хорошей ситуацией потери качества кода.
Он начинает реализовывать несколько ответственностей — связность падает.
Загрузка картинок реализована в нескольких местах приложения — связанность растет.
Самое время вынести загрузку изображений в свой класс.
Первая попытка:
Я привёл этот пример, потому что я часто встречаю такое вынесение функционала, захваты-
вающее слишком многое.
В этом случае класс ImageUploader кроме своей главной обязанности (загрузки изображе-
ний) присваивает и значение полю класса User.
Что в этом плохого?
Класс ImageUploader знает про класс User и его свойство avatarUrl.
Такие знания часто меняются. Простейший случай — загрузка изображений для другой
сущности.
Чтобы реализовать это изменение, придётся изменять класс ImageUploader, а также методы
класса UserController.
Это и есть пример, когда одно маленькое изменение порождает целый каскад изменений в
классах, не связанных с изначальным изменением.
Попробуем реализовать ImageUploader с высокой связностью:
Внедрение зависимостей 6
Да, это не выглядит как логика, которую необходимо выносить, но в будущем загрузка
изображений может стать сложнее (например, добавится создание миниатюр).
Даже в обратном случае, функционал загрузки изображений вынесен в отдельный класс и
много времени это не отняло.
Любые изменения в будущем будет проще реализовать.
Dependency Injection
Класс ImageUploader создан, но как использовать его в методе UserController::store?
1 ImageUploader::upload(...);
Это было просто, но теперь метод store имеет жесткую зависимость от класса ImageUploader.
Представим, что таких зависимых методов в приложении стало много и команда решила
использовать другое хранилище для изображений.
Но не для всех, а лишь некоторых мест их загрузки. Как разработчики будут реализовывать
это изменение?
Создадут класс AnotherImageUploader и заменят вызовы класса ImageUploader на вызовы
AnotherImageUploader во всех нужных местах.
В крупных проектах такие изменения, затрагивающие большое количество классов, крайне
нежелательны — они часто приводят к ошибкам.
Изменение способа хранения изображений не должно влиять на код, работающий с сущно-
стями.
Зависимости стали менее жёсткими. Классы не создают другие классы и не требуют стати-
ческих методов.
Однако, метод 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);
Техника 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
Тут совершён двойной грех: использование одного имени для интерфейса и класса, а также
одного имени для абсолютно разных программных объектов.
Механизм пространств имён даёт возможность для таких обходных маневров.
Как можно увидеть, даже в коде класса приходится использовать алиасы 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 interface Storage{}
2
3 class S3Storage implements Storage{}
4
5 class FileStorage implements Storage{}
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 }
Когда возникла необходимость логировать все задачи в очереди, был создан класс OurRedisQueue,
отнаследованный от 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 }
28 ->needs(Queue::class)
29 ->give(RedisQueue::class);
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().
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 }
20 {
21 // Проверки используя $this->googleVision,
22 // $weakerRules и $fileContent
23 }
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 }
• ООП путем;
• путём конфигурации.
ООП путь
Я собираюсь использовать полиморфизм, поэтому надо создать интерфейсы.
Внедрение зависимостей 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
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 }
44 ->disk('...')
45 ->put($fileName, $fileContent);
46
47 return $fileName;
48 }
49 }
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 ];
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 }
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 }
1 using ExtensionMethods;
2
3 //...
4
5 dispatcher.MultiDispatch(events);
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
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 может быть удалён и сервис-классы могут просто использовать этот
новый интерфейс:
В качестве бонуса теперь можно легко переключиться на другой движок обработки событий
вместо стандартной Laravel-реализации, реализовав этот интерфейс в новом классе, исполь-
зуя там вызов другого движка.
Такие интерфейсы-обёртки могут быть весьма полезны, делая проект немного менее зави-
симым от конкретных библиотек.
Когда вызывающие классы хотят полный интерфейс, просто с новым методом, новый
интерфейс может наследоваться от старого:
Внедрение зависимостей 35
Для больших интерфейсов это может быть весьма долгим, рутинным действием.
Некоторые 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
намного более явное и стабильное.
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
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 }
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
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, но выглядит намного более объектно-
ориентированно и, что намного важнее, явно.
Разумеется, эти 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 }
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
Синтаксис PHP является некоторым барьером для использования DI и я надеюсь, что скоро
это будет нивелировано либо изменениями в синтаксисе, либо инструментами в средах
разработки.
После нескольких лет использования и неиспользования трейтов я могу сказать, что разра-
ботчики используют трейты по двум причинам:
Надо лечить болезнь, а не симптомы, поэтому лучше найти причины, заставляющие нас
использовать трейты и постараться их исправить.
Статические методы
Я писал, что используя статические методы и классы мы создаем жёсткую связь, но иногда
это нормально.
Пример из прошлой главы:
Ключи кэширования необходимы в двух местах: в классах декораторах для выборки сущно-
стей и в классах-слушателей событий, которые отлавливают события изменения сущностей
и чистят кэш.
Я мог бы использовать класс CacheKeys через DI, но в этом мало смысла.
Все эти классы декораторов и слушателей формируют некую структуру, которую можно
назвать «модуль кэширования» для этого приложения.
Класс CacheKeys будет приватной частью этого модуля. Никакой другой код приложения не
должен об этом классе знать.
В обоих этих случаях IDE не может самостоятельно понять, что был вызван метод publish
класса Post.
В случае необходимости добавить новый параметр в этот метод, нужно будет найти все
использования этого метода.
Представим ситуацию когда команда обнаруживает, что в поле email в базе данных находятся
значения не являющиеся корректными email-адресами.
Как это произошло?
Необходимо найти все возможные присвоения полю email класса User и проверить их.
Весьма непростая задача, если учесть, что поле email виртуальное и оно может быть
присвоено вот так:
1 $user = User::create($request->all());
2 //or
3 $user->fill($request->all())
Эта автомагия, которая помогала нам так быстро создавать приложения, показывает свою
истинную сущность, преподнося такие вот сюрпризы.
Такие баги в продакшене иногда очень критичны и каждая минута важна, а я до сих пор
помню как целый день в огромном проекте, который длится уже лет 10, искал все возможные
присвоения одного поля, пытаясь найти где оно получает некорректное значение.
После нескольких подобных случаев тяжелого дебага, а также сложных рефакторингов, я
выработал себе правило: делать PHP-код как можно более статичным.
IDE должна знать все про каждый метод и каждое поле, которое я использую.
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 комментария функции.
Меня пару раз спрашивали: зачем я делаю 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());
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 моделью:
Этот класс имеет несколько виртуальных полей, представляющих поля таблицы 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
1 User::create($request->all());
Потом пришлось его поменять, поскольку поле avatarUrl нельзя присваивать напрямую из
объекта запроса.
1 $request->only(['email', 'name']);
Но если мы и так перечисляем все поля, может просто сделаем создание объекта более
цивилизованным?
20 foreach($request['options'] as $option) {
21 //...
22 }
23
24 $entity->save();
25 });
26
27 return redirect()->...
28 }
Этот метод реализует как минимум две ответственности: логику работы с HTTP запросом/о-
тветом и бизнес-логику.
Каждый раз когда разработчик меняет http-логику, он вынужден читать много кода бизнес-
логики и наоборот.
Такой код сложнее дебажить и рефакторить, поэтому вынесение логики в сервис-классы тоже
может быть хорошой идеей для этого проекта.
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 }
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);
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 с полной типизацией создан. Заглядывать в этот класс
разработчики будут крайне редко, поэтому какой-то сложности в поддержке приложения
он не добавляет.
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 }
Классы 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 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
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
В этой книге я буду называть его Слоем приложения. Потому что могу.
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 }
Конструктор этого класса приватный, поэтому все объекты могут быть созданы только с
помощью статических методов FunctionResult::success и FunctionResult::error.
Это простенький трюк называется “именованные конструкторы”.
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, и т.д.
Недоступность стороннего API, ошибка хранилища файлов или проблемы со связью с базой
данных - это ошибки сервера.
Есть две противоборствующие школы обработок ошибок:
Код аскетов как и их девиз выглядит более логично, но ошибки клиента придётся постоянно
протаскивать наверх, как в примерах выше, из функций к тем, кто их вызвал, из Слоя
приложения в контроллеры и т.д.
Код же их противников имеет унифицированный алгоритм работы с любой ошибкой (про-
сто выбросить исключение) и более чистый код, поскольку не надо проверять результаты
методов на ошибочность.
Есть только один вариант выполнения запроса, который приводит к успеху: приложение
получило валидные данные, сущность пользователя загружена из базы данных, старый
пароль совпал, поменяли пароль на новый и сохранили все в базе.
Любой шаг в сторону от этого единого пути должен вызывать исключение.
Юзер ввёл невалидные данные - исключение, этому пользователю нельзя выполнить это
действие - исключение, упал сервер с базой данных - разумеется, тоже исключение.
Проблемой Единого Верного Пути является то, что где-то нужно будет отделить ошибки
клиента от ошибок сервера, поскольку ответы мы должны сгенерировать разные (помните
про 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
Сообщение “Old password is not valid” ещё неплохо, но, например, “Server Has Gone Away
(error 2006)” точно нет.
2. Любые серверные ошибки должны быть записаны в лог.
Мелкие приложения используют лог-файлы.
Когда приложение становится популярным, исключения могут происходить каждую секун-
ду.
Некоторые исключения сигнализируют о проблемах в коде и должны быть исправлены
немедленно.
Некоторые исключения являются нормой: интернет не идеален, запросы в самые стабильные
API один раз из миллиона могут заканчиваться неудачей.
Однако если частота таких ошибок резко возрастает, то разработчики должны среагировать
тоже.
В таких случаях, когда контроллере за ошибками требует много внимания, лучше использо-
вать специализированные сервисы, которые позволят группировать исключения и работать
с ними намного удобнее.
Если интересно, можете просто погуглить “error monitoring services” и найдёте несколько
таких сервисов.
Большие компании строят свои специализированные решения для записи и анализа логов
со всех своих серверов (часто на основе популярного на момент написания книги стэка ELK:
Elastic, LogStash, Kibana).
Некоторые компании не логируют ошибки клиента.
Некоторые логируют, но в отдельных хранилищах.
В любом случае, для любого приложения необходимо четко разделять ошибки сервера и
клиента.
44
45 // вернуть успешный ответ
46 }
47 }
Глобальный обработчик
В 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 }
Ну не красота?
С точки зрения вызывающего этот метод, некоторые из этих ошибок, такие как Error,
TypeError, QueryException, абсолютно вне контекста.
Какой-нибудь HTTP-контроллер вообще не знает, что с этими ошибками делать.
Единственное, что он может сделать - показать пользователю “Произошло что-то плохое и я
не знаю, что с этим делать”.
Но некоторые из них имеют смысл для него.
BusinessException говорит о том, что что-то не так с логикой и там есть сообщения прямо
для пользователя и контроллер точно знает, что с этим исключением делать.
Тоже самое можно сказать про ModelNotFoundException. Контроллер может показать 404
ошибку на это.
Да, мы вынесли все это из контроллеров в глобальный обработчик, но это не важно.
Итак, два типа ошибок:
Обработка ошибок 77
Первые ошибки хорошо бы обработать там, где этот метод вызывается, а вторые можно и
пробросить выше.
Запомним это и взглянем на язык Java.
В этом случае, каждый код вызывающий метод bar будет вынужден также что-то делать с
этим исключением:
1 Throwable(checked)
2 / \
3 Error(unchecked) Exception(checked)
4 \
5 RuntimeException(unchecked)
1 class Foo
2 {
3 public function bar()
4 {
5 throw new Exception();
6 }
7 }
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 }
Не очень удобно. Даже если учесть, что PhpStorm умеет генерировать все эти тэги автомати-
чески.
Возвращаясь к нашему неидеальному миру: Класс ModelNotFoundException в Laravel уже
отнаследован от RuntimeException.
Соответственно, он непроверяемый по умолчанию.
Это имеет смысл, поскольку глубоко внутри собственного обработчика ошибок Laravel
обрабатывает эти исключения сам.
Поэтому, в нашем текущем положении, стоит тоже пойти на такую сделку с совестью:
и забыть про тэги @throws держа в голове то, что все исключения BusinessException будут
обработаны в глобального обработчике.
Это одна из главных причин почему новые языки не имеют такую фичу с проверяемыми
исключениями и большинство Java-разработчиков не любят их.
Другая причина: некоторые библиотеки просто пишут “throws Exception” в своих методах.
“throws Exception” вообще не дает никакой полезной информации.
Это просто заставляет клиентский код повторять этот бесполезный “throws Exception” в своей
сигнатуре.
Я вернусь к исключениям в главе про Доменный слой, когда этот подход непроверяемыми
исключениями станет не очень удобным.
Обработка ошибок 82
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();
25
26 $post->saveOrFail();
27 }
28 }
1 $this->validate($request, [
2 'category_id' => [
3 'required|exists:categories,id,deleted_at,null',
4 ],
5 'title' => 'required',
6 'body' => 'required',
7 ]);
Надо разделить валидацию. В валидации 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 }
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 запросе
были отвалидированы.
Любой может написать такой код:
Никто не может поручиться, что в 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 }
Валидация аннотациями
Проект Symfony содержит отличный компонент для валидации аннотациями - symfony/validator.
Для того, чтобы использовать его вне Symfony нужно установить composer-пакеты symfony/validator,
doctrine/annotations и doctrine/cache и сделать небольшую инициализацию.
Перепишем наш RegisterUserDto:
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 }
Value objects
Решение этой проблемы лежит прямо в RegisterUserDto.
Мы не храним отдельно $birthDay, $birthMonth и $birthYear. Не валидируем их каждый раз.
Мы просто храним объект DateTime! Он всегда хранит корректную дату и время.
Сравнивая, даты мы никогда не сравниваем их года, месяцы и дни. Там есть метод diff() для
сравнений дат.
Этот класс содержит все знание о датах внутри себя.
Давайте попробуем сделать что-то похожее:
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 может без страха использовать эти значения:
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 }
25 $this->badWordsFilter->filter($optionText);
26 $pollOption->save();
27 }
28
29 // Вызов генерации sitemap
30
31 // Оповестить внешнее API о новом опросе
32 }
33 }
Это обычное создание опроса с опциями ответа, с фильтрацией мата во всех текстах и
некоторыми пост-действиями.
Объект опроса непростой.
Он абсолютно бесполезен без опций ответа.
Мы должны позаботиться о его консистентности.
Этот маленький промежуток времени, когда мы уже создали объект опроса и добавили его
в базу данных, но еще не создали его опции ответа, очень опасен!
Database transactions
Первая проблема - база данных.
В это время может произойти некоторая ошибка (с соединением с базой данных, да даже
просто в функции проверки на мат) и объект опроса будет в базе данных, но без опций
ответа.
Все движки баз данных, которые созданы для хранения важных данных, имеют механизм
транзакций.
Они гарантируют консистентность внутри транзакции.
Все запросы, обернутые в транзакцию, либо будут выполнены полностью, либо, при возник-
новении ошибки во время выполнения, ни один из них.
Выглядит как решение:
Отлично, наши данные теперь консистентны, но эта магия транзакций даётся базе данных
небесплатно.
Когда мы выполняем запросы в транзакции, база данных вынуждена хранить две копии
данных: для успешного или неуспешного результатов.
Для проектов с нагрузкой, которые могут выполнять сотни одновременных транзакций,
которые длятся долго, это может сильно сказаться на производительности.
Для проектов с небольшой нагрузкой это не настолько важно, но все равно стоит приобрести
привычку выполнять транзакции как можно быстрее.
Проверка на мат может требовать запроса на специальное API, которое может занять страшно
много времени.
Попробуем вынести из транзакции все, что возможно:
События 102
Очереди
Вторая проблема - время выполнения запроса.
Приложение должно отвечать как можно быстрее.
Создание опроса содержит тяжелые действия, такие как генерация sitemap или вызовы
внешних API.
Обычное решение - отложить эти действия с помощью механизма очередей.
События 103
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 есть поддержка механизма событий:
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 работает точно также, сообщая о том, когда должно быть запущено
выполнение этого слушателя: сразу же или в отложено.
События - очень мощная вещь, но здесь тоже есть ловушки.
Событие UserSaved будет генерироваться каждый раз когда сущность пользователя будет
сохранено в базу данных.
Сохранено, означает любой update или insert запрос.
Использование этих событий имеет множество недостатков.
UserSaved не самое удачное название для этого события.
UsersTableRowInsertedOrUpdated более подходящее.
Но и оно не всегда верное.
Это событие не будет сгенерировано при массовых операциях со строками базы данных.
Событие “Deleted” не будет вызвано, если строка в базе данных будет удалена с помощью
механизма каскадного удаления в базе данных.
Главная же проблема это то, что это события уровня инфраструктуры, события строчек базы
данных, но они используются как бизнес-события, или события доменной области.
Разницу легко осознать в примере с созданием сущности опроса:
События 107
35 }
36 }
36 {
37 // ...
38 foreach($event->getPoll()->options as $option)
39 {...}
40 }
41 }
1 $poll->load('options');
Первые шаги
Вы, вероятно, уже слышали про unit-тестирование.
Оно довольно популярно сейчас.
Я довольно часто общаюсь с разработчиками, которые утверждают, что не начинают писать
код, пока не напишут тест для него.
TDD-маньяки!
Начинать писать unit-тесты довольно сложно, особенно если вы пишете, используя фрейм-
ворки, такие как Laravel.
Unit-тесты одни из лучших индикаторов качества кода в проекте.
Фреймворки пытаются сделать процесс добавления новых фич как можно более быстрым,
позволяя срезать углы в некоторых местах, но высоко-связанный код обычная тому цена.
Сущности железно связанные с базой данных, классы с большим количеством зависимостей,
которые бывает трудно найти (Laravel фасады).
В этой главе я постараюсь протестировать код Laravel приложения и показать главные
трудности, но начнем с самого начала.
Чистая функция - это функция, результат которой зависит только от введенных данных.
Она не меняет никакие внешние значения и просто вычисляет результат.
Примеры:
1 OK (3 tests, 3 assertions)
Отлично!
Класс unit-теста содержит список требований к функции:
1 OK (5 tests, 5 assertions)
Отлично. Проверка краевых значений (0, длина $limit, длина $limit+1, и т.д.) очень важная
часть тестирования.
Многие ошибки находятся именно в этих местах.
Unit-тестирование 115
Когда я писал функцию cutString, я думал, что длина исходной строки мне понадобится
дальше и сохранил её в переменную, но оказалось, что дальше нам нужна только переменная
$limit.
Теперь я могу удалить эту переменную.
19
20 if(strlen($source) <= $limit) {
21 return $source;
22 }
23
24 return substr($source, 0, $limit-3) . '...';
25 }
Вызов expectException проверяет то, что исключение будет выброшено. Если этого не
произойдет, то тест будет признан упавшим.
Есть также шаблон 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
Однако, метод publish зависит от текущего состояния объекта и части тестов более ощутимы:
21 $this->expectException(CantPublishException::class);
22
23 // выполнение
24 $post->publish();
25 }
26 }
В этом случае трудно себе представить другой возможный вариант реализации этой зави-
симости и это прекрасный момент, чтобы поговорить про инкапсуляцию и почему 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 здесь модуль, который содержит не только класс 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 }
Стабы и фейки
Обычно зависимость - это интерфейс, который имеет несколько реализаций.
Использование реальных реализаций этого интерфейса во время unit-тестирования - плохая
идея, поскольку там могут проводиться те самые операции ввода-вывода, замедляющие
тестирование и не дающие провести тестирование этого модуля в изоляции.
Прогон unit-тестов должен быть быстр как молния, поскольку запускаться они будут часто
и важно, чтобы разработчик запустив их не потерял фокус над кодом.
Написал код - прогнал тесты, еще написал код - прогнал тесты.
Быстрые тесты позволят ему оставаться более продуктивным, не позволяя отвлекаться.
Unit-тестирование 122
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
Работает!
Такие классы называются фейками.
Библиотеки для unit-тестирования могут создавать такие классы на лету.
Тот же самый тест, но с использованием метода createMock библиотеки PHPUnit:
Стабы удобны когда нужно быстро настроить простую реализацию, однако когда тестов с
этой зависимостью становится много, фэйковый класс смотрится пооптимальнее.
Библиотеки могут создавать стабы не только для интерфейсов, но и для реальных классов,
что может быть весьма удобно при работе с легаси-проектами или для ленивых разработчи-
ков.
Моки
Иногда разработчик хочет протестировать вызваны ли методы стаба, сколько раз и какие
параметры переданы были.
Unit-тест в этом случае начинает знать слишком многое о том, как этот класс
работает.
Как следствие, такие тесты очень легко ломаются.
Небольшой рефакторинг и тесты падают.
если это случается слишком часто, команда может просто забить на unit-тестирование.
Это называется тестированием методом белого ящика.
Тестированием методом черного ящика пытается тестировать только входные и
выходные данные, не залезая внутрь.
Разумеется, тестирование черным ящиком намного стабильнее.
Эти проверки могут быть реализованы в фейковом классе, но это будет весьма непросто и
мало кто захочет делать это для каждой возможной зависимости.
Библиотеки для тестирования могут создавать специальные мок-классы, которые позволяют
легко проверять вызовы их методов.
Типы тестов ПО
Люди придумали множество способов тестировать приложения.
Security testing для проверки приложений на различные уязвимости.
Performance testing для проверки насколько хорошо приложение ведет себя при нагрузке.
В этой главе мы сфокусируемся на проверке корректности работы приложений.
Unit-тестирование уже было рассмотрено.
Интеграционное тестирование проверяет совместную работу нескольких модулей.
Пример: Попросить UserService зарегистрировать нового пользователя и проверить, что
новая строка создана в базе данных, нужное событие (UserRegistered) было сгенерировано
и соответствующий email был послан (ну или хотя бы фреймворк получил команду сделать
это).
Тестирование в Laravel
Laravel (текущая версия 6.12) предоставляет много инструментов для различного тестирова-
ния.
Этот тест просто проверяет, что запрос POST /user вернул успешный результат.
Это не выглядит законченным тестом.
Тест должен проверять, что пользователь реально создан.
Но как?
Первый ответ, приходящий в голову: просто сделать запрос в базу данных и проверить это.
Опять пример из документации:
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/”:
Моки Laravel-фасадов
Laravel предоставляет удобную реализацию шаблона Service Locator - Laravel-фасады.
Я всегда говорю Laravel-фасады, чтобы не путаться со структурным шаблоном Фасад,
который про другое.
Laravel не только предлагает их использовать, но и предоставляет инструменты для тести-
рования кода, который использует фасады.
Давайте напишем один из предыдущих примеров с использованием Laravel-фасадов и
протестируем этот код:
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 }
Для того, чтобы проверить создание записей в базе данных нужно использовать вызовы
методов, таких как assertDatabaseHas или что-то вроде PollService::getById, которое делает
этот тест неким функциональным тестом к Слою Приложения, поскольку он просит Слой
Приложения чтото сделать и проверяет результат тоже вызвав его.
Unit-тестирование 132
Чтобы понять, что конкретно он требует для своей работы, нужно просмотреть весь его код.
Это делает написание тестов для него весьма неудобным.
Хуже всего, если в класс будет добавлена новая зависимость с помощью laravel-фасада.
Тесты будут продолжать работать как ни в чем не бывало, но не с моком, а с реальной
реализацией этого фасада: реальные вызовы API и т.д.
Я слышал несколько реальных историй, когда разработчики запускали тесты и это приво-
дило к тысячам реальных денежных переводов!
Я думаю, что ноги у таких случаев растут именно из-за таких вот случаев, когда неожиданно
в тесты попадет реальная реализация.
Я называю это “форсированным интеграционным тестированием”.
Разработчик хочет написать unit-тест, но код обладает такой высокой связанностью, так
крепко сцеплен с фреймворком, что этого просто не получается.
Попробуем это сделать!
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 }
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
Для unit-тестов это недопустимо, нужно использовать обычный базовый класс PHPUnit:
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 }
Причины в следующем:
Код класса PollService без него выглядит в разы читабельнее, что весьма важно.
Это похоже на шаблон Репозиторий, но не является им.
Он просто пытается заменить операции Eloquent с базой данных.
Если объект опроса будет иметь больше отношений, то придётся реализовывать методы
save%ИмяОтношения% для каждого из них.
Unit-тесты же для контроллеров или Слоя Приложения, как мы уже убедились ранее, писать
весьма неприятно, а поддерживать - тем более. “Форсированно интеграционные” тесты
проще, но они могут быть не очень стабильны.
Если ваш проект имеет основную логику (не связанную с базой данных), которую вы очень
хотите покрыть unit-тестами, чтобы, например, проверить поведение при краевых случаях,
это весьма толстый намёк на то, что основная логика проекта выросла настолько, что
нуждается в отделении от Слоя Приложения.
В свой собственный слой.