Лучшие практики обработки исключений в Java
Практические правила обработки исключений в Java — раннее завершение, правильные типы, не игнорируйте исключения и логируйте с пользой.
Предыдущие главы охватили механику — try, catch, finally, throw, throws, пользовательские классы. Эта глава посвящена суждению. Две программы могут использовать одни и те же конструкции, и одна будет надёжной, а другая — хрупкой. Разница заключается в небольшом наборе привычек, которые с практикой становятся рефлексом.
Быстро завершайте при некорректных входных данных
Если метод вызван с аргументами, которые он не может обработать, бросьте исключение немедленно:
public void send(String to, String body) {
if (to == null || to.isBlank()) {
throw new IllegalArgumentException("to must be non-blank");
}
if (body == null) {
throw new NullPointerException("body");
}
// ...real work
}Не пытайтесь «сделать всё возможное» с null. Ошибка на стороне вызывающего кода, и чем ближе исключение к нему, тем проще его исправить. Objects.requireNonNull(body, "body") — стандартная однострочная замена для случая с null.
Противоположная привычка — молча подставлять значения по умолчанию вместо отсутствующих данных — приводит к ошибкам, которые проявляются на пять уровней глубже без каких-либо подсказок о том, кто что передал.
Бросайте наиболее конкретный подходящий тип
Exception редко является правильным вариантом для броска, а RuntimeException — только когда нет более конкретного типа. Стандартная библиотека даёт вам словарь — используйте его:
- Неверный аргумент →
IllegalArgumentException - Null-аргумент →
NullPointerException(Objects.requireNonNull) - Неверное состояние →
IllegalStateException - Операция не поддерживается →
UnsupportedOperationException - Индекс вне диапазона →
IndexOutOfBoundsException - Число вне диапазона →
ArithmeticExceptionилиIllegalArgumentException
Для доменных ошибок создайте пользовательское исключение, а не переиспользуйте встроенное.
Перехватывайте наиболее конкретный тип, для которого у вас есть план
Симметричное правило. catch (Exception e) — это запах кода. Оно перехватывает программные ошибки (NullPointerException, IllegalStateException), восстанавливаемые сбои (IOException) и неизвестные исключения библиотек в одной корзине — и почти всегда обрабатывает их одинаково, что почти всегда неверно.
// Bad — what does this even handle?
try { complex(); }
catch (Exception e) { log("failed"); }
// Better — specific cases get specific responses
try { complex(); }
catch (IOException e) { retryLater(); }
catch (ParseException e) { recordCorruptInput(e); }Когда вы действительно не знаете, что делать с классом исключений, ответ — не перехватывайте его. Позвольте ему распространяться до обработчика, который знает.
Никогда не игнорируйте исключение молча
Худший паттерн обработки исключений:
try { doWork(); }
catch (Exception e) { } // never write thisКогда неизбежная производственная ошибка случится, не будет стека вызовов, сообщения, записи в лог — сбой просто исчез. Если вы действительно намерены проигнорировать сбой (редко, но возможно — например, закрытие ресурса на пути очистки), скажите об этом явно:
try { connection.close(); }
catch (IOException ignored) {
// close-time failure on a cleanup path; original cause already propagating
}Имя переменной ignored и комментарий делают намерение видимым для следующего читателя.
Логируйте с пользой, логируйте один раз
Распространены два недостатка логирования:
- Логирование без достаточного контекста —
log.error("failed")ничего не говорит. - Логирование с последующим перебросом — каждый слой логирует одно и то же исключение, и один и тот же стек вызовов попадает в лог пять раз.
Выберите слой, который знает наибольший контекст (обычно высокоуровневый: обработчик запросов, исполнитель задач) и логируйте там с входными данными, которые вызвали сбой. Нижележащие слои должны сосредоточиться на трансляции исключения, а не его логировании.
try {
userService.activate(id);
} catch (UserNotFoundException e) {
log.warn("activation failed: no user with id={}", id, e); // include the exception object as the last arg
return Response.notFound();
}Передача исключения в качестве последнего аргумента логгера — соглашение SLF4J: оно гарантирует, что полный стек вызовов и любая цепочка причин окажутся в выводе.
Сохраняйте причину при оборачивании
Когда вы транслируете исключение на уровень выше, всегда передавайте оригинал как причину:
// Good — cause is preserved
catch (IOException e) {
throw new ConfigLoadException("failed to load " + path, e);
}
// Bad — original IOException is lost
catch (IOException e) {
throw new ConfigLoadException("failed to load " + path);
}Цепочка Caused by: в результирующем стеке вызовов позволяет дежурному инженеру отследить доменное исключение до сбоя на уровне байтов. Потеряйте её — и получасовая отладка превратится в полдня.
Не используйте исключения для управления потоком
Бросок исключений дорог — построение стека вызовов при создании является основной стоимостью. Что важнее, это затуманивает намерение. Цикл, использующий try/catch (NoSuchElementException), чтобы знать, когда остановиться, скрывает то, что делает:
// Bad
try {
while (true) {
process(iter.next());
}
} catch (NoSuchElementException end) { }
// Good
while (iter.hasNext()) {
process(iter.next());
}Когда «не найдено» — обычный результат, возвращайте Optional<T> или boolean. Оставьте исключения для действительно исключительного.
Используйте finally и try-with-resources для очистки
finally должен освобождать ресурсы. try-with-resources должен быть выбором по умолчанию для всего, что реализует AutoCloseable. Не помещайте бизнес-логику в finally — он выполняется и на пути успеха, и на пути ошибки и не может различить их. И не делайте return из finally — это молча отбрасывает оригинальное исключение или возвращаемое значение, что является одной из сложнейших для диагностики ошибок.
Документируйте то, что бросаете
Если метод может бросить исключение, важное для вызывающих — проверяемое или нет — скажите об этом в Javadoc:
/**
* Looks up a user by id.
*
* @throws UserNotFoundException if no user with that id exists
* @throws IllegalArgumentException if id is null or blank
*/
public User lookup(String id) { ... }Компилятор применяет это для проверяемых исключений в сигнатуре. Для непроверяемых Javadoc — единственный контракт, и вызывающие действительно нуждаются в нём, когда исключение влияет на то, как они должны использовать метод.
Используйте исключения для сбоев, возвращаемые значения — для обычных результатов
Обобщающее правило, связывающее всё остальное. Исключение говорит что-то пошло не так, и я не могу это исправить здесь. Возвращаемое значение говорит вот результат. Когда «не найдено» является частью нормальной работы, возвращайте Optional.empty(), boolean или сигнальное значение. Когда «соединение с базой данных разорвано» — бросайте.
Код, соблюдающий это различие, спокоен: счастливый путь выглядит как прямая линия, нестандартный путь — в отдельном блоке, и читатель с первого взгляда может понять, где что.
Рабочий пример
Небольшая функция обработки заказов, объединяющая практики этой главы — быстрая проверка входных данных, конкретные встроенные типы исключений, оборачивание с причиной и единственный верхнеуровневый обработчик, логирующий один раз.
Четыре вызова, четыре разных пути. Успешный завершается нормально. Два случая IllegalArgumentException (null-идентификатор, нулевая сумма) сообщаются с сообщением, объясняющим что было неверным во входных данных. Симулированный сбой сервиса всплывает как доменное OrderProcessingException с оригинальным IllegalStateException, связанным через getCause(). Ничего не проглочено, ничего не залогировано дважды, и каждый сбой точно указывает, какое значение его вызвало.
Что дальше
На этом завершается часть 8 — у вас есть рабочее понимание механики исключений Java и суждение для их правильного использования. Следующая часть посвящена глубокому изучению строк — наиболее распространённого типа в коде Java с гораздо большей глубиной, чем кажется на поверхности. Перейдите к классу Java String.