Эффективную обработку исключений рассмотрим на примере абстрактного микросервиса, который будет отвечать за:
Если не обрабатывать возможные ошибки, то в любой момент может все сломаться. У такого сервиса, в объекте заказа, может отсутствовать важная информация, или может не работать связь с налоговой, что бы синхронизировать квитанцию, или, может быть недоступен сервер баз данных, или, например, в принтере закончилась бумага.
Необходимо правильно и активно реагировать на любую ситуацию, чтобы минимизировать ошибки при обработке новых заказов.
Ниже приведен пример класса 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")