Программирование

Обработка исключений

Либо вы обрабатываете свои исключения, либо они будут обрабатывать вас

Обзор

Это предназначено для поддержки принятия решений, связанных с тем, что делать при возникновении исключения. Должны ли вы даже поймать его? Если вы это сделаете, вы просто зарегистрируете его или перебросите его? Должны ли вы инкапсулировать его в другой? Для этой цели нам необходимо рассмотреть некоторые ключевые понятия о том, как структурированы исключения и что означает их обработка.

TL;DR

Я начал этот пост с объяснения того, как исключения работают в Java. Если вы уже это знаете, просто перейдите к «Когда что делать?».

Исключения Java — помните об иерархиях!

Чтобы представить эту тему, важно объяснить, как работает механизм исключений в Java.

Все, что наследуется от Throwable, может быть выброшено, и оно работает точно так же, как и любое исключение.

И как это работает?

public static void main(String[] args) {
 try {
  int[] myNumbers = {1, 2, 3};
  String text = null;
  if (myNumbers[2] == 50) { //This will never be true
   text = "OK"; //text will not be initialized.
  }
  System.out.println(text.length()); //This will throw a NullPointerException.
  System.out.println("The end."); //This will never be executed.
 } catch (NullPointerException exception) {
  System.out.println("Something went wrong:" + e.getMessage());
  throw exception;
 } finally {
  System.out.println("The 'try catch' is finished."); //This will always be printed.
  //if this next line wasn't commented, the NullPointerException
  // thrown on line 8 would be lost.
  //throw new Exception("erasure");
 }
 //Here goes code that will work correctly
 // AND MAKES SENSE even if the code in the try block doesn't work.
}

По сути, при возникновении исключения выполнение кода останавливается. Ни одна строка после создания исключения не выполняется, если исключение не обрабатывается блоком catch.

Исключение обрабатывается в первом блоке catch, который объявляет соответствующий тип (т. е. если есть catch с этим классом или любым суперклассом в его иерархии), выполняется блок catch.

Выполняется только один блок catch, и он первый объявляет соответствующий тип.

Блоки finally выполняются всегда, даже если нет блока catch, соответствующего исключению (или вообще нет блока catch). Когда генерируется исключение (либо из-за отсутствия соответствующего блока catch, либо из-за того, что блок catch повторно выдает его или выдает новый), блок finally выполняется до того, как исключение будет выдано вызывающему методу. Поэтому мы должны быть особенно осторожны с кодом внутри блока finally. Если там что-то выброшено (например, другое исключение), исходное исключение не будет выброшено. Он будет заменен на тот, который возник в блоке finally.

Если вам нравится эта история, нажмите на аплодисменты в конце, чтобы я знал, о чем вы хотите прочитать.

Я не зарабатываю на блоге ни копейки. Если вы хотите поддержать создание большего количества контента, поделитесь блогом со своими коллегами и подпишитесь на него, чтобы получать уведомления о новых историях!

Проверено против непроверено

Проверенные исключения — это те, которые язык Java обязывает разработчика либо перехватывать, либо явно объявлять о том, что метод выдает их. Они «проверяются» во время компиляции, отсюда и название. Все проверенные исключения наследуются непосредственно от Exception или любого другого подкласса, в иерархии которого нет RuntimeException.

Исключения во время выполнения представляют собой проблемы, которые являются результатом проблемы программирования, и поэтому нельзя разумно ожидать, что клиентский код API сможет восстановиться после них или каким-либо образом их обработать. Такие проблемы включают арифметические исключения, такие как деление на ноль; исключения указателя, такие как попытка доступа к объекту через нулевую ссылку; и исключения индексации, такие как попытка доступа к элементу массива через слишком большой или слишком маленький индекс.

Исключения во время выполнения могут возникать в любом месте программы, а в типичной программе их может быть очень много. Необходимость добавлять исключения во время выполнения в каждое объявление метода снижает ясность программы. Таким образом, компилятор не требует, чтобы вы перехватывали или определяли исключения времени выполнения (хотя вы можете это сделать). И только потому, что метод не может восстановиться после него, это не значит, что ваше приложение не может. Иногда вы можете выполнить его по-другому, просто повторить попытку, попытаться исправить проблему перед повторной попыткой или даже выполнить другое бизнес-правило.

Контекст и уровни абстракции

Что делает этот метод?

public static String fileToString(String filePath) throws IOException {
 File file = new File(filePath);
 StringBuilder content = new StringBuilder();
 try (BufferedReader bufferedFileReader = new BufferedReader(new FileReader(file))){
  while (bufferedFileReader.ready()) {
   content.append(bufferedFileReader.readLine());
   content.append(System.getProperty("line.separator"));
  }
  if (content.length() != 0) {
   content
    .deleteCharAt(content.lastIndexOf(System.getProperty("line.separator")));
  }
 }
 return content.toString();
}

Он ТОЛЬКО читает данные из файла. Ничего больше. Как мы можем предсказать, взглянув на этот метод, что он читает содержимое файла конфигурации?

public Configuration loadConfiguration(Organization organization)
   throws DataNotAvailableException {
 log.trace("Loading configuration %.", organization.getId());
 Configuration configuration = new Configuration();
 try{
  configuration.parse(FileLoader.fileToString(organization.getConfigurationFilePath()));
  log.debug("Configuration loaded %.", organization.getId());
 } catch (FileNotFoundException exception) {
  log.warn("No configuration found for organization %, loading default configuration.",
    organization.getId(), exception);
  configuration.loadDefaults();
 } catch (IOException | ParseException exception) {
  log.error("Configuration file possibly corrupted, organization %.",
    organization.getId());
  throw new DataNotAvailableException("Error loading or parsing configuration data" +
   " for organization %.", organization.getId(), exception);
 }
 return configuration;
}

Этот метод может загружать конфигурацию для настольного приложения, верно? Или его можно вызвать внутри микросервиса REST. Как мы могли сказать, что это веб-приложение?

@WebServlet("/login")
public class LoginServlet extends HttpServlet {
@EJB
    private OrganizationService organizationService;
@Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
       throws ServletException, IOException {
        String organizationId = request.getParameter("organizationId");
        try {
          //this is where the configuration gets loaded.
          Organization organization = organizationService.find(organizationId);
          request.getSession().setAttribute("user", user);
          response.sendRedirect("home");
        } catch (OrganizationNotFoundException exception) {
          log.error(exception);
          request.setAttribute("error", MessageBundle.find("error.organizationNotFound"));
          request.getRequestDispatcher("/login.jsp").forward(request, response);
        } catch (DataNotAvailableException exception) {
          log.error(exception);
          request.setAttribute("error",
             MessageBundle.find("error.organizationDataNotLoaded"));
          request.getRequestDispatcher("/login.jsp").forward(request, response);
        } catch (Throwable throwable) {
          log.error(t);
          request.setAttribute("error", MessageBundle.find("error.internalError"));
          request.getRequestDispatcher("/login.jsp").forward(request, response);
        }
    }
}

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

Примечание
В реальной ситуации в ваше приложение должна быть встроена структура обработки исключений. Это означает, что исключения вашего приложения наследуются от общего корня, что у вас есть стандарт того, как их регистрировать и как получить доступ к тому, что было записано, возможно, способы автоматического получения уведомлений, когда происходит что-то серьезное, но также и то, что все ваши высокоуровневые классы имеют дело с метательными. В приведенном выше примере это означает, что ваш LoginServlet, вероятно, будет иметь общий корень со всеми другими вашими сервлетами:

public abstract class ApplicationServlet extends HttpServlet {
@Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
       throws ServletException, IOException {
        try {
          executePost(HttpServletRequest request, HttpServletResponse response);
        } catch (Throwable throwable) {
          log.error(t);
          request.setAttribute("error", MessageBundle.find("error.internalError"));
          request.getRequestDispatcher("/login.jsp").forward(request, response);
        }
    }
    
    //LoginServlet should override this method, there will be no need to duplicate
    // catch (Throwable throwable) in any servlet.
    protected abstract void executePost(HttpServletRequest request,
      HttpServletResponse response) throws ServletException, IOException;
}
@WebServlet("/login")
public class LoginServlet extends ApplicationServlet {
@EJB
    private OrganizationService organizationService;
@Override //overrides the executePost method from ApplicationServlet
    protected void executePost(HttpServletRequest request, HttpServletResponse response)
       throws ServletException, IOException {
        String organizationId = request.getParameter("organizationId");
        try {
          //this is where the configuration gets loaded.
          Organization organization = organizationService.find(organizationId);
          request.getSession().setAttribute("user", user);
          response.sendRedirect("home");
        } catch (OrganizationNotFoundException exception) {
          log.error(exception);
          request.setAttribute("error", MessageBundle.find("error.organizationNotFound"));
          request.getRequestDispatcher("/login.jsp").forward(request, response);
        } catch (DataNotAvailableException exception) {
          log.error(exception);
          request.setAttribute("error",
             MessageBundle.find("error.organizationDataNotLoaded"));
          request.getRequestDispatcher("/login.jsp").forward(request, response);
        }
    }
}

Когда что делать?

Пусть это через

Метод fileToString ничего не может сказать о его использовании или намерениях. Поэтому, если бы это было перехватом исключения, нет возможности определить, что с ним делать. Возможные варианты: похоронить его (пустой блок catch) или пропустить. Похоронить исключение недопустимо. Это приводит вызывающий метод к «вере в то, что все прошло хорошо» и продолжению выполнения кода в состоянии ошибки. Это предотвращает соответствующую обработку на более высоких уровнях и может привести к повреждению/потере данных.

Поэтому окончательное решение ловить его или нет связано с ответом на вопрос:

Могу ли я что-нибудь сделать, чтобы исправить это или помочь методу вызывающего объекта справиться с этим?

НЕТ: Пропустите это. Объявите, что метод выдает это исключение, и будьте счастливы.

ДА: поймать. И тоже будь счастлив.

Ловить

Полное лечение

Полная обработка — это когда ваш код может возобновить обработку и первоначальный запрос от внешнего агента (пользователя, другой системы и т. д.) может быть выполнен.

Это означает, что у вас есть либо:

· Исправлены условия, вызвавшие исключение, и повторена попытка.
Чрезвычайно ценно, когда вы можете понять, что пошло не так.

· Повторные попытки несколько раз, и один из них сработал.
Очень часто встречается при проблемах с сетью, таких как тайм-ауты.

· Предпринял альтернативный план действий.
Метод loadConfiguration делает именно это в блоке catch FileNotFoundException.

Другими словами, обработка восстанавливаема и все необходимые шаги для восстановления выполнены успешно.

Примечание
Только когда вы завершите лечение и полностью выздоровеете, вам будет разрешено «использовать» исключение. Если это не так, вы либо повторно выбрасываете его, либо инкапсулируете (оборачиваете) в новый, который должен быть выброшен.

Частичная обработка

Частичная обработка — это когда можно что-то сделать, чтобы уменьшить влияние не полностью выполненного кода или когда можно добавить дополнительную информацию. Примерами дополнительной информации могут быть:

· для помощи вызывающему методу в лечении и, возможно, восстановлении проблемы.

· для устранения или отката побочных эффектов частично выполненного кода.

· для информирования другого агента (зарегистрировать событие, отправить уведомление и т. д.), часто для помощи в устранении неполадок. Катастрофический сбой также может немедленно уведомить службы поддержки.

· поднять уровень абстракции, инкапсулировав исключение в другое.

Метод loadConfiguration имеет блок catch для IOException и ParseException, который выполняет 3 из них. В частности, он регистрирует информацию, повышает уровень абстракции, инкапсулируя исключение в новое исключение, и добавляет в новое исключение ценную информацию для помощи в устранении неполадок.

Инкапсулировать или нет?

Вопрос об инкапсуляции (или упаковке) действительно связан с уровнем абстракции слоя, на котором выполняется код. Обратите внимание, что в JPA есть QueryTimeoutException, который является надклассом PersistenceException для инкапсуляции исключения тайм-аута, которое может возникнуть, если при обмене данными с базой данных возникнут сетевые проблемы. Это происходит потому, что JPA является абстракцией более высокого уровня по сравнению с JDBC и SQL. JDBC, с другой стороны, инкапсулирует тайм-аут с помощью SQLTimeoutException.

В том же духе метод loadConfiguration инкапсулирует IOException и ParseException в DataNotAvailableException. Благодаря этому, если в будущем эта информация будет храниться в базе данных и парсинг не потребуется, не будет необходимости менять вызывающий метод. Все, что нужно знать вызывающему абоненту, это то, что данные недоступны. Знание того, где оно хранилось, не помогает решить, что делать. Если есть что-то, что нужно сделать в отношении этого аспекта, то метод loadConfiguration должен взять на себя ответственность за обработку этого аспекта, независимо от того, является ли эта обработка частичной или полной.

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

  • public Throwable::(String message, Throwable cause) (самый популярный);
  • public Throwable::Throwable(Throwable cause) (избегайте этого, всегда предпочтите передать сообщение).
  • public Throwable::(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace)

Или вызовите метод public Throwable Throwable::initCause(Throwable cause), чтобы установить его после создания исключения. Этот метод не является предпочтительным, так как его легко забыть вызвать.

Пример (из метода loadConfiguration выше):

} catch (IOException | ParseException exception) {
 log.error("Configuration file possibly corrupted, organization %.",
   organization.getId());
 throw new DataNotAvailableException("Error loading or parsing configuration data" +
  " for organization %.", organization.getId(), exception);
//                    ^ Message                           ^ Cause
}

Неустранимые ситуации

В случае неустранимых ситуаций важно не допустить, чтобы код продолжал выполняться. Выполнение в состоянии ошибки может вызвать другие ошибки и привести к потере/повреждению данных.

Откатите все эффекты частичной обработки (если они есть) и сообщите запрашивающей стороне, что обработка не может быть выполнена. В методе doPost это делалось широко, добавляя пользователю сообщения об ошибках.

Обычно уведомления этих внешних агентов (пользователя, другой системы и т. д.) можно делать только в тех методах, которые находятся ближе к началу запроса (глубже в стеке).

В данном примере за это уведомление отвечает первый метод, который выполняется на стороне сервера. В асинхронных методах обычно уведомление о результатах (включая возможные сообщения об ошибках) выдается по электронной почте, push-уведомлениям (мобильные устройства) или даже в системных отчетах.

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

Отсутствие уведомления о том, что что-то пошло не так, не только заставляет звонящего предположить, что запрос был успешно обработан и цель достигнута, но также ограничивает технический анализ, проводимый разработчиками, и часто затрудняет воспроизведение проблем. Проблема, которая не регистрируется и не вызывает никаких уведомлений, называется скрытой ошибкой.

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

Ведение журнала и исключения

Регистрация часто идет с исключениями. Конечно, разработчик может решить логировать что угодно, даже идеально работающий код. Однако при возникновении исключений рекомендуется протоколировать их возникновение.

Уровни журнала

Если вам удалось выйти из исключительной ситуации, и цель метода все равно будет достигнута, общепринято, что следует использовать уровень журнала WARN или ниже. Уровни ниже WARN (обычно INFO, поскольку DEBUG и TRACE связаны с другими целями) обычно применимы, когда результат метода не был потерян. Например, в методе loadConfiguration ловушка FileNotFoundException не смогла загрузить конфигурацию организации. Предполагается, что это новая организация. Это предположение не обходится без возможных потерь (т. е. если файл хранился где-то еще или был потерян по какой-то внешней причине). В этом случае будет уместно WARN. В противном случае попытка извлечения конфигурации из вторичного местоположения была выполнена успешно, уровень ниже WARN был бы более подходящим.

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

Однако, если блок catch не выполняет частичную обработку, а только инкапсулирует его, нет необходимости ничего регистрировать, пока инкапсуляция сохраняет исходное исключение нетронутым, чтобы к его трассировке стека можно было получить доступ на другом уровне. Если это не так, возможно, подойдет журнал уровня WARN.

Журналы на уровне ERROR зарезервированы для ситуаций, когда метод не может восстановиться и обработка запроса не может быть продолжена. Обычно они связаны с ситуациями, когда внешний агент (пользователь, другая система и т. д.) должен быть проинформирован о том, что первоначальный запрос не был выполнен.

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

Что регистрировать

Полное руководство по методам ведения журналов см. в другом посте: Вход в журнал или нет?

Наиболее важной информацией является выбрасываемое сообщение/сообщение об исключении (public String Throwable::getMessage()), строка класса и метода/исходного кода, где это произошло, и, конечно, его тип (имя класса исключения).

Однако часто этого недостаточно для расследования причины исключения. Для этого убедитесь, что трассировка стека также регистрируется. Это позволяет разработчику отслеживать вызовы методов до точки отказа.

Другая очень важная часть информации, которая часто требуется, — это причина исключения. Всякий раз, когда исключение инкапсулирует другое, инкапсулированное исключение рассматривается как «причина».

Доступ к причине можно получить, вызвав метод Throwable public Throwable Throwable::getCause(). Обычно, когда трассировка стека полностью зарегистрирована, причина будет указана там (см. строку 8 ниже):

Exception in thread "main" java.lang.RuntimeException: Some other message
    at Exceptions.main(Exceptions.java:4)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at com.intellij.rt.execution.application.AppMain.main(AppMain.java:147)
Caused by: java.lang.RuntimeException: Some message
    at Exceptions.main(Exceptions.java:3)
    ... 5 more

Итак, резюмируя:

  • бросаемое/исключительное сообщение (public String Throwable::getMessage());
  • строку класса и метода/исходного кода, где это произошло;
  • Его тип (имя класса исключений);
  • трассировка стека, включая причину (предпочтительно) ИЛИ трассировка стека и причина отдельно.

Всегда следите за тем, чтобы ваша инфраструктура обработки исключений (и/или любые сторонние библиотеки, которые вы можете использовать) собирала всю эту информацию. Предпочтительно, чтобы для автоматического сбора всей этой информации было достаточно просто передать экземпляр исключения в фреймворк.

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

Метательный и безопасность

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

Вот почему обычно считается хорошей практикой перехватывать Throwable на самом высоком уровне приложения, регистрировать произошедшее на уровне ERROR и информировать внешний агент (пользователя, другую систему и т. д.) об ошибке в абстрактных/общих терминах.