Эффективную обработку исключений рассмотрим на примере абстрактного микросервиса, который будет отвечать за:
Если не обрабатывать возможные ошибки, то в любой момент может все сломаться. У такого сервиса, в объекте заказа, может отсутствовать важная информация, или может не работать связь с налоговой, что бы синхронизировать квитанцию, или, может быть недоступен сервер баз данных, или, например, в принтере закончилась бумага.
Необходимо правильно и активно реагировать на любую ситуацию, чтобы минимизировать ошибки при обработке новых заказов.
Ниже приведен пример класса OrderService
такого микросервиса, который хотя и будет работать, но он очень плохо и неэффективно спроектирован:
class OrderService: """Класс микросервиса""" def emit(self, order_id: str) -> dict: try: # Получение заказа из базы order_status = status_service.get_order_status(order_id) except Exception as e: logger.exception( f"Заказ {order_id} не найден в БД " f"Исключение: {e}." ) raise e # Прослушивание событий заказа (is_order_locked_in_emission, seconds_in_emission) = status_service.is_order_locked_in_emission(order_id) if is_order_locked_in_emission: logger.info( "Эмиссия была заблокирована после долгого ожидания!" f"Время, проведенное в этом состоянии: {seconds_in_emission} сек. " f"Заказ: {order_id}, order_status: {order_status.value}" ) elif order_status == OrderStatus.EMISSION_IN_PROGRESS: logger.info("Прерывание эмиссии, т.к. она уже выполняется!") return {"order_id": order_id, "order_status": order_status.value} elif order_status == OrderStatus.EMISSION_SUCCESSFUL: logger.info( "Прерывание эмиссии, потому что она уже произошла! " f"Заказ: {order_id}, " f"order_status: {order_status.value}" ) return {"order_id": order_id, "order_status": order_status.value} try: # Печать чека receipt_note = receipt_service.create(order_id) except Exception as e: logger.exception( "Печать чека не удалась! " f"Заказ: {order_id}, " f"Исключение: {e}" ) raise e try: # Передача чека в налоговою broker.emit_receipt_note(receipt_note) except Exception as e: logger.exception( "Передача чека не удалась! " f"Заказ: {order_id}, " f"Исключение: {e}" ) raise e order_status = status_service.get_order_status(order_id) return {"order_id": order_id, "order_status": order_status.value}
Сосредоточимся на чрезмерных действиях в классе OrderService
, которые делают его чем-то вроде антипаттерна Blob, а позже рассмотрим правильный повторный вызов исключений + правильное ведение журнала исключений.
Смотря на класс OrderService
, кажется, что он слишком много делает. Начинающие программисты могут возразить, что этот сервис делает только то, что должен делать (т. е. все шаги, связанные с генерацией квитанций), но он выполняет гораздо больше работы.
Класс OrderService
, по большому счету, фокусируется на отслеживании/создании ошибок (например, база данных, печать, статус заказа), а не на том, что он должен делать (например, получение, проверка статуса, создание, отправка) и как на все это реагировать в случае сбоев.
Если этот класс нужно будет повторно использовать (скажем, покупателю нужна еще одна копия квитанции о заказе), то придется копировать большую часть кода. Хотя OrderService
работает нормально, его сложно поддерживать, и неясно, как один шаг коррелирует с другим из-за повторяющихся блоков исключений между шагами, которые, к тому же отвлекают внимание от бизнес-логики.
Сделаем исключения более точными и конкретными. Преимущества не видны сразу, они станут понятны позже. Сейчас просто следите за развитием кода.
В примере представлено только то, что модифицируется:
try: order_status = status_service.get_order_status(order_id) except Exception as e: logger.exception(...) # конкретизируем исключение при повторном вызове raise OrderNotFound(order_id) from e ... try: ... except Exception as e: logger.exception(...) # конкретизируем исключение при повторном вызове raise ReceiptGenerationFailed(order_id) from e try: broker.emit_receipt_note(receipt_note) except Exception as e: logger.exception(...) # конкретизируем исключение при повторном вызове raise ReceiptEmissionFailed(order_id) from e
Обратите внимание на использование from e
, который является правильным способом вызова исключения из другого и сохраняет полную трассировку стека.
Чтобы "не учить" класс OrderService
тому, что может пойти не так, вынесем обработку ошибок в классы, которые отвечают за каждый конкретный шаг логики.
# Вынесем исключения из класса `OrderService` class StatusService: """Проверка статуса заказа""" def get_order_status(order_id): try: ... except Exception as e: raise OrderNotFound(order_id) from e class ReceiptService: """Печать чека заказа""" def create(order_id): try: ... except Exception as e: raise ReceiptGenerationFailed(order_id) from e class Broker: """Передача чека заказа""" def emit_receipt_note(receipt_note): try: ... except Exception as e: raise ReceiptEmissionFailed(order_id) from e
Теперь классы StatusService
, ReceiptService
и Broker
сами будут сообщать, если произойдет исключение!
class OrderService: """Класс микросервиса""" def emit(self, order_id: str) -> dict: try: order_status = status_service.get_order_status(order_id) (is_order_locked_in_emission, seconds_in_emission) = status_service.is_order_locked_in_emission(order_id) if is_order_locked_in_emission: logger.info( "Эмиссия была заблокирована после долгого ожидания!" f"Время, проведенное в этом состоянии: {seconds_in_emission} сек. " f"Заказ: {order_id}, order_status: {order_status.value}" ) elif order_status == OrderStatus.EMISSION_IN_PROGRESS: logger.info("Прерывание эмиссии, т.к. она уже выполняется!") return {"order_id": order_id, "order_status": order_status.value} elif order_status == OrderStatus.EMISSION_SUCCESSFUL: logger.info( "Прерывание эмиссии, потому что она уже произошла! " f"Заказ: {order_id}, " f"order_status: {order_status.value}" ) return {"order_id": order_id, "order_status": order_status.value} receipt_note = receipt_service.create(order_id) broker.emit_receipt_note(receipt_note) order_status = status_service.get_order_status(order_id) # теперь классы сами сообщают об исключениях except OrderNotFound as e: logger.exception( f"Заказ {order_id} не найден в БД " f"Исключение: {e}." ) raise except ReceiptGenerationFailed as e: logger.exception( "Печать чека не удалась! " f"Заказ: {order_id}, " f"Исключение: {e}" ) raise except ReceiptEmissionFailed as e: logger.exception( "Передача чека не удалась! " f"Заказ: {order_id}, " f"Исключение: {e}" ) raise else: return {"order_id": order_id, "order_status": order_status.value}
Теперь есть один блок try/except
, в котором располагается вся бизнес-логика. Сгруппированы блоки except
с конкретными исключениями, которые дают понимание ситуации "когда и что произойдет" , и, наконец, есть блок else
, описывающий, что произойдет, если все пройдет успешно.
Обратите внимание, что сохранена инструкция raise
без повторного объявления объекта исключения e
. Это не опечатка, а правильный способ повторного вызова текущего исключения.
Вместо того, чтобы выводить пользовательское сообщение в блоках исключений, исключения должны делать это сами - в конце концов, они уже конкретные. Это напоминает принцип Tell-Don’t-Ask
, который помогает помнить, что объектно-ориентированное программирование предназначено для связки данных и функций для их обработки.
### Создаем классы исключений class OrderCreationException(Exception): pass class OrderNotFound(OrderCreationException): def __init__(self, order_id): self.order_id = order_id super().__init__(f"Заказ {order_id} не найден в БД.") class ReceiptGenerationFailed(OrderCreationException): def __init__(self, order_id): self.order_id = order_id super().__init__(f"Печать чека не удалась! Заказ: {order_id} ") class ReceiptEmissionFailed(OrderCreationException): def __init__(self, order_id): self.order_id = order_id super().__init__(f"Передача чека не удалась! Заказ: {order_id} ") class OrderService: """Класс микросервиса""" def emit(self, order_id: str) -> dict: try: ... except OrderNotFound: logger.exception("Ошибка базы данных") raise except ReceiptGenerationFailed: logger.exception("Проблема с генерацией квитанции") raise except ReceiptEmissionFailed: logger.exception("Проблема с отправкой квитанции") raise else: return {"order_id": order_id, "order_status": order_status.value}
Обратите внимание, что рекомендуемый способ логирования исключений, точно такой же, как показано выше: logger.exception('ЛЮБОЕ СООБЩЕНИЕ')
. Не нужно больше передавать объект исключения e
, так как он передается неявно. Кроме того, пользовательское сообщение, которое определяется внутри каждого класса исключения с помощью order_id
, также будет отображаться в журналах, следовательно выполнено требование Don’t repeat yourself.
Теперь, всякий раз, когда вызывается исключение, пользовательское сообщение будет установлено автоматически, и не нужно думать о регистрации идентификатора order_id
, который его сгенерировал.
Пример вывода сообщения об исключении:
Невозможно отправить квитанцию Traceback (most recent call last): File "/path/test.py", line 19, in <module> tryme() File "/path/test.py", line 14, in tryme raise ReceiptEmissionFailed(order_id) ReceiptEmissionFailed: Передача чека не удалась! Заказ: 10
Класс OrderService
больше похож на координацию вызовов реальных сервисов бизнес-логики, что лучше подходит в качестве шаблона проектирования Facade
, который позволяет скрыть сложность системы путём сведения всех возможных внешних вызовов к одному объекту, делегирующему их соответствующим объектам системы.
Кроме того, можно заметить, что он запрашивает у status_service
данные, чтобы что-то с ними делать, что ломает принцип Tell-Don’t-Ask
.
Перейдем к упрощению:
# Переименовано, для соответствия тому, что есть на самом деле class OrderFacade: def emit(self, order_id: str) -> dict: try: # ПРИМЕЧАНИЕ: регистрация информации по-прежнему происходит внутри status_service.ensure_order_unlocked(order_id) receipt_note = receipt_service.create(order_id) broker.emit_receipt_note(receipt_note) order_status = status_service.get_order_status(order_id) except OrderAlreadyInProgress as e: # Новый блок logger.info("Прерывание запроса на эмиссию, т.к. он уже выполняется!") return {"order_id": order_id, "order_status": e.order_status.value} except OrderAlreadyEmitted as e: # Новый блок logger.info(f"Прерывание эмиссии, т.к. это уже произошло! {e}") return {"order_id": order_id, "order_status": e.order_status.value} except OrderNotFound: logger.exception("Ошибка базы данных") raise except ReceiptGenerationFailed: logger.exception("Проблема с генерацией квитанции") raise except ReceiptEmissionFailed: logger.exception("Проблема с отправкой квитанции") raise else: return {"order_id": order_id, "order_status": order_status.value}
В примере выше, код, отвечающий за прослушивание событий заказа был вынесен в отдельный метод .sure_order_unlocked()
, принадлежащей классу StatusService
, который теперь отвечает за генерацию/регистрацию исключений.
Теперь видно что происходит, когда все идет хорошо, и как крайние случаи могут привести к разным результатам.
Обратите внимание, что для блоков исключений OrderAlreadyInProgress
и OrderAlreadyEmitted
в журнале выводится объект исключения e
. В данном случае, это сделано потому, что в них не используется уровень журналирования log.exception
, следовательно, сообщение об исключении не появятся.
Всегда классифицируйте пользовательские исключения с помощью базового класса и расширяйте все конкретные исключения из него. Это во-первых полезно, а во вторых можно повторно использовать логику для связанного кода.
Исключения - это объекты, которые несут с собой информацию. Не стесняйтесь добавлять пользовательские атрибуты, которые могут помочь понять, что происходит. Не позволяйте бизнес-коду теряться в большом количестве блоков try/except
и сообщений.
# базовой исключение для категории class OrderCreationException(Exception): pass # Конкретная ошибка с пользовательским сообщением. class OrderNotFound(OrderCreationException): def __init__(self, order_id): self.order_id = order_id # custom property super().__init__( f"Заказ №{order_id} в БД не найден" )
Еще одна вещь, которую люди часто делают неправильно - это захват и создание исключений.
try: ... except CustomException as ex: # делаем что-то (например, ведение журнала) raise
Это особенно актуально, так как сохраняет всю трассировку стека и помогает команде делать отладку.
try: ... except CustomException as ex: raise MyNewException() from ex
Еще один совет, который убережет от лишнего кода - это использование метода Logger.exception()
.
Не нужно регистрировать объект исключения. Метод Logger.exception()
предназначен для использования "как есть" внутри блоков except
. Он уже обрабатывает трассировку стека с информацией о выполнении и отображает, какое исключение его вызвало, с его сообщением, установленным на уровень ERROR
!
try: ... except CustomException: logger.exception("custom message")