W3docs

Пользовательские исключения в Java

Создайте собственные классы исключений в Java, расширив Exception или RuntimeException для ошибок предметной области.

Встроенные исключения охватывают большинство общих сбоев, но ничего не знают о вашей предметной области. Когда что-то идёт не так способом, специфичным для вашего кода — «пользователь не найден», «недействительный купон», «конфигурация рассинхронизирована» — правильным решением, как правило, является определение собственного типа исключения. Пользовательские исключения просты в написании, делают трассировки стека понятными и позволяют обработчикам точно реагировать на нужный сбой.

Минимальная форма

Пользовательское исключение — это класс, расширяющий Exception (или один из его подклассов). Кратчайший рабочий вариант:

public class UserNotFoundException extends Exception {
  public UserNotFoundException(String message) {
    super(message);
  }
}

Это полноценное проверяемое пользовательское исключение. Вы можете вызвать throw new UserNotFoundException("id=42") из любого места, а вызывающий код может перехватить его через catch (UserNotFoundException e).

Проверяемое или непроверяемое?

Самое важное решение при определении класса исключения: что вы расширяете?

  • extends Exception → проверяемое. Компилятор обязывает вызывающий код обработать его или объявить.
  • extends RuntimeException → непроверяемое. Вызывающий код может обработать его, но не обязан.

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

Для исключений предметной области в современном Java-коде RuntimeException является более распространённым выбором — отчасти потому, что проверяемые исключения плохо сочетаются с потоками и лямбдами, а отчасти потому, что большинство сбоев предметной области всё равно всплывают до единого обработчика верхнего уровня. Начинайте с RuntimeException, если у вас нет конкретной причины принудить к обработке.

Четыре конструктора

По соглашению класс исключения предоставляет те же четыре конструктора, что и встроенные:

public class ConfigLoadException extends RuntimeException {
  public ConfigLoadException() {
    super();
  }
  public ConfigLoadException(String message) {
    super(message);
  }
  public ConfigLoadException(String message, Throwable cause) {
    super(message, cause);
  }
  public ConfigLoadException(Throwable cause) {
    super(cause);
  }
}

Зачем все четыре:

  • Без аргументов — для инструментов и фреймворков, которые используют рефлексию класса.
  • Только сообщение — наиболее распространённый случай в собственном коде.
  • Сообщение + причина — для оборачивания низкоуровневого исключения. Наиболее важный для включения.
  • Только причина — когда сообщение причины само по себе уже достаточно описательно.

Вам не нужно вводить все четыре каждый раз — IDE генерирует их одним нажатием клавиши — но пропускать формы с причиной — это реальная потеря. Без них нельзя сохранить исходное исключение при оборачивании.

Хранение полезного состояния

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

public class UserNotFoundException extends RuntimeException {
  private final String userId;

  public UserNotFoundException(String userId) {
    super("user not found: " + userId);
    this.userId = userId;
  }

  public String getUserId() { return userId; }
}

Теперь блок catch может что-то сделать со сбоем, а не просто разбирать сообщение:

catch (UserNotFoundException e) {
  metrics.recordMissingUser(e.getUserId());
  return Response.notFound();
}

Держите поля неизменяемыми (final), а конструктор минимальным. Исключения создаются на пути сбоя — они должны быть быстрыми и никогда не генерировать исключения сами по себе.

Оборачивание с причиной

Самая полезная техника при работе с пользовательскими исключениями — перевод низкоуровневого исключения в доменное с сохранением оригинала:

public Config load(Path p) {
  try {
    return parser.parse(Files.readString(p));
  } catch (IOException e) {
    throw new ConfigLoadException("could not read " + p, e);
  } catch (ParseException e) {
    throw new ConfigLoadException("invalid config in " + p, e);
  }
}

Вызывающий код видит единственный тип исключения, соответствующий словарю его уровня. Исходный сбой не теряется — он прикреплён через getCause() и отображается в printStackTrace() в строке Caused by:.

Вот как вы сохраняете разделение слоёв. API Config не пропускает IOException или ParseException; оба переводятся в нечто, означающее «загрузка конфигурации завершилась неудачей».

Небольшая иерархия

Когда у вас есть семейство связанных сбоев, дайте им общего родителя:

public class PaymentException extends RuntimeException {
  public PaymentException(String message)               { super(message); }
  public PaymentException(String message, Throwable c)  { super(message, c); }
}

public class CardDeclinedException extends PaymentException {
  public CardDeclinedException(String message) { super(message); }
}
public class InsufficientFundsException extends PaymentException {
  public InsufficientFundsException(String message) { super(message); }
}
public class FraudCheckFailedException extends PaymentException {
  public FraudCheckFailedException(String message) { super(message); }
}

Вызывающий код может быть конкретным (catch (CardDeclinedException)) или широким (catch (PaymentException)) в зависимости от необходимости. Общий родитель также даёт единственный импорт для использования в клаузе throws, когда метод может выбросить любое из них.

Чего следует избегать

  • Не расширяйте Throwable или Error напрямую. Всегда проходите через Exception или RuntimeException.
  • Не переопределяйте getMessage() для вычисления строк при каждом вызове. Создавайте сообщение в конструкторе и позвольте родительскому классу хранить его.
  • Не добавляйте логику в исключение. Оно существует для переноса информации. Восстановление принадлежит catch.
  • Не размножайте исключения. Каждый новый тип исключения — это небольшой контракт, который вызывающий код теперь может захотеть обработать. Если два сбоя требуют одинаковой обработки, они, вероятно, должны быть одним типом.

Практический пример

Небольшой модуль обработки заказов с собственным семейством исключений. Базовый класс оборачивает низкоуровневые сбои; подклассы несут детали предметной области; драйвер перехватывает их на разных уровнях специфичности, показывая, как иерархия позволяет выбирать.

java— editable, runs on the server

Драйвер перехватывает EmptyOrderException первым (конкретный случай, который нужно обрабатывать по-другому), затем OrderException как перехват для всего семейства. Когда проверка завершается неудачей, цепочка причин ссылается на исходное IllegalStateException, так что вы не теряете информацию при переводе на уровень предметной области.

Что дальше

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

Практика

Практика
Вы проектируете исключение для ситуации 'платёж был отклонён эмитентом карты'. Вызывающий код может захотеть повторить попытку, запросить пользователя или использовать другую карту. Какой дизайн наиболее обоснован?
Вы проектируете исключение для ситуации 'платёж был отклонён эмитентом карты'. Вызывающий код может захотеть повторить попытку, запросить пользователя или использовать другую карту. Какой дизайн наиболее обоснован?
Was this page helpful?