Как стать автором
Обновить

Тип данных Either как альтернатива выбрасыванию исключений

Время на прочтение8 мин
Количество просмотров9.2K
Автор оригинала: Mario Fernandez

Исключения – это базовый элемент многих языков программирования. Они обычно используются для обработки аномальных или непредусмотренных условий, при устранении которых необходим особый подход, нарушающий нормальный поток задач в приложении. В некоторых языках, например, в C++ или Java, исключения используются повсюду. Но не все языки спроектированы так. В C# или Kotlin нет проверяемых исключений. В других языках, например, Go и Rust, исключений нет вообще.

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

Различные типы ошибок

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

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


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

Как это обычно выглядит?

Давайте для примера воспользуемся сервисом бекенда, предоставляющим REST API. Простая архитектура выглядит так: тонкий контроллер вызывает сервис, который, в свою очередь, вызывает сторонний API — такая структура выглядит хорошо тестируемой и прямолинейной. Пока не столкнемся с условиями, приводящими к ошибке.

Если не обработать исключение, оно будет всплывать до тех пор, пока API не вернет ошибку 500 вызывающей стороне. Нам такого не надо.

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

@ExceptionHandler(JWTVerificationException::class)

fun handleException(exception: JWTVerificationException): ResponseEntity<ErrorMessage> {

    return ResponseEntity

      .status(HttpStatus.BAD_GATEWAY)

      .body(ErrorMessage.fromException(exception))

}

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

Допустим, наш сервис из вышеприведенной схемы должен проверить веб-токены JSON. Идея проста. Мы получаем JWT в виде строки и хотим знать, является ли она действительным токеном. Если является, то мы хотим получить конкретные свойства, которые обернем в TokenAuthentication. Следующий интерфейс определяет именно это:

interface Verifier {

    / **

     * @param jwt a jwt token

     * @return authentication credentials

     * /

    fun verify(jwt: String): TokenAuthentication
}

Сигнатура, не сообщающая, какова ситуация на самом деле

Если закопаться в реализацию Verifier, то в конечном итоге найдется нечто подобное:

/ **

 * Выполнить верификацию применительно к конкретному токену

 *

 * @param token to verify.

 * @return a verified and decoded JWT.

 * @throws AlgorithmMismatchException 

 * @throws SignatureVerificationException

 * @throws TokenExpiredException

 * @throws InvalidClaimException

 * /

public DecodedJWT verifyByCallingExternalApi(String token);

Как упоминалось выше, в Kotlin нет проверяемых исключений. Но это означает, что сигнатура Verifier в данном случае врет! Этот метод может выбросить исключение. Единственный способ понять, что именно происходит – посмотреть на реализацию. Тот факт, что за этой информацией приходится лезть в реализацию, явно свидетельствует, что инкапсуляции не хватает.

Явный подход

Таким образом, хочу изменить в реализации Verifier две вещи.

1. Метод verify не должен выбрасывать исключение.
2. Из сигнатуры этого метода должно быть понятно, что может произойти ошибка.

Можно использовать обнуляемый тип; в таком случае verify вернет TokenAuthentication?. Но это фиаско, братан: мы теряем всю информацию о том, что же на самом деле пошло не так. Если к ошибке могли бы привести разные причины, то информацию об этих причинах требуется сохранить. 

Знакомьтесь с Either (тадам!...).

Тип данных Either

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

В нашем случае Either  то сущность, значение которой может быть одного из двух типов, «левое» и «правое». Есть соглашение, по которому Right соответствует успеху, а Left – ошибке. Этот паттерн распространен в сообществе функционального программирования. Он позволяет выразить тот факт, что вызов может вернуть либо корректное значение, либо ошибку, а также различать их. Но принцип именования Left/Right – это просто условность. Он может помочь тем, кто пользуется номенклатурой существующих библиотек. Можно воспользоваться иным соглашением, более понятным в вашей команде, например, Error/Success.

Можно создать простую реализацию при помощи запечатанных классов. Ниже показано, как они сочетаются с выражением when, чтобы код стал чище и в то же время безопаснее.

sealed class Either<out L, out R> {

    data class Left<out L, out R>(val a: L) : Either<L, R>()

    data class Right<out L, out R>(val b: R) : Either<L, R>()

}

fun <E> E.left() = Either.Left<E, Nothing>(this)

fun <T> T.right() = Either.Right<Nothing, T>(this)

Давайте перепишем наш код с использованием Either.

Адаптируем интерфейс

Теперь класс Verifier возвращает тип Either, чтобы указать, что вычисление может закончиться неудачно.

interface Verifier {

    / **

     * @param jwt a jwt token

     * @return authentication credentials, or an error if the validation fails

     * /

    fun verify(jwt: String): Either<JWTVerificationException, TokenAuthentication>

}

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

Обертываем код, выбрасывающий исключение

Внутри нашей реализации Verifier мы обертываем проблемный код в метол расширения под названием unsafeVerify. Используем методы расширения, определенные выше, чтобы создать обе стороны Either:

private fun JWTVerifier.unsafeVerify(jwt: String): Either<JWTVerificationException, TokenAuthentication> = try {

    verifyByCallingExternalApi(jwt).right()

} catch (e: JWTVerificationException) {

    e.left()

}

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

Использование в качестве клиента

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

Можем использовать выражение when, благодаря тому, что определили Either как запечатанный класс.

val result = verifier.verify(jwt)

when (result) {

    is Either.Left -> ResponseEntity.badRequest().build()

    is Either.Right -> ResponseEntity.ok("Worked!")

}

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

Операции над значением Either

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

Нужно расширить Either двумя новыми методами, map и flatMap. Начнем с map:

fun <L, R, B> Either<L, R>.map(f: (R) -> B): Either<L, B> = when (this) {

    is Either.Left -> this.a.left()

    is Either.Right -> f(this.b).right()

}

Мы хотим применить функцию к тому значению, которое содержится у нас внутри Either. Either сдвинут вправо; это означает, что, как только он принимает значение Left (соответствующее ошибке), дальнейшие вычисления не применяются. Возвращаясь к нашему методу ‘unsafeVerify, мы хотим преобразовать результат данного вызова, что и сделаем при помощи нашего нового метода map:

verifier

    .unsafeVerify(jwt)

    .map { it.asToken() }

Нам еще остается закрыть один случай. Что, если операция, которую мы хотим применить, сама возвращает Either? Если мы используем словарь, то вернем Either от Either и будем так вкладывать типы до тех пор, пока это не станет невозможно. Чтобы это предотвратить, воспользуемся новым методом flatMap.

fun <L, R, B> Either<L, R>.flatMap(f: (R) -> Either<L, B>): Either<L, B> = when (this) {

    is Either.Left -> this.a.left()

    is Either.Right -> f(this.b)

}

Если хотите глубже покопаться в этих функциональных концепциях – почитайте эту статью.

Стрелочная библиотека

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

Одна интересная черта библиотеки Arrow – в том, что в ней предоставляется собственная разновидность нотации do , позволяющая значительно упростить сцепленные операции над типами. В стрелочной библиотеке это называется выделение монад.

Нормальная цепочка операций могла бы выглядеть так:

request.getHeader(Headers.AUTHORIZATION)

  .toEither()

  .flatMap { header ->

    header.extractToken()

      .flatMap { jwt ->

        verifier

          .verify(jwt)

          .map { token ->

            SecurityContextHolder.getContext().authentication = token

        }

      }

  }

Можно избавиться от синтаксиса с вложениями, и получится так:

Either.fx {

    val (header) = request.getHeader(Headers.AUTHORIZATION).toEither()

    val (jwt) = header.extractToken()

    val (token) = verifier.verify(jwt)

    SecurityContextHolder.getContext().authentication = token

}

Думаю, этот код проще читать, он напоминает синтаксис async/await, применяемый с промисами в JavaScript.

Приложение: встроенное решение

В Kotlin 1.3 и выше существует встроенный способ работы с вычислениями, которые могут не удаться. Это класс Result, обычно используемый в блоке runCatching:

runCatching {

    methodThatMightThrow()

}.getOrElse { ex ->

    dealWithTheException(ex)

}

Этот класс еще нельзя использовать в качестве возвращаемого типа, поэтому и в нашем интерфейсе он неприменим. Более того, Either красиво интегрируется со всем прочим функционалом стрелочной библиотеки.  

Заключение

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

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

Теги:
Хабы:
+15
Комментарии14

Публикации

Информация

Сайт
piter.com
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия