Исключения и контроль ошибок

Подходы к контролю исключительных ситуаций. Использование исключений и корректный ох отлов.

Скрытый контент
Комментарии (37)
voodooism

Было бы хорошо если бы к видео отдельно прикладывались слайды

Ответить
Александр

значит ли что в MVC в большинстве случаях, trow (выброс) создается в моделях, отлавливается в контроллере, контроллер формирует нужную строку или массив или объект, и передает в вид, ну или возвращает аякс запросу объект, для последующего вывода в удобном формате на фронте ?

А если в самом контроллере(или там в скрипте для процедурного стиля) создавать trow , то как правильно его в этом же контроллере обработать через try-catch , и вобще правильный ли такой подход все в одном файле ?

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

В общем случае throw кидается всегда чем-то внутренним, а вверху либо отлавливается в контроллере или посреднике (чтобы сформировать красивый ответ с ошибкой), либо перехватывается самим фреймворком и пишется в логи, выводя ответ 500 Server Error.

Ответить
Александр

Дмитрий, спасибо вам! Если вас не затруднит, не могли бы ли вы рассказать про логирование?

Ответить
Артем

Понравилось "выкинуть на улицу")

Ответить
S.Polessky

У меня было такое один раз.

Ответить
Ruslan

Кадр 44:44 акшен в контролере . Мы так во флэш выбросим сообщение. А что будет с рендером? - Вывод об ошибке на всю страницу и потеря всех заполненных данных? Чтоб этого не было должны ли мы передать наш шаблон с переменной $form ?

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

После флэша экшен не прерывается и выводится тот же $this->render со всеми данными формы. И над формой выводим блок с флэш-сообщением.

Ответить
Ruslan

Спасибо. Видно затмение в мозгу, решил, что после catch код исполняться не будет.

Ответить
Джамал

Здравствуйте, такой вопрос. Часто в API проектах наблюдаю такой класс ClientException и в нем есть метод render в котором написан код который отдает ошибку в виде json. Является ли это хорошей практикой ? Если нет, то лучше ловить ClientException и в блоке catch отдавать ответ в виде json?

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

Это не всегда удобно.

Если нужно выводить ошибку как-нибудь по-своему, то тогда встроенный метод render не подойдёт.

Ответить
Джамал

Спасибо!

Ответить
S.Polessky

Есть ли правила, когда нужно создавать свой уникальный класс Exception, а не использовать встроенные вроде DomainException?

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

Ответить
S.Polessky

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

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

Было исключение:

throw new \DomainException("Недостаточно средств на балансе.")

Появилась бизнес-задача - показать пользователю, что он может пополнить баланс. Можно дополнить исключение так:

throw new \DomainException("Недостаточно средств на балансе. " . Html::a("Пополнить баланс?", ['/deposit/add']));

..но это ухудшает читабельность, и создаёт избыточность из-за добавление класса Html:

Поэтому, вводим кастомное:

throw new NotEnoughBalanceException("Недостаточно средств на балансе.")

...а в Controller'е можем уже добавить ссылку на пополнение баланса

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

В идеале лучше делать отдельные классы для каждого исключения. Но на практике это имеет смысл:

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

Когда в нашем коде разные ошибки нужно обрабатывать по-разному в разных catch. Например, если метод может выбрасывать NotEnoughBalanceException и NotConfirmedPhoneException, по которым нам нужно выше делать разные вещи.

Когда исключение может содержать дополнительные поля или методы для формирования сообщения или логирования вроде new NotEnoughException($balance) или NotEnoughException::fromBalance($balance) с последующим использованием выше метода $exception->getBalance().

Ответить
S.Polessky

"RuntimeException - для инфраструктурных проблем "

А можно несколько примеров таких проблем? Если, допустим, нужен расширение intl, а его нет - это RuntimeException, так?

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

Да, проблемы отсутствия расширений, ошибок сети, подключения к БД,

Ответить
S.Polessky

Вот и нашел пример для LogicException. Не знаю корректен ли он, надеюсь Дмитрий даст свою оценку.

Кейс: В магазине была возможность заказа только без регистрации. Ввели регистрацию, за ней функцию - привязать свои гостевые заказы по e-mail.

public function assignUser($userId)
{
    if ($this->user_id) {
        throw new \LogicException("User already assigned");
    }

    $this->user_id = $userId;
}

Почему LogicException: Документация нам гласит: "Исключение, которое представляет ошибку в логике программы. Такой тип исключений должен непосредственно привести к исправлениям в вашем коде". В коде больше не осталось мест, где пользователь мог бы напрямую "дергнуть" привязку - например, указав orderID в форме.

Значит ошибка может быть только в коде, например - мы забыли в QueryBuilder прописать условие не получать уже привязанные заказы:

Order::find()->where(['email' => $email])->all()

// Выведет все заказы, в т.ч. уже привязанные. Для исправления добавить: ->andWhere(['user_id' => null])

P.S. Ошибся веткой комментариев. Этот комментарий для комментария ниже :)

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

Для 'User already assigned' мы у себя чаще используем DomainException, так как это ошибка бизнес-логики, а не просто логики.

Ответить
S.Polessky

Поделюсь своим кейсом, где применил это исключение:

PayPal стал плохо обрабатывать верификацию запросов: вместо VERIFIED, INVALID порой стал возвращать "Fatal Failure". Повторные попытки - решали проблему.

Но т.к. время выполнения PHP ограничено, а число рекурсий неизвестно - ввёл лимит. А для исключения - RuntimeException.

Сниппет:

public function validate(array $data, $retryCount = 0)
{
    $result = $this->sendDataToValidator($data);

    if (($result === "VERIFIED" || $result === "INVALID")) {
        return $result === "VERIFIED";
    }

    if ($retryCount === self::RETRY_QUERY_QUANTITY) {
        throw new \RuntimeException("Can not resolve PayPal. Reached limit of retries");
    }

    return $this->validate($data, ++$retryCount);
}

P.S. Осталось найти примеры для LogicException :)

Ответить
Владимир Перепеченко

Дмитрий, как всегда, интересно/полезно.

Жаль что не раскрыта тема PHP ошибок (Errors, а не Exceptions). Они (Errors) всегда есть и их надо логгировать и выводить сообщения на критичных. Но есть ньюансы:

  • какие то ошибки ошибки прерывают скрипт, какие то нет
  • если по каждой ошибке бросать ErrorExcepttion который зачем-то существует в PHP, то вызванное исключение всегда прервёт исполнение программы
  • в примерах обработок Exceptions в Ваших скринкастах нет примеров обработки Errors к миддлеверах (того же Slim). Только обработка исключений.

Для меня это дело остаётся ещё темным, особенно - как обрабатывать Errors в Slim middleware. Просьба хотя бы намекнуть.

Ответить
Владимир Перепеченко

Дело осложняется тем, что некоторые Error при крахе не выкидываются и чтобы их залоггировать приходится вытаскивать их через функции:

register_shutdown_function()
error_get_last()

или это всё надуманные проблемы у меня в голове?

Ответить
Владимир Перепеченко

Честно говоря, я плохо понимаю связь между PHP ошибками, которые я пытаюсь отловить через set_error_handler(), и объектом Error https://www.php.net/manual/ru/class.error.php

Возможно, что этой связи нет вовсе. Тут теряюсь, ибо моя задача логгировать все ошибки PHP + исключения, не отловленные через try/catch. При этом, чтобы безобидные ошибки (notice, warning) только логгировать, а не бросать для них ErrorException, который прервет работу программы.

Как реализовать это надежно - пока непонятно. Подкинете идей, Дмитрий?

Ответить
Владимир Перепеченко

Чуть прояснилось в голове после прочтения Ошибки в PHP 7

Но все равно есть туман: - остались ли в PHP 8 PHP ошибки или они все уже заменены на Errors imlements Throuble - если остались, то какие из них фатальные, что прерывают программу и не отлавливаются через set_error_handler() и как их всё таки залоггировать

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

Постепенно в PHP многие старые ошибки переделывают на отлавливаемые по Throwable исключения. Так что прогресс в этом есть.

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

При этом, чтобы безобидные ошибки (notice, warning) только логгировать, а не бросать для них ErrorException, который прервет работу программы.

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

Ответить
Владимир Перепеченко

>когда абсолютно всё прерывает программу.

то есть рекомендуете останавливать продакшен из за: E_WARNING E_DEPRECATED E_NOTICE

у меня куча legacy лапшекода - продакшен сразу ляжет :)

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

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

В легаси тоже постепенно исправить. Много недочётов и все deprecated находит Psalm.

Ответить
Владимир Перепеченко

Понял, thanks!

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

моя задача логгировать все ошибки PHP + исключения, не отловленные через try/catch

Как-то отдельно их ловить и логировать необязательно. Если что-то фатальное не поймалось блоком try-catch в приложении, то это залогирует у себя сам PHP-FPM. Просто смотрите и его логи.

Ответить
Владимир Перепеченко

Благодарю за пояснения, Дмитрий. Стало больше определённости.

Ответить
Tema

Спасибо Дмитрий становится более понятно.

Есть впечатление что DTO появилось из за рефакторинга если в функции или методе более 2 параметров и если они логически связаны например user то их можно вынести в interface, class и использовать вместо 3 и более параметров

class CreateUserDto
{
    public string $name;
    public string $email;
    public string $password;
}

class UserController {
public function create($createUserDto CreateUserDto) {
    return $createUserDto;
}

$user = new UserController();
$user->create('name', 'email', 'pass');

@Controller('users')
export class UsersController {
  public constructor(private readonly usersService: UsersService) {}

  @Post()
  public async create(@Body() createUserDto: CreateUserDto) {
    return await this.usersService.create(createUserDto);
  }

import { IsEmail, IsNotEmpty } from 'class-validator';

export class CreateUserDto{
 @IsNotEmpty()
  name: string;

  @IsEmail()
  email: string;

  @IsNotEmpty()
  password: string;
}
Ответить
Дмитрий Елисеев

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

Ответить
Tema

Дмитрий если у usera email неверный то какую ошибку лучше выбрать? BadRequestException?

  • BadRequestException
  • UnauthorizedException
  • NotFoundException
  • ForbiddenException
  • NotAcceptableException
  • RequestTimeoutException
  • ConflictException
  • GoneException
  • HttpVersionNotSupportedException
  • PayloadTooLargeException
  • UnsupportedMediaTypeException
  • UnprocessableEntityException
  • InternalServerErrorException
  • NotImplementedException
  • ImATeapotException
  • MethodNotAllowedException
  • BadGatewayException
  • ServiceUnavailableException
  • GatewayTimeoutException
  • PreconditionFailedException

Не совсем ещё понял что можно делать внутри сервиса( Вроде удобно сервис внутри сервиса использовать я не знаю плохая это практика или нет( И не слишком ли избыточно делать проверку и хешировать пароль так?

import { BadRequestException, Injectable } from '@nestjs/common';
import { UsersService } from './../users/users.service';
import { CreateAuthDto } from './dto/create-auth.dto';
import { UpdateAuthDto } from './dto/update-auth.dto';
import * as bcrypt from 'bcrypt';
import { User } from '@prisma/client';

@Injectable()
export class AuthService {
  private user: User;

  public constructor(private readonly usersService: UsersService) {}

  public async register(
    createAuthDto: CreateAuthDto,
  ): Promise<User | BadRequestException> {
    this.user = await this.usersService.findOne({ email: createAuthDto.email });

    if (this.user && this.user.email === createAuthDto.email) {
      throw new BadRequestException('Choose another email');
    }

    createAuthDto.password = await bcrypt.hash(createAuthDto.password, 12);

    return await this.usersService.create(createAuthDto);
  }
}
{
    "statusCode": 400,
    "message": "Choose another email",
    "error": "Bad Request"
}
Ответить
Дмитрий Елисеев

Дмитрий если у usera email неверный то какую ошибку лучше выбрать? BadRequestException?

У меня ошибки валидации возвращаются с HTTP-статусом 422 UnprocessableEntity, а внутри сервиса и сущности доменные ошибки DomainException перехватываются и преобразуются в статус 409 Conflict.

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

Вроде удобно сервис внутри сервиса использовать я не знаю плохая это практика или нет(

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

И не слишком ли избыточно делать проверку и хешировать пароль так?

Похожее мы делаем в эпизоде о регистрации.

Ответить
Tema

Спасибо вам большое Дмитрий

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

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

Google
GitHub
Yandex
MailRu