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

Одной из основных претензий к Java у меня всегда была её “многословность”. Особенно эта многословность раздражает в обработке исключений, где сравнительно небольшой участок кода часто обильно сдабривался “соплями” обработки исключений. Разнообразные среды типа IDEA эту многословность позволяли в той или иной степени сгладить в процессе написания кода, но, к сожалению, она никуда не девалась из кода уже написанного и мозолила глаза. Как эту радость прятали в Scala я уже не помню, т.к. язык мне показался мало пригодным для моих нужд, а вот варианты доступные в Clojure мне очень даже понравились.
Исключения в Clojure, по логике работы с ними, можно разделить на две группы. Первая группа – это исключения возбуждаемые Java-библиотеками. Для примера возьмем функцию выполняющую запрос HEAD:

(defn test-fn [url]
  (client/head url)
  )


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

(defn test-invalid-name []
  (try
    (test-fn "https://qqq.www/")
    (catch java.io.IOException e
      (log/info "try/catch :("))))

Ко второй группе исключений относятся Clojure-специфические исключения не имеющие никакой дополнительной иерархии и реализуемые классом clojure.lang.ExceptionInfo. Объект ExceptionInfo просто включает в себя всю информацию об исключении вместе с произвольными пользовательскими данными. Так как Clojure работает поверх JVM, а не собственной виртуальной машины, ни о каких условиях и сигналах присутствующих в LISP, к сожалению, речи не идет и приходится пользоваться тем, что дают.

(defn test-standard []
  (try
    (throw (ex-info "My text" {:type :my-error1 :cause :something-happens}))
    (catch clojure.lang.ExceptionInfo err   ;; (1)
      (let [data (ex-data err)]
        (case (:type data)                  ;; (2)
          :my-error1 (prn "Error 1 handler" (:cause data))
          :my-error2 (prn "Error 2 handler" (:cause data))      
    )))))

Ловится исключение с указанием типа объекта 1 с последующим определением сценария обработки в зависимости от внутренних данных 2. В целом, выглядит чуть лучше, но до совершенства далеко.

При этом, в Clojure мире, имеется ощутимо более удачный вариант реализуемый библиотекой slingshot. Сама slingshot работает поверх стандартных исключений Clojure с использованием ExceptionInfo объектов.

(defn test-slingshot []
  (try+                                                     ;; (1)
    (throw+ {:type :my-error1 :cause :something-happens})   ;; (2)
    (catch [:type :my-error1] {:keys [type cause]}          ;; (3)
      (prn "Error 1 handler" cause))
    (catch [:type :my-error2] {:keys [type cause]}
      (prn "Error 2 handler" cause))
    (catch Object err                                       ;; (4)
      (prn "all other errors" (ex-data err)))
    )
  )

Изменения привносимые slingshot в обработку исключений я бы назвал косметическими, при этом нельзя не отметить что данный синтаксический сахар очень и очень удобен. try 1 каких-либо изменений не претерпел, а просто обзавелся дополнительным + в имени. throw 2 упростился за счет автоматического создания объекта ExceptionInfo, что делает использование ex-info не нужным. Но больше всего радует catch 3, который позволяет автоматизировать как матчинг по ключам, так и извлечение интересующих в исключении данных. При желании, можно обработать исключения и сопоставляя тип объекта 4, что выглядит полезным для обработки всех возможных исключений разом.
Но, честно говоря, не это привлекло меня в работе с исключениями в Clojure, а библиотека dire. Для примера возьмем приведенную выше функцию test-fn и добавим немного логирования для упрощения понимания процесса.

(defn test-dire []
  (try
    (prn "BEGIN :: test-dire")
    (prn "DATA ::" (test-fn "https://goo.gl/404"))
    (prn "END :: test-dire")
    (catch Exception e
      (log/info "try/catch :(" (type e)))))

В консоли окажется вполне ожидаемый текст и на первый взгляд можно перехватить именно ExceptionInfo и работать уже с ним:

"BEGIN :: test-dire"
=> nil
Aug 13, 2015 9:56:12 PM error-handling.test invoke
INFO: try/catch :( clojure.lang.ExceptionInfo

[/cc]
На практике все немного сложнее, для того что бы в этом убедиться достаточно заменить https://goo.gl/404 на https://qqq.www/404:

"BEGIN :: test-dire"
=> nil
Aug 13, 2015 10:00:03 PM error-handling.test invoke
INFO: try/catch :( java.net.UnknownHostException

Как я и говорил выше, “прилететь” может не только Clojure-специфическое 1, но и обычное Java 3 исключение. В итоге функция учитывающая оба типа ошибок начинает выглядеть следующим образом:

(defn test-dire []
  (try
    (prn "BEGIN :: test-dire")
    (prn "DATA ::" (test-fn "https://goo.gl/404"))
    (prn "END :: test-dire")
    (catch clojure.lang.ExceptionInfo e                   ;; (1)
      (let [data (ex-data e)
            status (get-in data [:object :status])
            url (get-in data [:environment 'req :url])]   ;; (2)
        (log/info "HTTP(S) code" status "for URL" url)))
    (catch Exception e                                    ;; (3)
      (log/info "try/catch :(" (type e)))))

Не очень красиво, правда? Зато можем извлечь потенциально полезную информацию о деталях ошибки и записать её в удобной форме в лог 2. Но хочется-то всего того же, только без безобразных обработчиков мешающий восприятию основного сценария! И вот тут и выходит на сцену dire с возможность обработать ошибку при помощи сторонней вспомогательной функции:

(with-handler! #'test-fn                  ;; (1)
               [:status *]
               (fn [e & args]
                 (let [{:keys [status request-time]} e]
                   (log/info "with-handler: status" status "for" (first args) "with request time" request-time))
                 ))

(with-handler! #'test-fn                  ;; (2)
               java.io.IOException
               (fn [e & args]
                 (log/info "with-handler/Object" (.getMessage e))
                 ))

Благодаря dire к одной функции можно прикрепить цепочку обработчиков, которые будут вызываться последовательно в порядке объявления. В примере выше сначала 1 будет обработано любое исключение, имеющее ключ :status с любым (¡¡¡воссторг!!!) значением. Если исключение не было обработано в первом обработчике, то очередь за вторым 2, обрабатывающим Java-специфические ошибки.
Кроме явного улучшения читабельности кода, имеется еще один крайне важный побочный эффект: так как исключение уже было обработано во внешнем обработчике, функция test-fn вернет nil, что в свою очередь позволит test-dire продолжить исполнение.

"BEGIN :: test-dire"
"DATA ::" nil
"END :: test-dire"
=> nil
Aug 13, 2015 10:03:41 PM error-handling.test invoke
INFO: with-handler: status 404 for https://goo.gl/404 with request time 254

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

Leave a Reply