Warum brauchen wir Fehlerbehandlung?

Fehler! Es ist ein wohlbekanntes Wesen für Benutzer jeder entwickelten Anwendung der Welt – und mit jeder meine ich wirklich jede 😀 Es ist ein untrennbarer Bestandteil jeder Anwendung – also stellen wir uns dem!

Wir können Fehler nicht vermeiden, da nicht alles unter der Kontrolle der Entwickler liegt und nicht alles in der Welt perfekt ist. Um eine robuste Anwendung zu haben, brauchen wir eine gute Lösung zur Fehlerbehandlung.

Was ist eine angemessene Lösung zur Fehlerbehandlung?

Jede Lösung wird entwickelt, um ein Problem zu lösen. Um eine angemessene Lösung zu haben, müssen wir das Problem richtig kennen, trivial, oder? Schauen wir uns nun genauer an, was wir mit unserer Fehlerbehandlung erreichen wollen.

Ein wichtiges Ziel jeder Anwendung ist es, ihre Benutzer glücklich zu halten, auch wenn ein Fehler auftritt.

if you agree continue;

Verschiedene Benutzertypen interagieren mit der Anwendung, und jeder Typ hat seine eigenen Erwartungen, wenn ein Fehler auftritt. Sie alle glücklich zu halten bedeutet, dass wir all diese Erwartungen erfüllen müssen (keine Panik!). Lassen wir es aufschlüsseln.

Endbenutzer

Hier ist, was ich als Endbenutzer erwarte:

  • Wenn ein Fehler auftritt, erwarte ich eine klare, leicht verständliche, aber dennoch beschreibende Fehlermeldung ohne unnötige technische Details.
  • Wenn ich etwas tun kann, um das Problem zu lösen, würde ich mich natürlich über einen Hinweis freuen.
  • Andernfalls möchte ich den Fehler einfach melden oder Unterstützung vom Help-Desk-Team erhalten können.
  • Ich möchte nicht allein mit dem Fehler sein!

Help-Desk-Team

Als Mitglied des Help-Desk-Teams habe ich auch Erwartungen!

  • Ich erwarte Kategorien für Fehler, damit ich die Lösung leicht finden kann.
  • Es wäre schön, wenn wir dieselbe Sprache wie Endbenutzer sprechen könnten! Glaubt mir, es ist schwer, jemanden zu verstehen, der eine andere Sprache spricht.
  • Ich muss wissen, ob ich das Problem selbst lösen kann oder ob ich es an das Entwicklungs-/Wartungsteam weiterleiten sollte.

Entwicklungs- oder Wartungsteam

Unsere Erwartungen? Das ist einfach! Wir haben nur eine Erwartung!

  • Wir möchten alles haben! Natürlich bezogen auf den Fehler. Je mehr wir wissen, desto besser 😀

Lösung

Jetzt, da wir wissen, was die Erwartungen sind, können wir in den spaßigen Teil einsteigen.

Die bisher besprochenen Konzepte waren generisch und sprachunabhängig. Jetzt müssen wir konkret werden, damit ich die Lösung erklären kann, die wir in einigen unserer Projekte bei Cloudflight verwenden.

Ich beschreibe die Lösung im Kontext einer Webanwendung, die mit Kotlin und Spring Boot entwickelt wurde, aber das kann gerne basierend auf eurem Kontext und euren Bedürfnissen angepasst werden.

Benutzerdefinierte Ausnahme auf Anwendungsebene

Wir müssen Fehler im Zusammenhang mit der Geschäftslogik von anderen Fehlern wie Netzwerk- oder Datenbankfehlern unterscheiden, damit wir Endbenutzern nur relevante Informationen liefern können.

Wir erstellen eine Basis-Exception-Klasse namens ApplicationException. Wann immer etwas schiefgeht, wird eine Instanz dieser Exception oder einer ihrer Unterklassen geworfen. Ihr könnt so viele Unterklassen definieren, wie ihr möchtet!

open class ApplicationException(
    open val code: String,
    val httpStatus: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR,
    override val cause: Throwable?,
    override val message: String,
) : RuntimeException()
open class ApplicationNotFoundException(
    override val code: String,
    override val cause: Throwable? = null,
    override val message: String,
) : ApplicationException(code, HttpStatus.NOT_FOUND, cause, message)
open class ApplicationBadRequestException(
    override val code: String,
    override val cause: Throwable? = null,
    override val message: String = "",
) : ApplicationException(code, HttpStatus.BAD_REQUEST, cause, message)
 ...
  • code: Dieser wird als Fehlerkategorie verwendet.
  • Endbenutzer können ihn verwenden, um Hilfe vom Help-Desk-Team zu erhalten. Es ist die gemeinsame Sprache zwischen Endbenutzern und dem Help-Desk-Team!
  • Das Help-Desk-Team kann ihn verwenden, um die mögliche Lösung für den Fehler aus einer vorausgefüllten Tabelle nachzuschlagen, die Fehlercodes möglichen Lösungen zuordnet.
  • httpStatus: Dieser wird verwendet, um den HTTP-Status basierend auf dem Fehlertyp zu setzen.
  • cause: Das ist die verschachtelte Exception, falls eine vorhanden ist.
  • message: Das ist… ihr habt es erraten! „Klare, leicht verständliche, aber dennoch beschreibende Fehlermeldung ohne unnötige technische Details”

Einheitliches Fehlermodell

Jetzt können wir unsere Exceptions auf Anwendungsebene von anderen Exceptions unterscheiden. Wir müssen auch eine Klasse definieren, die einen Fehler beschreibt. Alle Exceptions werden in dieses Modell übersetzt, bevor sie die Anwendung verlassen. Auf diese Weise haben wir ein einheitliches Fehlerantwortmodell, und unsere Anwendungsclients können alle Fehler auf dieselbe Weise behandeln. Nennen wir es APIErrorDTO.

data class APIErrorDTO(
    val id: String,
    val code: String,
    val message: String,
    val details: List<ErrorDetailDTO>,
)
data class ErrorDetailDTO(
    val code: String,
    val message: String,
)
  • id: Dieser identifiziert einen Fehler eindeutig.
  • Endbenutzern kann eine Schaltfläche „Fehler melden” bereitgestellt werden, die ein Ticket aus dem Fehler mit allen angehängten Details erstellt.
  • Das Entwicklungsteam kann ihn auch verwenden, um die Logs und alle anderen Details im Zusammenhang mit dem Fehler zu finden.
  • code und message: Diese kommen aus der Exception auf Anwendungsebene.
  • details: Wenn verschachtelte Anwendungsfehler vorhanden sind, werden code und message dieser zu details hinzugefügt, damit Benutzer mehr Informationen darüber erhalten.

Globale Exception-Behandlung

In Spring Boot können fast alle Exceptions global an einer Stelle mit der Annotation @ControllerAdvice behandelt werden. Wir fangen die Exceptions hier ab und wandeln sie in eine APIErrorDTO-Instanz um.

@ControllerAdvice
class GlobalExceptionHandler : ResponseEntityExceptionHandler() {
    ...
    @ExceptionHandler(ApplicationException::class)
    fun handleApplicationException(exception: ApplicationException, request: WebRequest): ResponseEntity<APIErrorDTO> {
        val apiErrorDTO = createAPIErrorDTO(exception)
        val httpStatus = getHttpStatus(exception)
        if (HttpStatus.INTERNAL_SERVER_ERROR == httpStatus) {
            request.setAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE, exception, WebRequest.SCOPE_REQUEST)
        }
        return ResponseEntity<APIErrorDTO>(apiErrorDTO, HttpHeaders(), httpStatus)
    }
    fun createAPIErrorDTO(
        exception: Throwable?, defaultErrorCode: String = DEFAULT_ERROR_CODE,
        defaultMessage: String = ""
    ): APIErrorDTO {
        val errorId = UUID.randomUUID().toString()
        val errorCode = if (exception is ApplicationException) exception.code else defaultErrorCode
        var message = if (exception is ApplicationException) exception.message else defaultMessage
        val errorDetail = mutableListOf<ErrorDetailDTO>()
        var cause = exception?.cause
        while (cause != null) {
            if (cause is ApplicationException) {
                errorDetail.add(ErrorDetailDTO(cause.code, cause.message))
            }
            cause = cause.cause
        }
        return APIErrorDTO(errorId, errorCode, errorDetail, message)
    }
    private fun getHttpStatus(exception: ApplicationException): HttpStatus {
        var httpStatus = exception.httpStatus
        var cause = exception.cause
        while (cause != null) {
            if (cause is ApplicationException) {
                httpStatus = cause.httpStatus
            } else if (cause is AccessDeniedException) {
                httpStatus = HttpStatus.FORBIDDEN
            }
            cause = cause.cause
        }
        return httpStatus
    }
}

Wenn ihr erwartet, hier einen anderen Exception-Typ zu erhalten, könnt ihr eine neue Methode dafür hinzufügen. Dann könnt ihr handleApplicationException von dort mit entsprechenden Argumenten aufrufen. Auf diese Weise könnt ihr ein APIErrorDTO aus jeder Ausnahme erstellen. Wenn ihr zum Beispiel erwartet, eine NestedRuntimeException zu erhalten, könnt ihr folgende Methode zu eurer GlobalExceptionHandler-Klasse hinzufügen:

   @ExceptionHandler(NestedRuntimeException::class)
    fun nestedRuntimeExceptionTransformer(
        exception: NestedRuntimeException,
        request: WebRequest
    ): ResponseEntity<APIErrorDTO> {
        return handleApplicationException(
            ApplicationUnprocessableException(DEFAULT_ERROR_CODE, "a relevant message!", exception),
            request
        )
    }

Übrigens können wir nicht alle Exceptions im GlobalExceptionHandler abfangen. Wir müssen benutzerdefinierte ErrorAttributes definieren, um alle Exceptions, auch die außerhalb unserer Kontrolle, in das APIErrorDTO zu übersetzen.

Benutzerdefinierte Fehlerattribute

Mithilfe der folgenden Klasse können wir andere Exceptions in das APIErrorDTO übersetzen.

class CustomErrorAttributes : DefaultErrorAttributes() {
    override fun getErrorAttributes(webRequest: WebRequest?, options: ErrorAttributeOptions?): MutableMap<String, Any> {
        val defaultErrorCode =
            getStatusCode(webRequest)?.toString() ?: DEFAULT_ERROR_CODE
        return createAPIErrorDTO(
            this.getError(webRequest),
            defaultErrorCode,
            defaultMessage = getDefaultErrorMessage(webRequest)
        ).toMap()
    }
    private fun getDefaultErrorMessage(webRequest: WebRequest?): String {
        val defaultErrorMessage: String
        val errorMessage = webRequest?.getAttribute(WebUtils.ERROR_MESSAGE_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST)?.toString()
        defaultErrorMessage = if (!errorMessage.isNullOrBlank()) {
            errorMessage
        } else {
            try {
                val statusCode = getStatusCode(webRequest)
                if (statusCode == null) UNKNOWN_ERROR_MESSAGE else HttpStatus.valueOf(statusCode).reasonPhrase
            } catch (e: Exception) {
                UNKNOWN_ERROR_MESSAGE
            }
        }
        return defaultErrorMessage
    }
    private fun getStatusCode(webRequest: WebRequest?): Int? {
        val statusCodeAttribute = webRequest?.getAttribute(WebUtils.ERROR_STATUS_CODE_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST)
        return if (statusCodeAttribute != null) statusCodeAttribute as Int else null
    }
}

Aspektorientierte Programmierung (AOP)

Wir alle schätzen sauberen und lesbaren Code, oder? Anstatt unseren Code mit Try/Catch-Blöcken zu überfüllen, werden wir Spring AOP verwenden, um unseren Code sauber und prägnant zu gestalten. Definieren wir einen Aspekt, mit dem wir unsere Methoden dekorieren können, und entfernen wir etwas Boilerplate-Code hier und dort.

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class ExceptionWrapper(val exception: KClass<out ApplicationException>)
@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
class ExceptionWrapperAspect {
    @Around("@annotation(io.cloudflight.thiscouldbeyourproject.server.common.exception.ExceptionWrapper)")
    fun wrapException(joinPoint: ProceedingJoinPoint): Any? {
        try {
            return joinPoint.proceed()
        } catch (e: Throwable) {
            val methodSignature: MethodSignature = joinPoint.signature as MethodSignature
            val method: Method = methodSignature.method
            val exceptionWrapper: ExceptionWrapper =
                method.annotations.find { it is ExceptionWrapper } as ExceptionWrapper
            val constructor =
                exceptionWrapper.exception.constructors.firstOrNull { it.parameters.size == 1 && it.parameters.first().type.classifier == Throwable::class }
            throw
            constructor?.call(e) ?: ApplicationException(
                code = DEFAULT_ERROR_CODE,
                i18nMessage = DEFAULT_ERROR_MESSAGE,
                cause = e
            )
        }
    }
}

Mit diesen Mitteln müssen wir nur den Einstiegspunkt des Use-Cases mit der Annotation @ExceptionWrapper versehen. So könnt ihr sicherstellen, dass Endbenutzer eine angemessene Meldung erhalten, wenn etwas schiefgeht. Beachtet, dass ihr nicht alle eure Methoden mit @ExceptionWrapper annotieren müsst. Es reicht, sie nur am Einstiegspunkt des Use-Cases zu haben. Ihr könnt in einer unteren Schicht immer noch einen Fehler abfangen, wenn er behebbar ist oder wenn ihr dem Endbenutzer mehr Informationen mitteilen möchtet, die nur dort verfügbar sind, wo der Fehler auftritt. Im letzteren Fall könnt ihr den Fehler abfangen und eine Unterklasse von ApplicationException werfen!

    @Transactional
    @ExceptionWrapper(CreateUserException::class)
    override fun createUser(user: UserChange): User {
        if (EmailFormatIsInvalid(user.email))
            throw EmailFormatIsInvalid()
        ...
        return createdUser
    }
class CreateUserException(cause: Throwable) : ApplicationException(
    code = "ERR-CU-1",
    message = "Failed to create the user",
    cause = cause
)
class EmailFormatIsInvalid : ApplicationUnprocessableException(
    code = "ERR-CU-2",
    message = "Format of provided email is invalid. The format should look like local-part@domain e.g. example@cloudflight.io",
)

Fazit

Schauen wir nun, ob wir mit dieser Lösung alle Erwartungen unserer Benutzer erfüllt haben:

Endbenutzer

  • Mit der message können wir den Fehler beschreiben und Hinweise zur Lösung geben, wenn möglich.
  • Mit der id können wir unseren Benutzern ermöglichen, den Fehler mit minimalem Aufwand zu melden.
  • Mit dem code können Benutzer Unterstützung vom Help-Desk-Team erhalten.

Help-Desk-Team

Using thecode:

  • Wir können Fehler kategorisieren und eine Lösungstabelle erstellen, die Fehlercodes möglichen Lösungen zuordnet.
  • Wir erleichtern die Kommunikation zwischen Benutzern und dem Help-Desk-Team.
  • Das Help-Desk-Team kann entscheiden, ob es die Anfrage an das Entwicklungsteam weiterleiten soll oder nicht. Ihr fragt vielleicht, wie? Wenn code auf den Standard-Fehlercode gesetzt ist, sollte es weitergeleitet werden, oder?

Entwicklungs- oder Wartungsteam

  • Mit der id können wir jeden Fehler eindeutig identifizieren und die Logs oder alle anderen damit zusammenhängenden Informationen finden und sie zum Debuggen verwenden.

Alles in allem glaube ich, dass unsere Benutzer jetzt zufrieden sein sollten, auch wenn ein Fehler auftritt. Wenn ihr das nicht glaubt oder Verbesserungsmöglichkeiten seht, lasst mich bitte eure Meinungen in den Kommentaren wissen. Danke!