Привет, Хабр! Представляю вашему вниманию перевод статьи Fixing 7 Common Java Exception Handling Mistakes автора Thorben Janssen.

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

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

Ошибка 1: объявление java.lang.Exception или java.lang.Throwable


Как вы уже знаете, вам нужно либо объявить, либо обработать проверяемое исключение. Но проверяемые исключения — это не единственные, которые вы можете указать. Вы можете использовать любой подкласс java.lang.Throwable в предложении throws. Таким образом, вместо указания двух разных исключений, которые выбрасывает следующий фрагмент кода, вы можете просто использовать исключение java.lang.Exception в предложении throws.

public void doNotSpecifyException() throws Exception {
doSomething();
}
public void doSomething() throws NumberFormatException, IllegalArgumentException {
// do something
}

Но это не значит, что вы должны это сделать. Указание Exeption или Throwable делает почти невозможным правильное обращение с ними при вызове вашего метода.Единственная информация, которую получает вызывающий вами метод, заключается в том, что что-то может пойти не так. Но вы не делитесь какой-либо информацией о каких-либо исключительных событиях, которые могут произойти. Вы скрываете эту информацию за обобщенными причинами выброса исключений.Становится еще хуже, когда ваше приложение меняется со временем. Выброс обобщенных исключений скрывает все изменения исключений, которые вызывающий должен ожидать и обрабатывать. Это может привести к нескольким непредвиденным ошибкам, которые необходимо найти в тестовом примере вместо ошибки компилятора.

Используйте конкретные классы


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

public void specifySpecificExceptions() throws NumberFormatException, IllegalArgumentException {
doSomething();
}

Ошибка 2: перехват обобщенных исключений


Серьезность этой ошибки зависит от того, какой программный компонент вы реализуете, и где вы обнаруживаете исключение. Возможно, было бы хорошо поймать java.lang.Exception в основном методе вашего приложения Java SE. Но вы должны предпочесть поймать определенные исключения, если вы реализуете библиотеку или работаете над более глубокими слоями вашего приложения.

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

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

try {
doSomething();
} catch (NumberFormatException e) {
// handle the NumberFormatException
log.error(e);
} catch (IllegalArgumentException e) {
// handle the IllegalArgumentException
log.error(e);
}

Ошибка 3: Логирование и проброс исключений


Это одна из самых популярных ошибок при обработке исключений Java. Может показаться логичным регистрировать исключение там, где оно было брошено, а затем пробросить его вызывающему объекту, который может реализовать конкретную обработку для конкретного случая использования. Но вы не должны делать это по трем причинам:

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

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

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

Регистрируйте исключение там, где вы его обрабатываете


Таким образом, лучше всего регистрировать исключение тогда, когда вы его обрабатываете. Как в следующем фрагменте кода. Метод doSomething генерирует исключение. Метод doMore просто указывает его, потому что у разработчика недостаточно информации для его обработки. Затем он обрабатывается в методе doEvenMore, который также записывает сообщение журнала.

public void doEvenMore() {
try {
doMore();
} catch (NumberFormatException e) {
// handle the NumberFormatException
} catch (IllegalArgumentException e) {
// handle the IllegalArgumentException
}
}
public void doMore() throws NumberFormatException, IllegalArgumentException {
doSomething();
}
public void doSomething() throws NumberFormatException, IllegalArgumentException {
// do something
}

Ошибка 4: использование исключений для управления потоком


Использование исключений для управления потоком вашего приложения считается анти-шаблоном по двум основным причинам:

Они в основном работают как оператор Go To, потому что они отменяют выполнение блока кода и переходят к первому блоку catch, который обрабатывает исключение. Это делает код очень трудным для чтения.

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

Ошибка 5: удалить причину возникновения исключения


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

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

try {
doSomething();
} catch (NumberFormatException e) {
throw new MyBusinessException(e, ErrorCode.CONFIGURATION_ERROR);
} catch (IllegalArgumentException e) {
throw new MyBusinessException(e, ErrorCode.UNEXPECTED);
}

Ошибка 6: Обобщение исключений


Когда вы обобщаете исключение, вы ловите конкретный, например, NumberFormatException, и вместо этого генерируете неспецифическое java.lang.Exception. Это похоже, но даже хуже, чем первая ошибка, которую я описал в этой статье. Он не только скрывает информацию о конкретном случае ошибки на вашем API, но также затрудняет доступ.

public void doNotGeneralizeException() throws Exception {
try {
doSomething();
} catch (NumberFormatException e) {
throw new Exception(e);
} catch (IllegalArgumentException e) {
throw new Exception(e);
}
}

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

try {
doNotGeneralizeException();
} catch (Exception e) {
if (e.getCause() instanceof NumberFormatException) {
log.error("NumberFormatException: " + e);
} else if (e.getCause() instanceof IllegalArgumentException) {
log.error("IllegalArgumentException: " + e);
} else {
log.error("Unexpected exception: " + e);
}
}

Итак, какой подход лучший?

Будьте конкретны и сохраняйте причину возникновения исключения.


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

try {
doSomething();
} catch (NumberFormatException e) {
throw new MyBusinessException(e, ErrorCode.CONFIGURATION_ERROR);
} catch (IllegalArgumentException e) {
throw new MyBusinessException(e, ErrorCode.UNEXPECTED);
}

Ошибка 7: добавление ненужных преобразований исключений


Как я уже объяснял ранее, может быть полезно обернуть исключения в пользовательские, если вы установите исходное исключение в качестве причины. Но некоторые архитекторы переусердствуют и вводят специальный класс исключений для каждого архитектурного уровня. Таким образом, они улавливают исключение в уровне персистентности и переносят его в MyPersistenceException. Бизнес-уровень ловит и обертывает его в MyBusinessException, и это продолжается до тех пор, пока оно не достигнет уровня API или не будет обработано.

public void persistCustomer(Customer c) throws MyPersistenceException {
// persist a Customer
}
public void manageCustomer(Customer c) throws MyBusinessException {
// manage a Customer
try {
persistCustomer(c);
} catch (MyPersistenceException e) {
throw new MyBusinessException(e, e.getCode()); 
}
}
public void createCustomer(Customer c) throws MyApiException {
// create a Customer
try {
manageCustomer(c);
} catch (MyBusinessException e) {
throw new MyApiException(e, e.getCode()); 
}
}

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

Обязательно добавьте информацию


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

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

public void persistCustomer(Customer c) {
// persist a Customer
}
public void manageCustomer(Customer c) throws MyBusinessException {
// manage a Customer
throw new MyBusinessException(e, e.getCode()); 
}
public void createCustomer(Customer c) throws MyBusinessException {
// create a Customer
manageCustomer(c);
}

Комментарии (11)


  1. sshikov
    09.09.2017 17:44
    -1

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


    Суть в том, что если вы не словили что-то в main потоке, то в худшем случае упадет все приложение. И вы об этом узнаете (хотя возможно в логах и будет пусто). А если упадет какой-то поток — то вы вообще не узнаете ничего. Типовой случай — callback в потоке, созданном фреймворком.


  1. Bonart
    09.09.2017 17:54
    +2

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


    Четвертый совет дает неверные аргументы и игнорирует один важный случай.
    Исключения — это структурный переход вперед-вверх, что является полезной разновидностью goto, такой же как return или continue.
    Но они непригодны для обычного потока управления, так как очень дороги в плане потребления ресурсов.
    В языках, где это не так (питон) исключения используются куда шире.
    Есть один случай когда исключение можно использовать в обычном потоке управления и в яве: это прерывание выполнения задачи. Здесь раскрутка стека в плюс а потребление ресурсов не в минус.


    1. CyberSoft
      09.09.2017 23:15

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

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


      Java движется в сторону удешевления исключений, например, в Java 7 добавили конструктор Throwable(String message, Throwable cause, boolean enableSuppression,boolean writableStackTrace), который даёт возможность опустить запись стектрейса. А для тех, кому он нужен и для этого создают исключение, в Java 9 добавили StackWalker API


  1. Jef239
    10.09.2017 02:49
    +1

    Очень односторонне. Не знаток java, но активно работал с исключениями в Delphi. Разумный подход такой:

    1. Логгируем там, где есть максимум дополнительной информации, которую мы можем записать в лог. То есть не просто «файл не открыт», а файл пытались открыть с такими-то параметрами доступа. Не просто — не удалось преобразование такой-то строки в число, а значение такого текстового поля не числовое. Такой подход дает сообщения об ошибке понятные для пользователей.
    2. Реагируем на ошибку обычно на этом же уровне. Но если нужна ещё и реакция на более высоких уровнях — меняем тип исключения на «логгировать не надо, но ошибка в файлах» или «логгировать не надо, но ошибка во вводе полей».
    3. После реакции меняем тип исключения на самое общее «логгировать не надо, завершаем работу функции». Его назначение — просто быстрый выход из середины кода.

    Особенно удобен такой подход при обслуживании всяких непредвиденных исключений типа деления на ноль или обращения через нулевой указатель.


  1. sah4ez32
    10.09.2017 08:25

    Сталкивался со случаем управления потоком исполнения через исключения, и это дико не удобно. Получается есть 2 потока и множество не явных переходов из первого во второй. И ещё если ты не автор этого кода, то это просто ужас.


    А ситуация с catch (Exception e) — это вообще из ряда вон выходящее. Хорошо если такая конструкция встречается в 1-2 местах на проекте и она обоснована, но вот в качестве архитектурного шаблона — это издевательство. Викторина какая-то, угадай что я хотел обработать...


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


  1. lastrix
    10.09.2017 11:09

    Насчет использования исключений в стиле goto не совсем согласен (ошибка 4), есть % тех случаев, которые просто требуют именно выброса исключений.
    Как пример: Вы трассируете некую структуру данных (предположительно неограниченную — граф, дерево или список списков) и создаете собственную структуру данных с ограничением: не более N узлов.
    Делать постоянные проверки на останов — снижение быстродействия более чем в 10 раз (такой код уже не будет оптимизироваться JIT даже если вы гуру в Java), не говоря о значительной потере читаемости такого кода, в котором везде и всюду if-else натыканы. С исключениями работает почти мгновенно. Разумеется исключение должно при этом иметь адекватное название, например TraceNodeLimitExceededException.
    Так что ошибка 4 является ошибкой далеко не всегда.
    Правильнее было бы назвать — рекомендация: "Не используйте исключения для управления ходом исполнения приложения, если нет значительной потери в быстродействии или читаемости кода."


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


  1. Revertis
    10.09.2017 11:40
    +3

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


  1. Throwable
    11.09.2017 15:20

    Ошибка одна — перехват исключений. В подавляющем большинстве случаев его в бизнес-коде вообще не должно быть. Практически всегда обработка исключений — это удел абстракций верхнего уровня, где как правило нет особой необходимости сильно различать тип эксепшна. К сожалению в Java checked exceptions обязуют перехватывать непосредственно в вызывающем методе, хотя практически всегда это не его ответственность. В частности в Java все исключения доступа к ресурсам checked: IOException, SQLException, JMSException, etc… Архитекторы посчитали, что вызывающий метод во что бы то ни стало просто обязан позаботиться о том, что если вдруг что-то пойдет не так. Реально же бизнес-код вообще не должен заботиться об ошибке доступа к ресурсам также как и не должен заботиться об InterruptedException или OutOfMemoryError — это должны компоненты верхнего уровня универсальным способом, например откатить транзакцию.


    1. lastrix
      12.09.2017 07:50

      Но для этого же существует суффикс при определении метода: throws И не нужно тогда ничего обрабатывать, при этом верхние уровни абстракции получат всю информацию о том, что использует ваша библиотека.


      1. Throwable
        13.09.2017 11:08
        -1

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


        Есть расхожий паттерн о том, что эксепшны низких уровней нужно заворачивать в собственные более внятные: например при чтении конфигурации SQLException или IOException завернуть в свой ConfigurationException. Это плохо, потому как затрудняет абстракциям верхнего уровня получить внятный доступ к причине (в каком из цепочки getCause() он лежит?). В случае же, когда этого не требуется и абстракции верхнего уровня абсолютно пофиг на тип эксепшна, нет никакого смысла в заворачивании.


        Checked должны быть эксепшны, связанные с бизнес-логикой: валидацией данных, некорректным состоянием, etc. Эксепшны, связанные с ресурсами должны быть только unchecked, и это ошибка дизайна Java API.


        1. lastrix
          13.09.2017 12:22

          Допустим, что убираем checked. Вообще никаких исключений не видно, которые мог бы выбросить фреймворк, например для работы с http.
          Что именно ловить? Checked исключения — это вариант возвращаемого значения с приятным бонусом в виде неявного завершения блока кода, вместо проверок возвращаемого методом кода (результата) — ловишь исключение. Это не только код упрощает, но и избавляет от нужды читать список констант (почему все упало) и пользоваться какими-то левыми инструментами, что бы получить детали.


          А насчет заворачивания исключений многократно могу лишь сказать так: руки кривые и излишнее проектирование. Не каждый уровень бизнес-логики вообще требует какой-либо работы с исключениями.
          Исключения не везде нужны. В лучшем случае в 1% функционала его требует обязательно, все остальное — проектирование ради проектирования.


          Вот чего не хватает в Java, так это возможности сказать, что любые исключения в этом методе считать unchecked — как раз избавится от ручного управления списком throws. Современные IDE и компиляторы вполне умны, что бы понять какое исключение может быть в вызываемом методе и передать его выше. Для этого вроде как даже Java Reflections достаточно, информация присутствует.