├── .gitignore ├── manuscript ├── 0-intro.md ├── 1-bad-habits.md ├── 10-cqrs.md ├── 11-es.md ├── 12-end.md ├── 2-di.md ├── 3-painless-refactoring.md ├── 4-application-layer.md ├── 5-error-handling.md ├── 6-validation.md ├── 7-events.md ├── 8-unit-test.md ├── 9-domain-layer.md ├── Book.txt └── images │ ├── application_layer.png │ ├── cache_module.png │ ├── complex_logic.png │ ├── conf1.png │ ├── conf2.png │ ├── coupling_cohesion.png │ ├── es_cqrs.png │ ├── functional_testing.png │ ├── functional_testing2.png │ ├── hard_dependencies.png │ ├── integration_testing_example.png │ ├── master_slave.png │ ├── master_slave_cqrs.png │ ├── oracle_cqrs.png │ ├── orchestrator.png │ ├── saga_events.png │ ├── two_level_validation.png │ ├── typical_product.png │ └── unit.png └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea -------------------------------------------------------------------------------- /manuscript/0-intro.md: -------------------------------------------------------------------------------- 1 | # Предисловие 2 | 3 | > «Разработка ПО — это Искусство Компромисса», wiki.c2.com 4 | 5 | Я видел множество проектов, выросших из простой «MVC» структуры. 6 | Часто разработчики объясняют шаблон MVC так: «View (представление) — это HTML-шаблоны, Model (модель) — это класс Active Record (например, Eloquent) и один контроллер, чтобы править всеми!». 7 | Хорошо, не один, но обычно вся дополнительная логика реализуется в классах-контроллерах. 8 | Контроллеры часто содержат огромное количество кода, реализующего разную логику (загрузку картинок, вызовы внешних API, работу с базой, и т.д.). 9 | Иногда некоторая логика выносится в «базовые» контроллеры, чтобы уменьшить количество дублированного кода, и они распухают тысячами строк. 10 | Одни и те же проблемы возникают как в средних проектах, так и в огромных порталах с миллионами посетителей в день. 11 | 12 | Шаблон MVC был изобретен в 1970-х годах для графического интерфейса пользователя (GUI). 13 | Простые CRUD (Create, Read, Update и Delete) веб-приложения, в сущности, являются просто интерфейсом к базе данных, поэтому пере-изобретённый шаблон «MVC для веб» стал очень популярен. 14 | Однако, веб-приложения очень быстро перестают быть только лишь интерфейсом к базе данных. 15 | Что говорит шаблон MVC про работу с файлами (изображения, музыка, видео), внешними API, кэшэм? 16 | Что если у сущностей поведение отличается от простого Создать-Изменить-Удалить? 17 | Ответ простой: Модель в терминах MVC — это не только класс Active Record. Она содержит всю логику работы со всеми данными приложения. 18 | Больше 90% кода современного сложного веб-приложения — это Модель, и это не только ORM сущности, но и огромный пласт других классов. 19 | Создатель фреймворка Symfony Fabien Potencier как-то написал: «Я не люблю MVC, потому что это не то, как работает веб. Symfony2 — это HTTP фреймворк; это Request/Response фреймворк» 20 | Я могу сказать то же самое про Laravel и многие другие фреймворки. Задача веб-приложения проста: получить запрос и отдать ответ. Чем сложнее приложение, тем выгоднее разработчикам понимать это и не думать о проекте в терминах MVC. Классы web-контроллеров, как и классы консольных команд, например, являются лишь точками входа в наше приложение. Описывать остальную часть как Model и View для многих приложений будет некорректным и только мешает пониманию. 21 | 22 | Фреймворки такие, как Laravel, содержат кучу RAD-возможностей (Rapid Application Development - быстрая разработка приложений), которые позволяют разрабатывать приложения эффективно срезая некоторые углы. 23 | Они весьма полезны на стадии приложения «интерфейс для работы с базой данных», но часто становятся источником боли по мере развития. 24 | Я делал много рефакторингов просто, чтобы избавить приложения от таких возможностей. 25 | Вся эта авто-магия и «удобные» валидации в стиле «быстро сходить в базу данных и проверить нет ли такого email в таблице» хороши, но разработчик должен полностью понимать как они работают и когда лучше от них отказаться. 26 | 27 | С другой стороны, советы от крутых разработчиков в стиле «ваш код на 100% должен быть покрыт юнит-тестами», «не используйте статические методы» и «зависеть нужно только от абстракций» быстро становятся своеобразными карго-культами для некоторых проектов. 28 | Слепое следование им приводит к огромным потерям во времени. 29 | Я видел интерфейс **IUser** с более чем 50 свойствами (полями) и класс **User: IUser** со всеми этими свойствами скопированными туда (это был C# проект). 30 | Я видел огромное количество абстракций просто для того, чтобы достичь требуемого процента покрытия юнит-тестами. 31 | Некоторые эти советы могут быть неверно истолкованы, некоторые применимы только в конкретной ситуации, некоторые имеют важные исключения. 32 | Разработчик должен понимать какую проблему решает шаблон, в каких условиях он применим, и самое важное, в каких условиях лучше от него отказаться. 33 | 34 | Проекты бывают разные. 35 | К некоторым хорошо придутся определённые шаблоны и практики. Для других они будут излишни. 36 | Один умный человек сказал: «Разработка ПО — это всегда компромисс между краткосрочной и долгосрочной продуктивностью». 37 | Если мне нужен один функционал в другом месте проекта, то я могу просто скопировать его туда. 38 | Это будет очень продуктивно, но доставит проблемы в будущем. 39 | Почти каждое решение про рефакторинг или применение какого-либо шаблона представляет собой ту же дилемму. 40 | Иногда, решение не применять шаблон, который сделает код «лучше» будет более правильным, поскольку полезный эффект от него будет меньше, чем время затраченное на его реализацию. 41 | Балансирование между шаблонами, практиками, техниками, технологиями и выбор наиболее подходящей комбинации для конкретного проекта является наиболее важным умением разработчика/архитектора. 42 | 43 | В этой книге я покажу наиболее частые проблемы, возникающие в процессе роста проекта и как разработчики обычно решают их. 44 | Причины данных решений весьма важная часть книги. 45 | 46 | Я должен предупредить: 47 | 48 | * Эта книга не для начинающих. Чтобы понимать описываемые проблемы вы должны поучаствовать хотя бы в одном проекте. В одиночку или в команде. 49 | * Эта книга не пособие. Много шаблонов будут описаны поверхностно, с целью просто познакомить читателя с ними. Несколько полезных ссылок вас ожидает в конце книги. 50 | * Примеры этой книги никогда не будут идеальными. Я могу назвать какой-то код «корректным» и найти кучу ошибок в нем в следующей главе. -------------------------------------------------------------------------------- /manuscript/1-bad-habits.md: -------------------------------------------------------------------------------- 1 | # Плохие привычки 2 | 3 | A> "Сегодня курение спасет жизни!" 4 | 5 | ## Проблемы роста 6 | 7 | В этой главе я попытаюсь показать, как обычно проекты растут и решают возникающие проблемы. 8 | Начнём с простого примера: 9 | 10 | ```php 11 | public function store(Request $request) 12 | { 13 | $this->validate($request, [ 14 | 'email' => 'required|email', 15 | ]); 16 | 17 | $user = User::create($request->all()); 18 | 19 | if(!$user) { 20 | return redirect()->back()->withMessage('...'); 21 | } 22 | 23 | return redirect()->route('users'); 24 | } 25 | 26 | public function update($id, Request $request) 27 | { 28 | // все примерно так же, как и в store() 29 | } 30 | ``` 31 | 32 | Пример практически скопирован из документации, и он показывает всю мощь и элегантность Laravel. Это хорошо, фреймворк и должен быть таким, предоставляющим хорошие инструменты для решения стандартных задач. Проблемы возникают тогда, когда нужно добавлять фичи, не укладывающиеся в такую элегантную структуру. Их начинают лепить одна к другой, не обращая внимания на архитектуру. Со стороны это выглядит как маленький симпатичный домик, к которому пристраивают одну часть за другой, пока это не станет выглядеть как страшная мешанина. 33 | 34 | Причина в том, что разработчик, реализуя непростые требования, хочет оставаться в тепличных условиях маленького домика, тогда, когда для этой реализации явно нужно что-то побольше. Давайте посмотрим как это может происходить на практике. 35 | 36 | Для нашего приложения появляются новые требования — добавить загрузку аватара и отправку email пользователю после создания. 37 | 38 | ```php 39 | public function store(Request $request) 40 | { 41 | $this->validate($request, [ 42 | 'email' => 'required|email', 43 | 'avatar' => 'required|image', 44 | ]); 45 | 46 | $avatarFileName = ...; 47 | \Storage::disk('s3')->put( 48 | $avatarFileName, $request->file('avatar')); 49 | 50 | $user = new User($request->except('avatar')); 51 | $user->avatarUrl = $avatarFileName; 52 | $user->save(); 53 | 54 | \Email::send($user, 'Hi email'); 55 | 56 | return redirect()->route('users'); 57 | } 58 | ``` 59 | 60 | Какая-то логика должна быть скопирована в **update** метод, но, например, отправка email должна быть только после создания. Код всё ещё выглядит неплохо, но количество дублированного кода растет. Еще 3-4 таких добавления и у нас будут красоваться два уже весьма больших и почти одинаковых метода **store** и **update**. Тут часто совершается ошибка - в попытке устранить дублирование кода создается метод-монстр с названием, допустим, **updateOrCreateUser**. Этому методу нужен параметр, чтобы понимать, что он сейчас делает: "update" или "create". 61 | Сразу после выделения код обычно выглядит весьма прилично, но с добавлением новых требований, особенно разных для создания и изменения сущности, разработчик начинает осознавать, что попал в капкан. В одном из проектов я видел 700-строковый метод с большим количеством `if($update)`: 62 | 63 | ```php 64 | protected function updateOrCreateUser(..., boolean $update) 65 | { 66 | if ($update)... 67 | if ($update)... 68 | if (!$update)... 69 | } 70 | ``` 71 | 72 | Стоит ли говорить, что в нем частенько заводились баги, которые было трудно отлавливать в такой каше. Ошибка проста - разработчик вынес **разную** логику, которая показалась ему похожей, в один метод. Выносить в отдельный метод или класс надо всегда только одинаковую логику. Логика создания или редактирования сущности может показаться одинаковой поначалу, но почти наверняка окажется разной по мере развития проекта. 73 | 74 | Здесь я хочу отвлечься и поговорить про naming - процесс наименования элементов языка (переменных, методов и классов). Если стараться именовать методы по смыслу, то многое можно понять уже по имени метода. **updateOrCreateUser** - тут проблема видна без какого-либо анализа. **Or**(**Или**) это явный знак как минимум двух разных действий - двух разных логик. С развитием проекта все такие логики имеют свойство всё сильнее и сильнее отличаться друг от друга, и находиться в одном месте им совершенно противопоказано. 75 | 76 | Надо всегда стараться называть методы, классы и переменные по смыслу. Иногда даже просто попытка назвать их хорошо может навести на хорошие мысли, которые помогут улучшить дизайн вашего кода. 77 | 78 | В случае почти одинаковых действий **create** и **update**, более правильным будет оставить каждую в своем методе и уже внутри каждого искать одинаковые действия, такие как загрузки изображений, выделять их в методы или классы с точными понятными именами. Результатом будут два метода с говорящими именами, с читабельным кодом, а бонусом будут выделенные методы и классы, которые, вполне вероятно, можно будет использовать в других местах приложения. 79 | 80 | ## Выделение логики 81 | 82 | На проект приходит новое требование — автоматически проверять загружаемые картинки на неподобающий контент. Некоторые разработчики просто добавят этот код в **store** метод и скопируют его в **update** метод. Более опытные выделят эту логику в новый метод контроллера и вызовут этот метод в обоих местах. Еще более опытные Laravel-разработчики найдут, что код для загрузки изображения стал довольно большим и создадут отдельный класс, например **ImageUploader**, который будет содержать логику загрузки изображений и проверки их содержимого на неподобающий контент. 83 | 84 | ```php 85 | class ImageUploader 86 | { 87 | /** 88 | * @returns bool|string 89 | */ 90 | public static function upload(UploadedFile $file) {...} 91 | } 92 | ``` 93 | 94 | **ImageUploader::upload** метод возвращает **false** если загрузка не была успешной, например, при ошибке облачного хранилища или неприемлемом контенте. При удачной загрузке будет возвращен URL-адрес картинки. 95 | 96 | ```php 97 | public function store(Request $request) 98 | { 99 | ... 100 | $avatarFileName = ImageUploader::upload( 101 | $request->file('avatar') 102 | ); 103 | 104 | if ($avatarFileName === false) { 105 | return %some_error%; 106 | } 107 | ... 108 | } 109 | ``` 110 | 111 | Методы контроллера стали проще, поскольку логика загрузки картинок-аватарок вынесена в другой класс. Отлично! Если на проекте возникнет необходимость загружать другие картинки, то нужный класс уже готов к использованию. Необходимо только добавить новый параметр в метод **upload** — например, папку, куда сохранять картинки. 112 | 113 | ```php 114 | public static function upload(UploadedFile $file, string $folder) 115 | ``` 116 | 117 | Новое требование — немедленно забанить пользователя, который загрузил неприемлемый контент. 118 | Звучит немного странно, учитывая неидеальную точность современных анализаторов изображений, но это было настоящим требованием на одном из моих проектов! 119 | 120 | ```php 121 | public static function upload(UploadedFile $file, string $folder) 122 | { 123 | ... 124 | if (check failed) { 125 | $this->banUser(\Auth::user()); 126 | } 127 | ... 128 | } 129 | ``` 130 | 131 | Новое требование — не банить пользователя, если неприемлемый контент был загружен в приватные места. 132 | 133 | ```php 134 | public static function upload( 135 | UploadedFile $file, 136 | string $folder, 137 | bool $dontBan = false) 138 | ``` 139 | 140 | Когда я говорю «новое требование» это не означает, что оно появляется на следующий день. В больших проектах между этими «новыми требованиями» могут пройти месяцы или годы. Их реализацией могут заниматься другие разработчики, которые не понимают почему этот код был написан таким образом. Их задача — просто реализовать это требование в коде, по возможности сделав это быстро. Даже если им не нравится какая-то часть кода, им трудно оценить время на рефакторинг. А также, что более важно, трудно не сломать что-либо. Это довольно частая проблема. 141 | 142 | Новое требование — приватные места пользователя должны иметь менее строгие правила проверки контента. 143 | 144 | ```php 145 | public static function upload( 146 | UploadedFile $file, 147 | string $folder, 148 | bool $dontBan = false, 149 | bool $weakerRules = false) 150 | ``` 151 | 152 | Последнее требование для этого примера — приложение не должно банить сразу. Только после нескольких попыток загрузить неприемлемый контент. 153 | 154 | ```php 155 | public static function upload( 156 | UploadedFile $file, 157 | string $folder, 158 | bool $dontBan = false, 159 | bool $weakerRules = false, 160 | int $banThreshold = 5) 161 | { 162 | //... 163 | if (check failed && !$dontBan) { 164 | if (\RateLimiter::tooManyAttempts(..., $banThreshold)) { 165 | $this->banUser(\Auth::user()); 166 | } 167 | } 168 | //... 169 | } 170 | ``` 171 | 172 | Этот код уже не так хорош. Функция загрузки изображения имеет кучу странных параметров про проверку контента и бан юзеров. Если процесс бана юзера изменится, разработчик должен открыть класс **ImageUploader** и реализовывать изменения там, что выглядит не очень логично. Читать код вызова метода **upload** все сложнее и сложнее: 173 | 174 | ```php 175 | ImageUploader::upload( 176 | $request->file('avatar'), 'avatars', true, false 177 | ); 178 | ``` 179 | 180 | В новых версиях PHP можно указывать именованные параметры и код может выглядеть так: 181 | 182 | ```php 183 | ImageUploader::upload( 184 | $request->file('avatar'), 185 | folder: 'avatars', 186 | dontBan: true, 187 | weakerRules: false 188 | ); 189 | ``` 190 | 191 | Это намного читабельнее, но является лишь попыткой скрыть настоящую проблему. Любой boolean-параметр для функции означает, что у нее внутри спрятано как минимум две логики, а каждый дополнительный boolean-параметр увеличивает это число, в некоторых случаях даже экспоненциально. 192 | 193 | Такие параметры почти всегда означают нарушение Принципа единственной ответственности (Single Responsibility Principle). Класс **ImageUploader** теперь занимается далеко не только загрузкой изображений, но и кучей разных других дел. У него присутствуют и другие проблемы, но мы поговорим о них позже. 194 | 195 | ## Соблазнительная "простота" REST 196 | 197 | Подход RESTful очень популярен. Laravel-разработчики используют ресурсные контроллеры с готовыми методами store, update, delete, и т.д. даже для web роутов, не только для API. Он выглядит очень просто. Всего 4 глагола: **GET** (прочитать), **POST** (создать), **PUT/PATCH** (изменить) и **DELETE** (удалить). 198 | 199 | Он действительно весьма хорошо ложится на проекты, которые представляют собой те же самые простые операции над сущностями — обычные CRUD-приложения (Create, Read, Update, Delete) с формами для создания/редактирования и списками сущностей с кнопкой «Удалить». Но когда приложение становится более сложным, подход RESTful моментально становится весьма неуютным. Например, я погуглил фразу «REST API ban user» и первые три результата с примерами из документаций к разным API отличались разительно. 200 | 201 | ``` 202 | PUT /api/users/banstatus 203 | params: 204 | UserID 205 | IsBanned 206 | Description 207 | ``` 208 | 209 | ``` 210 | POST /api/users/ban userId reason 211 | 212 | POST /api/users/un-ban userId 213 | ``` 214 | 215 | ``` 216 | PUT /api/users/{id}/status 217 | params: 218 | status: guest, regular, banned, quarantine 219 | 220 | Там также была огромная таблица: какие статусы могут быть 221 | изменены на какие и что при этом произойдет 222 | ``` 223 | 224 | Как видите, любой нестандартный глагол — и RESTful подход становится весьма неоднозначным, особенно для начинающих. Обычно все методы реализовываются через метод изменения сущности. Когда я спросил на одном из своих семинаров, «Как бы вы реализовали бан юзера в своем REST API?», первый ответ был: 225 | 226 | ``` 227 | PUT /api/users/{id} 228 | params: 229 | IsBanned=true 230 | ``` 231 | 232 | 233 | {float=right,style=float:right; margin: 20px 0 20px 20px;} 234 | ![](images/typical_product.png) 235 | 236 | Я часто вспоминаю картинку «типичный продукт Apple, типичный продукт Google» как лучшую иллюстрацию проблемы. Проблема эта в том, что разработчики, осознавая, что в конечном итоге изменения приведут к простому UPDATE SQL-запросу к таблице, соответствующей данной модели, начинают все эти изменения реализовывать через `update()` метод этой модели. На картинке мы видим результат крайней степени этой привычки, оказавший существенное влияние даже на интерфейс пользователя. 237 | 238 | Нет ничего проще, чем написать `$model->update($request->all())`, но последствий такой простоты очень много и все они плохи. Дальше в этой книге мы придем к паре из них, здесь же я хочу остановиться на потере контроля. Когда код находится под контролем в каждой его точке понятно, что здесь происходит - какая логика здесь исполняется. Код `$model->update($request->all())` говорит нам о том, что мы просто обновляем строчку в базе данных данными HTTP-запроса. Для приложения не предоставляющего абсолютно никакой дополнительной логики, кроме как интерфейса к строчкам базы данных - это совершенно нормально, ибо такова поставленная задача. Но сущности, даже простейшие, имеют привычку обрастать каким-то смыслом, вырастая из простого набора полей таблицы в некий объект с поведением. Пользователь - это не просто набор полей из таблички users. Это объект, модель чего-то настоящего из реального мира, который можно забанить или разбанить. Даже публикацию в блоге можно опубликовать или снять с публикации. 239 | 240 | `$model->update($request->all())` сразу становится не настолько явным действием. Произошла ли здесь публикация поста? Забанили пользователя или нет? Мы не знаем! Контроль над кодом потерян, а он нужен: как только сущность обрастает поведением, рано или поздно мы захотим реагировать на это поведение. Посылкой email или очисткой кеша. Можно придумать много попыток восстановить этот контроль, но разделяются они на две категории: лечение причины и лечение последствий. 241 | 242 | Вот пример лечения последствий, часто виденный мною в исходниках: 243 | 244 | ```php 245 | function afterUserUpdate(User $user) 246 | { 247 | if (!$user->getOriginal('isBanned') && $user->isBanned) { 248 | // Отправить письмо о 'бане' 249 | } 250 | } 251 | ``` 252 | 253 | Этот код кричит о проблеме. Данные каким-то волшебным образом изменились (скорее всего через `$user->update($data)`), а здесь мы пытаемся осознать что же реально произошло. Старайтесь изо всех сил избегать таких ситуаций. Нужно лечить причину потери контроля. Если нужно забанить пользователя нужен явный вызов команды ban: будь это метод `UserController::ban`, `$user->ban()`, или класс `BanUserCommand`. А внутри всем всегда будет понятно, что реально происходит с сущностью. Это важно. 254 | 255 | ## Поклонение темной магии PHP 256 | 257 | Иногда разработчики не видят (или не хотят видеть) простого пути реализации чего-либо. Они пишут код с рефлексией, магическими методами или другими динамическими фичами языка PHP. Код, который было трудно писать и будет намного труднее читать. Я частенько этим грешил. Как каждый разработчик, я думаю. 258 | 259 | Я покажу один веселый пример. Я написал простой класс для работы с ключами кэширования для одного из проектов. Ключи кэширования нужны как минимум в двух местах: при чтении из кэша и при удалении значений оттуда до срока. Очевидное решение: 260 | 261 | ```php 262 | final class CacheKeys 263 | { 264 | public static function getUserByIdKey(int $id) 265 | { 266 | return sprintf('user_%d_%d', $id, User::VERSION); 267 | } 268 | 269 | public static function getUserByEmailKey(string $email) 270 | { 271 | return sprintf('user_email_%s_%d', 272 | $email, 273 | User::VERSION); 274 | } 275 | //... 276 | } 277 | 278 | $key = CacheKeys::getUserByIdKey($id); 279 | ``` 280 | 281 | Помните догму «Не используйте статические функции!»? Почти всегда она верна, но это хороший пример исключения. Мы поговорим об этом в главе про внедрение зависимостей. Когда в другом проекте возникла такая же необходимость я показал этот класс разработчику сказав, что можно сделать также. Чуть погодя он сказал, что этот класс «не очень красивый» и показал свой вариант: 282 | 283 | ```php 284 | /** 285 | * @method static string getUserByIdKey(int $id) 286 | * @method static string getUserByEmailKey(string $email) 287 | */ 288 | class CacheKeys 289 | { 290 | const USER_BY_ID = 'user_%d'; 291 | const USER_BY_EMAIL = 'user_email_%s'; 292 | 293 | public static function __callStatic( 294 | string $name, array $arguments) 295 | { 296 | $cacheString = static::getCacheKeyString($name); 297 | return call_user_func_array('sprintf', 298 | array_prepend($arguments, $cacheString)); 299 | } 300 | 301 | protected static function getCacheKeyString(string $input) 302 | { 303 | return constant('static::' . static::getConstName($input)); 304 | } 305 | 306 | protected static function getConstName(string $input) 307 | { 308 | return strtoupper( 309 | static::fromCamelCase( 310 | substr($input, 3, strlen($input) - 6)) 311 | ); 312 | } 313 | 314 | protected static function fromCamelCase(string $input) 315 | { 316 | preg_match_all('<огромный regexp>', $input, $matches); 317 | $ret = $matches[0]; 318 | foreach ($ret as &$match) { 319 | $match = $match == strtoupper($match) 320 | ? strtolower($match) 321 | : lcfirst($match); 322 | } 323 | return implode('_', $ret); 324 | } 325 | } 326 | 327 | $key = CacheKeys::getUserById($id); 328 | ``` 329 | 330 | Этот код трансформирует строки вида «getUserById» в строки «USER_BY_ID» и использует значение константы с таким именем. Большое количество разработчиков, особенно те, кто помоложе, обожают писать подобный «красивый» код. Иногда этот код позволяет сэкономить несколько строк кода. Иногда нет. Но он всегда будет крайне сложным в отладке и поддержке. Разработчик должен подумать раз 10 прежде, чем использовать подобные «крутые» возможности языка. 331 | 332 | ## «Быстрая» разработка приложений (RAD) 333 | 334 | Некоторые разработчики фреймворков тоже любят динамические возможности и тоже реализуют подобную «магию». Она помогает быстро реализовывать простые мелкие проекты, но используя подобную магию разработчик теряет контроль над выполнением кода приложения и когда проект растет, это превращается в проблему. В прошлом примере было упущено использование констант `*::VERSION`, поскольку используя такую динамику, трудно как-либо изменить логику. 335 | 336 | Другой пример: Laravel приложения часто содержат много подобного кода: 337 | 338 | ```php 339 | class UserController 340 | { 341 | public function update($id) 342 | { 343 | $user = User::find($id); 344 | if ($user === null) { 345 | abort(404); 346 | } 347 | //логика с $user 348 | } 349 | } 350 | ``` 351 | Laravel предлагает использовать «implicit route binding». Этот код работает так же, как и предыдущий: 352 | 353 | ```php 354 | // in the route file 355 | Route::post('api/users/{user}', 'UserController@update'); 356 | 357 | class UserController 358 | { 359 | public function update(User $user) 360 | { 361 | //логика с $user 362 | } 363 | } 364 | ``` 365 | 366 | Это действительно выглядит приятнее и позволяет избавиться от некоторого количества дублированного кода. Но здесь мы опять потеряли контроль над кодом. Смотря в данный код мы не знаем как именно сущность User была запрошена. В большинстве случаев это не добавит никаких проблем и даже более того: если конкретно данному методу неважно откуда была взята сущность, например он просто покажет пользователю email данного юзера - это даже хорошо. Но бывают и другие ситуации. 367 | 368 | Спустя некоторое количество времени, когда проект немного вырастет, разработчики начнут внедрять кеширование. Проблема в том, что кеширование можно применять для запросов чтения (GET), но не для запросов записи (POST). Подробнее об этом в главе про CQRS. Разделение операций чтения и записи станет намного больше, если проект начнет использовать разные базы данных для чтения и записи (это случается довольно часто в проектах с высокой нагрузкой). Laravel позволяет довольно легко сконфигурировать подобную работу с базами для чтения и записи. Продолжая использовать фичи фреймворка, мы будем вынуждены перейти с «implicit route binding» на «explicit route binding» и реализовать это как-то так: 369 | 370 | ```php 371 | Route::bind('user', function ($id) { 372 | // получить и вернуть закешированного юзера или abort(404); 373 | }); 374 | 375 | Route::bind('userToWrite', function ($id) { 376 | return App\User::onWriteConnection()->find($id) ?? abort(404); 377 | }); 378 | 379 | Route::get('api/users/{user}', 'UserController@edit'); 380 | Route::post('api/users/{userToWrite}', 'UserController@update'); 381 | ``` 382 | 383 | Этот код выглядит очень странно и легко позволяет сделать ошибку. Это произошло потому, что тут опять лечат последствия, а не причину. Вместо явного запроса сущности по id разработчики использовали неявную «оптимизацию» и потеряли контроль над своим кодом, а такие попытки его вернуть лишь ухудшают ситуацию. 384 | 385 | Замечаете один и тот же шаблон? Разработчик принимает неудачное решение, которое кажется удачным поначалу. По мере усложнения проекта это решение все больше и больше усложняет жизнь. Вместо того чтобы собрать волю в кулак и признать, что решение было плохим, разработчик не исправляет его, а исправляет лишь его последствия, что делает код все хуже и хуже. Одним из главных умений разработчика является то шестое чувство, когда начинаешь понимать, что твой код "сопротивляется" изменениям - не хочет меняться легко и плавно, а требует все больше и больше усилий, заплаток и сделок с совестью для реализации таких изменений. Почувствовав такое сопротивление, надо искать его причины, найти те самые неудачные решения в дизайне кода, и исправить их. Это сделает проект намного более податливым к следующим изменениям. Именно это шестое чувство и позволяет большим проектам долго оставаться на плаву, не превращаясь в то самое "легаси", которого мы все стараемся избежать при приеме на работу. 386 | 387 | Фреймворки предлагают много возможностей потерять контроль над своим кодом. Нужно быть весьма осторожными с ними. Как небольшой подытог могу сказать следующее: чем меньше «магии» в коде, тем легче его читать и поддерживать. В очень редких случаях, таких как реализация библиотеки ORM, нормально использовать магию, но только там и даже тогда не стоит сильно увлекаться. 388 | 389 | Изначальный код мог быть сокращен без использования route binding: 390 | 391 | ```php 392 | class UserController 393 | { 394 | public function update($id) 395 | { 396 | $user = User::findOrFail($id); 397 | //... 398 | } 399 | } 400 | ``` 401 | 402 | Нет смысла «оптимизировать» эту одну строчку кода. В дальнейшем при реализации кеширования можно будет явно отделить выборки с кешированием от выборок без него. 403 | 404 | ## Преждевременная оптимизация 405 | 406 | Тут разработчику может прийти мысль сразу подготовить код к будущим изменениям, например, заранее определить места, в которых нужно будет кешировать данные и выделить их в отдельные классы/методы. А то и реализовать кеширование сразу, несмотря на то, что проект в этом необходимости не испытывает. Это довольно тонкий момент. Разработчик делает ставку на то, что проект будет испытывать определенные проблемы и пытается заранее подготовить код к ним. Проблемой может быть неготовность к нагрузкам или неготовность к некоторым изменениям в коде (основная тема данной книги). Ставкой является то дополнительное время, которое понадобится разработчику или всей команде на реализацию этой подготовки. Если проект действительно в будущем испытает эти проблемы, то ставка будет оправдана, поскольку времени на исправление кода наверняка понадобилось бы больше. 407 | 408 | Проблема в том, что предугадать проблемы удается весьма редко. Да, опытные разработчики из компаний FAANG или близким к ним, разрабатывая следующий сервис, наверняка знают в каких местах будут проблемы с нагрузкой и действительно могут довольно точно предугадывать такие моменты и экономить время, делая оптимизацию заранее. В большинстве же случаев попытки заранее оптимизировать приложение - это просто трата драгоценного времени. 409 | 410 | Не стоит применять архитектурные решения из последних глав этой книги для простеньких приложений - это будут большие затраты с отрицательной пользой. Не стоит реализовывать кеширование или другие архитектурные решения для борьбы с нагрузками для проекта, в котором нет уверенности хотя бы на 90 процентов в реальности и типе будущих нагрузок. Это тоже будет кучей времени выброшенной в мусорку, плюс почти наверняка добавит багов - не зря инвалидация кеша названа одной из главных проблем программирования. Если коротко - не занимайтесь преждевременной оптимизацией. 411 | 412 | ## Экономия строк кода 413 | 414 | Когда я учился программированию в университете, мы любили показывать друг другу примеры супер короткого кода. Обычно это была одна строка кода, реализующая какой-то алгоритм. Если автор этой строки пытался понять её спустя несколько дней — это могло занять несколько минут, но это все равно был «крутой» код. 415 | 416 | В промышленной разработке крутость кода ценится намного меньше читабельности. Код должен быть понятен любому другому программисту при наименьших когнитивных затратах. Самый короткий код далеко не всегда самый читабельный. Не надо пытаться экономить байты, теряя чистоту и ясность кода. Обычно, несколько простых классов намного лучше, чем один, но сложный класс, и это работает со всеми элементами языка(методы, модули и т.д.). 417 | 418 | Еще один пример с одного из моих проектов: 419 | 420 | ```php 421 | public function userBlockage( 422 | UserBlockageRequest $request, $userId) 423 | { 424 | /** @var User $user */ 425 | $user = User::findOrFail($userId); 426 | 427 | $done = $user->getIsBlocked() 428 | ? $user->unblock() 429 | : $user->block($request->get('reason')); 430 | 431 | return response()->json([ 432 | 'status' => $done, 433 | 'blocked' => $user->getIsBlocked() 434 | ]); 435 | } 436 | ``` 437 | 438 | Разработчик хотел сэкономить пару строк кода и реализовал блокировку и разблокировку пользователя одним методом. Проблемы начались с наименования этого метода. Неудачное существительное **blockage** вместо естественных глаголов **block** и **unblock**. Каждый раз когда вам трудно как-либо назвать программный объект (класс, метод) - в нем таится какая-то проблема! Кроме очевидного нарушения логики - странного двойственного действия в одном методе, здесь также есть и проблема с конкурентностью. Когда два модератора одновременно захотят заблокировать одного и того же пользователя, первый его заблокирует, а второй — разблокирует. 439 | 440 | Отдельные методы для блокировки и разблокировки - намного более логичное решение. Больше строк, но гораздо меньше неявности и других проблем. 441 | 442 | ## Прочие источники боли 443 | 444 | Я забыл рассказать о главном враге — Copy-Paste Driven Development. Просто скопировать логику в то место, где она понадобилась - очень продуктивно в краткосрочной перспективе. Не нужно заботиться о выделении этой логики в отдельный класс или метод и подключать ее в каждом месте. Но Вселенная ответит очень быстро и больно - эффективно поддерживать одну и ту же логику, размноженную в нескольких местах не удастся никому. 445 | 446 | С дублированной логикой есть и обратная проблема. Иногда, хоть и довольно редко, логика бывает одинаковая в каждой строчке, но она разная по смыслу. Здесь соблазн выделить эти логики в один метод или класс еще более велик, чем с create и update. В будущем эти части кода легко могут оказаться разными из-за изменившихся требований, но разработчики часто попадают в ловушку, пытаясь устранить эту "дублированность" заранее. Совет очень простой - дублированная логика - это та логика, которая всегда будет одинаково меняться с изменением требований. Но более подробно мы будем об этом говорить в следующей главе. 447 | -------------------------------------------------------------------------------- /manuscript/10-cqrs.md: -------------------------------------------------------------------------------- 1 | # CQRS 2 | 3 | ## Чтение и запись - это разные ответственности? 4 | 5 | Как мы выяснили в прошлой главе, геттеры совсем не нужны для реализации корректной доменной модели. Сущности и методы, изменяющие их состояния - вполне достаточный набор. Однако, данные необходимо где-то показывать. Неужели нет иного способа, кроме создания методов-геттеров? Давайте попробуем "изобрести" что-нибудь, а для этого мне нужно вспомнить прошлое. 6 | 7 | ### Хранимые процедуры и представления 8 | 9 | Первым проектом в моей профессиональной карьере было огромное приложение с логикой, хранимой в базе данных: в тысячах хранимых процедурах и представлениях. Я писал клиент для всего этого на С++. Представление в базе данных, это сохраненный SQL select-запрос, который выглядит как таблица. Использоваться эта "таблица" должна только для чтения, хотя некоторые движки баз данных позволяют туда писать, но это выглядит не очень логично - писать в результат SQL-запроса. 10 | 11 | ```sql 12 | CREATE TABLE t (qty INT, price INT); 13 | INSERT INTO t VALUES(3, 50); 14 | CREATE VIEW v AS SELECT qty, price, qty*price AS value FROM t; 15 | SELECT * FROM v; 16 | +------+-------+-------+ 17 | | qty | price | value | 18 | +------+-------+-------+ 19 | | 3 | 50 | 150 | 20 | +------+-------+-------+ 21 | ``` 22 | 23 | Пример представления из документации MySQL. Оно содержит поле `value`, которое не содержится в таблице. 24 | 25 | Хранимая процедура это просто набор инструкций, написанных в процедурном расширении языка SQL (**PL/SQL** в Oracle, **Transact-SQL** в MSSQL, и другие). Это как PHP-функция, которая выполняется внутри базы данных. 26 | 27 | ```sql 28 | PROCEDURE raise_salary ( 29 | emp_id NUMBER, 30 | amount NUMBER 31 | ) IS 32 | BEGIN 33 | UPDATE employees 34 | SET salary = salary + amount 35 | WHERE employee_id = emp_id; 36 | END; 37 | ``` 38 | 39 | Пример простейшей процедуры в **PL/SQL**. Как я уже говорил, система была огромной, с невероятным количеством логики. Без каких-либо ограничений, такие системы мгновенно превращаются в монструозный неподдерживаемый кусок... кода. Для каждой сущности там была определённая структура процедур и представлений: 40 | 41 | 42 | ![](images/oracle_cqrs.png) 43 | 44 | 45 | Таблицы там были, как приватные поля класса: трогать их извне было нельзя. Можно было лишь вызывать хранимые процедуры и делать select из представлений. Когда я писал эту книгу, я осознал, что все эти хранимки и представления составляют Слой Приложения. Точно также как слой приложения, описанный мною в предыдущих главах, прячет базу данных от своих клиентов (HTTP, Console и т.д.), эти хранимки и представления прятали реальную таблицу с данными от своих клиентов. 46 | 47 | Я вспомнил это всё, потому что здесь очень наглядно показано насколько разными являются операции записи, которые изменяют данные (хранимые процедуры), и операции чтения, нужные для показа данных пользователям (представления). Они используют совершенно разные типы объектов базы данных. 48 | 49 | ### Master-slave репликация 50 | 51 | Когда приложение вдруг становится популярным и нагрузка возрастает, первое, что обычно разработчики делают для снижения нагрузки на базу данных, это использование одной базы данных для операций записи и одной (или несколько других) для операций чтения. Это называется master-slave replication. 52 | 53 | 54 | ![](images/master_slave.png) 55 | 56 | 57 | Все изменения идут на главную базу данных и реплицируются на slave базы данных, которые называются репликами. То же самое: write-запросы идут в одно место, read-запросы в другое. 58 | 59 | Иногда процесс репликации немного подтормаживает, в силу разных причин, и read-реплики содержат немного старые данные. Пользователи могут изменить какие-то данные в приложении, но продолжать видеть старые данные в нём. Кстати, то же самое происходит тогда, когда в приложении не очень аккуратно реализовано кеширование. Вообще, архитектура системы с одной базой данных и кешем очень похожа на архитектуру приложений с мастер-слейв репликацией. Кеш здесь является подобием read-реплики, которую обновляют вручную из приложения, а не автоматически. 60 | 61 | Но любые проблемы с репликацией остаются позади, и реплики всегда догонят мастер, а кеш протухнет и старые данные заменятся обновлёнными. Т.е. если пользователь изменил какие-либо данные, то он увидит результат, если не сразу, то чуть позже. Этот тип консистентности называется eventual (eventual consistency, по-русски "Согласованность в конечном счёте"). Она - типичный атрибут любых систем с разными хранилищами для записи и чтения. 62 | 63 | Разработчики должны всегда держать этот факт в голове. Если выполняется операция записи, все запросы, включая select-запросы, должны идти в хранилище записи. Никаких значений из реплик или кеша. Иначе можно обновить базу данных, используя устаревшие значения. Это условие заставляет разделять слой приложения на две части: код для операций чтения и код для операций записи. 64 | 65 | 66 | ![](images/master_slave_cqrs.png) 67 | 68 | 69 | Но это не единственная причина для такого разделения. 70 | 71 | ## Типичный сервисный класс 72 | 73 | ```php 74 | final class PostService 75 | { 76 | // Методы чтения 77 | public function getById($id): Post{} 78 | public function getLatestPosts(): array{} 79 | public function getAuthorPosts($authorId): array{} 80 | 81 | // Методы записи 82 | public function create(PostCreateDto $dto){} 83 | public function publish($postId){} 84 | public function delete($postId){} 85 | } 86 | ``` 87 | 88 | Обычный сервисный класс для простой сущности. Он состоит из методов для операций чтения и методов для операций записи. Манипуляции и рефакторинги этого класса немного усложнены. 89 | 90 | Попробуем реализовать кеширование. Если его реализовать прямо в этом классе, то у него будут как минимум две ответственности: работа с базой данных и кеширование. Самое простое решение - шаблон Декоратор, который я уже применял в главе про внедрение зависимостей. Проблема в том, что для методов записи всё это не нужно: кеширование имеет смысл только для операций чтения. Этот простой факт тоже позволяет осознать, что чтение и запись надо отделять друг от друга. 91 | 92 | Попробуем в **PostService** оставить только операции записи: 93 | 94 | ```php 95 | final class PostService 96 | { 97 | public function create(PostCreateDto $dto){} 98 | public function publish($postId){} 99 | public function delete($postId){} 100 | } 101 | 102 | interface PostQueries 103 | { 104 | public function getById($id): Post; 105 | public function getLatestPosts(): array; 106 | public function getAuthorPosts($authorId): array; 107 | } 108 | 109 | final class DatabasePostQueries implements PostQueries{} 110 | 111 | final class CachedPostQueries implements PostQueries 112 | { 113 | public function __construct( 114 | private PostQueries $baseQueries, 115 | private Cache $cache, 116 | ) {} 117 | 118 | public function getById($id): Post 119 | { 120 | return $this->cache->remember('post_' . $id, 121 | function() use($id) { 122 | return $this->baseQueries->getById($id); 123 | }); 124 | } 125 | //... 126 | } 127 | ``` 128 | 129 | Выглядит неплохо! Разделение операций записи и чтения делают рефакторинг и другие манипуляции намного проще, а это говорит о том, что это действие угодно богам. 130 | 131 | ### Отчёты 132 | 133 | SQL-запросы для отчётов очень легко показывают разницу в природе запросов чтения и записи. Сложнейшие конструкции из группировок, агрегаций и вычисляемых полей. Когда разработчик пытаются запросить эти данные, используя сущности Eloquent, это выглядит кошмарно. Сущности Eloquent не предназначены содержать агрегированные значения и строить подобные запросы. 134 | 135 | Простая идея использовать **язык структурированных запросов** (**Structured Query Language**, **SQL**) быстро приходит в голову. SQL запросы намного более удобны для этой цели. Данные, полученные из этих запросов, можно хранить в простейших классах, как DTO, или просто в массивах. Это еще один пример, когда для одних и тех же данных используются абсолютно разные модели. 136 | 137 | ## Command Query Responsibility Segregation 138 | 139 | Шаблон **Command Query Responsibility Segregation**(CQRS) предлагает полностью разделять код на модели чтения и модели записи. Модель здесь - это множество классов, работающих с базой данных: сервисные классы, сущности, объекты-значения и т.д. 140 | 141 | Модели для чтения и записи, будучи полностью разделёнными, могут быть реализованы на абсолютно разных технологиях. Write-модель с Доктриной или другим data-mapper и Read-модель с какой-нибудь Active Record библиотекой, а то и просто на чистых SQL-запросах и простейших классах в стиле DTO. Технологии и архитектура для каждой модели выбираются исходя из нужд проекта, без оглядки на другую модель. 142 | 143 | Для приложения из прошлой главы с write-моделью, реализованной с помощью Доктрины, read-модель может быть реализована просто с помощью Eloquent: 144 | 145 | ```php 146 | 147 | namespace App\ReadModels; 148 | 149 | use Illuminate\Database\Eloquent\Builder; 150 | use Illuminate\Database\Eloquent\Model; 151 | 152 | abstract class ReadModel extends Model 153 | { 154 | public $incrementing = false; 155 | 156 | protected function performInsert(Builder $query) 157 | { 158 | throw new WriteOperationIsNotAllowedForReadModel(); 159 | } 160 | 161 | protected function performUpdate(Builder $query) 162 | { 163 | throw new WriteOperationIsNotAllowedForReadModel(); 164 | } 165 | 166 | protected function performDeleteOnModel() 167 | { 168 | throw new WriteOperationIsNotAllowedForReadModel(); 169 | } 170 | 171 | public function truncate() 172 | { 173 | throw new WriteOperationIsNotAllowedForReadModel(); 174 | } 175 | } 176 | 177 | final class WriteOperationIsNotAllowedForReadModel 178 | extends \RuntimeException 179 | { 180 | public function __construct() 181 | { 182 | parent::__construct( 183 | "Операция записи недоступна в модели для чтения"); 184 | } 185 | } 186 | ``` 187 | 188 | Базовый класс для Eloquent моделей для чтения. Все операции, которые пишут в базу данных, переопределены с генерацией исключения, чтобы исключить даже теоретическую возможность записать данные с помощью этих классов. 189 | 190 | ```php 191 | final class Client extends ReadModel{} 192 | 193 | final class Freelancer extends ReadModel{} 194 | 195 | final class Proposal extends ReadModel{} 196 | 197 | final class Job extends ReadModel 198 | { 199 | public function proposals() 200 | { 201 | return $this->hasMany(Proposal::class, 'job_id', 'id'); 202 | } 203 | } 204 | 205 | final class ClientsController extends Controller 206 | { 207 | public function get(UuidInterface $id) 208 | { 209 | return Client::findOrFail($id); 210 | } 211 | } 212 | 213 | final class FreelancersController extends Controller 214 | { 215 | public function get(UuidInterface $id) 216 | { 217 | return Freelancer::findOrFail($id); 218 | } 219 | } 220 | 221 | final class JobsController extends Controller 222 | { 223 | public function get(UuidInterface $id) 224 | { 225 | return Job::findOrFail($id); 226 | } 227 | 228 | public function getWithProposals(UuidInterface $id) 229 | { 230 | return Job::with('proposals')->findOrFail($id); 231 | } 232 | } 233 | ``` 234 | 235 | Простейшая реализация. Просто сущности, запрашиваемые напрямую из контроллеров. Как видите, даже со сложной write-моделью некоторые модели для чтения могут быть элементарными, и иногда нет смысла выстраивать какие-либо сложные архитектуры для них. Если нужно, можно реализовать некоторые ***Query**- или ***Repository**-классы, с кеширующими декораторами и другими шаблонами. Огромным бонусом идёт то, что write-модель не будет даже затронута! 236 | 237 | Случаи с простой моделью для записи, но сложной моделью для чтения тоже возможны. Один раз я участвовал в высоконагруженном контент-проекте. Write-модель не была особенно сложной, и она была реализована просто слоем приложения с Eloquent-сущностями. А read-модель содержала много сложных запросов, иногда несколько разных сущностей для одной таблицы, сложное кеширование и т.д. Там были использованы простые SQL-запросы и обычные классы с публичными полями, как read-сущности. 238 | 239 | ## Пара слов в конце главы 240 | 241 | Как и любой шаблон, CQRS имеет и преимущества и недостатки. Он позволяет независимо друг от друга разрабатывать модели для записи и чтения. Это позволяет уменьшить сложность модели для записи (удалить геттеры и другую логику, используемую только для чтения) и модели для чтения (использовать простейшие сущности и чистые SQL-запросы). С другой стороны, для большинства приложений это будет дублирование сущностей, некоторой части слоя приложения и т.д. Очевидно, что создание двух моделей одного и того же почти всегда дороже, чем создание одной. 242 | 243 | Read- и write-модели часто требуют синхронизации и задача, например "добавить поле в сущность" разбивается на две под-задачи: сделать это для модели чтения и записи. Всё имеет свою цену. И это опять превращается в виртуальные весы в голове архитектора. Здесь я лишь немного описал гирьки на чашах этих весов. -------------------------------------------------------------------------------- /manuscript/11-es.md: -------------------------------------------------------------------------------- 1 | # Event sourcing 2 | 3 | A> 1. e4 - e5 4 | A> 5 | A> 2. Кf3 - Кc6 6 | A> 7 | A> 3. Сb5 - a6 8 | 9 | Вы играли в шахматы? Даже если нет, вы знаете эту игру. Два игрока просто двигают фигуры. И это лучший пример для шаблона, про который я хочу поговорить. 10 | 11 | ## Игра королей 12 | 13 | Когда любители шахмат хотят узнать про какую-то игру гроссмейстеров, конечная позиция на доске их мало интересует. Они хотят знать про каждый ход! 14 | 15 | 1. d4 - Кf6 16 | 2. Сg5 17 | 18 | «Вот это шутка от чемпиона мира!!!» 19 | 20 | Смысл текущей позиции на доске полностью зависит от ходов, сделанных ранее: 21 | 22 | 1. Самое важное: кто ходит следующим? Позиция может одновременно быть выигранной или проигранной в зависимости от этого. 23 | 2. Рокировка возможна только если король и ладья не двигались до этого. 24 | 3. Взятие на проходе возможно только сразу после того, как пешка сделала ход через клетку. 25 | 26 | Давайте создадим приложение для игры в шахматы. Как оно будет хранить игры? Я вижу два варианта: 27 | 28 | 1. Хранить текущую позицию на доске(т.е. где какие фигуры стоят) с некоторой дополнительной информацией: кто ходит следующим, какие рокировки возможны и некоторая информация о последнем ходе для расчета возможности «взятия на проходе». Все сделанные ходы будут храниться в отдельной таблице, просто для истории. 29 | 2. Хранить только сделанные ходы и каждый раз «проигрывать» их для того, чтобы получить текущую позицию. 30 | 31 | Как зеркальное отображение этих идей, в шахматном мире существуют две основные нотации: 32 | 33 | **FEN** - хранит текущую позицию на доске со всей необходимой дополнительной информацией. Пример: 34 | 35 | ``` 36 | rnbqkbnr/pp1ppppp/8/2p5/4P3/8/PPPP1PPP/RNBQKBNR w KQkq c6 0 2 37 | ``` 38 | 39 | **PGN** - просто хранит все ходы. Пример: 40 | 41 | ``` 42 | [Event "F/S Return Match"] 43 | [Site "Belgrade, Serbia JUG"] 44 | [Date "1992.11.04"] 45 | [Round "29"] 46 | [White "Fischer, Robert J."] 47 | [Black "Spassky, Boris V."] 48 | [Result "1/2-1/2"] 49 | 50 | 1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 51 | ...(все остальные ходы)... 52 | 42. g4 Bd3 43. Re6 1/2-1/2 53 | ``` 54 | 55 | Как видите обе идеи представлены в шахматном мире достаточно широко. Первый путь традиционен для веб-приложений: мы почти всегда просто храним текущее состояние в базе данных. Иногда, имеются также таблицы, где хранится история изменений записей, но эти данные всегда вторичны - это просто некоторые аудит-логи. 56 | 57 | Идея хранить только изменения, без текущего состояния выглядит на первый взгляд странной. Каждый раз проматывать все изменения какой-либо сущности, чтобы получить её текущее состояние - это очень медленно, но иметь полную историю игры может быть весьма полезно. 58 | 59 | Давайте представим два приложения для игры в шахматы и проанализируем оба пути. Первое приложение будет хранить шахматные игры в такой таблице: 60 | id, текущее положение на доске, кто ходит, возможности рокировки, поле для взятия на проходе, если такое есть. 61 | 62 | Второе приложение будет просто хранить все ходы с начала партий. 63 | 64 | Требования к приложениям постоянно меняются и здесь я нарочно забыл пару шахматных правил про ничью. «Правило 50 ходов» говорит, что должна быть объявлена ничья если произошло 50 ходов с обеих сторон без взятия или хода пешкой. Такое правило добавлено, чтобы избежать бесконечных партий и в компьютерных шахматах ничья объявляется автоматически. Как наши два приложения будут реализовывать данное правило? 65 | 66 | Первое приложение должно будет добавить новое поле в таблицу: сколько ходов сделано без взятий и движений пешкой и оно будет вычисляться после каждого хода. Остаётся только проблема с теми партиями, которые идут прямо сейчас, там это поле ещё не посчитано и придётся оставить их без этого правила. Проблема в том, что если вместо шахмат мы возьмём какие-нибудь важные сущности из финансовых или страховых областей, то никто нам не позволит просто оставить старые сущности без новых «правил», потому что эти правила могут быть законами или важными финансовыми показателями. 67 | 68 | Второе приложение просто добавит эту логику в свои алгоритмы и это правило мгновенно станет работать для всех партий, в том числе и для текущих. 69 | 70 | Кажется, вторая система готова к изменениям гораздо лучше, но если вы ещё сомневаетесь, то вот вам ещё одно правило про ничьи: тройное повторение. Если позиция на доске повторилась три раза за время партии, то объявляется ничья. Я не знаю каким образом разработчики первого приложения будут реализовывать это требование: им придётся где-то хранить все предыдущие положения на доске. Я часто наблюдаю такую картину: выбран неверный вариант хранения данных, алгоритм или другое архитектурное решение и каждое изменение требований вызывает боль. Код сильно «сопротивляется» изменениям. В ход идут костыли и заплатки. Когда такое происходит, стоит немного отвлечься и попробовать взглянуть на систему по-другому, подвергая сомнению каждое архитектурное решение, сделанное ранее. Вероятно, ещё не поздно его поменять. 71 | 72 | Второе приложение опять просто добавит в свою логику это правило и ничего менять в системе хранения партий не придётся. Как видите, в некоторых предметных областях знание о том, что было ранее, очень важно для бизнес-логики и идея хранения всей истории сущности как активного участника логики, а не как пассивных логов, может быть весьма здравой. 73 | 74 | Однако такое приложение все ещё трудно представить: история важна для логики, но пользователям обычно интересно только текущее состояние сущностей. Рассчитывать финальное состояние для каждого запроса на чтение в популярном приложении может сильно ударить по производительности. Для решения этой проблемы можно использовать идеи из прошлой главы. Там мы говорили о полностью отделенном коде для работы с операциями записи и чтения. Здесь разными будут и хранилища данных. 75 | 76 | Для операций записи будут использоваться хранилище с таблицами, которые хранят всю историю сущностей (есть и специальные хранилища оптимизированные для хранения событий - можно погуглить Event store). Для операций чтения будет использоваться традиционное хранилище с таблицами, хранящими текущее состояние. После каждого сделанного хода можно рассчитывать текущее состояние и записывать его в таблицу, которая будет использоваться для операций чтения. 77 | 78 | Я выбрал шахматы, поскольку это лучший пример настоящей Event sourcing предметной области. Шаблон Event sourcing предлагает хранить все изменения в системе как последовательность событий. Т.е. вместо традиционной таблицы **posts**: 79 | 80 | ``` 81 | PostId, Title, Text, Published, CreatedBy 82 | ``` 83 | 84 | Всё будет храниться в таблице **post_events**, в которую можно только вставлять новые записи или считывать их, т.е. никаких update или delete запросов - историю нельзя изменять. Выглядеть она будет так: 85 | 86 | ``` 87 | PostId, EventName, EventDate, EventData 88 | ``` 89 | 90 | Вероятные события: 91 | 92 | * **PostCreated**(Title, Text, CreatedBy) 93 | * **PostPublished**(PublishedBy) 94 | * **PostDeleted** 95 | 96 | Разумеется, блог это явно не та предметная область, для которой стоит реализовывать шаблон Event Sourcing. Очень сложно придумать такую логику с постами в блог, которая зависела бы от их истории. Хранение данных как последовательность событий имеет такие преимущества: 97 | 98 | * разработчики могут «продебажить» любую сущность и понять как именно она пришла к своему текущему состоянию. 99 | * состояние всего приложения можно рассчитать на любой момент времени и увидеть как всё было тогда. 100 | * любое состояние, базирующееся на исторических событиях может быть просчитано для любой сущности, в том числе и для старых. Например, если были забыты поля ´created_at´ и ´updated_at´ - их всегда можно рассчитать для всех сущностей позже. 101 | * любая логика, которая зависит от истории сущности, может быть реализована и она заработает немедленно для всех сущностей. Даже созданных до того, как эта логика зародилась в чьей то голове. Пример, некий трекер задач: требование о том, что если задание было назначено на одного и того же пользователя 3 раза - подписать менеджера на эту задачу. 102 | 103 | Существует довольно много индустрий, в которых текущее состояние сущностей не является единственными важными данными: 104 | 105 | * Баланс вашего банковского счёта всегда рассчитывается как результат всех транзакций с этим счётом, которые банк хранит как основной источник данных. 106 | * Расчеты страховых компаний полностью основаны на истории. 107 | * Медицинские данные всегда лишь некие записи из истории. 108 | * Бухгалтерские программы работают исключительно с произошедшими событиями. 109 | 110 | Реальными примерами технологий, использующих подход Event sourcing, являются современные системы хранения кода (git) и блокчейн. 111 | 112 | Git хранит данные как последовательности изменений. Изменение, которое чаще называется коммитом, содержит события, такие как: файл А создан с таким вот содержимым, такие-то строки вставлены в содержимое файла Б в такую-то позицию, файл В удалён. 113 | 114 | Блокчейном называется последовательность информационных блоков, в которую можно только добавлять и каждый блок содержит криптографический хеш, вычисляемый из предыдущего блока. 115 | 116 | Базы данных хранят все операции, которые изменяют данные (insert, update и delete запросы), в специальном логе транзакций и в некоторых ситуациях он используется как главный источник данных. Процесс репликации обычно основан на передаче этого лога транзакций с мастер базы данных в реплики. 117 | 118 | ## Unit-тестирование сущностей 119 | 120 | Давайте взглянем снова на unit-тесты для модели, написанные в главе про Доменный слой. 121 | 122 | ```php 123 | class JobApplyTest extends UnitTestCase 124 | { 125 | public function testApplySameFreelancer() 126 | { 127 | $job = $this->createJob(); 128 | $freelancer = $this->createFreelancer(); 129 | 130 | $freelancer->apply($job, 'cover letter'); 131 | 132 | $this->expectException(SameFreelancerProposalException::class); 133 | 134 | $freelancer->apply($job, 'another cover letter'); 135 | } 136 | } 137 | ``` 138 | 139 | Вместо того, чтобы создавать сущность в нужном нам состоянии и тестировать её поведение в этом состоянии, тесты вынуждены создать сущность в ее изначальном состоянии и, выполняя некоторые команды, довести её до нужной кондиции. Этот тест повторяет идеи Event sourcing. 140 | 141 | События - это единственная информация, которую эти модели отдают наружу и unit-тесты могут проверять только их. Что, если некая сущность вернёт событие об успешном действии, но забудет обновить своё состояние? Тест на данное действие будет успешным. Если сущность хорошо покрыта тестами, то, скорее всего, какие-нибудь другие тесты упадут. Если сущность **Job** не добавит заявку, то тест **testApplySameFreelancer** "упадёт". Однако для сложных сущностей (помните Монополию и шахматы?), таких тестов может не найтись и сущность с некорректной логикой пройдёт все unit-тесты. Простой пример с публикацией статей: 142 | 143 | ```php 144 | class Post 145 | { 146 | public function publish() 147 | { 148 | if (empty($this->body)) { 149 | throw new CantPublishException(); 150 | } 151 | 152 | //$this->published = true; 153 | 154 | $this->record(new PostPublished($this->id)); 155 | } 156 | } 157 | 158 | class PublishPostTest extends \PHPUnit\Framework\TestCase 159 | { 160 | public function testSuccessfulPublish() 161 | { 162 | // initialize 163 | $post = new Post('title', 'body'); 164 | 165 | // run 166 | $post->publish(); 167 | 168 | // check 169 | $this->assertEventsHas( 170 | PostPublished::class, $post->releaseEvents()); 171 | } 172 | } 173 | ``` 174 | 175 | Тесты будут корректными, но поле в базе не обновится. Пост опубликован не будет и функциональные тесты, если они написаны, должны "упасть", но надеяться на другие тесты - не самая лучшая стратегия. В Event sourcing (ES) системах события являются главным источником данных, поэтому само событие PostPublished является фактически аналогом записи `$this->published = true;` и unit-тестирование ES-сущностей выглядит намного более естественным. Тесты проверяют реальное поведение. 176 | 177 | ## Мир без магии 178 | 179 | Как сущности из главы про Доменный слой сохраняются в базе данных? Классы сущностей там скрывают почти всю информацию о себе, с приватными полями и публичные методы там только для действий, изменяющих состояние. Доктрина анализирует файлы с сущностями, получая мета-информацию о том, как поля должны быть сохранены и в каких таблицах. После команд **persist** и **flush** она использует всю мощь тёмной магии PHP reflection, получая нужные значения и сохраняя их в базе. Что, если магия покинет наш грешный мир? В таком мире будут невозможны Doctrine-сущности. 180 | 181 | Можно попробовать забыть про **Сокрытие информации** и сделать всю внутренность сущностей публичной, либо просто реализовать шаблон Event Sourcing! События публичны и полностью открыты. Их можно легко сохранять в базу. Как следствие, системы, обеспечивающие сохранение ES-сущностей в хранилищах, на несколько порядков проще, чем Doctrine и ей подобные. 182 | 183 | 184 | ![](images/es_cqrs.png) 185 | 186 | 187 | Архитектура ES-систем повторяет схему CQRS-приложений, но различия моделей для чтения и записи коснулись и хранилищ. Модель для записи, вместо данных, которые могли бы использоваться в модели для чтения, просто хранит события, а данные для чтения - лишь проекция этих событий: 188 | 189 | * Традиционное текущее состояние сущностей в таблицах 190 | * Полнотекстовые индексы для поиска (SphinxSearch или Elasticsearch) 191 | * Специальные статистические таблицы для отчётов (они частенько хранятся в отдельных таблицах или базах данных и в традиционных системах) 192 | * Могут быть и другие данные для чтения 193 | 194 | События - это достоверный источник всех данных во всех приложениях. Хранение их как первостепенный источник данных - весьма неплохая мысль, однако всё имеет свою цену. 195 | 196 | ## Реализация ES 197 | 198 | На момент написания книги, лучшей PHP библиотекой для реализации ES-подхода была **prooph/event-sourcing**, однако недавно я обнаружил обращение одного из разработчиков (полный вариант - https://www.sasaprolic.com/2018/08/the-future-of-prooph-components.html), в котором он объясняет почему они решили отказаться от дальнейшей разработки этой библиотеки. Если помните, я уже говорил, что в данном подходе практически не нужна сложная магия с рефлексией, и разработчик прямо пишет, что для реализации ES-подхода неправильно использовать какую-либо библиотеку, потому что написать те несколько строк кода для реализации подхода будет полезно как с точки зрения понимания ES разработчиками проекта, так и с точки зрения уменьшения зависимостей проекта от других библиотек. Однако это не помешает мне рассмотреть пример использование данной библиотеки, написанный самими разработчиками: **prooph/proophessor-do**. 199 | 200 | Каждая сущность имеет Value object для своего id. Это имеет смысл как для сокрытия реального типа, используемого для ключа, так и для того, чтобы случайно в коде не попытаться получить сущность, используя id для другой сущности. Этот подход популярен и в Doctrine-подобных сущностях. 201 | 202 | ```php 203 | interface ValueObject 204 | { 205 | public function sameValueAs(ValueObject $object): bool; 206 | } 207 | 208 | final class TodoId implements ValueObject 209 | { 210 | private function __construct(private UuidInterface $uuid) {} 211 | 212 | public static function generate(): TodoId 213 | { 214 | return new self(Uuid::uuid4()); 215 | } 216 | 217 | public static function fromString(string $todoId): TodoId 218 | { 219 | return new self(Uuid::fromString($todoId)); 220 | } 221 | 222 | public function toString(): string 223 | { 224 | return $this->uuid->toString(); 225 | } 226 | 227 | public function sameValueAs(ValueObject $other): bool 228 | { 229 | return \get_class($this) === \get_class($other) 230 | && $this->uuid->equals($other->uuid); 231 | } 232 | } 233 | ``` 234 | Здесь **TodoId** просто представляет собой UUID значение. 235 | 236 | ```php 237 | final class TodoWasPosted extends AggregateChanged 238 | { 239 | /** @var UserId */ 240 | private $assigneeId; 241 | 242 | /** @var TodoId */ 243 | private $todoId; 244 | 245 | /** @var TodoText */ 246 | private $text; 247 | 248 | /** @var TodoStatus */ 249 | private $todoStatus; 250 | 251 | public static function byUser(UserId $assigneeId, TodoText $text, 252 | TodoId $todoId, TodoStatus $todoStatus): TodoWasPosted 253 | { 254 | /** @var self $event */ 255 | $event = self::occur(...); 256 | 257 | $event->todoId = $todoId; 258 | $event->text = $text; 259 | $event->assigneeId = $assigneeId; 260 | $event->todoStatus = $todoStatus; 261 | 262 | return $event; 263 | } 264 | 265 | public function todoId(): TodoId {...} 266 | 267 | public function assigneeId(): UserId {...} 268 | 269 | public function text(): TodoText {...} 270 | 271 | public function todoStatus(): TodoStatus {...} 272 | } 273 | ``` 274 | 275 | Событие, возникающее при создании объекта Todo. **AggregateChanged** - это базовый класс для всех ES-событий в **Prooph**. Именованный конструктор используется для того, чтобы код выглядел как естественное предложение на английском: **TodoWasPosted::byUser(...)**. Всё, даже текст и статус, обёрнуты в Value Object классы. Пользу от такого сокрытия информации я приводил в главе про Доменный слой. 276 | 277 | Каждая сущность должна наследоваться от класса **AggregateRoot**. Главные его части: 278 | 279 | ```php 280 | abstract class AggregateRoot 281 | { 282 | /** 283 | * List of events that are not committed to the EventStore 284 | * 285 | * @var AggregateChanged[] 286 | */ 287 | protected $recordedEvents = []; 288 | 289 | /** 290 | * Get pending events and reset stack 291 | * 292 | * @return AggregateChanged[] 293 | */ 294 | protected function popRecordedEvents(): array 295 | { 296 | $pendingEvents = $this->recordedEvents; 297 | 298 | $this->recordedEvents = []; 299 | 300 | return $pendingEvents; 301 | } 302 | 303 | /** 304 | * Record an aggregate changed event 305 | */ 306 | protected function recordThat(AggregateChanged $event): void 307 | { 308 | $this->version += 1; 309 | 310 | $this->recordedEvents[] = 311 | $event->withVersion($this->version); 312 | 313 | $this->apply($event); 314 | } 315 | 316 | abstract protected function aggregateId(): string; 317 | 318 | /** 319 | * Apply given event 320 | */ 321 | abstract protected function apply(AggregateChanged $event); 322 | } 323 | ``` 324 | 325 | Тот же самый шаблон хранения событий в сущности, использованный нами ранее. Различия лишь в методе **apply**. ES-сущности могут изменять своё состояние только применяя события. Каждый раз сущность восстанавливает своё состояние, "проигрывая" все события, произошедшие с ней с самого начала. 326 | 327 | ```php 328 | final class Todo extends AggregateRoot 329 | { 330 | /** @var TodoId */ 331 | private $todoId; 332 | 333 | /** @var UserId */ 334 | private $assigneeId; 335 | 336 | /** @var TodoText */ 337 | private $text; 338 | 339 | /** @var TodoStatus */ 340 | private $status; 341 | 342 | public static function post( 343 | TodoText $text, 344 | UserId $assigneeId, 345 | TodoId $todoId): Todo 346 | { 347 | $self = new self(); 348 | $self->recordThat(TodoWasPosted::byUser( 349 | $assigneeId, $text, $todoId, TodoStatus::OPEN())); 350 | 351 | return $self; 352 | } 353 | 354 | /** 355 | * @throws Exception\TodoNotOpen 356 | */ 357 | public function markAsDone(): void 358 | { 359 | $status = TodoStatus::DONE(); 360 | 361 | if (! $this->status->is(TodoStatus::OPEN())) { 362 | throw Exception\TodoNotOpen::triedStatus($status, $this); 363 | } 364 | 365 | $this->recordThat(TodoWasMarkedAsDone::fromStatus( 366 | $this->todoId, $this->status, $status, $this->assigneeId)); 367 | } 368 | 369 | protected function aggregateId(): string 370 | { 371 | return $this->todoId->toString(); 372 | } 373 | 374 | /** 375 | * Apply given event 376 | */ 377 | protected function apply(AggregateChanged $event): void 378 | { 379 | switch (get_class($event)) { 380 | case TodoWasPosted::class: 381 | $this->todoId = $event->todoId(); 382 | $this->assigneeId = $event->assigneeId(); 383 | $this->text = $event->text(); 384 | $this->status = $event->todoStatus(); 385 | break; 386 | case TodoWasMarkedAsDone::class: 387 | $this->status = $event->newStatus(); 388 | break; 389 | } 390 | } 391 | } 392 | ``` 393 | 394 | Небольшая часть сущности **Todo**. Главное различие - состояние сущности полностью зависит от событий. Метод **markAsDone** не меняет состояние напрямую. Только через событие **TodoWasMarkedAsDone**. 395 | 396 | Для сохранения сущности используется id и все события, которые с ним произошли с последнего сохранения, получаемые с помощью метода **popRecordedEvents**. Они сохраняются в хранилище событий (это может быть просто таблица в базе данных, или что-то другое). 397 | Для выстраивания сущности по id из хранилища получают все события, создают новый объект нужного класса и "проигрывают" все события через него. 398 | 399 | ```php 400 | final class Todo extends AggregateRoot 401 | { 402 | /** @var null|TodoDeadline */ 403 | private $deadline; 404 | 405 | /** 406 | * @throws Exception\InvalidDeadline 407 | * @throws Exception\TodoNotOpen 408 | */ 409 | public function addDeadline( 410 | UserId $userId, TodoDeadline $deadline) 411 | { 412 | if (! $this->assigneeId()->sameValueAs($userId)) { 413 | throw Exception\InvalidDeadline::userIsNotAssignee( 414 | $userId, $this->assigneeId()); 415 | } 416 | 417 | if ($deadline->isInThePast()) { 418 | throw Exception\InvalidDeadline::deadlineInThePast( 419 | $deadline); 420 | } 421 | 422 | if ($this->status->is(TodoStatus::DONE())) { 423 | throw Exception\TodoNotOpen::triedToAddDeadline( 424 | $deadline, $this->status); 425 | } 426 | 427 | $this->recordThat(DeadlineWasAddedToTodo::byUserToDate( 428 | $this->todoId, $this->assigneeId, $deadline)); 429 | 430 | if ($this->isMarkedAsExpired()) { 431 | $this->unmarkAsExpired(); 432 | } 433 | } 434 | } 435 | ``` 436 | 437 | Другая часть сущности **Todo**: добавление дедлайна. Просто почитайте код. Он выглядит как простой английский текст, благодаря использованию правильно именованных конструкторов и объектов-значений. Дедлайн - это не просто **DateTime**, а специальный объект **TodoDeadline** со всеми нужными вспомогательными методами, такими как **isInThePast**. Всё это делает клиентский код очень чистым, легким для чтения, что очень важно для больших проектов, разрабатываемых командой программистов. 438 | 439 | Не хочу углубляться дальше в этот пример, я рекомендую ознакомиться самим, если интересно - **https://github.com/prooph/proophessor-do** 440 | 441 | Проекции - это объекты, которые трансформируют ES-события в данные, удобные для чтения. Практически каждая ES-система имеет проекции, которые выстраивают традиционные таблицы, содержащие текущее состояние сущностей. 442 | 443 | ```php 444 | final class Table 445 | { 446 | const TODO = 'read_todo'; 447 | //... 448 | } 449 | 450 | 451 | final class TodoReadModel extends AbstractReadModel 452 | { 453 | /** 454 | * @var Connection 455 | */ 456 | private $connection; 457 | 458 | public function __construct(Connection $connection) 459 | { 460 | $this->connection = $connection; 461 | } 462 | 463 | public function init(): void 464 | { 465 | $tableName = Table::TODO; 466 | 467 | $sql = <<connection->prepare($sql); 482 | $statement->execute(); 483 | } 484 | 485 | public function isInitialized(): bool 486 | { 487 | $tableName = Table::TODO; 488 | 489 | $sql = "SHOW TABLES LIKE '$tableName';"; 490 | 491 | $statement = $this->connection->prepare($sql); 492 | $statement->execute(); 493 | 494 | $result = $statement->fetch(); 495 | 496 | if (false === $result) { 497 | return false; 498 | } 499 | 500 | return true; 501 | } 502 | 503 | public function reset(): void 504 | { 505 | $tableName = Table::TODO; 506 | 507 | $sql = "TRUNCATE TABLE `$tableName`;"; 508 | 509 | $statement = $this->connection->prepare($sql); 510 | $statement->execute(); 511 | } 512 | 513 | public function delete(): void 514 | { 515 | $tableName = Table::TODO; 516 | 517 | $sql = "DROP TABLE `$tableName`;"; 518 | 519 | $statement = $this->connection->prepare($sql); 520 | $statement->execute(); 521 | } 522 | 523 | protected function insert(array $data): void 524 | { 525 | $this->connection->insert(Table::TODO, $data); 526 | } 527 | 528 | protected function update( 529 | array $data, array $identifier): void 530 | { 531 | $this->connection->update( 532 | Table::TODO, 533 | $data, 534 | $identifier 535 | ); 536 | } 537 | } 538 | ``` 539 | 540 | Этот класс представляет таблицу для содержания текущего состояния todo-задач. Методы **init**, **reset** и **delete** используются когда система хочет создать или пересоздать проекцию. Методы **insert** и **update** очевидно для добавления/изменения записей в таблице. Такой же класс может быть создан для построения полнотекстовых индексов, статистических данных или просто для логирования всех событий в файле (это не самый лучший вариант использования проекций - все события и так хранятся в хранилище событий). 541 | 542 | ```php 543 | $readModel = new TodoReadModel( 544 | $container->get('doctrine.connection.default')); 545 | 546 | $projection = $projectionManager 547 | ->createReadModelProjection('todo', $readModel); 548 | 549 | $projection 550 | ->fromStream('todo_stream') 551 | ->when([ 552 | TodoWasPosted::class 553 | => function ($state, TodoWasPosted $event) { 554 | $this->readModel()->stack('insert', [ 555 | 'id' => $event->todoId()->toString(), 556 | 'assignee_id' => $event->assigneeId()->toString(), 557 | 'text' => $event->text()->toString(), 558 | 'status' => $event->todoStatus()->toString(), 559 | ]); 560 | }, 561 | TodoWasMarkedAsDone::class 562 | => function ($state, TodoWasMarkedAsDone $event) { 563 | $this->readModel()->stack( 564 | 'update', 565 | [ 566 | 'status' => $event->newStatus()->toString(), 567 | ], 568 | [ 569 | 'id' => $event->todoId()->toString(), 570 | ] 571 | ); 572 | }, 573 | // ... 574 | ]) 575 | ->run(); 576 | ``` 577 | 578 | Это конфигурация проекции. Она использует класс **TodoReadModel** и трансформирует события в команды для этого класса. Событие **TodoWasPosted** приводит к созданию новой записи в таблице. Событие **TodoWasMarkedAsDone** изменяет поле статуса для определённого **id**. После трансформа всех событий таблица **read_todo** будет содержать текущее состояние всех todo-задач. Типичный процесс работы с данными в ES-системе такой: получение сущности из хранилища событий, вызов команды (**markAsDone** или **addDeadline**), получение всех новых событий из сущности (их может быть больше одного), сохранение их в хранилище событий, вызов всех проекций. Некоторые проекции хочется вызвать сразу, особенно те, которые изменяют таблицы с текущим состояние. Некоторые можно выполнить отложено в очереди. 579 | 580 | ## Уникальные данные в ES-системах 581 | 582 | Одним из недостатков подхода ES является то, что данные в них невозможно проверять условиями, такими как уникальные индексы. В традиционных базах данных, обычный уникальный индекс на колонку `users.email` позволяет нам быть спокойными - в системе не будет двух юзеров с одинаковыми email. Сущности же в ES-системах абсолютно независимы друг от друга. Разные пользователи с одинаковыми email вполне могут жить рядом друг с другом, но требования к системе такого не допускают. 583 | 584 | Некоторые приложения используют уникальные индексы из таблиц для чтения, но на 100% такие проверки не защищают. Некоторые системы просто создают специальную таблицу с одним полем для email под уникальным индексом и вставляют значение туда перед попыткой сохранить событие о создании пользователя. 585 | 586 | ## Пара слов в конце главы 587 | 588 | Шаблон Event Sourcing - очень мощная вещь. Он позволяет легко реализовывать логику, основанную на исторических данных. Он может помочь приложениям для регулируемых областей иметь правильные аудит-логи. Он также помогает приложениям быть лучше готовыми к изменениям, особенно если эти изменения основаны на том, что происходило с сущностями в прошлом. 589 | 590 | Недостатков тоже хватает. ES очень "дорогой" подход. Дорогой по времени кодирования, по железу и времени системных администраторов для обслуживания проекта, по требуемому уровню разработчиков. В команду будет сложнее вливаться новым членам. "Мышление событиями" сильно отличается от "мышления строчками в базе данных". Анализируя проекты, подобные **proophessor-do**, или создавая свои, особенно для нестандартных предметных областей, можно достичь более глубокого понимания преимуществ и недостатков этого подхода для каждого приложения и более обоснованно выбирать или не выбирать его для новых проектов. 591 | -------------------------------------------------------------------------------- /manuscript/12-end.md: -------------------------------------------------------------------------------- 1 | # Заключение 2 | 3 | Эта книга просто некий обзор практик, которые мне показались полезными при разработке приложений. Возможно, кому-то она поможет выбрать нужную для своего проекта. Главное, нужно понять, что она не о том, что надо каждое приложение взять и переписать с использованием Event Sourcing. К каждому приложению нужен свой подход и одни и те же практики отлично подходят к одним приложениям, но будут вредны для других. 4 | 5 | Небольшой список литературы, если кому-то захочется углубиться: 6 | 7 | ## Классика 8 | 9 | * **"Clean Code"** by Robert Martin 10 | * **"Refactoring: Improving the Design of Existing Code"** by Martin Fowler 11 | 12 | ## DDD 13 | 14 | * **"Domain-Driven Design: Tackling Complexity in the Heart of Software"** by Eric Evans 15 | * **"Implementing Domain-Driven Design"** by Vaughn Vernon 16 | 17 | ## ES и CQRS 18 | 19 | * https://aka.ms/cqrs - CQRS journey by Microsoft 20 | * https://github.com/prooph/proophessor-do - Prooph library example project 21 | 22 | ## Unit тестирование 23 | 24 | * F.I.R.S.T Principles of Unit Testing -------------------------------------------------------------------------------- /manuscript/3-painless-refactoring.md: -------------------------------------------------------------------------------- 1 | # Безболезненный рефакторинг 2 | 3 | A> "Надежно зафиксированный больной не нуждается в анестезии." 4 | 5 | ## "Статическая" типизация 6 | 7 | Маленькие и большие программные проекты отличаются многим, в том числе и стилем работы. 8 | Большую часть времени в небольших проектах занимается собственно кодирование - набор кодовой базы. 9 | В больших проектах же - навигация по этой кодовой базе: перемещение от одного класса к другому, от вызова метода к его коду, от кода метода к его вызовам (функция Find Usages). 10 | Иногда больше 95% времени на задачу занимает именно блуждание по лабиринту кода. 11 | 12 | А для того, чтобы это блуждание было менее болезненным, проекты постоянно нуждаются в рефакторинге: 13 | - извлечение методов и классов из других методов и классов; 14 | - переименование их, добавление и удаление параметров; 15 | 16 | Современные среды разработки (IDE) имеют на борту кучу инструментов для продвинутой навигации и рефакторинга, которые облегчают его, а иногда и выполняют его полностью автоматически. 17 | Однако, динамическая природа PHP часто вставляет палки в колёса. 18 | 19 | ```php 20 | public function publishPost($id) 21 | { 22 | $post = Post::find($id); 23 | $post->publish(); 24 | } 25 | 26 | // или 27 | 28 | public function publishPost($post) 29 | { 30 | $post->publish(); 31 | } 32 | ``` 33 | 34 | В обоих этих случаях IDE не может самостоятельно понять, что был вызван метод **publish** класса **Post**. 35 | Для того чтобы добавить новый параметр в этот метод, нужно будет найти все использования этого метода. 36 | 37 | ```php 38 | public function publish(User $publishedBy) 39 | ``` 40 | IDE не сможет сама найти их. 41 | Разработчику придётся искать по всему проекту слово «publish» и найти среди результатов именно вызовы данного метода. 42 | Для каких-то более распространённых слов (name или create) и при большом размере проекта это может быть весьма мучительно. 43 | 44 | Представим ситуацию когда команда обнаруживает, что в поле `email` в базе данных находятся значения, не являющиеся корректными email-адресами. 45 | Как это произошло? 46 | Необходимо найти все возможные присвоения полю **email** класса **User** и проверить их. 47 | Весьма непростая задача, если учесть, что поле `email` виртуальное и оно может быть присвоено вот так: 48 | 49 | ```php 50 | $user = User::create($request->all()); 51 | //or 52 | $user->fill($request->all()); 53 | ``` 54 | Эта автомагия, которая помогала нам так быстро создавать приложения, показывает свою истинную сущность, преподнося такие вот сюрпризы. 55 | Такие баги в продакшене иногда очень критичны и каждая минута важна, а я до сих пор помню, как целый день в огромном проекте, который длится уже лет 10, искал все возможные присвоения одного поля, пытаясь найти, где оно получает некорректное значение. 56 | 57 | После нескольких подобных случаев тяжелого дебага, а также сложных рефакторингов, я выработал себе правило: делать PHP-код как можно более статичным. 58 | IDE должна знать все про каждый метод и каждое поле, которое я использую. 59 | 60 | ```php 61 | public function publish(Post $post) 62 | { 63 | $post->publish(); 64 | } 65 | 66 | // или с phpDoc 67 | 68 | public function publish($id) 69 | { 70 | /** 71 | * @var Post $post 72 | */ 73 | $post = Post::find($id); 74 | $post->publish(); 75 | } 76 | ``` 77 | Комментарии phpDoc могут помочь и в сложных случаях: 78 | 79 | ```php 80 | /** 81 | * @var Post[] $posts 82 | */ 83 | $posts = Post::all(); 84 | foreach($posts as $post) { 85 | $post->// Здесь IDE должна подсказывать 86 | // все методы и поля класса Post 87 | } 88 | ``` 89 | Подсказки IDE приятны при написании кода, но намного важнее, что подсказывая их, она понимает откуда они и всегда найдёт их использования. 90 | 91 | Если функция возвращает объект какого-то класса, он должен быть объявлен как return-тип (начиная с PHP7) или в `@return` теге phpDoc-комментария функции. 92 | 93 | ```php 94 | public function getPost($id): Post 95 | { 96 | //... 97 | } 98 | 99 | /** 100 | * @return Post[] | Collection 101 | */ 102 | public function getPostsBySomeCriteria(...) 103 | { 104 | return Post::where(...)->get(); 105 | } 106 | ``` 107 | Меня пару раз спрашивали: зачем я делаю Java из PHP? 108 | Это не совсем так. 109 | Я просто создаю маленькие комментарии, чтобы иметь удобные подсказки от IDE прямо сейчас и огромную помощь в будущем, при навигации, рефакторинге и дебаггинге. 110 | Даже для небольших проектов они невероятно полезны. 111 | 112 | ## Шаблоны 113 | 114 | На сегодняшний день все больше и больше проектов имеют только API-интерфейс, однако количество проектов, напрямую генерирующих HTML все ещё велико. 115 | Они используют шаблоны, в которых много вызовов методов и полей. Типичный вызов шаблона в Laravel: 116 | 117 | ```php 118 | return view('posts.create', [ 119 | 'author' => \Auth::user(), 120 | 'categories' => Category::all(), 121 | ]); 122 | ``` 123 | Он выглядит как вызов некоей функции. Сравните с этим псевдо-кодом: 124 | 125 | ```php 126 | /** 127 | * @param User $author 128 | * @param Category[] | Collection $categories 129 | */ 130 | function showPostCreateView(User $author, $categories): string 131 | { 132 | // 133 | } 134 | 135 | return showPostCreateView(\Auth::user(), Category::all()); 136 | ``` 137 | Хочется так же описать и параметры шаблонов. 138 | Это легко, когда шаблоны написаны на чистом PHP — комментарии phpDoc легко бы помогли. 139 | Для шаблонных движков, таких как Blade, это не так просто и зависит от IDE. 140 | Я работаю в PhpStorm, поэтому могу говорить только про него. С недавних пор там тоже можно объявлять типы через phpDoc: 141 | 142 | ```php 143 | 149 | 150 | @foreach($categories as $category) 151 | {{$category->//Category class fields and methods autocomplete}} 152 | @endforeach 153 | ``` 154 | 155 | Я понимаю, что многим это кажется уже перебором и бесполезной тратой времени, но после всех этих усилий по статической «типизации» мой код в разы более гибкий. 156 | Я легко нахожу все использования полей и методов, могу переименовать все автоматически. Каждый рефакторинг приносит минимум боли. 157 | 158 | ## Поля моделей 159 | Использование магических методов **__get**, **__set**, **__call** и других соблазнительно, но опасно — находить такие магические вызовы будет сложно. 160 | Если вы используете их, лучше снабдить эти классы нужными phpDoc комментариями. Пример с небольшой Eloquent моделью: 161 | 162 | ```php 163 | class User extends Model 164 | { 165 | public function roles() 166 | { 167 | return $this->hasMany(Role::class); 168 | } 169 | } 170 | ``` 171 | Этот класс имеет несколько виртуальных полей, представляющих поля таблицы **users**, а также поле **roles**. 172 | С помощью пакета laravel-ide-helper можно автоматически сгенерировать phpDoc для этого класса. 173 | Всего один вызов artisan команды и для всех моделей будут сгенерированы комментарии: 174 | 175 | ```php 176 | /** 177 | * App\User 178 | * 179 | * @property int $id 180 | * @property string $name 181 | * @property string $email 182 | * @property-read Collection|\App\Role[] $roles 183 | * @method static Builder|\App\User whereEmail($value) 184 | * @method static Builder|\App\User whereId($value) 185 | * @method static Builder|\App\User whereName($value) 186 | * @mixin \Eloquent 187 | */ 188 | class User extends Model 189 | { 190 | public function roles() 191 | { 192 | return $this->hasMany(Role::class); 193 | } 194 | } 195 | 196 | $user = new User(); 197 | $user->// Здесь IDE подскажет все поля! 198 | ``` 199 | 200 | Возвратимся к примеру из прошлой главы: 201 | 202 | ```php 203 | public function store(Request $request, ImageUploader $imageUploader) 204 | { 205 | $this->validate($request, [ 206 | 'email' => 'required|email', 207 | 'name' => 'required', 208 | 'avatar' => 'required|image', 209 | ]); 210 | 211 | $avatarFileName = ...; 212 | $imageUploader->upload($avatarFileName, $request->file('avatar')); 213 | 214 | $user = new User($request->except('avatar')); 215 | $user->avatarUrl = $avatarFileName; 216 | 217 | if (!$user->save()) { 218 | return redirect()->back()->withMessage('...'); 219 | } 220 | 221 | \Email::send($user->email, 'Hi email'); 222 | 223 | return redirect()->route('users'); 224 | } 225 | ``` 226 | Создание сущности User выглядит странновато. До некоторых изменений оно выглядело хотя бы красиво: 227 | 228 | ```php 229 | User::create($request->all()); 230 | ``` 231 | Потом пришлось его поменять, поскольку поле **avatarUrl** нельзя присваивать напрямую из объекта запроса. 232 | 233 | ```php 234 | $user = new User($request->except('avatar')); 235 | $user->avatarUrl = $avatarFileName; 236 | ``` 237 | Оно не только выглядит странно, но и небезопасно. 238 | Этот метод используется в обычной регистрации пользователя. 239 | В будущем может быть добавлено поле **admin**, которое будет выделять администраторов от обычных смертных. 240 | Какой-нибудь сообразительный хакер может просто сам добавить новое поле в форму регистрации: 241 | 242 | ```html 243 | 244 | ``` 245 | Он станет администратором сразу же после регистрации. 246 | По этим причинам некоторые эксперты советуют перечислять все нужные поля (есть ещё метод **$request->validated()**, но его изъяны будут понятны позже в книге, если будете читать внимательно): 247 | 248 | ```php 249 | $request->only(['email', 'name']); 250 | ``` 251 | Но если мы и так перечисляем все поля, может просто сделаем создание объекта более цивилизованным? 252 | 253 | ```php 254 | $user = new User(); 255 | $user->email = $request['email']; 256 | $user->name = $request['name']; 257 | $user->avatarUrl = $avatarFileName; 258 | ``` 259 | Этот код уже можно показывать в приличном обществе. Он будет понятен любому PHP-разработчику. 260 | IDE теперь всегда найдёт, что в этом месте полю **email** класса **User** было присвоено значение. 261 | 262 | «Что, если у сущности 50 полей?» 263 | Вероятно, стоит немного поменять интерфейс пользователя? 50 полей - многовато для любого, будь то пользователь или разработчик. 264 | Если не согласны, то дальше в книге будут показаны пару приемов, с помощью которых можно сократить данный код даже для большого количества полей. 265 | 266 | ## Laravel Idea 267 | 268 | Это было настолько важным для меня, что я разработал плагин для PhpStorm - [Laravel Idea](https://laravel-idea.com/?utm_medium=book&utm_source=book_architecture&utm_campaign=link_inside_book). Он весьма неплохо разбирается в магии Laravel и устраняет необходимость во всех phpDoc, о которых я писал выше, предлагает кучу кодо-генераций и сотни других функций. Приведу пару примеров. 269 | 270 | ```php 271 | User::where('email', $email); 272 | ``` 273 | 274 | Плагин виртуально свяжет строку `'email'` в этом коде с полем `$email` класса User. 275 | Это позволяет подсказывать все поля сущности для первого аргумента метода `where`, а также находить все подобные использования поля `$email` 276 | и даже автоматически переименовать все такие строки, если пользователь переименует `$email` в какой-нибудь `$firstEmail`. Это работает даже для сложных случаев: 277 | 278 | ```php 279 | Post::with('author:email'); 280 | 281 | Post::with([ 282 | 'author' => function (Builder $query) { 283 | $query->where('email', 'some@email'); 284 | }]); 285 | ``` 286 | 287 | В обоих этих случаях PhpStorm найдёт, что здесь было использовано поле `$email`. То же самое с роутингом: 288 | 289 | ```php 290 | Route::get('/', 'HomeController@index'); 291 | ``` 292 | 293 | Здесь присутствуют ссылки на класс `HomeController` и метод `index` в нём. Если попросить PhpStorm найти места, где используется метод index - он найдет место в этом файле роутов. Такие фичи, на первый взгляд не нужные, позволяют держать приложение под бОльшим контролем, который просто необходим для приложений среднего или большого размеров. 294 | 295 | Мы сделали наш код более удобным для будущих рефакторингов или дебага. Эта «статическая типизация» не является обязательной, но она крайне полезна. Необходимо хотя бы попробовать. 296 | -------------------------------------------------------------------------------- /manuscript/4-application-layer.md: -------------------------------------------------------------------------------- 1 | # Слой Приложения 2 | 3 | A> "Я Винстон Вульф. Я решаю проблемы." 4 | 5 | Продолжаем наш пример. 6 | Приложение растёт, и в форму регистрации добавились новые поля: дата рождения и опция согласия получения email-рассылки. 7 | 8 | ```php 9 | public function store( 10 | Request $request, 11 | ImageUploader $imageUploader) 12 | { 13 | $this->validate($request, [ 14 | 'email' => 'required|email', 15 | 'name' => 'required', 16 | 'avatar' => 'required|image', 17 | 'birthDate' => 'required|date', 18 | ]); 19 | 20 | $avatarFileName = ...; 21 | $imageUploader->upload( 22 | $avatarFileName, $request->file('avatar')); 23 | 24 | $user = new User(); 25 | $user->email = $request['email']; 26 | $user->name = $request['name']; 27 | $user->avatarUrl = $avatarFileName; 28 | $user->subscribed = $request->has('subscribed'); 29 | $user->birthDate = new DateTime($request['birthDate']); 30 | 31 | if(!$user->save()) { 32 | return redirect()->back()->withMessage('...'); 33 | } 34 | 35 | \Email::send($user->email, 'Hi email'); 36 | 37 | return redirect()->route('users'); 38 | } 39 | ``` 40 | Потом у приложения появляется API для мобильного приложения и регистрация пользователей должна быть реализована и там. 41 | Давайте ещё нафантазируем некую консольную artisan-команду, которая импортирует пользователей и она тоже хочет их регистрировать. 42 | И telegram-бот! 43 | В итоге у приложения появилось несколько интерфейсов, кроме стандартного HTML и везде необходимо использовать такие действия, как регистрация пользователя или публикация статьи. 44 | Самое естественное решение здесь выделить общую логику работы с сущностью (User в данном примере) в отдельный класс. 45 | Такие классы часто называют сервисными классами: 46 | 47 | ```php 48 | final class UserService 49 | { 50 | public function getById(...): User; 51 | public function getByEmail(...): User; 52 | 53 | public function create(...); 54 | public function ban(...); 55 | ... 56 | } 57 | ``` 58 | Но множество интерфейсов приложения (API, Web, и т.д.) не являются единственной причиной создания сервисных классов. 59 | Методы контроллеров начинают расти и обычно содержат две большие части: 60 | 61 | ```php 62 | public function doSomething(Request $request, $id) 63 | { 64 | $entity = Entity::find($id); 65 | 66 | if (!$entity) { 67 | abort(404); 68 | } 69 | 70 | if (count($request['options']) < 2) { 71 | return redirect()->back()->withMessage('...'); 72 | } 73 | 74 | if ($entity->something) { 75 | return redirect()->back()->withMessage('...'); 76 | } 77 | 78 | \Db::transaction(function () use ($request, $entity) { 79 | $entity->someProperty = $request['someProperty']; 80 | 81 | foreach($request['options'] as $option) { 82 | //... 83 | } 84 | 85 | $entity->save(); 86 | }); 87 | 88 | return redirect()->... 89 | } 90 | ``` 91 | Этот метод реализует как минимум две ответственности: логику работы с HTTP запросом/ответом и бизнес-логику. 92 | Каждый раз когда разработчик меняет http-логику, он вынужден читать много кода бизнес-логики и наоборот. 93 | Такой код сложнее дебажить и рефакторить, поэтому вынесение логики в сервис-классы тоже может быть хорошей идеей для этого проекта. 94 | 95 | ## Передача данных запроса 96 | 97 | Начнем создавать класс **UserService**. 98 | Первой проблемой будет передача данных запроса туда. 99 | Некоторым методам не нужно много данных и, например, для удаления статьи нужен только её id. 100 | Однако, для таких действий, как регистрация пользователя, данных может понадобиться много. 101 | Мы не можем использовать класс **Request**, поскольку он доступен только для web и недоступен, например для консоли. Попробуем простые массивы: 102 | 103 | ```php 104 | final class UserService 105 | { 106 | public function __construct( 107 | private ImageUploader $imageUploader, 108 | private EmailSender $emailSender) {} 109 | 110 | public function create(array $request) 111 | { 112 | $avatarFileName = ...; 113 | $this->imageUploader->upload( 114 | $avatarFileName, $request['avatar']); 115 | 116 | $user = new User(); 117 | $user->email = $request['email']; 118 | $user->name = $request['name']; 119 | $user->avatarUrl = $avatarFileName; 120 | $user->subscribed = isset($request['subscribed']); 121 | $user->birthDate = new DateTime($request['birthDate']); 122 | 123 | if (!$user->save()) { 124 | return false; 125 | } 126 | 127 | $this->emailSender->send($user->email, 'Hi email'); 128 | 129 | return true; 130 | } 131 | } 132 | 133 | // Controller 134 | public function store(Request $request, UserService $userService) 135 | { 136 | $this->validate($request, [ 137 | 'email' => 'required|email', 138 | 'name' => 'required', 139 | 'avatar' => 'required|image', 140 | 'birthDate' => 'required|date', 141 | ]); 142 | 143 | if (!$userService->create($request->all())) { 144 | return redirect()->back()->withMessage('...'); 145 | } 146 | 147 | return redirect()->route('users'); 148 | } 149 | ``` 150 | Я просто вынес логику без каких-либо изменений и вижу проблему. 151 | Когда мы попытаемся зарегистрировать пользователя из консоли, то код будет выглядеть примерно так: 152 | 153 | ```php 154 | $data = [ 155 | 'email' => $email, 156 | 'name' => $name, 157 | 'avatar' => $avatarFile, 158 | 'birthDate' => $birthDate->format('Y-m-d'), 159 | ]; 160 | 161 | if ($subscribed) { 162 | $data['subscribed'] = true; 163 | } 164 | 165 | $userService->create($data); 166 | ``` 167 | Выглядит чересчур витиевато. 168 | Вместе с кодом создания пользователя в сервис переехала и HTML-специфика передаваемых данных. 169 | Булевы значения проверяются присутствием нужного ключа в массиве. Значения даты получаются преобразованием из строк. 170 | Использование такого формата данных весьма неудобно, если эти данные пришли не из HTML-формы. 171 | Нам нужен новый формат данных, более естественный, более удобный. 172 | Шаблон **Data Transfer Object**(DTO) предлагает просто создавать объекты с нужными полями: 173 | 174 | ```php 175 | final class UserCreateDto 176 | { 177 | private string $email; 178 | 179 | private DateTime $birthDate; 180 | 181 | private bool $subscribed; 182 | 183 | public function __construct( 184 | string $email, DateTime $birthDate, bool $subscribed) 185 | { 186 | $this->email = $email; 187 | $this->birthDate = $birthDate; 188 | $this->subscribed = $subscribed; 189 | } 190 | 191 | public function getEmail(): string 192 | { 193 | return $this->email; 194 | } 195 | 196 | public function getBirthDate(): DateTime 197 | { 198 | return $this->birthDate; 199 | } 200 | 201 | public function isSubscribed(): bool 202 | { 203 | return $this->subscribed; 204 | } 205 | } 206 | ``` 207 | 208 | Частенько я слышу возражения вроде «Я не хочу создавать целый класс только для того, чтобы передать данные. Массивы справляются не хуже.» 209 | Это верно отчасти и для какого-то уровня сложности приложений создавать классы DTO, вероятно, не стоит. 210 | Дальше в книге я приведу пару аргументов в пользу DTO. Здесь же лишь напишу, что в современных IDE такой класс создается очень быстро - достаточно лишь описать поля - параметры конструктора и getter методы генерируются автоматически. 211 | Заглядывать в этот класс разработчики будут крайне редко, поэтому какой-то сложности в поддержке приложения он не добавляет. 212 | С появлением в современном PHP readonly полей и классов создавать такие DTO-классы стало еще легче. 213 | 214 | ```php 215 | readonly final class UserCreateDto 216 | { 217 | public function __construct( 218 | public string $email; 219 | public DateTime $birthDate; 220 | public bool $subscribed; 221 | ) {} 222 | } 223 | ``` 224 | 225 | Комбинация из private поля и геттер-метода использовалась, чтобы обеспечить неизменяемость данных внутри DTO-объекта. Модификатор readonly гарантирует её, поэтому мы можем сделать поля public. 226 | 227 | ```php 228 | final class UserService 229 | { 230 | //... 231 | 232 | public function create(UserCreateDto $request) 233 | { 234 | $avatarFileName = ...; 235 | $this->imageUploader->upload( 236 | $avatarFileName, $request->avatarFile); 237 | 238 | $user = new User(); 239 | $user->email = $request->email; 240 | $user->avatarUrl = $avatarFileName; 241 | $user->subscribed = $request->subscribed; 242 | $user->birthDate = $request->birthDate; 243 | 244 | if (!$user->save()) { 245 | return false; 246 | } 247 | 248 | $this->emailSender->send($user->email, 'Hi email'); 249 | 250 | return true; 251 | } 252 | } 253 | 254 | public function store(Request $request, UserService $userService) 255 | { 256 | $this->validate($request, [ 257 | 'email' => 'required|email', 258 | 'name' => 'required', 259 | 'avatar' => 'required|image', 260 | 'birthDate' => 'required|date', 261 | ]); 262 | 263 | $dto = new UserCreateDto( 264 | $request['email'], 265 | new DateTime($request['birthDate']), 266 | $request->has('subscribed')); 267 | 268 | if (!$userService->create($dto)) { 269 | return redirect()->back()->withMessage('...'); 270 | } 271 | 272 | return redirect()->route('users'); 273 | } 274 | ``` 275 | Теперь это выглядит канонично. 276 | Сервисный класс получает чистую DTO и выполняет действие. 277 | Правда, метод контроллера теперь довольно большой. Конструктор DTO класса может быть весьма длинным. 278 | Можно попробовать вынести логику работы с данными запроса оттуда. 279 | В Laravel есть удобные классы для этого — **Form Requests**. 280 | 281 | ```php 282 | final class UserCreateRequest extends FormRequest 283 | { 284 | public function rules() 285 | { 286 | return [ 287 | 'email' => 'required|email', 288 | 'name' => 'required', 289 | 'avatar' => 'required|image', 290 | 'birthDate' => 'required|date', 291 | ]; 292 | } 293 | 294 | public function getDto(): UserCreateDto 295 | { 296 | return new UserCreateDto( 297 | $this->get('email'), 298 | new DateTime($this->get('birthDate')), 299 | $this->has('subscribed')); 300 | } 301 | } 302 | 303 | final class UserController extends Controller 304 | { 305 | public function store( 306 | UserCreateRequest $request, UserService $userService) 307 | { 308 | if (!$userService->create($request->getDto())) { 309 | return redirect()->back()->withMessage('...'); 310 | } 311 | 312 | return redirect()->route('users'); 313 | } 314 | } 315 | ``` 316 | Если какой-либо класс просит класс, наследованный от **FormRequest**, как зависимость, Laravel создаёт его и выполняет валидацию автоматически. 317 | В случае неверных данных метод **store** не будет выполняться, поэтому можно всегда быть уверенными в валидности данных в **UserCreateRequest**. 318 | 319 | ## Работа с базой данных 320 | 321 | Простой пример: 322 | 323 | ```php 324 | class PostController 325 | { 326 | public function publish($id, PostService $postService) 327 | { 328 | $post = Post::find($id); 329 | 330 | if (!$post) { 331 | abort(404); 332 | } 333 | 334 | if (!$postService->publish($post)) { 335 | return redirect()->back()->withMessage('...'); 336 | } 337 | 338 | return redirect()->route('posts'); 339 | } 340 | } 341 | 342 | final class PostService 343 | { 344 | public function publish(Post $post) 345 | { 346 | $post->published = true; 347 | 348 | return $post->save(); 349 | } 350 | } 351 | ``` 352 | Публикация статьи — это пример простейшего не-CRUD действия. 353 | В примере все выглядит неплохо, но если попробовать вызвать метод сервиса из консоли, то нужно будет опять доставать сущность **Post** из базы данных. 354 | 355 | ```php 356 | public function handle(PostService $postService) 357 | { 358 | $post = Post::find(...); 359 | 360 | if (!$post) { 361 | $this->error(...); 362 | return; 363 | } 364 | 365 | if (!$postService->publish($post)) { 366 | $this->error(...); 367 | } else { 368 | $this->info(...); 369 | } 370 | } 371 | ``` 372 | Это пример нарушения Принципа единственной ответственности (SRP) и высокой связанности (coupling). 373 | Каждая часть приложения (и сервисные классы, и веб-контроллеры, и консольные команды) работает с базой данных. 374 | Любое изменение, связанное с работой с базой данных, может повлечь изменения во всем приложении. 375 | Необходимо спрятать работу с базой данных внутри сервисного класса. 376 | Иногда я вижу интересный вариант в виде метода **getById** в сервисах: 377 | 378 | ```php 379 | class PostController 380 | { 381 | public function publish($id, PostService $postService) 382 | { 383 | $post = $postService->getById($id); 384 | 385 | if (!$post) { 386 | abort(404); 387 | } 388 | 389 | if (!$postService->publish($post)) { 390 | return redirect()->back()->withMessage('...'); 391 | } 392 | 393 | return redirect()->route('posts'); 394 | } 395 | } 396 | ``` 397 | Этот код просто получает сущность и передаёт её в другой метод сервиса, но методу контроллера сущность **Post** не нужна. 398 | Он может просто попросить сервис опубликовать статью с таким то **id**. 399 | Наиболее логичное и простое решение: 400 | 401 | ```php 402 | class PostController 403 | { 404 | public function publish($id, PostService $postService) 405 | { 406 | if (!$postService->publish($id)) { 407 | return redirect()->back()->withMessage('...'); 408 | } 409 | 410 | return redirect()->route('posts'); 411 | } 412 | } 413 | 414 | final class PostService 415 | { 416 | public function publish(int $id) 417 | { 418 | $post = Post::find($id); 419 | 420 | if (!$post) { 421 | return false; 422 | } 423 | 424 | $post->published = true; 425 | 426 | return $post->save(); 427 | } 428 | } 429 | ``` 430 | Одно из главных преимуществ создания сервисных классов — это консолидация всей работы с бизнес-логикой и инфраструктурой, включая хранилища данных, такие как базы данных и файлы, в одном месте, оставляя Web, API, Console и другим интерфейсам работу исключительно со своими обязанностями. 431 | Часть приложения для работы с Web(веб-контроллеры, request классы) должна просто готовить данные для сервис-классов и показывать результаты пользователю. 432 | То же самое про другие интерфейсы. 433 | Это Принцип единственной ответственности для слоёв. 434 | Слоёв? Да. 435 | 436 | Слоем называют группу классов, объединенных схожей ответственностью и схожими зависимостями. 437 | Например, слой веб-контроллеров - классы, которые принимают веб-запрос, передают его в сервисные классы, получают от них ответ и отдают результат в нужной форме. 438 | Слоем они названы из-за того, что веб-запрос(или запрос из консоли) проходит сквозь эти слои(веб-контроллеров, сервисный классов, работы с базой данных) и возвращается через них же. 439 | 440 | Все сервис-классы, прячущие логику приложения внутри себя, формируют структуру, которая имеет множество имён: 441 | * **Сервисный слой** (Service layer), из-за **сервисных** классов. 442 | * **Слой приложения** (Application layer), потому что он содержит всю логику приложения, исключая интерфейсы к нему. 443 | * в GRASP этот слой называется **Слой контроллеров** (Controllers layer), поскольку сервисные классы там называются контроллерами. 444 | * наверняка есть и другие названия. 445 | 446 | В этой книге я буду называть его **Слоем приложения**. Потому что могу. 447 | 448 | То, что я описал здесь, очень похоже на архитектурный шаблон **Гексагональная архитектура**, или **Луковая архитектура**, или еще десятки подобных. 449 | Неудивительно, поскольку они решают ровно те же задачи. 450 | Однако, гораздо полезнее для развития разработчика самому осознать причины выделения кода в отдельные классы, почувствовать какие части кода могут работать с базой данных или данными веб-запроса. 451 | Самому увидеть как участки кода с похожими обязанностями и потребностями выстраиваются в некое подобие слоев. 452 | Самому чувствовать когда проекту требуется выделение логики в другие классы, а когда нет. 453 | Получив подобный опыт и набив руку, можно ознакомиться с данными шаблонами и, возможно, принять один из них как стандарт на каком-либо проекте, полностью осознавая, что данный шаблон подходит проекту, не являясь микроскопом, коим забивают гвозди или пушкой, которой стреляют по воробьям. 454 | 455 | ## Сервисные классы или классы команд? 456 | 457 | Когда сущность большая, с кучей различных действий, сервисный класс для нее тоже будет большим. 458 | Разные действия, такие как отредактировать статью или опубликовать её, требуют разных зависимостей. 459 | Всем нужна база данных, но одни хотят посылать письма, другие — залить файл в хранилище, третьи — вызвать какое-нибудь внешнее API. 460 | Количество параметров конструктора сервисного класса растёт довольно быстро, хотя каждое из них может использоваться лишь в одном-двух методах. 461 | Довольно быстро становится понятно, что класс занимается слишком многими делами. 462 | Поэтому разработчики часто начинают создавать классы на каждое действие с сущностями. 463 | 464 | Насколько мне известно, стандарта на название таких классов тоже нет. 465 | Я видел суффикс **UseCase** (например **PublishPostUseCase**), суффикс **Action** (**PublishPostAction**), но предпочитаю суффикс **Command**: **CreatePostCommand**, **PublishPostCommand**, **DeletePostCommand**. 466 | 467 | ```php 468 | final class PublishPostCommand 469 | { 470 | public function execute($id) 471 | { 472 | //... 473 | } 474 | } 475 | ``` 476 | В шаблоне **Command Bus** суффикс **Command** используется для DTO-классов, а классы исполняющие команды называются **CommandHandler**. 477 | 478 | ```php 479 | readonly final class ChangeUserPasswordCommand 480 | { 481 | public function __construct( 482 | public int $id; 483 | public string $oldPassword; 484 | public string $newPassword; 485 | ) {} 486 | } 487 | 488 | final class ChangeUserPasswordCommandHandler 489 | { 490 | public function handle( 491 | ChangeUserPasswordCommand $command) 492 | { 493 | //... 494 | } 495 | } 496 | 497 | // или если один класс исполняет много команд 498 | 499 | final class UserCommandHandler 500 | { 501 | public function handleChangePassword( 502 | ChangeUserPasswordCommand $command) 503 | { 504 | //... 505 | } 506 | } 507 | ``` 508 | 509 | В книге я, для простоты, буду использовать **Service**-классы. 510 | 511 | Небольшая ремарка про длинные имена классов. 512 | «**ChangeUserPasswordCommandHandler** — ого! Не слишком ли длинное имя? Не хочу писать его каждый раз!» 513 | Полностью его написать придется всего один раз — при создании. 514 | Дальше IDE будет полностью подсказывать его. 515 | Хорошее, полностью описывающее класс, название намного важнее. 516 | Каждый разработчик может сразу сказать что примерно делает класс **ChangeUserPasswordCommandHandler** не заглядывая внутрь него. 517 | 518 | ## Пара слов в конце главы 519 | 520 | Выделение слоя приложения - весьма ответственный шаг и причина должна быть серьезной. Их всего две: 521 | 522 | 1. Разные интерфейсы к одним и тем же действиям (Web, API, Console, различные боты). Тут вынесение общей логики просится само. 523 | 2. Разросшиеся и сложные логики обработки веб-запроса, например, и бизнес-логики. В данном случае разделение логик по разным местам может заметно улучшить связность кода. Как правило, это ведет к повышенной багостойкости кода. -------------------------------------------------------------------------------- /manuscript/5-error-handling.md: -------------------------------------------------------------------------------- 1 | # Обработка ошибок 2 | 3 | A> "Каждый заслуживает второй второй шанс, Пэм!" 4 | 5 | Язык С, который дал основу синтаксиса для многих современных языков, имеет простую конвенцию для ошибок. 6 | Если функция должна вернуть какие-то данные, но не может вернуть из-за ошибки, она возвращает null. 7 | Если функция выполняет какую-то задачу, не возвращая никакого результата, то в случае успеха она возвращает 0, а в случае ошибки -1 или какой-нибудь код ошибки. 8 | Много PHP разработчиков полюбили такую простоту и используют те же принципы. 9 | Код может выглядеть так: 10 | 11 | ```php 12 | readonly final class ChangeUserPasswordDto 13 | { 14 | public function __construct( 15 | public readonly int $userId, 16 | public readonly string $oldPassword, 17 | public readonly string $newPassword) 18 | {} 19 | } 20 | 21 | final class UserService 22 | { 23 | public function changePassword( 24 | ChangeUserPasswordDto $dto): bool 25 | { 26 | $user = User::find($dto->userId); 27 | if($user === null) { 28 | return false; // пользователь не найден 29 | } 30 | 31 | if(!password_verify($dto->oldPassword, $user->password)) { 32 | return false; // старый пароль неверный 33 | } 34 | 35 | $user->password = password_hash($dto->newPassword); 36 | return $user->save(); 37 | } 38 | } 39 | 40 | final class UserController 41 | { 42 | public function changePassword(UserService $service, 43 | ChangeUserPasswordRequest $request) 44 | { 45 | if($service->changePassword($request->getDto())) { 46 | // возвращаем успешный ответ 47 | } else { 48 | // возвращаем ответ с ошибкой 49 | } 50 | } 51 | } 52 | ``` 53 | Ну, по крайней мере, это работает. 54 | Но что, если пользователь хочет узнать в чем причина ошибки? 55 | Комментарии рядом с `return false` бесполезны во время выполнения кода. 56 | Можно попробовать коды ошибки, но часто кроме кода нужна и дополнительная информация для пользователя. 57 | Попробуем создать специальный класс результата функции: 58 | 59 | ```php 60 | final class FunctionResult 61 | { 62 | /** @var bool */ 63 | public $success; 64 | 65 | /** @var mixed */ 66 | public $returnValue; 67 | 68 | /** @var string */ 69 | public $errorMessage; 70 | 71 | private function __construct() {} 72 | 73 | public static function success( 74 | $returnValue = null): FunctionResult 75 | { 76 | $result = new self(); 77 | $result->success = true; 78 | $result->returnValue = $returnValue; 79 | 80 | return $result; 81 | } 82 | 83 | public static function error( 84 | string $errorMessage): FunctionResult 85 | { 86 | $result = new self(); 87 | $result->success = false; 88 | $result->errorMessage = $errorMessage; 89 | 90 | return $result; 91 | } 92 | } 93 | ``` 94 | Конструктор этого класса приватный, поэтому все объекты могут быть созданы только с помощью статических методов **FunctionResult::success** и **FunctionResult::error**. 95 | Этот простенький трюк называется "именованные конструкторы". 96 | 97 | ```php 98 | return FunctionResult::error("Something is wrong"); 99 | ``` 100 | Выглядит намного проще и информативнее, чем 101 | 102 | ```php 103 | return new FunctionResult(false, null, "Something is wrong"); 104 | ``` 105 | 106 | Как только конструктор вашего класса вырастает так, что вызовы new этого класса выглядят коряво, задумайтесь об именованных конструкторах для него. 107 | Наш код будет выглядеть так: 108 | 109 | ```php 110 | class UserService 111 | { 112 | public function changePassword( 113 | ChangeUserPasswordDto $dto): FunctionResult 114 | { 115 | $user = User::find($dto->userId); 116 | if($user === null) { 117 | return FunctionResult::error("User was not found"); 118 | } 119 | 120 | if(!password_verify($dto->oldPassword, $user->password)) { 121 | return FunctionResult::error("Old password isn't valid"); 122 | } 123 | 124 | $user->password = password_hash($dto->newPassword); 125 | 126 | $databaseSaveResult = $user->save(); 127 | 128 | if(!$databaseSaveResult->success) { 129 | return FunctionResult::error("Database error"); 130 | } 131 | 132 | return FunctionResult::success(); 133 | } 134 | } 135 | 136 | final class UserController 137 | { 138 | public function changePassword(UserService $service, 139 | ChangeUserPasswordRequest $request) 140 | { 141 | $result = $service->changePassword($request->getDto()); 142 | 143 | if($result->success) { 144 | // возвращаем успешный ответ 145 | } else { 146 | // возвращаем ответ с ошибкой 147 | // с текстом $result->errorMessage 148 | } 149 | } 150 | } 151 | ``` 152 | Каждый метод (даже save() у Eloquent модели в этом воображаемом мире) возвращает объект **FunctionResult** с полной информацией о том, как завершилось выполнение функции. 153 | Когда я показывал этот пример на одном семинаре один слушатель сказал: "Зачем так делать? Есть же исключения!" 154 | Да, исключения (exceptions) есть, но дайте лишь показать пример из языка Go: 155 | 156 | ```go 157 | f, err := os.Open("filename.ext") 158 | if err != nil { 159 | log.Fatal(err) 160 | } 161 | // do something with the open *File f 162 | ``` 163 | 164 | Обработка ошибок там реализована похожим образом. Без исключений. Популярность языка растёт, поэтому без исключений вполне можно жить. 165 | Однако, чтобы продолжать использовать класс **FunctionResult**, придётся реализовать стек вызовов функций, необходимый для отлавливания ошибок в будущем и корректное логирование каждой ошибки. 166 | Все приложение будет состоять из проверок **if($result->success)**. 167 | Не очень похоже на код моей мечты... 168 | Мне нравится код, который просто описывает действия, не проверяя состояние ошибки на каждом шагу. 169 | Попробуем использовать исключения. 170 | 171 | ## Исключения (Exceptions) 172 | 173 | Когда пользователь просит приложение выполнить действие (зарегистрироваться или отменить заказ), приложение может выполнить его или нет. 174 | Во втором случае, причин может быть множество. 175 | Одним из лучших иллюстраций этого является список кодов HTTP-ответа. 176 | Там есть коды 2хх и 3хх для успешных ответов, таких как 200 Ok или 302 Found. 177 | Коды 4xx и 5xx нужны для неуспешных ответов, но они разные. 178 | 179 | * 4xx для ошибок клиента: 400 Bad Request, 401 Unauthorized, 403 Forbidden, и т.д. 180 | * 5xx для ошибок сервера: 500 Internal Server Error, 503 Service Unavailable, и т.д. 181 | 182 | Соответственно, все ошибки валидации, авторизации, не найденные сущности и попытки изменить пароль с неверным старым паролем - это ошибки клиента. 183 | Недоступность стороннего API, ошибка хранилища файлов или проблемы со связью с базой данных - это ошибки сервера. 184 | 185 | Есть две противоборствующие школы обработок ошибок: 186 | 187 | 1. Девизом школы Аскетов Исключения является "Исключения только для исключительных ситуаций". Любое исключение считают вещью весьма необычной, способной произойти только из-за событий непреодолимой силы (отказ бд или файловой системы) и почти все исключения превращаются в 500-тые ответы сервера. Для ситуаций с неверно введённым email или неправильным паролем они используют что-то вроде объекта **FunctionResult**. 188 | 2. Адепты же школы Единого Верного Пути считают любую негативную ситуацию, т.е. ситуацию, которая не даёт выполнить действие пользователя, исключением. 189 | 190 | Код аскетов, как и их девиз, выглядит более логично, но ошибки клиента придётся постоянно протаскивать наверх, как в примерах выше, из функций к тем, кто их вызвал, из Слоя приложения в контроллеры и т.д. 191 | Код же их противников имеет унифицированный алгоритм работы с любой ошибкой (просто выбросить исключение) и более чистый код, поскольку не надо проверять результаты методов на ошибочность. 192 | Есть только один вариант выполнения запроса, который приводит к успеху: приложение получило валидные данные, сущность пользователя загружена из базы данных, старый пароль совпал, поменяли пароль на новый и сохранили все в базе. 193 | Любой шаг в сторону от этого единого пути должен вызывать исключение. 194 | Юзер ввёл невалидные данные - исключение, этому пользователю нельзя выполнить это действие - исключение, упал сервер с базой данных - разумеется, тоже исключение. 195 | Проблемой Единого Верного Пути является то, что где-то нужно будет отделить ошибки клиента от ошибок сервера, поскольку ответы мы должны сгенерировать разные (помните про 400-ые и 500-ые коды ответов?) да и логироваться такие ошибки должны по-разному. 196 | 197 | Сложно сказать какой из путей предпочтительнее. 198 | Когда приложение только-только обзавелось отдельным слоем Приложения, второй путь кажется более приятным. 199 | Код чище, в любом приватном методе сервисного класса если что-то не понравилось можно просто выбросить исключение и оно сразу дойдёт до адресата. 200 | Однако если приложение будет расти дальше, например будет создан еще и Доменный слой, то это увлечение исключениями может оказаться вредным. 201 | Некоторые из них, будучи выброшенными, но не пойманными на нужном уровне могут быть проинтерпретированы неверно на более высоком уровне. 202 | Количество try-catch блоков начнёт расти и код уже не будет таким чистым. 203 | 204 | Laravel выбрасывает исключения для 404 ошибки, для ошибки доступа (код 403) да и вообще имеет класс **HttpException** в котором можно указать HTTP-код ошибки. 205 | Поэтому, в этой книге я тоже выберу второй вариант и буду генерировать исключения при любых проблемах. 206 | 207 | Пишем код с исключениями: 208 | 209 | ```php 210 | class UserService 211 | { 212 | public function changePassword( 213 | ChangeUserPasswordDto $dto): void 214 | { 215 | $user = User::findOrFail($dto->userId); 216 | 217 | if(!password_verify($dto->oldPassword, $user->password)) { 218 | throw new \Exception("Old password is not valid"); 219 | } 220 | 221 | $user->password = password_hash($dto->newPassword); 222 | 223 | $user->saveOrFail(); 224 | } 225 | } 226 | 227 | final class UserController 228 | { 229 | public function changePassword(UserService $service, 230 | ChangeUserPasswordRequest $request) 231 | { 232 | try { 233 | $service->changePassword($request->getDto()); 234 | } catch(\Throwable $e) { 235 | // log error 236 | // return failure web response with $e->getMessage(); 237 | } 238 | 239 | // return success web response 240 | } 241 | } 242 | ``` 243 | Даже на таком простом примере видно, что код Метода **UserService::changePassword** стал намного чище. 244 | Любой шаг в сторону от основной ветки выполнения вызывает исключение, которое ловится в контроллере. 245 | Eloquent тоже имеет методы для работы в таком стиле: **findOrFail()**, **firstOrFail()** и кое-какие другие ***OrFail()** методы. 246 | Правда этот код все ещё не без проблем: 247 | 248 | 1. **Exception::getMessage()** не самое лучшее сообщение для того, чтобы показывать пользователю. 249 | Сообщение "Old password is not valid" ещё неплохо, но, например, "Server Has Gone Away (error 2006)" точно нет. 250 | 2. Любые серверные ошибки должны быть записаны в лог. 251 | Мелкие приложения используют лог-файлы. 252 | Когда приложение становится популярным, исключения могут происходить каждую секунду. 253 | Некоторые исключения сигнализируют о проблемах в коде и должны быть исправлены немедленно. 254 | Некоторые исключения являются нормой: интернет не идеален, запросы в самые стабильные API один раз из миллиона могут заканчиваться неудачей. 255 | Однако если частота таких ошибок резко возрастает(перестало работать какое-то внешнее API), то разработчики должны среагировать тоже. 256 | В таких случаях, когда контроль за ошибками требует много внимания, лучше использовать специализированные сервисы, которые позволят группировать исключения и работать с ними намного удобнее. 257 | Если интересно, можете просто погуглить "error monitoring services" и найдёте несколько таких сервисов. 258 | Большие компании строят свои специализированные решения для записи и анализа логов со всех своих серверов (часто на основе популярного на момент написания книги стэка ELK: Elastic, LogStash, Kibana). 259 | Некоторые компании не логируют ошибки клиента. 260 | Некоторые логируют, но в отдельных хранилищах. 261 | В любом случае, для любого приложения необходимо четко разделять ошибки сервера и клиента. 262 | 263 | ## Базовый класс исключения 264 | 265 | Первый шаг - создать базовый класс для всех исключений бизнес-логики таких, как "Старый пароль неверен". 266 | В PHP есть класс **\DomainException**, который мог бы быть использован с этой целью, но он уже используется в других местах, например в сторонних библиотеках и это может привести к путанице. 267 | Проще создать свой класс, скажем **BusinessException**. 268 | 269 | ```php 270 | class BusinessException extends \Exception 271 | { 272 | /** 273 | * @var string 274 | */ 275 | private $userMessage; 276 | 277 | public function __construct(string $userMessage) 278 | { 279 | $this->userMessage = $userMessage; 280 | parent::__construct("Business exception"); 281 | } 282 | 283 | public function getUserMessage(): string 284 | { 285 | return $this->userMessage; 286 | } 287 | } 288 | 289 | // Теперь ошибка верификации старого пароля вызовет исключение 290 | 291 | if(!password_verify($command->getOldPassword(), $user->password)) { 292 | throw new BusinessException("Old password is not valid"); 293 | } 294 | 295 | final class UserController 296 | { 297 | public function changePassword(UserService $service, 298 | ChangeUserPasswordRequest $request) 299 | { 300 | try { 301 | $service->changePassword($request->getDto()); 302 | } catch(BusinessException $e) { 303 | // вернуть ошибочный ответ 304 | // с одним из 400-ых кодов 305 | // с $e->getUserMessage(); 306 | } catch(\Throwable $e) { 307 | // залогировать ошибку 308 | 309 | // вернуть ошибочный ответ (с кодом 500) 310 | // с текстом "Houston, we have a problem" 311 | // Не возвращая реальный текст ошибки 312 | } 313 | 314 | // вернуть успешный ответ 315 | } 316 | } 317 | ``` 318 | Этот код ловит **BusinessException** и показывает его сообщение пользователю. 319 | Другие исключения покажут некое "Внутренняя ошибка, мы работаем над этим" и исключение будет отправлено в лог. 320 | Код работает корректно, но секция **catch** будет повторена один в один в каждом методе каждого контроллера. 321 | Стоит вынести логику обработки исключений на более высокий уровень. 322 | 323 | ## Глобальный обработчик 324 | 325 | В Laravel (как и почти во всех фреймворках) есть глобальный обработчик исключений и, как ни странно, здесь весьма удобно обрабатывать почти все исключения нашего приложения. 326 | В новых версиях Laravel он устроен по-другому. Я же рассмотрю класс **app/Exceptions/Handler.php** из старых версий. Класс **Handler** реализует две очень близкие ответственности: логирование исключений и сообщение пользователям о них. 327 | 328 | ```php 329 | 330 | namespace App\Exceptions; 331 | 332 | class Handler extends ExceptionHandler 333 | { 334 | protected $dontReport = [ 335 | // Это означает, что BusinessException 336 | // не будет логироваться 337 | // но будет показан пользователю 338 | BusinessException::class, 339 | ]; 340 | 341 | public function report(Exception $e) 342 | { 343 | if ($this->shouldReport($e)) 344 | { 345 | // Это отличное место для 346 | // интеграции сторонних сервисов 347 | // для мониторинга ошибок 348 | } 349 | 350 | // это залогирует исключение 351 | // по умолчанию в файл laravel.log 352 | parent::report($e); 353 | } 354 | 355 | public function render($request, Exception $e) 356 | { 357 | if ($e instanceof BusinessException) 358 | { 359 | if($request->ajax()) 360 | { 361 | $json = [ 362 | 'success' => false, 363 | 'error' => $e->getUserMessage(), 364 | ]; 365 | 366 | return response()->json($json, 400); 367 | } 368 | else 369 | { 370 | return redirect()->back() 371 | ->withInput() 372 | ->withErrors([ 373 | 'error' => trans($e->getUserMessage())]); 374 | } 375 | } 376 | 377 | // Стандартный показ ошибки 378 | // такой как страница 404 379 | // или страница "Oops" для 500 ошибок 380 | return parent::render($request, $e); 381 | } 382 | } 383 | ``` 384 | Простой пример глобального обработчика. 385 | Метод **report** может быть использован для дополнительного логирования. 386 | Вся **catch** секция из контроллера переехала в метод **render**. 387 | Здесь все ошибки логики будут отловлены и будут сгенерированы правильные сообщения для пользователя. 388 | Посмотрите на контроллер: 389 | 390 | ```php 391 | final class UserController 392 | { 393 | public function changePassword(UserService $service, 394 | ChangeUserPasswordRequest $request) 395 | { 396 | $service->changePassword($request->getDto()); 397 | 398 | // возвращаем успешный ответ 399 | } 400 | } 401 | ``` 402 | 403 | Прекрасно. Бизнес-логика уехала из контроллера в сервисный класс. Валидация в Request-объект. Обработка исключений в глобальный обработчик. Контроллеру осталось лишь контролировать процесс на самом высоком уровне. Наконец-то, его работа соответствует названию! 404 | 405 | ## Проверяемые и непроверяемые исключения 406 | 407 | Закройте глаза. Сейчас я буду вещать о высоких материях, которые в конце концов окажутся бесполезными. Представьте себе берег моря и метод **UserService::changePassword**. 408 | Подумайте какие ошибки там могут возникнуть? 409 | 410 | * **Illuminate\Database\Eloquent\ModelNotFoundException** если пользователя с таким **id** не существует 411 | * **Illuminate\Database\QueryException** если запрос в базу данных не может быть выполнен 412 | * **App\Exceptions\BusinessException** если старый пароль неверен 413 | * **TypeError** если где-то глубоко внутри кода функция **foo**(**SomeClass** **$x**) получит параметр **$x** с другим типом 414 | * **Error** если **$var->method()** будет вызван, когда переменная **$var** == **null** 415 | * еще много других исключений 416 | 417 | С точки зрения вызывающего этот метод, некоторые из этих ошибок, такие как **Error**, **TypeError**, **QueryException**, абсолютно вне контекста. 418 | Какой-нибудь HTTP-контроллер вообще не знает, что с этими ошибками делать. 419 | Единственное, что он может, это показать пользователю сообщение "Произошло что-то плохое и я не знаю, что с этим делать". 420 | Но некоторые из них имеют смысл для него. 421 | **BusinessException** говорит о том, что что-то не так с логикой и там есть сообщения прямо для пользователя и контроллер точно знает, что с этим исключением делать. 422 | То же самое можно сказать про **ModelNotFoundException**. Контроллер может показать 404 ошибку на это. 423 | Да, мы вынесли все это из контроллеров в глобальный обработчик, но это не важно. 424 | Итак, два типа ошибок: 425 | 426 | 1. Ошибки, которые понятны вызывающему коду и могут быть эффективно обработаны там 427 | 2. Другие ошибки 428 | 429 | Первые ошибки хорошо бы обработать там, где этот метод вызывается, а вторые можно и пробросить выше. 430 | Запомним это и взглянем на язык Java. 431 | 432 | ```java 433 | public class Foo 434 | { 435 | public void bar() 436 | { 437 | throw new Exception("test"); 438 | } 439 | } 440 | ``` 441 | Этот код даже не скомпилируется. 442 | Сообщение компилятора: "Error:(5, 9) java: unreported exception java.lang.Exception; must be caught or declared to be thrown" 443 | Есть два способа исправить это. Поймать его: 444 | 445 | ```java 446 | public class Foo 447 | { 448 | public void bar() 449 | { 450 | try { 451 | throw new Exception("test"); 452 | } catch(Exception e) { 453 | // do something 454 | } 455 | } 456 | } 457 | ``` 458 | Или описать исключение в сигнатуре метода: 459 | 460 | ```java 461 | public class Foo 462 | { 463 | public void bar() throws Exception 464 | { 465 | throw new Exception("test"); 466 | } 467 | } 468 | ``` 469 | В этом случае каждый код, вызывающий метод **bar** будет вынужден что-то делать с этим исключением: 470 | 471 | ```java 472 | public class FooCaller 473 | { 474 | public void caller() throws Exception 475 | { 476 | (new Foo)->bar(); 477 | } 478 | 479 | public void caller2() 480 | { 481 | try { 482 | (new Foo)->bar(); 483 | } catch(Exception e) { 484 | // do something 485 | } 486 | } 487 | } 488 | ``` 489 | Разумеется, работать так с **каждым** исключение будет той еще пыткой. 490 | В Java исключения делятся на два типа: 491 | 1. **проверяемые**(checked) исключения, которые обязаны быть пойманы или объявлены в сигнатуре 492 | 2. **непроверяемые**(unchecked), которые могут быть выброшены без всяких дополнительных условий. 493 | 494 | Взглянем на корень дерева классов исключений в Java (PHP, начиная с седьмой версии, имеет такое же): 495 | 496 | ``` 497 | Throwable(checked) 498 | / \ 499 | Error(unchecked) Exception(checked) 500 | \ 501 | RuntimeException(unchecked) 502 | ``` 503 | **Throwable**, **Exception** и все их наследники - проверяемые исключения. 504 | Кроме **Error**, **RuntimeException** и всех их наследников. Их можно выбросить везде и ничего за это не будет. 505 | 506 | ```java 507 | public class File 508 | { 509 | public String getCanonicalPath() throws IOException { 510 | //... 511 | } 512 | } 513 | ``` 514 | Что сигнатура метода **getCanonicalPath** говорит разработчику? 515 | Там нет никаких параметров, возвращает строку, может выбросить исключение **IOException**, а также любое непроверяемое исключение. 516 | Возвращаясь к двум типам ошибок: 517 | 518 | 1. Ошибки, которые понятны вызывающему коду и могут быть эффективно обработаны там 519 | 2. Другие ошибки 520 | 521 | Проверяемые исключения созданы для ошибок первого типа. 522 | Непроверяемые - для второго. 523 | Вызывающий код может эффективно обработать проверяемое исключение, и эта строгость обязывает его сделать это. 524 | Все это приводит к более корректной работе с ошибками. 525 | 526 | Хорошо, в Java это есть, в PHP - нет. Но IDE, которое я использую, PhpStorm, имитирует поведение Java. 527 | 528 | ```php 529 | class Foo 530 | { 531 | public function bar() 532 | { 533 | throw new Exception(); 534 | } 535 | } 536 | ``` 537 | PhpStorm подсветит 'throw new Exception();' с предупреждением: 'Unhandled Exception'. 538 | Есть два пути избавиться от этого: 539 | 540 | 1. Поймать исключение 541 | 2. Описать его в тэге @throws phpDoc-комментария метода: 542 | 543 | ```php 544 | class Foo 545 | { 546 | /** 547 | * @throws Exception 548 | */ 549 | public function bar() 550 | { 551 | throw new Exception(); 552 | } 553 | } 554 | ``` 555 | 556 | Список непроверяемых классов конфигурируется. 557 | По умолчанию он выглядит так: **\Error**, **\RuntimeException** и **\LogicException**. 558 | Их можно выбрасывать не опасаясь предупреждений. 559 | 560 | Со всей этой информацией можно попробовать спроектировать структуру классов исключения для приложения. 561 | Я бы хотел информировать код, вызывающий **UserService::changePassword** про ошибки: 562 | 563 | 1. **ModelNotFoundException**, когда пользователь с таким **id** не найден 564 | 2. **BusinessException**, эта ошибка содержит сообщение, предназначенное для пользователя и может быть обработана сразу. 565 | Все остальные ошибки могут быть обработаны позже. 566 | Итак, в идеальном мире: 567 | 568 | ```php 569 | class ModelNotFoundException extends \Exception 570 | {...} 571 | 572 | class BusinessException extends \Exception 573 | {...} 574 | 575 | final class UserService 576 | { 577 | /** 578 | * @param ChangeUserPasswordDto $command 579 | * @throws ModelNotFoundException 580 | * @throws BusinessException 581 | */ 582 | public function changePassword( 583 | ChangeUserPasswordDto $command): void 584 | {...} 585 | } 586 | ``` 587 | Но мы уже вынесли всю логику обработки ошибок в глобальный обработчик, поэтому придется копировать все эти **@throws** тэги в методе контроллера: 588 | 589 | ```php 590 | final class UserController 591 | { 592 | /** 593 | * @param UserService $service 594 | * @param Request $request 595 | * @throws ModelNotFoundException 596 | * @throws BusinessException 597 | */ 598 | public function changePassword(UserService $service, 599 | ChangeUserPasswordRequest $request) 600 | { 601 | $service->changePassword($request->getDto()); 602 | 603 | // возвращаем успешный ответ 604 | } 605 | } 606 | ``` 607 | Не очень удобно. Даже если учесть, что PhpStorm умеет генерировать все эти тэги автоматически. 608 | Возвращаясь к нашему неидеальному миру: Класс **ModelNotFoundException** в Laravel уже отнаследован от **\RuntimeException**. 609 | Соответственно, он непроверяемый по умолчанию. 610 | Это имеет смысл, поскольку глубоко внутри собственного обработчика ошибок Laravel обрабатывает эти исключения сам. 611 | Поэтому, в нашем текущем положении, стоит тоже пойти на такую сделку с совестью: 612 | 613 | ```php 614 | class BusinessException extends \RuntimeException 615 | {...} 616 | ``` 617 | 618 | и забыть про тэги **@throws** держа в голове то, что все исключения **BusinessException** будут обработаны в глобальном обработчике. 619 | 620 | Это одна из главных причин почему новые языки не имеют такую фичу с проверяемыми исключениями и большинство Java-разработчиков не любят их. 621 | Другая причина: некоторые библиотеки просто пишут "throws Exception" в своих методах. 622 | "throws Exception" вообще не дает никакой полезной информации. 623 | Это просто заставляет клиентский код повторять этот бесполезный "throws Exception" в своей сигнатуре. 624 | 625 | Я вернусь к исключениям в главе про Доменный слой, когда этот подход с непроверяемыми исключениями станет не очень удобным. 626 | 627 | ## Пара слов в конце главы 628 | 629 | Функция или метод, возвращающие более одного типа, могущие вернуть **null** или возвращающие булевое значение (хорошо все прошло или нет), делают вызывающий код грязным. 630 | Возвращенное значение нужно будет проверять сразу после вызова. 631 | Код с исключениями выглядит намного чище: 632 | 633 | ```php 634 | // Без исключений 635 | $user = User::find($command->getUserId()); 636 | if($user === null) { 637 | // обрабатываем ошибку 638 | } 639 | 640 | $user->doSomething(); 641 | 642 | 643 | // С исключением 644 | $user = User::findOrFail($command->getUserId()); 645 | $user->doSomething(); 646 | ``` 647 | 648 | С другой стороны, использование объектов как **FunctionResult** даёт разработчикам больший контроль над исполнением. 649 | Например, **findOrFail** вызванное в неправильном месте в неправильное время заставит приложение показать пользователю 404ю ошибку вместо корректного сообщения об ошибке. 650 | С исключениями надо всегда быть настороже. 651 | -------------------------------------------------------------------------------- /manuscript/6-validation.md: -------------------------------------------------------------------------------- 1 | # Валидация 2 | 3 | A> "...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..." 4 | 5 | ## Валидация связанная с базой данных 6 | 7 | Как всегда, главу начнём с примера кода с практиками, накопленными в предыдущих главах. 8 | Создание статьи: 9 | 10 | ```php 11 | class PostController extends Controller 12 | { 13 | public function create(Request $request, PostService $service) 14 | { 15 | $this->validate($request, [ 16 | 'category_id' => 'required|exists:categories', 17 | 'title' => 'required', 18 | 'body' => 'required', 19 | ]); 20 | 21 | $service->create(/* DTO */); 22 | 23 | //... 24 | } 25 | } 26 | 27 | class PostService 28 | { 29 | public function create(CreatePostDto $dto) 30 | { 31 | $post = new Post(); 32 | $post->category_id = $dto->categoryId; 33 | $post->title = $dto->title; 34 | $post->body = $dto->body; 35 | 36 | $post->saveOrFail(); 37 | } 38 | } 39 | ``` 40 | 41 | Я, краткости ради, вернул валидацию обратно в контроллер. 42 | Одной из проверок является существование нужной категории в базе данных. 43 | Давайте представим, что в будущем в модель категорий будет добавлен трейт **SoftDeletes**. 44 | Этот функционал будет помечать строки в базе данных как удаленные вместо того, чтобы физически их удалять, а также не включать строки, помеченные как удаленные в результаты запросов. 45 | Всё приложение продолжит работать, не заметив этого изменения. 46 | Кроме этой валидации. 47 | Она позволит создать статью с удаленной категорией, нарушив тем самым консистентность данных. 48 | Исправим это: 49 | 50 | ```php 51 | $this->validate($request, [ 52 | 'category_id' => [ 53 | 'required|exists:categories,id,deleted_at,null', 54 | ], 55 | 'title' => 'required', 56 | 'body' => 'required', 57 | ]); 58 | ``` 59 | Была добавлена проверка на пометку на удаление. 60 | Могу ещё добавить, что таких правок может понадобиться много, ведь статьи не только создаются. 61 | Любое другое изменение может опять сломать нашу "умную" валидацию. 62 | Например, пометка "archived" для категорий, которая позволит им оставаться на сайте, но не позволяет добавлять новые статьи в них. 63 | Мы не делали никаких изменений в форме добавления статьи, и вообще во всей HTTP части приложения. 64 | Изменения касались либо бизнес-логики (архивные категории), либо логики хранения данных (Soft delete), однако менять приходится классы HTTP-запросов с их валидацией. 65 | Это ещё один пример высокой связанности (high coupling). 66 | Не так давно мы решили вынести всю работу с базой данных в Слой Приложения, но по старой привычке всё ещё лезем в базу напрямую из валидации, игнорируя все абстракции, которые строятся с помощью Eloquent или Слоя Приложения. 67 | 68 | 69 | ![](images/application_layer.png) 70 | 71 | 72 | Надо разделить валидацию. В валидации HTTP слоя нам просто необходимо убедиться, что пользователь не ошибся при вводе данных: 73 | 74 | ```php 75 | $this->validate($request, [ 76 | 'category_id' => 'required', 77 | 'title' => 'required', 78 | 'body' => 'required', 79 | ]); 80 | 81 | class PostService 82 | { 83 | public function create(CreatePostDto $dto) 84 | { 85 | $category = Category::find($dto->categoryId); 86 | 87 | if($category === null) { 88 | // throw "Category not found" exception 89 | } 90 | 91 | if($category->archived) { 92 | // throw "Category archived" exception 93 | } 94 | 95 | $post = new Post(); 96 | $post->category_id = $category->id; 97 | $post->title = $dto->title; 98 | $post->body = $dto->body; 99 | 100 | $post->saveOrFail(); 101 | } 102 | } 103 | ``` 104 | Валидацию же, затрагивающую бизнес-логику или базу данных, необходимо проводить в более правильных местах. 105 | Теперь код работает более стабильно: валидация в контроллерах или **FormRequest** не меняется от случайных изменений в других слоях. 106 | Метод **PostService::create** не доверяет такие важные проверки вызывающему коду и валидирует всё сам. 107 | В качестве бонуса, приложение теперь имеет намного более понятные тексты ошибок. 108 | 109 | ## Два уровня валидации 110 | 111 | 112 | ![](images/two_level_validation.png) 113 | 114 | 115 | В прошлом примере валидация была разделена на две части и я сказал, что метод **PostService::create** не доверяет сложную валидацию вызывающему коду, но он всё ещё доверяет ему в простом: 116 | 117 | ```php 118 | $post->title = $dto->title; 119 | ``` 120 | Здесь мы исходим из того, что заголовок у нас будет непустой, однако на 100 процентов уверенности нет. 121 | Да, сейчас оно проверяется правилом 'required' при валидации, но это далеко, где-то в контроллерах или ещё дальше. 122 | Метод **PostService::create** может быть вызван из другого кода, и там эта проверка может быть забыта. 123 | Давайте рассмотрим пример с регистрацией пользователя (он удобнее): 124 | 125 | ```php 126 | readonly final class RegisterUserDto 127 | { 128 | public function __construct( 129 | public string $name, 130 | public string $email, 131 | public DateTime $birthDate, 132 | ) {} 133 | } 134 | 135 | class UserService 136 | { 137 | public function register(RegisterUserDto $request) 138 | { 139 | $existingUser = User::whereEmail($request->email) 140 | ->first(); 141 | 142 | if($existingUser !== null) { 143 | throw new UserWithThisEmailAlreadyExists(...); 144 | } 145 | 146 | $user = new User(); 147 | $user->name = $request->name; 148 | $user->email = $request->email; 149 | $user->birthDate = $request->birthDate; 150 | 151 | $user->saveOrFail(); 152 | } 153 | } 154 | ``` 155 | После начала использования DTO мы вынуждены забыть про то, что данные в web запросе были отвалидированы. 156 | Любой может написать такой код: 157 | 158 | ```php 159 | $userService->register(new RegisterUserDto('', '', new DateTime())); 160 | ``` 161 | Никто не может поручиться, что в **name** будет лежать непустая строка, а в **email** строка с верным email адресом. 162 | Что делать? 163 | Можно дублировать валидацию в сервисном классе: 164 | 165 | ```php 166 | class UserService 167 | { 168 | public function register(RegisterUserDto $request) 169 | { 170 | if(empty($request->name)) { 171 | throw // 172 | } 173 | 174 | if(!filter_var($request->email, 175 | FILTER_VALIDATE_EMAIL)) { 176 | throw // 177 | } 178 | 179 | //... 180 | } 181 | } 182 | ``` 183 | Или сделать такую же валидацию в конструкторе DTO класса, но в приложении будет куча мест, где нужно будет получать email, имя или подобные данные. Много кода будет дублироваться. Я могу предложить два варианта. 184 | 185 | ## Валидация аннотациями 186 | 187 | Проект **Symfony** содержит отличный компонент для валидации аннотациями - **symfony/validator**. 188 | Перепишем наш **RegisterUserDto**: 189 | 190 | ```php 191 | use Symfony\Component\Validator\Constraints as Assert; 192 | 193 | readonly class RegisterUserDto 194 | { 195 | public function __construct( 196 | #[Assert\NotBlank] 197 | private string $name; 198 | 199 | #[Assert\NotBlank] 200 | #[Assert\Email] 201 | private string $email; 202 | 203 | #[Assert\NotNull] 204 | private DateTime $birthDate; 205 | ) {} 206 | } 207 | ``` 208 | 209 | Просим валидатор в сервисном классе и используем его для проверки DTO: 210 | 211 | ```php 212 | class UserService 213 | { 214 | public function __construct( 215 | private ValidatorInterface $validator 216 | ) {} 217 | 218 | public function register(RegisterUserDto $dto) 219 | { 220 | $violations = $this->validator->validate($dto); 221 | 222 | if (count($violations) > 0) { 223 | throw new ValidationException($violations); 224 | } 225 | 226 | $existingUser = User::whereEmail($dto->email)->first(); 227 | 228 | if($existingUser !== null) { 229 | throw new UserWithThisEmailAlreadyExists(...); 230 | } 231 | 232 | $user = new User(); 233 | $user->name = $dto->name; 234 | $user->email = $dto->email; 235 | $user->birthDate = $dto->birthDate; 236 | 237 | $user->saveOrFail(); 238 | } 239 | } 240 | ``` 241 | 242 | Правила валидации описываются аннотациями. 243 | Метод **ValidatorInterface::validate** возвращает список нарушений правил валидации. 244 | Если он пуст - всё хорошо. Если нет, выбрасываем исключение валидации - **ValidationException**. 245 | Используя эту явную валидацию, в Слое Приложения можно быть уверенным в валидности данных. 246 | Также, в качестве бонуса, можно удалить валидацию в слое Web, API и т.д, поскольку все данные уже проверяются глубже. 247 | Отличная идея, но с этим есть некоторые проблемы. 248 | 249 | ### Проблема данных Http запроса 250 | В первую очередь, данные, которые передаются от пользователей в HTTP-запросе, не всегда равны данным, передаваемым в Слой Приложения. 251 | Когда пользователь меняет свой пароль, приложение запрашивает старый пароль, новый пароль и повторить новый пароль. 252 | Валидация Web-слоя должна проверить поля нового пароля на совпадение, а Слою Приложения эти данные просто не нужны, он получит только значения старого и нового пароля. 253 | 254 | Другой пример: одно из значений, передаваемых в Слой Приложения заполняется email-адресом текущего пользователя. 255 | Если этот email окажется пустым, то пользователь может увидеть сообщение "Формат email неверный", при том, что он даже не вводил никакого email! 256 | Поэтому, делать валидацию пользовательского ввода в Слое Приложения - не самая лучшая идея. 257 | 258 | ### Проблема сложных структур данных 259 | Представьте некий DTO создания заказа такси - **CreateTaxiOrderDto**. 260 | Это будет авиа-такси, поэтому заказы могут быть из одной страны в другую. 261 | Там будут поля **fromHouse**, **fromStreet**, **fromCity**, **fromState**, **fromCountry**, **toHouse**, **toStreet**, **toCity**,... 262 | Огромный DTO с кучей полей, дублирующих друг-друга, зависящих друг от друга. 263 | Номер дома не имеет никакого смысла без имени улицы. 264 | Имя улицы, без города и страны. 265 | Валидация подобных данных будет сложной и регулярно дублируемой в разных DTO-объектах. 266 | 267 | ## Value objects 268 | Решение этой проблемы лежит прямо в **RegisterUserDto**. 269 | Мы не храним отдельно **$birthDay**, **$birthMonth** и **$birthYear**. Не валидируем их каждый раз. 270 | Мы просто храним объект **DateTime**! Он всегда хранит корректную дату и время. 271 | Сравнивая даты, мы никогда не сравниваем их года, месяцы и дни. Там есть метод **diff()** для сравнений дат. 272 | Этот класс содержит все знания о датах внутри себя, избавляя нас от необходимости дублировать логику работы с ними везде. 273 | Можно попробовать сделать что-то похожее и с другими данными: 274 | 275 | ```php 276 | final class Email 277 | { 278 | /** @var string */ 279 | private $email; 280 | 281 | private function __construct(string $email) 282 | { 283 | if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { 284 | throw new InvalidArgumentException( 285 | 'Email ' . $email . ' is not valid'); 286 | } 287 | 288 | $this->email = $email; 289 | } 290 | 291 | public static function createFromString( 292 | string $email) 293 | { 294 | return new static($email); 295 | } 296 | 297 | public function value(): string 298 | { 299 | return $this->email; 300 | } 301 | } 302 | 303 | final class UserName 304 | { 305 | /** @var string */ 306 | private $name; 307 | 308 | private function __construct(string $name) 309 | { 310 | if (/* Some validation of $name value*. 311 | It depends on project requirements. */) { 312 | throw new InvalidArgumentException( 313 | 'Invalid user name: ' . $name); 314 | } 315 | 316 | $this->name = $name; 317 | } 318 | 319 | public static function createFromString( 320 | string $name) 321 | { 322 | return new static($name); 323 | } 324 | 325 | public function value(): string 326 | { 327 | return $this->name; 328 | } 329 | } 330 | 331 | readonly final class RegisterUserDto 332 | { 333 | public function __construct( 334 | public UserName $name, 335 | public Email $email, 336 | public DateTime $birthDate, 337 | ) {} 338 | } 339 | ``` 340 | Да, создавать класс для каждого возможного типа вводимых данных - это не то, о чем мечтает каждый программист. 341 | Но это естественный путь декомпозиции приложения. 342 | Вместо того, чтобы использовать строки и всегда сомневаться, лежит ли в них нужное значение, эти классы позволяют всегда иметь корректные значения, как с DateTime. 343 | Этот шаблон называется **Объект-значение**(**Value Object** или **VO**). 344 | В поле **email** больше не лежит просто строка. Поле это теперь типа **Email**, который без сомнения можно использовать везде, где нужны email-адреса. 345 | **UserService** может без страха использовать эти значения: 346 | 347 | ```php 348 | final class UserService 349 | { 350 | public function register(RegisterUserDto $dto) 351 | { 352 | //... 353 | $user = new User(); 354 | $user->name = $dto->name; 355 | $user->email = $dto->email; 356 | $user->birthDate = $dto->bithDate; 357 | 358 | $user->saveOrFail(); 359 | } 360 | } 361 | ``` 362 | Для того чтобы полям Eloquent-сущности можно было присваивать такие VO как **Email**, необходимо реализовать их преобразование через механизм casting. 363 | Это влечет за собой дополнительные затраты и необходимо еще раз задуматься "а стоит ли данный проект таких затрат?". 364 | Для многих проектов страхи, описанные выше, не так значительны и вполне можно обойтись без такого значительного усложнения логики. 365 | Однако, есть проекты, в которых цена ошибки будет слишком велика и такие усилия по защите целостности данных будут вполне оправданы. 366 | 367 | ## Объект-значение как композиция других значений 368 | Объекты-значения **Email** и **UserName** - это просто оболочки для строк, но шаблон **Объект-значение** - более широкое понятие. 369 | Географическая координата может быть описана двумя float значениями: долгота и широта. 370 | Обычно, мало кому интересна долгота, без знания широты. 371 | Создав объект **GeoPoint**, можно во всем приложении работать с ним. 372 | 373 | ```php 374 | readonly final class GeoPoint 375 | { 376 | public function __construct( 377 | public float $latitude, 378 | public float $longitude, 379 | ) {} 380 | 381 | public function isEqual(GeoPoint $other): bool 382 | { 383 | // просто примера ради 384 | return $this->getDistance($other)->getMeters() < 10; 385 | } 386 | 387 | public function getDistance(GeoPoint $other): Distance 388 | { 389 | // Вычисление расстояния между $this и $other 390 | } 391 | } 392 | 393 | final class City 394 | { 395 | //... 396 | private GeoPoint $centerPoint; 397 | 398 | public function getDistance(City $other): Distance 399 | { 400 | return $this->centerPoint 401 | ->getDistance($other->centerPoint); 402 | } 403 | } 404 | ``` 405 | Примером того, как знание о координатах инкапсулировано в классе **GeoPoint**, является метод **getDistance** класса **City**. 406 | Для вычисления дистанции между городами просто используется расстояние между двумя центральными точками городов. 407 | 408 | Другие примеры объектов-значений: 409 | 410 | * **Money**(int **amount**, Currency **currency**) 411 | * **Address**(string **street**, string **city**, string **state**, string **country**, string **zipcode**) 412 | 413 | Вы заметили, что в прошлом примере я пытаюсь не использовать примитивные типы, такие как строки и числа? 414 | Метод **getDistance()** возвращает объект **Distance**, а не int или float. 415 | Класс **Distance** может иметь методы **getMeters()**: float или **getMiles()**: float. 416 | А также **Distance::isEqual**(**Distance** **$other**) для сравнения двух расстояний. 417 | Это тоже объект-значение! 418 | Для многих проектов такая детализация излишня, и метод **GeoPoint::getDistance()**: возвращающий число с плавающей запятой расстояния в метрах более, чем достаточен. 419 | Я лишь хотел показать пример того, что я называю "мышлением объектами". 420 | Мы ещё вернемся к объектам-значениям позднее в этой книге. 421 | Вероятно, вы понимаете, что этот шаблон слишком мощный, чтобы использоваться только как поле в DTO. 422 | 423 | ## Объекты-значения и валидация 424 | 425 | ```php 426 | final class UserController extends Controller 427 | { 428 | public function register( 429 | Request $request, UserService $service) 430 | { 431 | $this->validate($request, [ 432 | 'name' => 'required', 433 | 'email' => 'required|email', 434 | 'birth_date' => 'required|date', 435 | ]); 436 | 437 | $service->register(new RegisterUserDto( 438 | UserName::create($request['name']), 439 | Email::create($request['email']), 440 | DateTime::createFromFormat('some format', $request) 441 | )); 442 | 443 | //return ok response 444 | } 445 | } 446 | ``` 447 | 448 | В этом коде можно легко найти дубликаты. 449 | Значение email-адреса сначала валидируется с помощью Laravel валидации, а потом в конструкторе класса **Email**: 450 | 451 | ```php 452 | final class Email 453 | { 454 | private function __construct(string $email) 455 | { 456 | if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { 457 | throw new InvalidArgumentException( 458 | 'Email ' . $email . ' is not valid'); 459 | } 460 | 461 | $this->email = $email; 462 | } 463 | 464 | //... 465 | } 466 | ``` 467 | 468 | Идея удаления кода Laravel-валидации выглядит интересной. 469 | Можно удалить вызов **$this->validate()** и просто ловить исключение **InvalidArgumentException** в глобальном обработчике ошибок. 470 | Но, как я уже писал, данные HTTP-запроса не всегда равны данным, передаваемым в Слой Приложения, да и исключение **InvalidArgumentException** может быть выброшено во многих других ситуациях. 471 | Опять может повториться ситуация, когда пользователь видит ошибки про данные, которые он не вводил. 472 | 473 | Если вы помните, PhpStorm по умолчанию имеет 3 класса непроверяемых исключений: **Error**, **RuntimeException** и **LogicException**: 474 | 475 | * **Error** означает ошибку языка PHP, например **TypeError**, **ParseError**, и т.д. 476 | * **RuntimeException** означает ошибку времени выполнения, не зависящую от нашего кода, например проблемы с соединением с базой данных. 477 | * **InvalidArgumentException** наследует от **LogicException**. 478 | Описание **LogicException** в документации PHP: "Исключение означает ошибку в логике приложения. Эти ошибки должны напрямую вести к исправлению кода.". 479 | Поэтому, если код написан верно, он никогда не должен выбрасывать **LogicException**. 480 | 481 | Это означает, что проверки в конструкторах объектов-значений нужны только для того, чтобы убедиться, что данные были проверены ранее, например с помощью вызова метода ->validate() и стандартного валидатора Laravel. 482 | Они не должны быть использованы в качестве валидации данных, введенных пользователем. 483 | Это валидация кода нашего приложения. 484 | 485 | ## Пара слов в конце главы 486 | 487 | Вынесение логики в Слой Приложения ведет к некоторым проблемам с валидацией данных. 488 | Мы не можем напрямую использовать объекты **FormRequest** и приходится использовать некие объекты передачи данных (DTO), пусть даже это будут простые массивы. 489 | Если Слой Приложения всегда получает ровно те данные, которые ввел пользователь, то вся валидация может быть перенесена туда и использовано решение с пакетом **symfony/validator** или другим. 490 | Но это будет опасно и не очень удобно, если идет работа со сложными структурами данных, такими как адреса или точки координат, например. 491 | 492 | Валидация может быть оставлена в Web, API и других частях кода, а Слой Приложения будет просто доверять переданным ему данным. 493 | По моему опыту это работает только в маленьких проектах. 494 | Большие проекты, над которыми работают команды разработчиков, постоянно будут сталкиваться с проблемами невалидных данных, которые будут вести к неправильным значениям в базе данных или к выбросу неверных исключений. 495 | 496 | Шаблон объект-значение требует некоторого дополнительного кодинга и "мышления объектами" от программистов, но это наиболее безопасный и естественный способ представлять данные, имеющие какой-то дополнительный смысл, т.е. "не просто строка, а email". 497 | Как всегда, это выбор между краткосрочной и долгосрочной производительностью. 498 | -------------------------------------------------------------------------------- /manuscript/7-events.md: -------------------------------------------------------------------------------- 1 | # События 2 | 3 | Действие Слоя Приложения всегда содержит главную часть, где выполняется основная логика действия, а также некоторые дополнительные действия. 4 | Регистрация пользователя состоит из, собственно, создания сущности пользователя, а также посылки соответствующего письма. 5 | Обновление текста статьи содержит обновление значения **$post->text** с сохранением сущности, а также, например вызов **Cache::forget** для инвалидации кеша. 6 | Псевдо-реальный пример: сайт с опросами. 7 | Создание сущности опроса: 8 | 9 | ```php 10 | final class SurveyService 11 | { 12 | public function __construct( 13 | private ProfanityFilter $profanityFilter 14 | /*, ... другие зависимости */ 15 | ) {} 16 | 17 | public function create(SurveyCreateDto $request) 18 | { 19 | $survey = new Survey(); 20 | $survey->question = $this->profanityFilter->filter( 21 | $request->getQuestion()); 22 | //... 23 | $survey->save(); 24 | 25 | foreach($request->getOptions() as $optionText) 26 | { 27 | $option = new SurveyOption(); 28 | $option->survey_id = $survey->id; 29 | $option->text = 30 | $this->profanityFilter->filter($optionText); 31 | $option->save(); 32 | } 33 | 34 | // Вызов генерации sitemap 35 | 36 | // Оповестить внешнее API о новом опросе 37 | } 38 | } 39 | ``` 40 | Это обычное создание опроса с вариантами ответа, с фильтрацией мата во всех текстах и некоторыми пост-действиями. 41 | Объект опроса непростой. 42 | Он абсолютно бесполезен без вариантов ответа. 43 | Мы должны позаботиться о его консистентности. 44 | Этот маленький промежуток времени, когда мы уже создали объект опроса и добавили его в базу данных, но еще не создали его варианты ответа, очень опасен! 45 | 46 | ## Database transactions 47 | 48 | Первая проблема - база данных. 49 | В это время может произойти некоторая ошибка (с соединением с базой данных, да даже просто в функции проверки на мат) и объект опроса будет в базе данных, но без вариантов ответа. 50 | Все движки баз данных, которые созданы для хранения важных данных, имеют механизм транзакций. 51 | Они гарантируют консистентность внутри транзакции. 52 | Все запросы, обернутые в транзакцию, либо будут выполнены полностью, либо, при возникновении ошибки во время выполнения, ни один из них. 53 | Выглядит как решение: 54 | 55 | ```php 56 | final class SurveyService 57 | { 58 | public function __construct( 59 | ..., private DatabaseConnection $connection 60 | ) {} 61 | 62 | public function create(SurveyCreateDto $request) 63 | { 64 | $this->connection->transaction(function() use ($request) { 65 | $survey = new Survey(); 66 | $survey->question = $this->profanityFilter->filter( 67 | $request->getQuestion()); 68 | //... 69 | $survey->save(); 70 | 71 | foreach($request->getOptions() as $optionText) { 72 | $option = new SurveyOption(); 73 | $option->survey_id = $survey->id; 74 | $option->text = 75 | $this->profanityFilter->filter($optionText); 76 | $option->save(); 77 | } 78 | 79 | // Вызов генерации sitemap 80 | 81 | // Оповестить внешнее API о новом опросе 82 | }); 83 | } 84 | } 85 | ``` 86 | 87 | Отлично, наши данные теперь консистентны, но эта магия транзакций даётся базе данных не бесплатно. 88 | Когда мы выполняем запросы в транзакции, база данных вынуждена хранить две копии данных: для успешного или неуспешного результатов. 89 | Для проектов с нагрузкой, которые могут выполнять сотни одновременных транзакций, длящихся долго, это может сильно сказаться на производительности. 90 | Для проектов с небольшой нагрузкой это не настолько важно, но все равно стоит приобрести привычку выполнять транзакции как можно быстрее. 91 | Проверка на мат может требовать запроса на специальное API, которое может занять страшно много времени. 92 | Попробуем вынести из транзакции все, что возможно: 93 | 94 | ```php 95 | final class SurveyService 96 | { 97 | public function create(SurveyCreateDto $request) 98 | { 99 | $filteredRequest = $this->filterRequest($request); 100 | 101 | $this->connection->transaction( 102 | function() use ($filteredRequest) { 103 | $survey = new Survey(); 104 | $survey->question = $filteredRequest->getQuestion(); 105 | //... 106 | $survey->save(); 107 | 108 | foreach($filteredRequest->getOptions() 109 | as $optionText) { 110 | $option = new SurveyOption(); 111 | $option->survey_id = $survey->id; 112 | $option->text = $optionText; 113 | $option->save(); 114 | } 115 | }); 116 | 117 | // Вызов генерации sitemap 118 | 119 | // Оповестить внешнее API о новом опросе 120 | } 121 | 122 | private function filterRequest( 123 | SurveyCreateDto $request): SurveyCreateDto 124 | { 125 | // фильтрует тексты в запросе 126 | // и возвращает такое же DTO но с "чистыми" данными 127 | } 128 | } 129 | ``` 130 | 131 | Теперь внутри транзакции только легкие действия и она выполняется моментально. Так и должно быть. 132 | 133 | ## Очереди 134 | 135 | Вторая проблема - время выполнения запроса. 136 | Приложение должно отвечать как можно быстрее. 137 | Создание опроса содержит тяжелые действия, такие как генерация sitemap или вызовы внешних API. 138 | Обычное решение - отложить эти действия с помощью механизма очередей. 139 | Вместо того чтобы выполнять тяжелое действие в обработчике web-запроса, приложение может создать задачу для выполнения этого действия и положить его в очередь. 140 | Дальше из очереди его возьмет служба, расположенная на том же сервере. Или она будет на других серверах, созданных специально для обработки задач из очередей. Такие сервера называют воркер-серверами. 141 | Очередью может быть таблица в базе данных, список в Redis или специальный софт для очередей, например Kafka или RabbitMQ. 142 | 143 | Laravel предоставляет несколько путей работы с очередями. Один из них: jobs. 144 | Как я говорил ранее, действие состоит из главной части и несколько второстепенных действий. 145 | Главное действие при создании сущности опроса не может быть выполнено без фильтрации мата, но все пост-действия можно отложить. 146 | Вообще, в некоторых ситуациях, когда действие занимает слишком много времени, можно его полностью отложить, сказав пользователю "Скоро выполним", но это пока не наш случай. 147 | 148 | ```php 149 | final class SitemapGenerationJob implements ShouldQueue 150 | { 151 | public function handle() 152 | { 153 | // Вызов генератора sitemap 154 | } 155 | } 156 | 157 | final class NotifyExternalApiJob implements ShouldQueue {} 158 | 159 | use Illuminate\Contracts\Bus\Dispatcher; 160 | 161 | final class SurveyService 162 | { 163 | public function __construct(..., 164 | private Dispatcher $dispatcher 165 | ) {} 166 | 167 | public function create(SurveyCreateDto $request) 168 | { 169 | $filteredRequest = $this->filterRequest($request); 170 | 171 | $survey = new Survey(); 172 | $this->connection->transaction(...); 173 | 174 | $this->dispatcher->dispatch( 175 | new SitemapGenerationJob()); 176 | $this->dispatcher->dispatch( 177 | new NotifyExternalApiJob($survey->id)); 178 | } 179 | } 180 | ``` 181 | 182 | Если класс содержит интерфейс **ShouldQueue**, то выполнение этой задачи будет отложено в очередь. 183 | Этот код выполняется достаточно быстро, но мне он все ещё не нравится. 184 | Таких пост-действий может быть очень много и сервисный класс начинает знать слишком много. 185 | Он выполняет каждое пост-действие, но с высокоуровневой точки зрения, действие создания опроса не должно знать про генерацию sitemap или взаимодействие с внешними API. 186 | В проектах с огромным количеством пост-действий, контролировать их становится очень непросто. 187 | 188 | ## События 189 | 190 | Вместо того чтобы напрямую вызывать каждое пост-действие, сервисный класс может просто информировать приложение о том, что что-то произошло. 191 | Приложение может реагировать на эти события, выполняя нужные пост-действия. 192 | В Laravel есть поддержка механизма событий: 193 | 194 | ```php 195 | final class SurveyCreated 196 | { 197 | public function __construct( 198 | public readonly int $surveyId 199 | ) {} 200 | } 201 | 202 | use Illuminate\Contracts\Events\Dispatcher; 203 | 204 | final class SurveyService 205 | { 206 | public function __construct(..., 207 | private Dispatcher $dispatcher 208 | ) {} 209 | 210 | public function create(SurveyCreateDto $request) 211 | { 212 | // ... 213 | 214 | $survey = new Survey(); 215 | $this->connection->transaction( 216 | function() use ($filteredRequest, $survey) { 217 | // ... 218 | }); 219 | 220 | $this->dispatcher->dispatch(new SurveyCreated($survey->id)); 221 | } 222 | } 223 | 224 | final class SitemapGenerationListener implements ShouldQueue 225 | { 226 | public function handle($event) 227 | { 228 | // Call sitemap generator 229 | } 230 | } 231 | 232 | final class EventServiceProvider extends ServiceProvider 233 | { 234 | protected $listen = [ 235 | SurveyCreated::class => [ 236 | SitemapGenerationListener::class, 237 | NotifyExternalApiListener::class, 238 | ], 239 | ]; 240 | } 241 | ``` 242 | 243 | Теперь Слой Приложения просто сообщает, что был создан новый опрос (событие **SurveyCreated**). 244 | Приложение имеет конфигурацию для реагирования на события. 245 | Классы слушателей событий (**Listener**) содержат реакцию на события. 246 | Интерфейс **ShouldQueue** работает точно также, сообщая о том, когда должно быть запущено выполнение этого слушателя: сразу же или отложено. 247 | События - очень мощная вещь, но здесь тоже есть ловушки. 248 | 249 | ## Использование событий Eloquent 250 | 251 | Laravel генерирует кучу событий. 252 | События системы кеширования: **CacheHit**, **CacheMissed**, и т.д.. 253 | События оповещений: **NotificationSent**, **NotificationFailed**, и т.д.. 254 | Eloquent тоже генерирует свои события. 255 | Пример из документации: 256 | 257 | ```php 258 | 259 | class User extends Authenticatable 260 | { 261 | /** 262 | * The event map for the model. 263 | * 264 | * @var array 265 | */ 266 | protected $dispatchesEvents = [ 267 | 'saved' => UserSaved::class, 268 | 'deleted' => UserDeleted::class, 269 | ]; 270 | } 271 | ``` 272 | 273 | Событие **UserSaved** будет генерироваться каждый раз когда сущность пользователя будет сохранена в базу данных. 274 | Сохранена, означает любой update или insert запрос. 275 | Использование этих событий имеет множество недостатков. 276 | 277 | **UserSaved** не самое удачное название для этого события. 278 | **UsersTableRowInsertedOrUpdated** более подходящее. 279 | Но и оно не всегда верное. 280 | Это событие не будет сгенерировано при массовых операциях со строками базы данных. 281 | Событие "Deleted" не будет вызвано, если строка в базе данных будет удалена с помощью механизма каскадного удаления в базе данных. 282 | Главная же проблема это то, что это события уровня инфраструктуры, события строчек базы данных, но они используются как бизнес-события, или события доменной области. 283 | Разницу легко осознать в примере с созданием сущности опроса: 284 | 285 | ```php 286 | final class SurveyService 287 | { 288 | public function create(SurveyCreateDto $request) 289 | { 290 | //... 291 | $this->connection->transaction(function() use (...) { 292 | $survey = new Survey(); 293 | $survey->question = $filteredRequest->getQuestion(); 294 | //... 295 | $survey->save(); 296 | 297 | foreach($filteredRequest->getOptions() as $optionText){ 298 | $option = new SurveyOption(); 299 | $option->survey_id = $survey->id; 300 | $option->text = $optionText; 301 | $option->save(); 302 | } 303 | }); 304 | //... 305 | } 306 | } 307 | ``` 308 | 309 | Вызов **$survey->save();** сгенерирует событие 'saved' для этой сущности. 310 | Первая проблема в том, что объект опроса еще не готов и неконсистентен. 311 | Он все еще не имеет вариантов ответа. 312 | В слушателе, который захочет отправить email с этим опросом, явно хочется иметь этот объект полностью, со всеми вариантами ответа. 313 | Для отложенных слушателей это не проблема, но на машинах разработчиков часто значение **QUEUE_DRIVER** - 'sync', поэтому все отложенные слушатели и задачи будут выполняться сразу и поведение будет разным. 314 | Я сильно рекомендую избегать кода, который правильно работает "иногда, в некоторой особой ситуации", а иногда может преподнести неприятный сюрприз. 315 | 316 | Вторая проблема - эти события вызываются прямо внутри транзакции. 317 | Выполнение слушателей сразу или даже отправка их в очередь делают транзакции более долгими и хрупкими. 318 | А самое страшное, что событие вроде **SurveyCreated**, может быть вызвано, но дальше в транзакции будет ошибка и вся она будет откачена назад. 319 | Письмо же пользователю об опросе, который даже не создался, все равно будет отправлено. 320 | Я нашел пару пакетов для Laravel, которые ловят все эти события, хранят их временно, и выполняют только после того, как транзакция будет успешно завершена (гуглите "Laravel transactional events"). 321 | Да, они решают множество из этих проблем, но всё это выглядит так неестественно! 322 | Простая идея генерировать нормальное бизнес-событие **SurveyCreated** после успешной транзакции намного лучше. 323 | 324 | ## Сущности как поля классов-событий 325 | Я часто вижу как Eloquent-сущности используются напрямую в полях событий: 326 | 327 | ```php 328 | final class SurveyCreated 329 | { 330 | public function __construct( 331 | public readonly Survey $survey 332 | ) {} 333 | } 334 | 335 | final class SurveyService 336 | { 337 | public function create(SurveyCreateDto $request) 338 | { 339 | // ... 340 | $survey = new Survey(); 341 | // ... 342 | $this->dispatcher->dispatch(new SurveyCreated($survey)); 343 | } 344 | } 345 | 346 | final class SendSurveyCreatedEmailListener implements ShouldQueue 347 | { 348 | public function handle(SurveyCreated $event) 349 | { 350 | // ... 351 | foreach($event->survey->options as $option) 352 | {...} 353 | } 354 | } 355 | ``` 356 | Это простой пример слушателя, который использует значения **HasMany**-отношения. 357 | Этот код работает. Когда выполняется код **$event->survey->options** Eloquent делает запрос в базу данных и получает все варианты ответа. 358 | Другой пример: 359 | 360 | ```php 361 | final class SurveyOptionAdded 362 | { 363 | public function __construct( 364 | public readonly Survey $survey 365 | ) {} 366 | } 367 | 368 | final class SurveyService 369 | { 370 | public function addOption(SurveyAddOptionDto $request) 371 | { 372 | $survey = Survey::findOrFail($request->getSurveyId()); 373 | 374 | if($survey->options->count() >= Survey::MAX_POSSIBLE_OPTIONS) { 375 | throw new BusinessException('Max options amount exceeded'); 376 | } 377 | 378 | $survey->options()->create(...); 379 | 380 | $this->dispatcher->dispatch(new SurveyOptionAdded($survey)); 381 | } 382 | } 383 | 384 | final class SomeListener implements ShouldQueue 385 | { 386 | public function handle(SurveyOptionAdded $event) 387 | { 388 | // ... 389 | foreach($event->survey->options as $option) 390 | {...} 391 | } 392 | } 393 | ``` 394 | 395 | А вот тут уже не все хорошо. 396 | Когда сервисный класс проверяет количество вариантов ответа, он получает свежую коллекцию текущих вариантов ответа данного опроса. 397 | Потом он добавляет новый вариант ответа, вызвав **$survey->options()->create(...)**; 398 | Дальше, слушатель, выполняя **$event->survey->options** получает старую версию вариантов ответа, без новосозданной. 399 | Это поведение Eloquent, который имеет два механизма работы с отношениями. Метод **options()** и псевдо-поле **options**, которое вроде бы и соответствует этому методу, но хранит свою версию данных. 400 | Поэтому, передавая сущность в события, разработчик должен озаботиться консистентностью значений в отношениях, например вызвав: 401 | 402 | ```php 403 | $survey->load('options'); 404 | ``` 405 | 406 | до передачи объекта в событие. 407 | Это все делает код приложения весьма хрупким. Его легко сломать неосторожной передачей объекта в событие. 408 | Намного проще просто передавать **id** сущности. 409 | Каждый слушатель всегда может получить свежую версию сущности, запросив её из базы данных по этому **id**. 410 | 411 | ## Пара слов в конце главы 412 | 413 | С развитием приложения, а особенно с ростом нагрузки на сервера, появляется естественное желание сократить время ответа сервера и время выполнения транзакций базы данных. 414 | 415 | Все тяжелые действия лучше выполнить в другом потоке. Как правило, с помощью очередей. В этом нет какой-то серьезной сложности. Вполне достаточно организованно и скрупулезно подойти к данному вопросу. В сложном кейсе, когда количество действий и пост-действий становится большим, события и настройка их слушателей сильно помогает организовать все не превращая код приложения в хаос. 416 | 417 | С транзакциями также нет какой-либо сложности, но неявная и скрытая логика, такая как события **Eloquent**-моделей, могут внести неразбериху в этот процесс. Явные события, вызываемые в контролируемых местах, намного более стабильная и управляемая стратегия. Событие **UserBanned** явно выражает, что произошло, в отличие от сильно более общего **UserSaved**. -------------------------------------------------------------------------------- /manuscript/8-unit-test.md: -------------------------------------------------------------------------------- 1 | # Unit-тестирование 2 | 3 | ## Первые шаги 4 | 5 | Вы, вероятно, уже слышали про unit-тестирование. 6 | Оно довольно популярно сейчас. 7 | Я довольно часто общаюсь с разработчиками, которые утверждают, что не начинают писать код, пока не напишут тест для него. 8 | TDD-маньяки! 9 | Начинать писать unit-тесты довольно сложно, особенно если вы пишете, используя фреймворки такие, как Laravel. 10 | Unit-тесты одни из лучших индикаторов качества кода в проекте. 11 | Фреймворки пытаются сделать процесс добавления новых фич как можно более быстрым, позволяя срезать углы в некоторых местах, но высоко-связанный код обычная тому цена. 12 | Сущности железно связанные с базой данных, классы с большим количеством зависимостей, которые бывает трудно найти (Laravel фасады). 13 | В этой главе я постараюсь протестировать код Laravel приложения и показать главные трудности, но начнем с самого начала. 14 | 15 | **Чистая функция** - это функция, результат которой зависит **только** от введенных данных. 16 | Она не меняет никакие внешние значения и просто вычисляет результат. 17 | Примеры: 18 | 19 | ```php 20 | function strpos(string $needle, string $haystack) 21 | function array_chunk(array $input, $size, $preserve_keys = null) 22 | ``` 23 | 24 | Чистые функции очень простые и предсказуемые. 25 | Unit-тесты для них писать легко. 26 | Попробуем написать простую функцию (это может быть и методом класса) методом TDD: 27 | 28 | ```php 29 | function cutString(string $source, int $limit): string 30 | { 31 | return ''; // начнем просто возвращая пустую строку 32 | } 33 | 34 | class CutStringTest extends \PHPUnit\Framework\TestCase 35 | { 36 | public function testEmpty() 37 | { 38 | $this->assertEquals('', cutString('', 20)); 39 | } 40 | 41 | public function testShortString() 42 | { 43 | $this->assertEquals('short', cutString('short', 20)); 44 | } 45 | 46 | public function testCut() 47 | { 48 | $this->assertEquals('long string shoul...', 49 | cutString('long string should be cut', 20)); 50 | } 51 | } 52 | ``` 53 | 54 | Я здесь использую PHPUnit для написания тестов. 55 | Название функции не очень удачное, но просто взглянув на тесты, можно понять что она делает. 56 | Тесты проверяют результат с помощью assertEquals. 57 | Unit-тесты могут служить документацией к коду, если они такие же простые и легко-читаемые. 58 | 59 | Если я запущу эти тесты, то получу такой вывод: 60 | 61 | ``` 62 | Failed asserting that two strings are equal. 63 | Expected :'short' 64 | Actual :'' 65 | 66 | Failed asserting that two strings are equal. 67 | Expected :'long string shoul...' 68 | Actual :'' 69 | ``` 70 | 71 | Разумеется, ведь наша функция еще не написана. 72 | Время ее написать: 73 | 74 | ```php 75 | function cutString(string $source, int $limit): string 76 | { 77 | $len = strlen($source); 78 | 79 | if($len < $limit) { 80 | return $source; 81 | } 82 | 83 | return substr($source, 0, $limit-3) . '...'; 84 | } 85 | ``` 86 | 87 | Вывод PHPUnit после этих правок: 88 | 89 | ``` 90 | OK (3 tests, 3 assertions) 91 | ``` 92 | Отлично! 93 | Класс unit-теста содержит список требований к функции: 94 | 95 | * Для пустой строки результат тоже должен быть пуст. 96 | * Для строк, которые короче лимита, должна вернуться эта строка без изменений. 97 | * Для строк длиннее лимита, результатом должна стать строка, укороченная до этого лимита с тремя точками в конце. 98 | 99 | Успешные тесты говорят о том, что код удовлетворяет требованиям. 100 | Но это не так! 101 | В коде небольшая ошибка и функция не работает как задумано, если длина строки совпадает с лимитом. 102 | Хорошая привычка: если найден баг, надо написать тест, который его воспроизведет и упадёт. 103 | Нам в любом случае нужно будет проверить исправлен ли этот баг и unit-тест хорошее место для этого. 104 | Новые тест-методы: 105 | 106 | ```php 107 | class CutStringTest extends \PHPUnit\Framework\TestCase 108 | { 109 | // старые тесты 110 | 111 | public function testLimit() 112 | { 113 | $this->assertEquals('limit', cutString('limit', 5)); 114 | } 115 | 116 | public function testBeyondTheLimit() 117 | { 118 | $this->assertEquals('beyondl...', 119 | cutString('beyondlimit', 10)); 120 | } 121 | } 122 | ``` 123 | 124 | **testBeyondTheLimit** выполняется хорошо, а **testLimit** падает: 125 | 126 | ``` 127 | Failed asserting that two strings are equal. 128 | Expected :'limit' 129 | Actual :'li...' 130 | ``` 131 | 132 | Исправление простое: поменять **<** на **<=** 133 | 134 | ```php 135 | function cutString(string $source, int $limit): string 136 | { 137 | $len = strlen($source); 138 | 139 | if($len <= $limit) { 140 | return $source; 141 | } 142 | 143 | return substr($source, 0, $limit-3) . '...'; 144 | } 145 | ``` 146 | 147 | Сразу же запускаем тесты: 148 | 149 | ``` 150 | OK (5 tests, 5 assertions) 151 | ``` 152 | 153 | Отлично. Проверка краевых значений (0, длина **$limit**, длина **$limit**+1, и т.д.) очень важная часть тестирования. 154 | Многие ошибки находятся именно в этих местах. 155 | 156 | Когда я писал функцию **cutString**, я думал, что длина исходной строки мне понадобится дальше и сохранил её в переменную. Но оказалось, что дальше нам нужна только переменная **$limit**. 157 | Теперь я могу удалить эту переменную. 158 | 159 | ```php 160 | function cutString(string $source, int $limit): string 161 | { 162 | if(strlen($source) <= $limit) { 163 | return $source; 164 | } 165 | 166 | return substr($source, 0, $limit-3) . '...'; 167 | } 168 | ``` 169 | 170 | И опять: запускаем тесты! 171 | Я изменил код и мог что-то сломать при этом. 172 | Лучше это обнаружить как можно скорее и исправить. 173 | Эта привычка сильно повышает итоговую производительность. 174 | С хорошо написанными тестами, почти любая ошибка будет поймана сразу и разработчик может исправить её пока тот код, который он поменял, у него всё еще в голове. 175 | 176 | Я всё внимание обратил на главный функционал и забыл про пред-условия. 177 | Разумеется, параметр **$limit** в реальном проекте никогда не будет слишком маленький, но хороший дизайн функции предполагает проверку этого значения тоже: 178 | 179 | ```php 180 | class CutStringTest extends \PHPUnit\Framework\TestCase 181 | { 182 | //... 183 | 184 | public function testLimitCondition() 185 | { 186 | $this->expectException(InvalidArgumentException::class); 187 | 188 | cutString('limit', 4); 189 | } 190 | } 191 | 192 | function cutString(string $source, int $limit): string 193 | { 194 | if($limit < 5) { 195 | throw new InvalidArgumentException( 196 | 'The limit is too low'); 197 | } 198 | 199 | if(strlen($source) <= $limit) { 200 | return $source; 201 | } 202 | 203 | return substr($source, 0, $limit-3) . '...'; 204 | } 205 | ``` 206 | 207 | Вызов **expectException** проверяет то, что исключение будет выброшено. Если этого не произойдет, то тест будет признан упавшим. 208 | 209 | ## Тестирование классов с состоянием 210 | 211 | Чистые функции прекрасны, но в реальном мире слишком много вещей, которые нельзя описать исключительно ими. 212 | Объекты могут иметь **состояние**. 213 | Unit-тестирование классов с состоянием немного сложнее. 214 | Для таких тестов есть рекомендация делить код теста на три части: 215 | 1. **инициализация** объекта в нужном состоянии 216 | 2. **выполнение** тестируемого действия 217 | 3. **проверка** результата 218 | 219 | I> Есть также шаблон AAA: Arrange, Act, Assert, который описывает те же три шага. 220 | 221 | Начну с простого примера теста воображаемой сущности **Статья**, которая не является Eloquent моделью. 222 | Её можно создать только с непустым заголовком, а текст может быть пустым. 223 | Но опубликовать эту статью можно только, если её текст не пустой. 224 | 225 | ```php 226 | class Post 227 | { 228 | public string $title; 229 | public string $body; 230 | public bool $published = false; 231 | 232 | public function __construct( 233 | $title, $body 234 | ) { 235 | if (empty($title)) { 236 | throw new InvalidArgumentException( 237 | 'Title should not be empty'); 238 | } 239 | 240 | $this->title = $title; 241 | $this->body = $body; 242 | } 243 | 244 | public function publish() 245 | { 246 | if (empty($this->body)) { 247 | throw new CantPublishException( 248 | 'Cant publish post with empty body'); 249 | } 250 | 251 | $this->published = true; 252 | } 253 | } 254 | ``` 255 | 256 | Конструктор класса **Post** - чистая функция, поэтому тесты для нее подобны предыдущим: 257 | 258 | ```php 259 | class CreatePostTest extends \PHPUnit\Framework\TestCase 260 | { 261 | public function testSuccessfulCreate() 262 | { 263 | // инициализация и выполнение 264 | $post = new Post('title', ''); 265 | 266 | // проверка 267 | $this->assertEquals('title', $post->title); 268 | } 269 | 270 | public function testEmptyTitle() 271 | { 272 | // проверка 273 | $this->expectException(InvalidArgumentException::class); 274 | 275 | // инициализация и выполнение 276 | new Post('', ''); 277 | } 278 | } 279 | ``` 280 | 281 | Однако, метод **publish** зависит от текущего состояния объекта и части тестов более ощутимы: 282 | 283 | ```php 284 | class PublishPostTest extends \PHPUnit\Framework\TestCase 285 | { 286 | public function testSuccessfulPublish() 287 | { 288 | // инициализация 289 | $post = new Post('title', 'body'); 290 | 291 | // выполнение 292 | $post->publish(); 293 | 294 | // проверка 295 | $this->assertTrue($post->published); 296 | } 297 | 298 | public function testPublishEmptyBody() 299 | { 300 | // инициализация 301 | $post = new Post('title', ''); 302 | 303 | // проверка 304 | $this->expectException(CantPublishException::class); 305 | 306 | // выполнение 307 | $post->publish(); 308 | } 309 | } 310 | ``` 311 | 312 | При тестировании исключений **проверка**, которая обычно последняя, происходит до **выполнения**. 313 | Тестирование классов с состоянием сложнее тестирования чистых функций, поскольку разработчик должен держать в голове состояние объекта и проверить все возможные варианты пар "состояние-изменение". 314 | 315 | ## Тестирование классов с зависимостями 316 | 317 | Одной из важных особенностей unit-тестирования является тестирование в изоляции. 318 | Unit (класс, функция или другой модуль) должен быть изолирован от всего остального мира. 319 | Это будет гарантировать, что тест тестирует только этот модуль. 320 | Тест может упасть только по двум причинам: неправильный тест или неправильный код тестируемого модуля. 321 | Ни неправильно настроенная база данных, ни появившийся баг в какой-то из используемых библиотек не могут уронить unit-тесты. 322 | Тестирование в изоляции даёт нам эту простоту и быстродействие. 323 | Настоящие unit-тесты выполняются очень быстро, поскольку во время их выполнения не происходит никаких тяжелых операций, вроде запросов в базу данных, чтения файлов или вызовов API. 324 | Когда класс просит некоторые DI-зависимости, тест должен их ему предоставить. 325 | 326 | ### Зависимости на реальные классы 327 | 328 | В главе про внедрение зависимостей я писал про два типа возможных интерфейсов: 329 | 330 | 1. Есть интерфейс и несколько возможных реализаций. 331 | 2. Есть интерфейс и одна реализация. 332 | 333 | Для второго случая я предлагал не создавать интерфейса, теперь же хочу проанализировать это. 334 | Какая зависимость может быть реализована только одним возможным способом? 335 | Все операции ввода/вывода, такие как вызовы API, операции с файлами или запросы в базу данных, всегда могут иметь другие возможные реализации. С другим драйвером, декоратором и т.д. 336 | Иногда класс содержит некоторые большие вычисления и разработчик решает вынести эту логику в отдельный класс. 337 | Этот новый класс становится новой зависимостью. 338 | В этом случае трудно себе представить другой возможный вариант реализации этой зависимости и это прекрасный момент, чтобы поговорить про инкапсуляцию и почему unit-тестирование называется unit-тестированием, т.е. тестированием модулей, а не тестирование классов или функций. 339 | 340 | Это пример описанного случая. Класс **TaxCalculator** был вынесен в свой класс из класса **OrderService**. 341 | 342 | ```php 343 | class OrderService 344 | { 345 | public function __construct( 346 | private TaxCalculator $taxCalculator 347 | ) {} 348 | 349 | public function create(OrderCreateDto $orderCreateDto) 350 | { 351 | $order = new Order(); 352 | //... 353 | $order->sum = ...; 354 | $order->taxSum = $this->taxCalculator 355 | ->calculateTax($order); 356 | //... 357 | } 358 | } 359 | ``` 360 | 361 | Но если мы взглянем на класс **OrderService**, то увидим, что **TaxCalculator** не выглядит его зависимостью. 362 | Он не выглядит как что-то внешнее, нужное **OrderService** для работы. 363 | Он выглядит как часть класса **OrderService**. 364 | 365 | 366 | ![](images/unit.png) 367 | 368 | 369 | **OrderService** здесь является модулем, который содержит не только класс **OrderService**, но и класс **TaxCalculator**. 370 | Класс **TaxCalculator** должен быть внутренней зависимостью, а не внешней. 371 | 372 | ```php 373 | class OrderService 374 | { 375 | private TaxCalculator $taxCalculator = new TaxCalculator(); 376 | //... 377 | } 378 | ``` 379 | 380 | Теперь всему остальному коду необязательно знать про **TaxCalculator**. 381 | Unit-тесты могут тестировать класс **OrderService** не заботясь о предоставлении ему объекта **TaxCalculator**. 382 | Если условия изменятся и **TaxCalculator** станет внешней зависимостью (разные алгоритмы подсчета налогов), то зависимость будет несложно сделать публичной, нужно будет просто поставить его как параметр в конструктор и поменять код тестов. 383 | 384 | Модуль - весьма широкое понятие. 385 | В начале этой статьи модулем была маленькая функция, а иногда в модуле может содержаться несколько классов. 386 | Программные объекты внутри модуля должны быть сфокусированы на одной ответственности, другими словами, иметь сильную связность. 387 | Когда методы класса полностью независимы друг от друга, класс не является модулем. 388 | Каждый метод класса - это модуль в данном случае. 389 | Возможно, стоит вынести эти методы в отдельные классы, чтобы разработчики не просматривали кучу лишнего кода каждый раз? 390 | 391 | ### Стабы и фейки 392 | 393 | Обычно, зависимость - это интерфейс, который имеет несколько реализаций. 394 | Использование реальных реализаций этого интерфейса во время unit-тестирования - плохая идея, поскольку там могут проводиться те самые операции ввода-вывода, замедляющие тестирование и не дающие провести тестирование этого модуля в изоляции. 395 | Прогон unit-тестов должен быть быстр как молния, поскольку запускаться они будут часто и важно, чтобы разработчик запустив их не потерял фокус над кодом. 396 | Написал код - прогнал тесты, еще написал код - прогнал тесты. 397 | Быстрые тесты позволят ему оставаться более продуктивным, не позволяя отвлекаться. 398 | Решение в лоб задачи изоляции класса от зависимостей - создание отдельной реализации этого интерфейса, предназначенного просто для тестирования. 399 | Вернемся к предыдущему примеру и вообразим, что **TaxCalculator** стал зависимостью и это теперь интерфейс с некоей реализацией. 400 | 401 | ```php 402 | interface TaxCalculator 403 | { 404 | public function calculateTax(Order $order): float; 405 | } 406 | 407 | class OrderService 408 | { 409 | public function __construct( 410 | private TaxCalculator $taxCalculator 411 | ) {} 412 | 413 | public function create(OrderCreateDto $orderCreateDto) 414 | { 415 | $order = new Order(); 416 | //... 417 | $order->sum = ...; 418 | $order->taxSum = $this->taxCalculator 419 | ->calculateTax($order); 420 | //... 421 | } 422 | } 423 | 424 | class FakeTaxCalculator implements TaxCalculator 425 | { 426 | public function calculateTax(Order $order): float 427 | { 428 | return 0; 429 | } 430 | } 431 | 432 | class OrderServiceTest extends \PHPUnit\Framework\TestCase 433 | { 434 | public function testCreate() 435 | { 436 | $orderService = new OrderService(new FakeTaxCalculator()); 437 | 438 | $orderService->create(new OrderCreateDto(...)); 439 | 440 | // some assertions 441 | } 442 | } 443 | ``` 444 | 445 | Работает! 446 | Такие классы называются фейками. 447 | Библиотеки для unit-тестирования могут создавать такие классы на лету. 448 | Тот же самый тест, но с использованием метода **createMock** библиотеки PHPUnit: 449 | 450 | ```php 451 | class OrderServiceTest extends \PHPUnit\Framework\TestCase 452 | { 453 | public function testCreate() 454 | { 455 | $stub = $this->createMock(TaxCalculator::class); 456 | 457 | $stub->method('calculateTax') 458 | ->willReturn(0); 459 | 460 | $orderService = new OrderService($stub); 461 | 462 | $orderService->create(new OrderCreateDto(...)); 463 | 464 | // some assertions 465 | } 466 | } 467 | ``` 468 | 469 | Стабы удобны когда нужно быстро настроить простую реализацию, однако когда тестов с этой зависимостью становится много, вариант с фэйковым классом смотрится оптимальнее. 470 | Библиотеки могут создавать стабы не только для интерфейсов, но и для реальных классов, что может быть весьма удобно при работе с легаси-проектами или для ленивых разработчиков. 471 | 472 | ### Моки 473 | 474 | Иногда разработчик хочет протестировать вызваны ли методы стаба, сколько раз и какие параметры переданы были. 475 | 476 | > Вообще, я не считаю идею тестирования вызовов методов зависимостей хорошей идеей. 477 | > Unit-тест в этом случае начинает знать слишком многое о том, как этот класс работает. 478 | > Как следствие, такие тесты очень легко ломаются. 479 | > Небольшой рефакторинг и тесты падают. 480 | > Если это случается слишком часто, команда может просто забыть про unit-тестирование. 481 | > Это называется тестированием методом белого ящика. 482 | > Тестирование методом черного ящика пытается тестировать только входные и выходные данные, не залезая внутрь. 483 | > Разумеется, тестирование черным ящиком намного стабильнее. 484 | 485 | Эти проверки могут быть реализованы в фейковом классе, но это будет весьма непросто и мало кто захочет делать это для каждой возможной зависимости. 486 | Библиотеки для тестирования могут создавать специальные мок-классы, которые позволяют легко проверять вызовы их методов. 487 | 488 | ```php 489 | class OrderServiceTest extends \PHPUnit\Framework\TestCase 490 | { 491 | public function testCreate() 492 | { 493 | $stub = $this->createMock(TaxCalculator::class); 494 | 495 | // Конфигурация мок-класса 496 | $stub->expects($this->once()) 497 | ->method('calculateTax') 498 | ->willReturn(0); 499 | 500 | $orderService = new OrderService($stub); 501 | 502 | $orderService->create(new OrderCreateDto(...)); 503 | 504 | // некоторые проверки 505 | } 506 | } 507 | ``` 508 | 509 | Теперь тест проверяет, что во время выполнения метода **OrderService::create** с переданными параметрами **TaxCalculator::calculateTax** был вызван ровно один раз. 510 | С мок-классами можно делать различные проверки на значения параметров и количество вызовов, настраивать возвращаемые значения, выбрасывать исключения и т.д. 511 | Я не хочу фокусироваться на этом в данной книге. 512 | Фейки, стабы и моки имеют общее имя - test doubles, название для объектов, которые подставляются вместо реальных с целью тестирования. 513 | Они могут использоваться не только в unit-тестах, но в и интеграционных тестах. 514 | 515 | ## Типы тестов ПО 516 | 517 | Люди придумали множество способов тестировать приложения. 518 | Security testing для проверки приложений на различные уязвимости. 519 | Performance testing для проверки насколько хорошо приложение ведет себя при нагрузке. 520 | В этой главе мы сфокусируемся на проверке корректности работы приложений. 521 | Unit-тестирование уже было рассмотрено. 522 | **Интеграционное тестирование** проверяет совместную работу нескольких модулей. 523 | Пример: Попросить **UserService** зарегистрировать нового пользователя и проверить, что новая строка создана в базе данных, нужное событие (**UserRegistered**) было сгенерировано и соответствующий email был послан (ну или хотя бы фреймворк получил команду сделать это). 524 | 525 | 526 | ![](images/integration_testing_example.png) 527 | 528 | 529 | **Функциональное тестирование** (приёмочное или E2E - end to end) проверяет приложение на соответствие функциональным требованиям. 530 | Пример: Требование о создании некоей сущности. 531 | Тест открывает браузер, идёт на специфическую страницу, заполняет поля значениями, "нажимает" кнопку Создать и проверяет, что нужная сущность была создана, путем поиска её на определенной странице. 532 | 533 | ## Тестирование в Laravel 534 | 535 | Laravel предоставляет много инструментов для различного тестирования. 536 | 537 | ### Инструменты Laravel для функционального тестирования 538 | 539 | Инструменты для тестирования HTTP запросов, браузерного и консоли делают функциональное тестирование в Laravel весьма удобным, но мне не нравятся примеры из документации. 540 | Один из них, совсем немного измененный: 541 | 542 | ```php 543 | class ExampleTest extends TestCase 544 | { 545 | public function testBasicExample() 546 | { 547 | $response = $this->postJson('/users', [ 548 | 'name' => 'Sally', 549 | 'email' => 'sally@example.com' 550 | ]); 551 | 552 | $response 553 | ->assertOk() 554 | ->assertJson([ 555 | 'created' => true, 556 | ]); 557 | } 558 | } 559 | ``` 560 | 561 | Этот тест просто проверяет, что запрос **POST /user** вернул успешный результат. 562 | Это не выглядит законченным тестом. 563 | Тест должен проверять, что пользователь реально создан. 564 | Но как? 565 | Первый ответ, приходящий в голову: просто сделать запрос в базу данных и проверить это. 566 | Опять пример из документации: 567 | 568 | ```php 569 | class ExampleTest extends TestCase 570 | { 571 | public function testDatabase() 572 | { 573 | // Сделать запрос на создание пользователя 574 | 575 | $this->assertDatabaseHas('users', [ 576 | 'email' => 'sally@example.com' 577 | ]); 578 | } 579 | } 580 | ``` 581 | 582 | Хорошо. Напишем другой тест подобным образом: 583 | 584 | ```php 585 | class PostsTest extends TestCase 586 | { 587 | public function testDelete() 588 | { 589 | $response = $this->deleteJson('/posts/1'); 590 | 591 | $response->assertOk(); 592 | 593 | $this->assertDatabaseMissing('posts', [ 594 | 'id' => 1 595 | ]); 596 | } 597 | } 598 | ``` 599 | 600 | А вот тут уже небольшая ловушка. 601 | Абсолютно такая же как и в главе про валидацию. 602 | Просто добавив трейт **SoftDeletes** в класс **Post**, мы уроним этот тест. 603 | Однако, приложение работает абсолютно также, выполняет те же самые требования и пользователи этой разницы не заметят. 604 | Функциональные тесты не должны падать в таких условиях. 605 | Тест, который делает запрос в приложение, а потом лезет в базу данных проверять результат, не является настоящим функциональным тестом. 606 | Он знает слишком многое про то, как работает приложение, как оно хранит данные и какие таблицы для этого использует. 607 | Это еще один пример тестирования методом белого ящика. 608 | 609 | Как я уже говорил, функциональное тестирование проверяет, удовлетворяет ли приложение функциональным требованиям. 610 | Функциональное тестирование не про базу данных, оно о приложении в целом. 611 | Поэтому нормальные функциональные тесты не лезут внутрь приложения, они работают снаружи. 612 | 613 | ```php 614 | class PostsTest extends TestCase 615 | { 616 | public function testCreate() 617 | { 618 | $response = $this->postJson('/api/posts', [ 619 | 'title' => 'Post test title' 620 | ]); 621 | 622 | $response 623 | ->assertOk() 624 | ->assertJsonStructure([ 625 | 'id', 626 | ]); 627 | 628 | $checkResponse = $this->getJson( 629 | '/api/posts/' . $response->getData()->id); 630 | 631 | $checkResponse 632 | ->assertOk() 633 | ->assertJson([ 634 | 'title' => 'Post test title', 635 | ]); 636 | } 637 | 638 | public function testDelete() 639 | { 640 | // Здесь некоторая инициализация, чтобы создать 641 | // объект Post с id = $postId 642 | 643 | // Удостоверяемся, что этот объект есть 644 | $this->getJson('/api/posts/' . $postId) 645 | ->assertOk(); 646 | 647 | // Удаляем его 648 | $this->jsonDelete('/posts/' . $postId) 649 | ->assertOk(); 650 | 651 | // Проверяем, что больше в приложении его нет 652 | $this->getJson('/api/posts/' . $postId) 653 | ->assertStatus(404); 654 | } 655 | } 656 | ``` 657 | 658 | Этому тесту абсолютно все равно как удален объект, с помощью 'delete' SQL запроса или с помощью Soft delete шаблона. 659 | Функциональный тест проверяет поведение приложения в целом. 660 | Ожидаемое поведение, если объект удален - он не возвращается по своему id и тест проверяет именно это. 661 | 662 | Схема процессинга запросов "POST /posts/" и "GET /post/{id}": 663 | 664 | 665 | ![](images/functional_testing.png) 666 | 667 | 668 | Что должен видеть функциональный тест: 669 | 670 | 671 | ![](images/functional_testing2.png) 672 | 673 | 674 | ### Моки Laravel-фасадов 675 | 676 | Laravel предоставляет удобную реализацию шаблона **Service Locator** - Laravel-фасады. 677 | Laravel не только предлагает их использовать, но и предоставляет инструменты для тестирования кода, который использует фасады. 678 | Давайте напишем один из предыдущих примеров с использованием Laravel-фасадов и протестируем этот код: 679 | 680 | ```php 681 | final class Survey extends Model 682 | { 683 | public function options() 684 | { 685 | return $this->hasMany(SurveyOption::class); 686 | } 687 | } 688 | 689 | final class SurveyOption extends Model 690 | { 691 | } 692 | 693 | final class SurveyCreated 694 | { 695 | public function __construct( 696 | public readonly int $surveyId 697 | ) {} 698 | } 699 | 700 | final class SurveyCreateDto 701 | { 702 | public function __construct( 703 | public readonly string $title, 704 | /** @var string[] */ 705 | public readonly array $options, 706 | ) {} 707 | } 708 | 709 | final class SurveyService 710 | { 711 | public function create(SurveyCreateDto $dto) 712 | { 713 | if(count($dto->options) < 2) { 714 | throw new BusinessException( 715 | "Please provide at least 2 options"); 716 | } 717 | 718 | $survey = new Survey(); 719 | 720 | \DB::transaction(function() use ($dto, $survey) { 721 | $survey->title = $dto->title; 722 | $survey->save(); 723 | 724 | foreach ($dto->options as $option) { 725 | $survey->options()->create([ 726 | 'text' => $option, 727 | ]); 728 | } 729 | }); 730 | 731 | \Event::dispatch(new SurveyCreated($survey->id)); 732 | } 733 | } 734 | 735 | class SurveyServiceTest extends TestCase 736 | { 737 | public function testCreate() 738 | { 739 | \Event::fake(); 740 | 741 | $postService = new SurveyService(); 742 | $postService->create(new SurveyCreateDto( 743 | 'test title', 744 | ['option1', 'option2'])); 745 | 746 | \Event::assertDispatched(SurveyCreated::class); 747 | } 748 | } 749 | ``` 750 | 751 | * Вызов **\Event::fake()** трансформирует Laravel-фасад **Event** в мок-объект. 752 | * Метод **SurveyService::create** создаёт опрос с вариантами ответа, сохраняет его в базу данных и генерирует событие **SurveyCreated**. 753 | * Вызов **\Event::assertDispatched** проверяет, что это событие было вызвано. 754 | 755 | Я вижу несколько недостатков: 756 | 757 | * Это не является unit-тестом. 758 | Фасад **\Event** был заменен моком, но база данных - нет. 759 | Реальные строчки будут добавлены в базу данных. 760 | Для того чтобы сделать этот тест более чистым, в класс теста обычно добавляют трейт **RefreshDatabase**, который создаёт базу данных заново каждый раз. 761 | Это очень медленно. 762 | Один такой тест может быть выполнен за разумное время, но сотни таких займут уже несколько минут и никто не будет их выполнять после каждой мелкой правки. 763 | * Тесты проверяют только генерацию событий. 764 | Для того чтобы проверить создание записей в базе данных нужно использовать вызовы методов, таких как **assertDatabaseHas** или что-то вроде **SurveyService::getById**, которое делает этот тест неким функциональным тестом к Слою Приложения, поскольку он просит Слой Приложения что-то сделать и проверяет результат тоже вызвав его. 765 | * Зависимости класса **SurveyService** не описаны явно. 766 | Фасад **Event** вызывается где-то внутри. 767 | Чтобы понять, что конкретно он требует для своей работы, нужно просмотреть весь его код. 768 | Это делает написание тестов для него весьма неудобным. 769 | Хуже всего, если в класс будет добавлена новая зависимость с помощью laravel-фасада. 770 | Тесты будут продолжать работать как ни в чем не бывало, но не с моком, а с реальной реализацией этого фасада: реальные вызовы API и т.д. 771 | Я слышал несколько реальных историй, когда разработчики запускали тесты и это приводило к тысячам реальных денежных переводов! 772 | Ноги у такого растут именно из-за таких вот случаев, когда неожиданно в тесты попадет реальная реализация. 773 | 774 | Я называю это "форсированным интеграционным тестированием". 775 | Разработчик хочет написать unit-тест, но код обладает такой высокой связанностью, так крепко сцеплен с фреймворком, что этого просто не получается. 776 | Попробуем это сделать! 777 | 778 | ## Unit-тестирование Слоя Приложения 779 | 780 | ### Отсоединяем код от laravel-фасадов 781 | 782 | Для того чтобы протестировать метод **SurveyService::create** в изоляции, нужно убрать использование Laravel-фасадов и базы данных (Eloquent). 783 | Первая часть несложная, у нас есть Внедрение Зависимостей. 784 | 785 | * Фасад **\Event** представляет интерфейс **Illuminate\Contracts\Events\Dispatcher**. 786 | * Фасад **\DB** - **Illuminate\Database\ConnectionInterface**. 787 | 788 | Вообще, последнее не совсем правда. Фасад **\DB** представляет **\Illuminate\Database\DatabaseManager**, который содержит вот такую вот магию: 789 | 790 | ```php 791 | class DatabaseManager 792 | { 793 | /** 794 | * Dynamically pass methods to the default connection. 795 | * 796 | * @param string $method 797 | * @param array $parameters 798 | * @return mixed 799 | */ 800 | public function __call($method, $parameters) 801 | { 802 | return $this->connection()->$method(...$parameters); 803 | } 804 | } 805 | ``` 806 | 807 | Но нам будет достаточно только использования **ConnectionInterface** напрямую. 808 | 809 | ```php 810 | class SurveyService 811 | { 812 | public function __construct( 813 | private ConnectionInterface $connection, 814 | private Dispatcher $dispatcher 815 | ) {} 816 | 817 | public function create(SurveyCreateDto $dto) 818 | { 819 | if(count($dto->options) < 2) { 820 | throw new BusinessException( 821 | "Please provide at least 2 options"); 822 | } 823 | 824 | $survey = new Survey(); 825 | 826 | $this->connection->transaction(function() use ($dto, $survey) { 827 | $survey->title = $dto->title; 828 | $survey->save(); 829 | 830 | foreach ($dto->options as $option) { 831 | $survey->options()->create([ 832 | 'text' => $option, 833 | ]); 834 | } 835 | }); 836 | 837 | $this->dispatcher->dispatch(new SurveyCreated($survey->id)); 838 | } 839 | } 840 | ``` 841 | 842 | Хорошо. Для интерфейса **ConnectionInterface** я могу создать фейк класс **FakeConnection**. 843 | Класс **EventFake**, который используется, когда происходит вызов **\Event::fake()**, может быть использован напрямую. 844 | 845 | ```php 846 | use Illuminate\Support\Testing\Fakes\EventFake; 847 | //... 848 | 849 | class SurveyServiceTest extends TestCase 850 | { 851 | public function testCreateSurvey() 852 | { 853 | $eventFake = new EventFake( 854 | $this->createMock(Dispatcher::class)); 855 | 856 | $postService = new SurveyService( 857 | new FakeConnection(), $eventFake); 858 | 859 | $postService->create(new SurveyCreateDto( 860 | 'test title', 861 | ['option1', 'option2'])); 862 | 863 | $eventFake->assertDispatched(SurveyCreated::class); 864 | } 865 | } 866 | ``` 867 | 868 | Этот тест выглядит очень похоже на прошлый, с фасадами, но теперь он намного строже с зависимостями класса **SurveyService**. 869 | Но не совсем. 870 | Любой разработчик всё еще может использовать любой фасад внутри класса **SurveyService** и тест будет продолжать работать. 871 | Это происходит потому, что здесь используется специальный базовый класс для тестов, предоставляемый Laravel, который полностью настраивает рабочее окружение. 872 | 873 | ```php 874 | use Illuminate\Foundation\Testing\TestCase as BaseTestCase; 875 | 876 | abstract class TestCase extends BaseTestCase 877 | { 878 | use CreatesApplication; 879 | } 880 | ``` 881 | 882 | Для unit-тестов это недопустимо, нужно использовать обычный базовый класс PHPUnit: 883 | 884 | ```php 885 | 886 | abstract class TestCase extends \PHPUnit\Framework\TestCase 887 | { 888 | } 889 | ``` 890 | 891 | Теперь, если кто-то добавит вызов фасада, тест упадёт с ошибкой: 892 | 893 | ``` 894 | Error : Class 'SomeFacade' not found 895 | ``` 896 | 897 | Отлично, от laravel-фасадов мы код полностью избавили. 898 | 899 | ### Отсоединяем от базы данных 900 | 901 | Отсоединять от базы данных намного сложнее. 902 | Создадим класс репозитория (шаблон **Репозиторий**), чтобы собрать в нём всю работу с базой данных. 903 | 904 | ```php 905 | interface SurveyRepository 906 | { 907 | //... другие методы 908 | 909 | public function save(Survey $survey); 910 | 911 | public function saveOption(SurveyOption $option); 912 | } 913 | 914 | class EloquentSurveyRepository implements SurveyRepository 915 | { 916 | //... другие методы 917 | 918 | public function save(Survey $survey) 919 | { 920 | $survey->save(); 921 | } 922 | 923 | public function saveOption(SurveyOption $option) 924 | { 925 | $option->save(); 926 | } 927 | } 928 | 929 | class SurveyService 930 | { 931 | public function __construct( 932 | private ConnectionInterface $connection, 933 | private SurveyRepository $repository, 934 | private Dispatcher $dispatcher 935 | ) {} 936 | 937 | public function create(SurveyCreateDto $dto) 938 | { 939 | if(count($dto->options) < 2) { 940 | throw new BusinessException( 941 | "Please provide at least 2 options"); 942 | } 943 | 944 | $survey = new Survey(); 945 | 946 | $this->connection->transaction(function() use ($dto, $survey) { 947 | $survey->title = $dto->title; 948 | $this->repository->save($survey); 949 | 950 | foreach ($dto->options as $optionText) { 951 | $option = new SurveyOption(); 952 | $option->survey_id = $survey->id; 953 | $option->text = $optionText; 954 | 955 | $this->repository->saveOption($option); 956 | } 957 | }); 958 | 959 | $this->dispatcher->dispatch(new SurveyCreated($survey->id)); 960 | } 961 | } 962 | 963 | class SurveyServiceTest extends \PHPUnit\Framework\TestCase 964 | { 965 | public function testCreateSurvey() 966 | { 967 | $eventFake = new EventFake( 968 | $this->createMock(Dispatcher::class)); 969 | 970 | $repositoryMock = $this->createMock(SurveyRepository::class); 971 | 972 | $repositoryMock->method('save') 973 | ->with($this->callback(function(Survey $survey) { 974 | return $survey->title == 'test title'; 975 | })); 976 | 977 | $repositoryMock->expects($this->at(2)) 978 | ->method('saveOption'); 979 | 980 | $postService = new SurveyService( 981 | new FakeConnection(), $repositoryMock, $eventFake); 982 | 983 | $postService->create(new SurveyCreateDto( 984 | 'test title', 985 | ['option1', 'option2'])); 986 | 987 | $eventFake->assertDispatched(SurveyCreated::class); 988 | } 989 | } 990 | ``` 991 | 992 | Это корректный unit-тест. 993 | Класс **SurveyService** был протестирован в полной изоляции, не касаясь среды Laravel и базы данных. 994 | Но почему это не радует меня? 995 | Причины в следующем: 996 | 997 | * Я был вынужден создать абстракцию с репозиторием исключительно для того, чтобы написать unit-тесты. 998 | Код класса **SurveyService** без него выглядит в разы читабельнее, что весьма важно. 999 | Это похоже на шаблон **Репозиторий**, но не является им. 1000 | Он просто пытается заменить операции Eloquent с базой данных. 1001 | Если объект опроса будет иметь больше отношений, то придётся реализовывать методы **save%ИмяОтношения%** для каждого из них. 1002 | * Запрещены почти все операции Eloquent. 1003 | Да, они будут работать корректно в реальном приложении, но не в unit-тестах. 1004 | Раз за разом разработчики будут спрашивать себя - "а для чего нам эти unit-тесты?" 1005 | * С другой стороны, такие unit-тесты очень сложные. 1006 | Их сложно писать и сложно читать. 1007 | Притом, что это пример один из простейших - просто создание одной сущности с отношениями. 1008 | * Каждая добавленная зависимость заставит переписывать все unit-тесты. 1009 | Это делает поддержку таких тестов весьма трудоемким занятием. 1010 | 1011 | Это сложно измерить, но мне кажется, что польза от таких тестов намного меньше усилий затрачиваемых на них и урону читабельности кода. 1012 | В начале этой главы я сказал, что Unit-тесты - одни из лучших индикаторов качества кода в проекте. 1013 | Если код сложно тестировать, скорее всего он обладает высокой связанностью. 1014 | Класс **SurveyService** точно обладает. 1015 | Он содержит основную логику (проверку количества вариантов ответа и создание сущности), а также логику приложения (транзакции базы данных, генерация событий, запросы к API и т.д.). 1016 | Это можно исправить выделив из класса эту самую основную логику. 1017 | Об этом мы поговорим в следующей главе. 1018 | 1019 | ## Стратегия тестирования приложения 1020 | 1021 | В этом разделе я не хочу говорить про большие компании, которые создают или уже имеют стратегии тестирования до того, как проект начался. 1022 | Разговор будет про мелкие проекты, которые начинают расти. 1023 | В самом начале проект тестируется членами команды, которые просто используют приложение. 1024 | Время от времени, менеджер или разработчики открывают приложение, выполняют в нём некоторые действия, проверяя корректность его работы и насколько красив его интерфейс. 1025 | Это неавтоматическое тестирование без какой-либо стратегии. 1026 | 1027 | Дальше (обычно после каких-нибудь болезненных ошибок на продакшене) команда решает что-то изменить. 1028 | 1029 | Первое, очевидное, решение - нанять ручного тестировщика. 1030 | Он будет тестировать новый функционал, а также может описать главные сценарии работы с приложением и после каждого обновления проходить по этим сценариям, проверяя, что приложение работает как требуется. Ключевые слова - smoke тесты, регрессионное тестирование. 1031 | 1032 | Если приложение продолжит расти, то количество сценариев тоже будет расти. 1033 | В то же время, команда наверняка начнет чаще выкатывать обновления и вручную проверять каждый сценарий при каждом обновлении станет невозможно. 1034 | Решение - писать автоматические тесты. 1035 | Сценарии использования, написанный ручным тестировщиком могут быть сконвертированы в автоматические тесты для Selenium или других инструментов функционального тестирования. 1036 | 1037 | С точки зрения пользователя вашего приложения, функциональное тестирование является самым важным и весьма желательно уделить ему достаточное внимание. 1038 | Тем более, что если ваше приложение - это API, то писать функциональные тесты к нему - одно удовольствие. 1039 | 1040 | А что же unit-тесты? 1041 | Да, они могут помочь нам проверить много специфических случаев, которые сложно будет покрыть функциональными тестами, но главная их задача - помогать нам писать код. 1042 | Помните пример с **cutString** в начале главы? 1043 | Вопреки распространенному мнению, писать такой код с тестами чаще быстрее, чем без них. 1044 | Тесты сразу же проверят код на правильность, проверят поведение кода в краевых случаях и в дальнейшем не позволят изменениям в коде нарушить требования к этому коду. 1045 | Написание unit-тестов должно быть простым. 1046 | Они не должны быть тяжелым камнем на шее проекта, который постоянно хочется выбросить. 1047 | В коде наверняка есть много чистых функций, и писать их с помощью тестов - весьма хорошая практика. 1048 | 1049 | Unit-тесты же для контроллеров или Слоя Приложения, как мы уже убедились ранее, писать весьма неприятно, а поддерживать - тем более. 1050 | "Форсированно интеграционные" тесты проще, но они могут быть не очень стабильны. 1051 | Если ваш проект имеет основную логику (не связанную с базой данных), которую вы очень хотите покрыть unit-тестами, чтобы, например, проверить поведение при краевых случаях, это весьма толстый намёк на то, что основная логика проекта выросла настолько, что нуждается в отделении от Слоя Приложения. 1052 | В свой собственный слой. 1053 | -------------------------------------------------------------------------------- /manuscript/Book.txt: -------------------------------------------------------------------------------- 1 | 0-intro.md 2 | 1-bad-habits.md 3 | 2-di.md 4 | 3-painless-refactoring.md 5 | 4-application-layer.md 6 | 5-error-handling.md 7 | 6-validation.md 8 | 7-events.md 9 | 8-unit-test.md 10 | 9-domain-layer.md 11 | 10-cqrs.md 12 | 11-es.md 13 | 12-end.md -------------------------------------------------------------------------------- /manuscript/images/application_layer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adelf/acwa_book_ru/fe52d2ef2347b229f0ff69a16b1e7dd0eff5cd8d/manuscript/images/application_layer.png -------------------------------------------------------------------------------- /manuscript/images/cache_module.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adelf/acwa_book_ru/fe52d2ef2347b229f0ff69a16b1e7dd0eff5cd8d/manuscript/images/cache_module.png -------------------------------------------------------------------------------- /manuscript/images/complex_logic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adelf/acwa_book_ru/fe52d2ef2347b229f0ff69a16b1e7dd0eff5cd8d/manuscript/images/complex_logic.png -------------------------------------------------------------------------------- /manuscript/images/conf1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adelf/acwa_book_ru/fe52d2ef2347b229f0ff69a16b1e7dd0eff5cd8d/manuscript/images/conf1.png -------------------------------------------------------------------------------- /manuscript/images/conf2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adelf/acwa_book_ru/fe52d2ef2347b229f0ff69a16b1e7dd0eff5cd8d/manuscript/images/conf2.png -------------------------------------------------------------------------------- /manuscript/images/coupling_cohesion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adelf/acwa_book_ru/fe52d2ef2347b229f0ff69a16b1e7dd0eff5cd8d/manuscript/images/coupling_cohesion.png -------------------------------------------------------------------------------- /manuscript/images/es_cqrs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adelf/acwa_book_ru/fe52d2ef2347b229f0ff69a16b1e7dd0eff5cd8d/manuscript/images/es_cqrs.png -------------------------------------------------------------------------------- /manuscript/images/functional_testing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adelf/acwa_book_ru/fe52d2ef2347b229f0ff69a16b1e7dd0eff5cd8d/manuscript/images/functional_testing.png -------------------------------------------------------------------------------- /manuscript/images/functional_testing2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adelf/acwa_book_ru/fe52d2ef2347b229f0ff69a16b1e7dd0eff5cd8d/manuscript/images/functional_testing2.png -------------------------------------------------------------------------------- /manuscript/images/hard_dependencies.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adelf/acwa_book_ru/fe52d2ef2347b229f0ff69a16b1e7dd0eff5cd8d/manuscript/images/hard_dependencies.png -------------------------------------------------------------------------------- /manuscript/images/integration_testing_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adelf/acwa_book_ru/fe52d2ef2347b229f0ff69a16b1e7dd0eff5cd8d/manuscript/images/integration_testing_example.png -------------------------------------------------------------------------------- /manuscript/images/master_slave.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adelf/acwa_book_ru/fe52d2ef2347b229f0ff69a16b1e7dd0eff5cd8d/manuscript/images/master_slave.png -------------------------------------------------------------------------------- /manuscript/images/master_slave_cqrs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adelf/acwa_book_ru/fe52d2ef2347b229f0ff69a16b1e7dd0eff5cd8d/manuscript/images/master_slave_cqrs.png -------------------------------------------------------------------------------- /manuscript/images/oracle_cqrs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adelf/acwa_book_ru/fe52d2ef2347b229f0ff69a16b1e7dd0eff5cd8d/manuscript/images/oracle_cqrs.png -------------------------------------------------------------------------------- /manuscript/images/orchestrator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adelf/acwa_book_ru/fe52d2ef2347b229f0ff69a16b1e7dd0eff5cd8d/manuscript/images/orchestrator.png -------------------------------------------------------------------------------- /manuscript/images/saga_events.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adelf/acwa_book_ru/fe52d2ef2347b229f0ff69a16b1e7dd0eff5cd8d/manuscript/images/saga_events.png -------------------------------------------------------------------------------- /manuscript/images/two_level_validation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adelf/acwa_book_ru/fe52d2ef2347b229f0ff69a16b1e7dd0eff5cd8d/manuscript/images/two_level_validation.png -------------------------------------------------------------------------------- /manuscript/images/typical_product.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adelf/acwa_book_ru/fe52d2ef2347b229f0ff69a16b1e7dd0eff5cd8d/manuscript/images/typical_product.png -------------------------------------------------------------------------------- /manuscript/images/unit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adelf/acwa_book_ru/fe52d2ef2347b229f0ff69a16b1e7dd0eff5cd8d/manuscript/images/unit.png -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # «Архитектура сложных веб-приложений. С примерами на Laravel». Вторая редакция. 2 | 3 | Автор: Адель Файзрахманов, автор плагина **Laravel Idea** для **PhpStorm**. 4 | 5 | [](https://laravel-idea.com/?utm_medium=book&utm_source=book_architecture&utm_campaign=link_in_description) [Laravel Idea](https://laravel-idea.com/?utm_medium=book&utm_source=book_architecture&utm_campaign=link_in_description) — расширение для платформы IDEA (PhpStorm), экономящее время при разработке решений на основе Laravel. Прекрасное автозаполнение магии Laravel, навигация по коду, генераторы кода, автокомплит валидаторов и роутов, и многое другое. © [laravel.su](https://laravel.su) 6 | 7 | Telegram канал автора - https://t.me/adelf_on_programming 8 | 9 | 0. [Предисловие](manuscript/0-intro.md) — очень короткое, но важное 10 | 1. [Плохие привычки](manuscript/1-bad-habits.md) 11 | 2. [Внедрение зависимостей](manuscript/2-di.md) 12 | 3. [Безболезненный рефакторинг](manuscript/3-painless-refactoring.md) 13 | 4. [Слой Приложения](manuscript/4-application-layer.md) 14 | 5. [Обработка ошибок](manuscript/5-error-handling.md) 15 | 6. [Валидация](manuscript/6-validation.md) 16 | 7. [События](manuscript/7-events.md) 17 | 8. [Unit-тестирование](manuscript/8-unit-test.md) 18 | 9. [Доменный Слой](manuscript/9-domain-layer.md) 19 | 10. [CQRS](manuscript/10-cqrs.md) 20 | 11. [Event Sourcing](manuscript/11-es.md) 21 | 12. [Заключение](manuscript/12-end.md) 22 | 23 | * PDF версия - https://github.com/adelf/acwa_book_ru/releases/download/2.0/acwa_rus.pdf 24 | * epub версия - https://github.com/adelf/acwa_book_ru/releases/download/2.0/acwa_rus.epub --------------------------------------------------------------------------------