Общие соображения
Официального руководства по обработке ошибок в Kotlin не существует, но есть пост Романа Елизарова (бывшего ведущего дизайнера Kotlin), который можно считать полуофициальным руководством. В этом посте Елизаров рекомендует использовать исключения только для логических ошибок (тех, что программисты должны исправить в коде) и ошибок ввода-вывода (тех, что девопсы должны исправить в инфраструктуре или конфигурации). А для всего остального использовать функциональный (ака типизированный) подход к обработке ошибок.
Но в отличие от "прирождённых" функциональных языков с типами (Haskell, Scala, F#), Kotlin-у не хватает целого ряда инструментов для эффективной работы в таком стиле:
лаконичного синтаксиса для типов-сумм;
встроенной монады для ошибок;
синтаксической поддержки работы с монадами.
В результате работа с ошибками в функциональном стиле на Kotlin-е становится довольно громоздкой.
Кроме того функциональный стиль работы с ошибками не интегрируется с основными и даже нативными для Kotlin фреймворками и библиотеками. В частности ни Spring, ни Exposed не откатят транзакцию при возврате стандартного Result.failure из транзакционного метода/блока, не говоря уж о кастомном ADT. Как и в корутинах возврат Result.failure из корутины-потомка не приведёт к автоматическому останову корутин-сиблингов.
Наконец, в Clojure (функциональном языке) выброс исключения является идиоматичным способом обработки ошибок, а Скот Влашин (автор термина ROP и один из активных пропагандистов функциональной архитектуры) рекомендует исключения для быстрого завершения операции.
Поэтому Эргономичный подход вводит собственную классификацию ошибок - восстановимые и невосстановимые. Работа с восстановимыми ошибками выполняется в функциональном стиле, а с невосстановимыми - через исключения.
Такой подход снижает наглядность кода, в части возможных ошибочных исходов операций системы, однако этот недостаток можно частично нивелировать явным маппингом ожидаемых неостановимых ошибок на ответы в точках входа в приложение.
См. также:
Восстановимые и невосстановимые ошибки
Ошибки делятся на два вида - восстановимые и невосстановимые.
Ошибка является невосстановимой, если её возникновение обязательно должно повлечь отказ операции (в виде отката транзакции, возврата не 2хх HTTP кода статуса и т.д.), а обработка сводится к логгированию и/или отображению конечному пользователю (возможно после преобразования).
Невосстановимые ошибки отлавливаются максимально близко к началу стектрейса - в инфраструктуре (глобальный ExceptionHandler
в Spring MVC) или в точке входа в приложение (Controller
или всевозможные *Listener
в Spring) - и обрабатываются посредством логгирования и преобразования к формату, который может быть выведен конечному пользователю.
Восстановимые ошибки отлавливаются в том месте, где сразу же могут быть обработаны (а не в месте, где они где они выявлены), тут же обрабатываются и исполнение операции продолжается в штатном режиме.
Ожидаемые невосстановимые ошибки
Для ожидаемых невосстановимых ошибок заводится, базовый класс, наследующийся от RuntimeException
, например, DomainException
.
В этот класс добавляются поля, необходимые для отладки и корректного отображения пользователю, такие как:
Внутреннее сообщение об ошибке;
Код ошибки;
Сообщение для пользователя или ключ сообщения в файлах локализации;
Кроме, того как правило имеет смысл завести классы для стандартных часто встречаемых ошибок, таких как:
Доменная ошибка
ResourceNotFoundException
- строка в БД не найдена по идентификатору;Доменная
AlreadyExistsException
- нарушение уникальности естественного ключа в БД;Системная ошибка
ExternalSystemUnavailable
- любые ожидаемые проблемы при работе с внешними системами;
Обработка невосстановимых ошибок в Spring MVC-приложениях (HTTP/JSON API)
Неожиданные ошибки
Обработка неожиданных ошибок выполняется в глобальном по обработчике по следующим правилам:
Стандартные доменные ошибки - логгируются с уровнем warn и мапятся на стандартное тело ошибочного ответа со статусом 409 (см. Правила выбора статуса ответа);
Системная ошибка
ExternalSystemUnavailable
- логгируются с уровнем error и мапятся на стандартное тело ответа со статусом 502;Прочие доменные ошибки - логгируются с уровнем error и мапятся на стандартное тело ответа со статусом 500;
Все наследники
Exception
- логгируются с уровнем error и мапятся на стандартное тело ответа со статусом 500;Ошибки JVM (наследники
Error
) - не перехватываются и их обработка отдаётся на откуп фреймворку приложения.
Ожидаемые ошибки
Ожидаемые ошибки обрабатываются либо прямо в методе Spring Controller-а, либо в методах-обработчиках (с аннотацией ExceptionHandler
) в том же контроллере.
В случае обработки через ExceptionHandler
, необходимо заводить по отдельному классу-контроллеру на каждый эндпоинт, для того чтобы обеспечить наглядность всех возможных ожидаемых ошибок каждого эндпоинта.
fun handleReserveRoom(@Valid @RequestBody request: RoomReservationRequest): ResponseEntity<*> {
val res: Result<ReservationSuccess> = runCatching { reserveRoom((request)) }
return when (val v = res.unwrap()) {
is ReservationSuccess -> created(v)
is ReservationDatesInPastException -> conflictOf(v)
is EntityNotFoundException -> conflictOf(v)
is NoAvailableRoomsException -> conflictOf(v)
else -> throw (v as Throwable)
}
}
Типовые примеры
Проверка инвариантов параметров
Для контроля инвариантов аргументов, которые невозможно (а зачастую - просто неохота) выразить через типы, используются функции require
и requireNotNull
.
Возьмём в качестве примера класс сущности, описывающей некоторое событие, длительность которого по бизнес-правилам не должна превышать 24 часа.
Т.к. экземпляры этого класса создаются на основе данных из БД, а данные в БД технически могут быть модифицированы в обход приложения и его валидаций, то технически в системе может появиться объект, который нарушает инвариант длительности события. Для предотвращения появления таких объектов, можно в блок инициализации класса события добавить проверку:
data class Appointment(
val duration: Duration
) {
init {
require(duration.toHours() <= 24) { "Appointment duration must be less or equal than 24 hours" }
}
}
Проверка инвариантов состояния объекта
Для контроля инвариантов состояния объекта, которые невозможно (а зачастую - просто неохота) выразить через типы или API, используются функции check
и checkNotNull
.
Возьмём в качестве примера функцию аутентификации приложения во внешней системе, которая должна вернуть токен с определённой ролью, для того чтобы дальнейшем приложение могло использовать его для выполнения требуемых запросов.
И при том, что запрос аутентификации может быть выполнен успешно, однако возвращённый токен может не иметь требуемой роли. В этом случае исходя из принципа fail fast и для упрощения отладки подобных проблем стоит сразу же проверить инвариант, что токен имеет требуемую роль:
fun login(login: String, pass: String): Token {
val token = httpClient.post("/auth").body(LoginRq(login, pass))
check(token != null && token.hasRole("ADMIN"))
return value
}
Неожиданная невосстановимая ошибка в библиотечном коде
В JVM и особенно в методах, выполняющих ввод-вывод может в любой момент вылететь неожиданная и как следствие невосстановимая ошибка - от NullPointerException, через IOException и до OutOfMemoryError. Пытаться предвосхитить все возможные ошибки и обрабатывать их в каждом листовом методе прикладного кода бессмысленно.
Поэтому при вызове библиотечного кода, если у вас нет конкретного плана как восстановиться после конкретной ошибки, потенциальные ошибки вызова никак не обрабатываются - их обработка отдаётся на откуп глобального обработчика ошибок:
fun findUserById(userId: Long) {
// В этом вызове может вылететь любая из приведённых выше ошибок и множество других
return usersRepo.findById(userId)
}
Перехват ожидаемой невосстановимой ошибки в библиотечном методе
В случае если в коде приложения вызывается библиотечный метод, который может выбросить ожидаемую невосстановимую ошибку, которую необходимо замапить на доменную или стандартную ошибку - такая ошибка обрабатывается блоком try-catch.
fun getUser(userId: Long): User {
return try { externalSystem.getUser(userId) }
catch (e: HttpClientErrorException.NotFound) {
throw ResourceNotFoundException(e)
}
При том блок try
должен содержать в себе только один вызов, а если требуется обработка результата - она выполняется вне его:
fun getUserRoles(userId: Long): List<Roles> {
val user = try { externalSystem.getUser(userId) }
catch (e: HttpClientErrorException.NotFound) {
throw ResourceNotFoundException(e)
}
return user.roles
В случае, если и успешный ответ метода может привести к невосстановимой ошибке - лучше воспользоваться блоками runCatching
и when
и вспомогательной функцией value
для консистентного разбора всех возможных исходов:
fun Result<*>.value(): Any? =
if (this.isSuccess) this.getOrThrow()
else this.exceptionOrNull()!!
fun getUserRoles(userId: Long): List<Roles> {
val userResult = runCatching { externalSystem.getUser(userId) }
return when (val value = userResult.value())) {
is User -> value.roles
null -> throw ResourceNotFoundException()
is IOException -> throw ExternalSystemUnavailable(value)
else -> throw (value as Throwable)
}
}
При том следует помнить, что runCatching
перехватывает все исключения, включая наследников Error - фатальных сбоев виртуальной машины, которые не следует перехватывать.
Поэтому либо ветка else
должна перебрасывать исключение, либо в блоке when
должна быть отдельная ветка для переброса Error
-ов.
Восстановимая ошибка в библиотечном коде
Для обработки восстановимой ошибки в библиотечном коде используется блок runCatching
и утилита recover<T, E>
:
inline fun <T, reified E : Throwable> Result<T>.recover(body: (E) -> T): Result<T> =
when (val ex = exceptionOrNull()) {
is E -> success(body(ex))
else -> this
}
fun getUserRoles(userId: Long): List<Roles> {
val userResult = runCatching { externalSystem.getUser(userId) }
return userResult
.recover<List<Roles>?, IOException> { null }
.getOrThrow()
}
Невосстановимая ошибка в коде приложения
В случае, если код приложения сталкивается с ожидаемой невосстановимой ошибкой, то она выбрасывается исключением, наследующимся от DomainException:
class ReservationDatesInPastException(from: LocalDate)
: DomainException("Reservation dates in past: $from")
if (!ReservationRules.canAcceptAt(reservation, reservationRequestDate)) { // 3
throw ReservationDatesInPastException(reservation.from)
}
Восстановимая ошибка в коде приложения
Совершенно точно в этом случае не используется связка throw + catch внутри кода приложения. Вместо этого, используется один из вариантов функционального подхода к обработке ошибок.
У меня в шести проектах за последние четыре года на 80К строк Kotlin-кода нет ни одного такого кейса. Возможно такие кейсы встречаются только в библиотечном коде, либо в системах с бизнес-логикой существенно выше среднего. |