Привет! <br/> <br/> В большинстве мануалов утверждается, что контроллер должен быть компактным и выполнять лишь три основные функции: <br/> - принимать запрос <br/> - передавать данные классу-обработчику (или сервисному классу, как вы предпочтёте) <br/> - возвращать ответ <br/> <br/> Я предлагаю отвлечься от конкретных фреймворков и рассмотреть, каким образом наиболее эффективно передавать данные из контроллера в класс-обработчик. Для начала предлагаю создать идеальный контроллер для распространённой задачи: обновление имени и местоположения пользователя. Вот два варианта для обсуждения и оценки: <br/> <br/> <pre><code class="php">public function update(Request $request, Response $response, array $args): Response {
$payload = $request->getParsedBody() ?? [];
// validate $payload HERE
// find user, method return DTO object
$user = $this->userManager->getUserById((int)$args['id']);
if (isset($payload['name'])) {
$user->setName($payload['name']);
}
if (isset($payload['location'])) {
$user->setLocation($payload['location']);
}
$this->userManager->updateUser($user);
return $response->withStatus(204);
}</code></pre> <br/> <br/> <pre><code class="php">public function update(Request $request, Response $response, array $args): Response {
$payload = $request->getParsedBody() ?? [];
// validate $payload HERE
// find user, method return DTO object or throw exception
$user = $this->userManager->getUserById((int)$args['id']);
$this->userManager->updateUser($user, $payload['name'], $payload['location']);
return $response->withStatus(204);
}</code></pre> <br/> <br/> Можете прокомментировать второй вариант?
Оба предложенных вами варианта контроллера для обновления имени и местоположения пользователя имеют свои достоинства и недостатки. Рассмотрим второй вариант, который вы предложили, и проанализируем его характеристики.
### Второй вариант контроллера:
```php
public function update(Request $request, Response $response, array $args): Response {
$payload = $request->getParsedBody() ?? [];
// validate $payload HERE
// find user, method return DTO object or throw exception
$user = $this->userManager->getUserById((int)$args['id']);
$this->userManager->updateUser($user, $payload['name'], $payload['location']);
return $response->withStatus(204);
}
```
### Комментарий:
1. **Адаптация под бизнес-логику**: В данном варианте контроллер передает данные напрямую в метод обновления пользователя, что позволяет избежать дополнительных вызовов методов для задания параметров. Это уменьшает количество строк кода и может сделать код чище и понятнее.
2. **Упрощение объекта пользователя**: Пользовательские данные (имя и местоположение) передаются как параметры в метод `updateUser()`, что может существенно упростить логику изменения, если у вас есть контроллер, который должен работать с этими полями отдельно.
3. **Переход к более чистому коду**: Метод `updateUser()` инкапсулирует всю логику обновления, что может способствовать повышению ее реализации в будущем. Обработчик не обязан знать, как именно обновляются эти данные, и может сосредоточиться на своем основном предназначении.
4. **Проверка валидности данных**: Важно отметить, что вы комментируете место, где должна быть написана валидация `payload`. Это критически важный шаг, который нужно учитывать (например, проверка на пустое значение, формат имени и местоположения), чтобы избежать ошибок с невалидными данными.
5. **Отсутствие спагетти-кода**: Конструкция, в которой используете DTO, сохраняет чистоту и разделение ответственности. Это уменьшает вероятность добавления "спагетти-кода" в контроллер.
### Недостатки:
1. **Проблемы с параметрами**: Вам придется убедиться, что параметры `name` и `location` существуют в теле запроса, прежде чем передавать их в `updateUser()`, или обработать ситуацию, когда они отсутствуют.
2. **Неконтролируемость обновления**: В этом варианте, если вы хотите обновить только одно из полей (например, только имя), вам все равно придется передавать оба параметра.
3. **Сложности с дополнительными параметрами**: Если в будущем вам нужно будет добавить больше параметров в метод `updateUser()`, то сигнатура этого метода может стать слишком сложной и накладной.
### Заключение
Ваш второй вариант контроллера демонстрирует хороший баланс между простотой и ясностью, оставляя бизнес-логику внутри менеджера пользователей. Это делает контроллер более искусным в плане разделения ответственностей.
Для улучшения этого варианта можно рассмотреть следующий момент: если параметры могут отсутствовать, можно использовать метод, который будет принимать (или игнорировать) только те параметры, которые действительно переданы, или использовать DTO-объекты для передачи данных более структурированным образом. Это даст больше гибкости в будущем.
Если хотите идеал, то он должен соответствовать следующим пунктам: <br/> <br/> 1. Сериализация/десериализация - это дорогостоящее мероприятие, поэтому оно должно делаться только в двух местах: прямо на входе и прямо на выходе. Вход - это ваш контроллер, Выход - это другой сервис, куда вы передаёте данные, или база данных (тут тоже происходит сериализация, либо явно, либо в ORM). Во всех остальных слоях инфообмен должен совершаться уже при помощи объектов PHP либо нативных типов. Это экономит ресурсы. При передаче между слоями приложения объектов вместо значений либо ассоциативных массивов вы сразу будете видеть очепятки, IDE вам прекрасно поможет при помощи автодополнения, объекты могут иметь какие-то полезные методы. <br/> <br/> 2. Очень желательно в каждом из слоёв иметь собственный класс, отвечающий за данные. Например, нам в слой API приходит JSON-чик с новым пользователем. <br/> - Сериализуем JSON в DTO UserInAPI, сразу валидируем всё то, что мы можем валидировать без слоя бизнес-логики, и либо отдаём клиенту ошибку, либо передаём сам объект UserInAPI в следующий слой: слой бизнес-логики <br/> - В слое бизнес логики, получаем DTO UserInAPI на входе, преобразуем его в свой бизнес-объект UserInBusiness, валидируем его уже с точки зрения бизнеса, и либо возвращаем ошибку в слой API, либо совершаем над ним действия, и передаём объект класса UserInBusiness в слой работы с базой <br/> - В слое работы с базой данных получаем на входе объект UserInBusiness, преобразуем его уже в сущность базы данных UserInDB, валидируем всё на предмет корректности данных для базы, и либо возвращаем ошибку в бизнес, либо сохраняем сущность класса UserInDB в базу. <br/> <br/> Зачем такие сложности, вы спросите? А просто обратите внимание на то, что скорость изменения кода в разных слоях разная. <br/> - API вообще должен меняться раз в сто лет, чтобы не злить клиентов. Поэтому DTO класс UserInAPI будет стабильным и редко будет меняться. <br/> - Бизнес-логика меняется очень часто. У класса UserInBusiness постоянно будут добавляться поля и методы, тут жизнь будет кипеть. <br/> - Слой базы данных будет меняться реже, чем слой бизнеса, но чаще, чем слой API, потому что нам нужны будут новые поля в базе, новые таблицы и связанные таблицы. <br/> - И если мы один тип сущности протащим во все слои, то эта сущность обрастёт таким количеством различной хрени, что нам плохо станет, когда будем на неё смотреть. Либо она обрастёт кучей декораторов в каждом из слоёв. Поэтому лучше всё разделить. <br/> <br/> 3. Теперь внимание, казалось бы, что мы слишком сильно связываем наши слои, и нижестоящие слои знают что-то о вышестоящих, а это неправильно. Ведь мы передаём объект UserInAPI в слой бизнеса, т.е. слой бизнеса должен уметь работать с этим объектом. И так же слой базы должен уметь работать с объектом бизнеса UserInBusiness. Как же быть? А очень просто. На входе слоёв использовать интерфейсы. Т.е. в слое бизнеса мы будем принимать не сам класс UserInAPI, а объект, имплементирующий интерфейс UserIncoming, который объявим в бизнес слое, и заставим слой API сделать так, чтобы его класс UserInAPI имплементировал этот интерфейс. Таким образом слой бизнеса ничего не будет знать о слое API, а будет ждать на входе данные по контракту, описанному в интерфейсе. Бизнесу плевать на конкретную реализацию, ему нужны только методы getUsername, getEmail из интерфейса. А какой класс ему их предоставит - пофигу. Таким образом мы практически полностью разделяем слои и в два счёта сможем поменять слой API, где у нас HTTP контроллеры, на слой RabbitMQ, SOAP, Grpc и т.д.