Fehlerbehandlung: So verschönern Sie Ihre Exceptions
Fehlerbehandlung ist ein harter Brocken und nach meiner Erfahrung macht es kein Entwickler gerne. Trotzdem ist es bei fast jedem neuen Projekt das Letzte, was noch unbedingt getan werden muss. Dabei muss man mit verschiedenen Arten von Fehlern umgehen: nicht funktionierende Netzwerkverbindungen, abgestürzte Server oder benutzerspezifische Validierungsfehler (lokal oder vonseiten des Servers) – um nur ein paar zu nennen. Das Resultat sind umständliche Korrekturprozesse an allen Bestandteilen der App.
Nichtsdestotrotz ist es ein wichtiger, unumgänglicher Prozess. Deshalb hier unser Tipp, um den sauren Apfel zu versüßen:
Unser Setup ist ein ganz gewöhnliches. Für den API-Layer verwenden wir Retrofit mit einem OkHttp-Client, die Daten werden reaktiv über RxJava2 gelesen. Die Architektur unserer Apps entspricht dem MVVM-Model (was hier aber weniger bedeutend ist, da alles mit MVP genauso funktionieren würde). Unser ViewModel beinhaltet eine Möglichkeit zur Ablage. Dieses Repository ist für den Aufruf des Retrofit-API zuständig.
Fehlerbehandlung an einem Beispiel
Zur Anschaulichkeit skizzieren wir hier ein Beispiel auf Basis einer einfachen Login-Anfrage: E-Mail, Passwort, nichts weiter.
Nehmen wir mal an, wir hätten einen Server, der einen 401-Statuscode wegen ungültiger Berechtigung ausgibt, und darüber hinaus einen 400er-Validierungsfehler sowie einen 200er-Statuscode, bei dem alles ok läuft. Die 400er-Anfragen haben Fehlerstellen, die zusätzlich darüber informieren, welches Feld welchen Validierungsfehler aufweist:
{
"fields": [
{
"field": "email",
"error": "invalid_email"
}
]
}
An dieser Stelle wollen wir alle möglichen Fehler behandeln, die auftreten können, wenn wir eine Anfrage an das API stellen. Eine allgemeine Fehlerbehandlung für den Login-Screen kann folgendermaßen aussehen:
// 400 error body entities
class FieldError(val field: String, val error: String)
class ValidationErrorResponse(val fields: List)
// repository reading data from api
class Repository(val api: Api) {
fun login(email: String, password: String): Single = api.login(email, password)
}
// representation of login result - either OK or Error states
sealed class LoginResult {
object LoginOk : LoginResult()
data class LoginError(val error: Throwable) : LoginResult()
}
// viewModel making request and converting it to LoginResult and
// providing observable of this result
class LoginViewModel(val repository: Repository) {
private val loginResultSubject = BehaviorSubject.create()
fun observeLoginResult(): Observable = loginResultSubject
fun login(email: String, password: String) {
repository.login(email, password)
.subscribeOn(Schedulers.newThread())
.subscribe({
loginResultSubject.onNext(LoginResult.LoginOk)
}, {
loginResultSubject.onNext(LoginResult.LoginError(it))
})
}
}
// fragment (or activity) that is View layer that pass actions (login button click) to
// viewModel and observes viewModel state
class LoginFragment : Fragment {
val viewModel: LoginViewModel
var disposables: CompositeDisposable
fun onViewCreated() {
btnLogin.onClick {
viewModel.login(editEmail.text, editPassword.text)
}
disposables += viewModel.observeLoginResult()
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
when (it) {
is LoginResult.LoginOk -> finishLogin()
is LoginResult.LoginError -> {
when (it.error) {
is IOException -> showNoConnectinError()
is HttpException -> {
when (it.error.code()) {
401 -> showInvalidCredentialsError()
400 -> {
val errorResponse: ValidationErrorResponse = it.error.bodyToValidationError()
errorResponse.fields.forEach {
when (it.field) {
"email" -> {
when (it.error) {
"invalid_email" -> showInvalidEmailError()
}
}
"password" -> {
when (it.error) {
"short_password" -> showShortPasswordError()
"weak_password" -> showWeakPasswordError()
}
}
}
}
}
else -> showGeneralServerError()
}
}
else -> unexpectedError() // crash?
}
}
}
}
}
}
Puh, das war ganz schön viel Code, der nicht gerade sexy aussieht, nicht?
Zur Klarstellung: In unserem Beispiel scheint die Repository-Ebene vollkommen nutzlos zu sein. Wie sie gleich sehen werden, macht sie in der Praxis aber Sinn.
Festhalten können wir an dieser Stelle auch schon mal: Hübsch ist anders. Stellen Sie sich vor, jede einzelne API-Anfrage an allen Stellen der App, wie oben gezeigt, zu korrigieren. Alter Schwede!
Außer dass es nicht sehr schön ist, ist dieses Vorgehen auch nicht sehr sauber. Die Logik der Fehlerverarbeitung, das Fehler-Body-Format und alle möglichen Situationen, die aus dieser Anfrage entstehen können, gehören in keiner Weise zur View-Ebene.
Aber wo gehören sie hin? Zum ViewModel? Nope. Das ViewModel ist nur eine Verbindung zwischen dem Model (Business-Logik) und der Ansicht. Nein, in unserem Fall wird das Repository zum Modell! Um dieser Logik treu zu bleiben, machen wir alles etwas anders und schöner. Also zurück auf Anfang.
Erstmal führen wir weitere Klassen ein, die mögliche Fehler darstellen können ?:
class NoInternetConnectionException() : Exception()
class GeneralServerException(val code: Int) : Exception()
class UnexpectedException(val originalException: Exception) : Exception()
class InvalidCredentialsException : Exception()
class ValidationException(val errors: Map>) : Exception()
Dann bewegen wir den Code vom View zum Model:
class Repository(val api: Api) {
fun login(email: String, password: String): Single {
return api.login(email, password)
.onErrorResumeNext { error: Throwable ->
val exception = when (error) {
is HttpException -> {
when (error.code()) {
401 -> InvalidCredentialsException()
400 -> {
val errorResponse: ValidationErrorResponse = error.bodyToValidationError()
ValidationException(errorResponse.fields.groupBy({ it.error }, { it.field }))
}
else -> GeneralServerException()
}
}
is IOException -> NoInternetConnectionException()
else -> UnexpectedException(error)
}
Single.error(exception)
}
}
}
Wir verwenden den Operator OnErrorResumeNext von RxJava, der die Fehler verschiedenen Streams zuordnet. Wir ordnen es erneut dem Fehlerstream zu, aber mit zugeordneten Retrofit-Exceptions zu unseren Domain-Exceptions. Und tada, die Fehlerbehandlung im ViewModel sieht viel schöner aus:
fun onViewCreated() {
disposables += viewModel.observeLoginResult()
.observeOnMainThread()
.subscribe {
when (it) {
is LoginResult.LoginOk -> finishLogin()
is LoginResult.LoginError -> {
when (it.error) {
is NoInternetConnectionException -> showNoConnectionError()
is InvalidCredentialsException -> showInvalidCredentialsError()
is GeneralServerException -> showGeneralServerError()
is ValidationException -> showValidationErrors(it.error.errors)
is UnexpectedException -> unexpectedError() // crash?
}
}
}
}
}
Beautiful! Ein paar Dinge davon können wir jetzt mit ein wenig Hilfe der Kotlin-Extensions verallgemeinern.
Zum einen muss zum Beispiel die Fehlerzuordnung im Repository immer noch für jede Anfrage individuell durchgeführt werden. Für die sich überschneidenden Teile können wir das aber woanders hin auslagern.
Zum Beispiel durch eine Extension MapApiExceptions auf dem Single:
fun Single.mapApiExceptions(errorCodeMapper: ((HttpException) -> Exception?)? = null): Single {
return onErrorResumeNext { err: Throwable ->
val exception = when (err) {
is HttpException -> {
errorCodeMapper?.invoke(err) ?: GeneralServerException(err.code())
}
is IOException -> NoInternetConnectionException()
else -> UnexpectedException(err)
}
Single.error(exception)
}
}
Dies ermöglicht eine optionale Zuordnungsfunktion der HttpException zu anderen Exceptions. Ist diese Funktion gleich Null werden die Ergebnisse dem GeneralServerException zugeordnet.
Jetzt ändert sich unser Repository-Code folgendermaßen:
class Repository(val api: Api) {
fun login(email: String, password: String): Single {
return api.login(email, password)
.mapApiExceptions {
when (it.code()) {
401 -> InvalidCredentialsException()
400 -> {
val errorResponse: ValidationErrorResponse = it.bodyToValidationError()
ValidationException(errorResponse.fields.groupBy({ it.error }, { it.field }))
}
else -> null
}
}
}
}
Auch der View-Layer kann optimiert werden. GeneralServerException, NoInternetException und UnexpectedException werden wahrscheinlich überall gleich behandelt. Man kann sie also, wie folgt, abstrahieren:
fun Fragment.handleErrors(error:Throwable) {
when (error) {
is NoInternetConnectionException -> snackbar(view, R.string.general_no_connection_error)
is GeneralServerException -> snackbar(view, R.string.general_server_error)
else -> throw error
}
}
class LoginFragment : Fragment {
val viewModel: LoginViewModel
var disposables: CompositeDisposable
fun onViewCreated() {
disposables += viewModel.observeLoginResult()
.observeOnMainThread()
.subscribe {
when (it) {
is LoginResult.LoginOk -> finishLogin()
is LoginResult.LoginError -> {
when (it.error) {
is InvalidCredentialsException -> showInvalidCredentialsError()
is ValidationException -> showValidationErrors(it.error.errors)
else -> handleErrors(it.error)
}
}
}
}
}
}
Vergleichen wir unseren Usprungscode mit dem jetzigen, muss man fast lachen. Denn ganz anders als zuvor kann jetzt ganz einfach eine API-Anfrage in einem anderen Teil der App bearbeitet werden.
Eine weitere Verbesserung bestünde nun darin, DomainExceptions automatisch in einen OkHttpInterceptor / Retrofit CallAdapter einzuordnen – aber das wäre schon wieder ein ganz eigener Blog-Artikel. ?
Haftungsausschluss: Die Code-Snippets in diesem Blogbeitrag können als Pseudocode verwendet werden. Es gibt eine Menge Methoden, die für diesen Blog-Beitrag nicht relevant waren und deshalb nicht implementiert wurden.